@tekyzinc/gsd-t 5.11.30 → 5.11.32

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 CHANGED
@@ -2,6 +2,127 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [5.11.32] - 2026-08-14
6
+
7
+ ### Fixed — a missing graph is now BUILT, and a "wired" claim has to prove itself
8
+
9
+ A TimeTracking scan finished, reported success, and had done its whole deep
10
+ analysis with grep. The graph was healthy, current, and the run even logged
11
+ `graphWiringMode: WIRED` — but of 666 ledger events, every single one carried
12
+ `consumer: "cli"`. Not one came from an analyst agent. The reports say it
13
+ plainly in their own words: *"a repo-wide grep shows no caller anywhere."*
14
+ Nothing failed, because nothing was watching for this.
15
+
16
+ Three defects, one theme — **a check that cannot fail is not a check**:
17
+
18
+ - **An absent graph left consumers blind instead of building it.** Every
19
+ FAIL-LOUD clause said: don't fall back to grep, then *proceed without the
20
+ structural slice*. That is a third bad outcome sitting between the two the
21
+ doctrine names — the agent answers "what calls this?" with no structural
22
+ knowledge at all. The clauses in `/quick`, `/debug`, `/design-build` and
23
+ `/test-sync` now BUILD the index and re-run the query; only a failed build
24
+ halts. This was already the contract's own rule (§FAIL-LOUD line 64,
25
+ "REPAIR IT, do not merely halt") — the command docs had been contradicting
26
+ it. `[RULE] graph-absent-builds-not-degrades`
27
+ - **`/integrate` had a licensed exemption.** It printed `⚠ graph ABSENT —
28
+ structural wiring-check skipped (announced carve-out)`. A fresh worktree
29
+ carries no graph (it is gitignored), so the check did nothing on exactly the
30
+ runs it was written for. Announcing a skip does not stop it being a skip. It
31
+ now builds. The verify/integrate exception covers the HARD-FAIL only; it
32
+ never covered the build.
33
+ - **The anti-grep lint aimed at the wrong target.** Being static, its only
34
+ failure mode is a code shape (`try graph → catch → grep`). A consumer that
35
+ never calls the graph has no catch block, so the lint reports clean while the
36
+ entire run uses text search.
37
+
38
+ ### Added — `gsd-t graph-use-gate` (runtime, `[RULE] wired-claim-requires-query-evidence`)
39
+
40
+ `bin/gsd-t-graph-use-gate.cjs` reads the append-only ledger and fails any
41
+ consumer that logged `WIRED` with zero `kind:"query"` events. It detects the
42
+ **absence of use**, not the presence of a fallback — the thing a static lint
43
+ structurally cannot see. `kind:"read"` (the Read-intercept hook) is never
44
+ accepted as evidence, or it would mask the very failure being detected.
45
+
46
+ Run against the two projects that prompted this, it flags both — confirming the
47
+ defect is systemic, not a one-off. Wired into `gsd-t-verify-gate.cjs` as the
48
+ FAIL-CLOSED `graph-use` check, propagated via `PROJECT_BIN_TOOLS` (it reads each
49
+ project's own ledger), and covered by `test/m113-graph-use-gate.test.js` — whose
50
+ leading test is the negative one, because a gate nobody has watched fail is a
51
+ gate nobody knows works. Exit `0` clean · `4` violations · `64` bad input; a
52
+ missing ledger is a *documented* no-op PASS under `--verify-mode`, tagged
53
+ `noOpPass:true` so it stays distinguishable from wired-and-clean.
54
+
55
+ **Still open:** none of this covers ordinary conversational code-reading, which
56
+ is where the original complaint arose — an agent asked "what calls this?" in a
57
+ worktree with no graph and grepped, with nothing objecting. That needs a
58
+ PreToolUse hook on structural Grep/Read, and is deliberately left for its own
59
+ milestone rather than bolted on here.
60
+
61
+ ## [5.11.31] - 2026-08-11
62
+
63
+ ### Added — the scanner now ranks its own findings before it numbers them
64
+
65
+ A 492-finding scan of hilo-figma-atos was grouped by severity and ordered by
66
+ discovery inside each group, so "fix the criticals in order" gave you a dead
67
+ marketing page before the missing database access rules on 193 tenant tables.
68
+ Run by hand over that register, an architect pass changed the answer enough to
69
+ become a permanent stage:
70
+
71
+ - The worst defect in the codebase was filed HIGH. Account credits that cover a
72
+ whole invoice are never marked used, so the same credit is given away again
73
+ every month, forever. It sat at position 127.
74
+ - A typo in one text box silently routes every real card payment to the practice
75
+ gateway, where charges report success and no money moves. Filed MEDIUM.
76
+ - Every school's signed legal agreements are downloadable by any other school.
77
+ Filed LOW.
78
+ - A fabricated thunderstorm advisory for a named airport is shown to pilots on
79
+ every page. Filed MEDIUM.
80
+ - 22 findings were confirmed unreachable code, carried as risk.
81
+
82
+ And the work changed shape: 492 findings collapsed into 28 root causes, with 144
83
+ of the 328 medium/low findings attaching to a root that already existed. The
84
+ codebase does not have 492 problems; it has about 28, most repeated dozens of
85
+ times. Scheduled individually, that produces dozens of half-fixes of one defect.
86
+
87
+ **Four changes, all measured on that run:**
88
+
89
+ **Architect stage** (`phase("Architect")`, after Synthesis, before ordering) —
90
+ re-tiers by consequence, groups by root cause, ranks roots within tier and
91
+ members within root, and marks confirmed dead code so it sinks below live
92
+ findings. TD numbers are assigned AFTER it, so TD-1 is the most urgent thing in
93
+ the codebase. Adds an EXTREME tier: leaves wrong data behind, crosses a tenant
94
+ boundary, moves money wrongly, or touches safety — as against CRITICAL, where
95
+ the feature merely does not work. Carries the dynamic-import warning that nearly
96
+ cost a live credit-card form its rating, and halts if the ordering loses a
97
+ finding.
98
+
99
+ **Verification is batched, 10 findings per agent.** Measured head to head on the
100
+ same 20 findings spanning all four severities: batched caught 1 false positive
101
+ and 2 severity corrections against the individual arm's 0 and 1, for 71 fewer
102
+ characters of evidence and no changed verdict. Severity is comparative — an agent
103
+ seeing findings together can rank them, one seeing a single finding confirms
104
+ whatever it was handed. 492 findings went from ~570 verifier agents to ~50.
105
+
106
+ **Design-export snapshots are left out of slicing**, and named in the plan.
107
+ `.figma-make-exports/` held 1,532 source files and 968,597 lines on that project
108
+ — six near-identical copies of a design prototype, read by finders as if they
109
+ were the product, for two findings that were both about the folder itself. That
110
+ is roughly 19 slices of finder-and-verifier work.
111
+
112
+ **One severity phrase per tier.** The plain-English companion labelled all 61
113
+ criticals "fix before launch" for a system already serving customers, and had
114
+ drifted to twelve phrasings for four tiers. An unmapped tier is now announced
115
+ rather than silently labelled "review".
116
+
117
+ - `templates/workflows/gsd-t-scan.workflow.js`: architect stage + schema, batched
118
+ verify, tier-derived labels, EXTREME throughout.
119
+ - `bin/gsd-t-slice-budget.cjs`: snapshot-directory exclusion, reported by name.
120
+ - `test/m112-architect-stage.test.js`, `test/m112-severity-labels.test.js`: 15 new
121
+ regressions; slice-budget and schema-tolerance suites extended.
122
+
123
+ Two existing tests were pinned to variable names rather than behaviour and failed
124
+ on a change that preserved what they protect; both now assert the property.
125
+
5
126
  ## [5.11.30] - 2026-08-11
6
127
 
7
128
  ### Fixed — the scan crashed before any finder ran: `slices is not defined`
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v5.11.30** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v5.11.32** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
4
4
 
5
5
  **Eliminates context rot** — task-level fresh dispatch (one subagent per task, ~10-20% context each) means compaction never triggers.
6
6
  **Compaction-proof debug loops** — `gsd-t headless --debug-loop` runs test-fix-retest cycles as separate `claude -p` sessions. A JSONL debug ledger persists all hypothesis/fix/learning history across fresh sessions. Anti-repetition preamble injection prevents retrying failed hypotheses. Escalation tiers (sonnet → opus → human) and a hard iteration ceiling enforced externally.
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gsd-t-graph-use-gate.cjs
4
+ *
5
+ * Graph USE gate — proves a consumer that declared itself WIRED actually
6
+ * QUERIED the graph.
7
+ *
8
+ * [RULE] wired-claim-requires-query-evidence
9
+ * [RULE] use-gate-detects-absence-of-use-not-presence-of-fallback
10
+ *
11
+ * WHY THIS EXISTS (the gap it closes):
12
+ * `gsd-t-graph-anti-grep-lint.cjs` is STATIC. It reads source files and looks
13
+ * for a `try graph-query -> catch -> structural grep` fallback. That shape is
14
+ * the only way it can fail.
15
+ *
16
+ * A real scan failed a different way. Every analyst agent simply NEVER CALLED
17
+ * the graph — so there was no catch block, no fallback branch, nothing for a
18
+ * static scan to find. The lint passed, the run logged
19
+ * `{"kind":"wiring","graphWiringMode":"WIRED"}`, and the whole scan ran on
20
+ * text search anyway. A check whose only failure mode is a code shape cannot
21
+ * see a consumer that never tried.
22
+ *
23
+ * This gate reads the RUNTIME ledger instead. A consumer that stamped WIRED
24
+ * and issued zero graph queries in that run is a violation, because "wired"
25
+ * is a claim about behaviour and the ledger is the only record of behaviour.
26
+ *
27
+ * INPUT : .gsd-t/graphDB/logs/graph-events-*.jsonl (append-only, JSONL)
28
+ * OUTPUT : {ok, violations:[{consumer, wiringMode, queryCount, firstWiringTs}], ...}
29
+ * EXIT : 0 clean | 4 violations | 64 bad input (no ledger / unreadable)
30
+ *
31
+ * Unreadable input THROWS (LedgerUnavailable) rather than returning a value —
32
+ * a gate that hands back a result when it could not read its own evidence is
33
+ * the exact failure mode this file exists to remove.
34
+ *
35
+ * Node built-ins only (zero-dep invariant).
36
+ */
37
+
38
+ 'use strict';
39
+
40
+ const fs = require('fs');
41
+ const path = require('path');
42
+
43
+ // A `kind:"read"` event is the Read-intercept hook, NOT a structural query — it
44
+ // fires on ordinary file reads and would mask the exact failure this gate exists
45
+ // to catch. Only `kind:"query"` counts as evidence that the graph was consulted.
46
+ const EVIDENCE_KIND = 'query';
47
+ const WIRING_KIND = 'wiring';
48
+
49
+ // `cli` is the operator at a terminal, not a workflow consumer; it has no WIRED
50
+ // claim to honour. Consumers are exempt ONLY by explicit name here.
51
+ const NON_CONSUMER_IDS = new Set(['cli']);
52
+
53
+ /** Thrown when the ledger cannot be located or read. Never swallowed internally. */
54
+ class LedgerUnavailable extends Error {
55
+ constructor(message) {
56
+ super(message);
57
+ this.name = 'LedgerUnavailable';
58
+ }
59
+ }
60
+
61
+ function ledgerDir(projectDir) {
62
+ return path.join(projectDir, '.gsd-t', 'graphDB', 'logs');
63
+ }
64
+
65
+ /** Resolve every graph-events-*.jsonl file, oldest first. Throws if none. */
66
+ function ledgerFiles(projectDir) {
67
+ const dir = ledgerDir(projectDir);
68
+ let names;
69
+ try {
70
+ names = fs.readdirSync(dir);
71
+ } catch (err) {
72
+ throw new LedgerUnavailable(`graph event ledger not readable: ${dir} (${err.code || err.message})`);
73
+ }
74
+ const files = names
75
+ .filter((n) => /^graph-events-\d+\.jsonl$/.test(n))
76
+ .sort()
77
+ .map((n) => path.join(dir, n));
78
+ if (files.length === 0) {
79
+ throw new LedgerUnavailable(`no graph-events-*.jsonl in ${dir}`);
80
+ }
81
+ return files;
82
+ }
83
+
84
+ /**
85
+ * Parse the ledger into per-consumer counters.
86
+ * A malformed line is SKIPPED and COUNTED — it is never treated as a query,
87
+ * because counting an unparseable line as evidence would let a corrupt ledger
88
+ * satisfy the gate.
89
+ */
90
+ function readLedger(files, sinceTs) {
91
+ const consumers = Object.create(null); // prototype-less: consumer ids come from a file
92
+ let malformed = 0;
93
+ let total = 0;
94
+
95
+ const ensure = (id) => {
96
+ if (!consumers[id]) {
97
+ consumers[id] = { consumer: id, queryCount: 0, wiringModes: [], firstWiringTs: null };
98
+ }
99
+ return consumers[id];
100
+ };
101
+
102
+ for (const file of files) {
103
+ let text;
104
+ try {
105
+ text = fs.readFileSync(file, 'utf8');
106
+ } catch (err) {
107
+ throw new LedgerUnavailable(`cannot read ${file}: ${err.code || err.message}`);
108
+ }
109
+ for (const line of text.split('\n')) {
110
+ if (!line.trim()) continue;
111
+ total++;
112
+ let ev;
113
+ try {
114
+ ev = JSON.parse(line);
115
+ } catch (err) {
116
+ malformed++;
117
+ continue;
118
+ }
119
+ if (sinceTs && typeof ev.ts === 'string' && ev.ts < sinceTs) continue;
120
+
121
+ const id = typeof ev.consumer === 'string' && ev.consumer ? ev.consumer : 'unknown';
122
+ const kind = String(ev.kind || '');
123
+
124
+ if (kind === EVIDENCE_KIND) {
125
+ ensure(id).queryCount++;
126
+ } else if (kind === WIRING_KIND) {
127
+ const c = ensure(id);
128
+ c.wiringModes.push(String(ev.graphWiringMode || ''));
129
+ if (!c.firstWiringTs && typeof ev.ts === 'string') c.firstWiringTs = ev.ts;
130
+ }
131
+ }
132
+ }
133
+ return { consumers, malformed, total };
134
+ }
135
+
136
+ /**
137
+ * A consumer violates the gate when it CLAIMED wired and issued ZERO queries.
138
+ * Claiming `fallback-announced` or `disabled` is not a violation here — those
139
+ * are honest declarations, and the anti-grep lint governs whether the fallback
140
+ * itself is permitted.
141
+ */
142
+ function evaluate(consumers) {
143
+ const violations = [];
144
+ const checked = [];
145
+
146
+ for (const id of Object.keys(consumers)) {
147
+ if (NON_CONSUMER_IDS.has(id)) continue;
148
+ const c = consumers[id];
149
+ if (c.wiringModes.length === 0) continue; // never declared; nothing claimed, nothing to prove
150
+
151
+ const claimedWired = c.wiringModes.some((m) => m.toLowerCase() === 'wired');
152
+ checked.push({ consumer: id, wiringModes: c.wiringModes, queryCount: c.queryCount });
153
+
154
+ if (claimedWired && c.queryCount === 0) {
155
+ violations.push({
156
+ consumer: id,
157
+ wiringMode: 'WIRED',
158
+ queryCount: 0,
159
+ firstWiringTs: c.firstWiringTs,
160
+ evidence:
161
+ `consumer "${id}" logged graphWiringMode=WIRED but issued 0 graph queries ` +
162
+ `(kind:"query") in this window — a WIRED claim with no query evidence means ` +
163
+ `the structural question was answered some other way.`,
164
+ });
165
+ }
166
+ }
167
+ return { violations, checked };
168
+ }
169
+
170
+ /** Throws LedgerUnavailable when the evidence cannot be read. */
171
+ function runGate(opts = {}) {
172
+ const projectDir = opts.projectDir || process.cwd();
173
+
174
+ // Test override: callers may inject parsed consumers directly.
175
+ if (opts.consumers) {
176
+ const { violations, checked } = evaluate(opts.consumers);
177
+ return { ok: violations.length === 0, violations, checked, malformed: 0, ledgerFiles: [] };
178
+ }
179
+
180
+ const files = ledgerFiles(projectDir);
181
+ const { consumers, malformed } = readLedger(files, opts.since || null);
182
+ const { violations, checked } = evaluate(consumers);
183
+ return { ok: violations.length === 0, violations, checked, malformed, ledgerFiles: files };
184
+ }
185
+
186
+ // ─── CLI entry point ─────────────────────────────────────────────────────────
187
+
188
+ if (require.main === module) {
189
+ const argv = process.argv.slice(2);
190
+ const opts = { projectDir: process.cwd() };
191
+ for (let i = 0; i < argv.length; i++) {
192
+ if (argv[i] === '--project' || argv[i] === '--repo') opts.projectDir = argv[++i];
193
+ else if (argv[i] === '--since') opts.since = argv[++i];
194
+ else if (argv[i] === '--verify-mode') opts.verifyMode = true;
195
+ else if (!argv[i].startsWith('-')) opts.projectDir = argv[i];
196
+ }
197
+
198
+ let result;
199
+ try {
200
+ result = runGate(opts);
201
+ } catch (err) {
202
+ if (err instanceof LedgerUnavailable) {
203
+ // No ledger means NO CONSUMER HAS RUN YET — nothing has been claimed, so
204
+ // there is nothing to disprove. That is a documented no-op PASS in
205
+ // --verify-mode, distinguishable in the JSON (`noOpPass:true`) from a
206
+ // wired-but-broken run. Invoked directly it is BAD INPUT (exit 64): an
207
+ // operator asking about evidence that does not exist deserves to be told,
208
+ // not handed a clean bill.
209
+ if (opts.verifyMode) {
210
+ process.stdout.write(JSON.stringify(
211
+ { ok: true, noOpPass: true, reason: 'no-graph-event-ledger', violations: [], checked: [] }, null, 2) + '\n');
212
+ process.stderr.write('[graph-use-gate] PASS (no-op): no graph event ledger — no consumer has claimed WIRED yet.\n');
213
+ process.exit(0);
214
+ }
215
+ process.stderr.write(`[graph-use-gate] ERROR: ${err.message}\n`);
216
+ process.stdout.write(JSON.stringify({ ok: false, violations: [], error: err.message }, null, 2) + '\n');
217
+ process.exit(64);
218
+ }
219
+ throw err;
220
+ }
221
+
222
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
223
+
224
+ if (!result.ok) {
225
+ process.stderr.write(
226
+ `[graph-use-gate] FAIL: ${result.violations.length} consumer(s) claimed WIRED with zero graph queries.\n`
227
+ );
228
+ for (const v of result.violations) {
229
+ process.stderr.write(` ${v.consumer}: ${v.evidence}\n`);
230
+ }
231
+ process.exit(4);
232
+ }
233
+ process.stderr.write(
234
+ `[graph-use-gate] PASS: ${result.checked.length} declaring consumer(s), all WIRED claims backed by queries.\n`
235
+ );
236
+ process.exit(0);
237
+ }
238
+
239
+ module.exports = { runGate, readLedger, evaluate, ledgerFiles, LedgerUnavailable, EVIDENCE_KIND, NON_CONSUMER_IDS };
@@ -59,6 +59,32 @@ const SKIP_DIRS = new Set([
59
59
  'coverage', 'out', '.turbo', '.venv', 'venv', 'Pods', 'vendor', '.gradle',
60
60
  ]);
61
61
 
62
+ // [RULE] slice-budget-skips-design-export-snapshots
63
+ //
64
+ // hilo-figma-atos, 2026-08-11: `.figma-make-exports/` held 1,879 tracked files
65
+ // and 57 MB of design-tool exports — six dated snapshots of a prototype, each a
66
+ // near-copy of the last. They were measured, sliced, and read by finders as if
67
+ // they were the application. Out of the whole scan, exactly TWO findings came
68
+ // from them, and both were about the directory itself (it has no type-check
69
+ // exclusion; the same key is committed six times) rather than about defects in
70
+ // the product.
71
+ //
72
+ // These are recognised by NAME rather than by a project's own ignore rules,
73
+ // because the project had no such rule — that absence was itself one of the two
74
+ // findings. Matched as a path SEGMENT so a real source folder that merely
75
+ // contains the word (say `src/exports/`) is untouched.
76
+ const SNAPSHOT_DIR_PATTERNS = [
77
+ /^\.?figma-make-exports$/i,
78
+ /^\.?figma-exports$/i,
79
+ /^design-exports?$/i,
80
+ /^ui-snapshots?$/i,
81
+ /^__snapshots-export__$/i,
82
+ ];
83
+
84
+ function isSnapshotExportDir(name) {
85
+ return SNAPSHOT_DIR_PATTERNS.some((re) => re.test(name));
86
+ }
87
+
62
88
  /** Lines in one file. A file that cannot be read is reported, never counted as 0. */
63
89
  function countLines(file) {
64
90
  const text = fs.readFileSync(file, 'utf8');
@@ -72,7 +98,7 @@ function countLines(file) {
72
98
  }
73
99
 
74
100
  /** Every source file under a path, with its line count. */
75
- function measurePath(projectDir, rel, problems) {
101
+ function measurePath(projectDir, rel, problems, skippedSnapshots) {
76
102
  const abs = path.resolve(projectDir, rel);
77
103
  const out = [];
78
104
 
@@ -107,6 +133,12 @@ function measurePath(projectDir, rel, problems) {
107
133
  for (const ent of entries) {
108
134
  if (ent.isDirectory()) {
109
135
  if (SKIP_DIRS.has(ent.name)) continue;
136
+ if (isSnapshotExportDir(ent.name)) {
137
+ // Reported, never silent: a directory this large vanishing from the
138
+ // measurement without a word is how a coverage hole hides.
139
+ skippedSnapshots.push(path.relative(projectDir, path.join(dir, ent.name)));
140
+ continue;
141
+ }
110
142
  walk(path.join(dir, ent.name));
111
143
  continue;
112
144
  }
@@ -178,6 +210,7 @@ function splitSlice(slice, files, min, max, oversizedFiles) {
178
210
 
179
211
  function plan(projectDir, slices, min, max) {
180
212
  const problems = [];
213
+ const skippedSnapshots = [];
181
214
  const oversizedFiles = [];
182
215
  const out = [];
183
216
  let measuredLines = 0;
@@ -188,7 +221,7 @@ function plan(projectDir, slices, min, max) {
188
221
  const files = [];
189
222
  const seen = new Set();
190
223
  for (const p of paths) {
191
- for (const f of measurePath(projectDir, p, problems)) {
224
+ for (const f of measurePath(projectDir, p, problems, skippedSnapshots)) {
192
225
  // A file listed under two paths of one slice is one file, counted once.
193
226
  if (seen.has(f.file)) continue;
194
227
  seen.add(f.file);
@@ -232,6 +265,9 @@ function plan(projectDir, slices, min, max) {
232
265
  .sort((a, b) => b.lines - a.lines)
233
266
  .map((f) => ({ file: f.file, lines: f.lines })),
234
267
  problems,
268
+ // Design-tool export snapshots left out of the measurement, named so the
269
+ // omission is visible rather than inferred from a smaller total.
270
+ skippedSnapshots: Array.from(new Set(skippedSnapshots)).sort(),
235
271
  slices: out,
236
272
  };
237
273
  }
@@ -313,6 +313,8 @@ function _detectDefaultTrack2(projectDir, notes) {
313
313
 
314
314
  plan.push({ id: 'fallbacks', cmd: 'node', args: [path.join(__dirname, 'gsd-t-fallback-detect.cjs'), '--scan', '--project', projectDir, '--json'], timeoutMs: 120000 }); // M106: no unapproved continue-after-failure branch, FAIL-CLOSED (pre-existing ones excluded via .gsd-t/fallbacks-baseline.json)
315
315
 
316
+ plan.push({ id: 'graph-use', cmd: 'node', args: [path.join(__dirname, 'gsd-t-graph-use-gate.cjs'), '--project', projectDir, '--verify-mode'], timeoutMs: 30000 }); // M113: a consumer that logged graphWiringMode=WIRED must have issued >=1 graph query, FAIL-CLOSED (documented no-op PASS when no ledger exists yet). Catches what the STATIC anti-grep lint structurally cannot: a consumer that never queried at all.
317
+
316
318
  // secrets — gitleaks (PATH detection deferred to runtime)
317
319
  if (_hasOnPath('gitleaks')) {
318
320
  plan.push({
package/bin/gsd-t.js CHANGED
@@ -3487,6 +3487,10 @@ const PROJECT_BIN_TOOLS = [
3487
3487
  // every Write/Edit is DENIED (the guard halts rather than allowing unchecked),
3488
3488
  // so this entry is load-bearing — [[project_global_bin_propagation_gap]].
3489
3489
  "gsd-t-fallback-detect.cjs",
3490
+ // M113 — Graph USE gate. The verify gate dispatches to it by absolute path,
3491
+ // and it reads the project's OWN .gsd-t/graphDB/logs ledger, so it must live
3492
+ // in the project — [[project_global_bin_propagation_gap]].
3493
+ "gsd-t-graph-use-gate.cjs",
3490
3494
  // M107 — Concise rewriter, invoked by the Stop hook.
3491
3495
  // M108 — Install self-check. Every project carries its own copy so it can
3492
3496
  // verify and repair itself even when the global install is what broke.
@@ -98,7 +98,7 @@ Debug applies the **WRITER pattern** from `graph-consumer-wiring-contract.md`:
98
98
 
99
99
  **WRITER half (re-index after fix):** After the fix lands, the workflow triggers a re-index of the edited files so downstream graph queries see fresh edges (`graph-freshness-contract.md` D4 surface — `freshness_check_on_query` over the touched set). `[RULE] debug-reader-and-writer-both`.
100
100
 
101
- **FAIL-LOUD on graph-unavailable:** On `{ok:false, reason:"graph-unavailable"}`, the debug workflow surfaces `"graph unavailable fix it (gsd-t graph status)"` and halts the graph-query step. It does NOT fall back to grep for the structural question. The existing debug-loop logic (2-cycle cap, loop-ledger halt) is NOT disrupted — the graph query is additive, injected before the fix agent receives context.
101
+ **BUILD-THEN-QUERY on graph-absent:** On `{ok:false, reason:"graph-unavailable"}` the debug workflow **builds the index right then** (`gsd-t graph index`) and re-runs the localization query. It does NOT fall back to grep for the structural question, and it does NOT skip the query. Only a **failed build** halts the graph-query step, with the build's own stderr surfaced. The existing debug-loop logic (2-cycle cap, loop-ledger halt) is NOT disrupted — the graph query is additive, injected before the fix agent receives context. `[RULE] graph-absent-builds-not-degrades`.
102
102
 
103
103
  ## Contract-Boundary Debugging
104
104
 
@@ -10,7 +10,7 @@ Design-build applies the **WRITER pattern** from `graph-consumer-wiring-contract
10
10
 
11
11
  **WRITER half:** After each tier (elements → widgets → pages) generates files, the pipeline triggers a re-index of the generated files (`freshness_check_on_query` from `graph-freshness-contract.md` D4 surface) so the next tier's `who-imports` / `cluster` query sees the new edges from the generated components. `[RULE] design-build-writer-pattern`.
12
12
 
13
- **FAIL-LOUD on graph-unavailable:** On `{ok:false, reason:"graph-unavailable"}`, the structural query surfaces `"graph unavailable fix it (gsd-t graph status)"` the pipeline does NOT fall back to grep for the structural question. `[RULE] consumer-structural-grep-removed`.
13
+ **BUILD-THEN-QUERY on graph-absent:** On `{ok:false, reason:"graph-unavailable"}` the pipeline **builds the index right then** (`gsd-t graph index`) and re-runs the structural query. It does NOT fall back to grep, and it does NOT proceed without the structural answer. Only a **failed build** halts, with the build's own stderr surfaced. `[RULE] consumer-structural-grep-removed`, `[RULE] graph-absent-builds-not-degrades`.
14
14
 
15
15
  ## Step 1: Launch the Orchestrator
16
16
 
@@ -182,7 +182,7 @@ Quick applies the **WRITER pattern** from `graph-consumer-wiring-contract.md`:
182
182
 
183
183
  **WRITER half:** After edits land, the workflow triggers a re-index of the touched files (`freshness_check_on_query` from `graph-freshness-contract.md` D4 surface) so downstream graph queries see fresh edges. `[RULE] quick-writer-pattern`.
184
184
 
185
- **FAIL-LOUD on graph-unavailable:** On `{ok:false, reason:"graph-unavailable"}`, the structural-impact query surfaces `"graph unavailable fix it (gsd-t graph status)"` and the agent proceeds without the structural slice — it does NOT fall back to grep for the structural question. `[RULE] consumer-structural-grep-removed`.
185
+ **BUILD-THEN-QUERY on graph-absent:** On `{ok:false, reason:"graph-unavailable"}` the workflow **builds the index right then** (`gsd-t graph index`) and re-runs the query. It does NOT fall back to grep, and it does NOT proceed without the structural slice — proceeding blind is a third failure mode, not a safe default. Only a **failed build** halts, with the build's own stderr surfaced. `[RULE] consumer-structural-grep-removed`, `[RULE] graph-absent-builds-not-degrades`.
186
186
 
187
187
  ## Step 1.5: Graph-Enhanced Scope Check
188
188
 
@@ -40,7 +40,7 @@ Test-sync applies the **WRITER pattern** from `graph-consumer-wiring-contract.md
40
40
 
41
41
  **WRITER half:** After writing or updating tests, trigger a re-index of the edited test files (`freshness_check_on_query` from `graph-freshness-contract.md` D4 surface) so the next `test-impl` query sees the updated call-site edges from the new test code. `[RULE] test-sync-uses-test-impl-verb`.
42
42
 
43
- **FAIL-LOUD on graph-unavailable:** On `{ok:false, reason:"graph-unavailable"}`, the test-impl query surfaces `"graph unavailable fix it (gsd-t graph status)"` and the agent proceeds with filesystem discovery as announced fallbackit does NOT silently treat a missing graph as "no coverage". `[RULE] consumer-structural-grep-removed`.
43
+ **BUILD-THEN-QUERY on graph-absent:** On `{ok:false, reason:"graph-unavailable"}` the workflow **builds the index right then** (`gsd-t graph index`) and re-runs the `test-impl` / `untested-impl` queries. Filesystem discovery is NOT an acceptable substitute announcing a fallback does not stop it being one, and "which test exercises which impl" answered by filename convention is exactly the wrong-answer class the graph exists to remove. Only a **failed build** halts, with the build's own stderr surfaced. `[RULE] consumer-structural-grep-removed`, `[RULE] graph-absent-builds-not-degrades`.
44
44
 
45
45
  ## Step 1.5: Graph-Enhanced Test Discovery
46
46
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "5.11.30",
4
- "description": "GSD-T: Contract-Driven Development for Claude Code \u2014 54 slash commands with headless-by-default workflow spawning, unattended supervisor relay with event stream, graph-powered code analysis, real-time agent dashboard, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
3
+ "version": "5.11.32",
4
+ "description": "GSD-T: Contract-Driven Development for Claude Code 54 slash commands with headless-by-default workflow spawning, unattended supervisor relay with event stream, graph-powered code analysis, real-time agent dashboard, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
5
5
  "author": "Tekyz, Inc.",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -79,6 +79,15 @@ async function classifyGraphFailure(projectDir, reason, detail, phaseName) {
79
79
  if (env && (env.state === "ABSENT" || env.state === "BROKEN")) return env.state;
80
80
  return "BROKEN";
81
81
  }
82
+ // [RULE] absent-graph-auto-builds-once — build the index once via the existing
83
+ // `gsd-t graph index` path (local bin: gsd-t-graph-index.cjs build --repo <dir>).
84
+ async function buildGraphIndex(projectDir, phaseName) {
85
+ const r = await runCli(
86
+ projectDir, "graph index", ["build", "--repo", projectDir],
87
+ "gsd-t-graph-index.cjs", "graph-index", false, phaseName
88
+ ).catch(() => null);
89
+ return !!(r && r.ok);
90
+ }
82
91
  async function runVerifyGate(projectDir, label = "verify-gate", phaseName) { return runCli(projectDir, "verify-gate", ["--json"], "gsd-t-verify-gate.cjs", label, true, phaseName); }
83
92
  async function generateBrief(projectDir, { kind = "execute", milestone, domain, id, label = "brief", phaseName } = {}) {
84
93
  const argv = ["--kind", kind, "--spawn-id", id, "--out", `${projectDir}/.gsd-t/briefs/${id}.json`];
@@ -133,32 +142,54 @@ await persistWiringMode("Preflight");
133
142
 
134
143
  // M94-D10-T6: Graph Structural Slice — who-imports + blast-radius (ADDITIVE, announced-degradation)
135
144
  // [RULE] integrate-uses-graph-for-wiring-verification
136
- // [RULE] verify-integrate-graph-additive-announced-not-hard-fail — bootstrap carve-out:
137
- // integrate degrades ANNOUNCED on graph-unavailable, does NOT hard-fail.
145
+ // [RULE] verify-integrate-graph-additive-announced-not-hard-fail — integrate does NOT
146
+ // hard-fail on a graph it cannot use. It DOES build an ABSENT index first
147
+ // ([RULE] graph-absent-builds-not-degrades); only a BROKEN graph, or a build that
148
+ // itself fails, degrades announced.
138
149
  let _graphWhoImportsSlice = null;
139
150
  let _graphBlastRadiusSlice = null;
140
151
  let _graphIntegrateWarning = null;
141
152
 
142
153
  {
143
- const wiResult = await runCli(
154
+ // [RULE] graph-absent-builds-not-degrades an ABSENT graph is BUILT here, not
155
+ // skipped. The former "announced carve-out" skipped the structural wiring-check
156
+ // whenever the index had never been built — which is exactly when integrate most
157
+ // needs it, because a fresh worktree carries no graph (it is gitignored). The
158
+ // check therefore did nothing on the very runs it was written for. Announcing a
159
+ // skip does not stop it being a skip.
160
+ let wiEnv = (await runCli(
144
161
  projectDir, "graph who-imports", [], "gsd-t-graph-query-cli.cjs",
145
162
  "graph:who-imports", true, "Integrate"
146
- );
147
- const wiEnv = wiResult.envelope || {};
148
- if (wiEnv.ok === true) {
149
- _graphWhoImportsSlice = wiEnv;
150
- log(`M94 graph who-imports: ${(wiEnv.results || []).length} result(s) (tier: ${wiEnv.tier || "?"})`);
151
- } else if (wiEnv.ok === false) {
152
- // [RULE] one-availability-classifier — distinguish ABSENT (announced skip) from BROKEN (LOUD).
163
+ )).envelope || {};
164
+
165
+ if (wiEnv.ok === false) {
166
+ // [RULE] one-availability-classifier — ONE classify call, ONE state, no re-derivation.
153
167
  const _state = await classifyGraphFailure(projectDir, wiEnv.reason, wiEnv.detail, "Integrate");
154
- if (_state === "BROKEN") {
155
- _graphIntegrateWarning = `⚠ graph BROKEN (reason=${wiEnv.reason || "?"}) — structural wiring-check skipped. This is NOT merely un-indexed; FIX it (gsd-t graph status).`;
168
+ if (_state === "ABSENT") {
169
+ log("M94 graph who-imports: index ABSENT (never indexed) — building it now (gsd-t graph index)...");
170
+ if (await buildGraphIndex(projectDir, "Integrate")) {
171
+ wiEnv = (await runCli(
172
+ projectDir, "graph who-imports", [], "gsd-t-graph-query-cli.cjs",
173
+ "graph:who-imports", true, "Integrate"
174
+ )).envelope || {};
175
+ if (wiEnv.ok !== true) {
176
+ _graphIntegrateWarning = `⚠ graph built but who-imports still failed (reason=${wiEnv.reason || "?"}) — structural wiring-check skipped. FIX it (gsd-t graph status).`;
177
+ }
178
+ } else {
179
+ // A failed BUILD is loud, and distinct from a merely-missing index.
180
+ _graphIntegrateWarning = "⚠ graph ABSENT and `gsd-t graph index` FAILED — structural wiring-check cannot run. FIX it (gsd-t graph status).";
181
+ }
156
182
  } else {
157
- _graphIntegrateWarning = "⚠ graph ABSENT (never indexed) — structural wiring-check skipped (announced carve-out; build with gsd-t graph index)";
183
+ _graphIntegrateWarning = `⚠ graph BROKEN (reason=${wiEnv.reason || "?"}) — structural wiring-check skipped. This is NOT merely un-indexed; FIX it (gsd-t graph status).`;
158
184
  }
159
- log(`M94 graph who-imports: ${_graphIntegrateWarning}`);
160
- } else {
185
+ } else if (wiEnv.ok !== true) {
161
186
  _graphIntegrateWarning = `⚠ graph who-imports query unexpected envelope (reason: ${wiEnv.reason || "?"}); structural wiring-check skipped`;
187
+ }
188
+
189
+ if (wiEnv.ok === true) {
190
+ _graphWhoImportsSlice = wiEnv;
191
+ log(`M94 graph who-imports: ${(wiEnv.results || []).length} result(s) (tier: ${wiEnv.tier || "?"})`);
192
+ } else {
162
193
  log(`M94 graph who-imports: ${_graphIntegrateWarning}`);
163
194
  }
164
195
 
@@ -183,6 +183,55 @@ const VERIFY_SCHEMA = {
183
183
  },
184
184
  };
185
185
 
186
+ // One verifier reads a BATCH of findings and returns a verdict for each.
187
+ //
188
+ // [RULE] verify-batched-not-one-agent-per-finding
189
+ //
190
+ // Measured on hilo-figma-atos, 2026-08-11 — the same 20 findings, spanning all
191
+ // four severities, verified both ways against real code:
192
+ //
193
+ // one-agent-per-finding batched (20 in one)
194
+ // false positives caught 0 1
195
+ // severity corrections 1 2
196
+ // mean evidence length 426 chars 355 chars
197
+ //
198
+ // Batching did not merely cost less — it judged BETTER, and both disagreements
199
+ // were checked by hand and went the batch's way. An agent holding many findings
200
+ // sees them in relation to each other, and severity is a comparative judgment:
201
+ // "help articles leaking" is HIGH *next to* the cross-tenant student-record
202
+ // leaks beside it. An agent handed one finding alone has no yardstick, so it
203
+ // tends to confirm whatever it was given — which is what 20-of-20 confirmed,
204
+ // zero false positives, looked like on the unbatched side.
205
+ //
206
+ // The cost is ~70 characters less evidence per finding, which changed no verdict.
207
+ // The saving is the run: 492 findings went from ~570 verifier agents to ~50.
208
+ const VERIFY_BATCH_SCHEMA = {
209
+ type: "object",
210
+ required: ["verdicts"],
211
+ additionalProperties: true,
212
+ properties: {
213
+ verdicts: {
214
+ type: "array",
215
+ minItems: 1,
216
+ items: {
217
+ type: "object",
218
+ required: ["index", "verdict"],
219
+ additionalProperties: true,
220
+ properties: {
221
+ // The finding's position in the batch it was given. Position rather
222
+ // than title: a title can be paraphrased back, an index cannot.
223
+ index: { type: "integer" },
224
+ verdict: { type: "string", enum: ["confirmed", "CONFIRMED", "Confirmed", "false-positive", "FALSE-POSITIVE", "False-positive", "needs-detail", "NEEDS-DETAIL", "Needs-detail"] },
225
+ confirmed: { type: "boolean" },
226
+ note: { type: "string" },
227
+ evidence: { type: "string" },
228
+ correctedSeverity: { type: "string", enum: ["CRITICAL", "critical", "Critical", "HIGH", "high", "High", "MEDIUM", "medium", "Medium", "LOW", "low", "Low"] },
229
+ },
230
+ },
231
+ },
232
+ },
233
+ };
234
+
186
235
  // M75: synthesis no longer writes the register via one agent (the Hilo Scan #14
187
236
  // synthesis stalled after 9 of 322 items typing a 466KB file). Instead: a bounded
188
237
  // dedup agent (inline DEDUP_SCHEMA, small input) decides merge groups; the
@@ -988,44 +1037,81 @@ async function scanSlice(slice) {
988
1037
  if (verifyMode === "none" || finderResult.findings.length === 0) {
989
1038
  return { slice: sliceKey, findings: finderResult.findings || [], failed: false };
990
1039
  }
991
- // Fan out ALL verifies for this slice the global gate (not a per-slice limit)
992
- // bounds total in-flight, so this is safe AND keeps every worker slot busy.
993
- const verified = await parallel(
994
- finderResult.findings.map((f) => async () => {
995
- try {
996
- const verifyPrompt = [
997
- `You are a VERIFIER for one tech-debt finding in \`${projectDir}\`. Confirm it against the ACTUAL code (open the referenced files with Read) — do not trust the finder.`,
998
- `Finding: ${JSON.stringify(f)}`,
999
- `confirmed=true only if the defect genuinely exists. If misread → verdict="false-positive". If real but wrong severity → set correctedSeverity. If real but underspecified → verdict="needs-detail" (kept). Return JSON per the schema.`,
1000
- SHAPE_RULE,
1001
- ].join("\n");
1002
-
1003
- // Verify had NO retry: one wrapped call and the finding went through
1004
- // unverified. Same escalation as the finder, one step shorter — a lost
1005
- // verdict costs one finding, not a whole slice.
1006
- let v = await gatedAgent(verifyPrompt, {
1007
- label: `verify:${sliceKey}`, phase: "Deep Scan", schema: VERIFY_SCHEMA, model: "sonnet",
1008
- });
1009
- if (!v) {
1010
- v = await gatedAgent(verifyPrompt + UNWRAP_HINT, {
1011
- label: `verify:${sliceKey} (retry on opus)`, phase: "Deep Scan",
1012
- schema: VERIFY_SCHEMA, model: "opus",
1013
- });
1040
+ // Verify in BATCHESsee [RULE] verify-batched-not-one-agent-per-finding at
1041
+ // VERIFY_BATCH_SCHEMA for the measurement that chose this over one agent per
1042
+ // finding. Ten is small enough that every finding still gets its files opened,
1043
+ // and large enough that the verifier can weigh them against each other, which
1044
+ // is what severity judgment actually requires.
1045
+ const VERIFY_BATCH_SIZE = 10;
1046
+ const batches = [];
1047
+ for (let i = 0; i < finderResult.findings.length; i += VERIFY_BATCH_SIZE) {
1048
+ batches.push(finderResult.findings.slice(i, i + VERIFY_BATCH_SIZE));
1049
+ }
1050
+
1051
+ const verifiedBatches = await parallel(
1052
+ batches.map((batch, batchNo) => async () => {
1053
+ const verifyPrompt = [
1054
+ `You are a VERIFIER for ${batch.length} tech-debt finding(s) in \`${projectDir}\`. Confirm EACH against the ACTUAL code (open the referenced files with Read) — do not trust the finder.`,
1055
+ ``,
1056
+ `Findings, as a numbered list. Return one verdict per finding, using its \`index\`:`,
1057
+ ...batch.map((f, i) => `[${i}] ${JSON.stringify(f)}`),
1058
+ ``,
1059
+ `For EACH finding: verdict="confirmed" only if the defect genuinely exists. If the finder misread the code → verdict="false-positive". If real but underspecified → verdict="needs-detail" (kept).`,
1060
+ `Set correctedSeverity when the severity is wrong. You are seeing these findings TOGETHER — use that: severity is comparative, and a finding that looks alarming alone is often plainly lesser beside the others in this batch. Judge each one's consequence relative to the rest.`,
1061
+ `Put the file and line you actually checked in \`evidence\`. A verdict with no evidence from the code is the failure this step exists to prevent.`,
1062
+ `Return ONE object per finding, ${batch.length} in total, each carrying its \`index\`. Missing verdicts are treated as unverified.`,
1063
+ SHAPE_RULE,
1064
+ ].join("\n");
1065
+
1066
+ // Attempt 1 — sonnet. Attempt 2 names the likely mistake and escalates to
1067
+ // the model that has never made it. Same shape as the finder escalation.
1068
+ const attempt1 = await gatedAgent(verifyPrompt, {
1069
+ label: `verify:${sliceKey}#${batchNo + 1}`, phase: "Deep Scan",
1070
+ schema: VERIFY_BATCH_SCHEMA, model: "sonnet",
1071
+ });
1072
+ const v = (attempt1 && Array.isArray(attempt1.verdicts)) ? attempt1 : await gatedAgent(
1073
+ verifyPrompt + UNWRAP_HINT,
1074
+ {
1075
+ label: `verify:${sliceKey}#${batchNo + 1} (retry on opus)`, phase: "Deep Scan",
1076
+ schema: VERIFY_BATCH_SCHEMA, model: "opus",
1014
1077
  }
1015
- // Compared case-INSENSITIVELY: the schema now accepts "false-positive"
1016
- // in any casing, so an exact match would silently KEEP a finding the
1078
+ );
1079
+
1080
+ const byIndex = new Map();
1081
+ for (const r of ((v && Array.isArray(v.verdicts) && v.verdicts) || [])) {
1082
+ if (Number.isInteger(r.index)) byIndex.set(r.index, r);
1083
+ }
1084
+
1085
+ return batch.map((f, i) => {
1086
+ const r = byIndex.get(i);
1087
+ // No verdict came back for this finding. It is NOT dropped and NOT
1088
+ // silently passed as verified — it is kept and MARKED, so a batch that
1089
+ // answered for eight of ten cannot quietly delete the other two, and
1090
+ // the register can show which findings nobody checked.
1091
+ if (!r) return { ...f, _verify: "unverified" };
1092
+
1093
+ // Compared case-INSENSITIVELY: the schema accepts "false-positive" in
1094
+ // any casing, so an exact match would silently KEEP a finding the
1017
1095
  // verifier had rejected.
1018
- const verdict = String(v && v.verdict || "").toLowerCase();
1019
- if (!v || verdict === "false-positive" || v.confirmed === false) return null;
1020
- // Severity is normalised to the shouted form here, once, so the report
1096
+ const verdict = String(r.verdict || "").toLowerCase();
1097
+ if (verdict === "false-positive" || r.confirmed === false) return null;
1098
+
1099
+ // Severity normalised to the shouted form here, once, so the report
1021
1100
  // reads consistently no matter how a finder typed it.
1022
- const sev = String(v.correctedSeverity || f.severity || "").toUpperCase();
1023
- return { ...f, severity: sev, _verify: verdict };
1024
- } catch (e) {
1025
- return { ...f, _verify: "verify-errored" };
1026
- }
1101
+ const sev = String(r.correctedSeverity || f.severity || "").toUpperCase();
1102
+ const out = { ...f, severity: sev, _verify: verdict || "confirmed" };
1103
+ if (r.evidence) out._evidence = r.evidence;
1104
+ return out;
1105
+ });
1027
1106
  })
1028
1107
  );
1108
+
1109
+ // A batch whose agent died resolves to null; its findings are kept and marked
1110
+ // rather than lost — losing a real defect is worse than carrying an unchecked
1111
+ // one, and the mark is what stops it reading as verified.
1112
+ const verified = verifiedBatches.flatMap((res, batchNo) =>
1113
+ res === null ? batches[batchNo].map((f) => ({ ...f, _verify: "verify-errored" })) : res
1114
+ );
1029
1115
  return { slice: sliceKey, findings: verified.filter(Boolean), failed: false };
1030
1116
  }
1031
1117
 
@@ -1181,7 +1267,9 @@ if (allFindings.length > 1) {
1181
1267
  }
1182
1268
 
1183
1269
  // (a)+(c) Deterministically merge dups, sort by severity, assign TD numbers, format.
1184
- const SEV_ORDER = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
1270
+ // EXTREME is the architect's tier for anything that leaves wrong data behind,
1271
+ // breaches a tenant boundary, moves money wrongly, or touches safety.
1272
+ const SEV_ORDER = { EXTREME: 0, CRITICAL: 1, HIGH: 2, MEDIUM: 3, LOW: 4 };
1185
1273
  const dropped = new Set();
1186
1274
  const merged = [];
1187
1275
  for (const group of mergeGroups) {
@@ -1208,6 +1296,156 @@ for (const f of finalFindings) {
1208
1296
  counts.total = finalFindings.length;
1209
1297
 
1210
1298
 
1299
+ const ARCHITECT_SCHEMA = {
1300
+ type: "object",
1301
+ required: ["placements"],
1302
+ additionalProperties: true,
1303
+ properties: {
1304
+ roots: {
1305
+ type: "array",
1306
+ items: {
1307
+ type: "object",
1308
+ required: ["key", "name"],
1309
+ additionalProperties: true,
1310
+ properties: {
1311
+ key: { type: "string", description: "short id, e.g. R1" },
1312
+ // Short by contract, not by request. The first architect run produced
1313
+ // headings like "Doors that check who you are but never which flight
1314
+ // school you belong to" over 250-word explanations; a reader scanning
1315
+ // 28 roots for the one to fix next cannot skim that.
1316
+ name: { type: "string", maxLength: 40, description: "2-5 word noun phrase, e.g. 'Missing tenant scoping'. NOT a sentence." },
1317
+ why: { type: "string", maxLength: 320, description: "ONE sentence naming the shared mistake, plus the file:line citations that prove it. Keep every citation; compress the prose around them." },
1318
+ fix: { type: "string", maxLength: 200, description: "ONE sentence: the single change that closes them all." },
1319
+ tier: { type: "string", enum: ["EXTREME", "CRITICAL", "HIGH", "MEDIUM", "LOW", "extreme", "critical", "high", "medium", "low"] },
1320
+ rank: { type: "integer", description: "rank among roots of the same tier, 1 = worst" },
1321
+ rankReason:{ type: "string", maxLength: 220, description: "ONE sentence: why this root outranks the ones below it in its tier." },
1322
+ },
1323
+ },
1324
+ },
1325
+ placements: {
1326
+ type: "array",
1327
+ minItems: 1,
1328
+ items: {
1329
+ type: "object",
1330
+ required: ["index", "tier"],
1331
+ additionalProperties: true,
1332
+ properties: {
1333
+ index: { type: "integer", description: "the finding's index in the list given" },
1334
+ tier: { type: "string", enum: ["EXTREME", "CRITICAL", "HIGH", "MEDIUM", "LOW", "extreme", "critical", "high", "medium", "low"] },
1335
+ rootKey: { type: "string", description: "the root this belongs to, or omitted if standalone" },
1336
+ rankInRoot: { type: "integer", description: "rank among that root's members, 1 = worst" },
1337
+ alsoRoots: { type: "array", items: { type: "string" }, description: "other roots that also cause this" },
1338
+ deadCode: { type: "boolean", description: "true ONLY when confirmed unreachable — say which checks in `note`" },
1339
+ notADefect: { type: "boolean", description: "true when this is not a real defect" },
1340
+ reason: { type: "string", description: "one line, grounded in consequence" },
1341
+ note: { type: "string" },
1342
+ },
1343
+ },
1344
+ },
1345
+ },
1346
+ };
1347
+
1348
+ // ─── Architect — re-tier by consequence, group by root cause, rank ──────────
1349
+ //
1350
+ // [RULE] architect-ranks-before-numbering
1351
+ //
1352
+ // Run by hand over the hilo-figma-atos register on 2026-08-11, and it changed
1353
+ // the answer enough to become a permanent stage:
1354
+ //
1355
+ // · The worst finding in the codebase was filed HIGH. Account credits that
1356
+ // cover a whole invoice are never marked used, so the same credit is given
1357
+ // away again every month, forever. It sat at position 127.
1358
+ // · A typo in one text box silently routes every real card payment to the
1359
+ // practice gateway, where charges report success and no money moves. Filed
1360
+ // MEDIUM.
1361
+ // · Every school's signed legal agreements are downloadable by any other
1362
+ // school. Filed LOW.
1363
+ // · A fabricated thunderstorm advisory for a named airport is shown to pilots
1364
+ // on every page. Filed MEDIUM.
1365
+ // · 22 findings were genuinely unreachable code — carried as risk when they
1366
+ // cannot execute.
1367
+ //
1368
+ // And the shape of the work changed: 492 findings collapsed into 28 root causes.
1369
+ // 144 of the 328 medium/low findings attached to a root that already existed,
1370
+ // and only 7 new causes were needed. The codebase does not have 492 problems; it
1371
+ // has about 28, most of them repeated dozens of times. Scheduling the findings
1372
+ // individually produces dozens of half-fixes of one defect.
1373
+ //
1374
+ // Two things make this stage worth its cost, and both were measured, not assumed:
1375
+ // 1. Severity assigned per-finding is unreliable, because severity is
1376
+ // COMPARATIVE. An agent seeing findings together ranks them; an agent
1377
+ // seeing one finding alone confirms whatever it was handed.
1378
+ // 2. TD numbers must be assigned AFTER this, or the register's numbering
1379
+ // encodes the order slices happened to finish.
1380
+ async function architectPass(findings) {
1381
+ if (!findings.length) return null;
1382
+
1383
+ // One agent per ~150 findings — the size that held its judgment in the manual
1384
+ // run. More than that and the later findings get thinner treatment; fewer and
1385
+ // the agent loses the comparison that makes ranking possible.
1386
+ const ARCH_BATCH = 150;
1387
+ const chunks = [];
1388
+ for (let i = 0; i < findings.length; i += ARCH_BATCH) chunks.push(findings.slice(i, i + ARCH_BATCH));
1389
+
1390
+ log(`architect: ${findings.length} findings across ${chunks.length} pass(es) — re-tier by consequence, group by root cause, rank`);
1391
+
1392
+ const TIER_RULES = [
1393
+ `TIERS ARE ABOUT CONSEQUENCE, NOT THE KIND OF BUG. The dividing question for every finding: DOES IT LEAVE WRONG DATA BEHIND, OR DOES IT JUST FAIL TO DO ANYTHING?`,
1394
+ ` EXTREME — breach, data loss, money wrong, or safety. Irreversible or legally reportable. Cross-tenant read OR write, missing database-level access rules, leaked credentials, remote code execution, privilege escalation — AND ALSO anything that CORRUPTS DATA or REPORTS SUCCESS WHILE WRITING NOTHING (money shown as moved but not moved; a record referenced elsewhere that was never created; a kill switch that reports "off" while the thing runs).`,
1395
+ ` CRITICAL — the feature does not work; recoverable once fixed; NO bad data left behind. A page that never loads. An endpoint that fails and shows an empty list.`,
1396
+ ` HIGH — real, should be fixed, neither of the above.`,
1397
+ ` MEDIUM / LOW — confirm as filed.`,
1398
+ ` DEAD CODE is NOT a risk tier. Genuinely unreachable code cannot cause a problem — mark it dead and it moves to a cleanup list.`,
1399
+ ``,
1400
+ `⚠ AN EMPTY IMPORT LIST DOES NOT PROVE UNREACHABILITY. A page reached by dynamic import shows zero importers in the code graph and is still live and routed — this nearly cost a live credit-card form its EXTREME rating. Before calling anything dead: check the graph, search the name across the source tree, AND search for dynamic loading and the router file. Say which checks you ran.`,
1401
+ ].join("\n");
1402
+
1403
+ const results = await parallel(chunks.map((chunk, ci) => async () => {
1404
+ const listing = chunk.map((f, i) =>
1405
+ `[${i}] severity=${f.severity} area=${ascii(f.area) || "?"} | ${ascii(f.title)} | at ${(f.files && f.files.join(", ")) || "?"} | ${ascii(f.description || "").slice(0, 400)}`
1406
+ ).join("\n");
1407
+
1408
+ const prompt = [
1409
+ `⛔ Work ONLY inside \`${projectDir}\`. Read real code with Read/Grep to settle any question; the code graph answers structural questions (\`gsd-t graph who-imports <file>\`, \`who-calls\`, \`blast-radius\`) and must be preferred over grep for those.`,
1410
+ ``,
1411
+ `You are the ARCHITECT for a completed tech-debt scan. ${chunk.length} findings, already verified against the code by an earlier pass. DO NOT re-verify them all. Your job is judgment about TIER, ROOT CAUSE and ORDER.`,
1412
+ ``,
1413
+ TIER_RULES,
1414
+ ``,
1415
+ `YOUR HIGHEST-VALUE OUTPUT IS FINDING THE MIS-FILED ONES. Severity as assigned is not reliable: in the run that created this stage, the single worst defect in the codebase (account credits re-spent every month, forever) was filed HIGH, and a payment misrouting that makes charges succeed while no money moves was filed MEDIUM. Hunt specifically for money, cross-tenant access, safety, and silent data corruption hiding at a low severity.`,
1416
+ ``,
1417
+ `FINDINGS:`,
1418
+ listing,
1419
+ ``,
1420
+ `GROUP BY ROOT CAUSE. A root is one underlying cause where ONE fix closes several findings — e.g. many routes missing the same tenant check. A root with ONE member is not a group; leave it standalone. A root's tier is the tier of its WORST member.`,
1421
+ `WRITE IT SHORT. The name is a 2-5 word noun phrase ("Missing tenant scoping", "Silent write failures"), never a sentence. \`why\` and \`fix\` and \`rankReason\` are ONE sentence each. Someone scanning 28 roots to pick what to fix next cannot read a paragraph per root — but KEEP every file:line citation inside \`why\`, because those are what make the grouping checkable. Compress the prose, never the evidence.`,
1422
+ `Rank roots within their tier by worst consequence, and members within a root by consequence. RISK order, never the order the findings arrived.`,
1423
+ ``,
1424
+ `Return JSON per the schema. Every one of the ${chunk.length} findings must appear exactly once in \`placements\` — a finding you drop is a defect nobody will see again.`,
1425
+ SHAPE_RULE,
1426
+ ].join("\n");
1427
+
1428
+ const r = await gatedAgent(prompt, {
1429
+ label: `architect ${ci + 1}/${chunks.length}`, phase: "Architect",
1430
+ schema: ARCHITECT_SCHEMA, model: "opus",
1431
+ });
1432
+ return (r && Array.isArray(r.placements)) ? r : null;
1433
+ }));
1434
+
1435
+ const ok = results.filter(Boolean);
1436
+ if (!ok.length) {
1437
+ // Nothing to rank with. The register is still written, in its filed order —
1438
+ // said out loud, because a register that looks ranked and is not is worse
1439
+ // than one that never claimed to be.
1440
+ log(`⚠ ARCHITECT PRODUCED NOTHING — the register keeps its filed severities and discovery order. It is NOT prioritised; treat its ordering as arbitrary.`);
1441
+ return null;
1442
+ }
1443
+ if (ok.length < chunks.length) {
1444
+ log(`⚠ architect: ${ok.length} of ${chunks.length} passes returned — findings in the missing pass(es) keep their filed severity and sit unranked at the end.`);
1445
+ }
1446
+ return { chunks, results: ok.length === chunks.length ? results : results, partial: ok.length < chunks.length };
1447
+ }
1448
+
1211
1449
  // M75 chunked formatter: returns an ARRAY of markdown chunks, each ≤ ~30KB, so each
1212
1450
  // can be written through one bounded agent prompt WITHOUT truncation (a single write
1213
1451
  // of a 466KB register truncates at ~165KB — verified). Chunk 0 is the header+summary
@@ -1244,20 +1482,101 @@ function typeOf(f) {
1244
1482
  return "Other";
1245
1483
  }
1246
1484
  const TYPE_ORDER = ["Security / Vulnerability", "Dead Code", "Duplication", "Data Integrity / Concurrency", "Performance", "Contract Drift", "Testing", "Other"];
1247
- const SEV_ORDER2 = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
1248
- // Stable sort: severity, then type, then original index. Computed ONCE; both consumers use it.
1485
+ const SEV_ORDER2 = { EXTREME: 0, CRITICAL: 1, HIGH: 2, MEDIUM: 3, LOW: 4 };
1486
+
1487
+ // The architect runs HERE — before anything is numbered. Its output re-tiers the
1488
+ // findings and decides their order; TD numbers then follow that order, so TD-1 is
1489
+ // the most urgent thing in the codebase rather than whatever a slice finished
1490
+ // first. See [RULE] architect-ranks-before-numbering above.
1491
+ phase("Architect");
1492
+ const architect = await architectPass(finalFindings);
1493
+
1494
+ // Apply the architect's tiers and grouping back onto the findings.
1495
+ if (architect) {
1496
+ let retiered = 0, deadFound = 0, notDefect = 0;
1497
+ architect.chunks.forEach((chunk, ci) => {
1498
+ const res = architect.results[ci];
1499
+ if (!res) return; // that pass returned nothing; its findings keep their filed severity
1500
+ const roots = new Map();
1501
+ for (const r of (res.roots || [])) if (r && r.key) roots.set(r.key, r);
1502
+ for (const p of res.placements) {
1503
+ const f = chunk[p.index];
1504
+ if (!f) continue; // an index outside the batch is not a finding to place
1505
+ const tier = String(p.tier || "").toUpperCase();
1506
+ if (tier && tier !== f.severity) { f.severity = tier; retiered++; }
1507
+ if (p.deadCode) { f._deadCode = true; deadFound++; }
1508
+ if (p.notADefect) { f._notADefect = true; notDefect++; }
1509
+ if (p.rootKey) {
1510
+ const root = roots.get(p.rootKey);
1511
+ f._rootKey = p.rootKey;
1512
+ f._rootName = (root && root.name) || p.rootKey;
1513
+ f._rootFix = root && root.fix;
1514
+ f._rootWhy = root && root.why;
1515
+ f._rootRankReason = root && root.rankReason;
1516
+ f._rootRank = (root && Number.isInteger(root.rank)) ? root.rank : 99;
1517
+ f._rankInRoot = Number.isInteger(p.rankInRoot) ? p.rankInRoot : 99;
1518
+ }
1519
+ if (Array.isArray(p.alsoRoots) && p.alsoRoots.length) f._alsoRoots = p.alsoRoots;
1520
+ if (p.reason) f._archReason = p.reason;
1521
+ }
1522
+ });
1523
+ log(`architect: ${retiered} finding(s) re-tiered, ${deadFound} confirmed dead code, ${notDefect} judged not a defect`);
1524
+
1525
+ // Recount — the tiers just changed, so the header's numbers must follow.
1526
+ counts.critical = 0; counts.high = 0; counts.medium = 0; counts.low = 0; counts.extreme = 0;
1527
+ for (const f of finalFindings) {
1528
+ const s = String(f.severity || "").toUpperCase();
1529
+ if (s === "EXTREME") counts.extreme++;
1530
+ else if (s === "CRITICAL") counts.critical++;
1531
+ else if (s === "HIGH") counts.high++;
1532
+ else if (s === "MEDIUM") counts.medium++;
1533
+ else if (s === "LOW") counts.low++;
1534
+ }
1535
+ }
1536
+
1537
+ // Ordering — the single source of truth for both the register's TD numbering and
1538
+ // the consolidation stage's references.
1539
+ //
1540
+ // With an architect result: tier, then the root's rank within that tier, then the
1541
+ // finding's rank within its root — the order a person should work through them.
1542
+ // Confirmed dead code sinks below everything: it cannot cause a problem, so it
1543
+ // must not sit above things that can.
1544
+ //
1545
+ // Without one: the old severity-then-type-then-arrival order, which is arbitrary
1546
+ // inside a severity and was announced as such by architectPass().
1249
1547
  const orderedFindings = finalFindings
1250
1548
  .map((f, i) => ({ f, i, t: typeOf(f) }))
1251
1549
  .sort((a, b) => {
1252
- const sv = (SEV_ORDER2[a.f.severity] ?? 9) - (SEV_ORDER2[b.f.severity] ?? 9);
1550
+ const deadA = a.f._deadCode ? 1 : 0, deadB = b.f._deadCode ? 1 : 0;
1551
+ if (deadA !== deadB) return deadA - deadB;
1552
+ const sv = (SEV_ORDER2[String(a.f.severity || "").toUpperCase()] ?? 9)
1553
+ - (SEV_ORDER2[String(b.f.severity || "").toUpperCase()] ?? 9);
1253
1554
  if (sv !== 0) return sv;
1254
- const tv = TYPE_ORDER.indexOf(a.t) - TYPE_ORDER.indexOf(b.t);
1255
- if (tv !== 0) return tv;
1555
+ if (architect) {
1556
+ // A standalone finding ranks beside the roots, not after them: it is one
1557
+ // item the architect chose not to group, not a lesser item.
1558
+ const rr = (a.f._rootRank ?? 50) - (b.f._rootRank ?? 50);
1559
+ if (rr !== 0) return rr;
1560
+ const ri = (a.f._rankInRoot ?? 50) - (b.f._rankInRoot ?? 50);
1561
+ if (ri !== 0) return ri;
1562
+ } else {
1563
+ const tv = TYPE_ORDER.indexOf(a.t) - TYPE_ORDER.indexOf(b.t);
1564
+ if (tv !== 0) return tv;
1565
+ }
1256
1566
  return a.i - b.i;
1257
1567
  });
1258
1568
 
1569
+ // Every finding must survive the ordering. A sort cannot lose one, but the
1570
+ // architect's placement loop can only be trusted if this is checked rather than
1571
+ // assumed — a merge in the same family reported full coverage while having
1572
+ // dropped two.
1573
+ if (orderedFindings.length !== finalFindings.length) {
1574
+ log(`⚠ ORDERING LOST FINDINGS: ${finalFindings.length} in, ${orderedFindings.length} out — the register would under-report. Halting.`);
1575
+ return { status: "failed", reason: "ordering-lost-findings", expected: finalFindings.length, got: orderedFindings.length };
1576
+ }
1577
+
1259
1578
  function fmtChunks(today) {
1260
- const sevHead = { CRITICAL: "🔴 Critical", HIGH: "🟠 High", MEDIUM: "🟡 Medium", LOW: "🟢 Low" };
1579
+ const sevHead = { EXTREME: "🔴 Extreme", CRITICAL: "🟠 Critical", HIGH: "🟡 High", MEDIUM: "🔵 Medium", LOW: "🟢 Low" };
1261
1580
  const head = [];
1262
1581
  head.push(`# Tech Debt Register - ${projectDir.split("/").pop()}`, "");
1263
1582
  if (scanNumber) head.push(`**Scan #${scanNumber}** - Deep codebase scan (runtime-native, ${coverageComplete ? "full coverage" : "PARTIAL coverage"})`);
@@ -1272,10 +1591,17 @@ function fmtChunks(today) {
1272
1591
  head.push(`> Effort estimates use GSD-T-native units (domain / wave / spawn / token-spend). Never human-hours.`);
1273
1592
  head.push(`> TD numbering continues from the prior register (if any, archived). This scan begins at **TD-${tdStart}**.`, "");
1274
1593
  if (!coverageComplete) head.push(`> ⚠️ **PARTIAL COVERAGE - ${failedSlices.length} of ${slices.length} codebase areas were NOT scanned this pass** (failed to return findings): ${ascii(failedSlices.join(", "))}. Findings UNDER-COUNT the real debt. Re-run (resume) for full coverage.`, "");
1275
- head.push(`## Summary`, "", `| Severity | Count |`, `|----------|-------|`,
1276
- `| 🔴 CRITICAL | ${counts.critical} |`, `| 🟠 HIGH | ${counts.high} |`,
1277
- `| 🟡 MEDIUM | ${counts.medium} |`, `| 🟢 LOW | ${counts.low} |`,
1594
+ head.push(`## Summary`, "", `| Severity | Count |`, `|----------|-------|`);
1595
+ // EXTREME only appears when the architect produced it. A row of zero on every
1596
+ // register of a healthy project is noise, and an absent row hides no count.
1597
+ if (counts.extreme) head.push(`| 🔴 EXTREME | ${counts.extreme} |`);
1598
+ head.push(
1599
+ `| 🟠 CRITICAL | ${counts.critical} |`, `| 🟡 HIGH | ${counts.high} |`,
1600
+ `| 🔵 MEDIUM | ${counts.medium} |`, `| 🟢 LOW | ${counts.low} |`,
1278
1601
  `| **Total** | **${counts.total}** |`, "", "---", "");
1602
+ if (counts.extreme) {
1603
+ head.push(`> **EXTREME** means it leaves wrong data behind, crosses a tenant boundary, moves money wrongly, or touches safety. CRITICAL means the feature simply does not work, with nothing bad left behind.`, "");
1604
+ }
1279
1605
 
1280
1606
  function itemMd(f, td) {
1281
1607
  const L = [`### TD-${td} - ${ascii(f.title) || "(untitled)"}`,
@@ -1291,17 +1617,34 @@ function fmtChunks(today) {
1291
1617
 
1292
1618
  const CHUNK_MAX = 30000;
1293
1619
  const chunks = [head.join("\n")];
1294
- let buf = "", n = tdStart, lastSev = null, lastType = null;
1620
+ let buf = "", n = tdStart, lastSev = null, lastType = null, lastRoot = null;
1295
1621
  const flush = () => { if (buf) { chunks.push(buf); buf = ""; } };
1296
1622
  // Consume the shared `orderedFindings` (severity → type → original-index) so the
1297
1623
  // TD numbers assigned here are IDENTICAL to those the consolidation stage references.
1298
1624
  for (const { f, t } of orderedFindings) {
1299
1625
  let piece = "";
1300
- if (f.severity !== lastSev) { piece += `\n## ${sevHead[f.severity] || f.severity} Priority\n\n`; lastSev = f.severity; lastType = null; }
1626
+ if (f.severity !== lastSev) { piece += `\n## ${sevHead[f.severity] || f.severity} Priority\n\n`; lastSev = f.severity; lastType = null; lastRoot = null; }
1301
1627
  // Type sub-heading uses a bold marker line (NOT `###`) so it never collides with
1302
1628
  // the `### TD-N` item headings that downstream tools grep for. ASCII hyphens only
1303
1629
  // (M76: no em/en-dashes in fmtChunks literals).
1304
- if (t !== lastType) { piece += `**-- ${t} --**\n\n`; lastType = t; }
1630
+ // Root grouping is the fix unit. When the architect grouped the findings, the
1631
+ // register shows the root and its one-line cause/fix/rank above its members,
1632
+ // because scheduling the members separately produces separate half-fixes of
1633
+ // one defect. Bullets sit in <sub> so the eye lands on the finding titles.
1634
+ // Without an architect result there are no roots, and the old type marker
1635
+ // still separates the sections.
1636
+ if (f._rootKey && f._rootKey !== lastRoot) {
1637
+ piece += `\n### ${f._rootKey} - ${ascii(f._rootName) || f._rootKey}\n\n<sub>\n\n`;
1638
+ if (f._rootWhy) piece += `- **Cause:** ${ascii(f._rootWhy)}\n`;
1639
+ if (f._rootFix) piece += `- **Fix:** ${ascii(f._rootFix)}\n`;
1640
+ if (f._rootRankReason) piece += `- **Rank:** ${ascii(f._rootRankReason)}\n`;
1641
+ piece += `\n</sub>\n\n`;
1642
+ lastRoot = f._rootKey; lastType = null;
1643
+ } else if (!f._rootKey && lastRoot) {
1644
+ piece += `\n### Standalone\n\n`;
1645
+ lastRoot = null; lastType = null;
1646
+ }
1647
+ if (!f._rootKey && t !== lastType) { piece += `**-- ${t} --**\n\n`; lastType = t; }
1305
1648
  piece += itemMd(f, n++);
1306
1649
  if (buf.length + piece.length > CHUNK_MAX) flush();
1307
1650
  buf += piece;
@@ -1557,7 +1900,25 @@ log(`document phase: ${docsOk.length}/${docTargets.length} written/merged${docsF
1557
1900
  // then ASSEMBLE deterministically with severity section headers, and chunk-write.
1558
1901
  phase("Plain-English");
1559
1902
  const peTarget = `${projectDir}/.gsd-t/techdebt_in_plain_english.md`; // internal fixed name (shared copy suffixed in share/)
1560
- const sevLabel = { CRITICAL: "fix before launch", HIGH: "fix soon", MEDIUM: "schedule", LOW: "clean up eventually" };
1903
+ // [RULE] severity-label-never-assumes-unlaunched
1904
+ //
1905
+ // "fix before launch" was wrong on every scan of a system already serving
1906
+ // customers — which is most of them. A register handed to the owner of a live
1907
+ // product that dates its own advice to before go-live reads as boilerplate, and
1908
+ // boilerplate is skipped.
1909
+ //
1910
+ // One phrase per tier, defined here only. The plain-English companion took its
1911
+ // labels from this map and then drifted: the hilo-figma-atos file carried twelve
1912
+ // different phrasings for four tiers ("Worth scheduling" / "Worth scheduling
1913
+ // soon" / "Should be scheduled soon" / "Can be scheduled at normal priority"),
1914
+ // plus casing variants, and only 36 of 61 criticals were labelled at all.
1915
+ const sevLabel = {
1916
+ EXTREME: "immediate priority",
1917
+ CRITICAL: "fix soon",
1918
+ HIGH: "schedule this cycle",
1919
+ MEDIUM: "clean up eventually",
1920
+ LOW: "clean up eventually",
1921
+ };
1561
1922
  // Attach the deterministic TD number (matches the register: severity-sorted, tdStart+).
1562
1923
  const peItems = finalFindings.map((f, i) => ({
1563
1924
  td: tdStart + i, severity: f.severity, title: ascii(f.title),
@@ -1577,12 +1938,23 @@ const peResults = await parallel(peBatches.map((batch, bi) => async () => {
1577
1938
  `**What it is.** <1-2 sentences, no jargon; define any unavoidable term in parentheses>`,
1578
1939
  `**Why it matters.** <business/user consequence>`,
1579
1940
  `**Real-world analogy.** <a concrete everyday comparison that genuinely maps to THIS issue>`,
1580
- `**Severity.** <the plain-urgency phrase given per item>`,
1941
+ `**Severity.** <the item's \`severityPhrase\`, copied EXACTLY, capitalised, ending with a full stop — e.g. "Immediate priority.">`,
1942
+ `Use that phrase VERBATIM. Do not reword it, do not add "soon"/"eventually"/"worth", do not invent a variant. Four phrases exist and no others: "Immediate priority.", "Fix soon.", "Schedule this cycle.", "Clean up eventually." A reader scanning for what to do next is reading the phrase, not the sentence around it, so a rewording makes two identical priorities look different.`,
1943
+ `NEVER write "fix before launch" or any wording implying the system has not launched — most scanned systems are already live and serving customers.`,
1581
1944
  `Keep the td number EXACTLY. ASCII punctuation only (hyphens, straight quotes — NO em-dashes/smart-quotes/ellipsis). No preamble.`,
1582
1945
  ``,
1583
1946
  `Findings (batch ${bi + 1}/${peBatches.length}):`,
1584
1947
  "```json",
1585
- JSON.stringify(batch.map((it) => ({ ...it, severityPhrase: sevLabel[it.severity] || "review" }))),
1948
+ // A severity with no phrase is a bug in the map, not a finding to label
1949
+ // "review" — the old default quietly turned an unrecognised tier into a word
1950
+ // that says nothing, and read as deliberate. Normalised for case first,
1951
+ // because a finder that types "Critical" must not fall through.
1952
+ JSON.stringify(batch.map((it) => {
1953
+ const tier = String(it.severity || "").toUpperCase();
1954
+ const phrase = sevLabel[tier];
1955
+ if (!phrase) log(`⚠ severity "${it.severity}" has no label in sevLabel — ${it.title || "a finding"} will be labelled by its tier name`);
1956
+ return { ...it, severityPhrase: phrase || tier.toLowerCase() || "unrated" };
1957
+ })),
1586
1958
  "```",
1587
1959
  ].join("\n");
1588
1960
  try {