@tekyzinc/gsd-t 5.0.12 → 5.0.13

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,20 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [5.0.13] - 2026-07-12
6
+
7
+ ### Fixed — a BROKEN graph now HALTS instead of silently falling back to grep
8
+
9
+ Until now a broken code graph (CLI crash on a missing dependency, corrupt store) and an absent graph (never indexed) both collapsed to `reason:"graph-unavailable"`, and every consumer silently fell back to grep — the exact silent degradation that hid the 12-day resolver break. Now the two are distinguished and routed oppositely: **BROKEN → HALT and demand a fix; ABSENT → auto-build the index once, then continue.** Designed via a `/gsd-t-architect` Six-Stage Pass (reuse-first: no new halt system, envelope, or build path — all existing machinery reused).
10
+
11
+ - `bin/gsd-t-graph-availability.cjs` (NEW): single `classifyGraphFailure(reason)` helper — `graph-broken`→HALT, `graph-absent`→auto-build, any unknown/legacy reason→**BROKEN (fail-closed)**. `isTransient()` guard retries once on `SQLITE_BUSY`/timeout so a slow/locked query never wrongly halts. Added to `PROJECT_BIN_TOOLS` so it ships. Has a `classify` CLI arm for the sandboxed workflows.
12
+ - `bin/gsd-t-graph-query-cli.cjs`: producer edge emits the split reason codes (absent vs broken + `detail`).
13
+ - `bin/gsd-t.js`: delegation edge (`_graphQueryCli`) now classifies a process crash (exit≠0 + `MODULE_NOT_FOUND`) as `graph-broken` instead of fabricating `graph-unavailable`.
14
+ - `bin/gsd-t-file-disjointness.cjs` + `bin/gsd-t-parallel.cjs`: route through the classifier; ABSENT auto-builds once then re-checks; BROKEN halts (`gsd-t parallel` exits 3 with a loud message, no empty-plan fallback). Announced escape: `--disjointness-fallback=touches-only`.
15
+ - 8 workflows route graph failures through the classifier (via the sandboxed `runCli` helper — no require added; m71 lint stays green). scan/verify/integrate keep their announced ABSENT carve-out but name BROKEN loudly.
16
+ - `.gsd-t/contracts/graph-consumer-wiring-contract.md`: new §Broken-vs-Absent + 6 `[RULE]`s.
17
+ - `test/broken-graph-halts.test.js` (NEW, 15 tests) + `.gsd-t/pseudocode/PseudoCode-BrokenGraphHalts.md`.
18
+
5
19
  ## [5.0.12] - 2026-07-12
6
20
 
7
21
  ### Fixed — graph require-chain shipped incomplete → every project's graph silently dead
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v5.0.12** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v5.0.13** - 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.
@@ -47,6 +47,9 @@
47
47
  const fs = require("node:fs");
48
48
  const path = require("node:path");
49
49
  const { execSync } = require("node:child_process");
50
+ // Broken-Graph-Halts: the ONE shared availability classifier (absent→auto-build,
51
+ // broken→HALT). Never re-implement the string check here. [RULE] one-availability-classifier
52
+ const { classifyGraphFailure, isTransient } = require("./gsd-t-graph-availability.cjs");
50
53
 
51
54
  // ─── Event writer (append-only JSONL) ────────────────────────────────────
52
55
 
@@ -220,33 +223,57 @@ function queryBlastRadius(projectDir, filePath) {
220
223
  }
221
224
 
222
225
  let raw;
226
+ let execErr = null;
223
227
  try {
224
228
  raw = execSync(`node "${queryCliPath}" blast-radius "${filePath}"`, {
225
229
  cwd: projectDir,
226
230
  encoding: "utf8",
227
- stdio: ["ignore", "pipe", "ignore"],
231
+ stdio: ["ignore", "pipe", "pipe"],
228
232
  timeout: 10000,
229
233
  });
230
- } catch {
231
- // CLI present but query failed (graph index broken / parse error).
232
- return { blastRadius: [], graphAvailable: false, cliPresent: true };
234
+ } catch (e) {
235
+ // The CLI's fail() exits 1 while STILL emitting a granular envelope on stdout —
236
+ // execSync throws on the non-zero exit, but e.stdout carries the envelope. A true
237
+ // crash (missing require) leaves stdout empty and stderr populated → BROKEN.
238
+ execErr = e;
239
+ raw = (e && e.stdout) ? e.stdout : "";
233
240
  }
