@staix/agent-hub 0.12.34 → 0.12.36

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 (91) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +8 -6
  3. package/docs/agent-notes/adapters.md +6 -1
  4. package/docs/agent-notes/budget.md +4 -2
  5. package/docs/agent-notes/bus.md +5 -1
  6. package/docs/agent-notes/daemon.md +11 -3
  7. package/docs/agent-notes/local-worker.md +1 -1
  8. package/docs/agent-notes/tasks.md +15 -2
  9. package/docs/agent-notes/tests.md +5 -1
  10. package/docs/gsd-collaboration-analysis.md +5 -1
  11. package/docs/operations.md +97 -19
  12. package/docs/quickstart.md +8 -4
  13. package/docs/sdlc.md +100 -34
  14. package/docs/security.md +7 -3
  15. package/docs/specs/2026-09-19-agent-hub-design.md +237 -7
  16. package/docs/verification/2026-10-18-0.12.35.md +49 -0
  17. package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
  18. package/docs/verification/2026-10-19-0.12.36.md +63 -0
  19. package/docs/verified.json +26 -17
  20. package/package.json +1 -1
  21. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  22. package/plugins/agent-hub/server.js +15781 -14850
  23. package/src/adapters/acp.ts +3 -1
  24. package/src/adapters/claude-channel.ts +59 -29
  25. package/src/adapters/claude-print.ts +230 -0
  26. package/src/adapters/codex-appserver.ts +1 -1
  27. package/src/adapters/definitions/builtin-metadata.ts +17 -1
  28. package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
  29. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -5
  30. package/src/adapters/definitions/native-cli-entry-jcode.ts +6 -2
  31. package/src/adapters/definitions/native-cli-entry-kimi.ts +18 -12
  32. package/src/adapters/definitions/native-cli-entry-local.ts +3 -2
  33. package/src/adapters/definitions/native-cli-entry-pi.ts +8 -6
  34. package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -2
  35. package/src/adapters/definitions/native-cli-entry-types.ts +3 -0
  36. package/src/adapters/definitions/native-factories.ts +7 -0
  37. package/src/adapters/definitions/native-recovery.ts +2 -2
  38. package/src/adapters/definitions/qwen-native.ts +2 -2
  39. package/src/adapters/local-worker.ts +3 -2
  40. package/src/adapters/pi.ts +3 -1
  41. package/src/adapters/registry-types.ts +11 -2
  42. package/src/adapters/registry.ts +36 -8
  43. package/src/cli/help.ts +5 -0
  44. package/src/cli/identity.ts +5 -4
  45. package/src/cli/launch.ts +45 -7
  46. package/src/cli/main.ts +136 -29
  47. package/src/cli/preview.ts +16 -5
  48. package/src/cli/recovery-guidance.ts +39 -22
  49. package/src/cli/reset.ts +4 -2
  50. package/src/cli/statusline-tee.ts +5 -2
  51. package/src/cli/terminal-recovery.ts +73 -16
  52. package/src/cli/upgrade-restore-state.ts +16 -7
  53. package/src/cli/upgrade-runtime.ts +83 -52
  54. package/src/cli/upgrade.ts +43 -27
  55. package/src/hub/board.ts +29 -7
  56. package/src/hub/budget.ts +55 -3
  57. package/src/hub/bus.ts +12 -6
  58. package/src/hub/claude-state.ts +37 -0
  59. package/src/hub/conductor.ts +16 -10
  60. package/src/hub/config-apply.ts +13 -2
  61. package/src/hub/config-reload.ts +11 -1
  62. package/src/hub/config-trust.ts +1 -1
  63. package/src/hub/control-client.ts +1 -1
  64. package/src/hub/cost-governance.ts +76 -0
  65. package/src/hub/crash.ts +14 -5
  66. package/src/hub/daemon.ts +473 -194
  67. package/src/hub/execution-budget.ts +3 -3
  68. package/src/hub/hub-tools.ts +36 -6
  69. package/src/hub/instance-budget.ts +73 -0
  70. package/src/hub/instance-config.ts +47 -0
  71. package/src/hub/instance-execution.ts +50 -0
  72. package/src/hub/instances.ts +114 -0
  73. package/src/hub/lifecycle-schema.ts +7 -3
  74. package/src/hub/permission-mode.ts +7 -7
  75. package/src/hub/review-brief.ts +175 -0
  76. package/src/hub/roster.ts +7 -6
  77. package/src/hub/routing.ts +97 -18
  78. package/src/hub/sdlc-queue.ts +2 -2
  79. package/src/hub/sdlc.ts +286 -39
  80. package/src/hub/settings.ts +34 -6
  81. package/src/hub/snapshots.ts +40 -9
  82. package/src/hub/start-mode.ts +7 -4
  83. package/src/hub/status-roster.ts +18 -0
  84. package/src/hub/task-sweep.ts +143 -0
  85. package/src/hub/tasks.ts +595 -47
  86. package/src/hub/text-projection.ts +7 -3
  87. package/src/hub/usage.ts +57 -20
  88. package/src/hub/workflow.ts +30 -52
  89. package/src/pi/launch.ts +4 -3
  90. package/src/ui/index.html +1 -1
  91. package/templates/AGENTS.block.md +2 -1
