@staix/agent-hub 0.12.35 → 0.12.37

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 (71) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +15 -8
  3. package/docs/agent-notes/adapters.md +9 -2
  4. package/docs/agent-notes/budget.md +3 -2
  5. package/docs/agent-notes/bus.md +3 -0
  6. package/docs/agent-notes/daemon.md +11 -3
  7. package/docs/agent-notes/tasks.md +14 -1
  8. package/docs/agent-notes/tests.md +6 -2
  9. package/docs/memory.md +3 -1
  10. package/docs/operations.md +74 -18
  11. package/docs/quickstart.md +7 -9
  12. package/docs/sdlc.md +72 -5
  13. package/docs/security.md +11 -3
  14. package/docs/smoke.md +18 -2
  15. package/docs/specs/2026-09-19-agent-hub-design.md +108 -3
  16. package/docs/verification/2026-10-19-0.12.36.md +63 -0
  17. package/docs/verification/2026-10-19-0.12.37.md +60 -0
  18. package/docs/verification/2026-10-19-jcode-0.98.1.md +30 -0
  19. package/docs/verification/2026-10-19-reviewer-isolation.md +119 -0
  20. package/docs/verified.json +23 -15
  21. package/package.json +2 -2
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +2843 -1632
  24. package/src/adapters/acp.ts +3 -2
  25. package/src/adapters/claude-print.ts +92 -0
  26. package/src/adapters/codex-review-isolation.ts +276 -0
  27. package/src/adapters/definitions/builtin-metadata.ts +20 -1
  28. package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
  29. package/src/adapters/definitions/native-cli-entry-jcode.ts +3 -2
  30. package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -2
  31. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  32. package/src/adapters/definitions/native-factories.ts +24 -0
  33. package/src/adapters/definitions/native-policy-aux.ts +1 -1
  34. package/src/adapters/definitions/reviewer-variants.ts +32 -0
  35. package/src/adapters/jcode.ts +20 -5
  36. package/src/adapters/registry-types.ts +13 -3
  37. package/src/adapters/registry.ts +29 -5
  38. package/src/adapters/reviewer-print.ts +319 -0
  39. package/src/cli/help.ts +7 -0
  40. package/src/cli/identity.ts +2 -1
  41. package/src/cli/launch.ts +19 -6
  42. package/src/cli/main.ts +57 -4
  43. package/src/cli/memory.ts +13 -12
  44. package/src/cli/reset.ts +4 -2
  45. package/src/cli/statusline-tee.ts +5 -2
  46. package/src/cli/terminal-recovery.ts +7 -2
  47. package/src/cli/upgrade-runtime.ts +18 -15
  48. package/src/hub/board.ts +61 -11
  49. package/src/hub/bus.ts +11 -5
  50. package/src/hub/claude-state.ts +37 -0
  51. package/src/hub/config-apply.ts +3 -2
  52. package/src/hub/control-client.ts +1 -1
  53. package/src/hub/cost-governance.ts +76 -0
  54. package/src/hub/daemon.ts +269 -87
  55. package/src/hub/envelope.ts +1 -1
  56. package/src/hub/host-executable.ts +1 -1
  57. package/src/hub/hub-tools.ts +25 -1
  58. package/src/hub/instances.ts +29 -5
  59. package/src/hub/memory-profile.ts +67 -11
  60. package/src/hub/planning.ts +2 -0
  61. package/src/hub/release-admission.ts +64 -0
  62. package/src/hub/review-brief.ts +175 -0
  63. package/src/hub/reviewer-pool-config.ts +11 -0
  64. package/src/hub/reviewer-pool.ts +70 -0
  65. package/src/hub/routing.ts +58 -4
  66. package/src/hub/sdlc-release.ts +113 -0
  67. package/src/hub/sdlc.ts +205 -4
  68. package/src/hub/status-roster.ts +10 -0
  69. package/src/hub/task-sweep.ts +143 -0
  70. package/src/hub/tasks.ts +788 -52
  71. package/src/hub/usage.ts +57 -20
@@ -0,0 +1,70 @@
1
+ export type ReviewerPoolReason = "rotation" | "implementer skip" | "unavailable skip" | "same-family fallback";
2
+ export interface ReviewerPoolCandidate { id: string; kind: string; family: string; blocked?: string }
3
+ export interface ReviewerPoolInput {
4
+ pool: readonly string[];
5
+ candidates: readonly ReviewerPoolCandidate[];
6
+ implementerFamily?: string;
7
+ seed: number;
8
+ continuityFamily?: string;
9
+ rerotate?: boolean;
10
+ failed?: readonly string[];
11
+ }
12
+ export interface ReviewerPoolSelection {
13
+ reviewer: string | null;
14
+ family?: string;
15
+ reason?: ReviewerPoolReason;
16
+ walk: readonly { id: string; kind: string; family: string; reason: string }[];
17
+ blocked?: "no reviewer can run";
18
+ }
19
+
20
+ /** Pure selection: caller supplies declared candidates and current availability, never inferred authority. */
21
+ export function selectReviewerPool(input: ReviewerPoolInput): ReviewerPoolSelection {
22
+ const kinds = [...new Set(input.pool)];
23
+ const walk: { id: string; kind: string; family: string; reason: string }[] = [];
24
+ if (!kinds.length) return { reviewer: null, walk, blocked: "no reviewer can run" };
25
+ const seed = Number.isSafeInteger(input.seed) ? input.seed : 0;
26
+ const start = ((seed % kinds.length) + kinds.length) % kinds.length;
27
+ const rotated = [...kinds.slice(start), ...kinds.slice(0, start)];
28
+ const slots = rotated.flatMap(kind => {
29
+ const declared = input.candidates.filter(candidate => candidate.kind === kind).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
30
+ return declared.length ? declared : [{ id: "", kind, family: "", blocked: "no declared reviewer" }];
31
+ });
32
+ let ordered = slots;
33
+ // Preserve the rotated order within each family, including sibling instance ids.
34
+ const continuity = !input.rerotate && input.continuityFamily !== undefined && input.continuityFamily !== input.implementerFamily;
35
+ if (continuity) ordered = [...ordered.filter(candidate => candidate.family === input.continuityFamily), ...ordered.filter(candidate => candidate.family !== input.continuityFamily)];
36
+ const failed = new Set(input.failed ?? []);
37
+ let skippedOwner = false, skippedUnavailable = false;
38
+ const unavailable = (candidate: ReviewerPoolCandidate) => failed.has(candidate.id) ? "start failed for this review" : candidate.blocked;
39
+ const record = (candidate: ReviewerPoolCandidate, reason: string) => { walk.push({ id: candidate.id, kind: candidate.kind, family: candidate.family, reason }); };
40
+ for (const candidate of ordered) {
41
+ if (!candidate.id) {
42
+ skippedUnavailable = true;
43
+ record(candidate, "no declared reviewer");
44
+ continue;
45
+ }
46
+ if (input.implementerFamily !== undefined && candidate.family === input.implementerFamily) {
47
+ skippedOwner = true;
48
+ record(candidate, "implementer skip");
49
+ continue;
50
+ }
51
+ const blocked = unavailable(candidate);
52
+ if (blocked !== undefined) {
53
+ skippedUnavailable = true;
54
+ record(candidate, `unavailable skip: ${blocked}`);
55
+ continue;
56
+ }
57
+ const reason: ReviewerPoolReason = skippedUnavailable ? "unavailable skip" : skippedOwner ? "implementer skip" : "rotation";
58
+ record(candidate, `selected (${continuity && candidate.family === input.continuityFamily ? "continuity" : reason})`);
59
+ return { reviewer: candidate.id, family: candidate.family, reason, walk };
60
+ }
61
+ // The caller supplies separate fresh reviewer instances; this does not authorize an implementer's own session.
62
+ for (const candidate of ordered) {
63
+ if (!candidate.id || input.implementerFamily === undefined || candidate.family !== input.implementerFamily) continue;
64
+ const blocked = unavailable(candidate);
65
+ if (blocked !== undefined) { record(candidate, `unavailable skip: ${blocked}`); continue; }
66
+ record(candidate, "selected (same-family fallback)");
67
+ return { reviewer: candidate.id, family: candidate.family, reason: "same-family fallback", walk };
68
+ }
69
+ return { reviewer: null, walk, blocked: "no reviewer can run" };
70
+ }
@@ -1,3 +1,4 @@
1
+ import { selectReviewerPool, type ReviewerPoolSelection } from "./reviewer-pool.ts";
1
2
  import { ADAPTERS, definitionOf, kindOf, familyOf } from "../adapters/registry.ts";
