@tekyzinc/gsd-t 5.11.29 → 5.11.31

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,106 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [5.11.31] - 2026-08-11
6
+
7
+ ### Added — the scanner now ranks its own findings before it numbers them
8
+
9
+ A 492-finding scan of hilo-figma-atos was grouped by severity and ordered by
10
+ discovery inside each group, so "fix the criticals in order" gave you a dead
11
+ marketing page before the missing database access rules on 193 tenant tables.
12
+ Run by hand over that register, an architect pass changed the answer enough to
13
+ become a permanent stage:
14
+
15
+ - The worst defect in the codebase was filed HIGH. Account credits that cover a
16
+ whole invoice are never marked used, so the same credit is given away again
17
+ every month, forever. It sat at position 127.
18
+ - A typo in one text box silently routes every real card payment to the practice
19
+ gateway, where charges report success and no money moves. Filed MEDIUM.
20
+ - Every school's signed legal agreements are downloadable by any other school.
21
+ Filed LOW.
22
+ - A fabricated thunderstorm advisory for a named airport is shown to pilots on
23
+ every page. Filed MEDIUM.
24
+ - 22 findings were confirmed unreachable code, carried as risk.
25
+
26
+ And the work changed shape: 492 findings collapsed into 28 root causes, with 144
27
+ of the 328 medium/low findings attaching to a root that already existed. The
28
+ codebase does not have 492 problems; it has about 28, most repeated dozens of
29
+ times. Scheduled individually, that produces dozens of half-fixes of one defect.
30
+
31
+ **Four changes, all measured on that run:**
32
+
33
+ **Architect stage** (`phase("Architect")`, after Synthesis, before ordering) —
34
+ re-tiers by consequence, groups by root cause, ranks roots within tier and
35
+ members within root, and marks confirmed dead code so it sinks below live
36
+ findings. TD numbers are assigned AFTER it, so TD-1 is the most urgent thing in
37
+ the codebase. Adds an EXTREME tier: leaves wrong data behind, crosses a tenant
38
+ boundary, moves money wrongly, or touches safety — as against CRITICAL, where
39
+ the feature merely does not work. Carries the dynamic-import warning that nearly
40
+ cost a live credit-card form its rating, and halts if the ordering loses a
41
+ finding.
42
+
43
+ **Verification is batched, 10 findings per agent.** Measured head to head on the
44
+ same 20 findings spanning all four severities: batched caught 1 false positive
45
+ and 2 severity corrections against the individual arm's 0 and 1, for 71 fewer
46
+ characters of evidence and no changed verdict. Severity is comparative — an agent
47
+ seeing findings together can rank them, one seeing a single finding confirms
48
+ whatever it was handed. 492 findings went from ~570 verifier agents to ~50.
49
+
50
+ **Design-export snapshots are left out of slicing**, and named in the plan.
51
+ `.figma-make-exports/` held 1,532 source files and 968,597 lines on that project
52
+ — six near-identical copies of a design prototype, read by finders as if they
53
+ were the product, for two findings that were both about the folder itself. That
54
+ is roughly 19 slices of finder-and-verifier work.
55
+
56
+ **One severity phrase per tier.** The plain-English companion labelled all 61
57
+ criticals "fix before launch" for a system already serving customers, and had
58
+ drifted to twelve phrasings for four tiers. An unmapped tier is now announced
59
+ rather than silently labelled "review".
60
+
61
+ - `templates/workflows/gsd-t-scan.workflow.js`: architect stage + schema, batched
62
+ verify, tier-derived labels, EXTREME throughout.
63
+ - `bin/gsd-t-slice-budget.cjs`: snapshot-directory exclusion, reported by name.
64
+ - `test/m112-architect-stage.test.js`, `test/m112-severity-labels.test.js`: 15 new
65
+ regressions; slice-budget and schema-tolerance suites extended.
66
+
67
+ Two existing tests were pinned to variable names rather than behaviour and failed
68
+ on a change that preserved what they protect; both now assert the property.
69
+
70
+ ## [5.11.30] - 2026-08-11
71
+
72
+ ### Fixed — the scan crashed before any finder ran: `slices is not defined`
73
+
74
+ The Atos scan died after 4 agents (preflight, probe, graph-wiring) with a
75
+ JavaScript reference error. Nothing was written; no finder ever started.
76
+
77
+ 618: slices = budgetPlan.slices; // never declared at this scope
78
+
79
+ v5.11.26 added that assignment to a bare name. v5.11.27 then added a
80
+ `const slices` INSIDE a helper function — a different scope entirely, which made
81
+ the name look declared to anyone skimming the file. The workflow sandbox runs in
82
+ strict mode, where assigning to an undeclared name throws.
83
+
84
+ `slices` is now declared where the code that uses it runs, starting as the
85
+ probe's own carve so the budget-failed branch needs no assignment at all.
86
+
87
+ **Nothing caught this, which is the more important half.** `node --check` parses
88
+ an undeclared assignment happily — it is legal syntax, and only strict mode makes
89
+ it an error, at runtime. The sandbox lint checks banned requires and `args`
90
+ handling, not scope. No test executes this path, because the workflow only runs
91
+ against a real project.
92
+
93
+ - `templates/workflows/gsd-t-scan.workflow.js`: `let slices = rawSlices` at the
94
+ scope that runs them.
95
+ - `test/m112-workflow-undeclared-assignment.test.js`: a static check over every
96
+ workflow for an assignment to a name its scope never declares, with a
97
+ function-body map so an `if`/`else` block is not mistaken for a nested scope.
98
+
99
+ The check was itself wrong twice before it worked, and the meta-test is what
100
+ caught it: the first version tested a hand-written sample with the offending line
101
+ at column zero and passed while missing the real bug, which is indented two
102
+ spaces inside an `if`. Verified against the actual shipped file — it reports
103
+ line 618 there and passes the fixed one.
104
+
5
105
  ## [5.11.29] - 2026-08-11
