@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.
@@ -0,0 +1,255 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * gsd-t-shrink-metric — M92 D2 (shrink-verdict, the keystone move)
5
+ *
6
+ * A deterministic leanness readout for a change. Today verify's `overallVerdict`
7
+ * is a pure AND of additive gates — the schema literally CANNOT say "we made it
8
+ * smaller." This module measures the change's net size from a `git diff --numstat`
9
+ * envelope so a NET-NEGATIVE (leaner) change becomes a first-class, ORTHOGONAL
10
+ * success dimension surfaced ALONGSIDE the pass/fail enum (never folded into it).
11
+ *
12
+ * MEASURED, not attested ([[feedback_measure_dont_claim]]): leanness comes from
13
+ * `git diff --numstat`, never an LLM judgment.
14
+ *
15
+ * Output: { filesAdded, filesRemoved, filesModified, insertions, deletions, netLoc, leaner }
16
+ * netLoc = insertions - deletions
17
+ * leaner = netLoc <= 0 (removed at least as much as it added)
18
+ *
19
+ * Input — EITHER:
20
+ * --numstat <path|-> parse a PRE-CAPTURED `git diff --numstat` string (testable
21
+ * with no repo; `-` reads stdin).
22
+ * --range <base>..<head> --project-dir <p> compute LIVE (runs git itself).
23
+ *
24
+ * numstat grammar (porcelain `git diff --numstat`): one line per path,
25
+ * <insertions>\t<deletions>\t<path>
26
+ * where a BINARY file emits `-\t-\t<path>` (no textual LOC — counted as a modified
27
+ * file with 0 loc contribution). File add/remove/modify is inferred from the
28
+ * `--numstat` line alone WITHOUT a name-status pass:
29
+ * - deletions>0 AND insertions==0 → a file with only removals → filesRemoved
30
+ * - insertions>0 AND deletions==0 → a file with only additions → filesAdded
31
+ * - both>0, OR a binary line → filesModified
32
+ * (This is a heuristic over numstat: a wholly-new file shows insertions/0, a wholly-
33
+ * deleted file shows 0/deletions. It is deterministic and sufficient for the leanness
34
+ * readout; netLoc/leaner — the load-bearing signal — are EXACT regardless of the
35
+ * add/remove/modify split.)
36
+ *
37
+ * Hard engineering bar (mirror gsd-t-guard-map.cjs): zero deps (Node built-ins
38
+ * only), never throws (bad input → exitCode 64, never an uncaught throw),
39
+ * pure parsing. Deterministic — ZERO LLM judgment.
40
+ *
41
+ * Exit: 0 metric computed · 64 bad input.
42
+ */
43
+
44
+ const fs = require("node:fs");
45
+ const { execFileSync } = require("node:child_process");
46
+
47
+ // ─── numstat parsing (pure) ────────────────────────────────────────────────
48
+
49
+ /**
50
+ * Parse a `git diff --numstat` string into the shrink metric. Pure; never throws.
51
+ * @param {string} numstat - raw numstat text
52
+ * @returns {{ filesAdded, filesRemoved, filesModified, insertions, deletions, netLoc, leaner, files }}
53
+ */
54
+ function parseNumstat(numstat) {
55
+ const text = String(numstat == null ? "" : numstat);
56
+ const lines = text.split(/\r?\n/);
57
+
58
+ let filesAdded = 0;
59
+ let filesRemoved = 0;
60
+ let filesModified = 0;
61
+ let insertions = 0;
62
+ let deletions = 0;
63
+ let files = 0;
64
+
65
+ for (const raw of lines) {
66
+ const line = raw.replace(/\r$/, "");
67
+ if (line.trim() === "") continue;
68
+ // numstat columns are TAB-separated: <ins>\t<del>\t<path>. Be tolerant of
69
+ // runs of whitespace (git uses a single tab, but stay robust).
70
+ const m = line.match(/^\s*(-|\d+)\s+(-|\d+)\s+(.+)$/);
71
+ if (!m) {
72
+ // A non-empty line that is not a numstat row → malformed input.
73
+ return { _malformed: true, badLine: line.slice(0, 200) };
74
+ }
75
+ files += 1;
76
+ const insTok = m[1];
77
+ const delTok = m[2];
78
+ const binary = insTok === "-" || delTok === "-";
79
+
80
+ if (binary) {
81
+ // A binary change: counted as a modified file, 0 textual loc contribution.
82
+ filesModified += 1;
83
+ continue;
84
+ }
85
+
86
+ const ins = Number(insTok);
87
+ const del = Number(delTok);
88
+ // Number() of a \d+ token is always a finite non-negative integer here.
89
+ insertions += ins;
90
+ deletions += del;
91
+
92
+ if (del > 0 && ins === 0) filesRemoved += 1;
93
+ else if (ins > 0 && del === 0) filesAdded += 1;
94
+ else filesModified += 1; // both>0, or 0/0 (an empty/mode-only change)
95
+ }
96
+
97
+ const netLoc = insertions - deletions;
98
+ return {
99
+ filesAdded,
100
+ filesRemoved,
101
+ filesModified,
102
+ insertions,
103
+ deletions,
104
+ netLoc,
105
+ leaner: netLoc <= 0,
106
+ files,
107
+ };
108
+ }
109
+
110
+ // ─── live git (only on the --range path) ───────────────────────────────────
111
+
112
+ /**
113
+ * Capture `git diff --numstat <range>` for a project. Returns { ok, numstat } or
114
+ * { ok:false, reason }. Never throws.
115
+ * @param {string} range - e.g. "<base>..HEAD"
116
+ * @param {string} projectDir
117
+ */
118
+ function captureNumstat(range, projectDir) {
119
+ // SECURITY (M92 Red Team BUG-1, [[feedback_defense_in_depth_at_adapters]]):
120
+ // `execFileSync` blocks SHELL injection but NOT git-ARGUMENT injection — a range
121
+ // beginning with `-` is parsed by git as an OPTION (e.g. `--output=<path>` makes
122
+ // git OVERWRITE an arbitrary file). The range is LLM-derived upstream (verify
123
+ // computes the base via a haiku agent), so it is untrusted. Reject any
124
+ // dash-leading / non-string / empty range BEFORE the git call. The trailing `--`
125
+ // is defense-in-depth (NOT sufficient alone — `--output` is honored before `--`).
126
+ if (typeof range !== "string" || range === "" || range.startsWith("-")) {
127
+ return { ok: false, reason: `invalid range (refused: must be a non-empty, non-option string): ${JSON.stringify(range)}` };
128
+ }
129
+ try {
130
+ const out = execFileSync("git", ["diff", "--numstat", range, "--"], {
131
+ cwd: projectDir || ".",
132
+ encoding: "utf8",
133
+ stdio: ["ignore", "pipe", "pipe"],
134
+ });
135
+ return { ok: true, numstat: out };
136
+ } catch (e) {
137
+ return { ok: false, reason: `git diff failed: ${(e && e.message) || "unknown"}` };
138
+ }
139
+ }
140
+
141
+ function readNumstatFile(p) {
142
+ // `-` → stdin (fd 0). Otherwise a path.
143
+ if (p === "-") return fs.readFileSync(0, "utf8");
144
+ return fs.readFileSync(p, "utf8");
145
+ }
146
+
147
+ // ─── driver ─────────────────────────────────────────────────────────────────
148
+
149
+ /**
150
+ * Compute the shrink metric from options. Never throws — bad input → exitCode 64.
151
+ * @param {{ numstat?:string, range?:string, projectDir?:string }} o
152
+ * @returns {{ ok, exitCode, ... }}
153
+ */
154
+ function runMetric(o) {
155
+ const opt = (o && typeof o === "object") ? o : {};
156
+ const hasNumstat = typeof opt.numstat === "string" && opt.numstat.length > 0;
157
+ const hasRange = typeof opt.range === "string" && opt.range.length > 0;
158
+
159
+ if (!hasNumstat && !hasRange) {
160
+ return { ok: false, exitCode: 64, reason: "need --numstat <path|-> OR --range <base>..<head>" };
161
+ }
162
+ if (hasNumstat && hasRange) {
163
+ return { ok: false, exitCode: 64, reason: "give EITHER --numstat OR --range, not both" };
164
+ }
165
+
166
+ let numstatText;
167
+ let source;
168
+ if (hasNumstat) {
169
+ source = `numstat:${opt.numstat}`;
170
+ try {
171
+ numstatText = readNumstatFile(opt.numstat);
172
+ } catch (e) {
173
+ return { ok: false, exitCode: 64, reason: `cannot read --numstat input: ${(e && e.message) || "unknown"}`, source };
174
+ }
175
+ } else {
176
+ source = `range:${opt.range}`;
177
+ const cap = captureNumstat(opt.range, opt.projectDir);
178
+ if (!cap.ok) {
179
+ // A git failure on the live path is BAD INPUT (unresolvable range / not a repo)
180
+ // → exit 64. The verify workflow turns this into a logged skip-with-reason,
181
+ // never a fabricated metric.
182
+ return { ok: false, exitCode: 64, reason: cap.reason, source };
183
+ }
184
+ numstatText = cap.numstat;
185
+ }
186
+
187
+ const parsed = parseNumstat(numstatText);
188
+ if (parsed._malformed) {
189
+ return { ok: false, exitCode: 64, reason: `malformed numstat line: "${parsed.badLine}"`, source };
190
+ }
191
+
192
+ return {
193
+ ok: true,
194
+ exitCode: 0,
195
+ source,
196
+ filesAdded: parsed.filesAdded,
197
+ filesRemoved: parsed.filesRemoved,
198
+ filesModified: parsed.filesModified,
199
+ insertions: parsed.insertions,
200
+ deletions: parsed.deletions,
201
+ netLoc: parsed.netLoc,
202
+ leaner: parsed.leaner,
203
+ files: parsed.files,
204
+ };
205
+ }
206
+
207
+ // ─── CLI ─────────────────────────────────────────────────────────────────
208
+
209
+ function parseArgs(argv) {
210
+ const o = { numstat: null, range: null, projectDir: ".", help: false };
211
+ for (let i = 0; i < argv.length; i++) {
212
+ const a = argv[i];
213
+ if (a === "--help" || a === "-h") o.help = true;
214
+ else if (a === "--numstat") o.numstat = argv[++i];
215
+ else if (a === "--range") o.range = argv[++i];
216
+ else if (a === "--project-dir" || a === "--projectDir") o.projectDir = argv[++i];
217
+ else if (a === "--json") {/* default output is JSON */}
218
+ }
219
+ return o;
220
+ }
221
+
222
+ const HELP = `Usage:
223
+ gsd-t shrink-metric --numstat <path|-> [--json]
224
+ gsd-t shrink-metric --range <base>..<head> --project-dir <p> [--json]
225
+
226
+ The M92 shrink-metric (D2). Computes a deterministic leanness readout from a
227
+ \`git diff --numstat\` envelope: { filesAdded, filesRemoved, filesModified,
228
+ insertions, deletions, netLoc, leaner } where netLoc = insertions - deletions and
229
+ leaner = netLoc <= 0. MEASURED, zero LLM judgment.
230
+
231
+ --numstat PATH parse a pre-captured \`git diff --numstat\` string (\`-\` = stdin).
232
+ --range R compute live: runs \`git diff --numstat <R>\` in --project-dir.
233
+ --project-dir P cwd for the live --range git call (default ".").
234
+
235
+ Exit: 0 metric computed · 64 bad input (no source, both sources, unreadable, or
236
+ malformed numstat / unresolvable range).`;
237
+
238
+ function main() {
239
+ const o = parseArgs(process.argv.slice(2));
240
+ if (o.help) { process.stdout.write(HELP + "\n"); process.exit(0); }
241
+ let res;
242
+ try {
243
+ res = runMetric(o);
244
+ } catch (e) {
245
+ // Defense in depth — runMetric is written never to throw, but the contract
246
+ // mandates the module never throws, so any escape maps to 64.
247
+ res = { ok: false, exitCode: 64, reason: `metric-error: ${e && e.message}` };
248
+ }
249
+ process.stdout.write(JSON.stringify(res, null, 2) + "\n");
250
+ process.exit(res.exitCode);
251
+ }
252
+
253
+ if (require.main === module) main();
254
+
255
+ module.exports = { runMetric, parseNumstat, captureNumstat };
package/bin/gsd-t.js CHANGED
@@ -395,6 +395,53 @@ function addHeartbeatHook(hooks, event, cmd) {
395
395
  return true;
396
396
  }
