@tekyzinc/gsd-t 5.18.11 → 5.19.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 CHANGED
@@ -2,6 +2,54 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [5.19.11] - 2026-09-15
6
+
7
+ ### Fixed — a broken git made the worktree prompt vanish with nothing said
8
+
9
+ An Xcode update left the licence unaccepted, so every git command exited 69 with a licence
10
+ notice. The worktree picker's first check asks "is this a git repo" and read that failure as
11
+ a plain "no" — which is the ordinary case for most directories, so it exited silently. The
12
+ launcher then skipped the worktree prompt entirely and started the session where it stood.
13
+ A prompt that disappears reads as a GSD-T bug; the actual cause was git, two commands away.
14
+
15
+ - `bin/gsd-t-pick-worktree.cjs`: "not a repository" and "git could not answer" are now
16
+ different answers. git's own exit tells them apart — a genuine non-repo says so on stderr;
17
+ anything else (missing binary, unaccepted licence, broken install) HALTS with what git said
18
+ and how to fix it. The sibling checks in `gsd-t-worktree-detect.cjs` already halted this way;
19
+ this one was the inconsistent member.
20
+ - `test/m111-pick-worktree.test.js`: 3 regression tests — a licence-style failure halts, a
21
+ directory that is genuinely not a repo stays silent, and git missing from PATH also halts.
22
+
23
+ ## [5.19.10] - 2026-09-09
24
+
25
+ ### Added — M117 Graph Search Guard: the graph rule finally fires on the path work actually takes
26
+
27
+ The rule "read code structure through the graph, never grep around it" had three enforcement
28
+ points and all three missed. The Grep-tool hook and Read-tool hook never fire, because
29
+ bypass-permissions mode routes every search through Bash. The runtime use-gate only runs inside
30
+ `gsd-t verify`, so a plain conversation was never measured. The ledger recorded the result:
31
+ 2 grep events in three months, both June test probes, against 34,418 graph queries all issued by
32
+ the graph's own tooling. Not one came from a session choosing to consult it.
33
+
34
+ - `scripts/gsd-t-graph-search-guard.js`: a PreToolUse hook on Bash and Grep. Three outcomes and no
35
+ fourth — a structural code search BLOCKS with the graph command to run instead; a search over
36
+ content the graph does not index (`.md`, `.json`, `.sql`, config, prose) RUNS, because the graph
37
+ holds no answer to route to; a search that cannot be classified BLOCKS. No fail-open, no bypass
38
+ env var. A missing or unbuilt graph BLOCKS with `gsd-t graph index`, never a quiet fall back to
39
+ grep — that fallback is what let a project grep its way through 827 files with the graph unbuilt.
40
+ - `bin/gsd-t-code-search-classifier.cjs`: the three-way classifier. Deliberately NOT merged with
41
+ `gsd-t-grep-classifier.cjs` — that one feeds a hook that replaces grep output, so unsure means
42
+ "let grep run"; this one feeds a guard that blocks, so unsure means block. One module cannot hold
43
+ both defaults, and merging them would silently pick one caller's behaviour for both.
44
+ - `scripts/gsd-t-graph-use-report.js`: a Stop hook covering what no pattern-matcher can see — a turn
45
+ that answered a structural question by reading files end to end. It reports rather than blocks,
46
+ because a Stop hook fires after the work is done.
47
+ - Both hooks registered by `gsd-t install`; the classifier ships in both bin registries.
48
+ - `configureWriteEditHook` generalized to `configurePreToolUseHook` with the matcher as a parameter,
49
+ rather than copied for a Bash|Grep variant.
50
+ - 17 tests in `test/m117-graph-search-guard.test.js`, including the six real searches from the
51
+ session that prompted this, each of which ran unchallenged at the time.
52
+
5
53
  ## [5.18.11] - 2026-09-07
6
54
 