6
106
 
7
107
  ### Fixed — 20 of 27 projects had no usable code graph, and nothing said so
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v5.11.29** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v5.11.31** - 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.
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "5.11.29",
3
+ "version": "5.11.31",
4
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",
5
5
  "author": "Tekyz, Inc.",
6
6
  "license": "MIT",
@@ -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
@@ -602,6 +651,21 @@ const budgetPlan = await runCli(
602
651
  "slice-budget"
603
652
  );
604
653
 
654
+ // What the finders will actually run. Declared HERE, at the scope that uses it.
655
+ //
656
+ // [RULE] slices-declared-at-the-scope-that-runs-them
657
+ //
658
+ // v5.11.26 assigned to a bare `slices` that was never declared anywhere, and
659
+ // v5.11.27 added a `const slices` inside probePlaceholderFaults() — a different
660
+ // scope entirely. The workflow sandbox runs in strict mode, so the assignment
661
+ // below threw `slices is not defined` and killed the Atos scan after 4 agents,
662
+ // before a single finder ran. Nothing had checked it: `node --check` parses an
663
+ // undeclared assignment happily, and no test executed this path.
664
+ //
665
+ // It starts as the probe's own slices, so the failure branch below needs no
666
+ // assignment — an unmeasured plan still runs what the probe carved.
667
+ let slices = rawSlices;
668
+
605
669
  if (budgetPlan && budgetPlan.ok && Array.isArray(budgetPlan.slices) && budgetPlan.slices.length) {
606
670
  const a = budgetPlan.after || {};
607
671
  if (budgetPlan.slices.length > rawSlices.length) {
@@ -973,44 +1037,81 @@ async function scanSlice(slice) {
973
1037
  if (verifyMode === "none" || finderResult.findings.length === 0) {
974
1038
  return { slice: sliceKey, findings: finderResult.findings || [], failed: false };
975
1039
  }
976
- // Fan out ALL verifies for this slice the global gate (not a per-slice limit)
977
- // bounds total in-flight, so this is safe AND keeps every worker slot busy.
978
- const verified = await parallel(
979
- finderResult.findings.map((f) => async () => {
980
- try {
981
- const verifyPrompt = [
982
- `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.`,
983
- `Finding: ${JSON.stringify(f)}`,
984
- `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.`,
985
- SHAPE_RULE,
986
- ].join("\n");
987
-
988
- // Verify had NO retry: one wrapped call and the finding went through
989
- // unverified. Same escalation as the finder, one step shorter — a lost
990
- // verdict costs one finding, not a whole slice.
991
- let v = await gatedAgent(verifyPrompt, {
992
- label: `verify:${sliceKey}`, phase: "Deep Scan", schema: VERIFY_SCHEMA, model: "sonnet",
993
- });
994
- if (!v) {
995
- v = await gatedAgent(verifyPrompt + UNWRAP_HINT, {
996
- label: `verify:${sliceKey} (retry on opus)`, phase: "Deep Scan",
997
- schema: VERIFY_SCHEMA, model: "opus",
998
- });
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",
999
1077
  }
1000
- // Compared case-INSENSITIVELY: the schema now accepts "false-positive"
1001
- // 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
1002
1095
  // verifier had rejected.
1003
- const verdict = String(v && v.verdict || "").toLowerCase();
1004
- if (!v || verdict === "false-positive" || v.confirmed === false) return null;
1005
- // 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
1006
1100
  // reads consistently no matter how a finder typed it.
1007
- const sev = String(v.correctedSeverity || f.severity || "").toUpperCase();
1008
- return { ...f, severity: sev, _verify: verdict };
1009
- } catch (e) {
1010
- return { ...f, _verify: "verify-errored" };
1011
- }
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
+ });
1012
1106
  })
1013
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
+ );
1014
1115
  return { slice: sliceKey, findings: verified.filter(Boolean), failed: false };
1015
1116
  }