397
397
 
398
+ // ─── Brevity Guard (M93) ────────────────────────────────────────────────────
399
+ // A BLOCKING Stop hook (not async — it must run synchronously to block a verbose
400
+ // answer-mode reply). Enforces the Reader Contract: answer-first, no preamble,
401
+ // jargon glossed. Fail-open by design (the script never blocks on error).
402
+
403
+ const BREVITY_GUARD_SCRIPT = "gsd-t-brevity-guard.js";
404
+
405
+ function installBrevityGuard() {
406
+ ensureDir(SCRIPTS_DIR);
407
+ const src = path.join(PKG_SCRIPTS, BREVITY_GUARD_SCRIPT);
408
+ const dest = path.join(SCRIPTS_DIR, BREVITY_GUARD_SCRIPT);
409
+ if (!fs.existsSync(src)) { warn("Brevity-guard script not found in package — skipping"); return; }
410
+
411
+ const srcContent = fs.readFileSync(src, "utf8");
412
+ const destContent = fs.existsSync(dest) ? fs.readFileSync(dest, "utf8") : "";
413
+ if (normalizeEol(srcContent) !== normalizeEol(destContent)) {
414
+ copyFile(src, dest, BREVITY_GUARD_SCRIPT);
415
+ } else {
416
+ info("Brevity-guard script unchanged");
417
+ }
418
+
419
+ const parsed = readSettingsJson();
420
+ if (parsed === null && fs.existsSync(SETTINGS_JSON)) {
421
+ warn("settings.json has invalid JSON — cannot configure brevity-guard hook");
422
+ return;
423
+ }
424
+ const settings = parsed || {};
425
+ if (!settings.hooks) settings.hooks = {};
426
+ if (!settings.hooks.Stop) settings.hooks.Stop = [];
427
+
428
+ const already = settings.hooks.Stop.some((entry) =>
429
+ entry.hooks && entry.hooks.some((h) => h.command && h.command.includes(BREVITY_GUARD_SCRIPT))
430
+ );
431
+ if (already) { info("Brevity-guard hook already configured"); return; }
432
+
433
+ // Blocking (no async) so the Stop-hook block decision is honored.
434
+ const cmd = `node "${dest.replace(/\\/g, "\\\\")}"`;
435
+ settings.hooks.Stop.push({ matcher: "", hooks: [{ type: "command", command: cmd }] });
436
+
437
+ if (!isSymlink(SETTINGS_JSON)) {
438
+ fs.writeFileSync(SETTINGS_JSON, JSON.stringify(settings, null, 2));
439
+ success("Brevity-guard Stop hook configured in settings.json");
440
+ } else {
441
+ warn("Skipping settings.json write — target is a symlink");
442
+ }
443
+ }
444
+
398
445
  // ─── Context Meter ──────────────────────────────────────────────────────────
