@tekyzinc/gsd-t 4.11.11 → 4.13.10

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,30 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [4.13.10] - 2026-06-29
6
+
7
+ ### Added — The graph serves code (function-level slices) + Read-intercept (M98)
8
+
9
+ The code graph was index-only (names, files, line numbers, call/import edges — no code). M98 makes it SERVE CODE: a new query returns a single function's source, and a Read-intercept hook points structural code-reads at it.
10
+
11
+ - **`gsd-t graph body <funcId|symbol>`** (`bin/gsd-t-graph-query-cli.cjs`) — returns a function's SOURCE sliced LIVE from disk by line range, with its import lines, enclosing class header (for methods), and caller list attached. Reads live (never stores bodies — files are local, always fresh); the query path re-indexes a stale file BEFORE slicing so line ranges are current. Ambiguous bare symbol → candidate list, never a merged body. A pre-M98 node (no end line) is re-indexed inline to populate it; never guesses.
12
+ - **Function END lines captured** (`bin/gsd-t-graph-edge-extract.cjs`, `bin/gsd-t-graph-index.cjs`) — each function/method/class entity now records `endPosition` as `nodes.end_line` (the only schema change; idempotently `ALTER TABLE`-migrated onto a pre-M98 graph; kept fresh by the existing DELETE-then-reinsert re-index path).
13
+ - **Read-intercept hook** (`scripts/gsd-t-read-intercept.js`, PostToolUse on `Read`) — when a code read's offset/limit lands inside exactly one known function, AUGMENTS the output (original retained) with a pointer to `graph body <funcId>`. Default = pass-through: a bare/edit-intent read is NEVER shrunk (no-regression). Fail-open, no-op without a graph, code files only. Registered + removed by `gsd-t install` / uninstall (`configureReadInterceptHook` / `removeInterceptHooks`).
14
+
15
+ Measured on real binvoice: `body selectCommentId` returned the exact 43-line function from a 1,334-line file — 21× fewer characters — compiler-accurate, with imports + 11 callers. Suite: 2538/2538 pass (17 new M98 tests; all 7 ACs green incl. the AC-3 freshness + AC-4 ambiguity killing tests).
16
+
17
+ ## [4.12.10] - 2026-06-27
18
+
19
+ ### Added — Python call-graph resolution (scip-python)
20
+
21
+ The precise call-graph now covers Python, not just TS/JS. `buildScipResolver` runs scip-python alongside scip-typescript and merges both resolution maps — a repo with both languages gets one unified call-graph.
22
+
23
+ - `bin/gsd-t-graph-scip-upgrade.cjs`: run scip-python when Python source is present; merge its `fileRefs` into the resolver. New `detectRepoLanguages()` skips an indexer when its language is absent (no wasted run). `runScipPython` now emits to a real `.gsd-t/index-python.scip` and passes `--project-name` + `--project-version 0.0.0` (scip-python crashes on an undefined version).
24
+ - The SCIP reader was already language-agnostic (Python symbols use the same `name().` descriptor form), so cross-file Python calls resolve with no reader change.
25
+ - `gsd-t install` / `gsd-t doctor` already covered scip-python (added in M95/M96).
26
+
27
+ Proven on BDS (35 .py): 125 → 1,331 resolved calls (1,206 Python); `who-calls` returns real Python callers. Fixture test resolves a cross-file `pkg.util.compute_total` call. Suite: 2521/2521 pass.
28
+
5
29
  ## [4.11.11] - 2026-06-27
6
30
 
7
31
  ### Fixed — SCIP resolution missed nested/monorepo tsconfig (call-graph empty on subdir-tsconfig projects)
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v4.11.11** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v4.13.10** - 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.
@@ -255,6 +255,7 @@ function walkPython(rootNode, relPath, entities, edges) {
255
255
  name,
256
256
  type: 'function',
257
257
  line: node.startPosition.row + 1,
258
+ endLine: node.endPosition.row + 1,
258
259
  exported: false,
259
260
  });
260
261
  // walk body with this funcId as enclosing
@@ -275,6 +276,7 @@ function walkPython(rootNode, relPath, entities, edges) {
275
276
  name,
276
277
  type: 'class',
277
278
  line: node.startPosition.row + 1,
279
+ endLine: node.endPosition.row + 1,
278
280
  exported: false,
279
281
  });
280
282
  const body = node.childForFieldName('body');
@@ -390,6 +392,7 @@ function walkTSJS(rootNode, relPath, entities, edges) {
390
392
  name,
391
393
  type: enclosingClass ? 'method' : 'function',
392
394
  line: node.startPosition.row + 1,
395
+ endLine: node.endPosition.row + 1,
393
396
  exported: isExportedNode(node),
394
397
  ...(enclosingClass ? { parentClass: enclosingClass } : {}),
395
398
  });
@@ -412,6 +415,7 @@ function walkTSJS(rootNode, relPath, entities, edges) {
412
415
  name,
413
416
  type: 'method',
414
417
  line: node.startPosition.row + 1,
418
+ endLine: node.endPosition.row + 1,
415
419
  exported: true, // methods are implicitly exported via their class
416
420
  parentClass: enclosingClass || undefined,
417
421
  });
@@ -432,6 +436,7 @@ function walkTSJS(rootNode, relPath, entities, edges) {
432
436
  name,
433
437
  type: 'class',
434
438
  line: node.startPosition.row + 1,
439
+ endLine: node.endPosition.row + 1,
435
440
  exported: isExportedNode(node),
436
441
  });
437
442
  // Walk children with class context
@@ -462,6 +467,7 @@ function walkTSJS(rootNode, relPath, entities, edges) {
462
467
  name,
463
468
  type: 'function',
464
469
  line: decl.startPosition.row + 1,
470
+ endLine: valueNode.endPosition.row + 1,
465
471
  exported: isExp,
466
472
  });
467
473
  // Walk arrow/function body with this funcId
@@ -172,7 +172,8 @@ function buildSchema(db) {
172
172
  content_hash TEXT NOT NULL,
173
173
  file TEXT NOT NULL,
174
174
  name TEXT,
175
- func_id TEXT
175
+ func_id TEXT,
176
+ end_line INTEGER
176
177
  );
