@tekyzinc/gsd-t 5.10.12 → 5.11.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,99 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [5.11.11] - 2026-08-09
6
+
7
+ ### Fixed — the reply shortener was shortening itself
8
+
9
+ The child Claude was started with the personal settings, which carry the very
10
+ Stop hook that spawned it. It answered in about 4 seconds, its own hook then saw
11
+ an answer over the 60-word threshold and spawned a THIRD Claude, and the outer
12
+ call waited ~46s for work it had caused — past its own 45s limit, so it was
13
+ killed and returned nothing. Every turn, since the day it shipped.
14
+
15
+ `--setting-sources project` starts the child without the personal layer.
16
+ Measured twice each: **54.2s/54.5s before, 7.9s/6.2s after**; through the real
17
+ hook, 8.6s / 10.1s / 10.8s, all producing a rewrite.
18
+
19
+ The trigger was always the child's own REPLY crossing 60 words, never the input
20
+ text — a 63-character prompt that produces a long answer is just as slow.
21
+
22
+ Two flags rejected by measurement and recorded so they are not retried: `--bare`
23
+ skips the keychain and the child answers "Not logged in"; `--settings '{}'`
24
+ cannot remove an inherited hook, because settings layers merge.
25
+
26
+ The `--disallowed-tools` flag from 5.11.10 is removed — its root cause was
27
+ disproved (52.6s with, 52.5s without), and leaving it would enshrine a wrong
28
+ explanation.
29
+
30
+ Separately: the hook captured the rewriter's stderr into a pipe it never read,
31
+ so the loud timeout warning added in 5.11.10 reached nobody. It is inherited
32
+ now — the let-it-through paths are approved passes *because* the reader can see
33
+ them happen.
34
+
35
+ - `bin/gsd-t-concise-rewrite.cjs`: the child no longer inherits the hook that spawned it
36
+ - `scripts/gsd-t-concise-hook.js`: the child's stderr reaches the screen
37
+ - `test/m107-concise-rewrite.test.js`: 3 tests pin the recursion, the two rejected flags, the stderr
38
+
39
+ ## [5.11.10] - 2026-08-09
40
+
41
+ ### Fixed — four safety gates were passing without checking anything
42
+
43
+ A branch warning turned into an audit. Every fault below is the same shape: a
44
+ check that verifies nothing and reports a pass.
45
+
46
+ - The branch check was dead in every scaffolded project. It matched only the
47
+ phrase `Expected branch:`, while the project template wrote a table row
48
+ labelled `Branch`. Nothing fills that row mechanically, so both sides
49
+ changed: the row is renamed and the reader accepts either shape.
50
+ - The same check blocked the workflow the house rules mandate — a worktree on a
51
+ feature branch is correct by design, yet was flagged. In a worktree a named
52
+ branch now passes and says why; a detached HEAD fails, because commits made
53
+ with no branch attached are easily lost.
54
+ - An unreadable `CLAUDE.md` passed as "no rules". It halts now.
55
+ - The entire preflight gate was decorative in every installed project:
56
+ `cli-preflight.cjs` shipped without its six checks, found an empty directory,
57
+ and returned `ok:true`. Measured live: `checks run: 0`. Directories under
58
+ `bin/` now ship, and an empty registry fails instead of passing.
59
+ - Install-repair overwrote in-progress edits in GSD-T's own repo, where `bin/`
60
+ is the source rather than a copy of the published build. It now refuses that
61
+ repo, identified by its package name.
62
+
63
+ ### Fixed — the reply shortener never ran once, then hung when it did
64
+
65
+ Two faults stacked, so neither was visible. The hook read only the last record
66
+ of a turn; a turn almost always ends with a tool call, so it found no prose and
67
+ gave up. Underneath that, the rewrite itself hung: given a reply full of file
68
+ paths and commands, the spawned Claude read them as work to do and went off
69
+ running them — 45 seconds, killed on timeout, empty output and empty stderr,
70
+ every turn. Shortening prose needs no tools, so they are now forbidden.
71
+ Measured end to end: 41s and nothing, to 10.9s and a correct rewrite. A timeout
72
+ now writes to stderr; silence is what let it hide.
73
+
74
+ ### Added — a new worktree arrives runnable
75
+
76
+ `git worktree add` brings only tracked files, so `.env`, local settings and
77
+ installed dependencies stayed behind and the folder was born unable to run.
78
+ Provisioning carries local config (permissions preserved, so a 600 secret does
79
+ not become world-readable), skips per-session `.gsd-t` state, and reinstalls
80
+ dependencies from the worktree's own lockfile.
81
+
82
+ - `bin/cli-preflight-checks/branch-guard.cjs`: reads both rule shapes, worktree-aware, halts on an unreadable file
83
+ - `bin/cli-preflight.cjs`: an empty check registry fails instead of passing
84
+ - `bin/gsd-t-worktree-detect.cjs`: shared worktree test using git's own answer
85
+ - `bin/gsd-t-worktree-provision.cjs`: carry config, skip state, install deps
86
+ - `bin/gsd-t-install-check.cjs`: refuses to "repair" the GSD-T source repo
87
+ - `bin/gsd-t-concise-rewrite.cjs`: no tools, and a loud timeout
88
+ - `scripts/gsd-t-concise-hook.js`: finds the turn's prose past its tool calls
89
+ - `bin/gsd-t.js`: directories under `bin/` now propagate
90
+ - `templates/CLAUDE-project.md`: the branch row the reader can actually find
91
+ - `test/m112-worktree-provision.test.js`, `test/m107-concise-rewrite.test.js`: 24 new tests
92
+
93
+ Known limitation: `contracts-stable` has never fired once (its pattern expects
94
+ emphasis after `Status`, every real file writes it before) and `ports-free`
95
+ reads a config key nothing writes. Both are recorded and left for a separate
96
+ pass.
97
+
5
98
  ## [5.10.10] - 2026-08-07
