@staix/agent-hub 0.12.44 → 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 +40 -0
- package/README.md +83 -116
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/docs/adding-an-ai-tool.md +102 -14
- package/docs/agent-notes/README.md +15 -0
- package/docs/agent-notes/adapters.md +63 -46
- package/docs/agent-notes/budget.md +7 -2
- package/docs/agent-notes/bus.md +6 -4
- package/docs/agent-notes/daemon.md +15 -8
- package/docs/agent-notes/local-worker.md +6 -4
- package/docs/agent-notes/tasks.md +18 -5
- package/docs/agent-notes/tests.md +11 -9
- package/docs/dogfood.md +92 -0
- package/docs/events.md +14 -1
- package/docs/operations.md +349 -26
- package/docs/profiles.md +2 -2
- package/docs/quickstart.md +12 -2
- package/docs/sdlc.md +61 -13
- package/docs/security.md +24 -12
- package/docs/smoke.md +9 -4
- package/docs/specs/2026-09-19-agent-hub-design.md +873 -13
- package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
- package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
- package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
- package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
- package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
- package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
- package/docs/verification/2026-10-22-0.12.45.md +60 -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/assets/469-r1-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-board-768-light.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-board-390-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
- package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
- package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
- package/docs/verification/dogfood/2026-10-22.md +3109 -0
- package/docs/verified.json +19 -16
- package/package.json +5 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +10120 -9623
- package/src/adapters/adapter-api.ts +2 -0
- package/src/adapters/agy/definition.ts +3 -1
- package/src/adapters/agy/entry.ts +5 -4
- package/src/adapters/claude-channel.ts +32 -12
- package/src/adapters/codex-appserver.ts +19 -2
- package/src/adapters/codex-review-isolation.ts +12 -5
- package/src/adapters/compat.ts +140 -0
- package/src/adapters/conformance/acp.ts +114 -0
- package/src/adapters/conformance/codex.ts +191 -0
- package/src/adapters/conformance/enrollment.ts +43 -0
- package/src/adapters/conformance/factories.ts +17 -0
- package/src/adapters/conformance/fake-paths.ts +12 -0
- package/src/adapters/conformance/fakes/acp-server.ts +267 -0
- package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
- package/src/adapters/conformance/fakes/app-server.ts +119 -0
- package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
- package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
- package/src/adapters/conformance/fakes/model-server.ts +39 -0
- package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
- package/src/adapters/conformance/index.ts +11 -0
- package/src/adapters/conformance/kit.ts +50 -0
- package/src/adapters/conformance/local-pi.ts +100 -0
- package/src/adapters/conformance/owned-acp.ts +44 -0
- package/src/adapters/definitions/builtin-metadata.ts +25 -7
- 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-entry-kimi.ts +7 -1
- package/src/adapters/definitions/native-cli-entry.ts +7 -19
- 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/native-cli-preview.ts +2 -11
- package/src/adapters/definitions/qwen-native.ts +7 -3
- package/src/adapters/definitions/qwen.ts +5 -1
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-module-scan.ts +126 -0
- package/src/adapters/external-modules.ts +240 -0
- package/src/adapters/jcode.ts +72 -31
- package/src/adapters/kimi-review.ts +6 -3
- package/src/adapters/pi.ts +2 -0
- package/src/adapters/public.ts +7 -0
- package/src/adapters/registry-types.ts +13 -1
- package/src/adapters/registry.ts +52 -15
- 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 +36 -21
- package/src/cli/identity.ts +30 -7
- package/src/cli/init.ts +109 -11
- package/src/cli/main.ts +145 -16
- 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/cli/upgrade-labels.ts +1 -1
- package/src/cli/upgrade-presentation.ts +1 -1
- package/src/cli/upgrade-restore-state.ts +3 -2
- package/src/cli/upgrade-runtime.ts +4 -1
- package/src/hub/action-class.ts +362 -0
- package/src/hub/action-policy.ts +106 -0
- package/src/hub/board.ts +72 -11
- 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 +28 -11
- package/src/hub/config-reload.ts +8 -5
- package/src/hub/config-trust.ts +7 -4
- package/src/hub/control-client.ts +4 -1
- package/src/hub/daemon.ts +332 -61
- 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/facts.ts +76 -13
- package/src/hub/hub-tools.ts +45 -7
- package/src/hub/inbox-polling.ts +25 -0
- package/src/hub/instance-config.ts +17 -5
- package/src/hub/instance-execution.ts +20 -8
- package/src/hub/instances.ts +75 -2
- package/src/hub/peers.ts +9 -0
- package/src/hub/plan-conformance.ts +83 -0
- 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/review-findings.ts +104 -0
- package/src/hub/roster.ts +31 -5
- package/src/hub/sdlc-queue.ts +48 -11
- package/src/hub/sdlc-release-authorization.ts +173 -0
- package/src/hub/sdlc-release.ts +16 -1
- package/src/hub/sdlc.ts +71 -18
- package/src/hub/settings.ts +10 -3
- package/src/hub/snapshots.ts +101 -14
- 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 +556 -88
- package/src/hub/test-integrity.ts +162 -0
- package/src/hub/ui.ts +2 -2
- package/src/hub/usage-rollup.ts +361 -0
- package/src/local/sandbox.ts +5 -3
- package/src/ui/index.html +560 -78
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +44 -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
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { types } from "node:util";
|
|
2
|
+
import { sanitize } from "./envelope.ts";
|
|
3
|
+
|
|
4
|
+
export interface ReviewFinding {
|
|
5
|
+
id: string;
|
|
6
|
+
pass: "bugs" | "security" | "compliance";
|
|
7
|
+
severity: "important" | "nit";
|
|
8
|
+
path?: string;
|
|
9
|
+
disposition: "open" | "fixed" | "false_positive" | "accepted";
|
|
10
|
+
rationale?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface ReviewFindingSet { head?: string; items: ReviewFinding[] }
|
|
13
|
+
export interface ReviewFindingTally { important: number; nit: number; openImportant: number; openNit: number; fixed: number; falsePositive: number; accepted: number }
|
|
14
|
+
export const DEFAULT_MAX_NITS = 5;
|
|
15
|
+
const MAX_ITEMS = 64, MAX_BYTES = 64 * 1024;
|
|
16
|
+
const fail = (reason: string): never => { throw new Error("review findings: " + reason); };
|
|
17
|
+
function fields(raw: unknown): Record<string, PropertyDescriptor> {
|
|
18
|
+
if (!raw || typeof raw !== "object" || types.isProxy(raw) || Array.isArray(raw)
|
|
19
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(raw)) || Object.getOwnPropertySymbols(raw).length) return fail("plain own-data finding required");
|
|
20
|
+
const descriptors: Record<string, PropertyDescriptor> = Object.assign(Object.create(null), Object.getOwnPropertyDescriptors(raw));
|
|
21
|
+
if (Object.values(descriptors).some(field => !Object.hasOwn(field, "value") || !field.enumerable)) return fail("accessors and hidden fields refused");
|
|
22
|
+
if (Object.keys(descriptors).some(key => !["id", "pass", "severity", "path", "disposition", "rationale"].includes(key))) return fail("unknown finding field");
|
|
23
|
+
return descriptors;
|
|
24
|
+
}
|
|
25
|
+
function text(raw: unknown, max: number, path: boolean): string {
|
|
26
|
+
if (typeof raw !== "string" || !raw.trim() || Buffer.byteLength(raw) > max
|
|
27
|
+
|| path && /[\p{Cc}\p{Cf}]/u.test(raw)) return fail("invalid bounded finding text");
|
|
28
|
+
const clean = sanitize(raw.trim());
|
|
29
|
+
if (Buffer.byteLength(clean) > max) return fail("sanitized finding text exceeds its bound");
|
|
30
|
+
return clean;
|
|
31
|
+
}
|
|
32
|
+
/** Supplied findings are validated independently of lifecycle mode, before any board effect. */
|
|
33
|
+
export function parseReviewFindings(raw: unknown, options: { person: boolean; maxNits: number }): ReviewFinding[] {
|
|
34
|
+
if (typeof options.person !== "boolean" || !Number.isSafeInteger(options.maxNits) || options.maxNits < 0 || options.maxNits > MAX_ITEMS) return fail("invalid finding authority or nit limit");
|
|
35
|
+
if (raw === undefined) return [];
|
|
36
|
+
if (!Array.isArray(raw) || types.isProxy(raw) || Object.getPrototypeOf(raw) !== Array.prototype
|
|
37
|
+
|| Object.getOwnPropertySymbols(raw).length || raw.length > MAX_ITEMS) return fail("at most 64 own-data findings required");
|
|
38
|
+
const descriptors: Record<string, PropertyDescriptor> = Object.assign(Object.create(null), Object.getOwnPropertyDescriptors(raw));
|
|
39
|
+
if (Object.keys(descriptors).length !== raw.length + 1) return fail("dense finding array required");
|
|
40
|
+
const ids = new Set<string>();
|
|
41
|
+
const result = Array.from({ length: raw.length }, (_, index): ReviewFinding => {
|
|
42
|
+
const entry = descriptors[String(index)];
|
|
43
|
+
if (!entry || !Object.hasOwn(entry, "value") || !entry.enumerable) return fail("finding array accessors refused");
|
|
44
|
+
const item = fields(entry.value), id: unknown = item.id?.value, pass: unknown = item.pass?.value;
|
|
45
|
+
const severity: unknown = item.severity?.value, disposition: unknown = item.disposition?.value;
|
|
46
|
+
if (typeof id !== "string" || !/^[A-Za-z0-9._:-]+$/.test(id) || Buffer.byteLength(id) > 96 || ids.has(id)) return fail("invalid or duplicate finding id");
|
|
47
|
+
ids.add(id);
|
|
48
|
+
if (pass !== "bugs" && pass !== "security" && pass !== "compliance") return fail("invalid finding pass");
|
|
49
|
+
if (severity !== "important" && severity !== "nit") return fail("invalid finding severity");
|
|
50
|
+
if (disposition !== "open" && disposition !== "fixed" && disposition !== "false_positive" && disposition !== "accepted") return fail("invalid finding disposition");
|
|
51
|
+
const path = item.path ? text(item.path.value, 512, true) : undefined;
|
|
52
|
+
const rationale = item.rationale ? text(item.rationale.value, 2048, false) : undefined;
|
|
53
|
+
if (disposition === "accepted" && (!options.person || !rationale)) return fail("accepted findings require a person and rationale");
|
|
54
|
+
return { id, pass, severity, disposition, ...(path !== undefined ? { path } : {}), ...(rationale !== undefined ? { rationale } : {}) };
|
|
55
|
+
});
|
|
56
|
+
if (result.filter(item => item.severity === "nit").length > options.maxNits) return fail("nit limit exceeded");
|
|
57
|
+
if (Buffer.byteLength(JSON.stringify(result)) > MAX_BYTES) return fail("finding report exceeds 64 KiB");
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Prefer live artifact identity, skipping malformed historical candidates rather than hiding a valid fallback. */
|
|
62
|
+
export function findingHead(receipt?: { head?: string; reviewedHead?: string; localHead?: string }, evidence?: { head: string }, commit?: string): string | undefined {
|
|
63
|
+
return [evidence?.head, receipt?.head, receipt?.reviewedHead, receipt?.localHead, commit]
|
|
64
|
+
.find((head): head is string => typeof head === "string" && /^[a-f0-9]{40}$/.test(head));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Nits belong only to their head bucket, including the explicit undefined bucket.
|
|
69
|
+
* Open Important is an obligation across heads until an explicit disposition resolves its ID globally.
|
|
70
|
+
*/
|
|
71
|
+
export function findingsForHead(sets: readonly ReviewFindingSet[], head?: string): ReviewFinding[] {
|
|
72
|
+
interface Entry { item: ReviewFinding; order: number }
|
|
73
|
+
const current = new Map<string, Entry>(), important = new Map<string, Entry>();
|
|
74
|
+
let order = 0;
|
|
75
|
+
for (const set of sets) for (const item of set.items) {
|
|
76
|
+
const key = item.id, position = order++;
|
|
77
|
+
if (set.head === head) current.set(key, { item: { ...current.get(key)?.item, ...item }, order: position });
|
|
78
|
+
const prior = important.get(key);
|
|
79
|
+
if (item.severity === "important") {
|
|
80
|
+
important.set(key, { item: { ...prior?.item, ...item }, order: position });
|
|
81
|
+
} else if (prior && (prior.item.disposition === "open" || item.disposition !== "open")) {
|
|
82
|
+
// A nit/open reclassification cannot discharge Important. An explicit disposition can.
|
|
83
|
+
important.set(key, { item: { ...prior.item, ...item, severity: "important" }, order: position });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
for (const [key, entry] of important) {
|
|
87
|
+
if (entry.item.disposition === "open") current.set(key, entry);
|
|
88
|
+
else {
|
|
89
|
+
const seen = current.get(key);
|
|
90
|
+
// Settling on B also settles older A when A is queried again. A later new nit/open remains a head-local nit.
|
|
91
|
+
if (seen?.item.disposition === "open" && seen.order <= entry.order) current.delete(key);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return [...current.values()].map(entry => ({ ...entry.item }));
|
|
95
|
+
}
|
|
96
|
+
export function findingTally(items: readonly ReviewFinding[]): ReviewFindingTally {
|
|
97
|
+
const tally: ReviewFindingTally = { important: 0, nit: 0, openImportant: 0, openNit: 0, fixed: 0, falsePositive: 0, accepted: 0 };
|
|
98
|
+
for (const item of items) {
|
|
99
|
+
tally[item.severity]++;
|
|
100
|
+
if (item.disposition === "open") tally[item.severity === "important" ? "openImportant" : "openNit"]++;
|
|
101
|
+
else tally[item.disposition === "false_positive" ? "falsePositive" : item.disposition]++;
|
|
102
|
+
}
|
|
103
|
+
return tally;
|
|
104
|
+
}
|
package/src/hub/roster.ts
CHANGED
|
@@ -1,15 +1,41 @@
|
|
|
1
1
|
import { definitionOf as builtinDefinitionOf, type ProjectAdapterCatalog, type KindMap } from "../adapters/registry.ts";
|
|
2
|
-
import {
|
|
2
|
+
import { validateInstances } from "./instances.ts";
|
|
3
|
+
import { defaultRosterEnabled as builtinDefaultRosterEnabled, createProjectAdapterCatalog as builtinCatalog } from "../adapters/registry.ts";
|
|
3
4
|
|
|
4
5
|
export type PeerRoster = Partial<Record<string, boolean>>;
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
/**
|
|
8
|
+
* A roster key of an instance that was declared, put on the roster and later undeclared (#225). It has the shape a declaration
|
|
9
|
+
* could have had (`<builtin kind>-<name>`, the rule validateInstances applies to an id) and no definition now, so it grants and
|
|
10
|
+
* refuses nothing: the config still loads and `ahub roster remove` can delete it. Anything else stays an unsupported peer.
|
|
11
|
+
*/
|
|
12
|
+
export function staleInstanceKey(peer: string, catalog: ProjectAdapterCatalog | undefined): boolean {
|
|
13
|
+
const definitionOf = catalog?.definitionOf ?? builtinDefinitionOf;
|
|
14
|
+
if (!/^[a-z][a-z0-9-]{0,31}$/.test(peer) || definitionOf(peer)) return false; // a reserved id (user, hub, digest) never starts with a kind and a dash
|
|
15
|
+
if (!catalog) return false;
|
|
16
|
+
return catalog.definitions.some(kind => peer.startsWith(`${kind.kind}-`) && /^[a-z0-9][a-z0-9-]*$/.test(peer.slice(kind.kind.length + 1)) && canDeclareInstance(peer, kind.kind, catalog) && !catalog.externalDefinitions.some(item => item.kind === kind.kind));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The declarations a person can write for an extra instance of a kind: validateInstances decides which of them its definition takes. */
|
|
20
|
+
const DECLARATION_SHAPES: readonly Record<string, unknown>[] = [
|
|
21
|
+
{ roles: ["implementer"], start_mode: "headless" }, { roles: ["reviewer"], start_mode: "headless" }, { roles: ["reviewer"] }, { roles: ["planner"] },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/** Whether validateInstances would take `<kind>-<name>` for this kind under any declaration, so the answer cannot drift from its policy. */
|
|
25
|
+
function canDeclareInstance(id: string, kind: string, catalog: ProjectAdapterCatalog): boolean {
|
|
26
|
+
return DECLARATION_SHAPES.some(shape => { try { validateInstances({ [id]: { kind, ...shape } }, catalog); return true; } catch { return false; } });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function peerRosterConfig(value: unknown, instances?: KindMap, catalog?: ProjectAdapterCatalog, inertUnknown = false): PeerRoster {
|
|
7
30
|
const definitionOf = catalog?.definitionOf ?? builtinDefinitionOf;
|
|
8
31
|
if (value === undefined) return {};
|
|
9
32
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("roster must be an object of boolean peer values");
|
|
10
33
|
const roster: PeerRoster = {};
|
|
34
|
+
const requireKnownPeer = (peer: string) => {
|
|
35
|
+
if (!definitionOf(peer, instances) && !staleInstanceKey(peer, catalog ?? builtinCatalog())) throw new Error("roster contains an unsupported peer");
|
|
36
|
+
};
|
|
11
37
|
for (const [peer, on] of Object.entries(value)) {
|
|
12
|
-
if (!
|
|
38
|
+
if (!inertUnknown || !/^[a-z][a-z0-9-]{0,31}$/.test(peer)) requireKnownPeer(peer);
|
|
13
39
|
if (typeof on !== "boolean") throw new Error(`roster.${peer} must be a boolean`);
|
|
14
40
|
roster[peer] = on;
|
|
15
41
|
}
|
|
@@ -17,10 +43,10 @@ export function peerRosterConfig(value: unknown, instances?: KindMap, catalog?:
|
|
|
17
43
|
}
|
|
18
44
|
|
|
19
45
|
/** External ids keep their existing admission rules; Pi's legacy switch is only its default. */
|
|
20
|
-
export function rosterAllows(roster: PeerRoster, peer: string, piEnabled: boolean, instances: KindMap | undefined, catalog: ProjectAdapterCatalog): boolean {
|
|
46
|
+
export function rosterAllows(roster: PeerRoster, peer: string, piEnabled: boolean, instances: KindMap | undefined, catalog: ProjectAdapterCatalog, inertUnknown = false): boolean {
|
|
21
47
|
const definitionOf = catalog.definitionOf;
|
|
22
48
|
const defaultRosterEnabled = (id: string, enabled: boolean) => !builtinDefinitionOf(id) ? catalog.definitionOf(id)?.defaultOnRoster ?? false : builtinDefaultRosterEnabled(id, enabled);
|
|
23
49
|
const definition = definitionOf(peer, instances);
|
|
24
|
-
if (!definition) return
|
|
50
|
+
if (!definition) return !inertUnknown || !Object.hasOwn(roster, peer); // Only a captured module scope makes unresolved rows inert; legacy external clients keep their admission rules.
|
|
25
51
|
return roster[peer] ?? (definition.kind === peer ? defaultRosterEnabled(peer, piEnabled) : true);
|
|
26
52
|
}
|
package/src/hub/sdlc-queue.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { publicTestIntegrity, parseIssueLabels, readTestIntegrity, testIntegrityBlockers, type TestIntegrity } from "./test-integrity.ts";
|
|
2
|
+
import { planConformance, planConformanceBlockers } from "./plan-conformance.ts";
|
|
3
|
+
import { findingsForHead, findingHead, findingTally, type ReviewFindingSet, type ReviewFindingTally } from "./review-findings.ts";
|
|
1
4
|
import type { CriteriaView } from "./workflow.ts";
|
|
2
5
|
import { parsePrEvidence, readSdlcPr, sdlcGh, sdlcStatus, type PrEvidence, type SdlcConfig, type SdlcGh, type SdlcReceipt } from "./sdlc.ts";
|
|
3
|
-
export interface DeliveryTask { id: number; after: number[]; private: boolean; receipt?: SdlcReceipt; phase?: CriteriaView; }
|
|
4
|
-
export interface MergeQueueRow { pr: number; tasks: number[]; head?: string; private?: true; ready: boolean; blockers: string[]; after: number[]; }
|
|
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; }
|
|
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[]; }
|
|
5
8
|
export interface MergeConflict { left: number; right: number; files: number; order: "dependency" | "person_required"; }
|
|
6
9
|
export interface MergeQueue { repo: string; rows: MergeQueueRow[]; order: number[]; unordered?: number[]; conflicts: MergeConflict[]; complete: boolean; truncated: boolean; }
|
|
7
10
|
const validRepo = (value: string) => /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value) && !value.startsWith("-");
|
|
@@ -10,13 +13,20 @@ const parseSnapshot = (reply: { code: number; stdout: string }): { number: numbe
|
|
|
10
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); }
|
|
11
14
|
catch { return; }
|
|
12
15
|
};
|
|
13
|
-
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" } : {}) });
|
|
14
17
|
/** The last remote read gathers both open identities and mutable gates in one bounded request. */
|
|
15
|
-
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 };
|
|
16
21
|
const [owner, name] = repo.split("/");
|
|
17
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}}}";
|
|
18
23
|
const entries = [...evidence.entries()];
|
|
19
|
-
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(" ")}}}`;
|
|
20
30
|
try {
|
|
21
31
|
const reply = await run(["api", "graphql", "-f", `query=${query}`, "-f", `owner=${owner}`, "-f", `name=${name}`]);
|
|
22
32
|
if (reply.code !== 0) return { gatesStable: false };
|
|
@@ -30,34 +40,61 @@ async function finalSnapshot(repo: string, evidence: Map<number, PrEvidence>, ru
|
|
|
30
40
|
const threads = p?.reviewThreads, reviews = p?.reviewRequests, checks = p?.statusCheckRollup === null ? { nodes: [], pageInfo: { hasNextPage: false } } : p?.statusCheckRollup?.contexts;
|
|
31
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 };
|
|
32
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 };
|
|
33
44
|
if (gateIdentity(before) !== gateIdentity(after)) return { gatesStable: false, prs };
|
|
34
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
|
+
}
|
|
35
51
|
return { gatesStable: true, prs };
|
|
36
52
|
} catch { return { gatesStable: false }; }
|
|
37
53
|
}
|
|
38
54
|
/** Fixed metadata-only reads; local claims and unknown dependency evidence never become merge approval. */
|
|
39
|
-
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> {
|
|
40
56
|
if (!validRepo(repo)) throw new Error("merge queue requires owner/repository");
|
|
41
57
|
const result: MergeQueue = { repo, rows: [], order: [], conflicts: [], complete: false, truncated: false };
|
|
42
58
|
const deadline = performance.now() + 20_000;
|
|
43
|
-
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: "" }); };
|
|
44
60
|
const listArgs = ["pr", "list", "--repo", repo, "--state", "open", "--limit", "17", "--json", "number,headRefOid,baseRefName,baseRefOid"];
|
|
45
61
|
const initialPrs = parseSnapshot(await bounded(listArgs)); if (!initialPrs) return result;
|
|
46
62
|
result.truncated = initialPrs.length > 16; const ids = initialPrs.slice(0, 16).map(pr => pr.number);
|
|
47
63
|
const sameRepo = (value: unknown) => typeof value === "string" && value.toLowerCase() === repo.toLowerCase();
|
|
48
64
|
const evidence = new Map<number, PrEvidence>(), links = new Map<number, DeliveryTask[]>();
|
|
65
|
+
const issueApplicability = new Map<number, TestIntegrity["applicability"]>();
|
|
49
66
|
for (const pr of ids) {
|
|
50
67
|
const holders = tasks.filter(t => sameRepo(t.receipt?.repo) && t.receipt?.pr === pr); links.set(pr, holders);
|
|
51
68
|
const row: MergeQueueRow = { pr, tasks: holders.some(t => t.private) ? [] : holders.map(t => t.id), ready: false, blockers: [], after: [] }; result.rows.push(row);
|
|
52
|
-
if (holders.some(t => t.private)) { row.private = true; row.blockers.push("private lifecycle evidence unavailable"); continue; }
|
|
53
|
-
const read = await readSdlcPr(holders[0]?.receipt ?? { stage: config.stages[0] ?? "spec", repo, pr }, bounded);
|
|
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; }
|
|
70
|
+
const read = await readSdlcPr(holders[0]?.receipt ?? { stage: config.stages[0] ?? "spec", repo, pr }, bounded, config.mode !== "off");
|
|
54
71
|
if (!read || read.state !== "OPEN") { row.blockers.push("current PR evidence unknown or no longer open"); continue; }
|
|
55
72
|
evidence.set(pr, read); row.head = read.head;
|
|
73
|
+
for (const holder of holders) {
|
|
74
|
+
const items = findingsForHead(holder.findings ?? [], findingHead(holder.receipt, read));
|
|
75
|
+
if (!items.length) continue;
|
|
76
|
+
const tally = findingTally(items);
|
|
77
|
+
row.findings ??= findingTally([]);
|
|
78
|
+
for (const key of Object.keys(tally) as (keyof ReviewFindingTally)[]) row.findings[key] += tally[key];
|
|
79
|
+
}
|
|
56
80
|
if (!read.pathsComplete) row.blockers.push("PR file coverage is incomplete or unknown");
|
|
57
81
|
if (read.draft !== false) row.blockers.push("PR is draft or draft status is unknown");
|
|
58
82
|
if (!read.base) row.blockers.push("PR target branch is unknown");
|
|
59
83
|
const linked = holders.length ? holders : [undefined];
|
|
60
|
-
|
|
84
|
+
if (config.mode === "enforce" && row.findings?.openImportant) row.blockers.push("open important review findings: " + row.findings.openImportant);
|
|
85
|
+
for (const holder of linked) {
|
|
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;
|
|
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 });
|
|
95
|
+
row.blockers.push(...sdlcStatus(config, holder?.receipt, read, holder?.phase).blockers);
|
|
96
|
+
if (config.mode === "enforce") row.blockers.push(...planConformanceBlockers(conformance), ...testIntegrityBlockers(integrity));
|
|
97
|
+
}
|
|
61
98
|
}
|
|
62
99
|
const dependencyEvidence = new Map<number, PrEvidence | undefined>(); let dependencyReads = 0;
|
|
63
100
|
for (const row of result.rows) {
|
|
@@ -100,7 +137,7 @@ export async function readMergeQueue(repo: string, config: SdlcConfig, tasks: De
|
|
|
100
137
|
result.complete = !result.truncated && !remaining.size && !touchingCoverageUnknown && !personOrder.size;
|
|
101
138
|
// Empty/private-only snapshots have no intervening remote reads and offer no private PR evidence.
|
|
102
139
|
if (evidence.size) {
|
|
103
|
-
const finalRead = await finalSnapshot(repo, evidence, bounded);
|
|
140
|
+
const finalRead = await finalSnapshot(repo, evidence, bounded, issueApplicability);
|
|
104
141
|
const gatesStable = finalRead.gatesStable;
|
|
105
142
|
const finalPrs = finalRead.prs;
|
|
106
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-release.ts
CHANGED
|
@@ -4,7 +4,22 @@ import { spawn } from "node:child_process";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { childEnv, stopOwnedProcess } from "./child-process.ts";
|
|
6
6
|
import { hubGitSync } from "./git.ts";
|
|
7
|
-
import {
|
|
7
|
+
import type { SdlcGh } from "./sdlc.ts";
|
|
8
|
+
// Standalone document evidence does not need lifecycle/review code; load the default remote reader only when used.
|
|
9
|
+
let importDiagnosticShown = false;
|
|
10
|
+
const sdlcGh: SdlcGh = async (...args) => {
|
|
11
|
+
let reader: typeof import("./sdlc.ts");
|
|
12
|
+
try { reader = await import("./sdlc.ts"); }
|
|
13
|
+
catch {
|
|
14
|
+
if (!importDiagnosticShown) {
|
|
15
|
+
importDiagnosticShown = true;
|
|
16
|
+
try { console.warn("sdlc: release evidence reader unavailable (lifecycle import failed)"); } catch { /* a diagnostic cannot turn unknown evidence into an exception */ }
|
|
17
|
+
}
|
|
18
|
+
return { code: 1, stdout: "" };
|
|
19
|
+
}
|
|
20
|
+
try { return await reader.sdlcGh(...args); }
|
|
21
|
+
catch { return { code: 1, stdout: "" }; }
|
|
22
|
+
};
|
|
8
23
|
export interface DocEvidence { schemaVersion: 1; head?: string; version?: string; documents: { path?: string; state: "fresh" | "stale" | "nonancestor" | "unknown"; verifiedAgainst?: string; commits?: number; dirty?: boolean }[]; errors: number; fresh: boolean; }
|
|
9
24
|
export interface ReleaseReadiness { repo: string; head?: string; version?: string; tag?: string; tagCommit?: string; commits: string[]; mergedPrs: { pr: number; commit: string }[]; documents?: DocEvidence; liveIssues: { id: number; state: "open" | "closed" | "unknown" }[]; blockers: string[]; sourceReady: boolean; native: "unverified"; ready: false; }
|
|
10
25
|
export type ReleaseGit = (args: string[]) => { code: number; stdout: string };
|