@@ -216,6 +216,7 @@ export class Turns {
216
216
  // ponytail: rows of finished tasks stay (a few per file per task); prune by task state if hub.db ever grows.
217
217
  this.db.run("CREATE TABLE IF NOT EXISTS touches (task INTEGER NOT NULL, peer TEXT NOT NULL, path TEXT NOT NULL, at INTEGER NOT NULL, PRIMARY KEY (task, peer, path))");
218
218
  this.db.run("CREATE INDEX IF NOT EXISTS touches_path ON touches (path)"); // the PreToolUse hook looks up one path
219
+ this.db.run("CREATE TABLE IF NOT EXISTS turn_workspaces (turn TEXT PRIMARY KEY, root TEXT NOT NULL, start_tree TEXT, end_tree TEXT, changed TEXT NOT NULL DEFAULT '[]')");
219
220
  this.db.run("CREATE TABLE IF NOT EXISTS task_snapshots (task INTEGER NOT NULL, proposed_at INTEGER NOT NULL, start_tree TEXT, PRIMARY KEY (task, proposed_at))");
220
221
  }
221
222
  /** A proposal's original project snapshot; task ids may be reused after reset. Never repair a missing baseline later. */
@@ -231,27 +232,55 @@ export class Turns {
231
232
  throw error;
232
233
  }
233
234
  }