7
55
  ### Fixed — `gsd-t pick-worktree --name main` refused when the main checkout sat on a feature branch
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v5.18.11** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v5.19.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.
@@ -0,0 +1,318 @@
1
+ /**
2
+ * gsd-t-code-search-classifier.cjs
3
+ *
4
+ * M117 - Decide what a shell search command is asking for, so the search guard
5
+ * can stop the ones the code graph should answer.
6
+ *
7
+ * THIS IS NOT gsd-t-grep-classifier.cjs, AND THE DIFFERENCE IS THE POINT.
8
+ * That one feeds a hook that REPLACES grep output, so an unsure answer there
9
+ * means "let grep run" - a wrong guess only costs a missed opportunity. This
10
+ * one feeds a guard that BLOCKS, and under the graph rule an unsure answer is
11
+ * the dangerous direction: a search that proceeds because the classifier could
12
+ * not decide is a search that continued past a failure, which is the fallback
13
+ * the No-Fallback rule bans. So the defaults are inverted here on purpose.
14
+ * Merging the two would force one default onto both callers and silently break
15
+ * whichever one it was not chosen for.
16
+ *
17
+ * Three answers, no fourth:
18
+ *
19
+ * 'structural' asking who calls / who imports / where something is defined,
20
+ * over code. The graph answers this. BLOCK.
21
+ * 'content' searching text the graph does not index - markdown, JSON,
22
+ * SQL, config, prose, comments, log output. ALLOW. This is not
23
+ * a loophole: the graph has no answer, so there is nothing to
24
+ * route to.
25
+ * 'unclear' cannot tell. BLOCK, and say so. Never guessed either way.
26
+ *
27
+ * [RULE] search-classifier-unclear-blocks-never-allows
28
+ * [RULE] search-classifier-content-scope-is-what-graph-cannot-index
29
+ * [RULE] search-classifier-missing-input-is-unclear-not-content
30
+ *
31
+ * Zero dependencies. Pure - no filesystem, no spawning, no environment reads.
32
+ */
33
+
34
+ 'use strict';
35
+
36
+ // --- What the graph indexes ------------------------------------------------
37
+ // A search restricted to files OUTSIDE this set is a content search by
38
+ // definition: the graph holds nothing about them, so it cannot be the better
39
+ // answer. Kept as one shared constant because a second copy is where a
40
+ // mismatch hides.
41
+ const CODE_EXT = new Set([
42
+ '.js', '.cjs', '.mjs', '.jsx', '.ts', '.tsx', '.mts', '.cts',
43
+ '.py', '.pyi', '.go', '.rs', '.java', '.rb', '.php',
44
+ '.c', '.h', '.cc', '.cpp', '.hpp', '.cs', '.swift', '.kt', '.scala',
45
+ ]);
46
+
47
+ // Content the graph does not index, named explicitly so "it is a .md file" is a
48
+ // decision rather than an absence of evidence.
49
+ const CONTENT_EXT = new Set([
50
+ '.md', '.markdown', '.txt', '.rst',
51
+ '.json', '.jsonl', '.yaml', '.yml', '.toml', '.ini', '.cfg', '.conf', '.env',
52
+ '.sql', '.csv', '.tsv',
53
+ '.sh', '.bash', '.zsh', '.fish',
54
+ '.html', '.css', '.scss', '.less',
55
+ '.lock', '.log', '.xml', '.svg',
56
+ ]);
57
+
58
+ // The search programs this guard governs. `find` is here for `-name`, which is
59
+ // a "where does this live" question the graph answers.
60
+ const SEARCH_PROGRAMS = new Set(['grep', 'egrep', 'fgrep', 'rg', 'ripgrep', 'ag', 'ack', 'find', 'ugrep']);
61
+
62
+ // Flags whose NEXT argument names a file scope.
63
+ const SCOPE_FLAGS = new Set(['--include', '--glob', '-g', '--name', '-name', '-iname']);
64
+
65
+ // Flags of the form --include=<glob>, carrying the scope inline.
66
+ const INLINE_SCOPE_FLAGS = ['--include=', '--glob='];
67
+
68
+ // A bare identifier - a function, class, or variable name and nothing else.
69
+ const BARE_IDENT = /^[A-Za-z_$][A-Za-z0-9_$]{1,79}$/;
70
+
71
+ // A member access or call shape: Obj.method, this.method, foo(
72
+ const MEMBER_OR_CALL = /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)?\(?$/;
73
+
74
+ // Declaration-hunting shapes. These ask where something is DEFINED.
75
+ const DECLARATION_SHAPES = [
76
+ /^(?:async\s+)?function\s+[A-Za-z_$]/,
77
+ /^class\s+[A-Za-z_$]/,
78
+ /^def\s+[A-Za-z_$]/,
79
+ /^(?:export\s+)?(?:const|let|var)\s+[A-Za-z_$]/,
80
+ /^func\s+[A-Za-z_$]/,
81
+ /^(?:pub\s+)?fn\s+[A-Za-z_$]/,
82
+ ];
83
+
84
+ // Import/require shapes - "who depends on this".
85
+ const IMPORT_SHAPES = [
86
+ /\bimport\s/,
87
+ /\bfrom\s+\S+\s+import\b/,
88
+ /\brequire\s*\(/,
89
+ /\bexport\s+(?:default\s+)?(?:function|class|const)\b/,
90
+ ];
91
+
92
+ // Multi-word English: an error message or a sentence, not a symbol.
93
+ const PROSE_SHAPE = /^[A-Za-z][A-Za-z'-]*(?:\s+[A-Za-z][A-Za-z'-]*)+$/;
94
+
95
+ // A run of characters shaped like an identifier.
96
+ const IDENTIFIER_RUN = /[A-Za-z_$][A-Za-z0-9_$]{2,}/;
97
+
98
+ /**
99
+ * Pull the file extensions a search is scoped to, from its arguments.
100
+ * Returns a Set; empty means "no scope stated".
101
+ */
102
+ function scopedExtensions(argv) {
103
+ const found = new Set();
104
+
105
+ const addFrom = (s) => {
106
+ if (typeof s !== 'string') return;
107
+ // *.{js,ts} - a brace group naming several at once.
108
+ const brace = s.match(/\*\.\{([^}]+)\}/);
109
+ if (brace) {
110
+ for (const part of brace[1].split(',')) {
111
+ const e = '.' + part.trim().replace(/^\./, '');
112
+ if (e.length > 1) found.add(e.toLowerCase());
113
+ }
114
+ return;
115
+ }
116
+ // *.md, path/to/file.json
117
+ const m = s.match(/\.([A-Za-z0-9]+)$/);
118
+ if (m) found.add(('.' + m[1]).toLowerCase());
119
+ };
120
+
121
+ const inlineScopeValue = (arg) => {
122
+ for (const prefix of INLINE_SCOPE_FLAGS) {
123
+ if (arg.startsWith(prefix)) return arg.slice(prefix.length);
124
+ }
125
+ return null;
126
+ };
127
+
128
+ for (let i = 0; i < argv.length; i++) {
129
+ const a = argv[i];
130
+ if (typeof a !== 'string') continue;
131
+
132
+ if (SCOPE_FLAGS.has(a)) {
133
+ addFrom(argv[i + 1]);
134
+ i++;
135
+ continue;
136
+ }
137
+
138
+ const inline = inlineScopeValue(a);
139
+ if (inline !== null) {
140
+ addFrom(inline);
141
+ continue;
142
+ }
143
+
144
+ // ripgrep -t<type> shorthand: -tmd, -tjs. Counted only when the type names
145
+ // an extension we know; an unknown type states no scope.
146
+ const typeShorthand = a.match(/^-t([a-z]+)$/);
147
+ if (typeShorthand) {
148
+ const asExt = '.' + typeShorthand[1];
149
+ if (CODE_EXT.has(asExt)) found.add(asExt);
150
+ else if (CONTENT_EXT.has(asExt)) found.add(asExt);
151
+ continue;
152
+ }
153
+
154
+ if (!a.startsWith('-')) addFrom(a);
155
+ }
156
+ return found;
157
+ }
158
+
159
+ /**
160
+ * Is every extension this search touches outside the graph's index?
161
+ * Only true when a scope was actually stated - an unscoped search reaches code
162
+ * whether or not the pattern looks like prose.
163
+ */
164
+ function scopedEntirelyToContent(exts) {
165
+ if (exts.size === 0) return false;
166
+ for (const e of exts) {
167
+ if (!CONTENT_EXT.has(e)) return false;
168
+ }
169
+ return true;
170
+ }
171
+
172
+ function touchesCode(exts) {
173
+ for (const e of exts) {
174
+ if (CODE_EXT.has(e)) return true;
175
+ }
176
+ return false;
177
+ }
178
+
179
+ function asksByFileName(argv) {
180
+ for (const a of argv) {
181
+ if (a === '-name') return true;
182
+ if (a === '-iname') return true;
183
+ }
184
+ return false;
185
+ }
186
+
187
+ /**
188
+ * Classify a search.
189
+ *
190
+ * @param {object} input
191
+ * @param {string} input.pattern what is being searched for
192
+ * @param {string[]} [input.argv] the rest of the command's arguments
193
+ * @param {string} [input.program] grep | rg | find | ...
194
+ * @returns {{ verdict:'structural'|'content'|'unclear',
195
+ * reason:string, symbol:string|null, verb:string|null }}
196
+ */
197
+ function classifySearch(input) {
198
+ // A caller that hands over no pattern has not asked a question this can
199
+ // answer. Reporting 'content' would ALLOW the search on the strength of an
200
+ // input that never arrived - deciding by absence of evidence. It is unclear,
201
+ // and unclear blocks.
202
+ if (!input) return unclear('no input was given');
203
+ if (typeof input.pattern !== 'string') return unclear('no search pattern was given');
204
+ const pattern = input.pattern.trim();
205
+ if (!pattern) return unclear('the search pattern was empty');
206
+
207
+ const argv = Array.isArray(input.argv) ? input.argv : [];
208
+ const program = typeof input.program === 'string' ? input.program : 'grep';
209
+
210
+ const exts = scopedExtensions(argv);
211
+
212
+ // Scoped entirely to things the graph does not index. The graph has no
213
+ // answer, so there is nothing to route to.
214
+ if (scopedEntirelyToContent(exts)) {
215
+ return {
216
+ verdict: 'content',
217
+ reason: 'scoped to ' + [...exts].join(', ') + ' - the graph does not index these',
218
+ symbol: null,
219
+ verb: null,
220
+ };
221
+ }
222
+
223
+ // `find -name Foo.js` asks where a file lives. The graph knows.
224
+ if (program === 'find' && asksByFileName(argv) && touchesCode(exts)) {
225
+ return {
226
+ verdict: 'structural',
227
+ reason: 'locating a code file by name',
228
+ symbol: pattern.replace(/^\*/, '').replace(/\*$/, ''),
229
+ verb: 'defines',
230
+ };
231
+ }
232
+
233
+ // Unmistakable prose: several English words. The graph indexes symbols and
234
+ // edges, not sentences.
235
+ if (PROSE_SHAPE.test(pattern) && pattern.split(/\s+/).length >= 3) {
236
+ return { verdict: 'content', reason: 'a phrase, not a symbol', symbol: null, verb: null };
237
+ }
238
+
239
+ // A quoted string literal hunted through the codebase is content.
240
+ if (/^["'].*["']$/.test(pattern) && pattern.length > 12) {
241
+ return { verdict: 'content', reason: 'a string literal', symbol: null, verb: null };
242
+ }
243
+
244
+ // --- Structural shapes ---------------------------------------------------
245
+ for (const re of IMPORT_SHAPES) {
246
+ if (re.test(pattern)) {
247
+ return {
248
+ verdict: 'structural',
249
+ reason: 'asking who imports or requires something',
250
+ symbol: extractSymbol(pattern),
251
+ verb: 'who-imports',
252
+ };
253
+ }
254
+ }
255
+
256
+ for (const re of DECLARATION_SHAPES) {
257
+ if (re.test(pattern)) {
258
+ return {
259
+ verdict: 'structural',
260
+ reason: 'asking where something is defined',
261
+ symbol: extractSymbol(pattern),
262
+ verb: 'defines',
263
+ };
264
+ }
265
+ }
266
+
267
+ if (BARE_IDENT.test(pattern)) {
268
+ return {
269
+ verdict: 'structural',
270
+ reason: 'a bare symbol name - who calls it and who imports it is a graph question',
271
+ symbol: pattern,
272
+ verb: 'who-calls',
273
+ };
274
+ }
275
+
276
+ if (MEMBER_OR_CALL.test(pattern)) {
277
+ return {
278
+ verdict: 'structural',
279
+ reason: 'a call or member-access shape',
280
+ symbol: pattern.replace(/\($/, ''),
281
+ verb: 'who-calls',
282
+ };
283
+ }
284
+
285
+ // --- Everything left -----------------------------------------------------
286
+ // A regex, a mixed pattern, something with punctuation. It MIGHT be a text
287
+ // search and it might be a symbol hunt wearing a regex. Saying "probably
288
+ // text" here is the guess this classifier exists not to make.
289
+ //
290
+ // One narrowing first: a pattern holding no identifier-shaped run has nothing
291
+ // the graph could be asked about, whatever else it is.
292
+ if (!IDENTIFIER_RUN.test(pattern)) {
293
+ return {
294
+ verdict: 'content',
295
+ reason: 'no symbol-shaped text in the pattern',
296
+ symbol: null,
297
+ verb: null,
298
+ };
299
+ }
300
+
301
+ return unclear('could be a symbol hunt or a text search - it has to be said which');
302
+ }
303
+
304
+ function unclear(reason) {
305
+ return { verdict: 'unclear', reason, symbol: null, verb: null };
306
+ }
307
+
308
+ // Pull the most likely symbol out of a declaration/import pattern, for the
309
+ // suggested graph command. Null when nothing identifier-shaped is present.
310
+ function extractSymbol(pattern) {
311
+ const trailing = pattern.match(/([A-Za-z_$][A-Za-z0-9_$]{1,})\s*$/);
312
+ if (trailing) return trailing[1];
313
+ const anywhere = pattern.match(/([A-Za-z_$][A-Za-z0-9_$]{1,})/);
314
+ if (anywhere) return anywhere[1];
315
+ return null;
316
+ }
317
+
318
+ module.exports = { classifySearch, CODE_EXT, CONTENT_EXT, SEARCH_PROGRAMS };
@@ -208,9 +208,50 @@ function isSwitchedOff(cwd) {
208
208
  return cfg.enabled === false;
209
209
  }
210
210
 
211
- // Most directories are not git repos. That is ordinary, not a failure.
211
+ /**
212
+ * Is this directory a git repo?
213
+ *
214
+ * "No" and "git could not tell me" are DIFFERENT answers and must not collapse
215
+ * into one. Most directories are genuinely not repos, which is ordinary and
216
+ * silent. But a git that cannot run at all fails the same probe, and reading
217
+ * that as "not a repo" makes the whole picker exit silently — the launcher then
218
+ * skips the worktree prompt entirely and starts the session wherever it stood,
219
+ * with nothing said. That happened for real on 2026-09-15: an Xcode update left
220
+ * the license unaccepted, every git command exited 69, and `cc` quietly stopped
221
+ * asking. The prompt vanishing looked like a GSD-T bug for as long as it took to
222
+ * run git by hand.
223
+ *
224
+ * git distinguishes them itself: a plain "not a repository" exits 128 with that
225
+ * message on stderr. Anything else — a missing binary, an unaccepted licence, a
226
+ * broken install — is git failing to answer, and that HALTS with what git said.
227
+ */
212
228
  function isGitRepo(dir) {
213
- return spawnSync("git", ["rev-parse", "--git-dir"], { cwd: dir, stdio: "pipe" }).status === 0;
229
+ const r = spawnSync("git", ["rev-parse", "--git-dir"], {
230
+ cwd: dir, encoding: "utf8", timeout: 10000,
231
+ });
232
+
233
+ if (r.status === 0) return true;
234
+
235
+ // git never ran: no binary on PATH, or it could not be spawned.
236
+ if (r.error) {
237
+ fail(
238
+ `git could not be run (${r.error.message}), so it cannot be told whether ` +
239
+ `${dir} is a repository. Fix git, then start the session again.`
240
+ );
241
+ }
242
+
243
+ const stderr = String(r.stderr || "").trim();
244
+
245
+ // The ordinary answer: this is simply not a repository.
246
+ if (/not a git repository/i.test(stderr)) return false;
247
+
248
+ // Anything else is git declining to answer, not an answer.
249
+ fail(
250
+ `git could not say whether ${dir} is a repository — it exited ${r.status}` +
251
+ (stderr ? ` saying: ${stderr}` : " with no message") + ".\n" +
252
+ `Until git works, the worktree prompt cannot run. Fix the cause above, then ` +
253
+ `start the session again.`
254
+ );
214
255
  }
215
256
 
216
257
  // Shared with branch-guard so the two cannot drift apart — see
package/bin/gsd-t.js CHANGED
@@ -514,6 +514,24 @@ const FALLBACK_HOOK_MARKER = "gsd-t-fallback-guard";
514
514
  const FALLBACK_HOOK_COMMAND =
515
515
  'node "$(npm root -g)/@tekyzinc/gsd-t/scripts/gsd-t-fallback-guard.js"';
516
516
 
517
+ // ─── M117 Graph search guard (PreToolUse Bash|Grep) ─────────────────────────
518
+ // Blocks a search that asks a structural question about code, and names the
519
+ // graph command that answers it. NO `|| true`, for the same reason as the
520
+ // fallback guard: a missing script must fail loudly, not silently re-open the
521
+ // hole. The hole it closes is specific — the Grep-tool and Read-tool hooks
522
+ // never fired, because bypass mode routes every search through Bash.
523
+ const GRAPH_SEARCH_HOOK_MARKER = "gsd-t-graph-search-guard";
524
+ const GRAPH_SEARCH_HOOK_COMMAND =
525
+ 'node "$(npm root -g)/@tekyzinc/gsd-t/scripts/gsd-t-graph-search-guard.js"';
526
+
527
+ // ─── M117 Graph use report (Stop) ───────────────────────────────────────────
528
+ // Reports a turn that hit structural questions and never asked the graph. It
529
+ // reports rather than blocks: a Stop hook fires after the work is done, so
530
+ // blocking there punishes rather than redirects. Prevention is the guard above.
531
+ const GRAPH_USE_REPORT_MARKER = "gsd-t-graph-use-report";
532
+ const GRAPH_USE_REPORT_COMMAND =
533
+ 'bash -c \'[ -f "$(npm root -g)/@tekyzinc/gsd-t/scripts/gsd-t-graph-use-report.js" ] && node "$(npm root -g)/@tekyzinc/gsd-t/scripts/gsd-t-graph-use-report.js" || true\'';
534
+
517
535
  // ─── M108 Install self-heal (SessionStart) ──────────────────────────────────
518
536
  // Checks the project's tools before any work starts, restores what is missing,
519
537
  // and reports what it could not fix. Not a fallback — it repairs the failure
@@ -992,6 +1010,69 @@ function configureFallbackGuardHook(settingsPath) {
992
1010
  return configureWriteEditHook(settingsPath, FALLBACK_HOOK_MARKER, FALLBACK_HOOK_COMMAND, "fallback guard");
993
1011
  }
994
1012
 
1013
+ // M117 — register the graph search guard on Bash|Grep. Both doors: bypass mode
1014
+ // routes searches through Bash, and leaving either open makes it the habit.
1015
+ function configureGraphSearchGuardHook(settingsPath) {
1016
+ return configurePreToolUseHook(
1017
+ settingsPath, GRAPH_SEARCH_HOOK_MARKER, GRAPH_SEARCH_HOOK_COMMAND,
1018
+ "graph search guard", "Bash|Grep"
1019
+ );
1020
+ }
1021
+
1022
+ // M117 — register the Stop-time graph use report.
1023
+ function configureGraphUseReportHook(settingsPath) {
1024
+ return configureStopHook(
1025
+ settingsPath, GRAPH_USE_REPORT_MARKER, GRAPH_USE_REPORT_COMMAND, "graph use report"
1026
+ );
1027
+ }
1028
+
1029
+ // Register a Stop hook by marker. Same find-refresh-or-add shape as the
1030
+ // PreToolUse registrar; Stop entries carry no matcher.
1031
+ function configureStopHook(settingsPath, marker, command, label) {
1032
+ const targetPath = settingsPath || SETTINGS_JSON;
1033
+ let settings = {};
1034
+ if (fs.existsSync(targetPath)) {
1035
+ try {
1036
+ settings = JSON.parse(fs.readFileSync(targetPath, "utf8"));
1037
+ if (!settings || typeof settings !== "object") settings = {};
1038
+ } catch {
1039
+ warn(`settings.json has invalid JSON — cannot configure ${label} hook`);
1040
+ return { installed: false, action: "noop" };
1041
+ }
1042
+ }
1043
+ if (!settings.hooks) settings.hooks = {};
1044
+ if (!Array.isArray(settings.hooks.Stop)) settings.hooks.Stop = [];
1045
+
1046
+ let action = "noop";
1047
+ let found = false;
1048
+ for (const entry of settings.hooks.Stop) {
1049
+ if (!entry || !Array.isArray(entry.hooks)) continue;
1050
+ for (const h of entry.hooks) {
1051
+ if (!h || typeof h.command !== "string") continue;
1052
+ if (h.command === command || h.command.includes(marker)) {
1053
+ found = true;
1054
+ if (h.command !== command) { h.command = command; action = "updated"; }
1055
+ }
1056
+ }
1057
+ }
1058
+ if (!found) {
1059
+ settings.hooks.Stop.push({ hooks: [{ type: "command", command }] });
1060
+ action = "added";
1061
+ }
1062
+ if (action === "noop") return { installed: true, action: "noop" };
1063
+ if (isSymlink(targetPath)) {
1064
+ warn("Skipping settings.json write — target is a symlink");
1065
+ return { installed: false, action: "noop" };
1066
+ }
1067
+ try {
1068
+ fs.writeFileSync(targetPath, JSON.stringify(settings, null, 2));
1069
+ } catch (e) {
1070
+ warn(`Failed to write settings.json: ${e.message}`);
1071
+ return { installed: false, action: "noop" };
1072
+ }
1073
+ return { installed: true, action };
1074
+ }
1075
+
995
1076
  // M107 RETIRED (v5.11.15). The rewriter shortened a reply after it was written,
996
1077
  // and a Stop hook cannot unsay what is already on screen — so David read the
997
1078
  // long version, then the short one. It also cost a whole extra turn, and its
@@ -1097,6 +1178,13 @@ function configureEventHook(settingsPath, event, marker, command, label) {
1097
1178
  }
1098
1179
 
1099
1180
  function configureWriteEditHook(settingsPath, marker, command, label) {
1181
+ return configurePreToolUseHook(settingsPath, marker, command, label, "Write|Edit");
1182
+ }
1183
+
1184
+ // M117 — the same registrar, with the matcher named rather than assumed. The
1185
+ // graph search guard watches Bash|Grep, not Write|Edit, and a second copy of
1186
+ // this function is where the two would drift apart.
1187
+ function configurePreToolUseHook(settingsPath, marker, command, label, matcher) {
1100
1188
  const targetPath = settingsPath || SETTINGS_JSON;
1101
1189
  let settings = {};
1102
1190
  if (fs.existsSync(targetPath)) {
@@ -1121,13 +1209,13 @@ function configureWriteEditHook(settingsPath, marker, command, label) {
1121
1209
  if (h.command === cmd || h.command.includes(marker)) {
1122
1210
  found = true;
1123
1211
  if (h.command !== cmd) { h.command = cmd; action = "updated"; }
1124
- if (entry.matcher !== "Write|Edit") { entry.matcher = "Write|Edit"; action = action === "noop" ? "updated" : action; }
1212
+ if (entry.matcher !== matcher) { entry.matcher = matcher; action = action === "noop" ? "updated" : action; }
1125
1213
  }
1126
1214
  }
1127
1215
  }
1128
1216
  if (!found) {
1129
1217
  settings.hooks.PreToolUse.push({
1130
- matcher: "Write|Edit",
1218
+ matcher,
1131
1219
  hooks: [{ type: "command", command: cmd }],
1132
1220
  });
1133
1221
  action = "added";
@@ -1806,6 +1894,9 @@ const GLOBAL_BIN_TOOLS = [
1806
1894
  // write rather than allowing it unchecked, so an omission here breaks every
1807
1895
  // Write/Edit rather than failing silently. Also in PROJECT_BIN_TOOLS below.
1808
1896
  "gsd-t-fallback-detect.cjs",
1897
+ // M117 — Search classifier. The graph search guard resolves it from the
1898
+ // package when a project has no copy, so it must ship globally too.
1899
+ "gsd-t-code-search-classifier.cjs",
1809
1900
  // M108 — Install self-check, run by the SessionStart hook and by
1810
1901
  // `gsd-t install-check`.
1811
1902
  "gsd-t-install-check.cjs",
@@ -2346,6 +2437,25 @@ async function doInstall(opts = {}) {
2346
2437
  }
2347
2438
 
2348
2439
 
2440
+ // M117 — the graph search guard and its Stop-time report. The graph rule had
2441
+ // three enforcement points before this and all three missed the path actually
2442
+ // taken: the Grep-tool and Read-tool hooks never fire in bypass mode (every
2443
+ // search goes out through Bash), and the runtime use-gate only runs inside
2444
+ // verify, never in a plain conversation.
2445
+ const gsHook = configureGraphSearchGuardHook(SETTINGS_JSON);
2446
+ if (gsHook.installed) {
2447
+ if (gsHook.action === "added") success("Graph search guard added (blocks a structural code search, names the graph query — M117)");
2448
+ else if (gsHook.action === "updated") success("Graph search guard refreshed");
2449
+ else info("Graph search guard already configured");
2450
+ }
2451
+
2452
+ const gurHook = configureGraphUseReportHook(SETTINGS_JSON);
2453
+ if (gurHook.installed) {
2454
+ if (gurHook.action === "added") success("Graph use report added (flags a turn that did structural work without asking the graph — M117)");
2455
+ else if (gurHook.action === "updated") success("Graph use report refreshed");
2456
+ else info("Graph use report already configured");
2457
+ }
2458
+
2349
2459
  const ccHook = removeConciseHook(SETTINGS_JSON);
2350
2460
  if (ccHook.removed) success("Concise-rewrite hook removed — retired in v5.11.15");
2351
2461
 
@@ -3507,6 +3617,11 @@ const PROJECT_BIN_TOOLS = [
3507
3617
  // and it reads the project's OWN .gsd-t/graphDB/logs ledger, so it must live
3508
3618
  // in the project — [[project_global_bin_propagation_gap]].
3509
3619
  "gsd-t-graph-use-gate.cjs",
3620
+ // M117 — Search classifier for the graph search guard. The PreToolUse guard
3621
+ // prefers the project-local copy and DENIES every search if it cannot load
3622
+ // one, so an omission here blocks all work rather than failing quietly —
3623
+ // [[project_global_bin_propagation_gap]].
3624
+ "gsd-t-code-search-classifier.cjs",
3510
3625
  // M107 — Concise rewriter, invoked by the Stop hook.
3511
3626
  // M108 — Install self-check. Every project carries its own copy so it can
3512
3627
  // verify and repair itself even when the global install is what broke.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "5.18.11",
3
+ "version": "5.19.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,490 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gsd-t-graph-search-guard.js
4
+ *
5
+ * M117 - PreToolUse hook on Bash and Grep. Blocks a search that asks a
6
+ * structural question about code, and names the graph command that answers it.
7
+ *
8
+ * [RULE] search-guard-blocks-structural-code-search
9
+ * [RULE] search-guard-unclear-blocks-never-allows
10
+ * [RULE] search-guard-missing-graph-blocks-never-degrades
11
+ *
12
+ * WHY THIS EXISTS
13
+ * The graph rule already said "query the graph, do not grep around it", and
14
+ * two hooks already enforced it - on the Grep tool and the Read tool. Neither
15
+ * ever fired in real work. In bypass-permissions mode the standing house rule
16
+ * is to work through Bash, so every structural search went out as `grep` in a
17
+ * Bash call and sailed past both hooks. The ledger records the result: two
18
+ * grep events in three months, both from June test probes, against 34,418
19
+ * graph queries all from the graph's own tooling. The rule had no trigger on
20
+ * the path actually taken.
21
+ *
22
+ * THE THREE OUTCOMES (there is no fourth, and none of them continues past a
23
+ * failure):
24
+ * structural BLOCK. Print the graph command to run instead.
25
+ * content ALLOW. Markdown, JSON, SQL, config, prose - the graph does not
26
+ * index them, so it has no answer to route to. Not a bypass.
27
+ * unclear BLOCK. The classifier could not read the intent, and guessing
28
+ * "probably text" is the guess that keeps the rule toothless.
29
+ *
30
+ * A MISSING OR BROKEN GRAPH ALSO BLOCKS. Allowing the grep because the graph is
31
+ * unavailable is the exact fallback that hid the binvoice failure - a project
32
+ * grepping its way through 827 files while the graph sat unbuilt. The answer is
33
+ * `gsd-t graph index`, which the block message says.
34
+ *
35
+ * WHAT IT CANNOT SEE, STATED PLAINLY: it governs tool calls, not reasoning.
36
+ * Reading a file end to end to work out who calls something leaves no pattern
37
+ * for any guard to match. The Stop-time check (gsd-t-graph-use-report.js) is
38
+ * what covers that, by comparing structural work against graph queries issued.
39
+ *
40
+ * --- Stdin (Claude Code PreToolUse payload) --------------------------------
41
+ * { "tool_name": "Bash"|"Grep", "cwd": "...",
42
+ * "tool_input": { "command": "..." } | { "pattern": "...", "glob": "..." } }
43
+ *
44
+ * --- Decision contract -----------------------------------------------------
45
+ * Deny: {"hookSpecificOutput":{"hookEventName":"PreToolUse",
46
+ * "permissionDecision":"deny","permissionDecisionReason":"..."}}
47
+ * Allow: exit 0, no output.
48
+ *
49
+ * Zero dependencies.
50
+ */
51
+
52
+ "use strict";
53
+
54
+ const fs = require("fs");
55
+ const path = require("path");
56
+
57
+ function deny(reason) {
58
+ process.stdout.write(JSON.stringify({
59
+ hookSpecificOutput: {
60
+ hookEventName: "PreToolUse",
61
+ permissionDecision: "deny",
62
+ permissionDecisionReason: reason,
63
+ },
64
+ }) + "\n");
65
+ process.exit(0);
66
+ }
67
+
68
+ function allow() { process.exit(0); }
69
+
70
+ /**
71
+ * Locate the classifier. A project without one has an incomplete install, and
72
+ * the repair is `gsd-t install-check` - not a hunt for a copy elsewhere, and
73
+ * not silently letting every search through.
74
+ */
75
+ function findClassifier(projectDir) {
76
+ const inProject = path.join(projectDir, "bin", "gsd-t-code-search-classifier.cjs");
77
+ if (fs.existsSync(inProject)) return inProject;
78
+
79
+ const inPackage = path.join(__dirname, "..", "bin", "gsd-t-code-search-classifier.cjs");
80
+ if (fs.existsSync(inPackage)) return inPackage;
81
+
82
+ throw new Error(
83
+ "This project has no copy of the search classifier at " + inProject + ", which " +
84
+ "means its GSD-T install is incomplete. Run 'gsd-t install-check' to repair it."
85
+ );
86
+ }
87
+
88
+ /** Thrown when the settings file exists but cannot be understood. */
89
+ class ConfigUnreadable extends Error {}
90
+
91
+ /**
92
+ * Is the guard switched on for this project?
93
+ * An unreadable settings file throws - assuming "on" or "off" would be a guess
94
+ * about what the project wanted. Only an ABSENT file means on, because absence
95
+ * is unambiguous.
96
+ */
97
+ function isEnabled(projectDir) {
98
+ const p = path.join(projectDir, ".gsd-t", "graph-search-gate.json");
99
+ if (!fs.existsSync(p)) return true;
100
+ let raw;
101
+ try {
102
+ raw = fs.readFileSync(p, "utf8");
103
+ } catch (e) {
104
+ throw new ConfigUnreadable(p + " could not be read: " + e.message);
105
+ }
106
+ const cfg = JSON.parse(raw);
107
+ return cfg.enabled !== false;
108
+ }
109
+
110
+ // --- Command parsing -------------------------------------------------------
111
+ //
112
+ // Split a shell command into its pipeline stages, then look at each stage that
113
+ // runs a search program. A structural search buried in the middle of a pipe is
114
+ // still a structural search.
115
+
116
+ const SEARCH_PROGRAMS = new Set(["grep", "egrep", "fgrep", "rg", "ripgrep", "ag", "ack", "find", "ugrep"]);
117
+
118
+ // Flags that take a value in the NEXT argument, so that value is not the pattern.
119
+ const FLAGS_TAKING_VALUE = new Set([
120
+ "-e", "--regexp", "-f", "--file", "--include", "--exclude", "--glob", "-g",
121
+ "-m", "--max-count", "-A", "-B", "-C", "--after-context", "--before-context",
122
+ "--context", "-d", "--directories", "--color", "--colour", "-t", "--type",
123
+ "-name", "-iname", "-path", "-type", "-maxdepth", "-mindepth",
124
+ ]);
125
+
126
+ /**
127
+ * Break a command line into tokens, keeping quoted runs together and dropping
128
+ * the quotes. Good enough for reading a search invocation; it is not a shell.
129
+ */
130
+ function tokenize(command) {
131
+ const tokens = [];
132
+ let cur = "";
133
+ let quote = null;
134
+ let started = false;
135
+
136
+ for (let i = 0; i < command.length; i++) {
137
+ const ch = command[i];
138
+
139
+ if (quote) {
140
+ if (ch === quote) { quote = null; continue; }
141
+ cur += ch;
142
+ started = true;
143
+ continue;
144
+ }
145
+ if (ch === '"' || ch === "'") { quote = ch; started = true; continue; }
146
+ if (ch === "\\" && i + 1 < command.length) { cur += command[i + 1]; started = true; i++; continue; }
147
+ if (/\s/.test(ch)) {
148
+ if (started) { tokens.push(cur); cur = ""; started = false; }
149
+ continue;
150
+ }
151
+ cur += ch;
152
+ started = true;
153
+ }
154
+ if (started) tokens.push(cur);
155
+ return tokens;
156
+ }
157
+
158
+ /** Split a token list on shell stage separators. */
159
+ function pipelineStages(tokens) {
160
+ const SEPARATORS = new Set(["|", "||", "&&", ";", "&"]);
161
+ const stages = [];
162
+ let cur = [];
163
+ for (const t of tokens) {
164
+ if (SEPARATORS.has(t)) {
165
+ if (cur.length) stages.push(cur);
166
+ cur = [];
167
+ continue;
168
+ }
169
+ cur.push(t);
170
+ }
171
+ if (cur.length) stages.push(cur);
172
+ return stages;
173
+ }
174
+
175
+
176
+ // --- Where the pattern sits, one reader per spelling -----------------------
177
+
178
+ function hasExplicitRegexpFlag(argv) {
179
+ for (const a of argv) {
180
+ if (a === "-e") return true;
181
+ if (a === "--regexp") return true;
182
+ }
183
+ return false;
184
+ }
185
+
186
+ function patternFromRegexpFlag(argv) {
187
+ for (let i = 0; i < argv.length; i++) {
188
+ if (argv[i] === "-e" || argv[i] === "--regexp") return argv[i + 1];
189
+ }
190
+ return null;
191
+ }
192
+
193
+ function patternFromNameFlag(argv) {
194
+ for (let i = 0; i < argv.length; i++) {
195
+ if (argv[i] === "-name" || argv[i] === "-iname") return argv[i + 1];
196
+ }
197
+ return null;
198
+ }
199
+
200
+ function firstPositional(argv) {
201
+ for (let i = 0; i < argv.length; i++) {
202
+ const a = argv[i];
203
+ if (FLAGS_TAKING_VALUE.has(a)) { i++; continue; }
204
+ if (a.startsWith("-")) continue;
205
+ return a;
206
+ }
207
+ return null;
208
+ }
209
+
210
+ /**
211
+ * From one pipeline stage, work out the search program and its pattern.
212
+ * Returns null when the stage runs no search program.
213
+ */
214
+ function readSearchStage(stage) {
215
+ let idx = 0;
216
+
217
+ // Step past environment assignments and common prefixes.
218
+ while (idx < stage.length) {
219
+ const t = stage[idx];
220
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) { idx++; continue; }
221
+ if (t === "sudo" || t === "command" || t === "time" || t === "xargs") { idx++; continue; }
222
+ break;
223
+ }
224
+ if (idx >= stage.length) return null;
225
+
226
+ const program = path.basename(stage[idx]);
227
+ if (!SEARCH_PROGRAMS.has(program)) return null;
228
+
229
+ const argv = stage.slice(idx + 1);
230
+
231
+ // Where the pattern sits depends only on how the command was spelled. These
232
+ // are three SPELLINGS of the same argument, not three attempts with earlier
233
+ // ones falling back to later ones: `find` always carries it after -name,
234
+ // grep-family tools carry it after -e when that flag is used and positionally
235
+ // otherwise. Which reader applies is decided up front, from the command
236
+ // itself, so nothing here substitutes for a value that went missing.
237
+ const pattern = program === "find"
238
+ ? patternFromNameFlag(argv)
239
+ : (hasExplicitRegexpFlag(argv) ? patternFromRegexpFlag(argv) : firstPositional(argv));
240
+
241
+ // A search program was invoked but no pattern could be read out of it. That
242
+ // is NOT "no search here" - it is a search this guard could not inspect, and
243
+ // reporting null would let it run unexamined. It is returned as unreadable so
244
+ // the caller blocks and says so.
245
+ if (typeof pattern !== "string") {
246
+ return { program, pattern: null, argv, unreadable: true };
247
+ }
248
+ return { program, pattern, argv, unreadable: false };
249
+ }
250
+
251
+ // --- Graph availability ----------------------------------------------------
252
+ //
253
+ // A structural question needs a graph to answer it. No graph is a BLOCK with
254
+ // "build it", never a quiet permission to grep instead.
255
+
256
+ function graphStorePath(projectDir) {
257
+ const candidates = [
258
+ path.join(projectDir, ".gsd-t", "graphDB", "graph.db"),
259
+ path.join(projectDir, ".gsd-t", "graph.db"),
260
+ ];
261
+ for (const c of candidates) {
262
+ if (fs.existsSync(c)) return c;
263
+ }
264
+ return null;
265
+ }
266
+
267
+ function buildNoGraphReason(found, cls) {
268
+ return [
269
+ "This is a structural question about code, and this project has no code graph built.",
270
+ "",
271
+ " searching for: " + found.pattern,
272
+ " which is: " + cls.reason,
273
+ "",
274
+ "Build the graph, then ask it:",
275
+ "",
276
+ " gsd-t graph index",
277
+ "",
278
+ "Grepping instead would answer a question about relationships by matching text,",
279
+ "which is a different and wrong answer. A missing graph is a repairable condition,",
280
+ "not a reason to fall back to grep.",
281
+ ].join("\n");
282
+ }
283
+
284
+ function buildStructuralReason(found, cls) {
285
+ const lines = [];
286
+
287
+ const symbol = cls.symbol === null ? found.pattern : cls.symbol;
288
+ const verb = cls.verb === null ? "who-calls" : cls.verb;
289
+
290
+ lines.push(
291
+ "This is a structural question about code. The graph answers it; grep only matches text.",
292
+ "",
293
+ " searching for: " + found.pattern,
294
+ " which is: " + cls.reason,
295
+ "",
296
+ "Ask the graph instead:",
297
+ "",
298
+ " gsd-t graph " + verb + " " + symbol,
299
+ "",
300
+ "Other verbs: who-imports, who-calls, defines, blast-radius, body.",
301
+ "",
302
+ "If this really is a text search - a phrase in prose, a key in config, a string in a",
303
+ "document - scope it to the files the graph does not index, and it will run:",
304
+ "",
305
+ " grep --include='*.md' --include='*.json' ..."
306
+ );
307
+ return lines.join("\n");
308
+ }
309
+
310
+ function buildUnclearReason(found, cls) {
311
+ return [
312
+ "This search could be asking about code structure, and that has to be settled before",
313
+ "it runs - a guess in either direction is how the graph rule stopped having teeth.",
314
+ "",
315
+ " searching for: " + found.pattern,
316
+ " why unclear: " + cls.reason,
317
+ "",
318
+ "Say which it is:",
319
+ "",
320
+ " Structure (who calls, who imports, where defined):",
321
+ " gsd-t graph who-calls <symbol>",
322
+ "",
323
+ " Text in files the graph does not index (.md, .json, .sql, config, prose):",
324
+ " add --include='*.md' (or the right extensions) and run it again",
325
+ ].join("\n");
326
+ }
327
+
328
+
329
+ // --- Recording the decision ------------------------------------------------
330
+ //
331
+ // One line per block, into the same ledger the graph's own tooling writes. The
332
+ // Stop-time report reads these to spot a turn that hit structural questions and
333
+ // never asked the graph. Writing it must never change the decision, so a sink
334
+ // failure is swallowed here and nowhere else in this file.
335
+
336
+ function recordBlock(projectDir, program, pattern, verdict) {
337
+ try {
338
+ const dir = path.join(projectDir, ".gsd-t", "graphDB", "logs");
339
+ if (!fs.existsSync(dir)) return;
340
+ let names = fs.readdirSync(dir)
341
+ .filter((n) => n.startsWith("graph-events-") && n.endsWith(".jsonl"));
342
+ if (names.length === 0) return;
343
+ names.sort();
344
+ const file = path.join(dir, names[names.length - 1]);
345
+ const line = JSON.stringify({
346
+ kind: "search-blocked",
347
+ ts: new Date().toISOString(),
348
+ program,
349
+ verdict,
350
+ patternShape: String(pattern).slice(0, 120),
351
+ consumer: "search-guard",
352
+ });
353
+ fs.appendFileSync(file, line + "\n");
354
+ } catch (e) {
355
+ // The block still happens - the decision is made above and does not depend
356
+ // on this record. But the failure is SAID, not swallowed: the Stop-time
357
+ // report reads these lines, so a ledger that silently stopped accepting
358
+ // them would make that report quietly under-count and look clean.
359
+ // stderr, because stdout carries the permission decision.
360
+ process.stderr.write(
361
+ "[GSD-T GRAPH] the search-block record could not be written (" + e.message + "). " +
362
+ "The search was still blocked; the Stop-time graph-use report will under-count.\n"
363
+ );
364
+ }
365
+ }
366
+
367
+ function main() {
368
+ let input = "";
369
+ let done = false;
370
+
371
+ process.stdin.setEncoding("utf8");
372
+ process.stdin.on("data", (c) => { input += c; });
373
+ process.stdin.on("end", () => {
374
+ if (done) return;
375
+ done = true;
376
+ decide(input);
377
+ });
378
+
379
+ // A payload that never arrives is not a search to judge. Exiting 0 here is
380
+ // "nothing was asked", not "a failure was ignored".
381
+ setTimeout(() => {
382
+ if (done) return;
383
+ done = true;
384
+ decide(input);
385
+ }, 4000);
386
+ }
387
+
388
+ function decide(raw) {
389
+ let payload;
390
+ try {
391
+ payload = JSON.parse(raw);
392
+ } catch {
393
+ // No readable payload means no search to classify. Not applicable.
394
+ allow();
395
+ return;
396
+ }
397
+
398
+ const toolName = payload.tool_name;
399
+ if (toolName !== "Bash" && toolName !== "Grep") { allow(); return; }
400
+
401
+ const projectDir = typeof payload.cwd === "string" ? payload.cwd : process.cwd();
402
+
403
+ // Not a GSD-T project - this rule is GSD-T's, so it does not apply.
404
+ if (!fs.existsSync(path.join(projectDir, ".gsd-t"))) { allow(); return; }
405
+
406
+ let enabled;
407
+ try {
408
+ enabled = isEnabled(projectDir);
409
+ } catch (e) {
410
+ deny(
411
+ "The graph-search gate's settings could not be read, so it cannot be known whether " +
412
+ "this search is allowed: " + e.message + "\n\n" +
413
+ "Fix or delete .gsd-t/graph-search-gate.json."
414
+ );
415
+ return;
416
+ }
417
+ if (!enabled) { allow(); return; }
418
+
419
+ let classifySearch;
420
+ try {
421
+ const mod = require(findClassifier(projectDir));
422
+ classifySearch = mod.classifySearch;
423
+ } catch (e) {
424
+ deny("The graph-search gate could not load its classifier: " + e.message);
425
+ return;
426
+ }
427
+
428
+ // Gather the search stages this call would run.
429
+ const found = [];
430
+ if (toolName === "Grep") {
431
+ const gi = payload.tool_input;
432
+ if (!gi) { allow(); return; }
433
+ const argv = [];
434
+ if (typeof gi.glob === "string") { argv.push("--glob", gi.glob); }
435
+ if (typeof gi.path === "string") { argv.push(gi.path); }
436
+ if (typeof gi.type === "string") { argv.push("-t" + gi.type); }
437
+ if (typeof gi.pattern !== "string") { allow(); return; }
438
+ found.push({ program: "rg", pattern: gi.pattern, argv });
439
+ } else {
440
+ const command = payload.tool_input === undefined ? undefined : payload.tool_input.command;
441
+ if (typeof command !== "string") { allow(); return; }
442
+ for (const stage of pipelineStages(tokenize(command))) {
443
+ const s = readSearchStage(stage);
444
+ if (s !== null) found.push(s);
445
+ }
446
+ }
447
+
448
+ if (found.length === 0) { allow(); return; }
449
+
450
+ const hasGraph = graphStorePath(projectDir) !== null;
451
+
452
+ for (const f of found) {
453
+ // A search whose pattern could not be read is a search that cannot be
454
+ // judged. It blocks: letting it through would be deciding it is safe on
455
+ // the strength of evidence that could not be gathered.
456
+ if (f.unreadable === true) {
457
+ deny(
458
+ "A " + f.program + " search was invoked but this guard could not read what it " +
459
+ "searches for, so it cannot tell whether the code graph should answer it instead.\n\n" +
460
+ "Rewrite it so the pattern is a plain argument (or use -e <pattern>), or ask the " +
461
+ "graph directly:\n\n gsd-t graph who-calls <symbol>"
462
+ );
463
+ return;
464
+ }
465
+
466
+ let cls;
467
+ try {
468
+ cls = classifySearch({ pattern: f.pattern, argv: f.argv, program: f.program });
469
+ } catch (e) {
470
+ deny("The graph-search gate could not classify this search: " + e.message);
471
+ return;
472
+ }
473
+
474
+ if (cls.verdict === "structural") {
475
+ recordBlock(projectDir, f.program, f.pattern, "structural");
476
+ if (hasGraph) deny(buildStructuralReason(f, cls));
477
+ else deny(buildNoGraphReason(f, cls));
478
+ return;
479
+ }
480
+ if (cls.verdict === "unclear") {
481
+ recordBlock(projectDir, f.program, f.pattern, "unclear");
482
+ deny(buildUnclearReason(f, cls));
483
+ return;
484
+ }
485
+ }
486
+
487
+ allow();
488
+ }
489
+
490
+ main();
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gsd-t-graph-use-report.js
4
+ *
5
+ * M117 - A Stop hook. At the end of a turn it compares the structural work the
6
+ * session did against the graph queries it issued, and says so when the two do
7
+ * not match.
8
+ *
9
+ * [RULE] use-report-measures-behaviour-not-code-shape
10
+ * [RULE] use-report-never-blocks-it-reports
11
+ *
12
+ * WHY A SECOND MECHANISM
13
+ * The PreToolUse guard (gsd-t-graph-search-guard.js) blocks structural
14
+ * SEARCHES. It governs tool calls, which is all a pattern-matcher can see.
15
+ * It cannot see a session that read six files end to end and worked out the
16
+ * call graph by eye - there is no grep to catch, and no code shape a static
17
+ * scan could find. That is the same blind spot gsd-t-graph-use-gate.cjs was
18
+ * built for, and for the same reason: only the runtime ledger records what a
19
+ * session actually did.
20
+ *
21
+ * This is the conversational counterpart of that gate. The gate runs inside
22
+ * `gsd-t verify`, so it only ever sees workflow runs; most work is a plain
23
+ * conversation, which nothing was measuring.
24
+ *
25
+ * WHAT IT DOES NOT DO
26
+ * It does not block, and it is not a gate. A Stop hook fires after the work
27
+ * is finished, so blocking there would punish work already done rather than
28
+ * redirect it. It reports - into the ledger always, and to the session when
29
+ * the mismatch is worth saying out loud. Prevention is the guard's job.
30
+ *
31
+ * --- Stdin (Claude Code Stop payload) --------------------------------------
32
+ * { "cwd": "...", "session_id": "...", ... }
33
+ *
34
+ * --- Output ----------------------------------------------------------------
35
+ * Exit 0 always. When there is something to say, one line on stdout.
36
+ *
37
+ * Zero dependencies.
38
+ */
39
+
40
+ "use strict";
41
+
42
+ const fs = require("fs");
43
+ const path = require("path");
44
+
45
+ // How far back a "this turn" window reaches. A turn is minutes, not hours.
46
+ const WINDOW_MS = 30 * 60 * 1000;
47
+
48
+ function quiet() { process.exit(0); }
49
+
50
+ function say(message) {
51
+ process.stdout.write(message + "\n");
52
+ process.exit(0);
53
+ }
54
+
55
+ function ledgerPath(projectDir) {
56
+ const dir = path.join(projectDir, ".gsd-t", "graphDB", "logs");
57
+ if (!fs.existsSync(dir)) return null;
58
+ let names;
59
+ try {
60
+ names = fs.readdirSync(dir).filter((n) => n.startsWith("graph-events-") && n.endsWith(".jsonl"));
61
+ } catch {
62
+ return null;
63
+ }
64
+ if (names.length === 0) return null;
65
+ names.sort();
66
+ return path.join(dir, names[names.length - 1]);
67
+ }
68
+
69
+ /**
70
+ * Read the tail of a file without loading all of it. The graph ledger reaches
71
+ * tens of megabytes, and a Stop hook must not stall a turn.
72
+ */
73
+ function tailLines(file, maxBytes) {
74
+ let fd;
75
+ try {
76
+ fd = fs.openSync(file, "r");
77
+ } catch {
78
+ return null;
79
+ }
80
+ try {
81
+ const size = fs.fstatSync(fd).size;
82
+ const start = size > maxBytes ? size - maxBytes : 0;
83
+ const len = size - start;
84
+ const buf = Buffer.alloc(len);
85
+ fs.readSync(fd, buf, 0, len, start);
86
+ const text = buf.toString("utf8");
87
+ const lines = text.split("\n");
88
+ if (start > 0) lines.shift(); // the first line is probably cut in half
89
+ return lines;
90
+ } catch {
91
+ return null;
92
+ } finally {
93
+ fs.closeSync(fd);
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Count what happened inside the window: graph queries issued, and structural
99
+ * searches the guard blocked.
100
+ */
101
+ function readRecentActivity(file, sinceMs) {
102
+ const lines = tailLines(file, 2 * 1024 * 1024);
103
+ if (lines === null) return null;
104
+
105
+ let queries = 0;
106
+ let blockedStructural = 0;
107
+
108
+ for (const line of lines) {
109
+ const trimmed = line.trim();
110
+ if (!trimmed) continue;
111
+ let ev;
112
+ try {
113
+ ev = JSON.parse(trimmed);
114
+ } catch {
115
+ continue; // a half-written line at the tail; the next run reads it whole
116
+ }
117
+ const ts = Date.parse(ev.ts);
118
+ if (!Number.isFinite(ts)) continue;
119
+ if (ts < sinceMs) continue;
120
+
121
+ if (ev.kind === "query") queries++;
122
+ if (ev.kind === "search-blocked") blockedStructural++;
123
+ }
124
+
125
+ return { queries, blockedStructural };
126
+ }
127
+
128
+ function main() {
129
+ let input = "";
130
+ let done = false;
131
+
132
+ process.stdin.setEncoding("utf8");
133
+ process.stdin.on("data", (c) => { input += c; });
134
+ process.stdin.on("end", () => {
135
+ if (done) return;
136
+ done = true;
137
+ report(input);
138
+ });
139
+
140
+ setTimeout(() => {
141
+ if (done) return;
142
+ done = true;
143
+ report(input);
144
+ }, 3000);
145
+ }
146
+
147
+ function report(raw) {
148
+ let payload;
149
+ try {
150
+ payload = JSON.parse(raw);
151
+ } catch {
152
+ quiet();
153
+ return;
154
+ }
155
+
156
+ const projectDir = typeof payload.cwd === "string" ? payload.cwd : process.cwd();
157
+ if (!fs.existsSync(path.join(projectDir, ".gsd-t"))) { quiet(); return; }
158
+
159
+ const file = ledgerPath(projectDir);
160
+ if (file === null) { quiet(); return; }
161
+
162
+ const activity = readRecentActivity(file, Date.now() - WINDOW_MS);
163
+ if (activity === null) { quiet(); return; }
164
+
165
+ // The guard stopped structural searches this turn and the graph was never
166
+ // asked. Something structural was wanted and answered another way.
167
+ if (activity.blockedStructural > 0 && activity.queries === 0) {
168
+ say(
169
+ "[GSD-T GRAPH] " + activity.blockedStructural + " structural search(es) were blocked this " +
170
+ "turn and the graph was never queried. A structural question answered by reading files " +
171
+ "is the blind spot the guard cannot see - ask the graph: gsd-t graph who-calls <symbol>."
172
+ );
173
+ return;
174
+ }
175
+
176
+ quiet();
177
+ }
178
+
179
+ main();
@@ -331,6 +331,8 @@ NEED A STRUCTURAL ANSWER? (what imports this, who calls this, what breaks if I c
331
331
 
332
332
  **This governs plain conversational work, not just `/gsd-t-*` commands.** The failure that produced this rule (binvoice, 2026-08-11) was an ordinary session: it reached for the graph, found none, grepped an 827-file project, and nothing objected. Checking the graph's existence before reasoning about code is the first move, not a fallback.
333
333
 
334
+ **ENFORCED, not advised (M117).** A PreToolUse guard on Bash and Grep BLOCKS a search that asks a structural question about code and names the graph query to run instead. Three outcomes, no fourth: structural → blocked; a search over content the graph does not index (`.md`, `.json`, `.sql`, config, prose) → runs, because the graph holds no answer to route to; a search the guard cannot classify → blocked, because guessing "probably text" is how this rule stopped having teeth. **A missing or unbuilt graph BLOCKS too**, with `gsd-t graph index` — allowing grep because the graph is down is the exact fallback that let a project grep its way through 827 files. There is no bypass flag; a project may switch it off only by writing `.gsd-t/graph-search-gate.json` `{"enabled": false}`, which is a recorded decision rather than an absence. A Stop-time report covers what no pattern-matcher can see: a turn that answered a structural question by reading files end to end. Why this was needed: the rule already existed and had three enforcement points, and all three watched paths the work does not take — the Grep-tool and Read-tool hooks never fire in bypass mode, and the runtime use-gate only runs inside verify. The ledger showed two grep events in three months against 34,418 tool-issued queries.
335
+
334
336
  **Absence is a repairable condition, not a stop sign.** When this was checked across the machine, 20 of 27 registered projects had no usable graph — 2 never built, 18 holding a real index at a path the tooling stopped reading after the store moved. Every one of those sessions had been grepping. `gsd-t update-all` now repairs both automatically and reports any it could not.
335
337
 
336
338
  ## Prime Rule