@staix/agent-hub 0.12.23 → 0.12.24

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 (158) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +8 -3
  3. package/docs/addons/claude-mem.md +24 -0
  4. package/docs/addons/codex-plugin.md +8 -0
  5. package/docs/addons/dot-admit.md +8 -0
  6. package/docs/addons/open-code-review.md +8 -0
  7. package/docs/addons/ponytail.md +23 -0
  8. package/docs/addons/ripwire.md +22 -0
  9. package/docs/agent-notes/README.md +5 -0
  10. package/docs/agent-notes/adapters.md +8 -1
  11. package/docs/agent-notes/bus.md +3 -1
  12. package/docs/agent-notes/daemon.md +7 -1
  13. package/docs/agent-notes/local-worker.md +4 -2
  14. package/docs/agent-notes/tasks.md +4 -0
  15. package/docs/agent-notes/tests.md +2 -1
  16. package/docs/bench.md +28 -3
  17. package/docs/cooperbench.md +4 -0
  18. package/docs/data-policy.md +36 -0
  19. package/docs/events.md +3 -0
  20. package/docs/gsd-collaboration-analysis.md +48 -0
  21. package/docs/memory.md +231 -0
  22. package/docs/operations.md +651 -14
  23. package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
  24. package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
  25. package/docs/profiles.md +57 -0
  26. package/docs/quickstart.md +1 -1
  27. package/docs/research.md +31 -1
  28. package/docs/roles.md +85 -0
  29. package/docs/sdlc.md +170 -0
  30. package/docs/security.md +111 -4
  31. package/docs/smoke.md +282 -0
  32. package/docs/specs/2026-09-19-agent-hub-design.md +601 -7
  33. package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
  34. package/docs/specs/2026-10-12-office-view-design.md +212 -0
  35. package/docs/text-projection.md +19 -0
  36. package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
  37. package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
  38. package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
  39. package/docs/verification/2026-10-13-0.12.24.md +94 -0
  40. package/docs/verification/2026-10-13-board-phone.md +31 -0
  41. package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
  42. package/docs/verified.json +22 -13
  43. package/package.json +2 -1
  44. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  45. package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
  46. package/plugins/agent-hub/server.js +1175 -339
  47. package/scripts/check-docs.mjs +129 -0
  48. package/src/adapters/acp.ts +112 -13
  49. package/src/adapters/claude-channel.ts +48 -11
  50. package/src/adapters/codex-appserver.ts +11 -0
  51. package/src/adapters/jcode.ts +110 -0
  52. package/src/adapters/local-worker.ts +10 -7
  53. package/src/adapters/pi.ts +94 -20
  54. package/src/cli/attention.ts +60 -0
  55. package/src/cli/bench.ts +24 -5
  56. package/src/cli/board-read.ts +12 -0
  57. package/src/cli/console-state.ts +103 -17
  58. package/src/cli/console.ts +54 -8
  59. package/src/cli/facts-hook.ts +12 -4
  60. package/src/cli/help.ts +35 -9
  61. package/src/cli/identity.ts +18 -7
  62. package/src/cli/init.ts +48 -10
  63. package/src/cli/jcode-mcp.ts +58 -0
  64. package/src/cli/kimi-mcp.ts +53 -0
  65. package/src/cli/launch.ts +16 -1
  66. package/src/cli/main.ts +392 -57
  67. package/src/cli/memory.ts +193 -0
  68. package/src/cli/output.ts +45 -4
  69. package/src/cli/pet-config.ts +1 -0
  70. package/src/cli/pet.ts +83 -0
  71. package/src/cli/preview.ts +38 -5
  72. package/src/cli/status-lines.ts +1 -1
  73. package/src/cli/tail-render.ts +1 -0
  74. package/src/cli/upgrade-runtime.ts +32 -3
  75. package/src/cli/watch.ts +31 -0
  76. package/src/hub/activity.ts +53 -0
  77. package/src/hub/ask.ts +10 -5
  78. package/src/hub/bench.ts +10 -3
  79. package/src/hub/board.ts +222 -16
  80. package/src/hub/bus.ts +48 -9
  81. package/src/hub/citations.ts +7 -0
  82. package/src/hub/conductor.ts +12 -7
  83. package/src/hub/config-trust.ts +4 -2
  84. package/src/hub/crash.ts +9 -0
  85. package/src/hub/daemon.ts +910 -120
  86. package/src/hub/delivery-journal.ts +5 -0
  87. package/src/hub/envelope.ts +1 -1
  88. package/src/hub/events.ts +12 -2
  89. package/src/hub/facts.ts +26 -7
  90. package/src/hub/git.ts +8 -2
  91. package/src/hub/github-import.ts +28 -0
  92. package/src/hub/hub-tools.ts +49 -10
  93. package/src/hub/inference.ts +1 -1
  94. package/src/hub/lifecycle-schema.ts +17 -0
  95. package/src/hub/listen.ts +15 -0
  96. package/src/hub/manager.ts +12 -5
  97. package/src/hub/path-policy.ts +96 -0
  98. package/src/hub/peer-catalog.ts +28 -0
  99. package/src/hub/permission-mode.ts +19 -5
  100. package/src/hub/planning.ts +70 -0
  101. package/src/hub/profiles.ts +74 -0
  102. package/src/hub/projected-diff.ts +73 -0
  103. package/src/hub/projection-exec.ts +24 -0
  104. package/src/hub/research.ts +144 -21
  105. package/src/hub/roles.ts +123 -0
  106. package/src/hub/roster.ts +23 -0
  107. package/src/hub/routing.ts +18 -5
  108. package/src/hub/sdlc-queue.ts +112 -0
  109. package/src/hub/sdlc-release.ts +125 -0
  110. package/src/hub/sdlc.ts +250 -0
  111. package/src/hub/settings.ts +34 -15
  112. package/src/hub/shipped-roles.ts +25 -0
  113. package/src/hub/snapshots.ts +89 -15
  114. package/src/hub/start-mode.ts +7 -5
  115. package/src/hub/supervision.ts +1 -0
  116. package/src/hub/task-sweep.ts +1 -1
  117. package/src/hub/tasks.ts +892 -87
  118. package/src/hub/text-projection.ts +126 -0
  119. package/src/hub/tooling.ts +159 -0
  120. package/src/hub/ui.ts +16 -2
  121. package/src/hub/workflow.ts +71 -0
  122. package/src/local/sandbox.ts +6 -4
  123. package/src/local/tools.ts +11 -6
  124. package/src/memory/brief.ts +28 -14
  125. package/src/memory/import.ts +55 -0
  126. package/src/memory/recall.ts +84 -0
  127. package/src/memory/store.ts +750 -0
  128. package/src/memory/sync.ts +145 -0
  129. package/src/pet/NOTICE.md +10 -0
  130. package/src/pet/characters.ts +1438 -0
  131. package/src/pet/config.ts +31 -0
  132. package/src/pet/frame.ts +17 -0
  133. package/src/pet/pixels.ts +28 -0
  134. package/src/role-text.d.ts +1 -0
  135. package/src/ui/index.html +842 -46
  136. package/src/ui/needs.ts +58 -0
  137. package/src/ui/office-model.ts +440 -0
  138. package/src/ui/peer-palette.ts +16 -0
  139. package/src/ui/task-board.ts +49 -0
  140. package/src/ui/task-progress.ts +5 -1
  141. package/templates/AGENTS.block.md +3 -2
  142. package/templates/config.json +5 -1
  143. package/templates/profiles/dev.json +22 -0
  144. package/templates/profiles/general.json +26 -0
  145. package/templates/profiles/research.json +57 -0
  146. package/templates/profiles/writing.json +28 -0
  147. package/templates/roles/NOTICE.md +26 -0
  148. package/templates/roles/dev/backend-engineer.md +26 -0
  149. package/templates/roles/dev/code-reviewer.md +26 -0
  150. package/templates/roles/dev/frontend-engineer.md +26 -0
  151. package/templates/roles/dev/security-reviewer.md +26 -0
  152. package/templates/roles/dev/software-architect.md +26 -0
  153. package/templates/roles/dev/test-engineer.md +26 -0
  154. package/templates/roles/research/analyst.md +26 -0
  155. package/templates/roles/research/fact-checker.md +26 -0
  156. package/templates/roles/research/research-lead.md +26 -0
  157. package/templates/roles/research/researcher.md +26 -0
  158. package/templates/roles/research/statistician.md +26 -0