399
446
 
400
447
  const CONTEXT_METER_SCRIPT = "gsd-t-context-meter.js";
@@ -1613,6 +1660,9 @@ async function doInstall(opts = {}) {
1613
1660
  heading("Heartbeat (Real-time Events)");
1614
1661
  installHeartbeat();
1615
1662
 
1663
+ heading("Brevity Guard (M93 — concise replies)");
1664
+ installBrevityGuard();
1665
+
1616
1666
  heading("Update Check (Session Start)");
1617
1667
  installUpdateCheck();
1618
1668
 
@@ -2592,6 +2642,9 @@ const PROJECT_BIN_TOOLS = [
2592
2642
  // divergence-grammar = §4 parse/format round-trip (G4).
2593
2643
  "gsd-t-guard-map.cjs", "gsd-t-guard-map-derive.cjs", "gsd-t-milestone-state.cjs",
2594
2644
  "gsd-t-rule-consume.cjs", "gsd-t-divergence-grammar.cjs",
2645
+ // M93 — jargon-gloss lint for written docs (the file surface the brevity-guard
2646
+ // Stop hook can't reach). Propagated so a project's doc checks can invoke it.
2647
+ "gsd-t-jargon-lint.cjs",
2595
2648
  ];
2596
2649
 
2597
2650
  // Files that older versions of this installer copied into project bin/ but
@@ -2,6 +2,12 @@
2
2
 
3
3
  You are the lead agent. Define a new milestone by invoking the generic upper-stage Workflow at `templates/workflows/gsd-t-phase.workflow.js` with `phase: "milestone"`. A milestone is a significant deliverable (e.g., "User Authentication", "Payment Integration").
4
4
 
5
+ ## Default altitude: smallest change that hits the crux (M92)
6
+
7
+ **Before reaching for milestone ceremony, ask whether the ask even needs a milestone.** The default recommendation is the SMALLEST change that hits the crux — if the work is a one-file change or a focused fix, `/gsd-t-quick` (do it directly) is the answer, not a milestone with partition + plan→execute + competition.
8
+
9
+ Milestone ceremony — definition, partition into domains, plan→execute waves, Competition Mode — is the **opt-in escalation**, justified when the crux genuinely needs cross-domain coordination or carries real uncertainty (multiple coupled deliverables, several domains, a decomposition decision worth competing). It is never the implied-default "bigger is more rigorous." If you cannot name why the crux needs a milestone, the smaller path IS the recommendation.
10
+
5
11
  ## What this command does
6
12
 
7
13
  ```
@@ -2,6 +2,12 @@
2
2
 
3
3
  You are executing a small, focused task that doesn't need full phase planning. This is for bug fixes, config changes, small features, and ad-hoc work.
4
4
 
5
+ ## Default altitude: smallest change that hits the crux (M92)
6
+
7
+ **The default recommendation is the SMALLEST change that hits the crux — do it directly.** Before choosing scope: state the crux in one line, grep/read what already exists, then make the smallest one-file change that hits it — editing inward at the source, not outward at the N consumers.
8
+
9
+ Ceremony — the full execute workflow, partition, plan→execute, competition — is the **opt-in escalation**, reached for ONLY when the crux genuinely needs cross-domain coordination or real uncertainty (see Step 2's boundary check). It is never the implied-default "Recommended." If you cannot name why the crux needs ceremony, the smallest change IS the answer.
10
+
5
11
  ## Argument Parsing
6
12
 
7
13
  Parse `$ARGUMENTS`. The first positional arg is the quick task description (`$TASK`). M43 D4 removed the `--watch` opt-out; `--in-session`/`--headless` were never shipped. Under `.gsd-t/contracts/headless-default-contract.md` **v2.0.0** the inner subagent spawn (Step 0.1 fresh-dispatch) and all validation spawns (Design Verification Step 5.25, Red Team Step 5.5, doc-ripple Step 6) go headless unconditionally. A legacy `--watch` token is accepted but ignored (stderr deprecation line).
@@ -192,13 +198,14 @@ Based on $ARGUMENTS, determine:
192
198
  - Does it cross a domain boundary?
193
199
  - Does it affect any existing contract?
194
200
 
195
- ### If it crosses boundaries or affects contracts:
196
- Warn the user:
197
- "This change touches {domain-1} and {domain-2} and may affect {contract}.
198
- Should I proceed with quick mode or use the full execute workflow?"
201
+ ### Default within a single domain or pre-partition:
202
+ The smallest change that hits the crux is the recommendation. Proceed directly.
199
203
 
200
- ### If it's within a single domain or pre-partition:
201
- Proceed.
204
+ ### Escalate to ceremony ONLY when the crux needs it:
205
+ If — and only if — the change genuinely crosses domain boundaries or affects a contract (real cross-domain coordination / real uncertainty), warn the user:
206
+ "This change touches {domain-1} and {domain-2} and may affect {contract}.
207
+ The smallest direct change does not contain the crux — escalate to the full execute workflow?"
208
+ This is the opt-in escalation, justified by the crux — not a default.
202
209
 
203
210
  ## Step 3: Execute
204
211
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "4.8.10",
3
+ "version": "4.9.12",
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",