6
99
 
7
100
  ### Added — a project's CLAUDE.md is written from what actually happened in it
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v5.10.12** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v5.11.11** - 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.
@@ -3,40 +3,67 @@
3
3
  /**
4
4
  * branch-guard — verify current git branch matches CLAUDE.md "Expected branch" rule.
5
5
  *
6
- * Severity: error (blocks). If CLAUDE.md has no "Expected branch:" line, the check
7
- * passes with `msg: "no expected-branch rule set"` (info-grade pass).
6
+ * Severity: error (blocks). If CLAUDE.md declares no expected-branch rule, the
7
+ * check passes with a message that NAMES the gap ("NOT CHECKED ") rather than
8
+ * one that reads like approval: a pass that compared nothing must say so.
8
9
  *
9
- * Pure inspection runs `git branch --show-current` (read-only) and reads CLAUDE.md.
10
+ * The rule governs the MAIN checkout. Inside a linked worktree (a side copy of
11
+ * the repo) the house rules require a feature branch, so being off the declared
12
+ * branch there is correct, not a violation — the check passes and says why. The
13
+ * one genuinely dangerous state in a side copy is a detached HEAD, where commits
14
+ * belong to no branch and are easily lost; that still fails.
15
+ *
16
+ * An unreadable CLAUDE.md HALTS. Treating a permissions or corruption failure as
17
+ * "no rules to enforce" is a real failure wearing a pass.
18
+ *
19
+ * Pure inspection — runs read-only git commands and reads CLAUDE.md.
10
20
  */
11
21
 
12
22
  const fs = require('fs');
13
23
  const path = require('path');
14
24
  const { execSync } = require('child_process');
25
+ const { isLinkedWorktree } = require('../gsd-t-worktree-detect.cjs');
15
26
 
16
27
  const ID = 'branch-guard';
17
28
 
29
+ // Distinguishes "the file is not there" (ordinary) from "the file is there and
30
+ // could not be read" (a failure). Collapsing both to null is what let a real
31
+ // error pass as a clean run.
32
+ const NO_FILE = Symbol('no-claude-md');
33
+
18
34
  function _readClaudeMd(projectDir) {
19
35
  const file = path.join(projectDir, 'CLAUDE.md');
20
- if (!fs.existsSync(file)) return null;
21
- try {
22
- return fs.readFileSync(file, 'utf8');
23
- } catch (_) {
24
- return null;
25
- }
36
+ if (!fs.existsSync(file)) return NO_FILE;
37
+ // Deliberately no try/catch: an unreadable rules file is a real failure and
38
+ // must reach the caller.
39
+ return fs.readFileSync(file, 'utf8');
26
40
  }
27
41
 
