@tekyzinc/gsd-t 5.7.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,342 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gsd-t-install-check.cjs
4
+ *
5
+ * M108 — Install self-check and repair.
6
+ *
7
+ * [RULE] install-check-repairs-then-continues-never-routes-around
8
+ * [RULE] install-check-records-every-repair
9
+ * [RULE] install-check-halts-when-it-cannot-repair
10
+ *
11
+ * A project's `bin/` is supposed to hold every tool the installer ships. When
12
+ * one is missing, the tools that need it break — and until now nothing noticed.
13
+ * Binvoice ran for weeks with 21 of 38 tools.
14
+ *
15
+ * This is NOT a fallback. A fallback continues PAST a failure with a worse
16
+ * answer. This FIXES the failure — it copies the missing file from the
17
+ * installed package — and then the work continues with everything present. If
18
+ * it cannot fix it, it HALTS and says so; it never lets the work proceed on a
19
+ * broken install.
20
+ *
21
+ * Every repair is written to a shared log so a pattern across projects becomes
22
+ * visible: if the same tool keeps going missing everywhere, the installer
23
+ * itself is what needs fixing.
24
+ *
25
+ * ─── Usage ──────────────────────────────────────────────────────────────────
26
+ * node gsd-t-install-check.cjs --project <dir> # check + repair
27
+ * node gsd-t-install-check.cjs --project <dir> --check # report only
28
+ * node gsd-t-install-check.cjs --report # read the shared log
29
+ *
30
+ * ─── Exit codes ─────────────────────────────────────────────────────────────
31
+ * 0 install is complete (possibly after a repair)
32
+ * 4 something is missing and could not be repaired — HALT
33
+ * 64 bad input
34
+ *
35
+ * Zero dependencies.
36
+ */
37
+
38
+ "use strict";
39
+
40
+ const fs = require("fs");
41
+ const os = require("os");
42
+ const path = require("path");
43
+ const { execFileSync } = require("child_process");
44
+
45
+ const EXIT_OK = 0;
46
+ const EXIT_UNREPAIRABLE = 4;
47
+ const EXIT_BAD_INPUT = 64;
48
+
49
+ const LOG_PATH = path.join(os.homedir(), ".claude", "gsd-t-install-repairs.jsonl");
50
+
51
+ /**
52
+ * Where the installed package lives — the source every repair copies from.
53
+ *
54
+ * Two places, asked in order, because they answer DIFFERENT questions: "am I
55
+ * running from inside the package?" and "where does npm keep global installs?".
56
+ * That is not hunting for a file; it is two distinct facts about this machine.
57
+ *
58
+ * Throws with what went wrong. It never returns nothing as if that were an
59
+ * ordinary answer — without the package there is nothing to repair from, and
60
+ * the caller must stop.
61
+ */
62
+ function findPackageRoot() {
63
+ const problems = [];
64
+
65
+ // 1. Running from inside the package itself.
66
+ const here = path.resolve(__dirname, "..");
67
+ const herePkg = path.join(here, "package.json");
68
+ if (fs.existsSync(herePkg)) {
69
+ let pkg;
70
+ try {
71
+ pkg = JSON.parse(fs.readFileSync(herePkg, "utf8"));
72
+ } catch (e) {
73
+ problems.push(`${herePkg} could not be read: ${e.message}`);
74
+ }
75
+ if (pkg && pkg.name === "@tekyzinc/gsd-t") return here;
76
+ }
77
+
78
+ // 2. Ask npm where global installs live.
79
+ let root;
80
+ try {
81
+ root = execFileSync("npm", ["root", "-g"], { encoding: "utf8", timeout: 10000 }).trim();
82
+ } catch (e) {
83
+ problems.push(`npm could not be asked where global packages live: ${e.message}`);
84
+ }
85
+ if (root) {
86
+ const p = path.join(root, "@tekyzinc", "gsd-t");
87
+ if (fs.existsSync(path.join(p, "package.json"))) return p;
88
+ problems.push(`GSD-T is not installed at ${p}`);
89
+ }
90
+
91
+ throw new Error(
92
+ "The installed GSD-T package could not be located, so nothing can be checked " +
93
+ "or repaired.\n " + problems.join("\n ")
94
+ );
95
+ }
96
+
97
+ /** The list of tools every project is supposed to have. Read from the installer. */
98
+ function expectedTools(pkgRoot) {
99
+ const installer = path.join(pkgRoot, "bin", "gsd-t.js");
100
+ const src = fs.readFileSync(installer, "utf8");
101
+ const m = src.match(/const PROJECT_BIN_TOOLS = \[([^]*?)\n\];/);
102
+ if (!m) throw new Error(`could not read the tool list from ${installer}`);
103
+ return [...m[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]);
104
+ }
105
+
106
+ function packageVersion(pkgRoot) {
107
+ try {
108
+ return JSON.parse(fs.readFileSync(path.join(pkgRoot, "package.json"), "utf8")).version || "unknown";
109
+ } catch (_) {
110
+ return "unknown";
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Compare a project's bin/ against the expected list.
116
+ * Returns { missing, stale, present } — stale means the file exists but its
117
+ * content differs from the package's copy.
118
+ */
119
+ function inspect(projectDir, pkgRoot, tools) {
120
+ const binDir = path.join(projectDir, "bin");
121
+ const missing = [];
122
+ const stale = [];
123
+ let present = 0;
124
+
125
+ for (const tool of tools) {
126
+ const src = path.join(pkgRoot, "bin", tool);
127
+ const dest = path.join(binDir, tool);
128
+ if (!fs.existsSync(src)) {
129
+ // The package itself is missing a tool it promises. That is an installer
130
+ // defect, not a project one — recorded separately, never skipped quietly.
131
+ missing.push({ tool, reason: "absent from the installed package" });
132
+ continue;
133
+ }
134
+ if (!fs.existsSync(dest)) {
135
+ missing.push({ tool, reason: "absent from the project" });
136
+ continue;
137
+ }
138
+ try {
139
+ if (fs.readFileSync(src, "utf8") !== fs.readFileSync(dest, "utf8")) {
140
+ stale.push({ tool, reason: "differs from the installed package" });
141
+ } else {
142
+ present++;
143
+ }
144
+ } catch (e) {
145
+ missing.push({ tool, reason: `could not be compared: ${e.message}` });
146
+ }
147
+ }
148
+ return { missing, stale, present };
149
+ }
150
+
151
+ /** Copy one tool from the package into the project. Throws if it cannot. */
152
+ function repairOne(projectDir, pkgRoot, tool) {
153
+ const src = path.join(pkgRoot, "bin", tool);
154
+ const dest = path.join(projectDir, "bin", tool);
155
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
156
+ fs.copyFileSync(src, dest);
157
+ try { fs.chmodSync(dest, 0o755); } catch (_) { /* permissions are advisory here */ }
158
+ if (!fs.existsSync(dest)) throw new Error(`copy reported success but ${dest} is not there`);
159
+ }
160
+
161
+ /** Append a record of what was repaired, so a cross-project pattern is visible. */
162
+ function recordRepair(entry) {
163
+ try {
164
+ fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
165
+ fs.appendFileSync(LOG_PATH, JSON.stringify(entry) + "\n");
166
+ return true;
167
+ } catch (e) {
168
+ // The log could not be written. Say so out loud — a repair nobody can see
169
+ // is how this problem stayed invisible for weeks in the first place.
170
+ process.stderr.write(`[gsd-t] could not record the repair in ${LOG_PATH}: ${e.message}\n`);
171
+ return false;
172
+ }
173
+ }
174
+
175
+ /** Read the shared log and summarise which tools keep going missing. */
176
+ function readLog() {
177
+ if (!fs.existsSync(LOG_PATH)) return { entries: [], byTool: {}, byProject: {} };
178
+ const entries = [];
179
+ for (const line of fs.readFileSync(LOG_PATH, "utf8").split("\n")) {
180
+ if (!line.trim()) continue;
181
+ try { entries.push(JSON.parse(line)); } catch (_) { /* one bad line does not hide the rest */ }
182
+ }
183
+ const byTool = {};
184
+ const byProject = {};
185
+ for (const e of entries) {
186
+ for (const t of e.repaired || []) byTool[t] = (byTool[t] || 0) + 1;
187
+ byProject[e.project] = (byProject[e.project] || 0) + 1;
188
+ }
189
+ return { entries, byTool, byProject };
190
+ }
191
+
192
+ /**
193
+ * Which tools have gone missing in MORE THAN ONE project? That points at the
194
+ * installer, not at any single project.
195
+ */
196
+ function installerSuspects(byTool, entries) {
197
+ const projectsPerTool = {};
198
+ for (const e of entries) {
199
+ for (const t of e.repaired || []) {
200
+ (projectsPerTool[t] = projectsPerTool[t] || new Set()).add(e.project);
201
+ }
202
+ }
203
+ return Object.entries(projectsPerTool)
204
+ .filter(([, set]) => set.size > 1)
205
+ .map(([tool, set]) => ({ tool, projects: [...set] }))
206
+ .sort((a, b) => b.projects.length - a.projects.length);
207
+ }
208
+
209
+ function parseArgs(argv) {
210
+ const args = {};
211
+ for (let i = 2; i < argv.length; i++) {
212
+ const a = argv[i];
213
+ if (a === "--check") args.checkOnly = true;
214
+ else if (a === "--report") args.report = true;
215
+ else if (a === "--json") args.json = true;
216
+ else if (a === "--project") args.project = argv[++i];
217
+ }
218
+ return args;
219
+ }
220
+
221
+ function main() {
222
+ const args = parseArgs(process.argv);
223
+
224
+ if (args.report) {
225
+ const { entries, byTool, byProject } = readLog();
226
+ const suspects = installerSuspects(byTool, entries);
227
+ const out = { ok: true, exitCode: EXIT_OK, repairs: entries.length, byTool, byProject, installerSuspects: suspects };
228
+ if (args.json) process.stdout.write(JSON.stringify(out, null, 2) + "\n");
229
+ else if (!entries.length) process.stdout.write("No installs have needed repair.\n");
230
+ else {
231
+ process.stdout.write(`${entries.length} repair(s) recorded.\n\n`);
232
+ for (const [p, n] of Object.entries(byProject)) process.stdout.write(` ${p}: ${n}\n`);
233
+ if (suspects.length) {
234
+ process.stdout.write("\nThese tools went missing in more than one project — the installer is the likely cause:\n");
235
+ for (const s of suspects) process.stdout.write(` ${s.tool} (${s.projects.length} projects)\n`);
236
+ }
237
+ }
238
+ process.exit(EXIT_OK);
239
+ }
240
+
241
+ const projectDir = path.resolve(args.project || process.cwd());
242
+ if (!fs.existsSync(path.join(projectDir, ".gsd-t"))) {
243
+ // Not a GSD-T project. Nothing to check — this is "not applicable", which
244
+ // is a different thing from "checked and fine".
245
+ process.exit(EXIT_OK);
246
+ }
247
+
248
+ let pkgRoot;
249
+ try {
250
+ pkgRoot = findPackageRoot();
251
+ } catch (e) {
252
+ if (args.json) process.stdout.write(JSON.stringify({ ok: false, exitCode: EXIT_UNREPAIRABLE, halt: e.message }, null, 2) + "\n");
253
+ else process.stderr.write(`[gsd-t] ${e.message}\n`);
254
+ process.exit(EXIT_UNREPAIRABLE);
255
+ }
256
+
257
+ let tools;
258
+ try {
259
+ tools = expectedTools(pkgRoot);
260
+ } catch (e) {
261
+ const msg = `Cannot read the list of tools a project should have: ${e.message}`;
262
+ if (args.json) process.stdout.write(JSON.stringify({ ok: false, exitCode: EXIT_UNREPAIRABLE, halt: msg }, null, 2) + "\n");
263
+ else process.stderr.write(`[gsd-t] ${msg}\n`);
264
+ process.exit(EXIT_UNREPAIRABLE);
265
+ }
266
+
267
+ const found = inspect(projectDir, pkgRoot, tools);
268
+ const needsWork = [...found.missing, ...found.stale];
269
+
270
+ if (!needsWork.length) {
271
+ const out = { ok: true, exitCode: EXIT_OK, expected: tools.length, present: found.present, repaired: [] };
272
+ if (args.json) process.stdout.write(JSON.stringify(out, null, 2) + "\n");
273
+ process.exit(EXIT_OK);
274
+ }
275
+
276
+ if (args.checkOnly) {
277
+ const out = {
278
+ ok: false, exitCode: EXIT_UNREPAIRABLE, expected: tools.length,
279
+ present: found.present, missing: found.missing, stale: found.stale,
280
+ };
281
+ if (args.json) process.stdout.write(JSON.stringify(out, null, 2) + "\n");
282
+ else process.stdout.write(`${needsWork.length} of ${tools.length} tools need attention in ${projectDir}\n`);
283
+ process.exit(EXIT_UNREPAIRABLE);
284
+ }
285
+
286
+ // Repair.
287
+ const repaired = [];
288
+ const unrepairable = [];
289
+ for (const item of needsWork) {
290
+ if (item.reason === "absent from the installed package") {
291
+ unrepairable.push(item);
292
+ continue;
293
+ }
294
+ try {
295
+ repairOne(projectDir, pkgRoot, item.tool);
296
+ repaired.push(item.tool);
297
+ } catch (e) {
298
+ unrepairable.push({ tool: item.tool, reason: e.message });
299
+ }
300
+ }
301
+
302
+ const projectName = path.basename(projectDir);
303
+ if (repaired.length) {
304
+ recordRepair({
305
+ at: new Date().toISOString(),
306
+ project: projectName,
307
+ projectDir,
308
+ packageVersion: packageVersion(pkgRoot),
309
+ expected: tools.length,
310
+ wasPresent: found.present,
311
+ repaired,
312
+ unrepairable: unrepairable.map((u) => u.tool),
313
+ });
314
+ }
315
+
316
+ const out = {
317
+ ok: unrepairable.length === 0,
318
+ exitCode: unrepairable.length === 0 ? EXIT_OK : EXIT_UNREPAIRABLE,
319
+ project: projectName,
320
+ expected: tools.length,
321
+ repaired,
322
+ unrepairable,
323
+ };
324
+
325
+ if (args.json) {
326
+ process.stdout.write(JSON.stringify(out, null, 2) + "\n");
327
+ } else {
328
+ if (repaired.length) {
329
+ process.stdout.write(`[gsd-t] Repaired this project's install: restored ${repaired.length} missing tool(s).\n`);
330
+ }
331
+ if (unrepairable.length) {
332
+ process.stdout.write(`[gsd-t] ${unrepairable.length} tool(s) could NOT be restored:\n`);
333
+ for (const u of unrepairable) process.stdout.write(` ${u.tool} — ${u.reason}\n`);
334
+ process.stdout.write(`Work should not continue on a broken install. Reinstall GSD-T.\n`);
335
+ }
336
+ }
337
+ process.exit(out.exitCode);
338
+ }
339
+
340
+ if (require.main === module) main();
341
+
342
+ module.exports = { inspect, expectedTools, findPackageRoot, readLog, installerSuspects, LOG_PATH };
@@ -311,6 +311,8 @@ function _detectDefaultTrack2(projectDir, notes) {
311
311
 
312
312
  plan.push({ id: 'schema-id', cmd: 'node', args: [path.join(__dirname, 'gsd-t-schema-id-check.cjs'), '--project', projectDir], timeoutMs: 30000 }); // integer-identity PK on every NEW relational table, FAIL-CLOSED (no-op PASS when no new schema files)
313
313
 
314
+ plan.push({ id: 'fallbacks', cmd: 'node', args: [path.join(__dirname, 'gsd-t-fallback-detect.cjs'), '--scan', '--project', projectDir, '--json'], timeoutMs: 120000 }); // M106: no unapproved continue-after-failure branch, FAIL-CLOSED (pre-existing ones excluded via .gsd-t/fallbacks-baseline.json)
315
+
314
316
  // secrets — gitleaks (PATH detection deferred to runtime)
315
317
  if (_hasOnPath('gitleaks')) {
316
318
  plan.push({
package/bin/gsd-t.js CHANGED
@@ -505,6 +505,31 @@ const WORKTREE_HOOK_MARKER = "gsd-t-worktree-guard";
505
505
  const WORKTREE_HOOK_COMMAND =
506
506
  'bash -c \'[ -f "$(npm root -g)/@tekyzinc/gsd-t/scripts/gsd-t-worktree-guard.js" ] && node "$(npm root -g)/@tekyzinc/gsd-t/scripts/gsd-t-worktree-guard.js" || true\'';
507
507
 
508
+ // ─── M106 Fallback guard (PreToolUse Write|Edit) ────────────────────────────
509
+ // Denies a write that introduces a fallback the user never approved. NO `|| true`
510
+ // here, unlike the worktree guard: if this script is missing, the shell must fail
511
+ // loudly rather than let unchecked writes through. Swallowing its absence is the
512
+ // exact pattern the guard exists to stop.
513
+ const FALLBACK_HOOK_MARKER = "gsd-t-fallback-guard";
514
+ const FALLBACK_HOOK_COMMAND =
515
+ 'node "$(npm root -g)/@tekyzinc/gsd-t/scripts/gsd-t-fallback-guard.js"';
516
+
517
+ // ─── M108 Install self-heal (SessionStart) ──────────────────────────────────
518
+ // Checks the project's tools before any work starts, restores what is missing,
519
+ // and reports what it could not fix. Not a fallback — it repairs the failure
520
+ // and then the session runs correctly, rather than routing around it.
521
+ const INSTALL_HEAL_MARKER = "gsd-t-install-heal";
522
+ const INSTALL_HEAL_COMMAND =
523
+ 'bash -c \'[ -f "$(npm root -g)/@tekyzinc/gsd-t/scripts/gsd-t-install-heal.js" ] && node "$(npm root -g)/@tekyzinc/gsd-t/scripts/gsd-t-install-heal.js" || true\'';
524
+
525
+ // ─── M107 Concise hook (Stop) ───────────────────────────────────────────────
526
+ // Sends a long reply to a fresh Claude for a shorter rewrite. Fail-open by
527
+ // design (`|| true`): it governs how a reply READS, never whether it is true,
528
+ // so a broken rewriter must never gag a correct answer.
529
+ const CONCISE_HOOK_MARKER = "gsd-t-concise-hook";
530
+ const CONCISE_HOOK_COMMAND =
531
+ 'bash -c \'[ -f "$(npm root -g)/@tekyzinc/gsd-t/scripts/gsd-t-concise-hook.js" ] && node "$(npm root -g)/@tekyzinc/gsd-t/scripts/gsd-t-concise-hook.js" || true\'';
532
+
508
533
  // Append entries to {projectDir}/.gitignore. Each entry added only if absent.
509
534
  // Idempotent. Returns true if any entries were added, false otherwise.
510
535
  function ensureGitignoreEntries(projectDir, entries) {
@@ -963,6 +988,76 @@ function configureWorktreeGuardHook(settingsPath) {
963
988
  return configureWriteEditHook(settingsPath, WORKTREE_HOOK_MARKER, WORKTREE_HOOK_COMMAND, "worktree guard");
964
989
  }
965
990
 
991
+ // M106 — register the fallback guard on Write|Edit. Same registrar again.
992
+ function configureFallbackGuardHook(settingsPath) {
993
+ return configureWriteEditHook(settingsPath, FALLBACK_HOOK_MARKER, FALLBACK_HOOK_COMMAND, "fallback guard");
994
+ }
995
+
996
+ // M107 — register the concise rewriter on Stop. Not a Write|Edit hook, so it
997
+ // uses the Stop registrar below rather than configureWriteEditHook.
998
+ function configureConciseHook(settingsPath) {
999
+ return configureStopHook(settingsPath, CONCISE_HOOK_MARKER, CONCISE_HOOK_COMMAND, "concise rewrite");
1000
+ }
1001
+
1002
+ // M108 — register the install self-heal on SessionStart, so a broken install is
1003
+ // repaired before any work begins.
1004
+ function configureInstallHealHook(settingsPath) {
1005
+ return configureEventHook(settingsPath, "SessionStart", INSTALL_HEAL_MARKER, INSTALL_HEAL_COMMAND, "install self-heal");
1006
+ }
1007
+
1008
+ // Register a Stop hook by marker. Mirrors configureWriteEditHook exactly, on the
1009
+ // Stop event instead of PreToolUse.
1010
+ function configureStopHook(settingsPath, marker, command, label) {
1011
+ return configureEventHook(settingsPath, "Stop", marker, command, label);
1012
+ }
1013
+
1014
+ // Register a hook on any event by marker. One registrar for every event, so a
1015
+ // new hook needs no new copy of this logic.
1016
+ function configureEventHook(settingsPath, event, marker, command, label) {
1017
+ const targetPath = settingsPath || SETTINGS_JSON;
1018
+ let settings = {};
1019
+ if (fs.existsSync(targetPath)) {
1020
+ try {
1021
+ settings = JSON.parse(fs.readFileSync(targetPath, "utf8"));
1022
+ if (!settings || typeof settings !== "object") settings = {};
1023
+ } catch {
1024
+ warn(`settings.json has invalid JSON — cannot configure ${label} hook`);
1025
+ return { installed: false, action: "noop" };
1026
+ }
1027
+ }
1028
+ if (!settings.hooks) settings.hooks = {};
1029
+ if (!Array.isArray(settings.hooks[event])) settings.hooks[event] = [];
1030
+
1031
+ let action = "noop";
1032
+ let found = false;
1033
+ for (const entry of settings.hooks[event]) {
1034
+ if (!entry || !Array.isArray(entry.hooks)) continue;
1035
+ for (const h of entry.hooks) {
1036
+ if (h && typeof h.command === "string" && h.command.includes(marker)) {
1037
+ found = true;
1038
+ if (h.command !== command) { h.command = command; action = "refreshed"; }
1039
+ }
1040
+ }
1041
+ }
1042
+ if (!found) {
1043
+ // The install check goes FIRST on SessionStart, so a broken install is
1044
+ // repaired before anything else tries to use the missing tools.
1045
+ const entry = { matcher: "", hooks: [{ type: "command", command }] };
1046
+ if (event === "SessionStart") settings.hooks[event].unshift(entry);
1047
+ else settings.hooks[event].push(entry);
1048
+ action = "added";
1049
+ }
1050
+ if (action === "noop") return { installed: true, action };
1051
+
1052
+ try {
1053
+ fs.writeFileSync(targetPath, JSON.stringify(settings, null, 2));
1054
+ } catch (e) {
1055
+ warn(`Failed to write settings.json: ${e.message}`);
1056
+ return { installed: false, action: "noop" };
1057
+ }
1058
+ return { installed: true, action };
1059
+ }
1060
+
966
1061
  function configureWriteEditHook(settingsPath, marker, command, label) {
967
1062
  const targetPath = settingsPath || SETTINGS_JSON;
968
1063
  let settings = {};
@@ -1533,10 +1628,16 @@ const UTILITY_SCRIPTS = ["gsd-t-tools.js", "gsd-t-statusline.js", "gsd-t-event-w
1533
1628
 
1534
1629
  function installUtilityScripts() {
1535
1630
  ensureDir(SCRIPTS_DIR);
1631
+ const notDelivered = [];
1536
1632
  for (const script of UTILITY_SCRIPTS) {
1537
1633
  const src = path.join(PKG_SCRIPTS, script);
1538
1634
  const dest = path.join(SCRIPTS_DIR, script);
1539
- if (!fs.existsSync(src)) continue;
1635
+ if (!fs.existsSync(src)) {
1636
+ // Promised but absent from the package. Reported, never skipped quietly —
1637
+ // the same silence let projects run for weeks on a half install.
1638
+ notDelivered.push(script);
1639
+ continue;
1640
+ }
1540
1641
  const srcContent = fs.readFileSync(src, "utf8");
1541
1642
  const destContent = fs.existsSync(dest) ? fs.readFileSync(dest, "utf8") : "";
1542
1643
  if (normalizeEol(srcContent) !== normalizeEol(destContent)) {
@@ -1545,6 +1646,11 @@ function installUtilityScripts() {
1545
1646
  info(`${script} unchanged`);
1546
1647
  }
1547
1648
  }
1649
+ if (notDelivered.length) {
1650
+ warn(`${notDelivered.length} script(s) are named by the installer but absent from the package:`);
1651
+ for (const s of notDelivered) warn(` ${s}`);
1652
+ warn(` Anything depending on these will fail. This is a packaging defect.`);
1653
+ }
1548
1654
  }
1549
1655
 
1550
1656
  // ─── Global Bin Tools (~/.claude/bin/) ───────────────────────────────────────
@@ -1591,6 +1697,18 @@ const GLOBAL_BIN_TOOLS = [
1591
1697
  // caller but absent from BOTH lists is dead in every project
1592
1698
  // ([[project_global_bin_propagation_gap]], 4 prior occurrences).
1593
1699
  "gsd-t-pseudocode-style.cjs",
1700
+ // M106 — Fallback detector. scripts/gsd-t-fallback-guard.js (a PreToolUse hook)
1701
+ // shells out to this by absolute path; if it is missing the guard DENIES the
1702
+ // write rather than allowing it unchecked, so an omission here breaks every
1703
+ // Write/Edit rather than failing silently. Also in PROJECT_BIN_TOOLS below.
1704
+ "gsd-t-fallback-detect.cjs",
1705
+ // M107 — Concise rewriter. scripts/gsd-t-concise-hook.js (a Stop hook) shells
1706
+ // out to this; absent, the hook allows the long reply through (fail-open by
1707
+ // design — it governs readability, never truth).
1708
+ "gsd-t-concise-rewrite.cjs",
1709
+ // M108 — Install self-check, run by the SessionStart hook and by
1710
+ // `gsd-t install-check`.
1711
+ "gsd-t-install-check.cjs",
1594
1712
  ];
1595
1713
 
1596
1714
  function installGlobalBinTools() {
@@ -1838,10 +1956,14 @@ const SHARED_TEMPLATES = [
1838
1956
  function installSharedTemplates() {
1839
1957
  ensureDir(CLAUDE_TEMPLATES_DIR);
1840
1958
  let installed = 0, skipped = 0;
1959
+ const notDelivered = [];
1841
1960
  for (const file of SHARED_TEMPLATES) {
1842
1961
  const src = path.join(PKG_TEMPLATES, file);
1843
1962
  const dest = path.join(CLAUDE_TEMPLATES_DIR, file);
1844
- if (!fs.existsSync(src)) continue;
1963
+ if (!fs.existsSync(src)) {
1964
+ notDelivered.push(file); // promised but absent — reported, never silent
1965
+ continue;
1966
+ }
1845
1967
  if (fs.existsSync(dest) &&
1846
1968
  normalizeEol(fs.readFileSync(src, "utf8")) === normalizeEol(fs.readFileSync(dest, "utf8"))) {
1847
1969
  skipped++;
@@ -1852,6 +1974,10 @@ function installSharedTemplates() {
1852
1974
  }
1853
1975
  if (skipped > 0) info(`${skipped} templates unchanged`);
1854
1976
  success(`${installed + skipped} shared templates → ~/.claude/templates/`);
1977
+ if (notDelivered.length) {
1978
+ warn(`${notDelivered.length} template(s) are named by the installer but absent from the package:`);
1979
+ for (const f of notDelivered) warn(` ${f}`);
1980
+ }
1855
1981
  }
1856
1982
 
1857
1983
  function installCommands(isUpdate) {
@@ -2035,6 +2161,27 @@ async function doInstall(opts = {}) {
2035
2161
  else info("Worktree-collision guard already configured");
2036
2162
  }
2037
2163
 
2164
+ const fbHook = configureFallbackGuardHook(SETTINGS_JSON);
2165
+ if (fbHook.installed) {
2166
+ if (fbHook.action === "added") success("Fallback guard added (denies a write that adds an unapproved fallback — M106)");
2167
+ else if (fbHook.action === "refreshed") success("Fallback guard refreshed");
2168
+ else info("Fallback guard already configured");
2169
+ }
2170
+
2171
+ const ccHook = configureConciseHook(SETTINGS_JSON);
2172
+ if (ccHook.installed) {
2173
+ if (ccHook.action === "added") success("Concise-rewrite hook added (shortens a long reply before you read it — M107)");
2174
+ else if (ccHook.action === "refreshed") success("Concise-rewrite hook refreshed");
2175
+ else info("Concise-rewrite hook already configured");
2176
+ }
2177
+
2178
+ const healHook = configureInstallHealHook(SETTINGS_JSON);
2179
+ if (healHook.installed) {
2180
+ if (healHook.action === "added") success("Install self-heal added (restores missing tools before work starts — M108)");
2181
+ else if (healHook.action === "refreshed") success("Install self-heal refreshed");
2182
+ else info("Install self-heal already configured");
2183
+ }
2184
+
2038
2185
  heading("Graph Engine (CGC)");
2039
2186
  installCgc();
2040
2187
 
@@ -2993,6 +3140,16 @@ const PROJECT_BIN_TOOLS = [
2993
3140
  "gsd-t-model-tier-policy.cjs",
2994
3141
  // M86 — Model-profile config + resolver CLI (standard/pro/premium tier-spend switch).
2995
3142
  "gsd-t-model-profile.cjs",
3143
+ // M106 — Fallback detector. The PreToolUse guard prefers the project-local
3144
+ // copy; the verify gate dispatches to it by absolute path. Missing here means
3145
+ // every Write/Edit is DENIED (the guard halts rather than allowing unchecked),
3146
+ // so this entry is load-bearing — [[project_global_bin_propagation_gap]].
3147
+ "gsd-t-fallback-detect.cjs",
3148
+ // M107 — Concise rewriter, invoked by the Stop hook.
3149
+ "gsd-t-concise-rewrite.cjs",
3150
+ // M108 — Install self-check. Every project carries its own copy so it can
3151
+ // verify and repair itself even when the global install is what broke.
3152
+ "gsd-t-install-check.cjs",
2996
3153
  // M89 — Auto-research gate classifier (classify a guessed claim as internal vs external;
2997
3154
  // propagated to each registered project's bin/ so the workflow runCli fallback resolves
2998
3155
  // downstream — per [[project_global_bin_propagation_gap]]).
@@ -3157,10 +3314,20 @@ function copyBinToolsToProject(projectDir, projectName) {
3157
3314
  }
3158
3315
  }
3159
3316
  let copied = 0;
3317
+ // Tools this pass could NOT deliver. Every one is reported. A silent skip
3318
+ // here is how binvoice ran for weeks with 20 of 38 tools while every update
3319
+ // reported success — the missing files were never counted, so nothing looked
3320
+ // wrong. ([[project_global_bin_propagation_gap]], now 5 occurrences.)
3321
+ const notDelivered = [];
3160
3322
  for (const tool of PROJECT_BIN_TOOLS) {
3161
3323
  const src = path.join(PKG_ROOT, "bin", tool);
3162
3324
  const dest = path.join(projectBinDir, tool);
3163
- if (!fs.existsSync(src)) continue;
3325
+ if (!fs.existsSync(src)) {
3326
+ // The package promises a tool it does not contain. That is a packaging
3327
+ // defect and it affects EVERY project — it must never pass unremarked.
3328
+ notDelivered.push({ tool, why: "absent from the package being installed" });
3329
+ continue;
3330
+ }
3164
3331
  let needsCopy = true;
3165
3332
  if (fs.existsSync(dest)) {
3166
3333
  try {
@@ -3168,7 +3335,7 @@ function copyBinToolsToProject(projectDir, projectName) {
3168
3335
  const destContent = fs.readFileSync(dest, "utf8");
3169
3336
  if (srcContent === destContent) needsCopy = false;
3170
3337
  } catch {
3171
- // fall through, will copy
3338
+ // could not compare — copy, which is the safe direction
3172
3339
  }
3173
3340
  }
3174
3341
  if (needsCopy) {
@@ -3177,10 +3344,17 @@ function copyBinToolsToProject(projectDir, projectName) {
3177
3344
  try { fs.chmodSync(dest, 0o755); } catch {}
3178
3345
  copied++;
3179
3346
  } catch (e) {
3180
- warn(`${projectName} — failed to copy ${tool}: ${e.message}`);
3347
+ notDelivered.push({ tool, why: e.message });
3181
3348
  }
3182
3349
  }
3183
3350
  }
3351
+ if (notDelivered.length) {
3352
+ warn(`${projectName} — ${notDelivered.length} of ${PROJECT_BIN_TOOLS.length} tools were NOT delivered:`);
3353
+ for (const n of notDelivered) warn(` ${n.tool} — ${n.why}`);
3354
+ warn(` This project's install is incomplete. Tools that depend on the missing`);
3355
+ warn(` files will fail. Run 'gsd-t install-check --project ${projectDir}' after`);
3356
+ warn(` fixing the package.`);
3357
+ }
3184
3358
  let cleaned = 0;
3185
3359
  if (!_isGsdTSourcePackage(projectDir)) {
3186
3360
  for (const stray of DEPRECATED_BIN_STRAYS) {
@@ -5369,6 +5543,20 @@ if (require.main === module) {
5369
5543
  });
5370
5544
  process.exit(res.status == null ? 1 : res.status);
5371
5545
  }
5546
+ case "install-check": {
5547
+ // M108 — check this project's tools against the installed package, and
5548
+ // restore anything missing. `--check` reports without changing anything;
5549
+ // `--report` reads the shared log of every repair across all projects.
5550
+ const { spawnSync } = require("child_process");
5551
+ const js = path.join(__dirname, "gsd-t-install-check.cjs");
5552
+ if (!require("node:fs").existsSync(js)) {
5553
+ error(`gsd-t-install-check.cjs not found at ${js} — reinstall GSD-T`);
5554
+ process.exit(1);
5555
+ }
5556
+ const res = spawnSync(process.execPath, [js, ...args.slice(1)], { stdio: "inherit" });
5557
+ process.exit(res.status == null ? 1 : res.status);
5558
+ }
5559
+
5372
5560
  case "pseudocode-style": {
5373
5561
  // Contract v1.2.0 §1.1 — `gsd-t pseudocode-style (--doc <f> | --dir <d>)` thin
5374
5562
  // dispatcher to the PseudoCode flow-line STYLE gate. Sibling of `guard-map`: