@tekyzinc/gsd-t 4.10.11 → 4.11.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,35 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [4.11.10] - 2026-06-27
6
+
7
+ ### Added — M97: the code graph is the default for ambient code-reading (grep-intercept), + 3 call-resolution fixes
8
+
9
+ A PostToolUse hook on Claude's built-in `Grep` makes structural searches consult the code graph instead of raw text. The graph only powered explicit GSD-T commands before; now any "where is this used / who calls this" grep gets a precomputed graph answer.
10
+
11
+ - `bin/gsd-t-grep-classifier.cjs`: NEW — conservative structural-vs-text classifier (bare symbol / call shape / member call / import → structural verb; strings/regex/phrases → TEXT pass-through).
12
+ - `scripts/gsd-t-graph-intercept.js`: NEW — PostToolUse hook on Grep. Structural + graph-present → query the graph, REPLACE the grep output via `updatedToolOutput` (original hits kept beneath, labeled by tier); else pass through. FAIL-OPEN, never calls Grep/Read (no loop), no-op without a graph.
13
+ - `bin/gsd-t.js`: `gsd-t install` registers the hook (matcher `Grep`), idempotent.
14
+
15
+ **Fixed — the call-graph was EMPTY on real projects (3 bugs):**
16
+ - `bin/gsd-t-graph-index.cjs`: the CLI `build` path passed the upgrader MODULE as the `scip` option, so `build_index` skipped auto-building the resolver → 0 resolved edges on every `gsd-t graph index` (fixtures passed via the direct `build_index({dbPath})` path). Now the CLI lets build_index auto-build.
17
+ - `bin/gsd-t-scip-reader.cjs`: method names after `#` (`Class#method()`) weren't extracted (split on `/` only) — now split on last `/` OR `#`; and build-output docs (`dist-*`/`build-*`/`out-*`) are skipped so minified bundles don't pollute who-calls.
18
+ - `bin/gsd-t-graph-query-cli.cjs`: who-calls dropped results on a funcId `@line`-suffix mismatch (funcEntities key `file#name@line`, callGraph keys `file#name`) — now tolerant.
19
+ - `bin/gsd-t-graph-freshness.cjs`: hashed files with md5 while the indexer stores sha256 — every file read as "changed" → whole-repo re-index on every query (`gsd-t graph status` 30s→0.38s on a large project). Now sha256(16), excludes synced.
20
+
21
+ binvoice (real project): 2,579 resolved call edges; `who-calls(getSessionToken)` → real callers. Hook latency ~0.46s/call (node startup; query <100ms). Suite: 2519/2519 pass.
22
+
23
+ ## [4.10.12] - 2026-06-27
24
+
25
+ ### Fixed — freshness hash mismatch made every graph query re-index the whole repo
26
+
27
+ `compute_touched_files` hashed files with md5 (full length) while the indexer stores sha256 sliced to 16 chars — a guaranteed permanent mismatch. So every file read as "edited" on every query and freshness re-indexed the entire repo (re-running SCIP), hanging `gsd-t graph status` for 30s+ (then reporting graph-unavailable) on large projects.
28
+
29
+ - `bin/gsd-t-graph-freshness.cjs`: `hashFileContent` now `sha256().slice(0,16)` — matches the indexer's `contentHash` exactly. `EXCLUDE_DIRS` synced with the indexer's `SKIP_DIRS` (`.venv`/`site-packages`/etc.) so freshness doesn't see phantom ADDs from vendored dirs the indexer skipped.
30
+ - 5 D4 freshness test fixtures seeded stores with md5 hashes — updated to sha256(16) to match the real store.
31
+
32
+ Measured on Tekyz-CRM: `gsd-t graph status` 30s-timeout-unavailable → 0.38s ok; `compute_touched_files` 291ms / 11,821-files-stale → 12ms / 0-stale. This also makes graph queries cheap enough for ambient grep-interception (the next milestone). Suite: 2502/2502 pass.
33
+
5
34
  ## [4.10.11] - 2026-06-27
6
35
 
7
36
  ### Fixed — M96: the code graph now actually runs in projects (native-dep resolution)
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v4.10.11** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v4.11.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.
@@ -48,24 +48,48 @@ function fail(msg) { return `${C.red}✘${C.reset} ${msg}`; }
48
48
  // ─── Source-file extensions the indexer tracks ────────────────────────────────
