@tekyzinc/gsd-t 4.12.10 → 4.13.11
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 +23 -0
- package/README.md +1 -1
- package/bin/gsd-t-graph-edge-extract.cjs +6 -0
- package/bin/gsd-t-graph-index.cjs +26 -3
- package/bin/gsd-t-graph-query-cli.cjs +174 -4
- package/bin/gsd-t.js +117 -1
- package/package.json +1 -1
- package/scripts/gsd-t-read-intercept.js +154 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to GSD-T are documented here. Updated with each release.
|
|
4
4
|
|
|
5
|
+
## [4.13.11] - 2026-06-29
|
|
6
|
+
|
|
7
|
+
### Fixed — `gsd-t graph body` reachable from the front door (M98 follow-up)
|
|
8
|
+
|
|
9
|
+
M98 shipped the `body` slice logic in the query CLI and wired the Read-intercept hook, but the top-level `gsd-t graph` router in `bin/gsd-t.js` had no `case "body"` — so `gsd-t graph body <fn>` fell through to "Unknown graph subcommand". The headline M98 feature worked via the internal CLI and the hook, but was unreachable from the user-facing `gsd-t graph` command (the M98 test exercised the CLI directly, never the front door).
|
|
10
|
+
|
|
11
|
+
- **`bin/gsd-t.js`** — added `case "body"` to the graph subcommand router (delegates to the D5 query CLI, mirrors `who-calls`/`blast-radius`); added `body` to the usage string.
|
|
12
|
+
- **`test/m94-d5-graph-dispatch.test.js`** — new front-door regression test: `gsd-t graph body` must route to the CLI and emit a `{ ok, verb:"body" }` envelope, never "Unknown graph subcommand".
|
|
13
|
+
|
|
14
|
+
Verified on real binvoice: `gsd-t graph body selectCommentId` now returns the live 43-line slice (compiler-accurate, imports + callers) through the front door. Suite: 2541/2545 pass.
|
|
15
|
+
|
|
16
|
+
## [4.13.10] - 2026-06-29
|
|
17
|
+
|
|
18
|
+
### Added — The graph serves code (function-level slices) + Read-intercept (M98)
|
|
19
|
+
|
|
20
|
+
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.
|
|
21
|
+
|
|
22
|
+
- **`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.
|
|
23
|
+
- **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).
|
|
24
|
+
- **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`).
|
|
25
|
+
|
|
26
|
+
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).
|
|
27
|
+
|
|
5
28
|
## [4.12.10] - 2026-06-27
|
|
6
29
|
|
|
7
30
|
### Added — Python call-graph resolution (scip-python)
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# GSD-T: Contract-Driven Development for Claude Code
|
|
2
2
|
|
|
3
|
-
**v4.
|
|
3
|
+
**v4.13.11** - 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
|
-
|
|
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);
|
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");
|
|
@@ -3769,10 +3880,11 @@ function doGraph(args) {
|
|
|
3769
3880
|
case "who-imports": { const e = _graphQueryCli(["who-imports", args[1] || ""]); log(JSON.stringify(e, null, 2)); break; }
|
|
3770
3881
|
case "who-calls": { const e = _graphQueryCli(["who-calls", args[1] || ""]); log(JSON.stringify(e, null, 2)); break; }
|
|
3771
3882
|
case "blast-radius": { const e = _graphQueryCli(["blast-radius", args[1] || ""]); log(JSON.stringify(e, null, 2)); break; }
|
|
3883
|
+
case "body": { const e = _graphQueryCli(["body", args[1] || ""]); log(JSON.stringify(e, null, 2)); break; }
|
|
3772
3884
|
case "tasks": doGraphTaskOutput(args[1] || "table"); break;
|
|
3773
3885
|
default:
|
|
3774
3886
|
error(`Unknown graph subcommand: ${sub}`);
|
|
3775
|
-
info("Usage: gsd-t graph [index|status|query|who-imports|who-calls|blast-radius|tasks]");
|
|
3887
|
+
info("Usage: gsd-t graph [index|status|query|who-imports|who-calls|blast-radius|body|tasks]");
|
|
3776
3888
|
info(" gsd-t graph --output json|table (task DAG)");
|
|
3777
3889
|
}
|
|
3778
3890
|
}
|
|
@@ -4706,6 +4818,10 @@ module.exports = {
|
|
|
4706
4818
|
installContextMeter,
|
|
4707
4819
|
configureContextMeterHooks,
|
|
4708
4820
|
removeContextMeterHook,
|
|
4821
|
+
// M97/M98: intercept hook installers
|
|
4822
|
+
configureGraphInterceptHook,
|
|
4823
|
+
configureReadInterceptHook,
|
|
4824
|
+
removeInterceptHooks,
|
|
4709
4825
|
promptForApiKeyIfMissing,
|
|
4710
4826
|
resolveApiKeyEnvVar,
|
|
4711
4827
|
runTaskCounterRetirementMigration,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tekyzinc/gsd-t",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.13.11",
|
|
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());
|