1016
1117
 
@@ -1166,7 +1267,9 @@ if (allFindings.length > 1) {
1166
1267
  }
1167
1268
 
1168
1269
  // (a)+(c) Deterministically merge dups, sort by severity, assign TD numbers, format.
1169
- 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 };
1170
1273
  const dropped = new Set();
1171
1274
  const merged = [];
1172
1275
  for (const group of mergeGroups) {
@@ -1193,6 +1296,151 @@ for (const f of finalFindings) {
1193
1296
  counts.total = finalFindings.length;
1194
1297
 
1195
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
+ name: { type: "string", description: "plain-English name of the shared cause" },
1313
+ why: { type: "string", description: "why these are one cause, with code evidence" },
1314
+ fix: { type: "string", description: "the single fix that closes them" },
1315
+ tier: { type: "string", enum: ["EXTREME", "CRITICAL", "HIGH", "MEDIUM", "LOW", "extreme", "critical", "high", "medium", "low"] },
1316
+ rank: { type: "integer", description: "rank among roots of the same tier, 1 = worst" },
1317
+ rankReason:{ type: "string" },
1318
+ },
1319
+ },
1320
+ },
1321
+ placements: {
1322
+ type: "array",
1323
+ minItems: 1,
1324
+ items: {
1325
+ type: "object",
1326
+ required: ["index", "tier"],
1327
+ additionalProperties: true,
1328
+ properties: {
1329
+ index: { type: "integer", description: "the finding's index in the list given" },
1330
+ tier: { type: "string", enum: ["EXTREME", "CRITICAL", "HIGH", "MEDIUM", "LOW", "extreme", "critical", "high", "medium", "low"] },
1331
+ rootKey: { type: "string", description: "the root this belongs to, or omitted if standalone" },
1332
+ rankInRoot: { type: "integer", description: "rank among that root's members, 1 = worst" },
1333
+ alsoRoots: { type: "array", items: { type: "string" }, description: "other roots that also cause this" },
1334
+ deadCode: { type: "boolean", description: "true ONLY when confirmed unreachable — say which checks in `note`" },
1335
+ notADefect: { type: "boolean", description: "true when this is not a real defect" },
1336
+ reason: { type: "string", description: "one line, grounded in consequence" },
1337
+ note: { type: "string" },
1338
+ },
1339
+ },
1340
+ },
1341
+ },
1342
+ };
1343
+
1344
+ // ─── Architect — re-tier by consequence, group by root cause, rank ──────────
1345
+ //
1346
+ // [RULE] architect-ranks-before-numbering
1347
+ //
1348
+ // Run by hand over the hilo-figma-atos register on 2026-08-11, and it changed
1349
+ // the answer enough to become a permanent stage:
1350
+ //
1351
+ // · The worst finding in the codebase was filed HIGH. Account credits that
1352
+ // cover a whole invoice are never marked used, so the same credit is given
1353
+ // away again every month, forever. It sat at position 127.
1354
+ // · A typo in one text box silently routes every real card payment to the
1355
+ // practice gateway, where charges report success and no money moves. Filed
1356
+ // MEDIUM.
1357
+ // · Every school's signed legal agreements are downloadable by any other
1358
+ // school. Filed LOW.
1359
+ // · A fabricated thunderstorm advisory for a named airport is shown to pilots
1360
+ // on every page. Filed MEDIUM.
1361
+ // · 22 findings were genuinely unreachable code — carried as risk when they
1362
+ // cannot execute.
1363
+ //
1364
+ // And the shape of the work changed: 492 findings collapsed into 28 root causes.
1365
+ // 144 of the 328 medium/low findings attached to a root that already existed,
1366
+ // and only 7 new causes were needed. The codebase does not have 492 problems; it
1367
+ // has about 28, most of them repeated dozens of times. Scheduling the findings
1368
+ // individually produces dozens of half-fixes of one defect.
1369
+ //
1370
+ // Two things make this stage worth its cost, and both were measured, not assumed:
1371
+ // 1. Severity assigned per-finding is unreliable, because severity is
1372
+ // COMPARATIVE. An agent seeing findings together ranks them; an agent
1373
+ // seeing one finding alone confirms whatever it was handed.
1374
+ // 2. TD numbers must be assigned AFTER this, or the register's numbering
1375
+ // encodes the order slices happened to finish.
1376
+ async function architectPass(findings) {
1377
+ if (!findings.length) return null;
1378
+
1379
+ // One agent per ~150 findings — the size that held its judgment in the manual
1380
+ // run. More than that and the later findings get thinner treatment; fewer and
1381
+ // the agent loses the comparison that makes ranking possible.
1382
+ const ARCH_BATCH = 150;
1383
+ const chunks = [];
1384
+ for (let i = 0; i < findings.length; i += ARCH_BATCH) chunks.push(findings.slice(i, i + ARCH_BATCH));
1385
+
1386
+ log(`architect: ${findings.length} findings across ${chunks.length} pass(es) — re-tier by consequence, group by root cause, rank`);
1387
+
1388
+ const TIER_RULES = [
1389
+ `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?`,
1390
+ ` 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).`,
1391
+ ` 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.`,
1392
+ ` HIGH — real, should be fixed, neither of the above.`,
1393
+ ` MEDIUM / LOW — confirm as filed.`,
1394
+ ` DEAD CODE is NOT a risk tier. Genuinely unreachable code cannot cause a problem — mark it dead and it moves to a cleanup list.`,
1395
+ ``,
1396
+ `⚠ 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.`,
1397
+ ].join("\n");
1398
+
1399
+ const results = await parallel(chunks.map((chunk, ci) => async () => {
1400
+ const listing = chunk.map((f, i) =>
1401
+ `[${i}] severity=${f.severity} area=${ascii(f.area) || "?"} | ${ascii(f.title)} | at ${(f.files && f.files.join(", ")) || "?"} | ${ascii(f.description || "").slice(0, 400)}`
1402
+ ).join("\n");
1403
+
1404
+ const prompt = [
1405
+ `⛔ 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.`,
1406
+ ``,
1407
+ `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.`,
1408
+ ``,
1409
+ TIER_RULES,
1410
+ ``,
1411
+ `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.`,
1412
+ ``,
1413
+ `FINDINGS:`,
1414
+ listing,
1415
+ ``,
1416
+ `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. For each root give a plain-English name, why they are one cause (with code evidence), the single fix, and its members by index. A root with ONE member is not a group; leave it standalone. A root's tier is the tier of its WORST member.`,
1417
+ `Rank roots within their tier by worst consequence, and members within a root by consequence. RISK order, never the order the findings arrived.`,
1418
+ ``,
1419
+ `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.`,
1420
+ SHAPE_RULE,
1421
+ ].join("\n");
1422
+
1423
+ const r = await gatedAgent(prompt, {
1424
+ label: `architect ${ci + 1}/${chunks.length}`, phase: "Architect",
1425
+ schema: ARCHITECT_SCHEMA, model: "opus",
1426
+ });
1427
+ return (r && Array.isArray(r.placements)) ? r : null;
1428
+ }));
1429
+
1430
+ const ok = results.filter(Boolean);
1431
+ if (!ok.length) {
1432
+ // Nothing to rank with. The register is still written, in its filed order —
1433
+ // said out loud, because a register that looks ranked and is not is worse
1434
+ // than one that never claimed to be.
1435
+ log(`⚠ ARCHITECT PRODUCED NOTHING — the register keeps its filed severities and discovery order. It is NOT prioritised; treat its ordering as arbitrary.`);
1436
+ return null;
1437
+ }
1438
+ if (ok.length < chunks.length) {
1439
+ 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.`);
1440
+ }
1441
+ return { chunks, results: ok.length === chunks.length ? results : results, partial: ok.length < chunks.length };
1442
+ }
1443
+
1196
1444
  // M75 chunked formatter: returns an ARRAY of markdown chunks, each ≤ ~30KB, so each
1197
1445
  // can be written through one bounded agent prompt WITHOUT truncation (a single write
1198
1446
  // of a 466KB register truncates at ~165KB — verified). Chunk 0 is the header+summary
@@ -1229,20 +1477,99 @@ function typeOf(f) {
1229
1477
  return "Other";
1230
1478
  }
1231
1479
  const TYPE_ORDER = ["Security / Vulnerability", "Dead Code", "Duplication", "Data Integrity / Concurrency", "Performance", "Contract Drift", "Testing", "Other"];
1232
- const SEV_ORDER2 = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
1233
- // Stable sort: severity, then type, then original index. Computed ONCE; both consumers use it.
1480
+ const SEV_ORDER2 = { EXTREME: 0, CRITICAL: 1, HIGH: 2, MEDIUM: 3, LOW: 4 };
1481
+
1482
+ // The architect runs HERE — before anything is numbered. Its output re-tiers the
1483
+ // findings and decides their order; TD numbers then follow that order, so TD-1 is
1484
+ // the most urgent thing in the codebase rather than whatever a slice finished
1485
+ // first. See [RULE] architect-ranks-before-numbering above.
1486
+ phase("Architect");
1487
+ const architect = await architectPass(finalFindings);
1488
+
1489
+ // Apply the architect's tiers and grouping back onto the findings.
1490
+ if (architect) {
1491
+ let retiered = 0, deadFound = 0, notDefect = 0;
1492
+ architect.chunks.forEach((chunk, ci) => {
1493
+ const res = architect.results[ci];
1494
+ if (!res) return; // that pass returned nothing; its findings keep their filed severity
1495
+ const roots = new Map();
1496
+ for (const r of (res.roots || [])) if (r && r.key) roots.set(r.key, r);
1497
+ for (const p of res.placements) {
1498
+ const f = chunk[p.index];
1499
+ if (!f) continue; // an index outside the batch is not a finding to place
1500
+ const tier = String(p.tier || "").toUpperCase();
1501
+ if (tier && tier !== f.severity) { f.severity = tier; retiered++; }
1502
+ if (p.deadCode) { f._deadCode = true; deadFound++; }
1503
+ if (p.notADefect) { f._notADefect = true; notDefect++; }
1504
+ if (p.rootKey) {
1505
+ const root = roots.get(p.rootKey);
1506
+ f._rootKey = p.rootKey;
1507
+ f._rootName = (root && root.name) || p.rootKey;
1508
+ f._rootFix = root && root.fix;
1509
+ f._rootRank = (root && Number.isInteger(root.rank)) ? root.rank : 99;
1510
+ f._rankInRoot = Number.isInteger(p.rankInRoot) ? p.rankInRoot : 99;
1511
+ }
1512
+ if (Array.isArray(p.alsoRoots) && p.alsoRoots.length) f._alsoRoots = p.alsoRoots;
1513
+ if (p.reason) f._archReason = p.reason;
1514
+ }
1515
+ });
1516
+ log(`architect: ${retiered} finding(s) re-tiered, ${deadFound} confirmed dead code, ${notDefect} judged not a defect`);
1517
+
1518
+ // Recount — the tiers just changed, so the header's numbers must follow.
1519
+ counts.critical = 0; counts.high = 0; counts.medium = 0; counts.low = 0; counts.extreme = 0;
1520
+ for (const f of finalFindings) {
1521
+ const s = String(f.severity || "").toUpperCase();
1522
+ if (s === "EXTREME") counts.extreme++;
1523
+ else if (s === "CRITICAL") counts.critical++;
1524
+ else if (s === "HIGH") counts.high++;
1525
+ else if (s === "MEDIUM") counts.medium++;
1526
+ else if (s === "LOW") counts.low++;
1527
+ }
1528
+ }
1529
+
1530
+ // Ordering — the single source of truth for both the register's TD numbering and
1531
+ // the consolidation stage's references.
1532
+ //
1533
+ // With an architect result: tier, then the root's rank within that tier, then the
1534
+ // finding's rank within its root — the order a person should work through them.
1535
+ // Confirmed dead code sinks below everything: it cannot cause a problem, so it
1536
+ // must not sit above things that can.
1537
+ //
1538
+ // Without one: the old severity-then-type-then-arrival order, which is arbitrary
1539
+ // inside a severity and was announced as such by architectPass().
1234
1540
  const orderedFindings = finalFindings
1235
1541
  .map((f, i) => ({ f, i, t: typeOf(f) }))
1236
1542
  .sort((a, b) => {
1237
- const sv = (SEV_ORDER2[a.f.severity] ?? 9) - (SEV_ORDER2[b.f.severity] ?? 9);
1543
+ const deadA = a.f._deadCode ? 1 : 0, deadB = b.f._deadCode ? 1 : 0;
1544
+ if (deadA !== deadB) return deadA - deadB;
1545
+ const sv = (SEV_ORDER2[String(a.f.severity || "").toUpperCase()] ?? 9)
1546
+ - (SEV_ORDER2[String(b.f.severity || "").toUpperCase()] ?? 9);
1238
1547
  if (sv !== 0) return sv;
1239
- const tv = TYPE_ORDER.indexOf(a.t) - TYPE_ORDER.indexOf(b.t);
1240
- if (tv !== 0) return tv;
1548
+ if (architect) {
1549
+ // A standalone finding ranks beside the roots, not after them: it is one
1550
+ // item the architect chose not to group, not a lesser item.
1551
+ const rr = (a.f._rootRank ?? 50) - (b.f._rootRank ?? 50);
1552
+ if (rr !== 0) return rr;
1553
+ const ri = (a.f._rankInRoot ?? 50) - (b.f._rankInRoot ?? 50);
1554
+ if (ri !== 0) return ri;
1555
+ } else {
1556
+ const tv = TYPE_ORDER.indexOf(a.t) - TYPE_ORDER.indexOf(b.t);
1557
+ if (tv !== 0) return tv;
1558
+ }
1241
1559
  return a.i - b.i;
1242
1560
  });
1243
1561
 
1562
+ // Every finding must survive the ordering. A sort cannot lose one, but the
1563
+ // architect's placement loop can only be trusted if this is checked rather than
1564
+ // assumed — a merge in the same family reported full coverage while having
1565
+ // dropped two.
1566
+ if (orderedFindings.length !== finalFindings.length) {
1567
+ log(`⚠ ORDERING LOST FINDINGS: ${finalFindings.length} in, ${orderedFindings.length} out — the register would under-report. Halting.`);
1568
+ return { status: "failed", reason: "ordering-lost-findings", expected: finalFindings.length, got: orderedFindings.length };
1569
+ }
1570
+
1244
1571
  function fmtChunks(today) {
1245
- const sevHead = { CRITICAL: "🔴 Critical", HIGH: "🟠 High", MEDIUM: "🟡 Medium", LOW: "🟢 Low" };
1572
+ const sevHead = { EXTREME: "🔴 Extreme", CRITICAL: "🟠 Critical", HIGH: "🟡 High", MEDIUM: "🔵 Medium", LOW: "🟢 Low" };
1246
1573
  const head = [];
1247
1574
  head.push(`# Tech Debt Register - ${projectDir.split("/").pop()}`, "");
1248
1575
  if (scanNumber) head.push(`**Scan #${scanNumber}** - Deep codebase scan (runtime-native, ${coverageComplete ? "full coverage" : "PARTIAL coverage"})`);
@@ -1257,10 +1584,17 @@ function fmtChunks(today) {
1257
1584
  head.push(`> Effort estimates use GSD-T-native units (domain / wave / spawn / token-spend). Never human-hours.`);
1258
1585
  head.push(`> TD numbering continues from the prior register (if any, archived). This scan begins at **TD-${tdStart}**.`, "");
1259
1586
  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.`, "");
1260
- head.push(`## Summary`, "", `| Severity | Count |`, `|----------|-------|`,
1261
- `| 🔴 CRITICAL | ${counts.critical} |`, `| 🟠 HIGH | ${counts.high} |`,
1262
- `| 🟡 MEDIUM | ${counts.medium} |`, `| 🟢 LOW | ${counts.low} |`,
1587
+ head.push(`## Summary`, "", `| Severity | Count |`, `|----------|-------|`);
1588
+ // EXTREME only appears when the architect produced it. A row of zero on every
1589
+ // register of a healthy project is noise, and an absent row hides no count.
1590
+ if (counts.extreme) head.push(`| 🔴 EXTREME | ${counts.extreme} |`);
1591
+ head.push(
1592
+ `| 🟠 CRITICAL | ${counts.critical} |`, `| 🟡 HIGH | ${counts.high} |`,
1593
+ `| 🔵 MEDIUM | ${counts.medium} |`, `| 🟢 LOW | ${counts.low} |`,
1263
1594
  `| **Total** | **${counts.total}** |`, "", "---", "");
1595
+ if (counts.extreme) {
1596
+ 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.`, "");
1597
+ }
1264
1598
 
1265
1599
  function itemMd(f, td) {
1266
1600
  const L = [`### TD-${td} - ${ascii(f.title) || "(untitled)"}`,
@@ -1542,7 +1876,25 @@ log(`document phase: ${docsOk.length}/${docTargets.length} written/merged${docsF
1542
1876
  // then ASSEMBLE deterministically with severity section headers, and chunk-write.
1543
1877
  phase("Plain-English");
1544
1878
  const peTarget = `${projectDir}/.gsd-t/techdebt_in_plain_english.md`; // internal fixed name (shared copy suffixed in share/)
1545
- const sevLabel = { CRITICAL: "fix before launch", HIGH: "fix soon", MEDIUM: "schedule", LOW: "clean up eventually" };
1879
+ // [RULE] severity-label-never-assumes-unlaunched
1880
+ //
1881
+ // "fix before launch" was wrong on every scan of a system already serving
1882
+ // customers — which is most of them. A register handed to the owner of a live
1883
+ // product that dates its own advice to before go-live reads as boilerplate, and
1884
+ // boilerplate is skipped.
1885
+ //
1886
+ // One phrase per tier, defined here only. The plain-English companion took its
1887
+ // labels from this map and then drifted: the hilo-figma-atos file carried twelve
1888
+ // different phrasings for four tiers ("Worth scheduling" / "Worth scheduling
1889
+ // soon" / "Should be scheduled soon" / "Can be scheduled at normal priority"),
1890
+ // plus casing variants, and only 36 of 61 criticals were labelled at all.
1891
+ const sevLabel = {
1892
+ EXTREME: "immediate priority",
1893
+ CRITICAL: "fix soon",
1894
+ HIGH: "schedule this cycle",
1895
+ MEDIUM: "clean up eventually",
1896
+ LOW: "clean up eventually",
1897
+ };
1546
1898
  // Attach the deterministic TD number (matches the register: severity-sorted, tdStart+).
1547
1899
  const peItems = finalFindings.map((f, i) => ({
1548
1900
  td: tdStart + i, severity: f.severity, title: ascii(f.title),
@@ -1562,12 +1914,23 @@ const peResults = await parallel(peBatches.map((batch, bi) => async () => {
1562
1914
  `**What it is.** <1-2 sentences, no jargon; define any unavoidable term in parentheses>`,
1563
1915
  `**Why it matters.** <business/user consequence>`,
1564
1916
  `**Real-world analogy.** <a concrete everyday comparison that genuinely maps to THIS issue>`,
1565
- `**Severity.** <the plain-urgency phrase given per item>`,
1917
+ `**Severity.** <the item's \`severityPhrase\`, copied EXACTLY, capitalised, ending with a full stop — e.g. "Immediate priority.">`,
1918
+ `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.`,
1919
+ `NEVER write "fix before launch" or any wording implying the system has not launched — most scanned systems are already live and serving customers.`,
1566
1920
  `Keep the td number EXACTLY. ASCII punctuation only (hyphens, straight quotes — NO em-dashes/smart-quotes/ellipsis). No preamble.`,
1567
1921
  ``,
1568
1922
  `Findings (batch ${bi + 1}/${peBatches.length}):`,
1569
1923
  "```json",
1570
- JSON.stringify(batch.map((it) => ({ ...it, severityPhrase: sevLabel[it.severity] || "review" }))),
1924
+ // A severity with no phrase is a bug in the map, not a finding to label
1925
+ // "review" — the old default quietly turned an unrecognised tier into a word
1926
+ // that says nothing, and read as deliberate. Normalised for case first,
1927
+ // because a finder that types "Critical" must not fall through.
1928
+ JSON.stringify(batch.map((it) => {
1929
+ const tier = String(it.severity || "").toUpperCase();
1930
+ const phrase = sevLabel[tier];
1931
+ if (!phrase) log(`⚠ severity "${it.severity}" has no label in sevLabel — ${it.title || "a finding"} will be labelled by its tier name`);
1932
+ return { ...it, severityPhrase: phrase || tier.toLowerCase() || "unrated" };
1933
+ })),
1571
1934
  "```",
1572
1935
  ].join("\n");
1573
1936
  try {