234
- try {
235
- const envelope = JSON.parse((raw || "").trim());
236
- if (!envelope.ok && envelope.reason === "graph-unavailable") {
237
- // CLI present but graph index not built yet.
238
- return { blastRadius: [], graphAvailable: false, cliPresent: true };
239
- }
240
- if (envelope.ok && Array.isArray(envelope.results)) {
241
- const files = envelope.results.map((r) =>
242
- typeof r === "string" ? r : (r && (r.file || r.path || ""))
243
- ).filter(Boolean);
244
- return { blastRadius: files, graphAvailable: true, cliPresent: true };
241
+ const trimmed = (raw || "").trim();
242
+ // Parse the envelope FIRST (trust the producer's granular reason) — even on a
243
+ // non-zero exit, a valid envelope means the producer classified it (absent/broken).
244
+ if (trimmed) {
245
+ try {
246
+ const envelope = JSON.parse(trimmed);
247
+ if (!envelope.ok) {
248
+ // Route the producer's reason through the ONE classifier.
249
+ // [RULE] one-availability-classifier [RULE] crash-classified-not-fabricated
250
+ const c = classifyGraphFailure(envelope.reason);
251
+ return {
252
+ blastRadius: [], graphAvailable: false, cliPresent: true,
253
+ state: c.state, reason: envelope.reason,
254
+ transient: isTransient(envelope.detail),
255
+ };
256
+ }
257
+ if (Array.isArray(envelope.results)) {
258
+ const files = envelope.results.map((r) =>
259
+ typeof r === "string" ? r : (r && (r.file || r.path || ""))
260
+ ).filter(Boolean);
261
+ return { blastRadius: files, graphAvailable: true, cliPresent: true, state: "OK" };
262
+ }
263
+ // ok:true but no results array — unexpected shape → BROKEN (fail-closed).
264
+ return { blastRadius: [], graphAvailable: false, cliPresent: true, state: "BROKEN", reason: "graph-broken" };
265
+ } catch {
266
+ // stdout present but not JSON → CLI printed garbage → BROKEN.
267
+ return { blastRadius: [], graphAvailable: false, cliPresent: true, state: "BROKEN", reason: "graph-broken" };
245
268
  }
246
- return { blastRadius: [], graphAvailable: false, cliPresent: true };
247
- } catch {
248
- return { blastRadius: [], graphAvailable: false, cliPresent: true };
249
269
  }
270
+ // No envelope on stdout at all. A crash (empty stdout + throw) → BROKEN, unless the
271
+ // failure looks transient (lock/timeout), in which case the caller retries once.
272
+ const detail = execErr && (execErr.stderr || execErr.message);
273
+ return {
274
+ blastRadius: [], graphAvailable: false, cliPresent: true,
275
+ state: "BROKEN", reason: "graph-broken", transient: isTransient(detail),
276
+ };
250
277
  }
251
278
 
252
279
  /**
@@ -266,6 +293,33 @@ function queryBlastRadius(projectDir, filePath) {
266
293
  * [RULE] execute-disjointness-graph-aware-dependency-overlap
267
294
  * [RULE] execute-disjointness-output-flips-on-graph-edge
268
295
  */
296
+ // [RULE] absent-graph-auto-builds-once — build the index ONCE via the existing
297
+ // `gsd-t graph index` path when the graph is ABSENT (never indexed). Returns true on
298
+ // a successful build. Never throws.
299
+ function autoBuildGraphIndex(projectDir) {
300
+ try {
301
+ execSync(`node "${path.join(projectDir, "bin", "gsd-t-graph-index.cjs")}" build --repo "${projectDir}"`, {
302
+ cwd: projectDir,
303
+ encoding: "utf8",
304
+ stdio: ["ignore", "ignore", "pipe"],
305
+ timeout: 300000,
306
+ });
307
+ return true;
308
+ } catch {
309
+ return false;
310
+ }
311
+ }
312
+
313
+ // [RULE] false-broken-guarded — retry ONCE on a transient failure (DB lock / timeout)
314
+ // so a slow/locked query does not wrongly HALT fan-out.
315
+ function queryBlastRadiusRetrying(projectDir, filePath) {
316
+ let r = queryBlastRadius(projectDir, filePath);
317
+ if (!r.graphAvailable && r.cliPresent && r.transient) {
318
+ r = queryBlastRadius(projectDir, filePath);
319
+ }
320
+ return r;
321
+ }
322
+
269
323
  function graphAwareDisjointCheck(projectDir, touchesA, touchesB) {
270
324
  // Fast path: literal Touches overlap — already know they're non-disjoint.
271
325
  if (haveOverlap(touchesA, touchesB)) {
@@ -276,14 +330,16 @@ function graphAwareDisjointCheck(projectDir, touchesA, touchesB) {
276
330
  let graphAvailableConfirmed = false;
277
331
 
278
332
  for (const fileA of touchesA.slice(0, 5)) {
279
- const { blastRadius, graphAvailable, cliPresent } = queryBlastRadius(projectDir, fileA);
333
+ const { blastRadius, graphAvailable, cliPresent, state } = queryBlastRadiusRetrying(projectDir, fileA);
280
334
  if (!cliPresent) {
281
335
  // No local graph CLI in this project — skip graph check entirely.
282
336
  return { verdict: "no-cli" };
283
337
  }
284
338
  if (!graphAvailable) {
285
- // CLI present but index missing/broken FAIL LOUD.
286
- return { verdict: "graph-unavailable", reason: "GRAPH_UNAVAILABLE" };
339
+ // CLI present but graph absent (never indexed) or broken (corrupt/crash).
340
+ // Carry the classified state up so the caller can auto-build ABSENT vs HALT BROKEN.
341
+ // [RULE] one-availability-classifier
342
+ return { verdict: "graph-unavailable", reason: "GRAPH_UNAVAILABLE", state: state || "BROKEN" };
287
343
  }
288
344
  graphAvailableConfirmed = true;
289
345
  const setBtouch = new Set(touchesB);
@@ -299,7 +355,7 @@ function graphAwareDisjointCheck(projectDir, touchesA, touchesB) {
299
355
  }
300
356
 
301
357
  for (const fileB of touchesB.slice(0, 5)) {
302
- const { blastRadius, graphAvailable, cliPresent } = queryBlastRadius(projectDir, fileB);
358
+ const { blastRadius, graphAvailable, cliPresent, state } = queryBlastRadiusRetrying(projectDir, fileB);
303
359
  if (!cliPresent) {
304
360
  if (!graphAvailableConfirmed) {
305
361
  return { verdict: "no-cli" };
@@ -308,8 +364,8 @@ function graphAwareDisjointCheck(projectDir, touchesA, touchesB) {
308
364
  }
309
365
  if (!graphAvailable) {
310
366
  if (!graphAvailableConfirmed) {
311
- // CLI present but index missing/broken → FAIL LOUD.
312
- return { verdict: "graph-unavailable", reason: "GRAPH_UNAVAILABLE" };
367
+ // CLI present but graph absent/broken → carry classified state up.
368
+ return { verdict: "graph-unavailable", reason: "GRAPH_UNAVAILABLE", state: state || "BROKEN" };
313
369
  }
314
370
  continue; // Transient error on B after A confirmed available — skip
315
371
  }
@@ -356,10 +412,26 @@ function groupByOverlapGraphAware(items, projectDir, fallbackToTouchesOnly) {
356
412
  const union = (i, j) => { const a = find(i), b = find(j); if (a !== b) parent[a] = b; };
357
413
 
358
414
  let graphUnavailable = false;
415
+ let graphBuilt = false; // [RULE] absent-graph-auto-builds-once — build at most once
359
416
 
360
417
  for (let i = 0; i < n; i++) {
361
418
  for (let j = i + 1; j < n; j++) {
362
- const result = graphAwareDisjointCheck(projectDir, items[i].touches, items[j].touches);
419
+ let result = graphAwareDisjointCheck(projectDir, items[i].touches, items[j].touches);
420
+ // ABSENT (never indexed) → auto-build ONCE, then re-check this pair. A second
421
+ // consecutive absent (build failed or still absent) = BROKEN infra.
422
+ // [RULE] absent-graph-auto-builds-once
423
+ if (result.verdict === "graph-unavailable" && result.state === "ABSENT" && !graphBuilt) {
424
+ graphBuilt = true;
425
+ process.stderr.write(`[gsd-t disjointness] graph ABSENT — building index once (gsd-t graph index)...\n`);
426
+ const ok = autoBuildGraphIndex(projectDir);
427
+ if (ok) {
428
+ result = graphAwareDisjointCheck(projectDir, items[i].touches, items[j].touches);
429
+ }
430
+ // If still absent after the build, treat as BROKEN (build infra failing).
431
+ if (result.verdict === "graph-unavailable" && result.state === "ABSENT") {
432
+ result = { verdict: "graph-unavailable", reason: "GRAPH_UNAVAILABLE", state: "BROKEN" };
433
+ }
434
+ }
363
435
  if (result.verdict === "no-cli") {
364
436
  // No local graph CLI in this project → skip graph check for all pairs.
365
437
  // Fall through to Touches-only for this pair (NOT FAIL LOUD — this is "no graph").
@@ -368,14 +440,17 @@ function groupByOverlapGraphAware(items, projectDir, fallbackToTouchesOnly) {
368
440
  }
369
441
  } else if (result.verdict === "graph-unavailable") {
370
442
  graphUnavailable = true;
443
+ // BROKEN graph → HALT (never grep-guess). The bootstrap escape hatch is the
444
+ // ONLY sanctioned continue, and it is loudly announced.
445
+ // [RULE] broken-graph-halts-never-greps
371
446
  if (fallbackToTouchesOnly) {
372
447
  // Bootstrap escape hatch: ANNOUNCED WARNING, degrade to literal-Touches.
373
448
  // NEVER silent. NEVER applied to graph-says-non-disjoint verdicts.
374
449
  process.stderr.write(
375
- `[gsd-t disjointness] WARNING: graph unavailable — falling back to ` +
450
+ `[gsd-t disjointness] WARNING: graph BROKEN — falling back to ` +
376
451
  `literal-Touches-only check (--disjointness-fallback=touches-only). ` +
377
452
  `Transitive dependency overlaps will NOT be detected. ` +
378
- `Fix the graph index (gsd-t graph build) before the next parallel execute.\n`
453
+ `Fix the graph (gsd-t graph status) before the next parallel execute.\n`
379
454
  );
380
455
  if (haveOverlap(items[i].touches, items[j].touches)) {
381
456
  union(i, j);
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * gsd-t-graph-availability.cjs — the ONE shared graph-availability classifier.
5
+ *
6
+ * Splits a graph-query failure into ABSENT (never indexed → auto-build then continue)
7
+ * vs BROKEN (missing dep / corrupt store / crash → HALT, demand a fix). Every consumer
8
+ * (the 8 workflows + file-disjointness) routes its `ok:false` envelope's `reason` through
9
+ * this ONE helper instead of re-implementing the string check.
10
+ *
11
+ * Reason codes (produced at the two seams — gsd-t-graph-query-cli.cjs + gsd-t.js
12
+ * _graphQueryCli):
13
+ * "graph-absent" → no store on disk yet (never indexed) → ABSENT
14
+ * "graph-broken" → store present-but-unreadable / CLI crash / corrupt → BROKEN
15
+ *
16
+ * [RULE] one-availability-classifier — the absent-vs-broken decision lives HERE, once.
17
+ * [RULE] unknown-reason-fails-closed-to-broken — any unrecognised reason (incl. the
18
+ * legacy "graph-unavailable") classifies as BROKEN/HALT, never ABSENT/continue.
19
+ * [RULE] false-broken-guarded — a transient failure (SQLITE_BUSY / timeout) is
20
+ * retry-eligible; callers retry ONCE before classifying BROKEN.
21
+ */
22
+
23
+ const ABSENT = { state: "ABSENT", action: "auto-build-then-continue" };
24
+ const BROKEN = { state: "BROKEN", action: "HALT-demand-fix" };
25
+
26
+ /**
27
+ * classifyGraphFailure(reason) → { state, action }.
28
+ * Fail-closed: anything that is not exactly "graph-absent" is BROKEN.
29
+ *
30
+ * @param {string} reason the `reason` field of an `ok:false` graph envelope
31
+ * @returns {{ state: "ABSENT"|"BROKEN", action: string }}
32
+ */
33
+ function classifyGraphFailure(reason) {
34
+ if (reason === "graph-absent") return { ...ABSENT };
35
+ if (reason === "graph-broken") return { ...BROKEN };
36
+ // [RULE] unknown-reason-fails-closed-to-broken — legacy "graph-unavailable" and
37
+ // any unknown reason HALT rather than silently continue.
38
+ return { ...BROKEN };
39
+ }
40
+
41
+ /**
42
+ * isTransient(detail) → boolean.
43
+ * A transient failure (DB lock / timeout) may resolve on a retry, so a slow or
44
+ * locked query must not wrongly HALT all work. Callers retry ONCE, then classify.
45
+ * [RULE] false-broken-guarded
46
+ *
47
+ * @param {string} detail the `detail` field of the failing envelope (or stderr)
48
+ * @returns {boolean}
49
+ */
50
+ function isTransient(detail) {
51
+ if (!detail || typeof detail !== "string") return false;
52
+ return /SQLITE_BUSY|database is locked|ETIMEDOUT|timed?\s*out|timeout/i.test(detail);
53
+ }
54
+
55
+ module.exports = { classifyGraphFailure, isTransient };
56
+
57
+ // ─── CLI arm (Bash-invokable — the sandboxed workflows call this, not require) ───
58
+ // Usage: node bin/gsd-t-graph-availability.cjs classify <reason> [detail]
59
+ // Prints a JSON envelope: { ok:true, state, action, transient }
60
+ if (require.main === module) {
61
+ const [, , sub, reason, detail] = process.argv;
62
+ if (sub === "classify") {
63
+ const c = classifyGraphFailure(reason);
64
+ const out = { ok: true, reason: reason || null, state: c.state, action: c.action, transient: isTransient(detail) };
65
+ process.stdout.write(JSON.stringify(out) + "\n");
66
+ process.exit(0);
67
+ }
68
+ process.stdout.write(JSON.stringify({ ok: false, reason: "unknown-subcommand", usage: "classify <reason> [detail]" }) + "\n");
69
+ process.exit(1);
70
+ }
@@ -474,7 +474,8 @@ function loadSqliteStore(dbPath) {
474
474
  }
475
475
 
476
476
  function loadStore(storePath) {
477
- if (!storePath) return { ok: false, reason: "graph-unavailable" };
477
+ // ABSENT: no store file anywhere on disk (never indexed). [RULE] one-availability-classifier
478
+ if (!storePath) return { ok: false, reason: "graph-absent" };
478
479
 
479
480
  // SQLite is the PICKED store (K1) — read it first; fall back to JSONL baseline.
480
481
  // Sibling-db fallback uses resolver (not a raw path.join). [RULE] one-resolver-only
@@ -484,7 +485,8 @@ function loadStore(storePath) {
484
485
  })();
485
486
  const loaded = (storePath.endsWith(".db") ? loadSqliteStore(storePath) : loadJsonlStore(storePath))
486
487
  || loadJsonlStore(storePath) || (_siblingDb && fs.existsSync(_siblingDb) ? loadSqliteStore(_siblingDb) : null);
487
- if (!loaded) return { ok: false, reason: "graph-unavailable" };
488
+ // BROKEN: a store file exists but could not be read/parsed (corrupt). NOT absent.
489
+ if (!loaded) return { ok: false, reason: "graph-broken", detail: "store present but unreadable" };
488
490
 
489
491
  try {
490
492
  // Load skipped files (best-effort — never fails the store load)
@@ -492,7 +494,8 @@ function loadStore(storePath) {
492
494
  const index = buildIndex(loaded.records, skippedFiles);
493
495
  return { ok: true, index };
494
496
  } catch (_e) {
495
- return { ok: false, reason: "graph-unavailable" };
497
+ // Store loaded but buildIndex threw on corrupt records → BROKEN, name the cause.
498
+ return { ok: false, reason: "graph-broken", detail: _e && _e.code ? _e.code : "buildIndex-failed" };
496
499
  }
497
500
  }
498
501
 
@@ -527,8 +530,9 @@ function loadFreshnessModule() {
527
530
  function runFreshnessCheck(storePath) {
528
531
  const freshnessModule = loadFreshnessModule();
529
532
  if (!freshnessModule) {
530
- // D4 not yet built — fail-loud per [RULE] parser-fail-disables-loud-never-silent
531
- return { ok: false, reason: "graph-unavailable" };
533
+ // D4 not yet built / unloadable this is BROKEN infra (a missing require), not an
534
+ // un-indexed repo. Fail-loud per [RULE] parser-fail-disables-loud-never-silent.
535
+ return { ok: false, reason: "graph-broken", detail: "freshness module unavailable" };
532
536
  }
533
537
 
534
538
  try {
@@ -545,7 +549,8 @@ function runFreshnessCheck(storePath) {
545
549
  // (Without this, a fake/non-existent storePath yields projectRoot="/" and
546
550
  // compute_touched_files walks the entire filesystem → OOM. [RULE]
547
551
  // parser-fail-disables-loud-never-silent: absent store = graph-unavailable.)
548
- if (!db) return { ok: false, reason: "graph-unavailable" };
552
+ // No real store at this root → ABSENT (never indexed), not broken.
553
+ if (!db) return { ok: false, reason: "graph-absent" };
549
554
  const touched = freshnessModule.compute_touched_files(db, projectRoot);
550
555
  // parse_and_put (from D3) lets freshness re-index stale files; pass it when
551
556
  // available so an edited file is refreshed before the answer.
@@ -565,7 +570,8 @@ function runFreshnessCheck(storePath) {
565
570
  reindexedFiles: Array.isArray(t.edits) ? t.edits.slice(0, 20) : [],
566
571
  };
567
572
  } catch (_e) {
568
- return { ok: false, reason: "graph-unavailable" };
573
+ // Parse/corrupt/other freshness error → BROKEN, carry the cause code.
574
+ return { ok: false, reason: "graph-broken", detail: _e && _e.code ? _e.code : "freshness-failed" };
569
575
  }
570
576
  }
571
577
 
@@ -1344,13 +1350,16 @@ if (require.main === module) {
1344
1350
  };
1345
1351
  } catch { /* fail-open */ }
1346
1352
  if (!freshnessResult.ok) {
1347
- fail({ ok: false, reason: "graph-unavailable" });
1353
+ // Propagate the granular reason (graph-absent / graph-broken) so the consumer's
1354
+ // classifier can route ABSENT→auto-build vs BROKEN→HALT. Never collapse to a
1355
+ // single "graph-unavailable" here. [RULE] one-availability-classifier
1356
+ fail({ ok: false, reason: freshnessResult.reason || "graph-broken", detail: freshnessResult.detail });
1348
1357
  }
1349
1358
 
1350
1359
  // ── Step 2: Load store (fail-loud if missing or corrupt) ──
1351
1360
  const storeResult = loadStore(storePath);
1352
1361
  if (!storeResult.ok) {
1353
- fail({ ok: false, reason: "graph-unavailable" });
1362
+ fail({ ok: false, reason: storeResult.reason || "graph-broken", detail: storeResult.detail });
1354
1363
  }
1355
1364
 
1356
1365
  const index = storeResult.index;
@@ -145,6 +145,10 @@ function parseArgv(argv) {
145
145
  else if (a.startsWith("--domain=")) out.domain = a.slice("--domain=".length);
146
146
  else if (a === "--command") out.command = argv[++i] || null;
147
147
  else if (a.startsWith("--command=")) out.command = a.slice("--command=".length);
148
+ // Broken-Graph-Halts bootstrap escape hatch — degrade to literal-Touches (announced)
149
+ // when the graph is BROKEN, instead of HALTing. Off by default (HALT is the default).
150
+ else if (a === "--disjointness-fallback") out.disjointnessFallback = argv[++i] || null;
151
+ else if (a.startsWith("--disjointness-fallback=")) out.disjointnessFallback = a.slice("--disjointness-fallback=".length);
148
152
  else if (a === "--max-workers") out.maxWorkers = parseInt(argv[++i], 10);
149
153
  else if (a.startsWith("--max-workers=")) out.maxWorkers = parseInt(a.slice("--max-workers=".length), 10);
150
154
  else if (a === "--stagger") out.stagger = parseInt(argv[++i], 10);
@@ -236,7 +240,21 @@ function runParallel(opts) {
236
240
  }
237
241
 
238
242
  // ── D5 disjointness gate ──
239
- const disj = proveDisjointness({ tasks: depReady, projectDir });
243
+ const disjointnessFallback = (opts && opts.disjointnessFallback) || null;
244
+ const disj = proveDisjointness({ tasks: depReady, projectDir, disjointnessFallback });
245
+ // Broken-Graph-Halts: a BROKEN graph (missing dep / corrupt / crash) makes the
246
+ // transitive-overlap check unprovable. HALT loudly — do NOT silently emit an empty
247
+ // parallel plan (that would be a silent grep-equivalent fallback).
248
+ // [RULE] broken-graph-halts-never-greps
249
+ if (disj.graphUnavailable) {
250
+ process.stderr.write(
251
+ `[gsd-t parallel] HALT: graph BROKEN — file-disjointness is unprovable, so a safe ` +
252
+ `parallel plan cannot be built. Fix the graph (gsd-t graph status), or re-run with ` +
253
+ `--disjointness-fallback=touches-only to degrade to literal-Touches (announced). ` +
254
+ `Refusing to fan out on an unprovable plan.\n`
255
+ );
256
+ process.exit(3);
257
+ }
240
258
  const disjointTaskIds = new Set();
241
259
  for (const group of disj.parallel || []) {
242
260
  for (const t of group) disjointTaskIds.add(t.id);
package/bin/gsd-t.js CHANGED
@@ -2993,6 +2993,12 @@ const PROJECT_BIN_TOOLS = [
2993
2993
  // copied tool finds the engine from the GSD-T global package, not the project's
2994
2994
  // own (usually absent) node_modules. Fail-loud with remediation if all miss.
2995
2995
  "gsd-t-require-store.cjs",
2996
+ // Broken-Graph-Halts — the ONE shared availability classifier (graph-absent vs
2997
+ // graph-broken → ABSENT/auto-build vs BROKEN/HALT). The sandboxed workflows invoke
2998
+ // it via Bash (`node bin/gsd-t-graph-availability.cjs classify <reason>`), so it MUST
2999
+ // ship to every project or the consumers can't classify. Same propagation class as
3000
+ // gsd-t-graph-store-resolver.cjs above. [[project_global_bin_propagation_gap]]
3001
+ "gsd-t-graph-availability.cjs",
2996
3002
  ];
2997
3003
 
2998
3004
  // Files that older versions of this installer copied into project bin/ but
@@ -3910,6 +3916,35 @@ function doChangelog() {
3910
3916
  *
3911
3917
  * [RULE] graph-status-live — NEVER hits the dead M20–M21 "No graph index found" path.
3912
3918
  */
3919
+ // Broken-graph seam (delegation edge): classify a spawnSync result into a graph
3920
+ // envelope. STOPS discarding result.status/result.stderr. A crash (missing require →
3921
+ // exit≠0, empty stdout, MODULE_NOT_FOUND on stderr) means the CLI itself is BROKEN —
3922
+ // surface it so the consumer HALTs, never fabricate a collapsed "graph-unavailable"
3923
+ // that hides the crash. Extracted (pure) so it is unit-testable with a simulated result.
3924
+ // [RULE] crash-classified-not-fabricated
3925
+ function _classifyGraphSpawnResult(result) {
3926
+ const stdout = (result.stdout || "").trim();
3927
+ // Trust a valid JSON envelope on stdout — the producer already classified the reason
3928
+ // (graph-absent / graph-broken / a real answer). Parse FIRST so a non-zero exit that
3929
+ // still emitted an envelope (fail() exits 1) keeps the producer's granular reason.
3930
+ if (stdout) {
3931
+ try {
3932
+ return JSON.parse(stdout);
3933
+ } catch {
3934
+ // stdout present but not JSON → the CLI printed garbage → BROKEN.
3935
+ return { ok: false, reason: "graph-broken", detail: stdout };
3936
+ }
3937
+ }
3938
+ // No valid envelope on stdout. If the process crashed (spawn error, or non-zero
3939
+ // exit with empty stdout), the CLI is BROKEN.
3940
+ if (result.error) {
3941
+ return { ok: false, reason: "graph-broken", detail: result.error.message };
3942
+ }
3943
+ // exit != 0 with no envelope, OR exit 0 with empty stdout (shouldn't happen) →
3944
+ // treat as BROKEN defensively.
3945
+ return { ok: false, reason: "graph-broken", detail: result.stderr || `exit ${result.status}, no output` };
3946
+ }
3947
+
3913
3948
  function _graphQueryCli(verbAndArgs) {
3914
3949
  const { spawnSync } = require("child_process");
3915
3950
  const cliPath = require("path").join(__dirname, "gsd-t-graph-query-cli.cjs");
@@ -3918,18 +3953,7 @@ function _graphQueryCli(verbAndArgs) {
3918
3953
  cwd: process.cwd(),
3919
3954
  timeout: 30000,
3920
3955
  });
3921
- if (result.error) {
3922
- return { ok: false, reason: "graph-unavailable", detail: result.error.message };
3923
- }
3924
- const stdout = (result.stdout || "").trim();
3925
- if (!stdout) {
3926
- return { ok: false, reason: "graph-unavailable", detail: result.stderr || "no output" };
3927
- }
3928
- try {
3929
- return JSON.parse(stdout);
3930
- } catch {
3931
- return { ok: false, reason: "graph-unavailable", detail: stdout };
3932
- }
3956
+ return _classifyGraphSpawnResult(result);
3933
3957
  }
3934
3958
 
3935
3959
  /**
@@ -4920,6 +4944,7 @@ module.exports = {
4920
4944
  copyFile,
4921
4945
  copyBinToolsToProject,
4922
4946
  PROJECT_BIN_TOOLS,
4947
+ _classifyGraphSpawnResult,
4923
4948
  hasPlaywright,
4924
4949
  hasSwagger,
4925
4950
  hasApi,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "5.0.12",
3
+ "version": "5.0.13",
4
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",
@@ -85,6 +85,27 @@ async function generateBrief(projectDir, { kind = "execute", milestone, domain,
85
85
  const r = await runCli(projectDir, "brief", argv, "gsd-t-context-brief.cjs", label, false, phaseName);
86
86
  return { ok: r.ok, briefPath: `${projectDir}/.gsd-t/briefs/${id}.json`, via: r.via };
87
87
  }
88
+ // Broken-Graph-Halts: route a failing graph envelope's reason through the ONE shared
89
+ // classifier via Bash (sandbox bans require). Returns "ABSENT" | "BROKEN" (fail-closed).
90
+ // [RULE] one-availability-classifier [RULE] unknown-reason-fails-closed-to-broken
91
+ async function classifyGraphFailure(projectDir, reason, detail, phaseName) {
92
+ const r = await runCli(
93
+ projectDir, "graph-availability",
94
+ ["classify", String(reason || ""), String(detail || "")],
95
+ "gsd-t-graph-availability.cjs", "graph-classify", true, phaseName
96
+ ).catch(() => null);
97
+ const env = r && r.envelope;
98
+ if (env && (env.state === "ABSENT" || env.state === "BROKEN")) return env.state;
99
+ return "BROKEN";
100
+ }
101
+ // [RULE] absent-graph-auto-builds-once — build the index once via the existing path.
102
+ async function buildGraphIndex(projectDir, phaseName) {
103
+ const r = await runCli(
104
+ projectDir, "graph index", ["build", "--repo", projectDir],
105
+ "gsd-t-graph-index.cjs", "graph-index", false, phaseName
106
+ ).catch(() => null);
107
+ return !!(r && r.ok);
108
+ }
88
109
 
89
110
  const projectDir = _args.projectDir || ".";
90
111
  const symptom = _args.symptom || null;
@@ -317,11 +338,11 @@ async function runResearchForClaim(projectDir, claimText, artifactPath, phaseNam
317
338
  // the structural slice — the fail-loud requirement is met by the logged message;
318
339
  // the agent is NOT given a grep fallback to find the call chain).
319
340
  // [RULE] debug-reader-and-writer-both
320
- async function queryGraphForDebug(projectDir, symptom, phaseName) {
341
+ async function queryGraphForDebug(projectDir, symptom, phaseName, _rebuilt) {
321
342
  // Derive a plausible target hint from the symptom text (the file or function name)
322
343
  // to pass to blast-radius. This is heuristic — the agent reads the full slice.
323
344
  const targetHint = (symptom || "").slice(0, 80).replace(/['"]/g, "").trim();
324
- if (!targetHint) return null;
345
+ if (!targetHint) return { line: null, halt: false };
325
346
 
326
347
  const blastResult = await runCli(
327
348
  projectDir,
@@ -333,15 +354,22 @@ async function queryGraphForDebug(projectDir, symptom, phaseName) {
333
354
  phaseName
334
355
  );
335
356
  const env = blastResult.envelope;
336
- if (!env) return null;
337
- if (!env.ok && env.reason === "graph-unavailable") {
338
- log(`M94-D11 READER: graph unavailable — fix it (gsd-t graph status). Debug proceeds without structural slice.`);
339
- return null; // Fail-loud logged; no grep fallback
357
+ if (!env) return { line: null, halt: false };
358
+ if (!env.ok) {
359
+ const state = await classifyGraphFailure(projectDir, env.reason, env.detail, phaseName);
360
+ if (state === "ABSENT" && !_rebuilt) {
361
+ log(`M94-D11 READER: graph ABSENT — building index once, then re-querying.`);
362
+ await buildGraphIndex(projectDir, phaseName);
363
+ return queryGraphForDebug(projectDir, symptom, phaseName, true);
364
+ }
365
+ const haltMessage = `graph BROKEN (reason=${env.reason || "?"}) — debug HALTED. Fix it: run gsd-t graph status. No grep fallback.`;
366
+ log(`M94-D11 READER: ${haltMessage}`);
367
+ return { line: null, halt: true, haltMessage };
340
368
  }
341
- if (env.ok && Array.isArray(env.results)) {
342
- return `## Graph structural slice (blast-radius for "${targetHint}"):\n${JSON.stringify(env.results.slice(0, 20), null, 2)}`;
369
+ if (Array.isArray(env.results)) {
370
+ return { line: `## Graph structural slice (blast-radius for "${targetHint}"):\n${JSON.stringify(env.results.slice(0, 20), null, 2)}`, halt: false };
343
371
  }
344
- return null;
372
+ return { line: null, halt: false };
345
373
  }
346
374
 
347
375
  // M94-D11 §WRITER: Trigger a freshness pass over the touched set after the fix lands.
@@ -381,7 +409,12 @@ await persistWiringMode("Preflight");
381
409
 
382
410
  // M94-D11 §READER: query graph ONCE before cycles (the structural slice is the same
383
411
  // for all cycles — same symptom). Injected into each cycle's prompt.
384
- const _graphSliceLine = await queryGraphForDebug(projectDir, symptom, "Preflight") || "";
412
+ // [RULE] broken-graph-halts-never-greps a BROKEN graph HALTS debug (no grep fallback).
413
+ const _graphRead = await queryGraphForDebug(projectDir, symptom, "Preflight");
414
+ if (_graphRead && _graphRead.halt) {
415
+ return { status: "blocked-needs-human", reason: "graph-broken", detail: _graphRead.haltMessage };
416
+ }
417
+ const _graphSliceLine = (_graphRead && _graphRead.line) || "";
385
418
 
386
419
  let lastResult = null;
387
420
  // M90 §3 (fix-cycle: gate lifecycle) — track THIS run's per-signature cycle counts so the
@@ -39,6 +39,20 @@ async function runCli(projectDir, subcmd, argv, localBin, label, parseJson = tru
39
39
  return r || { ok: false, exitCode: -1, envelope: null, via: "error" };
40
40
  }
41
41
  async function runPreflight(projectDir, label = "preflight", phaseName) { return runCli(projectDir, "preflight", ["--json"], "cli-preflight.cjs", label, true, phaseName); }
42
+ // Broken-Graph-Halts (EXEMPT carve-out): integrate is additive/announced — it does not
43
+ // hard-fail on an unavailable graph. But it MUST DISTINGUISH absent from broken and name
44
+ // BROKEN loudly, never silently continue as if merely un-indexed.
45
+ // [RULE] one-availability-classifier [RULE] broken-graph-halts-never-greps (carve-out: name BROKEN loudly)
46
+ async function classifyGraphFailure(projectDir, reason, detail, phaseName) {
47
+ const r = await runCli(
48
+ projectDir, "graph-availability",
49
+ ["classify", String(reason || ""), String(detail || "")],
50
+ "gsd-t-graph-availability.cjs", "graph-classify", true, phaseName
51
+ ).catch(() => null);
52
+ const env = r && r.envelope;
53
+ if (env && (env.state === "ABSENT" || env.state === "BROKEN")) return env.state;
54
+ return "BROKEN";
55
+ }
42
56
  async function runVerifyGate(projectDir, label = "verify-gate", phaseName) { return runCli(projectDir, "verify-gate", ["--json"], "gsd-t-verify-gate.cjs", label, true, phaseName); }
43
57
  async function generateBrief(projectDir, { kind = "execute", milestone, domain, id, label = "brief", phaseName } = {}) {
44
58
  const argv = ["--kind", kind, "--spawn-id", id, "--out", `${projectDir}/.gsd-t/briefs/${id}.json`];
@@ -108,8 +122,14 @@ let _graphIntegrateWarning = null;
108
122
  if (wiEnv.ok === true) {
109
123
  _graphWhoImportsSlice = wiEnv;
110
124
  log(`M94 graph who-imports: ${(wiEnv.results || []).length} result(s) (tier: ${wiEnv.tier || "?"})`);
111
- } else if (wiEnv.reason === "graph-unavailable") {
112
- _graphIntegrateWarning = "⚠ graph unavailable structural wiring-check skipped, fix it (gsd-t graph status)";
125
+ } else if (wiEnv.ok === false) {
126
+ // [RULE] one-availability-classifier distinguish ABSENT (announced skip) from BROKEN (LOUD).
127
+ const _state = await classifyGraphFailure(projectDir, wiEnv.reason, wiEnv.detail, "Integrate");
128
+ if (_state === "BROKEN") {
129
+ _graphIntegrateWarning = `⚠ graph BROKEN (reason=${wiEnv.reason || "?"}) — structural wiring-check skipped. This is NOT merely un-indexed; FIX it (gsd-t graph status).`;
130
+ } else {
131
+ _graphIntegrateWarning = "⚠ graph ABSENT (never indexed) — structural wiring-check skipped (announced carve-out; build with gsd-t graph index)";
132
+ }
113
133
  log(`M94 graph who-imports: ${_graphIntegrateWarning}`);
114
134
  } else {
115
135
  _graphIntegrateWarning = `⚠ graph who-imports query unexpected envelope (reason: ${wiEnv.reason || "?"}); structural wiring-check skipped`;
@@ -120,6 +120,27 @@ async function runCli(projectDir, subcmd, argv, localBin, label, parseJson = tru
120
120
  return r || { ok: false, exitCode: -1, envelope: null, via: "error" };
121
121
  }
122
122
  async function runPreflight(projectDir, label = "preflight", phaseNameOpt) { return runCli(projectDir, "preflight", ["--json"], "cli-preflight.cjs", label, true, phaseNameOpt); }
123
+ // Broken-Graph-Halts: route a failing graph reason through the ONE shared classifier
124
+ // via Bash (sandbox bans require). Returns "ABSENT" | "BROKEN" (fail-closed).
125
+ // [RULE] one-availability-classifier [RULE] unknown-reason-fails-closed-to-broken
126
+ async function classifyGraphFailure(projectDir, reason, detail, phaseNameOpt) {
127
+ const r = await runCli(
128
+ projectDir, "graph-availability",
129
+ ["classify", String(reason || ""), String(detail || "")],
130
+ "gsd-t-graph-availability.cjs", "graph-classify", true, phaseNameOpt
131
+ ).catch(() => null);
132
+ const env = r && r.envelope;
133
+ if (env && (env.state === "ABSENT" || env.state === "BROKEN")) return env.state;
134
+ return "BROKEN";
135
+ }
136
+ // [RULE] absent-graph-auto-builds-once — build the index once via the existing path.
137
+ async function buildGraphIndex(projectDir, phaseNameOpt) {
138
+ const r = await runCli(
139
+ projectDir, "graph index", ["build", "--repo", projectDir],
140
+ "gsd-t-graph-index.cjs", "graph-index", false, phaseNameOpt
141
+ ).catch(() => null);
142
+ return !!(r && r.ok);
143
+ }
123
144
  // M83: the deterministic plan-hardening gate. Returns the parsed envelope
124
145
  // ({ ok, exitCode, violations, ... }); ok:false means ≥1 untraceable AC.
125
146
  async function runTraceabilityGate(projectDir, milestone, label = "traceability-gate", phaseNameOpt) {
@@ -176,11 +197,11 @@ const PHASE_GRAPH_VERB_MAP = {
176
197
  * [RULE] phase-workflow-fail-loud-no-grep — on graph-unavailable, surface the loud
177
198
  * message and NEVER fall back to grep for the structural question.
178
199
  */
179
- async function queryStructuralSlice(projectDir, phaseName, phaseNameOpt) {
200
+ async function queryStructuralSlice(projectDir, phaseName, phaseNameOpt, _rebuilt) {
180
201
  const verb = PHASE_GRAPH_VERB_MAP[phaseName];
181
202
  if (!verb) {
182
203
  // Phase has no mapped structural verb (milestone/discuss/design-decompose/doc-ripple) — no-op.
183
- return { ok: true, verb: null, slice: null, graphUnavailable: false, loudMessage: null };
204
+ return { ok: true, verb: null, slice: null, graphUnavailable: false, graphBroken: false, loudMessage: null };
184
205
  }
185
206
  // The graph query uses gsd-t-graph-query-cli.cjs (local) or `gsd-t graph <verb>` (global).
186
207
  // argv: only the verb (no target) for phase-level queries that return a global set
@@ -190,20 +211,28 @@ async function queryStructuralSlice(projectDir, phaseName, phaseNameOpt) {
190
211
  `graph:${verb}`, true, phaseNameOpt
191
212
  );
192
213
  const env = r.envelope || {};
193
- if (env.ok === false && env.reason === "graph-unavailable") {
194
- // [RULE] phase-workflow-fail-loud-no-grep: surface LOUD, no grep fallback.
195
- const loudMessage = `⚠ graph unavailable — structural ${verb} slice NOT injected (fix it: gsd-t graph status). NO grep fallback — structural question unanswered.`;
214
+ if (env.ok === false) {
215
+ // [RULE] one-availability-classifier route the reason through the shared classifier.
216
+ const state = await classifyGraphFailure(projectDir, env.reason, env.detail, phaseNameOpt);
217
+ if (state === "ABSENT" && !_rebuilt) {
218
+ log(`M94 graph-slice: graph ABSENT — building index once, then re-querying ${verb}.`);
219
+ await buildGraphIndex(projectDir, phaseNameOpt);
220
+ return queryStructuralSlice(projectDir, phaseName, phaseNameOpt, true);
221
+ }
222
+ // BROKEN (or still-absent after one build) → HALT the phase. No grep fallback.
223
+ // [RULE] broken-graph-halts-never-greps [RULE] phase-workflow-fail-loud-no-grep
224
+ const loudMessage = `⚠ graph BROKEN (reason=${env.reason || "?"}) — structural ${verb} slice NOT injected. Phase HALTED — fix it: run gsd-t graph status. NO grep fallback.`;
196
225
  log(`M94 graph-slice: ${loudMessage}`);
197
- return { ok: false, verb, slice: null, graphUnavailable: true, loudMessage };
226
+ return { ok: false, verb, slice: null, graphUnavailable: true, graphBroken: true, loudMessage };
198
227
  }
199
228
  if (env.ok === true) {
200
229
  log(`M94 graph-slice: ${phaseName} → ${verb} → ${(env.results || []).length} result(s) (tier: ${env.tier || "?"})`);
201
- return { ok: true, verb, slice: env, graphUnavailable: false, loudMessage: null };
230
+ return { ok: true, verb, slice: env, graphUnavailable: false, graphBroken: false, loudMessage: null };
202
231
  }
203
- // Unexpected envelope (graph returned ok:false for a non-unavailable reason).
204
- const loudMessage = `⚠ graph ${verb} query returned unexpected envelope (ok=${env.ok}, reason=${env.reason || "?"}). Structural slice NOT injected.`;
232
+ // Unexpected envelope (no ok field at all) → fail-closed to BROKEN.
233
+ const loudMessage = `⚠ graph ${verb} query returned unexpected envelope (ok=${env.ok}, reason=${env.reason || "?"}). Treated as BROKEN — structural slice NOT injected.`;
205
234
  log(`M94 graph-slice: ${loudMessage}`);
206
- return { ok: false, verb, slice: null, graphUnavailable: false, loudMessage };
235
+ return { ok: false, verb, slice: null, graphUnavailable: true, graphBroken: true, loudMessage };
207
236
  }
208
237
 
209
238
  // M82: run the deterministic selection oracle over a candidate-set spec. The spec
@@ -857,6 +886,10 @@ const briefLine = `**Brief (REQUIRED):** ${brief.briefPath || "(no brief — re-
857
886
  // the pre-computed structural answer and has no reason to grep for structure.
858
887
  // [RULE] phase-workflow-injects-structural-slice
859
888
  const _graphSliceResult = await queryStructuralSlice(projectDir, phaseName, "Phase");
889
+ // [RULE] broken-graph-halts-never-greps — a BROKEN graph HALTS the phase (no grep fallback).
890
+ if (_graphSliceResult.graphBroken) {
891
+ return { status: "blocked-needs-human", reason: "graph-broken", detail: _graphSliceResult.loudMessage };
892
+ }
860
893
  const _graphSliceLine = _graphSliceResult.ok && _graphSliceResult.slice
861
894
  ? `\n**Structural Graph Slice (${_graphSliceResult.verb}):** ${JSON.stringify(_graphSliceResult.slice)}\nUse this pre-computed graph slice to answer structural questions — do NOT grep for ${_graphSliceResult.verb} answers.`
862
895
  : _graphSliceResult.loudMessage
@@ -71,6 +71,28 @@ async function runCli(projectDir, subcmd, argv, localBin, label, parseJson = tru
71
71
  async function runPreflight(projectDir, label = "preflight", phaseName) {
72
72
  return runCli(projectDir, "preflight", ["--json"], "cli-preflight.cjs", label, true, phaseName);
73
73
  }
74
+ // Broken-Graph-Halts: route a failing graph envelope's reason through the ONE shared
75
+ // classifier (sandbox bans require → call it via Bash). Returns "ABSENT" | "BROKEN".
76
+ // [RULE] one-availability-classifier [RULE] unknown-reason-fails-closed-to-broken
77
+ async function classifyGraphFailure(projectDir, reason, detail, phaseName) {
78
+ const r = await runCli(
79
+ projectDir, "graph-availability",
80
+ ["classify", String(reason || ""), String(detail || "")],
81
+ "gsd-t-graph-availability.cjs", "graph-classify", true, phaseName
82
+ ).catch(() => null);
83
+ const env = r && r.envelope;
84
+ if (env && (env.state === "ABSENT" || env.state === "BROKEN")) return env.state;
85
+ return "BROKEN"; // fail-closed if the classifier itself is unreachable
86
+ }
87
+ // [RULE] absent-graph-auto-builds-once — build the index once via the existing
88
+ // `gsd-t graph index` path (local bin: gsd-t-graph-index.cjs build --repo <dir>).
89
+ async function buildGraphIndex(projectDir, phaseName) {
90
+ const r = await runCli(
91
+ projectDir, "graph index", ["build", "--repo", projectDir],
92
+ "gsd-t-graph-index.cjs", "graph-index", false, phaseName
93
+ ).catch(() => null);
94
+ return !!(r && r.ok);
95
+ }
74
96
  async function runVerifyGate(projectDir, label = "verify-gate", phaseName) {
75
97
  return runCli(projectDir, "verify-gate", ["--json"], "gsd-t-verify-gate.cjs", label, true, phaseName);
76
98
  }
@@ -184,15 +206,16 @@ const RESEARCH_RESULT_SCHEMA = {
184
206
  };
185
207
 
186
208
  // M94-D11 §READER: Query blast-radius / who-imports for structural impact before editing.
187
- // Returns a short text snippet injected into the Execute phase agent prompt.
188
- // On graph-unavailable: logs the fail-loud message and returns null (no grep fallback).
189
- // [RULE] quick-writer-pattern
190
- async function queryGraphForQuick(projectDir, task, phaseName) {
209
+ // Returns { line, halt, haltMessage }:
210
+ // BROKEN graph → { halt:true, haltMessage } (HALT the workflow never grep-fallback)
211
+ // ABSENT graph → auto-build once, re-query; still absent → BROKEN → halt
212
+ // [RULE] quick-writer-pattern [RULE] broken-graph-halts-never-greps
213
+ async function queryGraphForQuick(projectDir, task, phaseName, _rebuilt) {
191
214
  // Extract a plausible target hint from the task description (first word-token that
192
215
  // looks like a file or function name). Heuristic — the agent uses the full slice.
193
216
  const words = (task || "").split(/\s+/).filter(Boolean);
194
217
  const targetHint = words.find((w) => w.includes("/") || w.includes(".") || w.includes("#")) || words[0] || "";
195
- if (!targetHint || targetHint.length < 2) return null;
218
+ if (!targetHint || targetHint.length < 2) return { line: null, halt: false };
196
219
 
197
220
  const r = await runCli(
198
221
  projectDir,
@@ -205,15 +228,23 @@ async function queryGraphForQuick(projectDir, task, phaseName) {
205
228
  ).catch(() => ({ ok: false, exitCode: -1, envelope: null }));
206
229
 
207
230
  const env = r && r.envelope;
208
- if (!env) return null;
209
- if (!env.ok && env.reason === "graph-unavailable") {
210
- log(`M94-D11 READER: graph unavailable — fix it (gsd-t graph status). Quick proceeds without structural slice.`);
211
- return null; // Fail-loud logged; no grep fallback [RULE consumer-structural-grep-removed]
231
+ if (!env) return { line: null, halt: false };
232
+ if (!env.ok) {
233
+ const state = await classifyGraphFailure(projectDir, env.reason, env.detail, phaseName);
234
+ if (state === "ABSENT" && !_rebuilt) {
235
+ log(`M94-D11 READER: graph ABSENT — building index once, then re-querying.`);
236
+ await buildGraphIndex(projectDir, phaseName);
237
+ return queryGraphForQuick(projectDir, task, phaseName, true);
238
+ }
239
+ // BROKEN (or still-absent after one build) → HALT. NO grep fallback.
240
+ const haltMessage = `graph BROKEN (reason=${env.reason || "?"}) — quick HALTED. Fix it: run gsd-t graph status. No grep fallback.`;
241
+ log(`M94-D11 READER: ${haltMessage}`);
242
+ return { line: null, halt: true, haltMessage };
212
243
  }
213
- if (env.ok && Array.isArray(env.results)) {
214
- return `## Graph structural slice (blast-radius for "${targetHint}"):\n${JSON.stringify(env.results.slice(0, 20), null, 2)}`;
244
+ if (Array.isArray(env.results)) {
245
+ return { line: `## Graph structural slice (blast-radius for "${targetHint}"):\n${JSON.stringify(env.results.slice(0, 20), null, 2)}`, halt: false };
215
246
  }
216
- return null;
247
+ return { line: null, halt: false };
217
248
  }
218
249
 
219
250
  // M94-D11 §WRITER: Trigger a freshness pass over the touched set after edits.
@@ -249,8 +280,12 @@ const brief = await generateBrief(projectDir, { kind: "execute", id: "quick-brie
249
280
  await persistWiringMode("Preflight");
250
281
 
251
282
  // M94-D11 §READER: query graph for structural impact before the Execute agent reasons
252
- // [RULE] quick-writer-pattern
253
- const _graphSliceLine = await queryGraphForQuick(projectDir, task, "Preflight") || "";
283
+ // [RULE] quick-writer-pattern [RULE] broken-graph-halts-never-greps
284
+ const _graphRead = await queryGraphForQuick(projectDir, task, "Preflight");
285
+ if (_graphRead && _graphRead.halt) {
286
+ return { status: "blocked-needs-human", reason: "graph-broken", detail: _graphRead.haltMessage };
287
+ }
288
+ const _graphSliceLine = (_graphRead && _graphRead.line) || "";
254
289
 
255
290
  phase("Execute");
256
291
  const result = await agent(
@@ -312,6 +312,24 @@ async function runCliBuild() {
312
312
  return r || { ok: false, reason: "build-failed" };
313
313
  }
314
314
 
315
+ // Broken-Graph-Halts (EXEMPT carve-out): scan continues in announced grep-mode when the
316
+ // graph is unavailable, but it MUST DISTINGUISH absent from broken. On ABSENT it builds
317
+ // once (below); on BROKEN it must name BROKEN loudly, not treat it as merely un-indexed.
318
+ // Routes the reason through the ONE shared classifier via Bash.
319
+ // [RULE] one-availability-classifier [RULE] unknown-reason-fails-closed-to-broken
320
+ async function classifyGraphFailure(reason, detail) {
321
+ const r = await agent(
322
+ [
323
+ `Classify a GSD-T graph-availability failure for the project at \`${projectDir}\`. Steps:`,
324
+ `1. If \`${projectDir}/bin/gsd-t-graph-availability.cjs\` exists, run: \`node ${projectDir}/bin/gsd-t-graph-availability.cjs classify '${String(reason || "").replace(/'/g, "'\\''")}' '${String(detail || "").replace(/'/g, "'\\''")}'\` (via="local"). Otherwise run: \`gsd-t graph-availability classify '${String(reason || "")}' '${String(detail || "")}'\` (via="global"). cwd \`${projectDir}\`.`,
325
+ `2. The command prints ONE JSON envelope with a "state" field ("ABSENT" or "BROKEN"). Copy "state" up to the top level of your reply.`,
326
+ `Do NOT do any other work.`,
327
+ ].join("\n"),
328
+ { label: "graph:classify", phase: "Graph-Wiring", model: "haiku", schema: { type: "object", required: ["state"], additionalProperties: true, properties: { state: { type: "string" } } } }
329
+ ).catch(() => null);
330
+ return (r && (r.state === "ABSENT" || r.state === "BROKEN")) ? r.state : "BROKEN";
331
+ }
332
+
315
333
  // M99 D2: persist a kind:'wiring' ledger line for this workflow.
316
334
  // M81 sandbox: all I/O through agent() Bash; no require/fs in the sandbox.
317
335
  // Uses the `gsd-t graph wiring-log` CLI shim (avoids embedding require() in strings).
@@ -413,20 +431,32 @@ if (graphMode === "disabled") {
413
431
  // every project lacking a pre-built index (hilo-figma-atos 2026-06-30).
414
432
  // [RULE] scan-builds-index-when-absent
415
433
  if (!statusResult || !statusResult.ok) {
416
- log(`graph-wiring: index not queryable (${(statusResult && statusResult.reason) || "graph-unavailable"}) building it now (gsd-t graph index)...`);
417
- const buildResult = await runCliBuild();
418
- if (buildResult && buildResult.ok) {
419
- log(`graph-wiring: index build OK — re-probing status.`);
420
- statusResult = await runCli("status", null, "status");
434
+ // [RULE] one-availability-classifier distinguish ABSENT (build once) from BROKEN (name it).
435
+ const _reason = (statusResult && statusResult.reason) || "graph-broken";
436
+ const _state = await classifyGraphFailure(_reason, statusResult && statusResult.detail);
437
+ if (_state === "ABSENT") {
438
+ // [RULE] absent-graph-auto-builds-once / [RULE] scan-builds-index-when-absent
439
+ log(`graph-wiring: index ABSENT (${_reason}) — building it now (gsd-t graph index)...`);
440
+ const buildResult = await runCliBuild();
441
+ if (buildResult && buildResult.ok) {
442
+ log(`graph-wiring: index build OK — re-probing status.`);
443
+ statusResult = await runCli("status", null, "status");
444
+ } else {
445
+ log(`graph-wiring: index build FAILED [${(buildResult && buildResult.reason) || "build-failed"}] — graph now BROKEN (build infra failing).`);
446
+ }
421
447
  } else {
422
- log(`graph-wiring: index build FAILED [${(buildResult && buildResult.reason) || "build-failed"}] falling back to grep-mode.`);
448
+ // BROKEN do NOT waste a build; name it loudly (announced carve-out continues grep-mode).
449
+ log(`graph-wiring: graph BROKEN (${_reason}) — NOT merely un-indexed; a build won't fix it. Fix it: gsd-t graph status.`);
423
450
  }
424
451
  }
425
452
  if (!statusResult || !statusResult.ok) {
426
- // Graph still unavailable after a build attempt announce fallback, continue
427
- // with intact grep-mode scan. [RULE] parser-fail-disables-loud-never-silent
453
+ // Graph still unavailable classify for an honest ABSENT-vs-BROKEN message, then
454
+ // announce fallback and continue with intact grep-mode scan (exempt carve-out).
455
+ // [RULE] parser-fail-disables-loud-never-silent [RULE] broken-graph-halts-never-greps (carve-out: name BROKEN loudly)
428
456
  graphWiringMode = "fallback-announced";
429
- log(`⚠ GRAPH-FALLBACK (ANNOUNCED): graph unavailable after build attempt [reason=${(statusResult && statusResult.reason) || "graph-unavailable"}, via=${(statusResult && statusResult.via) || "?"}] — scan continues in full grep-mode (today's architecture, intact). Structural findings from LLM reconstruction only.`);
457
+ const _finReason = (statusResult && statusResult.reason) || "graph-broken";
458
+ const _finState = await classifyGraphFailure(_finReason, statusResult && statusResult.detail);
459
+ log(`⚠ GRAPH-FALLBACK (ANNOUNCED): graph ${_finState} [reason=${_finReason}, via=${(statusResult && statusResult.via) || "?"}] — scan continues in full grep-mode (today's architecture, intact). Structural findings from LLM reconstruction only.${_finState === "BROKEN" ? " NOTE: graph is BROKEN, not merely absent — fix it (gsd-t graph status)." : ""}`);
430
460
  await persistWiringMode("fallback-announced"); // M99 D2 [RULE] wiring-mode-three-states
431
461
  } else {
432
462
  // Step 2: query the structural slice (dead-code + dangling + clusters).
@@ -77,6 +77,20 @@ async function runCli(projectDir, subcmd, argv, localBin, label, parseJson = tru
77
77
  return r || { ok: false, exitCode: -1, envelope: null, via: "error" };
78
78
  }
79
79
  async function runPreflight(projectDir, label = "preflight", phaseName) { return runCli(projectDir, "preflight", ["--json"], "cli-preflight.cjs", label, true, phaseName); }
80
+ // Broken-Graph-Halts (EXEMPT carve-out): verify's graph slice is additive/announced — it
81
+ // does not hard-fail on an unavailable graph. But it MUST DISTINGUISH absent from broken and
82
+ // name BROKEN loudly, never silently continue as if merely un-indexed.
83
+ // [RULE] one-availability-classifier [RULE] broken-graph-halts-never-greps (carve-out: name BROKEN loudly)
84
+ async function classifyGraphFailure(projectDir, reason, detail, phaseName) {
85
+ const r = await runCli(
86
+ projectDir, "graph-availability",
87
+ ["classify", String(reason || ""), String(detail || "")],
88
+ "gsd-t-graph-availability.cjs", "graph-classify", true, phaseName
89
+ ).catch(() => null);
90
+ const env = r && r.envelope;
91
+ if (env && (env.state === "ABSENT" || env.state === "BROKEN")) return env.state;
92
+ return "BROKEN";
93
+ }
80
94
  async function runVerifyGate(projectDir, label = "verify-gate", phaseName) { return runCli(projectDir, "verify-gate", ["--json"], "gsd-t-verify-gate.cjs", label, true, phaseName); }
81
95
  // M92 D2 (keystone) — the deterministic shrink-metric. Computes the milestone diff's
82
96
  // net size from `git diff --numstat <range>` via the shared runCli helper (M71-clean:
@@ -295,8 +309,14 @@ let _graphVerifyWarning = null;
295
309
  if (dcEnv.ok === true) {
296
310
  _graphDeadCodeSlice = dcEnv;
297
311
  log(`M94 graph dead-code: ${(dcEnv.results || []).length} dead-code result(s) (tier: ${dcEnv.tier || "?"})`);
298
- } else if (dcEnv.reason === "graph-unavailable") {
299
- _graphVerifyWarning = "⚠ graph unavailable structural gate skipped, fix it (gsd-t graph status)";
312
+ } else if (dcEnv.ok === false) {
313
+ // [RULE] one-availability-classifier distinguish ABSENT (announced skip) from BROKEN (LOUD).
314
+ const _state = await classifyGraphFailure(projectDir, dcEnv.reason, dcEnv.detail, "Verify-Gate");
315
+ if (_state === "BROKEN") {
316
+ _graphVerifyWarning = `⚠ graph BROKEN (reason=${dcEnv.reason || "?"}) — structural gate skipped. This is NOT merely un-indexed; FIX it (gsd-t graph status).`;
317
+ } else {
318
+ _graphVerifyWarning = "⚠ graph ABSENT (never indexed) — structural gate skipped (announced carve-out; build with gsd-t graph index)";
319
+ }
300
320
  log(`M94 graph dead-code: ${_graphVerifyWarning}`);
301
321
  } else {
302
322
  _graphVerifyWarning = `⚠ graph dead-code query unexpected envelope (reason: ${dcEnv.reason || "?"}); structural gate skipped`;