@staix/agent-hub 0.28.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 (90) hide show
  1. package/CHANGELOG.md +22 -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 +4 -1
  7. package/docs/agent-notes/local-worker.md +3 -1
  8. package/docs/agent-notes/tasks.md +7 -3
  9. package/docs/agent-notes/tests.md +3 -0
  10. package/docs/design/adapters.md +56 -0
  11. package/docs/design/agent-hub-architecture.md +12 -0
  12. package/docs/design/bus.md +8 -0
  13. package/docs/design/task-board.md +19 -1
  14. package/docs/dogfood.md +13 -7
  15. package/docs/operations.md +177 -20
  16. package/docs/quickstart.md +18 -3
  17. package/docs/sdlc.md +2 -2
  18. package/docs/security.md +45 -4
  19. package/docs/smoke.md +13 -2
  20. package/docs/specs/2026-10-12-office-view-design.md +86 -3
  21. package/docs/verification/2026-11-03-0.29.0.md +59 -0
  22. package/docs/verification/2026-11-03-0.30.0.md +54 -0
  23. package/docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md +42 -0
  24. package/docs/verification/2026-11-03-agy-1.4.12.md +90 -0
  25. package/docs/verification/2026-11-03-agy-tui-close.md +49 -0
  26. package/docs/verification/2026-11-03-control-write-stall-windows.json +241 -0
  27. package/docs/verification/2026-11-03-control-write-stall-windows.md +20 -0
  28. package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
  29. package/docs/verification/2026-11-03-live-board-latency.json +556 -0
  30. package/docs/verification/2026-11-03-live-board-latency.md +64 -0
  31. package/docs/verification/2026-11-03-pi-150-compat.md +40 -0
  32. package/docs/verification/2026-11-03-pi-title-api.md +26 -0
  33. package/docs/verification/2026-11-03-qwen-0265-acp.md +55 -0
  34. package/docs/verification/2026-11-03-qwen-0265-dual-output.md +40 -0
  35. package/docs/verification/dogfood/2026-11-03.md +3095 -0
  36. package/docs/verification/pi-150-model-free.ts +57 -0
  37. package/docs/verification/pi-150-native.mjs +21 -0
  38. package/docs/verification/pi-150-title-api.mjs +31 -0
  39. package/docs/verified.json +14 -14
  40. package/package.json +2 -2
  41. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  42. package/plugins/agent-hub/server.js +715 -45
  43. package/src/adapters/compat.ts +10 -10
  44. package/src/adapters/definitions/builtin-metadata.ts +2 -2
  45. package/src/adapters/local-worker.ts +22 -6
  46. package/src/adapters/pi.ts +92 -10
  47. package/src/adapters/registry-types.ts +4 -0
  48. package/src/adapters/registry.ts +2 -0
  49. package/src/cli/autonomy.ts +66 -1
  50. package/src/cli/console-state.ts +6 -2
  51. package/src/cli/help.ts +3 -1
  52. package/src/cli/identity.ts +8 -1
  53. package/src/cli/main.ts +39 -0
  54. package/src/cli/output.ts +5 -0
  55. package/src/cli/recovery-package.ts +9 -0
  56. package/src/cli/worktree-tidy.ts +158 -0
  57. package/src/cli/worktrees-output.ts +43 -0
  58. package/src/hub/board.ts +31 -5
  59. package/src/hub/bus.ts +4 -4
  60. package/src/hub/conductor.ts +4 -3
  61. package/src/hub/config-apply.ts +2 -1
  62. package/src/hub/control-client.ts +16 -1
  63. package/src/hub/control-write-trace.ts +409 -0
  64. package/src/hub/daemon.ts +408 -66
  65. package/src/hub/dogfood-read.ts +62 -9
  66. package/src/hub/hub-tools.ts +12 -6
  67. package/src/hub/manager.ts +9 -1
  68. package/src/hub/peer-doing.ts +33 -3
  69. package/src/hub/peers.ts +7 -0
  70. package/src/hub/routing.ts +9 -2
  71. package/src/hub/runtime-capture-prune.ts +4 -1
  72. package/src/hub/sdlc-release-authorization.ts +13 -4
  73. package/src/hub/sdlc-release.ts +23 -10
  74. package/src/hub/sdlc.ts +15 -2
  75. package/src/hub/settings.ts +3 -0
  76. package/src/hub/task-worktree.ts +331 -0
  77. package/src/hub/tasks.ts +192 -25
  78. package/src/hub/ui.ts +2 -0
  79. package/src/hub/worktree-prs.ts +100 -0
  80. package/src/hub/worktree-shelf.ts +246 -0
  81. package/src/local/deny.ts +49 -0
  82. package/src/local/tools.ts +4 -2
  83. package/src/memory/brief.ts +5 -0
  84. package/src/memory/capture.ts +1 -1
  85. package/src/memory/client.ts +5 -2
  86. package/src/pi/extension.ts +43 -3
  87. package/src/ui/index.html +133 -10
  88. package/src/ui/office-model.ts +64 -8
  89. package/src/ui/office-space.ts +1 -0
  90. package/src/ui/plugin.ts +14 -3
@@ -12,7 +12,8 @@ import type { ProfileName } from "./profiles.ts";
12
12
  import type { StampedEvent } from "./events.ts";
13
13
 
14
14
  export const DOGFOOD_BYTE_CAP = 16 * 1024 * 1024;
15
- const MAX_TASKS = 500, MAX_PRS = 16, MAX_CI_HEADS = 8, MAX_CI_READS = 32;
15
+ /** MAX_PII bounds the PII inventory (ids and PR refs only), separately from the windowed task rows (#1147). */
16
+ const MAX_TASKS = 500, MAX_PII = 2000, MAX_PRS = 16, MAX_CI_HEADS = 8, MAX_CI_READS = 32;
16
17
  const date = (value: unknown): number | undefined => typeof value === "string" && Number.isFinite(Date.parse(value)) ? Date.parse(value) : undefined;
17
18
  const id = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) > 0;
18
19
  const repoName = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value) && !value.startsWith("-");
@@ -51,13 +52,59 @@ export function dogfoodEvents(lines: Source<string[]>, since: number, until: num
51
52
  }
52
53
  return { data: out, available: lines.available, complete };
53
54
  }
