@staix/agent-hub 0.12.34 → 0.12.36

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 (91) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +8 -6
  3. package/docs/agent-notes/adapters.md +6 -1
  4. package/docs/agent-notes/budget.md +4 -2
  5. package/docs/agent-notes/bus.md +5 -1
  6. package/docs/agent-notes/daemon.md +11 -3
  7. package/docs/agent-notes/local-worker.md +1 -1
  8. package/docs/agent-notes/tasks.md +15 -2
  9. package/docs/agent-notes/tests.md +5 -1
  10. package/docs/gsd-collaboration-analysis.md +5 -1
  11. package/docs/operations.md +97 -19
  12. package/docs/quickstart.md +8 -4
  13. package/docs/sdlc.md +100 -34
  14. package/docs/security.md +7 -3
  15. package/docs/specs/2026-09-19-agent-hub-design.md +237 -7
  16. package/docs/verification/2026-10-18-0.12.35.md +49 -0
  17. package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
  18. package/docs/verification/2026-10-19-0.12.36.md +63 -0
  19. package/docs/verified.json +26 -17
  20. package/package.json +1 -1
  21. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  22. package/plugins/agent-hub/server.js +15781 -14850
  23. package/src/adapters/acp.ts +3 -1
  24. package/src/adapters/claude-channel.ts +59 -29
  25. package/src/adapters/claude-print.ts +230 -0
  26. package/src/adapters/codex-appserver.ts +1 -1
  27. package/src/adapters/definitions/builtin-metadata.ts +17 -1
  28. package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
  29. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -5
  30. package/src/adapters/definitions/native-cli-entry-jcode.ts +6 -2
  31. package/src/adapters/definitions/native-cli-entry-kimi.ts +18 -12
  32. package/src/adapters/definitions/native-cli-entry-local.ts +3 -2
  33. package/src/adapters/definitions/native-cli-entry-pi.ts +8 -6
  34. package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -2
  35. package/src/adapters/definitions/native-cli-entry-types.ts +3 -0
  36. package/src/adapters/definitions/native-factories.ts +7 -0
  37. package/src/adapters/definitions/native-recovery.ts +2 -2
  38. package/src/adapters/definitions/qwen-native.ts +2 -2
  39. package/src/adapters/local-worker.ts +3 -2
  40. package/src/adapters/pi.ts +3 -1
  41. package/src/adapters/registry-types.ts +11 -2
  42. package/src/adapters/registry.ts +36 -8
  43. package/src/cli/help.ts +5 -0
  44. package/src/cli/identity.ts +5 -4
  45. package/src/cli/launch.ts +45 -7
  46. package/src/cli/main.ts +136 -29
  47. package/src/cli/preview.ts +16 -5
  48. package/src/cli/recovery-guidance.ts +39 -22
  49. package/src/cli/reset.ts +4 -2
  50. package/src/cli/statusline-tee.ts +5 -2
  51. package/src/cli/terminal-recovery.ts +73 -16
  52. package/src/cli/upgrade-restore-state.ts +16 -7
  53. package/src/cli/upgrade-runtime.ts +83 -52
  54. package/src/cli/upgrade.ts +43 -27
  55. package/src/hub/board.ts +29 -7
  56. package/src/hub/budget.ts +55 -3
  57. package/src/hub/bus.ts +12 -6
  58. package/src/hub/claude-state.ts +37 -0
  59. package/src/hub/conductor.ts +16 -10
  60. package/src/hub/config-apply.ts +13 -2
  61. package/src/hub/config-reload.ts +11 -1
  62. package/src/hub/config-trust.ts +1 -1
  63. package/src/hub/control-client.ts +1 -1
  64. package/src/hub/cost-governance.ts +76 -0
  65. package/src/hub/crash.ts +14 -5
  66. package/src/hub/daemon.ts +473 -194
  67. package/src/hub/execution-budget.ts +3 -3
  68. package/src/hub/hub-tools.ts +36 -6
  69. package/src/hub/instance-budget.ts +73 -0
  70. package/src/hub/instance-config.ts +47 -0
  71. package/src/hub/instance-execution.ts +50 -0
  72. package/src/hub/instances.ts +114 -0
  73. package/src/hub/lifecycle-schema.ts +7 -3
  74. package/src/hub/permission-mode.ts +7 -7
  75. package/src/hub/review-brief.ts +175 -0
  76. package/src/hub/roster.ts +7 -6
  77. package/src/hub/routing.ts +97 -18
  78. package/src/hub/sdlc-queue.ts +2 -2
  79. package/src/hub/sdlc.ts +286 -39
  80. package/src/hub/settings.ts +34 -6
  81. package/src/hub/snapshots.ts +40 -9
  82. package/src/hub/start-mode.ts +7 -4
  83. package/src/hub/status-roster.ts +18 -0
  84. package/src/hub/task-sweep.ts +143 -0
  85. package/src/hub/tasks.ts +595 -47
  86. package/src/hub/text-projection.ts +7 -3
  87. package/src/hub/usage.ts +57 -20
  88. package/src/hub/workflow.ts +30 -52
  89. package/src/pi/launch.ts +4 -3
  90. package/src/ui/index.html +1 -1
  91. package/templates/AGENTS.block.md +2 -1
package/src/hub/sdlc.ts CHANGED
@@ -1,20 +1,50 @@
1
- import { phaseReceipt, type PhaseReceipt, type PhaseView } from "./workflow.ts";
1
+ import type { CostPlan } from "./cost-governance.ts";
2
+ import { criteriaReceipt, type CriteriaReceipt, type CriteriaView, type LegacyPhaseReceipt } from "./workflow.ts";
2
3
  import { constants, openSync, fstatSync, readSync, closeSync } from "node:fs";
3
4
  import { join } from "node:path";
4
5
  import { spawn } from "node:child_process";
5
6
  import { childEnv, stopOwnedProcess } from "./child-process.ts";
6
7
  import { hostExecutable } from "./host-executable.ts";
7
8
 
9
+ /** The off-mode default (#257). Off output, receipts and schemas keep this shape. */
8
10
  export const SDLC_STAGES = ["spec", "plan", "implement", "review", "verify", "merge", "release"] as const;
9
- export type SdlcBaseStage = typeof SDLC_STAGES[number];
11
+ /** The enabled default (#481 S0): the AI-Native SDLC Playbook stage model. */
12
+ export const PLAYBOOK_STAGES = ["intent", "spec", "plan", "build", "test", "review", "merge", "release", "maintain"] as const;
13
+ export type SdlcBaseStage = typeof SDLC_STAGES[number] | typeof PLAYBOOK_STAGES[number];
14
+ const BASES: readonly string[] = [...new Set<string>([...SDLC_STAGES, ...PLAYBOOK_STAGES])];
15
+ /** Legacy ids read as their playbook stage; a receipt written with one stores the canonical id. */
16
+ const ALIASES: Record<string, string> = { implement: "build", verify: "test" };
17
+ export const canonicalStage = (stage: string): string => ALIASES[stage] ?? stage;
18
+ const PLAYBOOK_LABEL: Record<string, string> = { intent: "Plan", spec: "Design", plan: "Build", build: "Build", test: "Test", review: "Deploy", merge: "Deploy", release: "Deploy", maintain: "Maintain" };
19
+ export const playbookStage = (base: string): string | undefined => PLAYBOOK_LABEL[canonicalStage(base)];
20
+ /** #481 S8: `workflow` and the `phase` gate/receipt name load with a warning before this hub version and refuse from it. */
21
+ export const RETIRED_WORKFLOW_FROM = "0.13.0";
22
+ const olderThan = (version: string, limit: string): boolean => {
23
+ const a = version.split(/[.-]/).slice(0, 3).map(Number), b = limit.split(".").map(Number);
24
+ for (let i = 0; i < 3; i++) if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) < (b[i] ?? 0);
25
+ return false;
26
+ };
10
27
  export type SdlcStage = string;
