@staix/agent-hub 0.12.45 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +83 -116
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +10 -7
- package/docs/agent-notes/budget.md +6 -1
- package/docs/agent-notes/bus.md +6 -4
- package/docs/agent-notes/daemon.md +6 -1
- package/docs/agent-notes/local-worker.md +2 -1
- package/docs/agent-notes/tasks.md +10 -0
- package/docs/agent-notes/tests.md +4 -2
- package/docs/dogfood.md +92 -0
- package/docs/events.md +14 -1
- package/docs/operations.md +250 -19
- package/docs/profiles.md +2 -2
- package/docs/quickstart.md +8 -2
- package/docs/sdlc.md +36 -6
- package/docs/security.md +20 -11
- package/docs/smoke.md +9 -4
- package/docs/specs/2026-09-19-agent-hub-design.md +759 -8
- package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
- package/docs/verification/2026-10-22-code-facts.md +78 -0
- package/docs/verification/2026-10-22-codex-reviewer-isolation.md +47 -0
- package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
- package/docs/verification/2026-10-23-0.13.0.md +89 -0
- package/docs/verification/dogfood/2026-10-22.md +3109 -0
- package/docs/verified.json +14 -14
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +613 -329
- package/src/adapters/agy/entry.ts +5 -4
- package/src/adapters/claude-channel.ts +20 -9
- package/src/adapters/codex-appserver.ts +18 -2
- package/src/adapters/codex-review-isolation.ts +12 -5
- package/src/adapters/compat.ts +140 -0
- package/src/adapters/definitions/builtin-metadata.ts +2 -1
- package/src/adapters/definitions/native-cli-doctor.ts +12 -5
- package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
- package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
- package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
- package/src/adapters/definitions/qwen-native.ts +7 -3
- package/src/adapters/definitions/qwen.ts +2 -1
- package/src/adapters/jcode.ts +72 -31
- package/src/adapters/kimi-review.ts +6 -3
- package/src/adapters/registry-types.ts +3 -1
- package/src/adapters/registry.ts +1 -0
- package/src/cli/agents.ts +40 -0
- package/src/cli/console-review.ts +54 -0
- package/src/cli/console-state.ts +124 -29
- package/src/cli/console-symbols.ts +74 -0
- package/src/cli/console-usage.ts +89 -0
- package/src/cli/console.ts +26 -1
- package/src/cli/help.ts +16 -6
- package/src/cli/identity.ts +29 -6
- package/src/cli/init.ts +109 -11
- package/src/cli/main.ts +122 -11
- package/src/cli/output.ts +44 -1
- package/src/cli/preview.ts +1 -1
- package/src/cli/sdlc-release-guard.ts +82 -0
- package/src/hub/action-class.ts +362 -0
- package/src/hub/action-policy.ts +106 -0
- package/src/hub/board.ts +43 -8
- package/src/hub/budget.ts +71 -2
- package/src/hub/bus.ts +43 -3
- package/src/hub/checks.ts +80 -31
- package/src/hub/code-facts.ts +223 -0
- package/src/hub/config-apply.ts +10 -1
- package/src/hub/config-trust.ts +7 -4
- package/src/hub/daemon.ts +237 -22
- package/src/hub/dogfood-read.ts +199 -0
- package/src/hub/dogfood.ts +163 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +11 -2
- package/src/hub/hub-tools.ts +24 -2
- package/src/hub/inbox-polling.ts +25 -0
- package/src/hub/instances.ts +73 -1
- package/src/hub/profiles.ts +7 -2
- package/src/hub/release-worktree.ts +56 -0
- package/src/hub/review-brief.ts +36 -0
- package/src/hub/sdlc-queue.ts +31 -9
- package/src/hub/sdlc-release-authorization.ts +173 -0
- package/src/hub/sdlc.ts +49 -11
- package/src/hub/settings.ts +5 -2
- package/src/hub/snapshots.ts +93 -8
- package/src/hub/symbol-normalizer.ts +26 -0
- package/src/hub/task-code-symbols.ts +90 -0
- package/src/hub/task-sweep.ts +3 -2
- package/src/hub/tasks.ts +292 -54
- package/src/hub/test-integrity.ts +162 -0
- package/src/hub/usage-rollup.ts +361 -0
- package/src/local/sandbox.ts +5 -3
- package/src/ui/index.html +1 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/dev.json +6 -1
- package/templates/profiles/general.json +6 -1
- package/templates/profiles/research.json +6 -1
- package/templates/profiles/writing.json +6 -1
- package/templates/release-guard.block.md +6 -0
package/src/hub/profiles.ts
CHANGED
|
@@ -10,6 +10,7 @@ export type ProfileName = (typeof PROFILE_NAMES)[number];
|
|
|
10
10
|
export interface ProjectProfile {
|
|
11
11
|
name: string; classes: Record<string, string>; plan_keys: string[]; role_text: Record<string, string>;
|
|
12
12
|
catalog_roles: Record<string, string[]>; agents: string; example_checks: string[];
|
|
13
|
+
review_checklist?: string[];
|
|
13
14
|
}
|
|
14
15
|
const PROFILES: Record<ProfileName, ProjectProfile> = { dev, research, writing, general };
|
|
15
16
|
export function profileName(raw: unknown): ProfileName {
|
|
@@ -34,10 +35,10 @@ export function resolveProfile(raw?: unknown, overrides?: unknown): ProjectProfi
|
|
|
34
35
|
const original = projectProfile(raw);
|
|
35
36
|
if (overrides === undefined) return original;
|
|
36
37
|
if (!overrides || typeof overrides !== "object" || Array.isArray(overrides)) throw new Error("profile_overrides must be an object");
|
|
37
|
-
const data = overrides as Record<string, unknown>, allowed = new Set(["classes", "plan_keys", "role_text", "catalog_roles", "agents", "example_checks"]);
|
|
38
|
+
const data = overrides as Record<string, unknown>, allowed = new Set(["classes", "plan_keys", "role_text", "catalog_roles", "agents", "example_checks", "review_checklist"]);
|
|
38
39
|
if (Object.keys(data).some(key => !allowed.has(key))) throw new Error("unknown profile override field");
|
|
39
40
|
if (!Object.keys(data).length) return original;
|
|
40
|
-
const result: ProjectProfile = { ...original, classes: { ...original.classes }, plan_keys: [...original.plan_keys], role_text: { ...original.role_text }, catalog_roles: Object.fromEntries(Object.entries(original.catalog_roles).map(([peer, ids]) => [peer, [...ids]])), example_checks: [...original.example_checks] };
|
|
41
|
+
const result: ProjectProfile = { ...original, classes: { ...original.classes }, plan_keys: [...original.plan_keys], role_text: { ...original.role_text }, catalog_roles: Object.fromEntries(Object.entries(original.catalog_roles).map(([peer, ids]) => [peer, [...ids]])), example_checks: [...original.example_checks], ...(original.review_checklist ? { review_checklist: [...original.review_checklist] } : {}) };
|
|
41
42
|
const text = (value: unknown, cap: number): string => { if (typeof value !== "string" || value.length > cap) throw new Error("profile text is missing or oversized"); return sanitize(value); };
|
|
42
43
|
if (data.classes !== undefined) {
|
|
43
44
|
if (!data.classes || typeof data.classes !== "object" || Array.isArray(data.classes) || Object.keys(data.classes).length > 32) throw new Error("profile classes must be a bounded base-class map");
|
|
@@ -63,6 +64,10 @@ export function resolveProfile(raw?: unknown, overrides?: unknown): ProjectProfi
|
|
|
63
64
|
if (!Array.isArray(data.example_checks) || data.example_checks.length > 16) throw new Error("profile example_checks must be a bounded display list");
|
|
64
65
|
result.example_checks = data.example_checks.map(value => text(value, 2000));
|
|
65
66
|
}
|
|
67
|
+
if (data.review_checklist !== undefined) {
|
|
68
|
+
if (!Array.isArray(data.review_checklist) || !data.review_checklist.length || data.review_checklist.length > 16 || data.review_checklist.some(value => typeof value !== "string" || !value.trim() || value.length > 500)) throw new Error("profile review_checklist must be bounded checklist lines");
|
|
69
|
+
result.review_checklist = data.review_checklist.map(value => sanitize(value.trim()));
|
|
70
|
+
}
|
|
66
71
|
return result;
|
|
67
72
|
}
|
|
68
73
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstatSync, readFileSync, readlinkSync } from "node:fs";
|
|
3
|
+
import { dirname, relative, resolve } from "node:path";
|
|
4
|
+
import type { ReleaseGit } from "./sdlc-release.ts";
|
|
5
|
+
|
|
6
|
+
/** Filter-free tracked checkout comparison. Unknown or transformed content refuses. */
|
|
7
|
+
// ponytail: 4096 entries, 4 MiB/file, 32 MiB total and 250 ms; larger/gitlink/transformed checkouts require a future bounded artifact attestation.
|
|
8
|
+
export function releaseTrackedClean(root: string, git: ReleaseGit): boolean {
|
|
9
|
+
const start = performance.now(), deadline = () => performance.now() - start <= 250;
|
|
10
|
+
const run = (args: string[]) => {
|
|
11
|
+
if (!deadline()) return;
|
|
12
|
+
const result = git(args);
|
|
13
|
+
return deadline() && result.code === 0 && Buffer.byteLength(result.stdout) <= 2 * 1024 * 1024 ? result.stdout : undefined;
|
|
14
|
+
};
|
|
15
|
+
try {
|
|
16
|
+
const base = resolve(root);
|
|
17
|
+
if (!lstatSync(base).isDirectory() || lstatSync(base).isSymbolicLink()) return false;
|
|
18
|
+
if (run(["diff-index", "--cached", "--quiet", "--no-ext-diff", "--no-textconv", "HEAD", "--"]) === undefined) return false;
|
|
19
|
+
const inventory = run(["ls-files", "--stage", "-z"]);
|
|
20
|
+
if (inventory === undefined || inventory && !inventory.endsWith("\0")) return false;
|
|
21
|
+
const entries = inventory ? inventory.slice(0, -1).split("\0") : [];
|
|
22
|
+
if (entries.length > 4096) return false;
|
|
23
|
+
let total = 0;
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
if (!deadline()) return false;
|
|
26
|
+
const match = /^([0-7]{6}) ([0-9a-f]{40}) ([0-3])\t([\s\S]+)$/.exec(entry);
|
|
27
|
+
if (!match) return false;
|
|
28
|
+
const [, mode, oid, stage, name] = match;
|
|
29
|
+
if (stage !== "0" || !["100644", "100755", "120000"].includes(mode!)) return false;
|
|
30
|
+
if (!name || name.length > 4096 || name.startsWith("/") || name.includes("\\") || name.split("/").some(part => !part || part === "." || part === "..") || /[\p{Cc}]/u.test(name)) return false;
|
|
31
|
+
const file = resolve(base, name), within = relative(base, file);
|
|
32
|
+
if (!within || within === ".." || within.startsWith("../")) return false;
|
|
33
|
+
for (let parent = dirname(file); parent !== base; parent = dirname(parent)) {
|
|
34
|
+
if (!deadline()) return false;
|
|
35
|
+
const info = lstatSync(parent);
|
|
36
|
+
if (!info.isDirectory() || info.isSymbolicLink()) return false;
|
|
37
|
+
}
|
|
38
|
+
const before = lstatSync(file);
|
|
39
|
+
if (before.size > 4 * 1024 * 1024 || total + before.size > 32 * 1024 * 1024) return false;
|
|
40
|
+
let bytes: Buffer;
|
|
41
|
+
if (mode === "120000") {
|
|
42
|
+
if (!before.isSymbolicLink()) return false;
|
|
43
|
+
bytes = readlinkSync(file, { encoding: "buffer" });
|
|
44
|
+
} else {
|
|
45
|
+
if (!before.isFile() || before.isSymbolicLink() || !!(before.mode & 0o111) !== (mode === "100755")) return false;
|
|
46
|
+
bytes = readFileSync(file);
|
|
47
|
+
}
|
|
48
|
+
total += bytes.length;
|
|
49
|
+
if (bytes.length > 4 * 1024 * 1024 || total > 32 * 1024 * 1024 || !deadline()) return false;
|
|
50
|
+
const after = lstatSync(file);
|
|
51
|
+
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) return false;
|
|
52
|
+
if (createHash("sha1").update(`blob ${bytes.length}\0`).update(bytes).digest("hex") !== oid) return false;
|
|
53
|
+
}
|
|
54
|
+
return deadline();
|
|
55
|
+
} catch { return false; }
|
|
56
|
+
}
|
package/src/hub/review-brief.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import type { CodeFactResult, CodeSymbol } from "./code-facts.ts";
|
|
1
2
|
import { childEnv } from "./child-process.ts";
|
|
2
3
|
import { hubGitSync } from "./git.ts";
|
|
4
|
+
import { symbolsOverlap } from "./symbol-normalizer.ts";
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* #520: the review brief ahub builds itself, in place of `ocr delegate preview` and `rule`. Every reviewer gets the same
|
|
@@ -10,6 +12,7 @@ import { hubGitSync } from "./git.ts";
|
|
|
10
12
|
export interface BriefFile { path: string; status: string; oldPath?: string; insertions?: number; deletions?: number }
|
|
11
13
|
export interface BriefExcluded { path: string; status: string; oldPath?: string; reason: string }
|
|
12
14
|
export interface ReviewBrief {
|
|
15
|
+
symbols?: { state: "known" | "unknown"; complete?: boolean; files: {path:string; symbols:CodeSymbol[]}[]; planned: string[]; unplanned: string[]; unseen: string[] };
|
|
13
16
|
head: string;
|
|
14
17
|
/** The default branch the head is compared with: origin/HEAD, origin/main or origin/master. */
|
|
15
18
|
base: string;
|
|
@@ -149,6 +152,38 @@ function attributes(cwd: string, source: string, paths: string[]): Map<string, {
|
|
|
149
152
|
return out;
|
|
150
153
|
}
|
|
151
154
|
|
|
155
|
+
/** One plan name against one changed name: a qualified pair must be equal, otherwise the last segments are. Shared with the console's task detail (#684 2b). */
|
|
156
|
+
export const planSymbolMatches = (left: string, right: string): boolean => symbolsOverlap(left, right);
|
|
157
|
+
/** Project derived names through the host metadata screen before comparing or rendering them. */
|
|
158
|
+
export function withCodeSymbols(brief:ReviewBrief,result:CodeFactResult<CodeSymbol[]>,plan:readonly string[]|undefined,publicMetadataText:(text:string)=>boolean):ReviewBrief {
|
|
159
|
+
const unknown={...brief,symbols:{state:"unknown" as const,files:[],planned:[],unplanned:[],unseen:[]}};
|
|
160
|
+
if(result.state!=="known")return unknown;
|
|
161
|
+
try {
|
|
162
|
+
const paths=new Set(brief.reviewable.map(file=>file.path));
|
|
163
|
+
const changed=result.value.filter(s=>!s.ambiguous && paths.has(s.path) && publicMetadataText(s.path) && publicMetadataText(s.name) && publicMetadataText(s.kind));
|
|
164
|
+
const planned=(plan??[]).filter(name=>publicMetadataText(name));
|
|
165
|
+
const matches=planSymbolMatches;
|
|
166
|
+
const names=[...new Set(changed.map(s=>s.name))];
|
|
167
|
+
return {...brief,symbols:{state:"known",complete:changed.length===result.value.length && brief.withheld===0,files:[...paths].map(path=>({path,symbols:changed.filter(s=>s.path===path)})),
|
|
168
|
+
planned:names.filter(name=>planned.some(p=>matches(p,name))),unplanned:names.filter(name=>!planned.some(p=>matches(p,name))),unseen:planned.filter(p=>!names.some(name=>matches(p,name)))}};
|
|
169
|
+
} catch { return unknown; }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function renderCodeSymbols(brief:ReviewBrief):string[] {
|
|
173
|
+
const symbols=brief.symbols;
|
|
174
|
+
if(!symbols)return [];
|
|
175
|
+
if(symbols.state === "unknown")return ["Changed symbols: unknown (optional code facts unavailable)."];
|
|
176
|
+
const lines=[
|
|
177
|
+
"Changed symbols (advisory; change: unknown):",
|
|
178
|
+
...symbols.files.flatMap(file=>file.symbols.length ? file.symbols.map(symbol=>`- ${file.path}: ${symbol.name} (${symbol.kind}, lines ${symbol.line}${symbol.endLine === undefined ? "..unknown" : `..${symbol.endLine}`}; change: unknown)`) : [`- ${file.path}: no changed symbol observed`]),
|
|
179
|
+
`Changed and planned: ${symbols.planned.join(", ") || "none observed"}`,
|
|
180
|
+
`Changed, not planned: ${symbols.unplanned.join(", ") || "none observed"}`,
|
|
181
|
+
`Planned, not seen as changed: ${symbols.unseen.join(", ") || "none observed"}`,
|
|
182
|
+
"Ceiling: ripwire 0.6.7 does not report deleted symbols; these lists never establish plan conformance.",
|
|
183
|
+
];
|
|
184
|
+
return Buffer.byteLength(lines.join("\n"))<=16000 ? lines : ["Changed symbols: unknown (advice exceeds 16000 bytes)."];
|
|
185
|
+
}
|
|
186
|
+
|
|
152
187
|
/** The brief as review text: what every reviewer reads and how it accounts for its coverage. */
|
|
153
188
|
export function renderReviewBrief(brief: ReviewBrief): string {
|
|
154
189
|
const file = (f: BriefFile) => `- ${f.oldPath ? `${f.oldPath} -> ` : ""}${f.path} (${f.status}${f.insertions !== undefined ? `, +${f.insertions} -${f.deletions}` : ""})`;
|
|
@@ -156,6 +191,7 @@ export function renderReviewBrief(brief: ReviewBrief): string {
|
|
|
156
191
|
`Review brief: head ${brief.head} against ${brief.base} (merge base ${brief.mergeBase.slice(0, 12)}); ${brief.reviewable.length} reviewable, ${brief.excluded.length} excluded${brief.withheld ? `, ${brief.withheld} withheld by the privacy policy` : ""}.`,
|
|
157
192
|
...(brief.reviewable.length ? ["Reviewable:", ...brief.reviewable.map(file)] : ["Reviewable: none (record why in the note)."]),
|
|
158
193
|
...(brief.excluded.length ? ["Excluded (still read their diff when it matters):", ...brief.excluded.map(e => `- ${e.oldPath ? `${e.oldPath} -> ` : ""}${e.path} (${e.status}): ${e.reason}`)] : []),
|
|
194
|
+
...renderCodeSymbols(brief),
|
|
159
195
|
...(brief.unlisted.length ? ["Not in the brief's list, read directly:", ...brief.unlisted.map(path => `- ${path}`)] : []),
|
|
160
196
|
...brief.ruleGroups.filter(g => g.sources.length).map(g => `Rules ${g.sources.join(" + ")} apply to: ${brief.ruleGroups.length === 1 ? "every reviewable file" : g.paths.join(", ")}`),
|
|
161
197
|
...(brief.rulesMissing.length ? ["Configured rules not loaded (read them in the repository):", ...brief.rulesMissing.map(m => `- ${m.source}: ${m.reason}`)] : []),
|
package/src/hub/sdlc-queue.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { publicTestIntegrity, parseIssueLabels, readTestIntegrity, testIntegrityBlockers, type TestIntegrity } from "./test-integrity.ts";
|
|
1
2
|
import { planConformance, planConformanceBlockers } from "./plan-conformance.ts";
|
|
2
3
|
import { findingsForHead, findingHead, findingTally, type ReviewFindingSet, type ReviewFindingTally } from "./review-findings.ts";
|
|
3
4
|
import type { CriteriaView } from "./workflow.ts";
|
|
4
5
|
import { parsePrEvidence, readSdlcPr, sdlcGh, sdlcStatus, type PrEvidence, type SdlcConfig, type SdlcGh, type SdlcReceipt } from "./sdlc.ts";
|
|
5
6
|
export interface DeliveryTask { plan?: { paths?: string[]; revision: number; fingerprint: string; accepted: { repo: string; pr: number; head: string; revision: number; fingerprint: string; paths: string[] }[] }; findings?: readonly ReviewFindingSet[]; id: number; after: number[]; private: boolean; receipt?: SdlcReceipt; phase?: CriteriaView; }
|
|
6
|
-
export interface MergeQueueRow { planConformance?: { task?: number; state: string; outside: number; untouched: number; accepted: boolean }[]; findings?: ReviewFindingTally; pr: number; tasks: number[]; head?: string; private?: true; ready: boolean; blockers: string[]; after: number[]; }
|
|
7
|
+
export interface MergeQueueRow { testIntegrity?: (TestIntegrity & { task?: number })[]; planConformance?: { task?: number; state: string; outside: number; untouched: number; accepted: boolean }[]; findings?: ReviewFindingTally; pr: number; tasks: number[]; head?: string; private?: true; ready: boolean; blockers: string[]; after: number[]; }
|
|
7
8
|
export interface MergeConflict { left: number; right: number; files: number; order: "dependency" | "person_required"; }
|
|
8
9
|
export interface MergeQueue { repo: string; rows: MergeQueueRow[]; order: number[]; unordered?: number[]; conflicts: MergeConflict[]; complete: boolean; truncated: boolean; }
|
|
9
10
|
const validRepo = (value: string) => /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value) && !value.startsWith("-");
|
|
@@ -12,13 +13,20 @@ const parseSnapshot = (reply: { code: number; stdout: string }): { number: numbe
|
|
|
12
13
|
try { const data = JSON.parse(reply.stdout); if (reply.code !== 0 || !Array.isArray(data) || data.length > 17 || data.some(p => !validId(p?.number) || typeof p.headRefOid !== "string" || !/^[0-9a-f]{40}$/.test(p.headRefOid) || typeof p.baseRefName !== "string" || !p.baseRefName || p.baseRefName.length > 200 || /[\x00-\x1f\x7f]/.test(p.baseRefName) || typeof p.baseRefOid !== "string" || !/^[0-9a-f]{40}$/.test(p.baseRefOid)) || new Set(data.map(p => p.number)).size !== data.length) return; return data.map(p => ({ number: p.number, head: p.headRefOid, base: p.baseRefName, baseHead: p.baseRefOid })).sort((a, b) => a.number - b.number); }
|
|
13
14
|
catch { return; }
|
|
14
15
|
};
|
|
15
|
-
const gateIdentity = (p: PrEvidence) => JSON.stringify({ head: p.head, state: p.state, checks: [...p.checks].sort((a, b) => a.name.localeCompare(b.name) || a.state.localeCompare(b.state)), review: p.review, pending: p.pendingReviews, unresolved: p.unresolved, draft: p.draft, base: p.base, baseHead: p.baseHead, pathsComplete: p.pathsComplete });
|
|
16
|
+
const gateIdentity = (p: PrEvidence) => JSON.stringify({ head: p.head, state: p.state, checks: [...p.checks].sort((a, b) => a.name.localeCompare(b.name) || a.state.localeCompare(b.state)), review: p.review, pending: p.pendingReviews, unresolved: p.unresolved, draft: p.draft, base: p.base, baseHead: p.baseHead, pathsComplete: p.pathsComplete, ...(p.issueLabels ? { issue: p.issueLabels.issue, bug: p.issueLabels.labels?.some(name => name.toLowerCase() === "bug") ?? "unknown" } : {}) });
|
|
16
17
|
/** The last remote read gathers both open identities and mutable gates in one bounded request. */
|
|
17
|
-
async function finalSnapshot(repo: string, evidence: Map<number, PrEvidence>, run: SdlcGh): Promise<{ gatesStable: boolean; prs?: ReturnType<typeof parseSnapshot> }> {
|
|
18
|
+
async function finalSnapshot(repo: string, evidence: Map<number, PrEvidence>, run: SdlcGh, issues: Map<number, TestIntegrity["applicability"]>): Promise<{ gatesStable: boolean; prs?: ReturnType<typeof parseSnapshot> }> {
|
|
19
|
+
// ponytail: one snapshot verifies at most 32 linked issues; paginate within the same deadline if queues grow.
|
|
20
|
+
if (issues.size > 32) return { gatesStable: false };
|
|
18
21
|
const [owner, name] = repo.split("/");
|
|
19
22
|
const fields = "headRefOid state isDraft baseRefName baseRefOid changedFiles reviewDecision reviewRequests(first:100){nodes{requestedReviewer{__typename}} pageInfo{hasNextPage}} reviewThreads(first:100){nodes{isResolved} pageInfo{hasNextPage}} statusCheckRollup{contexts(first:100){nodes{... on CheckRun{name conclusion status} ... on StatusContext{context state}} pageInfo{hasNextPage}}}";
|
|
20
23
|
const entries = [...evidence.entries()];
|
|
21
|
-
const
|
|
24
|
+
const issueAlias = (issue: number) => {
|
|
25
|
+
const primary = entries.find(([, value]) => value.issueLabels?.issue === issue);
|
|
26
|
+
return primary ? `queue_i${primary[0]}` : `queue_issue${issue}`;
|
|
27
|
+
};
|
|
28
|
+
const extraIssues = [...issues.keys()].filter(issue => !entries.some(([, value]) => value.issueLabels?.issue === issue));
|
|
29
|
+
const query = `query($owner:String!,$name:String!){repository(owner:$owner,name:$name){pullRequests(first:17,states:OPEN){nodes{number headRefOid baseRefName baseRefOid} pageInfo{hasNextPage}} ${entries.map(([id, before]) => `queue_p${id}:pullRequest(number:${id}){${fields}}${before.issueLabels ? ` queue_i${id}:issue(number:${before.issueLabels.issue}){number labels(first:100){nodes{name} pageInfo{hasNextPage}}}` : ""}`).join(" ")} ${extraIssues.map(issue => `${issueAlias(issue)}:issue(number:${issue}){number labels(first:100){nodes{name} pageInfo{hasNextPage}}}`).join(" ")}}}`;
|
|
22
30
|
try {
|
|
23
31
|
const reply = await run(["api", "graphql", "-f", `query=${query}`, "-f", `owner=${owner}`, "-f", `name=${name}`]);
|
|
24
32
|
if (reply.code !== 0) return { gatesStable: false };
|
|
@@ -32,27 +40,34 @@ async function finalSnapshot(repo: string, evidence: Map<number, PrEvidence>, ru
|
|
|
32
40
|
const threads = p?.reviewThreads, reviews = p?.reviewRequests, checks = p?.statusCheckRollup === null ? { nodes: [], pageInfo: { hasNextPage: false } } : p?.statusCheckRollup?.contexts;
|
|
33
41
|
if (threads?.pageInfo?.hasNextPage !== false || reviews?.pageInfo?.hasNextPage !== false || checks?.pageInfo?.hasNextPage !== false || !Array.isArray(threads.nodes) || threads.nodes.length > 100 || threads.nodes.some((n: { isResolved?: unknown }) => typeof n?.isResolved !== "boolean") || !Array.isArray(reviews.nodes) || !Array.isArray(checks.nodes) || checks.nodes.length > 100) return { gatesStable: false, prs };
|
|
34
42
|
const after = parsePrEvidence({ ...p, files: before.paths.map(path => ({ path })), statusCheckRollup: checks.nodes, reviewRequests: reviews.nodes, unresolvedThreads: threads.nodes.filter((n: { isResolved: boolean }) => !n.isResolved).length });
|
|
43
|
+
if (before.issueLabels) after.issueLabels = { issue: before.issueLabels.issue, labels: parseIssueLabels(data.data.repository[`queue_i${id}`], before.issueLabels.issue) ?? null };
|
|
35
44
|
if (gateIdentity(before) !== gateIdentity(after)) return { gatesStable: false, prs };
|
|
36
45
|
}
|
|
46
|
+
for (const [issue, expected] of issues) {
|
|
47
|
+
const labels = parseIssueLabels(data.data.repository[issueAlias(issue)], issue);
|
|
48
|
+
const current = labels === undefined ? "unknown" : labels.some(name => name.toLowerCase() === "bug") ? "bug" : "not_bug";
|
|
49
|
+
if (current !== expected) return { gatesStable: false, prs };
|
|
50
|
+
}
|
|
37
51
|
return { gatesStable: true, prs };
|
|
38
52
|
} catch { return { gatesStable: false }; }
|
|
39
53
|
}
|
|
40
54
|
/** Fixed metadata-only reads; local claims and unknown dependency evidence never become merge approval. */
|
|
41
|
-
export async function readMergeQueue(repo: string, config: SdlcConfig, tasks: DeliveryTask[], run: SdlcGh = sdlcGh): Promise<MergeQueue> {
|
|
55
|
+
export async function readMergeQueue(repo: string, config: SdlcConfig, tasks: DeliveryTask[], run: SdlcGh = sdlcGh, readableMetadata?: (path: string) => boolean): Promise<MergeQueue> {
|
|
42
56
|
if (!validRepo(repo)) throw new Error("merge queue requires owner/repository");
|
|
43
57
|
const result: MergeQueue = { repo, rows: [], order: [], conflicts: [], complete: false, truncated: false };
|
|
44
58
|
const deadline = performance.now() + 20_000;
|
|
45
|
-
const bounded: SdlcGh = args => { const remaining = deadline - performance.now(); return remaining > 0 ? run(args, Math.min(10_000, remaining)) : Promise.resolve({ code: 1, stdout: "" }); };
|
|
59
|
+
const bounded: SdlcGh = (args, timeout, cwd, cap) => { const remaining = deadline - performance.now(); return remaining > 0 ? run(args, Math.min(timeout ?? 10_000, remaining), cwd, cap) : Promise.resolve({ code: 1, stdout: "" }); };
|
|
46
60
|
const listArgs = ["pr", "list", "--repo", repo, "--state", "open", "--limit", "17", "--json", "number,headRefOid,baseRefName,baseRefOid"];
|
|
47
61
|
const initialPrs = parseSnapshot(await bounded(listArgs)); if (!initialPrs) return result;
|
|
48
62
|
result.truncated = initialPrs.length > 16; const ids = initialPrs.slice(0, 16).map(pr => pr.number);
|
|
49
63
|
const sameRepo = (value: unknown) => typeof value === "string" && value.toLowerCase() === repo.toLowerCase();
|
|
50
64
|
const evidence = new Map<number, PrEvidence>(), links = new Map<number, DeliveryTask[]>();
|
|
65
|
+
const issueApplicability = new Map<number, TestIntegrity["applicability"]>();
|
|
51
66
|
for (const pr of ids) {
|
|
52
67
|
const holders = tasks.filter(t => sameRepo(t.receipt?.repo) && t.receipt?.pr === pr); links.set(pr, holders);
|
|
53
68
|
const row: MergeQueueRow = { pr, tasks: holders.some(t => t.private) ? [] : holders.map(t => t.id), ready: false, blockers: [], after: [] }; result.rows.push(row);
|
|
54
69
|
if (holders.some(t => t.private)) { row.private = true; if (config.mode !== "off") row.planConformance = [{ state: "unknown", outside: 0, untouched: 0, accepted: false }]; row.blockers.push("private lifecycle evidence unavailable"); continue; }
|
|
55
|
-
const read = await readSdlcPr(holders[0]?.receipt ?? { stage: config.stages[0] ?? "spec", repo, pr }, bounded);
|
|
70
|
+
const read = await readSdlcPr(holders[0]?.receipt ?? { stage: config.stages[0] ?? "spec", repo, pr }, bounded, config.mode !== "off");
|
|
56
71
|
if (!read || read.state !== "OPEN") { row.blockers.push("current PR evidence unknown or no longer open"); continue; }
|
|
57
72
|
evidence.set(pr, read); row.head = read.head;
|
|
58
73
|
for (const holder of holders) {
|
|
@@ -70,8 +85,15 @@ export async function readMergeQueue(repo: string, config: SdlcConfig, tasks: De
|
|
|
70
85
|
for (const holder of linked) {
|
|
71
86
|
const conformance = holder?.plan ? { ...planConformance(holder.plan.paths, read), accepted: holder.plan.accepted.some(item => item.repo === holder.receipt?.repo && item.pr === holder.receipt?.pr && item.head === read.head && item.revision === holder.plan!.revision && item.fingerprint === holder.plan!.fingerprint && JSON.stringify([...item.paths].sort()) === JSON.stringify([...read.paths].sort())) } : undefined;
|
|
72
87
|
if (holder && conformance && config.mode !== "off") (row.planConformance ??= []).push({ task: holder.id, state: conformance.state, outside: conformance.outside.length, untouched: conformance.untouched.length, accepted: conformance.accepted });
|
|
88
|
+
const rawIntegrity = config.mode !== "off" && holder?.receipt ? await readTestIntegrity(holder.receipt, read, bounded) : undefined;
|
|
89
|
+
if (rawIntegrity && holder?.receipt?.issue) {
|
|
90
|
+
const issue = holder.receipt.issue, before = issueApplicability.get(issue);
|
|
91
|
+
issueApplicability.set(issue, before !== undefined && before !== rawIntegrity.applicability ? "unknown" : rawIntegrity.applicability);
|
|
92
|
+
}
|
|
93
|
+
const integrity = rawIntegrity ? publicTestIntegrity(rawIntegrity, readableMetadata) : undefined;
|
|
94
|
+
if (integrity) (row.testIntegrity ??= []).push({ task: holder!.id, ...integrity });
|
|
73
95
|
row.blockers.push(...sdlcStatus(config, holder?.receipt, read, holder?.phase).blockers);
|
|
74
|
-
if (config.mode === "enforce") row.blockers.push(...planConformanceBlockers(conformance));
|
|
96
|
+
if (config.mode === "enforce") row.blockers.push(...planConformanceBlockers(conformance), ...testIntegrityBlockers(integrity));
|
|
75
97
|
}
|
|
76
98
|
}
|
|
77
99
|
const dependencyEvidence = new Map<number, PrEvidence | undefined>(); let dependencyReads = 0;
|
|
@@ -115,7 +137,7 @@ export async function readMergeQueue(repo: string, config: SdlcConfig, tasks: De
|
|
|
115
137
|
result.complete = !result.truncated && !remaining.size && !touchingCoverageUnknown && !personOrder.size;
|
|
116
138
|
// Empty/private-only snapshots have no intervening remote reads and offer no private PR evidence.
|
|
117
139
|
if (evidence.size) {
|
|
118
|
-
const finalRead = await finalSnapshot(repo, evidence, bounded);
|
|
140
|
+
const finalRead = await finalSnapshot(repo, evidence, bounded, issueApplicability);
|
|
119
141
|
const gatesStable = finalRead.gatesStable;
|
|
120
142
|
const finalPrs = finalRead.prs;
|
|
121
143
|
if (!gatesStable || !finalPrs || JSON.stringify(finalPrs) !== JSON.stringify(initialPrs) || finalPrs.some(pr => evidence.has(pr.number) && (evidence.get(pr.number)?.head !== pr.head || evidence.get(pr.number)?.base !== pr.base || evidence.get(pr.number)?.baseHead !== pr.baseHead))) {
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { releaseTrackedClean } from "./release-worktree.ts";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { USER } from "./envelope.ts";
|
|
4
|
+
import { hubGitEnv, hubGitSync } from "./git.ts";
|
|
5
|
+
import { readDocEvidence, type DocEvidence, type ReleaseGit, type ReleaseReadiness } from "./sdlc-release.ts";
|
|
6
|
+
import type { SdlcGh } from "./sdlc.ts";
|
|
7
|
+
import type { ReleaseGuardRequest, ReleasePushUpdate } from "../cli/sdlc-release-guard.ts";
|
|
8
|
+
|
|
9
|
+
class ReleaseAuthorizationError extends Error {}
|
|
10
|
+
export const releaseAuthorizationError = (error: unknown): string => error instanceof ReleaseAuthorizationError ? error.message : "release authorization evidence unavailable";
|
|
11
|
+
|
|
12
|
+
const SHA = /^[0-9a-f]{40}$/;
|
|
13
|
+
const VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
14
|
+
export const validReleaseVersion = (value: unknown): value is string => typeof value === "string" && value.length <= 128 && VERSION.test(value);
|
|
15
|
+
export interface ReleaseDocsVerdict { fresh: true; errors: 0; documents: number; digest: string }
|
|
16
|
+
export interface ReleaseAuthorization { schemaVersion: 1; id: string; by: typeof USER; commit: string; version: string; at: number; docs: ReleaseDocsVerdict }
|
|
17
|
+
export interface ReleaseAuthorizationPorts {
|
|
18
|
+
git?: ReleaseGit;
|
|
19
|
+
docs?: (root: string) => Promise<DocEvidence | undefined>;
|
|
20
|
+
gh?: SdlcGh;
|
|
21
|
+
now?: () => number;
|
|
22
|
+
current?: () => boolean;
|
|
23
|
+
record: (receipt: ReleaseAuthorization) => boolean;
|
|
24
|
+
}
|
|
25
|
+
export type ReleaseGuardVerdict = { ok: true; receipts: ReleaseAuthorization[] } | { ok: false; error: string };
|
|
26
|
+
interface Origin { hash: string; urls: string[]; repo?: string }
|
|
27
|
+
interface Entry { receipt: ReleaseAuthorization; origin: Origin }
|
|
28
|
+
const digest = (value: unknown) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
29
|
+
function docsVerdict(value: DocEvidence | undefined, commit: string, version: string): ReleaseDocsVerdict | undefined {
|
|
30
|
+
if (!value || value.schemaVersion !== 1 || value.head !== commit || value.version !== version || value.fresh !== true || value.errors !== 0
|
|
31
|
+
|| !Array.isArray(value.documents) || value.documents.length > 128 || value.documents.some(row => row.state !== "fresh" || row.dirty === true)) return;
|
|
32
|
+
// Paths may be withheld in a public release report; the commit binds their identities, not this projection.
|
|
33
|
+
const rows = value.documents.map(({ state, verifiedAgainst, commits, dirty }) => ({ state, verifiedAgainst, commits, dirty })).map(row => JSON.stringify(row)).sort();
|
|
34
|
+
return { fresh: true, errors: 0, documents: rows.length, digest: digest({ commit, version, rows }) };
|
|
35
|
+
}
|
|
36
|
+
function githubRepo(url: string): string | undefined {
|
|
37
|
+
const match = url.match(/^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?\/?$/);
|
|
38
|
+
return match?.[1];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Host-owned consent, never imported from a task receipt or an agent-writable file. Restart revokes it. */
|
|
42
|
+
export class ReleaseAuthorizations {
|
|
43
|
+
private readonly entries = new Map<string, Entry>();
|
|
44
|
+
private latest?: Entry;
|
|
45
|
+
constructor(private readonly root: string, private readonly ports: ReleaseAuthorizationPorts) {}
|
|
46
|
+
private current(): void { if (this.ports.current?.() === false) throw new ReleaseAuthorizationError("release authorization unavailable during lifecycle transition"); }
|
|
47
|
+
private git(): ReleaseGit {
|
|
48
|
+
let remaining = 5000;
|
|
49
|
+
return args => {
|
|
50
|
+
if (remaining <= 0) return { code: 1, stdout: "" };
|
|
51
|
+
const start = performance.now();
|
|
52
|
+
try {
|
|
53
|
+
if (this.ports.git) return this.ports.git(args);
|
|
54
|
+
const result = hubGitSync(args, { cwd: this.root, env: hubGitEnv(process.env, { repositoryIsolation: true }), encoding: "utf8", timeout: Math.max(1, Math.min(1000, remaining)), maxBuffer: 256 * 1024 });
|
|
55
|
+
return { code: result.status ?? 1, stdout: result.stdout ?? "" };
|
|
56
|
+
} finally { remaining -= performance.now() - start; }
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
private read(git: ReleaseGit, args: string[]): string {
|
|
60
|
+
const result = git(args);
|
|
61
|
+
if (result.code !== 0 || Buffer.byteLength(result.stdout) > 256 * 1024) throw new ReleaseAuthorizationError("release target evidence unavailable");
|
|
62
|
+
return result.stdout.trim();
|
|
63
|
+
}
|
|
64
|
+
private origin(git: ReleaseGit, remote = "origin"): Origin {
|
|
65
|
+
if (!/^[A-Za-z0-9_.-]{1,128}$/.test(remote) || remote.startsWith("-")) throw new ReleaseAuthorizationError("release guard requires a configured named remote");
|
|
66
|
+
const urls = this.read(git, ["remote", "get-url", "--push", "--all", remote]).split("\n");
|
|
67
|
+
if (!urls.length || urls.length > 8 || urls.some(url => !url || url.length > 4096 || /[\p{Cc}]/u.test(url))) throw new ReleaseAuthorizationError("release remote identity unavailable");
|
|
68
|
+
const sorted = [...new Set(urls)].sort();
|
|
69
|
+
const fetch = this.read(git, ["remote", "get-url", "--all", remote]).split("\n");
|
|
70
|
+
if (!fetch.length || fetch.length > 8 || fetch.some(url => !url || url.length > 4096 || /[\p{Cc}]/u.test(url))) throw new ReleaseAuthorizationError("release remote identity unavailable");
|
|
71
|
+
const repos = [...sorted, ...fetch].map(githubRepo);
|
|
72
|
+
return { hash: digest({ push: sorted, fetch: [...new Set(fetch)].sort() }), urls: sorted, ...(repos[0] && repos.every(repo => repo === repos[0]) ? { repo: repos[0] } : {}) };
|
|
73
|
+
}
|
|
74
|
+
private commit(git: ReleaseGit, ref: string): string {
|
|
75
|
+
if (!ref || ref.length > 256 || ref.startsWith("-") || /[\s\p{Cc}]/u.test(ref)) throw new ReleaseAuthorizationError("invalid release source ref");
|
|
76
|
+
const commit = this.read(git, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`]);
|
|
77
|
+
if (!SHA.test(commit)) throw new ReleaseAuthorizationError("release commit unavailable");
|
|
78
|
+
return commit;
|
|
79
|
+
}
|
|
80
|
+
private version(git: ReleaseGit, commit: string): string {
|
|
81
|
+
if (!SHA.test(commit)) throw new ReleaseAuthorizationError("invalid release commit");
|
|
82
|
+
let value: unknown;
|
|
83
|
+
try { value = JSON.parse(this.read(git, ["show", `${commit}:package.json`])).version; } catch { throw new ReleaseAuthorizationError("committed package version unavailable"); }
|
|
84
|
+
if (!validReleaseVersion(value)) throw new ReleaseAuthorizationError("committed package version unavailable");
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
async authorize(by: string, version: unknown, expectedCommit: unknown): Promise<ReleaseAuthorization> {
|
|
88
|
+
if (by !== USER) throw new ReleaseAuthorizationError("only the person may authorize a release");
|
|
89
|
+
if (!validReleaseVersion(version) || typeof expectedCommit !== "string" || !SHA.test(expectedCommit)) throw new ReleaseAuthorizationError("authorization needs an exact version and candidate commit");
|
|
90
|
+
this.current();
|
|
91
|
+
const git = this.git(), commit = this.commit(git, "HEAD"), origin = this.origin(git);
|
|
92
|
+
if (commit !== expectedCommit || this.version(git, commit) !== version) throw new ReleaseAuthorizationError("candidate commit or version differs from the person's request");
|
|
93
|
+
const docs = docsVerdict(await (this.ports.docs ?? readDocEvidence)(this.root), commit, version);
|
|
94
|
+
if (!docs) throw new ReleaseAuthorizationError("release authorization requires fresh committed documentation evidence");
|
|
95
|
+
this.current();
|
|
96
|
+
if (this.commit(git, "HEAD") !== commit || this.version(git, commit) !== version || this.origin(git).hash !== origin.hash) throw new ReleaseAuthorizationError("release candidate changed during authorization");
|
|
97
|
+
const receipt: ReleaseAuthorization = { schemaVersion: 1, id: randomUUID(), by: USER, commit, version, at: (this.ports.now ?? Date.now)(), docs };
|
|
98
|
+
if (!this.ports.record(structuredClone(receipt))) throw new ReleaseAuthorizationError("release approval audit could not be recorded");
|
|
99
|
+
const entry = { receipt, origin };
|
|
100
|
+
this.entries.set(`${version}:${commit}`, entry); this.latest = entry;
|
|
101
|
+
// ponytail: retain at most 64 approvals in this daemon generation; older candidates need a new person approval.
|
|
102
|
+
if (this.entries.size > 64) this.entries.delete(this.entries.keys().next().value!);
|
|
103
|
+
return structuredClone(receipt);
|
|
104
|
+
}
|
|
105
|
+
private matched(git: ReleaseGit, version: string, commit: string, remote?: string, url?: string): ReleaseAuthorization {
|
|
106
|
+
this.current();
|
|
107
|
+
const entry = this.entries.get(`${version}:${commit}`);
|
|
108
|
+
if (!entry || this.version(git, commit) !== version) throw new ReleaseAuthorizationError("no matching person release authorization; ask the person to authorize this commit and version");
|
|
109
|
+
const origin = this.origin(git);
|
|
110
|
+
if (origin.hash !== entry.origin.hash || remote && this.origin(git, remote).hash !== origin.hash || url !== undefined && !origin.urls.includes(url)) throw new ReleaseAuthorizationError("release remote differs from the authorized project");
|
|
111
|
+
return structuredClone(entry.receipt);
|
|
112
|
+
}
|
|
113
|
+
async guardRequest(request: ReleaseGuardRequest): Promise<ReleaseGuardVerdict> {
|
|
114
|
+
try {
|
|
115
|
+
this.current(); const git = this.git();
|
|
116
|
+
if (request.kind === "tag-push") {
|
|
117
|
+
const commit = this.commit(git, request.sourceRef);
|
|
118
|
+
return { ok: true, receipts: [this.matched(git, request.version, commit, request.remote)] };
|
|
119
|
+
}
|
|
120
|
+
if (request.kind === "npm-publish") {
|
|
121
|
+
if (!releaseTrackedClean(this.root, git)) throw new ReleaseAuthorizationError("npm publish requires a clean tracked worktree");
|
|
122
|
+
const commit = this.commit(git, "HEAD"), version = this.version(git, commit);
|
|
123
|
+
const receipt = this.matched(git, version, commit);
|
|
124
|
+
const docs = docsVerdict(await (this.ports.docs ?? readDocEvidence)(this.root), commit, version);
|
|
125
|
+
this.current();
|
|
126
|
+
if (!docs || docs.digest !== receipt.docs.digest || this.commit(git, "HEAD") !== commit || !releaseTrackedClean(this.root, git)) throw new ReleaseAuthorizationError("publish checkout or documentation evidence changed");
|
|
127
|
+
return { ok: true, receipts: [this.matched(git, version, commit)] };
|
|
128
|
+
}
|
|
129
|
+
const origin = this.origin(git), repo = origin.repo;
|
|
130
|
+
if (!repo || !this.ports.gh) throw new ReleaseAuthorizationError("GitHub release target evidence unavailable");
|
|
131
|
+
const [owner, name] = repo.split("/");
|
|
132
|
+
const query = "query($owner:String!,$name:String!,$ref:String!){repository(owner:$owner,name:$name){ref(qualifiedName:$ref){target{... on Commit{oid} ... on Tag{target{... on Commit{oid}}}}}}}";
|
|
133
|
+
const result = await this.ports.gh(["api", "graphql", "--hostname", "github.com", "-f", `query=${query}`, "-f", `owner=${owner}`, "-f", `name=${name}`, "-f", `ref=refs/tags/${request.tag}`], 10000);
|
|
134
|
+
let commit: string;
|
|
135
|
+
try {
|
|
136
|
+
const body = JSON.parse(result.stdout), repository = body?.data?.repository;
|
|
137
|
+
if (result.code !== 0 || body.errors || !repository || !Object.hasOwn(repository, "ref")) throw new ReleaseAuthorizationError();
|
|
138
|
+
if (repository.ref === null) {
|
|
139
|
+
if (!request.target || !SHA.test(request.target)) throw new ReleaseAuthorizationError();
|
|
140
|
+
commit = this.commit(git, request.target);
|
|
141
|
+
} else {
|
|
142
|
+
commit = repository.ref.target?.oid ?? repository.ref.target?.target?.oid;
|
|
143
|
+
if (!SHA.test(commit)) throw new ReleaseAuthorizationError();
|
|
144
|
+
}
|
|
145
|
+
} catch { throw new ReleaseAuthorizationError("GitHub tag unknown, or a new release lacks an explicit full commit target"); }
|
|
146
|
+
this.current();
|
|
147
|
+
if (this.origin(git).hash !== origin.hash) throw new ReleaseAuthorizationError("release remote changed during readback");
|
|
148
|
+
return { ok: true, receipts: [this.matched(git, request.version, commit)] };
|
|
149
|
+
} catch (error) { return { ok: false, error: releaseAuthorizationError(error) }; }
|
|
150
|
+
}
|
|
151
|
+
guardPush(remoteName: string, remoteUrl: string, updates: readonly ReleasePushUpdate[]): ReleaseGuardVerdict {
|
|
152
|
+
try {
|
|
153
|
+
this.current(); const git = this.git(), receipts: ReleaseAuthorization[] = [];
|
|
154
|
+
if (updates.length > 128) throw new ReleaseAuthorizationError("release push inventory exceeds the guard bound");
|
|
155
|
+
for (const update of updates) {
|
|
156
|
+
if (!validReleaseVersion(update.version) || update.remoteRef !== `refs/tags/v${update.version}` || !SHA.test(update.localOid) || /^0+$/.test(update.localOid)) throw new ReleaseAuthorizationError("release push target is invalid or deleted");
|
|
157
|
+
if (this.read(git, ["rev-parse", "--verify", "--end-of-options", update.localRef]) !== update.localOid) throw new ReleaseAuthorizationError("release tag changed after Git captured the push");
|
|
158
|
+
const commit = this.commit(git, update.localOid);
|
|
159
|
+
receipts.push(this.matched(git, update.version, commit, remoteName, remoteUrl));
|
|
160
|
+
}
|
|
161
|
+
return { ok: true, receipts };
|
|
162
|
+
} catch (error) { return { ok: false, error: releaseAuthorizationError(error) }; }
|
|
163
|
+
}
|
|
164
|
+
report<T extends ReleaseReadiness>(report: T): T & { authorization?: { state: "matched" | "stale"; receipt: ReleaseAuthorization } } {
|
|
165
|
+
if (!this.latest) return report;
|
|
166
|
+
const entry = this.entries.get(`${report.version}:${report.head}`), verdict = report.head && report.version ? docsVerdict(report.documents, report.head, report.version) : undefined;
|
|
167
|
+
let matched = !!entry && !!entry.origin.repo && entry.origin.repo.toLowerCase() === report.repo.toLowerCase() && !!verdict && verdict.digest === entry.receipt.docs.digest;
|
|
168
|
+
try { matched &&= this.origin(this.git()).hash === entry!.origin.hash; } catch { matched = false; }
|
|
169
|
+
const receipt = structuredClone((matched ? entry! : this.latest).receipt);
|
|
170
|
+
const blockers = matched ? report.blockers.map(item => item === "native acceptance and person release decision unverified" ? "native acceptance unverified" : item) : report.blockers;
|
|
171
|
+
return { ...report, blockers, authorization: { state: matched ? "matched" : "stale", receipt } };
|
|
172
|
+
}
|
|
173
|
+
}
|
package/src/hub/sdlc.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseIssueLabels, parseRedFirst, testIntegrityBlockers, type RedFirstReceipt, type TestIntegrity } from "./test-integrity.ts";
|
|
1
2
|
import { planConformanceBlockers, type PlanConformance } from "./plan-conformance.ts";
|
|
2
3
|
import { DEFAULT_MAX_NITS, findingsForHead, findingHead, findingTally, type ReviewFindingSet, type ReviewFindingTally } from "./review-findings.ts";
|
|
3
4
|
import type { CostPlan } from "./cost-governance.ts";
|
|
@@ -55,6 +56,7 @@ export interface SdlcReceipt {
|
|
|
55
56
|
localHead?: string;
|
|
56
57
|
localResult?: "passed" | "failed" | "unknown";
|
|
57
58
|
observed?: number;
|
|
59
|
+
redFirst?: RedFirstReceipt;
|
|
58
60
|
}
|
|
59
61
|
export interface PrEvidence {
|
|
60
62
|
head: string;
|
|
@@ -64,6 +66,8 @@ export interface PrEvidence {
|
|
|
64
66
|
unresolved: number | null;
|
|
65
67
|
pendingReviews: number | null;
|
|
66
68
|
paths: string[];
|
|
69
|
+
fileChanges?: { path: string; change: string }[];
|
|
70
|
+
issueLabels?: { issue: number; labels: string[] | null };
|
|
67
71
|
pathsComplete?: boolean;
|
|
68
72
|
/** All affected path identities are represented; rename/copy destinations alone cannot certify S2. */
|
|
69
73
|
typesComplete?: boolean;
|
|
@@ -178,7 +182,7 @@ export function loadSdlc(cwd: string, version?: string): SdlcConfig {
|
|
|
178
182
|
export function sdlcReceipt(value: unknown, previous: SdlcReceipt | undefined = undefined, config: SdlcConfig = sdlcConfig(undefined), version?: string): SdlcReceipt {
|
|
179
183
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdlc receipt: expected an object");
|
|
180
184
|
const raw = value as Record<string, unknown>;
|
|
181
|
-
for (const key of Object.keys(raw)) if (!["stage", "repo", "issue", "pr", "reviewedHead", "localHead", "localResult", "criteria", "phase"].includes(key)) throw new Error(`sdlc receipt.${key}: unsupported or externally observed field`);
|
|
185
|
+
for (const key of Object.keys(raw)) if (!["stage", "repo", "issue", "pr", "reviewedHead", "localHead", "localResult", "criteria", "phase", ...(config.mode !== "off" ? ["redFirst"] : [])].includes(key)) throw new Error(`sdlc receipt.${key}: unsupported or externally observed field`);
|
|
182
186
|
if (raw.criteria !== undefined && raw.phase !== undefined) throw new Error("sdlc receipt: give criteria only; phase is its retired name");
|
|
183
187
|
if (raw.phase !== undefined && version !== undefined && !olderThan(version, RETIRED_WORKFLOW_FROM)) throw new Error(`sdlc receipt.phase: retired in ${RETIRED_WORKFLOW_FROM} (#481); use criteria`);
|
|
184
188
|
previous ??= { stage: config.stages[0] ?? "spec" };
|
|
@@ -199,6 +203,9 @@ export function sdlcReceipt(value: unknown, previous: SdlcReceipt | undefined =
|
|
|
199
203
|
if (!["passed", "failed", "unknown"].includes(String(raw.localResult))) throw new Error("sdlc receipt.localResult: expected passed, failed or unknown");
|
|
200
204
|
next.localResult = raw.localResult as SdlcReceipt["localResult"];
|
|
201
205
|
}
|
|
206
|
+
if (raw.redFirst !== undefined) next.redFirst = parseRedFirst(raw.redFirst);
|
|
207
|
+
// A test receipt never carries across a different linked issue or artifact.
|
|
208
|
+
if (raw.redFirst === undefined && (next.repo !== previous.repo || next.issue !== previous.issue || next.pr !== previous.pr)) delete next.redFirst;
|
|
202
209
|
// An acceptance names one spec issue; relinking the receipt to another drops it (#481 S1).
|
|
203
210
|
if (next.repo !== previous.repo || next.issue !== previous.issue) delete next.acceptance;
|
|
204
211
|
// A new artifact cannot inherit another PR's review or local gate.
|
|
@@ -225,7 +232,7 @@ export function parsePrEvidence(value: unknown): PrEvidence {
|
|
|
225
232
|
const paths = p.files.map((f: Record<string, unknown>) => { if (typeof f.path !== "string" || !f.path || f.path.length > 1000) throw new Error("PR readback: invalid file identity"); return f.path; });
|
|
226
233
|
const completeTypes = p.files.every((file: Record<string, unknown>) => typeof file.changeType === "string" && ["ADDED", "DELETED", "MODIFIED", "CHANGED"].includes(file.changeType));
|
|
227
234
|
// Rename/copy metadata exposes only its destination, never a complete inventory of affected identities.
|
|
228
|
-
return { head: p.headRefOid, state: p.state, checks, paths, pathsComplete: Number.isSafeInteger(p.changedFiles) && p.changedFiles >= 0 && p.changedFiles === paths.length, typesComplete: completeTypes, ...(typeof p.isDraft === "boolean" ? { draft: p.isDraft } : {}), ...(typeof p.baseRefName === "string" && p.baseRefName && p.baseRefName.length <= 200 && !/[\x00-\x1f\x7f]/.test(p.baseRefName) ? { base: p.baseRefName } : {}), ...(sha(p.baseRefOid) ? { baseHead: p.baseRefOid } : {}),
|
|
235
|
+
return { head: p.headRefOid, state: p.state, checks, paths, fileChanges: p.files.map((file: Record<string, unknown>) => ({ path: file.path as string, change: typeof file.changeType === "string" ? file.changeType : "UNKNOWN" })), pathsComplete: Number.isSafeInteger(p.changedFiles) && p.changedFiles >= 0 && p.changedFiles === paths.length, typesComplete: completeTypes, ...(typeof p.isDraft === "boolean" ? { draft: p.isDraft } : {}), ...(typeof p.baseRefName === "string" && p.baseRefName && p.baseRefName.length <= 200 && !/[\x00-\x1f\x7f]/.test(p.baseRefName) ? { base: p.baseRefName } : {}), ...(sha(p.baseRefOid) ? { baseHead: p.baseRefOid } : {}),
|
|
229
236
|
review: p.reviewDecision === "APPROVED" ? "approved" : p.reviewDecision === "CHANGES_REQUESTED" ? "changes_requested" : "unknown",
|
|
230
237
|
pendingReviews: Array.isArray(p.reviewRequests) && p.reviewRequests.length <= 100 && p.reviewRequests.every((r: unknown) => !!r && typeof r === "object") ? p.reviewRequests.length : null,
|
|
231
238
|
unresolved: Number.isSafeInteger(p.unresolvedThreads) && p.unresolvedThreads >= 0 ? p.unresolvedThreads : null,
|
|
@@ -248,16 +255,16 @@ export const SPEC_STATE_TEXT: Record<Exclude<SpecState, "current">, string> = {
|
|
|
248
255
|
changed: "spec changed after acceptance: the person re-accepts with ahub sdlc accept <task> --issue <n>",
|
|
249
256
|
unknown: "spec edit time unknown: the issue could not be read",
|
|
250
257
|
};
|
|
251
|
-
export interface SdlcStatus { planConformance?: PlanConformance & { accepted?: boolean; earlierPushes?: "unknown"; revisionsAfterFirstObservedPush?: number }; findings?: ReviewFindingTally; spec?: { issue: number; state: SpecState }; playbook?: string; criteria?: CriteriaView; mode: SdlcMode; stage: SdlcStage; definition?: StageDefinition; entry?: string[]; exit?: string[]; blockers: string[]; ready: boolean; actor: string; next: string; }
|
|
258
|
+
export interface SdlcStatus { testIntegrity?: TestIntegrity; planConformance?: PlanConformance & { accepted?: boolean; earlierPushes?: "unknown"; revisionsAfterFirstObservedPush?: number }; findings?: ReviewFindingTally; spec?: { issue: number; state: SpecState }; playbook?: string; criteria?: CriteriaView; mode: SdlcMode; stage: SdlcStage; definition?: StageDefinition; entry?: string[]; exit?: string[]; blockers: string[]; ready: boolean; actor: string; next: string; }
|
|
252
259
|
/** The acceptance-criteria checklist (#481 S8, Compliance pass) counts once recorded, and on every task under the retired gsd-lite setting. Off keeps #257's output. */
|
|
253
260
|
export const criteriaRequired = (config: SdlcConfig, receipt: SdlcReceipt | undefined): boolean => config.workflow === "gsd-lite" || (config.mode !== "off" && !!checklistOf(receipt));
|
|
254
|
-
export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined, evidence?: PrEvidence, criteria?: CriteriaView, specEdit?: SpecEdit, sets: readonly ReviewFindingSet[] = [], commit?: string, plan?: SdlcStatus["planConformance"]): SdlcStatus {
|
|
261
|
+
export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined, evidence?: PrEvidence, criteria?: CriteriaView, specEdit?: SpecEdit, sets: readonly ReviewFindingSet[] = [], commit?: string, plan?: SdlcStatus["planConformance"], integrity?: TestIntegrity): SdlcStatus {
|
|
255
262
|
const stage = receipt?.stage ?? config.stages[0] ?? "spec", blockers: string[] = [];
|
|
256
263
|
const definition = stageDefinition(config, stage);
|
|
257
264
|
const items = findingsForHead(sets, findingHead(receipt, evidence, commit));
|
|
258
265
|
const findings = items.length ? findingTally(items) : undefined;
|
|
259
266
|
if (config.mode === "enforce" && findings?.openImportant) blockers.push("open important review findings: " + findings.openImportant);
|
|
260
|
-
if (config.mode === "enforce") blockers.push(...planConformanceBlockers(plan));
|
|
267
|
+
if (config.mode === "enforce") blockers.push(...planConformanceBlockers(plan), ...testIntegrityBlockers(integrity));
|
|
261
268
|
if (!definition) blockers.push("lifecycle stage definition unknown");
|
|
262
269
|
if (!receipt?.repo || !receipt.issue) blockers.push("issue identity missing");
|
|
263
270
|
if (!receipt?.pr) blockers.push("PR identity missing");
|
|
@@ -283,9 +290,10 @@ export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined,
|
|
|
283
290
|
const shown = criteria && (criteriaRequired(config, receipt) || definition && [...definition.entry_gates, ...definition.exit_gates].includes("criteria"));
|
|
284
291
|
// Shown, not counted as a readiness blocker: the spec gate is build entry (#481 S1).
|
|
285
292
|
const spec = config.mode !== "off" && receipt?.issue ? { spec: { issue: receipt.issue, state: specState(receipt, specEdit) } } : {};
|
|
286
|
-
return { ...spec, ...(config.mode !== "off" && plan ? { planConformance: plan } : {}), ...(findings ? { findings } : {}), ...(config.mode !== "off" && definition && playbookStage(definition.base) ? { playbook: playbookStage(definition.base) } : {}), ...(shown ? { criteria } : {}), ...(custom ? { definition, ...custom } : {}), mode: config.mode, stage, blockers, ready: blockers.length === 0,
|
|
293
|
+
return { ...spec, ...(config.mode !== "off" && integrity ? { testIntegrity: integrity } : {}), ...(config.mode !== "off" && plan ? { planConformance: plan } : {}), ...(findings ? { findings } : {}), ...(config.mode !== "off" && definition && playbookStage(definition.base) ? { playbook: playbookStage(definition.base) } : {}), ...(shown ? { criteria } : {}), ...(custom ? { definition, ...custom } : {}), mode: config.mode, stage, blockers, ready: blockers.length === 0,
|
|
287
294
|
actor: !definition ? "person" : shown && criteria.blockers.length ? "reviewer/person" : config.definitions ? definition.actor : baseActor(definition.base),
|
|
288
|
-
|
|
295
|
+
// #694: the identity blocker keeps its exact text for gate matching; the next step says how to supply it.
|
|
296
|
+
next: blockers[0] === "issue identity missing" ? "issue identity missing: name one issue (title #N, refs or hub_sdlc_record)" : blockers[0] ?? "person may inspect merge readiness; no automatic merge" };
|
|
289
297
|
}
|
|
290
298
|
|
|
291
299
|
function stageGates(def: StageDefinition, blockers: string[], checklist: string[]): { entry: string[]; exit: string[] } {
|
|
@@ -337,23 +345,53 @@ export async function readSpecEdit(repoName: string, issue: number, run: SdlcGh
|
|
|
337
345
|
} catch { return undefined; }
|
|
338
346
|
}
|
|
339
347
|
|
|
340
|
-
|
|
348
|
+
/**
|
|
349
|
+
* #694: the one open PR whose head is `commit`, or, with no commit, whose branch is `branch`. Zero or several matches, a
|
|
350
|
+
* failed read or malformed output give undefined: the hub links only what it verified, never text an agent wrote.
|
|
351
|
+
*/
|
|
352
|
+
export async function openPrFor(repository: string, head: { commit?: string; branch?: string }, run: SdlcGh = sdlcGh): Promise<number | undefined> {
|
|
353
|
+
if (!repo(repository) || head.commit !== undefined && !sha(head.commit) || !head.commit && !head.branch) return undefined;
|
|
354
|
+
try {
|
|
355
|
+
const listed = await run(["pr", "list", "--repo", repository, "--state", "open", "--limit", "100", "--json", "number,headRefOid,headRefName"], 10_000, undefined, 256 * 1024);
|
|
356
|
+
if (listed.code !== 0) return undefined;
|
|
357
|
+
const rows: unknown = JSON.parse(listed.stdout);
|
|
358
|
+
// A full page may be truncated: uniqueness is unknown, so nothing is linked. Every row must be well formed.
|
|
359
|
+
if (!Array.isArray(rows) || rows.length >= 100) return undefined;
|
|
360
|
+
if (!rows.every((row: { number?: unknown; headRefOid?: unknown; headRefName?: unknown }) => id(row?.number) && sha(row.headRefOid) && typeof row.headRefName === "string")) return undefined;
|
|
361
|
+
const hits = rows.filter((row: { headRefOid: string; headRefName: string }) => head.commit ? row.headRefOid === head.commit : row.headRefName === head.branch);
|
|
362
|
+
return hits.length === 1 ? (hits[0] as { number: number }).number : undefined;
|
|
363
|
+
} catch { return undefined; }
|
|
364
|
+
}
|
|
365
|
+
/** #694: the first link of an issue or PR is not a relink: it keeps everything the receipt already holds. */
|
|
366
|
+
export function linkedReceipt(previous: SdlcReceipt | undefined, link: { repo: string; issue?: number; pr?: number }, config: SdlcConfig): SdlcReceipt | undefined {
|
|
367
|
+
if (!repo(link.repo) || link.issue !== undefined && !id(link.issue) || link.pr !== undefined && !id(link.pr)) return undefined;
|
|
368
|
+
if (previous?.repo && previous.repo.toLowerCase() !== link.repo.toLowerCase()) return undefined;
|
|
369
|
+
if (link.issue !== undefined && previous?.issue !== undefined || link.pr !== undefined && previous?.pr !== undefined) return undefined;
|
|
370
|
+
return { ...(previous ?? { stage: config.stages[0] ?? "spec" }), repo: previous?.repo ?? link.repo, ...(link.issue !== undefined ? { issue: link.issue } : {}), ...(link.pr !== undefined ? { pr: link.pr } : {}) };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export async function readSdlcPr(receipt: SdlcReceipt, run: SdlcGh = sdlcGh, withIntegrity = false): Promise<PrEvidence | undefined> {
|
|
341
374
|
if (!repo(receipt.repo) || !id(receipt.pr)) return undefined;
|
|
342
375
|
try {
|
|
343
376
|
const view = await run(["pr", "view", String(receipt.pr), "--repo", receipt.repo, "--json", "headRefOid,state,statusCheckRollup,reviewDecision,reviewRequests,files,changedFiles,mergeCommit,isDraft,baseRefName,baseRefOid"]);
|
|
344
377
|
if (view.code !== 0) return undefined;
|
|
345
378
|
const [owner, name] = receipt.repo.split("/");
|
|
346
|
-
|
|
379
|
+
// The enabled S4 label read shares the existing thread snapshot, never a second readback for a known non-bug.
|
|
380
|
+
const labels = withIntegrity && id(receipt.issue) ? { issue: receipt.issue, labels: null as string[] | null } : undefined;
|
|
381
|
+
const query = `query($owner:String!,$name:String!,$number:Int!${labels ? ",$issue:Int!" : ""}){repository(owner:$owner,name:$name){pullRequest(number:$number){headRefOid reviewThreads(first:100){nodes{isResolved} pageInfo{hasNextPage}}}${labels ? " issue(number:$issue){number labels(first:100){nodes{name} pageInfo{hasNextPage}}}" : ""}}}`;
|
|
382
|
+
const threads = await run(["api", "graphql", "-f", `query=${query}`, "-f", `owner=${owner}`, "-f", `name=${name}`, ...(labels ? ["-F", `issue=${labels.issue}`] : []), "-F", `number=${receipt.pr}`]);
|
|
347
383
|
const p = JSON.parse(view.stdout);
|
|
348
384
|
delete p.unresolvedThreads; // only the complete thread query can establish this fact
|
|
349
385
|
if (threads.code === 0) {
|
|
350
|
-
const
|
|
386
|
+
const response = JSON.parse(threads.stdout);
|
|
387
|
+
const current = response?.data?.repository?.pullRequest;
|
|
388
|
+
if (labels && response.errors === undefined) labels.labels = parseIssueLabels(response?.data?.repository?.issue, labels.issue) ?? null;
|
|
351
389
|
const nodes = current?.reviewThreads?.nodes;
|
|
352
390
|
// Paginated or malformed thread data is unknown, never a clean review.
|
|
353
391
|
if (current?.headRefOid === p.headRefOid && current?.reviewThreads?.pageInfo?.hasNextPage === false && Array.isArray(nodes) && nodes.every(n => typeof n?.isResolved === "boolean")) p.unresolvedThreads = nodes.filter(n => !n.isResolved).length;
|
|
354
392
|
else if (current?.headRefOid && current.headRefOid !== p.headRefOid) return undefined;
|
|
355
393
|
}
|
|
356
|
-
return parsePrEvidence(p);
|
|
394
|
+
return { ...parsePrEvidence(p), ...(labels ? { issueLabels: labels } : {}) };
|
|
357
395
|
} catch { return undefined; }
|
|
358
396
|
}
|
|
359
397
|
|