@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.
- package/CHANGELOG.md +27 -0
- package/README.md +8 -6
- package/docs/agent-notes/adapters.md +6 -1
- package/docs/agent-notes/budget.md +4 -2
- package/docs/agent-notes/bus.md +5 -1
- package/docs/agent-notes/daemon.md +11 -3
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/tasks.md +15 -2
- package/docs/agent-notes/tests.md +5 -1
- package/docs/gsd-collaboration-analysis.md +5 -1
- package/docs/operations.md +97 -19
- package/docs/quickstart.md +8 -4
- package/docs/sdlc.md +100 -34
- package/docs/security.md +7 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +237 -7
- package/docs/verification/2026-10-18-0.12.35.md +49 -0
- package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
- package/docs/verification/2026-10-19-0.12.36.md +63 -0
- package/docs/verified.json +26 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +15781 -14850
- package/src/adapters/acp.ts +3 -1
- package/src/adapters/claude-channel.ts +59 -29
- package/src/adapters/claude-print.ts +230 -0
- package/src/adapters/codex-appserver.ts +1 -1
- package/src/adapters/definitions/builtin-metadata.ts +17 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -5
- package/src/adapters/definitions/native-cli-entry-jcode.ts +6 -2
- package/src/adapters/definitions/native-cli-entry-kimi.ts +18 -12
- package/src/adapters/definitions/native-cli-entry-local.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-pi.ts +8 -6
- package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-types.ts +3 -0
- package/src/adapters/definitions/native-factories.ts +7 -0
- package/src/adapters/definitions/native-recovery.ts +2 -2
- package/src/adapters/definitions/qwen-native.ts +2 -2
- package/src/adapters/local-worker.ts +3 -2
- package/src/adapters/pi.ts +3 -1
- package/src/adapters/registry-types.ts +11 -2
- package/src/adapters/registry.ts +36 -8
- package/src/cli/help.ts +5 -0
- package/src/cli/identity.ts +5 -4
- package/src/cli/launch.ts +45 -7
- package/src/cli/main.ts +136 -29
- package/src/cli/preview.ts +16 -5
- package/src/cli/recovery-guidance.ts +39 -22
- package/src/cli/reset.ts +4 -2
- package/src/cli/statusline-tee.ts +5 -2
- package/src/cli/terminal-recovery.ts +73 -16
- package/src/cli/upgrade-restore-state.ts +16 -7
- package/src/cli/upgrade-runtime.ts +83 -52
- package/src/cli/upgrade.ts +43 -27
- package/src/hub/board.ts +29 -7
- package/src/hub/budget.ts +55 -3
- package/src/hub/bus.ts +12 -6
- package/src/hub/claude-state.ts +37 -0
- package/src/hub/conductor.ts +16 -10
- package/src/hub/config-apply.ts +13 -2
- package/src/hub/config-reload.ts +11 -1
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/control-client.ts +1 -1
- package/src/hub/cost-governance.ts +76 -0
- package/src/hub/crash.ts +14 -5
- package/src/hub/daemon.ts +473 -194
- package/src/hub/execution-budget.ts +3 -3
- package/src/hub/hub-tools.ts +36 -6
- package/src/hub/instance-budget.ts +73 -0
- package/src/hub/instance-config.ts +47 -0
- package/src/hub/instance-execution.ts +50 -0
- package/src/hub/instances.ts +114 -0
- package/src/hub/lifecycle-schema.ts +7 -3
- package/src/hub/permission-mode.ts +7 -7
- package/src/hub/review-brief.ts +175 -0
- package/src/hub/roster.ts +7 -6
- package/src/hub/routing.ts +97 -18
- package/src/hub/sdlc-queue.ts +2 -2
- package/src/hub/sdlc.ts +286 -39
- package/src/hub/settings.ts +34 -6
- package/src/hub/snapshots.ts +40 -9
- package/src/hub/start-mode.ts +7 -4
- package/src/hub/status-roster.ts +18 -0
- package/src/hub/task-sweep.ts +143 -0
- package/src/hub/tasks.ts +595 -47
- package/src/hub/text-projection.ts +7 -3
- package/src/hub/usage.ts +57 -20
- package/src/hub/workflow.ts +30 -52
- package/src/pi/launch.ts +4 -3
- package/src/ui/index.html +1 -1
- package/templates/AGENTS.block.md +2 -1
package/src/hub/sdlc.ts
CHANGED
|
@@ -1,20 +1,50 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
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" | "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
const
|
|
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 (!
|
|
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)) || !
|
|
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
|
|
72
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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" :
|
|
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
|
-
|
|
170
|
-
export
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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[],
|
|
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 === "
|
|
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
|
+
}
|
package/src/hub/settings.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
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 } : {}) };
|