11
- export type StageGate = "issue" | "pr" | "local" | "review" | "checks" | "phase";
28
+ export type StageGate = "issue" | "pr" | "local" | "review" | "checks" | "criteria";
12
29
  export interface StageDefinition { base: SdlcBaseStage; actor: string; entry_gates: StageGate[]; exit_gates: StageGate[]; after: string[]; }
13
30
  export type SdlcMode = "off" | "advise" | "enforce";
14
- export interface SdlcConfig { workflow?: "gsd-lite"; mode: SdlcMode; requiredChecks: string[]; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; }
31
+ /** `workflow: "gsd-lite"` marks the retired setting during its window (#481 S8): every task then needs a criteria checklist. */
32
+ export interface SdlcConfig { workflow?: "gsd-lite"; mode: SdlcMode; requiredChecks: string[]; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; deprecations?: string[]; }
33
+ export interface PlanReviewReceipt {
34
+ planner: string;
35
+ planningRevision: number;
36
+ recordedAt: number;
37
+ scope?: CostPlan;
38
+ issue?: PlanIssueMetadata;
39
+ }
15
40
  export interface SdlcReceipt {
16
41
  stage: SdlcStage;
17
- phase?: PhaseReceipt;
42
+ planReview?: PlanReviewReceipt;
43
+ criteria?: CriteriaReceipt;
44
+ /** Stored before #481 S8 and read as `criteria`; never written again. */
45
+ phase?: LegacyPhaseReceipt;
46
+ /** The person's spec acceptance (#481 S1): set only by Tasks.acceptSpec, never by hub_sdlc_record. */
47
+ acceptance?: SpecAcceptance;
18
48
  repo?: string;
19
49
  issue?: number;
20
50
  pr?: number;
@@ -43,33 +73,42 @@ const repo = (value: unknown): value is string => typeof value === "string" && /
43
73
  const id = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
44
74
 
45
75
  const STAGE_ID = /^[a-z][a-z0-9_-]{0,47}$/;
46
- const STAGE_GATES: StageGate[] = ["issue", "pr", "local", "review", "checks", "phase"];
47
- const baseActor = (base: SdlcBaseStage) => base === "review" || base === "verify" ? "reviewer" : base === "merge" || base === "release" ? "person" : "owner/planner";
76
+ // #451 froze this literal as non-peer gate names ("local" is the local-check gate). "phase" is read as criteria (#481 S8).
77
+ const STAGE_GATES = ["issue", "pr", "local", "review", "checks", "phase"];
78
+ const GATES = STAGE_GATES.map(gate => gate === "phase" ? "criteria" : gate) as StageGate[];
79
+ const baseActor = (base: string) => base === "review" || canonicalStage(base) === "test" ? "reviewer" : base === "merge" || base === "release" ? "person" : "owner/planner";
80
+ /** A receipt's checklist under either stored name. */
81
+ export const checklistOf = (receipt: SdlcReceipt | undefined): CriteriaReceipt | undefined => receipt?.criteria ?? receipt?.phase;
48
82
  export function stageDefinition(config: SdlcConfig, stage: string): StageDefinition | undefined {
49
- if (!config.stages.includes(stage)) return undefined;
83
+ if (!config.stages.includes(stage)) return stage !== canonicalStage(stage) ? stageDefinition(config, canonicalStage(stage)) : undefined;
50
84
  if (config.definitions && Object.hasOwn(config.definitions, stage)) return config.definitions[stage];
51
- if (!(SDLC_STAGES as readonly string[]).includes(stage)) return undefined;
85
+ if (!BASES.includes(stage)) return undefined;
52
86
  const base = stage as SdlcBaseStage;
53
87
  return { base, actor: baseActor(base), entry_gates: [], exit_gates: [], after: [] };
54
88
  }
55
89
  export function lifecycleStage(config: SdlcConfig, base: SdlcBaseStage): string {
56
90
  return config.stages.find(stage => stageDefinition(config, stage)?.base === base) ?? base;
57
91
  }
58
- function definitions(raw: unknown, stages: unknown): { stages: string[]; definitions: Record<string, StageDefinition> } {
92
+ function definitions(raw: unknown, stages: unknown, phaseAlias: { allowed: boolean; used: boolean }): { stages: string[]; definitions: Record<string, StageDefinition> } {
59
93
  if (!raw || typeof raw !== "object" || Array.isArray(raw) || Object.keys(raw).length > 32) throw new Error("sdlc.definitions: expected at most 32 stage definitions");
60
94
  if (!Array.isArray(stages) || !stages.length || stages.length > 32 || stages.some(s => typeof s !== "string" || !STAGE_ID.test(s)) || new Set(stages).size !== stages.length) throw new Error("sdlc.stages: expected 1..32 distinct bounded stage ids");
61
95
  const result: Record<string, StageDefinition> = Object.create(null);
62
96
  for (const [id, value] of Object.entries(raw)) {
63
97
  if (!STAGE_ID.test(id) || !stages.includes(id) || !value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdlc.definitions: invalid or unlisted stage");
64
98
  const def = value as Record<string, unknown>;
65
- if (Object.keys(def).some(k => !["base", "actor", "entry_gates", "exit_gates", "after"].includes(k)) || !(SDLC_STAGES as readonly unknown[]).includes(def.base)) throw new Error("sdlc.definitions: unknown field or base");
99
+ if (Object.keys(def).some(k => !["base", "actor", "entry_gates", "exit_gates", "after"].includes(k)) || !BASES.includes(def.base as string)) throw new Error("sdlc.definitions: unknown field or base");
66
100
  const base = def.base as SdlcBaseStage;
67
101
  if ((SDLC_STAGES as readonly string[]).includes(id) && id !== base) throw new Error("sdlc.definitions: built-in stage base cannot change");
102
+ if (!(SDLC_STAGES as readonly string[]).includes(id) && (PLAYBOOK_STAGES as readonly string[]).includes(id) && canonicalStage(id) !== canonicalStage(base)) throw new Error("sdlc.definitions: built-in playbook stage base cannot change");
68
103
  const actor = def.actor ?? baseActor(base);
69
104
  if (typeof actor !== "string" || actor !== baseActor(base) && !["owner", "reviewer", "person", "planner"].includes(actor)) throw new Error("sdlc.definitions.actor: unknown actor");
70
105
  const gates = (key: string): StageGate[] => {
71
- const list = def[key] ?? [];
72
- if (!Array.isArray(list) || list.length > 6 || list.some(g => !STAGE_GATES.includes(g)) || new Set(list).size !== list.length) throw new Error(`sdlc.definitions.${key}: unknown or duplicate gate`);
106
+ const given = def[key] ?? [];
107
+ const retired = Array.isArray(given) && given.includes("phase");
108
+ if (retired && !phaseAlias.allowed) throw new Error(`sdlc.definitions.${key}: the phase gate was retired in ${RETIRED_WORKFLOW_FROM} (#481); name it criteria`);
109
+ if (retired) phaseAlias.used = true;
110
+ const list = Array.isArray(given) ? given.map(g => g === "phase" ? "criteria" : g) : given;
111
+ if (!Array.isArray(list) || list.length > 6 || list.some(g => !GATES.includes(g)) || new Set(list).size !== list.length) throw new Error(`sdlc.definitions.${key}: unknown or duplicate gate`);
73
112
  return [...list] as StageGate[];
74
113
  };
75
114
  const after = def.after ?? [];
@@ -81,28 +120,38 @@ function definitions(raw: unknown, stages: unknown): { stages: string[]; definit
81
120
  return { stages: [...stages], definitions: result };
82
121
  }
83
122
 
84
- export function sdlcConfig(value: unknown): SdlcConfig {
123
+ /** `version` is the running hub's, which closes the #481 S8 window; the daemon and Tasks pass it. Without one the window is open. */
124
+ export function sdlcConfig(value: unknown, version?: string): SdlcConfig {
85
125
  if (value === undefined) return { mode: "off", requiredChecks: [], stages: [...SDLC_STAGES] };
86
126
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdlc: expected a table");
87
127
  const raw = value as Record<string, unknown>;
88
128
  for (const key of Object.keys(raw)) if (!["mode", "required_checks", "stages", "workflow", "definitions"].includes(key)) throw new Error(`sdlc.${key}: unknown field`);
89
129
  const mode = raw.mode ?? "off";
90
130
  if (mode !== "off" && mode !== "advise" && mode !== "enforce") throw new Error("sdlc.mode: expected off, advise or enforce");
131
+ // ponytail: the #481 S8 window is a fixed version; delete `workflow`, the phase aliases and their tests once it passes.
132
+ const window = version === undefined || olderThan(version, RETIRED_WORKFLOW_FROM);
133
+ if (raw.workflow !== undefined && !window) throw new Error(`sdlc.workflow: retired in ${RETIRED_WORKFLOW_FROM} (#481); remove it and record a criteria checklist instead`);
91
134
  const workflow = raw.workflow ?? "none";
92
135
  if (workflow !== "none" && workflow !== "gsd-lite") throw new Error("sdlc.workflow: expected none or gsd-lite");
136
+ const notes = workflow === "gsd-lite" ? [`sdlc.workflow = "gsd-lite" is deprecated and stops loading in ${RETIRED_WORKFLOW_FROM} (#481): remove it; every task then needs a criteria checklist recorded with hub_sdlc_record`] : [];
137
+ const phaseAlias = { allowed: window, used: false };
93
138
  const checks = raw.required_checks ?? [];
94
139
  if (!Array.isArray(checks) || checks.length > 32 || checks.some(c => typeof c !== "string" || !c.trim() || c.length > 120 || /[\r\n\x00-\x1f]/.test(c)) || new Set(checks).size !== checks.length) throw new Error("sdlc.required_checks: expected at most 32 unique nonempty names");
95
140
  if (raw.definitions !== undefined) {
96
- const custom = definitions(raw.definitions, raw.stages);
97
- if (workflow !== "gsd-lite" && Object.values(custom.definitions).some(d => [...d.entry_gates, ...d.exit_gates].includes("phase"))) throw new Error("sdlc.definitions: phase gates require workflow = gsd-lite");
98
- return { mode, requiredChecks: [...checks] as string[], ...custom, ...(workflow === "gsd-lite" ? { workflow } : {}) };
141
+ const custom = definitions(raw.definitions, raw.stages, phaseAlias);
142
+ if (phaseAlias.used) notes.push(`the sdlc phase gate is deprecated and stops loading in ${RETIRED_WORKFLOW_FROM} (#481): name it criteria`);
143
+ return { mode, requiredChecks: [...checks] as string[], ...custom, ...(workflow === "gsd-lite" ? { workflow } : {}), ...(notes.length ? { deprecations: notes } : {}) };
99
144
  }
100
- const stages = raw.stages ?? [...SDLC_STAGES];
101
- if (!Array.isArray(stages) || stages.length !== SDLC_STAGES.length || stages.some((stage, i) => stage !== SDLC_STAGES[i])) throw new Error("sdlc.stages: use spec, plan, implement, review, verify, merge, release in that order; custom stages are not supported yet");
102
- return { mode, requiredChecks: [...checks] as string[], stages: [...stages] as SdlcStage[], ...(workflow === "gsd-lite" ? { workflow } : {}) };
145
+ // Off keeps the #257 seven stages byte for byte; an enabled lifecycle defaults to the playbook (#481 S0).
146
+ const given = raw.stages ?? (mode === "off" ? [...SDLC_STAGES] : [...PLAYBOOK_STAGES]);
147
+ const exactly = (list: readonly string[]) => Array.isArray(given) && given.length === list.length && given.every((stage, i) => stage === list[i]);
148
+ // An enabled #257 list keeps its order under canonical ids, so implement/verify stay read-only aliases there too.
149
+ const stages = mode !== "off" && exactly(SDLC_STAGES) ? SDLC_STAGES.map(canonicalStage) : given;
150
+ if (!exactly(SDLC_STAGES) && !exactly(PLAYBOOK_STAGES)) throw new Error(`sdlc.stages: use ${PLAYBOOK_STAGES.join(", ")} in that order (or the #257 list ${SDLC_STAGES.join(", ")}); other lists need definitions`);
151
+ return { mode, requiredChecks: [...checks] as string[], stages: [...stages as string[]], ...(workflow === "gsd-lite" ? { workflow } : {}), ...(notes.length ? { deprecations: notes } : {}) };
103
152
  }
104
153
 
105
- export function loadSdlc(cwd: string): SdlcConfig {
154
+ export function loadSdlc(cwd: string, version?: string): SdlcConfig {
106
155
  try {
107
156
  const fd = openSync(join(cwd, ".agenthub", "sdlc.toml"), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
108
157
  try {
@@ -112,23 +161,29 @@ export function loadSdlc(cwd: string): SdlcConfig {
112
161
  while ((n = readSync(fd, bytes, length, bytes.length - length, null)) > 0) { length += n; if (length > cap) throw new Error("sdlc.toml exceeds 64 KiB"); }
113
162
  const after = fstatSync(fd);
114
163
  if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || length !== after.size) throw new Error("sdlc.toml changed during read");
115
- return sdlcConfig(Bun.TOML.parse(bytes.subarray(0, length).toString("utf8")));
164
+ return sdlcConfig(Bun.TOML.parse(bytes.subarray(0, length).toString("utf8")), version);
116
165
  } finally { closeSync(fd); }
117
166
  }
118
167
  catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return sdlcConfig(undefined); throw error; }
119
168
  }
120
169
 
121
170
  /** Identities and claimed local receipts only. External facts are read, never accepted as input. */
122
- export function sdlcReceipt(value: unknown, previous: SdlcReceipt | undefined = undefined, config: SdlcConfig = sdlcConfig(undefined)): SdlcReceipt {
171
+ export function sdlcReceipt(value: unknown, previous: SdlcReceipt | undefined = undefined, config: SdlcConfig = sdlcConfig(undefined), version?: string): SdlcReceipt {
123
172
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdlc receipt: expected an object");
124
173
  const raw = value as Record<string, unknown>;
125
- for (const key of Object.keys(raw)) if (!["stage", "repo", "issue", "pr", "reviewedHead", "localHead", "localResult", "phase"].includes(key)) throw new Error(`sdlc receipt.${key}: unsupported or externally observed field`);
174
+ 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`);
175
+ if (raw.criteria !== undefined && raw.phase !== undefined) throw new Error("sdlc receipt: give criteria only; phase is its retired name");
176
+ 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`);
126
177
  previous ??= { stage: config.stages[0] ?? "spec" };
127
178
  const next = { ...previous };
128
- if (raw.phase !== undefined) next.phase = phaseReceipt(raw.phase);
179
+ const checklist = raw.criteria ?? raw.phase;
180
+ // Off keeps the #257 receipt bytes for a phase write (task import stays retired); everything else stores criteria.
181
+ if (raw.phase !== undefined && config.mode === "off" && !config.workflow) { const legacy = criteriaReceipt(raw.phase); next.phase = { id: legacy.id, ...(legacy.head ? { head: legacy.head } : {}), tasks: [], criteria: legacy.criteria }; delete next.criteria; }
182
+ else if (checklist !== undefined) { next.criteria = criteriaReceipt(checklist); delete next.phase; }
129
183
  if (raw.stage !== undefined) {
130
184
  if (typeof raw.stage !== "string" || !stageDefinition(config, raw.stage)) throw new Error("sdlc receipt.stage: unknown stage");
131
- next.stage = raw.stage as SdlcStage;
185
+ // A legacy id is accepted from older clients but stored under the stage the config actually lists (#481 S0).
186
+ next.stage = (config.stages.includes(raw.stage) ? raw.stage : canonicalStage(raw.stage)) as SdlcStage;
132
187
  }
133
188
  if (raw.repo !== undefined) { if (!repo(raw.repo)) throw new Error("sdlc receipt.repo: expected owner/repository"); next.repo = raw.repo; }
134
189
  for (const key of ["issue", "pr"] as const) if (raw[key] !== undefined) { if (!id(raw[key])) throw new Error(`sdlc receipt.${key}: expected a positive integer`); next[key] = raw[key]; }
@@ -137,10 +192,12 @@ export function sdlcReceipt(value: unknown, previous: SdlcReceipt | undefined =
137
192
  if (!["passed", "failed", "unknown"].includes(String(raw.localResult))) throw new Error("sdlc receipt.localResult: expected passed, failed or unknown");
138
193
  next.localResult = raw.localResult as SdlcReceipt["localResult"];
139
194
  }
195
+ // An acceptance names one spec issue; relinking the receipt to another drops it (#481 S1).
196
+ if (next.repo !== previous.repo || next.issue !== previous.issue) delete next.acceptance;
140
197
  // A new artifact cannot inherit another PR's review or local gate.
141
198
  if (next.repo !== previous.repo || next.pr !== previous.pr) {
142
199
  delete next.head; delete next.observed;
143
- if (raw.phase === undefined) delete next.phase;
200
+ if (checklist === undefined) { delete next.criteria; delete next.phase; }
144
201
  if (raw.reviewedHead === undefined) delete next.reviewedHead;
145
202
  if (raw.localHead === undefined) delete next.localHead;
146
203
  if (raw.localResult === undefined) delete next.localResult;
@@ -155,7 +212,7 @@ export function parsePrEvidence(value: unknown): PrEvidence {
155
212
  const name = c.name ?? c.context;
156
213
  if (typeof name !== "string" || !name || name.length > 120) throw new Error("PR readback: invalid check identity");
157
214
  const native = c.conclusion ?? c.state;
158
- const state = native === "SUCCESS" ? "passed" : ["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED"].includes(String(native)) ? "failed" : ["PENDING", "QUEUED", "IN_PROGRESS"].includes(String(c.status ?? native)) ? "pending" : "unknown";
215
+ const state = ["PENDING", "QUEUED", "IN_PROGRESS"].includes(String(c.status ?? native)) ? "pending" : native === "SUCCESS" ? "passed" : ["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED"].includes(String(native)) ? "failed" : "unknown";
159
216
  return { name, state } as PrEvidence["checks"][number];
160
217
  });
161
218
  const paths = p.files.map((f: Record<string, unknown>) => { if (typeof f.path !== "string" || !f.path || f.path.length > 1000) throw new Error("PR readback: invalid file identity"); return f.path; });
@@ -166,8 +223,26 @@ export function parsePrEvidence(value: unknown): PrEvidence {
166
223
  ...(sha(p.mergeCommit?.oid) ? { mergeCommit: p.mergeCommit.oid } : {}) };
167
224
  }
168
225
 
169
- export interface SdlcStatus { workflow?: "gsd-lite"; phase?: PhaseView; mode: SdlcMode; stage: SdlcStage; definition?: StageDefinition; entry?: string[]; exit?: string[]; blockers: string[]; ready: boolean; actor: string; next: string; }
170
- export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined, evidence?: PrEvidence, phase?: PhaseView): SdlcStatus {
226
+ /** Issue identity and edit times only; no body is fetched or stored. `lastEditedAt` moves on a body edit, not on comments. */
227
+ export interface SpecEdit { lastEditedAt: string | null; updatedAt: string }
228
+ export interface SpecAcceptance extends SpecEdit { repo: string; issue: number; acceptedAt: number }
229
+ export type SpecState = "missing" | "current" | "changed" | "unknown";
230
+ /** Whether the receipt's spec issue is accepted at its current body. `edit` undefined means the readback failed. */
231
+ export function specState(receipt: SdlcReceipt | undefined, edit: SpecEdit | undefined): SpecState {
232
+ const accepted = receipt?.acceptance;
233
+ if (!accepted || accepted.repo !== receipt?.repo || accepted.issue !== receipt?.issue) return "missing";
234
+ if (!edit) return "unknown";
235
+ return edit.lastEditedAt === accepted.lastEditedAt ? "current" : "changed";
236
+ }
237
+ export const SPEC_STATE_TEXT: Record<Exclude<SpecState, "current">, string> = {
238
+ missing: "spec acceptance missing: the person runs ahub sdlc accept <task> --issue <n>",
239
+ changed: "spec changed after acceptance: the person re-accepts with ahub sdlc accept <task> --issue <n>",
240
+ unknown: "spec edit time unknown: the issue could not be read",
241
+ };
242
+ export interface SdlcStatus { spec?: { issue: number; state: SpecState }; playbook?: string; criteria?: CriteriaView; mode: SdlcMode; stage: SdlcStage; definition?: StageDefinition; entry?: string[]; exit?: string[]; blockers: string[]; ready: boolean; actor: string; next: string; }
243
+ /** The acceptance-criteria checklist (#481 S8, Compliance pass) counts once recorded, and on every task under the retired gsd-lite setting. Off keeps #257's output. */
244
+ export const criteriaRequired = (config: SdlcConfig, receipt: SdlcReceipt | undefined): boolean => config.workflow === "gsd-lite" || (config.mode !== "off" && !!checklistOf(receipt));
245
+ export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined, evidence?: PrEvidence, criteria?: CriteriaView, specEdit?: SpecEdit): SdlcStatus {
171
246
  const stage = receipt?.stage ?? config.stages[0] ?? "spec", blockers: string[] = [];
172
247
  const definition = stageDefinition(config, stage);
173
248
  if (!definition) blockers.push("lifecycle stage definition unknown");
@@ -187,20 +262,22 @@ export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined,
187
262
  if (evidence.pendingReviews !== 0) blockers.push("review requests pending or unknown");
188
263
  if (evidence.unresolved !== 0) blockers.push("review threads unresolved or unknown");
189
264
  }
190
- if (config.workflow === "gsd-lite") {
191
- blockers.unshift(...(phase?.blockers ?? ["phase board evidence unknown"]));
192
- if (!evidence || receipt?.phase?.head !== evidence.head) blockers.push("phase checklist head missing or stale");
193
- }
194
- const custom = config.definitions && definition ? stageGates(definition, blockers, receipt, evidence, phase) : undefined;
265
+ const viewBlockers = criteria?.blockers ?? ["criteria board evidence unknown"];
266
+ const staleHead = !evidence || checklistOf(receipt)?.head !== evidence.head ? ["criteria checklist head missing or stale"] : [];
267
+ if (criteriaRequired(config, receipt)) { blockers.unshift(...viewBlockers); blockers.push(...staleHead); }
268
+ const custom = config.definitions && definition ? stageGates(definition, blockers, [...viewBlockers, ...staleHead]) : undefined;
195
269
  if (custom) for (const blocker of [...custom.entry, ...custom.exit]) if (!blockers.includes(blocker)) blockers.push(blocker);
196
- return { ...(config.workflow ? { workflow: config.workflow, ...(phase ? { phase } : {}) } : {}), ...(custom ? { definition, ...custom } : {}), mode: config.mode, stage, blockers, ready: blockers.length === 0,
197
- actor: !definition ? "person" : phase?.blockers.length ? "reviewer/person" : config.definitions && definition ? definition.actor : stage === "review" || stage === "verify" ? "reviewer" : stage === "merge" || stage === "release" ? "person" : "owner/planner",
270
+ const shown = criteria && (criteriaRequired(config, receipt) || definition && [...definition.entry_gates, ...definition.exit_gates].includes("criteria"));
271
+ // Shown, not counted as a readiness blocker: the spec gate is build entry (#481 S1).
272
+ const spec = config.mode !== "off" && receipt?.issue ? { spec: { issue: receipt.issue, state: specState(receipt, specEdit) } } : {};
273
+ return { ...spec, ...(config.mode !== "off" && definition && playbookStage(definition.base) ? { playbook: playbookStage(definition.base) } : {}), ...(shown ? { criteria } : {}), ...(custom ? { definition, ...custom } : {}), mode: config.mode, stage, blockers, ready: blockers.length === 0,
274
+ actor: !definition ? "person" : shown && criteria.blockers.length ? "reviewer/person" : config.definitions ? definition.actor : baseActor(definition.base),
198
275
  next: blockers[0] ?? "person may inspect merge readiness; no automatic merge" };
199
276
  }
200
277
 
201
- function stageGates(def: StageDefinition, blockers: string[], receipt: SdlcReceipt | undefined, evidence: PrEvidence | undefined, phase: PhaseView | undefined): { entry: string[]; exit: string[] } {
278
+ function stageGates(def: StageDefinition, blockers: string[], checklist: string[]): { entry: string[]; exit: string[] } {
202
279
  const select = (gates: StageGate[]) => [...new Set(gates.flatMap(gate => {
203
- if (gate === "phase") return [...(phase?.blockers ?? ["phase board evidence unknown"]), ...(!evidence || receipt?.phase?.head !== evidence.head ? ["phase checklist head missing or stale"] : [])];
280
+ if (gate === "criteria") return checklist;
204
281
  const matching = (text: string) => gate === "issue" ? text === "issue identity missing" : gate === "pr" ? ["PR identity missing", "current PR evidence unknown", "PR is not open"].includes(text) : gate === "local" ? ["current PR evidence unknown", "local gate on current head missing or failed"].includes(text) : gate === "checks" ? text === "current PR evidence unknown" || text === "required check set unknown" || text.startsWith("required check ") : ["current PR evidence unknown", "current head has not been reviewed", "GitHub review approval missing or changes requested", "review requests pending or unknown", "review threads unresolved or unknown"].includes(text);
205
282
  return blockers.filter(matching);
206
283
  }))];
@@ -233,6 +310,20 @@ export const sdlcGh: SdlcGh = (args, timeoutMs = 10_000, cwd, maxOutputBytes = 2
233
310
  child.once("close", code => { clearTimeout(timer); resolve({ code: failed ? 1 : code ?? 1, stdout: failed ? "" : stdout }); });
234
311
  });
235
312
 
313
+ /** Read-only, fixed argv: the issue's edit times, never its body. */
314
+ export async function readSpecEdit(repoName: string, issue: number, run: SdlcGh = sdlcGh): Promise<SpecEdit | undefined> {
315
+ if (!repo(repoName) || !id(issue)) return undefined;
316
+ const [owner, name] = repoName.split("/");
317
+ try {
318
+ const out = await run(["api", "graphql", "-f", "query=query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){issue(number:$number){number lastEditedAt updatedAt}}}", "-f", `owner=${owner}`, "-f", `name=${name}`, "-F", `number=${issue}`]);
319
+ if (out.code !== 0) return undefined;
320
+ const found = JSON.parse(out.stdout)?.data?.repository?.issue;
321
+ const stamp = (v: unknown) => typeof v === "string" && v.length <= 40 && !Number.isNaN(Date.parse(v));
322
+ if (found?.number !== issue || !stamp(found.updatedAt) || (found.lastEditedAt !== null && !stamp(found.lastEditedAt))) return undefined;
323
+ return { lastEditedAt: found.lastEditedAt, updatedAt: found.updatedAt };
324
+ } catch { return undefined; }
325
+ }
326
+
236
327
  export async function readSdlcPr(receipt: SdlcReceipt, run: SdlcGh = sdlcGh): Promise<PrEvidence | undefined> {
237
328
  if (!repo(receipt.repo) || !id(receipt.pr)) return undefined;
238
329
  try {
@@ -252,3 +343,159 @@ export async function readSdlcPr(receipt: SdlcReceipt, run: SdlcGh = sdlcGh): Pr
252
343
  return parsePrEvidence(p);
253
344
  } catch { return undefined; }
254
345
  }
346
+
347
+ export interface PlanIssueMetadata {
348
+ repo: string;
349
+ issue: number;
350
+ state: "OPEN" | "CLOSED";
351
+ /** Body edit time; comment activity must never invalidate a plan review. */
352
+ editedAt: string;
353
+ /** Nullable GitHub body edit field, retained to compare independent S1 acceptance evidence. */
354
+ lastEditedAt?: string | null;
355
+ }
356
+
357
+ /** Metadata only: never request issue bodies or comments for a planning receipt. */
358
+ export async function readPlanIssueMetadata(repository: string, number: number, run: SdlcGh = sdlcGh): Promise<PlanIssueMetadata | undefined> {
359
+ if (!repo(repository) || !id(number)) return undefined;
360
+ const [owner, name] = repository.split("/");
361
+ try {
362
+ const result = await run(["api", "graphql", "-f", "query=query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){issue(number:$number){number state createdAt lastEditedAt}}}", "-f", `owner=${owner}`, "-f", `name=${name}`, "-F", `number=${number}`], 10_000, undefined, 8 * 1024);
363
+ if (result.code !== 0 || Buffer.byteLength(result.stdout) > 8 * 1024) return undefined;
364
+ const response = JSON.parse(result.stdout);
365
+ if (response.errors !== undefined) return undefined;
366
+ const issue = response.data?.repository?.issue;
367
+ if (!issue || issue.number !== number || !["OPEN", "CLOSED"].includes(issue.state)) return undefined;
368
+ const editedAt = issue.lastEditedAt === null ? issue.createdAt : issue.lastEditedAt;
369
+ if (typeof editedAt !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(editedAt) || !Number.isFinite(Date.parse(editedAt))) return undefined;
370
+ return { repo: repository, issue: number, state: issue.state, editedAt, lastEditedAt: issue.lastEditedAt };
371
+ } catch { return undefined; }
372
+ }
373
+
374
+ /** A complete resolved-thread snapshot already proves there is no pending unanswered bot thread. */
375
+ export function reviewReadinessFromEvidence(evidence: PrEvidence | undefined, expected: string | undefined, required: readonly string[]): ReviewReadiness | undefined {
376
+ if (!evidence || evidence.unresolved !== 0) return undefined;
377
+ const checks = required.length ? [...required] : ["check (ubuntu-latest)", "check (macos-latest)", "seeded guards"];
378
+ if (!checks.every(name => evidence.checks.some(check => check.name === name)) || evidence.checks.some(check => check.state === "unknown")) return undefined;
379
+ const blockers: string[] = [];
380
+ if (!expected || !sha(expected)) blockers.push("review head missing");
381
+ else if (evidence.head !== expected) blockers.push("review head changed");
382
+ if (evidence.state !== "OPEN") blockers.push("review PR is not open");
383
+ if (!checks.every(name => { const matched = evidence.checks.filter(check => check.name === name); return matched.length === 1 && matched[0]?.state === "passed"; })) blockers.push("exact-head full CI is not green");
384
+ return { head: evidence.head, ready: blockers.length === 0, blockers };
385
+ }
386
+
387
+ export interface ReviewReadiness { head?: string; ready: boolean; blockers: string[]; reusedRun?: number }
388
+ /** Metadata-only pre-review admission; no remote body or comment text is requested. */
389
+ export async function readReviewReadiness(receipt: SdlcReceipt, expected: string | undefined, run: SdlcGh = sdlcGh, requiredChecks: readonly string[] = []): Promise<ReviewReadiness> {
390
+ const unknown = (): ReviewReadiness => ({ ready: false, blockers: ["review readiness unavailable"] });
391
+ if (!repo(receipt.repo) || !id(receipt.pr)) return unknown();
392
+ const [owner, name] = receipt.repo.split("/");
393
+ const query = "query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){headRefOid state commits(last:1){nodes{commit{oid tree{oid} statusCheckRollup{contexts(first:100){nodes{... on CheckRun{name status conclusion} ... on StatusContext{context state}} pageInfo{hasNextPage}}}}}} reviewThreads(first:100){nodes{isResolved comments(first:100){nodes{author{__typename} createdAt} pageInfo{hasNextPage}}} pageInfo{hasNextPage}}}}}";
394
+ try {
395
+ const deadline = performance.now() + 20_000;
396
+ const bounded: SdlcGh = (args, timeout, cwd, cap) => performance.now() < deadline
397
+ ? run(args, Math.max(1, Math.min(timeout ?? 10_000, deadline - performance.now())), cwd, cap)
398
+ : Promise.resolve({ code: 1, stdout: "" });
399
+ const args = ["api", "graphql", "-f", `query=${query}`, "-f", `owner=${owner}`, "-f", `name=${name}`, "-F", `number=${receipt.pr}`];
400
+ const response = await bounded(args, 10_000, undefined, 512 * 1024);
401
+ if (response.code !== 0 || Buffer.byteLength(response.stdout) > 512 * 1024) return unknown();
402
+ const parsed = JSON.parse(response.stdout), pr = parsed.data?.repository?.pullRequest;
403
+ if (parsed.errors || !pr || !sha(pr.headRefOid)) return unknown();
404
+ const blockers: string[] = [];
405
+ if (!expected || !sha(expected)) blockers.push("review head missing");
406
+ else if (pr.headRefOid !== expected) blockers.push("review head changed");
407
+ if (pr.state !== "OPEN") blockers.push("review PR is not open");
408
+ const commit = pr.commits?.nodes?.[0]?.commit;
409
+ if (!commit || commit.oid !== pr.headRefOid) blockers.push("review head changed");
410
+ const contexts = commit?.statusCheckRollup?.contexts;
411
+ const nodes: unknown[] | undefined = contexts?.nodes;
412
+ const full = requiredChecks.length ? [...requiredChecks] : ["check (ubuntu-latest)", "check (macos-latest)", "seeded guards"];
413
+ let reusedRun: number | undefined;
414
+ if (contexts?.pageInfo?.hasNextPage !== false || !Array.isArray(nodes) || nodes.length > 100
415
+ || !full.every(name => {
416
+ const matches = (nodes as { name?: string; context?: string; status?: string; conclusion?: string; state?: string }[]).filter(node => (node?.name ?? node?.context) === name);
417
+ return matches.length === 1 && (matches[0]?.name ? matches[0].status === "COMPLETED" && matches[0].conclusion === "SUCCESS" : matches[0]?.state === "SUCCESS");
418
+ })) blockers.push("exact-head full CI is not green");
419
+ if (blockers.includes("exact-head full CI is not green") && contexts?.pageInfo?.hasNextPage === false && Array.isArray(nodes)
420
+ && full.every(name => { const matches = (nodes as { name?: string; conclusion?: string }[]).filter(node => node?.name === name); return matches.length === 1 && matches[0]?.conclusion === "SKIPPED"; }) && sha(commit?.tree?.oid)) {
421
+ reusedRun = await identicalTreeGate(receipt.repo, commit.tree.oid, bounded, full);
422
+ if (reusedRun !== undefined) {
423
+ // Proof reads can await several API calls: recheck the complete head/check/thread snapshot before admission.
424
+ const after = await bounded(args, 10_000, undefined, 512 * 1024);
425
+ try { if (after.code !== 0 || JSON.stringify(JSON.parse(after.stdout)?.data?.repository?.pullRequest) !== JSON.stringify(pr)) reusedRun = undefined; }
426
+ catch { reusedRun = undefined; }
427
+ if (reusedRun !== undefined) blockers.splice(blockers.indexOf("exact-head full CI is not green"), 1);
428
+ }
429
+ }
430
+ const threads = pr.reviewThreads;
431
+ if (threads?.pageInfo?.hasNextPage !== false || !Array.isArray(threads.nodes) || threads.nodes.length > 100) blockers.push("bot review thread evidence unavailable");
432
+ else for (const thread of threads.nodes) {
433
+ if (thread?.isResolved === true) continue;
434
+ if (thread?.isResolved !== false) { blockers.push("bot review thread evidence unavailable"); continue; }
435
+ const comments = thread?.comments;
436
+ if (comments?.pageInfo?.hasNextPage !== false || !Array.isArray(comments.nodes) || comments.nodes.length > 100
437
+ || comments.nodes.some((comment: { author?: { __typename?: string }; createdAt?: string }) => !["Bot", "User"].includes(comment?.author?.__typename ?? "") || !Number.isFinite(Date.parse(comment.createdAt ?? "")))) {
438
+ blockers.push("bot review thread evidence unavailable"); continue;
439
+ }
440
+ let unanswered = false;
441
+ for (const comment of comments.nodes) {
442
+ if (comment.author.__typename === "Bot") unanswered = true;
443
+ else if (comment.author.__typename === "User") unanswered = false;
444
+ }
445
+ if (unanswered) blockers.push("bot review threads unanswered");
446
+ }
447
+ return { head: pr.headRefOid, ready: blockers.length === 0, blockers: [...new Set(blockers)], ...(reusedRun !== undefined ? { reusedRun } : {}) };
448
+ } catch { return unknown(); }
449
+ }
450
+
451
+ /** Same proof floor as scripts/ci-gates.mjs: the identical tree includes the workflow itself. No skipped proof. */
452
+ async function identicalTreeGate(repository: string, tree: string, run: SdlcGh, required: readonly string[]): Promise<number | undefined> {
453
+ try {
454
+ const list = await run(["api", `repos/${repository}/actions/workflows/check.yml/runs?status=success&per_page=20`, "--jq", "{total_count,workflow_runs:[.workflow_runs[]|{id,event,conclusion,head_sha}]}"], 10_000, undefined, 64 * 1024);
455
+ if (list.code !== 0) return;
456
+ const runs = JSON.parse(list.stdout)?.workflow_runs;
457
+ if (!Array.isArray(runs) || runs.length > 20) return;
458
+ for (const candidate of runs) {
459
+ if (!id(candidate?.id) || candidate.conclusion !== "success" || !["pull_request", "push"].includes(candidate.event) || !sha(candidate.head_sha)) continue;
460
+ const commit = await run(["api", `repos/${repository}/git/commits/${candidate.head_sha}`, "--jq", "{tree:{sha:.tree.sha}}"], 10_000, undefined, 4096);
461
+ if (commit.code !== 0 || JSON.parse(commit.stdout)?.tree?.sha !== tree) continue;
462
+ const jobs = await run(["api", `repos/${repository}/actions/runs/${candidate.id}/jobs?per_page=100`, "--jq", '{total_count,jobs:[.jobs[]|{name,conclusion,reused:any(.steps[]?; .name=="Reuse verified identical-tree full gate" and .conclusion=="success")}]}'], 10_000, undefined, 32 * 1024);
463
+ if (jobs.code !== 0) continue;
464
+ const proof = JSON.parse(jobs.stdout);
465
+ if (!Array.isArray(proof.jobs) || proof.jobs.length > 100 || proof.total_count !== proof.jobs.length) continue;
466
+ if (required.every(name => {
467
+ const found = proof.jobs.filter((job: { name?: string }) => job?.name === name); return found.length === 1 && found[0]?.conclusion === "success" && found[0]?.reused !== true;
468
+ })) return candidate.id;
469
+ }
470
+ } catch { return; }
471
+ }
472
+
473
+ /** PR cost evidence excludes titles, bodies and comment text; unknown/truncated coverage never becomes zero. */
474
+ export async function readPrCostHistory(repository: string, number: number, run: SdlcGh = sdlcGh): Promise<import("./cost-governance.ts").CostPr | undefined> {
475
+ if (!repo(repository) || !id(number)) return;
476
+ const [owner, name] = repository.split("/");
477
+ try {
478
+ const query = "query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){number state headRefOid mergedAt commits(first:100){nodes{commit{oid}} pageInfo{hasNextPage}} reviews(first:100){nodes{databaseId commit{oid}} pageInfo{hasNextPage}}}}}";
479
+ const reply = await run(["api", "graphql", "-f", `query=${query}`, "-f", `owner=${owner}`, "-f", `name=${name}`, "-F", `number=${number}`], 10_000, undefined, 128 * 1024);
480
+ if (reply.code !== 0) return;
481
+ const parsed = JSON.parse(reply.stdout), pr = parsed.data?.repository?.pullRequest;
482
+ if (parsed.errors || pr?.number !== number || !["OPEN", "CLOSED", "MERGED"].includes(pr.state)) return;
483
+ const result: import("./cost-governance.ts").CostPr = { repo: repository, pr: number, state: pr.state, complete: false,
484
+ ...(typeof pr.mergedAt === "string" && Number.isFinite(Date.parse(pr.mergedAt)) ? { mergedAt: Date.parse(pr.mergedAt) } : {}) };
485
+ if (pr.state !== "MERGED") return result; // Cost metrics count merged PRs, not an active work queue.
486
+ const reviews = pr.reviews, commits = pr.commits;
487
+ const reviewComplete = reviews?.pageInfo?.hasNextPage === false && Array.isArray(reviews.nodes) && reviews.nodes.length <= 100 && reviews.nodes.every((node: { databaseId?: number }) => id(node?.databaseId));
488
+ const commitComplete = commits?.pageInfo?.hasNextPage === false && Array.isArray(commits.nodes) && commits.nodes.length <= 100 && sha(pr.headRefOid) && commits.nodes.every((node: { commit?: { oid?: string } }) => sha(node?.commit?.oid));
489
+ if (reviewComplete) result.reviews = new Set(reviews.nodes.map((review: { databaseId?: unknown }) => review.databaseId).filter((id: unknown) => Number.isSafeInteger(id))).size;
490
+ const heads = new Set<string>([pr.headRefOid, ...(commits?.nodes ?? []).map((node: { commit?: { oid?: string } }) => node?.commit?.oid), ...(reviews?.nodes ?? []).map((node: { commit?: { oid?: string } }) => node?.commit?.oid)].filter(sha));
491
+ const checks = await run(["api", `repos/${repository}/actions/workflows/check.yml/runs?per_page=100`, "--jq", "{total_count,workflow_runs:[.workflow_runs[]|{id,run_attempt,head_sha,pull_requests:[.pull_requests[].number]}]}"], 10_000, undefined, 128 * 1024);
492
+ if (checks.code !== 0) return result;
493
+ const runs = JSON.parse(checks.stdout);
494
+ if (!Array.isArray(runs.workflow_runs) || runs.workflow_runs.length > 100 || !Number.isSafeInteger(runs.total_count) || runs.total_count < runs.workflow_runs.length) return result;
495
+ const linked = runs.workflow_runs.filter((row: { head_sha?: string; pull_requests?: number[] }) => heads.has(row.head_sha ?? "") || Array.isArray(row.pull_requests) && row.pull_requests.includes(number));
496
+ if (linked.some((row: { id?: number; run_attempt?: number }) => !id(row.id) || !id(row.run_attempt))) return result;
497
+ result.ciRuns = linked.map((row: { id: number; run_attempt: number }) => ({ id: row.id, attempt: row.run_attempt }));
498
+ result.complete = reviewComplete && commitComplete && runs.total_count === runs.workflow_runs.length;
499
+ return result;
500
+ } catch { return; }
501
+ }
@@ -1,5 +1,6 @@
1
- import { definitionOf, ADAPTERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
1
+ import { definitionOf, type KindMap, ADAPTERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
2
2
  import { createHash } from "node:crypto";
3
+ import { types } from "node:util";
3
4
  import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
5
  import { tmpdir } from "node:os";
5
6
  import { basename, dirname, join } from "node:path";
@@ -70,12 +71,37 @@ export const SETTINGS: readonly SettingDef[] = [
70
71
  * Never editable from settings, whatever the session: these choose what the hub executes or where secrets and task
71
72
  * text go. A key equal to an entry, or below it, is refused by name.
72
73
  */
73
- export const NEVER_EDITABLE = ["kimi_cmd", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "codex_bin", "pi.cmd", "checks", "mlx", "omniroute", "memory.worker_url", "local.read_allow", "local.bash_network", "local.network_allow", "bench.enabled", "terminal"] as const;
74
+ export const NEVER_EDITABLE = ["kimi_cmd", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "codex_bin", "pi.cmd", "checks", "mlx", "omniroute", "memory.worker_url", "local.read_allow", "local.bash_network", "local.network_allow", "bench.enabled", "terminal", "instances"] as const;
75
+
76
+ /** Derive project-bound rows from the same builtin templates used by lookup, without registering aliases globally. */
77
+ export function projectSettings(instances?: KindMap): readonly SettingDef[] {
78
+ if (!instances || typeof instances !== "object" || types.isProxy(instances) || Array.isArray(instances)
79
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(instances)) || Object.getOwnPropertySymbols(instances).length) return SETTINGS;
80
+ const extra: SettingDef[] = [];
81
+ for (const peer of Object.keys(instances)) {
82
+ if (definitionOf(peer)) continue; // An explicit declaration never changes a builtin's setting definition.
83
+ const adapter = definitionOf(peer, instances);
84
+ if (!adapter) continue;
85
+ const copy = (base: SettingDef, key: string): SettingDef => Object.freeze({ ...base, key,
86
+ label: base.label.replace(`${adapter.kind}:`, `${peer}:`),
87
+ path: Object.freeze(base.path.map(part => part === adapter.kind ? peer : part)),
88
+ ...(base.values ? { values: Object.freeze([...base.values]) } : {}),
89
+ });
90
+ for (const prefix of ["roster", "permission", "permission_modes"]) {
91
+ const base = SETTINGS.find(def => def.key === `${prefix}.${adapter.kind}`);
92
+ if (base) extra.push(copy(base, `${prefix}.${peer}`));
93
+ }
94
+ // I2 declarations select the headless engine; settings cannot opt an extra into a native TUI.
95
+ extra.push(Object.freeze({ key: `peers.${peer}.start_mode`, group: "Start", label: `${peer}: start mode (headless only)`,
96
+ type: "enum", values: Object.freeze(["headless"]), store: "config", applies: "next use", risk: "raises", floor: "headless", path: Object.freeze(["peers", peer, "start_mode"]) } satisfies SettingDef));
97
+ }
98
+ return extra.length ? Object.freeze([...SETTINGS, ...extra]) : SETTINGS;
99
+ }
74
100
 
75
101
  /** The definition of a key, or why there is none. */
76
- export function settingDef(key: unknown): SettingDef | string {
102
+ export function settingDef(key: unknown, instances?: KindMap): SettingDef | string {
77
103
  if (typeof key !== "string" || key.length > 80) return "unknown setting";
78
- const found = SETTINGS.find((s) => s.key === key);
104
+ const found = projectSettings(instances).find((s) => s.key === key);
79
105
  if (found) return found;
80
106
  if (NEVER_EDITABLE.some((never) => key === never || key.startsWith(`${never}.`))) return `${key} chooses what the hub runs or where secrets and task text go: it is never editable from settings; edit .agenthub/config.local.json in your editor`;
81
107
  return "unknown setting; run ahub settings list";
@@ -390,6 +416,8 @@ const same = (a: unknown, b: unknown): boolean => JSON.stringify(a ?? null) ===
390
416
  */
391
417
  export function settingRows(input: {
392
418
  cwd: string; routing: Routing;
419
+ /** Captured trusted declarations for this daemon/project, never taken from a settings request. */
420
+ instances?: KindMap;
393
421
  running: (path: readonly string[]) => unknown;
394
422
  next: ((path: readonly string[]) => unknown) | undefined;
395
423
  mode: (peer: string) => { value?: string; note?: string };
@@ -404,9 +432,9 @@ export function settingRows(input: {
404
432
  const projectRouting = existsSync(join(input.cwd, ".agenthub", "routing.toml"));
405
433
  const routes = [...Object.keys(input.routing.routes), ...Object.keys(input.routing.hub_routes ?? {})];
406
434
  const scalar = (value: unknown): SettingValue => typeof value === "string" || typeof value === "boolean" || typeof value === "number" ? value : Array.isArray(value) ? value.map(String) : null;
407
- return SETTINGS.map((def): SettingRow => {
435
+ return projectSettings(input.instances).map((def): SettingRow => {
408
436
  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 }),
409
- ...(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)!) } : {}) };
437
+ ...(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) } : {}) };
410
438
  if (def.store === "runtime") {
411
439
  const mode = input.mode(def.path[0]!);
412
440
  return { ...base, values: def.values!, file: "this hub run", value: mode.value ?? null, fileValue: mode.value ?? null, source: "this hub run", ...(mode.note ? { note: mode.note } : {}) };