@staix/agent-hub 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -3
- package/README.md +2 -2
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/benchmarks.md +27 -0
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/bus.md +2 -2
- package/docs/agent-notes/daemon.md +15 -6
- package/docs/agent-notes/local-worker.md +4 -0
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +17 -9
- package/docs/agent-notes/tests.md +9 -2
- package/docs/bench.md +1 -1
- package/docs/data-policy.md +2 -0
- package/docs/dogfood.md +37 -4
- package/docs/events.md +2 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +565 -62
- package/docs/profiles.md +13 -1
- package/docs/quickstart.md +14 -9
- package/docs/research.md +7 -4
- package/docs/sdlc.md +54 -10
- package/docs/security.md +396 -34
- package/docs/smoke.md +127 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +1070 -22
- package/docs/verification/2026-10-27-0.19.0.md +5 -5
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
- package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
- package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
- package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
- package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
- package/docs/verification/2026-10-29-0.21.0.md +61 -0
- package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
- package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
- package/docs/verification/claude-implementer-permission-triage.ts +160 -0
- package/docs/verification/claude-permission-transport-native.py +71 -14
- package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
- package/docs/verified.json +15 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1016 -280
- package/src/adapters/acp.ts +79 -3
- package/src/adapters/channel-peer.ts +4 -0
- package/src/adapters/claude-channel.ts +13 -6
- package/src/adapters/claude-implementer.ts +60 -7
- package/src/adapters/codex-appserver.ts +77 -4
- package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
- package/src/adapters/conformance/kit.ts +3 -9
- package/src/adapters/definitions/builtin-metadata.ts +3 -114
- package/src/adapters/definitions/native-config-defaults.ts +1 -1
- package/src/adapters/definitions/native-config-policy.ts +3 -2
- package/src/adapters/definitions/native-context.ts +12 -0
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-modules.ts +63 -3
- package/src/adapters/jcode.ts +6 -2
- package/src/adapters/local-worker.ts +40 -25
- package/src/adapters/native-proof-ports.ts +10 -0
- package/src/adapters/registry-types.ts +4 -0
- package/src/broker/protocol.ts +203 -0
- package/src/broker/server.ts +314 -0
- package/src/broker/store.ts +548 -0
- package/src/cli/bench.ts +3 -2
- package/src/cli/broker.ts +58 -0
- package/src/cli/conductor-recovery.ts +142 -0
- package/src/cli/configured-acp-entry.ts +26 -6
- package/src/cli/console-state.ts +59 -17
- package/src/cli/console.ts +17 -2
- package/src/cli/facts-hook.ts +3 -1
- package/src/cli/help.ts +28 -16
- package/src/cli/identity.ts +23 -10
- package/src/cli/init.ts +34 -7
- package/src/cli/intake-output.ts +33 -0
- package/src/cli/main.ts +314 -71
- package/src/cli/output.ts +41 -1
- package/src/cli/recovery-package.ts +18 -1
- package/src/cli/role-holders.ts +35 -0
- package/src/cli/room.ts +304 -0
- package/src/cli/say-input.ts +16 -0
- package/src/cli/schedule-output.ts +49 -0
- package/src/cli/sdlc-output.ts +44 -6
- package/src/cli/smoke-pty-bridge.ts +33 -0
- package/src/cli/smoke-pty.ts +145 -0
- package/src/cli/smoke-run.ts +139 -0
- package/src/cli/smoke-target.ts +130 -0
- package/src/cli/source-output.ts +29 -0
- package/src/cli/task-output.ts +3 -2
- package/src/cli/upgrade-runtime.ts +98 -16
- package/src/cli/upgrade.ts +66 -7
- package/src/hub/ask.ts +76 -12
- package/src/hub/board.ts +132 -22
- package/src/hub/bus.ts +399 -56
- package/src/hub/child-process.ts +2 -0
- package/src/hub/ci-watch.ts +42 -20
- package/src/hub/claude-failure-stage.ts +85 -0
- package/src/hub/claude-shell-wrapper.ts +11 -5
- package/src/hub/conductor.ts +17 -4
- package/src/hub/config-apply.ts +32 -5
- package/src/hub/config-reload.ts +7 -5
- package/src/hub/config-trust.ts +7 -1
- package/src/hub/context-window.ts +33 -3
- package/src/hub/control-client.ts +14 -2
- package/src/hub/corpus-config.ts +34 -0
- package/src/hub/corpus-evidence.ts +184 -0
- package/src/hub/corpus.ts +373 -0
- package/src/hub/cost-governance.ts +26 -0
- package/src/hub/daemon.ts +1150 -171
- package/src/hub/deliverable-files.ts +8 -2
- package/src/hub/delivery-journal.ts +123 -17
- package/src/hub/dogfood-profile.ts +32 -5
- package/src/hub/dogfood-read.ts +6 -2
- package/src/hub/envelope.ts +11 -0
- package/src/hub/eval-gate.ts +131 -0
- package/src/hub/events.ts +1 -1
- package/src/hub/external-instance.ts +69 -0
- package/src/hub/facts.ts +15 -5
- package/src/hub/folder-trigger.ts +223 -0
- package/src/hub/hub-tools.ts +2 -1
- package/src/hub/inference.ts +20 -5
- package/src/hub/instance-config.ts +69 -3
- package/src/hub/instance-execution.ts +6 -2
- package/src/hub/instances.ts +32 -4
- package/src/hub/intake.ts +125 -0
- package/src/hub/learning.ts +85 -0
- package/src/hub/lifecycle.ts +7 -3
- package/src/hub/limits.ts +12 -5
- package/src/hub/native-permission-request.ts +77 -0
- package/src/hub/operational-authority.ts +63 -5
- package/src/hub/operational-settings.ts +76 -0
- package/src/hub/operational-view.ts +1 -1
- package/src/hub/packs.ts +132 -0
- package/src/hub/path-policy.ts +7 -0
- package/src/hub/peer-doing.ts +130 -0
- package/src/hub/peers.ts +12 -0
- package/src/hub/queue-backpressure.ts +58 -0
- package/src/hub/read-tools.ts +2 -0
- package/src/hub/recovery-authority.ts +151 -0
- package/src/hub/recovery-policy.ts +220 -0
- package/src/hub/research.ts +24 -14
- package/src/hub/restart.ts +7 -0
- package/src/hub/routing.ts +54 -11
- package/src/hub/schedules.ts +266 -0
- package/src/hub/sdlc-merge.ts +38 -14
- package/src/hub/sdlc-metrics.ts +32 -10
- package/src/hub/sdlc-queue.ts +7 -1
- package/src/hub/sdlc-release.ts +1 -1
- package/src/hub/sdlc.ts +37 -7
- package/src/hub/security-baseline.ts +38 -0
- package/src/hub/settings.ts +6 -1
- package/src/hub/shipped-packs.ts +8 -0
- package/src/hub/snapshots.ts +72 -6
- package/src/hub/source-read.ts +160 -0
- package/src/hub/sources.ts +311 -0
- package/src/hub/tasks.ts +910 -110
- package/src/hub/workflow.ts +1 -1
- package/src/local/doc-read.ts +69 -0
- package/src/local/proxy.ts +4 -13
- package/src/local/tools.ts +37 -3
- package/src/local/web-fetch-config.ts +43 -0
- package/src/local/web-fetch.ts +183 -0
- package/src/memory/brief.ts +85 -13
- package/src/memory/capture.ts +1 -1
- package/src/rooms/application.ts +114 -0
- package/src/rooms/boundary.ts +103 -0
- package/src/rooms/commands.ts +294 -0
- package/src/rooms/crypto.ts +371 -0
- package/src/rooms/link.ts +584 -0
- package/src/rooms/policy.ts +82 -0
- package/src/rooms/remote-peer.ts +80 -0
- package/src/rooms/service.ts +426 -0
- package/src/rooms/status.ts +146 -0
- package/src/rooms/store.ts +1210 -0
- package/src/rooms/wire.ts +465 -0
- package/src/ui/index.html +91 -4
- package/templates/packs/example.pack.json +13 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
3
|
+
import { corpusPathProblem } from "./corpus-config.ts";
|
|
4
|
+
|
|
5
|
+
// agent-hub #512 (hub task #330): machine-local schedules. Spec: "Schedules and folder triggers (#512)".
|
|
6
|
+
|
|
7
|
+
export const MIN_EVERY_MS = 15 * 60_000;
|
|
8
|
+
export const MAX_EVERY_MS = 365 * 86_400_000;
|
|
9
|
+
export const MAX_SCHEDULES = 64;
|
|
10
|
+
export const MAX_SCHEDULE_SOURCES = 16;
|
|
11
|
+
/** Under the board's 8000-character detail cap with the fixed lines and 16 source lines around it, so the marker line is never cut off. */
|
|
12
|
+
export const MAX_SCHEDULE_DETAIL = 7000;
|
|
13
|
+
/** The first line of a scheduled task's detail: how the board finds the task a schedule made, whatever hub.db remembers. */
|
|
14
|
+
export const SCHEDULE_MARKER = "Schedule key: ";
|
|
15
|
+
|
|
16
|
+
export interface ScheduleEntry {
|
|
17
|
+
id: string;
|
|
18
|
+
every: string;
|
|
19
|
+
class: string;
|
|
20
|
+
title: string;
|
|
21
|
+
detail?: string;
|
|
22
|
+
sources?: string[];
|
|
23
|
+
owner?: string;
|
|
24
|
+
queue: "backlog" | "ready";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The source id form of `src/hub/sources.ts` (`validSourceId`), repeated here because that module reaches the file system and the converters and this one is bundled into the Claude channel server through the config schema; `test/schedules.test.ts` pins that the two agree. */
|
|
28
|
+
export const SOURCE_ID = /^src:[0-9a-f]{16}$/;
|
|
29
|
+
const ID = /^[a-z0-9][a-z0-9_-]{0,47}$/, PEER = /^[a-z][a-z0-9-]{0,31}$/, CLASS = /^[a-z][a-z0-9_]{0,47}$/, KEYS = new Set(["id", "every", "cron", "class", "title", "detail", "sources", "owner", "queue"]);
|
|
30
|
+
const control = /[\x00-\x1f\x7f]/;
|
|
31
|
+
|
|
32
|
+
/** `<n>m|h|d` to milliseconds; a thrown reason otherwise. The 15-minute floor keeps a typo from flooding the board. */
|
|
33
|
+
export function parseEvery(value: unknown): number {
|
|
34
|
+
const match = typeof value === "string" ? /^([1-9][0-9]{0,5})([mhd])$/.exec(value) : null;
|
|
35
|
+
if (!match) throw new Error("every must be <n>m, <n>h or <n>d, such as 90m, 6h or 1d");
|
|
36
|
+
const ms = Number(match[1]) * { m: 60_000, h: 3_600_000, d: 86_400_000 }[match[2] as "m" | "h" | "d"];
|
|
37
|
+
if (ms < MIN_EVERY_MS) throw new Error("every must be at least 15m");
|
|
38
|
+
if (ms > MAX_EVERY_MS) throw new Error("every must be at most 365d");
|
|
39
|
+
return ms;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The checked `schedules` list. Every refusal names the entry; nothing here reads the board, a clock or a file. */
|
|
43
|
+
export function schedulesConfig(input: unknown): ScheduleEntry[] {
|
|
44
|
+
if (input === undefined) return [];
|
|
45
|
+
if (!Array.isArray(input)) throw new Error("schedules must be a list");
|
|
46
|
+
if (input.length > MAX_SCHEDULES) throw new Error(`schedules holds at most ${MAX_SCHEDULES} entries`);
|
|
47
|
+
const seen = new Set<string>(), out: ScheduleEntry[] = [];
|
|
48
|
+
input.forEach((raw, index) => {
|
|
49
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`schedules[${index}] must be an object`);
|
|
50
|
+
const row = raw as Record<string, unknown>;
|
|
51
|
+
const name = typeof row.id === "string" && ID.test(row.id) ? `schedules.${row.id}` : `schedules[${index}]`;
|
|
52
|
+
const bad = (reason: string): never => { throw new Error(`${name}: ${reason}`); };
|
|
53
|
+
for (const key of Object.keys(row)) if (!KEYS.has(key)) bad(`unknown key ${key}`);
|
|
54
|
+
if (typeof row.id !== "string" || !ID.test(row.id)) bad("id is 1 to 48 lower-case letters, digits, - or _");
|
|
55
|
+
if (seen.has(row.id as string)) bad("duplicate id");
|
|
56
|
+
seen.add(row.id as string);
|
|
57
|
+
if (row.cron !== undefined) bad("cron not supported yet; use every");
|
|
58
|
+
try { parseEvery(row.every); } catch (error) { bad((error as Error).message); }
|
|
59
|
+
if (typeof row.class !== "string" || !CLASS.test(row.class)) bad("class is required");
|
|
60
|
+
if (typeof row.title !== "string" || !row.title.trim() || row.title.length > 300 || control.test(row.title)) bad("title is 1 to 300 printable characters on one line");
|
|
61
|
+
if (row.detail !== undefined && (typeof row.detail !== "string" || row.detail.length > MAX_SCHEDULE_DETAIL || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(row.detail))) bad(`detail is text of at most ${MAX_SCHEDULE_DETAIL} characters`);
|
|
62
|
+
if (row.owner !== undefined && (typeof row.owner !== "string" || !PEER.test(row.owner))) bad("owner is a peer id");
|
|
63
|
+
if (row.queue !== undefined && row.queue !== "backlog" && row.queue !== "ready") bad('queue is "backlog" or "ready"');
|
|
64
|
+
if (row.sources !== undefined) {
|
|
65
|
+
if (!Array.isArray(row.sources) || row.sources.length > MAX_SCHEDULE_SOURCES || !row.sources.every(id => typeof id === "string" && SOURCE_ID.test(id))) bad(`sources is a list of at most ${MAX_SCHEDULE_SOURCES} source ids (src:<16 hex>)`);
|
|
66
|
+
}
|
|
67
|
+
out.push({
|
|
68
|
+
id: row.id as string, every: row.every as string, class: row.class as string, title: (row.title as string).replace(/\s+/g, " ").trim(),
|
|
69
|
+
...(row.detail !== undefined ? { detail: row.detail as string } : {}), ...(row.sources !== undefined ? { sources: [...(row.sources as string[])] } : {}),
|
|
70
|
+
...(row.owner !== undefined ? { owner: row.owner as string } : {}), queue: (row.queue as "backlog" | "ready" | undefined) ?? "backlog",
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// agent-hub #512c (hub task #336): machine-local folder triggers. The pure config only; the scan, the store and the registration are in `folder-trigger.ts`, which reaches the disk.
|
|
77
|
+
export const MAX_FOLDER_TRIGGERS = 16;
|
|
78
|
+
export const MAX_FOLDER_GLOB = 100;
|
|
79
|
+
/** The first line of a folder-triggered task's detail: which trigger made it. A task per file, so unlike a schedule's marker it blocks nothing. */
|
|
80
|
+
export const FOLDER_MARKER = "Folder trigger: ";
|
|
81
|
+
|
|
82
|
+
export interface FolderEntry {
|
|
83
|
+
id: string;
|
|
84
|
+
folder: string;
|
|
85
|
+
glob?: string;
|
|
86
|
+
class: string;
|
|
87
|
+
title: string;
|
|
88
|
+
detail?: string;
|
|
89
|
+
owner?: string;
|
|
90
|
+
queue: "backlog" | "ready";
|
|
91
|
+
}
|
|
92
|
+
const FOLDER_KEYS = new Set(["id", "folder", "glob", "class", "title", "detail", "owner", "queue"]);
|
|
93
|
+
|
|
94
|
+
/** A file-name pattern: letters, digits, `.`, `_`, `-`, space, `*` and `?`. Never a path, so it cannot reach outside the folder. */
|
|
95
|
+
export const folderGlobProblem = (value: unknown): string | undefined =>
|
|
96
|
+
typeof value !== "string" || !value || value.length > MAX_FOLDER_GLOB || !/^[A-Za-z0-9._ *?-]+$/.test(value) || value === "." || value === ".."
|
|
97
|
+
? `glob is a file-name pattern of at most ${MAX_FOLDER_GLOB} characters (letters, digits, . _ - space, * and ?), never a path` : undefined;
|
|
98
|
+
|
|
99
|
+
/** Whether a file name matches a trigger's glob. Dot files never match: a hidden file is not something a person dropped in. */
|
|
100
|
+
export function folderGlobMatches(glob: string | undefined, name: string): boolean {
|
|
101
|
+
if (name.startsWith(".")) return false;
|
|
102
|
+
if (glob === undefined) return true;
|
|
103
|
+
return new RegExp(`^${glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`).test(name);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The checked `folder_triggers` list. Every refusal names the entry; nothing here reads the disk (the folder check does, at each poll). */
|
|
107
|
+
export function foldersConfig(input: unknown): FolderEntry[] {
|
|
108
|
+
if (input === undefined) return [];
|
|
109
|
+
if (!Array.isArray(input)) throw new Error("folder_triggers must be a list");
|
|
110
|
+
if (input.length > MAX_FOLDER_TRIGGERS) throw new Error(`folder_triggers holds at most ${MAX_FOLDER_TRIGGERS} entries`);
|
|
111
|
+
const seen = new Set<string>(), out: FolderEntry[] = [];
|
|
112
|
+
input.forEach((raw, index) => {
|
|
113
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`folder_triggers[${index}] must be an object`);
|
|
114
|
+
const row = raw as Record<string, unknown>;
|
|
115
|
+
const name = typeof row.id === "string" && ID.test(row.id) ? `folder_triggers.${row.id}` : `folder_triggers[${index}]`;
|
|
116
|
+
const bad = (reason: string): never => { throw new Error(`${name}: ${reason}`); };
|
|
117
|
+
for (const key of Object.keys(row)) if (!FOLDER_KEYS.has(key)) bad(`unknown key ${key}`);
|
|
118
|
+
if (typeof row.id !== "string" || !ID.test(row.id)) bad("id is 1 to 48 lower-case letters, digits, - or _");
|
|
119
|
+
if (seen.has(row.id as string)) bad("duplicate id");
|
|
120
|
+
seen.add(row.id as string);
|
|
121
|
+
const folderProblem = corpusPathProblem(row.folder);
|
|
122
|
+
if (folderProblem) bad(folderProblem.replace("a corpus path", "folder"));
|
|
123
|
+
if (row.glob !== undefined) { const problem = folderGlobProblem(row.glob); if (problem) bad(problem); }
|
|
124
|
+
if (typeof row.class !== "string" || !CLASS.test(row.class)) bad("class is required");
|
|
125
|
+
if (typeof row.title !== "string" || !row.title.trim() || row.title.length > 300 || control.test(row.title)) bad("title is 1 to 300 printable characters on one line");
|
|
126
|
+
if (row.detail !== undefined && (typeof row.detail !== "string" || row.detail.length > MAX_SCHEDULE_DETAIL || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(row.detail))) bad(`detail is text of at most ${MAX_SCHEDULE_DETAIL} characters`);
|
|
127
|
+
if (row.owner !== undefined && (typeof row.owner !== "string" || !PEER.test(row.owner))) bad("owner is a peer id");
|
|
128
|
+
if (row.queue !== undefined && row.queue !== "backlog" && row.queue !== "ready") bad('queue is "backlog" or "ready"');
|
|
129
|
+
out.push({
|
|
130
|
+
id: row.id as string, folder: row.folder as string, ...(row.glob !== undefined ? { glob: row.glob as string } : {}), class: row.class as string,
|
|
131
|
+
title: (row.title as string).replace(/\s+/g, " ").trim(), ...(row.detail !== undefined ? { detail: row.detail as string } : {}),
|
|
132
|
+
...(row.owner !== undefined ? { owner: row.owner as string } : {}), queue: (row.queue as "backlog" | "ready" | undefined) ?? "backlog",
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** What one poll of a trigger did: `baseline` (first poll, recorded only), `ran`, `none`, `held`, `refused` (the folder may not be read, with the reason) or `failed`. */
|
|
139
|
+
export interface FolderRunResult { id: string; outcome: "baseline" | "ran" | "none" | "held" | "refused" | "failed"; tasks: number[]; files: number; reason?: string; more?: true }
|
|
140
|
+
/**
|
|
141
|
+
* One file a poll found: its real path, name, size, mtime and SHA-256, and never its bytes (a scan of a big inbox would hold them all). `seen` marks a file the trigger
|
|
142
|
+
* already recorded at this path, size and mtime: its hash is the recorded one and the file was not read again.
|
|
143
|
+
*/
|
|
144
|
+
export interface ScannedFile { real: string; name: string; size: number; mtime: number; sha256: string; seen?: true }
|
|
145
|
+
/** What a read of a trigger's folder gave. `refused` is the reason the folder may not be read; `truncated` says the hash budget ran out and some files wait for the next poll. */
|
|
146
|
+
export interface FolderScan { folder?: string; files: ScannedFile[]; skipped: { name: string; reason: string }[]; refused?: string; truncated: boolean }
|
|
147
|
+
/** The part of the folder state `Tasks` needs. The scan, the disk and the table are in `folder-trigger.ts`, which the Claude channel bundle must not reach. */
|
|
148
|
+
export interface FolderStorePort {
|
|
149
|
+
baselined(trigger: string): boolean;
|
|
150
|
+
begin(trigger: string, now: number): void;
|
|
151
|
+
markBaselined(trigger: string, now: number): void;
|
|
152
|
+
polled(trigger: string, now: number, error?: string): void;
|
|
153
|
+
isNew(trigger: string, sha256: string): boolean;
|
|
154
|
+
record(trigger: string, file: Pick<ScannedFile, "real" | "size" | "mtime" | "sha256">, task: number | null, now: number): void;
|
|
155
|
+
observe(trigger: string, file: Pick<ScannedFile, "real" | "size" | "mtime" | "sha256">, now: number): void;
|
|
156
|
+
prune(keep: readonly string[]): void;
|
|
157
|
+
}
|
|
158
|
+
/** New files one poll registers per trigger: a drop of a whole archive becomes a handful of tasks per tick, never a burst. */
|
|
159
|
+
export const MAX_NEW_PER_POLL = 20;
|
|
160
|
+
|
|
161
|
+
/** The detail of a folder-triggered task: the marker first, then the person's text, then the one source it names. Never the file's name or path: both can be private. */
|
|
162
|
+
export function folderDetail(entry: FolderEntry, source: string): string {
|
|
163
|
+
return [`${FOLDER_MARKER}${entry.id}`, `Made by the machine-local folder trigger "${entry.id}" for a new file, registered as the source below.`, ...(entry.detail ? ["", entry.detail] : []), "", `Source: ${source}`].join("\n");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface ScheduleRow {
|
|
167
|
+
id: string;
|
|
168
|
+
/** The start of the current period: the first time the schedule was seen, then the last run. The next run is this plus `every`. */
|
|
169
|
+
anchor: number;
|
|
170
|
+
last_run: number | null;
|
|
171
|
+
last_task: number | null;
|
|
172
|
+
/** Ticks that were due and skipped (held, or the previous task still open) since the last run: coalesced into the one run that follows. */
|
|
173
|
+
coalesced: number;
|
|
174
|
+
last_error: string | null;
|
|
175
|
+
/** #335: 1 while a person has paused it. */
|
|
176
|
+
paused?: number;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** `schedule_runs` in hub.db: when each schedule last ran and what it made. The board stays the record of the tasks themselves. */
|
|
180
|
+
export class ScheduleStore {
|
|
181
|
+
private readonly db: Database;
|
|
182
|
+
constructor(dbPath: string) {
|
|
183
|
+
this.db = new Database(dbPath, { create: true });
|
|
184
|
+
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
185
|
+
this.db.run("CREATE TABLE IF NOT EXISTS schedule_runs (id TEXT PRIMARY KEY, anchor INTEGER NOT NULL, last_run INTEGER, last_task INTEGER, coalesced INTEGER NOT NULL DEFAULT 0, last_error TEXT)");
|
|
186
|
+
// #335: a person's pause. A column on the existing table, so an older database gains it and every older row reads as not paused.
|
|
187
|
+
if (!(this.db.query("PRAGMA table_info(schedule_runs)").all() as { name: string }[]).some(column => column.name === "paused")) this.db.run("ALTER TABLE schedule_runs ADD COLUMN paused INTEGER NOT NULL DEFAULT 0");
|
|
188
|
+
}
|
|
189
|
+
get(id: string): ScheduleRow | undefined { return (this.db.query("SELECT * FROM schedule_runs WHERE id = ?").get(id) as ScheduleRow | null) ?? undefined; }
|
|
190
|
+
rows(): ScheduleRow[] { return this.db.query("SELECT * FROM schedule_runs ORDER BY id").all() as ScheduleRow[]; }
|
|
191
|
+
/** A schedule seen for the first time starts its period now: it never runs at once, and a restart never replays a missed one. */
|
|
192
|
+
ensure(id: string, now: number): ScheduleRow {
|
|
193
|
+
this.db.query("INSERT OR IGNORE INTO schedule_runs(id, anchor) VALUES(?, ?)").run(id, now);
|
|
194
|
+
return this.get(id)!;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* A schedule's pause generation, in memory: `setPaused` bumps it. A run captures it when it is admitted and its write fence asks again, so a pause or a resume that
|
|
198
|
+
* lands while the run awaits its PII screen cancels that run instead of being overtaken by it. A restart ends every run in flight, so memory is enough.
|
|
199
|
+
*/
|
|
200
|
+
private readonly epochs = new Map<string, number>();
|
|
201
|
+
epoch(id: string): number { return this.epochs.get(id) ?? 0; }
|
|
202
|
+
/**
|
|
203
|
+
* #335: pause or resume one schedule. A pause stops the timer from proposing it and counts no skipped tick. Resuming starts a fresh period at `now`, so a long
|
|
204
|
+
* pause is never replayed as a run the moment it ends, as a restart is never replayed either.
|
|
205
|
+
*/
|
|
206
|
+
setPaused(id: string, paused: boolean, now: number): void {
|
|
207
|
+
this.epochs.set(id, this.epoch(id) + 1);
|
|
208
|
+
this.db.query("INSERT OR IGNORE INTO schedule_runs(id, anchor) VALUES(?, ?)").run(id, now);
|
|
209
|
+
if (paused) this.db.query("UPDATE schedule_runs SET paused = 1 WHERE id = ?").run(id);
|
|
210
|
+
else this.db.query("UPDATE schedule_runs SET paused = 0, anchor = ?, coalesced = 0 WHERE id = ?").run(now, id);
|
|
211
|
+
}
|
|
212
|
+
skipped(id: string): void { this.db.query("UPDATE schedule_runs SET coalesced = coalesced + 1 WHERE id = ?").run(id); }
|
|
213
|
+
ran(id: string, now: number, task: number): void {
|
|
214
|
+
// The anchor never moves back: a resume that landed after the task was written already started a later period, and an older pass timestamp must not shorten it.
|
|
215
|
+
this.db.query("UPDATE schedule_runs SET anchor = MAX(anchor, ?), last_run = ?, last_task = ?, coalesced = 0, last_error = NULL WHERE id = ?").run(now, now, task, id);
|
|
216
|
+
}
|
|
217
|
+
failed(id: string, reason: string): void { this.db.query("UPDATE schedule_runs SET last_error = ? WHERE id = ?").run(reason.slice(0, 200), id); }
|
|
218
|
+
/** A schedule removed from the config keeps no row. */
|
|
219
|
+
prune(keep: readonly string[]): void {
|
|
220
|
+
for (const row of this.rows()) if (!keep.includes(row.id)) this.db.query("DELETE FROM schedule_runs WHERE id = ?").run(row.id);
|
|
221
|
+
}
|
|
222
|
+
close(): void { try { this.db.close(); } catch { /* already closed */ } }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** What one run of a schedule did. `changed`: the person paused or resumed it after the run was admitted, so it wrote nothing (#335). */
|
|
226
|
+
export type ScheduleOutcome = "ran" | "held" | "waiting" | "failed" | "changed";
|
|
227
|
+
export interface ScheduleRunResult { id: string; outcome: ScheduleOutcome; task?: number }
|
|
228
|
+
|
|
229
|
+
export type ScheduleState = "scheduled" | "due" | "waiting" | "held" | "paused";
|
|
230
|
+
export interface ScheduleView {
|
|
231
|
+
id: string;
|
|
232
|
+
every: string;
|
|
233
|
+
class: string;
|
|
234
|
+
queue: "backlog" | "ready";
|
|
235
|
+
next: number;
|
|
236
|
+
last?: number;
|
|
237
|
+
task?: number;
|
|
238
|
+
state: ScheduleState;
|
|
239
|
+
coalesced?: number;
|
|
240
|
+
error?: string;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* What status shows for each schedule. `open` maps a schedule id to the open task it made. A due schedule that is held or whose task is open says so;
|
|
245
|
+
* one that is simply due (before its next tick) reads `due`. Titles and details are never shown: they are task text.
|
|
246
|
+
*/
|
|
247
|
+
export function scheduleViews(entries: readonly ScheduleEntry[], rows: readonly ScheduleRow[], now: number, open: ReadonlyMap<string, number>, held: boolean): ScheduleView[] {
|
|
248
|
+
const byId = new Map(rows.map(row => [row.id, row]));
|
|
249
|
+
return entries.map(entry => {
|
|
250
|
+
const row = byId.get(entry.id), anchor = row?.anchor ?? now, next = anchor + parseEvery(entry.every), openTask = open.get(entry.id);
|
|
251
|
+
const state: ScheduleState = row?.paused ? "paused" : now < next ? "scheduled" : held ? "held" : openTask !== undefined ? "waiting" : "due";
|
|
252
|
+
return {
|
|
253
|
+
id: entry.id, every: entry.every, class: entry.class, queue: entry.queue, next, state,
|
|
254
|
+
...(row?.last_run != null ? { last: row.last_run } : {}), ...(openTask !== undefined ? { task: openTask } : row?.last_task != null ? { task: row.last_task } : {}),
|
|
255
|
+
...(row?.coalesced ? { coalesced: row.coalesced } : {}), ...(row?.last_error ? { error: row.last_error } : {}),
|
|
256
|
+
};
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The detail of a scheduled task: the marker line first, then the person's text and the sources it names, all fixed words around config text. */
|
|
261
|
+
export function scheduledDetail(entry: ScheduleEntry): string {
|
|
262
|
+
return [`${SCHEDULE_MARKER}${entry.id}`, `Scheduled every ${entry.every} by the machine-local schedule "${entry.id}".`, ...(entry.detail ? ["", entry.detail] : []),
|
|
263
|
+
...(entry.sources?.length ? ["", ...entry.sources.map(id => `Source: ${id}`)] : [])].join("\n");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export const scheduleKeyOf = (detail: string | undefined): string | undefined => detail?.startsWith(SCHEDULE_MARKER) ? detail.slice(SCHEDULE_MARKER.length).split("\n")[0] : undefined;
|
package/src/hub/sdlc-merge.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { findingsForHead, findingHead, findingTally } from "./review-findings.ts";
|
|
2
2
|
import { readMergeQueue, type DeliveryTask, type MergeQueue } from "./sdlc-queue.ts";
|
|
3
|
-
import {
|
|
3
|
+
import { evalGapBlockers, evalGateView, evalReportedGap, evalRequiredChecks, readEvalFor, type EvalResult } from "./eval-gate.ts";
|
|
4
|
+
import { mergeCostFrom } from "./cost-governance.ts";
|
|
5
|
+
import { readPrCostHistory, readSdlcPr, stageDefinition, type AutoMergeMode, type PrEvidence, type SdlcConfig, type SdlcGh, type MergeRecord } from "./sdlc.ts";
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* #786 T1/T2: the one guarded merge. Given a task and the head its reviewer approved, it re-reads the PR (`readSdlcPr`) and the merge queue
|
|
@@ -26,7 +28,7 @@ export interface MergeInput {
|
|
|
26
28
|
run: SdlcGh;
|
|
27
29
|
readableMetadata?: (path: string) => boolean;
|
|
28
30
|
/** Final synchronous authority and queue-input fence, after fresh gates and immediately before the gh call. */
|
|
29
|
-
beforeMerge?: () => string | undefined;
|
|
31
|
+
beforeMerge?: (queue: MergeQueue | undefined) => string | undefined;
|
|
30
32
|
/** Post-run host fence and durable progress record. False or a throw withholds cleanup. No remote text is supplied. */
|
|
31
33
|
recordMerge?: (record: MergeRecord) => boolean;
|
|
32
34
|
/**
|
|
@@ -55,7 +57,7 @@ const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
|
55
57
|
* threads, draft). Its other blockers (identity, local gate, GitHub approval) are the lifecycle's, not this merge's, and are not repeated here.
|
|
56
58
|
*/
|
|
57
59
|
const QUEUE_BLOCKERS = [/^touching PRs require a person order$/, /^dependency /, /^artifact dependency cycle/, /^open PR /, /^PR file coverage is incomplete/,
|
|
58
|
-
/^required check .*: not successful on current head$/, /^review requests pending or unknown$/, /^review threads unresolved or unknown$/, /^PR is draft or draft status unknown$/];
|
|
60
|
+
/^required check .*: not successful on current head$/, /^review requests pending or unknown$/, /^review threads unresolved or unknown$/, /^PR is draft or draft status is unknown$/];
|
|
59
61
|
|
|
60
62
|
/** The mode that applies to one repository: its own table first, then the top-level key, default off. */
|
|
61
63
|
export function mergeModeFor(config: SdlcConfig, repo: string | undefined): AutoMergeMode {
|
|
@@ -78,10 +80,10 @@ export function assertMergeArgv(argv: readonly string[], head: string): void {
|
|
|
78
80
|
if (!shape) throw new Error("refusing a merge argv outside the guarded shape");
|
|
79
81
|
}
|
|
80
82
|
|
|
81
|
-
function checkGates(evidence: PrEvidence, required: readonly string[]): string[] {
|
|
83
|
+
function checkGates(evidence: PrEvidence, required: readonly string[], extra: readonly string[] = []): string[] {
|
|
82
84
|
if (!required.length) return ["required checks unknown: name them in sdlc.toml"];
|
|
83
85
|
const gates: string[] = [];
|
|
84
|
-
for (const name of required) {
|
|
86
|
+
for (const name of [...new Set([...required, ...extra])]) {
|
|
85
87
|
const matches = evidence.checks.filter(check => check.name === name);
|
|
86
88
|
const state = !matches.length ? "missing" : matches.some(c => c.state === "failed") ? "failing" : matches.some(c => c.state === "pending") ? "pending" : matches.some(c => c.state !== "passed") ? "unknown" : undefined;
|
|
87
89
|
if (state) gates.push(`required check ${name}: ${state}`);
|
|
@@ -112,18 +114,18 @@ function dropEchoes(gates: string[]): string[] {
|
|
|
112
114
|
if (check) return !named(new RegExp(`^required check ${check[1]!.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}: `));
|
|
113
115
|
if (gate === "queue: review requests pending or unknown") return !named(/review requests? (unknown|pending)|^\d+ review requests? pending$/);
|
|
114
116
|
if (gate === "queue: review threads unresolved or unknown") return !named(/review threads unknown|unresolved review thread/);
|
|
115
|
-
if (gate === "queue: PR is draft or draft status unknown") return !named(/^draft /);
|
|
117
|
+
if (gate === "queue: PR is draft or draft status is unknown") return !named(/^draft /);
|
|
116
118
|
return true;
|
|
117
119
|
});
|
|
118
120
|
}
|
|
119
121
|
|
|
120
122
|
/** The gates one reading of the PR can fail. Run on every reading this decision takes, so a gate that changes between reads is never judged on the older one. */
|
|
121
|
-
function evidenceGates(evidence: PrEvidence, approved: string | undefined, required: readonly string[]): string[] {
|
|
123
|
+
function evidenceGates(evidence: PrEvidence, approved: string | undefined, required: readonly string[], extra: readonly string[] = []): string[] {
|
|
122
124
|
const gates: string[] = [];
|
|
123
125
|
if (approved && evidence.head !== approved) gates.push(`head changed: reviewed ${approved.slice(0, 8)}, PR is at ${evidence.head.slice(0, 8)}`);
|
|
124
126
|
if (evidence.state !== "OPEN") gates.push(`PR is ${evidence.state.toLowerCase()}`);
|
|
125
127
|
if (evidence.draft !== false) gates.push("draft (or draft status unknown)");
|
|
126
|
-
gates.push(...checkGates(evidence, required));
|
|
128
|
+
gates.push(...checkGates(evidence, required, extra));
|
|
127
129
|
if (evidence.review === "changes_requested") gates.push("change request pending");
|
|
128
130
|
if (evidence.pendingReviews === null) gates.push("review requests unknown");
|
|
129
131
|
else if (evidence.pendingReviews > 0) gates.push(`${evidence.pendingReviews} review request${evidence.pendingReviews === 1 ? "" : "s"} pending`);
|
|
@@ -151,11 +153,22 @@ export function localGates(input: { subject: DeliveryTask; state: string; taskSt
|
|
|
151
153
|
return gates;
|
|
152
154
|
}
|
|
153
155
|
|
|
156
|
+
/**
|
|
157
|
+
* #481 S5: in enforce, a configuration PR whose eval check passed but reported no pass rate or suite version, or whose file coverage is unknown, does not merge.
|
|
158
|
+
* Advise and off add nothing here. Synchronous on purpose: the result is read before the final PR read, so judging the last reading awaits nothing remote.
|
|
159
|
+
* A result read for another head is ignored by the view, and a passed check with no result read at all counts as unreported (the PR moved into scope after the read).
|
|
160
|
+
*/
|
|
161
|
+
function evalGaps(config: SdlcConfig, evidence: PrEvidence, result: EvalResult | undefined): string[] {
|
|
162
|
+
if (config.mode !== "enforce") return [];
|
|
163
|
+
const view = evalGateView(config, evidence, result);
|
|
164
|
+
return [...new Set([...evalGapBlockers(view), ...(view?.state === "passed" && !result && view.check ? [evalReportedGap(view.check)] : [])])];
|
|
165
|
+
}
|
|
166
|
+
|
|
154
167
|
/**
|
|
155
168
|
* Every failing gate, never the first only: a person reading a refusal fixes them in one pass. Unknown evidence is a failing gate, never a pass.
|
|
156
169
|
* `approvedHead` is the head the reviewer's verdict recorded (`receipt.reviewedHead`); a head that moved since is `head changed`.
|
|
157
170
|
*/
|
|
158
|
-
export async function decideMerge(input: MergeInput): Promise<MergeDecision> {
|
|
171
|
+
export async function decideMerge(input: MergeInput, seen?: { queue?: MergeQueue }): Promise<MergeDecision> {
|
|
159
172
|
const { subject, config, run } = input, receipt = subject.receipt, repo = receipt?.repo, pr = receipt?.pr, approved = receipt?.reviewedHead;
|
|
160
173
|
const gates: string[] = [];
|
|
161
174
|
if (subject.private) return { refused: ["private task: a person merges"] };
|
|
@@ -164,16 +177,19 @@ export async function decideMerge(input: MergeInput): Promise<MergeDecision> {
|
|
|
164
177
|
const evidence = await readSdlcPr(receipt, run);
|
|
165
178
|
if (!evidence) return { refused: [...gates, "PR evidence unavailable"] };
|
|
166
179
|
const required = requiredChecksFor(config, repo);
|
|
167
|
-
|
|
180
|
+
// The eval metadata is read before the final PR read, never after it: nothing remote is awaited between the last reading and its judgment.
|
|
181
|
+
const evalResult = config.mode === "enforce" ? await readEvalFor(config, evidence, repo, run) : undefined;
|
|
182
|
+
gates.push(...evidenceGates(evidence, approved, required, evalRequiredChecks(config, evidence)), ...evalGaps(config, evidence, evalResult));
|
|
168
183
|
gates.push(...localGates({ subject, state: input.state, taskState: input.taskState, config, head: evidence.head }));
|
|
169
184
|
let queue: MergeQueue | undefined;
|
|
170
185
|
try { queue = await readMergeQueue(repo, config, input.tasks, run, input.readableMetadata); } catch { queue = undefined; }
|
|
186
|
+
if (seen) seen.queue = queue; // `guardedMerge` hands it to the final fence: that fence compares the board links this queue read and no others (#491)
|
|
171
187
|
gates.push(...queueGates(queue, pr));
|
|
172
188
|
// The queue read newer evidence than the first read. Read the PR once more, last, and judge every gate on it too: a check, review request, thread, draft or
|
|
173
189
|
// head that moved between the reads refuses here. `--match-head-commit` pins the head only, never these gates.
|
|
174
190
|
const last = await readSdlcPr(receipt, run);
|
|
175
191
|
if (!last) gates.push("PR evidence unavailable on the final read");
|
|
176
|
-
else { gates.push(...evidenceGates(last, approved, required)); gates.push(...localGates({ subject, state: input.state, taskState: input.taskState, config, head: last.head })); }
|
|
192
|
+
else { gates.push(...evidenceGates(last, approved, required, evalRequiredChecks(config, last)), ...evalGaps(config, last, evalResult)); gates.push(...localGates({ subject, state: input.state, taskState: input.taskState, config, head: last.head })); }
|
|
177
193
|
const row = queue?.rows.find(r => r.pr === pr);
|
|
178
194
|
if (row?.head !== undefined && approved && row.head !== approved && !gates.some(g => g.startsWith("head changed"))) gates.push(`head changed: the queue read ${row.head.slice(0, 8)}`);
|
|
179
195
|
if (gates.length) return { refused: dropEchoes([...new Set(gates)]) };
|
|
@@ -204,7 +220,8 @@ export function dispatchGates(input: Pick<MergeInput, "subject" | "taskState"> &
|
|
|
204
220
|
*/
|
|
205
221
|
export async function guardedMerge(input: MergeInput & { mode: AutoMergeMode }): Promise<MergeOutcome> {
|
|
206
222
|
if (input.mode === "off") return { mode: "off", decision: { refused: ["auto_merge is off for this repository"] }, ran: false };
|
|
207
|
-
const
|
|
223
|
+
const seen: { queue?: MergeQueue } = {};
|
|
224
|
+
const decision = await decideMerge(input, seen);
|
|
208
225
|
if (!("action" in decision) || input.mode !== "on") return { mode: input.mode, decision, ran: false };
|
|
209
226
|
// ---- synchronous dispatch segment: no await from here to the call to `run` ----
|
|
210
227
|
assertMergeArgv(decision.argv, decision.head);
|
|
@@ -213,7 +230,7 @@ export async function guardedMerge(input: MergeInput & { mode: AutoMergeMode }):
|
|
|
213
230
|
if (refused.length) return { mode: input.mode, decision: { refused }, ran: false };
|
|
214
231
|
}
|
|
215
232
|
let started: Promise<{ code: number; stdout: string }>;
|
|
216
|
-
const moved = input.beforeMerge?.();
|
|
233
|
+
const moved = input.beforeMerge?.(seen.queue);
|
|
217
234
|
if (moved) return { mode: input.mode, decision: { refused: [moved] }, ran: false };
|
|
218
235
|
try { started = Promise.resolve(input.run(decision.argv, 10_000)); } catch { started = Promise.resolve({ code: 1, stdout: "" }); }
|
|
219
236
|
// ---- end of the synchronous segment ----
|
|
@@ -236,7 +253,14 @@ export async function guardedMerge(input: MergeInput & { mode: AutoMergeMode }):
|
|
|
236
253
|
}
|
|
237
254
|
const result: NonNullable<MergeOutcome["result"]> = after?.state === "MERGED" ? {exit,state:"merged",...(after.mergeCommit?{mergeCommit:after.mergeCommit}:{})} : after ? {exit,state:"not_merged"} : {exit,state:"unknown"};
|
|
238
255
|
if (input.recordMerge) {
|
|
239
|
-
|
|
256
|
+
// #481 S7: the merged PR's review and CI figures, read once now that the merge is observed. After the merge there is nothing left to race, so this await is fine;
|
|
257
|
+
// its own bound keeps a slow GitHub from holding the record, and a failed read records no cost (unknown), never a zero.
|
|
258
|
+
const costDeadline = performance.now() + 8000;
|
|
259
|
+
const cost = result.state === "merged" ? await readPrCostHistory(decision.repo, decision.pr, (args, timeout, cwd, cap) => {
|
|
260
|
+
const left = costDeadline - performance.now();
|
|
261
|
+
return left > 0 ? input.run(args, Math.min(timeout ?? 10_000, left), cwd, cap) : Promise.resolve({ code: 1, stdout: "" });
|
|
262
|
+
}).then(mergeCostFrom, () => undefined) : undefined;
|
|
263
|
+
let record: MergeRecord={repo:decision.repo,pr:decision.pr,head:decision.head,at:Date.now(),...result,...(cost?{cost}:{}),cleanup:"not_started",ownerNotice:"not_started"};
|
|
240
264
|
const save = () => {try {return input.recordMerge!({...record});}catch{return false;}};
|
|
241
265
|
if (!save()) {record={...record,cleanup:"skipped",reason:"authority_changed"};save();}
|
|
242
266
|
else if (result.state!=="merged" || !result.mergeCommit) {record={...record,cleanup:"skipped",reason:result.state==="merged"?"missing_commit":"not_merged"};save();}
|
package/src/hub/sdlc-metrics.ts
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
// A metric that cannot be derived is `unknown` and counted as such, never 0.
|
|
3
3
|
// `first_pass_check` reads the hub's own completion check (`check passed` / `check failed` at hub_task_done), not PR CI: it is unknown where completion checks are off.
|
|
4
4
|
|
|
5
|
-
export const BAND_METRICS = ["acceptance_to_plan_ms", "acceptance_to_first_review_ms", "first_pass_approval", "rework_rounds", "first_pass_check", "stale_head_re_reviews"] as const;
|
|
5
|
+
export const BAND_METRICS = ["acceptance_to_plan_ms", "acceptance_to_first_review_ms", "first_pass_approval", "rework_rounds", "first_pass_check", "stale_head_re_reviews", "hotfix_rate", "security_open_alerts"] as const;
|
|
6
6
|
export type BandMetric = typeof BAND_METRICS[number];
|
|
7
|
+
/** Metrics whose series is not made of tasks: one value per shipped release (`hotfix_rate`, 1 for a hotfix) or per recorded security scan (`security_open_alerts`). The caller supplies them; the point's `task` is then a release or scan id. */
|
|
8
|
+
export const EXTERNAL_METRICS: readonly BandMetric[] = ["hotfix_rate", "security_open_alerts"];
|
|
9
|
+
export type SeriesPoint = { value: number; task: number };
|
|
7
10
|
export const BAND_TIERS = ["ok", "log", "diagnose", "propose"] as const;
|
|
8
11
|
export type BandTier = typeof BAND_TIERS[number];
|
|
9
12
|
|
|
@@ -30,6 +33,10 @@ export interface TaskSample {
|
|
|
30
33
|
first_pass_check: 0 | 1 | null;
|
|
31
34
|
same_head_ci_reruns: number | null;
|
|
32
35
|
stale_head_re_reviews: number | null;
|
|
36
|
+
/** #481 S7 merge-time readback: review handoffs (dones) up to the merge, CI runs of the merged PR and GitHub reviews on it. Null where the merge was not read. */
|
|
37
|
+
review_rounds_at_merge: number | null;
|
|
38
|
+
ci_runs_at_merge: number | null;
|
|
39
|
+
github_reviews_at_merge: number | null;
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
export interface Summary { n: number; unknown: number; mean: number | null; median: number | null; p90: number | null; max: number | null }
|
|
@@ -50,20 +57,23 @@ export interface BandResult {
|
|
|
50
57
|
lastTask: number | null;
|
|
51
58
|
/** The task whose value started the run of non-ok values this one belongs to, else null: one breach episode has one card. */
|
|
52
59
|
episode: number | null;
|
|
60
|
+
/** What `lastTask` and `episode` number: a task unless the metric is an external series (a release or a security scan). */
|
|
61
|
+
subject?: "task" | "release" | "scan";
|
|
53
62
|
}
|
|
63
|
+
export const SUBJECT: Partial<Record<BandMetric, "release" | "scan">> = { hotfix_rate: "release", security_open_alerts: "scan" };
|
|
54
64
|
|
|
55
65
|
/** Which way is worse. A rate that falls and a time that grows are both breaches. */
|
|
56
66
|
export const BAD_DIRECTION: Record<BandMetric, "up" | "down"> = {
|
|
57
|
-
acceptance_to_plan_ms: "up", acceptance_to_first_review_ms: "up", first_pass_approval: "down", rework_rounds: "up", first_pass_check: "down", stale_head_re_reviews: "up",
|
|
67
|
+
acceptance_to_plan_ms: "up", acceptance_to_first_review_ms: "up", first_pass_approval: "down", rework_rounds: "up", first_pass_check: "down", stale_head_re_reviews: "up", hotfix_rate: "up", security_open_alerts: "up",
|
|
58
68
|
};
|
|
59
69
|
const HOUR = 3_600_000;
|
|
60
70
|
/** Per-metric floor on the absolute deviation, so a flat baseline does not make every wobble a breach. */
|
|
61
71
|
export const DEFAULT_MIN_DELTA: Record<BandMetric, number> = {
|
|
62
|
-
acceptance_to_plan_ms: HOUR, acceptance_to_first_review_ms: HOUR, first_pass_approval: 0.5, rework_rounds: 1, first_pass_check: 0.5, stale_head_re_reviews: 1,
|
|
72
|
+
acceptance_to_plan_ms: HOUR, acceptance_to_first_review_ms: HOUR, first_pass_approval: 0.5, rework_rounds: 1, first_pass_check: 0.5, stale_head_re_reviews: 1, hotfix_rate: 0.5, security_open_alerts: 1,
|
|
63
73
|
};
|
|
64
74
|
/** Per-metric floor on the baseline deviation: a flat baseline (every value the same) must not turn one step into thousands of deviations. */
|
|
65
75
|
export const SD_FLOOR: Record<BandMetric, number> = {
|
|
66
|
-
acceptance_to_plan_ms: HOUR / 2, acceptance_to_first_review_ms: HOUR / 2, first_pass_approval: 0.5, rework_rounds: 0.5, first_pass_check: 0.5, stale_head_re_reviews: 0.5,
|
|
76
|
+
acceptance_to_plan_ms: HOUR / 2, acceptance_to_first_review_ms: HOUR / 2, first_pass_approval: 0.5, rework_rounds: 0.5, first_pass_check: 0.5, stale_head_re_reviews: 0.5, hotfix_rate: 0.5, security_open_alerts: 0.5,
|
|
67
77
|
};
|
|
68
78
|
export const DEFAULT_BAND: Omit<BandConfig, "minDelta"> = { window: 20, minSamples: 8, logSigma: 2, diagnoseSigma: 3, proposeSigma: 4, driftRun: 6 };
|
|
69
79
|
|
|
@@ -91,7 +101,7 @@ function staleHeadRounds(history: MetricTask["history"]): number | null {
|
|
|
91
101
|
}
|
|
92
102
|
|
|
93
103
|
/** One task's values from its own history. A private task never reaches here. */
|
|
94
|
-
export function taskSample(task: MetricTask, extra: { mergedAt?: number; ciReruns?: number | null } = {}): TaskSample {
|
|
104
|
+
export function taskSample(task: MetricTask, extra: { mergedAt?: number; ciReruns?: number | null; ciRuns?: number | null; reviews?: number | null } = {}): TaskSample {
|
|
95
105
|
const history = [...task.history].filter(entry => Number.isFinite(entry.at) && entry.at >= 0).sort((a, b) => a.at - b.at);
|
|
96
106
|
const view: MetricTask = { id: task.id, history };
|
|
97
107
|
const accepted = first(view, ["accepted"]);
|
|
@@ -116,6 +126,10 @@ export function taskSample(task: MetricTask, extra: { mergedAt?: number; ciRerun
|
|
|
116
126
|
rework_rounds: dones.length && approvedAt !== null ? history.filter(entry => entry.event === "changes_requested" && entry.evidenceOnly !== true).length : null,
|
|
117
127
|
first_pass_check: ci ? (ci.event === "check passed" ? 1 : 0) : null,
|
|
118
128
|
same_head_ci_reruns: extra.ciReruns === undefined ? null : extra.ciReruns,
|
|
129
|
+
// Counted only for a merged PR whose figures were read: a done before the merge is one review round.
|
|
130
|
+
review_rounds_at_merge: extra.mergedAt !== undefined && Number.isFinite(extra.mergedAt) ? dones.filter(entry => entry.at <= extra.mergedAt!).length : null,
|
|
131
|
+
ci_runs_at_merge: extra.ciRuns === undefined ? null : extra.ciRuns,
|
|
132
|
+
github_reviews_at_merge: extra.reviews === undefined ? null : extra.reviews,
|
|
119
133
|
stale_head_re_reviews: approvedAt !== null ? staleHeadRounds(history) : null,
|
|
120
134
|
};
|
|
121
135
|
}
|
|
@@ -131,7 +145,8 @@ export function summarize(values: readonly (number | null)[]): Summary {
|
|
|
131
145
|
const round = (value: number) => Math.round(value * 1000) / 1000;
|
|
132
146
|
|
|
133
147
|
/** The values of one metric in the order they happened, unknown ones left out. */
|
|
134
|
-
export
|
|
148
|
+
export type TaskMetric = Exclude<BandMetric, "hotfix_rate" | "security_open_alerts">;
|
|
149
|
+
export function series(samples: readonly TaskSample[], metric: TaskMetric): SeriesPoint[] {
|
|
135
150
|
const final = metric === "rework_rounds" || metric === "stale_head_re_reviews";
|
|
136
151
|
const when = (sample: TaskSample) => final ? sample.approved_at ?? sample.at : sample.at;
|
|
137
152
|
return [...samples].sort((a, b) => when(a) - when(b) || a.id - b.id).flatMap(sample => sample[metric] === null ? [] : [{ value: sample[metric] as number, task: sample.id }]);
|
|
@@ -172,7 +187,8 @@ export const EPISODE_LOOKBACK = 200;
|
|
|
172
187
|
* key for as long as it lasts, and it is derived from history alone, so it survives a restart.
|
|
173
188
|
*/
|
|
174
189
|
export function controlEpisode(points: readonly { value: number; task: number }[], metric: BandMetric, config: BandConfig): BandResult {
|
|
175
|
-
const
|
|
190
|
+
const subject = SUBJECT[metric];
|
|
191
|
+
const now = { ...controlBands(points, metric, config), ...(subject ? { subject } : {}) };
|
|
176
192
|
if (now.tier === "ok") return now;
|
|
177
193
|
let start = points.length - 1;
|
|
178
194
|
for (let end = points.length - 2; end >= 0 && points.length - 1 - end <= EPISODE_LOOKBACK; end--) {
|
|
@@ -193,7 +209,10 @@ export interface MetricsReport {
|
|
|
193
209
|
first_pass_check: { n: number; unknown: number; rate: number | null };
|
|
194
210
|
same_head_ci_reruns: Summary;
|
|
195
211
|
stale_head_re_reviews: Summary;
|
|
212
|
+
review_rounds_at_merge: Summary; ci_runs_at_merge: Summary; github_reviews_at_merge: Summary;
|
|
196
213
|
};
|
|
214
|
+
/** Merged PRs whose review and CI figures were read at merge, and how many of the counted tasks have none (unknown, never 0). */
|
|
215
|
+
merged: { read: number; unknown: number };
|
|
197
216
|
stalls: StallCounts;
|
|
198
217
|
bands: BandResult[] | "off";
|
|
199
218
|
}
|
|
@@ -207,12 +226,12 @@ const rate = (values: readonly (number | null)[]) => {
|
|
|
207
226
|
* The whole report from board tasks. `since` keeps samples that count at or after it; `mergedAt` and `ciReruns` come from bounded
|
|
208
227
|
* PR readbacks keyed by task id and are optional. Private tasks are counted as withheld and read for nothing else.
|
|
209
228
|
*/
|
|
210
|
-
export function sdlcMetrics(tasks: readonly MetricTask[], opts: { since?: number; bands?: BandsConfig; stalls?: StallCounts; mergedAt?: Readonly<Record<number, number>>; ciReruns?: Readonly<Record<number, number | null>> } = {}): MetricsReport {
|
|
229
|
+
export function sdlcMetrics(tasks: readonly MetricTask[], opts: { since?: number; bands?: BandsConfig; stalls?: StallCounts; mergedAt?: Readonly<Record<number, number>>; ciReruns?: Readonly<Record<number, number | null>>; ciRuns?: Readonly<Record<number, number | null>>; reviews?: Readonly<Record<number, number | null>>; external?: Partial<Record<BandMetric, readonly SeriesPoint[]>> } = {}): MetricsReport {
|
|
211
230
|
const visible = tasks.filter(task => !task.private);
|
|
212
|
-
const all = visible.map(task => taskSample(task, { mergedAt: opts.mergedAt?.[task.id], ciReruns: opts.ciReruns?.[task.id] }));
|
|
231
|
+
const all = visible.map(task => taskSample(task, { mergedAt: opts.mergedAt?.[task.id], ciReruns: opts.ciReruns?.[task.id], ciRuns: opts.ciRuns?.[task.id], reviews: opts.reviews?.[task.id] }));
|
|
213
232
|
const samples = opts.since === undefined ? all : all.filter(sample => sample.at >= opts.since!);
|
|
214
233
|
const bands: BandResult[] | "off" = opts.bands && Object.keys(opts.bands).length
|
|
215
|
-
? BAND_METRICS.flatMap(metric => opts.bands![metric] ? [controlEpisode(series(samples, metric), metric, opts.bands![metric]!)] : [])
|
|
234
|
+
? BAND_METRICS.flatMap(metric => opts.bands![metric] ? [controlEpisode(EXTERNAL_METRICS.includes(metric) ? [...(opts.external?.[metric] ?? [])] : series(samples, metric as TaskMetric), metric, opts.bands![metric]!)] : [])
|
|
216
235
|
: "off";
|
|
217
236
|
return {
|
|
218
237
|
since: opts.since ?? null, tasks: samples.length, withheldPrivate: tasks.length - visible.length,
|
|
@@ -222,7 +241,10 @@ export function sdlcMetrics(tasks: readonly MetricTask[], opts: { since?: number
|
|
|
222
241
|
first_pass_approval: rate(samples.map(s => s.first_pass_approval)), rework_rounds: summarize(samples.map(s => s.rework_rounds)),
|
|
223
242
|
first_pass_check: rate(samples.map(s => s.first_pass_check)), same_head_ci_reruns: summarize(samples.map(s => s.same_head_ci_reruns)),
|
|
224
243
|
stale_head_re_reviews: summarize(samples.map(s => s.stale_head_re_reviews)),
|
|
244
|
+
review_rounds_at_merge: summarize(samples.map(s => s.review_rounds_at_merge)), ci_runs_at_merge: summarize(samples.map(s => s.ci_runs_at_merge)),
|
|
245
|
+
github_reviews_at_merge: summarize(samples.map(s => s.github_reviews_at_merge)),
|
|
225
246
|
},
|
|
247
|
+
merged: { read: samples.filter(s => s.ci_runs_at_merge !== null || s.github_reviews_at_merge !== null).length, unknown: samples.filter(s => s.ci_runs_at_merge === null && s.github_reviews_at_merge === null).length },
|
|
226
248
|
stalls: opts.stalls ?? { open: 0, byKind: {}, doneWithoutWrites: 0 }, bands,
|
|
227
249
|
};
|
|
228
250
|
}
|
package/src/hub/sdlc-queue.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { publicTestIntegrity, parseIssueLabels, readTestIntegrity, testIntegrityBlockers, type TestIntegrity } from "./test-integrity.ts";
|
|
2
2
|
import { planConformance, planConformanceBlockers } from "./plan-conformance.ts";
|
|
3
|
+
import { evalGapBlockers, evalGateView, readEvalFor, type EvalGate } from "./eval-gate.ts";
|
|
3
4
|
import { findingsForHead, findingHead, findingTally, type ReviewFindingSet, type ReviewFindingTally } from "./review-findings.ts";
|
|
4
5
|
import type { CriteriaView } from "./workflow.ts";
|
|
5
6
|
import { parsePrEvidence, readSdlcPr, sdlcGh, sdlcStatus, type PrEvidence, type SdlcConfig, type SdlcGh, type SdlcReceipt } from "./sdlc.ts";
|
|
6
7
|
export interface DeliveryTask { plan?: { paths?: string[]; revision: number; fingerprint: string; accepted: { repo: string; pr: number; head: string; revision: number; fingerprint: string; paths: string[] }[] }; findings?: readonly ReviewFindingSet[]; id: number; after: number[]; private: boolean; receipt?: SdlcReceipt; phase?: CriteriaView; }
|
|
7
|
-
export interface MergeQueueRow { testIntegrity?: (TestIntegrity & { task?: number })[]; planConformance?: { task?: number; state: string; outside: number; untouched: number; accepted: boolean }[]; findings?: ReviewFindingTally; pr: number; tasks: number[]; head?: string; private?: true; ready: boolean; blockers: string[]; after: number[]; }
|
|
8
|
+
export interface MergeQueueRow { evalGate?: EvalGate; testIntegrity?: (TestIntegrity & { task?: number })[]; planConformance?: { task?: number; state: string; outside: number; untouched: number; accepted: boolean }[]; findings?: ReviewFindingTally; pr: number; tasks: number[]; head?: string; private?: true; ready: boolean; blockers: string[]; after: number[]; }
|
|
8
9
|
export interface MergeConflict { left: number; right: number; files: number; order: "dependency" | "person_required"; }
|
|
9
10
|
export interface MergeQueue { repo: string; rows: MergeQueueRow[]; order: number[]; unordered?: number[]; conflicts: MergeConflict[]; complete: boolean; truncated: boolean; }
|
|
10
11
|
const validRepo = (value: string) => /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value) && !value.startsWith("-");
|
|
@@ -93,6 +94,11 @@ export async function readMergeQueue(repo: string, config: SdlcConfig, tasks: De
|
|
|
93
94
|
const integrity = rawIntegrity ? publicTestIntegrity(rawIntegrity, readableMetadata) : undefined;
|
|
94
95
|
if (integrity) (row.testIntegrity ??= []).push({ task: holder!.id, ...integrity });
|
|
95
96
|
row.blockers.push(...sdlcStatus(config, holder?.receipt, read, holder?.phase).blockers);
|
|
97
|
+
// #481 S5: the eval check itself is a required check inside sdlcStatus; its recorded pass rate and suite are read once per PR here.
|
|
98
|
+
if (!row.evalGate) {
|
|
99
|
+
const gate = evalGateView(config, read, await readEvalFor(config, read, repo, bounded));
|
|
100
|
+
if (gate) { row.evalGate = gate; if (config.mode === "enforce") row.blockers.push(...evalGapBlockers(gate)); }
|
|
101
|
+
}
|
|
96
102
|
if (config.mode === "enforce") row.blockers.push(...planConformanceBlockers(conformance), ...testIntegrityBlockers(integrity));
|
|
97
103
|
}
|
|
98
104
|
}
|
package/src/hub/sdlc-release.ts
CHANGED
|
@@ -21,7 +21,7 @@ const sdlcGh: SdlcGh = async (...args) => {
|
|
|
21
21
|
catch { return { code: 1, stdout: "" }; }
|
|
22
22
|
};
|
|
23
23
|
export interface DocEvidence { schemaVersion: 1; head?: string; version?: string; documents: { path?: string; state: "fresh" | "stale" | "nonancestor" | "unknown"; verifiedAgainst?: string; commits?: number; dirty?: boolean }[]; errors: number; fresh: boolean; }
|
|
24
|
-
export interface ReleaseReadiness { repo: string; head?: string; version?: string; tag?: string; tagCommit?: string; commits: string[]; mergedPrs: { pr: number; commit: string }[]; documents?: DocEvidence; liveIssues: { id: number; state: "open" | "closed" | "unknown" }[]; blockers: string[]; sourceReady: boolean; native: "unverified"; ready: false; }
|
|
24
|
+
export interface ReleaseReadiness { /** #481 AC12: interval, approval-to-release lead time and hotfix rate over shipped releases, filled by Tasks.releaseReadiness. */ releaseMetrics?: ReturnType<typeof releaseMetrics>; 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; }
|
|
25
25
|
export type ReleaseGit = (args: string[]) => { code: number; stdout: string };
|
|
26
26
|
export interface ReleaseReaders { gh?: SdlcGh; git?: ReleaseGit; docs?: (root: string, timeoutMs: number) => Promise<DocEvidence | undefined>; }
|
|
27
27
|
const SHA = /^[0-9a-f]{40}$/;
|