@tekyzinc/gsd-t 4.8.10 → 4.9.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,340 @@
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
+ const JARGON_PATTERNS = [
67
+ /\bS2-M\d+\b/, // S2-M7
68
+ /\bHC-\d+\b/, // HC-003
69
+ /\bM\d+(?:-D\d+(?:-T\d+)?)?\b/, // M92, M92-D1, M92-D1-T3
70
+ ];
71
+
72
+ // Narration openers: first-person "about to" framing — intent without content.
73
+ const NARRATION_OPENER =
74
+ /^(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)\b/i;
75
+
76
+ // ── Transcript tail-scan (REUSED from gsd-t-conversation-capture.js) ─────────
77
+ // Pull the most recent assistant turn from a Claude Code transcript JSONL by
78
+ // scanning from the tail. Returns { text, hasMutatingTool, toolOnly } or null.
79
+ // Distinguishes text blocks (the answer) from tool_use blocks (the action signal).
80
+ function _safeTranscriptPath(p) {
81
+ if (typeof p !== "string" || p.length === 0) return null;
82
+ if (!path.isAbsolute(p)) return null;
83
+ const home = process.env.HOME || os.homedir();
84
+ if (!home) return null;
85
+ const allowedRoot = path.resolve(home, ".claude", "projects") + path.sep;
86
+ const resolved = path.resolve(p);
87
+ if (!resolved.startsWith(allowedRoot)) return null;
88
+ return resolved;
89
+ }
90
+
91
+ function _readFileTail(filePath, bytes) {
92
+ let fd = -1;
93
+ try {
94
+ const st = fs.statSync(filePath);
95
+ if (!st.isFile()) return "";
96
+ const size = st.size;
97
+ if (size === 0) return "";
98
+ const want = Math.min(bytes, size);
99
+ const start = size - want;
100
+ fd = fs.openSync(filePath, "r");
101
+ const buf = Buffer.alloc(want);
102
+ fs.readSync(fd, buf, 0, want, start);
103
+ let str = buf.toString("utf8");
104
+ if (start > 0) {
105
+ const nl = str.indexOf("\n");
106
+ if (nl >= 0) str = str.slice(nl + 1);
107
+ }
108
+ return str;
109
+ } catch (_) {
110
+ return "";
111
+ } finally {
112
+ if (fd >= 0) { try { fs.closeSync(fd); } catch (_) { /* noop */ } }
113
+ }
114
+ }
115
+
116
+ const MUTATING_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
117
+ // Bash command is a mutation if it invokes a write-ish verb (heuristic, conservative).
118
+ const BASH_MUTATION =
119
+ /\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/;
120
+
121
+ function _isMutatingToolBlock(b) {
122
+ if (!b || b.type !== "tool_use") return false;
123
+ if (MUTATING_TOOLS.has(b.name)) return true;
124
+ if (b.name === "Bash") {
125
+ const cmd = b.input && typeof b.input.command === "string" ? b.input.command : "";
126
+ return BASH_MUTATION.test(cmd);
127
+ }
128
+ return false;
129
+ }
130
+
131
+ function _readAssistantFromTranscript(transcriptPath) {
132
+ const safe = _safeTranscriptPath(transcriptPath);
133
+ if (!safe) return null;
134
+ const tail = _readFileTail(safe, 64 * 1024);
135
+ if (!tail) return null;
136
+ const lines = tail.split("\n");
137
+ for (let i = lines.length - 1; i >= 0; i--) {
138
+ const line = lines[i];
139
+ if (!line) continue;
140
+ let row;
141
+ try { row = JSON.parse(line); } catch (_) { continue; }
142
+ if (!row || row.type !== "assistant") continue;
143
+ if (row.isSidechain === true) continue;
144
+ const msg = row.message;
145
+ if (!msg) continue;
146
+ const blocks = msg.content;
147
+ if (typeof blocks === "string") {
148
+ return { text: blocks, hasMutatingTool: false, toolOnly: false };
149
+ }
150
+ if (!Array.isArray(blocks)) continue;
151
+ const texts = [];
152
+ let hasMutatingTool = false;
153
+ let hasToolUse = false;
154
+ for (const b of blocks) {
155
+ if (b && b.type === "text" && typeof b.text === "string") texts.push(b.text);
156
+ if (b && b.type === "tool_use") {
157
+ hasToolUse = true;
158
+ if (_isMutatingToolBlock(b)) hasMutatingTool = true;
159
+ }
160
+ }
161
+ const text = texts.join("");
162
+ if (text.length === 0 && !hasToolUse) continue; // empty, keep scanning
163
+ return { text, hasMutatingTool, toolOnly: text.trim().length === 0 && hasToolUse };
164
+ }
165
+ return null;
166
+ }
167
+
168
+ // ── Detection (pure) ─────────────────────────────────────────────────────────
169
+
170
+ // Strip the dated status banner (first line, "Day: Mon DD, YYYY ... — GSD-T ...").
171
+ function _stripBanner(text) {
172
+ const nl = text.indexOf("\n");
173
+ const first = nl >= 0 ? text.slice(0, nl) : text;
174
+ if (/^[A-Z][a-z]{2,8}:\s+\w{3}\s+\d{1,2},\s+\d{4}/.test(first.trim())) {
175
+ return nl >= 0 ? text.slice(nl + 1) : "";
176
+ }
177
+ return text;
178
+ }
179
+
180
+ // Remove fenced code blocks and table rows (content, never preamble).
181
+ function _stripStructured(text) {
182
+ const out = [];
183
+ let inFence = false;
184
+ for (const raw of text.split("\n")) {
185
+ const line = raw.trim();
186
+ if (/^```/.test(line)) { inFence = !inFence; continue; }
187
+ if (inFence) continue;
188
+ if (/^\|.*\|/.test(line)) continue; // table row
189
+ out.push(raw);
190
+ }
191
+ return out.join("\n");
192
+ }
193
+
194
+ // Split prose into sentences (coarse — period/!/? or newline boundary).
195
+ function _sentences(text) {
196
+ return text
197
+ .replace(/\s+/g, " ")
198
+ .split(/(?<=[.!?])\s+/)
199
+ .map((s) => s.trim())
200
+ .filter((s) => s.length > 0);
201
+ }
202
+
203
+ // Count narration sentences in the LEADING region (before substantive content).
204
+ // A reply may open with ONE short acknowledgement/framing sentence (e.g. "Important
205
+ // question." / "Fair challenge.") — that's wanted, not preamble. But narration that
206
+ // STACKS after such an ack ("Let me find the record. Before I answer, I should check…")
207
+ // is the egregious case the user flagged. So: tolerate a single short non-narration
208
+ // lead sentence, then count narration; stop at the first substantive (non-narration,
209
+ // non-short-ack) sentence. Block fires at >=2 narration in that leading region.
210
+ function _leadingNarrationCount(prose) {
211
+ const sentences = _sentences(prose);
212
+ let count = 0;
213
+ let ackUsed = false;
214
+ for (const s of sentences) {
215
+ if (NARRATION_OPENER.test(s)) { count++; continue; }
216
+ // A short non-narration sentence (<= ~12 words) is treated as an allowed
217
+ // acknowledgement/framing line ONCE; a second one, or any longer sentence,
218
+ // is real content → the leading region ends.
219
+ const words = s.trim().split(/\s+/).length;
220
+ if (!ackUsed && words <= 12) { ackUsed = true; continue; }
221
+ break;
222
+ }
223
+ return count;
224
+ }
225
+
226
+ // Find a bare unglossed high-signal jargon token (first occurrence, no gloss).
227
+ // A gloss = parenthetical or quote in the SAME sentence containing the token.
228
+ function _findBareJargon(prose) {
229
+ const sentences = _sentences(prose);
230
+ const seen = new Set();
231
+ for (const s of sentences) {
232
+ for (const re of JARGON_PATTERNS) {
233
+ const m = s.match(re);
234
+ if (!m) continue;
235
+ const token = m[0];
236
+ if (seen.has(token)) continue;
237
+ // skip allowlisted ALL-CAPS lookalikes (none of these patterns are, but defensive)
238
+ if (ALLOWLIST.has(token)) continue;
239
+ seen.add(token);
240
+ // Glossed if a parenthetical or quoted phrase appears in the same sentence.
241
+ const hasGloss = /\([^)]*\)/.test(s) || /["“'][^"”']{4,}["”']/.test(s);
242
+ if (!hasGloss) return token;
243
+ }
244
+ }
245
+ return null;
246
+ }
247
+
248
+ // NOTE: bare ALL-CAPS acronyms (TTL, TLS, CDN, …) are intentionally NOT policed.
249
+ // The spec lists them but caps it "extend conservatively / do NOT police every
250
+ // acronym" — generic ALL-CAPS is a false-positive engine on common tech terms.
251
+ // Only the high-signal code-token patterns above (S2-M\d, HC-\d, M\d[-D\d][-T\d])
252
+ // are unambiguous jargon worth a forced gloss. The ALLOWLIST is retained for any
253
+ // future, deliberate extension of JARGON_PATTERNS into acronym territory.
254
+
255
+ // Core detector. mode: "answer" | "action". Returns { block, reason }.
256
+ function detect(text, mode) {
257
+ if (mode === "action") return { block: false };
258
+ if (typeof text !== "string" || text.trim().length === 0) return { block: false };
259
+
260
+ const prose = _stripStructured(_stripBanner(text));
261
+
262
+ const narration = _leadingNarrationCount(prose);
263
+ if (narration >= 2) {
264
+ return {
265
+ block: true,
266
+ reason: "Answer-first: lead with the answer, not " + narration +
267
+ " stacked 'about-to' narration sentences. Cut the preamble; state the answer, then detail.",
268
+ };
269
+ }
270
+
271
+ const jargon = _findBareJargon(prose);
272
+ if (jargon) {
273
+ return {
274
+ block: true,
275
+ reason: "Gloss '" + jargon + "' on first use (one plain-language clause in parens). Bare code-token, no gloss.",
276
+ };
277
+ }
278
+
279
+ return { block: false };
280
+ }
281
+
282
+ // ── Mode classification from a transcript turn ───────────────────────────────
283
+ function classifyMode(turn) {
284
+ if (turn && (turn.hasMutatingTool || turn.toolOnly)) return "action";
285
+ return "answer";
286
+ }
287
+
288
+ // ── Entry: process a Stop-hook payload → { block, reason } ───────────────────
289
+ function processPayload(payload) {
290
+ if (!payload || typeof payload !== "object") return { block: false }; // fail-open
291
+ if (payload.stop_hook_active) return { block: false }; // loop-guard (truthy — never re-block a re-entry, even if the flag arrives non-boolean)
292
+ const turn = _readAssistantFromTranscript(payload.transcript_path);
293
+ if (!turn) return { block: false }; // no message → fail-open
294
+ return detect(turn.text, classifyMode(turn));
295
+ }
296
+
297
+ // ── CLI / hook driver ────────────────────────────────────────────────────────
298
+ function _emitBlock(reason) {
299
+ process.stdout.write(JSON.stringify({ decision: "block", reason }) + "\n");
300
+ }
301
+
302
+ function runCli(argv) {
303
+ // --text "<reply>" --mode answer|action
304
+ let text = null, mode = "answer";
305
+ for (let i = 0; i < argv.length; i++) {
306
+ if (argv[i] === "--text") { text = argv[i + 1]; i++; }
307
+ else if (argv[i] === "--mode") { mode = argv[i + 1]; i++; }
308
+ }
309
+ const res = detect(typeof text === "string" ? text : "", mode === "action" ? "action" : "answer");
310
+ if (res.block) { _emitBlock(res.reason); process.exit(1); }
311
+ process.exit(0);
312
+ }
313
+
314
+ function main() {
315
+ try {
316
+ const argv = process.argv.slice(2);
317
+ if (argv.includes("--text")) { runCli(argv); return; }
318
+
319
+ let input = "";
320
+ process.stdin.setEncoding("utf8");
321
+ process.stdin.on("data", (c) => { input += c; });
322
+ process.stdin.on("end", () => {
323
+ try {
324
+ let payload;
325
+ try { payload = JSON.parse(input); } catch { process.exit(0); } // fail-open
326
+ const res = processPayload(payload);
327
+ if (res.block) { _emitBlock(res.reason); process.exit(0); } // block via JSON, exit 0
328
+ process.exit(0); // allow
329
+ } catch { process.exit(0); } // fail-open
330
+ });
331
+ process.stdin.on("error", () => process.exit(0)); // fail-open
332
+ } catch { process.exit(0); } // fail-open
333
+ }
334
+
335
+ if (require.main === module) main();
336
+
337
+ module.exports = {
338
+ detect, classifyMode, processPayload, _findBareJargon,
339
+ _leadingNarrationCount, _readAssistantFromTranscript, ALLOWLIST,
340
+ };
@@ -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
- log(`M90 arch-trigger quick: fired=${triggerEnv.fired} reason=${triggerEnv.reason || "?"} provenByAdversaryOnly=${triggerEnv.provenByAdversaryOnly || false}`);
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
- `Return JSON per VERDICT_SCHEMA with blockingFindings listing concrete things that must fix.`,
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
  };