@tekyzinc/gsd-t 4.8.10 → 4.9.12
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 +35 -0
- package/README.md +1 -1
- package/bin/gsd-t-architectural-trigger.cjs +80 -5
- package/bin/gsd-t-jargon-lint.cjs +363 -0
- package/bin/gsd-t-shrink-metric.cjs +255 -0
- package/bin/gsd-t.js +53 -0
- package/commands/gsd-t-milestone.md +6 -0
- package/commands/gsd-t-quick.md +13 -6
- package/package.json +1 -1
- package/scripts/gsd-t-brevity-guard.js +380 -0
- package/templates/CLAUDE-global.md +16 -0
- package/templates/prompts/blind-adversary-subagent.md +4 -0
- package/templates/prompts/pre-mortem-subagent.md +4 -0
- package/templates/prompts/qa-subagent.md +4 -0
- package/templates/prompts/red-team-subagent.md +4 -0
- package/templates/workflows/gsd-t-execute.workflow.js +7 -1
- package/templates/workflows/gsd-t-quick.workflow.js +16 -2
- package/templates/workflows/gsd-t-verify.workflow.js +82 -1
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* GSD-T Stop hook — blocks exhausting ANSWER-mode replies before the user reads them.
|
|
4
|
+
*
|
|
5
|
+
* The deterministic enforcement the prose "be concise" rule never had: when the
|
|
6
|
+
* assistant answers a question with stacked process-narration (intent-without-content)
|
|
7
|
+
* preamble, or drops a bare unglossed high-signal jargon token, this hook BLOCKS the
|
|
8
|
+
* stop and forces a rewrite (answer-first, glossed). Tuned CONSERVATIVE: catch only the
|
|
9
|
+
* egregious cases (≥2 stacked narration sentences, or a bare high-signal token) — never
|
|
10
|
+
* police word count, tone, or every acronym.
|
|
11
|
+
*
|
|
12
|
+
* ─── Stdin (Claude Code Stop hook payload) ───────────────────────────────────
|
|
13
|
+
* { "transcript_path": "...", "stop_hook_active": true|false,
|
|
14
|
+
* "session_id": "...", "hook_event_name": "Stop", "cwd": "..." }
|
|
15
|
+
*
|
|
16
|
+
* ─── Block contract (confirmed via Claude Code hooks docs) ────────────────────
|
|
17
|
+
* https://code.claude.com/docs/en/hooks.md
|
|
18
|
+
* A Stop hook blocks by writing `{"decision":"block","reason":"<why>"}` to stdout
|
|
19
|
+
* and exiting 0. `reason` is fed back to Claude as the instruction for what to fix
|
|
20
|
+
* (here: rewrite answer-first / gloss the term). We use THIS structured form (not
|
|
21
|
+
* exit-2-stderr) so the reason is delivered cleanly. Exit 0 with no JSON = allow.
|
|
22
|
+
*
|
|
23
|
+
* ─── Loop-guard ──────────────────────────────────────────────────────────────
|
|
24
|
+
* If `stop_hook_active === true` (this Stop is already a re-entry from a prior
|
|
25
|
+
* block), EXIT 0 immediately — never block twice on the same turn (no infinite
|
|
26
|
+
* rewrite loop).
|
|
27
|
+
*
|
|
28
|
+
* ─── Mode discriminator ──────────────────────────────────────────────────────
|
|
29
|
+
* ACTION-mode (about to change code: latest assistant turn carried a mutating
|
|
30
|
+
* tool_use — Write/Edit/NotebookEdit, or a Bash mutation — OR is a tool_use-only
|
|
31
|
+
* turn with empty text) → intent-first is WANTED → ALLOW.
|
|
32
|
+
* ANSWER-mode (a pure-text reply to a question) → enforce answer-first.
|
|
33
|
+
*
|
|
34
|
+
* ─── FAIL-OPEN (non-negotiable) ──────────────────────────────────────────────
|
|
35
|
+
* ANY internal error / unreadable transcript / malformed payload / no assistant
|
|
36
|
+
* message → EXIT 0 (allow). A broken guard must NEVER gag a legitimate reply.
|
|
37
|
+
*
|
|
38
|
+
* ─── CLI (unit-testable without a transcript) ────────────────────────────────
|
|
39
|
+
* node gsd-t-brevity-guard.js --text "<reply>" --mode answer|action
|
|
40
|
+
* → prints `{"decision":"block","reason":...}` + exit 1 on BLOCK,
|
|
41
|
+
* exit 0 (no output) on ALLOW. (CLI exit 1 ≠ hook exit; hook always exits 0.)
|
|
42
|
+
*
|
|
43
|
+
* ─── INTEGRATE-SEAM (do NOT wire here) ───────────────────────────────────────
|
|
44
|
+
* Add to ~/.claude/settings.json (and the installer settings template):
|
|
45
|
+
* "hooks": { "Stop": [ { "hooks": [ {
|
|
46
|
+
* "type": "command",
|
|
47
|
+
* "command": "node ~/.claude/scripts/hooks/gsd-t-brevity-guard.js"
|
|
48
|
+
* } ] } ] }
|
|
49
|
+
*
|
|
50
|
+
* Zero deps. Pure text analysis. Zero LLM. Never throws.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
"use strict";
|
|
54
|
+
|
|
55
|
+
const fs = require("fs");
|
|
56
|
+
const path = require("path");
|
|
57
|
+
const os = require("os");
|
|
58
|
+
|
|
59
|
+
// ── Jargon allowlist (exempt acronyms — extend conservatively) ──────────────
|
|
60
|
+
const ALLOWLIST = new Set([
|
|
61
|
+
"GSD-T", "QA", "CLI", "API", "URL", "DB", "JSON", "HTML", "CSS", "HTTP",
|
|
62
|
+
"NDJSON", "DTO", "PDT", "EST", "UTC", "AND", "OR", "NOT",
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
// High-signal code-token patterns (bare, unglossed → block on first use).
|
|
66
|
+
// LIVE-REPLY scope only: limited to the genuinely opaque IDs the user can't decode
|
|
67
|
+
// (contract / hard-constraint / stage-milestone codes). A bare `M92` milestone ref
|
|
68
|
+
// is established conversational shorthand once context exists — forcing a gloss on
|
|
69
|
+
// it every turn is over-strict and false-blocks clean answers. The stricter
|
|
70
|
+
// `M\d+-D\d+`-and-bare-`M\d+` rule stays in the DOC lint (bin/gsd-t-jargon-lint.cjs),
|
|
71
|
+
// where a reader has no conversational context. (M93 live-tuning, 2026-06-23.)
|
|
72
|
+
const JARGON_PATTERNS = [
|
|
73
|
+
/\bS2-M\d+\b/, // S2-M7 (stage-milestone code)
|
|
74
|
+
/\bHC-\d+\b/, // HC-003 (hard-constraint / contract id)
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
// Narration openers: first-person "about to" framing — intent without content.
|
|
78
|
+
const NARRATION_OPENER =
|
|
79
|
+
/^(let me|before i\b|i'?ll\b|i'?m going to|i am going to|first,?\s+let me|i want to|i need to|let's|let us|going to|i need to be|i have to|i should|i want to be|let me untangle|i'?m going to verify)\b/i;
|
|
80
|
+
|
|
81
|
+
// Meta-commentary / hedge openers — sentences that talk ABOUT the answer instead
|
|
82
|
+
// of giving it ("the honest correction:", "here's the real picture", "good catch —
|
|
83
|
+
// and I…", "where does that leave you"). These don't match NARRATION_OPENER but are
|
|
84
|
+
// the same preamble disease — over-explaining around the answer. Counted in the
|
|
85
|
+
// interleaved scan, NOT the leading scan (they appear mid-reply).
|
|
86
|
+
const META_OPENER =
|
|
87
|
+
/^(good (catch|question|point|instinct)|fair (challenge|point)|the honest|here'?s (the|what)|so,? (where|to|that|let)|that'?s (a |the )?(real|fair|honest)|the (real |accurate )?(picture|version|correction|nuance)|to (untangle|be (clear|precise|careful|honest))|i (conflated|misspoke|need to be|want to be))\b/i;
|
|
88
|
+
|
|
89
|
+
// ── Transcript tail-scan (REUSED from gsd-t-conversation-capture.js) ─────────
|
|
90
|
+
// Pull the most recent assistant turn from a Claude Code transcript JSONL by
|
|
91
|
+
// scanning from the tail. Returns { text, hasMutatingTool, toolOnly } or null.
|
|
92
|
+
// Distinguishes text blocks (the answer) from tool_use blocks (the action signal).
|
|
93
|
+
function _safeTranscriptPath(p) {
|
|
94
|
+
if (typeof p !== "string" || p.length === 0) return null;
|
|
95
|
+
if (!path.isAbsolute(p)) return null;
|
|
96
|
+
const home = process.env.HOME || os.homedir();
|
|
97
|
+
if (!home) return null;
|
|
98
|
+
const allowedRoot = path.resolve(home, ".claude", "projects") + path.sep;
|
|
99
|
+
const resolved = path.resolve(p);
|
|
100
|
+
if (!resolved.startsWith(allowedRoot)) return null;
|
|
101
|
+
return resolved;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function _readFileTail(filePath, bytes) {
|
|
105
|
+
let fd = -1;
|
|
106
|
+
try {
|
|
107
|
+
const st = fs.statSync(filePath);
|
|
108
|
+
if (!st.isFile()) return "";
|
|
109
|
+
const size = st.size;
|
|
110
|
+
if (size === 0) return "";
|
|
111
|
+
const want = Math.min(bytes, size);
|
|
112
|
+
const start = size - want;
|
|
113
|
+
fd = fs.openSync(filePath, "r");
|
|
114
|
+
const buf = Buffer.alloc(want);
|
|
115
|
+
fs.readSync(fd, buf, 0, want, start);
|
|
116
|
+
let str = buf.toString("utf8");
|
|
117
|
+
if (start > 0) {
|
|
118
|
+
const nl = str.indexOf("\n");
|
|
119
|
+
if (nl >= 0) str = str.slice(nl + 1);
|
|
120
|
+
}
|
|
121
|
+
return str;
|
|
122
|
+
} catch (_) {
|
|
123
|
+
return "";
|
|
124
|
+
} finally {
|
|
125
|
+
if (fd >= 0) { try { fs.closeSync(fd); } catch (_) { /* noop */ } }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const MUTATING_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
130
|
+
// Bash command is a mutation if it invokes a write-ish verb (heuristic, conservative).
|
|
131
|
+
const BASH_MUTATION =
|
|
132
|
+
/\b(rm|mv|cp|mkdir|touch|tee|chmod|chown|git\s+(commit|add|push|rm|mv|checkout|reset|merge|rebase)|npm\s+(install|i|publish|run)|node\s|>>?|sed\s+-i)\b/;
|
|
133
|
+
|
|
134
|
+
function _isMutatingToolBlock(b) {
|
|
135
|
+
if (!b || b.type !== "tool_use") return false;
|
|
136
|
+
if (MUTATING_TOOLS.has(b.name)) return true;
|
|
137
|
+
if (b.name === "Bash") {
|
|
138
|
+
const cmd = b.input && typeof b.input.command === "string" ? b.input.command : "";
|
|
139
|
+
return BASH_MUTATION.test(cmd);
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function _readAssistantFromTranscript(transcriptPath) {
|
|
145
|
+
const safe = _safeTranscriptPath(transcriptPath);
|
|
146
|
+
if (!safe) return null;
|
|
147
|
+
const tail = _readFileTail(safe, 64 * 1024);
|
|
148
|
+
if (!tail) return null;
|
|
149
|
+
const lines = tail.split("\n");
|
|
150
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
151
|
+
const line = lines[i];
|
|
152
|
+
if (!line) continue;
|
|
153
|
+
let row;
|
|
154
|
+
try { row = JSON.parse(line); } catch (_) { continue; }
|
|
155
|
+
if (!row || row.type !== "assistant") continue;
|
|
156
|
+
if (row.isSidechain === true) continue;
|
|
157
|
+
const msg = row.message;
|
|
158
|
+
if (!msg) continue;
|
|
159
|
+
const blocks = msg.content;
|
|
160
|
+
if (typeof blocks === "string") {
|
|
161
|
+
return { text: blocks, hasMutatingTool: false, toolOnly: false };
|
|
162
|
+
}
|
|
163
|
+
if (!Array.isArray(blocks)) continue;
|
|
164
|
+
const texts = [];
|
|
165
|
+
let hasMutatingTool = false;
|
|
166
|
+
let hasToolUse = false;
|
|
167
|
+
for (const b of blocks) {
|
|
168
|
+
if (b && b.type === "text" && typeof b.text === "string") texts.push(b.text);
|
|
169
|
+
if (b && b.type === "tool_use") {
|
|
170
|
+
hasToolUse = true;
|
|
171
|
+
if (_isMutatingToolBlock(b)) hasMutatingTool = true;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const text = texts.join("");
|
|
175
|
+
if (text.length === 0 && !hasToolUse) continue; // empty, keep scanning
|
|
176
|
+
return { text, hasMutatingTool, toolOnly: text.trim().length === 0 && hasToolUse };
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Detection (pure) ─────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
// Strip the dated status banner (first line, "Day: Mon DD, YYYY ... — GSD-T ...").
|
|
184
|
+
function _stripBanner(text) {
|
|
185
|
+
const nl = text.indexOf("\n");
|
|
186
|
+
const first = nl >= 0 ? text.slice(0, nl) : text;
|
|
187
|
+
if (/^[A-Z][a-z]{2,8}:\s+\w{3}\s+\d{1,2},\s+\d{4}/.test(first.trim())) {
|
|
188
|
+
return nl >= 0 ? text.slice(nl + 1) : "";
|
|
189
|
+
}
|
|
190
|
+
return text;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Remove fenced code blocks and table rows (content, never preamble).
|
|
194
|
+
function _stripStructured(text) {
|
|
195
|
+
const out = [];
|
|
196
|
+
let inFence = false;
|
|
197
|
+
for (const raw of text.split("\n")) {
|
|
198
|
+
const line = raw.trim();
|
|
199
|
+
if (/^```/.test(line)) { inFence = !inFence; continue; }
|
|
200
|
+
if (inFence) continue;
|
|
201
|
+
if (/^\|.*\|/.test(line)) continue; // table row
|
|
202
|
+
out.push(raw);
|
|
203
|
+
}
|
|
204
|
+
return out.join("\n");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Split prose into sentences (coarse — period/!/? or newline boundary).
|
|
208
|
+
function _sentences(text) {
|
|
209
|
+
return text
|
|
210
|
+
.replace(/\s+/g, " ")
|
|
211
|
+
.split(/(?<=[.!?])\s+/)
|
|
212
|
+
.map((s) => s.trim())
|
|
213
|
+
.filter((s) => s.length > 0);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Count narration sentences in the LEADING region (before substantive content).
|
|
217
|
+
// A reply may open with ONE short acknowledgement/framing sentence (e.g. "Important
|
|
218
|
+
// question." / "Fair challenge.") — that's wanted, not preamble. But narration that
|
|
219
|
+
// STACKS after such an ack ("Let me find the record. Before I answer, I should check…")
|
|
220
|
+
// is the egregious case the user flagged. So: tolerate a single short non-narration
|
|
221
|
+
// lead sentence, then count narration; stop at the first substantive (non-narration,
|
|
222
|
+
// non-short-ack) sentence. Block fires at >=2 narration in that leading region.
|
|
223
|
+
function _leadingNarrationCount(prose) {
|
|
224
|
+
const sentences = _sentences(prose);
|
|
225
|
+
let count = 0;
|
|
226
|
+
let ackUsed = false;
|
|
227
|
+
for (const s of sentences) {
|
|
228
|
+
if (NARRATION_OPENER.test(s)) { count++; continue; }
|
|
229
|
+
// A short non-narration sentence (<= ~12 words) is treated as an allowed
|
|
230
|
+
// acknowledgement/framing line ONCE; a second one, or any longer sentence,
|
|
231
|
+
// is real content → the leading region ends.
|
|
232
|
+
const words = s.trim().split(/\s+/).length;
|
|
233
|
+
if (!ackUsed && words <= 12) { ackUsed = true; continue; }
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
return count;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Count narration + meta-commentary sentences INTERLEAVED in the opening region
|
|
240
|
+
// (the first WINDOW sentences), regardless of contiguity. Catches the real failure
|
|
241
|
+
// shape the leading-only scan misses: an ack, then narration, then a content
|
|
242
|
+
// sentence, then MORE narration ("Good catch… Let me untangle… What I said was X…
|
|
243
|
+
// Let me verify…"). Conservative: only scans the opening window, needs >=2 hits.
|
|
244
|
+
const INTERLEAVED_WINDOW = 6;
|
|
245
|
+
function _interleavedPreambleCount(prose) {
|
|
246
|
+
const sentences = _sentences(prose).slice(0, INTERLEAVED_WINDOW);
|
|
247
|
+
let count = 0;
|
|
248
|
+
for (const s of sentences) {
|
|
249
|
+
if (NARRATION_OPENER.test(s) || META_OPENER.test(s)) count++;
|
|
250
|
+
}
|
|
251
|
+
return count;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Find a bare unglossed high-signal jargon token (first occurrence, no gloss).
|
|
255
|
+
// A gloss = parenthetical or quote in the SAME sentence containing the token.
|
|
256
|
+
function _findBareJargon(prose) {
|
|
257
|
+
const sentences = _sentences(prose);
|
|
258
|
+
const seen = new Set();
|
|
259
|
+
for (const s of sentences) {
|
|
260
|
+
for (const re of JARGON_PATTERNS) {
|
|
261
|
+
const m = s.match(re);
|
|
262
|
+
if (!m) continue;
|
|
263
|
+
const token = m[0];
|
|
264
|
+
if (seen.has(token)) continue;
|
|
265
|
+
// skip allowlisted ALL-CAPS lookalikes (none of these patterns are, but defensive)
|
|
266
|
+
if (ALLOWLIST.has(token)) continue;
|
|
267
|
+
seen.add(token);
|
|
268
|
+
// Glossed if a parenthetical or quoted phrase appears in the same sentence.
|
|
269
|
+
const hasGloss = /\([^)]*\)/.test(s) || /["“'][^"”']{4,}["”']/.test(s);
|
|
270
|
+
if (!hasGloss) return token;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// NOTE: bare ALL-CAPS acronyms (TTL, TLS, CDN, …) are intentionally NOT policed.
|
|
277
|
+
// The spec lists them but caps it "extend conservatively / do NOT police every
|
|
278
|
+
// acronym" — generic ALL-CAPS is a false-positive engine on common tech terms.
|
|
279
|
+
// Only the high-signal code-token patterns above (S2-M\d, HC-\d, M\d[-D\d][-T\d])
|
|
280
|
+
// are unambiguous jargon worth a forced gloss. The ALLOWLIST is retained for any
|
|
281
|
+
// future, deliberate extension of JARGON_PATTERNS into acronym territory.
|
|
282
|
+
|
|
283
|
+
// Core detector. mode: "answer" | "action". Returns { block, reason }.
|
|
284
|
+
function detect(text, mode) {
|
|
285
|
+
if (mode === "action") return { block: false };
|
|
286
|
+
if (typeof text !== "string" || text.trim().length === 0) return { block: false };
|
|
287
|
+
|
|
288
|
+
const prose = _stripStructured(_stripBanner(text));
|
|
289
|
+
|
|
290
|
+
const narration = _leadingNarrationCount(prose);
|
|
291
|
+
if (narration >= 2) {
|
|
292
|
+
return {
|
|
293
|
+
block: true,
|
|
294
|
+
reason: "Answer-first: lead with the answer, not " + narration +
|
|
295
|
+
" stacked 'about-to' narration sentences. Cut the preamble; state the answer, then detail.",
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Interleaved preamble: narration/meta-commentary scattered through the opening
|
|
300
|
+
// (not just stacked at the front). The real exhausting shape: ack → narrate →
|
|
301
|
+
// a bit of content → narrate again → over-explain.
|
|
302
|
+
const interleaved = _interleavedPreambleCount(prose);
|
|
303
|
+
if (interleaved >= 2) {
|
|
304
|
+
return {
|
|
305
|
+
block: true,
|
|
306
|
+
reason: "Answer-first: the opening has " + interleaved +
|
|
307
|
+
" preamble/meta sentences (narration or 'here's the real picture'-style framing) around the answer. Lead with the answer; cut the meta-commentary.",
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const jargon = _findBareJargon(prose);
|
|
312
|
+
if (jargon) {
|
|
313
|
+
return {
|
|
314
|
+
block: true,
|
|
315
|
+
reason: "Gloss '" + jargon + "' on first use (one plain-language clause in parens). Bare code-token, no gloss.",
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return { block: false };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── Mode classification from a transcript turn ───────────────────────────────
|
|
323
|
+
function classifyMode(turn) {
|
|
324
|
+
if (turn && (turn.hasMutatingTool || turn.toolOnly)) return "action";
|
|
325
|
+
return "answer";
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── Entry: process a Stop-hook payload → { block, reason } ───────────────────
|
|
329
|
+
function processPayload(payload) {
|
|
330
|
+
if (!payload || typeof payload !== "object") return { block: false }; // fail-open
|
|
331
|
+
if (payload.stop_hook_active) return { block: false }; // loop-guard (truthy — never re-block a re-entry, even if the flag arrives non-boolean)
|
|
332
|
+
const turn = _readAssistantFromTranscript(payload.transcript_path);
|
|
333
|
+
if (!turn) return { block: false }; // no message → fail-open
|
|
334
|
+
return detect(turn.text, classifyMode(turn));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ── CLI / hook driver ────────────────────────────────────────────────────────
|
|
338
|
+
function _emitBlock(reason) {
|
|
339
|
+
process.stdout.write(JSON.stringify({ decision: "block", reason }) + "\n");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function runCli(argv) {
|
|
343
|
+
// --text "<reply>" --mode answer|action
|
|
344
|
+
let text = null, mode = "answer";
|
|
345
|
+
for (let i = 0; i < argv.length; i++) {
|
|
346
|
+
if (argv[i] === "--text") { text = argv[i + 1]; i++; }
|
|
347
|
+
else if (argv[i] === "--mode") { mode = argv[i + 1]; i++; }
|
|
348
|
+
}
|
|
349
|
+
const res = detect(typeof text === "string" ? text : "", mode === "action" ? "action" : "answer");
|
|
350
|
+
if (res.block) { _emitBlock(res.reason); process.exit(1); }
|
|
351
|
+
process.exit(0);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function main() {
|
|
355
|
+
try {
|
|
356
|
+
const argv = process.argv.slice(2);
|
|
357
|
+
if (argv.includes("--text")) { runCli(argv); return; }
|
|
358
|
+
|
|
359
|
+
let input = "";
|
|
360
|
+
process.stdin.setEncoding("utf8");
|
|
361
|
+
process.stdin.on("data", (c) => { input += c; });
|
|
362
|
+
process.stdin.on("end", () => {
|
|
363
|
+
try {
|
|
364
|
+
let payload;
|
|
365
|
+
try { payload = JSON.parse(input); } catch { process.exit(0); } // fail-open
|
|
366
|
+
const res = processPayload(payload);
|
|
367
|
+
if (res.block) { _emitBlock(res.reason); process.exit(0); } // block via JSON, exit 0
|
|
368
|
+
process.exit(0); // allow
|
|
369
|
+
} catch { process.exit(0); } // fail-open
|
|
370
|
+
});
|
|
371
|
+
process.stdin.on("error", () => process.exit(0)); // fail-open
|
|
372
|
+
} catch { process.exit(0); } // fail-open
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (require.main === module) main();
|
|
376
|
+
|
|
377
|
+
module.exports = {
|
|
378
|
+
detect, classifyMode, processPayload, _findBareJargon,
|
|
379
|
+
_leadingNarrationCount, _readAssistantFromTranscript, ALLOWLIST,
|
|
380
|
+
};
|
|
@@ -30,6 +30,22 @@ The litmus test: if a sentence would survive being deleted without the user losi
|
|
|
30
30
|
|
|
31
31
|
Verbose mode (opt-in, `Output Style: verbose`): full narrative prose, inline rationale, the longer style. Don't apply verbose unless a project requests it.
|
|
32
32
|
|
|
33
|
+
## Reader Contract (the question-vs-action split)
|
|
34
|
+
|
|
35
|
+
Two reply shapes, picked by what the user wants. **Discriminator: are you modifying something, or just telling?**
|
|
36
|
+
|
|
37
|
+
| Situation | Lead with | Why |
|
|
38
|
+
|-----------|-----------|-----|
|
|
39
|
+
| **Question** (wants an answer) | **answer first** | They want the conclusion, not the path to it |
|
|
40
|
+
| **Action** (about to change code) | **intent first** | Lets them short-circuit a wrong direction before you spend the edit |
|
|
41
|
+
|
|
42
|
+
- **Question → answer first.** No process-narration — drop "let me find/check/verify before I answer". One direction-ack sentence is fine; stacking 2+ "about to do X" lines before the answer is the banned pattern. (Do the checking silently, then state the verified result.)
|
|
43
|
+
- **Action → intent first.** This is the ONLY place leading-with-intent is correct.
|
|
44
|
+
- **Gloss jargon.** Never a bare code/acronym (e.g. `S2-M7` = section 2, milestone 7; `HC-003` = a rule ID) — say what it means in plain words on first use.
|
|
45
|
+
- **Format.** Bullets/tables over paragraphs; expand only on request; the dated banner stays (first line, always).
|
|
46
|
+
|
|
47
|
+
**ENFORCED, not advised:** the `gsd-t-brevity-guard` Stop hook (a gate that runs when a reply finishes) is the backstop — a verbose/narrating reply is caught there. This block makes the gate rarely need to fire.
|
|
48
|
+
|
|
33
49
|
|
|
34
50
|
# GSD-T: Contract-Driven Development
|
|
35
51
|
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Blind-Adversary Subagent Prompt — Architectural Premise Challenge
|
|
2
2
|
|
|
3
|
+
<!-- reader-contract -->
|
|
4
|
+
**Report concisely:** verdict/answer first, no preamble. Gloss every code/jargon term (e.g. `M93-D2` = milestone 93, domain 2) in plain words on first use. Bullets over paragraphs. Expand only if asked.
|
|
5
|
+
<!-- /reader-contract -->
|
|
6
|
+
|
|
3
7
|
**Model:** `fable` (M85 tier policy — highest-leverage judgment; separate context from the proposing agent)
|
|
4
8
|
|
|
5
9
|
**Framing:** You are reviewing someone ELSE's architectural design — you did NOT propose it and have no attachment to it. Your goal is to find the **fatal flaw** in the premise being challenged, before a single line of code is committed to that premise. This framing (independent reviewer, not the author) is essential for escaping self-preference bias: the proposing agent's prior context makes it systematically less able to see its own premise's failures (source: https://arxiv.org/abs/2310.08118 — LLM self-evaluation is biased toward confirming prior outputs; https://arxiv.org/abs/2404.13076 — blind adversarial framing surfaces failures that self-critique misses).
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Pre-Mortem Subagent Prompt — Adversarial Plan Review (pre-execute)
|
|
2
2
|
|
|
3
|
+
<!-- reader-contract -->
|
|
4
|
+
**Report concisely:** verdict/answer first, no preamble. Gloss every code/jargon term (e.g. `M93-D2` = milestone 93, domain 2) in plain words on first use. Bullets over paragraphs. Expand only if asked.
|
|
5
|
+
<!-- /reader-contract -->
|
|
6
|
+
|
|
3
7
|
You are an adversarial Pre-Mortem reviewer. You attack the PLAN, not the code — because the code does not exist yet. Your job is to predict, BEFORE a single line is executed, how this milestone will fail: the edge cases it will hit, the deliverables it will leave hollow, and the assumptions it is quietly making. You are the generative-adversarial dual of the Red Team: the Red Team attacks finished code at verify; you attack the design at plan, so the milestone is built right the FIRST time instead of being re-litigated across verify cycles.
|
|
4
8
|
|
|
5
9
|
**Inverted incentives.** Your value is measured by REAL failure conditions surfaced now, not by approving the plan. A plan you bless that later burns verify cycles is YOUR failure. Assume the plan is flawed and find where.
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# QA Subagent Prompt — Per-Task Validation
|
|
2
2
|
|
|
3
|
+
<!-- reader-contract -->
|
|
4
|
+
**Report concisely:** verdict/answer first, no preamble. Gloss every code/jargon term (e.g. `M93-D2` = milestone 93, domain 2) in plain words on first use. Bullets over paragraphs. Expand only if asked.
|
|
5
|
+
<!-- /reader-contract -->
|
|
6
|
+
|
|
3
7
|
You are the QA agent. Your sole job is test generation, execution, and gap reporting. You write ZERO feature code. You never modify implementation files — only test files and reports.
|
|
4
8
|
|
|
5
9
|
<!-- M61 D7-T3: Workflow-stage invocation -->
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Red Team Subagent Prompt — Adversarial QA (per-domain)
|
|
2
2
|
|
|
3
|
+
<!-- reader-contract -->
|
|
4
|
+
**Report concisely:** verdict/answer first, no preamble. Gloss every code/jargon term (e.g. `M93-D2` = milestone 93, domain 2) in plain words on first use. Bullets over paragraphs. Expand only if asked.
|
|
5
|
+
<!-- /reader-contract -->
|
|
6
|
+
|
|
3
7
|
You are a Red Team QA adversary. Your job is to BREAK the code that was just written for this domain. You operate with inverted incentives — your value is measured by REAL bugs found, not tests passed.
|
|
4
8
|
|
|
5
9
|
<!-- M61 D7-T3: Workflow-stage invocation -->
|
|
@@ -548,9 +548,15 @@ for (const dr of domainResults.filter(Boolean)) {
|
|
|
548
548
|
reason: triggerEnv.reason || null,
|
|
549
549
|
provenByAdversaryOnly: triggerEnv.provenByAdversaryOnly || false,
|
|
550
550
|
stopDirective: triggerEnv.stopDirective || false,
|
|
551
|
+
// M92 — surface the cheaper-first response rung + its directive (look→smallest→spike→defer)
|
|
552
|
+
// so the "look at what exists / smallest change" guidance reaches the worker.
|
|
553
|
+
mode: triggerEnv.mode || null,
|
|
554
|
+
responseDirective: triggerEnv.lookDirective || triggerEnv.smallestDirective || triggerEnv.deferDirective || null,
|
|
551
555
|
});
|
|
552
556
|
}
|
|
553
|
-
log(`M90 arch-trigger: ${domainArchTriggerResults.length} domain(s) fired extend-existing-code signal`
|
|
557
|
+
log(`M90 arch-trigger: ${domainArchTriggerResults.length} domain(s) fired extend-existing-code signal` +
|
|
558
|
+
(domainArchTriggerResults.some((r) => r.mode === "look")
|
|
559
|
+
? ` — M92 default response: LOOK (grep/read existing before scoping)` : ""));
|
|
554
560
|
|
|
555
561
|
phase("Integrate");
|
|
556
562
|
const integratePrompt = [
|
|
@@ -182,8 +182,17 @@ const result = await agent(
|
|
|
182
182
|
`Quick task: ${task}`,
|
|
183
183
|
`**Brief:** ${brief.briefPath || "(no brief)"}`,
|
|
184
184
|
``,
|
|
185
|
+
`**CRUX-FIRST — START HERE (smallest change is the default, ceremony is opt-in):**`,
|
|
186
|
+
`1. CRUX: state the crux of this ask in ONE line — the single thing that must become true.`,
|
|
187
|
+
`2. WHAT EXISTS: grep/read what ALREADY exists for this before choosing any scope.`,
|
|
188
|
+
` Do not build outward until you know what is already here to edit.`,
|
|
189
|
+
`3. SMALLEST CHANGE: propose the SMALLEST change that hits the crux — edit INWARD at the`,
|
|
190
|
+
` source (the one place the behavior is defined), not OUTWARD at the N consumers.`,
|
|
191
|
+
` The recommendation is "do it directly / one-file change," not a partition or plan.`,
|
|
192
|
+
`4. ESCALATE ONLY IF NEEDED: reach for ceremony (plan→execute, partition, competition) ONLY`,
|
|
193
|
+
` when the crux genuinely needs cross-domain coordination or real uncertainty — and say WHY.`,
|
|
194
|
+
``,
|
|
185
195
|
`Constraints from CLAUDE.md:`,
|
|
186
|
-
`- SIMPLICITY ABOVE ALL — minimal change`,
|
|
187
196
|
`- Check downstream effects before changing existing code`,
|
|
188
197
|
`- Run affected tests before reporting done`,
|
|
189
198
|
`- Update relevant docs in the same commit`,
|
|
@@ -372,13 +381,18 @@ if (Array.isArray(result.filesEdited) && result.filesEdited.length > 0) {
|
|
|
372
381
|
"Research"
|
|
373
382
|
);
|
|
374
383
|
const triggerEnv = triggerResult.envelope || {};
|
|
375
|
-
|
|
384
|
+
const m92Directive = triggerEnv.lookDirective || triggerEnv.smallestDirective || triggerEnv.deferDirective || null;
|
|
385
|
+
log(`M90 arch-trigger quick: fired=${triggerEnv.fired} reason=${triggerEnv.reason || "?"} provenByAdversaryOnly=${triggerEnv.provenByAdversaryOnly || false}` +
|
|
386
|
+
(triggerEnv.mode ? ` — M92 response: ${triggerEnv.mode}${triggerEnv.mode === "look" ? " (grep/read existing before scoping)" : ""}` : ""));
|
|
376
387
|
quickArchTriggerResult = {
|
|
377
388
|
existingFiles,
|
|
378
389
|
fired: triggerEnv.fired || false,
|
|
379
390
|
reason: triggerEnv.reason || null,
|
|
380
391
|
provenByAdversaryOnly: triggerEnv.provenByAdversaryOnly || false,
|
|
381
392
|
stopDirective: triggerEnv.stopDirective || false,
|
|
393
|
+
// M92 — cheaper-first response rung + its directive surfaced to the quick worker.
|
|
394
|
+
mode: triggerEnv.mode || null,
|
|
395
|
+
responseDirective: m92Directive,
|
|
382
396
|
};
|
|
383
397
|
}
|
|
384
398
|
}
|
|
@@ -78,6 +78,13 @@ async function runCli(projectDir, subcmd, argv, localBin, label, parseJson = tru
|
|
|
78
78
|
}
|
|
79
79
|
async function runPreflight(projectDir, label = "preflight", phaseName) { return runCli(projectDir, "preflight", ["--json"], "cli-preflight.cjs", label, true, phaseName); }
|
|
80
80
|
async function runVerifyGate(projectDir, label = "verify-gate", phaseName) { return runCli(projectDir, "verify-gate", ["--json"], "gsd-t-verify-gate.cjs", label, true, phaseName); }
|
|
81
|
+
// M92 D2 (keystone) — the deterministic shrink-metric. Computes the milestone diff's
|
|
82
|
+
// net size from `git diff --numstat <range>` via the shared runCli helper (M71-clean:
|
|
83
|
+
// the git call runs in the agent's Bash, NOT via require/fs/child_process here). model:
|
|
84
|
+
// "haiku" (mechanical), like the other deterministic-gate CLI calls.
|
|
85
|
+
async function runShrinkMetric(projectDir, range, label = "m92:shrink-metric", phaseName) {
|
|
86
|
+
return runCli(projectDir, "shrink-metric", ["--range", range, "--project-dir", projectDir, "--json"], "gsd-t-shrink-metric.cjs", label, true, phaseName);
|
|
87
|
+
}
|
|
81
88
|
async function generateBrief(projectDir, { kind = "verify", milestone, domain, id, label = "brief", phaseName } = {}) {
|
|
82
89
|
const argv = ["--kind", kind, "--spawn-id", id, "--out", `${projectDir}/.gsd-t/briefs/${id}.json`];
|
|
83
90
|
if (milestone) argv.push("--milestone", milestone);
|
|
@@ -206,6 +213,20 @@ const VERDICT_SCHEMA = {
|
|
|
206
213
|
overallVerdict: { type: "string", enum: ["VERIFIED", "VERIFIED-WITH-WARNINGS", "VERIFY-FAILED"] },
|
|
207
214
|
summary: { type: "string" },
|
|
208
215
|
blockingFindings: { type: "array", items: { type: "string" } },
|
|
216
|
+
// M92 D2 (keystone) — ADDITIVE, ORTHOGONAL "did it get leaner" readout. The
|
|
217
|
+
// 3-enum stays the pure correctness gate (AND-of-gates); `shrink` is a SEPARATE
|
|
218
|
+
// success dimension surfaced ALONGSIDE it (never folded into pass/fail). MEASURED
|
|
219
|
+
// by bin/gsd-t-shrink-metric.cjs from `git diff --numstat`, never LLM-attested.
|
|
220
|
+
// Optional: omitted when the diff base is unavailable (logged skip-with-reason).
|
|
221
|
+
shrink: {
|
|
222
|
+
type: "object",
|
|
223
|
+
required: ["netLoc", "leaner"],
|
|
224
|
+
additionalProperties: true,
|
|
225
|
+
properties: {
|
|
226
|
+
netLoc: { type: "number" },
|
|
227
|
+
leaner: { type: "boolean" },
|
|
228
|
+
},
|
|
229
|
+
},
|
|
209
230
|
},
|
|
210
231
|
};
|
|
211
232
|
|
|
@@ -693,6 +714,55 @@ const stages = [
|
|
|
693
714
|
|
|
694
715
|
const triadResults = await parallel(stages);
|
|
695
716
|
|
|
717
|
+
// ─── M92 D2 Shrink-Metric (keystone — ADDITIVE leanness dimension) ─────────
|
|
718
|
+
// Before synthesis, MEASURE the milestone diff's net size so the verdict can SAY
|
|
719
|
+
// "we made it smaller" — a first-class, ORTHOGONAL success dimension surfaced
|
|
720
|
+
// ALONGSIDE the overallVerdict enum (never folded into pass/fail). MEASURED, never
|
|
721
|
+
// LLM-attested ([[feedback_measure_dont_claim]]): the metric is computed by
|
|
722
|
+
// bin/gsd-t-shrink-metric.cjs from `git diff --numstat <base>..HEAD`.
|
|
723
|
+
//
|
|
724
|
+
// The diff base = the milestone branch-point (merge-base of HEAD against the default
|
|
725
|
+
// branch). M71-clean: BOTH the merge-base resolution AND the numstat run go through an
|
|
726
|
+
// agent's Bash, never require/fs/child_process in this orchestrator. If the base can't
|
|
727
|
+
// be resolved (detached HEAD, shallow clone, no default branch), the metric is SKIPPED
|
|
728
|
+
// with a logged reason — NEVER fabricated ([[feedback_no_silent_degradation]]).
|
|
729
|
+
const SHRINK_BASE_SCHEMA = {
|
|
730
|
+
type: "object",
|
|
731
|
+
required: ["resolved"],
|
|
732
|
+
additionalProperties: true,
|
|
733
|
+
properties: { resolved: { type: "boolean" }, base: { type: "string" }, reason: { type: "string" } },
|
|
734
|
+
};
|
|
735
|
+
const shrinkBase = await agent(
|
|
736
|
+
[
|
|
737
|
+
`Resolve the milestone diff BASE for the git repo at "${projectDir}" (the branch-point of the current work). Run ONLY git, report JSON. Steps:`,
|
|
738
|
+
`1. Find the default branch tip. Try in order, using the first that resolves to a commit (cwd "${projectDir}"):`,
|
|
739
|
+
` a. \`git -C "${projectDir}" rev-parse --verify --quiet origin/HEAD\` (then its symbolic target), else`,
|
|
740
|
+
` b. \`git -C "${projectDir}" rev-parse --verify --quiet main\`, else`,
|
|
741
|
+
` c. \`git -C "${projectDir}" rev-parse --verify --quiet master\`.`,
|
|
742
|
+
`2. Compute the merge-base of HEAD and that default tip: \`git -C "${projectDir}" merge-base HEAD <defaultTip>\`.`,
|
|
743
|
+
`3. If a merge-base commit is produced AND it differs from HEAD's own sha (there IS a diff to measure), return { "resolved": true, "base": "<merge-base-sha>" }.`,
|
|
744
|
+
`4. If NO default branch resolves, OR merge-base fails, OR merge-base === HEAD (nothing to measure), return { "resolved": false, "reason": "<short reason, e.g. no default branch / detached / merge-base===HEAD>" }.`,
|
|
745
|
+
`Do NOT do any other work. ONLY run git and report.`,
|
|
746
|
+
].join("\n"),
|
|
747
|
+
{ label: "m92:shrink-base", phase: "Synthesis", schema: SHRINK_BASE_SCHEMA, model: "haiku" }
|
|
748
|
+
).catch((e) => ({ resolved: false, reason: `shrink-base agent error: ${e && e.message}` }));
|
|
749
|
+
|
|
750
|
+
let shrink = null;
|
|
751
|
+
let shrinkSkipReason = null;
|
|
752
|
+
if (shrinkBase && shrinkBase.resolved && shrinkBase.base) {
|
|
753
|
+
const sm = await runShrinkMetric(projectDir, `${shrinkBase.base}..HEAD`, "m92:shrink-metric", "Synthesis");
|
|
754
|
+
if (sm.ok && sm.envelope && typeof sm.envelope.netLoc === "number" && typeof sm.envelope.leaner === "boolean") {
|
|
755
|
+
shrink = { netLoc: sm.envelope.netLoc, leaner: sm.envelope.leaner, insertions: sm.envelope.insertions, deletions: sm.envelope.deletions, filesAdded: sm.envelope.filesAdded, filesRemoved: sm.envelope.filesRemoved, filesModified: sm.envelope.filesModified };
|
|
756
|
+
log(`M92 shrink-metric: netLoc=${shrink.netLoc} leaner=${shrink.leaner} (+${shrink.insertions}/-${shrink.deletions}) — surfacing as the orthogonal leanness dimension`);
|
|
757
|
+
} else {
|
|
758
|
+
shrinkSkipReason = `shrink-metric CLI did not return a usable metric (exitCode=${sm.exitCode}) — SKIPPED, not fabricated`;
|
|
759
|
+
log(`M92 shrink-metric: SKIP — ${shrinkSkipReason}`);
|
|
760
|
+
}
|
|
761
|
+
} else {
|
|
762
|
+
shrinkSkipReason = `diff base unavailable (${(shrinkBase && shrinkBase.reason) || "unknown"}) — shrink-metric SKIPPED, never fabricated`;
|
|
763
|
+
log(`M92 shrink-metric: SKIP — ${shrinkSkipReason}`);
|
|
764
|
+
}
|
|
765
|
+
|
|
696
766
|
phase("Synthesis");
|
|
697
767
|
const synthesisPrompt = [
|
|
698
768
|
`You are the synthesis agent. Three orthogonal validators have run.`,
|
|
@@ -710,7 +780,15 @@ const synthesisPrompt = [
|
|
|
710
780
|
`- VERIFIED-WITH-WARNINGS if: Red Team GRUDGING-PASS, QA suite green, contracts compliant, AND any of: code-review ultra has "important" findings, OR skipUltra=true (reason: ${skipUltraReason || "(none — would have failed above)"}), OR QA shallowTests.length === 1 (single non-core).`,
|
|
711
781
|
`- VERIFY-FAILED otherwise (Red Team FAIL, QA fail>0, contract violations>0, shallowTests ≥ 2 or in core paths).`,
|
|
712
782
|
``,
|
|
713
|
-
|
|
783
|
+
`**M92 SHRINK DIMENSION (ORTHOGONAL — do NOT fold into pass/fail):** a deterministic shrink-metric measured the milestone diff:`,
|
|
784
|
+
shrink
|
|
785
|
+
? ` shrink = ${JSON.stringify({ netLoc: shrink.netLoc, leaner: shrink.leaner })} (full: +${shrink.insertions}/-${shrink.deletions}, files +${shrink.filesAdded}/-${shrink.filesRemoved}/~${shrink.filesModified})`
|
|
786
|
+
: ` shrink = (SKIPPED — ${shrinkSkipReason}; report it as not-measured, NEVER fabricate a value)`,
|
|
787
|
+
shrink
|
|
788
|
+
? ` Copy this measured shrink object VERBATIM into the "shrink" field of your output ({ "netLoc": ${shrink.netLoc}, "leaner": ${shrink.leaner} }). It is MEASURED — do not recompute or alter it. If leaner=true, explicitly ACKNOWLEDGE in the summary that the milestone made the codebase smaller (net-negative LOC) as a SUCCESS dimension, ORTHOGONAL to the correctness verdict — a VERIFIED-or-not correctness result and a leaner:true result are independent and BOTH reported.`
|
|
789
|
+
: ` OMIT the "shrink" field (the metric was skipped — say so in the summary; do NOT invent a netLoc/leaner).`,
|
|
790
|
+
``,
|
|
791
|
+
`Return JSON per VERDICT_SCHEMA. Include blockingFindings listing concrete things that must fix${shrink ? ", and the measured shrink object" : ""}.`,
|
|
714
792
|
].join("\n");
|
|
715
793
|
|
|
716
794
|
const verdict = await agent(synthesisPrompt, {
|
|
@@ -730,5 +808,8 @@ return {
|
|
|
730
808
|
testDataPurge: td.envelope,
|
|
731
809
|
guardMap: { discovery: guardMapDiscovery, results: guardMapResults },
|
|
732
810
|
triad: triadResults,
|
|
811
|
+
// M92 D2 (keystone) — the measured leanness dimension, surfaced ALONGSIDE the
|
|
812
|
+
// overallVerdict enum (orthogonal; null + a reason when the diff base was unavailable).
|
|
813
|
+
shrink: shrink || { skipped: true, reason: shrinkSkipReason },
|
|
733
814
|
verdict,
|
|
734
815
|
};
|