49
49
  const TRACKED_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.py']);
50
50
 
51
- // ─── Content-hash (SHA-256 hex, first 16 bytes 32 hex chars) ───────────────
52
- // Using MD5 for speed (same family as graph-store.js) collision-resistance
53
- // is not required here; we only need change-detection fidelity.
51
+ // ─── Content-hash MUST match the indexer's contentHash EXACTLY ─────────────
52
+ // The indexer (gsd-t-graph-index.cjs::contentHash) stores sha256(content) sliced
53
+ // to the first 16 hex chars. Freshness MUST compute the identical value, or EVERY
54
+ // file reads as "edited" on every query (the hash never matches) and freshness
55
+ // re-indexes the whole repo — the hang. (Was md5/full-length: a guaranteed
56
+ // permanent mismatch.) [RULE] freshness-hash-matches-indexer-hash
54
57
  function hashFileContent(filePath) {
55
58
  try {
56
59
  const content = fs.readFileSync(filePath);
57
- return crypto.createHash('md5').update(content).digest('hex');
60
+ return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
58
61
  } catch {
59
62
  return null;
60
63
  }
61
64
  }
62
65
 
63
66
  // ─── Walk the working tree for source files ───────────────────────────────────
67
+ // MUST stay in sync with the indexer's SKIP_DIRS (gsd-t-graph-index.cjs). If
68
+ // freshness walks a dir the indexer skipped, every file in it reads as a phantom
69
+ // ADD on every query → re-index storm (bee-poc/Tekyz-CRM: 9,008 phantom adds from
70
+ // .venv + build bundles). [RULE] freshness-excludes-match-indexer-skipdirs
64
71
  const EXCLUDE_DIRS = new Set([
65
72
  'node_modules', '.git', 'dist', 'build', 'coverage',
66
73
  '.gsd-t', '.claude', '__pycache__', '.next', 'out',
74
+ '.cache', '.nyc_output', '.turbo',
75
+ '.venv', 'venv', 'env', '.dart_tool', 'Pods', 'vendor', '.gradle',
76
+ '.idea', '.vscode', 'tmp', '.tmp', 'site-packages',
67
77
  ]);
68
78
 
