@tekyzinc/gsd-t 5.0.12 → 5.0.14
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 +27 -0
- package/README.md +1 -1
- package/bin/gsd-t-file-disjointness.cjs +102 -27
- package/bin/gsd-t-graph-availability.cjs +70 -0
- package/bin/gsd-t-graph-query-cli.cjs +18 -9
- package/bin/gsd-t-parallel.cjs +19 -1
- package/bin/gsd-t.js +37 -12
- package/commands/gsd-t-architect.md +6 -0
- package/package.json +1 -1
- package/scripts/gsd-t-architect-oversight-guard.js +8 -2
- package/templates/CLAUDE-global.md +17 -0
- package/templates/workflows/gsd-t-debug.workflow.js +43 -10
- package/templates/workflows/gsd-t-integrate.workflow.js +22 -2
- package/templates/workflows/gsd-t-phase.workflow.js +49 -10
- package/templates/workflows/gsd-t-quick.workflow.js +49 -14
- package/templates/workflows/gsd-t-scan.workflow.js +39 -9
- package/templates/workflows/gsd-t-verify.workflow.js +22 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to GSD-T are documented here. Updated with each release.
|
|
4
4
|
|
|
5
|
+
## [5.0.14] - 2026-07-13
|
|
6
|
+
|
|
7
|
+
### Added — No-Fallback-Ever Doctrine (never branch around a failure without asking)
|
|
8
|
+
|
|
9
|
+
"Pretend every project is a vampire and the word fallback is garlic." A fallback — anything that CONTINUES after a failure (catch-and-continue, `|| default`, silent degrade, try-X-else-Y where Y masks X failing) — is now BANNED unless the user approves it first, or you can cite a confirmed reproducible case only a fallback catches. This reflex is what hid the 12-day broken-graph break and the "npm install silently kept the old version" skew. The preferred move where a straight line can fail is a HALT (stop + demand fix), which is explicitly NOT a fallback. Strongest sibling of the existing No-Silent-Degradation feedback.
|
|
10
|
+
|
|
11
|
+
- `templates/CLAUDE-global.md` (+ `~/.claude/CLAUDE.md`): the doctrine (definition) — banned scope, the halt-not-fallback distinction, the ask-first test, and the release-process clause (verify the change landed / version advanced, HALT if not — never trust a reported-success install).
|
|
12
|
+
- `scripts/gsd-t-architect-oversight-guard.js`: the Write/Edit trigger reminder now also challenges fallbacks at the build moment (reused the existing architect hook — no new hook).
|
|
13
|
+
- `templates/workflows/gsd-t-phase.workflow.js` + `commands/gsd-t-architect.md`: added Stage 6b (No-Fallback) to the Six-Stage Pass directive.
|
|
14
|
+
- `.gsd-t/contracts/architects-oversight-contract.md`: Stage 6b row.
|
|
15
|
+
- Reused the existing `bin/gsd-t-graph-anti-grep-lint.cjs` for the graph-specific mechanical guard; deliberately did NOT build a general static fallback-detector (it can't judge proven-necessary — that's the ask).
|
|
16
|
+
- `test/m101-architect-oversight-hook.test.js`: 4 wiring tests (doctrine in both templates, hook, workflow, command).
|
|
17
|
+
|
|
18
|
+
## [5.0.13] - 2026-07-12
|
|
19
|
+
|
|
20
|
+
### Fixed — a BROKEN graph now HALTS instead of silently falling back to grep
|
|
21
|
+
|
|
22
|
+
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).
|
|
23
|
+
|
|
24
|
+
- `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.
|
|
25
|
+
- `bin/gsd-t-graph-query-cli.cjs`: producer edge emits the split reason codes (absent vs broken + `detail`).
|
|
26
|
+
- `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`.
|
|
27
|
+
- `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`.
|
|
28
|
+
- 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.
|
|
29
|
+
- `.gsd-t/contracts/graph-consumer-wiring-contract.md`: new §Broken-vs-Absent + 6 `[RULE]`s.
|
|
30
|
+
- `test/broken-graph-halts.test.js` (NEW, 15 tests) + `.gsd-t/pseudocode/PseudoCode-BrokenGraphHalts.md`.
|
|
31
|
+
|
|
5
32
|
## [5.0.12] - 2026-07-12
|
|
6
33
|
|
|
7
34
|
### 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.
|
|
3
|
+
**v5.0.14** - 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", "
|
|
231
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
228
232
|
timeout: 10000,
|
|
229
233
|
});
|
|
230
|
-
} catch {
|
|
231
|
-
// CLI
|
|
232
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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 } =
|
|
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
|
|
286
|
-
|
|
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 } =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 —
|
|
531
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
1362
|
+
fail({ ok: false, reason: storeResult.reason || "graph-broken", detail: storeResult.detail });
|
|
1354
1363
|
}
|
|
1355
1364
|
|
|
1356
1365
|
const index = storeResult.index;
|
package/bin/gsd-t-parallel.cjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|
|
@@ -79,6 +79,12 @@ The subagent works through the six stages IN ORDER. Each can kill or reshape the
|
|
|
79
79
|
the simpler fix actually works** — if the plan says "handled elsewhere / picked up later,"
|
|
80
80
|
VERIFY that's true; do not assert self-healing you haven't checked. (This is the trap that
|
|
81
81
|
hides inside a simplification.)
|
|
82
|
+
6b. **NO-FALLBACK-EVER** — Does the design add ANY fallback (anything that CONTINUES after a
|
|
83
|
+
failure: catch-and-continue, `|| default`, silent degrade, try-X-else-Y where Y masks X
|
|
84
|
+
failing)? If yes, do NOT design it in — surface it as an OPEN QUESTION for the user, UNLESS
|
|
85
|
+
you can cite a confirmed reproducible case only a fallback catches. The straight-line process
|
|
86
|
+
that produces the result is the goal; where it can fail, prefer a **HALT** (stop + demand
|
|
87
|
+
fix), which is NOT a fallback. (See CLAUDE.md § No-Fallback-Ever Doctrine.)
|
|
82
88
|
|
|
83
89
|
A stage the subagent cannot answer with evidence is a HALT — surface it as an open question for
|
|
84
90
|
the user, do not paper over it with a guess.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tekyzinc/gsd-t",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.14",
|
|
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",
|
|
@@ -33,13 +33,19 @@
|
|
|
33
33
|
const fs = require("fs");
|
|
34
34
|
const path = require("path");
|
|
35
35
|
|
|
36
|
-
//
|
|
36
|
+
// A pointer, not the doctrine. Keeps context cost ~nil. Carries TWO reminders:
|
|
37
|
+
// the Six-Stage Pass (design-first) and the No-Fallback-Ever rule (never branch
|
|
38
|
+
// around a failure without asking) — both fire at the write-code moment.
|
|
37
39
|
const REMINDER =
|
|
38
40
|
"[GSD-T ARCHITECT] About to write/edit code — run the Architect's Oversight " +
|
|
39
41
|
"Six-Stage Pass FIRST (Objective → Conflict → Reuse[query the graph] → " +
|
|
40
42
|
"Simplicity → Reuse-forecast → Risk), each answered with evidence not conviction. " +
|
|
41
43
|
"Is this the simplest design, and does something reusable already exist? " +
|
|
42
|
-
"
|
|
44
|
+
"NO-FALLBACK-EVER: if this adds anything that CONTINUES AFTER A FAILURE " +
|
|
45
|
+
"(catch-and-continue, || default, silent degrade, try-X-else-Y) — STOP and ask the " +
|
|
46
|
+
"user first, unless you can cite a confirmed reproducible case only a fallback catches. " +
|
|
47
|
+
"A HALT (stop + demand fix) is NOT a fallback and is the preferred move. " +
|
|
48
|
+
"See ~/.claude/CLAUDE.md § Architect's Oversight + No-Fallback-Ever Doctrines.";
|
|
43
49
|
|
|
44
50
|
// File extensions that are PROSE/config, not code — skip the reminder for these.
|
|
45
51
|
// (The doctrine's own artifacts are markdown; reminding while authoring them is noise.)
|
|
@@ -456,6 +456,23 @@ See memory pointer: `feedback_auto_research_external_gaps`.
|
|
|
456
456
|
|
|
457
457
|
**§Enforcement (three layers — same shape as Unproven-Assumption):** (1) this doctrine = the *definition* (reference, always available); (2) a **PreToolUse hook on Write/Edit** = the *trigger* — injects a one-line reminder pointing here at the build moment, so it can't be missed under load; (3) the **plan/milestone workflow gate** = the *execution* — the Six-Stage Pass runs as blocking `agent()` steps with graph/doc evidence, and pseudocode-completeness is a verify check. Injecting the doctrine ≠ executing it; the workflow does the doing.
|
|
458
458
|
|
|
459
|
+
### No-Fallback-Ever Doctrine (governed, enforced — the strongest sibling of No-Silent-Degradation)
|
|
460
|
+
|
|
461
|
+
**Pretend every project is a vampire and the word "fallback" is garlic.** A fallback — **anything that continues after a failure** (a `catch` that keeps going, `|| default`, a silent degrade, a secondary path, a "try X else Y" where Y masks X failing) — is **BANNED unless the user explicitly approves it first, OR you can cite a confirmed, reproducible case where the straight-line path provably fails and ONLY a fallback catches it.** No trivial exception. This is a Destructive-Action-Guard-class STOP: before writing any such branch, **halt and ask the user.**
|
|
462
|
+
|
|
463
|
+
**Why:** the reflex to add a fallback "just in case" is what hides real failures — a fallback that guards an *unproven* case doesn't add safety, it adds a place for a real failure to hide (e.g. a graph query that silently falls back to grep when the graph is actually broken). The desired discipline is the **straight-line process that produces the result**; if the straight line can fail, the answer is **HALT and surface it**, not branch around it.
|
|
464
|
+
|
|
465
|
+
**The one thing that is NOT a fallback (and is REQUIRED where a straight line can fail):** a **HALT** — refusing to continue and demanding a fix (return `blocked-needs-human`, exit non-zero, loud message). A halt is the *opposite* of a fallback: it stops the hiding instead of enabling it. When you're tempted to write a fallback, the correct move is almost always a halt.
|
|
466
|
+
|
|
467
|
+
**Test before writing any "continue after failure" branch:**
|
|
468
|
+
1. Is this a fallback (continues past a failure) or a halt (stops)? Halt → allowed. Fallback → continue to 2.
|
|
469
|
+
2. Can I name a **confirmed, reproducible** failing case where ONLY this fallback catches it? If NO → **STOP and ask the user.** If YES → cite it in a comment + the commit, then proceed.
|
|
470
|
+
3. Never judge "trivial" yourself — the trivial-fallback judgment is exactly the one that has been failing. Ask.
|
|
471
|
+
|
|
472
|
+
**Applies to the release process too:** a publish/propagate step must **verify the change actually landed** (installed version advanced; a sample project received the new file) and **HALT if it didn't** — never trust an install/copy that *reports* success. A package manager silently keeping the old version is itself a banned silent fallback.
|
|
473
|
+
|
|
474
|
+
**§Enforcement (three layers):** (1) this doctrine = the definition; (2) the **Architect's Oversight Write/Edit trigger is extended** to challenge every fallback at the build moment ("is this a fallback? name the proven case or ask"); (3) mechanical guards where they already exist — the graph consumers' `try→catch→grep` fallback is caught by `bin/gsd-t-graph-anti-grep-lint.cjs` (reused, not duplicated); a general static "detect every fallback" lint is deliberately NOT built (it can't judge *proven-necessary* — that is the human/architect ask, which is layer 2).
|
|
475
|
+
|
|
459
476
|
### Phase Flow
|
|
460
477
|
- Upon completing a phase, automatically proceed to the next phase
|
|
461
478
|
- ONLY run Discussion phase if truly required (clear path → skip to Plan)
|
|
@@ -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
|
|
338
|
-
|
|
339
|
-
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
112
|
-
|
|
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
|
|
194
|
-
// [RULE]
|
|
195
|
-
const
|
|
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 (
|
|
204
|
-
const loudMessage = `⚠ graph ${verb} query returned unexpected envelope (ok=${env.ok}, reason=${env.reason || "?"}).
|
|
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:
|
|
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
|
|
@@ -895,6 +928,12 @@ const _architectPassLine = _ARCHITECT_PHASES.has(phaseName)
|
|
|
895
928
|
` if stability forbids touching it → build new BUT register a "reuse-candidate" graph link`,
|
|
896
929
|
` to the twin (never a silent rogue duplicate).`,
|
|
897
930
|
` 6. RISK — any security or stability/scalability risk? Am I sure?`,
|
|
931
|
+
` 6b. NO-FALLBACK-EVER — does this plan add ANY fallback (anything that CONTINUES after a`,
|
|
932
|
+
` failure: catch-and-continue, || default, silent degrade, try-X-else-Y where Y masks X`,
|
|
933
|
+
` failing)? If yes: STOP and surface it as an OPEN QUESTION for the user — do NOT design`,
|
|
934
|
+
` it in — UNLESS you can cite a confirmed reproducible case only a fallback catches. The`,
|
|
935
|
+
` straight-line process that produces the result is the goal; where it can fail, prefer a`,
|
|
936
|
+
` HALT (stop + demand fix), which is NOT a fallback. Fallbacks are rare and ask-first.`,
|
|
898
937
|
`A stage you cannot answer with evidence HALTS the plan (needs-human) — do not proceed on a hunch.`,
|
|
899
938
|
].join("\n")
|
|
900
939
|
: "";
|
|
@@ -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
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
|
|
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
|
|
210
|
-
|
|
211
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
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
|
|
427
|
-
// with intact grep-mode scan
|
|
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
|
-
|
|
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.
|
|
299
|
-
|
|
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`;
|