@staix/agent-hub 0.12.22 → 0.12.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +117 -0
- package/README.md +22 -9
- package/docs/addons/claude-mem.md +24 -0
- package/docs/addons/codex-plugin.md +8 -0
- package/docs/addons/dot-admit.md +8 -0
- package/docs/addons/open-code-review.md +8 -0
- package/docs/addons/ponytail.md +23 -0
- package/docs/addons/ripwire.md +22 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +18 -6
- package/docs/agent-notes/benchmarks.md +3 -0
- package/docs/agent-notes/budget.md +4 -0
- package/docs/agent-notes/bus.md +3 -1
- package/docs/agent-notes/daemon.md +14 -1
- package/docs/agent-notes/local-worker.md +6 -3
- package/docs/agent-notes/tasks.md +6 -1
- package/docs/agent-notes/tests.md +4 -1
- package/docs/bench.md +28 -3
- package/docs/cooperbench.md +4 -0
- package/docs/data-policy.md +36 -0
- package/docs/events.md +10 -3
- package/docs/gsd-collaboration-analysis.md +48 -0
- package/docs/memory.md +231 -0
- package/docs/operations.md +873 -30
- package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
- package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
- package/docs/profiles.md +57 -0
- package/docs/quickstart.md +9 -4
- package/docs/research.md +31 -1
- package/docs/roles.md +85 -0
- package/docs/sdlc.md +170 -0
- package/docs/security.md +124 -8
- package/docs/smoke.md +354 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +676 -15
- package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
- package/docs/specs/2026-10-12-office-view-design.md +212 -0
- package/docs/text-projection.md +19 -0
- package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
- package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
- package/docs/verification/2026-10-11-0.12.23.md +90 -0
- package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
- package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
- package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
- package/docs/verification/2026-10-13-0.12.24.md +94 -0
- package/docs/verification/2026-10-13-board-phone.md +31 -0
- package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
- package/docs/verified.json +34 -16
- package/package.json +2 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
- package/plugins/agent-hub/server.js +1108 -261
- package/scripts/check-docs.mjs +129 -0
- package/src/adapters/acp-quota.ts +13 -0
- package/src/adapters/acp.ts +201 -20
- package/src/adapters/claude-channel.ts +48 -11
- package/src/adapters/codex-appserver.ts +54 -13
- package/src/adapters/jcode.ts +110 -0
- package/src/adapters/local-worker.ts +10 -7
- package/src/adapters/pi.ts +94 -20
- package/src/cli/attention.ts +60 -0
- package/src/cli/bench.ts +26 -6
- package/src/cli/board-read.ts +12 -0
- package/src/cli/console-state.ts +131 -39
- package/src/cli/console.ts +73 -17
- package/src/cli/facts-hook.ts +12 -4
- package/src/cli/help.ts +52 -21
- package/src/cli/identity.ts +19 -8
- package/src/cli/init.ts +48 -10
- package/src/cli/jcode-mcp.ts +58 -0
- package/src/cli/kimi-mcp.ts +53 -0
- package/src/cli/launch.ts +16 -1
- package/src/cli/main.ts +641 -193
- package/src/cli/memory.ts +193 -0
- package/src/cli/output.ts +404 -0
- package/src/cli/pet-config.ts +1 -0
- package/src/cli/pet.ts +83 -0
- package/src/cli/preview.ts +40 -6
- package/src/cli/reset.ts +3 -0
- package/src/cli/status-lines.ts +1 -29
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/terminal-recovery.ts +27 -1
- package/src/cli/upgrade-runtime.ts +56 -10
- package/src/cli/upgrade.ts +23 -5
- package/src/cli/watch.ts +31 -0
- package/src/hub/activity.ts +53 -0
- package/src/hub/ask.ts +10 -5
- package/src/hub/bench.ts +10 -3
- package/src/hub/board.ts +224 -16
- package/src/hub/budget.ts +39 -7
- package/src/hub/bus.ts +58 -20
- package/src/hub/child-process.ts +11 -0
- package/src/hub/citations.ts +7 -0
- package/src/hub/conductor.ts +34 -17
- package/src/hub/config-trust.ts +20 -7
- package/src/hub/conflicts.ts +2 -0
- package/src/hub/context-window.ts +8 -1
- package/src/hub/crash.ts +11 -1
- package/src/hub/daemon.ts +1218 -113
- package/src/hub/db-timeout.ts +3 -0
- package/src/hub/delivery-journal.ts +7 -1
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +14 -2
- package/src/hub/execution-budget.ts +2 -0
- package/src/hub/facts.ts +28 -8
- package/src/hub/git.ts +38 -0
- package/src/hub/github-import.ts +28 -0
- package/src/hub/hub-tools.ts +49 -10
- package/src/hub/inference.ts +1 -1
- package/src/hub/lifecycle-schema.ts +17 -0
- package/src/hub/lifecycle.ts +10 -4
- package/src/hub/listen.ts +15 -0
- package/src/hub/manager.ts +26 -6
- package/src/hub/path-policy.ts +96 -0
- package/src/hub/peer-catalog.ts +28 -0
- package/src/hub/peers.ts +5 -3
- package/src/hub/permission-mode.ts +33 -5
- package/src/hub/planning.ts +70 -0
- package/src/hub/profiles.ts +74 -0
- package/src/hub/project.ts +2 -1
- package/src/hub/projected-diff.ts +73 -0
- package/src/hub/projection-exec.ts +24 -0
- package/src/hub/recovery-error.ts +31 -0
- package/src/hub/recovery-store.ts +6 -5
- package/src/hub/registry.ts +2 -1
- package/src/hub/research.ts +144 -21
- package/src/hub/roles.ts +123 -0
- package/src/hub/roster.ts +23 -0
- package/src/hub/routing.ts +132 -18
- package/src/hub/sdlc-queue.ts +112 -0
- package/src/hub/sdlc-release.ts +125 -0
- package/src/hub/sdlc.ts +250 -0
- package/src/hub/settings.ts +411 -0
- package/src/hub/shipped-roles.ts +25 -0
- package/src/hub/snapshots.ts +92 -16
- package/src/hub/start-mode.ts +50 -0
- package/src/hub/supervision.ts +1 -0
- package/src/hub/task-sweep.ts +1 -1
- package/src/hub/tasks.ts +1014 -111
- package/src/hub/terminal-open.ts +64 -0
- package/src/hub/text-projection.ts +126 -0
- package/src/hub/tooling.ts +159 -0
- package/src/hub/ui.ts +40 -14
- package/src/hub/workflow.ts +71 -0
- package/src/local/deny.ts +29 -7
- package/src/local/sandbox.ts +50 -10
- package/src/local/tools.ts +11 -6
- package/src/memory/brief.ts +28 -14
- package/src/memory/import.ts +55 -0
- package/src/memory/recall.ts +86 -2
- package/src/memory/store.ts +750 -0
- package/src/memory/sync.ts +145 -0
- package/src/models/mlx.ts +2 -1
- package/src/pet/NOTICE.md +10 -0
- package/src/pet/characters.ts +1438 -0
- package/src/pet/config.ts +31 -0
- package/src/pet/frame.ts +17 -0
- package/src/pet/pixels.ts +28 -0
- package/src/pi/tool-receipts.ts +2 -1
- package/src/role-text.d.ts +1 -0
- package/src/ui/index.html +983 -40
- package/src/ui/needs.ts +58 -0
- package/src/ui/office-model.ts +440 -0
- package/src/ui/peer-palette.ts +16 -0
- package/src/ui/task-board.ts +49 -0
- package/src/ui/task-progress.ts +5 -1
- package/templates/AGENTS.block.md +3 -2
- package/templates/config.json +5 -1
- package/templates/profiles/dev.json +22 -0
- package/templates/profiles/general.json +26 -0
- package/templates/profiles/research.json +57 -0
- package/templates/profiles/writing.json +28 -0
- package/templates/roles/NOTICE.md +26 -0
- package/templates/roles/dev/backend-engineer.md +26 -0
- package/templates/roles/dev/code-reviewer.md +26 -0
- package/templates/roles/dev/frontend-engineer.md +26 -0
- package/templates/roles/dev/security-reviewer.md +26 -0
- package/templates/roles/dev/software-architect.md +26 -0
- package/templates/roles/dev/test-engineer.md +26 -0
- package/templates/roles/research/analyst.md +26 -0
- package/templates/roles/research/fact-checker.md +26 -0
- package/templates/roles/research/research-lead.md +26 -0
- package/templates/roles/research/researcher.md +26 -0
- package/templates/roles/research/statistician.md +26 -0
package/src/hub/routing.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
|
-
import type
|
|
3
|
+
import { CLASSES, type Task, type TaskClass } from "./board.ts";
|
|
4
4
|
import type { PeerId, PeerState } from "./envelope.ts";
|
|
5
5
|
|
|
6
6
|
import { parseHubRoutes, type HubRoute } from "../models/route/config.ts";
|
|
@@ -36,21 +36,115 @@ export interface Routing {
|
|
|
36
36
|
constraints: { pii: "local_only" | "off"; long_context: "skip_local" | "off"; budget_paused: "skip_peer" | "off" };
|
|
37
37
|
/** `efficient_wait_ms`, opt-in (#199): how long a hub/auto request waits for a busy MLX slot before it moves to dgx/fast; absent, nothing moves for load. */
|
|
38
38
|
pi: { dgx_max_context_tokens: number; mlx_max_context_tokens: number; efficient_wait_ms?: number };
|
|
39
|
+
/** The keys `.agenthub/routing.local.toml` set (#269), such as `classes.implement.peers`; every other value is routing.toml's. */
|
|
40
|
+
sources?: Record<string, "routing.local.toml">;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What `.agenthub/routing.local.toml` may hold (#269): a person's machine-local override of these keys and nothing
|
|
45
|
+
* else, so a settings write can never reach targets, routes, signals or the PII constraint.
|
|
46
|
+
*/
|
|
47
|
+
export interface RoutingOverlay {
|
|
48
|
+
stay_switch?: StaySwitchMode;
|
|
49
|
+
classes?: Partial<Record<TaskClass, { peers?: PeerId[]; escalate_to?: PeerId[]; route?: string; pi_backend?: "dgx" | "mlx" }>>;
|
|
50
|
+
}
|
|
51
|
+
export const OVERLAY_FILE = "routing.local.toml";
|
|
52
|
+
const OVERLAY_PEER = /^[a-z][a-z0-9-]{0,31}$/;
|
|
53
|
+
const OVERLAY_ROUTE = /^[A-Za-z0-9][A-Za-z0-9_./-]{0,63}$/;
|
|
54
|
+
const OVERLAY_PEERS_MAX = 8;
|
|
55
|
+
|
|
56
|
+
/** A checked copy of an overlay, in a fixed key order; throws on anything outside the keys above. */
|
|
57
|
+
export function checkOverlay(raw: unknown): RoutingOverlay {
|
|
58
|
+
const bad = (what: string): never => { throw new Error(`${OVERLAY_FILE}: ${what}`); };
|
|
59
|
+
const table = (value: unknown, where: string): Record<string, unknown> => (!value || typeof value !== "object" || Array.isArray(value) ? bad(`${where} must be a table`) : value as Record<string, unknown>);
|
|
60
|
+
const peers = (value: unknown, where: string): PeerId[] => {
|
|
61
|
+
if (!Array.isArray(value) || value.length > OVERLAY_PEERS_MAX || value.some((p) => typeof p !== "string" || !OVERLAY_PEER.test(p)) || new Set(value).size !== value.length) bad(`${where} must list up to ${OVERLAY_PEERS_MAX} distinct peer ids`);
|
|
62
|
+
return [...(value as PeerId[])];
|
|
63
|
+
};
|
|
64
|
+
const top = table(raw, "the file");
|
|
65
|
+
const out: RoutingOverlay = {};
|
|
66
|
+
for (const [key, value] of Object.entries(top)) {
|
|
67
|
+
if (key === "stay_switch") {
|
|
68
|
+
if (value !== "off" && value !== "shadow" && value !== "enforce") bad('stay_switch must be "off", "shadow" or "enforce"');
|
|
69
|
+
out.stay_switch = value as StaySwitchMode;
|
|
70
|
+
} else if (key === "classes") {
|
|
71
|
+
for (const [name, policy] of Object.entries(table(value, "classes"))) {
|
|
72
|
+
if (!(CLASSES as readonly string[]).includes(name)) bad(`[classes.${name}] is not a task class`);
|
|
73
|
+
const entry: NonNullable<RoutingOverlay["classes"]>[TaskClass] = {};
|
|
74
|
+
for (const [field, v] of Object.entries(table(policy, `[classes.${name}]`))) {
|
|
75
|
+
if (field === "peers" || field === "escalate_to") entry[field] = peers(v, `[classes.${name}] ${field}`);
|
|
76
|
+
else if (field === "route") entry.route = typeof v === "string" && OVERLAY_ROUTE.test(v) ? v : bad(`[classes.${name}] route must be a route id`);
|
|
77
|
+
else if (field === "pi_backend") entry.pi_backend = v === "dgx" || v === "mlx" ? v : bad(`[classes.${name}] pi_backend must be "dgx" or "mlx"`);
|
|
78
|
+
else bad(`[classes.${name}] ${field} is not a setting this file holds; edit routing.toml`);
|
|
79
|
+
}
|
|
80
|
+
if (Object.keys(entry).length) (out.classes ??= {})[name as TaskClass] = entry;
|
|
81
|
+
}
|
|
82
|
+
} else bad(`${key} is not a setting this file holds; edit routing.toml`);
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
export const parseOverlay = (text: string): RoutingOverlay => checkOverlay(Bun.TOML.parse(text));
|
|
87
|
+
|
|
88
|
+
/** The overlay as TOML. Every value is a checked id or a closed word, so a JSON string is a valid TOML basic string. */
|
|
89
|
+
export function overlayToml(overlay: RoutingOverlay): string {
|
|
90
|
+
const checked = checkOverlay(overlay);
|
|
91
|
+
const lines = ["# Written by ahub settings and the dashboard (machine-local). It overrides routing.toml key by key."];
|
|
92
|
+
if (checked.stay_switch) lines.push(`stay_switch = ${JSON.stringify(checked.stay_switch)}`);
|
|
93
|
+
for (const name of CLASSES) {
|
|
94
|
+
const entry = checked.classes?.[name];
|
|
95
|
+
if (!entry) continue;
|
|
96
|
+
lines.push("", `[classes.${name}]`);
|
|
97
|
+
for (const field of ["peers", "escalate_to"] as const) if (entry[field]) lines.push(`${field} = [${entry[field]!.map((p) => JSON.stringify(p)).join(", ")}]`);
|
|
98
|
+
if (entry.route) lines.push(`route = ${JSON.stringify(entry.route)}`);
|
|
99
|
+
if (entry.pi_backend) lines.push(`pi_backend = ${JSON.stringify(entry.pi_backend)}`);
|
|
100
|
+
}
|
|
101
|
+
return `${lines.join("\n")}\n`;
|
|
39
102
|
}
|
|
40
103
|
|
|
41
104
|
const TEMPLATE = join(import.meta.dir, "..", "..", "templates", "routing.toml");
|
|
42
105
|
export const LOCAL: PeerId = "local";
|
|
43
106
|
export const PI: PeerId = "pi";
|
|
44
107
|
|
|
45
|
-
/**
|
|
108
|
+
/**
|
|
109
|
+
* `.agenthub/routing.toml`, or the shipped default when the project has none, with `.agenthub/routing.local.toml`
|
|
110
|
+
* merged over it key by key (#269). Throws on a file that does not parse or lacks a fixed model.
|
|
111
|
+
*/
|
|
46
112
|
export function loadRouting(cwd: string): Routing {
|
|
47
|
-
|
|
113
|
+
const text = routingText(cwd);
|
|
114
|
+
let overlay: string | undefined;
|
|
48
115
|
try {
|
|
49
|
-
|
|
116
|
+
overlay = readFileSync(join(cwd, ".agenthub", OVERLAY_FILE), "utf8");
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
119
|
+
}
|
|
120
|
+
return parseRouting(text, overlay === undefined ? undefined : parseOverlay(overlay));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The project's routing.toml as text, or the shipped default when it has none. */
|
|
124
|
+
export function routingText(cwd: string): string {
|
|
125
|
+
try {
|
|
126
|
+
return readFileSync(join(cwd, ".agenthub", "routing.toml"), "utf8");
|
|
50
127
|
} catch {
|
|
51
|
-
|
|
128
|
+
return readFileSync(TEMPLATE, "utf8");
|
|
52
129
|
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The policy of a routing.toml text with an overlay merged over it: what `loadRouting` reads, without the disk. */
|
|
133
|
+
export function parseRouting(text: string, overlay?: RoutingOverlay): Routing {
|
|
53
134
|
const raw = Bun.TOML.parse(text) as Partial<Routing>;
|
|
135
|
+
const sources: NonNullable<Routing["sources"]> = {};
|
|
136
|
+
if (overlay) {
|
|
137
|
+
const checked = checkOverlay(overlay);
|
|
138
|
+
if (checked.stay_switch) { (raw as Record<string, unknown>).stay_switch = checked.stay_switch; sources.stay_switch = OVERLAY_FILE; }
|
|
139
|
+
const known = new Set([...Object.keys(raw.routes ?? {}), ...Object.keys(raw.hub_routes ?? {})]);
|
|
140
|
+
for (const [name, entry] of Object.entries(checked.classes ?? {})) {
|
|
141
|
+
if (entry.route && !known.has(entry.route)) throw new Error(`${OVERLAY_FILE}: [classes.${name}] route ${entry.route} names no route in routing.toml`);
|
|
142
|
+
const classes = (raw.classes ??= {}) as Record<string, ClassPolicy>;
|
|
143
|
+
// A class the shared file does not have starts from an empty peer list: the overlay may add its own.
|
|
144
|
+
classes[name] = { ...(classes[name] ?? { peers: [] }), ...entry };
|
|
145
|
+
for (const field of Object.keys(entry)) sources[`classes.${name}.${field}`] = OVERLAY_FILE;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
54
148
|
if (!raw.local?.fixed_model) throw new Error("routing.toml: [local] fixed_model is required (the path that works without Switchyard)");
|
|
55
149
|
const signals = { pii_patterns: [], long_context_tokens: 120_000, pii_screen: "off" as const, ...raw.signals };
|
|
56
150
|
for (const p of signals.pii_patterns) new RegExp(p); // a bad pattern fails here, at load, not in the middle of an assignment
|
|
@@ -82,22 +176,26 @@ export function loadRouting(cwd: string): Routing {
|
|
|
82
176
|
signals,
|
|
83
177
|
constraints: { pii: "local_only", long_context: "skip_local", budget_paused: "skip_peer", ...raw.constraints },
|
|
84
178
|
pi,
|
|
179
|
+
...(Object.keys(sources).length ? { sources } : {}),
|
|
85
180
|
};
|
|
86
181
|
}
|
|
87
182
|
|
|
88
|
-
const cache = new Map<string, { mtime:
|
|
183
|
+
const cache = new Map<string, { mtime: string; routing: Routing }>();
|
|
89
184
|
|
|
90
185
|
/**
|
|
91
186
|
* loadRouting behind an mtime check. routing.toml is edited while the hub runs and read on every task operation;
|
|
92
187
|
* a half-saved file must not abort an operation midway, so the last good parse stays in force until the file parses again.
|
|
93
188
|
*/
|
|
94
189
|
export function currentRouting(cwd: string, log: (line: string) => void = () => {}): Routing {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
190
|
+
// Both files count: a settings write changes only the overlay.
|
|
191
|
+
const mtime = ["routing.toml", OVERLAY_FILE].map((name) => {
|
|
192
|
+
try {
|
|
193
|
+
const stat = statSync(join(cwd, ".agenthub", name));
|
|
194
|
+
return `${stat.mtimeMs}/${stat.size}`;
|
|
195
|
+
} catch {
|
|
196
|
+
return 0; // no project file: the shipped template, or no overlay
|
|
197
|
+
}
|
|
198
|
+
}).join(":");
|
|
101
199
|
const hit = cache.get(cwd);
|
|
102
200
|
if (hit && hit.mtime === mtime) return hit.routing;
|
|
103
201
|
try {
|
|
@@ -106,13 +204,13 @@ export function currentRouting(cwd: string, log: (line: string) => void = () =>
|
|
|
106
204
|
return routing;
|
|
107
205
|
} catch (e) {
|
|
108
206
|
if (!hit) throw e;
|
|
109
|
-
log(`routing
|
|
207
|
+
log(`routing does not load, keeping the previous policy: ${(e as Error).message}`);
|
|
110
208
|
cache.set(cwd, { mtime, routing: hit.routing });
|
|
111
209
|
return hit.routing;
|
|
112
210
|
}
|
|
113
211
|
}
|
|
114
212
|
|
|
115
|
-
export type Signal = "pii" | "long_context" | "urgent";
|
|
213
|
+
export type Signal = "pii" | "pii_path" | "long_context" | "urgent";
|
|
116
214
|
|
|
117
215
|
/** What the policy can see in a task. Context length is estimated from the referenced files, 3 characters per token as elsewhere. */
|
|
118
216
|
export function detectSignals(task: Pick<Task, "title" | "detail" | "refs">, routing: Routing, cwd: string): Signal[] {
|
|
@@ -150,11 +248,19 @@ export interface Assignment {
|
|
|
150
248
|
* prints its trace. `states`: effective bus states of attached peers (a peer that is not in the map is not attached).
|
|
151
249
|
*/
|
|
152
250
|
export function assign(
|
|
153
|
-
task: Pick<Task, "class" | "signals" | "reserved"
|
|
251
|
+
task: Pick<Task, "class" | "signals" | "reserved"> & Partial<Pick<Task, "state">>,
|
|
154
252
|
states: Record<PeerId, PeerState>,
|
|
155
253
|
routing: Routing,
|
|
156
254
|
opts: {
|
|
157
255
|
exclude?: PeerId[];
|
|
256
|
+
/** Owner admission limits do not remove otherwise eligible reviewers. */
|
|
257
|
+
excludeOwners?: PeerId[];
|
|
258
|
+
/** Live membership supplied as data; routing never reads the roster store. */
|
|
259
|
+
offRoster?: PeerId[];
|
|
260
|
+
/** Watched peers take work only on an explicit nomination, never a routing singleton. */
|
|
261
|
+
pullOnly?: PeerId[];
|
|
262
|
+
namedOwner?: PeerId;
|
|
263
|
+
namedReviewer?: PeerId;
|
|
158
264
|
candidates?: PeerId[];
|
|
159
265
|
notReviewer?: PeerId;
|
|
160
266
|
waitsFor?: number[];
|
|
@@ -174,17 +280,25 @@ export function assign(
|
|
|
174
280
|
roles?: Record<string, string[]>;
|
|
175
281
|
} = {},
|
|
176
282
|
): Assignment {
|
|
283
|
+
if (task.state === "backlog") return { trace: ["backlog: not routed; pull into To Do first"] };
|
|
177
284
|
const policy = routing.classes[task.class];
|
|
178
285
|
const trace: string[] = [`class ${task.class}${policy ? "" : " (no [classes] entry: only an explicit owner can take it)"}`, `signals: ${task.signals.join(", ") || "none"}`];
|
|
286
|
+
// Which file each of this class's values comes from (#269), only when the machine-local overlay sets one.
|
|
287
|
+
const local = Object.keys(routing.sources ?? {}).filter((key) => key.startsWith(`classes.${task.class}.`)).map((key) => key.slice(`classes.${task.class}.`.length));
|
|
288
|
+
if (local.length) trace.push(`policy source: ${local.join(", ")} from ${OVERLAY_FILE}; the rest from routing.toml`);
|
|
179
289
|
// A reserved owner (issue #207) is offered the task first. Named candidates (an assign, an escalation, a relay) replace it.
|
|
180
290
|
const reserved = opts.candidates ? undefined : task.reserved ?? undefined;
|
|
181
291
|
// Readiness is an input like peer states (issue #34): a task that waits for others goes to nobody yet.
|
|
182
292
|
if (opts.waitsFor?.length) return { trace: [...trace, `blocked: waits for ${opts.waitsFor.map((id) => `#${id}`).join(", ")} (not approved)`, ...(reserved ? [`reserved owner ${reserved}: offered first once it is ready`] : [])] };
|
|
183
|
-
const pii = task.signals.includes("pii") && routing.constraints.pii === "local_only";
|
|
293
|
+
const pii = task.signals.includes("pii_path") || (task.signals.includes("pii") && routing.constraints.pii === "local_only");
|
|
184
294
|
|
|
185
295
|
const blocked = (peer: PeerId, role: "owner" | "reviewer"): string | undefined => {
|
|
296
|
+
if (opts.offRoster?.includes(peer)) return "off roster";
|
|
297
|
+
const named = role === "owner" ? opts.namedOwner ?? reserved : opts.namedReviewer;
|
|
298
|
+
if (opts.pullOnly?.includes(peer) && named !== peer) return "pull-only: takes work when named or when it claims it";
|
|
186
299
|
if (!Object.hasOwn(states, peer)) return "not attached";
|
|
187
300
|
if (opts.exclude?.includes(peer)) return "excluded (declined or replaced)";
|
|
301
|
+
if (role === "owner" && opts.excludeOwners?.includes(peer)) return "owner admission: WIP or delivery hold";
|
|
188
302
|
if (states[peer] === "offline") return "offline";
|
|
189
303
|
if (states[peer] === "paused" && routing.constraints.budget_paused === "skip_peer") return "paused";
|
|
190
304
|
const failing = peerEntry(opts.failing, peer);
|
|
@@ -270,8 +384,8 @@ export function assign(
|
|
|
270
384
|
// Reviewer candidates (issue #92): the review class's peers first, then peers holding the reviewer role, deduped.
|
|
271
385
|
const classPeers = routing.classes.review?.peers ?? [];
|
|
272
386
|
const rolePeers = Object.keys(opts.roles ?? {}).filter((p) => opts.roles![p]!.includes("reviewer"));
|
|
273
|
-
trace.push(` reviewer candidates: [classes.review] peers ${classPeers.join(", ") || "none"}${rolePeers.length ? `; reviewer role ${rolePeers.join(", ")}` : ""}`);
|
|
274
|
-
reviewer = pick([...new Set([...classPeers, ...rolePeers])], "reviewer", owner ?? opts.notReviewer);
|
|
387
|
+
trace.push(opts.namedReviewer === undefined ? ` reviewer candidates: [classes.review] peers ${classPeers.join(", ") || "none"}${rolePeers.length ? `; reviewer role ${rolePeers.join(", ")}` : ""}` : ` reviewer candidate explicitly named: ${opts.namedReviewer}`);
|
|
388
|
+
reviewer = pick(opts.namedReviewer === undefined ? [...new Set([...classPeers, ...rolePeers])] : [opts.namedReviewer], "reviewer", owner ?? opts.notReviewer);
|
|
275
389
|
trace.push(reviewer ? `reviewer: ${reviewer}` : "reviewer: none, done will approve directly");
|
|
276
390
|
const reviewerHold = reviewer ? peerEntry(opts.held, reviewer) : undefined;
|
|
277
391
|
if (reviewerHold) trace.push(` hold: ${reviewer}'s queue is held: ${reviewerHold} (it receives the review once the hold is resolved)`);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { PhaseView } from "./workflow.ts";
|
|
2
|
+
import { parsePrEvidence, readSdlcPr, sdlcGh, sdlcStatus, type PrEvidence, type SdlcConfig, type SdlcGh, type SdlcReceipt } from "./sdlc.ts";
|
|
3
|
+
export interface DeliveryTask { id: number; after: number[]; private: boolean; receipt?: SdlcReceipt; phase?: PhaseView; }
|
|
4
|
+
export interface MergeQueueRow { pr: number; tasks: number[]; head?: string; private?: true; ready: boolean; blockers: string[]; after: number[]; }
|
|
5
|
+
export interface MergeConflict { left: number; right: number; files: number; order: "dependency" | "person_required"; }
|
|
6
|
+
export interface MergeQueue { repo: string; rows: MergeQueueRow[]; order: number[]; unordered?: number[]; conflicts: MergeConflict[]; complete: boolean; truncated: boolean; }
|
|
7
|
+
const validRepo = (value: string) => /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value) && !value.startsWith("-");
|
|
8
|
+
const validId = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
9
|
+
const parseSnapshot = (reply: { code: number; stdout: string }): { number: number; head: string; base: string; baseHead: string }[] | undefined => {
|
|
10
|
+
try { const data = JSON.parse(reply.stdout); if (reply.code !== 0 || !Array.isArray(data) || data.length > 17 || data.some(p => !validId(p?.number) || typeof p.headRefOid !== "string" || !/^[0-9a-f]{40}$/.test(p.headRefOid) || typeof p.baseRefName !== "string" || !p.baseRefName || p.baseRefName.length > 200 || /[\x00-\x1f\x7f]/.test(p.baseRefName) || typeof p.baseRefOid !== "string" || !/^[0-9a-f]{40}$/.test(p.baseRefOid)) || new Set(data.map(p => p.number)).size !== data.length) return; return data.map(p => ({ number: p.number, head: p.headRefOid, base: p.baseRefName, baseHead: p.baseRefOid })).sort((a, b) => a.number - b.number); }
|
|
11
|
+
catch { return; }
|
|
12
|
+
};
|
|
13
|
+
const gateIdentity = (p: PrEvidence) => JSON.stringify({ head: p.head, state: p.state, checks: [...p.checks].sort((a, b) => a.name.localeCompare(b.name) || a.state.localeCompare(b.state)), review: p.review, pending: p.pendingReviews, unresolved: p.unresolved, draft: p.draft, base: p.base, baseHead: p.baseHead, pathsComplete: p.pathsComplete });
|
|
14
|
+
/** The last remote read gathers both open identities and mutable gates in one bounded request. */
|
|
15
|
+
async function finalSnapshot(repo: string, evidence: Map<number, PrEvidence>, run: SdlcGh): Promise<{ gatesStable: boolean; prs?: ReturnType<typeof parseSnapshot> }> {
|
|
16
|
+
const [owner, name] = repo.split("/");
|
|
17
|
+
const fields = "headRefOid state isDraft baseRefName baseRefOid changedFiles reviewDecision reviewRequests(first:100){nodes{requestedReviewer{__typename}} pageInfo{hasNextPage}} reviewThreads(first:100){nodes{isResolved} pageInfo{hasNextPage}} statusCheckRollup{contexts(first:100){nodes{... on CheckRun{name conclusion status} ... on StatusContext{context state}} pageInfo{hasNextPage}}}";
|
|
18
|
+
const entries = [...evidence.entries()];
|
|
19
|
+
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]) => `queue_p${id}:pullRequest(number:${id}){${fields}}`).join(" ")}}}`;
|
|
20
|
+
try {
|
|
21
|
+
const reply = await run(["api", "graphql", "-f", `query=${query}`, "-f", `owner=${owner}`, "-f", `name=${name}`]);
|
|
22
|
+
if (reply.code !== 0) return { gatesStable: false };
|
|
23
|
+
const data = JSON.parse(reply.stdout);
|
|
24
|
+
if (data.errors || !data.data?.repository) return { gatesStable: false };
|
|
25
|
+
const open = data.data.repository.pullRequests;
|
|
26
|
+
const prs = parseSnapshot({ code: 0, stdout: JSON.stringify(open?.nodes) });
|
|
27
|
+
if (!prs || open?.pageInfo?.hasNextPage !== false) return { gatesStable: false, prs };
|
|
28
|
+
for (const [id, before] of entries) {
|
|
29
|
+
const p = data.data.repository[`queue_p${id}`];
|
|
30
|
+
const threads = p?.reviewThreads, reviews = p?.reviewRequests, checks = p?.statusCheckRollup === null ? { nodes: [], pageInfo: { hasNextPage: false } } : p?.statusCheckRollup?.contexts;
|
|
31
|
+
if (threads?.pageInfo?.hasNextPage !== false || reviews?.pageInfo?.hasNextPage !== false || checks?.pageInfo?.hasNextPage !== false || !Array.isArray(threads.nodes) || threads.nodes.length > 100 || threads.nodes.some((n: { isResolved?: unknown }) => typeof n?.isResolved !== "boolean") || !Array.isArray(reviews.nodes) || !Array.isArray(checks.nodes) || checks.nodes.length > 100) return { gatesStable: false, prs };
|
|
32
|
+
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 });
|
|
33
|
+
if (gateIdentity(before) !== gateIdentity(after)) return { gatesStable: false, prs };
|
|
34
|
+
}
|
|
35
|
+
return { gatesStable: true, prs };
|
|
36
|
+
} catch { return { gatesStable: false }; }
|
|
37
|
+
}
|
|
38
|
+
/** Fixed metadata-only reads; local claims and unknown dependency evidence never become merge approval. */
|
|
39
|
+
export async function readMergeQueue(repo: string, config: SdlcConfig, tasks: DeliveryTask[], run: SdlcGh = sdlcGh): Promise<MergeQueue> {
|
|
40
|
+
if (!validRepo(repo)) throw new Error("merge queue requires owner/repository");
|
|
41
|
+
const result: MergeQueue = { repo, rows: [], order: [], conflicts: [], complete: false, truncated: false };
|
|
42
|
+
const deadline = performance.now() + 20_000;
|
|
43
|
+
const bounded: SdlcGh = args => { const remaining = deadline - performance.now(); return remaining > 0 ? run(args, Math.min(10_000, remaining)) : Promise.resolve({ code: 1, stdout: "" }); };
|
|
44
|
+
const listArgs = ["pr", "list", "--repo", repo, "--state", "open", "--limit", "17", "--json", "number,headRefOid,baseRefName,baseRefOid"];
|
|
45
|
+
const initialPrs = parseSnapshot(await bounded(listArgs)); if (!initialPrs) return result;
|
|
46
|
+
result.truncated = initialPrs.length > 16; const ids = initialPrs.slice(0, 16).map(pr => pr.number);
|
|
47
|
+
const sameRepo = (value: unknown) => typeof value === "string" && value.toLowerCase() === repo.toLowerCase();
|
|
48
|
+
const evidence = new Map<number, PrEvidence>(), links = new Map<number, DeliveryTask[]>();
|
|
49
|
+
for (const pr of ids) {
|
|
50
|
+
const holders = tasks.filter(t => sameRepo(t.receipt?.repo) && t.receipt?.pr === pr); links.set(pr, holders);
|
|
51
|
+
const row: MergeQueueRow = { pr, tasks: holders.some(t => t.private) ? [] : holders.map(t => t.id), ready: false, blockers: [], after: [] }; result.rows.push(row);
|
|
52
|
+
if (holders.some(t => t.private)) { row.private = true; row.blockers.push("private lifecycle evidence unavailable"); continue; }
|
|
53
|
+
const read = await readSdlcPr(holders[0]?.receipt ?? { stage: config.stages[0] ?? "spec", repo, pr }, bounded);
|
|
54
|
+
if (!read || read.state !== "OPEN") { row.blockers.push("current PR evidence unknown or no longer open"); continue; }
|
|
55
|
+
evidence.set(pr, read); row.head = read.head;
|
|
56
|
+
if (!read.pathsComplete) row.blockers.push("PR file coverage is incomplete or unknown");
|
|
57
|
+
if (read.draft !== false) row.blockers.push("PR is draft or draft status is unknown");
|
|
58
|
+
if (!read.base) row.blockers.push("PR target branch is unknown");
|
|
59
|
+
const linked = holders.length ? holders : [undefined];
|
|
60
|
+
for (const holder of linked) row.blockers.push(...sdlcStatus(config, holder?.receipt, read, holder?.phase).blockers);
|
|
61
|
+
}
|
|
62
|
+
const dependencyEvidence = new Map<number, PrEvidence | undefined>(); let dependencyReads = 0;
|
|
63
|
+
for (const row of result.rows) {
|
|
64
|
+
if (row.private) continue;
|
|
65
|
+
for (const holder of links.get(row.pr) ?? []) for (const id of holder.after) {
|
|
66
|
+
const dependency = tasks.find(t => t.id === id);
|
|
67
|
+
if (!dependency || dependency.private || !dependency.receipt || !sameRepo(dependency.receipt.repo) || !validId(dependency.receipt.pr)) { row.blockers.push("dependency merge evidence unknown or private"); continue; }
|
|
68
|
+
const pr = dependency.receipt.pr;
|
|
69
|
+
if (pr === row.pr) continue; // task dependency within this same artifact
|
|
70
|
+
if (ids.includes(pr)) { row.after.push(pr); row.blockers.push("dependency PR is still open"); continue; }
|
|
71
|
+
if (!dependencyEvidence.has(pr) && dependencyReads < 8) { dependencyReads++; dependencyEvidence.set(pr, await readSdlcPr(dependency.receipt, bounded)); }
|
|
72
|
+
const read = dependencyEvidence.get(pr);
|
|
73
|
+
if (!read || read.state !== "MERGED" || !read.mergeCommit) row.blockers.push("dependency PR merge is not verified");
|
|
74
|
+
}
|
|
75
|
+
row.after = [...new Set(row.after)].sort((a, b) => a - b);
|
|
76
|
+
}
|
|
77
|
+
const reaches = (from: number, target: number, seen = new Set<number>()): boolean => {
|
|
78
|
+
if (seen.has(from)) return false; seen.add(from);
|
|
79
|
+
return (result.rows.find(r => r.pr === from)?.after ?? []).some(before => before === target || reaches(before, target, seen));
|
|
80
|
+
};
|
|
81
|
+
for (let i = 0; i < result.rows.length; i++) for (const right of result.rows.slice(i + 1)) {
|
|
82
|
+
const left = result.rows[i]!, a = evidence.get(left.pr), b = evidence.get(right.pr); if (!a || !b || !a.base || a.base !== b.base) continue;
|
|
83
|
+
const files = new Set(a.paths.filter(path => b.paths.includes(path))).size; if (!files) continue;
|
|
84
|
+
const ordered = reaches(left.pr, right.pr) || reaches(right.pr, left.pr);
|
|
85
|
+
result.conflicts.push({ left: left.pr, right: right.pr, files, order: ordered ? "dependency" : "person_required" });
|
|
86
|
+
if (!ordered) { left.blockers.push("touching PRs require a person order"); right.blockers.push("touching PRs require a person order"); }
|
|
87
|
+
}
|
|
88
|
+
const remaining = new Set(ids);
|
|
89
|
+
while (remaining.size) {
|
|
90
|
+
const next = result.rows.filter(r => remaining.has(r.pr) && r.after.every(pr => !remaining.has(pr))).map(r => r.pr).sort((a, b) => a - b);
|
|
91
|
+
if (!next.length) { for (const row of result.rows.filter(r => remaining.has(r.pr))) row.blockers.push("artifact dependency cycle requires a person"); break; }
|
|
92
|
+
for (const pr of next) { remaining.delete(pr); result.order.push(pr); }
|
|
93
|
+
}
|
|
94
|
+
const touchingCoverageUnknown = result.rows.some(row => evidence.get(row.pr)?.pathsComplete !== true || !evidence.get(row.pr)?.base);
|
|
95
|
+
const personOrder = new Set(result.conflicts.filter(conflict => conflict.order === "person_required").flatMap(conflict => [conflict.left, conflict.right]));
|
|
96
|
+
const unresolvedOrder = new Set([...remaining, ...personOrder]);
|
|
97
|
+
for (const row of result.rows) if (result.truncated || touchingCoverageUnknown || [...personOrder].some(pr => reaches(row.pr, pr))) unresolvedOrder.add(row.pr);
|
|
98
|
+
if (unresolvedOrder.size) { result.unordered = ids.filter(pr => unresolvedOrder.has(pr)); result.order = result.order.filter(pr => !unresolvedOrder.has(pr)); }
|
|
99
|
+
for (const row of result.rows) { if (result.truncated) row.blockers.push("open PR coverage truncated; touching order unknown"); if (touchingCoverageUnknown) row.blockers.push("open PR file coverage incomplete; touching order unknown"); row.blockers = [...new Set(row.blockers)]; row.ready = !row.private && !row.blockers.length; }
|
|
100
|
+
result.complete = !result.truncated && !remaining.size && !touchingCoverageUnknown && !personOrder.size;
|
|
101
|
+
// Empty/private-only snapshots have no intervening remote reads and offer no private PR evidence.
|
|
102
|
+
if (evidence.size) {
|
|
103
|
+
const finalRead = await finalSnapshot(repo, evidence, bounded);
|
|
104
|
+
const gatesStable = finalRead.gatesStable;
|
|
105
|
+
const finalPrs = finalRead.prs;
|
|
106
|
+
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))) {
|
|
107
|
+
result.complete = false; result.order = []; result.unordered = ids;
|
|
108
|
+
for (const row of result.rows) { row.ready = false; row.blockers.push("open PR set changed or could not be reverified"); }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { childEnv, stopOwnedProcess } from "./child-process.ts";
|
|
4
|
+
import { hubGitSync } from "./git.ts";
|
|
5
|
+
import { sdlcGh, type SdlcGh } from "./sdlc.ts";
|
|
6
|
+
export interface DocEvidence { schemaVersion: 1; head?: string; version?: string; documents: { path?: string; state: "fresh" | "stale" | "nonancestor" | "unknown"; verifiedAgainst?: string; commits?: number; dirty?: boolean }[]; errors: number; fresh: boolean; }
|
|
7
|
+
export interface ReleaseReadiness { repo: string; head?: string; version?: string; tag?: string; tagCommit?: string; commits: string[]; mergedPrs: { pr: number; commit: string }[]; documents?: DocEvidence; liveIssues: { id: number; state: "open" | "closed" | "unknown" }[]; blockers: string[]; sourceReady: boolean; native: "unverified"; ready: false; }
|
|
8
|
+
export type ReleaseGit = (args: string[]) => { code: number; stdout: string };
|
|
9
|
+
export interface ReleaseReaders { gh?: SdlcGh; git?: ReleaseGit; docs?: (root: string, timeoutMs: number) => Promise<DocEvidence | undefined>; }
|
|
10
|
+
const SHA = /^[0-9a-f]{40}$/;
|
|
11
|
+
const VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
12
|
+
const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
13
|
+
/** One identity floor for initial and final remote tag observations. */
|
|
14
|
+
function tagMatches(target: unknown, commit: string): boolean {
|
|
15
|
+
const parsed = target as { oid?: unknown; target?: { oid?: unknown } } | null | undefined;
|
|
16
|
+
const oid = parsed?.oid ?? parsed?.target?.oid;
|
|
17
|
+
return typeof oid === "string" && SHA.test(oid) && oid === commit;
|
|
18
|
+
}
|
|
19
|
+
/** Package-owned checker only; no caller-supplied program or document text is executed. */
|
|
20
|
+
export function readDocEvidence(root: string, timeoutMs = 10_000): Promise<DocEvidence | undefined> {
|
|
21
|
+
return new Promise(resolve => {
|
|
22
|
+
const child = spawn(process.execPath, [fileURLToPath(new URL("../../scripts/check-docs.mjs", import.meta.url)), root, "--json", "--committed"], { cwd: root, env: childEnv(), detached: true, stdio: ["ignore", "pipe", "ignore"] });
|
|
23
|
+
let output = "", invalid = false;
|
|
24
|
+
const stop = () => { invalid = true; void stopOwnedProcess(child, { group: true }).catch(() => {}); };
|
|
25
|
+
const timer = setTimeout(stop, Math.max(1, Math.min(10_000, timeoutMs)));
|
|
26
|
+
child.stdout?.setEncoding("utf8"); child.stdout?.on("data", chunk => { output += chunk; if (Buffer.byteLength(output) > 256 * 1024) { output = ""; stop(); } });
|
|
27
|
+
child.once("error", () => { invalid = true; clearTimeout(timer); resolve(undefined); });
|
|
28
|
+
child.once("close", (code, signal) => {
|
|
29
|
+
clearTimeout(timer); if (invalid || signal || (code !== 0 && code !== 1)) return resolve(undefined);
|
|
30
|
+
try {
|
|
31
|
+
const data = JSON.parse(output);
|
|
32
|
+
if (data.schemaVersion !== 1 || !Array.isArray(data.documents) || data.documents.length > 128 || !Number.isSafeInteger(data.errors) || data.errors < 0 || typeof data.fresh !== "boolean" || (data.head !== undefined && !SHA.test(data.head)) || (data.version !== undefined && !VERSION.test(data.version)) || data.documents.some((d: {state?:string}) => !["fresh","stale","nonancestor","unknown"].includes(d.state ?? ""))) return resolve(undefined);
|
|
33
|
+
resolve(data);
|
|
34
|
+
} catch { resolve(undefined); }
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/** Read-only checklist. Native acceptance remains an explicit human/operational gate. */
|
|
39
|
+
export async function readReleaseReadiness(root: string, repo: string, liveIssues: number[], readers: ReleaseReaders = {}): Promise<ReleaseReadiness> {
|
|
40
|
+
if (!REPO.test(repo) || repo.startsWith("-") || liveIssues.length > 32 || liveIssues.some(id => !Number.isSafeInteger(id) || id <= 0) || new Set(liveIssues).size !== liveIssues.length) throw new Error("release evidence requires owner/repository and bounded issue ids");
|
|
41
|
+
const report: ReleaseReadiness = { repo, commits: [], mergedPrs: [], liveIssues: [], blockers: [], sourceReady: false, native: "unverified", ready: false };
|
|
42
|
+
const deadline = performance.now() + 20_000;
|
|
43
|
+
const gh: SdlcGh = args => { const left = deadline - performance.now(); return left > 0 ? (readers.gh ?? sdlcGh)(args, Math.min(left,10_000)) : Promise.resolve({code:1,stdout:""}); };
|
|
44
|
+
const git: ReleaseGit = readers.git ?? (args => { const r = hubGitSync(args, { cwd: root, encoding: "utf8", timeout: Math.max(1, Math.min(1000, deadline - performance.now())), maxBuffer: 256 * 1024 }); return { code: r.status ?? 1, stdout: r.stdout ?? "" }; });
|
|
45
|
+
const first = git(["rev-parse","--verify","HEAD"]); const head = first.stdout.trim();
|
|
46
|
+
if (first.code !== 0 || !SHA.test(head)) { report.blockers.push("local HEAD unknown"); return report; } report.head = head;
|
|
47
|
+
try { const r = git(["show",`${head}:package.json`]), pkg = JSON.parse(r.stdout); if (r.code === 0 && typeof pkg.version === "string" && VERSION.test(pkg.version)) report.version = pkg.version; else report.blockers.push("package version unknown"); } catch { report.blockers.push("package version unknown"); }
|
|
48
|
+
const published = await gh(["release","list","--repo",repo,"--exclude-drafts","--order","desc","--limit","1","--json","tagName,isDraft,isPrerelease"]);
|
|
49
|
+
let tag: string | undefined;
|
|
50
|
+
try { const rows = JSON.parse(published.stdout), p = Array.isArray(rows) && rows.length === 1 ? rows[0] : undefined; if (published.code === 0 && p && typeof p.tagName === "string" && p.tagName.startsWith("v") && VERSION.test(p.tagName.slice(1)) && p.isDraft === false && typeof p.isPrerelease === "boolean") tag = p.tagName; } catch { /* unknown metadata */ }
|
|
51
|
+
if (!tag) report.blockers.push("published release identity unknown");
|
|
52
|
+
else {
|
|
53
|
+
report.tag = tag; const local = git(["rev-parse","--verify",`refs/tags/${tag}^{commit}`]);
|
|
54
|
+
const [owner,name] = repo.split("/");
|
|
55
|
+
const ref = await gh(["api","graphql","-f","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}}}}}}}","-f",`owner=${owner}`,"-f",`name=${name}`,"-f",`ref=refs/tags/${tag}`]);
|
|
56
|
+
const commit = local.stdout.trim();
|
|
57
|
+
let remoteMatches = false; try { remoteMatches = tagMatches(JSON.parse(ref.stdout)?.data?.repository?.ref?.target, commit); } catch { /* unknown */ }
|
|
58
|
+
if (local.code !== 0 || ref.code !== 0 || !SHA.test(commit) || !remoteMatches) report.blockers.push("local and remote release tag identity differ or are unknown");
|
|
59
|
+
else if (git(["merge-base","--is-ancestor",commit,head]).code !== 0) report.blockers.push("published release is not a verified ancestor");
|
|
60
|
+
else {
|
|
61
|
+
report.tagCommit = commit;
|
|
62
|
+
const work = git(["rev-list","--first-parent","--max-count=1001",`${commit}..${head}`]); const commits = work.stdout.trim().split("\n").filter(Boolean);
|
|
63
|
+
if (work.code !== 0 || commits.length > 1000 || commits.some(c => !SHA.test(c))) report.blockers.push("merged work coverage unknown or truncated"); else report.commits = commits;
|
|
64
|
+
let advanced = false;
|
|
65
|
+
try { advanced = !!report.version && Bun.semver.order(report.version, tag.slice(1)) > 0; } catch { /* invalid version remains blocked */ }
|
|
66
|
+
if (!advanced) report.blockers.push("package version has not advanced past published release");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (report.tagCommit) {
|
|
70
|
+
// Resolve only commits since the verified tag, never the repository's lifetime PR list.
|
|
71
|
+
const [owner, name] = repo.split("/");
|
|
72
|
+
for (let start = 0; start < report.commits.length; start += 100) {
|
|
73
|
+
const batch = report.commits.slice(start, start + 100);
|
|
74
|
+
const objects = batch.map((commit, i) => `c${i}:object(oid:"${commit}"){... on Commit{oid associatedPullRequests(first:10){nodes{number state mergeCommit{oid}} pageInfo{hasNextPage}}}}`).join(" ");
|
|
75
|
+
const pulls = await gh(["api", "graphql", "-f", `query=query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${objects}}}`, "-f", `owner=${owner}`, "-f", `name=${name}`]);
|
|
76
|
+
try {
|
|
77
|
+
const data = JSON.parse(pulls.stdout)?.data?.repository;
|
|
78
|
+
if (pulls.code !== 0 || !data) throw new Error("unavailable");
|
|
79
|
+
for (const [i, commit] of batch.entries()) {
|
|
80
|
+
const object = data[`c${i}`], connection = object?.associatedPullRequests, rows = connection?.nodes;
|
|
81
|
+
if (object?.oid !== commit || connection?.pageInfo?.hasNextPage !== false || !Array.isArray(rows) || rows.length > 10 || rows.some(p => !Number.isSafeInteger(p?.number) || p.number <= 0 || !["OPEN", "CLOSED", "MERGED"].includes(p.state) || (p.state === "MERGED" && !SHA.test(p.mergeCommit?.oid)))) throw new Error("incomplete");
|
|
82
|
+
for (const p of rows) if (p.state === "MERGED" && p.mergeCommit.oid === commit && !report.mergedPrs.some(r => r.pr === p.number)) report.mergedPrs.push({ pr: p.number, commit });
|
|
83
|
+
}
|
|
84
|
+
} catch { report.blockers.push("merged PR coverage unknown or truncated"); break; }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const left = deadline - performance.now(); report.documents = left > 0 ? await (readers.docs ?? readDocEvidence)(root,Math.min(10_000,left)) : undefined;
|
|
88
|
+
if (!report.documents || report.documents.head !== head || !report.documents.fresh || report.documents.errors || report.documents.documents.some(d => d.state !== "fresh")) report.blockers.push("documentation freshness or ancestry is unknown or stale");
|
|
89
|
+
if (!liveIssues.length) report.blockers.push("native verification ledger unspecified");
|
|
90
|
+
for (const id of liveIssues) {
|
|
91
|
+
const reply = await gh(["issue","view",String(id),"--repo",repo,"--json","number,state"]); let state: "open"|"closed"|"unknown" = "unknown";
|
|
92
|
+
try { const p = JSON.parse(reply.stdout); if (reply.code === 0 && p.number === id && ["OPEN","CLOSED"].includes(p.state)) state = p.state.toLowerCase(); } catch { /* unknown */ }
|
|
93
|
+
report.liveIssues.push({id,state}); if (state !== "closed") report.blockers.push("native verification ledger open or unknown");
|
|
94
|
+
}
|
|
95
|
+
// The predecessor and tag can change while documents/issues are read. Recheck both in the last remote snapshot.
|
|
96
|
+
if (report.tag && report.tagCommit) {
|
|
97
|
+
const [owner, name] = repo.split("/");
|
|
98
|
+
const ledgers = liveIssues.map(id => `ledger_${id}:issue(number:${id}){number state}`).join(" ");
|
|
99
|
+
const query = `query($owner:String!,$name:String!,$ref:String!){repository(owner:$owner,name:$name){releases(first:100,orderBy:{field:CREATED_AT,direction:DESC}){nodes{tagName isDraft} pageInfo{hasNextPage}} ref(qualifiedName:$ref){target{... on Commit{oid} ... on Tag{target{... on Commit{oid}}}}} ${ledgers}}}`;
|
|
100
|
+
const snapshot = await gh(["api", "graphql", "-f", `query=${query}`, "-f", `owner=${owner}`, "-f", `name=${name}`, "-f", `ref=refs/tags/${report.tag}`]);
|
|
101
|
+
let stable = false, issuesStable = false;
|
|
102
|
+
let finalIssues: ReleaseReadiness["liveIssues"] = report.liveIssues.map(issue => ({ id: issue.id, state: "unknown" }));
|
|
103
|
+
try {
|
|
104
|
+
const data = JSON.parse(snapshot.stdout), repository = data.data?.repository, rows = repository?.releases?.nodes;
|
|
105
|
+
if (snapshot.code === 0 && !data.errors && Array.isArray(rows) && rows.length <= 100 && typeof repository.releases.pageInfo?.hasNextPage === "boolean" && rows.every(p => typeof p?.tagName === "string" && typeof p.isDraft === "boolean")) {
|
|
106
|
+
finalIssues = report.liveIssues.map(issue => {
|
|
107
|
+
const value = repository[`ledger_${issue.id}`];
|
|
108
|
+
const state: "open" | "closed" | "unknown" = value?.number === issue.id && ["OPEN", "CLOSED"].includes(value.state) ? value.state.toLowerCase() : "unknown";
|
|
109
|
+
return { id: issue.id, state };
|
|
110
|
+
});
|
|
111
|
+
issuesStable = finalIssues.every((issue, index) => issue.state === "closed" && issue.state === report.liveIssues[index]?.state);
|
|
112
|
+
const newest = rows.find(p => !p.isDraft), target = repository.ref?.target;
|
|
113
|
+
stable = newest?.tagName === report.tag && tagMatches(target, report.tagCommit);
|
|
114
|
+
}
|
|
115
|
+
} catch { /* unavailable predecessor remains blocked */ }
|
|
116
|
+
if (!stable) report.blockers.push("published release or remote tag changed or could not be reverified");
|
|
117
|
+
report.liveIssues = finalIssues;
|
|
118
|
+
if (!issuesStable) report.blockers.push("live issue states changed or could not be reverified");
|
|
119
|
+
}
|
|
120
|
+
const last = git(["rev-parse","--verify","HEAD"]); if (last.code !== 0 || last.stdout.trim() !== head) report.blockers.push("local HEAD changed during release readback");
|
|
121
|
+
report.blockers = [...new Set(report.blockers)]; report.sourceReady = report.blockers.length === 0;
|
|
122
|
+
// Closed issue metadata is not a native receipt or permission to publish.
|
|
123
|
+
report.blockers.push("native acceptance and person release decision unverified");
|
|
124
|
+
return report;
|
|
125
|
+
}
|