speccle 0.12.0 → 0.16.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/README.md CHANGED
@@ -4,23 +4,37 @@ The deterministic tooling the skills invoke — one bin:
4
4
 
5
5
  ```
6
6
  speccle init # record repo facts + vendor the skills into .claude/skills/
7
- speccle doctor # report staleness across the CLI, skills, and strength stack
7
+ speccle doctor # report staleness across the CLI, skills, CI driver, and strength stack
8
8
  speccle update # refresh the vendored skills; print the CLI + stack fix commands
9
9
  speccle lint # enforce the convention over a repo's specs
10
10
  speccle claims # join criteria to the test names that claim them
11
+ speccle verify # run .speccle/checks/ over a change set: cross-file invariants
12
+ speccle risk # score a change set from spec-aware signals; gate on the threshold
13
+ speccle calibrate # record / report the calibration evidence a threshold moves on
14
+ speccle remedy # record / recall the known remedy for a class of finding
15
+ speccle review init # scaffold the opt-in CI review driver (a GitHub Actions workflow)
16
+ speccle review run # review a pull request with the lens panel and post one review
11
17
  speccle strength # oracle-strength heatmap: per-criterion killed ÷ covered
12
18
  speccle strength init # provision the strength stack into a target
13
19
  ```
14
20
 
15
21
  - `init` / `doctor` / `update` — the setup and staleness surface: `init` records the
16
22
  repo's test facts in `.speccle/config.json` and vendors the skills into
