Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/kanban/home-session.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,35 @@ any error, including a missing `state.db`.
Consumers — keep them on this one helper: `kanban list --home`, `kanban show`'s
`home:` label, the guard's same-home test, and the kanban-home-cards plugin.

## Birth stamp, unhomed, session-first list, takeover

- **Every card is born homed.** `kanban_db.create_task` (the one choke point
for CLI, tool, swarm, dashboard) stamps `session_id`: explicit > first homed
parent's home > `"unhomed"`. Never NULL. It also prepends
`origin: <platform> <chat_name> (<chat_id>) · session <id> · <date>` unless
the body already has an `origin:` line.
- **`unhomed`** (cron/script/shell births) is foreign to every chat session;
it loads as `Task.session_id=None, unhomed=True`, so notification/wake
routing is unchanged. Raw-SQL readers of `tasks.session_id` must treat it
like NULL: `backfill_notify_sub_user_ids` (`kanban notify-repair`) maps it to
"no creator provenance", so a user-less card never adopts the lone human in
its chat.
- **`kanban list`** with a caller session prints `THIS SESSION (n)` then
`OTHER SESSIONS (m)`, one line each. `--this-session` (= `--home`) filters,
`--all` gives the flat view. The `kanban_list` tool does the same: this
session's cards in full under `tasks`, others collapsed to
`{id, status, title[, unhomed]}` under `other_sessions`; `all=true` is flat.
- **Re-scope.** There is no `edit` verb. The title/body writers are
`specify` (guarded like every other mutator) and the dashboard's
`PATCH /tasks/:id`, which — like every dashboard mutation — runs with no
session identity (the human operator's surface) and is therefore unguarded.
- **`--takeover REASON`** (alias of `--foreign-ok`) acts on a foreign or
unhomed card; it writes a `takeover` event and a `takeover:` comment.
`comment` is never guarded. Sessionless callers (cron sweeps) stay unguarded.
- **`kanban home-lint`** prints nothing and exits 0 when every open card has a
home. Otherwise it lists the ids and exits 1. `--backfill [--dry-run]` stamps
them `unhomed` and adds a comment.

## Config

```yaml
Expand All @@ -36,3 +65,5 @@ kanban:
- 2026-09-24: guard armed — `kanban.home_guard` added with default `refuse`
(`warn` = escape hatch); home is the session lineage (`home_ids`), not the
exact session id.
- 2026-09-24: birth stamping (never NULL, `unhomed` sentinel, origin line),
session-first `list`, `--takeover` + `takeover` event, `home-lint`.
96 changes: 87 additions & 9 deletions hermes_cli/kanban.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ def _task_to_dict(t: kb.Task) -> dict[str, Any]:
"provider_override": t.provider_override,
"reasoning_effort": t.reasoning_effort,
"session_id": t.session_id,
"unhomed": bool(getattr(t, "unhomed", False)),
"workflow_template_id": t.workflow_template_id,
"current_step_key": t.current_step_key,
}
Expand Down Expand Up @@ -555,9 +556,13 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
p_list.add_argument("--session", default=None,
help="Filter by originating chat/agent session id "
"(set on tasks created from inside an ACP loop)")
p_list.add_argument("--home", action="store_true",
p_list.add_argument("--home", "--this-session", action="store_true",
dest="home",
help="Only cards whose home session is this session "
"(== --session $HERMES_SESSION_ID)")
p_list.add_argument("--all", action="store_true", dest="flat_all",
help="Flat board-wide view (no THIS SESSION / OTHER "
"SESSIONS grouping)")
p_list.add_argument("--archived", action="store_true",
help="Include archived tasks")
p_list.add_argument("--json", action="store_true")
Expand Down Expand Up @@ -1424,6 +1429,18 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu

_add_clone_arguments(p_clone)

# --- home-lint ---
p_hl = sub.add_parser(
"home-lint",
help="Report open cards with no home session (exit 1); "
"--backfill stamps them 'unhomed'",
)
p_hl.add_argument("--backfill", action="store_true",
help="Stamp each homeless open card 'unhomed' + comment")
p_hl.add_argument("--dry-run", action="store_true",
help="With --backfill: list what would be stamped")
p_hl.add_argument("--json", action="store_true")

# --- repair ---
p_repair = sub.add_parser(
"repair",
Expand Down Expand Up @@ -1454,12 +1471,14 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
_p = sub.choices.get(_name)
if _p is not None and "foreign_ok" not in {a.dest for a in _p._actions}:
_p.add_argument(
"--takeover",
"--foreign-ok",
dest="foreign_ok",
default=None,
metavar="REASON",
help="Act on a card whose home session is another session; "
"REASON is posted as a comment the home session sees.",
help="Act on a card whose home session is another session "
"(or an unhomed card); records a takeover event and "
"posts REASON as a comment the home session sees.",
)
return kanban_parser

Expand Down Expand Up @@ -1610,6 +1629,7 @@ def kanban_command(args: argparse.Namespace) -> int:
"specify": _cmd_specify,
"decompose": _cmd_decompose,
"gc": _cmd_gc,
"home-lint": _cmd_home_lint,
}
handler = handlers.get(action)
if not handler:
Expand Down Expand Up @@ -1701,7 +1721,9 @@ def _resolve_session_flag(value: Optional[str]) -> Optional[str]:
return None if value.lower() in ("", "none") else value


def _home_label(session_id: Optional[str]) -> str:
def _home_label(session_id: Optional[str], *, unhomed: bool = False) -> str:
if unhomed:
return "unhomed (no session owns it; --takeover to act)"
if not session_id:
return "unstamped"
caller = _caller_session_id()
Expand Down Expand Up @@ -2392,11 +2414,67 @@ def _cmd_list(args: argparse.Namespace) -> int:
if not tasks:
print("(no matching tasks)")
return 0
for t in tasks:
print(_fmt_task_line(t))
caller = None
if not (getattr(args, "flat_all", False) or args.session
or home_session_ids is not None):
caller = _caller_session_id()
if not caller:
for t in tasks:
print(_fmt_task_line(t))
return 0
print(_format_session_grouped(tasks, kb.home_ids(caller)))
return 0


def _cmd_home_lint(args: argparse.Namespace) -> int:
"""Silent + exit 0 when every open card has a home; else list ids, exit 1.

Designed for a no_agent cron (empty stdout = nothing delivered). With
``--backfill`` stamps the stragglers ``unhomed`` and exits 0.
"""
with kb.connect_closing() as conn:
if getattr(args, "backfill", False):
ids = kb.backfill_unhomed(conn, dry_run=bool(args.dry_run))
verb = "would stamp" if args.dry_run else "stamped"
if args.json:
print(json.dumps({"action": verb, "ids": ids}))
elif ids:
print(f"home-lint: {verb} {len(ids)} card(s) unhomed: {', '.join(ids)}")
return 0
ids = kb.find_homeless_open_tasks(conn)
if args.json:
print(json.dumps({"homeless_open": ids}))
elif ids:
print(
f"kanban home-lint: {len(ids)} open card(s) have NO home session "
f"(a create path is not stamping): {', '.join(ids)} -- "
"fix the path; `hermes kanban home-lint --backfill` stamps them unhomed"
)
return 1 if ids else 0


def _format_session_grouped(tasks, home: "frozenset[str]") -> str:
"""Session-first listing: this session's cards in full, every other
session's cards collapsed to one ``id · status · title`` line each.

A session sees its OWN work first; foreign cards stay visible (for
mentions/comments) but read as someone else's. ``--all`` = flat view.
"""
mine = [t for t in tasks if t.session_id and t.session_id in home]
others = [t for t in tasks if not (t.session_id and t.session_id in home)]
lines = [f"THIS SESSION ({len(mine)})"]
lines += [_fmt_task_line(t) for t in mine] or [" (none)"]
lines.append("")
lines.append(
f"OTHER SESSIONS ({len(others)}) -- not yours: comment, don't act "
"(--takeover REASON to act; --all for the flat view)"
)
for t in others:
tag = " [unhomed]" if t.unhomed else ""
lines.append(f" {t.id} \u00b7 {t.status} \u00b7 {t.title}{tag}")
return "\n".join(lines)


def _print_triage_banner(triage_ids, stranded) -> None:
"""Header naming cards that need a human, and what they're freezing.

Expand Down Expand Up @@ -2457,7 +2535,7 @@ def _cmd_show(args: argparse.Namespace) -> int:
if getattr(args, "json", False):
payload = {
"task": _task_to_dict(task),
"home": _home_label(task.session_id),
"home": _home_label(task.session_id, unhomed=task.unhomed),
"latest_summary": latest_summary,
"parents": parents,
"children": children,
Expand Down Expand Up @@ -2512,8 +2590,8 @@ def _cmd_show(args: argparse.Namespace) -> int:
print(f"Task {task.id}: {task.title}")
print(f" status: {task.status}")
print(f" assignee: {task.assignee or '-'}")
print(f" session: {task.session_id or '-'}")
print(f" home: {_home_label(task.session_id)}")
print(f" session: {task.session_id or (kb.UNHOMED_SESSION if task.unhomed else '-')}")
print(f" home: {_home_label(task.session_id, unhomed=task.unhomed)}")
if task.tenant:
print(f" tenant: {task.tenant}")
print(f" workspace: {task.workspace_kind}" +
Expand Down
Loading
Loading