@staix/agent-hub 0.12.35 → 0.12.37
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 +25 -0
- package/README.md +15 -8
- package/docs/agent-notes/adapters.md +9 -2
- package/docs/agent-notes/budget.md +3 -2
- package/docs/agent-notes/bus.md +3 -0
- package/docs/agent-notes/daemon.md +11 -3
- package/docs/agent-notes/tasks.md +14 -1
- package/docs/agent-notes/tests.md +6 -2
- package/docs/memory.md +3 -1
- package/docs/operations.md +74 -18
- package/docs/quickstart.md +7 -9
- package/docs/sdlc.md +72 -5
- package/docs/security.md +11 -3
- package/docs/smoke.md +18 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +108 -3
- package/docs/verification/2026-10-19-0.12.36.md +63 -0
- package/docs/verification/2026-10-19-0.12.37.md +60 -0
- package/docs/verification/2026-10-19-jcode-0.98.1.md +30 -0
- package/docs/verification/2026-10-19-reviewer-isolation.md +119 -0
- package/docs/verified.json +23 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +2843 -1632
- package/src/adapters/acp.ts +3 -2
- package/src/adapters/claude-print.ts +92 -0
- package/src/adapters/codex-review-isolation.ts +276 -0
- package/src/adapters/definitions/builtin-metadata.ts +20 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
- package/src/adapters/definitions/native-cli-entry-jcode.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-factories.ts +24 -0
- package/src/adapters/definitions/native-policy-aux.ts +1 -1
- package/src/adapters/definitions/reviewer-variants.ts +32 -0
- package/src/adapters/jcode.ts +20 -5
- package/src/adapters/registry-types.ts +13 -3
- package/src/adapters/registry.ts +29 -5
- package/src/adapters/reviewer-print.ts +319 -0
- package/src/cli/help.ts +7 -0
- package/src/cli/identity.ts +2 -1
- package/src/cli/launch.ts +19 -6
- package/src/cli/main.ts +57 -4
- package/src/cli/memory.ts +13 -12
- package/src/cli/reset.ts +4 -2
- package/src/cli/statusline-tee.ts +5 -2
- package/src/cli/terminal-recovery.ts +7 -2
- package/src/cli/upgrade-runtime.ts +18 -15
- package/src/hub/board.ts +61 -11
- package/src/hub/bus.ts +11 -5
- package/src/hub/claude-state.ts +37 -0
- package/src/hub/config-apply.ts +3 -2
- package/src/hub/control-client.ts +1 -1
- package/src/hub/cost-governance.ts +76 -0
- package/src/hub/daemon.ts +269 -87
- package/src/hub/envelope.ts +1 -1
- package/src/hub/host-executable.ts +1 -1
- package/src/hub/hub-tools.ts +25 -1
- package/src/hub/instances.ts +29 -5
- package/src/hub/memory-profile.ts +67 -11
- package/src/hub/planning.ts +2 -0
- package/src/hub/release-admission.ts +64 -0
- package/src/hub/review-brief.ts +175 -0
- package/src/hub/reviewer-pool-config.ts +11 -0
- package/src/hub/reviewer-pool.ts +70 -0
- package/src/hub/routing.ts +58 -4
- package/src/hub/sdlc-release.ts +113 -0
- package/src/hub/sdlc.ts +205 -4
- package/src/hub/status-roster.ts +10 -0
- package/src/hub/task-sweep.ts +143 -0
- package/src/hub/tasks.ts +788 -52
- package/src/hub/usage.ts +57 -20
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export type ReviewerPoolReason = "rotation" | "implementer skip" | "unavailable skip" | "same-family fallback";
|
|
2
|
+
export interface ReviewerPoolCandidate { id: string; kind: string; family: string; blocked?: string }
|
|
3
|
+
export interface ReviewerPoolInput {
|
|
4
|
+
pool: readonly string[];
|
|
5
|
+
candidates: readonly ReviewerPoolCandidate[];
|
|
6
|
+
implementerFamily?: string;
|
|
7
|
+
seed: number;
|
|
8
|
+
continuityFamily?: string;
|
|
9
|
+
rerotate?: boolean;
|
|
10
|
+
failed?: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
export interface ReviewerPoolSelection {
|
|
13
|
+
reviewer: string | null;
|
|
14
|
+
family?: string;
|
|
15
|
+
reason?: ReviewerPoolReason;
|
|
16
|
+
walk: readonly { id: string; kind: string; family: string; reason: string }[];
|
|
17
|
+
blocked?: "no reviewer can run";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Pure selection: caller supplies declared candidates and current availability, never inferred authority. */
|
|
21
|
+
export function selectReviewerPool(input: ReviewerPoolInput): ReviewerPoolSelection {
|
|
22
|
+
const kinds = [...new Set(input.pool)];
|
|
23
|
+
const walk: { id: string; kind: string; family: string; reason: string }[] = [];
|
|
24
|
+
if (!kinds.length) return { reviewer: null, walk, blocked: "no reviewer can run" };
|
|
25
|
+
const seed = Number.isSafeInteger(input.seed) ? input.seed : 0;
|
|
26
|
+
const start = ((seed % kinds.length) + kinds.length) % kinds.length;
|
|
27
|
+
const rotated = [...kinds.slice(start), ...kinds.slice(0, start)];
|
|
28
|
+
const slots = rotated.flatMap(kind => {
|
|
29
|
+
const declared = input.candidates.filter(candidate => candidate.kind === kind).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
30
|
+
return declared.length ? declared : [{ id: "", kind, family: "", blocked: "no declared reviewer" }];
|
|
31
|
+
});
|
|
32
|
+
let ordered = slots;
|
|
33
|
+
// Preserve the rotated order within each family, including sibling instance ids.
|
|
34
|
+
const continuity = !input.rerotate && input.continuityFamily !== undefined && input.continuityFamily !== input.implementerFamily;
|
|
35
|
+
if (continuity) ordered = [...ordered.filter(candidate => candidate.family === input.continuityFamily), ...ordered.filter(candidate => candidate.family !== input.continuityFamily)];
|
|
36
|
+
const failed = new Set(input.failed ?? []);
|
|
37
|
+
let skippedOwner = false, skippedUnavailable = false;
|
|
38
|
+
const unavailable = (candidate: ReviewerPoolCandidate) => failed.has(candidate.id) ? "start failed for this review" : candidate.blocked;
|
|
39
|
+
const record = (candidate: ReviewerPoolCandidate, reason: string) => { walk.push({ id: candidate.id, kind: candidate.kind, family: candidate.family, reason }); };
|
|
40
|
+
for (const candidate of ordered) {
|
|
41
|
+
if (!candidate.id) {
|
|
42
|
+
skippedUnavailable = true;
|
|
43
|
+
record(candidate, "no declared reviewer");
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (input.implementerFamily !== undefined && candidate.family === input.implementerFamily) {
|
|
47
|
+
skippedOwner = true;
|
|
48
|
+
record(candidate, "implementer skip");
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const blocked = unavailable(candidate);
|
|
52
|
+
if (blocked !== undefined) {
|
|
53
|
+
skippedUnavailable = true;
|
|
54
|
+
record(candidate, `unavailable skip: ${blocked}`);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const reason: ReviewerPoolReason = skippedUnavailable ? "unavailable skip" : skippedOwner ? "implementer skip" : "rotation";
|
|
58
|
+
record(candidate, `selected (${continuity && candidate.family === input.continuityFamily ? "continuity" : reason})`);
|
|
59
|
+
return { reviewer: candidate.id, family: candidate.family, reason, walk };
|
|
60
|
+
}
|
|
61
|
+
// The caller supplies separate fresh reviewer instances; this does not authorize an implementer's own session.
|
|
62
|
+
for (const candidate of ordered) {
|
|
63
|
+
if (!candidate.id || input.implementerFamily === undefined || candidate.family !== input.implementerFamily) continue;
|
|
64
|
+
const blocked = unavailable(candidate);
|
|
65
|
+
if (blocked !== undefined) { record(candidate, `unavailable skip: ${blocked}`); continue; }
|
|
66
|
+
record(candidate, "selected (same-family fallback)");
|
|
67
|
+
return { reviewer: candidate.id, family: candidate.family, reason: "same-family fallback", walk };
|
|
68
|
+
}
|
|
69
|
+
return { reviewer: null, walk, blocked: "no reviewer can run" };
|
|
70
|
+
}
|
package/src/hub/routing.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { selectReviewerPool, type ReviewerPoolSelection } from "./reviewer-pool.ts";
|
|
1
2
|
import { ADAPTERS, definitionOf, kindOf, familyOf } from "../adapters/registry.ts";
|
|
2
3
|
import { readFileSync, statSync } from "node:fs";
|
|
3
4
|
import { join, resolve } from "node:path";
|
|
@@ -288,8 +289,12 @@ export const peerEntry = <T>(map: Readonly<Record<string, T>> | undefined, peer:
|
|
|
288
289
|
export type RoutingRefusalCode = "instance" | "family" | "tests" | "spec" | "roster" | "pull-only" | "not-attached" | "excluded" | "owner-admission" | "offline" | "paused" | "failing" | "pii" | "policy";
|
|
289
290
|
export interface RoutingRefusal { peer: PeerId; role: "owner" | "reviewer"; code: RoutingRefusalCode; reason: string }
|
|
290
291
|
export interface Assignment {
|
|
292
|
+
reviewSelection?: ReviewerPoolSelection;
|
|
293
|
+
reviewBlocked?: true;
|
|
291
294
|
/** A same-family reviewer was chosen only because no other family was eligible. */
|
|
292
295
|
sameFamilyFallback?: true;
|
|
296
|
+
/** #223 R5: the planner reviews because the declared reviewer-only instance was not eligible. */
|
|
297
|
+
plannerFallback?: true;
|
|
293
298
|
refusals?: RoutingRefusal[];
|
|
294
299
|
owner?: PeerId;
|
|
295
300
|
reviewer?: PeerId;
|
|
@@ -307,7 +312,7 @@ export interface Assignment {
|
|
|
307
312
|
* prints its trace. `states`: effective bus states of attached peers (a peer that is not in the map is not attached).
|
|
308
313
|
*/
|
|
309
314
|
export function assign(
|
|
310
|
-
task: Pick<Task, "class" | "signals" | "reserved"> & Partial<Pick<Task, "state" | "title" | "detail" | "refs" | "external">>,
|
|
315
|
+
task: Pick<Task, "class" | "signals" | "reserved"> & Partial<Pick<Task, "state" | "title" | "detail" | "refs" | "external" | "id" | "sdlc" | "history">>,
|
|
311
316
|
states: Record<PeerId, PeerState>,
|
|
312
317
|
routing: Routing,
|
|
313
318
|
opts: InstanceRoutingOptions & {
|
|
@@ -322,6 +327,7 @@ export function assign(
|
|
|
322
327
|
pullOnly?: PeerId[];
|
|
323
328
|
namedOwner?: PeerId;
|
|
324
329
|
namedReviewer?: PeerId;
|
|
330
|
+
reviewPool?: { pool: readonly string[]; rerotate?: boolean; failed?: readonly string[]; required?: boolean };
|
|
325
331
|
candidates?: PeerId[];
|
|
326
332
|
notReviewer?: PeerId;
|
|
327
333
|
waitsFor?: number[];
|
|
@@ -362,6 +368,8 @@ export function assign(
|
|
|
362
368
|
const refusals = new Map<string, RoutingRefusal>();
|
|
363
369
|
const blocked = (peer: PeerId, role: "owner" | "reviewer"): string | undefined => {
|
|
364
370
|
const deny = (code: RoutingRefusalCode, reason: string): string => { refusals.set(`${role}:${peer}`, { peer, role, code, reason }); return reason; };
|
|
371
|
+
const unavailable = role === "reviewer" ? definitionOf(peer, opts.instances)?.reviewer?.unavailable : undefined;
|
|
372
|
+
if (unavailable) return deny("policy", unavailable);
|
|
365
373
|
if (role === "owner" && opts.capabilities && !pii) {
|
|
366
374
|
const caps = peerEntry(opts.capabilities, peer);
|
|
367
375
|
if (needsTests && !caps?.tests) return deny("tests", `missing tests: ${caps?.testsReason ?? "capability unavailable"}`);
|
|
@@ -374,6 +382,7 @@ export function assign(
|
|
|
374
382
|
if (opts.exclude?.includes(peer)) return deny("excluded", "excluded (declined or replaced)");
|
|
375
383
|
if (role === "owner" && opts.excludeOwners?.includes(peer)) return deny("owner-admission", "owner admission: WIP or delivery hold");
|
|
376
384
|
const adapter = definitionOf(peer, opts.instances);
|
|
385
|
+
if (role === "owner" && adapter?.routing?.reviewOnly) return deny("owner-admission", "owner admission: a review-only instance owns no task");
|
|
377
386
|
if (role === "owner" && needsTests && adapter && adapter.kind !== peer) {
|
|
378
387
|
if (!peerEntry(opts.roles, peer)?.includes("implementer")) return deny("owner-admission", "owner admission: declared write owner requires the current implementer role");
|
|
379
388
|
if (peerEntry(opts.ownerIsolation, peer) !== true) return deny("owner-admission", "owner admission: declared write owner requires a verified isolated execution root");
|
|
@@ -458,6 +467,9 @@ export function assign(
|
|
|
458
467
|
|
|
459
468
|
let reviewer: PeerId | undefined;
|
|
460
469
|
let sameFamilyFallback = false;
|
|
470
|
+
let plannerFallback = false;
|
|
471
|
+
let reviewSelection: ReviewerPoolSelection | undefined;
|
|
472
|
+
let reviewBlocked = false;
|
|
461
473
|
if (task.class !== "review") {
|
|
462
474
|
if (pii) trace.push("reviewer: user (pii: no second on-prem peer; ahub review <id> <verdict>)");
|
|
463
475
|
else {
|
|
@@ -471,11 +483,53 @@ export function assign(
|
|
|
471
483
|
const sameFamily = (peer: PeerId) => ownerFamily !== undefined && familyOf(peer, opts.instances) === ownerFamily;
|
|
472
484
|
const independent = ownerFamily === undefined ? [] : automatic.filter(peer => peer !== reviewOwner && !blocked(peer, "reviewer") && !sameFamily(peer));
|
|
473
485
|
const named = opts.namedReviewer;
|
|
474
|
-
|
|
486
|
+
const declaredPool = opts.reviewPool && named === undefined ? Object.keys(opts.instances ?? {}).flatMap(id => {
|
|
487
|
+
const roles = peerEntry(opts.roles, id), definition = definitionOf(id, opts.instances);
|
|
488
|
+
const kind = kindOf(id, opts.instances), family = familyOf(id, opts.instances);
|
|
489
|
+
if (roles?.length !== 1 || roles[0] !== "reviewer" || !definition?.routing?.reviewOnly || !kind || !family) return [];
|
|
490
|
+
const reason = id === reviewOwner ? "is the owner" : definition.reviewer?.unavailable ?? blocked(id, "reviewer")
|
|
491
|
+
?? (states[id] === "paused" ? "paused" : undefined)
|
|
492
|
+
?? peerEntry(opts.held, id)
|
|
493
|
+
?? ((peerEntry(opts.quota, id)?.headroom ?? Infinity) <= 0 ? "quota exhausted" : undefined);
|
|
494
|
+
return [{ id, kind, family, ...(reason === undefined ? {} : { blocked: reason }) }];
|
|
495
|
+
}) : [];
|
|
496
|
+
if (declaredPool.length || opts.reviewPool?.required && named === undefined) {
|
|
497
|
+
for (const candidate of declaredPool) if (!opts.reviewPool!.pool.includes(candidate.kind)) trace.push(` reviewer pool candidate ${candidate.id} (${candidate.family}): unavailable skip: ${candidate.blocked ?? "kind outside configured pool"}`);
|
|
498
|
+
const last = task.history?.findLast(entry => entry.event === "changes_requested");
|
|
499
|
+
const continuityFamily = last && "reviewerFamily" in last && typeof last.reviewerFamily === "string" ? last.reviewerFamily : last ? familyOf(last.by, opts.instances) : undefined;
|
|
500
|
+
const pr = task.sdlc?.pr;
|
|
501
|
+
const seed = typeof pr === "number" && Number.isSafeInteger(pr) && pr > 0 ? pr : task.id ?? 0;
|
|
502
|
+
reviewSelection = selectReviewerPool({ pool: opts.reviewPool!.pool, candidates: declaredPool, seed, implementerFamily: ownerFamily, continuityFamily, rerotate: opts.reviewPool!.rerotate, failed: opts.reviewPool!.failed });
|
|
503
|
+
for (const row of reviewSelection.walk) trace.push(row.id ? ` reviewer pool candidate ${row.id} (${row.family}): ${row.reason}` : ` reviewer pool kind ${row.kind}: ${row.reason}`);
|
|
504
|
+
reviewer = reviewSelection.reviewer ?? "user";
|
|
505
|
+
if (reviewSelection.blocked) {
|
|
506
|
+
trace.push("blocked: no reviewer can run");
|
|
507
|
+
// Optional defaults retain the legacy planner escape when no fresh reviewer can run.
|
|
508
|
+
const planners = opts.reviewPool?.required ? [] : automatic.filter(peer => peer !== reviewOwner && peerEntry(opts.roles, peer)?.includes("planner") && !definitionOf(peer, opts.instances)?.routing?.reviewOnly && !blocked(peer, "reviewer"));
|
|
509
|
+
const otherFamily = planners.filter(peer => !sameFamily(peer));
|
|
510
|
+
const planner = pick(otherFamily.length ? otherFamily : planners, "reviewer", reviewOwner);
|
|
511
|
+
if (planner) {
|
|
512
|
+
reviewer = planner;
|
|
513
|
+
plannerFallback = true;
|
|
514
|
+
trace.push(` planner fallback: no declared reviewer-only instance is eligible; ${planner} reviews`);
|
|
515
|
+
} else reviewBlocked = true;
|
|
516
|
+
}
|
|
517
|
+
} else if (named !== undefined && named !== reviewOwner && !blocked(named, "reviewer") && sameFamily(named) && independent.length) {
|
|
475
518
|
const reason = `same family as ${reviewOwner}; eligible other-family reviewer available`;
|
|
476
519
|
refusals.set(`reviewer:${named}`, { peer: named, role: "reviewer", code: "family", reason });
|
|
477
520
|
trace.push(` reviewer candidate ${named}: skipped, ${reason}`);
|
|
478
|
-
} else
|
|
521
|
+
} else {
|
|
522
|
+
// #223 R1: a declared instance that reviews and does not plan goes first, busy or not.
|
|
523
|
+
const holds = (peer: PeerId, role: string) => opts.roles?.[peer]?.includes(role) === true;
|
|
524
|
+
const dedicated = (peer: PeerId) => !!opts.instances && Object.hasOwn(opts.instances, peer) && holds(peer, "reviewer") && !holds(peer, "planner");
|
|
525
|
+
const pool = independent.length ? independent : automatic;
|
|
526
|
+
const ready = named === undefined ? pool.filter(peer => dedicated(peer) && peer !== reviewOwner && !blocked(peer, "reviewer")) : [];
|
|
527
|
+
reviewer = pick(named === undefined ? ready.length ? ready : pool : [named], "reviewer", reviewOwner, named === undefined);
|
|
528
|
+
if (named === undefined && !ready.length && reviewer && holds(reviewer, "planner") && Object.keys(opts.roles ?? {}).some(dedicated)) {
|
|
529
|
+
plannerFallback = true;
|
|
530
|
+
trace.push(` planner fallback: no declared reviewer-only instance is eligible; ${reviewer} reviews`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
479
533
|
if (reviewer && sameFamily(reviewer)) {
|
|
480
534
|
sameFamilyFallback = true;
|
|
481
535
|
trace.push(` same-family fallback: ${reviewer} and ${reviewOwner} share ${ownerFamily}; no eligible other family`);
|
|
@@ -490,7 +544,7 @@ export function assign(
|
|
|
490
544
|
const piBackend = policy?.pi_backend;
|
|
491
545
|
if (owner && definitionOf(owner, opts.instances)?.routing?.trace === "route") trace.push(`route: ${route ?? "(none)"}, fixed_model ${fixedModel}`);
|
|
492
546
|
if (owner && definitionOf(owner, opts.instances)?.routing?.trace === "backend") trace.push(`${definitionOf(owner, opts.instances)!.routing!.backendLabel} decision: backend ${piBackend ?? "dgx"}${task.signals.includes("long_context") ? `, context limit ${piBackend === "mlx" ? routing.pi.mlx_max_context_tokens : routing.pi.dgx_max_context_tokens}` : ""}`);
|
|
493
|
-
return { ...(sameFamilyFallback ? { sameFamilyFallback: true as const } : {}), ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), ...(unreserved ? { unreserved } : {}), ...(refusals.size ? { refusals: [...refusals.values()] } : {}), trace };
|
|
547
|
+
return { ...(reviewSelection ? { reviewSelection } : {}), ...(reviewBlocked ? { reviewBlocked: true as const } : {}), ...(sameFamilyFallback ? { sameFamilyFallback: true as const } : {}), ...(plannerFallback ? { plannerFallback: true as const } : {}), ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), ...(unreserved ? { unreserved } : {}), ...(refusals.size ? { refusals: [...refusals.values()] } : {}), trace };
|
|
494
548
|
}
|
|
495
549
|
|
|
496
550
|
/**
|
package/src/hub/sdlc-release.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { Task } from "./board.ts";
|
|
2
|
+
import type { Release } from "./planning.ts";
|
|
1
3
|
import { spawn } from "node:child_process";
|
|
2
4
|
import { fileURLToPath } from "node:url";
|
|
3
5
|
import { childEnv, stopOwnedProcess } from "./child-process.ts";
|
|
@@ -123,3 +125,114 @@ export async function readReleaseReadiness(root: string, repo: string, liveIssue
|
|
|
123
125
|
report.blockers.push("native acceptance and person release decision unverified");
|
|
124
126
|
return report;
|
|
125
127
|
}
|
|
128
|
+
|
|
129
|
+
export type HotfixCause = "loss" | "security_privacy" | "upgrade_start_blocked" | "core_flow_no_workaround";
|
|
130
|
+
export interface ReleaseTrainData {
|
|
131
|
+
hotfix?: { cause: HotfixCause; reason: string };
|
|
132
|
+
scope?: number[];
|
|
133
|
+
history: { at: number; by: string; event: "configured" | "cut" | "shipped"; scope?: number[]; ciRuns?: number; reviews?: number }[];
|
|
134
|
+
}
|
|
135
|
+
export const RELEASE_TRAIN_HISTORY_LIMIT = 256;
|
|
136
|
+
const HOTFIX_CAUSES: readonly string[] = ["loss", "security_privacy", "upgrade_start_blocked", "core_flow_no_workaround"];
|
|
137
|
+
const trainObject = (value: unknown): Record<string, unknown> => {
|
|
138
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("release train: expected an object");
|
|
139
|
+
return value as Record<string, unknown>;
|
|
140
|
+
};
|
|
141
|
+
const trainKeys = (value: Record<string, unknown>, keys: readonly string[]) => {
|
|
142
|
+
if (Object.keys(value).some(key => !keys.includes(key))) throw new Error("release train: unknown field");
|
|
143
|
+
};
|
|
144
|
+
const trainScope = (value: unknown): number[] => {
|
|
145
|
+
// ponytail: 1000 tasks per release; raise together with bounded report readers if larger trains are needed.
|
|
146
|
+
if (!Array.isArray(value) || value.length > 1000 || value.some(id => !Number.isSafeInteger(id) || id < 1) || new Set(value).size !== value.length) throw new Error("release train: invalid scope");
|
|
147
|
+
return [...value].sort((a, b) => a - b);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/** Structural validation only: the Tasks boundary screens reason text and establishes caller authority. */
|
|
151
|
+
export function releaseTrainData(input: unknown): ReleaseTrainData {
|
|
152
|
+
const raw = trainObject(input); trainKeys(raw, ["hotfix", "scope", "history"]);
|
|
153
|
+
const result: ReleaseTrainData = { history: [] };
|
|
154
|
+
if (raw.hotfix !== undefined) {
|
|
155
|
+
const hotfix = trainObject(raw.hotfix); trainKeys(hotfix, ["cause", "reason"]);
|
|
156
|
+
if (typeof hotfix.cause !== "string" || !HOTFIX_CAUSES.includes(hotfix.cause) || typeof hotfix.reason !== "string" || !hotfix.reason.trim() || Buffer.byteLength(hotfix.reason) > 1000) throw new Error("release train: invalid hotfix cause or reason");
|
|
157
|
+
result.hotfix = { cause: hotfix.cause as HotfixCause, reason: hotfix.reason.trim() };
|
|
158
|
+
}
|
|
159
|
+
if (raw.scope !== undefined) result.scope = trainScope(raw.scope);
|
|
160
|
+
// ponytail: 256 recorded train events; archive settled release evidence before increasing this ceiling.
|
|
161
|
+
if (!Array.isArray(raw.history) || raw.history.length > RELEASE_TRAIN_HISTORY_LIMIT) throw new Error("release train: invalid history");
|
|
162
|
+
result.history = raw.history.map(value => {
|
|
163
|
+
const row = trainObject(value); trainKeys(row, ["at", "by", "event", "scope", "ciRuns", "reviews"]);
|
|
164
|
+
if (!Number.isSafeInteger(row.at) || (row.at as number) < 1 || (row.at as number) > 8_640_000_000_000_000 || typeof row.by !== "string" || !/^[a-z][a-z0-9-]{0,63}$/.test(row.by) || !["configured", "cut", "shipped"].includes(String(row.event))) throw new Error("release train: invalid history event");
|
|
165
|
+
const event: ReleaseTrainData["history"][number] = { at: row.at as number, by: row.by, event: row.event as "configured" | "cut" | "shipped" };
|
|
166
|
+
if (row.scope !== undefined) event.scope = trainScope(row.scope);
|
|
167
|
+
for (const key of ["ciRuns", "reviews"] as const) if (row[key] !== undefined) {
|
|
168
|
+
if (!Number.isSafeInteger(row[key]) || (row[key] as number) < 0 || (row[key] as number) > 1_000_000) throw new Error("release train: invalid resource count");
|
|
169
|
+
event[key] = row[key] as number;
|
|
170
|
+
}
|
|
171
|
+
return event;
|
|
172
|
+
});
|
|
173
|
+
if (result.history.some((row, index) => index > 0 && row.at < result.history[index - 1]!.at)) throw new Error("release train: history dates are out of order");
|
|
174
|
+
return result;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const storedTrain = (release: Release): ReleaseTrainData | undefined => {
|
|
178
|
+
const raw = (release as Release & { train?: ReleaseTrainData }).train;
|
|
179
|
+
if (raw === undefined) return undefined;
|
|
180
|
+
try { return releaseTrainData(raw); } catch { return undefined; }
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/** Board metadata only; a trigger is a request to cut, never release authorization or native readiness. */
|
|
184
|
+
export function releaseTrainReadiness(release: Release, tasks: Task[], now = Date.now()) {
|
|
185
|
+
const train = storedTrain(release), assigned = tasks.filter(task => task.release === release.id), frozen = ["frozen", "shipped"].includes(release.state);
|
|
186
|
+
const scope = frozen ? train?.scope : assigned.map(task => task.id);
|
|
187
|
+
const blockers: string[] = [];
|
|
188
|
+
if ((release as Release & { train?: ReleaseTrainData }).train !== undefined && !train) blockers.push("release train evidence unknown");
|
|
189
|
+
if (!scope) blockers.push("release scope unknown");
|
|
190
|
+
else if (!scope.length) blockers.push("release scope empty");
|
|
191
|
+
if (frozen && scope && JSON.stringify([...scope].sort((a, b) => a - b)) !== JSON.stringify(assigned.map(task => task.id).sort((a, b) => a - b))) blockers.push("frozen release scope changed");
|
|
192
|
+
const rows = new Map(tasks.map(task => [task.id, task]));
|
|
193
|
+
const states = (scope ?? []).map(id => {
|
|
194
|
+
const task = rows.get(id), state = task?.state ?? "unknown", ready = state === "approved";
|
|
195
|
+
if (!task) blockers.push(`task #${id}: scope evidence unknown`);
|
|
196
|
+
else if (task.release !== release.id) blockers.push(`task #${id}: release assignment changed`);
|
|
197
|
+
if (!ready) blockers.push(`task #${id}: not approved`);
|
|
198
|
+
return { id, state, ready };
|
|
199
|
+
});
|
|
200
|
+
const trigger: "tasks_approved" | "planned_date" | "waiting" = states.length && states.every(task => task.ready) && !blockers.length ? "tasks_approved"
|
|
201
|
+
: release.cut !== null && Number.isFinite(now) && release.cut <= now ? "planned_date" : "waiting";
|
|
202
|
+
return { trigger, tasks: states, blockers };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Counts are explicit recorded event deltas, not inferred from successful gates or absent history. */
|
|
206
|
+
export function releaseMetrics(releases: Release[], tasks: Task[]) {
|
|
207
|
+
const unknown = new Set<number>(), shipped: { id: number; at: number }[] = [];
|
|
208
|
+
const approvalToReleaseMs: { release: number; task: number; ms: number | null }[] = [];
|
|
209
|
+
let hotfixCount = 0, knownKinds = 0, ciRuns = 0, reviews = 0, ciKnown = true, reviewsKnown = true;
|
|
210
|
+
const byTask = new Map(tasks.map(task => [task.id, task]));
|
|
211
|
+
const settled = releases.filter(release => release.state === "shipped");
|
|
212
|
+
for (const release of settled) {
|
|
213
|
+
const train = storedTrain(release);
|
|
214
|
+
if (!train?.history.length) { unknown.add(release.id); ciKnown = false; reviewsKnown = false; continue; }
|
|
215
|
+
knownKinds++;
|
|
216
|
+
if (train.hotfix) hotfixCount++;
|
|
217
|
+
for (const key of ["ciRuns", "reviews"] as const) {
|
|
218
|
+
const recorded = train.history.flatMap(event => event[key] === undefined ? [] : [event[key]!]);
|
|
219
|
+
if (!recorded.length) { unknown.add(release.id); if (key === "ciRuns") ciKnown = false; else reviewsKnown = false; }
|
|
220
|
+
else if (key === "ciRuns") ciRuns += recorded.reduce((sum, value) => sum + value, 0);
|
|
221
|
+
else reviews += recorded.reduce((sum, value) => sum + value, 0);
|
|
222
|
+
}
|
|
223
|
+
const event = [...train.history].reverse().find(row => row.event === "shipped");
|
|
224
|
+
if (!event || release.shippedAt !== event.at) { unknown.add(release.id); continue; }
|
|
225
|
+
shipped.push({ id: release.id, at: event.at });
|
|
226
|
+
const scope = train.scope ?? [...train.history].reverse().find(row => row.event === "cut")?.scope;
|
|
227
|
+
if (!scope) { unknown.add(release.id); continue; }
|
|
228
|
+
for (const id of scope) {
|
|
229
|
+
const task = byTask.get(id), approved = task?.history.findLast(row => row.at <= event.at && (row.event === "approved" || row.event === "done" && task.state === "approved" && task.reviewer === null))?.at ?? null;
|
|
230
|
+
const ms = approved !== null && approved <= event.at ? event.at - approved : null;
|
|
231
|
+
if (ms === null) unknown.add(release.id);
|
|
232
|
+
approvalToReleaseMs.push({ release: release.id, task: id, ms });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
shipped.sort((a, b) => a.at - b.at || a.id - b.id);
|
|
236
|
+
const intervals = shipped.length !== releases.filter(release => release.state === "shipped").length ? [] : shipped.slice(1).map((row, index) => ({ from: shipped[index]!.id, to: row.id, ms: row.at - shipped[index]!.at }));
|
|
237
|
+
return { intervals, approvalToReleaseMs, hotfixCount: knownKinds === settled.length && knownKinds > 0 ? hotfixCount : null, hotfixRate: knownKinds === settled.length && knownKinds > 0 ? hotfixCount / knownKinds : null, ciRuns: ciKnown && settled.length ? ciRuns : null, reviews: reviewsKnown && settled.length ? reviews : null, unknown: [...unknown].sort((a, b) => a - b) };
|
|
238
|
+
}
|
package/src/hub/sdlc.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CostPlan } from "./cost-governance.ts";
|
|
1
2
|
import { criteriaReceipt, type CriteriaReceipt, type CriteriaView, type LegacyPhaseReceipt } from "./workflow.ts";
|
|
2
3
|
import { constants, openSync, fstatSync, readSync, closeSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
@@ -29,11 +30,21 @@ export interface StageDefinition { base: SdlcBaseStage; actor: string; entry_gat
|
|
|
29
30
|
export type SdlcMode = "off" | "advise" | "enforce";
|
|
30
31
|
/** `workflow: "gsd-lite"` marks the retired setting during its window (#481 S8): every task then needs a criteria checklist. */
|
|
31
32
|
export interface SdlcConfig { workflow?: "gsd-lite"; mode: SdlcMode; requiredChecks: string[]; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; deprecations?: string[]; }
|
|
33
|
+
export interface PlanReviewReceipt {
|
|
34
|
+
planner: string;
|
|
35
|
+
planningRevision: number;
|
|
36
|
+
recordedAt: number;
|
|
37
|
+
scope?: CostPlan;
|
|
38
|
+
issue?: PlanIssueMetadata;
|
|
39
|
+
}
|
|
32
40
|
export interface SdlcReceipt {
|
|
33
41
|
stage: SdlcStage;
|
|
42
|
+
planReview?: PlanReviewReceipt;
|
|
34
43
|
criteria?: CriteriaReceipt;
|
|
35
44
|
/** Stored before #481 S8 and read as `criteria`; never written again. */
|
|
36
45
|
phase?: LegacyPhaseReceipt;
|
|
46
|
+
/** The person's spec acceptance (#481 S1): set only by Tasks.acceptSpec, never by hub_sdlc_record. */
|
|
47
|
+
acceptance?: SpecAcceptance;
|
|
37
48
|
repo?: string;
|
|
38
49
|
issue?: number;
|
|
39
50
|
pr?: number;
|
|
@@ -181,6 +192,8 @@ export function sdlcReceipt(value: unknown, previous: SdlcReceipt | undefined =
|
|
|
181
192
|
if (!["passed", "failed", "unknown"].includes(String(raw.localResult))) throw new Error("sdlc receipt.localResult: expected passed, failed or unknown");
|
|
182
193
|
next.localResult = raw.localResult as SdlcReceipt["localResult"];
|
|
183
194
|
}
|
|
195
|
+
// An acceptance names one spec issue; relinking the receipt to another drops it (#481 S1).
|
|
196
|
+
if (next.repo !== previous.repo || next.issue !== previous.issue) delete next.acceptance;
|
|
184
197
|
// A new artifact cannot inherit another PR's review or local gate.
|
|
185
198
|
if (next.repo !== previous.repo || next.pr !== previous.pr) {
|
|
186
199
|
delete next.head; delete next.observed;
|
|
@@ -199,7 +212,7 @@ export function parsePrEvidence(value: unknown): PrEvidence {
|
|
|
199
212
|
const name = c.name ?? c.context;
|
|
200
213
|
if (typeof name !== "string" || !name || name.length > 120) throw new Error("PR readback: invalid check identity");
|
|
201
214
|
const native = c.conclusion ?? c.state;
|
|
202
|
-
const state = native === "SUCCESS" ? "passed" : ["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED"].includes(String(native)) ? "failed" :
|
|
215
|
+
const state = ["PENDING", "QUEUED", "IN_PROGRESS"].includes(String(c.status ?? native)) ? "pending" : native === "SUCCESS" ? "passed" : ["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED"].includes(String(native)) ? "failed" : "unknown";
|
|
203
216
|
return { name, state } as PrEvidence["checks"][number];
|
|
204
217
|
});
|
|
205
218
|
const paths = p.files.map((f: Record<string, unknown>) => { if (typeof f.path !== "string" || !f.path || f.path.length > 1000) throw new Error("PR readback: invalid file identity"); return f.path; });
|
|
@@ -210,10 +223,26 @@ export function parsePrEvidence(value: unknown): PrEvidence {
|
|
|
210
223
|
...(sha(p.mergeCommit?.oid) ? { mergeCommit: p.mergeCommit.oid } : {}) };
|
|
211
224
|
}
|
|
212
225
|
|
|
213
|
-
|
|
226
|
+
/** Issue identity and edit times only; no body is fetched or stored. `lastEditedAt` moves on a body edit, not on comments. */
|
|
227
|
+
export interface SpecEdit { lastEditedAt: string | null; updatedAt: string }
|
|
228
|
+
export interface SpecAcceptance extends SpecEdit { repo: string; issue: number; acceptedAt: number }
|
|
229
|
+
export type SpecState = "missing" | "current" | "changed" | "unknown";
|
|
230
|
+
/** Whether the receipt's spec issue is accepted at its current body. `edit` undefined means the readback failed. */
|
|
231
|
+
export function specState(receipt: SdlcReceipt | undefined, edit: SpecEdit | undefined): SpecState {
|
|
232
|
+
const accepted = receipt?.acceptance;
|
|
233
|
+
if (!accepted || accepted.repo !== receipt?.repo || accepted.issue !== receipt?.issue) return "missing";
|
|
234
|
+
if (!edit) return "unknown";
|
|
235
|
+
return edit.lastEditedAt === accepted.lastEditedAt ? "current" : "changed";
|
|
236
|
+
}
|
|
237
|
+
export const SPEC_STATE_TEXT: Record<Exclude<SpecState, "current">, string> = {
|
|
238
|
+
missing: "spec acceptance missing: the person runs ahub sdlc accept <task> --issue <n>",
|
|
239
|
+
changed: "spec changed after acceptance: the person re-accepts with ahub sdlc accept <task> --issue <n>",
|
|
240
|
+
unknown: "spec edit time unknown: the issue could not be read",
|
|
241
|
+
};
|
|
242
|
+
export interface SdlcStatus { spec?: { issue: number; state: SpecState }; playbook?: string; criteria?: CriteriaView; mode: SdlcMode; stage: SdlcStage; definition?: StageDefinition; entry?: string[]; exit?: string[]; blockers: string[]; ready: boolean; actor: string; next: string; }
|
|
214
243
|
/** The acceptance-criteria checklist (#481 S8, Compliance pass) counts once recorded, and on every task under the retired gsd-lite setting. Off keeps #257's output. */
|
|
215
244
|
export const criteriaRequired = (config: SdlcConfig, receipt: SdlcReceipt | undefined): boolean => config.workflow === "gsd-lite" || (config.mode !== "off" && !!checklistOf(receipt));
|
|
216
|
-
export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined, evidence?: PrEvidence, criteria?: CriteriaView): SdlcStatus {
|
|
245
|
+
export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined, evidence?: PrEvidence, criteria?: CriteriaView, specEdit?: SpecEdit): SdlcStatus {
|
|
217
246
|
const stage = receipt?.stage ?? config.stages[0] ?? "spec", blockers: string[] = [];
|
|
218
247
|
const definition = stageDefinition(config, stage);
|
|
219
248
|
if (!definition) blockers.push("lifecycle stage definition unknown");
|
|
@@ -239,7 +268,9 @@ export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined,
|
|
|
239
268
|
const custom = config.definitions && definition ? stageGates(definition, blockers, [...viewBlockers, ...staleHead]) : undefined;
|
|
240
269
|
if (custom) for (const blocker of [...custom.entry, ...custom.exit]) if (!blockers.includes(blocker)) blockers.push(blocker);
|
|
241
270
|
const shown = criteria && (criteriaRequired(config, receipt) || definition && [...definition.entry_gates, ...definition.exit_gates].includes("criteria"));
|
|
242
|
-
|
|
271
|
+
// Shown, not counted as a readiness blocker: the spec gate is build entry (#481 S1).
|
|
272
|
+
const spec = config.mode !== "off" && receipt?.issue ? { spec: { issue: receipt.issue, state: specState(receipt, specEdit) } } : {};
|
|
273
|
+
return { ...spec, ...(config.mode !== "off" && definition && playbookStage(definition.base) ? { playbook: playbookStage(definition.base) } : {}), ...(shown ? { criteria } : {}), ...(custom ? { definition, ...custom } : {}), mode: config.mode, stage, blockers, ready: blockers.length === 0,
|
|
243
274
|
actor: !definition ? "person" : shown && criteria.blockers.length ? "reviewer/person" : config.definitions ? definition.actor : baseActor(definition.base),
|
|
244
275
|
next: blockers[0] ?? "person may inspect merge readiness; no automatic merge" };
|
|
245
276
|
}
|
|
@@ -279,6 +310,20 @@ export const sdlcGh: SdlcGh = (args, timeoutMs = 10_000, cwd, maxOutputBytes = 2
|
|
|
279
310
|
child.once("close", code => { clearTimeout(timer); resolve({ code: failed ? 1 : code ?? 1, stdout: failed ? "" : stdout }); });
|
|
280
311
|
});
|
|
281
312
|
|
|
313
|
+
/** Read-only, fixed argv: the issue's edit times, never its body. */
|
|
314
|
+
export async function readSpecEdit(repoName: string, issue: number, run: SdlcGh = sdlcGh): Promise<SpecEdit | undefined> {
|
|
315
|
+
if (!repo(repoName) || !id(issue)) return undefined;
|
|
316
|
+
const [owner, name] = repoName.split("/");
|
|
317
|
+
try {
|
|
318
|
+
const out = await run(["api", "graphql", "-f", "query=query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){issue(number:$number){number lastEditedAt updatedAt}}}", "-f", `owner=${owner}`, "-f", `name=${name}`, "-F", `number=${issue}`]);
|
|
319
|
+
if (out.code !== 0) return undefined;
|
|
320
|
+
const found = JSON.parse(out.stdout)?.data?.repository?.issue;
|
|
321
|
+
const stamp = (v: unknown) => typeof v === "string" && v.length <= 40 && !Number.isNaN(Date.parse(v));
|
|
322
|
+
if (found?.number !== issue || !stamp(found.updatedAt) || (found.lastEditedAt !== null && !stamp(found.lastEditedAt))) return undefined;
|
|
323
|
+
return { lastEditedAt: found.lastEditedAt, updatedAt: found.updatedAt };
|
|
324
|
+
} catch { return undefined; }
|
|
325
|
+
}
|
|
326
|
+
|
|
282
327
|
export async function readSdlcPr(receipt: SdlcReceipt, run: SdlcGh = sdlcGh): Promise<PrEvidence | undefined> {
|
|
283
328
|
if (!repo(receipt.repo) || !id(receipt.pr)) return undefined;
|
|
284
329
|
try {
|
|
@@ -298,3 +343,159 @@ export async function readSdlcPr(receipt: SdlcReceipt, run: SdlcGh = sdlcGh): Pr
|
|
|
298
343
|
return parsePrEvidence(p);
|
|
299
344
|
} catch { return undefined; }
|
|
300
345
|
}
|
|
346
|
+
|
|
347
|
+
export interface PlanIssueMetadata {
|
|
348
|
+
repo: string;
|
|
349
|
+
issue: number;
|
|
350
|
+
state: "OPEN" | "CLOSED";
|
|
351
|
+
/** Body edit time; comment activity must never invalidate a plan review. */
|
|
352
|
+
editedAt: string;
|
|
353
|
+
/** Nullable GitHub body edit field, retained to compare independent S1 acceptance evidence. */
|
|
354
|
+
lastEditedAt?: string | null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Metadata only: never request issue bodies or comments for a planning receipt. */
|
|
358
|
+
export async function readPlanIssueMetadata(repository: string, number: number, run: SdlcGh = sdlcGh): Promise<PlanIssueMetadata | undefined> {
|
|
359
|
+
if (!repo(repository) || !id(number)) return undefined;
|
|
360
|
+
const [owner, name] = repository.split("/");
|
|
361
|
+
try {
|
|
362
|
+
const result = await run(["api", "graphql", "-f", "query=query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){issue(number:$number){number state createdAt lastEditedAt}}}", "-f", `owner=${owner}`, "-f", `name=${name}`, "-F", `number=${number}`], 10_000, undefined, 8 * 1024);
|
|
363
|
+
if (result.code !== 0 || Buffer.byteLength(result.stdout) > 8 * 1024) return undefined;
|
|
364
|
+
const response = JSON.parse(result.stdout);
|
|
365
|
+
if (response.errors !== undefined) return undefined;
|
|
366
|
+
const issue = response.data?.repository?.issue;
|
|
367
|
+
if (!issue || issue.number !== number || !["OPEN", "CLOSED"].includes(issue.state)) return undefined;
|
|
368
|
+
const editedAt = issue.lastEditedAt === null ? issue.createdAt : issue.lastEditedAt;
|
|
369
|
+
if (typeof editedAt !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(editedAt) || !Number.isFinite(Date.parse(editedAt))) return undefined;
|
|
370
|
+
return { repo: repository, issue: number, state: issue.state, editedAt, lastEditedAt: issue.lastEditedAt };
|
|
371
|
+
} catch { return undefined; }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** A complete resolved-thread snapshot already proves there is no pending unanswered bot thread. */
|
|
375
|
+
export function reviewReadinessFromEvidence(evidence: PrEvidence | undefined, expected: string | undefined, required: readonly string[]): ReviewReadiness | undefined {
|
|
376
|
+
if (!evidence || evidence.unresolved !== 0) return undefined;
|
|
377
|
+
const checks = required.length ? [...required] : ["check (ubuntu-latest)", "check (macos-latest)", "seeded guards"];
|
|
378
|
+
if (!checks.every(name => evidence.checks.some(check => check.name === name)) || evidence.checks.some(check => check.state === "unknown")) return undefined;
|
|
379
|
+
const blockers: string[] = [];
|
|
380
|
+
if (!expected || !sha(expected)) blockers.push("review head missing");
|
|
381
|
+
else if (evidence.head !== expected) blockers.push("review head changed");
|
|
382
|
+
if (evidence.state !== "OPEN") blockers.push("review PR is not open");
|
|
383
|
+
if (!checks.every(name => { const matched = evidence.checks.filter(check => check.name === name); return matched.length === 1 && matched[0]?.state === "passed"; })) blockers.push("exact-head full CI is not green");
|
|
384
|
+
return { head: evidence.head, ready: blockers.length === 0, blockers };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export interface ReviewReadiness { head?: string; ready: boolean; blockers: string[]; reusedRun?: number }
|
|
388
|
+
/** Metadata-only pre-review admission; no remote body or comment text is requested. */
|
|
389
|
+
export async function readReviewReadiness(receipt: SdlcReceipt, expected: string | undefined, run: SdlcGh = sdlcGh, requiredChecks: readonly string[] = []): Promise<ReviewReadiness> {
|
|
390
|
+
const unknown = (): ReviewReadiness => ({ ready: false, blockers: ["review readiness unavailable"] });
|
|
391
|
+
if (!repo(receipt.repo) || !id(receipt.pr)) return unknown();
|
|
392
|
+
const [owner, name] = receipt.repo.split("/");
|
|
393
|
+
const query = "query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){headRefOid state commits(last:1){nodes{commit{oid tree{oid} statusCheckRollup{contexts(first:100){nodes{... on CheckRun{name status conclusion} ... on StatusContext{context state}} pageInfo{hasNextPage}}}}}} reviewThreads(first:100){nodes{isResolved comments(first:100){nodes{author{__typename} createdAt} pageInfo{hasNextPage}}} pageInfo{hasNextPage}}}}}";
|
|
394
|
+
try {
|
|
395
|
+
const deadline = performance.now() + 20_000;
|
|
396
|
+
const bounded: SdlcGh = (args, timeout, cwd, cap) => performance.now() < deadline
|
|
397
|
+
? run(args, Math.max(1, Math.min(timeout ?? 10_000, deadline - performance.now())), cwd, cap)
|
|
398
|
+
: Promise.resolve({ code: 1, stdout: "" });
|
|
399
|
+
const args = ["api", "graphql", "-f", `query=${query}`, "-f", `owner=${owner}`, "-f", `name=${name}`, "-F", `number=${receipt.pr}`];
|
|
400
|
+
const response = await bounded(args, 10_000, undefined, 512 * 1024);
|
|
401
|
+
if (response.code !== 0 || Buffer.byteLength(response.stdout) > 512 * 1024) return unknown();
|
|
402
|
+
const parsed = JSON.parse(response.stdout), pr = parsed.data?.repository?.pullRequest;
|
|
403
|
+
if (parsed.errors || !pr || !sha(pr.headRefOid)) return unknown();
|
|
404
|
+
const blockers: string[] = [];
|
|
405
|
+
if (!expected || !sha(expected)) blockers.push("review head missing");
|
|
406
|
+
else if (pr.headRefOid !== expected) blockers.push("review head changed");
|
|
407
|
+
if (pr.state !== "OPEN") blockers.push("review PR is not open");
|
|
408
|
+
const commit = pr.commits?.nodes?.[0]?.commit;
|
|
409
|
+
if (!commit || commit.oid !== pr.headRefOid) blockers.push("review head changed");
|
|
410
|
+
const contexts = commit?.statusCheckRollup?.contexts;
|
|
411
|
+
const nodes: unknown[] | undefined = contexts?.nodes;
|
|
412
|
+
const full = requiredChecks.length ? [...requiredChecks] : ["check (ubuntu-latest)", "check (macos-latest)", "seeded guards"];
|
|
413
|
+
let reusedRun: number | undefined;
|
|
414
|
+
if (contexts?.pageInfo?.hasNextPage !== false || !Array.isArray(nodes) || nodes.length > 100
|
|
415
|
+
|| !full.every(name => {
|
|
416
|
+
const matches = (nodes as { name?: string; context?: string; status?: string; conclusion?: string; state?: string }[]).filter(node => (node?.name ?? node?.context) === name);
|
|
417
|
+
return matches.length === 1 && (matches[0]?.name ? matches[0].status === "COMPLETED" && matches[0].conclusion === "SUCCESS" : matches[0]?.state === "SUCCESS");
|
|
418
|
+
})) blockers.push("exact-head full CI is not green");
|
|
419
|
+
if (blockers.includes("exact-head full CI is not green") && contexts?.pageInfo?.hasNextPage === false && Array.isArray(nodes)
|
|
420
|
+
&& full.every(name => { const matches = (nodes as { name?: string; conclusion?: string }[]).filter(node => node?.name === name); return matches.length === 1 && matches[0]?.conclusion === "SKIPPED"; }) && sha(commit?.tree?.oid)) {
|
|
421
|
+
reusedRun = await identicalTreeGate(receipt.repo, commit.tree.oid, bounded, full);
|
|
422
|
+
if (reusedRun !== undefined) {
|
|
423
|
+
// Proof reads can await several API calls: recheck the complete head/check/thread snapshot before admission.
|
|
424
|
+
const after = await bounded(args, 10_000, undefined, 512 * 1024);
|
|
425
|
+
try { if (after.code !== 0 || JSON.stringify(JSON.parse(after.stdout)?.data?.repository?.pullRequest) !== JSON.stringify(pr)) reusedRun = undefined; }
|
|
426
|
+
catch { reusedRun = undefined; }
|
|
427
|
+
if (reusedRun !== undefined) blockers.splice(blockers.indexOf("exact-head full CI is not green"), 1);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const threads = pr.reviewThreads;
|
|
431
|
+
if (threads?.pageInfo?.hasNextPage !== false || !Array.isArray(threads.nodes) || threads.nodes.length > 100) blockers.push("bot review thread evidence unavailable");
|
|
432
|
+
else for (const thread of threads.nodes) {
|
|
433
|
+
if (thread?.isResolved === true) continue;
|
|
434
|
+
if (thread?.isResolved !== false) { blockers.push("bot review thread evidence unavailable"); continue; }
|
|
435
|
+
const comments = thread?.comments;
|
|
436
|
+
if (comments?.pageInfo?.hasNextPage !== false || !Array.isArray(comments.nodes) || comments.nodes.length > 100
|
|
437
|
+
|| comments.nodes.some((comment: { author?: { __typename?: string }; createdAt?: string }) => !["Bot", "User"].includes(comment?.author?.__typename ?? "") || !Number.isFinite(Date.parse(comment.createdAt ?? "")))) {
|
|
438
|
+
blockers.push("bot review thread evidence unavailable"); continue;
|
|
439
|
+
}
|
|
440
|
+
let unanswered = false;
|
|
441
|
+
for (const comment of comments.nodes) {
|
|
442
|
+
if (comment.author.__typename === "Bot") unanswered = true;
|
|
443
|
+
else if (comment.author.__typename === "User") unanswered = false;
|
|
444
|
+
}
|
|
445
|
+
if (unanswered) blockers.push("bot review threads unanswered");
|
|
446
|
+
}
|
|
447
|
+
return { head: pr.headRefOid, ready: blockers.length === 0, blockers: [...new Set(blockers)], ...(reusedRun !== undefined ? { reusedRun } : {}) };
|
|
448
|
+
} catch { return unknown(); }
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Same proof floor as scripts/ci-gates.mjs: the identical tree includes the workflow itself. No skipped proof. */
|
|
452
|
+
async function identicalTreeGate(repository: string, tree: string, run: SdlcGh, required: readonly string[]): Promise<number | undefined> {
|
|
453
|
+
try {
|
|
454
|
+
const list = await run(["api", `repos/${repository}/actions/workflows/check.yml/runs?status=success&per_page=20`, "--jq", "{total_count,workflow_runs:[.workflow_runs[]|{id,event,conclusion,head_sha}]}"], 10_000, undefined, 64 * 1024);
|
|
455
|
+
if (list.code !== 0) return;
|
|
456
|
+
const runs = JSON.parse(list.stdout)?.workflow_runs;
|
|
457
|
+
if (!Array.isArray(runs) || runs.length > 20) return;
|
|
458
|
+
for (const candidate of runs) {
|
|
459
|
+
if (!id(candidate?.id) || candidate.conclusion !== "success" || !["pull_request", "push"].includes(candidate.event) || !sha(candidate.head_sha)) continue;
|
|
460
|
+
const commit = await run(["api", `repos/${repository}/git/commits/${candidate.head_sha}`, "--jq", "{tree:{sha:.tree.sha}}"], 10_000, undefined, 4096);
|
|
461
|
+
if (commit.code !== 0 || JSON.parse(commit.stdout)?.tree?.sha !== tree) continue;
|
|
462
|
+
const jobs = await run(["api", `repos/${repository}/actions/runs/${candidate.id}/jobs?per_page=100`, "--jq", '{total_count,jobs:[.jobs[]|{name,conclusion,reused:any(.steps[]?; .name=="Reuse verified identical-tree full gate" and .conclusion=="success")}]}'], 10_000, undefined, 32 * 1024);
|
|
463
|
+
if (jobs.code !== 0) continue;
|
|
464
|
+
const proof = JSON.parse(jobs.stdout);
|
|
465
|
+
if (!Array.isArray(proof.jobs) || proof.jobs.length > 100 || proof.total_count !== proof.jobs.length) continue;
|
|
466
|
+
if (required.every(name => {
|
|
467
|
+
const found = proof.jobs.filter((job: { name?: string }) => job?.name === name); return found.length === 1 && found[0]?.conclusion === "success" && found[0]?.reused !== true;
|
|
468
|
+
})) return candidate.id;
|
|
469
|
+
}
|
|
470
|
+
} catch { return; }
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** PR cost evidence excludes titles, bodies and comment text; unknown/truncated coverage never becomes zero. */
|
|
474
|
+
export async function readPrCostHistory(repository: string, number: number, run: SdlcGh = sdlcGh): Promise<import("./cost-governance.ts").CostPr | undefined> {
|
|
475
|
+
if (!repo(repository) || !id(number)) return;
|
|
476
|
+
const [owner, name] = repository.split("/");
|
|
477
|
+
try {
|
|
478
|
+
const query = "query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){number state headRefOid mergedAt commits(first:100){nodes{commit{oid}} pageInfo{hasNextPage}} reviews(first:100){nodes{databaseId commit{oid}} pageInfo{hasNextPage}}}}}";
|
|
479
|
+
const reply = await run(["api", "graphql", "-f", `query=${query}`, "-f", `owner=${owner}`, "-f", `name=${name}`, "-F", `number=${number}`], 10_000, undefined, 128 * 1024);
|
|
480
|
+
if (reply.code !== 0) return;
|
|
481
|
+
const parsed = JSON.parse(reply.stdout), pr = parsed.data?.repository?.pullRequest;
|
|
482
|
+
if (parsed.errors || pr?.number !== number || !["OPEN", "CLOSED", "MERGED"].includes(pr.state)) return;
|
|
483
|
+
const result: import("./cost-governance.ts").CostPr = { repo: repository, pr: number, state: pr.state, complete: false,
|
|
484
|
+
...(typeof pr.mergedAt === "string" && Number.isFinite(Date.parse(pr.mergedAt)) ? { mergedAt: Date.parse(pr.mergedAt) } : {}) };
|
|
485
|
+
if (pr.state !== "MERGED") return result; // Cost metrics count merged PRs, not an active work queue.
|
|
486
|
+
const reviews = pr.reviews, commits = pr.commits;
|
|
487
|
+
const reviewComplete = reviews?.pageInfo?.hasNextPage === false && Array.isArray(reviews.nodes) && reviews.nodes.length <= 100 && reviews.nodes.every((node: { databaseId?: number }) => id(node?.databaseId));
|
|
488
|
+
const commitComplete = commits?.pageInfo?.hasNextPage === false && Array.isArray(commits.nodes) && commits.nodes.length <= 100 && sha(pr.headRefOid) && commits.nodes.every((node: { commit?: { oid?: string } }) => sha(node?.commit?.oid));
|
|
489
|
+
if (reviewComplete) result.reviews = new Set(reviews.nodes.map((review: { databaseId?: unknown }) => review.databaseId).filter((id: unknown) => Number.isSafeInteger(id))).size;
|
|
490
|
+
const heads = new Set<string>([pr.headRefOid, ...(commits?.nodes ?? []).map((node: { commit?: { oid?: string } }) => node?.commit?.oid), ...(reviews?.nodes ?? []).map((node: { commit?: { oid?: string } }) => node?.commit?.oid)].filter(sha));
|
|
491
|
+
const checks = await run(["api", `repos/${repository}/actions/workflows/check.yml/runs?per_page=100`, "--jq", "{total_count,workflow_runs:[.workflow_runs[]|{id,run_attempt,head_sha,pull_requests:[.pull_requests[].number]}]}"], 10_000, undefined, 128 * 1024);
|
|
492
|
+
if (checks.code !== 0) return result;
|
|
493
|
+
const runs = JSON.parse(checks.stdout);
|
|
494
|
+
if (!Array.isArray(runs.workflow_runs) || runs.workflow_runs.length > 100 || !Number.isSafeInteger(runs.total_count) || runs.total_count < runs.workflow_runs.length) return result;
|
|
495
|
+
const linked = runs.workflow_runs.filter((row: { head_sha?: string; pull_requests?: number[] }) => heads.has(row.head_sha ?? "") || Array.isArray(row.pull_requests) && row.pull_requests.includes(number));
|
|
496
|
+
if (linked.some((row: { id?: number; run_attempt?: number }) => !id(row.id) || !id(row.run_attempt))) return result;
|
|
497
|
+
result.ciRuns = linked.map((row: { id: number; run_attempt: number }) => ({ id: row.id, attempt: row.run_attempt }));
|
|
498
|
+
result.complete = reviewComplete && commitComplete && runs.total_count === runs.workflow_runs.length;
|
|
499
|
+
return result;
|
|
500
|
+
} catch { return; }
|
|
501
|
+
}
|
package/src/hub/status-roster.ts
CHANGED
|
@@ -6,3 +6,13 @@ export function rosterInStatus(status: unknown, peer: string): boolean | undefin
|
|
|
6
6
|
const row = rows.find((r: unknown) => !!r && typeof r === "object" && (r as { peer?: unknown }).peer === peer) as { on?: unknown } | undefined;
|
|
7
7
|
return typeof row?.on === "boolean" ? row.on : undefined;
|
|
8
8
|
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The plugin bundle the running daemon verifies Jcode's project MCP entry against. The launcher writes this path rather
|
|
12
|
+
* than its own copy: the CLI on PATH and the daemon can come from different installs, and the daemon compares the whole
|
|
13
|
+
* entry. undefined for a hub that sends none or a path that is not that bundle (an older hub, a malformed reply).
|
|
14
|
+
*/
|
|
15
|
+
export function bundleInStatus(status: unknown): string | undefined {
|
|
16
|
+
const value = (status as { pluginBundle?: unknown } | undefined)?.pluginBundle;
|
|
17
|
+
return typeof value === "string" && value.startsWith("/") && /[\\/]plugins[\\/]agent-hub[\\/]server\.js$/.test(value) ? value : undefined;
|
|
18
|
+
}
|