@tekyzinc/gsd-t 5.11.31 → 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,62 @@
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
+
5
61
  ## [5.11.31] - 2026-08-11
6
62
 
7
63
  ### Added — the scanner now ranks its own findings before it numbers them
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v5.11.31** - 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 };
@@ -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.31",
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
 
@@ -1309,12 +1309,16 @@ const ARCHITECT_SCHEMA = {
1309
1309
  additionalProperties: true,
1310
1310
  properties: {
1311
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" },
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." },
1315
1319
  tier: { type: "string", enum: ["EXTREME", "CRITICAL", "HIGH", "MEDIUM", "LOW", "extreme", "critical", "high", "medium", "low"] },
1316
1320
  rank: { type: "integer", description: "rank among roots of the same tier, 1 = worst" },
1317
- rankReason:{ type: "string" },
1321
+ rankReason:{ type: "string", maxLength: 220, description: "ONE sentence: why this root outranks the ones below it in its tier." },
1318
1322
  },
1319
1323
  },
1320
1324
  },
@@ -1413,7 +1417,8 @@ async function architectPass(findings) {
1413
1417
  `FINDINGS:`,
1414
1418
  listing,
1415
1419
  ``,
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.`,
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.`,
1417
1422
  `Rank roots within their tier by worst consequence, and members within a root by consequence. RISK order, never the order the findings arrived.`,
1418
1423
  ``,
1419
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.`,
@@ -1506,6 +1511,8 @@ if (architect) {
1506
1511
  f._rootKey = p.rootKey;
1507
1512
  f._rootName = (root && root.name) || p.rootKey;
1508
1513
  f._rootFix = root && root.fix;
1514
+ f._rootWhy = root && root.why;
1515
+ f._rootRankReason = root && root.rankReason;
1509
1516
  f._rootRank = (root && Number.isInteger(root.rank)) ? root.rank : 99;
1510
1517
  f._rankInRoot = Number.isInteger(p.rankInRoot) ? p.rankInRoot : 99;
1511
1518
  }
@@ -1610,17 +1617,34 @@ function fmtChunks(today) {
1610
1617
 
1611
1618
  const CHUNK_MAX = 30000;
1612
1619
  const chunks = [head.join("\n")];
1613
- let buf = "", n = tdStart, lastSev = null, lastType = null;
1620
+ let buf = "", n = tdStart, lastSev = null, lastType = null, lastRoot = null;
1614
1621
  const flush = () => { if (buf) { chunks.push(buf); buf = ""; } };
1615
1622
  // Consume the shared `orderedFindings` (severity → type → original-index) so the
1616
1623
  // TD numbers assigned here are IDENTICAL to those the consolidation stage references.
1617
1624
  for (const { f, t } of orderedFindings) {
1618
1625
  let piece = "";
1619
- 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; }
1620
1627
  // Type sub-heading uses a bold marker line (NOT `###`) so it never collides with
1621
1628
  // the `### TD-N` item headings that downstream tools grep for. ASCII hyphens only
1622
1629
  // (M76: no em/en-dashes in fmtChunks literals).
1623
- 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; }
1624
1648
  piece += itemMd(f, n++);
1625
1649
  if (buf.length + piece.length > CHUNK_MAX) flush();
1626
1650
  buf += piece;