234
- begin(id: string, peer: string, startTree: string | undefined): void {
235
- this.db.query("INSERT OR REPLACE INTO turns (id, peer, started, start_tree) VALUES (?, ?, ?, ?)").run(id, peer, Date.now(), startTree ?? null);
235
+ begin(id: string, peer: string, startTree: string | undefined, workspace?: string): void {
236
+ this.db.transaction(() => {
237
+ this.db.query("INSERT OR REPLACE INTO turns (id, peer, started, start_tree) VALUES (?, ?, ?, ?)").run(id, peer, Date.now(), workspace === undefined ? startTree ?? null : null);
238
+ this.db.query("DELETE FROM turn_workspaces WHERE turn = ?").run(id);
239
+ if (workspace !== undefined) this.db.query("INSERT INTO turn_workspaces (turn, root, start_tree) VALUES (?, ?, ?)").run(id, workspace, startTree ?? null);
240
+ })();
241
+ }
242
+ /** Only new extra-instance turns carry this evidence; absence preserves legacy external-peer workspace semantics. */
243
+ workspace(id: string): string | undefined {
244
+ try {
245
+ const row = this.db.query("SELECT root FROM turn_workspaces WHERE turn = ?").get(id) as { root: string } | null;
246
+ if (row && (typeof row.root !== "string" || !row.root)) throw new Error("invalid turn workspace evidence");
247
+ return row?.root;
248
+ } catch (error) {
249
+ if (/no such table/.test((error as Error).message)) return undefined;
250
+ throw error;
251
+ }
252
+ }
253
+ private snapshotFields<T extends { id: string; start_tree: string | null; end_tree: string | null; changed: string }>(row: T): T {
254
+ if (this.workspace(row.id) === undefined) return row;
255
+ const fields = this.db.query("SELECT start_tree, end_tree, changed FROM turn_workspaces WHERE turn = ?").get(row.id) as { start_tree: string | null; end_tree: string | null; changed: string };
256
+ return { ...row, ...fields };
236
257
  }
237
258
  /** The first native turn of a hub turn: reverting the conversation drops it and every later one. */
238
259
  native(id: string, native: string): void {
239
260
  this.db.query("UPDATE turns SET native = ? WHERE id = ? AND native IS NULL").run(native, id);
240
261
  }
241
262
  end(id: string, endTree: string | undefined, changed: string[], keep: number): void {
242
- this.db.query("UPDATE turns SET ended = ?, end_tree = ?, changed = ? WHERE id = ?").run(Date.now(), endTree ?? null, JSON.stringify(changed), id);
263
+ this.db.transaction(() => {
264
+ const scoped = this.workspace(id) !== undefined;
265
+ // Older CLIs do not know workspace receipts. Leave their canonical-checkout snapshot view unknown.
266
+ this.db.query("UPDATE turns SET ended = ?, end_tree = ?, changed = ? WHERE id = ?").run(Date.now(), scoped ? null : endTree ?? null, scoped ? "[]" : JSON.stringify(changed), id);
267
+ if (scoped) this.db.query("UPDATE turn_workspaces SET end_tree = ?, changed = ? WHERE turn = ?").run(endTree ?? null, JSON.stringify(changed), id);
268
+ })();
243
269
  const peer = (this.db.query("SELECT peer FROM turns WHERE id = ?").get(id) as { peer: string } | null)?.peer;
244
270
  if (peer) this.db.query("DELETE FROM turns WHERE peer = ? AND id NOT IN (SELECT id FROM turns WHERE peer = ? ORDER BY started DESC, rowid DESC LIMIT ?)").run(peer, peer, keep);
271
+ this.db.run("DELETE FROM turn_workspaces WHERE turn NOT IN (SELECT id FROM turns)");
245
272
  }
246
273
  list(peer?: string, limit = 20): TurnRecord[] {
247
274
  const rows = (peer
248
275
  ? this.db.query("SELECT * FROM turns WHERE peer = ? ORDER BY started DESC, rowid DESC LIMIT ?").all(peer, limit)
249
276
  : this.db.query("SELECT * FROM turns ORDER BY started DESC, rowid DESC LIMIT ?").all(limit)) as (Omit<TurnRecord, "changed"> & { changed: string })[];
250
- return rows.map((r) => ({ ...r, changed: JSON.parse(r.changed) as string[] }));
277
+ return rows.map(row => this.snapshotFields(row)).map((r) => ({ ...r, changed: JSON.parse(r.changed) as string[] }));
251
278
  }
252
279
  get(id: string): TurnRecord | undefined {
253
280
  const r = this.db.query("SELECT * FROM turns WHERE id = ?").get(id) as (Omit<TurnRecord, "changed"> & { changed: string }) | null;
254
- return r ? { ...r, changed: JSON.parse(r.changed) as string[] } : undefined;
281
+ if (!r) return undefined;
282
+ const record = this.snapshotFields(r);
283
+ return { ...record, changed: JSON.parse(record.changed) as string[] };
255
284
  }
256
285
  /**
257
286
  * Other peers' turns that overlapped this one in time: their changes are in this turn's diff too. `unknown` lists
@@ -263,21 +292,23 @@ export class Turns {
263
292
  // before" time in Turns.end if that ever matters.
264
293
  overlapping(turn: TurnRecord, keep: number): { paths: string[]; unknown: string[] } {
265
294
  const rows = this.db.query("SELECT id, changed, ended, start_tree, end_tree FROM turns WHERE id != ? AND peer != ? AND started < ? AND (ended IS NULL OR ended > ?)").all(turn.id, turn.peer, turn.ended ?? Date.now(), turn.started) as { id: string; changed: string; ended: number | null; start_tree: string | null; end_tree: string | null }[];
266
- const known = rows.filter((r) => r.ended !== null && r.start_tree !== null && r.end_tree !== null);
295
+ const scopedRows = rows.map(row => this.snapshotFields(row));
296
+ const known = scopedRows.filter((r) => r.ended !== null && r.start_tree !== null && r.end_tree !== null);
267
297
  const pruned = (this.db.query("SELECT peer, MIN(started) AS oldest FROM turns WHERE peer != ? GROUP BY peer HAVING COUNT(*) >= ?").all(turn.peer, keep) as { peer: string; oldest: number }[])
268
298
  .filter((p) => p.oldest > turn.started)
269
299
  .map((p) => `${p.peer} (its turns from then were pruned)`);
270
- return { paths: [...new Set(known.flatMap((r) => JSON.parse(r.changed) as string[]))], unknown: [...rows.filter((r) => !known.includes(r)).map((r) => r.id), ...pruned] };
300
+ return { paths: [...new Set(known.flatMap((r) => JSON.parse(r.changed) as string[]))], unknown: [...scopedRows.filter((r) => !known.includes(r)).map((r) => r.id), ...pruned] };
271
301
  }
272
302
 
273
303
  /** Other peers' completed, snapshotted turns that overlapped this turn and changed at least one of the same files. */
274
304
  concurrentWith(turn: TurnRecord): TurnRecord[] {
275
305
  if (turn.ended === null || turn.start_tree === null || turn.end_tree === null || !turn.changed.length) return [];
276
306
  const rows = this.db.query(`SELECT * FROM turns WHERE id != ? AND peer != ? AND started < ? AND ended > ?
277
- AND start_tree IS NOT NULL AND end_tree IS NOT NULL ORDER BY started, rowid`)
307
+ ORDER BY started, rowid`)
278
308
  .all(turn.id, turn.peer, turn.ended, turn.started) as (Omit<TurnRecord, "changed"> & { changed: string })[];
279
309
  const changed = new Set(turn.changed);
280
- return rows.map((r) => ({ ...r, changed: JSON.parse(r.changed) as string[] }))
310
+ return rows.map(row => this.snapshotFields(row)).filter(row => row.start_tree !== null && row.end_tree !== null)
311
+ .map((r) => ({ ...r, changed: JSON.parse(r.changed) as string[] }))
281
312
  .filter((other) => other.changed.some((path) => changed.has(path)));
282
313
  }
283
314
 
@@ -1,4 +1,4 @@
1
- import { definitionOf, SUPPORTED_PEERS, START_MODE_PEERS as REGISTRY_START_MODE_PEERS } from "../adapters/registry.ts";
1
+ import { definitionOf, SUPPORTED_PEERS, START_MODE_PEERS as REGISTRY_START_MODE_PEERS, type KindMap } from "../adapters/registry.ts";
2
2
 
3
3
  /**
4
4
  * #269: how a peer comes up when the hub starts it: its own TUI in a terminal, or headless. A person who types
@@ -20,22 +20,25 @@ export const isStartablePeer = (value: unknown): value is StartablePeer => typeo
20
20
 
21
21
  export type PeerStartConfig = Record<string, { start_mode?: StartMode }>;
22
22
  /** The checked per-peer start modes; only peers with both forms may be configured. */
23
- export function peerStartConfig(value: unknown): PeerStartConfig {
23
+ export function peerStartConfig(value: unknown, instances?: KindMap): PeerStartConfig {
24
24
  if (value === undefined) return {};
25
25
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("peers must be an object of per-peer settings");
26
26
  const result: PeerStartConfig = {};
27
27
  for (const [peer, block] of Object.entries(value)) {
28
- if (!(REGISTRY_START_MODE_PEERS as readonly string[]).includes(peer)) throw new Error(`peers.${peer}: only ${REGISTRY_START_MODE_PEERS.join(" and ")} have a start mode`);
28
+ const extra = definitionOf(peer, instances)?.kind !== peer && !!definitionOf(peer, instances);
29
+ if (!(REGISTRY_START_MODE_PEERS as readonly string[]).includes(peer) && !extra) throw new Error(`peers.${peer}: only ${REGISTRY_START_MODE_PEERS.join(" and ")} have a start mode`);
29
30
  if (!block || typeof block !== "object" || Array.isArray(block)) throw new Error(`peers.${peer} must be an object`);
30
31
  const mode = (block as { start_mode?: unknown }).start_mode;
31
32
  if (mode !== undefined && !(START_MODES as readonly unknown[]).includes(mode)) throw new Error(`peers.${peer}.start_mode must be tui or headless`);
33
+ if (extra && mode !== undefined && mode !== "headless") throw new Error(`peers.${peer}.start_mode: extra instances require headless mode in I2`);
32
34
  result[peer] = mode === undefined ? {} : { start_mode: mode as StartMode };
33
35
  }
34
36
  return result;
35
37
  }
36
38
 
37
39
  /** The mode a start of `peer` takes when nothing more specific was asked: `tui` wherever the peer has one. */
38
- export function startModeOf(peers: PeerStartConfig | undefined, peer: string): StartMode {
40
+ export function startModeOf(peers: PeerStartConfig | undefined, peer: string, instances?: KindMap): StartMode {
41
+ if (definitionOf(peer, instances)?.kind !== peer && definitionOf(peer, instances)) return peers?.[peer]?.start_mode ?? "headless";
39
42
  if ((REGISTRY_START_MODE_PEERS as readonly string[]).includes(peer)) return peers?.[peer]?.start_mode ?? definitionOf(peer)?.start.defaultForm ?? "tui";
40
43
  return definitionOf(peer)?.start.defaultForm ?? "headless";
41
44
  }
@@ -0,0 +1,18 @@
1
+ // No imports: native CLI entries load this from inside the adapter registry's import graph (#484).
2
+ /** A peer's membership from a `status` reply: undefined when the hub sends no roster (older than #484), never read as off. */
3
+ export function rosterInStatus(status: unknown, peer: string): boolean | undefined {
4
+ const rows = (status as { roster?: unknown } | undefined)?.roster;
5
+ if (!Array.isArray(rows)) return undefined;
6
+ const row = rows.find((r: unknown) => !!r && typeof r === "object" && (r as { peer?: unknown }).peer === peer) as { on?: unknown } | undefined;
7
+ return typeof row?.on === "boolean" ? row.on : undefined;
8
+ }
9
+
10
+ /**
11
+ * The plugin bundle the running daemon verifies Jcode's project MCP entry against. The launcher writes this path rather
12
+ * than its own copy: the CLI on PATH and the daemon can come from different installs, and the daemon compares the whole
13
+ * entry. undefined for a hub that sends none or a path that is not that bundle (an older hub, a malformed reply).
14
+ */
15
+ export function bundleInStatus(status: unknown): string | undefined {
16
+ const value = (status as { pluginBundle?: unknown } | undefined)?.pluginBundle;
17
+ return typeof value === "string" && value.startsWith("/") && /[\\/]plugins[\\/]agent-hub[\\/]server\.js$/.test(value) ? value : undefined;
18
+ }
@@ -58,3 +58,146 @@ export function nextSweep(task: Task, config: TaskSweepConfig, now: number, read
58
58
  if (previous && (previous.step === 3 || now - previous.at < config.ladder_min * 60_000)) return undefined;
59
59
  return { kind, activity, step: previous ? (previous.step + 1) as 2 | 3 : 1, at: now };
60
60
  }
61
+
62
+ export interface GroomingItem {
63
+ kind: "text-dependency" | "overlap" | "duplicate" | "over-wip" | "stale" | "superseded" | "blocked" | "closed-issue";
64
+ task: number;
65
+ related?: number[];
66
+ code?: string;
67
+ personCommand?: string;
68
+ }
69
+ export interface PlannerGroomingOptions {
70
+ repository: string;
71
+ limits: Record<string, number>;
72
+ wip: Record<string, number>;
73
+ states: Record<string, string>;
74
+ blockers?: readonly { task: number; code: string }[];
75
+ issues?: ReadonlyMap<string, { repo: string; issue: number; state: "OPEN" | "CLOSED"; editedAt: string; lastEditedAt?: string | null }>;
76
+ reviewedIssues?: ReadonlyMap<number, { editedAt: string }>;
77
+ acceptedIssues?: ReadonlyMap<number, { repo: string; issue: number; lastEditedAt: string | null }>;
78
+ }
79
+ const GROOMING_TASK_LIMIT = 500, GROOMING_ITEM_LIMIT = 256;
80
+ const GROOMING_CODES = new Set(["tests", "spec", "roster", "pull-only", "not-attached", "excluded", "owner-admission", "offline", "paused", "failing", "pii", "policy", "cancelled", "unavailable", "runner", "approvals", "quota", "dependency"]);
81
+ const groomingRepo = (value: unknown): string | undefined => {
82
+ if (typeof value !== "string") return undefined;
83
+ if (value.startsWith("path:/") && value.length <= 4096 && !/\p{Cc}/u.test(value)) return value;
84
+ return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value) && !value.startsWith("-") ? value.toLowerCase() : undefined;
85
+ };
86
+ const groomingOwn = <T>(values: Record<string, T>, key: string): T | undefined => Object.hasOwn(values, key) ? values[key] : undefined;
87
+ function groomingPath(value: string): string | undefined {
88
+ if (!value || value.length > 1000 || /\p{Cc}/u.test(value)) return;
89
+ const parts: string[] = [];
90
+ for (const part of value.split("/")) {
91
+ if (!part || part === ".") continue;
92
+ if (part === "..") { if (!parts.length) return; parts.pop(); }
93
+ else parts.push(part);
94
+ }
95
+ return parts.length ? (value.startsWith("/") ? "/" : "") + parts.join("/") : value.startsWith("/") ? "/" : ".";
96
+ }
97
+
98
+ /** Read-only ids/codes projection. Callers retain authority, privacy screening and the current metadata snapshot. */
99
+ export function plannerGrooming(tasks: readonly Task[], options: PlannerGroomingOptions): { items: GroomingItem[]; unknown: number[]; truncated: boolean } {
100
+ const items: GroomingItem[] = [], unknown = new Set<number>(), seen = new Set<string>();
101
+ let truncated = tasks.length > GROOMING_TASK_LIMIT;
102
+ const repository = groomingRepo(options.repository);
103
+ // ponytail: bounded O(500²) snapshot; add revision-keyed paging/indexing before supporting larger complete boards.
104
+ const snapshot = tasks.slice(0, GROOMING_TASK_LIMIT).filter(task => task.state !== "approved" && (task.state !== "cancelled" || task.history.some(entry => entry.supersededBy))).sort((a, b) => a.id - b.id);
105
+ const add = (item: GroomingItem) => {
106
+ const key = JSON.stringify(item);
107
+ if (seen.has(key)) return;
108
+ seen.add(key);
109
+ if (items.length >= GROOMING_ITEM_LIMIT) { truncated = true; return; }
110
+ items.push(item);
111
+ };
112
+ const repositories = new Map<number, string>();
113
+ const scoped = snapshot.flatMap(task => {
114
+ const scopes = [task.refs.repo, task.external?.repo, task.sdlc?.repo].filter(value => value !== undefined);
115
+ const normalized = scopes.map(groomingRepo);
116
+ if (normalized.some(value => !value) || new Set(normalized).size > 1 || (!repository && !normalized.length)) { unknown.add(task.id); return []; }
117
+ // ponytail: without project-root/Git identity, unqualified absolute paths cannot be attributed safely.
118
+ // A future hardened repository resolver (#169) may prove their scope instead of leaving it unknown.
119
+ if (!normalized.length && [...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])].some(path => path.startsWith("/"))) { unknown.add(task.id); return []; }
120
+ repositories.set(task.id, (normalized[0] ?? repository)!);
121
+ return [task];
122
+ });
123
+ const ids = new Set(scoped.map(task => task.id));
124
+ const issueIds = new Map<number, Set<number>>();
125
+ for (const task of scoped) {
126
+ const taskRepository = repositories.get(task.id)!;
127
+ const replacements = new Set<number>();
128
+ for (const entry of task.history) {
129
+ if (!entry.supersededBy) continue;
130
+ const match = /^#([1-9][0-9]*)$/.exec(entry.supersededBy);
131
+ const id = match ? Number(match[1]) : NaN;
132
+ if (Number.isSafeInteger(id) && id !== task.id && ids.has(id) && repositories.get(id) === taskRepository) replacements.add(id);
133
+ else unknown.add(task.id);
134
+ }
135
+ if (replacements.size) add({ kind: "superseded", task: task.id, related: [...replacements].sort((a, b) => a - b) });
136
+ if (task.state === "cancelled") continue; // Closed rows contribute supersession evidence only.
137
+ const issues = new Set<number>();
138
+ for (const issue of [task.external?.issue, task.sdlc?.issue]) {
139
+ if (issue === undefined) continue;
140
+ if (Number.isSafeInteger(issue) && issue > 0) issues.add(issue); else unknown.add(task.id);
141
+ }
142
+ // A bare title reference is an issue. Bare detail numbers remain board task references.
143
+ for (const match of task.title.matchAll(/(?<![A-Za-z0-9_./-])(?:https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/issues\/([1-9][0-9]*)\b|([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#([1-9][0-9]*)\b|#([1-9][0-9]*)\b)/g)) {
144
+ const scope = match[1] ?? match[3], issue = Number(match[2] ?? match[4] ?? match[5]);
145
+ if (scope && groomingRepo(scope) !== taskRepository) { unknown.add(task.id); continue; }
146
+ if (Number.isSafeInteger(issue)) issues.add(issue); else unknown.add(task.id);
147
+ }
148
+ issueIds.set(task.id, issues);
149
+ const missing = new Set<number>();
150
+ for (const match of task.detail.matchAll(/^\s*(?:after|waits for)\s+#([1-9][0-9]*)\b/gim)) {
151
+ const id = Number(match[1]);
152
+ if (id !== task.id && Number.isSafeInteger(id) && !(task.deps ?? []).includes(id)) {
153
+ if (ids.has(id)) missing.add(id); else unknown.add(task.id);
154
+ }
155
+ }
156
+ if (missing.size) add({ kind: "text-dependency", task: task.id, related: [...missing].sort((a, b) => a - b) });
157
+ const owner = task.owner ?? task.reserved;
158
+ if (owner) {
159
+ const count = groomingOwn(options.wip, owner), limit = groomingOwn(options.limits, owner), state = groomingOwn(options.states, owner);
160
+ if (count === undefined || limit === undefined || !Number.isSafeInteger(count) || count < 0 || !Number.isSafeInteger(limit) || limit < 0) unknown.add(task.id);
161
+ else if (task.owner && count > limit) add({ kind: "over-wip", task: task.id, personCommand: `ahub task unassign ${task.id}` });
162
+ if (state === "offline" || state === "paused") add({ kind: "blocked", task: task.id, code: state });
163
+ else if (state === undefined) unknown.add(task.id);
164
+ }
165
+ for (const issue of issues) {
166
+ const metadata = options.issues?.get(`${taskRepository}#${issue}`);
167
+ if (!metadata || groomingRepo(metadata.repo) !== taskRepository || metadata.issue !== issue || !["OPEN", "CLOSED"].includes(metadata.state) || !Number.isFinite(Date.parse(metadata.editedAt))) { unknown.add(task.id); continue; }
168
+ if (metadata.state === "CLOSED") add({ kind: "closed-issue", task: task.id });
169
+ const reviewed = options.reviewedIssues?.get(task.id), accepted = options.acceptedIssues?.get(task.id);
170
+ if (reviewed) {
171
+ if (!Number.isFinite(Date.parse(reviewed.editedAt))) unknown.add(task.id);
172
+ else if (Date.parse(metadata.editedAt) > Date.parse(reviewed.editedAt)) add({ kind: "stale", task: task.id });
173
+ }
174
+ if (accepted) {
175
+ const stamp = (value: unknown): value is string | null => value === null || typeof value === "string" && Number.isFinite(Date.parse(value));
176
+ // An old metadata snapshot has no lastEditedAt: its createdAt fallback cannot prove S1's null body stamp.
177
+ if (groomingRepo(accepted.repo) !== taskRepository || accepted.issue !== issue || !stamp(accepted.lastEditedAt)
178
+ || !Object.hasOwn(metadata, "lastEditedAt") || !stamp(metadata.lastEditedAt)) unknown.add(task.id);
179
+ else if (accepted.lastEditedAt !== metadata.lastEditedAt) add({ kind: "stale", task: task.id });
180
+ }
181
+ if (!reviewed && !accepted) unknown.add(task.id);
182
+ }
183
+ }
184
+ const places = (task: Task) => ({ paths: new Set([...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])].map(groomingPath).filter((path): path is string => path !== undefined)), symbols: new Set(task.plan?.symbols ?? []) });
185
+ const open = scoped.filter(task => task.state !== "cancelled");
186
+ const positions = new Map(open.map(task => [task.id, places(task)]));
187
+ const overlaps = (left: string, right: string): boolean => left === right || left === "." || right === "."
188
+ || left.startsWith(right.endsWith("/") ? right : right + "/") || right.startsWith(left.endsWith("/") ? left : left + "/");
189
+ for (let left = 0; left < open.length; left++) for (const right of open.slice(left + 1)) {
190
+ const task = open[left]!;
191
+ if (repositories.get(task.id) !== repositories.get(right.id)) continue;
192
+ const a = positions.get(task.id)!, b = positions.get(right.id)!;
193
+ if ([...a.paths].some(path => [...b.paths].some(other => overlaps(path, other))) || [...a.symbols].some(symbol => b.symbols.has(symbol))) add({ kind: "overlap", task: task.id, related: [right.id] });
194
+ if ([...issueIds.get(task.id)!].some(issue => issueIds.get(right.id)!.has(issue))) add({ kind: "duplicate", task: task.id, related: [right.id] });
195
+ }
196
+ for (const blocker of (options.blockers ?? []).slice(0, GROOMING_TASK_LIMIT)) {
197
+ if (!positions.has(blocker.task)) continue;
198
+ if (GROOMING_CODES.has(blocker.code)) add({ kind: "blocked", task: blocker.task, code: blocker.code });
199
+ else unknown.add(blocker.task);
200
+ }
201
+ if ((options.blockers?.length ?? 0) > GROOMING_TASK_LIMIT) truncated = true;
202
+ return { items, unknown: [...unknown].sort((a, b) => a - b), truncated };
203
+ }