17
- `.claude/skills/`; `doctor` reports whether those skills and the strength stack still
18
- match this CLI; `update` refreshes the skills forward and prints the CLI + stack fix
19
- commands. See [Install](https://github.com/matthewalton/speccle/blob/main/README.md#install)
23
+ `.claude/skills/`; `doctor` reports whether those skills, the CI driver's pinned
24
+ version, and the strength stack still match this CLI; `update` refreshes the skills
25
+ forward and prints the CLI + stack fix commands. See
26
+ [Install](https://github.com/matthewalton/speccle/blob/main/README.md#install)
20
27
  and [Updating](https://github.com/matthewalton/speccle/blob/main/README.md#updating).
21
28
  - `lint` — enforce the [convention](https://github.com/matthewalton/speccle/blob/main/docs/convention.md) over a repo's specs.
22
29
  - `claims` — join every criterion to the test names carrying its id, statically. No
23
30
  reports needed, so it is cheap enough to gate on.
31
+ - `verify` / `risk` — the change-set surface. Both read the working tree's pending change
32
+ by default, or a committed range with `--base <ref>`, measured at the merge base — which
33
+ is what a CI run needs, where the working tree is clean. `risk` exits 1 at or above the
34
+ review threshold, so it works as a status check.
35
+ - `calibrate` / `remedy` — the meta loop's two records: the evidence a review threshold
36
+ moves on, and the known-correct remedy for a class of finding.
37
+ - `review init` / `review run` — the CI driver. See [review](#review) below.
24
38
  - `strength` — join specs + Stryker mutation report + coverage into per-criterion
25
39
  `killed ÷ covered`.
26
40
  - `strength init` — the setup `strength` measures against: install the stack's
@@ -30,8 +44,9 @@ The bin is named after the package; every command is an explicit subcommand (a b
30
44
  invocation is a usage error, exit code 2). `strength` names the measurement — oracle
31
45
  strength — not the heatmap rendering of it.
32
46
 
33
- Everything here is a **Speccle tool**: deterministic, independently runnable, emits
34
- typed JSON, never calls an LLM (see [CONTEXT.md](https://github.com/matthewalton/speccle/blob/main/CONTEXT.md)).
47
+ Every command here but one is a **Speccle tool**: deterministic, independently runnable,
48
+ emits typed JSON, never calls an LLM (see [CONTEXT.md](https://github.com/matthewalton/speccle/blob/main/CONTEXT.md)).
49
+ The exception is `review run`, the CI driver, which calls a model by definition.
35
50
 
36
51
  ## lint
37
52
 
@@ -75,11 +90,252 @@ test no string name, the criterion id takes its identifier-safe spelling:
75
90
  `func test_CHECKOUT_1_taxRounds()` claims `CHECKOUT-1`. Reports always render the
76
91
  bracketed form.
77
92
 
93
+ With no `--dialect`, the dialect comes from `.speccle/config.json`, resolved **per spec
94
+ folder** — so a mixed-language tree is joined in one pass, each slice under its own dialect
95
+ (the config's longest matching `overrides` path wins). `--dialect` forces one dialect across
96
+ every folder instead. The report names every dialect in play, and each feature the one its
97
+ folder joined under:
98
+
99
+ ```
100
+ ios/ladder/SPEC.md (swift)
101
+ LADDER-1 1 test name A rung raises the climber by one
102
+ web/basket/SPEC.md (ts-vitest)
103
+ BASKET-1 1 test name When an item is added, its line quantity increments by exactly 1
104
+
105
+ swift, ts-vitest — 2 spec files, 2 criteria, 2 claimed, clean
106
+ ```
107
+
78
108
  Names are read statically, so a name built dynamically shows up as unclaimed — the
79
109
  failure mode is a false alarm, never a silent pass. `--json` emits the typed
80
110
  `ClaimsReport` (see [`src/claims.ts`](src/claims.ts)). Exit codes: `0` every criterion
81
111
  claimed and no unknown claims, `1` otherwise, `2` usage error.
82
112
 
113
+ ## verify
114
+
115
+ ```sh
116
+ speccle verify [path] [--json] [--base <ref>]
117
+ ```
118
+
119
+ Runs every check in `.speccle/checks/` over a **change set**. Its reason to exist is the
120
+ class of invariant no linter can hold, because the fact is about the whole change rather
121
+ than any one file: _a changed `@Model` requires a round-trip test in the same change._
122
+
123
+ One check, one JSON file:
124
+
125
+ ```json
126
+ {
127
+ "when": { "path": "features/**/src/*.ts", "contains": "@Model" },
128
+ "require": { "path": "features/**/src/*.test.ts", "contains": "roundTrip" },
129
+ "message": "a changed @Model needs a round-trip test in the same change",
130
+ "because": "PR #412 shipped a schema change with no round-trip coverage"
131
+ }
132
+ ```
133
+
134
+ `when` is the trigger — absent means always enforced, and a check whose trigger never fires
135
+ reports as **inactive**, not as a pass. Then exactly one requirement: `require` breaches when
136
+ no changed file matches it, `forbid` breaches when any does, naming the offenders. Each is a
137
+ **predicate** — a glob over root-relative posix paths (`**` spans directories), optionally
138
+ narrowed by a `contains` regex the file's current content must match. `because` records the
139
+ finding that created the check and prints on breach, so a breach still explains itself a year
140
+ later.
141
+
142
+ ```
143
+ model-roundtrip a changed @Model needs a round-trip test in the same change
144
+ because PR #412 shipped a schema change with no round-trip coverage
145
+
146
+ 1 check, 1 breach
147
+ ```
148
+
149
+ The change set is the working tree's pending change by default, or the commits between
150
+ `--base <ref>` and HEAD, measured from their merge base — what a CI run needs, where the tree
151
+ is clean and the change is committed. A repo that has authored no checks passes trivially; a
152
+ malformed check file is a hard error naming the file, because a check that silently does
153
+ nothing is worse than no check at all.
154
+
155
+ `--json` emits the typed `VerifyReport` (see [`src/verify.ts`](src/verify.ts)). Exit codes:
156
+ `0` no breach, `1` at least one breach, `2` usage error.
157
+
158
+ ## risk
159
+
160
+ ```sh
161
+ speccle risk [path] [--json] [--base <ref>] [--dialect <name>]
162
+ ```
163
+
164
+ Scores a change set from spec-aware signals — facts only Speccle can see, because only
165
+ Speccle knows which code is governed by which spec. The score decides **how supervised the
166
+ change is**: at or above the review threshold a human is required, and review stops at
167
+ findings instead of fixing unasked.
168
+
169
+ Four baseline signals ship with the CLI and apply in every repo unmodified:
170
+
171
+ | signal | weight | fires on |
172
+ | -------------------- | -----: | --------------------------------------------------------------------- |
173
+ | `criterion-retired` | 4 | a criterion vanished from a changed `SPEC.md` |
174
+ | `spec-silent-change` | 3 | production source changed in a governed slice whose `SPEC.md` did not |
175
+ | `unclaimed-change` | 3 | changed code lives in a slice with a criterion no test claims |
176
+ | `criterion-reworded` | 2 | a criterion's statement changed |
177
+
178
+ ```
179
+ spec-silent-change +3 production source changed in a governed slice whose SPEC.md did not
180
+ features/checkout/src/checkout.ts
181
+ unclaimed-change +3 changed code lives in a slice with a criterion no test claims
182
+ CHECKOUT-2
183
+
184
+ score 6 ≥ threshold 3 — human required, review stops at findings
185
+ this score is a floor — a risk lens may escalate it, never lower it
186
+ ```
187
+
188
+ Every fired signal carries its evidence — the files, criterion ids, or slices it fired on —
189
+ so the number is auditable rather than asserted. The score is a **floor**: a risk lens reading
190
+ the change may escalate beyond it, never below it.
191
+
192
+ The default review threshold is `3`, deliberately low, so most changes are supervised. A repo
193
+ may reweight in `.speccle/risk.json` — the one sanctioned piece of configurable judgement here,
194
+ because what counts as consequential really is repo-specific:
195
+
196
+ ```json
197
+ {
198
+ "threshold": 4,
199
+ "weights": { "criterion-reworded": 0 },
200
+ "signals": [
201
+ {
202
+ "id": "migration-without-rollback",
203
+ "weight": 5,
204
+ "when": { "path": "db/migrations/*.sql" },
205
+ "message": "a migration changed",
206
+ "because": "the incident on 2026-03-02"
207
+ }
208
+ ]
209
+ }
210
+ ```
211
+
212
+ A `0` weight mutes a baseline signal, and a muted signal is not computed at all. Only a human
213
+ edits this file, and a malformed one is a hard error rather than a silently-ignored weight —
214
+ a quietly dropped signal is exactly the invisible reduction of supervision the score exists to
215
+ prevent.
216
+
217
+ Whether a changed file is production source or a test is a per-path question, and
218
+ `spec-silent-change` answers it from `.speccle/config.json`, resolved **at each changed file's
219
+ own path** — so in a mixed tree an `ios/` slice's `PlayerTests.swift` reads as a test even
220
+ where the repo defaults to `ts-vitest`. `--dialect` forces one dialect across every path instead.
221
+
222
+ `--base` moves both the change set and the criterion baseline to the merge base. That pairing
223
+ is load-bearing: on a branch, HEAD already contains the change, so diffing a changed `SPEC.md`
224
+ against HEAD would compare it with itself and no criterion would ever read as retired or
225
+ reworded.
226
+
227
+ `--json` emits the typed `RiskReport` (see [`src/risk.ts`](src/risk.ts)). Exit codes: `0`
228
+ below the review threshold (review may fix and report), `1` at or above it (a human is
229
+ required), `2` usage error. Exit `1` is the verdict, not a failure — it is what makes the
230
+ command usable as a status check.
231
+
232
+ ## calibrate
233
+
234
+ ```sh
235
+ speccle calibrate record [path] --needed-human <true|false> --found-real <true|false>
236
+ [--escalated] [--note <text>] [--dialect <name>] [--json]
237
+ speccle calibrate report [path] [--json]
238
+ ```
239
+
240
+ The evidence a review threshold moves on. `record` appends one line to
241
+ `.speccle/calibration.jsonl` per reviewed change: `risk` computes the deterministic floor —
242
+ score, threshold, the signals that fired — and the caller supplies the honest human verdict.
243
+
244
+ The two are recorded side by side and never conflated, which is the whole point. `--needed-human`
245
+ and `--found-real` are required and never defaulted, because recording the gate's verdict as if
246
+ it were the human's produces exactly the dishonest data a threshold must not rise on.
247
+
248
+ ```
249
+ recorded .speccle/calibration.jsonl — 1 entry
250
+ score 6 vs threshold 3 — floor required a human
251
+ verdict: needed a human, found something real
252
+ signals: spec-silent-change, unclaimed-change
253
+ ```
254
+
255
+ `report` reads the record back and answers three questions arithmetically: which signals fired
256
+ but never on a change that mattered, which fired on every change that genuinely needed a human,
257
+ and what threshold the record would support.
258
+
259
+ ```
260
+ 1 reviewed change — 1 needed a human, floor gated 1
261
+
262
+ spec-silent-change 1/1 mattered — on every change that needed a human
263
+ unclaimed-change 1/1 mattered — on every change that needed a human
264
+
265
+ proposals — evidence, not instructions (only a human reduces supervision):
266
+ spec-silent-change and unclaimed-change fired on every change that needed a human — reliable so far
267
+ the record would support a review threshold up to 6 (now 3) — only a human may raise it
268
+ ```
269
+
270
+ `supportedThreshold` is the cheapest change that needed a human — conservative on purpose,
271
+ since a floor any higher would have missed it. Escalation is excluded from that arithmetic: a
272
+ safety net must never be the reason a floor sits high. Two counters name the failure directions
273
+ in plain terms — `floorMisses` (needed a human, and neither the floor nor a lens caught it) and
274
+ `overSupervised` (the floor gated a change the verdict says was fine).
275
+
276
+ The report **proposes and never applies**. Nothing here edits `.speccle/risk.json`; only a
277
+ human reduces supervision.
278
+
279
+ `--json` emits the typed `RecordReport` / `CalibrationReport` (see
280
+ [`src/calibration.ts`](src/calibration.ts)). Both subcommands exit `0` whenever they produced a
281
+ report — the verdict is `risk`'s job — and `2` on a usage error, including a missing verdict
282
+ flag or a malformed record line, which is reported with its line number rather than skipped.
283
+
284
+ ## remedy
285
+
286
+ ```sh
287
+ speccle remedy record [path] --class <handle> --finding <text> --fix <text>
288
+ --route <check|criterion|lens|none> [--artefact <ref>]
289
+ [--note <text>] [--json]
290
+ speccle remedy recall [path] --class <handle> [--json]
291
+ ```
292
+
293
+ The memory that makes a repeat finding get the same answer twice. `record` appends one line to
294
+ `.speccle/remedies.jsonl`: what the finding was, the fix applied to the code this time, and the
295
+ **prevention route** chosen so the class stops recurring.
296
+
297
+ | route | prevention artefact |
298
+ | ----------- | ------------------------------ |
299
+ | `check` | a `.speccle/checks/` path |
300
+ | `criterion` | a `SPEC.md` criterion id |
301
+ | `lens` | a `.speccle/lenses/` path |
302
+ | `none` | names none — an honest one-off |
303
+
304
+ Every route but `none` must name its `--artefact`: a record is only worth consulting if it
305
+ points at the prevention it chose. `none` is the honest escape — a finding whose class is not
306
+ worth preventing prevents nothing, and must not pretend otherwise.
307
+
308
+ ```
309
+ recorded .speccle/remedies.jsonl — 1 remedy
310
+ missing-model-roundtrip-test: a changed @Model shipped with no round-trip test
311
+ fix: added a round-trip test over the changed model
312
+ remedy: check → .speccle/checks/model-roundtrip.json
313
+ ```
314
+
315
+ `recall` looks a class up before routing a finding fresh. Matching is deterministic on the
316
+ class handle's tokens — lowercased alphanumeric runs — and an entry matches when one side's
317
+ tokens are a subset of the other's, so `missing-roundtrip` still recalls
318
+ `missing-model-roundtrip-test`. Most-recent first: the latest answer to a class wins.
319
+
320
+ ```
321
+ 1 prior remedy for "missing-roundtrip" — reuse to fix consistently:
322
+
323
+ missing-model-roundtrip-test (2026-07-24T19:39:15.815Z)
324
+ finding: a changed @Model shipped with no round-trip test
325
+ fix: added a round-trip test over the changed model
326
+ remedy: check → .speccle/checks/model-roundtrip.json
327
+ ```
328
+
329
+ The `--class` handle is the reviewer's judgement, not the tool's — it only stores and retrieves,
330
+ the same honesty split `calibrate` draws around the human verdict.
331
+
332
+ `--json` emits the typed `RemedyRecordReport` / `RemedyRecallReport` (see
333
+ [`src/remedy.ts`](src/remedy.ts)). Both subcommands exit `0` whenever they produced a report,
334
+ **including a recall that matched nothing** — no prior remedy is an answer ("route it fresh,
335
+ then record it"), not a failure. `2` is a usage error: a missing required flag, an unknown
336
+ route, an artefact on a `none` remedy, or a malformed record line, reported with its line
337
+ number rather than skipped.
338
+
83
339
  ## strength
84
340
 
85
341
  ```sh
@@ -125,6 +381,55 @@ no spec declares are reported too.
125
381
  command exits `0` whenever it produced a report — judging a diff against a threshold is a
126
382
  separate concern.
127
383
 
384
+ ## review
385
+
386
+ The outer loop has two drivers. The **local driver** is the `review` skill: it fans the
387
+ lenses as subagents in a session and needs no key. The **CI driver** is these two commands,
388
+ and it is **opt-in**, because it needs a metered `ANTHROPIC_API_KEY`.
389
+
390
+ ```sh
391
+ speccle review init [path] [--json]
392
+ ```
393
+
394
+ Writes one file — `.github/workflows/speccle-review.yml` — pinned to the version of the CLI
395
+ that wrote it. Nothing else is vendored: the driver ships in this tarball and the workflow
396
+ fetches it from npm, so the code doing the reviewing never comes from the branch under
397
+ review. Re-running moves the pin, which is how a repo updates the driver.
398
+
399
+ `doctor` reports that pin as its `driver` row, so a workflow left behind on an old version
400
+ shows up as stale rather than sitting there unnoticed; a repo that never opted in reads
401
+ `not installed` and is not a failure. `update` moves the pin **only when the workflow is
402
+ already there** — it will not scaffold one, because opting a repo into a driver that spends
403
+ a metered key per run has to be deliberate.
404
+
405
+ ```sh
406
+ speccle review run --pr <number> [path] [--repo <owner/name>] [--base <ref>]
407
+ [--force] [--model <id>] [--dry-run] [--json]
408
+ ```
409
+
410
+ Fans every lens in `.speccle/lenses/` over the pull request's change set — one model call
411
+ per lens, findings forced into shape by a tool schema — and posts them as a single review
412
+ with inline comments. It **finds and comments only**: it never edits the tree, commits, or
413
+ pushes, because a fix has to re-run the checks-gate and be revertible, which is the local
414
+ driver's job.
415
+
416
+ - Skips `risk.md` (it escalates authority rather than reporting findings) and an unauthored
417
+ `house-conventions.md`, exactly as the local driver does.
418
+ - Posts one automatic review per pull request, recognised by a marker in its own body;
419
+ `--force` overrides that, which is what the workflow's `@review` rerun path passes.
420
+ - A finding that cannot anchor to a line in the diff is carried in the summary rather than
421
+ dropped, and if GitHub rejects the anchors outright the retry carries every finding in
422
+ the body.
423
+ - Reads `ANTHROPIC_API_KEY` and `GITHUB_TOKEN` from the environment; `--dry-run` reports
424
+ what would be posted and posts nothing. The default model is overridable with
425
+ `SPECCLE_REVIEW_MODEL`.
426
+
427
+ The risk verdict leads the summary, and the workflow runs `speccle risk --base` as its own
428
+ step so the **status check stays deterministic** — it survives a bad API day. Whether a
429
+ failing check blocks the merge is branch protection: GitHub's setting, the repo's call.
430
+
431
+ This is the one command here that calls a model.
432
+
128
433
  ## strength init
129
434
 
130
435
  ```sh
@@ -147,9 +452,16 @@ instead of a hand-assembled config recipe. In one run it:
147
452
  `features/**/*.ts`; override with `--mutate`, repeatable);
148
453
  - writes a `vitest.config.ts` with the istanbul provider and `json-summary` reporter.
149
454
 
150
- Init also warns (best-effort, via `~/.claude/settings.json`) when the target vendors
151
- the speccle skills project-level in `.claude/skills/` while a user-level speccle plugin
152
- is still enabled two copies of every skill would load.
455
+ It also reports a `speccle-oracle` devDependency as **superseded** the name this CLI
456
+ published under before 0.11.0. A repo provisioned back then keeps that package beside the
457
+ current one, exposing a stale binary in `node_modules/.bin/`. Init names the removal
458
+ command and stops there: your `package.json` is yours to change.
459
+
460
+ Both init commands warn (best-effort, via `~/.claude/settings.json`) when the target
461
+ vendors the speccle skills project-level in `.claude/skills/` while a user-level speccle
462
+ plugin is still enabled — two copies of every skill would load. `speccle init` is the run
463
+ that vendors them, so it warns too rather than leaving the discovery to a `strength init`
464
+ a contract-only user may never run.
153
465
 
154
466
  An existing Stryker or vitest/vite config is **kept, never overwritten** — init reports
155
467
  it and names the fields it must carry itself. The command is idempotent: re-running
@@ -0,0 +1,156 @@
1
+ import { appendFile, mkdir, readFile } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { isDirectory, messageOf } from "./changeset.js";
4
+ import { reviewThreshold, risk } from "./risk.js";
5
+ /** Where the meta loop keeps its calibration record (ADR-0042): append-only, one entry per line. */
6
+ export const CALIBRATION_FILE = ".speccle/calibration.jsonl";
7
+ /**
8
+ * Appends one calibration entry: `risk` computes the deterministic floor (score, signals,
9
+ * threshold, humanRequired) over the change set, the caller supplies the honest human verdict.
10
+ * The floor and the verdict are recorded side by side and never conflated.
11
+ */
12
+ export async function recordCalibration(target, input, options = {}) {
13
+ const root = resolve(target);
14
+ if (!(await isDirectory(root)))
15
+ throw new Error(`path not found: ${target}`);
16
+ const { now, ...riskOptions } = options;
17
+ const assessment = await risk(root, riskOptions);
18
+ const stamp = (now ?? (() => new Date().toISOString()))();
19
+ const entry = {
20
+ at: stamp,
21
+ score: assessment.score,
22
+ threshold: assessment.threshold,
23
+ humanRequired: assessment.humanRequired,
24
+ escalated: input.escalated ?? false,
25
+ signals: assessment.signals.map((signal) => signal.id),
26
+ verdict: { neededHuman: input.neededHuman, foundReal: input.foundReal },
27
+ ...(input.note !== undefined && input.note !== "" && { note: input.note }),
28
+ };
29
+ const file = join(root, CALIBRATION_FILE);
30
+ await mkdir(dirname(file), { recursive: true });
31
+ await appendFile(file, JSON.stringify(entry) + "\n");
32
+ return { root, file: CALIBRATION_FILE, entry, count: (await readEntries(root)).length };
33
+ }
34
+ /**
35
+ * Reads the calibration record and answers ADR-0042's three questions deterministically: which
36
+ * signals never corresponded to a real problem, which fired on every change a human was needed
37
+ * for, and the threshold the record would support. It proposes; it never applies.
38
+ */
39
+ export async function calibrationReport(target) {
40
+ const root = resolve(target);
41
+ if (!(await isDirectory(root)))
42
+ throw new Error(`path not found: ${target}`);
43
+ const entries = await readEntries(root);
44
+ const currentThreshold = await reviewThreshold(root);
45
+ const mattered = (entry) => entry.verdict.neededHuman || entry.verdict.foundReal;
46
+ const neededEntries = entries.filter((entry) => entry.verdict.neededHuman);
47
+ const ids = [...new Set(entries.flatMap((entry) => entry.signals))].sort();
48
+ const signals = ids.map((id) => {
49
+ const fired = entries.filter((entry) => entry.signals.includes(id));
50
+ const firedOnMattered = fired.filter(mattered).length;
51
+ return {
52
+ id,
53
+ fired: fired.length,
54
+ firedOnMattered,
55
+ neverUseful: fired.length > 0 && firedOnMattered === 0,
56
+ firedOnEveryNeeded: neededEntries.length > 0 && neededEntries.every((entry) => entry.signals.includes(id)),
57
+ };
58
+ });
59
+ // Conservative on purpose (ADR-0041 favours more supervision): the floor could rise no higher
60
+ // than the cheapest change that genuinely needed a human, or the floor alone would have missed
61
+ // it. Escalation is ignored here — a safety net must not be the reason a floor sits high.
62
+ const supportedThreshold = neededEntries.length === 0 ? null : Math.min(...neededEntries.map((entry) => entry.score));
63
+ const floorMisses = entries.filter((entry) => entry.verdict.neededHuman && !entry.humanRequired && !entry.escalated).length;
64
+ const overSupervised = entries.filter((entry) => entry.humanRequired && !entry.verdict.neededHuman).length;
65
+ return {
66
+ root,
67
+ file: CALIBRATION_FILE,
68
+ count: entries.length,
69
+ neededByHuman: neededEntries.length,
70
+ gatedByFloor: entries.filter((entry) => entry.humanRequired).length,
71
+ floorMisses,
72
+ overSupervised,
73
+ currentThreshold,
74
+ supportedThreshold,
75
+ signals,
76
+ proposals: proposalsFrom(entries.length, signals, currentThreshold, supportedThreshold),
77
+ };
78
+ }
79
+ function proposalsFrom(count, signals, currentThreshold, supportedThreshold) {
80
+ if (count === 0)
81
+ return ["no calibration entries yet — review some changes to build the record"];
82
+ const out = [];
83
+ const never = signals.filter((signal) => signal.neverUseful).map((signal) => signal.id);
84
+ if (never.length > 0) {
85
+ out.push(`${and(never)} fired but never on a change that mattered — weigh their weight, not a licence to delete`);
86
+ }
87
+ const reliable = signals.filter((signal) => signal.firedOnEveryNeeded).map((signal) => signal.id);
88
+ if (reliable.length > 0) {
89
+ out.push(`${and(reliable)} fired on every change that needed a human — reliable so far`);
90
+ }
91
+ if (supportedThreshold === null) {
92
+ out.push("no change has needed a human yet — the record cannot support a threshold move");
93
+ }
94
+ else if (supportedThreshold > currentThreshold) {
95
+ out.push(`the record would support a review threshold up to ${supportedThreshold} (now ${currentThreshold}) — only a human may raise it`);
96
+ }
97
+ else if (supportedThreshold < currentThreshold) {
98
+ out.push(`a change that needed a human scored ${supportedThreshold}, below the threshold of ${currentThreshold} — the floor would miss it; only a human may lower the threshold`);
99
+ }
100
+ else {
101
+ out.push(`the threshold of ${currentThreshold} matches the record — no move indicated`);
102
+ }
103
+ return out;
104
+ }
105
+ async function readEntries(root) {
106
+ let raw;
107
+ try {
108
+ raw = await readFile(join(root, CALIBRATION_FILE), "utf8");
109
+ }
110
+ catch {
111
+ return []; // no record yet: nothing has been calibrated.
112
+ }
113
+ const entries = [];
114
+ const lines = raw.split("\n");
115
+ for (let i = 0; i < lines.length; i++) {
116
+ const line = lines[i].trim();
117
+ if (line === "")
118
+ continue;
119
+ let entry;
120
+ try {
121
+ entry = JSON.parse(line);
122
+ }
123
+ catch (err) {
124
+ throw new Error(`${CALIBRATION_FILE}:${i + 1} is not valid JSON: ${messageOf(err)}`);
125
+ }
126
+ assertEntry(entry, i + 1);
127
+ entries.push(entry);
128
+ }
129
+ return entries;
130
+ }
131
+ // A malformed entry must fail loudly, naming the line — a silently-dropped entry is dishonest
132
+ // calibration data, the one thing ADR-0042 will not tolerate in the record.
133
+ function assertEntry(entry, line) {
134
+ const at = `${CALIBRATION_FILE}:${line}`;
135
+ const isNumber = (value) => typeof value === "number" && Number.isFinite(value);
136
+ if (!isNumber(entry.score) || !isNumber(entry.threshold)) {
137
+ throw new Error(`${at}: "score" and "threshold" must be numbers`);
138
+ }
139
+ if (typeof entry.humanRequired !== "boolean" || typeof entry.escalated !== "boolean") {
140
+ throw new Error(`${at}: "humanRequired" and "escalated" must be booleans`);
141
+ }
142
+ if (!Array.isArray(entry.signals) || entry.signals.some((id) => typeof id !== "string")) {
143
+ throw new Error(`${at}: "signals" must be an array of signal ids`);
144
+ }
145
+ const verdict = entry.verdict;
146
+ const fields = verdict;
147
+ if (typeof verdict !== "object" ||
148
+ verdict === null ||
149
+ typeof fields.neededHuman !== "boolean" ||
150
+ typeof fields.foundReal !== "boolean") {
151
+ throw new Error(`${at}: "verdict" needs boolean "neededHuman" and "foundReal"`);
152
+ }
153
+ }
154
+ function and(ids) {
155
+ return ids.length <= 1 ? (ids[0] ?? "") : `${ids.slice(0, -1).join(", ")} and ${ids.at(-1)}`;
156
+ }
@@ -0,0 +1,136 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { gitStdout } from "./git.js";
4
+ /** The changed files a predicate matches — by path glob, then by content when `contains` is set. */
5
+ export async function matching(predicate, changed, read) {
6
+ const pattern = globToRegExp(predicate.path);
7
+ const contains = predicate.contains === undefined ? undefined : new RegExp(predicate.contains);
8
+ const matches = [];
9
+ for (const file of changed) {
10
+ if (!pattern.test(file))
11
+ continue;
12
+ if (contains !== undefined) {
13
+ const source = await read(file);
14
+ if (source === undefined || !contains.test(source))
15
+ continue;
16
+ }
17
+ matches.push(file);
18
+ }
19
+ return matches;
20
+ }
21
+ export async function anyMatches(predicate, changed, read) {
22
+ return (await matching(predicate, changed, read)).length > 0;
23
+ }
24
+ /**
25
+ * A reader for the change set's current content, caching each file so a file read by several
26
+ * predicates is only touched once. Missing files read as undefined rather than throwing.
27
+ */
28
+ export function contentReader(root) {
29
+ const cache = new Map();
30
+ return async (file) => {
31
+ if (!cache.has(file))
32
+ cache.set(file, await readMaybe(join(root, file)));
33
+ return cache.get(file);
34
+ };
35
+ }
36
+ /** The pending change set: everything the working tree differs from its last commit by. */
37
+ export function gitChangeSet(root) {
38
+ // --untracked-files=all lists new files individually; the default collapses a wholly
39
+ // untracked directory to its name, hiding the files that must be seen.
40
+ const args = ["status", "--porcelain", "--untracked-files=all"];
41
+ const stdout = gitStdout(root, args);
42
+ if (stdout === undefined) {
43
+ throw new Error("could not read a change set from git — run this inside a git repository");
44
+ }
45
+ const changed = new Set();
46
+ for (const line of stdout.split("\n")) {
47
+ if (line === "")
48
+ continue;
49
+ // Porcelain: two status chars, a space, then the path — or "old -> new" for a rename.
50
+ const path = line.slice(3);
51
+ changed.add(path.includes(" -> ") ? path.slice(path.indexOf(" -> ") + 4) : path);
52
+ }
53
+ return [...changed];
54
+ }
55
+ /**
56
+ * The committed change set between a base ref and HEAD — the change set a CI driver reviews,
57
+ * where the working tree is clean and the change lives in commits instead. Measured from the
58
+ * **merge base**, not the base's tip, so commits that landed on the base after this branch
59
+ * left it are not attributed to this change; that is the set a pull request shows.
60
+ */
61
+ export function gitRangeChangeSet(root, base) {
62
+ const mergeBase = gitStdout(root, ["merge-base", base, "HEAD"])?.trim();
63
+ if (mergeBase === undefined || mergeBase === "") {
64
+ // The likeliest cause in CI by far: a shallow checkout that fetched no shared history.
65
+ throw new Error(`no merge base between "${base}" and HEAD — fetch enough history for the two to share a commit`);
66
+ }
67
+ // A rename reports its destination path only, matching the working tree's "old -> new".
68
+ const stdout = gitStdout(root, ["diff", "--name-only", mergeBase, "HEAD"]);
69
+ if (stdout === undefined)
70
+ throw new Error(`could not diff "${base}" against HEAD`);
71
+ const changed = stdout.split("\n").filter((line) => line !== "");
72
+ return { changed: [...new Set(changed)], baseline: mergeBase };
73
+ }
74
+ /** Validates a predicate's shape, naming the offending file — a silently broken predicate is worse than none. */
75
+ export function assertPredicate(predicate, key, file) {
76
+ if (predicate === undefined)
77
+ return;
78
+ if (typeof predicate.path !== "string" || predicate.path === "") {
79
+ throw new Error(`${file}: "${key}" needs a non-empty "path" glob`);
80
+ }
81
+ if (predicate.contains !== undefined) {
82
+ try {
83
+ new RegExp(predicate.contains);
84
+ }
85
+ catch (err) {
86
+ throw new Error(`${file}: "${key}.contains" is not a valid regex: ${messageOf(err)}`);
87
+ }
88
+ }
89
+ }
90
+ /**
91
+ * A minimatch-style glob over posix paths: `**` spans directory boundaries, a single `*`
92
+ * and `?` stay within one segment.
93
+ */
94
+ function globToRegExp(glob) {
95
+ let re = "";
96
+ for (let i = 0; i < glob.length; i++) {
97
+ const char = glob[i];
98
+ if (char === "*") {
99
+ if (glob[i + 1] === "*") {
100
+ i++;
101
+ if (glob[i + 1] === "/") {
102
+ i++;
103
+ re += "(?:.*/)?"; // `**/` also matches zero directories
104
+ }
105
+ else
106
+ re += ".*";
107
+ }
108
+ else
109
+ re += "[^/]*";
110
+ }
111
+ else if (char === "?")
112
+ re += "[^/]";
113
+ else
114
+ re += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
115
+ }
116
+ return new RegExp(`^${re}$`);
117
+ }
118
+ async function readMaybe(path) {
119
+ try {
120
+ return await readFile(path, "utf8");
121
+ }
122
+ catch {
123
+ return undefined;
124
+ }
125
+ }
126
+ export async function isDirectory(path) {
127
+ try {
128
+ return (await stat(path)).isDirectory();
129
+ }
130
+ catch {
131
+ return false;
132
+ }
133
+ }
134
+ export function messageOf(err) {
135
+ return err instanceof Error ? err.message : String(err);
136
+ }