@staix/agent-hub 0.20.0 → 0.22.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 +66 -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 +3 -3
- package/docs/agent-notes/daemon.md +17 -8
- 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 +39 -4
- package/docs/events.md +2 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +610 -62
- package/docs/profiles.md +13 -1
- package/docs/quickstart.md +18 -9
- package/docs/research.md +7 -4
- package/docs/sdlc.md +54 -10
- package/docs/security.md +396 -34
- package/docs/smoke.md +133 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +1101 -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/2026-10-30-0.22.0.md +49 -0
- package/docs/verification/2026-10-30-445-capability-probes.md +172 -0
- package/docs/verification/2026-10-30-bus-delivery-operational-settings.md +77 -0
- package/docs/verification/2026-10-30-jcode-ac4.md +24 -0
- package/docs/verification/2026-10-30-jcode-ac6.md +52 -0
- package/docs/verification/2026-10-30-recovery-wait-settings.md +141 -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 +29 -16
- package/src/cli/identity.ts +25 -10
- package/src/cli/init.ts +34 -7
- package/src/cli/intake-output.ts +33 -0
- package/src/cli/learn-output.ts +38 -0
- package/src/cli/main.ts +334 -73
- package/src/cli/output.ts +41 -1
- package/src/cli/recovery-package.ts +18 -1
- package/src/cli/recovery-waits.ts +26 -0
- 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 +82 -0
- package/src/cli/smoke-pty.ts +153 -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 +112 -28
- package/src/cli/upgrade.ts +68 -8
- package/src/hub/ask.ts +76 -12
- package/src/hub/board.ts +132 -22
- package/src/hub/bus.ts +416 -65
- 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 +34 -6
- 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 +1209 -183
- package/src/hub/deliverable-files.ts +8 -2
- package/src/hub/delivery-journal.ts +200 -73
- 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 +117 -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 +154 -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 +27 -2
- 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 +987 -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
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}$/;
|
package/src/hub/sdlc.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { planConformanceBlockers, type PlanConformance } from "./plan-conformanc
|
|
|
3
3
|
import { DEFAULT_MAX_NITS, findingsForHead, findingHead, findingTally, type ReviewFindingSet, type ReviewFindingTally } from "./review-findings.ts";
|
|
4
4
|
import type { CostPlan } from "./cost-governance.ts";
|
|
5
5
|
import { bandsConfig, type BandsConfig } from "./sdlc-metrics.ts";
|
|
6
|
+
import { configPathList, evalGapBlockers, evalGateView, evalRequiredChecks, type EvalGate, type EvalResult } from "./eval-gate.ts";
|
|
7
|
+
import { SECURITY_SOURCES, type SecuritySource } from "./security-baseline.ts";
|
|
6
8
|
import { criteriaReceipt, type CriteriaReceipt, type CriteriaView, type LegacyPhaseReceipt } from "./workflow.ts";
|
|
7
9
|
import { constants, openSync, fstatSync, readSync, closeSync } from "node:fs";
|
|
8
10
|
import { join } from "node:path";
|
|
@@ -36,9 +38,11 @@ export type SdlcMode = "off" | "advise" | "enforce";
|
|
|
36
38
|
/** #786: `off` never merges, `dry-run` reports the decision and runs nothing, `on` runs the guarded `gh pr merge`. */
|
|
37
39
|
export type AutoMergeMode = "off" | "dry-run" | "on";
|
|
38
40
|
export const AUTO_MERGE_MODES: readonly AutoMergeMode[] = ["off", "dry-run", "on"];
|
|
41
|
+
/** #481 S7: which GitHub alert sources the scan reads, how often, and for which repository (absent: the project repository). */
|
|
42
|
+
export interface SecurityScanConfig { everyH: number; sources: SecuritySource[]; repo?: string }
|
|
39
43
|
/** #786: one repository's own gate: its required checks (before the top-level list) and its own merge mode. */
|
|
40
44
|
export interface RepoMergePolicy { requiredChecks?: string[]; autoMerge?: AutoMergeMode }
|
|
41
|
-
export interface SdlcConfig { workflow?: "gsd-lite"; maxNits?: number; /** #481 S7: optional control bands per metric; absent means none. */ bands?: BandsConfig; mode: SdlcMode; requiredChecks: string[]; /** #786: absent reads as "off"; written only when the file names it. */ autoMerge?: AutoMergeMode; /** #786: per-repository tables, keyed by lowercase owner/name. */ repos?: Record<string, RepoMergePolicy>; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; deprecations?: string[]; }
|
|
45
|
+
export interface SdlcConfig { workflow?: "gsd-lite"; maxNits?: number; /** #481 S7: optional control bands per metric; absent means none. */ bands?: BandsConfig; /** #481 S7: the scheduled security-scan baseline; absent means none is scheduled. */ securityScan?: SecurityScanConfig; /** #481 S5: the one check a configuration PR needs; no default, the person names it. */ evalCheck?: string; /** #481 S5: patterns that mark a PR as a configuration change; absent reads as the defaults in eval-gate.ts, an empty list turns the gate off. */ configPaths?: string[]; mode: SdlcMode; requiredChecks: string[]; /** #786: absent reads as "off"; written only when the file names it. */ autoMerge?: AutoMergeMode; /** #786: per-repository tables, keyed by lowercase owner/name. */ repos?: Record<string, RepoMergePolicy>; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; deprecations?: string[]; }
|
|
42
46
|
export interface PlanReviewReceipt {
|
|
43
47
|
planner: string;
|
|
44
48
|
planAuthor?: string | null;
|
|
@@ -56,6 +60,8 @@ export interface MergeRecord {
|
|
|
56
60
|
cleanup: "not_started" | "claimed" | "deleted" | "already_gone" | "skipped" | "unknown";
|
|
57
61
|
reason?: "not_merged" | "missing_commit" | "metadata_unknown" | "identity_changed" | "protected_branch" | "default_branch" | "head_changed" | "authority_changed" | "delete_unconfirmed";
|
|
58
62
|
ownerNotice: "not_started" | "claimed" | "sent" | "unknown";
|
|
63
|
+
/** #481 S7: the merged PR's review and CI figures, read once at merge by the host. Absent when the PR could not be read; fields inside are null when unknown. */
|
|
64
|
+
cost?: import("./cost-governance.ts").MergeCost;
|
|
59
65
|
}
|
|
60
66
|
export interface SdlcReceipt {
|
|
61
67
|
merge?: MergeRecord;
|
|
@@ -152,6 +158,22 @@ const checkList = (value: unknown, what: string): string[] => {
|
|
|
152
158
|
if (!Array.isArray(value) || value.length > 32 || value.some(c => typeof c !== "string" || !c.trim() || c.length > 120 || /[\r\n\x00-\x1f]/.test(c)) || new Set(value).size !== value.length) throw new Error(`${what}: expected at most 32 unique nonempty names`);
|
|
153
159
|
return [...value] as string[];
|
|
154
160
|
};
|
|
161
|
+
const evalCheckName = (value: unknown): string => {
|
|
162
|
+
if (typeof value !== "string" || !value.trim() || value.length > 120 || /[\r\n\x00-\x1f]/.test(value)) throw new Error("sdlc.eval_check: expected one nonempty check name");
|
|
163
|
+
return value;
|
|
164
|
+
};
|
|
165
|
+
const securityScanConfig = (value: unknown): SecurityScanConfig => {
|
|
166
|
+
const fail = (path: string, why: string): never => { throw new Error(`sdlc.security_scan${path}: ${why}`); };
|
|
167
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return fail("", "expected a table");
|
|
168
|
+
const raw = value as Record<string, unknown>;
|
|
169
|
+
for (const key of Object.keys(raw)) if (!["every_h", "sources", "repo"].includes(key)) fail(`.${key}`, "unknown field");
|
|
170
|
+
const everyH = raw.every_h === undefined ? 24 : raw.every_h;
|
|
171
|
+
if (typeof everyH !== "number" || !Number.isSafeInteger(everyH) || everyH < 1 || everyH > 720) fail(".every_h", "expected an integer number of hours from 1 to 720");
|
|
172
|
+
const sources = raw.sources;
|
|
173
|
+
if (!Array.isArray(sources) || !sources.length || sources.length > SECURITY_SOURCES.length || new Set(sources).size !== sources.length || sources.some(s => !SECURITY_SOURCES.includes(s as SecuritySource))) fail(".sources", `expected a nonempty list of unique names from ${SECURITY_SOURCES.join(", ")}; the scan has no default source`);
|
|
174
|
+
if (raw.repo !== undefined && !repo(raw.repo)) fail(".repo", "expected owner/repository");
|
|
175
|
+
return { everyH: everyH as number, sources: [...sources as SecuritySource[]], ...(raw.repo === undefined ? {} : { repo: raw.repo as string }) };
|
|
176
|
+
};
|
|
155
177
|
const autoMergeMode = (value: unknown, what: string): AutoMergeMode => {
|
|
156
178
|
if (!AUTO_MERGE_MODES.includes(value as AutoMergeMode)) throw new Error(`${what}: expected off, dry-run or on`);
|
|
157
179
|
return value as AutoMergeMode;
|
|
@@ -178,11 +200,15 @@ export function sdlcConfig(value: unknown, version?: string): SdlcConfig {
|
|
|
178
200
|
if (value === undefined) return { mode: "off", requiredChecks: [], stages: [...SDLC_STAGES] };
|
|
179
201
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdlc: expected a table");
|
|
180
202
|
const raw = value as Record<string, unknown>;
|
|
181
|
-
for (const key of Object.keys(raw)) if (!["mode", "required_checks", "stages", "workflow", "definitions", "max_nits", "bands", "auto_merge", "repos"].includes(key)) throw new Error(`sdlc.${key}: unknown field`);
|
|
203
|
+
for (const key of Object.keys(raw)) if (!["mode", "required_checks", "stages", "workflow", "definitions", "max_nits", "bands", "auto_merge", "repos", "eval_check", "config_paths", "security_scan"].includes(key)) throw new Error(`sdlc.${key}: unknown field`);
|
|
182
204
|
const maxNits = raw.max_nits === undefined ? DEFAULT_MAX_NITS : raw.max_nits;
|
|
183
205
|
if (typeof maxNits !== "number" || !Number.isSafeInteger(maxNits) || maxNits < 0 || maxNits > 64) throw new Error("sdlc.max_nits: expected an integer from 0 to 64");
|
|
184
206
|
const bands = bandsConfig(raw.bands), bandConfig = Object.keys(bands).length ? { bands } : {};
|
|
185
|
-
const
|
|
207
|
+
const evalConfig = {
|
|
208
|
+
...(raw.eval_check === undefined ? {} : { evalCheck: evalCheckName(raw.eval_check) }),
|
|
209
|
+
...(raw.config_paths === undefined ? {} : { configPaths: configPathList(raw.config_paths, "sdlc.config_paths") }),
|
|
210
|
+
};
|
|
211
|
+
const findingConfig = { ...(raw.max_nits === undefined ? {} : { maxNits }), ...bandConfig, ...evalConfig, ...(raw.security_scan === undefined ? {} : { securityScan: securityScanConfig(raw.security_scan) }) };
|
|
186
212
|
const mode = raw.mode ?? "off";
|
|
187
213
|
if (mode !== "off" && mode !== "advise" && mode !== "enforce") throw new Error("sdlc.mode: expected off, advise or enforce");
|
|
188
214
|
// ponytail: the #481 S8 window is a fixed version; delete `workflow`, the phase aliases and their tests once it passes.
|
|
@@ -336,12 +362,13 @@ export const SPEC_STATE_TEXT: Record<Exclude<SpecState, "current">, string> = {
|
|
|
336
362
|
changed: "spec changed after acceptance: the person re-accepts with ahub sdlc accept <task> --issue <n>",
|
|
337
363
|
unknown: "spec edit time unknown: the issue could not be read",
|
|
338
364
|
};
|
|
339
|
-
export interface SdlcStatus { testIntegrity?: TestIntegrity; planConformance?: PlanConformance & { accepted?: boolean; earlierPushes?: "unknown"; revisionsAfterFirstObservedPush?: number }; findings?: ReviewFindingTally; spec?: { issue: number; state: SpecState }; playbook?: string; criteria?: CriteriaView; mode: SdlcMode; stage: SdlcStage; definition?: StageDefinition; entry?: string[]; exit?: string[]; blockers: string[]; ready: boolean; actor: string; next: string; }
|
|
365
|
+
export interface SdlcStatus { evalGate?: EvalGate; testIntegrity?: TestIntegrity; planConformance?: PlanConformance & { accepted?: boolean; earlierPushes?: "unknown"; revisionsAfterFirstObservedPush?: number }; findings?: ReviewFindingTally; spec?: { issue: number; state: SpecState }; playbook?: string; criteria?: CriteriaView; mode: SdlcMode; stage: SdlcStage; definition?: StageDefinition; entry?: string[]; exit?: string[]; blockers: string[]; ready: boolean; actor: string; next: string; }
|
|
340
366
|
/** The acceptance-criteria checklist (#481 S8, Compliance pass) counts once recorded, and on every task under the retired gsd-lite setting. Off keeps #257's output. */
|
|
341
367
|
export const criteriaRequired = (config: SdlcConfig, receipt: SdlcReceipt | undefined): boolean => config.workflow === "gsd-lite" || (config.mode !== "off" && !!checklistOf(receipt));
|
|
342
|
-
export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined, evidence?: PrEvidence, criteria?: CriteriaView, specEdit?: SpecEdit, sets: readonly ReviewFindingSet[] = [], commit?: string, plan?: SdlcStatus["planConformance"], integrity?: TestIntegrity): SdlcStatus {
|
|
368
|
+
export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined, evidence?: PrEvidence, criteria?: CriteriaView, specEdit?: SpecEdit, sets: readonly ReviewFindingSet[] = [], commit?: string, plan?: SdlcStatus["planConformance"], integrity?: TestIntegrity, evalResult?: EvalResult): SdlcStatus {
|
|
343
369
|
const stage = receipt?.stage ?? config.stages[0] ?? "spec", blockers: string[] = [];
|
|
344
370
|
const definition = stageDefinition(config, stage);
|
|
371
|
+
const evalGate = evidence ? evalGateView(config, evidence, evalResult) : undefined;
|
|
345
372
|
const items = findingsForHead(sets, findingHead(receipt, evidence, commit));
|
|
346
373
|
const findings = items.length ? findingTally(items) : undefined;
|
|
347
374
|
if (config.mode === "enforce" && findings?.openImportant) blockers.push("open important review findings: " + findings.openImportant);
|
|
@@ -355,10 +382,13 @@ export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined,
|
|
|
355
382
|
if (receipt?.reviewedHead !== evidence.head) blockers.push("current head has not been reviewed");
|
|
356
383
|
if (receipt?.localHead !== evidence.head || receipt.localResult !== "passed") blockers.push("local gate on current head missing or failed");
|
|
357
384
|
if (!config.requiredChecks.length) blockers.push("required check set unknown");
|
|
358
|
-
|
|
385
|
+
// #481 S5: a configuration PR also needs the configured eval check; a code-only PR is unaffected.
|
|
386
|
+
for (const name of [...new Set([...config.requiredChecks, ...evalRequiredChecks(config, evidence)])]) {
|
|
359
387
|
const matches = evidence.checks.filter(c => c.name === name);
|
|
360
388
|
if (!matches.length || matches.some(c => c.state !== "passed")) blockers.push(`required check ${name}: not successful on current head`);
|
|
361
389
|
}
|
|
390
|
+
// Gaps in the eval evidence block only in enforce; advise shows them in `evalGate` and the advice notice (never a silent pass, never an advise block).
|
|
391
|
+
if (config.mode === "enforce") blockers.push(...evalGapBlockers(evalGate));
|
|
362
392
|
if (evidence.review !== "approved") blockers.push("GitHub review approval missing or changes requested");
|
|
363
393
|
if (evidence.pendingReviews !== 0) blockers.push("review requests pending or unknown");
|
|
364
394
|
if (evidence.unresolved !== 0) blockers.push("review threads unresolved or unknown");
|
|
@@ -371,7 +401,7 @@ export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined,
|
|
|
371
401
|
const shown = criteria && (criteriaRequired(config, receipt) || definition && [...definition.entry_gates, ...definition.exit_gates].includes("criteria"));
|
|
372
402
|
// Shown, not counted as a readiness blocker: the spec gate is build entry (#481 S1).
|
|
373
403
|
const spec = config.mode !== "off" && receipt?.issue ? { spec: { issue: receipt.issue, state: specState(receipt, specEdit) } } : {};
|
|
374
|
-
return { ...spec, ...(config.mode !== "off" && integrity ? { testIntegrity: integrity } : {}), ...(config.mode !== "off" && plan ? { planConformance: plan } : {}), ...(findings ? { findings } : {}), ...(config.mode !== "off" && definition && playbookStage(definition.base) ? { playbook: playbookStage(definition.base) } : {}), ...(shown ? { criteria } : {}), ...(custom ? { definition, ...custom } : {}), mode: config.mode, stage, blockers, ready: blockers.length === 0,
|
|
404
|
+
return { ...spec, ...(evalGate ? { evalGate } : {}), ...(config.mode !== "off" && integrity ? { testIntegrity: integrity } : {}), ...(config.mode !== "off" && plan ? { planConformance: plan } : {}), ...(findings ? { findings } : {}), ...(config.mode !== "off" && definition && playbookStage(definition.base) ? { playbook: playbookStage(definition.base) } : {}), ...(shown ? { criteria } : {}), ...(custom ? { definition, ...custom } : {}), mode: config.mode, stage, blockers, ready: blockers.length === 0,
|
|
375
405
|
actor: !definition ? "person" : shown && criteria.blockers.length ? "reviewer/person" : config.definitions ? definition.actor : baseActor(definition.base),
|
|
376
406
|
// #694: the identity blocker keeps its exact text for gate matching; the next step says how to supply it.
|
|
377
407
|
next: blockers[0] === "issue identity missing" ? "issue identity missing: name one issue (title #N, refs or hub_sdlc_record)" : blockers[0] ?? "person may inspect merge readiness; no automatic merge" };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { SdlcGh } from "./sdlc.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #481 S7: the scheduled security-scan baseline. The hub runs no scanner and invents no control: it reads the open-alert counts GitHub already holds for the
|
|
5
|
+
* repository (Dependabot, secret scanning, code scanning), records one scan row each interval, and the existing control-band path judges the series. A source that
|
|
6
|
+
* is not enabled, not permitted or unreadable counts as unknown, never as zero alerts, so a repository with no source configured records nothing.
|
|
7
|
+
*/
|
|
8
|
+
export const SECURITY_SOURCES = ["dependabot", "secret_scanning", "code_scanning"] as const;
|
|
9
|
+
export type SecuritySource = typeof SECURITY_SOURCES[number];
|
|
10
|
+
export interface SecurityScan { repo: string; sources: SecuritySource[]; counts: Partial<Record<SecuritySource, number | null>>; total: number | null }
|
|
11
|
+
const PATH: Record<SecuritySource, string> = { dependabot: "dependabot/alerts", secret_scanning: "secret-scanning/alerts", code_scanning: "code-scanning/alerts" };
|
|
12
|
+
const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
13
|
+
const MAX_ALERTS = 1000;
|
|
14
|
+
|
|
15
|
+
/** Open alerts of one source, exactly (paged to the cap), or null when the source cannot be read completely. Counts only: no alert text, name or location is kept. */
|
|
16
|
+
async function openAlerts(repo: string, source: SecuritySource, run: SdlcGh, deadline: number): Promise<number | null> {
|
|
17
|
+
let total = 0;
|
|
18
|
+
for (let page = 1; page <= MAX_ALERTS / 100; page++) {
|
|
19
|
+
const left = deadline - performance.now();
|
|
20
|
+
if (left <= 0) return null;
|
|
21
|
+
const reply = await run(["api", `repos/${repo}/${PATH[source]}?state=open&per_page=100&page=${page}`, "--jq", "length"], Math.min(10_000, left), undefined, 256).catch(() => undefined);
|
|
22
|
+
if (!reply || reply.code !== 0) return null;
|
|
23
|
+
const n = Number(reply.stdout.trim());
|
|
24
|
+
if (!Number.isSafeInteger(n) || n < 0 || n > 100) return null;
|
|
25
|
+
total += n;
|
|
26
|
+
if (n < 100) return total;
|
|
27
|
+
}
|
|
28
|
+
return null; // more than the cap: unknown, not a clipped number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Fixed argv, bounded time and output. The total is known only when every named source was read. */
|
|
32
|
+
export async function readSecurityScan(repo: string, sources: readonly SecuritySource[], run: SdlcGh): Promise<SecurityScan | undefined> {
|
|
33
|
+
if (!REPO.test(repo) || repo.startsWith("-") || !sources.length || sources.some(source => !SECURITY_SOURCES.includes(source))) return undefined;
|
|
34
|
+
const deadline = performance.now() + 20_000, counts: SecurityScan["counts"] = {};
|
|
35
|
+
for (const source of sources) counts[source] = await openAlerts(repo, source, run, deadline);
|
|
36
|
+
const known = sources.every(source => counts[source] !== null && counts[source] !== undefined);
|
|
37
|
+
return { repo, sources: [...sources], counts, total: known ? sources.reduce((sum, source) => sum + counts[source]!, 0) : null };
|
|
38
|
+
}
|
package/src/hub/settings.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { OPERATIONAL_NUMBERS } from "./operational-settings.ts";
|
|
2
|
+
import { QUEUE_BACKPRESSURE_LIMITS } from "./queue-backpressure.ts";
|
|
1
3
|
import { operationalRosterSetting, operationalSetting } from "./operational-authority.ts";
|
|
2
4
|
import { ACTION_CLASSES } from "./action-class.ts";
|
|
3
5
|
import { definitionOf, type KindMap, type ProjectAdapterCatalog, ADAPTERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
|
|
@@ -26,7 +28,9 @@ export interface SettingDef {
|
|
|
26
28
|
key: string;
|
|
27
29
|
group: "Permissions" | "Start" | "Routing" | "Switches" | "Roster" | "Roles";
|
|
28
30
|
label: string;
|
|
29
|
-
type: "enum" | "boolean" | "peers" | "roles" | "integer" | "number";
|
|
31
|
+
type: "enum" | "boolean" | "peers" | "roles" | "integer" | "number" | "lines";
|
|
32
|
+
/** Written only by the named hub command, never by `settings set`, a dashboard session or a conductor (#514b). */
|
|
33
|
+
managed?: "learn";
|
|
30
34
|
min?: number; max?: number;
|
|
31
35
|
/** The closed values of an enum; a route setting takes the project's own route ids instead. */
|
|
32
36
|
values?: readonly string[];
|
|
@@ -57,7 +61,10 @@ export const SETTINGS: readonly SettingDef[] = [
|
|
|
57
61
|
...["interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"].map((key): SettingDef => ({ key: `task_sweep.${key}`, group: "Switches", label: `Task sweep: ${key}`, type: "number", min: 1, max: 2_147_483_647 / (key === "interval_s" ? 1000 : 60_000), store: "config", applies: "live", risk: "raises", path: ["task_sweep", key] })),
|
|
58
62
|
...SETTING_PEERS.map((peer): SettingDef => ({ key: `tasks.wip_limits.${peer}`, group: "Routing", label: `${peer}: planner WIP limit`, type: "integer", min: 0, max: 32, store: "config", applies: "live", risk: "raises", floor: 0, path: ["tasks", "wip_limits", peer] })),
|
|
59
63
|
{ key: "tasks.escalate_after", group: "Routing", label: "Escalate after N consecutive changes_requested", type: "integer", min: 1, max: 10, store: "config", applies: "live", risk: "raises", path: ["tasks", "escalate_after"] },
|
|
64
|
+
...OPERATIONAL_NUMBERS.map(({ defaultValue, unit, inventory, ...def }): SettingDef => def),
|
|
60
65
|
{ key: "tasks.auto_pull", group: "Switches", label: "Auto-pull the top reserved ready card when a planner slot frees", type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["tasks", "auto_pull"] },
|
|
66
|
+
{ key: "tasks.queue_backpressure.max_queued", group: "Routing", label: "Automatic offers: max queued envelopes (0 disables)", type: "integer", min: 0, max: QUEUE_BACKPRESSURE_LIMITS.max_queued, store: "config", applies: "live", risk: "raises", path: ["tasks", "queue_backpressure", "max_queued"] },
|
|
67
|
+
{ key: "tasks.queue_backpressure.max_oldest_s", group: "Routing", label: "Automatic offers: max oldest queued age in seconds (0 disables)", type: "integer", min: 0, max: QUEUE_BACKPRESSURE_LIMITS.max_oldest_s, store: "config", applies: "live", risk: "raises", path: ["tasks", "queue_backpressure", "max_oldest_s"] },
|
|
61
68
|
{ key: "planner", group: "Roles", label: "Planners (task-local selection)", type: "peers", values: PLANNER_PEERS, store: "config", applies: "live", risk: "raises", path: ["planner"] },
|
|
62
69
|
...SETTING_PEERS.map((peer): SettingDef => ({ key: `catalog_roles.${peer}`, group: "Roles", label: `${peer}: catalog guidance`, type: "roles", store: "config", applies: "next use", risk: "safe", path: ["catalog_roles", peer] })),
|
|
63
70
|
{ key: "scoped_grants.protect_instructions", group: "Permissions", label: "Ask before writing instruction files", type: "boolean", store: "config", applies: "live", risk: "raises", floor: true, path: ["protect_instruction_files"] },
|
|
@@ -80,7 +87,16 @@ export const SETTINGS: readonly SettingDef[] = [
|
|
|
80
87
|
* Never editable from settings, whatever the session: these choose what the hub executes or where secrets and task
|
|
81
88
|
* text go. A key equal to an entry, or below it, is refused by name.
|
|
82
89
|
*/
|
|
83
|
-
export const NEVER_EDITABLE = ["kimi_cmd", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "codex_bin", "pi.cmd", "checks", "mlx", "omniroute", "memory.worker_url", "local.read_allow", "local.bash_network", "local.network_allow", "bench.enabled", "terminal", "instances", "adapters", "adapter_modules"] as const;
|
|
90
|
+
export const NEVER_EDITABLE = ["kimi_cmd", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "codex_bin", "pi.cmd", "checks", "mlx", "omniroute", "memory.worker_url", "local.read_allow", "local.bash_network", "local.network_allow", "bench.enabled", "terminal", "schedules", "folder_triggers", "corpus", "rooms", "instances", "adapters", "adapter_modules", "local.doc_read", "local.web_fetch"] as const;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The one key `ahub learn accept` writes (#514b): the active profile's review checklist, which replaces as a unit. It is NOT a row of `SETTINGS` and `settingDef` does NOT resolve it, so
|
|
94
|
+
* `settings set|get|list`, the dashboard, a settings session and a conductor all see an unknown setting and nothing about them changed. Only the daemon's learn operation passes this
|
|
95
|
+
* definition to `settingsSet` itself (a function argument no request field can reach), so the write still goes through the same writer, loader check, reload and undo as every other setting.
|
|
96
|
+
*/
|
|
97
|
+
export const LEARN_CHECKLIST: SettingDef = Object.freeze({ key: "profile_overrides.review_checklist", group: "Routing", label: "Profile review checklist (written by ahub learn accept)", type: "lines", managed: "learn",
|
|
98
|
+
store: "config", applies: "restart", restartReason: "Session tool schemas and profile contracts are bound at startup", risk: "raises", path: Object.freeze(["profile_overrides", "review_checklist"]) });
|
|
99
|
+
const LEARN_LINES_MAX = 16, LEARN_LINE_MAX = 500;
|
|
84
100
|
|
|
85
101
|
/** Derive project-bound rows from the same builtin templates used by lookup, without registering aliases globally. */
|
|
86
102
|
export function projectSettings(instances?: KindMap, adapterCatalog?: ProjectAdapterCatalog): readonly SettingDef[] {
|
|
@@ -138,6 +154,11 @@ export function projectSettings(instances?: KindMap, adapterCatalog?: ProjectAda
|
|
|
138
154
|
return extra.length ? Object.freeze([...settings, ...extra]) : settings;
|
|
139
155
|
}
|
|
140
156
|
|
|
157
|
+
/** What undo may put back: a registry key, or the one managed key `ahub learn accept` writes. Never used to set anything. The CLI passes its project scope; the daemon has its own wrapper over its project-scoped lookup. */
|
|
158
|
+
export function undoableDef(key: unknown, instances?: KindMap, adapterCatalog?: ProjectAdapterCatalog): SettingDef | string {
|
|
159
|
+
return key === LEARN_CHECKLIST.key ? LEARN_CHECKLIST : settingDef(key, instances, adapterCatalog);
|
|
160
|
+
}
|
|
161
|
+
|
|
141
162
|
/** The definition of a key, or why there is none. */
|
|
142
163
|
export function settingDef(key: unknown, instances?: KindMap, adapterCatalog?: ProjectAdapterCatalog): SettingDef | string {
|
|
143
164
|
if (typeof key !== "string" || key.length > 80) return "unknown setting";
|
|
@@ -158,6 +179,10 @@ export function checkSettingValue(def: SettingDef, raw: unknown, routes: readonl
|
|
|
158
179
|
if (typeof raw !== "number" || !(def.type === "integer" ? Number.isSafeInteger(raw) : Number.isFinite(raw)) || raw < (def.min ?? 0) || raw > (def.max ?? Number.MAX_SAFE_INTEGER)) throw new Error(`${def.key} takes ${def.type === "integer" ? "an integer" : "a number"} from ${def.min ?? 0} to ${def.max ?? Number.MAX_SAFE_INTEGER}`);
|
|
159
180
|
return raw;
|
|
160
181
|
}
|
|
182
|
+
if (def.type === "lines") {
|
|
183
|
+
if (!Array.isArray(raw) || !raw.length || raw.length > LEARN_LINES_MAX || raw.some(line => typeof line !== "string" || !line.trim() || line.length > LEARN_LINE_MAX || /[\x00-\x1f\x7f]/.test(line))) throw new Error(`${def.key} takes one to ${LEARN_LINES_MAX} lines of plain text, each at most ${LEARN_LINE_MAX} characters`);
|
|
184
|
+
return raw.map(line => line.trim());
|
|
185
|
+
}
|
|
161
186
|
if (def.type === "boolean") {
|
|
162
187
|
if (typeof raw !== "boolean") throw new Error(`${def.key} takes true or false`);
|
|
163
188
|
return raw;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import example from "../../templates/packs/example.pack.json" with { type: "text" };
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The packs the hub ships (#513a), by file name without `.pack.json`. Imported as text so the bundle carries them, as `shipped-roles.ts` carries the role catalog.
|
|
5
|
+
* Only the format example is here: the writing, analysis and ops packs are #513b, which adds a file to `templates/packs/` and a line here.
|
|
6
|
+
*/
|
|
7
|
+
// TypeScript types a `.json` import as its parsed shape even with `type: "text"`; Bun hands over the file text, which `parsePack` parses and bounds itself.
|
|
8
|
+
export const SHIPPED_PACKS: Readonly<Record<string, string>> = { example: example as unknown as string };
|