@tekyzinc/gsd-t 5.11.13 → 5.11.15

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,44 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [5.11.15] - 2026-08-09
6
+
7
+ ### Removed — the reply shortener (M107) is retired
8
+
9
+ It shortened a reply *after* it was written, and a Stop hook cannot unsay what
10
+ is already on screen: the long version was read, then the short one. It cost a
11
+ whole extra turn, and its instruction was misread often enough to print a third
12
+ copy. Every fix landed and the thing still was not worth its cost.
13
+
14
+ The Reader Contract, injected before every turn, does the same job for free and
15
+ in the only place it can work — before the words are written.
16
+
17
+ **Installing now REMOVES the hook** from machines that already have it. A
18
+ retired feature still running on 32 machines is not retired, and the installer
19
+ is the only thing that reaches them. Other hooks are untouched.
20
+
21
+ - deleted: `scripts/gsd-t-concise-hook.js`, `bin/gsd-t-concise-rewrite.cjs`
22
+ - `bin/gsd-t.js`: `removeConciseHook` replaces the registrar; the marker stays, because removal needs it to find the hook
23
+ - both propagation lists no longer carry the rewriter
24
+ - `test/m112-concise-retired.test.js`: 6 tests, including that install removes it and never re-adds it
25
+
26
+ ## [5.11.14] - 2026-08-09
27
+
28
+ ### Fixed — the shortened reply appeared as a third copy instead of the only one
29
+
30
+ The hook told the model to "replace it with this shorter version". A model
31
+ cannot replace a reply already sent — it can only write another. Read as an
32
+ instruction to restate, it re-emitted the LONG original, so the reader saw the
33
+ full reply, then the short one, then the full one again.
34
+
35
+ The instruction now describes the only action available: your entire next
36
+ message is this text, copied exactly, nothing before or after, and do not repeat
37
+ any part of the previous reply. Stated in words rather than wrapper markers,
38
+ which the model copies through.
39
+
40
+ - `scripts/gsd-t-concise-hook.js`: the block instruction
41
+ - `test/m107-concise-rewrite.test.js`: 3 tests — no "replace", the repeat is forbidden outright, and no markers that could land in the reply
42
+
5
43
  ## [5.11.13] - 2026-08-09
6
44
 
7
45
  ### Fixed — the reply shortener discarded 4 of every 6 rewrites
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v5.11.13** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v5.11.15** - 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.
package/bin/gsd-t.js CHANGED
@@ -523,13 +523,11 @@ const INSTALL_HEAL_MARKER = "gsd-t-install-heal";
523
523
  const INSTALL_HEAL_COMMAND =
524
524
  '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\'';
525
525
 
526
- // ─── M107 Concise hook (Stop) ───────────────────────────────────────────────
527
- // Sends a long reply to a fresh Claude for a shorter rewrite. Fail-open by
528
- // design (`|| true`): it governs how a reply READS, never whether it is true,
529
- // so a broken rewriter must never gag a correct answer.
526
+ // ─── M107 Concise hook RETIRED in v5.11.15 ────────────────────────────────
527
+ // The marker survives its removal: installing DELETES this hook from a machine
528
+ // that still has it, and a marker is how it is found. Deleting the name would
529
+ // leave the hook running everywhere it was already installed.
530
530
  const CONCISE_HOOK_MARKER = "gsd-t-concise-hook";
531
- const CONCISE_HOOK_COMMAND =
532
- '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\'';
533
531
 
534
532
  // Append entries to {projectDir}/.gitignore. Each entry added only if absent.
535
533
  // Idempotent. Returns true if any entries were added, false otherwise.
@@ -994,10 +992,18 @@ function configureFallbackGuardHook(settingsPath) {
994
992
  return configureWriteEditHook(settingsPath, FALLBACK_HOOK_MARKER, FALLBACK_HOOK_COMMAND, "fallback guard");
995
993
  }
996
994
 
