@staix/agent-hub 0.12.22 → 0.12.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +117 -0
- package/README.md +22 -9
- package/docs/addons/claude-mem.md +24 -0
- package/docs/addons/codex-plugin.md +8 -0
- package/docs/addons/dot-admit.md +8 -0
- package/docs/addons/open-code-review.md +8 -0
- package/docs/addons/ponytail.md +23 -0
- package/docs/addons/ripwire.md +22 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +18 -6
- package/docs/agent-notes/benchmarks.md +3 -0
- package/docs/agent-notes/budget.md +4 -0
- package/docs/agent-notes/bus.md +3 -1
- package/docs/agent-notes/daemon.md +14 -1
- package/docs/agent-notes/local-worker.md +6 -3
- package/docs/agent-notes/tasks.md +6 -1
- package/docs/agent-notes/tests.md +4 -1
- package/docs/bench.md +28 -3
- package/docs/cooperbench.md +4 -0
- package/docs/data-policy.md +36 -0
- package/docs/events.md +10 -3
- package/docs/gsd-collaboration-analysis.md +48 -0
- package/docs/memory.md +231 -0
- package/docs/operations.md +873 -30
- package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
- package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
- package/docs/profiles.md +57 -0
- package/docs/quickstart.md +9 -4
- package/docs/research.md +31 -1
- package/docs/roles.md +85 -0
- package/docs/sdlc.md +170 -0
- package/docs/security.md +124 -8
- package/docs/smoke.md +354 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +676 -15
- package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
- package/docs/specs/2026-10-12-office-view-design.md +212 -0
- package/docs/text-projection.md +19 -0
- package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
- package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
- package/docs/verification/2026-10-11-0.12.23.md +90 -0
- package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
- package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
- package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
- package/docs/verification/2026-10-13-0.12.24.md +94 -0
- package/docs/verification/2026-10-13-board-phone.md +31 -0
- package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
- package/docs/verified.json +34 -16
- package/package.json +2 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
- package/plugins/agent-hub/server.js +1108 -261
- package/scripts/check-docs.mjs +129 -0
- package/src/adapters/acp-quota.ts +13 -0
- package/src/adapters/acp.ts +201 -20
- package/src/adapters/claude-channel.ts +48 -11
- package/src/adapters/codex-appserver.ts +54 -13
- package/src/adapters/jcode.ts +110 -0
- package/src/adapters/local-worker.ts +10 -7
- package/src/adapters/pi.ts +94 -20
- package/src/cli/attention.ts +60 -0
- package/src/cli/bench.ts +26 -6
- package/src/cli/board-read.ts +12 -0
- package/src/cli/console-state.ts +131 -39
- package/src/cli/console.ts +73 -17
- package/src/cli/facts-hook.ts +12 -4
- package/src/cli/help.ts +52 -21
- package/src/cli/identity.ts +19 -8
- package/src/cli/init.ts +48 -10
- package/src/cli/jcode-mcp.ts +58 -0
- package/src/cli/kimi-mcp.ts +53 -0
- package/src/cli/launch.ts +16 -1
- package/src/cli/main.ts +641 -193
- package/src/cli/memory.ts +193 -0
- package/src/cli/output.ts +404 -0
- package/src/cli/pet-config.ts +1 -0
- package/src/cli/pet.ts +83 -0
- package/src/cli/preview.ts +40 -6
- package/src/cli/reset.ts +3 -0
- package/src/cli/status-lines.ts +1 -29
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/terminal-recovery.ts +27 -1
- package/src/cli/upgrade-runtime.ts +56 -10
- package/src/cli/upgrade.ts +23 -5
- package/src/cli/watch.ts +31 -0
- package/src/hub/activity.ts +53 -0
- package/src/hub/ask.ts +10 -5
- package/src/hub/bench.ts +10 -3
- package/src/hub/board.ts +224 -16
- package/src/hub/budget.ts +39 -7
- package/src/hub/bus.ts +58 -20
- package/src/hub/child-process.ts +11 -0
- package/src/hub/citations.ts +7 -0
- package/src/hub/conductor.ts +34 -17
- package/src/hub/config-trust.ts +20 -7
- package/src/hub/conflicts.ts +2 -0
- package/src/hub/context-window.ts +8 -1
- package/src/hub/crash.ts +11 -1
- package/src/hub/daemon.ts +1218 -113
- package/src/hub/db-timeout.ts +3 -0
- package/src/hub/delivery-journal.ts +7 -1
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +14 -2
- package/src/hub/execution-budget.ts +2 -0
- package/src/hub/facts.ts +28 -8
- package/src/hub/git.ts +38 -0
- package/src/hub/github-import.ts +28 -0
- package/src/hub/hub-tools.ts +49 -10
- package/src/hub/inference.ts +1 -1
- package/src/hub/lifecycle-schema.ts +17 -0
- package/src/hub/lifecycle.ts +10 -4
- package/src/hub/listen.ts +15 -0
- package/src/hub/manager.ts +26 -6
- package/src/hub/path-policy.ts +96 -0
- package/src/hub/peer-catalog.ts +28 -0
- package/src/hub/peers.ts +5 -3
- package/src/hub/permission-mode.ts +33 -5
- package/src/hub/planning.ts +70 -0
- package/src/hub/profiles.ts +74 -0
- package/src/hub/project.ts +2 -1
- package/src/hub/projected-diff.ts +73 -0
- package/src/hub/projection-exec.ts +24 -0
- package/src/hub/recovery-error.ts +31 -0
- package/src/hub/recovery-store.ts +6 -5
- package/src/hub/registry.ts +2 -1
- package/src/hub/research.ts +144 -21
- package/src/hub/roles.ts +123 -0
- package/src/hub/roster.ts +23 -0
- package/src/hub/routing.ts +132 -18
- package/src/hub/sdlc-queue.ts +112 -0
- package/src/hub/sdlc-release.ts +125 -0
- package/src/hub/sdlc.ts +250 -0
- package/src/hub/settings.ts +411 -0
- package/src/hub/shipped-roles.ts +25 -0
- package/src/hub/snapshots.ts +92 -16
- package/src/hub/start-mode.ts +50 -0
- package/src/hub/supervision.ts +1 -0
- package/src/hub/task-sweep.ts +1 -1
- package/src/hub/tasks.ts +1014 -111
- package/src/hub/terminal-open.ts +64 -0
- package/src/hub/text-projection.ts +126 -0
- package/src/hub/tooling.ts +159 -0
- package/src/hub/ui.ts +40 -14
- package/src/hub/workflow.ts +71 -0
- package/src/local/deny.ts +29 -7
- package/src/local/sandbox.ts +50 -10
- package/src/local/tools.ts +11 -6
- package/src/memory/brief.ts +28 -14
- package/src/memory/import.ts +55 -0
- package/src/memory/recall.ts +86 -2
- package/src/memory/store.ts +750 -0
- package/src/memory/sync.ts +145 -0
- package/src/models/mlx.ts +2 -1
- package/src/pet/NOTICE.md +10 -0
- package/src/pet/characters.ts +1438 -0
- package/src/pet/config.ts +31 -0
- package/src/pet/frame.ts +17 -0
- package/src/pet/pixels.ts +28 -0
- package/src/pi/tool-receipts.ts +2 -1
- package/src/role-text.d.ts +1 -0
- package/src/ui/index.html +983 -40
- package/src/ui/needs.ts +58 -0
- package/src/ui/office-model.ts +440 -0
- package/src/ui/peer-palette.ts +16 -0
- package/src/ui/task-board.ts +49 -0
- package/src/ui/task-progress.ts +5 -1
- package/templates/AGENTS.block.md +3 -2
- package/templates/config.json +5 -1
- package/templates/profiles/dev.json +22 -0
- package/templates/profiles/general.json +26 -0
- package/templates/profiles/research.json +57 -0
- package/templates/profiles/writing.json +28 -0
- package/templates/roles/NOTICE.md +26 -0
- package/templates/roles/dev/backend-engineer.md +26 -0
- package/templates/roles/dev/code-reviewer.md +26 -0
- package/templates/roles/dev/frontend-engineer.md +26 -0
- package/templates/roles/dev/security-reviewer.md +26 -0
- package/templates/roles/dev/software-architect.md +26 -0
- package/templates/roles/dev/test-engineer.md +26 -0
- package/templates/roles/research/analyst.md +26 -0
- package/templates/roles/research/fact-checker.md +26 -0
- package/templates/roles/research/research-lead.md +26 -0
- package/templates/roles/research/researcher.md +26 -0
- package/templates/roles/research/statistician.md +26 -0
package/src/hub/sdlc.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { phaseReceipt, type PhaseReceipt, type PhaseView } from "./workflow.ts";
|
|
2
|
+
import { constants, openSync, fstatSync, readSync, closeSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { childEnv, stopOwnedProcess } from "./child-process.ts";
|
|
6
|
+
|
|
7
|
+
export const SDLC_STAGES = ["spec", "plan", "implement", "review", "verify", "merge", "release"] as const;
|
|
8
|
+
export type SdlcBaseStage = typeof SDLC_STAGES[number];
|
|
9
|
+
export type SdlcStage = string;
|
|
10
|
+
export type StageGate = "issue" | "pr" | "local" | "review" | "checks" | "phase";
|
|
11
|
+
export interface StageDefinition { base: SdlcBaseStage; actor: string; entry_gates: StageGate[]; exit_gates: StageGate[]; after: string[]; }
|
|
12
|
+
export type SdlcMode = "off" | "advise" | "enforce";
|
|
13
|
+
export interface SdlcConfig { workflow?: "gsd-lite"; mode: SdlcMode; requiredChecks: string[]; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; }
|
|
14
|
+
export interface SdlcReceipt {
|
|
15
|
+
stage: SdlcStage;
|
|
16
|
+
phase?: PhaseReceipt;
|
|
17
|
+
repo?: string;
|
|
18
|
+
issue?: number;
|
|
19
|
+
pr?: number;
|
|
20
|
+
head?: string;
|
|
21
|
+
reviewedHead?: string;
|
|
22
|
+
localHead?: string;
|
|
23
|
+
localResult?: "passed" | "failed" | "unknown";
|
|
24
|
+
observed?: number;
|
|
25
|
+
}
|
|
26
|
+
export interface PrEvidence {
|
|
27
|
+
head: string;
|
|
28
|
+
state: "OPEN" | "MERGED" | "CLOSED";
|
|
29
|
+
checks: { name: string; state: "passed" | "failed" | "pending" | "unknown" }[];
|
|
30
|
+
review: "approved" | "changes_requested" | "unknown";
|
|
31
|
+
unresolved: number | null;
|
|
32
|
+
pendingReviews: number | null;
|
|
33
|
+
paths: string[];
|
|
34
|
+
pathsComplete?: boolean;
|
|
35
|
+
draft?: boolean;
|
|
36
|
+
base?: string;
|
|
37
|
+
baseHead?: string;
|
|
38
|
+
mergeCommit?: string;
|
|
39
|
+
}
|
|
40
|
+
const sha = (value: unknown): value is string => typeof value === "string" && /^[0-9a-f]{40}$/.test(value);
|
|
41
|
+
const repo = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value) && !value.startsWith("-");
|
|
42
|
+
const id = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
43
|
+
|
|
44
|
+
const STAGE_ID = /^[a-z][a-z0-9_-]{0,47}$/;
|
|
45
|
+
const STAGE_GATES: StageGate[] = ["issue", "pr", "local", "review", "checks", "phase"];
|
|
46
|
+
const baseActor = (base: SdlcBaseStage) => base === "review" || base === "verify" ? "reviewer" : base === "merge" || base === "release" ? "person" : "owner/planner";
|
|
47
|
+
export function stageDefinition(config: SdlcConfig, stage: string): StageDefinition | undefined {
|
|
48
|
+
if (!config.stages.includes(stage)) return undefined;
|
|
49
|
+
if (config.definitions && Object.hasOwn(config.definitions, stage)) return config.definitions[stage];
|
|
50
|
+
if (!(SDLC_STAGES as readonly string[]).includes(stage)) return undefined;
|
|
51
|
+
const base = stage as SdlcBaseStage;
|
|
52
|
+
return { base, actor: baseActor(base), entry_gates: [], exit_gates: [], after: [] };
|
|
53
|
+
}
|
|
54
|
+
export function lifecycleStage(config: SdlcConfig, base: SdlcBaseStage): string {
|
|
55
|
+
return config.stages.find(stage => stageDefinition(config, stage)?.base === base) ?? base;
|
|
56
|
+
}
|
|
57
|
+
function definitions(raw: unknown, stages: unknown): { stages: string[]; definitions: Record<string, StageDefinition> } {
|
|
58
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw) || Object.keys(raw).length > 32) throw new Error("sdlc.definitions: expected at most 32 stage definitions");
|
|
59
|
+
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");
|
|
60
|
+
const result: Record<string, StageDefinition> = Object.create(null);
|
|
61
|
+
for (const [id, value] of Object.entries(raw)) {
|
|
62
|
+
if (!STAGE_ID.test(id) || !stages.includes(id) || !value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdlc.definitions: invalid or unlisted stage");
|
|
63
|
+
const def = value as Record<string, unknown>;
|
|
64
|
+
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");
|
|
65
|
+
const base = def.base as SdlcBaseStage;
|
|
66
|
+
if ((SDLC_STAGES as readonly string[]).includes(id) && id !== base) throw new Error("sdlc.definitions: built-in stage base cannot change");
|
|
67
|
+
const actor = def.actor ?? baseActor(base);
|
|
68
|
+
if (typeof actor !== "string" || actor !== baseActor(base) && !["owner", "reviewer", "person", "planner"].includes(actor)) throw new Error("sdlc.definitions.actor: unknown actor");
|
|
69
|
+
const gates = (key: string): StageGate[] => {
|
|
70
|
+
const list = def[key] ?? [];
|
|
71
|
+
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`);
|
|
72
|
+
return [...list] as StageGate[];
|
|
73
|
+
};
|
|
74
|
+
const after = def.after ?? [];
|
|
75
|
+
if (!Array.isArray(after) || after.length > 8 || after.some(a => typeof a !== "string" || !stages.includes(a) || stages.indexOf(a) >= stages.indexOf(id)) || new Set(after).size !== after.length) throw new Error("sdlc.definitions.after: dependencies must be distinct earlier stages (no missing, self, forward or cyclic edge)");
|
|
76
|
+
result[id] = { base, actor: actor as string, entry_gates: gates("entry_gates"), exit_gates: gates("exit_gates"), after: [...after] as string[] };
|
|
77
|
+
}
|
|
78
|
+
const config = { stages, definitions: result } as SdlcConfig;
|
|
79
|
+
if (stages.some(s => !stageDefinition(config, s)) || !["review", "merge"].every(base => stages.some(s => stageDefinition(config, s)?.base === base))) throw new Error("sdlc.stages: define every custom id and include review and merge base destinations");
|
|
80
|
+
return { stages: [...stages], definitions: result };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function sdlcConfig(value: unknown): SdlcConfig {
|
|
84
|
+
if (value === undefined) return { mode: "off", requiredChecks: [], stages: [...SDLC_STAGES] };
|
|
85
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdlc: expected a table");
|
|
86
|
+
const raw = value as Record<string, unknown>;
|
|
87
|
+
for (const key of Object.keys(raw)) if (!["mode", "required_checks", "stages", "workflow", "definitions"].includes(key)) throw new Error(`sdlc.${key}: unknown field`);
|
|
88
|
+
const mode = raw.mode ?? "off";
|
|
89
|
+
if (mode !== "off" && mode !== "advise" && mode !== "enforce") throw new Error("sdlc.mode: expected off, advise or enforce");
|
|
90
|
+
const workflow = raw.workflow ?? "none";
|
|
91
|
+
if (workflow !== "none" && workflow !== "gsd-lite") throw new Error("sdlc.workflow: expected none or gsd-lite");
|
|
92
|
+
const checks = raw.required_checks ?? [];
|
|
93
|
+
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");
|
|
94
|
+
if (raw.definitions !== undefined) {
|
|
95
|
+
const custom = definitions(raw.definitions, raw.stages);
|
|
96
|
+
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");
|
|
97
|
+
return { mode, requiredChecks: [...checks] as string[], ...custom, ...(workflow === "gsd-lite" ? { workflow } : {}) };
|
|
98
|
+
}
|
|
99
|
+
const stages = raw.stages ?? [...SDLC_STAGES];
|
|
100
|
+
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");
|
|
101
|
+
return { mode, requiredChecks: [...checks] as string[], stages: [...stages] as SdlcStage[], ...(workflow === "gsd-lite" ? { workflow } : {}) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function loadSdlc(cwd: string): SdlcConfig {
|
|
105
|
+
try {
|
|
106
|
+
const fd = openSync(join(cwd, ".agenthub", "sdlc.toml"), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
107
|
+
try {
|
|
108
|
+
const before = fstatSync(fd), cap = 64 * 1024;
|
|
109
|
+
if (!before.isFile() || before.nlink !== 1 || before.size > cap) throw new Error("sdlc.toml must be a regular single-link file within 64 KiB");
|
|
110
|
+
const bytes = Buffer.alloc(cap + 1); let length = 0, n = 0;
|
|
111
|
+
while ((n = readSync(fd, bytes, length, bytes.length - length, null)) > 0) { length += n; if (length > cap) throw new Error("sdlc.toml exceeds 64 KiB"); }
|
|
112
|
+
const after = fstatSync(fd);
|
|
113
|
+
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || length !== after.size) throw new Error("sdlc.toml changed during read");
|
|
114
|
+
return sdlcConfig(Bun.TOML.parse(bytes.subarray(0, length).toString("utf8")));
|
|
115
|
+
} finally { closeSync(fd); }
|
|
116
|
+
}
|
|
117
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return sdlcConfig(undefined); throw error; }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Identities and claimed local receipts only. External facts are read, never accepted as input. */
|
|
121
|
+
export function sdlcReceipt(value: unknown, previous: SdlcReceipt | undefined = undefined, config: SdlcConfig = sdlcConfig(undefined)): SdlcReceipt {
|
|
122
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdlc receipt: expected an object");
|
|
123
|
+
const raw = value as Record<string, unknown>;
|
|
124
|
+
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`);
|
|
125
|
+
previous ??= { stage: config.stages[0] ?? "spec" };
|
|
126
|
+
const next = { ...previous };
|
|
127
|
+
if (raw.phase !== undefined) next.phase = phaseReceipt(raw.phase);
|
|
128
|
+
if (raw.stage !== undefined) {
|
|
129
|
+
if (typeof raw.stage !== "string" || !stageDefinition(config, raw.stage)) throw new Error("sdlc receipt.stage: unknown stage");
|
|
130
|
+
next.stage = raw.stage as SdlcStage;
|
|
131
|
+
}
|
|
132
|
+
if (raw.repo !== undefined) { if (!repo(raw.repo)) throw new Error("sdlc receipt.repo: expected owner/repository"); next.repo = raw.repo; }
|
|
133
|
+
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]; }
|
|
134
|
+
for (const key of ["reviewedHead", "localHead"] as const) if (raw[key] !== undefined) { if (!sha(raw[key])) throw new Error(`sdlc receipt.${key}: expected a full lowercase SHA`); next[key] = raw[key]; }
|
|
135
|
+
if (raw.localResult !== undefined) {
|
|
136
|
+
if (!["passed", "failed", "unknown"].includes(String(raw.localResult))) throw new Error("sdlc receipt.localResult: expected passed, failed or unknown");
|
|
137
|
+
next.localResult = raw.localResult as SdlcReceipt["localResult"];
|
|
138
|
+
}
|
|
139
|
+
// A new artifact cannot inherit another PR's review or local gate.
|
|
140
|
+
if (next.repo !== previous.repo || next.pr !== previous.pr) {
|
|
141
|
+
delete next.head; delete next.observed;
|
|
142
|
+
if (raw.phase === undefined) delete next.phase;
|
|
143
|
+
if (raw.reviewedHead === undefined) delete next.reviewedHead;
|
|
144
|
+
if (raw.localHead === undefined) delete next.localHead;
|
|
145
|
+
if (raw.localResult === undefined) delete next.localResult;
|
|
146
|
+
}
|
|
147
|
+
return next;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function parsePrEvidence(value: unknown): PrEvidence {
|
|
151
|
+
const p = value as Record<string, any>;
|
|
152
|
+
if (!p || !sha(p.headRefOid) || !["OPEN", "CLOSED", "MERGED"].includes(p.state) || !Array.isArray(p.statusCheckRollup) || !Array.isArray(p.files) || p.files.length > 1000) throw new Error("PR readback: invalid or incomplete structured evidence");
|
|
153
|
+
const checks = p.statusCheckRollup.map((c: Record<string, unknown>) => {
|
|
154
|
+
const name = c.name ?? c.context;
|
|
155
|
+
if (typeof name !== "string" || !name || name.length > 120) throw new Error("PR readback: invalid check identity");
|
|
156
|
+
const native = c.conclusion ?? c.state;
|
|
157
|
+
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";
|
|
158
|
+
return { name, state } as PrEvidence["checks"][number];
|
|
159
|
+
});
|
|
160
|
+
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; });
|
|
161
|
+
return { head: p.headRefOid, state: p.state, checks, paths, pathsComplete: Number.isSafeInteger(p.changedFiles) && p.changedFiles >= 0 && p.changedFiles === paths.length, ...(typeof p.isDraft === "boolean" ? { draft: p.isDraft } : {}), ...(typeof p.baseRefName === "string" && p.baseRefName && p.baseRefName.length <= 200 && !/[\x00-\x1f\x7f]/.test(p.baseRefName) ? { base: p.baseRefName } : {}), ...(sha(p.baseRefOid) ? { baseHead: p.baseRefOid } : {}),
|
|
162
|
+
review: p.reviewDecision === "APPROVED" ? "approved" : p.reviewDecision === "CHANGES_REQUESTED" ? "changes_requested" : "unknown",
|
|
163
|
+
pendingReviews: Array.isArray(p.reviewRequests) && p.reviewRequests.length <= 100 && p.reviewRequests.every((r: unknown) => !!r && typeof r === "object") ? p.reviewRequests.length : null,
|
|
164
|
+
unresolved: Number.isSafeInteger(p.unresolvedThreads) && p.unresolvedThreads >= 0 ? p.unresolvedThreads : null,
|
|
165
|
+
...(sha(p.mergeCommit?.oid) ? { mergeCommit: p.mergeCommit.oid } : {}) };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
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; }
|
|
169
|
+
export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined, evidence?: PrEvidence, phase?: PhaseView): SdlcStatus {
|
|
170
|
+
const stage = receipt?.stage ?? config.stages[0] ?? "spec", blockers: string[] = [];
|
|
171
|
+
const definition = stageDefinition(config, stage);
|
|
172
|
+
if (!definition) blockers.push("lifecycle stage definition unknown");
|
|
173
|
+
if (!receipt?.repo || !receipt.issue) blockers.push("issue identity missing");
|
|
174
|
+
if (!receipt?.pr) blockers.push("PR identity missing");
|
|
175
|
+
if (!evidence) blockers.push("current PR evidence unknown");
|
|
176
|
+
else {
|
|
177
|
+
if (evidence.state !== "OPEN") blockers.push("PR is not open");
|
|
178
|
+
if (receipt?.reviewedHead !== evidence.head) blockers.push("current head has not been reviewed");
|
|
179
|
+
if (receipt?.localHead !== evidence.head || receipt.localResult !== "passed") blockers.push("local gate on current head missing or failed");
|
|
180
|
+
if (!config.requiredChecks.length) blockers.push("required check set unknown");
|
|
181
|
+
for (const name of config.requiredChecks) {
|
|
182
|
+
const matches = evidence.checks.filter(c => c.name === name);
|
|
183
|
+
if (!matches.length || matches.some(c => c.state !== "passed")) blockers.push(`required check ${name}: not successful on current head`);
|
|
184
|
+
}
|
|
185
|
+
if (evidence.review !== "approved") blockers.push("GitHub review approval missing or changes requested");
|
|
186
|
+
if (evidence.pendingReviews !== 0) blockers.push("review requests pending or unknown");
|
|
187
|
+
if (evidence.unresolved !== 0) blockers.push("review threads unresolved or unknown");
|
|
188
|
+
}
|
|
189
|
+
if (config.workflow === "gsd-lite") {
|
|
190
|
+
blockers.unshift(...(phase?.blockers ?? ["phase board evidence unknown"]));
|
|
191
|
+
if (!evidence || receipt?.phase?.head !== evidence.head) blockers.push("phase checklist head missing or stale");
|
|
192
|
+
}
|
|
193
|
+
const custom = config.definitions && definition ? stageGates(definition, blockers, receipt, evidence, phase) : undefined;
|
|
194
|
+
if (custom) for (const blocker of [...custom.entry, ...custom.exit]) if (!blockers.includes(blocker)) blockers.push(blocker);
|
|
195
|
+
return { ...(config.workflow ? { workflow: config.workflow, ...(phase ? { phase } : {}) } : {}), ...(custom ? { definition, ...custom } : {}), mode: config.mode, stage, blockers, ready: blockers.length === 0,
|
|
196
|
+
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",
|
|
197
|
+
next: blockers[0] ?? "person may inspect merge readiness; no automatic merge" };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function stageGates(def: StageDefinition, blockers: string[], receipt: SdlcReceipt | undefined, evidence: PrEvidence | undefined, phase: PhaseView | undefined): { entry: string[]; exit: string[] } {
|
|
201
|
+
const select = (gates: StageGate[]) => [...new Set(gates.flatMap(gate => {
|
|
202
|
+
if (gate === "phase") return [...(phase?.blockers ?? ["phase board evidence unknown"]), ...(!evidence || receipt?.phase?.head !== evidence.head ? ["phase checklist head missing or stale"] : [])];
|
|
203
|
+
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);
|
|
204
|
+
return blockers.filter(matching);
|
|
205
|
+
}))];
|
|
206
|
+
return { entry: select(def.entry_gates), exit: select(def.exit_gates) };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function touchingPrs(prs: { pr: number; paths: string[] }[]): { before: number; after: number; paths: string[] }[] {
|
|
210
|
+
const conflicts: ReturnType<typeof touchingPrs> = [];
|
|
211
|
+
for (let i = 0; i < prs.length; i++) for (const right of prs.slice(i + 1)) {
|
|
212
|
+
const left = prs[i]!, paths = [...new Set(left.paths.filter(path => right.paths.includes(path)))].sort();
|
|
213
|
+
if (paths.length) conflicts.push({ before: left.pr, after: right.pr, paths });
|
|
214
|
+
}
|
|
215
|
+
return conflicts;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export type SdlcGh = (args: string[], timeoutMs?: number, cwd?: string, maxOutputBytes?: number) => Promise<{ code: number; stdout: string }>;
|
|
219
|
+
/** Read-only, fixed argv. Output and time are bounded; diagnostics never expose credentials or remote text. */
|
|
220
|
+
export const sdlcGh: SdlcGh = (args, timeoutMs = 10_000, cwd, maxOutputBytes = 256 * 1024) => new Promise((resolve) => {
|
|
221
|
+
const outputLimit = Number.isSafeInteger(maxOutputBytes) ? Math.min(2 * 1024 * 1024, Math.max(1, maxOutputBytes)) : 256 * 1024;
|
|
222
|
+
const child = spawn("gh", args, { ...(cwd ? { cwd } : {}), env: childEnv(), detached: true, stdio: ["ignore", "pipe", "ignore"] });
|
|
223
|
+
let stdout = "", failed = false;
|
|
224
|
+
const stop = () => { failed = true; void stopOwnedProcess(child, { group: true }).catch(() => {}); };
|
|
225
|
+
const timer = setTimeout(stop, Math.max(1, Math.min(10_000, timeoutMs)));
|
|
226
|
+
child.stdout?.setEncoding("utf8");
|
|
227
|
+
child.stdout?.on("data", chunk => { if (failed) return; stdout += chunk.toString(); if (Buffer.byteLength(stdout) > outputLimit) { stdout = ""; stop(); } });
|
|
228
|
+
child.once("error", () => { failed = true; clearTimeout(timer); resolve({ code: 1, stdout: "" }); });
|
|
229
|
+
child.once("close", code => { clearTimeout(timer); resolve({ code: failed ? 1 : code ?? 1, stdout: failed ? "" : stdout }); });
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
export async function readSdlcPr(receipt: SdlcReceipt, run: SdlcGh = sdlcGh): Promise<PrEvidence | undefined> {
|
|
233
|
+
if (!repo(receipt.repo) || !id(receipt.pr)) return undefined;
|
|
234
|
+
try {
|
|
235
|
+
const view = await run(["pr", "view", String(receipt.pr), "--repo", receipt.repo, "--json", "headRefOid,state,statusCheckRollup,reviewDecision,reviewRequests,files,changedFiles,mergeCommit,isDraft,baseRefName,baseRefOid"]);
|
|
236
|
+
if (view.code !== 0) return undefined;
|
|
237
|
+
const [owner, name] = receipt.repo.split("/");
|
|
238
|
+
const threads = await run(["api", "graphql", "-f", "query=query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){headRefOid reviewThreads(first:100){nodes{isResolved} pageInfo{hasNextPage}}}}}", "-f", `owner=${owner}`, "-f", `name=${name}`, "-F", `number=${receipt.pr}`]);
|
|
239
|
+
const p = JSON.parse(view.stdout);
|
|
240
|
+
delete p.unresolvedThreads; // only the complete thread query can establish this fact
|
|
241
|
+
if (threads.code === 0) {
|
|
242
|
+
const current = JSON.parse(threads.stdout)?.data?.repository?.pullRequest;
|
|
243
|
+
const nodes = current?.reviewThreads?.nodes;
|
|
244
|
+
// Paginated or malformed thread data is unknown, never a clean review.
|
|
245
|
+
if (current?.headRefOid === p.headRefOid && current?.reviewThreads?.pageInfo?.hasNextPage === false && Array.isArray(nodes) && nodes.every(n => typeof n?.isResolved === "boolean")) p.unresolvedThreads = nodes.filter(n => !n.isResolved).length;
|
|
246
|
+
else if (current?.headRefOid && current.headRefOid !== p.headRefOid) return undefined;
|
|
247
|
+
}
|
|
248
|
+
return parsePrEvidence(p);
|
|
249
|
+
} catch { return undefined; }
|
|
250
|
+
}
|