@staix/agent-hub 0.28.0 → 0.29.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 (40) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +2 -2
  3. package/docs/agent-notes/daemon.md +2 -0
  4. package/docs/agent-notes/local-worker.md +3 -1
  5. package/docs/agent-notes/tasks.md +3 -1
  6. package/docs/agent-notes/tests.md +1 -0
  7. package/docs/design/task-board.md +1 -1
  8. package/docs/dogfood.md +6 -3
  9. package/docs/operations.md +73 -11
  10. package/docs/quickstart.md +11 -3
  11. package/docs/sdlc.md +1 -1
  12. package/docs/security.md +17 -2
  13. package/docs/verification/2026-11-03-0.29.0.md +59 -0
  14. package/docs/verification/2026-11-03-control-write-stall-windows.json +241 -0
  15. package/docs/verification/2026-11-03-control-write-stall-windows.md +20 -0
  16. package/docs/verification/2026-11-03-live-board-latency.json +556 -0
  17. package/docs/verification/2026-11-03-live-board-latency.md +64 -0
  18. package/docs/verified.json +13 -13
  19. package/package.json +2 -2
  20. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  21. package/plugins/agent-hub/server.js +524 -18
  22. package/src/cli/help.ts +1 -1
  23. package/src/cli/output.ts +5 -0
  24. package/src/cli/recovery-package.ts +9 -0
  25. package/src/hub/board.ts +3 -2
  26. package/src/hub/control-client.ts +16 -1
  27. package/src/hub/control-write-trace.ts +409 -0
  28. package/src/hub/daemon.ts +171 -47
  29. package/src/hub/hub-tools.ts +1 -1
  30. package/src/hub/routing.ts +9 -2
  31. package/src/hub/runtime-capture-prune.ts +4 -1
  32. package/src/hub/sdlc-release-authorization.ts +13 -4
  33. package/src/hub/sdlc-release.ts +23 -10
  34. package/src/hub/sdlc.ts +15 -2
  35. package/src/hub/task-worktree.ts +331 -0
  36. package/src/hub/tasks.ts +162 -17
  37. package/src/local/deny.ts +49 -0
  38. package/src/local/tools.ts +4 -2
  39. package/src/memory/brief.ts +5 -0
  40. package/src/memory/client.ts +5 -2
package/src/hub/daemon.ts CHANGED
@@ -45,6 +45,7 @@ import { createStalenessProbe } from "./memory-staleness.ts";
45
45
  import { matchingPlanFails, planGuardNodes, taskMemoryQueryNodes } from "./memory-plan-guard.ts";
46
46
  import { planningWipLimits } from "./planning.ts";
47
47
  import { ToolWriteRegistry, toolWriteOperation, toolWriteUnknownReply } from "./tool-request.ts";
48
+ import { ControlWriteTelemetry, controlWriteMetadata, controlWriteSpan } from "./control-write-trace.ts";
48
49
  import { projectedDiff } from "./projected-diff.ts";
49
50
  import { listenWithoutPortCollision } from "./listen.ts";
50
51
  import { needs, type NeedsInput } from "../ui/needs.ts";