2
3
  import { readFileSync, statSync } from "node:fs";
3
4
  import { join, resolve } from "node:path";
@@ -288,8 +289,12 @@ export const peerEntry = <T>(map: Readonly<Record<string, T>> | undefined, peer:
288
289
  export type RoutingRefusalCode = "instance" | "family" | "tests" | "spec" | "roster" | "pull-only" | "not-attached" | "excluded" | "owner-admission" | "offline" | "paused" | "failing" | "pii" | "policy";
289
290
  export interface RoutingRefusal { peer: PeerId; role: "owner" | "reviewer"; code: RoutingRefusalCode; reason: string }
290
291
  export interface Assignment {
292
+ reviewSelection?: ReviewerPoolSelection;
293
+ reviewBlocked?: true;
291
294
  /** A same-family reviewer was chosen only because no other family was eligible. */
292
295
  sameFamilyFallback?: true;
296
+ /** #223 R5: the planner reviews because the declared reviewer-only instance was not eligible. */
297
+ plannerFallback?: true;
293
298
  refusals?: RoutingRefusal[];
294
299
  owner?: PeerId;
295
300
  reviewer?: PeerId;
@@ -307,7 +312,7 @@ export interface Assignment {
307
312
  * prints its trace. `states`: effective bus states of attached peers (a peer that is not in the map is not attached).
308
313
  */
309
314
  export function assign(
310
- task: Pick<Task, "class" | "signals" | "reserved"> & Partial<Pick<Task, "state" | "title" | "detail" | "refs" | "external">>,
315
+ task: Pick<Task, "class" | "signals" | "reserved"> & Partial<Pick<Task, "state" | "title" | "detail" | "refs" | "external" | "id" | "sdlc" | "history">>,
311
316
  states: Record<PeerId, PeerState>,
312
317
  routing: Routing,
313
318
  opts: InstanceRoutingOptions & {
@@ -322,6 +327,7 @@ export function assign(
322
327
  pullOnly?: PeerId[];
323
328
  namedOwner?: PeerId;
324
329
  namedReviewer?: PeerId;
330
+ reviewPool?: { pool: readonly string[]; rerotate?: boolean; failed?: readonly string[]; required?: boolean };
325
331
  candidates?: PeerId[];
326
332
  notReviewer?: PeerId;
327
333
  waitsFor?: number[];
@@ -362,6 +368,8 @@ export function assign(
362
368
  const refusals = new Map<string, RoutingRefusal>();
363
369
  const blocked = (peer: PeerId, role: "owner" | "reviewer"): string | undefined => {
364
370
  const deny = (code: RoutingRefusalCode, reason: string): string => { refusals.set(`${role}:${peer}`, { peer, role, code, reason }); return reason; };
371
+ const unavailable = role === "reviewer" ? definitionOf(peer, opts.instances)?.reviewer?.unavailable : undefined;
372
+ if (unavailable) return deny("policy", unavailable);
365
373
  if (role === "owner" && opts.capabilities && !pii) {
366
374
  const caps = peerEntry(opts.capabilities, peer);
367
375
  if (needsTests && !caps?.tests) return deny("tests", `missing tests: ${caps?.testsReason ?? "capability unavailable"}`);
@@ -374,6 +382,7 @@ export function assign(
374
382
  if (opts.exclude?.includes(peer)) return deny("excluded", "excluded (declined or replaced)");
375
383
  if (role === "owner" && opts.excludeOwners?.includes(peer)) return deny("owner-admission", "owner admission: WIP or delivery hold");
376
384
  const adapter = definitionOf(peer, opts.instances);
385
+ if (role === "owner" && adapter?.routing?.reviewOnly) return deny("owner-admission", "owner admission: a review-only instance owns no task");
377
386
  if (role === "owner" && needsTests && adapter && adapter.kind !== peer) {
378
387
  if (!peerEntry(opts.roles, peer)?.includes("implementer")) return deny("owner-admission", "owner admission: declared write owner requires the current implementer role");
379
388
  if (peerEntry(opts.ownerIsolation, peer) !== true) return deny("owner-admission", "owner admission: declared write owner requires a verified isolated execution root");
@@ -458,6 +467,9 @@ export function assign(
458
467
 
459
468
  let reviewer: PeerId | undefined;
460
469
  let sameFamilyFallback = false;
470
+ let plannerFallback = false;
471
+ let reviewSelection: ReviewerPoolSelection | undefined;
472
+ let reviewBlocked = false;
461
473
  if (task.class !== "review") {
462
474
  if (pii) trace.push("reviewer: user (pii: no second on-prem peer; ahub review <id> <verdict>)");
463
475
  else {
@@ -471,11 +483,53 @@ export function assign(
471
483
  const sameFamily = (peer: PeerId) => ownerFamily !== undefined && familyOf(peer, opts.instances) === ownerFamily;
472
484
  const independent = ownerFamily === undefined ? [] : automatic.filter(peer => peer !== reviewOwner && !blocked(peer, "reviewer") && !sameFamily(peer));
473
485
  const named = opts.namedReviewer;
474
- if (named !== undefined && named !== reviewOwner && !blocked(named, "reviewer") && sameFamily(named) && independent.length) {
486
+ const declaredPool = opts.reviewPool && named === undefined ? Object.keys(opts.instances ?? {}).flatMap(id => {
487
+ const roles = peerEntry(opts.roles, id), definition = definitionOf(id, opts.instances);
488
+ const kind = kindOf(id, opts.instances), family = familyOf(id, opts.instances);
489
+ if (roles?.length !== 1 || roles[0] !== "reviewer" || !definition?.routing?.reviewOnly || !kind || !family) return [];
490
+ const reason = id === reviewOwner ? "is the owner" : definition.reviewer?.unavailable ?? blocked(id, "reviewer")
491
+ ?? (states[id] === "paused" ? "paused" : undefined)
492
+ ?? peerEntry(opts.held, id)
493
+ ?? ((peerEntry(opts.quota, id)?.headroom ?? Infinity) <= 0 ? "quota exhausted" : undefined);
494
+ return [{ id, kind, family, ...(reason === undefined ? {} : { blocked: reason }) }];
495
+ }) : [];
496
+ if (declaredPool.length || opts.reviewPool?.required && named === undefined) {
497
+ for (const candidate of declaredPool) if (!opts.reviewPool!.pool.includes(candidate.kind)) trace.push(` reviewer pool candidate ${candidate.id} (${candidate.family}): unavailable skip: ${candidate.blocked ?? "kind outside configured pool"}`);
498
+ const last = task.history?.findLast(entry => entry.event === "changes_requested");
499
+ const continuityFamily = last && "reviewerFamily" in last && typeof last.reviewerFamily === "string" ? last.reviewerFamily : last ? familyOf(last.by, opts.instances) : undefined;
500
+ const pr = task.sdlc?.pr;
501
+ const seed = typeof pr === "number" && Number.isSafeInteger(pr) && pr > 0 ? pr : task.id ?? 0;
502
+ reviewSelection = selectReviewerPool({ pool: opts.reviewPool!.pool, candidates: declaredPool, seed, implementerFamily: ownerFamily, continuityFamily, rerotate: opts.reviewPool!.rerotate, failed: opts.reviewPool!.failed });
503
+ for (const row of reviewSelection.walk) trace.push(row.id ? ` reviewer pool candidate ${row.id} (${row.family}): ${row.reason}` : ` reviewer pool kind ${row.kind}: ${row.reason}`);
504
+ reviewer = reviewSelection.reviewer ?? "user";
505
+ if (reviewSelection.blocked) {
506
+ trace.push("blocked: no reviewer can run");
507
+ // Optional defaults retain the legacy planner escape when no fresh reviewer can run.
508
+ const planners = opts.reviewPool?.required ? [] : automatic.filter(peer => peer !== reviewOwner && peerEntry(opts.roles, peer)?.includes("planner") && !definitionOf(peer, opts.instances)?.routing?.reviewOnly && !blocked(peer, "reviewer"));
509
+ const otherFamily = planners.filter(peer => !sameFamily(peer));
510
+ const planner = pick(otherFamily.length ? otherFamily : planners, "reviewer", reviewOwner);
511
+ if (planner) {
512
+ reviewer = planner;
513
+ plannerFallback = true;
514
+ trace.push(` planner fallback: no declared reviewer-only instance is eligible; ${planner} reviews`);
515
+ } else reviewBlocked = true;
516
+ }
517
+ } else if (named !== undefined && named !== reviewOwner && !blocked(named, "reviewer") && sameFamily(named) && independent.length) {
475
518
  const reason = `same family as ${reviewOwner}; eligible other-family reviewer available`;
476
519
  refusals.set(`reviewer:${named}`, { peer: named, role: "reviewer", code: "family", reason });
477
520
  trace.push(` reviewer candidate ${named}: skipped, ${reason}`);
478
- } else reviewer = pick(named === undefined ? independent.length ? independent : automatic : [named], "reviewer", reviewOwner, named === undefined);
521
+ } else {
522
+ // #223 R1: a declared instance that reviews and does not plan goes first, busy or not.
523
+ const holds = (peer: PeerId, role: string) => opts.roles?.[peer]?.includes(role) === true;
524
+ const dedicated = (peer: PeerId) => !!opts.instances && Object.hasOwn(opts.instances, peer) && holds(peer, "reviewer") && !holds(peer, "planner");
525
+ const pool = independent.length ? independent : automatic;
526
+ const ready = named === undefined ? pool.filter(peer => dedicated(peer) && peer !== reviewOwner && !blocked(peer, "reviewer")) : [];
527
+ reviewer = pick(named === undefined ? ready.length ? ready : pool : [named], "reviewer", reviewOwner, named === undefined);
528
+ if (named === undefined && !ready.length && reviewer && holds(reviewer, "planner") && Object.keys(opts.roles ?? {}).some(dedicated)) {
529
+ plannerFallback = true;
530
+ trace.push(` planner fallback: no declared reviewer-only instance is eligible; ${reviewer} reviews`);
531
+ }
532
+ }
479
533
  if (reviewer && sameFamily(reviewer)) {
480
534
  sameFamilyFallback = true;
481
535
  trace.push(` same-family fallback: ${reviewer} and ${reviewOwner} share ${ownerFamily}; no eligible other family`);
@@ -490,7 +544,7 @@ export function assign(
490
544
  const piBackend = policy?.pi_backend;
491
545
  if (owner && definitionOf(owner, opts.instances)?.routing?.trace === "route") trace.push(`route: ${route ?? "(none)"}, fixed_model ${fixedModel}`);
492
546
  if (owner && definitionOf(owner, opts.instances)?.routing?.trace === "backend") trace.push(`${definitionOf(owner, opts.instances)!.routing!.backendLabel} decision: backend ${piBackend ?? "dgx"}${task.signals.includes("long_context") ? `, context limit ${piBackend === "mlx" ? routing.pi.mlx_max_context_tokens : routing.pi.dgx_max_context_tokens}` : ""}`);
493
- return { ...(sameFamilyFallback ? { sameFamilyFallback: true as const } : {}), ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), ...(unreserved ? { unreserved } : {}), ...(refusals.size ? { refusals: [...refusals.values()] } : {}), trace };
547
+ return { ...(reviewSelection ? { reviewSelection } : {}), ...(reviewBlocked ? { reviewBlocked: true as const } : {}), ...(sameFamilyFallback ? { sameFamilyFallback: true as const } : {}), ...(plannerFallback ? { plannerFallback: true as const } : {}), ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), ...(unreserved ? { unreserved } : {}), ...(refusals.size ? { refusals: [...refusals.values()] } : {}), trace };
494
548
  }
495
549
 
496
550
  /**
@@ -1,3 +1,5 @@
1
+ import type { Task } from "./board.ts";
2
+ import type { Release } from "./planning.ts";
1
3
  import { spawn } from "node:child_process";
2
4
  import { fileURLToPath } from "node:url";
3
5
  import { childEnv, stopOwnedProcess } from "./child-process.ts";
@@ -123,3 +125,114 @@ export async function readReleaseReadiness(root: string, repo: string, liveIssue
123
125
  report.blockers.push("native acceptance and person release decision unverified");
124
126
  return report;
125
127
  }
128
+
129
+ export type HotfixCause = "loss" | "security_privacy" | "upgrade_start_blocked" | "core_flow_no_workaround";
130
+ export interface ReleaseTrainData {
131
+ hotfix?: { cause: HotfixCause; reason: string };
132
+ scope?: number[];
133
+ history: { at: number; by: string; event: "configured" | "cut" | "shipped"; scope?: number[]; ciRuns?: number; reviews?: number }[];
134
+ }
135
+ export const RELEASE_TRAIN_HISTORY_LIMIT = 256;
136
+ const HOTFIX_CAUSES: readonly string[] = ["loss", "security_privacy", "upgrade_start_blocked", "core_flow_no_workaround"];
137
+ const trainObject = (value: unknown): Record<string, unknown> => {
138
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("release train: expected an object");
139
+ return value as Record<string, unknown>;
140
+ };
141
+ const trainKeys = (value: Record<string, unknown>, keys: readonly string[]) => {
142
+ if (Object.keys(value).some(key => !keys.includes(key))) throw new Error("release train: unknown field");
143
+ };
144
+ const trainScope = (value: unknown): number[] => {
145
+ // ponytail: 1000 tasks per release; raise together with bounded report readers if larger trains are needed.
146
+ if (!Array.isArray(value) || value.length > 1000 || value.some(id => !Number.isSafeInteger(id) || id < 1) || new Set(value).size !== value.length) throw new Error("release train: invalid scope");
147
+ return [...value].sort((a, b) => a - b);
148
+ };
149
+
150
+ /** Structural validation only: the Tasks boundary screens reason text and establishes caller authority. */
151
+ export function releaseTrainData(input: unknown): ReleaseTrainData {
152
+ const raw = trainObject(input); trainKeys(raw, ["hotfix", "scope", "history"]);
153
+ const result: ReleaseTrainData = { history: [] };
154
+ if (raw.hotfix !== undefined) {
155
+ const hotfix = trainObject(raw.hotfix); trainKeys(hotfix, ["cause", "reason"]);
156
+ if (typeof hotfix.cause !== "string" || !HOTFIX_CAUSES.includes(hotfix.cause) || typeof hotfix.reason !== "string" || !hotfix.reason.trim() || Buffer.byteLength(hotfix.reason) > 1000) throw new Error("release train: invalid hotfix cause or reason");
157
+ result.hotfix = { cause: hotfix.cause as HotfixCause, reason: hotfix.reason.trim() };
158
+ }
159
+ if (raw.scope !== undefined) result.scope = trainScope(raw.scope);
160
+ // ponytail: 256 recorded train events; archive settled release evidence before increasing this ceiling.
161
+ if (!Array.isArray(raw.history) || raw.history.length > RELEASE_TRAIN_HISTORY_LIMIT) throw new Error("release train: invalid history");
162
+ result.history = raw.history.map(value => {
163
+ const row = trainObject(value); trainKeys(row, ["at", "by", "event", "scope", "ciRuns", "reviews"]);
164
+ if (!Number.isSafeInteger(row.at) || (row.at as number) < 1 || (row.at as number) > 8_640_000_000_000_000 || typeof row.by !== "string" || !/^[a-z][a-z0-9-]{0,63}$/.test(row.by) || !["configured", "cut", "shipped"].includes(String(row.event))) throw new Error("release train: invalid history event");
165
+ const event: ReleaseTrainData["history"][number] = { at: row.at as number, by: row.by, event: row.event as "configured" | "cut" | "shipped" };
166
+ if (row.scope !== undefined) event.scope = trainScope(row.scope);
167
+ for (const key of ["ciRuns", "reviews"] as const) if (row[key] !== undefined) {
168
+ if (!Number.isSafeInteger(row[key]) || (row[key] as number) < 0 || (row[key] as number) > 1_000_000) throw new Error("release train: invalid resource count");
169
+ event[key] = row[key] as number;
170
+ }
171
+ return event;
172
+ });
173
+ if (result.history.some((row, index) => index > 0 && row.at < result.history[index - 1]!.at)) throw new Error("release train: history dates are out of order");
174
+ return result;
175
+ }
176
+
177
+ const storedTrain = (release: Release): ReleaseTrainData | undefined => {
178
+ const raw = (release as Release & { train?: ReleaseTrainData }).train;
179
+ if (raw === undefined) return undefined;
180
+ try { return releaseTrainData(raw); } catch { return undefined; }
181
+ };
182
+
183
+ /** Board metadata only; a trigger is a request to cut, never release authorization or native readiness. */
184
+ export function releaseTrainReadiness(release: Release, tasks: Task[], now = Date.now()) {
185
+ const train = storedTrain(release), assigned = tasks.filter(task => task.release === release.id), frozen = ["frozen", "shipped"].includes(release.state);
186
+ const scope = frozen ? train?.scope : assigned.map(task => task.id);
187
+ const blockers: string[] = [];
188
+ if ((release as Release & { train?: ReleaseTrainData }).train !== undefined && !train) blockers.push("release train evidence unknown");
189
+ if (!scope) blockers.push("release scope unknown");
190
+ else if (!scope.length) blockers.push("release scope empty");
191
+ if (frozen && scope && JSON.stringify([...scope].sort((a, b) => a - b)) !== JSON.stringify(assigned.map(task => task.id).sort((a, b) => a - b))) blockers.push("frozen release scope changed");
192
+ const rows = new Map(tasks.map(task => [task.id, task]));
193
+ const states = (scope ?? []).map(id => {
194
+ const task = rows.get(id), state = task?.state ?? "unknown", ready = state === "approved";
195
+ if (!task) blockers.push(`task #${id}: scope evidence unknown`);
196
+ else if (task.release !== release.id) blockers.push(`task #${id}: release assignment changed`);
197
+ if (!ready) blockers.push(`task #${id}: not approved`);
198
+ return { id, state, ready };
199
+ });
200
+ const trigger: "tasks_approved" | "planned_date" | "waiting" = states.length && states.every(task => task.ready) && !blockers.length ? "tasks_approved"
201
+ : release.cut !== null && Number.isFinite(now) && release.cut <= now ? "planned_date" : "waiting";
202
+ return { trigger, tasks: states, blockers };
203
+ }
204
+
205
+ /** Counts are explicit recorded event deltas, not inferred from successful gates or absent history. */
206
+ export function releaseMetrics(releases: Release[], tasks: Task[]) {
207
+ const unknown = new Set<number>(), shipped: { id: number; at: number }[] = [];
208
+ const approvalToReleaseMs: { release: number; task: number; ms: number | null }[] = [];
209
+ let hotfixCount = 0, knownKinds = 0, ciRuns = 0, reviews = 0, ciKnown = true, reviewsKnown = true;
210
+ const byTask = new Map(tasks.map(task => [task.id, task]));
211
+ const settled = releases.filter(release => release.state === "shipped");
212
+ for (const release of settled) {
213
+ const train = storedTrain(release);
214
+ if (!train?.history.length) { unknown.add(release.id); ciKnown = false; reviewsKnown = false; continue; }
215
+ knownKinds++;
216
+ if (train.hotfix) hotfixCount++;
217
+ for (const key of ["ciRuns", "reviews"] as const) {
218
+ const recorded = train.history.flatMap(event => event[key] === undefined ? [] : [event[key]!]);
219
+ if (!recorded.length) { unknown.add(release.id); if (key === "ciRuns") ciKnown = false; else reviewsKnown = false; }
220
+ else if (key === "ciRuns") ciRuns += recorded.reduce((sum, value) => sum + value, 0);
221
+ else reviews += recorded.reduce((sum, value) => sum + value, 0);
222
+ }
223
+ const event = [...train.history].reverse().find(row => row.event === "shipped");
224
+ if (!event || release.shippedAt !== event.at) { unknown.add(release.id); continue; }
225
+ shipped.push({ id: release.id, at: event.at });
226
+ const scope = train.scope ?? [...train.history].reverse().find(row => row.event === "cut")?.scope;
227
+ if (!scope) { unknown.add(release.id); continue; }
228
+ for (const id of scope) {
229
+ const task = byTask.get(id), approved = task?.history.findLast(row => row.at <= event.at && (row.event === "approved" || row.event === "done" && task.state === "approved" && task.reviewer === null))?.at ?? null;
230
+ const ms = approved !== null && approved <= event.at ? event.at - approved : null;
231
+ if (ms === null) unknown.add(release.id);
232
+ approvalToReleaseMs.push({ release: release.id, task: id, ms });
233
+ }
234
+ }
235
+ shipped.sort((a, b) => a.at - b.at || a.id - b.id);
236
+ const intervals = shipped.length !== releases.filter(release => release.state === "shipped").length ? [] : shipped.slice(1).map((row, index) => ({ from: shipped[index]!.id, to: row.id, ms: row.at - shipped[index]!.at }));
237
+ return { intervals, approvalToReleaseMs, hotfixCount: knownKinds === settled.length && knownKinds > 0 ? hotfixCount : null, hotfixRate: knownKinds === settled.length && knownKinds > 0 ? hotfixCount / knownKinds : null, ciRuns: ciKnown && settled.length ? ciRuns : null, reviews: reviewsKnown && settled.length ? reviews : null, unknown: [...unknown].sort((a, b) => a - b) };
238
+ }
package/src/hub/sdlc.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { CostPlan } from "./cost-governance.ts";
1
2
  import { criteriaReceipt, type CriteriaReceipt, type CriteriaView, type LegacyPhaseReceipt } from "./workflow.ts";
2
3
  import { constants, openSync, fstatSync, readSync, closeSync } from "node:fs";
3
4
  import { join } from "node:path";
@@ -29,11 +30,21 @@ export interface StageDefinition { base: SdlcBaseStage; actor: string; entry_gat
29
30
  export type SdlcMode = "off" | "advise" | "enforce";
30
31
  /** `workflow: "gsd-lite"` marks the retired setting during its window (#481 S8): every task then needs a criteria checklist. */
31
32
  export interface SdlcConfig { workflow?: "gsd-lite"; mode: SdlcMode; requiredChecks: string[]; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; deprecations?: string[]; }
33
+ export interface PlanReviewReceipt {
34
+ planner: string;
35
+ planningRevision: number;
36
+ recordedAt: number;
37
+ scope?: CostPlan;
38
+ issue?: PlanIssueMetadata;
39
+ }
32
40
  export interface SdlcReceipt {
33
41
  stage: SdlcStage;
42
+ planReview?: PlanReviewReceipt;
34
43
  criteria?: CriteriaReceipt;
35
44
  /** Stored before #481 S8 and read as `criteria`; never written again. */
36
45
  phase?: LegacyPhaseReceipt;
46
+ /** The person's spec acceptance (#481 S1): set only by Tasks.acceptSpec, never by hub_sdlc_record. */
47
+ acceptance?: SpecAcceptance;
37
48
  repo?: string;
38
49
  issue?: number;
39
50
  pr?: number;
@@ -181,6 +192,8 @@ export function sdlcReceipt(value: unknown, previous: SdlcReceipt | undefined =
181
192
  if (!["passed", "failed", "unknown"].includes(String(raw.localResult))) throw new Error("sdlc receipt.localResult: expected passed, failed or unknown");
182
193
  next.localResult = raw.localResult as SdlcReceipt["localResult"];
183
194
  }
195
+ // An acceptance names one spec issue; relinking the receipt to another drops it (#481 S1).
196
+ if (next.repo !== previous.repo || next.issue !== previous.issue) delete next.acceptance;
184
197
  // A new artifact cannot inherit another PR's review or local gate.
185
198
  if (next.repo !== previous.repo || next.pr !== previous.pr) {
186
199
  delete next.head; delete next.observed;
@@ -199,7 +212,7 @@ export function parsePrEvidence(value: unknown): PrEvidence {
199
212
  const name = c.name ?? c.context;
200
213
  if (typeof name !== "string" || !name || name.length > 120) throw new Error("PR readback: invalid check identity");
201
214
  const native = c.conclusion ?? c.state;
202
- const state = native === "SUCCESS" ? "passed" : ["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED"].includes(String(native)) ? "failed" : ["PENDING", "QUEUED", "IN_PROGRESS"].includes(String(c.status ?? native)) ? "pending" : "unknown";
215
+ const state = ["PENDING", "QUEUED", "IN_PROGRESS"].includes(String(c.status ?? native)) ? "pending" : native === "SUCCESS" ? "passed" : ["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED"].includes(String(native)) ? "failed" : "unknown";
203
216
  return { name, state } as PrEvidence["checks"][number];
204
217
  });
205
218
  const paths = p.files.map((f: Record<string, unknown>) => { if (typeof f.path !== "string" || !f.path || f.path.length > 1000) throw new Error("PR readback: invalid file identity"); return f.path; });
@@ -210,10 +223,26 @@ export function parsePrEvidence(value: unknown): PrEvidence {
210
223
  ...(sha(p.mergeCommit?.oid) ? { mergeCommit: p.mergeCommit.oid } : {}) };
211
224
  }
212
225
 
213
- export interface SdlcStatus { playbook?: string; criteria?: CriteriaView; mode: SdlcMode; stage: SdlcStage; definition?: StageDefinition; entry?: string[]; exit?: string[]; blockers: string[]; ready: boolean; actor: string; next: string; }
226
+ /** Issue identity and edit times only; no body is fetched or stored. `lastEditedAt` moves on a body edit, not on comments. */
227
+ export interface SpecEdit { lastEditedAt: string | null; updatedAt: string }
228
+ export interface SpecAcceptance extends SpecEdit { repo: string; issue: number; acceptedAt: number }
229
+ export type SpecState = "missing" | "current" | "changed" | "unknown";
230
+ /** Whether the receipt's spec issue is accepted at its current body. `edit` undefined means the readback failed. */
231
+ export function specState(receipt: SdlcReceipt | undefined, edit: SpecEdit | undefined): SpecState {
232
+ const accepted = receipt?.acceptance;
233
+ if (!accepted || accepted.repo !== receipt?.repo || accepted.issue !== receipt?.issue) return "missing";
234
+ if (!edit) return "unknown";
235
+ return edit.lastEditedAt === accepted.lastEditedAt ? "current" : "changed";
236
+ }
237
+ export const SPEC_STATE_TEXT: Record<Exclude<SpecState, "current">, string> = {
238
+ missing: "spec acceptance missing: the person runs ahub sdlc accept <task> --issue <n>",
239
+ changed: "spec changed after acceptance: the person re-accepts with ahub sdlc accept <task> --issue <n>",
240
+ unknown: "spec edit time unknown: the issue could not be read",
241
+ };
242
+ export interface SdlcStatus { spec?: { issue: number; state: SpecState }; playbook?: string; criteria?: CriteriaView; mode: SdlcMode; stage: SdlcStage; definition?: StageDefinition; entry?: string[]; exit?: string[]; blockers: string[]; ready: boolean; actor: string; next: string; }
214
243
  /** The acceptance-criteria checklist (#481 S8, Compliance pass) counts once recorded, and on every task under the retired gsd-lite setting. Off keeps #257's output. */
215
244
  export const criteriaRequired = (config: SdlcConfig, receipt: SdlcReceipt | undefined): boolean => config.workflow === "gsd-lite" || (config.mode !== "off" && !!checklistOf(receipt));
216
- export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined, evidence?: PrEvidence, criteria?: CriteriaView): SdlcStatus {
245
+ export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined, evidence?: PrEvidence, criteria?: CriteriaView, specEdit?: SpecEdit): SdlcStatus {
217
246
  const stage = receipt?.stage ?? config.stages[0] ?? "spec", blockers: string[] = [];
218
247
  const definition = stageDefinition(config, stage);
219
248
  if (!definition) blockers.push("lifecycle stage definition unknown");
@@ -239,7 +268,9 @@ export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined,
239
268
  const custom = config.definitions && definition ? stageGates(definition, blockers, [...viewBlockers, ...staleHead]) : undefined;
240
269
  if (custom) for (const blocker of [...custom.entry, ...custom.exit]) if (!blockers.includes(blocker)) blockers.push(blocker);
241
270
  const shown = criteria && (criteriaRequired(config, receipt) || definition && [...definition.entry_gates, ...definition.exit_gates].includes("criteria"));
242
- return { ...(config.mode !== "off" && definition && playbookStage(definition.base) ? { playbook: playbookStage(definition.base) } : {}), ...(shown ? { criteria } : {}), ...(custom ? { definition, ...custom } : {}), mode: config.mode, stage, blockers, ready: blockers.length === 0,
271
+ // Shown, not counted as a readiness blocker: the spec gate is build entry (#481 S1).
272
+ const spec = config.mode !== "off" && receipt?.issue ? { spec: { issue: receipt.issue, state: specState(receipt, specEdit) } } : {};
273
+ return { ...spec, ...(config.mode !== "off" && definition && playbookStage(definition.base) ? { playbook: playbookStage(definition.base) } : {}), ...(shown ? { criteria } : {}), ...(custom ? { definition, ...custom } : {}), mode: config.mode, stage, blockers, ready: blockers.length === 0,
243
274
  actor: !definition ? "person" : shown && criteria.blockers.length ? "reviewer/person" : config.definitions ? definition.actor : baseActor(definition.base),
244
275
  next: blockers[0] ?? "person may inspect merge readiness; no automatic merge" };
245
276
  }
@@ -279,6 +310,20 @@ export const sdlcGh: SdlcGh = (args, timeoutMs = 10_000, cwd, maxOutputBytes = 2
279
310
  child.once("close", code => { clearTimeout(timer); resolve({ code: failed ? 1 : code ?? 1, stdout: failed ? "" : stdout }); });
280
311
  });
281
312
 
313
+ /** Read-only, fixed argv: the issue's edit times, never its body. */
314
+ export async function readSpecEdit(repoName: string, issue: number, run: SdlcGh = sdlcGh): Promise<SpecEdit | undefined> {
315
+ if (!repo(repoName) || !id(issue)) return undefined;
316
+ const [owner, name] = repoName.split("/");
317
+ try {
318
+ const out = await run(["api", "graphql", "-f", "query=query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){issue(number:$number){number lastEditedAt updatedAt}}}", "-f", `owner=${owner}`, "-f", `name=${name}`, "-F", `number=${issue}`]);
319
+ if (out.code !== 0) return undefined;
320
+ const found = JSON.parse(out.stdout)?.data?.repository?.issue;
321
+ const stamp = (v: unknown) => typeof v === "string" && v.length <= 40 && !Number.isNaN(Date.parse(v));
322
+ if (found?.number !== issue || !stamp(found.updatedAt) || (found.lastEditedAt !== null && !stamp(found.lastEditedAt))) return undefined;
323
+ return { lastEditedAt: found.lastEditedAt, updatedAt: found.updatedAt };
324
+ } catch { return undefined; }
325
+ }
326
+
282
327
  export async function readSdlcPr(receipt: SdlcReceipt, run: SdlcGh = sdlcGh): Promise<PrEvidence | undefined> {
283
328
  if (!repo(receipt.repo) || !id(receipt.pr)) return undefined;
284
329
  try {
@@ -298,3 +343,159 @@ export async function readSdlcPr(receipt: SdlcReceipt, run: SdlcGh = sdlcGh): Pr
298
343
  return parsePrEvidence(p);
299
344
  } catch { return undefined; }
300
345
  }
346
+
347
+ export interface PlanIssueMetadata {
348
+ repo: string;
349
+ issue: number;
350
+ state: "OPEN" | "CLOSED";
351
+ /** Body edit time; comment activity must never invalidate a plan review. */
352
+ editedAt: string;
353
+ /** Nullable GitHub body edit field, retained to compare independent S1 acceptance evidence. */
354
+ lastEditedAt?: string | null;
355
+ }
356
+
357
+ /** Metadata only: never request issue bodies or comments for a planning receipt. */
358
+ export async function readPlanIssueMetadata(repository: string, number: number, run: SdlcGh = sdlcGh): Promise<PlanIssueMetadata | undefined> {
359
+ if (!repo(repository) || !id(number)) return undefined;
360
+ const [owner, name] = repository.split("/");
361
+ try {
362
+ const result = await run(["api", "graphql", "-f", "query=query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){issue(number:$number){number state createdAt lastEditedAt}}}", "-f", `owner=${owner}`, "-f", `name=${name}`, "-F", `number=${number}`], 10_000, undefined, 8 * 1024);
363
+ if (result.code !== 0 || Buffer.byteLength(result.stdout) > 8 * 1024) return undefined;
364
+ const response = JSON.parse(result.stdout);
365
+ if (response.errors !== undefined) return undefined;
366
+ const issue = response.data?.repository?.issue;
367
+ if (!issue || issue.number !== number || !["OPEN", "CLOSED"].includes(issue.state)) return undefined;
368
+ const editedAt = issue.lastEditedAt === null ? issue.createdAt : issue.lastEditedAt;
369
+ if (typeof editedAt !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(editedAt) || !Number.isFinite(Date.parse(editedAt))) return undefined;
370
+ return { repo: repository, issue: number, state: issue.state, editedAt, lastEditedAt: issue.lastEditedAt };
371
+ } catch { return undefined; }
372
+ }
373
+
374
+ /** A complete resolved-thread snapshot already proves there is no pending unanswered bot thread. */
375
+ export function reviewReadinessFromEvidence(evidence: PrEvidence | undefined, expected: string | undefined, required: readonly string[]): ReviewReadiness | undefined {
376
+ if (!evidence || evidence.unresolved !== 0) return undefined;
377
+ const checks = required.length ? [...required] : ["check (ubuntu-latest)", "check (macos-latest)", "seeded guards"];
378
+ if (!checks.every(name => evidence.checks.some(check => check.name === name)) || evidence.checks.some(check => check.state === "unknown")) return undefined;
379
+ const blockers: string[] = [];
380
+ if (!expected || !sha(expected)) blockers.push("review head missing");
381
+ else if (evidence.head !== expected) blockers.push("review head changed");
382
+ if (evidence.state !== "OPEN") blockers.push("review PR is not open");
383
+ if (!checks.every(name => { const matched = evidence.checks.filter(check => check.name === name); return matched.length === 1 && matched[0]?.state === "passed"; })) blockers.push("exact-head full CI is not green");
384
+ return { head: evidence.head, ready: blockers.length === 0, blockers };
385
+ }
386
+
387
+ export interface ReviewReadiness { head?: string; ready: boolean; blockers: string[]; reusedRun?: number }
388
+ /** Metadata-only pre-review admission; no remote body or comment text is requested. */
389
+ export async function readReviewReadiness(receipt: SdlcReceipt, expected: string | undefined, run: SdlcGh = sdlcGh, requiredChecks: readonly string[] = []): Promise<ReviewReadiness> {
390
+ const unknown = (): ReviewReadiness => ({ ready: false, blockers: ["review readiness unavailable"] });
391
+ if (!repo(receipt.repo) || !id(receipt.pr)) return unknown();
392
+ const [owner, name] = receipt.repo.split("/");
393
+ const query = "query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){headRefOid state commits(last:1){nodes{commit{oid tree{oid} statusCheckRollup{contexts(first:100){nodes{... on CheckRun{name status conclusion} ... on StatusContext{context state}} pageInfo{hasNextPage}}}}}} reviewThreads(first:100){nodes{isResolved comments(first:100){nodes{author{__typename} createdAt} pageInfo{hasNextPage}}} pageInfo{hasNextPage}}}}}";
394
+ try {
395
+ const deadline = performance.now() + 20_000;
396
+ const bounded: SdlcGh = (args, timeout, cwd, cap) => performance.now() < deadline
397
+ ? run(args, Math.max(1, Math.min(timeout ?? 10_000, deadline - performance.now())), cwd, cap)
398
+ : Promise.resolve({ code: 1, stdout: "" });
399
+ const args = ["api", "graphql", "-f", `query=${query}`, "-f", `owner=${owner}`, "-f", `name=${name}`, "-F", `number=${receipt.pr}`];
400
+ const response = await bounded(args, 10_000, undefined, 512 * 1024);
401
+ if (response.code !== 0 || Buffer.byteLength(response.stdout) > 512 * 1024) return unknown();
402
+ const parsed = JSON.parse(response.stdout), pr = parsed.data?.repository?.pullRequest;
403
+ if (parsed.errors || !pr || !sha(pr.headRefOid)) return unknown();
404
+ const blockers: string[] = [];
405
+ if (!expected || !sha(expected)) blockers.push("review head missing");
406
+ else if (pr.headRefOid !== expected) blockers.push("review head changed");
407
+ if (pr.state !== "OPEN") blockers.push("review PR is not open");
408
+ const commit = pr.commits?.nodes?.[0]?.commit;
409
+ if (!commit || commit.oid !== pr.headRefOid) blockers.push("review head changed");
410
+ const contexts = commit?.statusCheckRollup?.contexts;
411
+ const nodes: unknown[] | undefined = contexts?.nodes;
412
+ const full = requiredChecks.length ? [...requiredChecks] : ["check (ubuntu-latest)", "check (macos-latest)", "seeded guards"];
413
+ let reusedRun: number | undefined;
414
+ if (contexts?.pageInfo?.hasNextPage !== false || !Array.isArray(nodes) || nodes.length > 100
415
+ || !full.every(name => {
416
+ const matches = (nodes as { name?: string; context?: string; status?: string; conclusion?: string; state?: string }[]).filter(node => (node?.name ?? node?.context) === name);
417
+ return matches.length === 1 && (matches[0]?.name ? matches[0].status === "COMPLETED" && matches[0].conclusion === "SUCCESS" : matches[0]?.state === "SUCCESS");
418
+ })) blockers.push("exact-head full CI is not green");
419
+ if (blockers.includes("exact-head full CI is not green") && contexts?.pageInfo?.hasNextPage === false && Array.isArray(nodes)
420
+ && full.every(name => { const matches = (nodes as { name?: string; conclusion?: string }[]).filter(node => node?.name === name); return matches.length === 1 && matches[0]?.conclusion === "SKIPPED"; }) && sha(commit?.tree?.oid)) {
421
+ reusedRun = await identicalTreeGate(receipt.repo, commit.tree.oid, bounded, full);
422
+ if (reusedRun !== undefined) {
423
+ // Proof reads can await several API calls: recheck the complete head/check/thread snapshot before admission.
424
+ const after = await bounded(args, 10_000, undefined, 512 * 1024);
425
+ try { if (after.code !== 0 || JSON.stringify(JSON.parse(after.stdout)?.data?.repository?.pullRequest) !== JSON.stringify(pr)) reusedRun = undefined; }
426
+ catch { reusedRun = undefined; }
427
+ if (reusedRun !== undefined) blockers.splice(blockers.indexOf("exact-head full CI is not green"), 1);
428
+ }
429
+ }
430
+ const threads = pr.reviewThreads;
431
+ if (threads?.pageInfo?.hasNextPage !== false || !Array.isArray(threads.nodes) || threads.nodes.length > 100) blockers.push("bot review thread evidence unavailable");
432
+ else for (const thread of threads.nodes) {
433
+ if (thread?.isResolved === true) continue;
434
+ if (thread?.isResolved !== false) { blockers.push("bot review thread evidence unavailable"); continue; }
435
+ const comments = thread?.comments;
436
+ if (comments?.pageInfo?.hasNextPage !== false || !Array.isArray(comments.nodes) || comments.nodes.length > 100
437
+ || comments.nodes.some((comment: { author?: { __typename?: string }; createdAt?: string }) => !["Bot", "User"].includes(comment?.author?.__typename ?? "") || !Number.isFinite(Date.parse(comment.createdAt ?? "")))) {
438
+ blockers.push("bot review thread evidence unavailable"); continue;
439
+ }
440
+ let unanswered = false;
441
+ for (const comment of comments.nodes) {
442
+ if (comment.author.__typename === "Bot") unanswered = true;
443
+ else if (comment.author.__typename === "User") unanswered = false;
444
+ }
445
+ if (unanswered) blockers.push("bot review threads unanswered");
446
+ }
447
+ return { head: pr.headRefOid, ready: blockers.length === 0, blockers: [...new Set(blockers)], ...(reusedRun !== undefined ? { reusedRun } : {}) };
448
+ } catch { return unknown(); }
449
+ }
450
+
451
+ /** Same proof floor as scripts/ci-gates.mjs: the identical tree includes the workflow itself. No skipped proof. */
452
+ async function identicalTreeGate(repository: string, tree: string, run: SdlcGh, required: readonly string[]): Promise<number | undefined> {
453
+ try {
454
+ const list = await run(["api", `repos/${repository}/actions/workflows/check.yml/runs?status=success&per_page=20`, "--jq", "{total_count,workflow_runs:[.workflow_runs[]|{id,event,conclusion,head_sha}]}"], 10_000, undefined, 64 * 1024);
455
+ if (list.code !== 0) return;
456
+ const runs = JSON.parse(list.stdout)?.workflow_runs;
457
+ if (!Array.isArray(runs) || runs.length > 20) return;
458
+ for (const candidate of runs) {
459
+ if (!id(candidate?.id) || candidate.conclusion !== "success" || !["pull_request", "push"].includes(candidate.event) || !sha(candidate.head_sha)) continue;
460
+ const commit = await run(["api", `repos/${repository}/git/commits/${candidate.head_sha}`, "--jq", "{tree:{sha:.tree.sha}}"], 10_000, undefined, 4096);
461
+ if (commit.code !== 0 || JSON.parse(commit.stdout)?.tree?.sha !== tree) continue;
462
+ const jobs = await run(["api", `repos/${repository}/actions/runs/${candidate.id}/jobs?per_page=100`, "--jq", '{total_count,jobs:[.jobs[]|{name,conclusion,reused:any(.steps[]?; .name=="Reuse verified identical-tree full gate" and .conclusion=="success")}]}'], 10_000, undefined, 32 * 1024);
463
+ if (jobs.code !== 0) continue;
464
+ const proof = JSON.parse(jobs.stdout);
465
+ if (!Array.isArray(proof.jobs) || proof.jobs.length > 100 || proof.total_count !== proof.jobs.length) continue;
466
+ if (required.every(name => {
467
+ const found = proof.jobs.filter((job: { name?: string }) => job?.name === name); return found.length === 1 && found[0]?.conclusion === "success" && found[0]?.reused !== true;
468
+ })) return candidate.id;
469
+ }
470
+ } catch { return; }
471
+ }
472
+
473
+ /** PR cost evidence excludes titles, bodies and comment text; unknown/truncated coverage never becomes zero. */
474
+ export async function readPrCostHistory(repository: string, number: number, run: SdlcGh = sdlcGh): Promise<import("./cost-governance.ts").CostPr | undefined> {
475
+ if (!repo(repository) || !id(number)) return;
476
+ const [owner, name] = repository.split("/");
477
+ try {
478
+ const query = "query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){number state headRefOid mergedAt commits(first:100){nodes{commit{oid}} pageInfo{hasNextPage}} reviews(first:100){nodes{databaseId commit{oid}} pageInfo{hasNextPage}}}}}";
479
+ const reply = await run(["api", "graphql", "-f", `query=${query}`, "-f", `owner=${owner}`, "-f", `name=${name}`, "-F", `number=${number}`], 10_000, undefined, 128 * 1024);
480
+ if (reply.code !== 0) return;
481
+ const parsed = JSON.parse(reply.stdout), pr = parsed.data?.repository?.pullRequest;
482
+ if (parsed.errors || pr?.number !== number || !["OPEN", "CLOSED", "MERGED"].includes(pr.state)) return;
483
+ const result: import("./cost-governance.ts").CostPr = { repo: repository, pr: number, state: pr.state, complete: false,
484
+ ...(typeof pr.mergedAt === "string" && Number.isFinite(Date.parse(pr.mergedAt)) ? { mergedAt: Date.parse(pr.mergedAt) } : {}) };
485
+ if (pr.state !== "MERGED") return result; // Cost metrics count merged PRs, not an active work queue.
486
+ const reviews = pr.reviews, commits = pr.commits;
487
+ const reviewComplete = reviews?.pageInfo?.hasNextPage === false && Array.isArray(reviews.nodes) && reviews.nodes.length <= 100 && reviews.nodes.every((node: { databaseId?: number }) => id(node?.databaseId));
488
+ const commitComplete = commits?.pageInfo?.hasNextPage === false && Array.isArray(commits.nodes) && commits.nodes.length <= 100 && sha(pr.headRefOid) && commits.nodes.every((node: { commit?: { oid?: string } }) => sha(node?.commit?.oid));
489
+ if (reviewComplete) result.reviews = new Set(reviews.nodes.map((review: { databaseId?: unknown }) => review.databaseId).filter((id: unknown) => Number.isSafeInteger(id))).size;
490
+ const heads = new Set<string>([pr.headRefOid, ...(commits?.nodes ?? []).map((node: { commit?: { oid?: string } }) => node?.commit?.oid), ...(reviews?.nodes ?? []).map((node: { commit?: { oid?: string } }) => node?.commit?.oid)].filter(sha));
491
+ const checks = await run(["api", `repos/${repository}/actions/workflows/check.yml/runs?per_page=100`, "--jq", "{total_count,workflow_runs:[.workflow_runs[]|{id,run_attempt,head_sha,pull_requests:[.pull_requests[].number]}]}"], 10_000, undefined, 128 * 1024);
492
+ if (checks.code !== 0) return result;
493
+ const runs = JSON.parse(checks.stdout);
494
+ if (!Array.isArray(runs.workflow_runs) || runs.workflow_runs.length > 100 || !Number.isSafeInteger(runs.total_count) || runs.total_count < runs.workflow_runs.length) return result;
495
+ const linked = runs.workflow_runs.filter((row: { head_sha?: string; pull_requests?: number[] }) => heads.has(row.head_sha ?? "") || Array.isArray(row.pull_requests) && row.pull_requests.includes(number));
496
+ if (linked.some((row: { id?: number; run_attempt?: number }) => !id(row.id) || !id(row.run_attempt))) return result;
497
+ result.ciRuns = linked.map((row: { id: number; run_attempt: number }) => ({ id: row.id, attempt: row.run_attempt }));
498
+ result.complete = reviewComplete && commitComplete && runs.total_count === runs.workflow_runs.length;
499
+ return result;
500
+ } catch { return; }
501
+ }
@@ -6,3 +6,13 @@ export function rosterInStatus(status: unknown, peer: string): boolean | undefin
6
6
  const row = rows.find((r: unknown) => !!r && typeof r === "object" && (r as { peer?: unknown }).peer === peer) as { on?: unknown } | undefined;
7
7
  return typeof row?.on === "boolean" ? row.on : undefined;
8
8
  }
9
+
10
+ /**
11
+ * The plugin bundle the running daemon verifies Jcode's project MCP entry against. The launcher writes this path rather
12
+ * than its own copy: the CLI on PATH and the daemon can come from different installs, and the daemon compares the whole
13
+ * entry. undefined for a hub that sends none or a path that is not that bundle (an older hub, a malformed reply).
14
+ */
15
+ export function bundleInStatus(status: unknown): string | undefined {
16
+ const value = (status as { pluginBundle?: unknown } | undefined)?.pluginBundle;
17
+ return typeof value === "string" && value.startsWith("/") && /[\\/]plugins[\\/]agent-hub[\\/]server\.js$/.test(value) ? value : undefined;
18
+ }