@tekyzinc/gsd-t 5.18.10 → 5.19.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/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/commands/cpua.md CHANGED
@@ -133,8 +133,16 @@ if [ "$ON_DISK" != "{NEW_VERSION}" ]; then
133
133
  ON_DISK=$(node -p "require('$G/package.json').version")
134
134
  [ "$ON_DISK" = "{NEW_VERSION}" ] || { echo "HALT: global install still $ON_DISK"; exit 1; }
135
135
  fi
136
- # 3. Only now propagate.
136
+ # 3. Refresh the HOME install (commands + ~/.claude/.gsd-t-version) BEFORE propagating.
137
+ # v5.18.10 (2026-09-03): with the version file still on the old number, `update-all`
138
+ # reported all 33 projects "already current", copied nothing, and the global package
139
+ # was found back on the old version afterwards. `gsd-t install` writes the version
140
+ # file and the command files; only then does update-all see the new release.
141
+ gsd-t install 2>&1 | tail -5
142
+ [ "$(cat ~/.claude/.gsd-t-version)" = "{NEW_VERSION}" ] || { echo "HALT: ~/.claude/.gsd-t-version did not advance"; exit 1; }
143
+ # 4. Propagate, then prove the global is STILL the new version and a NEW file reached a project.
137
144
  gsd-t update-all 2>&1 | tail -30
145
+ [ "$(node -p "require('$G/package.json').version")" = "{NEW_VERSION}" ] || { echo "HALT: global package reverted during update-all"; exit 1; }
138
146
  ```
139
147
 
140
148
  Never `npm update -g` (npm rejects it for legacy version strings like `3.19.00`). After `update-all`, prove propagation by `ls`/`grep` of one NEW or CHANGED file in a real registered project — the "copied N tool(s)" line is a report, not proof. Note: `update-all` also overwrites `~/.claude/commands/*.md` from the package, so THIS file's source of truth is `commands/cpua.md` in the GSD-T repo; an edit made only in `~/.claude/commands/` is lost on the next propagation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "5.18.10",
3
+ "version": "5.19.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,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();