54
- /** Bounded read-only board snapshot. Select no task title/detail or history note into the result. */
55
- export function dogfoodTasks(file: string): Source<DogfoodTask[]> {
55
+ /** One PII task as the privacy inventory knows it: an id and its PR ref, never any text. */
56
+ export interface PiiRef { id: number; repo?: string; pr?: number }
57
+ /**
58
+ * #1147: every PII task of the board, read completely and independently of the scorecard window. Privacy depends only on a task's
59
+ * signals and its sdlc receipt, so only those are read. A row whose signals cannot be read (null, oversized, not a JSON array)
60
+ * could be a PII task, and a PII receipt that cannot be parsed hides its PR: both make the inventory incomplete, as does overflowing MAX_PII.
61
+ */
62
+ export function dogfoodPiiInventory(file: string): Source<PiiRef[]> {
63
+ let db: Database | undefined;
64
+ try {
65
+ db = new Database(file, { readonly: true });
66
+ db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
67
+ const rows = db.query(`SELECT id, unreadable, receipt_cut, sdlc FROM (
68
+ SELECT id,
69
+ CASE WHEN signals IS NULL OR length(signals) > 65536 THEN 1 WHEN NOT json_valid(signals) THEN 1 WHEN json_type(signals) <> 'array' THEN 1 ELSE 0 END AS unreadable,
70
+ CASE WHEN signals IS NOT NULL AND length(signals) <= 65536 AND json_valid(signals) AND json_type(signals) = 'array'
71
+ THEN EXISTS (SELECT 1 FROM json_each(signals) WHERE value IN ('pii','pii_path')) ELSE 0 END AS pii,
72
+ (sdlc IS NOT NULL AND length(sdlc) > 65536) AS receipt_cut,
73
+ CASE WHEN length(sdlc) <= 65536 THEN sdlc END AS sdlc FROM tasks)
74
+ WHERE unreadable = 1 OR pii = 1 ORDER BY id DESC LIMIT ?`).all(MAX_PII + 1) as { id: number; unreadable: number; receipt_cut: number; sdlc: string | null }[];
75
+ let complete = rows.length <= MAX_PII;
76
+ const refs: PiiRef[] = [];
77
+ for (const row of rows.slice(0, MAX_PII)) {
78
+ if (row.unreadable || row.receipt_cut || !id(row.id)) { complete = false; continue; }
79
+ try {
80
+ const receipt = JSON.parse(row.sdlc ?? "{}");
81
+ refs.push({ id: row.id, ...(repoName(receipt?.repo) ? { repo: receipt.repo } : {}), ...(id(receipt?.pr) ? { pr: receipt.pr } : {}) });
82
+ } catch { complete = false; }
83
+ }
84
+ return source(refs, complete);
85
+ } catch { return missing([]); }
86
+ finally { db?.close(); }
87
+ }
88
+ /**
89
+ * Bounded read-only board snapshot. Select no task title/detail or history note into the result.
90
+ * With a window (#1147) only tasks that overlap [since, until] are read (updated >= since and created <= until), so a board over
91
+ * MAX_TASKS rows still reads completely when the window holds few of them. A board without those columns reads its newest MAX_TASKS rows.
92
+ * `linkedPrs` adds every task whose receipt links one of those PR numbers, window or not: a PR's lead time, review rounds and findings
93
+ * need all its tasks, so a sibling last updated before `since` must not be dropped. Their overflow shares the MAX_TASKS bound and
94
+ * makes the source incomplete, which turns every board-derived PR metric unknown.
95
+ */
96
+ export function dogfoodTasks(file: string, window?: { since: number; until: number }, linkedPrs: number[] = []): Source<DogfoodTask[]> {
56
97
  let db: Database | undefined;
57
98
  try {
58
99
  db = new Database(file, { readonly: true });
59
100
  db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
60
101
  const columns = new Set((db.query("PRAGMA table_info(tasks)").all() as {name:string}[]).map(c=>c.name));
102
+ const windowed = window !== undefined && Number.isFinite(window.since) && Number.isFinite(window.until) && columns.has("created") && columns.has("updated");
103
+ const linked = windowed && columns.has("sdlc") ? [...new Set(linkedPrs.filter(n => Number.isSafeInteger(n) && n > 0))].slice(0, MAX_PRS) : [];
104
+ // json_extract raises on malformed JSON, so a row is only asked when its receipt is valid JSON.
105
+ const where = !windowed ? "" : linked.length
106
+ ? `WHERE (updated >= ? AND created <= ?) OR (CASE WHEN json_valid(sdlc) THEN json_extract(sdlc,'$.pr') END) IN (${linked.map(() => "?").join(",")})`
107
+ : "WHERE updated >= ? AND created <= ?";
61
108
  const metadata = columns.has("class") ? "CASE WHEN length(class) <= 48 THEN class END" : "NULL";
62
109
  const profile = columns.has("project_profile") ? "CASE WHEN length(project_profile) <= 65536 THEN project_profile END" : "NULL";
63
110
  const plan = columns.has("plan") ? "CASE WHEN length(plan) <= 65536 THEN plan END" : "NULL";
@@ -67,7 +114,7 @@ export function dogfoodTasks(file: string): Source<DogfoodTask[]> {
67
114
  const profileCut = columns.has("project_profile") ? "(project_profile IS NOT NULL AND length(project_profile) > 65536)" : "0";
68
115
  const rows = db.query(`SELECT (${planCut}) AS plan_cut, ${plan} AS plan, (${metadataCut}) AS metadata_cut, ${state} AS current_state, ${metadata} AS base_class, ${profileCut} AS profile_cut, ${profile} AS project_profile, id, (sdlc IS NOT NULL AND length(sdlc) > 65536) AS receipt_cut, CASE WHEN length(sdlc) <= 65536 THEN sdlc END AS sdlc,
69
116
  CASE WHEN length(signals) <= 65536 THEN signals END AS signals,
70
- CASE WHEN length(history) <= 131072 THEN history END AS history FROM tasks ORDER BY id DESC LIMIT ?`).all(MAX_TASKS + 1) as { plan_cut: number; plan: string | null; metadata_cut: number; current_state: string | null; profile_cut: number; base_class: string | null; project_profile: string | null; id: number; receipt_cut: number; sdlc: string | null; signals: string | null; history: string | null }[];
117
+ CASE WHEN length(history) <= 131072 THEN history END AS history FROM tasks ${where} ORDER BY id DESC LIMIT ?`).all(...(windowed ? [window!.since, window!.until, ...linked] : []), MAX_TASKS + 1) as { plan_cut: number; plan: string | null; metadata_cut: number; current_state: string | null; profile_cut: number; base_class: string | null; project_profile: string | null; id: number; receipt_cut: number; sdlc: string | null; signals: string | null; history: string | null }[];
71
118
  let complete = rows.length <= MAX_TASKS, bytes = 0;
72
119
  const tasks: DogfoodTask[] = [];
73
120
  for (const row of rows.slice(0,MAX_TASKS)) {
@@ -144,15 +191,18 @@ export function dogfoodJournal(file: string, since: number, until: number): Sour
144
191
  }
145
192
  const connection = (value: any): any[] | undefined => value?.pageInfo?.hasNextPage === false && Array.isArray(value.nodes) && value.nodes.length <= 100 ? value.nodes : undefined;
146
193
  /** Reuse S10 cost readback; supplementary reads carry timestamps/ids only, never comment or issue bodies. */
147
- export async function dogfoodPrs(repo: string, tasks: Source<DogfoodTask[]>, since: number, until: number, run: SdlcGh = sdlcGh): Promise<Source<DogfoodPr[]>> {
148
- if (!repoName(repo) || !tasks.available || !tasks.complete) return missing([]); // unknown privacy inventory forbids remote per-PR reads
194
+ export async function dogfoodPrs(repo: string, tasks: Source<DogfoodTask[]>, since: number, until: number, run: SdlcGh = sdlcGh, pii?: Source<PiiRef[]>): Promise<Source<DogfoodPr[]>> {
195
+ // An unknown privacy inventory forbids remote per-PR reads. With the PII inventory (#1147) only it decides privacy, so an incomplete
196
+ // windowed task list no longer blocks them (its links stay unknown elsewhere); without it the task list is the inventory, as before.
197
+ const inventory: Source<PiiRef[]> = pii ?? { ...tasks, data: tasks.data.filter(t => t.pii).map(t => ({ id: t.id, ...(t.repo ? { repo: t.repo } : {}), ...(t.pr ? { pr: t.pr } : {}) })) };
198
+ if (!repoName(repo) || !inventory.available || !inventory.complete) return missing([]);
149
199
  const deadline = performance.now() + 20000;
150
200
  const gh: SdlcGh = (args, timeout, cwd, cap) => performance.now() < deadline
151
201
  ? run(args, Math.max(1,Math.min(timeout ?? 5000,deadline-performance.now())),cwd,Math.min(cap ?? 128*1024,128*1024)) : Promise.resolve({code:1,stdout:""});
152
202
  const parse = async (args: string[]) => { try { const r = await gh(args); return r.code === 0 ? JSON.parse(r.stdout) : undefined; } catch { return undefined; } };
153
203
  const list = await parse(["pr","list","--repo",repo,"--state","merged","--search",`merged:>=${new Date(since).toISOString()}`,"--limit","100","--json","number,mergedAt,createdAt"]);
154
204
  if (!Array.isArray(list)) return missing([]);
155
- const hidden = new Set(tasks.data.filter(t => t.pii && (!t.repo || t.repo.toLowerCase() === repo.toLowerCase())).map(t => t.pr));
205
+ const hidden = new Set(inventory.data.filter(t => !t.repo || t.repo.toLowerCase() === repo.toLowerCase()).map(t => t.pr));
156
206
  const visible = list.filter(p => id(p?.number) && date(p.mergedAt) !== undefined && date(p.mergedAt)! <= until && !hidden.has(p.number));
157
207
  let complete = list.length < 100 && list.every(p => id(p?.number) && date(p.mergedAt) !== undefined) && visible.length <= MAX_PRS;
158
208
  // Read recent PRs first: their complete recorded head sets usually fit before
@@ -252,15 +302,18 @@ export function dogfoodProjectProfile(root: string): Source<ProfileName | null>
252
302
  return source(selected);
253
303
  }
254
304
  export async function readDogfood(root: string, stateDir: string, since: number, until = Date.now(), run: SdlcGh = sdlcGh) {
255
- const tasks = dogfoodTasks(join(stateDir,"hub.db"));
305
+ const tasks = dogfoodTasks(join(stateDir,"hub.db"),{since,until}), pii = dogfoodPiiInventory(join(stateDir,"hub.db"));
256
306
  let repo = "";
257
307
  try { const r = await run(["repo","view","--json","nameWithOwner"],5000,root,8192); if (r.code === 0) repo = JSON.parse(r.stdout).nameWithOwner; } catch { /* unknown */ }
258
308
  const events = dogfoodEvents(dogfoodLines(join(stateDir,"events.jsonl")),since,until);
259
309
  const log = dogfoodLines(join(stateDir,"hub.log"));
310
+ const prs = await dogfoodPrs(repo,tasks,since,until,run,pii);
260
311
  log.data = log.data.filter(line => {
261
312
  const at=date(line.slice(0,24));
262
313
  if (at === undefined) { log.complete = false; return false; }
263
314
  return at >= since && at <= until;
264
315
  });
265
- return dogfoodScorecard({since,until,tasks,events,log,projectProfile:dogfoodProjectProfile(root),journal:dogfoodJournal(join(stateDir,"hub.db"),since,until),prs:await dogfoodPrs(repo,tasks,since,until,run),features:dogfoodFeatures(join(root,"docs/dogfood.md"))});
316
+ const merged = prs.available ? prs.data.map(p => p.pr) : [];
317
+ const board = merged.length ? dogfoodTasks(join(stateDir,"hub.db"),{since,until},merged) : tasks;
318
+ return dogfoodScorecard({since,until,tasks:board,events,log,projectProfile:dogfoodProjectProfile(root),journal:dogfoodJournal(join(stateDir,"hub.db"),since,until),prs,features:dogfoodFeatures(join(root,"docs/dogfood.md"))});
266
319
  }
@@ -69,7 +69,7 @@ const plan = {
69
69
  } as const;
70
70
 
71
71
  // Accept may target a task from an older project profile; Tasks normalizes by the stored task profile.
72
- const acceptPlan = { ...plan, properties: { ...plan.properties, sections: list("document heading anchors, such as report.md#methods"), sources: list("citation ids, DOI or source URL ids"), claims: list("short claim ids to verify") } } as const;
72
+ const acceptPlan = { ...plan, properties: { ...plan.properties, sections: list("document heading anchors, such as report.md#methods"), sources: list("registered src:<16 hex> source ids; list available ids with ahub source list"), claims: list("short claim ids to verify") } } as const;
73
73
 
74
74
  export interface HubTool {
75
75
  name: string;
@@ -122,7 +122,7 @@ export const TASK_TOOLS: HubTool[] = [
122
122
  tool("hub_task_accept", "Take a task that was assigned to you, with your plan for it. Set refs.repo for work in another repository; acceptance may correct only that repository metadata.", { id, plan: acceptPlan, refs: { ...refs, properties: { repo: refs.properties.repo } } }, ["id"]),
123
123
  tool("hub_task_cancel", "Cancel proposed/backlog work you proposed as its configured planner, including work an owner accepted and then declined while it has no execution, branch, commit or PR evidence; work with any such evidence is person-only. Keeps history and does not stop a native turn or delete files.", { id, reason: str, superseded_by: str, revision: { type: "integer", minimum: 0 } }, ["id", "reason"]),
124
124
  tool("hub_task_decline", "Pass on a task assigned to you; the hub offers it to the next peer. A plain decline is a lasting refusal: you are never offered this task again. Pass capacity: true when you only have no room right now: you are skipped for this move only, and a later reroute or planner pull may offer it to you again.", { id, reason: str, capacity: { type: "boolean" } }, ["id"]),
125
- tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs; no duplicate hub_send chat is needed. If a covering spec is unchanged, update it or provide spec_ack with a public reason for each unchanged spec. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs, deliverables: deliverablesSchema, spec_ack: specAck }, ["id", "summary"]),
125
+ tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs; no duplicate hub_send chat is needed. When re-filing work after review, set refs.commit to the new full SHA; summary text does not change the review head. If a covering spec is unchanged, update it or provide spec_ack with a public reason for each unchanged spec. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs, deliverables: deliverablesSchema, spec_ack: specAck }, ["id", "summary"]),
126
126
  tool("hub_sdlc_record", "Record artifact ids or claimed local checks on your task. This grants no authority or external-check evidence. Only its owner/reviewer or the person may record; only its reviewer/person may name a reviewed head or phase checklist. Phase evidence refs are content hashes, not external verification.", { id, receipt: { type: "object", properties: { deliverables: deliverablesSchema, stage: { type: "string", enum: ["spec", "plan", "implement", "review", "verify", "merge", "release"] }, repo: str, issue: id, pr: id, localHead: str, localResult: { type: "string", enum: ["passed", "failed", "unknown"] }, reviewedHead: str, phase: { type: "object", properties: { id: str, head: str, tasks: { type: "array", maxItems: 64, items: { type: "object", properties: { id, after: { type: "array", maxItems: 16, items: id } }, required: ["id", "after"], additionalProperties: false } }, criteria: { type: "array", maxItems: 32, items: { type: "object", properties: { id: str, required: { type: "boolean" }, result: { type: "string", enum: ["verified", "failed", "unverified"] }, refs: { type: "array", maxItems: 8, items: str } }, required: ["id", "required", "result", "refs"], additionalProperties: false } } }, required: ["id", "tasks", "criteria"], additionalProperties: false } }, additionalProperties: false } }, ["id", "receipt"]),
127
127
  tool("hub_task_list", "The task board, newest updated first, 50 compact rows (id, state, owner, reviewer, title) per call. The reply is {tasks, total, next?}: pass next as cursor for the following page. Use ids for full rows of specific tasks, or detail: true for full rows of a page. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested", "cancelled"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" }, limit: { type: "integer", minimum: 1, maximum: 200, description: "rows per page, default 50" }, cursor: { type: "string", description: "the next value from the previous reply, unchanged" }, ids: { type: "array", minItems: 1, maxItems: 200, items: { type: "integer", minimum: 1 }, description: "full rows for exactly these task numbers; no paging" }, detail: { type: "boolean", description: "full rows instead of compact rows" } }),
128
128
  tool("hub_specs", "Read the project's design-spec checks or find specs covering repository-relative paths. Read-only; at most 20 result items are returned. Does not reveal private task data.", { action: { type: "string", enum: ["check", "covers"] }, paths: { type: "array", maxItems: 32, items: { type: "string", minLength: 1, maxLength: 2000 } } }, ["action"]),
@@ -154,10 +154,16 @@ export const PLANNER_REVIEW_TOOLS: HubTool[] = [
154
154
  tool("hub_plan_review", "Record a planner review against the task planning revision and host-read issue edit time. Configured planner or person only; never accepts caller issue timestamps.", { id, revision: { type: "integer", minimum: 0 }, reason: { ...str, maxLength: 1000 }, ...costPlanProperties }, ["id", "revision", "reason"]),
155
155
  tool("hub_sdlc_costs", "Read bounded review and CI cost metrics for linked merged PRs. Planner/person only; metadata only, no board writes.", { repo: str }),
156
156
  tool("hub_sdlc_metrics", "Read SDLC metrics from board history: time from acceptance to plan, first review and merge, first-pass approval and rework rounds, first-pass CI and same-head reruns, stale-head re-reviews, stalls, and the control band tiers when sdlc.toml sets [bands]. Planner/person only; read-only, no model and no GitHub issue.", { since: str }),
157
- tool("hub_backlog_return_untouched", "Return accepted but untouched work to the backlog with its history and planning retained. Refuses execution/branch/commit evidence; planner or person only.", { id, revision: { type: "integer", minimum: 0 }, reason: { ...str, maxLength: 1000 } }, ["id", "revision", "reason"]),
157
+ tool("hub_backlog_return_untouched", "Return accepted but untouched work to the backlog with its history and planning retained. Optional fields.reserved changes the reservation atomically before capacity is released; null clears it. Refuses execution/branch/commit evidence; planner or person only, and a person's reservation requires the person.", { id, revision: { type: "integer", minimum: 0 }, reason: { ...str, maxLength: 1000 }, fields: { type: "object", properties: { reserved: { type: ["string", "null"], minLength: 1, maxLength: 32 } }, additionalProperties: false } }, ["id", "revision", "reason"]),
158
158
  ];
159
159
  TASK_TOOLS.push(...PLANNER_REVIEW_TOOLS);
160
- export const TASK_TOOL_NAMES = new Set(TASK_TOOLS.map((t) => t.name));
160
+ /** Only definitions declaring a hub-owned title slot publish this tool. It is absent from global schema lists; native approval names must exclude it too. */
161
+ export const SESSION_TITLE_TOOL: HubTool = tool("hub_set_title", "Name your own session once the task is clear. Only an unnamed session accepts the first agent name; the person may replace it. Private contexts refuse the write. No task, branch or instance is renamed.", { title: { type: "string", description: "1-80 characters after doingText normalization (one line, controls removed, surrounding whitespace trimmed)" } }, ["title"]);
162
+ export function sessionTitleTools(tools: HubTool[], allowed = false): HubTool[] {
163
+ const filtered = tools.filter(tool => tool.name !== SESSION_TITLE_TOOL.name);
164
+ return allowed ? [...filtered, SESSION_TITLE_TOOL] : filtered;
165
+ }
166
+ export const TASK_TOOL_NAMES = new Set([...TASK_TOOLS.map((t) => t.name), SESSION_TITLE_TOOL.name]);
161
167
 
162
168
  /** Separately published: serving a schema grants no authority, only the daemon's explicit role does. */
163
169
  export const CONDUCTOR_TOOLS: HubTool[] = [
@@ -246,7 +252,7 @@ export function profileTaskTools(profile: ProfileSelection = "dev", stages?: rea
246
252
  properties.receipt = { ...receipt, properties: fields };
247
253
  }
248
254
  if (customProfile && tool.name === "hub_task_propose") properties.class = { type: "string", enum: [...new Set([...Object.keys(data.classes), ...PROFILE_CLASS_NAMES.filter(v => projectProfile("dev").classes[v])])] };
249
- if (customProfile && properties.plan && tool.name !== "hub_task_accept" && tool.name !== "hub_experiment_record") properties.plan = { ...plan, properties: { ...Object.fromEntries(data.plan_keys.map(key => [key, plan.properties[key as keyof typeof plan.properties] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "citation ids, DOI or source URL ids" : "short claim ids to verify")])) } };
255
+ if (customProfile && properties.plan && tool.name !== "hub_task_accept" && tool.name !== "hub_experiment_record") properties.plan = { ...plan, properties: { ...Object.fromEntries(data.plan_keys.map(key => [key, plan.properties[key as keyof typeof plan.properties] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "registered src:<16 hex> source ids; list available ids with ahub source list" : "short claim ids to verify")])) } };
250
256
  if (customProfile && tool.name === "hub_task_done" && data.name !== "dev") {
251
257
  const summaryProp = properties.summary as { type: string; description: string };
252
258
  properties.summary = {
@@ -258,7 +264,7 @@ export function profileTaskTools(profile: ProfileSelection = "dev", stages?: rea
258
264
  }
259
265
  if (customProfile && tool.name === "hub_backlog_split") {
260
266
  const parts = properties.parts as { items: { properties: Record<string, unknown> } };
261
- const selectedPlan = { ...plan, properties: { ...Object.fromEntries(data.plan_keys.map(key => [key, plan.properties[key as keyof typeof plan.properties] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "citation ids, DOI or source URL ids" : "short claim ids to verify")])) } };
267
+ const selectedPlan = { ...plan, properties: { ...Object.fromEntries(data.plan_keys.map(key => [key, plan.properties[key as keyof typeof plan.properties] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "registered src:<16 hex> source ids; list available ids with ahub source list" : "short claim ids to verify")])) } };
262
268
  properties.parts = { ...parts, items: { ...parts.items, properties: { ...parts.items.properties, plan: selectedPlan } } };
263
269
  }
264
270
  if (criteria && properties.plan && tool.name !== "hub_experiment_record") {
@@ -128,19 +128,27 @@ export function projectConsole(options: { registry: Registry; lifecycle: Lifecyc
128
128
  await bounded(lifecycle.stopProject(project, input.instanceId), 20_000);
129
129
  return { ok: true };
130
130
  }
131
+ // #1057: the worktree shelf's person actions. The refresh is the one that reads GitHub, so it gets the delivery reads' deadline in its own branch below.
132
+ const worktreeAction = ["worktree_override_set", "worktree_override_clear", "worktrees_refresh_prs"].includes(String(input.action));
131
133
  // The settings actions (#269) reach the project hub as an ordinary session's: this dashboard opens no settings session.
132
- if (!["snapshot", "send", "pause", "resume", "permit", "propose", "assign", "backlog_pull", "backlog_return", "planning", "planning_undo", "planner_pull", "backlog_split", "release_new", "release_change", "setting", "setting_undo", "setting_preview", "setting_reload", "config_edit_preview", "config_edit_apply", "start_peer", "sdlc_queue", "sdlc_release"].includes(String(input.action))) return { ok: false, error: "invalid dashboard action" };
134
+ if (!["snapshot", "send", "pause", "resume", "permit", "propose", "intake", "assign", "backlog_pull", "backlog_return", "planning", "planning_undo", "planner_pull", "backlog_split", "release_new", "release_change", "setting", "setting_undo", "setting_preview", "setting_reload", "config_edit_preview", "config_edit_apply", "start_peer", "sdlc_queue", "sdlc_release"].includes(String(input.action)) && !worktreeAction) return { ok: false, error: "invalid dashboard action" };
133
135
  client = await ControlClient.connect(project.stateDir, { role: "console", projectId: project.id, projectRoot: project.root, instanceId: input.instanceId });
134
136
  const deliveryRead = input.action === "sdlc_queue" || input.action === "sdlc_release";
135
137
  let nominalMs = 10_000;
136
138
  if (input.action === "start_peer") nominalMs = MAX_COMMAND_MS + 5_000;
137
139
  else if (input.action === "backlog_split") nominalMs = BACKLOG_SPLIT_SCREEN_TIMEOUT_MS + HUB_DB_BUSY_TIMEOUT_MS + 5_000;
140
+ else if (input.action === "intake") nominalMs = 90_000; // the CLI intake cap, including the hub model's bounded split
138
141
  else if (deliveryRead) nominalMs = 30_000;
142
+ else if (input.action === "worktrees_refresh_prs") nominalMs = 40_000; // up to 25 s of bounded gh reads plus two git passes
139
143
  const timeoutMs = options.forwardTimeoutMs?.(String(input.action), nominalMs) ?? nominalMs;
140
144
  const result = await client.request(input.action === "snapshot"
141
145
  ? { t: "ui_snapshot", after: input.after ?? 0, ...(input.memory !== undefined ? { memory: input.memory } : {}), ...(dashboardView(input.view) ? { view: input.view } : {}) }
142
146
  : { t: "ui_action", action: input, instanceId: input.instanceId }, timeoutMs);
143
147
  // ControlClient resolves its deadline as this exact sentinel; a hub refusal stays a known failure.
148
+ if (input.action === "intake" && result.ok === false && result.error === `no answer from the hub within ${Math.round(timeoutMs / 1000)} s`) {
149
+ const text = "Intake result is unconfirmed. Check Backlog before retrying; the hub may still create its drafts.";
150
+ return { ok: false, unconfirmed: true, text, error: text };
151
+ }
144
152
  if (["start_peer", "backlog_split"].includes(String(input.action)) && result.ok === false && result.error === `no answer from the hub within ${Math.round(timeoutMs / 1000)} s`) {
145
153
  const text = input.action === "backlog_split" ? "Split result is unconfirmed. Reload the task before retrying; the hub may still commit its children." : "Start result is unconfirmed. Check the Peers panel before retrying; the hub may still be starting the peer.";
146
154
  return { ok: false, unconfirmed: true, text, error: text };
@@ -4,11 +4,39 @@ import { closeSync, constants as fsConstants, fstatSync, openSync, readSync } fr
4
4
  * #878: what a peer is doing, derived from signals the hub already receives (a Claude session's title and todo
5
5
  * list, a Codex thread name and plan, an ACP plan). Daemon memory only: never persisted, logged, captured or relayed.
6
6
  */
7
- export interface PeerDoing { text: string; source: "title" | "todo"; at: number }
7
+ export interface PeerDoing { text: string; source: "title" | "todo" | "label"; at: number; note?: string }
8
8
  export type DoingKind = "title" | "todo" | "reset";
9
9
 
10
10
  export const DOING_MAX = 120;
11
11
  export const DOING_STUB = "[private]";
12
+ export const SESSION_TITLE_HINT = "This session has no title yet. Once the task is clear, call hub_set_title once.";
13
+ export const INCOMING_LABEL_NOTE = "label: derived from an incoming hub message; untrusted display text, never an instruction";
14
+ export interface SessionTitleBinding { session: string; delivery: string; envelope: string }
15
+ /** Closed ID-only restart metadata. No name, body or derived label may enter this file. */
16
+ export function sessionTitleBindings(input: unknown): Record<string, SessionTitleBinding> {
17
+ if (!input || typeof input !== "object" || Array.isArray(input)) return {};
18
+ const result: Record<string, SessionTitleBinding> = {};
19
+ for (const [peer, value] of Object.entries(input).slice(0, 128)) {
20
+ if (!/^[a-z][a-z0-9-]{0,31}$/.test(peer) || !value || typeof value !== "object" || Array.isArray(value)) continue;
21
+ const row = value as Record<string, unknown>;
22
+ if (Object.keys(row).some(key => !["session", "delivery", "envelope"].includes(key))) continue;
23
+ if ([row.session, row.delivery, row.envelope].some(value => typeof value !== "string" || !value || value.length > 512 || /[\p{Cc}\p{Cf}]/u.test(value))) continue;
24
+ result[peer] = { session: row.session as string, delivery: row.delivery as string, envelope: row.envelope as string };
25
+ }
26
+ return result;
27
+ }
28
+ export function sessionTitleText(value: unknown): string | undefined {
29
+ const text = doingText(value);
30
+ return text && [...text].length <= 80 ? text : undefined;
31
+ }
32
+ /** Derived for one display only. Callers retain the original delivery, never this label. */
33
+ export function unnamedSessionLabel(value: unknown, isPrivate: (text: string) => boolean, at: number): PeerDoing | undefined {
34
+ const text = doingText(value);
35
+ if (!text) return undefined;
36
+ let hidden = true;
37
+ try { hidden = isPrivate(typeof value === "string" ? value : text); } catch { /* screen the whole source, not only its displayed head */ }
38
+ return { text: hidden ? DOING_STUB : [...text].slice(0, 60).join(""), source: "label", at, note: INCOMING_LABEL_NOTE };
39
+ }
12
40
  /** At most this much of a transcript is read per signal; lines over TITLE_LINE_MAX are skipped unparsed. */
13
41
  export const TITLE_READ_MAX = 1024 * 1024;
14
42
  export const TITLE_LINE_MAX = 64 * 1024;
@@ -37,10 +65,12 @@ export class DoingTracker {
37
65
  }
38
66
 
39
67
  /** `privateNow`: the peer owns or reviews a PII task, or is in a private turn. */
40
- view(privateNow: boolean): PeerDoing | undefined {
68
+ view(privateNow: boolean, isPrivate?: (text: string) => boolean): PeerDoing | undefined {
41
69
  const value = this.item ?? this.title;
42
70
  if (!value) return undefined;
43
- return { text: value.private || privateNow ? DOING_STUB : value.text, source: value.source, at: value.at };
71
+ let hidden = value.private || privateNow;
72
+ if (!hidden && isPrivate) try { hidden = isPrivate(value.text); } catch { hidden = true; }
73
+ return { text: hidden ? DOING_STUB : value.text, source: value.source, at: value.at };
44
74
  }
45
75
  }
46
76
 
package/src/hub/peers.ts CHANGED
@@ -2,6 +2,7 @@ import type { Envelope, EnvelopeOpts, PeerId, PeerState } from "./envelope.ts";
2
2
  import type { PermissionMode } from "./permission-mode.ts";
3
3
  import type { StartForm } from "../adapters/registry-types.ts";
4
4
  import type { DoingKind } from "./peer-doing.ts";
5
+ export type SessionTitleResult = { ok: true } | { ok: false; reason: "already_named" | "invalid"; error?: string; mayHaveApplied?: true };
5
6
  import type { CapabilityManifest } from "./capabilities.ts";
6
7
 
7
8
  export interface DeliveryReceipt {
@@ -61,6 +62,12 @@ export interface PeerAdapter {
61
62
  captureResume?(): Promise<Record<string, unknown>>;
62
63
  /** Native session binding; absence or undefined is unbound, never inferred from a different reader. */
63
64
  readonly nativeSession?: string;
65
+ /** Verified native name, or the local session's own field. Empty means verified unnamed; undefined is unknown. */
66
+ readonly sessionTitle?: string;
67
+ /** Opaque title-session identity, separate from context/provenance's nativeSession authority. */
68
+ readonly titleSession?: string;
69
+ /** Native adapters check the live name at the actual write. The host guard is checked immediately before effects. */
70
+ setSessionTitle?(title: string, onlyIfUnnamed: boolean, current?: () => boolean): Promise<SessionTitleResult>;
64
71
  /** Fixed-builtin native proof only; present undefined is unknown, not an absent proxy. */
65
72
  readonly frontendProxy?: FrontendProxyPort;
66
73
  /** Fixed operator notices from builtin native adapters, never provider text or native ids. */
@@ -7,7 +7,7 @@ import { join, resolve } from "node:path";
7
7
  import { CLASSES, type Task, type TaskClass } from "./board.ts";
8
8
  import { isGitHubRepository, specRefs } from "./spec-read.ts";
9
9
 
10
- export interface PeerCapabilities { tests: boolean; spec: boolean; testsReason?: string; specReason?: string; /** #1081: the peer's shell can reach the network (through the egress proxy or directly). Absent means unknown, never refused. */ network?: boolean; networkReason?: string; /** #1081: the peer can create the task worktree (`git worktree add` runs outside a hub sandbox, #270). Absent means unknown, never refused. */ worktree?: boolean; worktreeReason?: string; }
10
+ export interface PeerCapabilities { tests: boolean; spec: boolean; testsReason?: string; specReason?: string; /** #1081: the peer's shell can reach the network (through the egress proxy or directly). Absent means unknown, never refused. */ network?: boolean; networkReason?: string; /** #1081: the peer can have the task worktree made for it: it makes its own, or (a hub sandbox, #270) the hub makes it (#1115) where it can. Absent means unknown, never refused. */ worktree?: boolean; worktreeReason?: string; }
11
11
  import type { PeerId, PeerState } from "./envelope.ts";
12
12
 
13
13
  import { parseHubRoutes, type HubRoute } from "../models/route/config.ts";
@@ -343,6 +343,12 @@ export function ownerRoleOfClass(cls: TaskClass): "planner" | "implementer" | "r
343
343
  return cls === "review" ? "reviewer" : cls === "plan" ? "planner" : "implementer";
344
344
  }
345
345
 
346
+ /**
347
+ * #1129: the classes whose owner changes the repository, so a hub-sandboxed owner needs the hub's task worktree and commit (#1115) whether or not the task declares
348
+ * `needs_worktree`. `test`, `summarize`, `triage`, `review` and `plan` read and report: they get none. One definition, so routing and `Tasks` cannot disagree.
349
+ */
350
+ export const writesRepository = (cls: TaskClass): boolean => cls === "implement" || cls === "bulk_edit";
351
+
346
352
  /** Why a planner was chosen: the person named it, rotation reached it, the task's recorded planner is kept, or there is none (PII, no holder). */
347
353
  export type PlannerReason = "rotation" | "named" | "kept" | "none";
348
354
 
@@ -493,7 +499,8 @@ export function assign(
493
499
  // #1081: work that declares it needs the network is never offered to a peer whose shell has none. Unknown (no `network` entry) is not a refusal.
494
500
  if (task.signals.includes("needs_network") && caps?.network === false) return deny("network", `no network: ${caps.networkReason ?? "its shell cannot reach the network"}`);
495
501
  // #1081: work that declares it needs a task worktree is never offered to a peer that cannot create one, so no branch ref is left behind by a refused accept.
496
- if (task.signals.includes("needs_worktree") && caps?.worktree === false) return deny("worktree", `no worktree: ${caps.worktreeReason ?? "it cannot create the task worktree"}`);
502
+ // #1129: write-class work needs one whether or not it declares it, because a hub-sandboxed owner cannot commit and the hub commits in its worktree. Unknown capability is not a refusal.
503
+ if ((task.signals.includes("needs_worktree") || writesRepository(task.class)) && caps?.worktree === false) return deny("worktree", `no worktree: ${caps.worktreeReason ?? "it cannot create the task worktree"}`);
497
504
  }
498
505
  if (opts.offRoster?.includes(peer)) return deny("roster", "off roster");
499
506
  const named = role === "owner" ? opts.namedOwner ?? reserved : opts.namedReviewer;
@@ -246,7 +246,10 @@ function knownLayout(root: string, directories: readonly string[], files: readon
246
246
  }
247
247
  }
248
248
  function captureLayout(root: string): void {
249
- knownLayout(root, ["src", "plugins", "templates", ".claude-plugin"], ["package.json", "README.md", "LICENSE", "CHANGELOG.md"], ["node_modules"]);
249
+ knownLayout(root, ["src", "plugins", "templates", ".claude-plugin"], ["package.json", "README.md", "LICENSE", "CHANGELOG.md"], ["node_modules", "scripts"]);
250
+ // #1120: captures made before the release checker was carried have no scripts directory; when present it holds exactly the checker.
251
+ // The name is repeated here on purpose: a pruner copied into a capture runs beside that capture's own verifier and imports nothing but verifyPackage from it. test/runtime-capture-checker.test.ts pins this name to RELEASE_CHECKER.
252
+ if (optionalInfo(join(root, "scripts"))) knownLayout(join(root, "scripts"), [], ["check-docs.mjs"]);
250
253
  const modules = join(root, "node_modules");
251
254
  if (optionalInfo(modules)) {
252
255
  knownLayout(modules, ["@staix"], []); const scope = join(modules, "@staix"); knownLayout(scope, ["agent-mem"], []);
@@ -2,7 +2,7 @@ import { releaseTrackedClean } from "./release-worktree.ts";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import { USER, type PeerId } from "./envelope.ts";
4
4
  import { hubGitEnv, hubGitSync } from "./git.ts";
5
- import { readDocEvidence, type DocEvidence, type ReleaseGit, type ReleaseReadiness } from "./sdlc-release.ts";
5
+ import { readDocEvidenceDetailed, type DocEvidence, type ReleaseGit, type ReleaseReadiness } from "./sdlc-release.ts";
6
6
  import type { SdlcGh } from "./sdlc.ts";
7
7
  import type { ReleaseGuardRequest, ReleasePushUpdate } from "../cli/sdlc-release-guard.ts";
8
8
 
@@ -17,6 +17,8 @@ export interface ReleaseAuthorization { schemaVersion: 1; id: string; by: PeerId
17
17
  export interface ReleaseAuthorizationPorts {
18
18
  git?: ReleaseGit;
19
19
  docs?: (root: string) => Promise<DocEvidence | undefined>;
20
+ /** Test seam: the checker program the default reader spawns (#1120). Never set from configuration. */
21
+ checker?: string;
20
22
  gh?: SdlcGh;
21
23
  now?: () => number;
22
24
  current?: () => boolean;
@@ -46,6 +48,13 @@ export class ReleaseAuthorizations {
46
48
  private latest?: Entry;
47
49
  constructor(private readonly root: string, private readonly ports: ReleaseAuthorizationPorts) {}
48
50
  private current(): void { if (this.ports.current?.() === false) throw new ReleaseAuthorizationError("release authorization unavailable during lifecycle transition"); }
51
+ /** #1120: a checker that is missing or failed is its own refusal; only evidence that is read and not fresh is "stale documentation". An injected reader keeps its undefined meaning (no evidence). */
52
+ private async docs(commit: string, version: string): Promise<ReleaseDocsVerdict | undefined> {
53
+ if (this.ports.docs) return docsVerdict(await this.ports.docs(this.root), commit, version);
54
+ const read = await readDocEvidenceDetailed(this.root, undefined, this.ports.checker);
55
+ if ("unavailable" in read) throw new ReleaseAuthorizationError(`release authorization needs the documentation checker: ${read.unavailable}`);
56
+ return docsVerdict(read.evidence, commit, version);
57
+ }
49
58
  private actor(by: PeerId): void {
50
59
  if (by !== USER && (typeof by !== "string" || !by.trim() || this.ports.conductor?.() !== by)) throw new ReleaseAuthorizationError("only the person or the configured conductor may authorize a release");
51
60
  }
@@ -95,8 +104,8 @@ export class ReleaseAuthorizations {
95
104
  this.current();
96
105
  const git = this.git(), commit = this.commit(git, "HEAD"), origin = this.origin(git);
97
106
  if (commit !== expectedCommit || this.version(git, commit) !== version) throw new ReleaseAuthorizationError("candidate commit or version differs from the person's request");
98
- const docs = docsVerdict(await (this.ports.docs ?? readDocEvidence)(this.root), commit, version);
99
- if (!docs) throw new ReleaseAuthorizationError("release authorization requires fresh committed documentation evidence");
107
+ const docs = await this.docs(commit, version);
108
+ if (!docs) throw new ReleaseAuthorizationError("release authorization requires fresh committed documentation evidence: run `node scripts/check-docs.mjs --committed` in the candidate checkout, restamp the stale documents in a committed change, then authorize again");
100
109
  this.current(); this.actor(by);
101
110
  if (this.commit(git, "HEAD") !== commit || this.version(git, commit) !== version || this.origin(git).hash !== origin.hash) throw new ReleaseAuthorizationError("release candidate changed during authorization");
102
111
  this.current(); this.actor(by);
@@ -127,7 +136,7 @@ export class ReleaseAuthorizations {
127
136
  if (!releaseTrackedClean(this.root, git)) throw new ReleaseAuthorizationError("npm publish requires a clean tracked worktree");
128
137
  const commit = this.commit(git, "HEAD"), version = this.version(git, commit);
129
138
  const receipt = this.matched(git, version, commit);
130
- const docs = docsVerdict(await (this.ports.docs ?? readDocEvidence)(this.root), commit, version);
139
+ const docs = await this.docs(commit, version);
131
140
  this.current();
132
141
  if (!docs || docs.digest !== receipt.docs.digest || this.commit(git, "HEAD") !== commit || !releaseTrackedClean(this.root, git)) throw new ReleaseAuthorizationError("publish checkout or documentation evidence changed");
133
142
  return { ok: true, receipts: [this.matched(git, version, commit)] };
@@ -1,6 +1,7 @@
1
1
  import type { Task } from "./board.ts";
2
2
  import type { Release } from "./planning.ts";
3
3
  import { spawn } from "node:child_process";
4
+ import { existsSync } from "node:fs";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { childEnv, stopOwnedProcess } from "./child-process.ts";
6
7
  import { hubGitSync } from "./git.ts";
@@ -23,7 +24,7 @@ const sdlcGh: SdlcGh = async (...args) => {
23
24
  export interface DocEvidence { schemaVersion: 1; head?: string; version?: string; documents: { path?: string; state: "fresh" | "stale" | "nonancestor" | "unknown"; verifiedAgainst?: string; commits?: number; dirty?: boolean }[]; errors: number; fresh: boolean; }
24
25
  export interface ReleaseReadiness { /** #481 AC12: interval, approval-to-release lead time and hotfix rate over shipped releases, filled by Tasks.releaseReadiness. */ releaseMetrics?: ReturnType<typeof releaseMetrics>; repo: string; head?: string; version?: string; tag?: string; tagCommit?: string; commits: string[]; mergedPrs: { pr: number; commit: string }[]; documents?: DocEvidence; liveIssues: { id: number; state: "open" | "closed" | "unknown" }[]; blockers: string[]; sourceReady: boolean; native: "unverified"; ready: false; }
25
26
  export type ReleaseGit = (args: string[]) => { code: number; stdout: string };
26
- export interface ReleaseReaders { gh?: SdlcGh; git?: ReleaseGit; docs?: (root: string, timeoutMs: number) => Promise<DocEvidence | undefined>; }
27
+ export interface ReleaseReaders { gh?: SdlcGh; git?: ReleaseGit; docs?: (root: string, timeoutMs: number) => Promise<DocEvidence | undefined>; /** Test seam (#1120): the checker program the default docs reader spawns. */ checker?: string; }
27
28
  const SHA = /^[0-9a-f]{40}$/;
28
29
  const VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
29
30
  const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
@@ -34,21 +35,31 @@ function tagMatches(target: unknown, commit: string): boolean {
34
35
  return typeof oid === "string" && SHA.test(oid) && oid === commit;
35
36
  }
36
37
  /** Package-owned checker only; no caller-supplied program or document text is executed. */
37
- export function readDocEvidence(root: string, timeoutMs = 10_000): Promise<DocEvidence | undefined> {
38
+ /** #1120: why no evidence could be read, apart from evidence that says the documents are stale. */
39
+ export type DocEvidenceRead = { evidence: DocEvidence } | { unavailable: string };
40
+ const CHECKER_PATH = fileURLToPath(new URL("../../scripts/check-docs.mjs", import.meta.url));
41
+ const CHECKER_MISSING = "the documentation checker scripts/check-docs.mjs is missing from the running hub, which predates the fix that carries it into captured runtimes; record consent from a hub started from a checkout or a release that carries the checker (docs/sdlc.md, release preparation)";
42
+ const CHECKER_FAILED = "the documentation checker in the running hub did not finish with readable evidence (it timed out, crashed or printed unreadable output); run `node scripts/check-docs.mjs --json --committed` in the candidate checkout to see why, then retry";
43
+ export async function readDocEvidence(root: string, timeoutMs = 10_000): Promise<DocEvidence | undefined> {
44
+ const read = await readDocEvidenceDetailed(root, timeoutMs);
45
+ return "evidence" in read ? read.evidence : undefined;
46
+ }
47
+ export function readDocEvidenceDetailed(root: string, timeoutMs = 10_000, checker = CHECKER_PATH): Promise<DocEvidenceRead> {
48
+ if (!existsSync(checker)) return Promise.resolve({ unavailable: CHECKER_MISSING });
38
49
  return new Promise(resolve => {
39
- const child = spawn(process.execPath, [fileURLToPath(new URL("../../scripts/check-docs.mjs", import.meta.url)), root, "--json", "--committed"], { cwd: root, env: childEnv(), detached: true, stdio: ["ignore", "pipe", "ignore"] });
50
+ const child = spawn(process.execPath, [checker, root, "--json", "--committed"], { cwd: root, env: childEnv(), detached: true, stdio: ["ignore", "pipe", "ignore"] });
40
51
  let output = "", invalid = false;
41
52
  const stop = () => { invalid = true; void stopOwnedProcess(child, { group: true }).catch(() => {}); };
42
53
  const timer = setTimeout(stop, Math.max(1, Math.min(10_000, timeoutMs)));
43
54
  child.stdout?.setEncoding("utf8"); child.stdout?.on("data", chunk => { output += chunk; if (Buffer.byteLength(output) > 256 * 1024) { output = ""; stop(); } });
44
- child.once("error", () => { invalid = true; clearTimeout(timer); resolve(undefined); });
55
+ child.once("error", () => { invalid = true; clearTimeout(timer); resolve({ unavailable: CHECKER_FAILED }); });
45
56
  child.once("close", (code, signal) => {
46
- clearTimeout(timer); if (invalid || signal || (code !== 0 && code !== 1)) return resolve(undefined);
57
+ clearTimeout(timer); if (invalid || signal || (code !== 0 && code !== 1)) return resolve({ unavailable: CHECKER_FAILED });
47
58
  try {
48
59
  const data = JSON.parse(output);
49
- if (data.schemaVersion !== 1 || !Array.isArray(data.documents) || data.documents.length > 128 || !Number.isSafeInteger(data.errors) || data.errors < 0 || typeof data.fresh !== "boolean" || (data.head !== undefined && !SHA.test(data.head)) || (data.version !== undefined && !VERSION.test(data.version)) || data.documents.some((d: {state?:string}) => !["fresh","stale","nonancestor","unknown"].includes(d.state ?? ""))) return resolve(undefined);
50
- resolve(data);
51
- } catch { resolve(undefined); }
60
+ if (data.schemaVersion !== 1 || !Array.isArray(data.documents) || data.documents.length > 128 || !Number.isSafeInteger(data.errors) || data.errors < 0 || typeof data.fresh !== "boolean" || (data.head !== undefined && !SHA.test(data.head)) || (data.version !== undefined && !VERSION.test(data.version)) || data.documents.some((d: {state?:string}) => !["fresh","stale","nonancestor","unknown"].includes(d.state ?? ""))) return resolve({ unavailable: CHECKER_FAILED });
61
+ resolve({ evidence: data });
62
+ } catch { resolve({ unavailable: CHECKER_FAILED }); }
52
63
  });
53
64
  });
54
65
  }
@@ -101,8 +112,10 @@ export async function readReleaseReadiness(root: string, repo: string, liveIssue
101
112
  } catch { report.blockers.push("merged PR coverage unknown or truncated"); break; }
102
113
  }
103
114
  }
104
- const left = deadline - performance.now(); report.documents = left > 0 ? await (readers.docs ?? readDocEvidence)(root,Math.min(10_000,left)) : undefined;
105
- if (!report.documents || report.documents.head !== head || !report.documents.fresh || report.documents.errors || report.documents.documents.some(d => d.state !== "fresh")) report.blockers.push("documentation freshness or ancestry is unknown or stale");
115
+ const left = deadline - performance.now(); report.documents = left > 0 ? await (readers.docs ?? ((dir, ms) => readDocEvidenceDetailed(dir, ms, readers.checker).then(read => "evidence" in read ? read.evidence : undefined)))(root,Math.min(10_000,left)) : undefined;
116
+ const checkerMissing = !readers.docs && !existsSync(readers.checker ?? CHECKER_PATH); // #1120: a hub without the checker says so instead of calling the documents stale
117
+ if (checkerMissing) report.blockers.push("documentation checker unavailable in the running hub: " + CHECKER_MISSING);
118
+ else if (!report.documents || report.documents.head !== head || !report.documents.fresh || report.documents.errors || report.documents.documents.some(d => d.state !== "fresh")) report.blockers.push("documentation freshness or ancestry is unknown or stale");
106
119
  if (!liveIssues.length) report.blockers.push("native verification ledger unspecified");
107
120
  for (const id of liveIssues) {
108
121
  const reply = await gh(["issue","view",String(id),"--repo",repo,"--json","number,state"]); let state: "open"|"closed"|"unknown" = "unknown";
package/src/hub/sdlc.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { parseIssueLabels, parseRedFirst, testIntegrityBlockers, type RedFirstReceipt, type TestIntegrity } from "./test-integrity.ts";
2
2
  import { planConformanceBlockers, type PlanConformance } from "./plan-conformance.ts";
3
+ import { controlWriteSpan } from "./control-write-trace.ts";
3
4
  import { DEFAULT_MAX_NITS, findingsForHead, findingHead, findingTally, type ReviewFindingSet, type ReviewFindingTally } from "./review-findings.ts";
4
5
  import type { CostPlan } from "./cost-governance.ts";
5
6
  import { bandsConfig, type BandsConfig } from "./sdlc-metrics.ts";
@@ -427,7 +428,7 @@ export function touchingPrs(prs: { pr: number; paths: string[] }[]): { before: n
427
428
 
428
429
  export type SdlcGh = (args: string[], timeoutMs?: number, cwd?: string, maxOutputBytes?: number) => Promise<{ code: number; stdout: string }>;
429
430
  /** Fixed argv and bounded output and time; diagnostics never expose credentials or remote text. Reads, except the one guarded `gh pr merge` that `sdlc-merge.ts` builds (#786). */
430
- export const sdlcGh: SdlcGh = (args, timeoutMs = 10_000, cwd, maxOutputBytes = 256 * 1024) => new Promise((resolve) => {
431
+ export const sdlcGh: SdlcGh = (args, timeoutMs = 10_000, cwd, maxOutputBytes = 256 * 1024) => controlWriteSpan("external", () => new Promise<{ code: number; stdout: string }>((resolve) => {
431
432
  const outputLimit = Number.isSafeInteger(maxOutputBytes) ? Math.min(2 * 1024 * 1024, Math.max(1, maxOutputBytes)) : 256 * 1024;
432
433
  let child: ReturnType<typeof spawn>;
433
434
  try {
@@ -440,7 +441,7 @@ export const sdlcGh: SdlcGh = (args, timeoutMs = 10_000, cwd, maxOutputBytes = 2
440
441
  child.stdout?.on("data", chunk => { if (failed) return; stdout += chunk.toString(); if (Buffer.byteLength(stdout) > outputLimit) { stdout = ""; stop(); } });
441
442
  child.once("error", () => { failed = true; clearTimeout(timer); resolve({ code: 1, stdout: "" }); });
442
443
  child.once("close", code => { clearTimeout(timer); resolve({ code: failed ? 1 : code ?? 1, stdout: failed ? "" : stdout }); });
443
- });
444
+ }));
444
445
 
445
446
  /** Read-only, fixed argv: the issue's edit times, never its body. */
446
447
  export async function readSpecEdit(repoName: string, issue: number, run: SdlcGh = sdlcGh): Promise<SpecEdit | undefined> {
@@ -506,6 +507,18 @@ export async function readSdlcPr(receipt: SdlcReceipt, run: SdlcGh = sdlcGh, wit
506
507
  } catch { return undefined; }
507
508
  }
508
509
 
510
+ /** Read only the current PR head when broader PR evidence is unavailable. */
511
+ export async function readSdlcPrHead(receipt: SdlcReceipt, run: SdlcGh = sdlcGh): Promise<string | undefined> {
512
+ if (!repo(receipt.repo) || !id(receipt.pr)) return undefined;
513
+ try {
514
+ const result = await run(["pr", "view", String(receipt.pr), "--repo", receipt.repo, "--json", "headRefOid"], 10_000, undefined, 1024);
515
+ if (result.code !== 0 || Buffer.byteLength(result.stdout) > 1024) return undefined;
516
+ const value: unknown = JSON.parse(result.stdout);
517
+ const head = (value as { headRefOid?: unknown } | null)?.headRefOid;
518
+ return sha(head) ? head : undefined;
519
+ } catch { return undefined; }
520
+ }
521
+
509
522
  export interface PlanIssueMetadata {
510
523
  repo: string;
511
524
  issue: number;
@@ -85,6 +85,9 @@ export const SETTINGS: readonly SettingDef[] = [
85
85
  { key: "specs.review", group: "Switches", label: "Covering spec review", type: "enum", values: ["off", "report", "require"], store: "config", applies: "live", risk: "safe", path: ["specs", "review"] },
86
86
  { key: "approvals.notify", group: "Switches", label: "Desktop notice for a waiting approval", type: "boolean", store: "config", applies: "live", risk: "safe", path: ["approvals", "notify"] },
87
87
  { key: "snapshots.enabled", group: "Switches", label: "Per-turn snapshots", type: "boolean", store: "config", applies: "live", risk: "safe", path: ["snapshots", "enabled"] },
88
+ // #1057: the worktree shelf. Both only choose what the shelf shows and when it raises one inbox alert; neither deletes anything, so they are safe and live.
89
+ { key: "worktrees.quiet_days", group: "Switches", label: "Worktrees: shelve one idle this many days (0 never)", type: "integer", min: 0, max: 3650, store: "config", applies: "live", risk: "safe", path: ["worktrees", "quiet_days"] },
90
+ { key: "worktrees.alert_over", group: "Switches", label: "Worktrees: inbox alert above this many settled (0 never)", type: "integer", min: 0, max: 100000, store: "config", applies: "live", risk: "safe", path: ["worktrees", "alert_over"] },
88
91
  { key: "coordination", group: "Switches", label: "Coordination of overlapping owners", type: "enum", values: ["advisory", "turn-free"], store: "config", applies: "next use", risk: "safe", path: ["coordination"] },
89
92
  ];
90
93