@@ -0,0 +1,123 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants, closeSync, fstatSync, lstatSync, openSync, readSync, readdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { sanitize } from "./envelope.ts";
5
+ import { SHIPPED_ROLES } from "./shipped-roles.ts";
6
+
7
+ export const ROLE_SECTIONS = ["Scope", "Not in scope", "Deliverables", "Hub use", "Review duties", "Evidence"] as const;
8
+ const BASES = ["planner", "implementer", "verifier", "reviewer"];
9
+ const CLASSES = ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"];
10
+ const ID = /^[a-z][a-z0-9-]{0,47}$/;
11
+ const PEER = /^[a-z][a-z0-9-]{0,31}$/;
12
+ export interface CatalogRole {
13
+ id: string; name: string; group: string; summary: string; base: string[]; classes: string[]; source: string;
14
+ body: string; review: string; layer: "shipped" | "project"; hash: string;
15
+ }
16
+ export interface RoleCatalog { roles: Map<string, CatalogRole>; errors: { id: string; reason: string }[] }
17
+ /** Flat headers are data, never YAML execution or authority configuration. */
18
+ export function parseRole(text: string, stem: string, layer: CatalogRole["layer"]): CatalogRole {
19
+ if (Buffer.byteLength(text) > 8192 || text.includes("\r") || !ID.test(stem)) throw new Error("invalid role size, encoding or file id");
20
+ const match = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(text);
21
+ if (!match) throw new Error("expected a flat header and body");
22
+ const header: Record<string, string> = Object.create(null);
23
+ const keys = ["id", "name", "group", "summary", "base", "classes", "source"];
24
+ for (const line of match[1]!.split("\n")) {
25
+ const field = /^([a-z]+): (.+)$/.exec(line);
26
+ if (!field || !keys.includes(field[1]!) || header[field[1]!] !== undefined || /[\x00-\x1f\x7f]/.test(field[2]!)) throw new Error("invalid or duplicate header field");
27
+ header[field[1]!] = field[2]!;
28
+ }
29
+ if (keys.some(key => !header[key]) || header.id !== stem || !ID.test(header.group!) || header.name!.length > 80 || header.summary!.length > 160 || header.source!.length > 300) throw new Error("invalid header identity or limits");
30
+ const list = (key: string, allowed: string[]) => {
31
+ const values = header[key]!.split(",").map(v => v.trim());
32
+ if (!values.length || new Set(values).size !== values.length || values.some(v => !allowed.includes(v))) throw new Error(`invalid ${key}`);
33
+ return values;
34
+ };
35
+ const body = match[2]!.trimEnd();
36
+ if (!body || sanitize(body).length > 2000 || /```|~~~|^\t|^ {4}|^(?:\$\s|(?:bun|npm|npx|git|ssh|curl|wget|rm|bash|sh|python[0-9.]*)\s)/m.test(body)) throw new Error("invalid body size or command/code block");
37
+ for (const char of text) if ((char.codePointAt(0)! > 127 && /[\p{P}\p{S}\p{Z}\p{C}]/u.test(char)) || /[\x00-\x08\x0b-\x1f\x7f]/.test(char)) throw new Error("use plain ASCII punctuation and no controls/emoji");
38
+ const headings = [...body.matchAll(/^#{1,6} (.+)$/gm)];
39
+ if (headings.length !== ROLE_SECTIONS.length || headings.some(h => !h[0].startsWith("## "))) throw new Error("unexpected role heading or personality section");
40
+ const sections = [...body.matchAll(/^## (.+)$/gm)];
41
+ if (sections.length !== ROLE_SECTIONS.length || sections.some((s, i) => s[1] !== ROLE_SECTIONS[i])) throw new Error("expected the six ordered role sections");
42
+ for (let i = 0; i < sections.length; i++) {
43
+ const start = sections[i]!.index! + sections[i]![0].length;
44
+ const end = sections[i + 1]?.index ?? body.length;
45
+ if (!body.slice(start, end).trim()) throw new Error("empty role section");
46
+ }
47
+ const reviewStart = sections[4]!.index! + sections[4]![0].length;
48
+ const review = body.slice(reviewStart, sections[5]!.index).trim();
49
+ return { id: stem, name: header.name!, group: header.group!, summary: header.summary!, source: header.source!, base: list("base", BASES), classes: list("classes", CLASSES), body, review, layer, hash: createHash("sha256").update(text).digest("hex") };
50
+ }
51
+ function projectRole(file: string): string {
52
+ const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
53
+ try {
54
+ const before = fstatSync(fd);
55
+ if (!before.isFile() || before.nlink !== 1 || before.size > 8192) throw new Error("role must be a bounded regular file");
56
+ const bytes = Buffer.alloc(8193); let length = 0, n = 0;
57
+ while ((n = readSync(fd, bytes, length, bytes.length - length, null)) > 0) { length += n; if (length > 8192) throw new Error("role exceeds file cap"); }
58
+ const after = fstatSync(fd);
59
+ if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || length !== after.size) throw new Error("role changed during read");
60
+ return bytes.subarray(0, length).toString("utf8");
61
+ } finally { closeSync(fd); }
62
+ }
63
+ export function loadRoleCatalog(cwd?: string): RoleCatalog {
64
+ const roles = new Map<string, CatalogRole>(), errors: RoleCatalog["errors"] = [];
65
+ for (const [id, text] of Object.entries(SHIPPED_ROLES)) {
66
+ try { roles.set(id, parseRole(text, id, "shipped")); } catch { errors.push({ id, reason: "invalid shipped role" }); }
67
+ }
68
+ if (!cwd) return { roles, errors };
69
+ try {
70
+ const config = join(cwd, ".agenthub"), dir = join(config, "roles");
71
+ for (const path of [config, dir]) { const stat = lstatSync(path); if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("unsafe role directory"); }
72
+ // ponytail: 64 flat project files, no recursive scan; explicit catalog index before larger collections.
73
+ const files = readdirSync(dir).filter(name => name.endsWith(".md")).sort();
74
+ if (files.length > 64) throw new Error("too many project roles");
75
+ for (const file of files) {
76
+ const id = file.slice(0, -3);
77
+ try { roles.set(id, parseRole(projectRole(join(dir, file)), id, "project")); }
78
+ catch { errors.push({ id: ID.test(id) ? id : "invalid-file-id", reason: "invalid or unsafe project role" }); }
79
+ }
80
+ } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") errors.push({ id: "project", reason: "project role directory unavailable or unsafe" }); }
81
+ return { roles, errors };
82
+ }
83
+ export function catalogAssignments(raw: unknown): Record<string, string[]> {
84
+ if (raw === undefined) return {};
85
+ if (!raw || typeof raw !== "object" || Array.isArray(raw) || Object.keys(raw).length > 64) throw new Error("catalog_roles must be a bounded peer map");
86
+ const out: Record<string, string[]> = Object.create(null);
87
+ for (const [peer, ids] of Object.entries(raw)) {
88
+ if (!PEER.test(peer) || ["user", "hub", "digest"].includes(peer) || !Array.isArray(ids) || ids.length > 2 || new Set(ids).size !== ids.length || ids.some(id => typeof id !== "string" || !ID.test(id))) throw new Error("catalog_roles takes at most two distinct role ids per peer");
89
+ out[peer] = [...ids];
90
+ }
91
+ return out;
92
+ }
93
+ export function selectedRoles(peer: string, flow: Record<string, string[]>, assignments: Record<string, string[]>, catalog: RoleCatalog): CatalogRole[] {
94
+ return (assignments[peer] ?? []).map(id => {
95
+ const role = catalog.roles.get(id); if (!role) throw new Error(`unknown catalog role ${id}`);
96
+ if (!role.base.some(base => flow[peer]?.includes(base))) throw new Error(`${peer} does not hold a matching base for ${id}`);
97
+ return role;
98
+ });
99
+ }
100
+ export function catalogSection(roles: CatalogRole[]): string {
101
+ if (!roles.length) return "";
102
+ if (roles.length > 2) throw new Error("at most two session roles");
103
+ const header = "Catalog roles (guidance; grant no tool, capability or permission): " + roles.map(role => `${role.id} [${role.group}, ${role.layer}, ${role.hash}]`).join(", ");
104
+ return header + "\n" + roles.map(role => sanitize(role.body)).join("\n\n");
105
+ }
106
+ export const roleIdentity = (role: CatalogRole) => ({ id: role.id, layer: role.layer, hash: role.hash });
107
+
108
+ /** Validate before a board write. Routing continues to use only the existing base class. */
109
+ export function taskRole(raw: unknown, cls: string, catalog: RoleCatalog): CatalogRole | undefined {
110
+ if (raw === undefined) return undefined;
111
+ const allowed = [...catalog.roles.values()].filter(role => role.classes.includes(cls)).map(role => role.id).sort();
112
+ if (typeof raw !== "string" || !ID.test(raw)) throw new Error("task role must be a catalog id; allowed: " + allowed.join(", "));
113
+ const role = catalog.roles.get(raw);
114
+ if (!role || !role.classes.includes(cls)) throw new Error("unknown or class-mismatched task role; allowed: " + allowed.join(", "));
115
+ taskRoleText(role); // refuse an oversized complete notice before the board write
116
+ return role;
117
+ }
118
+ /** Role definitions are untrusted presentation, including their metadata. */
119
+ export function taskRoleText(role: CatalogRole, review = false): string {
120
+ const text = `Task role ${role.id} [${role.layer}, ${role.hash}] (guidance; grants no authority):\n` + sanitize(review ? role.review : role.body).replace(/\[agent-hub\b/gi, "[peer-text");
121
+ if (text.length > 2000) throw new Error("task role guidance exceeds 2000 characters including its header");
122
+ return text;
123
+ }
@@ -0,0 +1,23 @@
1
+ import { isSupportedPeer, type KnownPeer } from "./peer-catalog.ts";
2
+
3
+ export type PeerRoster = Partial<Record<KnownPeer, boolean>>;
4
+ // Default participation policy, not another catalog. New catalog members default off.
5
+ const DEFAULT_ON: Partial<Record<KnownPeer, boolean>> = { claude: true, codex: true, kimi: true, local: true };
6
+
7
+ export function peerRosterConfig(value: unknown): PeerRoster {
8
+ if (value === undefined) return {};
9
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("roster must be an object of boolean peer values");
10
+ const roster: PeerRoster = {};
11
+ for (const [peer, on] of Object.entries(value)) {
12
+ if (!isSupportedPeer(peer)) throw new Error("roster contains an unsupported peer");
13
+ if (typeof on !== "boolean") throw new Error(`roster.${peer} must be a boolean`);
14
+ roster[peer] = on;
15
+ }
16
+ return roster;
17
+ }
18
+
19
+ /** External ids keep their existing admission rules; Pi's legacy switch is only its default. */
20
+ export function rosterAllows(roster: PeerRoster, peer: string, piEnabled: boolean): boolean {
21
+ if (!isSupportedPeer(peer)) return true;
22
+ return roster[peer] ?? (peer === "pi" ? piEnabled : DEFAULT_ON[peer] ?? false);
23
+ }
@@ -210,7 +210,7 @@ export function currentRouting(cwd: string, log: (line: string) => void = () =>
210
210
  }
211
211
  }
212
212
 
213
- export type Signal = "pii" | "long_context" | "urgent";
213
+ export type Signal = "pii" | "pii_path" | "long_context" | "urgent";
214
214
 
215
215
  /** What the policy can see in a task. Context length is estimated from the referenced files, 3 characters per token as elsewhere. */
216
216
  export function detectSignals(task: Pick<Task, "title" | "detail" | "refs">, routing: Routing, cwd: string): Signal[] {
@@ -248,11 +248,19 @@ export interface Assignment {
248
248
  * prints its trace. `states`: effective bus states of attached peers (a peer that is not in the map is not attached).
249
249
  */
250
250
  export function assign(
251
- task: Pick<Task, "class" | "signals" | "reserved">,
251
+ task: Pick<Task, "class" | "signals" | "reserved"> & Partial<Pick<Task, "state">>,
252
252
  states: Record<PeerId, PeerState>,
253
253
  routing: Routing,
254
254
  opts: {
255
255
  exclude?: PeerId[];
256
+ /** Owner admission limits do not remove otherwise eligible reviewers. */
257
+ excludeOwners?: PeerId[];
258
+ /** Live membership supplied as data; routing never reads the roster store. */
259
+ offRoster?: PeerId[];
260
+ /** Watched peers take work only on an explicit nomination, never a routing singleton. */
261
+ pullOnly?: PeerId[];
262
+ namedOwner?: PeerId;
263
+ namedReviewer?: PeerId;
256
264
  candidates?: PeerId[];
257
265
  notReviewer?: PeerId;
258
266
  waitsFor?: number[];
@@ -272,6 +280,7 @@ export function assign(
272
280
  roles?: Record<string, string[]>;
273
281
  } = {},
274
282
  ): Assignment {
283
+ if (task.state === "backlog") return { trace: ["backlog: not routed; pull into To Do first"] };
275
284
  const policy = routing.classes[task.class];
276
285
  const trace: string[] = [`class ${task.class}${policy ? "" : " (no [classes] entry: only an explicit owner can take it)"}`, `signals: ${task.signals.join(", ") || "none"}`];
277
286
  // Which file each of this class's values comes from (#269), only when the machine-local overlay sets one.
@@ -281,11 +290,15 @@ export function assign(
281
290
  const reserved = opts.candidates ? undefined : task.reserved ?? undefined;
282
291
  // Readiness is an input like peer states (issue #34): a task that waits for others goes to nobody yet.
283
292
  if (opts.waitsFor?.length) return { trace: [...trace, `blocked: waits for ${opts.waitsFor.map((id) => `#${id}`).join(", ")} (not approved)`, ...(reserved ? [`reserved owner ${reserved}: offered first once it is ready`] : [])] };
284
- const pii = task.signals.includes("pii") && routing.constraints.pii === "local_only";
293
+ const pii = task.signals.includes("pii_path") || (task.signals.includes("pii") && routing.constraints.pii === "local_only");
285
294
 
286
295
  const blocked = (peer: PeerId, role: "owner" | "reviewer"): string | undefined => {
296
+ if (opts.offRoster?.includes(peer)) return "off roster";
297
+ const named = role === "owner" ? opts.namedOwner ?? reserved : opts.namedReviewer;
298
+ if (opts.pullOnly?.includes(peer) && named !== peer) return "pull-only: takes work when named or when it claims it";
287
299
  if (!Object.hasOwn(states, peer)) return "not attached";
288
300
  if (opts.exclude?.includes(peer)) return "excluded (declined or replaced)";
301
+ if (role === "owner" && opts.excludeOwners?.includes(peer)) return "owner admission: WIP or delivery hold";
289
302
  if (states[peer] === "offline") return "offline";
290
303
  if (states[peer] === "paused" && routing.constraints.budget_paused === "skip_peer") return "paused";
291
304
  const failing = peerEntry(opts.failing, peer);
@@ -371,8 +384,8 @@ export function assign(
371
384
  // Reviewer candidates (issue #92): the review class's peers first, then peers holding the reviewer role, deduped.
372
385
  const classPeers = routing.classes.review?.peers ?? [];
373
386
  const rolePeers = Object.keys(opts.roles ?? {}).filter((p) => opts.roles![p]!.includes("reviewer"));
374
- trace.push(` reviewer candidates: [classes.review] peers ${classPeers.join(", ") || "none"}${rolePeers.length ? `; reviewer role ${rolePeers.join(", ")}` : ""}`);
375
- reviewer = pick([...new Set([...classPeers, ...rolePeers])], "reviewer", owner ?? opts.notReviewer);
387
+ trace.push(opts.namedReviewer === undefined ? ` reviewer candidates: [classes.review] peers ${classPeers.join(", ") || "none"}${rolePeers.length ? `; reviewer role ${rolePeers.join(", ")}` : ""}` : ` reviewer candidate explicitly named: ${opts.namedReviewer}`);
388
+ reviewer = pick(opts.namedReviewer === undefined ? [...new Set([...classPeers, ...rolePeers])] : [opts.namedReviewer], "reviewer", owner ?? opts.notReviewer);
376
389
  trace.push(reviewer ? `reviewer: ${reviewer}` : "reviewer: none, done will approve directly");
377
390
  const reviewerHold = reviewer ? peerEntry(opts.held, reviewer) : undefined;
378
391
  if (reviewerHold) trace.push(` hold: ${reviewer}'s queue is held: ${reviewerHold} (it receives the review once the hold is resolved)`);
@@ -0,0 +1,112 @@
1
+ import type { PhaseView } from "./workflow.ts";
2
+ import { parsePrEvidence, readSdlcPr, sdlcGh, sdlcStatus, type PrEvidence, type SdlcConfig, type SdlcGh, type SdlcReceipt } from "./sdlc.ts";
3
+ export interface DeliveryTask { id: number; after: number[]; private: boolean; receipt?: SdlcReceipt; phase?: PhaseView; }
4
+ export interface MergeQueueRow { pr: number; tasks: number[]; head?: string; private?: true; ready: boolean; blockers: string[]; after: number[]; }
5
+ export interface MergeConflict { left: number; right: number; files: number; order: "dependency" | "person_required"; }
6
+ export interface MergeQueue { repo: string; rows: MergeQueueRow[]; order: number[]; unordered?: number[]; conflicts: MergeConflict[]; complete: boolean; truncated: boolean; }
7
+ const validRepo = (value: string) => /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value) && !value.startsWith("-");
8
+ const validId = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
9
+ const parseSnapshot = (reply: { code: number; stdout: string }): { number: number; head: string; base: string; baseHead: string }[] | undefined => {
10
+ try { const data = JSON.parse(reply.stdout); if (reply.code !== 0 || !Array.isArray(data) || data.length > 17 || data.some(p => !validId(p?.number) || typeof p.headRefOid !== "string" || !/^[0-9a-f]{40}$/.test(p.headRefOid) || typeof p.baseRefName !== "string" || !p.baseRefName || p.baseRefName.length > 200 || /[\x00-\x1f\x7f]/.test(p.baseRefName) || typeof p.baseRefOid !== "string" || !/^[0-9a-f]{40}$/.test(p.baseRefOid)) || new Set(data.map(p => p.number)).size !== data.length) return; return data.map(p => ({ number: p.number, head: p.headRefOid, base: p.baseRefName, baseHead: p.baseRefOid })).sort((a, b) => a.number - b.number); }
11
+ catch { return; }
12
+ };
13
+ const gateIdentity = (p: PrEvidence) => JSON.stringify({ head: p.head, state: p.state, checks: [...p.checks].sort((a, b) => a.name.localeCompare(b.name) || a.state.localeCompare(b.state)), review: p.review, pending: p.pendingReviews, unresolved: p.unresolved, draft: p.draft, base: p.base, baseHead: p.baseHead, pathsComplete: p.pathsComplete });
14
+ /** The last remote read gathers both open identities and mutable gates in one bounded request. */
15
+ async function finalSnapshot(repo: string, evidence: Map<number, PrEvidence>, run: SdlcGh): Promise<{ gatesStable: boolean; prs?: ReturnType<typeof parseSnapshot> }> {
16
+ const [owner, name] = repo.split("/");
17
+ const fields = "headRefOid state isDraft baseRefName baseRefOid changedFiles reviewDecision reviewRequests(first:100){nodes{requestedReviewer{__typename}} pageInfo{hasNextPage}} reviewThreads(first:100){nodes{isResolved} pageInfo{hasNextPage}} statusCheckRollup{contexts(first:100){nodes{... on CheckRun{name conclusion status} ... on StatusContext{context state}} pageInfo{hasNextPage}}}";
18
+ const entries = [...evidence.entries()];
19
+ const query = `query($owner:String!,$name:String!){repository(owner:$owner,name:$name){pullRequests(first:17,states:OPEN){nodes{number headRefOid baseRefName baseRefOid} pageInfo{hasNextPage}} ${entries.map(([id]) => `queue_p${id}:pullRequest(number:${id}){${fields}}`).join(" ")}}}`;
20
+ try {
21
+ const reply = await run(["api", "graphql", "-f", `query=${query}`, "-f", `owner=${owner}`, "-f", `name=${name}`]);
22
+ if (reply.code !== 0) return { gatesStable: false };
23
+ const data = JSON.parse(reply.stdout);
24
+ if (data.errors || !data.data?.repository) return { gatesStable: false };
25
+ const open = data.data.repository.pullRequests;
26
+ const prs = parseSnapshot({ code: 0, stdout: JSON.stringify(open?.nodes) });
27
+ if (!prs || open?.pageInfo?.hasNextPage !== false) return { gatesStable: false, prs };
28
+ for (const [id, before] of entries) {
29
+ const p = data.data.repository[`queue_p${id}`];
30
+ const threads = p?.reviewThreads, reviews = p?.reviewRequests, checks = p?.statusCheckRollup === null ? { nodes: [], pageInfo: { hasNextPage: false } } : p?.statusCheckRollup?.contexts;
31
+ if (threads?.pageInfo?.hasNextPage !== false || reviews?.pageInfo?.hasNextPage !== false || checks?.pageInfo?.hasNextPage !== false || !Array.isArray(threads.nodes) || threads.nodes.length > 100 || threads.nodes.some((n: { isResolved?: unknown }) => typeof n?.isResolved !== "boolean") || !Array.isArray(reviews.nodes) || !Array.isArray(checks.nodes) || checks.nodes.length > 100) return { gatesStable: false, prs };
32
+ const after = parsePrEvidence({ ...p, files: before.paths.map(path => ({ path })), statusCheckRollup: checks.nodes, reviewRequests: reviews.nodes, unresolvedThreads: threads.nodes.filter((n: { isResolved: boolean }) => !n.isResolved).length });
33
+ if (gateIdentity(before) !== gateIdentity(after)) return { gatesStable: false, prs };
34
+ }
35
+ return { gatesStable: true, prs };
36
+ } catch { return { gatesStable: false }; }
37
+ }
38
+ /** Fixed metadata-only reads; local claims and unknown dependency evidence never become merge approval. */
39
+ export async function readMergeQueue(repo: string, config: SdlcConfig, tasks: DeliveryTask[], run: SdlcGh = sdlcGh): Promise<MergeQueue> {
40
+ if (!validRepo(repo)) throw new Error("merge queue requires owner/repository");
41
+ const result: MergeQueue = { repo, rows: [], order: [], conflicts: [], complete: false, truncated: false };
42
+ const deadline = performance.now() + 20_000;
43
+ const bounded: SdlcGh = args => { const remaining = deadline - performance.now(); return remaining > 0 ? run(args, Math.min(10_000, remaining)) : Promise.resolve({ code: 1, stdout: "" }); };
44
+ const listArgs = ["pr", "list", "--repo", repo, "--state", "open", "--limit", "17", "--json", "number,headRefOid,baseRefName,baseRefOid"];
45
+ const initialPrs = parseSnapshot(await bounded(listArgs)); if (!initialPrs) return result;
46
+ result.truncated = initialPrs.length > 16; const ids = initialPrs.slice(0, 16).map(pr => pr.number);
47
+ const sameRepo = (value: unknown) => typeof value === "string" && value.toLowerCase() === repo.toLowerCase();
48
+ const evidence = new Map<number, PrEvidence>(), links = new Map<number, DeliveryTask[]>();
49
+ for (const pr of ids) {
50
+ const holders = tasks.filter(t => sameRepo(t.receipt?.repo) && t.receipt?.pr === pr); links.set(pr, holders);
51
+ const row: MergeQueueRow = { pr, tasks: holders.some(t => t.private) ? [] : holders.map(t => t.id), ready: false, blockers: [], after: [] }; result.rows.push(row);
52
+ if (holders.some(t => t.private)) { row.private = true; row.blockers.push("private lifecycle evidence unavailable"); continue; }
53
+ const read = await readSdlcPr(holders[0]?.receipt ?? { stage: config.stages[0] ?? "spec", repo, pr }, bounded);
54
+ if (!read || read.state !== "OPEN") { row.blockers.push("current PR evidence unknown or no longer open"); continue; }
55
+ evidence.set(pr, read); row.head = read.head;
56
+ if (!read.pathsComplete) row.blockers.push("PR file coverage is incomplete or unknown");
57
+ if (read.draft !== false) row.blockers.push("PR is draft or draft status is unknown");
58
+ if (!read.base) row.blockers.push("PR target branch is unknown");
59
+ const linked = holders.length ? holders : [undefined];
60
+ for (const holder of linked) row.blockers.push(...sdlcStatus(config, holder?.receipt, read, holder?.phase).blockers);
61
+ }
62
+ const dependencyEvidence = new Map<number, PrEvidence | undefined>(); let dependencyReads = 0;
63
+ for (const row of result.rows) {
64
+ if (row.private) continue;
65
+ for (const holder of links.get(row.pr) ?? []) for (const id of holder.after) {
66
+ const dependency = tasks.find(t => t.id === id);
67
+ if (!dependency || dependency.private || !dependency.receipt || !sameRepo(dependency.receipt.repo) || !validId(dependency.receipt.pr)) { row.blockers.push("dependency merge evidence unknown or private"); continue; }
68
+ const pr = dependency.receipt.pr;
69
+ if (pr === row.pr) continue; // task dependency within this same artifact
70
+ if (ids.includes(pr)) { row.after.push(pr); row.blockers.push("dependency PR is still open"); continue; }
71
+ if (!dependencyEvidence.has(pr) && dependencyReads < 8) { dependencyReads++; dependencyEvidence.set(pr, await readSdlcPr(dependency.receipt, bounded)); }
72
+ const read = dependencyEvidence.get(pr);
73
+ if (!read || read.state !== "MERGED" || !read.mergeCommit) row.blockers.push("dependency PR merge is not verified");
74
+ }
75
+ row.after = [...new Set(row.after)].sort((a, b) => a - b);
76
+ }
77
+ const reaches = (from: number, target: number, seen = new Set<number>()): boolean => {
78
+ if (seen.has(from)) return false; seen.add(from);
79
+ return (result.rows.find(r => r.pr === from)?.after ?? []).some(before => before === target || reaches(before, target, seen));
80
+ };
81
+ for (let i = 0; i < result.rows.length; i++) for (const right of result.rows.slice(i + 1)) {
82
+ const left = result.rows[i]!, a = evidence.get(left.pr), b = evidence.get(right.pr); if (!a || !b || !a.base || a.base !== b.base) continue;
83
+ const files = new Set(a.paths.filter(path => b.paths.includes(path))).size; if (!files) continue;
84
+ const ordered = reaches(left.pr, right.pr) || reaches(right.pr, left.pr);
85
+ result.conflicts.push({ left: left.pr, right: right.pr, files, order: ordered ? "dependency" : "person_required" });
86
+ if (!ordered) { left.blockers.push("touching PRs require a person order"); right.blockers.push("touching PRs require a person order"); }
87
+ }
88
+ const remaining = new Set(ids);
89
+ while (remaining.size) {
90
+ const next = result.rows.filter(r => remaining.has(r.pr) && r.after.every(pr => !remaining.has(pr))).map(r => r.pr).sort((a, b) => a - b);
91
+ if (!next.length) { for (const row of result.rows.filter(r => remaining.has(r.pr))) row.blockers.push("artifact dependency cycle requires a person"); break; }
92
+ for (const pr of next) { remaining.delete(pr); result.order.push(pr); }
93
+ }
94
+ const touchingCoverageUnknown = result.rows.some(row => evidence.get(row.pr)?.pathsComplete !== true || !evidence.get(row.pr)?.base);
95
+ const personOrder = new Set(result.conflicts.filter(conflict => conflict.order === "person_required").flatMap(conflict => [conflict.left, conflict.right]));
96
+ const unresolvedOrder = new Set([...remaining, ...personOrder]);
97
+ for (const row of result.rows) if (result.truncated || touchingCoverageUnknown || [...personOrder].some(pr => reaches(row.pr, pr))) unresolvedOrder.add(row.pr);
98
+ if (unresolvedOrder.size) { result.unordered = ids.filter(pr => unresolvedOrder.has(pr)); result.order = result.order.filter(pr => !unresolvedOrder.has(pr)); }
99
+ for (const row of result.rows) { if (result.truncated) row.blockers.push("open PR coverage truncated; touching order unknown"); if (touchingCoverageUnknown) row.blockers.push("open PR file coverage incomplete; touching order unknown"); row.blockers = [...new Set(row.blockers)]; row.ready = !row.private && !row.blockers.length; }
100
+ result.complete = !result.truncated && !remaining.size && !touchingCoverageUnknown && !personOrder.size;
101
+ // Empty/private-only snapshots have no intervening remote reads and offer no private PR evidence.
102
+ if (evidence.size) {
103
+ const finalRead = await finalSnapshot(repo, evidence, bounded);
104
+ const gatesStable = finalRead.gatesStable;
105
+ const finalPrs = finalRead.prs;
106
+ if (!gatesStable || !finalPrs || JSON.stringify(finalPrs) !== JSON.stringify(initialPrs) || finalPrs.some(pr => evidence.has(pr.number) && (evidence.get(pr.number)?.head !== pr.head || evidence.get(pr.number)?.base !== pr.base || evidence.get(pr.number)?.baseHead !== pr.baseHead))) {
107
+ result.complete = false; result.order = []; result.unordered = ids;
108
+ for (const row of result.rows) { row.ready = false; row.blockers.push("open PR set changed or could not be reverified"); }
109
+ }
110
+ }
111
+ return result;
112
+ }
@@ -0,0 +1,125 @@
1
+ import { spawn } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+ import { childEnv, stopOwnedProcess } from "./child-process.ts";
4
+ import { hubGitSync } from "./git.ts";
5
+ import { sdlcGh, type SdlcGh } from "./sdlc.ts";
6
+ 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; }
7
+ export interface ReleaseReadiness { 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; }
8
+ export type ReleaseGit = (args: string[]) => { code: number; stdout: string };
9
+ export interface ReleaseReaders { gh?: SdlcGh; git?: ReleaseGit; docs?: (root: string, timeoutMs: number) => Promise<DocEvidence | undefined>; }
10
+ const SHA = /^[0-9a-f]{40}$/;
11
+ const VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
12
+ const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
13
+ /** One identity floor for initial and final remote tag observations. */
14
+ function tagMatches(target: unknown, commit: string): boolean {
15
+ const parsed = target as { oid?: unknown; target?: { oid?: unknown } } | null | undefined;
16
+ const oid = parsed?.oid ?? parsed?.target?.oid;
17
+ return typeof oid === "string" && SHA.test(oid) && oid === commit;
18
+ }
19
+ /** Package-owned checker only; no caller-supplied program or document text is executed. */
20
+ export function readDocEvidence(root: string, timeoutMs = 10_000): Promise<DocEvidence | undefined> {
21
+ return new Promise(resolve => {
22
+ 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"] });
23
+ let output = "", invalid = false;
24
+ const stop = () => { invalid = true; void stopOwnedProcess(child, { group: true }).catch(() => {}); };
25
+ const timer = setTimeout(stop, Math.max(1, Math.min(10_000, timeoutMs)));
26
+ child.stdout?.setEncoding("utf8"); child.stdout?.on("data", chunk => { output += chunk; if (Buffer.byteLength(output) > 256 * 1024) { output = ""; stop(); } });
27
+ child.once("error", () => { invalid = true; clearTimeout(timer); resolve(undefined); });
28
+ child.once("close", (code, signal) => {
29
+ clearTimeout(timer); if (invalid || signal || (code !== 0 && code !== 1)) return resolve(undefined);
30
+ try {
31
+ const data = JSON.parse(output);
32
+ 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);
33
+ resolve(data);
34
+ } catch { resolve(undefined); }
35
+ });
36
+ });
37
+ }
38
+ /** Read-only checklist. Native acceptance remains an explicit human/operational gate. */
39
+ export async function readReleaseReadiness(root: string, repo: string, liveIssues: number[], readers: ReleaseReaders = {}): Promise<ReleaseReadiness> {
40
+ if (!REPO.test(repo) || repo.startsWith("-") || liveIssues.length > 32 || liveIssues.some(id => !Number.isSafeInteger(id) || id <= 0) || new Set(liveIssues).size !== liveIssues.length) throw new Error("release evidence requires owner/repository and bounded issue ids");
41
+ const report: ReleaseReadiness = { repo, commits: [], mergedPrs: [], liveIssues: [], blockers: [], sourceReady: false, native: "unverified", ready: false };
42
+ const deadline = performance.now() + 20_000;
43
+ const gh: SdlcGh = args => { const left = deadline - performance.now(); return left > 0 ? (readers.gh ?? sdlcGh)(args, Math.min(left,10_000)) : Promise.resolve({code:1,stdout:""}); };
44
+ const git: ReleaseGit = readers.git ?? (args => { const r = hubGitSync(args, { cwd: root, encoding: "utf8", timeout: Math.max(1, Math.min(1000, deadline - performance.now())), maxBuffer: 256 * 1024 }); return { code: r.status ?? 1, stdout: r.stdout ?? "" }; });
45
+ const first = git(["rev-parse","--verify","HEAD"]); const head = first.stdout.trim();
46
+ if (first.code !== 0 || !SHA.test(head)) { report.blockers.push("local HEAD unknown"); return report; } report.head = head;
47
+ try { const r = git(["show",`${head}:package.json`]), pkg = JSON.parse(r.stdout); if (r.code === 0 && typeof pkg.version === "string" && VERSION.test(pkg.version)) report.version = pkg.version; else report.blockers.push("package version unknown"); } catch { report.blockers.push("package version unknown"); }
48
+ const published = await gh(["release","list","--repo",repo,"--exclude-drafts","--order","desc","--limit","1","--json","tagName,isDraft,isPrerelease"]);
49
+ let tag: string | undefined;
50
+ try { const rows = JSON.parse(published.stdout), p = Array.isArray(rows) && rows.length === 1 ? rows[0] : undefined; if (published.code === 0 && p && typeof p.tagName === "string" && p.tagName.startsWith("v") && VERSION.test(p.tagName.slice(1)) && p.isDraft === false && typeof p.isPrerelease === "boolean") tag = p.tagName; } catch { /* unknown metadata */ }
51
+ if (!tag) report.blockers.push("published release identity unknown");
52
+ else {
53
+ report.tag = tag; const local = git(["rev-parse","--verify",`refs/tags/${tag}^{commit}`]);
54
+ const [owner,name] = repo.split("/");
55
+ const ref = await gh(["api","graphql","-f","query=query($owner:String!,$name:String!,$ref:String!){repository(owner:$owner,name:$name){ref(qualifiedName:$ref){target{... on Commit{oid} ... on Tag{target{... on Commit{oid}}}}}}}","-f",`owner=${owner}`,"-f",`name=${name}`,"-f",`ref=refs/tags/${tag}`]);
56
+ const commit = local.stdout.trim();
57
+ let remoteMatches = false; try { remoteMatches = tagMatches(JSON.parse(ref.stdout)?.data?.repository?.ref?.target, commit); } catch { /* unknown */ }
58
+ if (local.code !== 0 || ref.code !== 0 || !SHA.test(commit) || !remoteMatches) report.blockers.push("local and remote release tag identity differ or are unknown");
59
+ else if (git(["merge-base","--is-ancestor",commit,head]).code !== 0) report.blockers.push("published release is not a verified ancestor");
60
+ else {
61
+ report.tagCommit = commit;
62
+ const work = git(["rev-list","--first-parent","--max-count=1001",`${commit}..${head}`]); const commits = work.stdout.trim().split("\n").filter(Boolean);
63
+ if (work.code !== 0 || commits.length > 1000 || commits.some(c => !SHA.test(c))) report.blockers.push("merged work coverage unknown or truncated"); else report.commits = commits;
64
+ let advanced = false;
65
+ try { advanced = !!report.version && Bun.semver.order(report.version, tag.slice(1)) > 0; } catch { /* invalid version remains blocked */ }
66
+ if (!advanced) report.blockers.push("package version has not advanced past published release");
67
+ }
68
+ }
69
+ if (report.tagCommit) {
70
+ // Resolve only commits since the verified tag, never the repository's lifetime PR list.
71
+ const [owner, name] = repo.split("/");
72
+ for (let start = 0; start < report.commits.length; start += 100) {
73
+ const batch = report.commits.slice(start, start + 100);
74
+ const objects = batch.map((commit, i) => `c${i}:object(oid:"${commit}"){... on Commit{oid associatedPullRequests(first:10){nodes{number state mergeCommit{oid}} pageInfo{hasNextPage}}}}`).join(" ");
75
+ const pulls = await gh(["api", "graphql", "-f", `query=query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${objects}}}`, "-f", `owner=${owner}`, "-f", `name=${name}`]);
76
+ try {
77
+ const data = JSON.parse(pulls.stdout)?.data?.repository;
78
+ if (pulls.code !== 0 || !data) throw new Error("unavailable");
79
+ for (const [i, commit] of batch.entries()) {
80
+ const object = data[`c${i}`], connection = object?.associatedPullRequests, rows = connection?.nodes;
81
+ if (object?.oid !== commit || connection?.pageInfo?.hasNextPage !== false || !Array.isArray(rows) || rows.length > 10 || rows.some(p => !Number.isSafeInteger(p?.number) || p.number <= 0 || !["OPEN", "CLOSED", "MERGED"].includes(p.state) || (p.state === "MERGED" && !SHA.test(p.mergeCommit?.oid)))) throw new Error("incomplete");
82
+ for (const p of rows) if (p.state === "MERGED" && p.mergeCommit.oid === commit && !report.mergedPrs.some(r => r.pr === p.number)) report.mergedPrs.push({ pr: p.number, commit });
83
+ }
84
+ } catch { report.blockers.push("merged PR coverage unknown or truncated"); break; }
85
+ }
86
+ }
87
+ const left = deadline - performance.now(); report.documents = left > 0 ? await (readers.docs ?? readDocEvidence)(root,Math.min(10_000,left)) : undefined;
88
+ 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");
89
+ if (!liveIssues.length) report.blockers.push("native verification ledger unspecified");
90
+ for (const id of liveIssues) {
91
+ const reply = await gh(["issue","view",String(id),"--repo",repo,"--json","number,state"]); let state: "open"|"closed"|"unknown" = "unknown";
92
+ try { const p = JSON.parse(reply.stdout); if (reply.code === 0 && p.number === id && ["OPEN","CLOSED"].includes(p.state)) state = p.state.toLowerCase(); } catch { /* unknown */ }
93
+ report.liveIssues.push({id,state}); if (state !== "closed") report.blockers.push("native verification ledger open or unknown");
94
+ }
95
+ // The predecessor and tag can change while documents/issues are read. Recheck both in the last remote snapshot.
96
+ if (report.tag && report.tagCommit) {
97
+ const [owner, name] = repo.split("/");
98
+ const ledgers = liveIssues.map(id => `ledger_${id}:issue(number:${id}){number state}`).join(" ");
99
+ const query = `query($owner:String!,$name:String!,$ref:String!){repository(owner:$owner,name:$name){releases(first:100,orderBy:{field:CREATED_AT,direction:DESC}){nodes{tagName isDraft} pageInfo{hasNextPage}} ref(qualifiedName:$ref){target{... on Commit{oid} ... on Tag{target{... on Commit{oid}}}}} ${ledgers}}}`;
100
+ const snapshot = await gh(["api", "graphql", "-f", `query=${query}`, "-f", `owner=${owner}`, "-f", `name=${name}`, "-f", `ref=refs/tags/${report.tag}`]);
101
+ let stable = false, issuesStable = false;
102
+ let finalIssues: ReleaseReadiness["liveIssues"] = report.liveIssues.map(issue => ({ id: issue.id, state: "unknown" }));
103
+ try {
104
+ const data = JSON.parse(snapshot.stdout), repository = data.data?.repository, rows = repository?.releases?.nodes;
105
+ if (snapshot.code === 0 && !data.errors && Array.isArray(rows) && rows.length <= 100 && typeof repository.releases.pageInfo?.hasNextPage === "boolean" && rows.every(p => typeof p?.tagName === "string" && typeof p.isDraft === "boolean")) {
106
+ finalIssues = report.liveIssues.map(issue => {
107
+ const value = repository[`ledger_${issue.id}`];
108
+ const state: "open" | "closed" | "unknown" = value?.number === issue.id && ["OPEN", "CLOSED"].includes(value.state) ? value.state.toLowerCase() : "unknown";
109
+ return { id: issue.id, state };
110
+ });
111
+ issuesStable = finalIssues.every((issue, index) => issue.state === "closed" && issue.state === report.liveIssues[index]?.state);
112
+ const newest = rows.find(p => !p.isDraft), target = repository.ref?.target;
113
+ stable = newest?.tagName === report.tag && tagMatches(target, report.tagCommit);
114
+ }
115
+ } catch { /* unavailable predecessor remains blocked */ }
116
+ if (!stable) report.blockers.push("published release or remote tag changed or could not be reverified");
117
+ report.liveIssues = finalIssues;
118
+ if (!issuesStable) report.blockers.push("live issue states changed or could not be reverified");
119
+ }
120
+ const last = git(["rev-parse","--verify","HEAD"]); if (last.code !== 0 || last.stdout.trim() !== head) report.blockers.push("local HEAD changed during release readback");
121
+ report.blockers = [...new Set(report.blockers)]; report.sourceReady = report.blockers.length === 0;
122
+ // Closed issue metadata is not a native receipt or permission to publish.
123
+ report.blockers.push("native acceptance and person release decision unverified");
124
+ return report;
125
+ }