997
- // M107 register the concise rewriter on Stop. Not a Write|Edit hook, so it
998
- // uses the Stop registrar below rather than configureWriteEditHook.
999
- function configureConciseHook(settingsPath) {
1000
- return configureStopHook(settingsPath, CONCISE_HOOK_MARKER, CONCISE_HOOK_COMMAND, "concise rewrite");
995
+ // M107 RETIRED (v5.11.15). The rewriter shortened a reply after it was written,
996
+ // and a Stop hook cannot unsay what is already on screen — so David read the
997
+ // long version, then the short one. It also cost a whole extra turn, and its
998
+ // own instruction was misread often enough to print a THIRD copy. The Reader
999
+ // Contract, injected before every turn, does the same job for free and in the
1000
+ // only place it can work: before the words are written.
1001
+ //
1002
+ // Installing now REMOVES the hook rather than adding it. A retired feature that
1003
+ // keeps running on 32 machines is not retired, and only the installer can reach
1004
+ // them.
1005
+ function removeConciseHook(settingsPath) {
1006
+ return removeStopHook(settingsPath, CONCISE_HOOK_MARKER);
1001
1007
  }
1002
1008
 
1003
1009
  // M108 — register the install self-heal on SessionStart, so a broken install is
@@ -1013,6 +1019,36 @@ function configureStopHook(settingsPath, marker, command, label) {
1013
1019
  return configureEventHook(settingsPath, "Stop", marker, command, label);
1014
1020
  }
1015
1021
 
1022
+ /**
1023
+ * Delete a Stop hook by marker, wherever it is registered.
1024
+ *
1025
+ * Retiring a hook in the package is not enough — it keeps firing on every
1026
+ * machine that already installed it, and the installer is the only thing that
1027
+ * reaches those machines. An unreadable or malformed settings file STOPS the
1028
+ * install: leaving a retired hook running while reporting success is the same
1029
+ * silent staleness that left eleven dead commands typeable for months.
1030
+ */
1031
+ function removeStopHook(settingsPath, marker) {
1032
+ if (!fs.existsSync(settingsPath)) return { removed: 0 };
1033
+
1034
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
1035
+ const groups = settings.hooks && settings.hooks.Stop;
1036
+ if (!Array.isArray(groups)) return { removed: 0 };
1037
+
1038
+ let removed = 0;
1039
+ for (const group of groups) {
1040
+ if (!Array.isArray(group.hooks)) continue;
1041
+ const before = group.hooks.length;
1042
+ group.hooks = group.hooks.filter((h) => !String(h && h.command || "").includes(marker));
1043
+ removed += before - group.hooks.length;
1044
+ }
1045
+ if (removed === 0) return { removed: 0 };
1046
+
1047
+ settings.hooks.Stop = groups.filter((g) => Array.isArray(g.hooks) && g.hooks.length > 0);
1048
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
1049
+ return { removed };
1050
+ }
1051
+
1016
1052
  // Register a hook on any event by marker. One registrar for every event, so a
1017
1053
  // new hook needs no new copy of this logic.
1018
1054
  function configureEventHook(settingsPath, event, marker, command, label) {
@@ -1761,10 +1797,6 @@ const GLOBAL_BIN_TOOLS = [
1761
1797
  // write rather than allowing it unchecked, so an omission here breaks every
1762
1798
  // Write/Edit rather than failing silently. Also in PROJECT_BIN_TOOLS below.
1763
1799
  "gsd-t-fallback-detect.cjs",
1764
- // M107 — Concise rewriter. scripts/gsd-t-concise-hook.js (a Stop hook) shells
1765
- // out to this; absent, the hook allows the long reply through (fail-open by
1766
- // design — it governs readability, never truth).
1767
- "gsd-t-concise-rewrite.cjs",
1768
1800
  // M108 — Install self-check, run by the SessionStart hook and by
1769
1801
  // `gsd-t install-check`.
1770
1802
  "gsd-t-install-check.cjs",
@@ -2301,12 +2333,8 @@ async function doInstall(opts = {}) {
2301
2333
  }
2302
2334
 
2303
2335
 
2304
- const ccHook = configureConciseHook(SETTINGS_JSON);
2305
- if (ccHook.installed) {
2306
- if (ccHook.action === "added") success("Concise-rewrite hook added (shortens a long reply before you read it — M107)");
2307
- else if (ccHook.action === "refreshed") success("Concise-rewrite hook refreshed");
2308
- else info("Concise-rewrite hook already configured");
2309
- }
2336
+ const ccHook = removeConciseHook(SETTINGS_JSON);
2337
+ if (ccHook.removed) success("Concise-rewrite hook removed — retired in v5.11.15");
2310
2338
 
2311
2339
  const healHook = configureInstallHealHook(SETTINGS_JSON);
2312
2340
  if (healHook.installed) {
@@ -3279,7 +3307,6 @@ const PROJECT_BIN_TOOLS = [
3279
3307
  // so this entry is load-bearing — [[project_global_bin_propagation_gap]].
3280
3308
  "gsd-t-fallback-detect.cjs",
3281
3309
  // M107 — Concise rewriter, invoked by the Stop hook.
3282
- "gsd-t-concise-rewrite.cjs",
3283
3310
  // M108 — Install self-check. Every project carries its own copy so it can
3284
3311
  // verify and repair itself even when the global install is what broke.
3285
3312
  "gsd-t-install-check.cjs",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "5.11.13",
3
+ "version": "5.11.15",
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",
@@ -1,276 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * gsd-t-concise-rewrite.cjs
4
- *
5
- * M107-D1 — Rewrites a reply into its shortest honest form.
6
- *
7
- * [RULE] concise-rewrite-never-changes-a-fact
8
- * [RULE] concise-rewrite-halts-never-silently-degrades
9
- * [RULE] concise-rewrite-preserves-questions-and-warnings
10
- *
11
- * A fresh Claude, given only the text and the rules, cuts what the writer is
12
- * attached to. It has no memory of the work, so it has nothing to defend.
13
- *
14
- * ─── Usage ──────────────────────────────────────────────────────────────────
15
- * node gsd-t-concise-rewrite.cjs --text "<reply>" [--project <dir>] [--json]
16
- * echo "<reply>" | node gsd-t-concise-rewrite.cjs --stdin
17
- *
18
- * ─── Exit codes ─────────────────────────────────────────────────────────────
19
- * 0 rewritten (or skipped because it was already short)
20
- * 4 the rewrite could not be done — the ORIGINAL is returned, marked
21
- * 64 bad input
22
- *
23
- * On failure it returns the ORIGINAL text and says so. It never returns
24
- * nothing, never truncates, never guesses. Showing a long reply is a poor
25
- * outcome; showing a silently mangled one is a wrong one.
26
- *
27
- * Zero dependencies beyond the `claude` CLI on PATH.
28
- */
29
-
30
- "use strict";
31
-
32
- const fs = require("fs");
33
- const path = require("path");
34
- const { spawnSync } = require("child_process");
35
-
36
- const EXIT_OK = 0;
37
- const EXIT_FAILED = 4;
38
- const EXIT_BAD_INPUT = 64;
39
-
40
- const DEFAULTS = {
41
- enabled: true,
42
- skipUnder: 60, // words — below this, rewriting buys nothing
43
- model: "sonnet",
44
- timeoutMs: 45000,
45
- };
46
-
47
- const RULES = `You rewrite a reply so it can be read in as little time as possible.
48
-
49
- The reader is a slow reader. Every extra line costs him real time. Jargon costs
50
- him more than length — he has to stop and translate it, and then ask for it
51
- again in plain words.
52
-
53
- Rewrite the reply below following these rules exactly:
54
-
55
- 1. ANSWER FIRST. The answer is the first thing. Nothing before it.
56
- 2. NO PREAMBLE. Cut any sentence that announces a point instead of making it.
57
- 3. NO BACKSTORY. Cut every explanation of why something failed before, what
58
- cannot work, how it works today, or what was rejected — unless the reply is
59
- answering a direct question about that. He asks when he wants it.
60
- 4. NO JARGON. Plain words. If a technical term is genuinely needed, put the
61
- plain meaning first and the term in brackets after it.
62
- 5. KEEP ONLY WHAT IS RELEVANT TO HIM. Ask of every sentence: does this change
63
- what he decides, what he does next, or what he now knows? If not, cut it.
64
- Work the writer did, steps taken, what was checked, what was ruled out — all
65
- of that is the writer's business, not his, unless he asked.
66
- 6. Prefer a short list or a small table over a paragraph.
67
-
68
- KEEP THESE. They are not optional, and dropping any one of them means the
69
- rewrite is rejected and thrown away:
70
-
71
- - The first line, if it is a dated status banner.
72
- - EVERY question being asked OF THE READER — one he is meant to answer. If the
73
- reply ends by asking him something, that question MUST appear in your rewrite,
74
- as its own line, at the end. This is the single most common way a rewrite is
75
- rejected.
76
- A question the writer asks HIMSELF is not one of these. "Is that the cause?",
77
- "Now the proof: does the hook fire?", "So what is slow here?" are thinking out
78
- loud — cut them like any other narration. The test is simple: would he type an
79
- answer to it? If not, it is not a question.
80
- - Any warning, failure, or thing that went wrong.
81
- - File paths and links, exactly as written.
82
- - Code blocks, exactly as written.
83
- - Specific numbers and names.
84
-
85
- Never:
86
- - change a fact or a number
87
- - drop a warning, a failure, or a question
88
- - add anything that was not in the original
89
- - soften a bad outcome
90
-
91
- Return ONLY the rewritten reply. No commentary about what you changed.`;
92
-
93
- function readConfig(projectDir) {
94
- const p = path.join(projectDir, ".gsd-t", "concise.json");
95
- if (!fs.existsSync(p)) return { ...DEFAULTS };
96
- try {
97
- return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(p, "utf8")) };
98
- } catch (_) {
99
- // Unreadable config → defaults. Rewriting with defaults is the safe
100
- // direction; it cannot lose content, only shorten it.
101
- return { ...DEFAULTS };
102
- }
103
- }
104
-
105
- function wordCount(s) {
106
- return (String(s).trim().match(/\S+/g) || []).length;
107
- }
108
-
109
- /**
110
- * The second pass — the rewrite reviewed before it is delivered.
111
- *
112
- * A keyword check used to sit here: it counted question marks, file paths and
113
- * numbers, and threw the whole rewrite away if a count dropped. It discarded
114
- * 4 of every 6 rewrites, almost always because the writer had asked HIMSELF a
115
- * question ("Now the proof: does it fire?") which the rewrite correctly cut as
116
- * narration. Counting punctuation cannot tell an ask from thinking out loud.
117
- *
118
- * A reader can. So the same model that wrote the short version now reads it
119
- * back against one question — is this what David needs, and is it as short as
120
- * his rules demand — and fixes it. The reviewer returns text, never a verdict,
121
- * so there is no path on which the work is discarded.
122
- */
123
- const REVIEW = `You are checking a shortened reply before it reaches David.
124
-
125
- He is a slow reader. Every extra line costs him real time.
126
-
127
- Two questions, both about the SHORT version:
128
-
129
- 1. Is this ONLY what he needs? Cut anything that does not change what he
130
- decides, what he does next, or what he now knows. The work someone did,
131
- steps taken, what was checked, what was ruled out — his business only if he
132
- asked.
133
-
134
- 2. Is it truly concise by his rules? Answer first, nothing before it. No
135
- preamble, no backstory, no jargon standing in for a plain word. Lists and
136
- small tables over paragraphs.
137
-
138
- Then check nothing was lost that he needs:
139
- - a question he is meant to ANSWER must still be there, as its own line at the
140
- end. A question the writer asked himself is narration — it should be gone.
141
- - warnings, failures, and things that went wrong stay.
142
- - file paths, links, code blocks and specific numbers stay exactly as written.
143
- - no fact, number or name may change, and nothing may be added.
144
-
145
- Return the final reply and nothing else. If it is already right, return it
146
- unchanged. Never return commentary, never return an empty response.`;
147
-
148
- /** One call to a fresh Claude. Used by both passes. */
149
- function askClaude(prompt, cfg) {
150
- // `--setting-sources project` is what stops the shortener shortening itself.
151
- //
152
- // A child started with the personal settings inherits the very Stop hook that
153
- // spawned it: it answers in about 4 seconds, its own hook then sees an answer
154
- // over the 60-word threshold and spawns a THIRD Claude, and the outer call
155
- // waits ~46s for work it caused — past the 45s limit, so it was killed and
156
- // returned nothing, every turn since it shipped. Measured: 54.2s/54.5s with
157
- // the personal settings, 7.9s/6.2s with only the project's.
158
- //
159
- // The trigger was always the CHILD'S OWN REPLY crossing 60 words, never the
160
- // input: a 63-character prompt that produces a long answer is just as slow
161
- // (57.2s on, 11.7s off).
162
- //
163
- // Not `--bare`, the documented skip-hooks flag: it also skips the keychain, so
164
- // the child returns "Not logged in" in 0.7s. Not `--settings '{}'` either —
165
- // settings layers merge, so a lower layer cannot remove a higher layer's hook
166
- // (54.5s/56.1s, unchanged).
167
- const run = spawnSync("claude",
168
- ["-p", prompt, "--model", cfg.model, "--dangerously-skip-permissions",
169
- "--setting-sources", "project"],
170
- { encoding: "utf8", timeout: cfg.timeoutMs, maxBuffer: 8 * 1024 * 1024 });
171
-
172
- if (run.error) {
173
- // A timeout is the failure that hid for two days: every turn paid the full
174
- // wait, produced nothing, and said nothing. Write it to stderr so the cost
175
- // is visible even though the reply still goes through untouched.
176
- const timedOut = run.error.code === "ETIMEDOUT";
177
- if (timedOut) {
178
- process.stderr.write(
179
- `[gsd-t] the concise rewriter timed out after ${Math.round(cfg.timeoutMs / 1000)}s ` +
180
- `and produced nothing — every turn is paying that wait. Switch it off with ` +
181
- `.gsd-t/concise.json {"enabled": false} until it is fixed.\n`
182
- );
183
- }
184
- return { ok: false, error: run.error.message, timedOut };
185
- }
186
- if (run.status !== 0) return { ok: false, error: `claude exited ${run.status}: ${(run.stderr || "").slice(0, 200)}` };
187
- const out = (run.stdout || "").trim();
188
- if (!out) return { ok: false, error: "the rewriter returned nothing" };
189
- return { ok: true, text: out };
190
- }
191
-
192
- /** Pass 1 — shorten it. */
193
- function rewrite(text, cfg) {
194
- return askClaude(`${RULES}\n\n--- REPLY TO REWRITE ---\n${text}`, cfg);
195
- }
196
-
197
- /**
198
- * Pass 2 — read the short version back and fix what pass 1 got wrong.
199
- *
200
- * Returns text, never a verdict, so the work is never discarded. If the review
201
- * itself fails, pass 1's rewrite stands: it was already an improvement, and
202
- * losing it because a second opinion did not arrive would be the old bug in a
203
- * new place.
204
- */
205
- function review(original, shortened, cfg) {
206
- const prompt = `${REVIEW}\n\n--- WHAT HE ORIGINALLY WROTE (for reference only) ---\n${original}\n\n--- THE SHORT VERSION TO CHECK AND RETURN ---\n${shortened}`;
207
- const r = askClaude(prompt, cfg);
208
- return r.ok ? r.text : shortened;
209
- }
210
-
211
- function parseArgs(argv) {
212
- const args = { project: process.cwd() };
213
- for (let i = 2; i < argv.length; i++) {
214
- const a = argv[i];
215
- if (a === "--json") args.json = true;
216
- else if (a === "--stdin") args.stdin = true;
217
- else if (a === "--text") args.text = argv[++i];
218
- else if (a === "--project") args.project = argv[++i];
219
- }
220
- return args;
221
- }
222
-
223
- function emit(result, asJson) {
224
- if (asJson) process.stdout.write(JSON.stringify(result, null, 2) + "\n");
225
- else process.stdout.write(result.text + "\n");
226
- process.exit(result.exitCode);
227
- }
228
-
229
- function main() {
230
- const args = parseArgs(process.argv);
231
- const projectDir = path.resolve(args.project);
232
- const cfg = readConfig(projectDir);
233
-
234
- let text = args.text;
235
- if (args.stdin) {
236
- try { text = fs.readFileSync(0, "utf8"); } catch (_) { text = ""; }
237
- }
238
- if (typeof text !== "string" || !text.trim()) {
239
- emit({ ok: false, exitCode: EXIT_BAD_INPUT, text: "", error: "no text given" }, true);
240
- }
241
-
242
- const before = wordCount(text);
243
-
244
- if (!cfg.enabled) {
245
- emit({ ok: true, exitCode: EXIT_OK, text, skipped: "switched off", words: before }, args.json);
246
- }
247
- if (before < cfg.skipUnder) {
248
- emit({ ok: true, exitCode: EXIT_OK, text, skipped: "already short", words: before }, args.json);
249
- }
250
-
251
- const r = rewrite(text, cfg);
252
- if (!r.ok) {
253
- emit({
254
- ok: false, exitCode: EXIT_FAILED, text,
255
- error: r.error,
256
- note: "Rewrite failed — this is the original, unchanged.",
257
- words: before,
258
- }, args.json);
259
- }
260
-
261
- // Second pass: the same model reads its own short version back, against the
262
- // only two questions that matter — is this what David needs, and is it as
263
- // short as his rules demand. It returns text, so nothing is ever discarded.
264
- const finalText = review(text, r.text, cfg);
265
-
266
- const after = wordCount(finalText);
267
- emit({
268
- ok: true, exitCode: EXIT_OK, text: finalText,
269
- words: before, wordsAfter: after,
270
- saved: before - after,
271
- }, args.json);
272
- }
273
-
274
- if (require.main === module) main();
275
-
276
- module.exports = { wordCount, readConfig, rewrite, review, askClaude, RULES, REVIEW };
@@ -1,255 +0,0 @@
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
- /**
88
- * Did the user actually type this, or is it a tool result wearing their role?
89
- *
90
- * The transcript records a tool's output as a "user" message whose content is
91
- * tool_result blocks. Only a message with real text is the person speaking.
92
- */
93
- function _isRealUserMessage(msg) {
94
- const content = msg.content;
95
- if (typeof content === "string") return content.trim().length > 0;
96
- if (!Array.isArray(content)) return false;
97
- return content.some(
98
- (b) => b && b.type === "text" && typeof b.text === "string" && b.text.trim().length > 0
99
- );
100
- }
101
-
102
- /**
103
- * The prose of the reply that just finished.
104
- *
105
- * A turn does not end with the words the user reads — it ends with whatever ran
106
- * last, and that is usually a tool call. Stopping at the FIRST assistant record
107
- * therefore found `toolOnly` and gave up, every time: the rewriter never ran
108
- * once in the two days after it shipped. So walk back through the tool calls to
109
- * the prose, and join every text block in the turn, since a reply is often
110
- * written in pieces around the tools it uses.
111
- *
112
- * The walk stops at the user's own message. Past that lies the PREVIOUS turn,
113
- * and rewriting a reply the user has already read would replace the wrong thing.
114
- */
115
- function lastAssistantText(transcriptPath) {
116
- const tail = readTail(transcriptPath, 512 * 1024);
117
- if (!tail) return null;
118
- const lines = tail.split("\n").filter(Boolean);
119
-
120
- const pieces = [];
121
- let sawAssistant = false;
122
-
123
- for (let i = lines.length - 1; i >= 0; i--) {
124
- let rec;
125
- try { rec = JSON.parse(lines[i]); } catch (_) { continue; }
126
- const msg = rec && rec.message;
127
- if (!msg) continue;
128
-
129
- // The start of this turn. Anything earlier belongs to a turn already read.
130
- //
131
- // A tool RESULT is also recorded with role "user", so the boundary is a user
132
- // message carrying real text — treating every "user" record as the boundary
133
- // stops at the first tool result and finds nothing at all.
134
- if (msg.role === "user") {
135
- if (_isRealUserMessage(msg)) break;
136
- continue;
137
- }
138
- if (msg.role !== "assistant") continue;
139
-
140
- sawAssistant = true;
141
- const content = Array.isArray(msg.content) ? msg.content : [];
142
- const text = content.filter((b) => b && b.type === "text")
143
- .map((b) => b.text || "").join("\n").trim();
144
- if (text) pieces.unshift(text);
145
- }
146
-
147
- if (!sawAssistant) return null;
148
-
149
- const text = pieces.join("\n\n").trim();
150
- // Every record in the turn was a tool call — there is no prose to shorten.
151
- return { text, toolOnly: !text };
152
- }
153
-
154
- function wordCount(s) {
155
- return (String(s).trim().match(/\S+/g) || []).length;
156
- }
157
-
158
- function readConfig(projectDir) {
159
- const p = path.join(projectDir, ".gsd-t", "concise.json");
160
- if (!fs.existsSync(p)) return { enabled: true, skipUnder: DEFAULT_SKIP_UNDER };
161
- try {
162
- const cfg = JSON.parse(fs.readFileSync(p, "utf8"));
163
- return {
164
- enabled: cfg.enabled !== false,
165
- skipUnder: Number.isFinite(cfg.skipUnder) ? cfg.skipUnder : DEFAULT_SKIP_UNDER,
166
- };
167
- } catch (_) {
168
- return { enabled: true, skipUnder: DEFAULT_SKIP_UNDER };
169
- }
170
- }
171
-
172
- /**
173
- * Locate the rewriter. Every project carries its own copy; if it does not, the
174
- * install is broken and the SessionStart heal hook repairs it. Two exact
175
- * locations, no hunting.
176
- */
177
- function findRewriter(projectDir) {
178
- const inProject = path.join(projectDir, "bin", "gsd-t-concise-rewrite.cjs");
179
- if (fs.existsSync(inProject)) return inProject;
180
- const inPackage = path.join(__dirname, "..", "bin", "gsd-t-concise-rewrite.cjs");
181
- if (fs.existsSync(inPackage)) return inPackage;
182
- return null; // the caller lets the reply through — see the note on main()
183
- }
184
-
185
- function main() {
186
- let input = "";
187
- let done = false;
188
-
189
- process.stdin.setEncoding("utf8");
190
- process.stdin.on("data", (c) => { input += c; });
191
-
192
- const finish = () => {
193
- if (done) return;
194
- done = true;
195
-
196
- let data;
197
- try { data = JSON.parse(input); } catch (_) { return allow(); }
198
- if (!data || typeof data !== "object") return allow();
199
-
200
- // One rewrite per turn. This Stop is our own re-entry — let it through.
201
- if (data.stop_hook_active === true) return allow();
202
-
203
- const cwd = (typeof data.cwd === "string" && data.cwd) ? data.cwd : process.cwd();
204
- const cfg = readConfig(cwd);
205
- if (!cfg.enabled) return allow();
206
-
207
- const tPath = safeTranscriptPath(data.transcript_path);
208
- if (!tPath) return allow();
209
-
210
- const last = lastAssistantText(tPath);
211
- if (!last || last.toolOnly || !last.text) return allow();
212
- if (wordCount(last.text) < cfg.skipUnder) return allow();
213
-
214
- const rewriter = findRewriter(cwd);
215
- if (!rewriter) return allow();
216
-
217
- // The rewriter's own warnings go to stderr. Captured into a pipe and never
218
- // read, they reach nobody — which is why the timeouts stayed invisible even
219
- // after a loud message was added for them. Letting the child write straight
220
- // to this process's stderr is what makes a give-up path visible; these paths
221
- // are approved passes precisely BECAUSE the reader can see them happen.
222
- const run = spawnSync(process.execPath,
223
- [rewriter, "--text", last.text, "--project", cwd, "--json"],
224
- { encoding: "utf8", timeout: 60000, maxBuffer: 8 * 1024 * 1024,
225
- stdio: ["ignore", "pipe", "inherit"] });
226
-
227
- if (run.error || !run.stdout) return allow();
228
-
229
- let result;
230
- try { result = JSON.parse(run.stdout); } catch (_) { return allow(); }
231
-
232
- // The rewriter returns the original when it cannot do better. Nothing was
233
- // lost, so there is nothing to replace.
234
- if (!result.ok || !result.text || result.skipped) return allow();
235
- if (result.text.trim() === last.text.trim()) return allow();
236
-
237
- const saved = (result.words || 0) - (result.wordsAfter || 0);
238
- if (saved < 15) return allow(); // not worth the extra turn
239
-
240
- block(
241
- "Your last reply was longer than it needed to be. Replace it with this " +
242
- "shorter version, exactly as written, and add nothing:\n\n" +
243
- result.text
244
- );
245
- };
246
-
247
- process.stdin.on("end", finish);
248
- process.stdin.on("error", finish);
249
- const wd = setTimeout(finish, 70000);
250
- if (wd.unref) wd.unref();
251
- }
252
-
253
- if (require.main === module) main();
254
-
255
- module.exports = { lastAssistantText, wordCount, readConfig, findRewriter, safeTranscriptPath };