@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 +38 -0
- package/README.md +1 -1
- package/bin/gsd-t.js +48 -21
- package/package.json +1 -1
- package/bin/gsd-t-concise-rewrite.cjs +0 -276
- package/scripts/gsd-t-concise-hook.js +0 -255
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.
|
|
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
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
//
|
|
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
|
|
998
|
-
//
|
|
999
|
-
|
|
1000
|
-
|
|
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 =
|
|
2305
|
-
if (ccHook.
|
|
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.
|
|
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 };
|