@staix/agent-hub 0.12.45 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +85 -116
  3. package/docs/agent-notes/README.md +6 -0
  4. package/docs/agent-notes/adapters.md +10 -7
  5. package/docs/agent-notes/budget.md +7 -1
  6. package/docs/agent-notes/bus.md +6 -4
  7. package/docs/agent-notes/daemon.md +14 -2
  8. package/docs/agent-notes/local-worker.md +2 -1
  9. package/docs/agent-notes/tasks.md +16 -3
  10. package/docs/agent-notes/tests.md +5 -3
  11. package/docs/dogfood.md +95 -0
  12. package/docs/events.md +14 -1
  13. package/docs/operations.md +290 -27
  14. package/docs/profiles.md +2 -2
  15. package/docs/quickstart.md +13 -3
  16. package/docs/sdlc.md +59 -8
  17. package/docs/security.md +26 -13
  18. package/docs/smoke.md +9 -4
  19. package/docs/specs/2026-09-19-agent-hub-design.md +797 -8
  20. package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
  21. package/docs/verification/2026-10-22-code-facts.md +78 -0
  22. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +61 -0
  23. package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
  24. package/docs/verification/2026-10-23-0.13.0.md +89 -0
  25. package/docs/verification/2026-10-23-0.14.0.md +69 -0
  26. package/docs/verification/dogfood/2026-10-22.md +3109 -0
  27. package/docs/verified.json +22 -17
  28. package/package.json +1 -1
  29. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  30. package/plugins/agent-hub/server.js +702 -347
  31. package/src/adapters/agy/entry.ts +5 -4
  32. package/src/adapters/claude-channel.ts +20 -9
  33. package/src/adapters/codex-appserver.ts +18 -2
  34. package/src/adapters/codex-review-isolation.ts +29 -11
  35. package/src/adapters/compat.ts +140 -0
  36. package/src/adapters/definitions/builtin-metadata.ts +3 -2
  37. package/src/adapters/definitions/native-cli-doctor.ts +12 -5
  38. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
  39. package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
  40. package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
  41. package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
  42. package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
  43. package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
  44. package/src/adapters/definitions/qwen-native.ts +7 -3
  45. package/src/adapters/definitions/qwen.ts +2 -1
  46. package/src/adapters/jcode.ts +72 -31
  47. package/src/adapters/kimi-review.ts +6 -3
  48. package/src/adapters/registry-types.ts +3 -1
  49. package/src/adapters/registry.ts +1 -0
  50. package/src/adapters/reviewer-print.ts +34 -7
  51. package/src/cli/agents.ts +40 -0
  52. package/src/cli/console-review.ts +54 -0
  53. package/src/cli/console-state.ts +124 -29
  54. package/src/cli/console-symbols.ts +74 -0
  55. package/src/cli/console-usage.ts +89 -0
  56. package/src/cli/console.ts +26 -1
  57. package/src/cli/help.ts +17 -6
  58. package/src/cli/identity.ts +29 -6
  59. package/src/cli/init.ts +109 -11
  60. package/src/cli/main.ts +127 -11
  61. package/src/cli/output.ts +91 -3
  62. package/src/cli/preview.ts +1 -1
  63. package/src/cli/sdlc-release-guard.ts +82 -0
  64. package/src/cli/status-lines.ts +1 -1
  65. package/src/hub/action-class.ts +362 -0
  66. package/src/hub/action-policy.ts +106 -0
  67. package/src/hub/board.ts +65 -9
  68. package/src/hub/budget.ts +71 -2
  69. package/src/hub/bus.ts +43 -3
  70. package/src/hub/checks.ts +80 -31
  71. package/src/hub/code-facts.ts +223 -0
  72. package/src/hub/config-apply.ts +10 -1
  73. package/src/hub/config-trust.ts +7 -4
  74. package/src/hub/daemon.ts +319 -34
  75. package/src/hub/dogfood-read.ts +199 -0
  76. package/src/hub/dogfood.ts +163 -0
  77. package/src/hub/envelope.ts +1 -1
  78. package/src/hub/events.ts +11 -2
  79. package/src/hub/hub-tools.ts +26 -3
  80. package/src/hub/inbox-polling.ts +25 -0
  81. package/src/hub/instances.ts +87 -5
  82. package/src/hub/manager.ts +2 -2
  83. package/src/hub/profiles.ts +7 -2
  84. package/src/hub/release-worktree.ts +56 -0
  85. package/src/hub/review-brief.ts +36 -0
  86. package/src/hub/sdlc-metrics.ts +260 -0
  87. package/src/hub/sdlc-queue.ts +31 -9
  88. package/src/hub/sdlc-release-authorization.ts +173 -0
  89. package/src/hub/sdlc.ts +54 -14
  90. package/src/hub/settings.ts +5 -2
  91. package/src/hub/snapshots.ts +93 -8
  92. package/src/hub/spec-read.ts +31 -0
  93. package/src/hub/symbol-normalizer.ts +26 -0
  94. package/src/hub/task-code-group.ts +52 -0
  95. package/src/hub/task-code-symbols.ts +90 -0
  96. package/src/hub/task-sweep.ts +3 -2
  97. package/src/hub/tasks.ts +455 -67
  98. package/src/hub/test-integrity.ts +162 -0
  99. package/src/hub/ui.ts +3 -0
  100. package/src/hub/usage-rollup.ts +419 -0
  101. package/src/local/sandbox.ts +5 -3
  102. package/src/ui/index.html +274 -2
  103. package/templates/AGENTS.block.md +1 -1
  104. package/templates/profiles/dev.json +6 -1
  105. package/templates/profiles/general.json +6 -1
  106. package/templates/profiles/research.json +6 -1
  107. package/templates/profiles/writing.json +6 -1
  108. package/templates/release-guard.block.md +6 -0
@@ -1,9 +1,10 @@
1
+ import { publicTestIntegrity, parseIssueLabels, readTestIntegrity, testIntegrityBlockers, type TestIntegrity } from "./test-integrity.ts";
1
2
  import { planConformance, planConformanceBlockers } from "./plan-conformance.ts";
2
3
  import { findingsForHead, findingHead, findingTally, type ReviewFindingSet, type ReviewFindingTally } from "./review-findings.ts";
3
4
  import type { CriteriaView } from "./workflow.ts";
4
5
  import { parsePrEvidence, readSdlcPr, sdlcGh, sdlcStatus, type PrEvidence, type SdlcConfig, type SdlcGh, type SdlcReceipt } from "./sdlc.ts";
5
6
  export interface DeliveryTask { plan?: { paths?: string[]; revision: number; fingerprint: string; accepted: { repo: string; pr: number; head: string; revision: number; fingerprint: string; paths: string[] }[] }; findings?: readonly ReviewFindingSet[]; id: number; after: number[]; private: boolean; receipt?: SdlcReceipt; phase?: CriteriaView; }
6
- export interface MergeQueueRow { planConformance?: { task?: number; state: string; outside: number; untouched: number; accepted: boolean }[]; findings?: ReviewFindingTally; pr: number; tasks: number[]; head?: string; private?: true; ready: boolean; blockers: string[]; after: number[]; }
7
+ export interface MergeQueueRow { testIntegrity?: (TestIntegrity & { task?: number })[]; planConformance?: { task?: number; state: string; outside: number; untouched: number; accepted: boolean }[]; findings?: ReviewFindingTally; pr: number; tasks: number[]; head?: string; private?: true; ready: boolean; blockers: string[]; after: number[]; }
7
8
  export interface MergeConflict { left: number; right: number; files: number; order: "dependency" | "person_required"; }
8
9
  export interface MergeQueue { repo: string; rows: MergeQueueRow[]; order: number[]; unordered?: number[]; conflicts: MergeConflict[]; complete: boolean; truncated: boolean; }
9
10
  const validRepo = (value: string) => /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value) && !value.startsWith("-");
@@ -12,13 +13,20 @@ const parseSnapshot = (reply: { code: number; stdout: string }): { number: numbe
12
13
  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); }
13
14
  catch { return; }
14
15
  };
15
- 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 });
16
+ 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, ...(p.issueLabels ? { issue: p.issueLabels.issue, bug: p.issueLabels.labels?.some(name => name.toLowerCase() === "bug") ?? "unknown" } : {}) });
16
17
  /** The last remote read gathers both open identities and mutable gates in one bounded request. */
17
- async function finalSnapshot(repo: string, evidence: Map<number, PrEvidence>, run: SdlcGh): Promise<{ gatesStable: boolean; prs?: ReturnType<typeof parseSnapshot> }> {
18
+ async function finalSnapshot(repo: string, evidence: Map<number, PrEvidence>, run: SdlcGh, issues: Map<number, TestIntegrity["applicability"]>): Promise<{ gatesStable: boolean; prs?: ReturnType<typeof parseSnapshot> }> {
19
+ // ponytail: one snapshot verifies at most 32 linked issues; paginate within the same deadline if queues grow.
20
+ if (issues.size > 32) return { gatesStable: false };
18
21
  const [owner, name] = repo.split("/");
19
22
  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}}}";
20
23
  const entries = [...evidence.entries()];
21
- 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(" ")}}}`;
24
+ const issueAlias = (issue: number) => {
25
+ const primary = entries.find(([, value]) => value.issueLabels?.issue === issue);
26
+ return primary ? `queue_i${primary[0]}` : `queue_issue${issue}`;
27
+ };
28
+ const extraIssues = [...issues.keys()].filter(issue => !entries.some(([, value]) => value.issueLabels?.issue === issue));
29
+ 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, before]) => `queue_p${id}:pullRequest(number:${id}){${fields}}${before.issueLabels ? ` queue_i${id}:issue(number:${before.issueLabels.issue}){number labels(first:100){nodes{name} pageInfo{hasNextPage}}}` : ""}`).join(" ")} ${extraIssues.map(issue => `${issueAlias(issue)}:issue(number:${issue}){number labels(first:100){nodes{name} pageInfo{hasNextPage}}}`).join(" ")}}}`;
22
30
  try {
23
31
  const reply = await run(["api", "graphql", "-f", `query=${query}`, "-f", `owner=${owner}`, "-f", `name=${name}`]);
24
32
  if (reply.code !== 0) return { gatesStable: false };
@@ -32,27 +40,34 @@ async function finalSnapshot(repo: string, evidence: Map<number, PrEvidence>, ru
32
40
  const threads = p?.reviewThreads, reviews = p?.reviewRequests, checks = p?.statusCheckRollup === null ? { nodes: [], pageInfo: { hasNextPage: false } } : p?.statusCheckRollup?.contexts;
33
41
  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 };
34
42
  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 });
43
+ if (before.issueLabels) after.issueLabels = { issue: before.issueLabels.issue, labels: parseIssueLabels(data.data.repository[`queue_i${id}`], before.issueLabels.issue) ?? null };
35
44
  if (gateIdentity(before) !== gateIdentity(after)) return { gatesStable: false, prs };
36
45
  }
46
+ for (const [issue, expected] of issues) {
47
+ const labels = parseIssueLabels(data.data.repository[issueAlias(issue)], issue);
48
+ const current = labels === undefined ? "unknown" : labels.some(name => name.toLowerCase() === "bug") ? "bug" : "not_bug";
49
+ if (current !== expected) return { gatesStable: false, prs };
50
+ }
37
51
  return { gatesStable: true, prs };
38
52
  } catch { return { gatesStable: false }; }
39
53
  }
40
54
  /** Fixed metadata-only reads; local claims and unknown dependency evidence never become merge approval. */