79
+ // Build-output dirs with a suffix (dist-local, build-ios, out-prod) — prefix
80
+ // match, matching the indexer's shouldSkipDir. [RULE] freshness-excludes-match-indexer-skipdirs
81
+ const EXCLUDE_DIR_PREFIXES = ['dist', 'build', 'out'];
82
+ function shouldExcludeDir(name) {
83
+ if (EXCLUDE_DIRS.has(name)) return true;
84
+ for (const pre of EXCLUDE_DIR_PREFIXES) {
85
+ if (name.length > pre.length && name.startsWith(pre) &&
86
+ (name[pre.length] === '-' || name[pre.length] === '.' || name[pre.length] === '_')) {
87
+ return true;
88
+ }
89
+ }
90
+ return false;
91
+ }
92
+
69
93
  function walkTree(dir, results = []) {
70
94
  // Defense-in-depth: refuse to walk the filesystem root or the user's home dir.
71
95
  // A bogus projectRoot (e.g. "/") from a fake store path would otherwise recurse
@@ -80,9 +104,9 @@ function walkTree(dir, results = []) {
80
104
  catch { return results; }
81
105
  for (const e of entries) {
82
106
  if (e.name.startsWith('.') && e.name !== '.claude') {
83
- if (EXCLUDE_DIRS.has(e.name)) continue;
107
+ if (shouldExcludeDir(e.name)) continue;
84
108
  }
85
- if (EXCLUDE_DIRS.has(e.name)) continue;
109
+ if (shouldExcludeDir(e.name)) continue;
86
110
  const full = path.join(dir, e.name);
87
111
  if (e.isDirectory()) {
88
112
  walkTree(full, results);
@@ -70,8 +70,30 @@ const SKIP_DIRS = new Set([
70
70
  'node_modules', '.next', 'dist', 'build', '.git',
71
71
  '.cache', '__pycache__', 'coverage', '.nyc_output',
72
72
  'out', '.turbo',
73
+ // M96 follow-up: vendored / generated trees that aren't project source and
74
+ // explode the file count (bee-poc had 18K files from .venv + iOS build assets,
75
+ // hanging scip-typescript at 8.5GB). Exclude language vendor/build dirs.
76
+ '.venv', 'venv', 'env', '.dart_tool', 'Pods', 'vendor', '.gradle',
77
+ '.idea', '.vscode', 'tmp', '.tmp', 'site-packages',
73
78
  ]);
74
79
 
80
+ // Build-output dirs often carry a suffix (dist-local, dist-test, build-ios,
81
+ // out-prod). Prefix-match these so generated/minified bundles never pollute the
82
+ // graph (binvoice indexed dist-local/dist-test → duplicate symbols in who-calls).
83
+ const SKIP_DIR_PREFIXES = ['dist', 'build', 'out'];
84
+
85
+ function shouldSkipDir(name) {
86
+ if (SKIP_DIRS.has(name)) return true;
87
+ for (const pre of SKIP_DIR_PREFIXES) {
88
+ // exact 'dist' is already in SKIP_DIRS; match 'dist-*'/'dist.*' variants.
89
+ if (name.length > pre.length && name.startsWith(pre) &&
90
+ (name[pre.length] === '-' || name[pre.length] === '.' || name[pre.length] === '_')) {
91
+ return true;
92
+ }
93
+ }
94
+ return false;
95
+ }
96
+
75
97
  // ── Content hash ──────────────────────────────────────────────────────────────
76
98
 
77
99
  /**
@@ -92,7 +114,7 @@ function enumerateFiles(root) {
92
114
  catch { return; }
93
115
  for (const e of entries) {
94
116
  if (e.isDirectory()) {
95
- if (!SKIP_DIRS.has(e.name)) walk(path.join(dir, e.name));
117
+ if (!shouldSkipDir(e.name)) walk(path.join(dir, e.name));
96
118
  } else if (e.isFile()) {
97
119
  const ext = path.extname(e.name).toLowerCase();
98
120
  if (PARSED_EXTS.has(ext)) {
@@ -487,17 +509,14 @@ if (require.main === module) {
487
509
  info(`build_index — repo: ${repoRoot}`);
488
510
  info(`Store: ${dbPath}`);
489
511
 
490
- // Load SCIP upgrader if available
491
- let scip = null;
492
- try {
493
- scip = require('./gsd-t-graph-scip-upgrade.cjs');
494
- } catch {
495
- warn('SCIP upgrader not found using tree-sitter-floor only');
496
- }
497
-
512
+ // Pass scip:null so build_index AUTO-BUILDS the repo-level SCIP resolver
513
+ // (runs scip-typescript once, reads index.scip, resolves call edges). Passing
514
+ // the raw upgrader MODULE here was the bug: build_index saw a truthy `scip`
515
+ // context with no `.resolver`, skipped auto-build, and stored 0 resolved edges
516
+ // on every CLI `gsd-t graph index` (M95/M96 fixtures passed because they called
517
+ // build_index({dbPath}) directly with scip:null). [RULE] cli-build-auto-resolves-scip
498
518
  const result = build_index(repoRoot, {
499
519
  dbPath,
500
- scip,
501
520
  onProgress: ({ file, tier, fileCount, total }) => {
502
521
  if (fileCount % 100 === 0) {
503
522
  info(`[${fileCount}/${total}] ${tier === 'compiler-accurate' ? C.green : C.dim}${file}${C.reset}`);
@@ -573,8 +573,9 @@ function queryWhoCalls(index, identity) {
573
573
  const coverage = computeCoverage(index.skippedFiles);
574
574
 
575
575
  if (isFuncId) {
576
- // File-qualified identity — exact funcId lookup
577
- const callers = index.callGraph.get(identity);
576
+ // File-qualified identity — exact funcId lookup (tolerate @line suffix:
577
+ // callGraph keys on `file#name`, callers may pass `file#name@line`).
578
+ const callers = index.callGraph.get(identity) || index.callGraph.get(identity.replace(/@\d+$/, ''));
578
579
  const results = callers ? Array.from(callers).sort() : [];
579
580
  return { results, tier: index.tier, coverage };
580
581
  }
@@ -593,8 +594,13 @@ function queryWhoCalls(index, identity) {
593
594
  }
594
595
 
595
596
  if (matchingFuncIds.length === 1) {
596
- // Unambiguous bare name — resolve directly
597
- const callers = index.callGraph.get(matchingFuncIds[0]);
597
+ // Unambiguous bare name — resolve directly.
598
+ // funcEntities key as `file#name@line`, but call edges (and thus callGraph)
599
+ // key as `file#name` (no @line) — try both so the @line-suffix difference
600
+ // doesn't drop real callers. [RULE] who-calls-funcid-line-suffix-tolerant
601
+ const fid = matchingFuncIds[0];
602
+ const fidNoLine = fid.replace(/@\d+$/, '');
603
+ const callers = index.callGraph.get(fid) || index.callGraph.get(fidNoLine);
598
604
  const results = callers ? Array.from(callers).sort() : [];
599
605
  return { results, tier: index.tier, coverage };
600
606
  }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * gsd-t-grep-classifier.cjs
3
+ *
4
+ * M97 — Classify a Grep tool call as STRUCTURAL (the code graph can answer it
5
+ * better) or TEXT (let grep run). The grep-intercept hook uses this to decide
6
+ * whether to replace grep output with a graph answer.
7
+ *
8
+ * CONSERVATIVE BY DESIGN: when unsure → TEXT (pass grep through). A false
9
+ * "structural" hijacks a legitimate text search (bad); a false "text" just means
10
+ * we miss a graph opportunity (harmless — grep still works). So the bar for
11
+ * STRUCTURAL is high and the patterns are narrow.
12
+ *
13
+ * STRUCTURAL = the pattern is asking "where is this symbol used / who calls it /
14
+ * who imports it" — answerable from the import/call graph:
15
+ * - a bare identifier (a function/class/variable name), no regex metachars
16
+ * - an explicit import line for a symbol (import ... X / from ... import X)
17
+ * - a function/method call shape (foo( / X.method() )
18
+ *
19
+ * TEXT = everything else: string literals, error messages, TODOs, regexes,
20
+ * multi-word phrases, paths, comments, anything with regex metacharacters that
21
+ * isn't a plain call shape.
22
+ *
23
+ * [RULE] grep-classifier-conservative-text-default
24
+ */
25
+
26
+ 'use strict';
27
+
28
+ // A plain identifier: starts with a letter/_/$ then word chars. No spaces, no
29
+ // regex metacharacters, no dots (a bare name, not a member access or path).
30
+ const BARE_IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
31
+
32
+ // A member-call shape: Obj.method or this.method (optionally with a trailing "(").
33
+ const MEMBER_CALL = /^[A-Za-z_$][A-Za-z0-9_$]*\.[A-Za-z_$][A-Za-z0-9_$]*\(?$/;
34
+
35
+ // A call shape: foo( — a bare name immediately followed by "(".
36
+ const CALL_SHAPE = /^[A-Za-z_$][A-Za-z0-9_$]*\($/;
37
+
38
+ // Import-of-a-symbol shapes (JS/TS + Python). Capture the symbol.
39
+ const IMPORT_SHAPES = [
40
+ /^import\s+\{?\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*\}?/, // import X / import { X }
41
+ /^from\s+\S+\s+import\s+([A-Za-z_$][A-Za-z0-9_$]*)/, // from m import X (python)
42
+ ];
43
+
44
+ // Regex metacharacters that signal a TEXT/regex search (not a bare symbol).
45
+ // (We allow a single trailing "(" for the call shape, handled before this.)
46
+ const REGEX_METACHARS = /[\\^$.*+?\[\]{}|()<>:;,"'`@#%&!~ \t]/;
47
+
48
+ /**
49
+ * Classify a grep pattern.
50
+ * @param {string} pattern the Grep tool's `pattern` input
51
+ * @param {object} [opts] reserved (e.g. path hints)
52
+ * @returns {{ structural: boolean, kind: string, symbol: string|null, verb: string|null }}
53
+ * kind: 'bare-symbol' | 'member-call' | 'call-shape' | 'import' | 'text'
54
+ * verb: the graph verb to run ('who-calls' | 'who-imports') or null for text
55
+ */
56
+ function classifyGrep(pattern, opts = {}) {
57
+ const TEXT = { structural: false, kind: 'text', symbol: null, verb: null };
58
+
59
+ if (typeof pattern !== 'string') return TEXT;
60
+ const p = pattern.trim();
61
+ if (!p) return TEXT;
62
+
63
+ // Too long to be a symbol → text. (Symbols are short; long patterns are prose.)
64
+ if (p.length > 80) return TEXT;
65
+
66
+ // 1. Import-of-a-symbol → who-imports on that symbol.
67
+ for (const re of IMPORT_SHAPES) {
68
+ const m = p.match(re);
69
+ if (m && m[1]) {
70
+ return { structural: true, kind: 'import', symbol: m[1], verb: 'who-imports' };
71
+ }
72
+ }
73
+
74
+ // 2. Bare identifier → who-calls + who-imports (the hook unions them).
75
+ if (BARE_IDENT.test(p)) {
76
+ return { structural: true, kind: 'bare-symbol', symbol: p, verb: 'who-calls' };
77
+ }
78
+
79
+ // 3. Call shape: foo( → who-calls on foo.
80
+ if (CALL_SHAPE.test(p)) {
81
+ return { structural: true, kind: 'call-shape', symbol: p.slice(0, -1), verb: 'who-calls' };
82
+ }
83
+
84
+ // 4. Member call: Obj.method / this.method → who-calls on the method name.
85
+ if (MEMBER_CALL.test(p)) {
86
+ const method = p.replace(/\(?$/, '').split('.').pop();
87
+ return { structural: true, kind: 'member-call', symbol: method, verb: 'who-calls' };
88
+ }
89
+
90
+ // 5. Anything with regex metachars / spaces / quotes → TEXT (conservative).
91
+ if (REGEX_METACHARS.test(p)) return TEXT;
92
+
93
+ // 6. Fallthrough → TEXT.
94
+ return TEXT;
95
+ }
96
+
97
+ module.exports = { classifyGrep, BARE_IDENT, MEMBER_CALL, CALL_SHAPE };
@@ -81,12 +81,14 @@ function loadScipProto() {
81
81
  */
82
82
  function funcNameFromSymbol(symbol) {
83
83
  if (!symbol || typeof symbol !== 'string') return null;
84
- // Parameters look like "name().(param)" skip (they contain "(...)" after "().")
85
- // Methods/functions end with "()." possibly preceded by the name.
86
- // Take the final descriptor (after the last unescaped '/').
87
- const lastSlash = symbol.lastIndexOf('/');
88
- const descriptor = lastSlash === -1 ? symbol : symbol.slice(lastSlash + 1);
89
- // Method/function descriptor: "<name>()." capture <name>
84
+ // Only function/method occurrences (end with "()."). Parameters are "name().(p)"
85
+ // they don't end with "()." so they're excluded.
86
+ if (!/\(\)\.$/.test(symbol)) return null;
87
+ // The callable name is the last descriptor segment. Top-level functions are
88
+ // ".../`file.ts`/name()."; methods are ".../`file.ts`/Class#method()." so the
89
+ // name follows the LAST '/' OR '#', whichever is later (methods key on '#').
90
+ const cut = Math.max(symbol.lastIndexOf('/'), symbol.lastIndexOf('#'));
91
+ const descriptor = cut === -1 ? symbol : symbol.slice(cut + 1);
90
92
  const m = descriptor.match(/^([A-Za-z_$][\w$]*)\(\)\.$/);
91
93
  return m ? m[1] : null;
92
94
  }
@@ -95,6 +97,24 @@ function funcNameFromSymbol(symbol) {
95
97
 
96
98
  const SYMBOL_ROLE_DEFINITION = 0x1; // SymbolRole.Definition bit
97
99
 
100
+ // scip-typescript indexes whatever its tsconfig includes — which on real projects
101
+ // often covers build output (dist-local/, dist-test/, build/). Those generated
102
+ // bundles must NOT enter the graph (duplicate, minified symbols pollute who-calls).
103
+ // Mirror the indexer's SKIP_DIRS prefix logic at the SCIP-doc level.
104
+ function isBuildOutputPath(relPath) {
105
+ const seg = String(relPath).split('/');
106
+ for (const s of seg) {
107
+ if (s === 'node_modules' || s === '.git' || s === 'coverage') return true;
108
+ for (const pre of ['dist', 'build', 'out']) {
109
+ if (s === pre || (s.length > pre.length && s.startsWith(pre) &&
110
+ (s[pre.length] === '-' || s[pre.length] === '.' || s[pre.length] === '_'))) {
111
+ return true;
112
+ }
113
+ }
114
+ }
115
+ return false;
116
+ }
117
+
98
118
  /**
99
119
  * Decode a `.scip` file and build resolution maps.
100
120
  *
@@ -128,7 +148,7 @@ function readScipIndex(scipPath) {
128
148
  // First pass: collect every DEFINITION occurrence → symbol → funcId.
129
149
  for (const doc of docs) {
130
150
  const relPath = doc.relative_path;
131
- if (!relPath) continue;
151
+ if (!relPath || isBuildOutputPath(relPath)) continue;
132
152
  for (const occ of doc.occurrences || []) {
133
153
  const isDef = (occ.symbol_roles & SYMBOL_ROLE_DEFINITION) !== 0;
134
154
  if (!isDef) continue;
@@ -142,7 +162,7 @@ function readScipIndex(scipPath) {
142
162
  // Second pass: collect every REFERENCE occurrence per file, resolved to the def.
143
163
  for (const doc of docs) {
144
164
  const relPath = doc.relative_path;
145
- if (!relPath) continue;
165
+ if (!relPath || isBuildOutputPath(relPath)) continue;
146
166
  const refs = [];
147
167
  for (const occ of doc.occurrences || []) {
148
168
  const isDef = (occ.symbol_roles & SYMBOL_ROLE_DEFINITION) !== 0;
package/bin/gsd-t.js CHANGED
@@ -453,6 +453,13 @@ const CONTEXT_METER_STALE_PATTERNS = [
453
453
  /node\s+"?\$CLAUDE_PROJECT_DIR\/scripts\/gsd-t-context-meter\.js"?/,
454
454
  ];
455
455
 
456
+ // M97 — graph-intercept PostToolUse hook on Grep. Runs from the global package;
457
+ // the script itself fails-open (no-op) in non-GSD-T projects / projects without a
458
+ // graph, so it is safe to register globally with a Grep matcher.
459
+ const GRAPH_INTERCEPT_HOOK_MARKER = "gsd-t-graph-intercept";
460
+ const GRAPH_INTERCEPT_HOOK_COMMAND =
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
+
456
463
  // Append entries to {projectDir}/.gitignore. Each entry added only if absent.
457
464
  // Idempotent. Returns true if any entries were added, false otherwise.
458
465
  function ensureGitignoreEntries(projectDir, entries) {
@@ -789,6 +796,60 @@ function configureContextMeterHooks(settingsPath) {
789
796
  return { installed: true, action };
790
797
  }
791
798
 
799
+ // M97 — register the graph-intercept PostToolUse hook (matcher "Grep").
800
+ // Idempotent: find-by-marker, refresh stale command, else add. Mirrors the
801
+ // context-meter installer. The script fails-open so this is safe globally.
802
+ function configureGraphInterceptHook(settingsPath) {
803
+ const targetPath = settingsPath || SETTINGS_JSON;
804
+ let settings = {};
805
+ if (fs.existsSync(targetPath)) {
806
+ try {
807
+ settings = JSON.parse(fs.readFileSync(targetPath, "utf8"));
808
+ if (!settings || typeof settings !== "object") settings = {};
809
+ } catch {
810
+ warn("settings.json has invalid JSON — cannot configure graph-intercept hook");
811
+ return { installed: false, action: "noop" };
812
+ }
813
+ }
814
+ if (!settings.hooks) settings.hooks = {};
815
+ if (!Array.isArray(settings.hooks.PostToolUse)) settings.hooks.PostToolUse = [];
816
+
817
+ const cmd = GRAPH_INTERCEPT_HOOK_COMMAND;
818
+ let action = "noop";
819
+ let found = false;
820
+ for (const entry of settings.hooks.PostToolUse) {
821
+ if (!entry || !Array.isArray(entry.hooks)) continue;
822
+ for (const h of entry.hooks) {
823
+ if (!h || typeof h.command !== "string") continue;
824
+ if (h.command === cmd || h.command.includes(GRAPH_INTERCEPT_HOOK_MARKER)) {
825
+ found = true;
826
+ if (h.command !== cmd) { h.command = cmd; action = "updated"; }
827
+ // ensure the matcher targets Grep
828
+ if (entry.matcher !== "Grep") { entry.matcher = "Grep"; action = action === "noop" ? "updated" : action; }
829
+ }
830
+ }
831
+ }
832
+ if (!found) {
833
+ settings.hooks.PostToolUse.push({
834
+ matcher: "Grep",
835
+ hooks: [{ type: "command", command: cmd }],
836
+ });
837
+ action = "added";
838
+ }
839
+ if (action === "noop") return { installed: true, action: "noop" };
840
+ if (isSymlink(targetPath)) {
841
+ warn("Skipping settings.json write — target is a symlink");
842
+ return { installed: false, action: "noop" };
843
+ }
844
+ try {
845
+ fs.writeFileSync(targetPath, JSON.stringify(settings, null, 2));
846
+ } catch (e) {
847
+ warn(`Failed to write settings.json: ${e.message}`);
848
+ return { installed: false, action: "noop" };
849
+ }
850
+ return { installed: true, action };
851
+ }
852
+
792
853
  // Remove any context meter PostToolUse hooks from settings.json.
793
854
  // Used during uninstall. Leaves all other hooks intact.
794
855
  function removeContextMeterHook(settingsPath) {
@@ -1721,6 +1782,14 @@ async function doInstall(opts = {}) {
1721
1782
  else info("Context meter hook already configured");
1722
1783
  }
1723
1784
 
1785
+ heading("Graph-Intercept (PostToolUse on Grep — M97)");
1786
+ const giHook = configureGraphInterceptHook(SETTINGS_JSON);
1787
+ if (giHook.installed) {
1788
+ if (giHook.action === "added") success("Graph-intercept hook added (structural greps consult the code graph)");
1789
+ else if (giHook.action === "updated") success("Graph-intercept hook refreshed");
1790
+ else info("Graph-intercept hook already configured");
1791
+ }
1792
+
1724
1793
  heading("Graph Engine (CGC)");
1725
1794
  installCgc();
1726
1795
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "4.10.11",
3
+ "version": "4.11.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,144 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gsd-t-graph-intercept.js — M97
4
+ *
5
+ * A PostToolUse hook on the `Grep` tool. When Claude runs a grep that is actually
6
+ * a STRUCTURAL question (who-calls / who-imports / a bare symbol), this answers it
7
+ * from the precomputed code graph and REPLACES the grep output the model sees
8
+ * (via `updatedToolOutput`). Text searches pass through untouched.
9
+ *
10
+ * Receives JSON on stdin (PostToolUse):
11
+ * { tool_name, tool_input: { pattern, path? }, tool_response/tool_output, cwd, ... }
12
+ *
13
+ * Emits JSON on stdout:
14
+ * { hookSpecificOutput: { hookEventName: "PostToolUse",
15
+ * updatedToolOutput: "<graph answer + original grep beneath>" } }
16
+ * ...or nothing (exit 0) to pass the grep through unchanged.
17
+ *
18
+ * INVARIANTS:
19
+ * - FAIL-OPEN: any error / missing graph / non-structural → pass through (emit nothing).
20
+ * - NEVER calls Grep/Read (loop guard). Only spawns the graph query CLI.
21
+ * - No graph in this project → pure no-op.
22
+ * - Original grep hits are RETAINED beneath the graph answer (no silent hiding).
23
+ *
24
+ * [RULE] graph-intercept-fail-open-never-breaks-grep
25
+ * [RULE] graph-intercept-structural-only-text-passes-through
26
+ */
27
+
28
+ 'use strict';
29
+
30
+ const fs = require('node:fs');
31
+ const path = require('node:path');
32
+ const { spawnSync } = require('node:child_process');
33
+
34
+ const OUTPUT_CAP = 9000; // stay under the 10K hook output cap, leave headroom
35
+
36
+ function passThrough() {
37
+ // Emit nothing → the original grep output reaches the model unchanged.
38
+ process.exit(0);
39
+ }
40
+
41
+ function emitReplacement(text) {
42
+ const out = {
43
+ hookSpecificOutput: {
44
+ hookEventName: 'PostToolUse',
45
+ updatedToolOutput: text.length > OUTPUT_CAP
46
+ ? text.slice(0, OUTPUT_CAP) + '\n…(truncated — query the graph CLI directly for the full list)'
47
+ : text,
48
+ },
49
+ };
50
+ process.stdout.write(JSON.stringify(out));
51
+ process.exit(0);
52
+ }
53
+
54
+ // Resolve the project-local graph query CLI. Returns null if absent.
55
+ function resolveQueryCli(cwd) {
56
+ const local = path.join(cwd, 'bin', 'gsd-t-graph-query-cli.cjs');
57
+ if (fs.existsSync(local)) return local;
58
+ return null;
59
+ }
60
+
61
+ function main(payload) {
62
+ // Only act on Grep.
63
+ if (!payload || payload.tool_name !== 'Grep') passThrough();
64
+
65
+ const cwd = payload.cwd || process.cwd();
66
+
67
+ // Must be a GSD-T project with a graph present.
68
+ if (!fs.existsSync(path.join(cwd, '.gsd-t'))) passThrough();
69
+ if (!fs.existsSync(path.join(cwd, '.gsd-t', 'graph.db'))) passThrough();
70
+
71
+ const pattern = payload.tool_input && payload.tool_input.pattern;
72
+ if (typeof pattern !== 'string' || !pattern) passThrough();
73
+
74
+ // Classify (the classifier itself fails safe → text).
75
+ let cls;
76
+ try {
77
+ const { classifyGrep } = require(path.join(__dirname, '..', 'bin', 'gsd-t-grep-classifier.cjs'));
78
+ cls = classifyGrep(pattern);
79
+ } catch { passThrough(); }
80
+ if (!cls || !cls.structural) passThrough();
81
+
82
+ const cliPath = resolveQueryCli(cwd);
83
+ if (!cliPath) passThrough();
84
+
85
+ // Query the graph. who-calls for symbols/calls; who-imports for imports.
86
+ // For a bare symbol we ALSO try who-imports so the model sees both usages.
87
+ const verbs = cls.verb === 'who-imports'
88
+ ? ['who-imports']
89
+ : ['who-calls', 'who-imports'];
90
+
91
+ const sections = [];
92
+ for (const verb of verbs) {
93
+ let res;
94
+ try {
95
+ res = spawnSync(process.execPath, [cliPath, verb, cls.symbol], {
96
+ cwd, encoding: 'utf8', timeout: 8000,
97
+ });
98
+ } catch { continue; }
99
+ // Parse the envelope regardless of exit status — an ambiguous-symbol result
100
+ // exits non-zero with ok:false but still carries a valid structural answer
101
+ // (the candidate list). Only a missing/garbled stdout is unusable.
102
+ if (!res || !res.stdout) continue;
103
+ let env;
104
+ try { env = JSON.parse(res.stdout.trim().split('\n').pop()); } catch { continue; }
105
+ if (!env) continue;
106
+ const results = env.results || [];
107
+ if (results.length) {
108
+ const tier = env.tier ? ` [tier: ${env.tier}]` : '';
109
+ const shown = results.slice(0, 60);
110
+ const more = results.length > 60 ? ` (+${results.length - 60} more)` : '';
111
+ sections.push(`${verb}(${cls.symbol})${tier}: ${shown.join(', ')}${more}`);
112
+ } else if ((env.candidates || []).length) {
113
+ // ambiguous symbol (ok:false, reason:'ambiguous-function') — the candidate
114
+ // list IS the structural answer: the symbol exists in N places.
115
+ const cands = env.candidates.slice(0, 20);
116
+ const more = env.candidates.length > 20 ? ` (+${env.candidates.length - 20} more)` : '';
117
+ sections.push(`${verb}(${cls.symbol}) → defined in ${env.candidates.length} place(s): ${cands.join(', ')}${more}`);
118
+ }
119
+ }
120
+
121
+ // No graph answer → pass the grep through (don't replace with nothing).
122
+ if (!sections.length) passThrough();
123
+
124
+ // Build the replacement: graph answer first (labeled), original grep beneath.
125
+ const original = payload.tool_response || payload.tool_output || '';
126
+ const replacement =
127
+ `▸ Structural answer from the GSD-T code graph (precomputed; faster + more accurate than text grep for "where is this used"):\n` +
128
+ sections.map((s) => ` • ${s}`).join('\n') +
129
+ `\n\n─── original grep output (kept for reference) ───\n` +
130
+ (typeof original === 'string' ? original : JSON.stringify(original));
131
+
132
+ emitReplacement(replacement);
133
+ }
134
+
135
+ // ── stdin → main, fail-open everywhere ────────────────────────────────────────
136
+ let input = '';
137
+ process.stdin.setEncoding('utf8');
138
+ process.stdin.on('data', (c) => { input += c; });
139
+ process.stdin.on('end', () => {
140
+ let payload;
141
+ try { payload = JSON.parse(input); } catch { passThrough(); }
142
+ try { main(payload); } catch { passThrough(); }
143
+ });
144
+ process.stdin.on('error', () => passThrough());