@staix/agent-hub 0.29.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +5 -3
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +2 -1
  5. package/docs/agent-notes/bus.md +1 -0
  6. package/docs/agent-notes/daemon.md +2 -1
  7. package/docs/agent-notes/tasks.md +4 -2
  8. package/docs/agent-notes/tests.md +2 -0
  9. package/docs/design/adapters.md +56 -0
  10. package/docs/design/agent-hub-architecture.md +12 -0
  11. package/docs/design/bus.md +8 -0
  12. package/docs/design/task-board.md +18 -0
  13. package/docs/dogfood.md +7 -4
  14. package/docs/operations.md +106 -11
  15. package/docs/quickstart.md +10 -3
  16. package/docs/sdlc.md +1 -1
  17. package/docs/security.md +28 -2
  18. package/docs/smoke.md +13 -2
  19. package/docs/specs/2026-10-12-office-view-design.md +86 -3
  20. package/docs/verification/2026-11-03-0.30.0.md +54 -0
  21. package/docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md +42 -0
  22. package/docs/verification/2026-11-03-agy-1.4.12.md +90 -0
  23. package/docs/verification/2026-11-03-agy-tui-close.md +49 -0
  24. package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
  25. package/docs/verification/2026-11-03-pi-150-compat.md +40 -0
  26. package/docs/verification/2026-11-03-pi-title-api.md +26 -0
  27. package/docs/verification/2026-11-03-qwen-0265-acp.md +55 -0
  28. package/docs/verification/2026-11-03-qwen-0265-dual-output.md +40 -0
  29. package/docs/verification/dogfood/2026-11-03.md +3095 -0
  30. package/docs/verification/pi-150-model-free.ts +57 -0
  31. package/docs/verification/pi-150-native.mjs +21 -0
  32. package/docs/verification/pi-150-title-api.mjs +31 -0
  33. package/docs/verified.json +14 -14
  34. package/package.json +2 -2
  35. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  36. package/plugins/agent-hub/server.js +193 -29
  37. package/src/adapters/compat.ts +10 -10
  38. package/src/adapters/definitions/builtin-metadata.ts +2 -2
  39. package/src/adapters/local-worker.ts +22 -6
  40. package/src/adapters/pi.ts +92 -10
  41. package/src/adapters/registry-types.ts +4 -0
  42. package/src/adapters/registry.ts +2 -0
  43. package/src/cli/autonomy.ts +66 -1
  44. package/src/cli/console-state.ts +6 -2
  45. package/src/cli/help.ts +2 -0
  46. package/src/cli/identity.ts +8 -1
  47. package/src/cli/main.ts +39 -0
  48. package/src/cli/worktree-tidy.ts +158 -0
  49. package/src/cli/worktrees-output.ts +43 -0
  50. package/src/hub/board.ts +28 -3
  51. package/src/hub/bus.ts +4 -4
  52. package/src/hub/conductor.ts +4 -3
  53. package/src/hub/config-apply.ts +2 -1
  54. package/src/hub/daemon.ts +237 -19
  55. package/src/hub/dogfood-read.ts +62 -9
  56. package/src/hub/hub-tools.ts +11 -5
  57. package/src/hub/manager.ts +9 -1
  58. package/src/hub/peer-doing.ts +33 -3
  59. package/src/hub/peers.ts +7 -0
  60. package/src/hub/settings.ts +3 -0
  61. package/src/hub/tasks.ts +30 -8
  62. package/src/hub/ui.ts +2 -0
  63. package/src/hub/worktree-prs.ts +100 -0
  64. package/src/hub/worktree-shelf.ts +246 -0
  65. package/src/memory/capture.ts +1 -1
  66. package/src/pi/extension.ts +43 -3
  67. package/src/ui/index.html +133 -10
  68. package/src/ui/office-model.ts +64 -8
  69. package/src/ui/office-space.ts +1 -0
  70. package/src/ui/plugin.ts +14 -3