42
+ /**
43
+ * Pull the expected branch out of CLAUDE.md, in either shape a project writes it.
44
+ *
45
+ * Nothing fills this in mechanically — /gsd-t-setup is a prose command, so a
46
+ * person or an assistant types the line by hand. Accepting only one shape is how
47
+ * the guard came to sit dead in every scaffolded project: the template wrote a
48
+ * table row and the reader looked for a sentence.
49
+ *
50
+ * Sentence: "Expected branch: main" "**Expected branch**: `develop`"
51
+ * Table row: "| Expected branch | `main` |"
52
+ *
53
+ * Returns the branch name, or an empty string when the project states no rule.
54
+ */
28
55
  function _extractExpectedBranch(text) {
29
- if (!text) return null;
30
- // Match `Expected branch:` (case-insensitive), allowing markdown emphasis like `**Expected branch**`,
31
- // backticks around the value, optional surrounding whitespace.
32
- // Examples that must match:
33
- // "Expected branch: main"
34
- // "Expected branch: `main`"
35
- // "**Expected branch**: `develop`"
36
- // "_Expected branch_: feature/foo"
37
- const re = /\*{0,2}\s*expected\s+branch\s*\*{0,2}\s*:\s*\**\s*`?([^\s`*\n]+)`?/i;
38
- const m = text.match(re);
39
- return m ? m[1].trim() : null;
56
+ if (!text || typeof text !== 'string') return '';
57
+
58
+ const row = text.match(
59
+ /^\s*\|\s*\**\s*expected\s+branch\s*\**\s*\|\s*`?([^\s`|*]+)`?\s*\|/im
60
+ );
61
+ if (row) return row[1].trim();
62
+
63
+ const line = text.match(/\*{0,2}\s*expected\s+branch\s*\*{0,2}\s*:\s*\**\s*`?([^\s`*\n|]+)`?/i);
64
+ if (line) return line[1].trim();
65
+
66
+ return '';
40
67
  }
41
68
 
42
69
  function _currentBranch(projectDir) {
@@ -50,18 +77,36 @@ function _currentBranch(projectDir) {
50
77
  }
51
78
 
52
79
  function run({ projectDir }) {
53
- const md = _readClaudeMd(projectDir);
54
- if (md == null) {
80
+ let md;
81
+ try {
82
+ md = _readClaudeMd(projectDir);
83
+ } catch (err) {
84
+ return {
85
+ ok: false,
86
+ msg:
87
+ 'CLAUDE.md exists but could not be read (' +
88
+ ((err && err.message) || err) +
89
+ ') — the branch rule cannot be checked; fix the file or its permissions',
90
+ };
91
+ }
92
+
93
+ if (md === NO_FILE) {
55
94
  return {
56
95
  ok: true,
57
- msg: 'no CLAUDE.md found, skipping',
96
+ msg: 'NOT CHECKED — no CLAUDE.md in this project',
58
97
  };
59
98
  }
99
+
100
+ // A project stating no rule is a fact about the project, and the verdict below
101
+ // reports exactly that. Nothing was looked up and lost, and no value stands in
102
+ // for a missing one.
60
103
  const expected = _extractExpectedBranch(md);
61
- if (!expected) {
104
+ const declaresARule = expected.length > 0;
105
+
106
+ if (declaresARule === false) {
62
107
  return {
63
108
  ok: true,
64
- msg: 'no expected-branch rule set',
109
+ msg: 'NOT CHECKED — this project declares no expected branch',
65
110
  };
66
111
  }
67
112
 
@@ -71,16 +116,49 @@ function run({ projectDir }) {
71
116
  } catch (err) {
72
117
  return {
73
118
  ok: false,
74
- msg: 'git branch --show-current failed: ' + (err && err.message || err),
119
+ msg: 'git branch --show-current failed: ' + ((err && err.message) || err),
75
120
  details: { expected },
76
121
  };
77
122
  }
78
123
 
79
- if (!actual) {
124
+ // A side copy of the repo is MEANT to be on its own branch — the house rules
125
+ // require it. Only the main checkout is held to the declared branch.
126
+ let inWorktree;
127
+ try {
128
+ inWorktree = isLinkedWorktree(projectDir);
129
+ } catch (err) {
130
+ return {
131
+ ok: false,
132
+ msg: 'could not tell whether this is a worktree: ' + ((err && err.message) || err),
133
+ details: { expected, actual },
134
+ };
135
+ }
136
+
137
+ if (inWorktree === true) {
138
+ if (actual === '') {
139
+ return {
140
+ ok: false,
141
+ msg: 'not on a branch in this worktree — commits made here would be lost',
142
+ details: { expected, actual: '', worktree: true },
143
+ };
144
+ }
145
+ return {
146
+ ok: true,
147
+ msg:
148
+ 'worktree on ' +
149
+ actual +
150
+ ' — the expected-branch rule (' +
151
+ expected +
152
+ ') governs the main checkout only',
153
+ details: { expected, actual, worktree: true },
154
+ };
155
+ }
156
+
157
+ if (actual === '') {
80
158
  return {
81
159
  ok: false,
82
160
  msg: 'detached HEAD or empty branch (expected ' + expected + ')',
83
- details: { expected, actual: '' },
161
+ details: { expected, actual: '', worktree: false },
84
162
  };
85
163
  }
86
164
 
@@ -88,14 +166,14 @@ function run({ projectDir }) {
88
166
  return {
89
167
  ok: true,
90
168
  msg: 'on expected branch ' + expected,
91
- details: { expected, actual },
169
+ details: { expected, actual, worktree: false },
92
170
  };
93
171
  }
94
172
 
95
173
  return {
96
174
  ok: false,
97
175
  msg: 'on ' + actual + ', expected ' + expected,
98
- details: { expected, actual },
176
+ details: { expected, actual, worktree: false },
99
177
  };
100
178
  }
101
179
 
@@ -107,4 +185,5 @@ module.exports = {
107
185
  _extractExpectedBranch,
108
186
  _readClaudeMd,
109
187
  _currentBranch,
188
+ NO_FILE,
110
189
  };
@@ -57,6 +57,26 @@ function runPreflight(opts) {
57
57
  checkResults.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
58
58
  notes.sort();
59
59
 
60
+ // A run that loaded no checks verified nothing, and must never report a clean
61
+ // pass. The global install shipped this runner without its checks directory
62
+ // for months: it recorded "checks dir unreadable" in a note and returned
63
+ // ok:true anyway, so every preflight in every installed project passed
64
+ // without looking at anything. A note nobody reads is not a report.
65
+ const nothingRan = registry.length === 0;
66
+ if (nothingRan) {
67
+ notes.push(
68
+ 'NOT CHECKED — no preflight checks were loaded, so nothing was verified. ' +
69
+ 'The checks directory is missing from this install; reinstall GSD-T.'
70
+ );
71
+ notes.sort();
72
+ return {
73
+ schemaVersion: SCHEMA_VERSION,
74
+ ok: false,
75
+ checks: checkResults,
76
+ notes,
77
+ };
78
+ }
79
+
60
80
  const ok = !checkResults.some((c) => c.ok === false && c.severity === 'error');
61
81
 
62
82
  return {
@@ -139,11 +139,42 @@ function checkInvariants(original, rewritten) {
139
139
  /** Ask a fresh Claude to do the rewrite. */
140
140
  function rewrite(text, cfg) {
141
141
  const prompt = `${RULES}\n\n--- REPLY TO REWRITE ---\n${text}`;
142
+ // `--setting-sources project` is what stops the shortener shortening itself.
143
+ //
144
+ // A child started with the personal settings inherits the very Stop hook that
145
+ // spawned it: it answers in about 4 seconds, its own hook then sees an answer
146
+ // over the 60-word threshold and spawns a THIRD Claude, and the outer call
147
+ // waits ~46s for work it caused — past the 45s limit, so it was killed and
148
+ // returned nothing, every turn since it shipped. Measured: 54.2s/54.5s with
149
+ // the personal settings, 7.9s/6.2s with only the project's.
150
+ //
151
+ // The trigger was always the CHILD'S OWN REPLY crossing 60 words, never the
152
+ // input: a 63-character prompt that produces a long answer is just as slow
153
+ // (57.2s on, 11.7s off).
154
+ //
155
+ // Not `--bare`, the documented skip-hooks flag: it also skips the keychain, so
156
+ // the child returns "Not logged in" in 0.7s. Not `--settings '{}'` either —
157
+ // settings layers merge, so a lower layer cannot remove a higher layer's hook
158
+ // (54.5s/56.1s, unchanged).
142
159
  const run = spawnSync("claude",
143
- ["-p", prompt, "--model", cfg.model, "--dangerously-skip-permissions"],
160
+ ["-p", prompt, "--model", cfg.model, "--dangerously-skip-permissions",
161
+ "--setting-sources", "project"],
144
162
  { encoding: "utf8", timeout: cfg.timeoutMs, maxBuffer: 8 * 1024 * 1024 });
145
163
 
146
- if (run.error) return { ok: false, error: run.error.message };
164
+ if (run.error) {
165
+ // A timeout is the failure that hid for two days: every turn paid the full
166
+ // wait, produced nothing, and said nothing. Write it to stderr so the cost
167
+ // is visible even though the reply still goes through untouched.
168
+ const timedOut = run.error.code === "ETIMEDOUT";
169
+ if (timedOut) {
170
+ process.stderr.write(
171
+ `[gsd-t] the concise rewriter timed out after ${Math.round(cfg.timeoutMs / 1000)}s ` +
172
+ `and produced nothing — every turn is paying that wait. Switch it off with ` +
173
+ `.gsd-t/concise.json {"enabled": false} until it is fixed.\n`
174
+ );
175
+ }
176
+ return { ok: false, error: run.error.message, timedOut };
177
+ }
147
178
  if (run.status !== 0) return { ok: false, error: `claude exited ${run.status}: ${(run.stderr || "").slice(0, 200)}` };
148
179
  const out = (run.stdout || "").trim();
149
180
  if (!out) return { ok: false, error: "the rewriter returned nothing" };
@@ -375,7 +375,11 @@ function scanStructural(clean, file, findings) {
375
375
  }
376
376
 
377
377
  // retry loop that gives up and proceeds
378
- const retryRe = /\b(?:for|while)\s*\([^)]*(?:attempt|retry|retries|tries)\b[^)]*\)\s*\{/gi;
378
+ // `\b` before the alternation is not enough: "entries" ends in "tries", so
379
+ // `for (const e of entries)` matched — a plain iteration read as a retry loop.
380
+ // Requiring a word boundary on BOTH sides keeps the real shapes (attempt,
381
+ // retry, retries, tries) and drops words that merely end in one.
382
+ const retryRe = /\b(?:for|while)\s*\([^)]*\b(?:attempt|retry|retries|tries)\b[^)]*\)\s*\{/gi;
379
383
  while ((m = retryRe.exec(clean)) !== null) {
380
384
  const braceIdx = clean.indexOf("{", m.index);
381
385
  if (braceIdx === -1) continue;
@@ -116,6 +116,28 @@ function packageVersion(pkgRoot) {
116
116
  * Returns { missing, stale, present } — stale means the file exists but its
117
117
  * content differs from the package's copy.
118
118
  */
119
+ /**
120
+ * Is this directory GSD-T's own source repo (or a worktree of it)?
121
+ *
122
+ * Identified by the package name in its own package.json — the one fact that
123
+ * cannot drift, since it IS what npm publishes. A path check would miss a
124
+ * worktree under ~/Worktrees, and a marker file would need maintaining.
125
+ *
126
+ * An unreadable or absent package.json means "not the source repo": an ordinary
127
+ * project that happens to have no manifest still deserves its repair.
128
+ */
129
+ function isOwnSourceRepo(dir) {
130
+ const manifest = path.join(dir, "package.json");
131
+ if (!fs.existsSync(manifest)) return false;
132
+ let parsed;
133
+ try {
134
+ parsed = JSON.parse(fs.readFileSync(manifest, "utf8"));
135
+ } catch (_) {
136
+ return false;
137
+ }
138
+ return parsed && parsed.name === "@tekyzinc/gsd-t";
139
+ }
140
+
119
141
  function inspect(projectDir, pkgRoot, tools) {
120
142
  const binDir = path.join(projectDir, "bin");
121
143
  const missing = [];
@@ -245,6 +267,24 @@ function main() {
245
267
  process.exit(EXIT_OK);
246
268
  }
247
269
 
270
+ // In a project, bin/ holds COPIES of the package's tools, so restoring one
271
+ // from the package is a repair. In GSD-T's own repo those same files are the
272
+ // SOURCE, and "repairing" them overwrites work in progress with the last
273
+ // published build. That happened: on 2026-08-09 a test run triggered a repair
274
+ // here and silently reverted two edited files mid-session, with the change
275
+ // discoverable only by reading the diff before committing.
276
+ if (isOwnSourceRepo(projectDir)) {
277
+ const msg =
278
+ `${projectDir} is the GSD-T source repo — bin/ here is the source, not a ` +
279
+ `copy of it. Repairing would overwrite your work with the published build.`;
280
+ if (args.json) {
281
+ process.stdout.write(JSON.stringify({ ok: true, exitCode: EXIT_OK, skipped: msg }, null, 2) + "\n");
282
+ } else {
283
+ process.stderr.write(`[gsd-t] ${msg}\n`);
284
+ }
285
+ process.exit(EXIT_OK);
286
+ }
287
+
248
288
  let pkgRoot;
249
289
  try {
250
290
  pkgRoot = findPackageRoot();
@@ -339,4 +379,4 @@ function main() {
339
379
 
340
380
  if (require.main === module) main();
341
381
 
342
- module.exports = { inspect, expectedTools, findPackageRoot, readLog, installerSuspects, LOG_PATH };
382
+ module.exports = { inspect, expectedTools, findPackageRoot, readLog, installerSuspects, isOwnSourceRepo, LOG_PATH };