41
- export async function readMergeQueue(repo: string, config: SdlcConfig, tasks: DeliveryTask[], run: SdlcGh = sdlcGh): Promise<MergeQueue> {
55
+ export async function readMergeQueue(repo: string, config: SdlcConfig, tasks: DeliveryTask[], run: SdlcGh = sdlcGh, readableMetadata?: (path: string) => boolean): Promise<MergeQueue> {
42
56
  if (!validRepo(repo)) throw new Error("merge queue requires owner/repository");
43
57
  const result: MergeQueue = { repo, rows: [], order: [], conflicts: [], complete: false, truncated: false };
44
58
  const deadline = performance.now() + 20_000;
45
- const bounded: SdlcGh = args => { const remaining = deadline - performance.now(); return remaining > 0 ? run(args, Math.min(10_000, remaining)) : Promise.resolve({ code: 1, stdout: "" }); };
59
+ const bounded: SdlcGh = (args, timeout, cwd, cap) => { const remaining = deadline - performance.now(); return remaining > 0 ? run(args, Math.min(timeout ?? 10_000, remaining), cwd, cap) : Promise.resolve({ code: 1, stdout: "" }); };
46
60
  const listArgs = ["pr", "list", "--repo", repo, "--state", "open", "--limit", "17", "--json", "number,headRefOid,baseRefName,baseRefOid"];
47
61
  const initialPrs = parseSnapshot(await bounded(listArgs)); if (!initialPrs) return result;
48
62
  result.truncated = initialPrs.length > 16; const ids = initialPrs.slice(0, 16).map(pr => pr.number);
49
63
  const sameRepo = (value: unknown) => typeof value === "string" && value.toLowerCase() === repo.toLowerCase();
50
64
  const evidence = new Map<number, PrEvidence>(), links = new Map<number, DeliveryTask[]>();
65
+ const issueApplicability = new Map<number, TestIntegrity["applicability"]>();
51
66
  for (const pr of ids) {
52
67
  const holders = tasks.filter(t => sameRepo(t.receipt?.repo) && t.receipt?.pr === pr); links.set(pr, holders);
53
68
  const row: MergeQueueRow = { pr, tasks: holders.some(t => t.private) ? [] : holders.map(t => t.id), ready: false, blockers: [], after: [] }; result.rows.push(row);
54
69
  if (holders.some(t => t.private)) { row.private = true; if (config.mode !== "off") row.planConformance = [{ state: "unknown", outside: 0, untouched: 0, accepted: false }]; row.blockers.push("private lifecycle evidence unavailable"); continue; }
55
- const read = await readSdlcPr(holders[0]?.receipt ?? { stage: config.stages[0] ?? "spec", repo, pr }, bounded);
70
+ const read = await readSdlcPr(holders[0]?.receipt ?? { stage: config.stages[0] ?? "spec", repo, pr }, bounded, config.mode !== "off");
56
71
  if (!read || read.state !== "OPEN") { row.blockers.push("current PR evidence unknown or no longer open"); continue; }
57
72
  evidence.set(pr, read); row.head = read.head;
58
73
  for (const holder of holders) {
@@ -70,8 +85,15 @@ export async function readMergeQueue(repo: string, config: SdlcConfig, tasks: De
70
85
  for (const holder of linked) {
71
86
  const conformance = holder?.plan ? { ...planConformance(holder.plan.paths, read), accepted: holder.plan.accepted.some(item => item.repo === holder.receipt?.repo && item.pr === holder.receipt?.pr && item.head === read.head && item.revision === holder.plan!.revision && item.fingerprint === holder.plan!.fingerprint && JSON.stringify([...item.paths].sort()) === JSON.stringify([...read.paths].sort())) } : undefined;
72
87
  if (holder && conformance && config.mode !== "off") (row.planConformance ??= []).push({ task: holder.id, state: conformance.state, outside: conformance.outside.length, untouched: conformance.untouched.length, accepted: conformance.accepted });
88
+ const rawIntegrity = config.mode !== "off" && holder?.receipt ? await readTestIntegrity(holder.receipt, read, bounded) : undefined;
89
+ if (rawIntegrity && holder?.receipt?.issue) {
90
+ const issue = holder.receipt.issue, before = issueApplicability.get(issue);
91
+ issueApplicability.set(issue, before !== undefined && before !== rawIntegrity.applicability ? "unknown" : rawIntegrity.applicability);
92
+ }
93
+ const integrity = rawIntegrity ? publicTestIntegrity(rawIntegrity, readableMetadata) : undefined;
94
+ if (integrity) (row.testIntegrity ??= []).push({ task: holder!.id, ...integrity });
73
95
  row.blockers.push(...sdlcStatus(config, holder?.receipt, read, holder?.phase).blockers);
74
- if (config.mode === "enforce") row.blockers.push(...planConformanceBlockers(conformance));
96
+ if (config.mode === "enforce") row.blockers.push(...planConformanceBlockers(conformance), ...testIntegrityBlockers(integrity));
75
97
  }
76
98
  }
77
99
  const dependencyEvidence = new Map<number, PrEvidence | undefined>(); let dependencyReads = 0;
@@ -115,7 +137,7 @@ export async function readMergeQueue(repo: string, config: SdlcConfig, tasks: De
115
137
  result.complete = !result.truncated && !remaining.size && !touchingCoverageUnknown && !personOrder.size;
116
138
  // Empty/private-only snapshots have no intervening remote reads and offer no private PR evidence.
117
139
  if (evidence.size) {
118
- const finalRead = await finalSnapshot(repo, evidence, bounded);
140
+ const finalRead = await finalSnapshot(repo, evidence, bounded, issueApplicability);
119
141
  const gatesStable = finalRead.gatesStable;
120
142
  const finalPrs = finalRead.prs;
121
143
  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))) {
@@ -0,0 +1,173 @@
1
+ import { releaseTrackedClean } from "./release-worktree.ts";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { USER } from "./envelope.ts";
4
+ import { hubGitEnv, hubGitSync } from "./git.ts";
5
+ import { readDocEvidence, type DocEvidence, type ReleaseGit, type ReleaseReadiness } from "./sdlc-release.ts";
6
+ import type { SdlcGh } from "./sdlc.ts";
7
+ import type { ReleaseGuardRequest, ReleasePushUpdate } from "../cli/sdlc-release-guard.ts";
8
+
9
+ class ReleaseAuthorizationError extends Error {}
10
+ export const releaseAuthorizationError = (error: unknown): string => error instanceof ReleaseAuthorizationError ? error.message : "release authorization evidence unavailable";
11
+
12
+ const SHA = /^[0-9a-f]{40}$/;
13
+ const VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
14
+ export const validReleaseVersion = (value: unknown): value is string => typeof value === "string" && value.length <= 128 && VERSION.test(value);
15
+ export interface ReleaseDocsVerdict { fresh: true; errors: 0; documents: number; digest: string }
16
+ export interface ReleaseAuthorization { schemaVersion: 1; id: string; by: typeof USER; commit: string; version: string; at: number; docs: ReleaseDocsVerdict }
17
+ export interface ReleaseAuthorizationPorts {
18
+ git?: ReleaseGit;
19
+ docs?: (root: string) => Promise<DocEvidence | undefined>;
20
+ gh?: SdlcGh;
21
+ now?: () => number;
22
+ current?: () => boolean;
23
+ record: (receipt: ReleaseAuthorization) => boolean;
24
+ }
25
+ export type ReleaseGuardVerdict = { ok: true; receipts: ReleaseAuthorization[] } | { ok: false; error: string };
26
+ interface Origin { hash: string; urls: string[]; repo?: string }
27
+ interface Entry { receipt: ReleaseAuthorization; origin: Origin }
28
+ const digest = (value: unknown) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
29
+ function docsVerdict(value: DocEvidence | undefined, commit: string, version: string): ReleaseDocsVerdict | undefined {
30
+ if (!value || value.schemaVersion !== 1 || value.head !== commit || value.version !== version || value.fresh !== true || value.errors !== 0
31
+ || !Array.isArray(value.documents) || value.documents.length > 128 || value.documents.some(row => row.state !== "fresh" || row.dirty === true)) return;
32
+ // Paths may be withheld in a public release report; the commit binds their identities, not this projection.
33
+ const rows = value.documents.map(({ state, verifiedAgainst, commits, dirty }) => ({ state, verifiedAgainst, commits, dirty })).map(row => JSON.stringify(row)).sort();
34
+ return { fresh: true, errors: 0, documents: rows.length, digest: digest({ commit, version, rows }) };
35
+ }
36
+ function githubRepo(url: string): string | undefined {
37
+ const match = url.match(/^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?\/?$/);
38
+ return match?.[1];
39
+ }
40
+
41
+ /** Host-owned consent, never imported from a task receipt or an agent-writable file. Restart revokes it. */
42
+ export class ReleaseAuthorizations {
43
+ private readonly entries = new Map<string, Entry>();
44
+ private latest?: Entry;
45
+ constructor(private readonly root: string, private readonly ports: ReleaseAuthorizationPorts) {}
46
+ private current(): void { if (this.ports.current?.() === false) throw new ReleaseAuthorizationError("release authorization unavailable during lifecycle transition"); }
47
+ private git(): ReleaseGit {
48
+ let remaining = 5000;
49
+ return args => {
50
+ if (remaining <= 0) return { code: 1, stdout: "" };
51
+ const start = performance.now();
52
+ try {
53
+ if (this.ports.git) return this.ports.git(args);
54
+ const result = hubGitSync(args, { cwd: this.root, env: hubGitEnv(process.env, { repositoryIsolation: true }), encoding: "utf8", timeout: Math.max(1, Math.min(1000, remaining)), maxBuffer: 256 * 1024 });
55
+ return { code: result.status ?? 1, stdout: result.stdout ?? "" };
56
+ } finally { remaining -= performance.now() - start; }
57
+ };
58
+ }
59
+ private read(git: ReleaseGit, args: string[]): string {
60
+ const result = git(args);
61
+ if (result.code !== 0 || Buffer.byteLength(result.stdout) > 256 * 1024) throw new ReleaseAuthorizationError("release target evidence unavailable");
62
+ return result.stdout.trim();
63
+ }
64
+ private origin(git: ReleaseGit, remote = "origin"): Origin {
65
+ if (!/^[A-Za-z0-9_.-]{1,128}$/.test(remote) || remote.startsWith("-")) throw new ReleaseAuthorizationError("release guard requires a configured named remote");
66
+ const urls = this.read(git, ["remote", "get-url", "--push", "--all", remote]).split("\n");
67
+ if (!urls.length || urls.length > 8 || urls.some(url => !url || url.length > 4096 || /[\p{Cc}]/u.test(url))) throw new ReleaseAuthorizationError("release remote identity unavailable");
68
+ const sorted = [...new Set(urls)].sort();
69
+ const fetch = this.read(git, ["remote", "get-url", "--all", remote]).split("\n");
70
+ if (!fetch.length || fetch.length > 8 || fetch.some(url => !url || url.length > 4096 || /[\p{Cc}]/u.test(url))) throw new ReleaseAuthorizationError("release remote identity unavailable");
71
+ const repos = [...sorted, ...fetch].map(githubRepo);
72
+ return { hash: digest({ push: sorted, fetch: [...new Set(fetch)].sort() }), urls: sorted, ...(repos[0] && repos.every(repo => repo === repos[0]) ? { repo: repos[0] } : {}) };
73
+ }
74
+ private commit(git: ReleaseGit, ref: string): string {
75
+ if (!ref || ref.length > 256 || ref.startsWith("-") || /[\s\p{Cc}]/u.test(ref)) throw new ReleaseAuthorizationError("invalid release source ref");
76
+ const commit = this.read(git, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`]);
77
+ if (!SHA.test(commit)) throw new ReleaseAuthorizationError("release commit unavailable");
78
+ return commit;
79
+ }
80
+ private version(git: ReleaseGit, commit: string): string {
81
+ if (!SHA.test(commit)) throw new ReleaseAuthorizationError("invalid release commit");
82
+ let value: unknown;
83
+ try { value = JSON.parse(this.read(git, ["show", `${commit}:package.json`])).version; } catch { throw new ReleaseAuthorizationError("committed package version unavailable"); }
84
+ if (!validReleaseVersion(value)) throw new ReleaseAuthorizationError("committed package version unavailable");
85
+ return value;
86
+ }
87
+ async authorize(by: string, version: unknown, expectedCommit: unknown): Promise<ReleaseAuthorization> {
88
+ if (by !== USER) throw new ReleaseAuthorizationError("only the person may authorize a release");
89
+ if (!validReleaseVersion(version) || typeof expectedCommit !== "string" || !SHA.test(expectedCommit)) throw new ReleaseAuthorizationError("authorization needs an exact version and candidate commit");
90
+ this.current();
91
+ const git = this.git(), commit = this.commit(git, "HEAD"), origin = this.origin(git);
92
+ if (commit !== expectedCommit || this.version(git, commit) !== version) throw new ReleaseAuthorizationError("candidate commit or version differs from the person's request");
93
+ const docs = docsVerdict(await (this.ports.docs ?? readDocEvidence)(this.root), commit, version);
94
+ if (!docs) throw new ReleaseAuthorizationError("release authorization requires fresh committed documentation evidence");
95
+ this.current();
96
+ if (this.commit(git, "HEAD") !== commit || this.version(git, commit) !== version || this.origin(git).hash !== origin.hash) throw new ReleaseAuthorizationError("release candidate changed during authorization");
97
+ const receipt: ReleaseAuthorization = { schemaVersion: 1, id: randomUUID(), by: USER, commit, version, at: (this.ports.now ?? Date.now)(), docs };
98
+ if (!this.ports.record(structuredClone(receipt))) throw new ReleaseAuthorizationError("release approval audit could not be recorded");
99
+ const entry = { receipt, origin };
100
+ this.entries.set(`${version}:${commit}`, entry); this.latest = entry;
101
+ // ponytail: retain at most 64 approvals in this daemon generation; older candidates need a new person approval.
102
+ if (this.entries.size > 64) this.entries.delete(this.entries.keys().next().value!);
103
+ return structuredClone(receipt);
104
+ }
105
+ private matched(git: ReleaseGit, version: string, commit: string, remote?: string, url?: string): ReleaseAuthorization {
106
+ this.current();
107
+ const entry = this.entries.get(`${version}:${commit}`);
108
+ if (!entry || this.version(git, commit) !== version) throw new ReleaseAuthorizationError("no matching person release authorization; ask the person to authorize this commit and version");
109
+ const origin = this.origin(git);
110
+ if (origin.hash !== entry.origin.hash || remote && this.origin(git, remote).hash !== origin.hash || url !== undefined && !origin.urls.includes(url)) throw new ReleaseAuthorizationError("release remote differs from the authorized project");
111
+ return structuredClone(entry.receipt);
112
+ }
113
+ async guardRequest(request: ReleaseGuardRequest): Promise<ReleaseGuardVerdict> {
114
+ try {
115
+ this.current(); const git = this.git();
116
+ if (request.kind === "tag-push") {
117
+ const commit = this.commit(git, request.sourceRef);
118
+ return { ok: true, receipts: [this.matched(git, request.version, commit, request.remote)] };
119
+ }
120
+ if (request.kind === "npm-publish") {
121
+ if (!releaseTrackedClean(this.root, git)) throw new ReleaseAuthorizationError("npm publish requires a clean tracked worktree");
122
+ const commit = this.commit(git, "HEAD"), version = this.version(git, commit);
123
+ const receipt = this.matched(git, version, commit);
124
+ const docs = docsVerdict(await (this.ports.docs ?? readDocEvidence)(this.root), commit, version);
125
+ this.current();
126
+ if (!docs || docs.digest !== receipt.docs.digest || this.commit(git, "HEAD") !== commit || !releaseTrackedClean(this.root, git)) throw new ReleaseAuthorizationError("publish checkout or documentation evidence changed");
127
+ return { ok: true, receipts: [this.matched(git, version, commit)] };
128
+ }
129
+ const origin = this.origin(git), repo = origin.repo;
130
+ if (!repo || !this.ports.gh) throw new ReleaseAuthorizationError("GitHub release target evidence unavailable");
131
+ const [owner, name] = repo.split("/");
132
+ const 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}}}}}}}";
133
+ const result = await this.ports.gh(["api", "graphql", "--hostname", "github.com", "-f", `query=${query}`, "-f", `owner=${owner}`, "-f", `name=${name}`, "-f", `ref=refs/tags/${request.tag}`], 10000);
134
+ let commit: string;
135
+ try {
136
+ const body = JSON.parse(result.stdout), repository = body?.data?.repository;
137
+ if (result.code !== 0 || body.errors || !repository || !Object.hasOwn(repository, "ref")) throw new ReleaseAuthorizationError();
138
+ if (repository.ref === null) {
139
+ if (!request.target || !SHA.test(request.target)) throw new ReleaseAuthorizationError();
140
+ commit = this.commit(git, request.target);
141
+ } else {
142
+ commit = repository.ref.target?.oid ?? repository.ref.target?.target?.oid;
143
+ if (!SHA.test(commit)) throw new ReleaseAuthorizationError();
144
+ }
145
+ } catch { throw new ReleaseAuthorizationError("GitHub tag unknown, or a new release lacks an explicit full commit target"); }
146
+ this.current();
147
+ if (this.origin(git).hash !== origin.hash) throw new ReleaseAuthorizationError("release remote changed during readback");
148
+ return { ok: true, receipts: [this.matched(git, request.version, commit)] };
149
+ } catch (error) { return { ok: false, error: releaseAuthorizationError(error) }; }
150
+ }
151
+ guardPush(remoteName: string, remoteUrl: string, updates: readonly ReleasePushUpdate[]): ReleaseGuardVerdict {
152
+ try {
153
+ this.current(); const git = this.git(), receipts: ReleaseAuthorization[] = [];
154
+ if (updates.length > 128) throw new ReleaseAuthorizationError("release push inventory exceeds the guard bound");
155
+ for (const update of updates) {
156
+ if (!validReleaseVersion(update.version) || update.remoteRef !== `refs/tags/v${update.version}` || !SHA.test(update.localOid) || /^0+$/.test(update.localOid)) throw new ReleaseAuthorizationError("release push target is invalid or deleted");
157
+ if (this.read(git, ["rev-parse", "--verify", "--end-of-options", update.localRef]) !== update.localOid) throw new ReleaseAuthorizationError("release tag changed after Git captured the push");
158
+ const commit = this.commit(git, update.localOid);
159
+ receipts.push(this.matched(git, update.version, commit, remoteName, remoteUrl));
160
+ }
161
+ return { ok: true, receipts };
162
+ } catch (error) { return { ok: false, error: releaseAuthorizationError(error) }; }
163
+ }
164
+ report<T extends ReleaseReadiness>(report: T): T & { authorization?: { state: "matched" | "stale"; receipt: ReleaseAuthorization } } {
165
+ if (!this.latest) return report;
166
+ const entry = this.entries.get(`${report.version}:${report.head}`), verdict = report.head && report.version ? docsVerdict(report.documents, report.head, report.version) : undefined;
167
+ let matched = !!entry && !!entry.origin.repo && entry.origin.repo.toLowerCase() === report.repo.toLowerCase() && !!verdict && verdict.digest === entry.receipt.docs.digest;
168
+ try { matched &&= this.origin(this.git()).hash === entry!.origin.hash; } catch { matched = false; }
169
+ const receipt = structuredClone((matched ? entry! : this.latest).receipt);
170
+ const blockers = matched ? report.blockers.map(item => item === "native acceptance and person release decision unverified" ? "native acceptance unverified" : item) : report.blockers;
171
+ return { ...report, blockers, authorization: { state: matched ? "matched" : "stale", receipt } };
172
+ }
173
+ }
package/src/hub/sdlc.ts CHANGED
@@ -1,6 +1,8 @@
1
+ import { parseIssueLabels, parseRedFirst, testIntegrityBlockers, type RedFirstReceipt, type TestIntegrity } from "./test-integrity.ts";
1
2
  import { planConformanceBlockers, type PlanConformance } from "./plan-conformance.ts";
2
3
  import { DEFAULT_MAX_NITS, findingsForHead, findingHead, findingTally, type ReviewFindingSet, type ReviewFindingTally } from "./review-findings.ts";
3
4
  import type { CostPlan } from "./cost-governance.ts";
5
+ import { bandsConfig, type BandsConfig } from "./sdlc-metrics.ts";
4
6
  import { criteriaReceipt, type CriteriaReceipt, type CriteriaView, type LegacyPhaseReceipt } from "./workflow.ts";
5
7
  import { constants, openSync, fstatSync, readSync, closeSync } from "node:fs";
6
8
  import { join } from "node:path";
@@ -31,7 +33,7 @@ export type StageGate = "issue" | "pr" | "local" | "review" | "checks" | "criter
31
33
  export interface StageDefinition { base: SdlcBaseStage; actor: string; entry_gates: StageGate[]; exit_gates: StageGate[]; after: string[]; }
32
34
  export type SdlcMode = "off" | "advise" | "enforce";
33
35
  /** `workflow: "gsd-lite"` marks the retired setting during its window (#481 S8): every task then needs a criteria checklist. */
34
- export interface SdlcConfig { workflow?: "gsd-lite"; maxNits?: number; mode: SdlcMode; requiredChecks: string[]; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; deprecations?: string[]; }
36
+ export interface SdlcConfig { workflow?: "gsd-lite"; maxNits?: number; /** #481 S7: optional control bands per metric; absent means none. */ bands?: BandsConfig; mode: SdlcMode; requiredChecks: string[]; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; deprecations?: string[]; }
35
37
  export interface PlanReviewReceipt {
36
38
  planner: string;
37
39
  planningRevision: number;
@@ -55,6 +57,7 @@ export interface SdlcReceipt {
55
57
  localHead?: string;
56
58
  localResult?: "passed" | "failed" | "unknown";
57
59
  observed?: number;
60
+ redFirst?: RedFirstReceipt;
58
61
  }
59
62
  export interface PrEvidence {
60
63
  head: string;
@@ -64,6 +67,8 @@ export interface PrEvidence {
64
67
  unresolved: number | null;
65
68
  pendingReviews: number | null;
66
69
  paths: string[];
70
+ fileChanges?: { path: string; change: string }[];
71
+ issueLabels?: { issue: number; labels: string[] | null };
67
72
  pathsComplete?: boolean;
68
73
  /** All affected path identities are represented; rename/copy destinations alone cannot certify S2. */
69
74
  typesComplete?: boolean;
@@ -129,10 +134,11 @@ export function sdlcConfig(value: unknown, version?: string): SdlcConfig {
129
134
  if (value === undefined) return { mode: "off", requiredChecks: [], stages: [...SDLC_STAGES] };
130
135
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdlc: expected a table");
131
136
  const raw = value as Record<string, unknown>;
132
- for (const key of Object.keys(raw)) if (!["mode", "required_checks", "stages", "workflow", "definitions", "max_nits"].includes(key)) throw new Error(`sdlc.${key}: unknown field`);
137
+ for (const key of Object.keys(raw)) if (!["mode", "required_checks", "stages", "workflow", "definitions", "max_nits", "bands"].includes(key)) throw new Error(`sdlc.${key}: unknown field`);
133
138
  const maxNits = raw.max_nits === undefined ? DEFAULT_MAX_NITS : raw.max_nits;
134
139
  if (typeof maxNits !== "number" || !Number.isSafeInteger(maxNits) || maxNits < 0 || maxNits > 64) throw new Error("sdlc.max_nits: expected an integer from 0 to 64");
135
- const findingConfig = raw.max_nits === undefined ? {} : { maxNits };
140
+ const bands = bandsConfig(raw.bands), bandConfig = Object.keys(bands).length ? { bands } : {};
141
+ const findingConfig = { ...(raw.max_nits === undefined ? {} : { maxNits }), ...bandConfig };
136
142
  const mode = raw.mode ?? "off";
137
143
  if (mode !== "off" && mode !== "advise" && mode !== "enforce") throw new Error("sdlc.mode: expected off, advise or enforce");
138
144
  // ponytail: the #481 S8 window is a fixed version; delete `workflow`, the phase aliases and their tests once it passes.
@@ -178,7 +184,7 @@ export function loadSdlc(cwd: string, version?: string): SdlcConfig {
178
184
  export function sdlcReceipt(value: unknown, previous: SdlcReceipt | undefined = undefined, config: SdlcConfig = sdlcConfig(undefined), version?: string): SdlcReceipt {
179
185
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdlc receipt: expected an object");
180
186
  const raw = value as Record<string, unknown>;
181
- for (const key of Object.keys(raw)) if (!["stage", "repo", "issue", "pr", "reviewedHead", "localHead", "localResult", "criteria", "phase"].includes(key)) throw new Error(`sdlc receipt.${key}: unsupported or externally observed field`);
187
+ for (const key of Object.keys(raw)) if (!["stage", "repo", "issue", "pr", "reviewedHead", "localHead", "localResult", "criteria", "phase", ...(config.mode !== "off" ? ["redFirst"] : [])].includes(key)) throw new Error(`sdlc receipt.${key}: unsupported or externally observed field`);
182
188
  if (raw.criteria !== undefined && raw.phase !== undefined) throw new Error("sdlc receipt: give criteria only; phase is its retired name");
183
189
  if (raw.phase !== undefined && version !== undefined && !olderThan(version, RETIRED_WORKFLOW_FROM)) throw new Error(`sdlc receipt.phase: retired in ${RETIRED_WORKFLOW_FROM} (#481); use criteria`);
184
190
  previous ??= { stage: config.stages[0] ?? "spec" };
@@ -199,6 +205,9 @@ export function sdlcReceipt(value: unknown, previous: SdlcReceipt | undefined =
199
205
  if (!["passed", "failed", "unknown"].includes(String(raw.localResult))) throw new Error("sdlc receipt.localResult: expected passed, failed or unknown");
200
206
  next.localResult = raw.localResult as SdlcReceipt["localResult"];
201
207
  }
208
+ if (raw.redFirst !== undefined) next.redFirst = parseRedFirst(raw.redFirst);
209
+ // A test receipt never carries across a different linked issue or artifact.
210
+ if (raw.redFirst === undefined && (next.repo !== previous.repo || next.issue !== previous.issue || next.pr !== previous.pr)) delete next.redFirst;
202
211
  // An acceptance names one spec issue; relinking the receipt to another drops it (#481 S1).
203
212
  if (next.repo !== previous.repo || next.issue !== previous.issue) delete next.acceptance;
204
213
  // A new artifact cannot inherit another PR's review or local gate.
@@ -225,7 +234,7 @@ export function parsePrEvidence(value: unknown): PrEvidence {
225
234
  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; });
226
235
  const completeTypes = p.files.every((file: Record<string, unknown>) => typeof file.changeType === "string" && ["ADDED", "DELETED", "MODIFIED", "CHANGED"].includes(file.changeType));
227
236
  // Rename/copy metadata exposes only its destination, never a complete inventory of affected identities.
228
- return { head: p.headRefOid, state: p.state, checks, paths, pathsComplete: Number.isSafeInteger(p.changedFiles) && p.changedFiles >= 0 && p.changedFiles === paths.length, typesComplete: completeTypes, ...(typeof p.isDraft === "boolean" ? { draft: p.isDraft } : {}), ...(typeof p.baseRefName === "string" && p.baseRefName && p.baseRefName.length <= 200 && !/[\x00-\x1f\x7f]/.test(p.baseRefName) ? { base: p.baseRefName } : {}), ...(sha(p.baseRefOid) ? { baseHead: p.baseRefOid } : {}),
237
+ return { head: p.headRefOid, state: p.state, checks, paths, fileChanges: p.files.map((file: Record<string, unknown>) => ({ path: file.path as string, change: typeof file.changeType === "string" ? file.changeType : "UNKNOWN" })), pathsComplete: Number.isSafeInteger(p.changedFiles) && p.changedFiles >= 0 && p.changedFiles === paths.length, typesComplete: completeTypes, ...(typeof p.isDraft === "boolean" ? { draft: p.isDraft } : {}), ...(typeof p.baseRefName === "string" && p.baseRefName && p.baseRefName.length <= 200 && !/[\x00-\x1f\x7f]/.test(p.baseRefName) ? { base: p.baseRefName } : {}), ...(sha(p.baseRefOid) ? { baseHead: p.baseRefOid } : {}),
229
238
  review: p.reviewDecision === "APPROVED" ? "approved" : p.reviewDecision === "CHANGES_REQUESTED" ? "changes_requested" : "unknown",
230
239
  pendingReviews: Array.isArray(p.reviewRequests) && p.reviewRequests.length <= 100 && p.reviewRequests.every((r: unknown) => !!r && typeof r === "object") ? p.reviewRequests.length : null,
231
240
  unresolved: Number.isSafeInteger(p.unresolvedThreads) && p.unresolvedThreads >= 0 ? p.unresolvedThreads : null,
@@ -248,16 +257,16 @@ export const SPEC_STATE_TEXT: Record<Exclude<SpecState, "current">, string> = {
248
257
  changed: "spec changed after acceptance: the person re-accepts with ahub sdlc accept <task> --issue <n>",
249
258
  unknown: "spec edit time unknown: the issue could not be read",
250
259
  };
251
- export interface SdlcStatus { planConformance?: PlanConformance & { accepted?: boolean; earlierPushes?: "unknown"; revisionsAfterFirstObservedPush?: number }; findings?: ReviewFindingTally; 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; }
260
+ export interface SdlcStatus { testIntegrity?: TestIntegrity; planConformance?: PlanConformance & { accepted?: boolean; earlierPushes?: "unknown"; revisionsAfterFirstObservedPush?: number }; findings?: ReviewFindingTally; 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; }
252
261
  /** 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. */
253
262
  export const criteriaRequired = (config: SdlcConfig, receipt: SdlcReceipt | undefined): boolean => config.workflow === "gsd-lite" || (config.mode !== "off" && !!checklistOf(receipt));
254
- export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined, evidence?: PrEvidence, criteria?: CriteriaView, specEdit?: SpecEdit, sets: readonly ReviewFindingSet[] = [], commit?: string, plan?: SdlcStatus["planConformance"]): SdlcStatus {
263
+ export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined, evidence?: PrEvidence, criteria?: CriteriaView, specEdit?: SpecEdit, sets: readonly ReviewFindingSet[] = [], commit?: string, plan?: SdlcStatus["planConformance"], integrity?: TestIntegrity): SdlcStatus {
255
264
  const stage = receipt?.stage ?? config.stages[0] ?? "spec", blockers: string[] = [];
256
265
  const definition = stageDefinition(config, stage);
257
266
  const items = findingsForHead(sets, findingHead(receipt, evidence, commit));
258
267
  const findings = items.length ? findingTally(items) : undefined;
259
268
  if (config.mode === "enforce" && findings?.openImportant) blockers.push("open important review findings: " + findings.openImportant);
260
- if (config.mode === "enforce") blockers.push(...planConformanceBlockers(plan));
269
+ if (config.mode === "enforce") blockers.push(...planConformanceBlockers(plan), ...testIntegrityBlockers(integrity));
261
270
  if (!definition) blockers.push("lifecycle stage definition unknown");
262
271
  if (!receipt?.repo || !receipt.issue) blockers.push("issue identity missing");
263
272
  if (!receipt?.pr) blockers.push("PR identity missing");
@@ -283,9 +292,10 @@ export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined,
283
292
  const shown = criteria && (criteriaRequired(config, receipt) || definition && [...definition.entry_gates, ...definition.exit_gates].includes("criteria"));
284
293
  // Shown, not counted as a readiness blocker: the spec gate is build entry (#481 S1).
285
294
  const spec = config.mode !== "off" && receipt?.issue ? { spec: { issue: receipt.issue, state: specState(receipt, specEdit) } } : {};
286
- return { ...spec, ...(config.mode !== "off" && plan ? { planConformance: plan } : {}), ...(findings ? { findings } : {}), ...(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,
295
+ return { ...spec, ...(config.mode !== "off" && integrity ? { testIntegrity: integrity } : {}), ...(config.mode !== "off" && plan ? { planConformance: plan } : {}), ...(findings ? { findings } : {}), ...(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,
287
296
  actor: !definition ? "person" : shown && criteria.blockers.length ? "reviewer/person" : config.definitions ? definition.actor : baseActor(definition.base),
288
- next: blockers[0] ?? "person may inspect merge readiness; no automatic merge" };
297
+ // #694: the identity blocker keeps its exact text for gate matching; the next step says how to supply it.
298
+ next: blockers[0] === "issue identity missing" ? "issue identity missing: name one issue (title #N, refs or hub_sdlc_record)" : blockers[0] ?? "person may inspect merge readiness; no automatic merge" };
289
299
  }
290
300
 
291
301
  function stageGates(def: StageDefinition, blockers: string[], checklist: string[]): { entry: string[]; exit: string[] } {
@@ -337,23 +347,53 @@ export async function readSpecEdit(repoName: string, issue: number, run: SdlcGh
337
347
  } catch { return undefined; }
338
348
  }
339
349
 
340
- export async function readSdlcPr(receipt: SdlcReceipt, run: SdlcGh = sdlcGh): Promise<PrEvidence | undefined> {
350
+ /**
351
+ * #694: the one open PR whose head is `commit`, or, with no commit, whose branch is `branch`. Zero or several matches, a
352
+ * failed read or malformed output give undefined: the hub links only what it verified, never text an agent wrote.
353
+ */
354
+ export async function openPrFor(repository: string, head: { commit?: string; branch?: string }, run: SdlcGh = sdlcGh): Promise<number | undefined> {
355
+ if (!repo(repository) || head.commit !== undefined && !sha(head.commit) || !head.commit && !head.branch) return undefined;
356
+ try {
357
+ const listed = await run(["pr", "list", "--repo", repository, "--state", "open", "--limit", "100", "--json", "number,headRefOid,headRefName"], 10_000, undefined, 256 * 1024);
358
+ if (listed.code !== 0) return undefined;
359
+ const rows: unknown = JSON.parse(listed.stdout);
360
+ // A full page may be truncated: uniqueness is unknown, so nothing is linked. Every row must be well formed.
361
+ if (!Array.isArray(rows) || rows.length >= 100) return undefined;
362
+ if (!rows.every((row: { number?: unknown; headRefOid?: unknown; headRefName?: unknown }) => id(row?.number) && sha(row.headRefOid) && typeof row.headRefName === "string")) return undefined;
363
+ const hits = rows.filter((row: { headRefOid: string; headRefName: string }) => head.commit ? row.headRefOid === head.commit : row.headRefName === head.branch);
364
+ return hits.length === 1 ? (hits[0] as { number: number }).number : undefined;
365
+ } catch { return undefined; }
366
+ }
367
+ /** #694: the first link of an issue or PR is not a relink: it keeps everything the receipt already holds. */
368
+ export function linkedReceipt(previous: SdlcReceipt | undefined, link: { repo: string; issue?: number; pr?: number }, config: SdlcConfig): SdlcReceipt | undefined {
369
+ if (!repo(link.repo) || link.issue !== undefined && !id(link.issue) || link.pr !== undefined && !id(link.pr)) return undefined;
370
+ if (previous?.repo && previous.repo.toLowerCase() !== link.repo.toLowerCase()) return undefined;
371
+ if (link.issue !== undefined && previous?.issue !== undefined || link.pr !== undefined && previous?.pr !== undefined) return undefined;
372
+ return { ...(previous ?? { stage: config.stages[0] ?? "spec" }), repo: previous?.repo ?? link.repo, ...(link.issue !== undefined ? { issue: link.issue } : {}), ...(link.pr !== undefined ? { pr: link.pr } : {}) };
373
+ }
374
+
375
+ export async function readSdlcPr(receipt: SdlcReceipt, run: SdlcGh = sdlcGh, withIntegrity = false): Promise<PrEvidence | undefined> {
341
376
  if (!repo(receipt.repo) || !id(receipt.pr)) return undefined;
342
377
  try {
343
378
  const view = await run(["pr", "view", String(receipt.pr), "--repo", receipt.repo, "--json", "headRefOid,state,statusCheckRollup,reviewDecision,reviewRequests,files,changedFiles,mergeCommit,isDraft,baseRefName,baseRefOid"]);
344
379
  if (view.code !== 0) return undefined;
345
380
  const [owner, name] = receipt.repo.split("/");
346
- const threads = await run(["api", "graphql", "-f", "query=query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){headRefOid reviewThreads(first:100){nodes{isResolved} pageInfo{hasNextPage}}}}}", "-f", `owner=${owner}`, "-f", `name=${name}`, "-F", `number=${receipt.pr}`]);
381
+ // The enabled S4 label read shares the existing thread snapshot, never a second readback for a known non-bug.
382
+ const labels = withIntegrity && id(receipt.issue) ? { issue: receipt.issue, labels: null as string[] | null } : undefined;
383
+ const query = `query($owner:String!,$name:String!,$number:Int!${labels ? ",$issue:Int!" : ""}){repository(owner:$owner,name:$name){pullRequest(number:$number){headRefOid reviewThreads(first:100){nodes{isResolved} pageInfo{hasNextPage}}}${labels ? " issue(number:$issue){number labels(first:100){nodes{name} pageInfo{hasNextPage}}}" : ""}}}`;
384
+ const threads = await run(["api", "graphql", "-f", `query=${query}`, "-f", `owner=${owner}`, "-f", `name=${name}`, ...(labels ? ["-F", `issue=${labels.issue}`] : []), "-F", `number=${receipt.pr}`]);
347
385
  const p = JSON.parse(view.stdout);
348
386
  delete p.unresolvedThreads; // only the complete thread query can establish this fact
349
387
  if (threads.code === 0) {
350
- const current = JSON.parse(threads.stdout)?.data?.repository?.pullRequest;
388
+ const response = JSON.parse(threads.stdout);
389
+ const current = response?.data?.repository?.pullRequest;
390
+ if (labels && response.errors === undefined) labels.labels = parseIssueLabels(response?.data?.repository?.issue, labels.issue) ?? null;
351
391
  const nodes = current?.reviewThreads?.nodes;
352
392
  // Paginated or malformed thread data is unknown, never a clean review.
353
393
  if (current?.headRefOid === p.headRefOid && current?.reviewThreads?.pageInfo?.hasNextPage === false && Array.isArray(nodes) && nodes.every(n => typeof n?.isResolved === "boolean")) p.unresolvedThreads = nodes.filter(n => !n.isResolved).length;
354
394
  else if (current?.headRefOid && current.headRefOid !== p.headRefOid) return undefined;
355
395
  }
356
- return parsePrEvidence(p);
396
+ return { ...parsePrEvidence(p), ...(labels ? { issueLabels: labels } : {}) };
357
397
  } catch { return undefined; }
358
398
  }
359
399
 
@@ -1,3 +1,4 @@
1
+ import { ACTION_CLASSES } from "./action-class.ts";
1
2
  import { definitionOf, type KindMap, type ProjectAdapterCatalog, ADAPTERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
2
3
  import { createHash } from "node:crypto";
3
4
  import { types } from "node:util";
@@ -59,6 +60,8 @@ export const SETTINGS: readonly SettingDef[] = [
59
60
  ...SETTING_PEERS.map((peer): SettingDef => ({ key: `catalog_roles.${peer}`, group: "Roles", label: `${peer}: catalog guidance`, type: "roles", store: "config", applies: "next use", risk: "safe", path: ["catalog_roles", peer] })),
60
61
  { key: "scoped_grants.protect_instructions", group: "Permissions", label: "Ask before writing instruction files", type: "boolean", store: "config", applies: "live", risk: "raises", floor: true, path: ["protect_instruction_files"] },
61
62
  ...SETTING_PEERS.map((peer): SettingDef => ({ key: `roster.${peer}`, group: "Roster", label: `${peer}: participates`, type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["roster", peer] })),
63
+ { key: "action_policy.enabled", group: "Permissions", label: "Always-ask action policy", type: "boolean", store: "config", applies: "live", risk: "raises", floor: true, path: ["action_policy", "enabled"] },
64
+ ...ACTION_CLASSES.map((kind): SettingDef => ({ key: `action_policy.budgets.${kind}`, group: "Permissions", label: `${kind}: approved session budget`, type: "integer", min: 0, max: 1000, store: "config", applies: "live", risk: "raises", floor: 0, path: ["action_policy", "budgets", kind] })),
62
65
  ...SETTING_PEERS.filter(peer => !definitionOf(peer)?.permission.unmanaged).map((peer): SettingDef => ({ key: `permission.${peer}`, group: "Permissions", label: `${peer}: mode now`, type: "enum", values: PERMISSION_MODES, store: "runtime", applies: "live", risk: "raises", floor: "ask", path: [peer] })),
63
66
  ...SETTING_PEERS.filter(peer => !definitionOf(peer)?.permission.unmanaged).map((peer): SettingDef => ({ key: `permission_modes.${peer}`, group: "Permissions", label: `${peer}: mode at next peer start`, type: "enum", values: PERMISSION_MODES, store: "config", applies: "next use", risk: "raises", floor: "ask", path: ["permission_modes", peer] })),
64
67
  ...START_MODE_PEERS.map((peer): SettingDef => ({ key: `peers.${peer}.start_mode`, group: "Start", label: `${peer}: start mode (tui unless set)`, type: "enum", values: START_MODES, store: "config", applies: "next use", risk: "raises", floor: "tui", path: ["peers", peer, "start_mode"] })),
@@ -470,7 +473,7 @@ export function settingRows(input: {
470
473
  const scalar = (value: unknown): SettingValue => typeof value === "string" || typeof value === "boolean" || typeof value === "number" ? value : Array.isArray(value) ? value.map(String) : null;
471
474
  return projectSettings(input.instances, input.adapterCatalog).map((def): SettingRow => {
472
475
  const base = { key: def.key, group: def.group, label: def.label, type: def.type, ...(["integer", "number"].includes(def.type) ? { min: def.min, max: def.max } : {}), applies: def.applies, ...(def.restartReason ? { restartReason: def.restartReason } : {}), risk: def.risk, ...(def.floor === undefined ? {} : { floor: def.floor }),
473
- ...(def.key === "scoped_grants.protect_instructions" ? { hint: "AGENTS.md, CLAUDE.md and QWEN.md remain grantable by default; enabling this asks before scoped writes and edits" } : def.group === "Permissions" ? { hint: permissionBoundary(def.path.at(-1)!, input.instances, input.adapterCatalog) } : {}) };
476
+ ...(def.key === "scoped_grants.protect_instructions" ? { hint: "AGENTS.md, CLAUDE.md and QWEN.md remain grantable by default; enabling this asks before scoped writes and edits" } : def.key.startsWith("action_policy.") ? { hint: "not enforced yet: adapter integration pending" } : def.group === "Permissions" ? { hint: permissionBoundary(def.path.at(-1)!, input.instances, input.adapterCatalog) } : {}) };
474
477
  if (def.locked) return { ...base, values: def.values, file: def.store === "runtime" ? "this hub run" : CONFIG_LOCAL,
475
478
  source: "instance declaration", value: "read-only", fileValue: "read-only", locked: def.locked, note: def.locked };
476
479
  if (def.store === "runtime") {
@@ -485,7 +488,7 @@ export function settingRows(input: {
485
488
  if (def.type === "roles" && input.roles) { const roles = input.roles(def.path[1]!); return { ...base, ...roles, fileValue: roles.pending !== undefined ? roles.pending : roles.value, file: CONFIG_LOCAL, source: valueAt(local, def.path) !== undefined ? CONFIG_LOCAL : valueAt(shared, def.path) !== undefined ? "config.json" : "profile/default", hint: "Guidance only; grants no tool or permission. At most two roles, applied at the next peer start." }; }
486
489
  const value = scalar(input.running(def.path));
487
490
  const stored = input.next ? scalar(input.next(def.path)) : value;
488
- const source = valueAt(local, def.path) !== undefined ? CONFIG_LOCAL : valueAt(shared, def.path) !== undefined ? "config.json" : "default";
491
+ const source = valueAt(local, def.path) !== undefined ? CONFIG_LOCAL : !def.key.startsWith("action_policy.") && valueAt(shared, def.path) !== undefined ? "config.json" : "default";
489
492
  const dropped = ignoredLine(input.ignored, def);
490
493
  return { ...base, ...(def.values ? { values: def.values } : {}), file: CONFIG_LOCAL, value, fileValue: stored, source, ...(same(value, stored) ? {} : { pending: stored }),
491
494
  ...(!input.next ? { note: "the config files do not load; fix them in your editor" } : dropped ? { note: `the hub does not read this value: ${dropped}` } : {}) };