@staix/agent-hub 0.12.45 → 0.14.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 +31 -0
- package/README.md +85 -116
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +10 -7
- package/docs/agent-notes/budget.md +7 -1
- package/docs/agent-notes/bus.md +6 -4
- package/docs/agent-notes/daemon.md +14 -2
- package/docs/agent-notes/local-worker.md +2 -1
- package/docs/agent-notes/tasks.md +16 -3
- package/docs/agent-notes/tests.md +5 -3
- package/docs/dogfood.md +95 -0
- package/docs/events.md +14 -1
- package/docs/operations.md +290 -27
- package/docs/profiles.md +2 -2
- package/docs/quickstart.md +13 -3
- package/docs/sdlc.md +59 -8
- package/docs/security.md +26 -13
- package/docs/smoke.md +9 -4
- package/docs/specs/2026-09-19-agent-hub-design.md +797 -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 +61 -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/2026-10-23-0.14.0.md +69 -0
- package/docs/verification/dogfood/2026-10-22.md +3109 -0
- package/docs/verified.json +22 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +702 -347
- 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 +29 -11
- package/src/adapters/compat.ts +140 -0
- package/src/adapters/definitions/builtin-metadata.ts +3 -2
- package/src/adapters/definitions/native-cli-doctor.ts +12 -5
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
- 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/adapters/reviewer-print.ts +34 -7
- 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 +17 -6
- package/src/cli/identity.ts +29 -6
- package/src/cli/init.ts +109 -11
- package/src/cli/main.ts +127 -11
- package/src/cli/output.ts +91 -3
- package/src/cli/preview.ts +1 -1
- package/src/cli/sdlc-release-guard.ts +82 -0
- package/src/cli/status-lines.ts +1 -1
- package/src/hub/action-class.ts +362 -0
- package/src/hub/action-policy.ts +106 -0
- package/src/hub/board.ts +65 -9
- 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 +319 -34
- 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 +26 -3
- package/src/hub/inbox-polling.ts +25 -0
- package/src/hub/instances.ts +87 -5
- package/src/hub/manager.ts +2 -2
- 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-metrics.ts +260 -0
- package/src/hub/sdlc-queue.ts +31 -9
- package/src/hub/sdlc-release-authorization.ts +173 -0
- package/src/hub/sdlc.ts +54 -14
- package/src/hub/settings.ts +5 -2
- package/src/hub/snapshots.ts +93 -8
- package/src/hub/spec-read.ts +31 -0
- package/src/hub/symbol-normalizer.ts +26 -0
- package/src/hub/task-code-group.ts +52 -0
- package/src/hub/task-code-symbols.ts +90 -0
- package/src/hub/task-sweep.ts +3 -2
- package/src/hub/tasks.ts +455 -67
- package/src/hub/test-integrity.ts +162 -0
- package/src/hub/ui.ts +3 -0
- package/src/hub/usage-rollup.ts +419 -0
- package/src/local/sandbox.ts +5 -3
- package/src/ui/index.html +274 -2
- 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/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,6 +1,8 @@
|
|
|
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";
|
|
5
|
+
import { bandsConfig, type BandsConfig } from "./sdlc-metrics.ts";
|
|
4
6
|
import { criteriaReceipt, type CriteriaReceipt, type CriteriaView, type LegacyPhaseReceipt } from "./workflow.ts";
|
|
5
7
|
import { constants, openSync, fstatSync, readSync, closeSync } from "node:fs";
|
|
6
8
|
import { join } from "node:path";
|
|
@@ -31,7 +33,7 @@ export type StageGate = "issue" | "pr" | "local" | "review" | "checks" | "criter
|
|
|
31
33
|
export interface StageDefinition { base: SdlcBaseStage; actor: string; entry_gates: StageGate[]; exit_gates: StageGate[]; after: string[]; }
|
|
32
34
|
export type SdlcMode = "off" | "advise" | "enforce";
|
|
33
35
|
/** `workflow: "gsd-lite"` marks the retired setting during its window (#481 S8): every task then needs a criteria checklist. */
|
|
34
|
-
export interface SdlcConfig { workflow?: "gsd-lite"; maxNits?: number; mode: SdlcMode; requiredChecks: string[]; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; deprecations?: string[]; }
|
|
36
|
+
export interface SdlcConfig { workflow?: "gsd-lite"; maxNits?: number; /** #481 S7: optional control bands per metric; absent means none. */ bands?: BandsConfig; mode: SdlcMode; requiredChecks: string[]; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; deprecations?: string[]; }
|
|
35
37
|
export interface PlanReviewReceipt {
|
|
36
38
|
planner: string;
|
|
37
39
|
planningRevision: number;
|
|
@@ -55,6 +57,7 @@ export interface SdlcReceipt {
|
|
|
55
57
|
localHead?: string;
|
|
56
58
|
localResult?: "passed" | "failed" | "unknown";
|
|
57
59
|
observed?: number;
|
|
60
|
+
redFirst?: RedFirstReceipt;
|
|
58
61
|
}
|
|
59
62
|
export interface PrEvidence {
|
|
60
63
|
head: string;
|
|
@@ -64,6 +67,8 @@ export interface PrEvidence {
|
|
|
64
67
|
unresolved: number | null;
|
|
65
68
|
pendingReviews: number | null;
|
|
66
69
|
paths: string[];
|
|
70
|
+
fileChanges?: { path: string; change: string }[];
|
|
71
|
+
issueLabels?: { issue: number; labels: string[] | null };
|
|
67
72
|
pathsComplete?: boolean;
|
|
68
73
|
/** All affected path identities are represented; rename/copy destinations alone cannot certify S2. */
|
|
69
74
|
typesComplete?: boolean;
|
|
@@ -129,10 +134,11 @@ export function sdlcConfig(value: unknown, version?: string): SdlcConfig {
|
|
|
129
134
|
if (value === undefined) return { mode: "off", requiredChecks: [], stages: [...SDLC_STAGES] };
|
|
130
135
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdlc: expected a table");
|
|
131
136
|
const raw = value as Record<string, unknown>;
|
|
132
|
-
for (const key of Object.keys(raw)) if (!["mode", "required_checks", "stages", "workflow", "definitions", "max_nits"].includes(key)) throw new Error(`sdlc.${key}: unknown field`);
|
|
137
|
+
for (const key of Object.keys(raw)) if (!["mode", "required_checks", "stages", "workflow", "definitions", "max_nits", "bands"].includes(key)) throw new Error(`sdlc.${key}: unknown field`);
|
|
133
138
|
const maxNits = raw.max_nits === undefined ? DEFAULT_MAX_NITS : raw.max_nits;
|
|
134
139
|
if (typeof maxNits !== "number" || !Number.isSafeInteger(maxNits) || maxNits < 0 || maxNits > 64) throw new Error("sdlc.max_nits: expected an integer from 0 to 64");
|
|
135
|
-
const
|
|
140
|
+
const bands = bandsConfig(raw.bands), bandConfig = Object.keys(bands).length ? { bands } : {};
|
|
141
|
+
const findingConfig = { ...(raw.max_nits === undefined ? {} : { maxNits }), ...bandConfig };
|
|
136
142
|
const mode = raw.mode ?? "off";
|
|
137
143
|
if (mode !== "off" && mode !== "advise" && mode !== "enforce") throw new Error("sdlc.mode: expected off, advise or enforce");
|
|
138
144
|
// ponytail: the #481 S8 window is a fixed version; delete `workflow`, the phase aliases and their tests once it passes.
|
|
@@ -178,7 +184,7 @@ export function loadSdlc(cwd: string, version?: string): SdlcConfig {
|
|
|
178
184
|
export function sdlcReceipt(value: unknown, previous: SdlcReceipt | undefined = undefined, config: SdlcConfig = sdlcConfig(undefined), version?: string): SdlcReceipt {
|
|
179
185
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdlc receipt: expected an object");
|
|
180
186
|
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`);
|
|
187
|
+
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
188
|
if (raw.criteria !== undefined && raw.phase !== undefined) throw new Error("sdlc receipt: give criteria only; phase is its retired name");
|
|
183
189
|
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
190
|
previous ??= { stage: config.stages[0] ?? "spec" };
|
|
@@ -199,6 +205,9 @@ export function sdlcReceipt(value: unknown, previous: SdlcReceipt | undefined =
|
|
|
199
205
|
if (!["passed", "failed", "unknown"].includes(String(raw.localResult))) throw new Error("sdlc receipt.localResult: expected passed, failed or unknown");
|
|
200
206
|
next.localResult = raw.localResult as SdlcReceipt["localResult"];
|
|
201
207
|
}
|
|
208
|
+
if (raw.redFirst !== undefined) next.redFirst = parseRedFirst(raw.redFirst);
|
|
209
|
+
// A test receipt never carries across a different linked issue or artifact.
|
|
210
|
+
if (raw.redFirst === undefined && (next.repo !== previous.repo || next.issue !== previous.issue || next.pr !== previous.pr)) delete next.redFirst;
|
|
202
211
|
// An acceptance names one spec issue; relinking the receipt to another drops it (#481 S1).
|
|
203
212
|
if (next.repo !== previous.repo || next.issue !== previous.issue) delete next.acceptance;
|
|
204
213
|
// A new artifact cannot inherit another PR's review or local gate.
|
|
@@ -225,7 +234,7 @@ export function parsePrEvidence(value: unknown): PrEvidence {
|
|
|
225
234
|
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
235
|
const completeTypes = p.files.every((file: Record<string, unknown>) => typeof file.changeType === "string" && ["ADDED", "DELETED", "MODIFIED", "CHANGED"].includes(file.changeType));
|
|
227
236
|
// 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 } : {}),
|
|
237
|
+
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
238
|
review: p.reviewDecision === "APPROVED" ? "approved" : p.reviewDecision === "CHANGES_REQUESTED" ? "changes_requested" : "unknown",
|
|
230
239
|
pendingReviews: Array.isArray(p.reviewRequests) && p.reviewRequests.length <= 100 && p.reviewRequests.every((r: unknown) => !!r && typeof r === "object") ? p.reviewRequests.length : null,
|
|
231
240
|
unresolved: Number.isSafeInteger(p.unresolvedThreads) && p.unresolvedThreads >= 0 ? p.unresolvedThreads : null,
|
|
@@ -248,16 +257,16 @@ export const SPEC_STATE_TEXT: Record<Exclude<SpecState, "current">, string> = {
|
|
|
248
257
|
changed: "spec changed after acceptance: the person re-accepts with ahub sdlc accept <task> --issue <n>",
|
|
249
258
|
unknown: "spec edit time unknown: the issue could not be read",
|
|
250
259
|
};
|
|
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; }
|
|
260
|
+
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
261
|
/** 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
262
|
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 {
|
|
263
|
+
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
264
|
const stage = receipt?.stage ?? config.stages[0] ?? "spec", blockers: string[] = [];
|
|
256
265
|
const definition = stageDefinition(config, stage);
|
|
257
266
|
const items = findingsForHead(sets, findingHead(receipt, evidence, commit));
|
|
258
267
|
const findings = items.length ? findingTally(items) : undefined;
|
|
259
268
|
if (config.mode === "enforce" && findings?.openImportant) blockers.push("open important review findings: " + findings.openImportant);
|
|
260
|
-
if (config.mode === "enforce") blockers.push(...planConformanceBlockers(plan));
|
|
269
|
+
if (config.mode === "enforce") blockers.push(...planConformanceBlockers(plan), ...testIntegrityBlockers(integrity));
|
|
261
270
|
if (!definition) blockers.push("lifecycle stage definition unknown");
|
|
262
271
|
if (!receipt?.repo || !receipt.issue) blockers.push("issue identity missing");
|
|
263
272
|
if (!receipt?.pr) blockers.push("PR identity missing");
|
|
@@ -283,9 +292,10 @@ export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined,
|
|
|
283
292
|
const shown = criteria && (criteriaRequired(config, receipt) || definition && [...definition.entry_gates, ...definition.exit_gates].includes("criteria"));
|
|
284
293
|
// Shown, not counted as a readiness blocker: the spec gate is build entry (#481 S1).
|
|
285
294
|
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,
|
|
295
|
+
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
296
|
actor: !definition ? "person" : shown && criteria.blockers.length ? "reviewer/person" : config.definitions ? definition.actor : baseActor(definition.base),
|
|
288
|
-
|
|
297
|
+
// #694: the identity blocker keeps its exact text for gate matching; the next step says how to supply it.
|
|
298
|
+
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
299
|
}
|
|
290
300
|
|
|
291
301
|
function stageGates(def: StageDefinition, blockers: string[], checklist: string[]): { entry: string[]; exit: string[] } {
|
|
@@ -337,23 +347,53 @@ export async function readSpecEdit(repoName: string, issue: number, run: SdlcGh
|
|
|
337
347
|
} catch { return undefined; }
|
|
338
348
|
}
|
|
339
349
|
|
|
340
|
-
|
|
350
|
+
/**
|
|
351
|
+
* #694: the one open PR whose head is `commit`, or, with no commit, whose branch is `branch`. Zero or several matches, a
|
|
352
|
+
* failed read or malformed output give undefined: the hub links only what it verified, never text an agent wrote.
|
|
353
|
+
*/
|
|
354
|
+
export async function openPrFor(repository: string, head: { commit?: string; branch?: string }, run: SdlcGh = sdlcGh): Promise<number | undefined> {
|
|
355
|
+
if (!repo(repository) || head.commit !== undefined && !sha(head.commit) || !head.commit && !head.branch) return undefined;
|
|
356
|
+
try {
|
|
357
|
+
const listed = await run(["pr", "list", "--repo", repository, "--state", "open", "--limit", "100", "--json", "number,headRefOid,headRefName"], 10_000, undefined, 256 * 1024);
|
|
358
|
+
if (listed.code !== 0) return undefined;
|
|
359
|
+
const rows: unknown = JSON.parse(listed.stdout);
|
|
360
|
+
// A full page may be truncated: uniqueness is unknown, so nothing is linked. Every row must be well formed.
|
|
361
|
+
if (!Array.isArray(rows) || rows.length >= 100) return undefined;
|
|
362
|
+
if (!rows.every((row: { number?: unknown; headRefOid?: unknown; headRefName?: unknown }) => id(row?.number) && sha(row.headRefOid) && typeof row.headRefName === "string")) return undefined;
|
|
363
|
+
const hits = rows.filter((row: { headRefOid: string; headRefName: string }) => head.commit ? row.headRefOid === head.commit : row.headRefName === head.branch);
|
|
364
|
+
return hits.length === 1 ? (hits[0] as { number: number }).number : undefined;
|
|
365
|
+
} catch { return undefined; }
|
|
366
|
+
}
|
|
367
|
+
/** #694: the first link of an issue or PR is not a relink: it keeps everything the receipt already holds. */
|
|
368
|
+
export function linkedReceipt(previous: SdlcReceipt | undefined, link: { repo: string; issue?: number; pr?: number }, config: SdlcConfig): SdlcReceipt | undefined {
|
|
369
|
+
if (!repo(link.repo) || link.issue !== undefined && !id(link.issue) || link.pr !== undefined && !id(link.pr)) return undefined;
|
|
370
|
+
if (previous?.repo && previous.repo.toLowerCase() !== link.repo.toLowerCase()) return undefined;
|
|
371
|
+
if (link.issue !== undefined && previous?.issue !== undefined || link.pr !== undefined && previous?.pr !== undefined) return undefined;
|
|
372
|
+
return { ...(previous ?? { stage: config.stages[0] ?? "spec" }), repo: previous?.repo ?? link.repo, ...(link.issue !== undefined ? { issue: link.issue } : {}), ...(link.pr !== undefined ? { pr: link.pr } : {}) };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export async function readSdlcPr(receipt: SdlcReceipt, run: SdlcGh = sdlcGh, withIntegrity = false): Promise<PrEvidence | undefined> {
|
|
341
376
|
if (!repo(receipt.repo) || !id(receipt.pr)) return undefined;
|
|
342
377
|
try {
|
|
343
378
|
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
379
|
if (view.code !== 0) return undefined;
|
|
345
380
|
const [owner, name] = receipt.repo.split("/");
|
|
346
|
-
|
|
381
|
+
// The enabled S4 label read shares the existing thread snapshot, never a second readback for a known non-bug.
|
|
382
|
+
const labels = withIntegrity && id(receipt.issue) ? { issue: receipt.issue, labels: null as string[] | null } : undefined;
|
|
383
|
+
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}}}" : ""}}}`;
|
|
384
|
+
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
385
|
const p = JSON.parse(view.stdout);
|
|
348
386
|
delete p.unresolvedThreads; // only the complete thread query can establish this fact
|
|
349
387
|
if (threads.code === 0) {
|
|
350
|
-
const
|
|
388
|
+
const response = JSON.parse(threads.stdout);
|
|
389
|
+
const current = response?.data?.repository?.pullRequest;
|
|
390
|
+
if (labels && response.errors === undefined) labels.labels = parseIssueLabels(response?.data?.repository?.issue, labels.issue) ?? null;
|
|
351
391
|
const nodes = current?.reviewThreads?.nodes;
|
|
352
392
|
// Paginated or malformed thread data is unknown, never a clean review.
|
|
353
393
|
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
394
|
else if (current?.headRefOid && current.headRefOid !== p.headRefOid) return undefined;
|
|
355
395
|
}
|
|
356
|
-
return parsePrEvidence(p);
|
|
396
|
+
return { ...parsePrEvidence(p), ...(labels ? { issueLabels: labels } : {}) };
|
|
357
397
|
} catch { return undefined; }
|
|
358
398
|
}
|
|
359
399
|
|
package/src/hub/settings.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ACTION_CLASSES } from "./action-class.ts";
|
|
1
2
|
import { definitionOf, type KindMap, type ProjectAdapterCatalog, ADAPTERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
|
|
2
3
|
import { createHash } from "node:crypto";
|
|
3
4
|
import { types } from "node:util";
|
|
@@ -59,6 +60,8 @@ export const SETTINGS: readonly SettingDef[] = [
|
|
|
59
60
|
...SETTING_PEERS.map((peer): SettingDef => ({ key: `catalog_roles.${peer}`, group: "Roles", label: `${peer}: catalog guidance`, type: "roles", store: "config", applies: "next use", risk: "safe", path: ["catalog_roles", peer] })),
|
|
60
61
|
{ key: "scoped_grants.protect_instructions", group: "Permissions", label: "Ask before writing instruction files", type: "boolean", store: "config", applies: "live", risk: "raises", floor: true, path: ["protect_instruction_files"] },
|
|
61
62
|
...SETTING_PEERS.map((peer): SettingDef => ({ key: `roster.${peer}`, group: "Roster", label: `${peer}: participates`, type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["roster", peer] })),
|
|
63
|
+
{ key: "action_policy.enabled", group: "Permissions", label: "Always-ask action policy", type: "boolean", store: "config", applies: "live", risk: "raises", floor: true, path: ["action_policy", "enabled"] },
|
|
64
|
+
...ACTION_CLASSES.map((kind): SettingDef => ({ key: `action_policy.budgets.${kind}`, group: "Permissions", label: `${kind}: approved session budget`, type: "integer", min: 0, max: 1000, store: "config", applies: "live", risk: "raises", floor: 0, path: ["action_policy", "budgets", kind] })),
|
|
62
65
|
...SETTING_PEERS.filter(peer => !definitionOf(peer)?.permission.unmanaged).map((peer): SettingDef => ({ key: `permission.${peer}`, group: "Permissions", label: `${peer}: mode now`, type: "enum", values: PERMISSION_MODES, store: "runtime", applies: "live", risk: "raises", floor: "ask", path: [peer] })),
|
|
63
66
|
...SETTING_PEERS.filter(peer => !definitionOf(peer)?.permission.unmanaged).map((peer): SettingDef => ({ key: `permission_modes.${peer}`, group: "Permissions", label: `${peer}: mode at next peer start`, type: "enum", values: PERMISSION_MODES, store: "config", applies: "next use", risk: "raises", floor: "ask", path: ["permission_modes", peer] })),
|
|
64
67
|
...START_MODE_PEERS.map((peer): SettingDef => ({ key: `peers.${peer}.start_mode`, group: "Start", label: `${peer}: start mode (tui unless set)`, type: "enum", values: START_MODES, store: "config", applies: "next use", risk: "raises", floor: "tui", path: ["peers", peer, "start_mode"] })),
|
|
@@ -470,7 +473,7 @@ export function settingRows(input: {
|
|
|
470
473
|
const scalar = (value: unknown): SettingValue => typeof value === "string" || typeof value === "boolean" || typeof value === "number" ? value : Array.isArray(value) ? value.map(String) : null;
|
|
471
474
|
return projectSettings(input.instances, input.adapterCatalog).map((def): SettingRow => {
|
|
472
475
|
const base = { key: def.key, group: def.group, label: def.label, type: def.type, ...(["integer", "number"].includes(def.type) ? { min: def.min, max: def.max } : {}), applies: def.applies, ...(def.restartReason ? { restartReason: def.restartReason } : {}), risk: def.risk, ...(def.floor === undefined ? {} : { floor: def.floor }),
|
|
473
|
-
...(def.key === "scoped_grants.protect_instructions" ? { hint: "AGENTS.md, CLAUDE.md and QWEN.md remain grantable by default; enabling this asks before scoped writes and edits" } : def.group === "Permissions" ? { hint: permissionBoundary(def.path.at(-1)!, input.instances, input.adapterCatalog) } : {}) };
|
|
476
|
+
...(def.key === "scoped_grants.protect_instructions" ? { hint: "AGENTS.md, CLAUDE.md and QWEN.md remain grantable by default; enabling this asks before scoped writes and edits" } : def.key.startsWith("action_policy.") ? { hint: "not enforced yet: adapter integration pending" } : def.group === "Permissions" ? { hint: permissionBoundary(def.path.at(-1)!, input.instances, input.adapterCatalog) } : {}) };
|
|
474
477
|
if (def.locked) return { ...base, values: def.values, file: def.store === "runtime" ? "this hub run" : CONFIG_LOCAL,
|
|
475
478
|
source: "instance declaration", value: "read-only", fileValue: "read-only", locked: def.locked, note: def.locked };
|
|
476
479
|
if (def.store === "runtime") {
|
|
@@ -485,7 +488,7 @@ export function settingRows(input: {
|
|
|
485
488
|
if (def.type === "roles" && input.roles) { const roles = input.roles(def.path[1]!); return { ...base, ...roles, fileValue: roles.pending !== undefined ? roles.pending : roles.value, file: CONFIG_LOCAL, source: valueAt(local, def.path) !== undefined ? CONFIG_LOCAL : valueAt(shared, def.path) !== undefined ? "config.json" : "profile/default", hint: "Guidance only; grants no tool or permission. At most two roles, applied at the next peer start." }; }
|
|
486
489
|
const value = scalar(input.running(def.path));
|
|
487
490
|
const stored = input.next ? scalar(input.next(def.path)) : value;
|
|
488
|
-
const source = valueAt(local, def.path) !== undefined ? CONFIG_LOCAL : valueAt(shared, def.path) !== undefined ? "config.json" : "default";
|
|
491
|
+
const source = valueAt(local, def.path) !== undefined ? CONFIG_LOCAL : !def.key.startsWith("action_policy.") && valueAt(shared, def.path) !== undefined ? "config.json" : "default";
|
|
489
492
|
const dropped = ignoredLine(input.ignored, def);
|
|
490
493
|
return { ...base, ...(def.values ? { values: def.values } : {}), file: CONFIG_LOCAL, value, fileValue: stored, source, ...(same(value, stored) ? {} : { pending: stored }),
|
|
491
494
|
...(!input.next ? { note: "the config files do not load; fix them in your editor" } : dropped ? { note: `the hub does not read this value: ${dropped}` } : {}) };
|