@@ -174,6 +175,7 @@ import { autonomyPreset, autonomyEnvironment, planAutonomy, autonomyRemovalKeys,
174
175
  import { readAutonomyDefaults } from "./autonomy-defaults.ts";
175
176
  import { AUTONOMY_UNDO, autonomyOwnershipDigest, autonomyUndoKeys, writeAutonomyPreset, LEARN_CHECKLIST, NEVER_EDITABLE, projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, sharedValue, settingDef as instanceSettingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingSource, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
176
177
  import { githubOriginRepository, githubRemoteRepository } from "./task-repository.ts";
178
+ import { TaskWorktrees, originVerdict } from "./task-worktree.ts";
177
179
  import { Bus } from "./bus.ts";
178
180
  import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
179
181
  import { DeliveryJournal } from "./delivery-journal.ts";
@@ -1208,7 +1210,7 @@ export async function startDaemon(opts: DaemonOptions) {
1208
1210
  let parsed: any;
1209
1211
  try {
1210
1212
  parsed = JSON.parse(String(data));
1211
- onMessage(sock, parsed);
1213
+ onControlMessage(sock, parsed);
1212
1214
  } catch (e) {
1213
1215
  if (isDatabaseBusy(e) && sock.data.authed && Number.isSafeInteger(parsed?.rid)) {
1214
1216
  const busy = new RecoveryDatabaseBusy();
@@ -1718,10 +1720,11 @@ export async function startDaemon(opts: DaemonOptions) {
1718
1720
  for (const t of board.list("in_progress")) if (!tasks.isPii(t)) renewLeases(t);
1719
1721
  }
1720
1722
  let syncRun: Promise<void> | undefined;
1723
+ let controlWriteTelemetry: ControlWriteTelemetry | undefined;
1721
1724
  /** Machine sync, when a person set a remote; one log line per failure, never a blocked turn. Stopping aborts and awaits it. */
1722
1725
  function syncNow(): Promise<void> {
1723
1726
  if (!memoryStore || syncRun || stopping) return syncRun ?? Promise.resolve();
1724
- syncRun = (async () => {
1727
+ const run = async () => {
1725
1728
  try {
1726
1729
  checkMemory();
1727
1730
  const r = await (opts.memorySync ?? syncMemory)(memoryStore!, { signal: syncStop.signal });
@@ -1729,7 +1732,8 @@ export async function startDaemon(opts: DaemonOptions) {
1729
1732
  else if (r.warning) log(`hub memory sync: ${r.warning}`);
1730
1733
  } catch { /* A stale binding disables native memory, never the bus. */ }
1731
1734
  finally { syncRun = undefined; }
1732
- })();
1735
+ };
1736
+ syncRun = controlWriteTelemetry ? controlWriteTelemetry.observe("memory", () => !stopping, run) : run();
1733
1737
  return syncRun;
1734
1738
  }
1735
1739
 
@@ -2047,6 +2051,13 @@ export async function startDaemon(opts: DaemonOptions) {
2047
2051
  }).finally(() => { refreshingRunner = undefined; });
2048
2052
  return refreshingRunner;
2049
2053
  };
2054
+ // #1115: the hub makes, commits and clears the task worktree of a hub-sandboxed peer. The sandbox profile is unchanged; the record file lives in the hub's state dir, which no sandbox root contains.
2055
+ const taskWorktree = new TaskWorktrees({
2056
+ root: opts.cwd, stateFile: join(opts.stateDir, "task-worktrees.json"),
2057
+ applies: peer => definitionOf(peer)?.trust.hubSandboxed === true,
2058
+ deny: () => config.local.deny, protectInstructions: () => config.protect_instruction_files,
2059
+ pushAllowed: urls => originVerdict(shellProjectRepository, urls, url => githubRemoteRepository(url)),
2060
+ });
2050
2061
  const peerCapabilities = () => {
2051
2062
  // Keep the last confirmed capability during a bounded, single-flight refresh; replace it only on a result.
2052
2063
  if (Date.now() - runnerStateAt >= 60_000) void refreshRunner();
@@ -2054,9 +2065,10 @@ export async function startDaemon(opts: DaemonOptions) {
2054
2065
  if (!definitionOf(peer)?.trust.hubSandboxed) return [peer, { tests: true, spec: true, network: true, worktree: true }];
2055
2066
  // #1081: Pi and the local worker run their shell under one sandbox network setting (`local.bash_network`): false is no network at all, an egress proxy or "direct" is some.
2056
2067
  const network = sandboxNetwork !== false;
2057
- // A hub sandbox never lets its shell run `git worktree add` (#270: it writes under the git common dir), so such a peer cannot create the task worktree. The hub-made writable worktree is #1115.
2058
- return [peer, { tests: runnerState.available && may(peer, "check_run"), spec: specState.available && may(peer, "spec_read"), network, worktree: false,
2059
- worktreeReason: "git worktree add runs outside the sandbox; this peer cannot create the task worktree (#270; a hub-made worktree is #1115)",
2068
+ // A hub sandbox never lets its shell run `git worktree add` (#270: it writes under the git common dir), so the hub makes the task worktree for such a peer (#1115): the capability is whether it can here.
2069
+ const hubWorktree = taskWorktree.available();
2070
+ return [peer, { tests: runnerState.available && may(peer, "check_run"), spec: specState.available && may(peer, "spec_read"), network, worktree: hubWorktree.ok,
2071
+ ...(hubWorktree.ok ? {} : { worktreeReason: `the hub cannot make a task worktree for it: ${hubWorktree.reason}; ${hubWorktree.next} (#1115)` }),
2060
2072
  ...(network ? {} : { networkReason: "local.bash_network is off, so its shell has no network (ahub settings local.bash_network)" }),
2061
2073
  testsReason: !may(peer, "check_run") ? `capabilities.${peer}` : `runner unavailable: ${runnerState.reason ?? "unknown"}`,
2062
2074
  specReason: !may(peer, "spec_read") ? `capabilities.${peer}` : specState.reason ?? "gh unauthenticated" }];
@@ -2129,7 +2141,7 @@ export async function startDaemon(opts: DaemonOptions) {
2129
2141
  if (why || configSourceTexts(opts.cwd)["config.local.json"] === null) throw new Error(`research.runners source trust unavailable${why ? `: ${why}` : ""}`);
2130
2142
  return parseResearchRunners(config.research.runners);
2131
2143
  },
2132
- capabilities: peerCapabilities, repository: memoryRepo,
2144
+ capabilities: peerCapabilities, taskWorktree, repository: memoryRepo,
2133
2145
  contextAdmission: () => ({
2134
2146
  policy: { max_used: config.context.max_assignment_used ?? 0, stale_min: config.context.stale_min },
2135
2147
  peers: (config.context.max_assignment_used ?? 0) > 0 ? Object.fromEntries(bus.knownPeers().filter(peer => definitionOf(peer)?.budget?.context).map(peer => [peer,
@@ -2241,13 +2253,16 @@ export async function startDaemon(opts: DaemonOptions) {
2241
2253
  read: task => publicCorpus([task.title, ...task.refs.paths ?? [], ...task.plan?.paths ?? [], ...task.plan?.symbols ?? []].join(" "), () => corpusBriefAllowed(task)).map(item => ({ id: item.id, kind: "note", line: `${item.id}: ${item.text.replace(/\s+/g, " ")}` })),
2242
2254
  }),
2243
2255
  ...(memoryStore ? { planFails: (task: import("./board.ts").Task) => {
2244
- checkMemory();
2245
- const context = { cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny };
2246
- const nodes = planGuardNodes(task, context);
2247
- const related = screenMemory(memoryStore.related(memoryProject, nodes, Number.MAX_SAFE_INTEGER));
2248
- const all = screenMemory(memoryStore.list({ project: memoryProject, kind: "fail", limit: Number.MAX_SAFE_INTEGER }));
2249
- const rows = [...new Map([...related, ...all].map(row => [row.id, row])).values()];
2250
- return matchingPlanFails(task, rows, context).slice(0, config.memory.brief_items).map(v => ({ id: `mem:${v.id}`, line: memoryLine(v), kind: v.kind, about: v.about }));
2256
+ const read = () => {
2257
+ checkMemory();
2258
+ const context = { cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny };
2259
+ const nodes = planGuardNodes(task, context);
2260
+ const related = screenMemory(memoryStore.related(memoryProject, nodes, Number.MAX_SAFE_INTEGER));
2261
+ const all = screenMemory(memoryStore.list({ project: memoryProject, kind: "fail", limit: Number.MAX_SAFE_INTEGER }));
2262
+ const rows = [...new Map([...related, ...all].map(row => [row.id, row])).values()];
2263
+ return matchingPlanFails(task, rows, context).slice(0, config.memory.brief_items).map(v => ({ id: `mem:${v.id}`, line: memoryLine(v), kind: v.kind, about: v.about }));
2264
+ };
2265
+ return controlWriteTelemetry ? controlWriteTelemetry.observe("memory", controlWritePrivacyGuard(true, true, undefined, task.id), read) : read();
2251
2266
  } } : {}),
2252
2267
  ...(memoryStore ? { store: { store: memoryStore, project: memoryProject, repo: memoryRepo, deny: config.local.deny, onNote: (id: string) => void suggestEdges(id) } } : {}),
2253
2268
  notify,
@@ -2270,14 +2285,29 @@ export async function startDaemon(opts: DaemonOptions) {
2270
2285
  log(`note for ${peer} not kept: ${(e as Error).message}`);
2271
2286
  }
2272
2287
  },
2273
- triage: { classify: (title, detail) => inference?.triage(title, detail) ?? Promise.resolve(undefined), onCampus: () => onCampus() },
2288
+ triage: {
2289
+ classify: (title, detail) => {
2290
+ const classify = () => inference?.triage(title, detail) ?? Promise.resolve(undefined);
2291
+ return controlWriteTelemetry ? controlWriteTelemetry.observe("external", controlWritePrivacyGuard(true, true), classify,
2292
+ () => boundedSafe(`${title}\n${detail}`)) : classify();
2293
+ },
2294
+ onCampus: () => controlWriteTelemetry ? controlWriteTelemetry.observe("external", controlWritePrivacyGuard(true, true), () => onCampus()) : onCampus(),
2295
+ },
2274
2296
  // #334: the hub model's split, validated against the same `hub_backlog_split` parts schema a planner's call is, and the source registry for `--source`.
2275
- intake: { split: (system, user, opts) => inference?.split(system, user, opts) ?? Promise.resolve(undefined), sourceKnown: id => sourceRegistry.get(id) !== undefined,
2297
+ intake: { split: (system, user, opts) => {
2298
+ const split = () => inference?.split(system, user, opts) ?? Promise.resolve(undefined);
2299
+ return controlWriteTelemetry ? controlWriteTelemetry.observe("external", controlWritePrivacyGuard(true, true), split,
2300
+ () => boundedSafe(`${system}\n${user}`)) : split();
2301
+ }, sourceKnown: id => sourceRegistry.get(id) !== undefined,
2276
2302
  partsSchema: () => profileTaskTools(resolvedProfile, lifecycleConfig.stages, lifecycleConfig.mode !== "off").find(tool => tool.name === "hub_backlog_split")?.inputSchema.properties.parts },
2277
2303
  studySource: id => sourceRegistry.get(id),
2278
2304
  experimentCheck: run => checkRunner.result(run),
2279
2305
  // The PII screen (issue #198): the model on this machine when MLX/Ollama is enabled, else the gateway on campus only.
2280
- piiScreen: opts.piiScreen ?? ((text) => screenPii(text, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model })),
2306
+ piiScreen: (text) => {
2307
+ const screen = () => (opts.piiScreen ?? ((value: string) => screenPii(value, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model })))(text);
2308
+ return controlWriteTelemetry ? controlWriteTelemetry.observe("external", controlWritePrivacyGuard(true, true), screen,
2309
+ () => boundedSafe(text)) : screen();
2310
+ },
2281
2311
  recordScreen: (record) => event({ type: "pii_screen", ...record }),
2282
2312
  quota: (): ReturnType<Budget["headroom"]> => projectBudgetHeadroom(budget.headroom(), accountMembers), // budget is built below; this runs at assignment time
2283
2313
  review: config.review,
@@ -2573,6 +2603,20 @@ export async function startDaemon(opts: DaemonOptions) {
2573
2603
  };
2574
2604
  const activityRings: Record<string, ActivityRing> = Object.fromEntries(SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.capabilities?.watch).map(peer => [peer, new ActivityRing()]));
2575
2605
  const contextPii = (peer: PeerId) => !!turns.get(peer)?.private || board.peerTaskSignals(peer).some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
2606
+ // Hoisted for the Tasks ports above; only the returned guard reads fully initialized daemon state.
2607
+ function controlWritePrivacyGuard(admittedAtReceive: boolean, inputPublicAtReceive: boolean, peer?: PeerId, taskId?: number) {
2608
+ return () => {
2609
+ try {
2610
+ if (!admittedAtReceive || !inputPublicAtReceive) return false;
2611
+ if (peer && (turns.get(peer)?.private || contextPii(peer))) return false;
2612
+ if (taskId !== undefined) {
2613
+ const task = board.get(taskId);
2614
+ if (!task || tasks.isPii(task) || !tasks.publicMetadataText(String(task.id))) return false;
2615
+ }
2616
+ return true;
2617
+ } catch { return false; }
2618
+ };
2619
+ }
2576
2620
  // #878: what each peer is doing. Daemon memory only: never persisted (status.json leaves it out), logged, captured or relayed.
2577
2621
  const doings = new Map<PeerId, DoingTracker>();
2578
2622
  const observeDoing = (peer: PeerId, kind: DoingKind, text?: unknown, learnedPrivate = false) => {
@@ -2815,22 +2859,25 @@ export async function startDaemon(opts: DaemonOptions) {
2815
2859
  };
2816
2860
  const snap = (what: string, capture = config.snapshots.enabled, peer?: string): { tree?: string; head?: string | null; commit?: string | null; reflog?: HeadMoveCursor; ms: number; budget?: SnapshotBudget; repo?: Repo } => {
2817
2861
  if (!capture) return { ms: 0 };
2818
- const t0 = performance.now(), budget = reserveSnapshotBudget();
2819
- let tree: string | undefined, head: string | null | undefined, commit: string | null | undefined, repo: Repo | undefined, reflog: HeadMoveCursor | undefined;
2820
- try {
2821
- repo = peer ? repoFor(peer, budget) : factsRepo;
2822
- const before = repo && headState(repo, budget);
2823
- tree = repo && (opts.snapshot ?? snapshot)(repo, budget);
2824
- const after = repo && headState(repo, budget);
2825
- head = after?.tree; commit = after?.head;
2826
- if (!before || !after || before.tree !== after.tree) tree = undefined;
2827
- if (tree && repo && head !== undefined && after) reflog = headMoveCursor(repo, head, budget, after.head);
2828
- if (repo && !tree) log(`snapshot failed at ${what}`);
2829
- } catch (error) {
2830
- tree = undefined; head = undefined; commit = undefined; reflog = undefined;
2831
- snapshotFailure(what, error);
2832
- }
2833
- return { tree, head, commit, reflog, budget, repo, ms: Math.round(performance.now() - t0) };
2862
+ const captureSnapshot = () => {
2863
+ const t0 = performance.now(), budget = reserveSnapshotBudget();
2864
+ let tree: string | undefined, head: string | null | undefined, commit: string | null | undefined, repo: Repo | undefined, reflog: HeadMoveCursor | undefined;
2865
+ try {
2866
+ repo = peer ? repoFor(peer, budget) : factsRepo;
2867
+ const before = repo && headState(repo, budget);
2868
+ tree = repo && (opts.snapshot ?? snapshot)(repo, budget);
2869
+ const after = repo && headState(repo, budget);
2870
+ head = after?.tree; commit = after?.head;
2871
+ if (!before || !after || before.tree !== after.tree) tree = undefined;
2872
+ if (tree && repo && head !== undefined && after) reflog = headMoveCursor(repo, head, budget, after.head);
2873
+ if (repo && !tree) log(`snapshot failed at ${what}`);
2874
+ } catch (error) {
2875
+ tree = undefined; head = undefined; commit = undefined; reflog = undefined;
2876
+ snapshotFailure(what, error);
2877
+ }
2878
+ return { tree, head, commit, reflog, budget, repo, ms: Math.round(performance.now() - t0) };
2879
+ };
2880
+ return controlWriteTelemetry ? controlWriteTelemetry.observe("snapshot", controlWritePrivacyGuard(true, true, peer as PeerId | undefined), captureSnapshot) : captureSnapshot();
2834
2881
  };
2835
2882
  function privateSnapshotWindow(): boolean {
2836
2883
  return [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
@@ -3302,6 +3349,10 @@ export async function startDaemon(opts: DaemonOptions) {
3302
3349
  // commit waits for those in flight, or its integrity digest misses their later writes. Completion checks outlive
3303
3350
  // their op, so recoveryReady() also waits for `tasks.checksPending()`: a check the commit's stop kills would write.
3304
3351
  let taskOpsInFlight = 0;
3352
+ controlWriteTelemetry = new ControlWriteTelemetry({ emit: line => {
3353
+ try { if (tasks.publicMetadataText(line) && boundedSafe(line)) log(line); } catch { /* diagnostics never fail a write */ }
3354
+ } });
3355
+ startupCleanup.push(() => controlWriteTelemetry?.stop());
3305
3356
  const toolWriteRegistry = new ToolWriteRegistry(instanceId, opts.toolWriteReplayMaxEntries === undefined ? {} : { maxEntries: opts.toolWriteReplayMaxEntries });
3306
3357
  const toolWriteScope = (c: Client, operation: string): string => {
3307
3358
  const actor = c.peer ?? USER, peer = c.peer ? bus.peers.get(c.peer) : undefined;
@@ -3925,7 +3976,7 @@ export async function startDaemon(opts: DaemonOptions) {
3925
3976
  const silent = tasks.silentFor(t.id);
3926
3977
  const overlap = a.plan == null && a.refs == null && !silent ? "" : tasks.overlaps(t);
3927
3978
  if (silent && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // the plans in this answer need no fact later
3928
- return [line(t), specs, overlap, tasks.planGuardText(t), tasks.submoduleNotice(t)].filter(Boolean).join("\n"); // #798 D1
3979
+ return [line(t), specs, overlap, tasks.planGuardText(t), tasks.submoduleNotice(t), tasks.worktreeNote(t, by)].filter(Boolean).join("\n"); // #798 D1
3929
3980
  }
3930
3981
  case "hub_task_cancel": {
3931
3982
  if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => !["id", "reason", "superseded_by", "revision"].includes(key))
@@ -3938,7 +3989,9 @@ export async function startDaemon(opts: DaemonOptions) {
3938
3989
  case "hub_task_decline":
3939
3990
  return line(await tasks.decline(by, a.id, a.reason, a.capacity === true));
3940
3991
  case "hub_task_done": {
3941
- const t = await tasks.done(by, a.id, a.summary, a.refs, a.deliverables, a.spec_ack);
3992
+ // #1115: a hub-sandboxed peer's worktree is staged, committed and pushed by the hub first, so the done carries the real head and branch.
3993
+ const handed = await tasks.worktreeHandoff(by, a.id, a.refs);
3994
+ const t = await tasks.done(by, a.id, a.summary, handed ?? a.refs, a.deliverables, a.spec_ack);
3942
3995
  const completion = t.history.findLast(h => h.by === by && (h.event === "done" || h.event === "done (checking)"));
3943
3996
  if (!piiTurn && !tasks.isPii(t) && completion && !completion.withheld && typeof a.summary === "string" && completion.note?.startsWith(a.summary)) {
3944
3997
  citeMemory(by, a.summary, `summary:${t.id}:${t.history.indexOf(completion)}`, [newEnvelope(by, "", { refs: { task: String(t.id) } })]);
@@ -4769,6 +4822,7 @@ export async function startDaemon(opts: DaemonOptions) {
4769
4822
  isolatedChecks: { pending: checkPending.size, orphans: orphanChecks, unknownReceipts: checkRunner.unknownReceipts ?? [], maintenanceUnavailable: checkRunner.maintenanceUnavailable === true },
4770
4823
  taskCapabilities: peerCapabilities(),
4771
4824
  tasks: board.counts(),
4825
+ ...(includeAttentionItems && controlWriteTelemetry ? { controlWriteTiming: controlWriteTelemetry.status() } : {}),
4772
4826
  taskStalls, pendingTaskOffers,
4773
4827
  ...(bus.storageError ? { deliveryError: bus.storageError } : {}),
4774
4828
  ...(recoveryOperationId ? { recovery: { operationId: recoveryOperationId, phase: recoveryPhase, ready: recoveryReady() } } : {}),
@@ -4799,20 +4853,28 @@ export async function startDaemon(opts: DaemonOptions) {
4799
4853
  // (the status reply is unchanged); readers without it judge the pid alone, as 0.12.20 did.
4800
4854
  const pidSignature = processSignature(process.pid);
4801
4855
  const writeStatus = () => {
4802
- try {
4803
- const file = join(opts.stateDir, "status.json"); // clients parse this on every connect: replace it atomically
4804
- writeFileSync(`${file}.${instanceId}.tmp`, `${JSON.stringify({ ...status(), ...(pidSignature ? { pidSignature } : {}) }, null, 2)}\n`);
4805
- renameSync(`${file}.${instanceId}.tmp`, file);
4806
- } catch { /* the state dir is gone; the watchdog is stopping the hub (issue #56) */ }
4856
+ const write = () => {
4857
+ try {
4858
+ const file = join(opts.stateDir, "status.json"); // clients parse this on every connect: replace it atomically
4859
+ writeFileSync(`${file}.${instanceId}.tmp`, `${JSON.stringify({ ...status(), ...(pidSignature ? { pidSignature } : {}) }, null, 2)}\n`);
4860
+ renameSync(`${file}.${instanceId}.tmp`, file);
4861
+ } catch { /* the state dir is gone; the watchdog is stopping the hub (issue #56) */ }
4862
+ };
4863
+ if (controlWriteTelemetry) controlWriteTelemetry.observe("status-hook", () => true, write);
4864
+ else write();
4807
4865
  };
4808
4866
  const taskChanged = board.onChange;
4809
4867
  board.onChange = (task, entry) => {
4810
- try { taskChanged?.(task, entry); }
4811
- finally {
4812
- try { handoffNotifyOrigin(task, entry); } catch { /* never throw after a board write */ }
4813
- try { settleSupersededDeliveries(); }
4814
- finally { writeStatus(); } // committed task transitions refresh token-free attention even without bus activity
4815
- }
4868
+ const changed = () => {
4869
+ try { taskChanged?.(task, entry); }
4870
+ finally {
4871
+ try { handoffNotifyOrigin(task, entry); } catch { /* never throw after a board write */ }
4872
+ try { settleSupersededDeliveries(); }
4873
+ finally { writeStatus(); } // committed task transitions refresh token-free attention even without bus activity
4874
+ }
4875
+ };
4876
+ if (controlWriteTelemetry) controlWriteTelemetry.observe("queue-hook", controlWritePrivacyGuard(true, true, undefined, task.id), changed);
4877
+ else changed();
4816
4878
  };
4817
4879
 
4818
4880
  // A queue change is not a bus event: without this, status.json reports the queue as it was before the last
@@ -4847,6 +4909,7 @@ export async function startDaemon(opts: DaemonOptions) {
4847
4909
  let reportingQueues = false;
4848
4910
  let queuePressureStatusQueued = false;
4849
4911
  bus.onQueues = () => {
4912
+ const changed = () => {
4850
4913
  if (stopping || settlingSuperseded || reportingQueues) return;
4851
4914
  remoteQueueChanged();
4852
4915
  reportingQueues = true;
@@ -4872,6 +4935,9 @@ export async function startDaemon(opts: DaemonOptions) {
4872
4935
  });
4873
4936
  }
4874
4937
  }
4938
+ };
4939
+ if (controlWriteTelemetry) controlWriteTelemetry.observe("queue-hook", () => true, changed);
4940
+ else changed();
4875
4941
  };
4876
4942
  // Opening the journal/restoring the snapshot preceded callbacks; classify those holds too.
4877
4943
  settleSupersededDeliveries();
@@ -8134,10 +8200,62 @@ export async function startDaemon(opts: DaemonOptions) {
8134
8200
  if (!result.ok) throw new Error(String(result.error));
8135
8201
  }
8136
8202
 
8137
- function onMessage(sock: Sock, msg: any): void {
8203
+ function onControlMessage(sock: Sock, msg: any): void {
8204
+ const client = sock.data;
8205
+ const admittedAtReceive = client.authed && !!client.role, peer = client.peer;
8206
+ if (!admittedAtReceive) return onMessage(sock, msg);
8207
+ const metadata = controlWriteMetadata(msg);
8208
+ if (!metadata || !controlWriteTelemetry) return onMessage(sock, msg);
8209
+ const inputPublicAtReceive = (() => {
8210
+ const rawArgs = msg?.args;
8211
+ if (rawArgs === undefined) return true;
8212
+ if (!rawArgs || typeof rawArgs !== "object" || Array.isArray(rawArgs)) return false;
8213
+ try {
8214
+ const idDescriptor = Object.getOwnPropertyDescriptor(rawArgs, "id");
8215
+ if (idDescriptor && (!Object.hasOwn(idDescriptor, "value") || idDescriptor.value !== undefined) && metadata.task === undefined) return false;
8216
+ const stack: { value: unknown; depth: number }[] = [{ value: rawArgs, depth: 0 }];
8217
+ const text: string[] = [];
8218
+ let bytes = 0, nodes = 0;
8219
+ while (stack.length) {
8220
+ const current = stack.pop()!;
8221
+ if (++nodes > 2048 || current.depth > 16) return undefined;
8222
+ if (typeof current.value === "string") {
8223
+ bytes += Buffer.byteLength(current.value);
8224
+ if (bytes > 32 * 1024) return undefined;
8225
+ text.push(current.value);
8226
+ } else if (Array.isArray(current.value)) {
8227
+ if (current.value.length > 256) return undefined;
8228
+ for (const value of current.value) stack.push({ value, depth: current.depth + 1 });
8229
+ } else if (current.value && typeof current.value === "object") {
8230
+ const values = Object.values(current.value);
8231
+ if (values.length > 256) return undefined;
8232
+ for (const value of values) stack.push({ value, depth: current.depth + 1 });
8233
+ }
8234
+ }
8235
+ const joined = text.join("\n");
8236
+ return tasks.publicMetadataText(joined) && boundedSafe(joined);
8237
+ } catch { return undefined; }
8238
+ })();
8239
+ const privacyCurrent = controlWritePrivacyGuard(admittedAtReceive, inputPublicAtReceive === true, peer, metadata.task);
8240
+ let finishControlWrite = () => {};
8241
+ let finished = false;
8242
+ const finish = () => { if (!finished) { finished = true; finishControlWrite(); } };
8243
+ try {
8244
+ void controlWriteTelemetry.run(metadata, privacyCurrent, () => {
8245
+ const completed = new Promise<void>(resolve => { finishControlWrite = resolve; });
8246
+ try {
8247
+ controlWriteSpan("daemon-dispatch", () => onMessage(sock, msg, finish));
8248
+ } catch (error) { finish(); throw error; }
8249
+ return completed;
8250
+ });
8251
+ } catch (error) { finish(); throw error; }
8252
+ }
8253
+
8254
+ function onMessage(sock: Sock, msg: any, finishControlWrite: () => void = () => {}): void {
8138
8255
  const c = sock.data;
8139
8256
  let refusalRecorded=false;
8140
8257
  const reply = (body: Record<string, unknown>) => {
8258
+ const sendReply = () => {
8141
8259
  const writeRequestId = typeof msg.requestId === "string" && msg.requestId.length <= 256 && !/[\p{Cc}\p{Cf}]/u.test(msg.requestId)
8142
8260
  && toolWriteOperation(msg) !== undefined && body.requestId === msg.requestId ? msg.requestId : undefined;
8143
8261
  let writeRefusal: string | undefined;
@@ -8157,6 +8275,10 @@ export async function startDaemon(opts: DaemonOptions) {
8157
8275
  body = { ...body, ...toolWriteUnknownReply(msg, writeRequestId, writeRefusal ?? "caller authority changed before the result could be returned"), ...authority };
8158
8276
  }
8159
8277
  if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify({ rid: msg.rid, ...body }));
8278
+ };
8279
+ try {
8280
+ controlWriteSpan("daemon-reply", sendReply);
8281
+ } finally { finishControlWrite(); }
8160
8282
  };
8161
8283
  const runTaskRequest = (run: () => Promise<string>, extraCurrent: () => void = () => {}, formatError: (error: unknown) => Record<string, unknown> = error => ({ t: "task", ok: false, error: error instanceof Error ? error.message : "task write refused" })): void => {
8162
8284
  const operation = toolWriteOperation(msg);
@@ -9548,6 +9670,7 @@ export async function startDaemon(opts: DaemonOptions) {
9548
9670
  }
9549
9671
  async function stopOnce(): Promise<void> {
9550
9672
  stopping = true;
9673
+ controlWriteTelemetry?.stop();
9551
9674
  roomCommands.stop();
9552
9675
  tasks.stopQueuePressureRechecks();
9553
9676
  const studiesStopped = tasks.stopStudies();
@@ -9647,6 +9770,7 @@ export async function startDaemon(opts: DaemonOptions) {
9647
9770
  bus.onQueues(); // initial restored holds are reported only after the listener is bound; writes status.json too
9648
9771
  log(`${RUN_START}${process.pid} control=127.0.0.1:${server.port} cwd=${opts.cwd}`);
9649
9772
  ready = true;
9773
+ controlWriteTelemetry?.start();
9650
9774
  handoffCatchUp();
9651
9775
  tasks.scheduleQueuePressureRecheck();
9652
9776
  for (const name of opts.configWatch === false ? [] : CONFIG_SOURCE_FILES) {
@@ -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"]),
@@ -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";