177
178
  CREATE TABLE IF NOT EXISTS edges (
178
179
  kind TEXT NOT NULL,
@@ -185,6 +186,21 @@ function buildSchema(db) {
185
186
  CREATE INDEX IF NOT EXISTS edges_kind_dst ON edges(kind, dst);
186
187
  CREATE INDEX IF NOT EXISTS nodes_file ON nodes(file);
187
188
  `);
189
+ migrateEndLine(db);
190
+ }
191
+
192
+ /**
193
+ * M98 — idempotent migration: add `nodes.end_line` to a pre-M98 graph that was
194
+ * built before the column existed. `CREATE TABLE IF NOT EXISTS` won't alter an
195
+ * existing table, so an older graph keeps its 7-column `nodes` until this runs.
196
+ * Safe to call on every open: checks PRAGMA first, ALTERs only when absent, never
197
+ * loses data. A NULL end_line is allowed (D2 re-indexes the file to populate it).
198
+ */
199
+ function migrateEndLine(db) {
200
+ const cols = db.prepare("PRAGMA table_info(nodes)").all();
201
+ if (!cols.some((c) => c.name === 'end_line')) {
202
+ db.exec('ALTER TABLE nodes ADD COLUMN end_line INTEGER');
203
+ }
188
204
  }
189
205
 
190
206
  /**
@@ -210,7 +226,7 @@ function getWriteStmts(db) {
210
226
  deleteNodes: db.prepare('DELETE FROM nodes WHERE file = ?'),
211
227
  deleteEdgesSrc: db.prepare("DELETE FROM edges WHERE src = ? OR src LIKE ? OR src = ?"),
212
228
  insFile: db.prepare('INSERT OR REPLACE INTO files (file, content_hash, tier, indexed_at) VALUES (?, ?, ?, ?)'),
213
- insNode: db.prepare('INSERT OR REPLACE INTO nodes (id, kind, tier, content_hash, file, name, func_id) VALUES (?, ?, ?, ?, ?, ?, ?)'),
229
+ insNode: db.prepare('INSERT OR REPLACE INTO nodes (id, kind, tier, content_hash, file, name, func_id, end_line) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'),
214
230
  insEdge: db.prepare('INSERT INTO edges (kind, src, dst, partial) VALUES (?, ?, ?, ?)'),
215
231
  };
216
232
  // db.transaction() wrapper is also created once (it closes over the stmts).
@@ -220,7 +236,7 @@ function getWriteStmts(db) {
220
236
  stmts.deleteEdgesSrc.run(file, `${file}#%`, file);
221
237
  stmts.insFile.run(file, hash, tier, now);
222
238
  for (const entity of entities) {
223
- stmts.insNode.run(entity.id, entity.type || 'function', tier, hash, file, entity.name || null, entity.id);
239
+ stmts.insNode.run(entity.id, entity.type || 'function', tier, hash, file, entity.name || null, entity.id, entity.endLine ?? null);
224
240
  }
225
241
  for (const edge of edges) {
226
242
  stmts.insEdge.run(edge.kind, edge.src, edge.dst, edge.partial ? 1 : 0);
@@ -300,6 +316,12 @@ function parse_and_put(absPath, relPath, options) {
300
316
  finalEntities = upgraded.entities;
301
317
  finalEdges = upgraded.edges;
302
318
  }
319
+ } else if (existingTier === 'compiler-accurate') {
320
+ // [RULE] reindex-tier-never-silently-downgraded — no SCIP context this call
321
+ // (e.g. a metadata-only re-index like M98's body end-line backfill). A file that
322
+ // WAS compiler-accurate must NOT silently drop to plain floor; label it STALE-SCIP
323
+ // so the tier reflects "previously accurate, not re-resolved" rather than a lie.
324
+ tier = 'tree-sitter-floor-STALE-SCIP';
303
325
  }
304
326
 
305
327
  // Normalize edges to store schema (map from parser-floor shape to store shape)
@@ -337,6 +359,7 @@ function parse_and_put(absPath, relPath, options) {
337
359
  file: relPath,
338
360
  exported: e.exported,
339
361
  parentClass: e.parentClass,
362
+ endLine: e.endLine ?? null, // M98 — function end line for body-slice
340
363
  }));
341
364
 