@@ -0,0 +1,158 @@
1
+ import { createHash } from "node:crypto";
2
+ import { statSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+
5
+ /**
6
+ * #1057 D5: `ahub worktrees tidy`. This module is the ONLY place a worktree or a local branch is removed, and it runs in the person's own CLI
7
+ * process: the daemon never imports it (a test greps for that). A preview reads facts and runs read-only git; `--confirm <fingerprint>` re-reads
8
+ * everything, refuses the whole run if the row set changed, and otherwise runs exactly two argv shapes per row:
9
+ *
10
+ * git -C <root> worktree remove <path> (no --force: git itself refuses a dirty or locked tree)
11
+ * git -C <root> branch -d <branch> (never -D: git itself refuses an unmerged branch)
12
+ *
13
+ * No other removal argv exists here, `--force` and `-D` appear in no argv, and `assertRemovalArgv` checks the shape right before each call.
14
+ */
15
+
16
+ /** One settled row from the hub's `worktrees` reply, resolved against the project root by `rowFromReply`; `head` and `locked` come from git here, never from the daemon. */
17
+ export interface TidyRow { path: string; shown: string; branch: string | null; head: string; locked: boolean; reason: string; instance: boolean; taskState?: string;
18
+ /** The branch THIS process read from git's own list (`null` when detached), as opposed to `branch`, which is the daemon's word. They must agree or the row is refused. */
19
+ liveBranch?: string | null }
20
+ export type RefusalReason = "branch changed since preview; run tidy again" | "main checkout" | "instance worktree" | "dirty" | "locked" | "no branch" | "unpushed commits" | "no upstream" | "branch not merged into the default branch" | "task not approved or cancelled" | "path missing" | "outside the project" | "git could not tell";
21
+ export interface TidyEntry { path: string; shown: string; branch: string | null; reason: string; refused: RefusalReason[] }
22
+ export interface TidyPlan { entries: TidyEntry[]; removable: TidyEntry[]; fingerprint: string }
23
+
24
+ /** Read-only and removal git, bounded. `undefined` is a failed or timed-out call, never an empty answer. */
25
+ export interface TidyGit { run(cwd: string, args: string[]): { status: number | null; stdout: string } | undefined }
26
+
27
+ /** The only two removal argv shapes. A word outside them, a flag, or a path that starts with `-` refuses before git runs. */
28
+ export function removalArgv(root: string, path: string, branch: string): [string[], string[]] {
29
+ return [["-C", root, "worktree", "remove", path], ["-C", root, "branch", "-d", branch]];
30
+ }
31
+ export function assertRemovalArgv(argv: readonly string[]): void {
32
+ const [, , verb, action, target] = argv;
33
+ const worktree = argv.length === 5 && argv[0] === "-C" && verb === "worktree" && action === "remove";
34
+ const branch = argv.length === 5 && argv[0] === "-C" && verb === "branch" && action === "-d";
35
+ if (!(worktree || branch) || !target || target.startsWith("-") || argv.some(word => word === "--force" || word === "-f" || word === "-D" || word === "--delete")) throw new Error("refused: not one of the two tidy removal commands");
36
+ if (!argv[1] || argv[1].startsWith("-")) throw new Error("refused: the project root is not a path");
37
+ }
38
+
39
+ const branchOk = (branch: string): boolean => /^[A-Za-z0-9][A-Za-z0-9._/-]{0,200}$/.test(branch) && !branch.includes("..") && !branch.endsWith("/") && !branch.endsWith(".lock");
40
+ const out = (r: { status: number | null; stdout: string } | undefined): string | undefined => r && r.status === 0 ? r.stdout.trim() : undefined;
41
+ const raw = (r: { status: number | null; stdout: string } | undefined): string | undefined => r && r.status === 0 ? r.stdout : undefined;
42
+
43
+ /**
44
+ * The refusal reasons for one settled row, from read-only git. Every check that cannot be answered refuses ("git could not tell"): unknown is never safe.
45
+ * Rows the daemon already shelved as `parked` still pass through these checks: parking is the person's wish to hide a row, not proof it is safe to delete.
46
+ */
47
+ export function refusals(row: TidyRow, root: string, git: TidyGit, defaultBranch: string | undefined, exists: (path: string) => boolean = p => { try { return statSync(p).isDirectory(); } catch { return false; } }): RefusalReason[] {
48
+ const why: RefusalReason[] = [];
49
+ if (row.path === root) return ["main checkout"];
50
+ if (row.instance) return ["instance worktree"];
51
+ if (!row.path.startsWith(root + "/")) why.push("outside the project");
52
+ if (row.locked) why.push("locked");
53
+ if (!row.branch || !branchOk(row.branch)) why.push("no branch");
54
+ // The daemon named a branch and git, read here and now, says another (or none): the checks below would inspect the wrong branch and `branch -d` would target a stale name.
55
+ if (row.liveBranch !== undefined && row.liveBranch !== row.branch) why.push("branch changed since preview; run tidy again");
56
+ if (row.taskState !== undefined && row.taskState !== "approved" && row.taskState !== "cancelled") why.push("task not approved or cancelled");
57
+ if (!exists(row.path)) { why.push("path missing"); return why; }
58
+ const status = git.run(row.path, ["status", "--porcelain", "--untracked-files=all"]);
59
+ if (!status || status.status !== 0) why.push("git could not tell"); else if (status.stdout.trim() !== "") why.push("dirty");
60
+ if (row.branch && branchOk(row.branch)) {
61
+ const upstream = git.run(row.path, ["rev-parse", "--abbrev-ref", `${row.branch}@{upstream}`]);
62
+ if (!upstream) why.push("git could not tell");
63
+ else if (upstream.status !== 0) why.push("no upstream");
64
+ else {
65
+ const ahead = out(git.run(row.path, ["rev-list", "--count", `${upstream.stdout.trim()}..${row.branch}`]));
66
+ if (ahead === undefined) why.push("git could not tell"); else if (ahead !== "0") why.push("unpushed commits");
67
+ }
68
+ if (!defaultBranch) why.push("git could not tell");
69
+ else {
70
+ const merged = git.run(row.path, ["merge-base", "--is-ancestor", row.branch, defaultBranch]);
71
+ if (!merged) why.push("git could not tell"); else if (merged.status === 1) why.push("branch not merged into the default branch"); else if (merged.status !== 0) why.push("git could not tell");
72
+ }
73
+ }
74
+ return [...new Set(why)];
75
+ }
76
+
77
+ /** The repository's default branch from its origin HEAD, else `main` or `master` when exactly one exists locally. Read-only. */
78
+ export function defaultBranchOf(root: string, git: TidyGit): string | undefined {
79
+ const head = out(git.run(root, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]));
80
+ if (head && /^[A-Za-z0-9._-]+\/[A-Za-z0-9._/-]+$/.test(head)) return head;
81
+ const local = ["main", "master"].filter(name => git.run(root, ["rev-parse", "--verify", "--quiet", `refs/heads/${name}`])?.status === 0);
82
+ return local.length === 1 ? local[0] : undefined;
83
+ }
84
+
85
+ /** The fingerprint covers every row's path, branch, HEAD and refusals, in path order: any change to the set or to a row changes it. */
86
+ export function fingerprintOf(entries: readonly { path: string; branch: string | null; head: string; refused: readonly string[]; liveBranch?: string | null }[]): string {
87
+ const hash = createHash("sha256");
88
+ for (const e of [...entries].sort((a, b) => a.path.localeCompare(b.path))) hash.update(JSON.stringify([e.path, e.branch, e.liveBranch ?? null, e.head, [...e.refused].sort()])).update("\n");
89
+ return hash.digest("hex").slice(0, 16);
90
+ }
91
+
92
+ /**
93
+ * The daemon shows project-relative paths and never an absolute one outside the project. A settled row whose path is outside the project (`outside: true`)
94
+ * or that names `..` cannot be resolved safely, so it becomes a refused row with no path to operate on.
95
+ */
96
+ export function rowsFromReply(settled: readonly { path?: unknown; branch?: unknown; reason?: unknown; outside?: unknown; taskState?: unknown }[], root: string, git: TidyGit): TidyRow[] {
97
+ const inventory = raw(git.run(root, ["worktree", "list", "--porcelain", "-z"]));
98
+ const listed = new Map<string, { head: string; locked: boolean; branch: string | null }>();
99
+ let current: { path: string; head: string; locked: boolean; branch: string | null } | undefined;
100
+ for (const field of (inventory ?? "").split("\0")) {
101
+ if (field === "") { if (current) listed.set(current.path, current); current = undefined; continue; }
102
+ if (field.startsWith("worktree ")) { if (current) listed.set(current.path, current); current = { path: field.slice(9), head: "", locked: false, branch: null }; }
103
+ else if (current && field.startsWith("HEAD ")) current.head = field.slice(5);
104
+ else if (current && field.startsWith("branch refs/heads/")) current.branch = field.slice(18); // `detached` has no branch field: it stays null
105
+ else if (current && (field === "locked" || field.startsWith("locked "))) current.locked = true;
106
+ }
107
+ if (current) listed.set(current.path, current);
108
+ return settled.flatMap(row => {
109
+ if (typeof row.path !== "string" || row.outside === true || row.path.includes("\0") || row.path.split("/").includes("..")) return [];
110
+ const absolute = row.path === "." ? root : resolve(join(root, row.path));
111
+ const fact = listed.get(absolute);
112
+ // Only a path git itself lists is operated on: the daemon's word alone never names a directory to remove.
113
+ if (!fact) return [];
114
+ return [{ path: absolute, shown: row.path, branch: typeof row.branch === "string" ? row.branch : null, head: fact.head, locked: fact.locked, liveBranch: fact.branch, reason: String(row.reason ?? ""), instance: false, ...(typeof row.taskState === "string" ? { taskState: row.taskState } : {}) }];
115
+ });
116
+ }
117
+
118
+ export function planTidy(rows: readonly TidyRow[], root: string, git: TidyGit): TidyPlan {
119
+ const defaultBranch = defaultBranchOf(root, git);
120
+ const detailed = rows.map(row => ({ row, refused: refusals(row, root, git, defaultBranch) }));
121
+ const entries: TidyEntry[] = detailed.map(({ row, refused }) => ({ path: row.path, shown: row.shown, branch: row.branch, reason: row.reason, refused }));
122
+ return { entries, removable: entries.filter(e => e.refused.length === 0), fingerprint: fingerprintOf(detailed.map(({ row, refused }) => ({ path: row.path, branch: row.branch, ...(row.liveBranch !== undefined ? { liveBranch: row.liveBranch } : {}), head: row.head, refused }))) };
123
+ }
124
+
125
+ export interface TidyOutcome { path: string; shown: string; branch: string; worktreeRemoved: boolean; branchDeleted: boolean; note?: string }
126
+ /**
127
+ * Re-read, compare, then remove. `fresh` is a second `planTidy` the caller took AFTER the person typed the fingerprint, so a worktree that
128
+ * changed in between refuses the whole run before anything is removed. Each removal is read back; a failed `worktree remove` skips its branch.
129
+ */
130
+ export function confirmTidy(fresh: TidyPlan, fingerprint: string, root: string, git: TidyGit): { ok: false; error: string } | { ok: true; outcomes: TidyOutcome[] } {
131
+ if (!/^[0-9a-f]{16}$/.test(fingerprint)) return { ok: false, error: "the fingerprint is 16 hex digits, from the preview line `fingerprint`" };
132
+ if (fresh.fingerprint !== fingerprint) return { ok: false, error: "the worktrees changed since the preview, so nothing was removed; run ahub worktrees tidy again and confirm the new fingerprint" };
133
+ if (!fresh.removable.length) return { ok: false, error: "nothing is safe to remove; see the refusals in the preview" };
134
+ const outcomes: TidyOutcome[] = [];
135
+ for (const entry of fresh.removable) {
136
+ const branch = entry.branch!;
137
+ const [removeWorktree, deleteBranch] = removalArgv(root, entry.path, branch);
138
+ assertRemovalArgv(removeWorktree); assertRemovalArgv(deleteBranch);
139
+ const removed = git.run(root, removeWorktree.slice(2));
140
+ const gone = removed?.status === 0 && git.run(root, ["worktree", "list", "--porcelain", "-z"])?.stdout.split("\0").every(field => field !== `worktree ${entry.path}`) === true;
141
+ if (!gone) { outcomes.push({ path: entry.path, shown: entry.shown, branch, worktreeRemoved: false, branchDeleted: false, note: "git refused or did not confirm removing the worktree; it and its branch were kept" }); continue; }
142
+ const deleted = git.run(root, deleteBranch.slice(2));
143
+ const branchGone = deleted?.status === 0 && git.run(root, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`])?.status === 1;
144
+ outcomes.push({ path: entry.path, shown: entry.shown, branch, worktreeRemoved: true, branchDeleted: branchGone, ...(branchGone ? {} : { note: "the worktree was removed; git kept the branch (not fully merged), which is safe" }) });
145
+ }
146
+ return { ok: true, outcomes };
147
+ }
148
+
149
+ /** Real git, bounded, run in this CLI process. Only the removal argv of `removalArgv` and the read shapes the checks above use reach it. */
150
+ export const realTidyGit = (spawn: (argv: string[], cwd: string) => { status: number | null; stdout: string } | undefined): TidyGit => ({
151
+ run(cwd, args) {
152
+ const write = args[0] === "worktree" && args[1] === "remove" || args[0] === "branch" && args[1] === "-d";
153
+ if (write) assertRemovalArgv(["-C", cwd, ...args]); // a removal always runs in the project root, with the worktree path or branch as its operand
154
+ // `--no-optional-locks`: a plain `git status` rewrites the worktree's own index file, which is exactly what the shelf reads as activity (D1). Without it a tidy
155
+ // preview would make every settled worktree look freshly edited, and it would leave the shelf on the next read. The preview must not change what it measures.
156
+ return spawn(["--no-optional-locks", "-C", cwd, "-c", "gc.auto=0", "-c", "maintenance.auto=false", ...args], cwd);
157
+ },
158
+ });
@@ -0,0 +1,43 @@
1
+ // Human output for `ahub worktrees` and `ahub worktrees tidy` (#1057). Pure: data in, spans out, no I/O. `--json` is the caller's branch and prints the same data.
2
+ import { blank, grid, heading, pair, s } from "./sdlc-output.ts";
3
+ import { moment } from "./sdlc-output.ts";
4
+ import type { Span } from "./console-state.ts";
5
+
6
+ const list = (value: unknown): any[] => Array.isArray(value) ? value : [];
7
+ const reasonLabel = (row: any): string => row?.reason === "merged" ? "PR merged" : row?.reason === "closed" ? "PR closed" : row?.reason === "quiet" ? "quiet" : row?.reason === "parked" ? "parked" : row?.reason === "settled" ? "settled" : "";
8
+ const taskLabel = (row: any): string => row?.private === true ? "task: private" : typeof row?.task === "number" ? `#${row.task}${typeof row.pr === "number" ? ` PR ${row.pr}` : ""}` : "";
9
+
10
+ /** `ahub worktrees`: live rows, then the shelf with each settled row's reason. */
11
+ export function renderWorktrees(reply: unknown, columns?: number, now = Date.now()): Span[][] {
12
+ const r = reply as any, live = list(r?.live), settled = list(r?.settled);
13
+ const rows = (items: any[], withReason: boolean) => items.map((row: any) => [
14
+ s(row.path), s(row.branch ?? "(detached)", "muted"), s(taskLabel(row), "issueRef"), ...(withReason ? [s(reasonLabel(row), "attention")] : []), s(moment(row.lastActiveAt, now), "muted"),
15
+ ]);
16
+ const out: Span[][] = [...heading(`Worktrees: ${live.length} live, ${settled.length} settled`, columns)];
17
+ if (live.length) out.push(...grid(["PATH", "BRANCH", "TASK", "ACTIVE"], rows(live, false), columns));
18
+ if (settled.length) out.push(blank, ...heading("Shelf (settled)", columns), ...grid(["PATH", "BRANCH", "TASK", "WHY", "ACTIVE"], rows(settled, true), columns));
19
+ if (!live.length && !settled.length) out.push([s("no worktrees", "muted")]);
20
+ if (r?.complete === false) out.push(blank, [s("the list was cut off at its bound; some worktrees are not shown", "attention")]);
21
+ if (settled.length) out.push(blank, [s("preview what could be removed with ahub worktrees tidy; nothing is removed until you confirm", "muted")]);
22
+ return out;
23
+ }
24
+
25
+ /** `ahub worktrees tidy`: each settled row that is safe to remove, each refused row with its reasons, and the fingerprint to confirm. */
26
+ export function renderTidyPreview(plan: unknown, columns?: number): Span[][] {
27
+ const p = plan as any, entries = list(p?.entries), removable = list(p?.removable), refused = entries.filter((e: any) => list(e.refused).length);
28
+ const out: Span[][] = [...heading(`Tidy preview: ${removable.length} safe to remove, ${refused.length} refused`, columns)];
29
+ if (removable.length) out.push(...grid(["PATH", "BRANCH", "WHY SETTLED"], removable.map((e: any) => [s(e.shown), s(e.branch ?? ""), s(reasonLabel(e))]), columns));
30
+ if (refused.length) out.push(blank, ...heading("Refused", columns), ...grid(["PATH", "BRANCH", "REFUSED BECAUSE"], refused.map((e: any) => [s(e.shown), s(e.branch ?? ""), s(list(e.refused).join("; "), "attention")]), columns));
31
+ if (!entries.length) out.push([s("nothing is on the shelf", "muted")]);
32
+ if (removable.length) out.push(blank, ...pair("fingerprint", String(p.fingerprint), columns, 12), [s(`confirm with: ahub worktrees tidy --confirm ${p.fingerprint}`, "muted")], [s("each removal runs git worktree remove and git branch -d, without force; git keeps anything it considers unsafe", "muted")]);
33
+ return out;
34
+ }
35
+
36
+ /** `ahub worktrees tidy --confirm`: what happened to each row. */
37
+ export function renderTidyResult(outcomes: unknown, columns?: number): Span[][] {
38
+ const rows = list(outcomes);
39
+ const out: Span[][] = [...heading(`Tidy: ${rows.filter((o: any) => o.worktreeRemoved).length} of ${rows.length} removed`, columns)];
40
+ out.push(...grid(["PATH", "BRANCH", "WORKTREE", "BRANCH"], rows.map((o: any) => [s(o.shown), s(o.branch), s(o.worktreeRemoved ? "removed" : "kept", o.worktreeRemoved ? "muted" : "attention"), s(o.branchDeleted ? "deleted" : "kept", o.branchDeleted ? "muted" : "attention")]), columns));
41
+ for (const o of rows.filter((x: any) => x.note)) out.push([s(`${o.shown}: ${o.note}`, "muted")]);
42
+ return out;
43
+ }
package/src/hub/board.ts CHANGED
@@ -474,6 +474,8 @@ export class Board {
474
474
  this.db.run("CREATE TABLE IF NOT EXISTS security_scans (id INTEGER PRIMARY KEY AUTOINCREMENT, at INTEGER NOT NULL, repo TEXT NOT NULL, sources TEXT NOT NULL, counts TEXT NOT NULL, total INTEGER)");
475
475
  // #843 AC3: one row per (agent id, installed version, planner) already queued, so a restart never repeats it.
476
476
  this.db.run("CREATE TABLE IF NOT EXISTS agent_drift_notices (marker TEXT PRIMARY KEY, at INTEGER NOT NULL)");
477
+ // #1057 D2: the person's park or pin of one worktree. Facts only: whether a worktree is settled is derived at read time and never stored here.
478
+ this.db.run("CREATE TABLE IF NOT EXISTS worktree_override (path TEXT PRIMARY KEY, value TEXT NOT NULL CHECK(value IN ('settled','active')), at INTEGER NOT NULL)");
477
479
  // Derived status-cache generation: only Board task/stall writes advance it, never journal writes.
478
480
  this.db.run("CREATE TABLE IF NOT EXISTS board_status_revision (id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL)");
479
481
  this.db.run("INSERT OR IGNORE INTO board_status_revision VALUES (1,0)");
@@ -1474,6 +1476,26 @@ export class Board {
1474
1476
  return this.db.query("INSERT OR IGNORE INTO agent_drift_notices(marker, at) VALUES(?, ?)").run(marker, Date.now()).changes > 0;
1475
1477
  }
1476
1478
  forgetAgentDriftNotice(marker: string): void { this.db.query("DELETE FROM agent_drift_notices WHERE marker=?").run(marker); }
1479
+ /** #1057 D2: every override, newest first, bounded. Rows made inert by later activity are removed lazily by the next write (`setWorktreeOverride`). */
1480
+ worktreeOverrides(): { path: string; value: "settled" | "active"; at: number }[] {
1481
+ return this.db.query("SELECT path, value, at FROM worktree_override ORDER BY at DESC LIMIT 1024").all() as { path: string; value: "settled" | "active"; at: number }[];
1482
+ }
1483
+ /** One override per path; a new write replaces the old. `path` is the absolute host path the shelf listed, never a name a request invented. */
1484
+ setWorktreeOverride(path: string, value: "settled" | "active", at: number): void {
1485
+ if (typeof path !== "string" || !path.startsWith("/") || path.length > 4096 || path.includes("\0")) throw new Error("invalid worktree path");
1486
+ if (value !== "settled" && value !== "active") throw new Error("invalid worktree override");
1487
+ if (!Number.isFinite(at) || at <= 0) throw new Error("invalid worktree override time");
1488
+ this.db.query("INSERT INTO worktree_override(path, value, at) VALUES(?,?,?) ON CONFLICT(path) DO UPDATE SET value=excluded.value, at=excluded.at").run(path, value, at);
1489
+ }
1490
+ clearWorktreeOverride(path: string): boolean {
1491
+ return this.db.query("DELETE FROM worktree_override WHERE path=?").run(path).changes > 0;
1492
+ }
1493
+ /** The lazy removal D2 names: an override whose worktree has newer activity is inert, so a write deletes it. `active` maps path to its lastActiveAt. */
1494
+ pruneInertWorktreeOverrides(activity: ReadonlyMap<string, number>): number {
1495
+ let removed = 0;
1496
+ for (const row of this.worktreeOverrides()) { const last = activity.get(row.path); if (last !== undefined && last > row.at) removed += this.clearWorktreeOverride(row.path) ? 1 : 0; }
1497
+ return removed;
1498
+ }
1477
1499
  clearStall(task: number): void { this.taskWrite(() => this.db.query("DELETE FROM task_stalls WHERE task=?").run(task)); }
1478
1500
  /** One bounded primary-key metadata read for a retry's saved replacement cause. */
1479
1501
  stall(task: number): TaskStall | undefined {
@@ -1512,6 +1534,9 @@ export class Board {
1512
1534
  if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
1513
1535
  const untouchedReturn = event === "backlog returned untouched" && patch.state === "backlog" && patch.owner === null && patch.reviewer === null
1514
1536
  && ["proposed", "in_progress"].includes(task.state) && expectedFlowRevision !== undefined && !!note?.trim() && !taskHasWorkEvidence(task);
1537
+ const returnPlanning: HistoryEntry["planning"] = untouchedReturn && Object.hasOwn(patch, "reserved")
1538
+ ? { revision: (task.planningRevision ?? 0) + 1, fields: ["reserved"], previous: { reserved: task.reserved ?? null } } : undefined;
1539
+ if (returnPlanning) patch = { ...patch, reserved: planningPatch({ reserved: patch.reserved }).reserved };
1515
1540
  // A new delivered head with a configured check is owner work again until that check finishes.
1516
1541
  const reviewCheck = task.sdlc?.merge?.state !== "merged" && event === "done (checking)" && ["in_review", "approved"].includes(task.state) && patch.state === "in_progress"
1517
1542
  && expectedFlowRevision !== undefined && (by === task.owner || by === "user") && patch.owner === undefined && (patch.reviewer === undefined || task.state === "approved" && !task.reviewer && patch.reviewer === "user")
@@ -1587,10 +1612,10 @@ export class Board {
1587
1612
  && priorHead !== undefined && /^[a-f0-9]{40}$/.test(priorHead);
1588
1613
  const previousHistory = backfill ? task.history.map((entry, index) => index === priorApproval && !entry.reviewedHead
1589
1614
  ? { ...entry, reviewedHead: entry.reviewFindings?.head ?? priorHead } : entry) : task.history;
1590
- const history = [...previousHistory, { at: Date.now(), by, event, ...(extra.invocation ? { invocation: extra.invocation } : {}), ...plannerHistory, ...authorHistory, ...(extra.selfReview === true ? { selfReview: true as const } : {}), ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...specMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.conductorManual === true ? { conductorManual: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(extra.reviewedHead ? { reviewedHead: extra.reviewedHead } : {}), ...(unmetCriteria?.length ? { unmetCriteria } : {}), ...(deliverables ? { deliverables } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewScope === "code" ? { reviewScope: "code" as const } : {}), ...(extra.evidenceOnly === true && event === "changes_requested" ? { evidenceOnly: true as const } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}), ...(["check passed", "check failed"].includes(event) && typeof extra.checkKey === "string" && /^[a-z][a-z0-9_]{0,47}$/.test(extra.checkKey) ? { checkKey: extra.checkKey } : {}), ...(["check passed", "check failed"].includes(event) && Number.isSafeInteger(extra.missingKeys) && extra.missingKeys! >= 0 && extra.missingKeys! <= 100_000 ? { missingKeys: extra.missingKeys } : {}), ...(event === "claim verdict" && extra.claim ? { claim: extra.claim } : {}) }];
1615
+ const history = [...previousHistory, { at: Date.now(), by, event, ...(returnPlanning ? { planning: returnPlanning } : {}), ...(extra.invocation ? { invocation: extra.invocation } : {}), ...plannerHistory, ...authorHistory, ...(extra.selfReview === true ? { selfReview: true as const } : {}), ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...specMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.conductorManual === true ? { conductorManual: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(extra.reviewedHead ? { reviewedHead: extra.reviewedHead } : {}), ...(unmetCriteria?.length ? { unmetCriteria } : {}), ...(deliverables ? { deliverables } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewScope === "code" ? { reviewScope: "code" as const } : {}), ...(extra.evidenceOnly === true && event === "changes_requested" ? { evidenceOnly: true as const } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}), ...(["check passed", "check failed"].includes(event) && typeof extra.checkKey === "string" && /^[a-z][a-z0-9_]{0,47}$/.test(extra.checkKey) ? { checkKey: extra.checkKey } : {}), ...(["check passed", "check failed"].includes(event) && Number.isSafeInteger(extra.missingKeys) && extra.missingKeys! >= 0 && extra.missingKeys! <= 100_000 ? { missingKeys: extra.missingKeys } : {}), ...(event === "claim verdict" && extra.claim ? { claim: extra.claim } : {}) }];
1591
1616
  this.taskWrite(() => {
1592
- const result = this.db.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ?, planner = ? WHERE id = ?")
1593
- .run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), planner, id);
1617
+ const result = this.db.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ?, planner = ?, planning_revision = ? WHERE id = ?")
1618
+ .run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), planner, returnPlanning?.revision ?? task.planningRevision ?? 0, id);
1594
1619
  if (result.changes) {
1595
1620
  if (next.codeSymbols) this.db.query("INSERT INTO task_code_symbols(task,payload) VALUES(?,?) ON CONFLICT(task) DO UPDATE SET payload=excluded.payload").run(id, JSON.stringify(next.codeSymbols));
1596
1621
  else this.db.query("DELETE FROM task_code_symbols WHERE task=?").run(id);
package/src/hub/bus.ts CHANGED
@@ -218,7 +218,7 @@ export class Bus {
218
218
  }
219
219
 
220
220
  /** Actual transport admission, using originals even when a digest was condensed. Never an enqueue metric. */
221
- onDelivered?: (peer: PeerId, originals: Envelope[]) => void;
221
+ onDelivered?: (peer: PeerId, originals: Envelope[], deliveryId?: string) => void;
222
222
  /** Before deliver's synchronous busy transition, with originals rather than a condensed digest. */
223
223
  onDeliver?: (peer: PeerId, originals: Envelope[]) => void;
224
224
  /** Final rendered journal payload, and its first positive acknowledgement. Observation cannot alter settlement. */
@@ -777,7 +777,7 @@ export class Bus {
777
777
  else this.journal.transition(id, state, receipt.reason);
778
778
  if (before?.state === "dispatching" && (state === "accepted" || state === "completed")) {
779
779
  this.memoryDelivery(peer, id, before.out, "delivered", before.originals);
780
- this.delivered(peer, before.originals);
780
+ this.delivered(peer, before.originals, id);
781
781
  }
782
782
  if (state === "failed") {
783
783
  if (before) this.failed(peer, before.originals, before.originals.some((e) => e.private) ? "private delivery failed" : receipt.reason, receipt.quota === true && before.state === "dispatching");
@@ -1258,8 +1258,8 @@ export class Bus {
1258
1258
  try { this.onMemoryDelivery?.(peer, id, out, field); } catch { /* optional telemetry never alters delivery */ }
1259
1259
  }
1260
1260
 
1261
- private delivered(peer: PeerId, originals: Envelope[]): void {
1262
- try { this.onDelivered?.(peer, originals); } catch { /* observation must never affect delivery settlement */ }
1261
+ private delivered(peer: PeerId, originals: Envelope[], deliveryId?: string): void {
1262
+ try { this.onDelivered?.(peer, originals, deliveryId); } catch { /* observation must never affect delivery settlement */ }
1263
1263
  }
1264
1264
 
1265
1265
  private deliveryFailed(peer: PeerId): void {
@@ -9,7 +9,7 @@ import { PreEffectToolRefusal } from "./tool-refusal.ts";
9
9
  import type { StartMode } from "./start-mode.ts";
10
10
  import type { HubEvent } from "./events.ts";
11
11
  import type { SupervisionFeed } from "./supervision.ts";
12
- import { DOING_STUB, type PeerDoing } from "./peer-doing.ts";
12
+ import { DOING_STUB, INCOMING_LABEL_NOTE, type PeerDoing } from "./peer-doing.ts";
13
13
  import type { Budget } from "./budget.ts";
14
14
  import { windowKind, type WindowKind } from "./window-kind.ts";
15
15
  import { supportsHeadlessInstances } from "./instances.ts";
@@ -144,11 +144,12 @@ export function publicConductorStatus(input: ConductorStatusInput, now = Date.no
144
144
  conductorHold: p.hold ? { actor: p.hold.actor, since: numberOrNull(p.hold.since) } : null,
145
145
  budgetPause: p.budgetPause ? { since: numberOrNull(p.budgetPause.since), resetsAt: numberOrNull(p.budgetPause.resetsAt) } : null,
146
146
  windows: p.windows === undefined ? null : p.windows.map((w) => ({ id: w.id, used: numberOrNull(w.used), resetsAt: numberOrNull(w.resetsAt), at: numberOrNull(w.at), source: w.source ?? null })),
147
- // #878: screened by the daemon, but written by the peer itself: framed as untrusted for the conductor agent.
147
+ // Native reports retain peer authorship; incoming message labels carry their own display provenance.
148
148
  doing: !p.doing ? null : p.doing.text === DOING_STUB ? { text: DOING_STUB, source: p.doing.source, at: numberOrNull(p.doing.at) }
149
+ : p.doing.source === "label" ? { text: sanitize(p.doing.text), source: "label", at: numberOrNull(p.doing.at), note: INCOMING_LABEL_NOTE, untrusted: true }
149
150
  : { text: sanitize(p.doing.text), source: p.doing.source, at: numberOrNull(p.doing.at), from: p.peer, untrusted: true },
150
151
  })),
151
- ...(input.peers.some(p => p.doing && p.doing.text !== DOING_STUB) ? { doingNote: DOING_NOTE } : {}),
152
+ ...(input.peers.some(p => p.doing && p.doing.source !== "label" && p.doing.text !== DOING_STUB) ? { doingNote: DOING_NOTE } : {}),
152
153
  taskCounts: Object.fromEntries(Object.entries(input.taskCounts).map(([state, count]) => [state, numberOrNull(count)])),
153
154
  approvals: input.approvals.map((a) => ({ peer: a.peer, tool: a.tool ?? null, ageMs: a.at === undefined || !Number.isFinite(a.at) ? null : Math.max(0, now - a.at) })),
154
155
  };
@@ -227,6 +227,7 @@ const schema: Record<string, Check> = {
227
227
  else if (/^[a-z][a-z0-9_]{0,47}$/.test(key)) text(value, `${k}.${key}`); else fail(`${k}.${key}`, "unknown key");
228
228
  } },
229
229
  snapshots: block({ enabled: bool, keep: number(1, 1_000_000, true) }), research: block({ enabled: bool, runners: researchRunners }), bench: block({ enabled: bool }),
230
+ worktrees: block({ quiet_days: number(0, 3650, true), alert_over: number(0, 100_000, true) }),
230
231
  peers: (v, k) => { map(block({ start_mode: words(["tui", "headless"]) }), START_MODE_PEERS)(v, k); peerStartConfig(v); },
231
232
  terminal: (v, k) => { block({ open: strings })(v, k); terminalTemplate(object(v, k).open); },
232
233
  start: (v, k) => { block({ agents: strings })(v, k); if (object(v, k).agents !== undefined) checkStartAgents(object(v, k).agents, `${k}.agents`); },
@@ -273,7 +274,7 @@ const restart: Record<string, string> = {
273
274
  profile: "Session tool schemas and profile contracts are bound at startup", profile_overrides: "Session tool schemas and profile contracts are bound at startup",
274
275
  text_projection: "Converter and cache ownership are bound at startup", corpus: "The corpus index is bound to its roots at startup", terminal: "Terminal provider is supplied by the daemon lifecycle", bench: "Benchmark execution environment is selected before work starts",
275
276
  };
276
- const live = new Set(["action_policy", "permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "invocation", "research", "snapshots", "tasks.wip_limits", "tasks.escalate_after", "tasks.auto_pull", "tasks.queue_backpressure", "schedules", "approvals.notify", "pet", "specs"]);
277
+ const live = new Set(["action_policy", "permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "invocation", "research", "snapshots", "worktrees", "tasks.wip_limits", "tasks.escalate_after", "tasks.auto_pull", "tasks.queue_backpressure", "schedules", "approvals.notify", "pet", "specs"]);
277
278
  const nextMemory = new Set(["memory.inject_tokens", "memory.brief_items", "memory.suggest_edges"]);
278
279
  /** Closed top-level schema; leaf overrides retain the existing session/runtime boundaries. */
279
280
  export function configApply(key: string, scopes: readonly ConfigIdentityScope[] = []): ApplyRule {