@tekyzinc/gsd-t 5.8.10 → 5.9.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.
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gsd-t-concise-hook.js
4
+ *
5
+ * M107-D2 — Stop hook. Sends a long reply to a fresh Claude for a shorter
6
+ * rewrite, then blocks the stop so the short version is what David reads.
7
+ *
8
+ * [RULE] concise-hook-never-loops
9
+ * [RULE] concise-hook-fails-open-on-rewrite-failure
10
+ *
11
+ * A Stop hook cannot edit a reply that is already written. It can only block
12
+ * the stop and hand back an instruction. So: read the reply from the
13
+ * transcript, get the short version, and block once with "replace your last
14
+ * reply with this". The model then emits the short version and stops.
15
+ *
16
+ * ─── Stdin (Claude Code Stop payload) ───────────────────────────────────────
17
+ * { "transcript_path": "...", "stop_hook_active": true|false, "cwd": "..." }
18
+ *
19
+ * ─── Loop guard ─────────────────────────────────────────────────────────────
20
+ * stop_hook_active === true means this Stop is already a re-entry from our
21
+ * own block. Exit 0 immediately. One rewrite per turn, never two.
22
+ *
23
+ * ─── Fail-open, deliberately ────────────────────────────────────────────────
24
+ * Unlike the fallback guard, this one ALLOWS on failure. It governs how a
25
+ * reply READS, not whether it is true. A broken rewriter must never gag a
26
+ * correct answer — showing a long reply is a poor outcome, showing none is a
27
+ * wrong one. The rewriter itself returns the original when it cannot do
28
+ * better, so no content is ever lost.
29
+ *
30
+ * Zero dependencies.
31
+ */
32
+
33
+ "use strict";
34
+
35
+ const fs = require("fs");
36
+ const os = require("os");
37
+ const path = require("path");
38
+ const { spawnSync } = require("child_process");
39
+
40
+ const DEFAULT_SKIP_UNDER = 60; // words
41
+
42
+ function allow() { process.exit(0); }
43
+
44
+ function block(reason) {
45
+ process.stdout.write(JSON.stringify({ decision: "block", reason }) + "\n");
46
+ process.exit(0);
47
+ }
48
+
49
+ /** Only read transcripts from the Claude projects directory. */
50
+ function safeTranscriptPath(p) {
51
+ if (typeof p !== "string" || !p) return null;
52
+ if (!path.isAbsolute(p)) return null;
53
+ const home = process.env.HOME || os.homedir();
54
+ if (!home) return null;
55
+ const root = path.resolve(home, ".claude", "projects") + path.sep;
56
+ const resolved = path.resolve(p);
57
+ return resolved.startsWith(root) ? resolved : null;
58
+ }
59
+
60
+ function readTail(filePath, bytes) {
61
+ let fd = -1;
62
+ try {
63
+ const st = fs.statSync(filePath);
64
+ if (!st.isFile() || st.size === 0) return "";
65
+ const want = Math.min(bytes, st.size);
66
+ const start = st.size - want;
67
+ fd = fs.openSync(filePath, "r");
68
+ const buf = Buffer.alloc(want);
69
+ fs.readSync(fd, buf, 0, want, start);
70
+ let s = buf.toString("utf8");
71
+ if (start > 0) {
72
+ const nl = s.indexOf("\n");
73
+ if (nl >= 0) s = s.slice(nl + 1);
74
+ }
75
+ return s;
76
+ } catch (_) {
77
+ return "";
78
+ } finally {
79
+ if (fd >= 0) { try { fs.closeSync(fd); } catch (_) { /* already closed */ } }
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Pull the last assistant turn's visible text from the transcript.
85
+ * Returns { text, toolOnly } — a tool-only turn has no prose to shorten.
86
+ */
87
+ function lastAssistantText(transcriptPath) {
88
+ const tail = readTail(transcriptPath, 512 * 1024);
89
+ if (!tail) return null;
90
+ const lines = tail.split("\n").filter(Boolean);
91
+ for (let i = lines.length - 1; i >= 0; i--) {
92
+ let rec;
93
+ try { rec = JSON.parse(lines[i]); } catch (_) { continue; }
94
+ const msg = rec && rec.message;
95
+ if (!msg || msg.role !== "assistant") continue;
96
+ const content = Array.isArray(msg.content) ? msg.content : [];
97
+ const text = content.filter((b) => b && b.type === "text")
98
+ .map((b) => b.text || "").join("\n").trim();
99
+ const hasTool = content.some((b) => b && b.type === "tool_use");
100
+ return { text, toolOnly: !text && hasTool };
101
+ }
102
+ return null;
103
+ }
104
+
105
+ function wordCount(s) {
106
+ return (String(s).trim().match(/\S+/g) || []).length;
107
+ }
108
+
109
+ function readConfig(projectDir) {
110
+ const p = path.join(projectDir, ".gsd-t", "concise.json");
111
+ if (!fs.existsSync(p)) return { enabled: true, skipUnder: DEFAULT_SKIP_UNDER };
112
+ try {
113
+ const cfg = JSON.parse(fs.readFileSync(p, "utf8"));
114
+ return {
115
+ enabled: cfg.enabled !== false,
116
+ skipUnder: Number.isFinite(cfg.skipUnder) ? cfg.skipUnder : DEFAULT_SKIP_UNDER,
117
+ };
118
+ } catch (_) {
119
+ return { enabled: true, skipUnder: DEFAULT_SKIP_UNDER };
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Locate the rewriter. Every project carries its own copy; if it does not, the
125
+ * install is broken and the SessionStart heal hook repairs it. Two exact
126
+ * locations, no hunting.
127
+ */
128
+ function findRewriter(projectDir) {
129
+ const inProject = path.join(projectDir, "bin", "gsd-t-concise-rewrite.cjs");
130
+ if (fs.existsSync(inProject)) return inProject;
131
+ const inPackage = path.join(__dirname, "..", "bin", "gsd-t-concise-rewrite.cjs");
132
+ if (fs.existsSync(inPackage)) return inPackage;
133
+ return null; // the caller lets the reply through — see the note on main()
134
+ }
135
+
136
+ function main() {
137
+ let input = "";
138
+ let done = false;
139
+
140
+ process.stdin.setEncoding("utf8");
141
+ process.stdin.on("data", (c) => { input += c; });
142
+
143
+ const finish = () => {
144
+ if (done) return;
145
+ done = true;
146
+
147
+ let data;
148
+ try { data = JSON.parse(input); } catch (_) { return allow(); }
149
+ if (!data || typeof data !== "object") return allow();
150
+
151
+ // One rewrite per turn. This Stop is our own re-entry — let it through.
152
+ if (data.stop_hook_active === true) return allow();
153
+
154
+ const cwd = (typeof data.cwd === "string" && data.cwd) ? data.cwd : process.cwd();
155
+ const cfg = readConfig(cwd);
156
+ if (!cfg.enabled) return allow();
157
+
158
+ const tPath = safeTranscriptPath(data.transcript_path);
159
+ if (!tPath) return allow();
160
+
161
+ const last = lastAssistantText(tPath);
162
+ if (!last || last.toolOnly || !last.text) return allow();
163
+ if (wordCount(last.text) < cfg.skipUnder) return allow();
164
+
165
+ const rewriter = findRewriter(cwd);
166
+ if (!rewriter) return allow();
167
+
168
+ const run = spawnSync(process.execPath,
169
+ [rewriter, "--text", last.text, "--project", cwd, "--json"],
170
+ { encoding: "utf8", timeout: 60000, maxBuffer: 8 * 1024 * 1024 });
171
+
172
+ if (run.error || !run.stdout) return allow();
173
+
174
+ let result;
175
+ try { result = JSON.parse(run.stdout); } catch (_) { return allow(); }
176
+
177
+ // The rewriter returns the original when it cannot do better. Nothing was
178
+ // lost, so there is nothing to replace.
179
+ if (!result.ok || !result.text || result.skipped) return allow();
180
+ if (result.text.trim() === last.text.trim()) return allow();
181
+
182
+ const saved = (result.words || 0) - (result.wordsAfter || 0);
183
+ if (saved < 15) return allow(); // not worth the extra turn
184
+
185
+ block(
186
+ "Your last reply was longer than it needed to be. Replace it with this " +
187
+ "shorter version, exactly as written, and add nothing:\n\n" +
188
+ result.text
189
+ );
190
+ };
191
+
192
+ process.stdin.on("end", finish);
193
+ process.stdin.on("error", finish);
194
+ const wd = setTimeout(finish, 70000);
195
+ if (wd.unref) wd.unref();
196
+ }
197
+
198
+ if (require.main === module) main();
199
+
200
+ module.exports = { lastAssistantText, wordCount, readConfig, findRewriter, safeTranscriptPath };
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gsd-t-fallback-guard.js
4
+ *
5
+ * M106-D2 — PreToolUse hook on Write|Edit. Denies a write that introduces a
6
+ * fallback the user never approved.
7
+ *
8
+ * [RULE] fallback-guard-denies-unapproved
9
+ * [RULE] fallback-guard-halts-never-allows-on-error
10
+ *
11
+ * A fallback added while chasing a bug never appears in any plan — this is the
12
+ * only trigger point that can catch it, because it fires at the moment the code
13
+ * is written.
14
+ *
15
+ * ─── Stdin (Claude Code PreToolUse payload) ─────────────────────────────────
16
+ * { "tool_name": "Write"|"Edit", "cwd": "...",
17
+ * "tool_input": { "file_path": "...", "content"|"new_string": "..." } }
18
+ *
19
+ * ─── Decision contract ──────────────────────────────────────────────────────
20
+ * Deny: {"hookSpecificOutput":{"hookEventName":"PreToolUse",
21
+ * "permissionDecision":"deny","permissionDecisionReason":"..."}}
22
+ * Allow: exit 0, no output.
23
+ *
24
+ * ─── HALT, never allow-on-error ─────────────────────────────────────────────
25
+ * This guard governs the No-Fallback rule, so it must not contain one. If the
26
+ * detector cannot run or its answer cannot be read, the write is DENIED with
27
+ * the reason — never quietly permitted. The single exception is a payload
28
+ * that is not a Write/Edit of source at all: that is "not applicable", not a
29
+ * failure, and it exits 0.
30
+ *
31
+ * Zero dependencies.
32
+ */
33
+
34
+ "use strict";
35
+
36
+ const fs = require("fs");
37
+ const path = require("path");
38
+ const { spawnSync } = require("child_process");
39
+
40
+ const SOURCE_EXT = new Set([".js", ".cjs", ".mjs", ".jsx", ".ts", ".tsx"]);
41
+ const TEST_PATH_RE = /(?:^|[\\/])(?:test|tests|__tests__|spec|e2e|fixtures?)[\\/]|\.(?:test|spec)\.[cm]?[jt]sx?$/i;
42
+
43
+ function deny(reason) {
44
+ process.stdout.write(JSON.stringify({
45
+ hookSpecificOutput: {
46
+ hookEventName: "PreToolUse",
47
+ permissionDecision: "deny",
48
+ permissionDecisionReason: reason,
49
+ },
50
+ }) + "\n");
51
+ process.exit(0);
52
+ }
53
+
54
+ function allow() { process.exit(0); }
55
+
56
+ /**
57
+ * Locate the detector.
58
+ *
59
+ * Every project is supposed to carry its own copy. If it does not, the
60
+ * project's install is broken, and the answer is to REPAIR it — which the
61
+ * SessionStart heal hook does — not to hunt around for a copy somewhere else.
62
+ * Hunting is what let binvoice run for weeks on 20 of 38 tools while every
63
+ * update reported success.
64
+ *
65
+ * Returns the path, or throws with what is wrong. It never returns "not found"
66
+ * as if that were an ordinary answer.
67
+ */
68
+ function findDetector(projectDir) {
69
+ const inProject = path.join(projectDir, "bin", "gsd-t-fallback-detect.cjs");
70
+ if (fs.existsSync(inProject)) return inProject;
71
+
72
+ // Running from inside the package itself (developing GSD-T).
73
+ const inPackage = path.join(__dirname, "..", "bin", "gsd-t-fallback-detect.cjs");
74
+ if (fs.existsSync(inPackage)) return inPackage;
75
+
76
+ throw new Error(
77
+ `This project has no copy of the fallback detector at ${inProject}, which means ` +
78
+ `its GSD-T install is incomplete. Run 'gsd-t install-check' to repair it — do not ` +
79
+ `work around it.`
80
+ );
81
+ }
82
+
83
+ /** Thrown when the settings file exists but cannot be understood. */
84
+ class ConfigUnreadable extends Error {}
85
+
86
+ /**
87
+ * Is the gate switched on for this project?
88
+ *
89
+ * A settings file that cannot be read is NOT assumed to mean "on" — that would
90
+ * be a guess about what the project wanted. It throws, and the caller denies
91
+ * the write and says why. Only an ABSENT file means "on", because absence is
92
+ * unambiguous.
93
+ */
94
+ function isEnabled(projectDir) {
95
+ const p = path.join(projectDir, ".gsd-t", "fallback-gate.json");
96
+ if (!fs.existsSync(p)) return true; // absent = on, by design
97
+ let raw;
98
+ try {
99
+ raw = fs.readFileSync(p, "utf8");
100
+ } catch (e) {
101
+ throw new ConfigUnreadable(`${p} could not be read: ${e.message}`);
102
+ }
103
+ const cfg = JSON.parse(raw); // a parse failure throws, and the caller denies
104
+ return cfg.enabled !== false;
105
+ }
106
+
107
+ function buildReason(findings, filePath) {
108
+ const lines = [
109
+ `This write adds ${findings.length === 1 ? "a fallback" : `${findings.length} fallbacks`} that was never approved.`,
110
+ "",
111
+ ];
112
+ for (const f of findings.slice(0, 5)) {
113
+ lines.push(` ${f.what}`);
114
+ if (f.snippet) lines.push(` ${f.snippet}`);
115
+ lines.push("");
116
+ }
117
+ lines.push(
118
+ "A fallback continues after a failure. It produces wrong data that looks correct,",
119
+ "and it removes the alarm for the bug that caused the failure.",
120
+ "",
121
+ "Do one of these:",
122
+ "",
123
+ " 1. Replace it with a halt — stop, and report what could not be done.",
124
+ " This is almost always the right answer.",
125
+ "",
126
+ " 2. Ask David for approval. Tell him, in plain words:",
127
+ " - what fails",
128
+ " - how often it really fails, with evidence",
129
+ " - why stopping is worse than continuing",
130
+ " - what it does instead (never a guessed value, never a partial result)",
131
+ " If he agrees, add the entry to .gsd-t/fallbacks.json and write again.",
132
+ "",
133
+ ` File: ${filePath}`
134
+ );
135
+ return lines.join("\n");
136
+ }
137
+
138
+ function main() {
139
+ let input = "";
140
+ let done = false;
141
+
142
+ process.stdin.setEncoding("utf8");
143
+ process.stdin.on("data", (c) => { input += c; });
144
+
145
+ const finish = () => {
146
+ if (done) return;
147
+ done = true;
148
+
149
+ let data;
150
+ try {
151
+ data = JSON.parse(input);
152
+ } catch (_) {
153
+ // An unparseable payload is not a write we can inspect — not applicable.
154
+ return allow();
155
+ }
156
+ if (!data || typeof data !== "object") return allow();
157
+
158
+ const tool = data.tool_name;
159
+ if (tool !== "Write" && tool !== "Edit") return allow();
160
+
161
+ const ti = (data.tool_input && typeof data.tool_input === "object") ? data.tool_input : {};
162
+ const filePath = typeof ti.file_path === "string" ? ti.file_path : "";
163
+ if (!filePath) return allow();
164
+
165
+ const ext = path.extname(filePath);
166
+ if (!SOURCE_EXT.has(ext)) return allow(); // not source — not applicable
167
+ if (TEST_PATH_RE.test(filePath.replace(/\\/g, "/"))) return allow(); // tests are exempt
168
+
169
+ const content = typeof ti.content === "string" ? ti.content
170
+ : typeof ti.new_string === "string" ? ti.new_string
171
+ : "";
172
+ if (!content.trim()) return allow();
173
+
174
+ const cwd = (typeof data.cwd === "string" && data.cwd) ? data.cwd : process.cwd();
175
+ let on;
176
+ try {
177
+ on = isEnabled(cwd);
178
+ } catch (e) {
179
+ return deny(
180
+ "The fallback gate's settings could not be read, so it is unclear whether\n" +
181
+ `this project has switched the gate off.\n\n${e.message}\n\n` +
182
+ "Fix the file, or delete it to leave the gate on."
183
+ );
184
+ }
185
+ if (!on) return allow(); // switched off for this project
186
+
187
+ let detector;
188
+ try {
189
+ detector = findDetector(cwd);
190
+ } catch (e) {
191
+ return deny(
192
+ `${e.message}\n\n` +
193
+ "This write cannot be checked, and allowing it unchecked is exactly what this\n" +
194
+ "guard exists to prevent."
195
+ );
196
+ }
197
+
198
+ const run = spawnSync(process.execPath,
199
+ [detector, "--text", content, "--file", filePath, "--project", cwd, "--json"],
200
+ { encoding: "utf8", timeout: 10000, maxBuffer: 8 * 1024 * 1024 });
201
+
202
+ if (run.error || typeof run.stdout !== "string" || !run.stdout.trim()) {
203
+ return deny(
204
+ "The fallback check could not run, so this write cannot be verified.\n" +
205
+ `Reason: ${run.error ? run.error.message : "the detector produced no output"}\n\n` +
206
+ "This guard halts rather than letting an unchecked write through."
207
+ );
208
+ }
209
+
210
+ let result;
211
+ try {
212
+ result = JSON.parse(run.stdout);
213
+ } catch (_) {
214
+ return deny("The fallback check returned something unreadable, so this write cannot be verified.");
215
+ }
216
+
217
+ if (result.exitCode === 64) {
218
+ return deny(
219
+ `The fallback check could not decide: ${result.error || "unknown reason"}\n\n` +
220
+ (result.halt || "Fix the problem above, then write again.")
221
+ );
222
+ }
223
+
224
+ if (result.ok) return allow();
225
+
226
+ return deny(buildReason(result.findings || [], filePath));
227
+ };
228
+
229
+ process.stdin.on("end", finish);
230
+ process.stdin.on("error", finish);
231
+ const wd = setTimeout(finish, 8000);
232
+ if (wd.unref) wd.unref();
233
+ }
234
+
235
+ if (require.main === module) main();
236
+
237
+ module.exports = { buildReason, findDetector, isEnabled };
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gsd-t-install-heal.js
4
+ *
5
+ * M108 — SessionStart hook. Checks this project's install, repairs what is
6
+ * missing, and reports anything it could not fix.
7
+ *
8
+ * [RULE] install-heal-repairs-before-work-starts
9
+ * [RULE] install-heal-reports-what-it-could-not-fix
10
+ *
11
+ * This is NOT a fallback. A fallback continues past a failure with a worse
12
+ * answer. This one FIXES the failure — copies the missing tool from the
13
+ * installed package — so the session then runs with everything present. When it
14
+ * cannot fix something it says so loudly rather than letting the session
15
+ * proceed on a broken install.
16
+ *
17
+ * It also surfaces the shared repair log: if the same tool keeps going missing
18
+ * across projects, the installer is what needs fixing, not each project.
19
+ *
20
+ * ─── Stdin (Claude Code SessionStart payload) ───────────────────────────────
21
+ * { "hook_event_name": "SessionStart", "cwd": "...", "session_id": "..." }
22
+ *
23
+ * ─── Output ─────────────────────────────────────────────────────────────────
24
+ * Prints to stdout, which Claude Code shows as session context. Silent when
25
+ * the install is already complete — the common case, and no news is good news.
26
+ *
27
+ * Zero dependencies.
28
+ */
29
+
30
+ "use strict";
31
+
32
+ const fs = require("fs");
33
+ const os = require("os");
34
+ const path = require("path");
35
+ const { spawnSync, execFileSync } = require("child_process");
36
+
37
+ /**
38
+ * Find the install checker. It ships beside this hook, so one place answers it.
39
+ * Throws with what is wrong — never returns nothing as an ordinary answer.
40
+ */
41
+ function findChecker() {
42
+ const beside = path.join(__dirname, "..", "bin", "gsd-t-install-check.cjs");
43
+ if (fs.existsSync(beside)) return beside;
44
+ throw new Error(
45
+ `The install checker is not at ${beside}, where it ships. The GSD-T install ` +
46
+ `itself is incomplete — reinstall it.`
47
+ );
48
+ }
49
+
50
+ function main() {
51
+ let input = "";
52
+ let done = false;
53
+
54
+ process.stdin.setEncoding("utf8");
55
+ process.stdin.on("data", (c) => { input += c; });
56
+
57
+ const finish = () => {
58
+ if (done) return;
59
+ done = true;
60
+
61
+ // Which project is this? The payload says. Guessing with process.cwd()
62
+ // could check — and repair — a different project than the one the session
63
+ // is in, so an unreadable payload stops instead.
64
+ let data;
65
+ try {
66
+ data = JSON.parse(input);
67
+ } catch (e) {
68
+ process.stdout.write(
69
+ `[GSD-T INSTALL] Could not tell which project this session is in, so its ` +
70
+ `install was not checked: ${e.message}\n`
71
+ );
72
+ process.exit(0);
73
+ }
74
+ const cwd = (data && typeof data.cwd === "string" && data.cwd) ? data.cwd : null;
75
+ if (!cwd) {
76
+ process.stdout.write(
77
+ "[GSD-T INSTALL] The session did not say which folder it is in, so this " +
78
+ "project's install was not checked.\n"
79
+ );
80
+ process.exit(0);
81
+ }
82
+
83
+ // Not a GSD-T project — nothing to check.
84
+ if (!fs.existsSync(path.join(cwd, ".gsd-t"))) { process.exit(0); }
85
+
86
+ let checker;
87
+ try {
88
+ checker = findChecker();
89
+ } catch (e) {
90
+ process.stdout.write(`[GSD-T INSTALL] ${e.message}\n`);
91
+ process.exit(0);
92
+ }
93
+
94
+ const run = spawnSync(process.execPath, [checker, "--project", cwd, "--json"], {
95
+ encoding: "utf8", timeout: 60000, maxBuffer: 8 * 1024 * 1024,
96
+ });
97
+
98
+ if (run.error || !run.stdout) {
99
+ process.stdout.write(
100
+ `[GSD-T INSTALL] The install check could not run: ${run.error ? run.error.message : "no output"}\n`
101
+ );
102
+ process.exit(0);
103
+ }
104
+
105
+ let result;
106
+ try {
107
+ result = JSON.parse(run.stdout);
108
+ } catch (_) {
109
+ process.stdout.write("[GSD-T INSTALL] The install check returned something unreadable.\n");
110
+ process.exit(0);
111
+ }
112
+
113
+ const repaired = result.repaired || [];
114
+ const unfixable = result.unrepairable || [];
115
+
116
+ if (repaired.length) {
117
+ process.stdout.write(
118
+ `[GSD-T INSTALL] This project was missing ${repaired.length} tool(s). ` +
119
+ `They have been restored from the installed package, and the session can proceed normally.\n`
120
+ );
121
+ }
122
+
123
+ if (unfixable.length) {
124
+ process.stdout.write(
125
+ `[GSD-T INSTALL] ${unfixable.length} tool(s) could NOT be restored:\n`
126
+ );
127
+ for (const u of unfixable) {
128
+ process.stdout.write(` ${u.tool} — ${u.reason}\n`);
129
+ }
130
+ process.stdout.write(
131
+ "Anything that depends on these will fail. Tell David the install is broken " +
132
+ "before doing work that relies on them.\n"
133
+ );
134
+ }
135
+
136
+ // If one tool keeps going missing across several projects, the installer is
137
+ // the cause, not each project. Say so ONCE — repeating it every session
138
+ // turns a real signal into noise you stop reading.
139
+ if (repaired.length) {
140
+ try {
141
+ const lib = require(checker);
142
+ const { entries } = lib.readLog();
143
+ const suspects = lib.installerSuspects({}, entries).filter((s) => s.projects.length >= 3);
144
+ const seenPath = path.join(os.homedir(), ".claude", "gsd-t-install-suspects-seen.json");
145
+ let alreadyTold = [];
146
+ if (fs.existsSync(seenPath)) {
147
+ alreadyTold = JSON.parse(fs.readFileSync(seenPath, "utf8"));
148
+ }
149
+ const fresh = suspects.filter((s) => !alreadyTold.includes(s.tool));
150
+ if (fresh.length) {
151
+ process.stdout.write(
152
+ "[GSD-T INSTALL] These tools have gone missing across several projects, which " +
153
+ "points at the installer rather than any one project:\n"
154
+ );
155
+ for (const s of fresh.slice(0, 5)) {
156
+ process.stdout.write(` ${s.tool} — ${s.projects.length} projects\n`);
157
+ }
158
+ process.stdout.write("This is worth fixing in GSD-T itself.\n");
159
+ fs.writeFileSync(seenPath, JSON.stringify([...alreadyTold, ...fresh.map((s) => s.tool)]));
160
+ }
161
+ } catch (e) {
162
+ // The repair above already happened and is unaffected. But this is the
163
+ // signal that tells you the INSTALLER is at fault rather than each
164
+ // project — losing it silently is how the underlying cause stays hidden.
165
+ process.stdout.write(
166
+ `[GSD-T INSTALL] Repairs were made, but the cross-project pattern could ` +
167
+ `not be read: ${e.message}\n`
168
+ );
169
+ }
170
+ }
171
+
172
+ process.exit(0);
173
+ };
174
+
175
+ process.stdin.on("end", finish);
176
+ process.stdin.on("error", finish);
177
+ const wd = setTimeout(finish, 20000);
178
+ if (wd.unref) wd.unref();
179
+ }
180
+
181
+ if (require.main === module) main();
182
+
183
+ module.exports = { findChecker };