342
365
  if (db) {
@@ -9,6 +9,7 @@
9
9
  * Verbs (D5 — original):
10
10
  * gsd-t graph who-imports <file> — file→file reverse import edges
11
11
  * gsd-t graph who-calls <file#function> — function→function reverse call edges (funcId-keyed)
12
+ * gsd-t graph body <file#function|symbol> — (M98) a function's SOURCE sliced LIVE from disk + imports + class header + callers
12
13
  * gsd-t graph blast-radius <target> — UNION of import-graph + call-graph reverse-reachable set (transitive)
13
14
  * gsd-t graph status — live queryable index state
14
15
  *
@@ -129,7 +130,7 @@ function resolveStorePath() {
129
130
 
130
131
  /**
131
132
  * @typedef {{ importGraph: Map<string,Set<string>>, callGraph: Map<string,Set<string>>,
132
- * funcEntities: Map<string,{name:string,file:string,tier:string}>,
133
+ * funcEntities: Map<string,{name:string,file:string,tier:string,endLine:?number}>,
133
134
  * allFiles: Set<string>, tier: string,
134
135
  * skippedFiles: Set<string> }} IndexStructure
135
136
  *
@@ -329,6 +330,7 @@ function buildIndex(records, skippedFiles) {
329
330
  name: ent.name,
330
331
  file: ent.file || rec.file,
331
332
  tier: ent.tier || rec.tier,
333
+ endLine: ent.endLine ?? null,
332
334
  });
333
335
  }
334
336
  }
@@ -399,7 +401,13 @@ function loadSqliteStore(dbPath) {
399
401
  let db;
400
402
  try {
401
403
  db = new Database(dbPath, { readonly: true });
402
- const nodes = db.prepare("SELECT id, kind, tier, file, name, func_id FROM nodes").all();
404
+ // M98: end_line is a post-M98 column; a pre-M98 graph (read-only here, so the
405
+ // write-path migration can't run) lacks it. Detect and select conditionally so
406
+ // an older index still loads (end_line just comes back null → body re-indexes).
407
+ const hasEndLine = db.prepare("PRAGMA table_info(nodes)").all().some((c) => c.name === "end_line");
408
+ const nodes = db.prepare(
409
+ `SELECT id, kind, tier, file, name, func_id, ${hasEndLine ? "end_line" : "NULL AS end_line"} FROM nodes`
410
+ ).all();
403
411
  const edges = db.prepare("SELECT kind, src, dst FROM edges").all();
404
412
 
405
413
  const norm = (p) => p.replace(/\\/g, "/");
@@ -427,7 +435,7 @@ function loadSqliteStore(dbPath) {
427
435
  return byFile.get(file);
428
436
  };
429
437
  for (const n of nodes) {
430
- if (n.func_id) rec(n.file).entities.push({ funcId: n.func_id, name: n.name, file: n.file, tier: n.tier });
438
+ if (n.func_id) rec(n.file).entities.push({ funcId: n.func_id, name: n.name, file: n.file, tier: n.tier, endLine: n.end_line });
431
439
  }
432
440
  for (const e of edges) {
433
441
  // src for an IMPORT edge is the source FILE; for a CALL edge it's a funcId
@@ -609,6 +617,116 @@ function queryWhoCalls(index, identity) {
609
617
  return { ambiguous: true, candidates: matchingFuncIds.sort() };
610
618
  }
611
619
 
620
+ // ─── Query: body (M98) ────────────────────────────────────────────────────────
621
+
622
+ /**
623
+ * body(identity): return a single function's SOURCE — read LIVE from disk by line
624
+ * range — plus context (the file's import lines + the enclosing class header if a
625
+ * method + the caller list from the call graph). The headline "smart-reach" win:
626
+ * ~180 tokens for one function vs ~7,750 for the whole file.
627
+ *
628
+ * Resolution mirrors who-calls disambiguation [RULE] who-calls-function-identity-disambiguated:
629
+ * - exact funcId (`file#name@line`) → that node
630
+ * - bare `name` matching exactly one node → that node
631
+ * - bare `name` matching N>1 → { ambiguous:true, candidates:[...] } — NEVER a merged body
632
+ * - no match → { notFound:true }
633
+ *
634
+ * INVARIANTS (feed verify gate, contract §2):
635
+ * [RULE] body-reads-live-never-stored — source comes from a live disk read, not a DB blob
636
+ * [RULE] body-freshness-reindex-before-slice — Step-1 freshness re-indexes a stale file BEFORE this runs
637
+ * [RULE] body-ambiguous-never-merged — ambiguous symbol → candidates, never a wrong/joined body
638
+ * [RULE] body-end-line-required — a node with NULL end_line returns needsReindex (caller re-indexes)
639
+ *
640
+ * @param {IndexStructure} index
641
+ * @param {string} identity funcId or bare name
642
+ * @param {string} projectRoot repo root (live file read is relative to this)
643
+ * @returns {{ ok?:boolean, funcId?:string, file?:string, lineRange?:[number,number],
644
+ * tier?:string, imports?:string[], classHeader?:?string, source?:string,
645
+ * callers?:string[], ambiguous?:boolean, candidates?:string[],
646
+ * notFound?:boolean, needsReindex?:boolean }}
647
+ */
648
+ function queryBody(index, identity, projectRoot) {
649
+ // ── Resolve identity → a single funcId ──
650
+ let funcId = null;
651
+ if (identity.includes("#")) {
652
+ // File-qualified. Tolerate a missing @line suffix (funcEntities key carries it).
653
+ if (index.funcEntities.has(identity)) {
654
+ funcId = identity;
655
+ } else {
656
+ const noLine = identity.replace(/@\d+$/, "");
657
+ for (const fid of index.funcEntities.keys()) {
658
+ if (fid === noLine || fid.replace(/@\d+$/, "") === noLine) { funcId = fid; break; }
659
+ }
660
+ }
661
+ } else {
662
+ // Bare name — disambiguate, NEVER merge.
663
+ const matches = [];
664
+ for (const [fid, meta] of index.funcEntities) {
665
+ if (meta.name === identity) matches.push(fid);
666
+ }
667
+ if (matches.length === 1) funcId = matches[0];
668
+ else if (matches.length > 1) return { ambiguous: true, candidates: matches.sort() };
669
+ }
670
+
671
+ if (!funcId) return { notFound: true };
672
+
673
+ const meta = index.funcEntities.get(funcId);
674
+ // Start line is encoded in the funcId suffix `@<line>`; end_line from the node.
675
+ const startMatch = /@(\d+)$/.exec(funcId);
676
+ const startLine = startMatch ? parseInt(startMatch[1], 10) : null;
677
+ const endLine = meta.endLine;
678
+
679
+ // [RULE] body-end-line-required — a pre-M98 node has no end_line; caller re-indexes.
680
+ if (startLine == null || endLine == null) {
681
+ return { needsReindex: true, funcId, file: meta.file };
682
+ }
683
+
684
+ // [RULE] body-reads-live-never-stored — slice the live file by line range.
685
+ const absFile = path.isAbsolute(meta.file) ? meta.file : path.join(projectRoot, meta.file);
686
+ let lines;
687
+ try { lines = fs.readFileSync(absFile, "utf8").split("\n"); }
688
+ catch (_e) { return { notFound: true, file: meta.file }; }
689
+
690
+ const source = lines.slice(startLine - 1, endLine).join("\n");
691
+
692
+ // Context: the file's top-level import/require lines.
693
+ const imports = [];
694
+ for (const ln of lines) {
695
+ const t = ln.trim();
696
+ if (/^(import\s|export\s.*\sfrom\s|const\s+\w+\s*=\s*require\(|.*\brequire\()/.test(t) && /from\s|require\(/.test(t)) {
697
+ imports.push(ln);
698
+ }
699
+ if (imports.length >= 40) break; // safety cap
700
+ }
701
+
702
+ // Context: enclosing class header (if this funcId is a method — its line sits
703
+ // inside a `class X {` block above it). Best-effort: nearest preceding class line.
704
+ let classHeader = null;
705
+ for (let i = startLine - 2; i >= 0; i--) {
706
+ const m = /^\s*(export\s+)?(abstract\s+)?class\s+[A-Za-z_$][\w$]*/.exec(lines[i]);
707
+ if (m) { classHeader = lines[i].trim(); break; }
708
+ // Stop if we hit a top-level non-indented statement that isn't a class (cheap bound).
709
+ if (/^\S/.test(lines[i]) && !/^(export|import|\/\/|\/\*|\*)/.test(lines[i])) break;
710
+ }
711
+
712
+ // Context: callers from the existing call graph (free).
713
+ const fidNoLine = funcId.replace(/@\d+$/, "");
714
+ const callerSet = index.callGraph.get(funcId) || index.callGraph.get(fidNoLine);
715
+ const callers = callerSet ? Array.from(callerSet).sort() : [];
716
+
717
+ return {
718
+ ok: true,
719
+ funcId,
720
+ file: meta.file,
721
+ lineRange: [startLine, endLine],
722
+ tier: meta.tier || index.tier,
723
+ imports,
724
+ classHeader,
725
+ source,
726
+ callers,
727
+ };
728
+ }
729
+
612
730
  // ─── Query: blast-radius ──────────────────────────────────────────────────────
613
731
 
614
732
  /**
@@ -1070,6 +1188,7 @@ module.exports = {
1070
1188
  buildIndexFromRecords,
1071
1189
  queryWhoImports,
1072
1190
  queryWhoCalls,
1191
+ queryBody,
1073
1192
  queryBlastRadius,
1074
1193
  queryStatus,
1075
1194
  // D9 additions
@@ -1107,7 +1226,7 @@ if (require.main === module) {
1107
1226
  }
1108
1227
 
1109
1228
  const ALL_VERBS = [
1110
- "who-imports", "who-calls", "blast-radius", "status",
1229
+ "who-imports", "who-calls", "body", "blast-radius", "status",
1111
1230
  "cluster", "dead-code", "orphan", "dangling", "test-impl",
1112
1231
  ];
1113
1232
 
@@ -1157,6 +1276,57 @@ if (require.main === module) {
1157
1276
  }
1158
1277
  emit({ ok: true, verb, target, results: queryResult.results, tier: queryResult.tier, coverage: queryResult.coverage });
1159
1278
 
1279
+ } else if (verb === "body") {
1280
+ if (!target) fail({ ok: false, reason: "missing-target", verb });
1281
+ const projectRoot = path.dirname(path.dirname(storePath));
1282
+ let bodyResult = queryBody(index, target, projectRoot);
1283
+
1284
+ // [RULE] body-end-line-required — a pre-M98 node lacks end_line and its file
1285
+ // wasn't stale (so Step-1 freshness didn't re-index it). Re-index that ONE file
1286
+ // inline to populate end_line, reload, retry. Never guess an end.
1287
+ // [RULE] body-reindex-preserves-tier — pass the file's EXISTING tier as
1288
+ // existingTier so this read-path re-index does NOT silently downgrade a
1289
+ // compiler-accurate file to tree-sitter-floor (parse_and_put without SCIP would).
1290
+ if (bodyResult.needsReindex && bodyResult.file) {
1291
+ try {
1292
+ const { parse_and_put } = require(path.join(__dirname, "gsd-t-graph-index.cjs"));
1293
+ const { openDb } = loadFreshnessModule() || {};
1294
+ const absFile = path.isAbsolute(bodyResult.file) ? bodyResult.file : path.join(projectRoot, bodyResult.file);
1295
+ const db = typeof openDb === "function" ? openDb(projectRoot, storePath) : null;
1296
+ if (db && parse_and_put) {
1297
+ // Read the file's stored tier so it is preserved through the re-index.
1298
+ let existingTier = null;
1299
+ try {
1300
+ const row = db.prepare("SELECT tier FROM files WHERE file = ?").get(bodyResult.file);
1301
+ existingTier = row ? row.tier : null;
1302
+ } catch { /* tier preservation is best-effort */ }
1303
+ try {
1304
+ parse_and_put(absFile, bodyResult.file, { db, existingTier });
1305
+ } finally {
1306
+ try { db.close(); } catch {}
1307
+ }
1308
+ const reloaded = loadStore(storePath);
1309
+ if (reloaded.ok) bodyResult = queryBody(reloaded.index, target, projectRoot);
1310
+ }
1311
+ } catch (_e) { /* fall through to the needsReindex envelope below */ }
1312
+ }
1313
+
1314
+ if (bodyResult.ambiguous) {
1315
+ // [RULE] body-ambiguous-never-merged
1316
+ process.stdout.write(JSON.stringify({
1317
+ ok: false, reason: "ambiguous-function", verb, target, candidates: bodyResult.candidates,
1318
+ }) + "\n");
1319
+ process.exit(2);
1320
+ }
1321
+ if (bodyResult.notFound) fail({ ok: false, reason: "not-found", verb, target, file: bodyResult.file });
1322
+ if (bodyResult.needsReindex) fail({ ok: false, reason: "end-line-unavailable", verb, target, file: bodyResult.file });
1323
+ emit({
1324
+ ok: true, verb, target: bodyResult.funcId, file: bodyResult.file,
1325
+ lineRange: bodyResult.lineRange, tier: bodyResult.tier,
1326
+ imports: bodyResult.imports, classHeader: bodyResult.classHeader,
1327
+ source: bodyResult.source, callers: bodyResult.callers,
1328
+ });
1329
+
1160
1330
  } else if (verb === "blast-radius") {
1161
1331
  if (!target) fail({ ok: false, reason: "missing-target", verb });
1162
1332
  const { results, tier, coverage } = queryBlastRadius(index, target);
@@ -146,16 +146,53 @@ function runScipTypescript(projectRoot, outPath) {
146
146
  return { ok: false, error: scip.stderr || `exit code ${scip.status}` };
147
147
  }
148
148
 
149
- function runScipPython(projectRoot) {
150
- const scip = spawnSync('scip-python', ['index', '.'], {
149
+ function runScipPython(projectRoot, outPath) {
150
+ // Emit to a REAL file (separate from the TS index) so it can be READ + merged.
151
+ // --project-name is required-ish for stable symbols; derive from the dir name.
152
+ const out = outPath || path.join(projectRoot, '.gsd-t', 'index-python.scip');
153
+ const projName = path.basename(projectRoot).replace(/[^A-Za-z0-9_-]/g, '-') || 'project';
154
+ // scip-python REQUIRES a project version — it crashes (makeModuleInit) when the
155
+ // version is undefined (no pyproject.toml). Pass an explicit fallback version.
156
+ const scip = spawnSync('scip-python',
157
+ ['index', '--project-name', projName, '--project-version', '0.0.0', '--output', out, '.'], {
151
158
  cwd: projectRoot,
152
159
  encoding: 'utf8',
153
- timeout: 120_000,
160
+ timeout: 180_000,
154
161
  });
155
- if (scip.status === 0) return { ok: true };
162
+ if (scip.status === 0) return { ok: true, scipPath: out };
156
163
  return { ok: false, error: scip.stderr || `exit code ${scip.status}` };
157
164
  }
158
165
 
166
+ // Cheap shallow language detection — does the repo contain TS/JS or Python source
167
+ // (excluding vendored/build dirs)? Used to skip an indexer when its language is
168
+ // absent. Walks at most a bounded depth to stay fast.
169
+ function detectRepoLanguages(repoRoot) {
170
+ const SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'out', '.venv', 'venv',
171
+ 'site-packages', '__pycache__', '.next', 'coverage', 'Pods', '.dart_tool', 'vendor']);
172
+ let typescript = false, python = false;
173
+ function isSkip(name) {
174
+ if (SKIP.has(name)) return true;
175
+ return ['dist', 'build', 'out'].some(p => name.length > p.length && name.startsWith(p) &&
176
+ (name[p.length] === '-' || name[p.length] === '.' || name[p.length] === '_'));
177
+ }
178
+ function walk(dir, depth) {
179
+ if ((typescript && python) || depth > 6) return;
180
+ let entries;
181
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
182
+ for (const e of entries) {
183
+ if (typescript && python) return;
184
+ if (e.isDirectory()) { if (!isSkip(e.name)) walk(path.join(dir, e.name), depth + 1); }
185
+ else if (e.isFile()) {
186
+ const ext = path.extname(e.name).toLowerCase();
187
+ if (ext === '.ts' || ext === '.tsx' || ext === '.js' || ext === '.jsx' || ext === '.mjs' || ext === '.cjs') typescript = true;
188
+ else if (ext === '.py') python = true;
189
+ }
190
+ }
191
+ }
192
+ walk(repoRoot, 0);
193
+ return { typescript, python };
194
+ }
195
+
159
196
  function runScipRust(projectRoot) {
160
197
  // rust-analyzer + scip: run `scip` (the rust-analyzer scip bridge) if available
161
198
  // As with TypeScript, Phase-1 is tier-labelling only
@@ -218,32 +255,45 @@ function buildScipResolver(repoRoot, opts = {}) {
218
255
  const { readScipIndex } = require('./gsd-t-scip-reader.cjs');
219
256
  const avail = detectScip();
220
257
 
221
- // Phase-1 of M95: TypeScript/JavaScript only (scip-typescript). Python is a
222
- // sequenced follow-on. If TS SCIP is absent, the resolver is a no-op (floor).
223
- if (!avail.typescript) {
224
- return { ok: false, reason: 'scip-typescript-absent', resolveFileEdges: passthroughResolver };
258
+ // Detect which languages have source present (so we only run the relevant
259
+ // indexer). Cheap shallow check: does the repo contain .ts/.tsx/.js or .py?
260
+ const langs = detectRepoLanguages(repoRoot);
261
+
262
+ // Run each available+relevant indexer, merge their resolution maps. The reader
263
+ // is language-agnostic (Python symbols use the same `name().` descriptor form),
264
+ // so TS and Python refs merge into one fileRefs map keyed by repo-relative path.
265
+ const fileRefs = new Map(); // relPath → [{symbol, funcId, line}]
266
+ const ranIndexers = [];
267
+
268
+ function mergeRead(read) {
269
+ if (!read || !read.ok) return;
270
+ for (const [file, refs] of read.fileRefs) {
271
+ if (fileRefs.has(file)) fileRefs.get(file).push(...refs);
272
+ else fileRefs.set(file, refs.slice());
273
+ }
225
274
  }
226
275
 
227
- const scipPath = path.join(repoRoot, '.gsd-t', 'index.scip');
228
- let run;
229
- try {
230
- run = runScipTypescript(repoRoot, scipPath);
231
- } catch (e) {
232
- return { ok: false, reason: `scip-run-threw: ${e.message}`, resolveFileEdges: passthroughResolver };
276
+ if (avail.typescript && langs.typescript) {
277
+ try {
278
+ const run = runScipTypescript(repoRoot, path.join(repoRoot, '.gsd-t', 'index.scip'));
279
+ if (run && run.ok) { mergeRead(readScipIndex(run.scipPath)); ranIndexers.push('typescript'); }
280
+ } catch { /* degrade to floor for TS */ }
233
281
  }
234
- if (!run || !run.ok) {
235
- return { ok: false, reason: run ? run.error : 'scip-run-failed', resolveFileEdges: passthroughResolver };
282
+ if (avail.python && langs.python) {
283
+ try {
284
+ const run = runScipPython(repoRoot, path.join(repoRoot, '.gsd-t', 'index-python.scip'));
285
+ if (run && run.ok) { mergeRead(readScipIndex(run.scipPath)); ranIndexers.push('python'); }
286
+ } catch { /* degrade to floor for Python */ }
236
287
  }
237
288
 
238
- const read = readScipIndex(run.scipPath);
239
- if (!read.ok) {
240
- return { ok: false, reason: read.reason, resolveFileEdges: passthroughResolver };
289
+ if (!ranIndexers.length) {
290
+ // No applicable indexer present/ran → floor mode.
291
+ const reason = (!avail.typescript && !avail.python) ? 'scip-indexers-absent'
292
+ : (!langs.typescript && !langs.python) ? 'no-indexable-source'
293
+ : 'scip-run-failed';
294
+ return { ok: false, reason, resolveFileEdges: passthroughResolver };
241
295
  }
242
296
 
243
- // Build a fast per-file callee lookup: for a given test/impl file, the set of
244
- // funcIds it references (resolved). Used to rewrite UNRESOLVED# call edges.
245
- const fileRefs = read.fileRefs; // relPath → [{symbol, funcId, line}]
246
-
247
297
  /**
248
298
  * Resolve a single file's call edges. For each CALL edge whose dst is
249
299
  * UNRESOLVED#<name>, if SCIP found a reference to <name> in this file that
@@ -276,7 +326,7 @@ function buildScipResolver(repoRoot, opts = {}) {
276
326
  return { edges: out, resolved };
277
327
  }
278
328
 
279
- return { ok: true, scipPath: run.scipPath, resolveFileEdges };
329
+ return { ok: true, indexers: ranIndexers, scipPath: path.join(repoRoot, '.gsd-t', 'index.scip'), resolveFileEdges };
280
330
  }
281
331
 
282
332
  /** No-op resolver used when SCIP is unavailable (floor mode). */
package/bin/gsd-t.js CHANGED
@@ -460,6 +460,13 @@ const GRAPH_INTERCEPT_HOOK_MARKER = "gsd-t-graph-intercept";
460
460
  const GRAPH_INTERCEPT_HOOK_COMMAND =
461
461
  'bash -c \'[ -f "$(npm root -g)/@tekyzinc/gsd-t/scripts/gsd-t-graph-intercept.js" ] && node "$(npm root -g)/@tekyzinc/gsd-t/scripts/gsd-t-graph-intercept.js" || true\'';
462
462
 
463
+ // M98 — read-intercept PostToolUse hook on Read. Same global-safe pattern as the
464
+ // M97 grep-intercept: the script fails-open (no-op) in non-GSD-T projects / projects
465
+ // without a graph / non-code reads, so it is safe to register globally with a Read matcher.
466
+ const READ_INTERCEPT_HOOK_MARKER = "gsd-t-read-intercept";
467
+ const READ_INTERCEPT_HOOK_COMMAND =
468
+ 'bash -c \'[ -f "$(npm root -g)/@tekyzinc/gsd-t/scripts/gsd-t-read-intercept.js" ] && node "$(npm root -g)/@tekyzinc/gsd-t/scripts/gsd-t-read-intercept.js" || true\'';
469
+
463
470
  // Append entries to {projectDir}/.gitignore. Each entry added only if absent.
464
471
  // Idempotent. Returns true if any entries were added, false otherwise.
465
472
  function ensureGitignoreEntries(projectDir, entries) {
@@ -850,6 +857,97 @@ function configureGraphInterceptHook(settingsPath) {
850
857
  return { installed: true, action };
851
858
  }
852
859
 
860
+ // M98 — register the read-intercept PostToolUse hook (matcher "Read").
861
+ // Idempotent: find-by-marker, refresh stale command, else add. Mirrors the
862
+ // M97 grep-intercept installer. The script fails-open so this is safe globally.
863
+ function configureReadInterceptHook(settingsPath) {
864
+ const targetPath = settingsPath || SETTINGS_JSON;
865
+ let settings = {};
866
+ if (fs.existsSync(targetPath)) {
867
+ try {
868
+ settings = JSON.parse(fs.readFileSync(targetPath, "utf8"));
869
+ if (!settings || typeof settings !== "object") settings = {};
870
+ } catch {
871
+ warn("settings.json has invalid JSON — cannot configure read-intercept hook");
872
+ return { installed: false, action: "noop" };
873
+ }
874
+ }
875
+ if (!settings.hooks) settings.hooks = {};
876
+ if (!Array.isArray(settings.hooks.PostToolUse)) settings.hooks.PostToolUse = [];
877
+
878
+ const cmd = READ_INTERCEPT_HOOK_COMMAND;
879
+ let action = "noop";
880
+ let found = false;
881
+ for (const entry of settings.hooks.PostToolUse) {
882
+ if (!entry || !Array.isArray(entry.hooks)) continue;
883
+ for (const h of entry.hooks) {
884
+ if (!h || typeof h.command !== "string") continue;
885
+ if (h.command === cmd || h.command.includes(READ_INTERCEPT_HOOK_MARKER)) {
886
+ found = true;
887
+ if (h.command !== cmd) { h.command = cmd; action = "updated"; }
888
+ if (entry.matcher !== "Read") { entry.matcher = "Read"; action = action === "noop" ? "updated" : action; }
889
+ }
890
+ }
891
+ }
892
+ if (!found) {
893
+ settings.hooks.PostToolUse.push({
894
+ matcher: "Read",
895
+ hooks: [{ type: "command", command: cmd }],
896
+ });
897
+ action = "added";
898
+ }
899
+ if (action === "noop") return { installed: true, action: "noop" };
900
+ if (isSymlink(targetPath)) {
901
+ warn("Skipping settings.json write — target is a symlink");
902
+ return { installed: false, action: "noop" };
903
+ }
904
+ try {
905
+ fs.writeFileSync(targetPath, JSON.stringify(settings, null, 2));
906
+ } catch (e) {
907
+ warn(`Failed to write settings.json: ${e.message}`);
908
+ return { installed: false, action: "noop" };
909
+ }
910
+ return { installed: true, action };
911
+ }
912
+
913
+ // M98 — remove the GSD-T intercept PostToolUse hooks (grep + read) from settings.json.
914
+ // Used during uninstall. Marker-based; leaves all other hooks intact. Idempotent.
915
+ function removeInterceptHooks(settingsPath) {
916
+ const targetPath = settingsPath || SETTINGS_JSON;
917
+ if (!fs.existsSync(targetPath)) return false;
918
+ let settings;
919
+ try {
920
+ settings = JSON.parse(fs.readFileSync(targetPath, "utf8"));
921
+ if (!settings || typeof settings !== "object") return false;
922
+ } catch {
923
+ warn("settings.json has invalid JSON — cannot remove intercept hooks");
924
+ return false;
925
+ }
926
+ if (!settings.hooks || !Array.isArray(settings.hooks.PostToolUse)) return false;
927
+
928
+ const markers = [GRAPH_INTERCEPT_HOOK_MARKER, READ_INTERCEPT_HOOK_MARKER];
929
+ const before = settings.hooks.PostToolUse.length;
930
+ settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter((entry) => {
931
+ if (!entry || !Array.isArray(entry.hooks)) return true;
932
+ return !entry.hooks.some(
933
+ (h) => h && typeof h.command === "string" && markers.some((m) => h.command.includes(m))
934
+ );
935
+ });
936
+ if (before - settings.hooks.PostToolUse.length === 0) return false;
937
+
938
+ if (isSymlink(targetPath)) {
939
+ warn("Skipping settings.json write — target is a symlink");
940
+ return false;
941
+ }
942
+ try {
943
+ fs.writeFileSync(targetPath, JSON.stringify(settings, null, 2));
944
+ return true;
945
+ } catch (e) {
946
+ warn(`Failed to write settings.json: ${e.message}`);
947
+ return false;
948
+ }
949
+ }
950
+
853
951
  // Remove any context meter PostToolUse hooks from settings.json.
854
952
  // Used during uninstall. Leaves all other hooks intact.
855
953
  function removeContextMeterHook(settingsPath) {
@@ -1790,6 +1888,14 @@ async function doInstall(opts = {}) {
1790
1888
  else info("Graph-intercept hook already configured");
1791
1889
  }
1792
1890
 
1891
+ // M98 — read-intercept: a structural code-read gets a graph note pointing at `graph body`.
1892
+ const riHook = configureReadInterceptHook(SETTINGS_JSON);
1893
+ if (riHook.installed) {
1894
+ if (riHook.action === "added") success("Read-intercept hook added (structural code reads point at graph body slices)");
1895
+ else if (riHook.action === "updated") success("Read-intercept hook refreshed");
1896
+ else info("Read-intercept hook already configured");
1897
+ }
1898
+
1793
1899
  heading("Graph Engine (CGC)");
1794
1900
  installCgc();
1795
1901
 
@@ -2259,6 +2365,11 @@ function doUninstall() {
2259
2365
  success("Context meter PostToolUse hook removed from settings.json");
2260
2366
  }
2261
2367
 
2368
+ // M98 — remove the graph + read intercept PostToolUse hooks
2369
+ if (removeInterceptHooks(SETTINGS_JSON)) {
2370
+ success("Graph/read-intercept PostToolUse hooks removed from settings.json");
2371
+ }
2372
+
2262
2373
  warn("~/.claude/CLAUDE.md was NOT removed (may contain your customizations)");
2263
2374
  info("Remove manually if desired: delete the GSD-T section from ~/.claude/CLAUDE.md");
2264
2375
  info("Project files (.gsd-t/, docs/, CLAUDE.md) were NOT removed");
@@ -4706,6 +4817,10 @@ module.exports = {
4706
4817
  installContextMeter,
4707
4818
  configureContextMeterHooks,
4708
4819
  removeContextMeterHook,
4820
+ // M97/M98: intercept hook installers
4821
+ configureGraphInterceptHook,
4822
+ configureReadInterceptHook,
4823
+ removeInterceptHooks,
4709
4824
  promptForApiKeyIfMissing,
4710
4825
  resolveApiKeyEnvVar,
4711
4826
  runTaskCounterRetirementMigration,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "4.11.11",
3
+ "version": "4.13.10",
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",
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gsd-t-read-intercept.js — M98
4
+ *
5
+ * A PostToolUse hook on the `Read` tool. When Claude reads an INDEXED code file
6
+ * with an offset+limit that lands inside exactly one known function's line range,
7
+ * this APPENDS a graph note pointing at the precise `graph body <funcId>` slice
8
+ * (which carries the imports + class header + callers + the ~43× token win).
9
+ *
10
+ * DEFAULT = PASS-THROUGH. A bare `Read(file)` with no structural signal, a non-code
11
+ * file, an unindexed file, or any error → the original full-file output reaches the
12
+ * model UNCHANGED. This hook only ever AUGMENTS (appends a note); it NEVER replaces
13
+ * or shrinks a file read — silently shrinking would break editing. (M98 Decision #4
14
+ * + the open-question resolved conservatively: option (c), no-regression.)
15
+ *
16
+ * Receives JSON on stdin (PostToolUse):
17
+ * { tool_name, tool_input: { file_path, offset?, limit? }, tool_response/tool_output, cwd, ... }
18
+ *
19
+ * Emits JSON on stdout:
20
+ * { hookSpecificOutput: { hookEventName: "PostToolUse",
21
+ * updatedToolOutput: "<original file output> + graph note" } }
22
+ * ...or nothing (exit 0) to pass the Read through unchanged.
23
+ *
24
+ * INVARIANTS:
25
+ * [RULE] read-intercept-fail-open — any error / missing graph / non-code → pass through
26
+ * [RULE] read-intercept-augment-never-shrink — only APPEND; never replace the file body
27
+ * [RULE] read-intercept-structural-only — augment ONLY when offset+limit ∈ one funcId range
28
+ * - NEVER calls Read/Grep (loop guard). Reads the graph DB directly (read-only).
29
+ * - No graph in this project → pure no-op.
30
+ */
31
+
32
+ 'use strict';
33
+
34
+ const fs = require('node:fs');
35
+ const path = require('node:path');
36
+
37
+ const OUTPUT_CAP = 9000; // stay under the 10K hook output cap
38
+
39
+ // Code extensions the graph indexes — only these are candidates for a slice note.
40
+ const CODE_EXTS = new Set([
41
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.rs',
42
+ ]);
43
+
44
+ function passThrough() {
45
+ // Emit nothing → the original Read output reaches the model unchanged.
46
+ process.exit(0);
47
+ }
48
+
49
+ // Append `note` beneath `original`, but NEVER shrink the original the model already
50
+ // has. [RULE] read-intercept-augment-never-shrink: if original + note would exceed
51
+ // the hook output cap, pass through (emit nothing) — the note is advisory; a full
52
+ // file read is not worth truncating to fit a pointer.
53
+ function emitAugment(original, note) {
54
+ const combined = original + note;
55
+ if (combined.length > OUTPUT_CAP) passThrough();
56
+ const out = {
57
+ hookSpecificOutput: {
58
+ hookEventName: 'PostToolUse',
59
+ updatedToolOutput: combined,
60
+ },
61
+ };
62
+ process.stdout.write(JSON.stringify(out));
63
+ process.exit(0);
64
+ }
65
+
66
+ function main(payload) {
67
+ // Only act on Read.
68
+ if (!payload || payload.tool_name !== 'Read') passThrough();
69
+
70
+ const cwd = payload.cwd || process.cwd();
71
+
72
+ // Must be a GSD-T project with a graph present.
73
+ if (!fs.existsSync(path.join(cwd, '.gsd-t'))) passThrough();
74
+ if (!fs.existsSync(path.join(cwd, '.gsd-t', 'graph.db'))) passThrough();
75
+
76
+ const input = payload.tool_input || {};
77
+ const filePath = input.file_path;
78
+ if (typeof filePath !== 'string' || !filePath) passThrough();
79
+
80
+ // Only code files. (Non-code / docs / config → pass through, AC-6.)
81
+ if (!CODE_EXTS.has(path.extname(filePath).toLowerCase())) passThrough();
82
+
83
+ // STRUCTURAL SIGNAL (conservative): the read must carry an offset+limit. A bare
84
+ // full-file read has no structural target → pass through (no silent shrinking).
85
+ const offset = Number(input.offset);
86
+ const limit = Number(input.limit);
87
+ if (!Number.isFinite(offset) || !Number.isFinite(limit) || limit <= 0) passThrough();
88
+ const readStart = offset; // 1-based first line read (Read's offset is 1-based)
89
+ const readEnd = offset + limit - 1;
90
+
91
+ // Relativize the file path against cwd so it matches stored funcIds (file#name@line).
92
+ let rel = filePath;
93
+ if (path.isAbsolute(filePath)) rel = path.relative(cwd, filePath);
94
+ rel = rel.split(path.sep).join('/');
95
+
96
+ // Find the function whose [start,end] range the read window lands inside, by
97
+ // enumerating this file's funcIds straight from the graph DB (read-only). Cheaper
98
+ // and more direct than spawning the query CLL — and it's the same store the CLI reads.
99
+ let match = null;
100
+ try {
101
+ // Resolve the store loader from the global package (where this hook ships) first,
102
+ // falling back to the project's own copy — a synthetic project may have neither,
103
+ // in which case we fail-open (pass through).
104
+ let requireStore;
105
+ try { requireStore = require(path.join(__dirname, '..', 'bin', 'gsd-t-require-store.cjs')); }
106
+ catch { requireStore = require(path.join(cwd, 'bin', 'gsd-t-require-store.cjs')); }
107
+ const Database = requireStore.requireBetterSqlite();
108
+ const db = new Database(path.join(cwd, '.gsd-t', 'graph.db'), { readonly: true });
109
+ try {
110
+ const hasEnd = db.prepare('PRAGMA table_info(nodes)').all().some((c) => c.name === 'end_line');
111
+ if (hasEnd) {
112
+ const rows = db.prepare(
113
+ 'SELECT func_id, name, end_line FROM nodes WHERE file = ? AND func_id IS NOT NULL AND end_line IS NOT NULL'
114
+ ).all(rel);
115
+ for (const r of rows) {
116
+ const m = /@(\d+)$/.exec(r.func_id);
117
+ if (!m) continue;
118
+ const start = parseInt(m[1], 10);
119
+ const end = r.end_line;
120
+ // The read window starts inside this function's [start,end] body.
121
+ if (readStart >= start && readStart <= end) {
122
+ // Prefer the innermost (largest start) enclosing function.
123
+ if (!match || start > match.start) match = { funcId: r.func_id, name: r.name, start, end };
124
+ }
125
+ }
126
+ }
127
+ } finally {
128
+ try { db.close(); } catch { /* best-effort */ }
129
+ }
130
+ } catch (_e) { passThrough(); }
131
+
132
+ if (!match) passThrough();
133
+
134
+ // Build the augment note (appended beneath the original file output, kept intact).
135
+ const original = payload.tool_response || payload.tool_output || '';
136
+ const note =
137
+ `\n\n▸ GSD-T code graph: this read (lines ${readStart}-${readEnd}) sits inside ` +
138
+ `\`${match.name}\` (${rel}:${match.start}-${match.end}). For just this function's ` +
139
+ `source + its imports, class header, and callers (≈10× fewer tokens), run:\n` +
140
+ ` gsd-t graph body '${match.funcId}'`;
141
+
142
+ emitAugment(typeof original === 'string' ? original : JSON.stringify(original), note);
143
+ }
144
+
145
+ // ── stdin → main, fail-open everywhere ────────────────────────────────────────
146
+ let inputBuf = '';
147
+ process.stdin.setEncoding('utf8');
148
+ process.stdin.on('data', (c) => { inputBuf += c; });
149
+ process.stdin.on('end', () => {
150
+ let payload;
151
+ try { payload = JSON.parse(inputBuf); } catch { passThrough(); }
152
+ try { main(payload); } catch { passThrough(); }
153
+ });
154
+ process.stdin.on('error', () => passThrough());