@tekyzinc/gsd-t 5.10.12 → 5.11.10

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,65 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [5.11.10] - 2026-08-09
6
+
7
+ ### Fixed — four safety gates were passing without checking anything
8
+
9
+ A branch warning turned into an audit. Every fault below is the same shape: a
10
+ check that verifies nothing and reports a pass.
11
+
12
+ - The branch check was dead in every scaffolded project. It matched only the
13
+ phrase `Expected branch:`, while the project template wrote a table row
14
+ labelled `Branch`. Nothing fills that row mechanically, so both sides
15
+ changed: the row is renamed and the reader accepts either shape.
16
+ - The same check blocked the workflow the house rules mandate — a worktree on a
17
+ feature branch is correct by design, yet was flagged. In a worktree a named
18
+ branch now passes and says why; a detached HEAD fails, because commits made
19
+ with no branch attached are easily lost.
20
+ - An unreadable `CLAUDE.md` passed as "no rules". It halts now.
21
+ - The entire preflight gate was decorative in every installed project:
22
+ `cli-preflight.cjs` shipped without its six checks, found an empty directory,
23
+ and returned `ok:true`. Measured live: `checks run: 0`. Directories under
24
+ `bin/` now ship, and an empty registry fails instead of passing.
25
+ - Install-repair overwrote in-progress edits in GSD-T's own repo, where `bin/`
26
+ is the source rather than a copy of the published build. It now refuses that
27
+ repo, identified by its package name.
28
+
29
+ ### Fixed — the reply shortener never ran once, then hung when it did
30
+
31
+ Two faults stacked, so neither was visible. The hook read only the last record
32
+ of a turn; a turn almost always ends with a tool call, so it found no prose and
33
+ gave up. Underneath that, the rewrite itself hung: given a reply full of file
34
+ paths and commands, the spawned Claude read them as work to do and went off
35
+ running them — 45 seconds, killed on timeout, empty output and empty stderr,
36
+ every turn. Shortening prose needs no tools, so they are now forbidden.
37
+ Measured end to end: 41s and nothing, to 10.9s and a correct rewrite. A timeout
38
+ now writes to stderr; silence is what let it hide.
39
+
40
+ ### Added — a new worktree arrives runnable
41
+
42
+ `git worktree add` brings only tracked files, so `.env`, local settings and
43
+ installed dependencies stayed behind and the folder was born unable to run.
44
+ Provisioning carries local config (permissions preserved, so a 600 secret does
45
+ not become world-readable), skips per-session `.gsd-t` state, and reinstalls
46
+ dependencies from the worktree's own lockfile.
47
+
48
+ - `bin/cli-preflight-checks/branch-guard.cjs`: reads both rule shapes, worktree-aware, halts on an unreadable file
49
+ - `bin/cli-preflight.cjs`: an empty check registry fails instead of passing
50
+ - `bin/gsd-t-worktree-detect.cjs`: shared worktree test using git's own answer
51
+ - `bin/gsd-t-worktree-provision.cjs`: carry config, skip state, install deps
52
+ - `bin/gsd-t-install-check.cjs`: refuses to "repair" the GSD-T source repo
53
+ - `bin/gsd-t-concise-rewrite.cjs`: no tools, and a loud timeout
54
+ - `scripts/gsd-t-concise-hook.js`: finds the turn's prose past its tool calls
55
+ - `bin/gsd-t.js`: directories under `bin/` now propagate
56
+ - `templates/CLAUDE-project.md`: the branch row the reader can actually find
57
+ - `test/m112-worktree-provision.test.js`, `test/m107-concise-rewrite.test.js`: 24 new tests
58
+
59
+ Known limitation: `contracts-stable` has never fired once (its pattern expects
60
+ emphasis after `Status`, every real file writes it before) and `ports-free`
61
+ reads a config key nothing writes. Both are recorded and left for a separate
62
+ pass.
63
+
5
64
  ## [5.10.10] - 2026-08-07
6
65
 
7
66
  ### 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.10** - 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 {
@@ -136,14 +136,39 @@ function checkInvariants(original, rewritten) {
136
136
  return lost;
137
137
  }
138
138
 
139
+ // Shortening prose needs no tools. Left able to use them, the rewriter read the
140
+ // file paths and commands INSIDE the reply as work to do and went off running
141
+ // them — 41 seconds, killed on timeout, empty output and empty stderr, on every
142
+ // turn for two days. Forbidding tools returns the same rewrite in about 7
143
+ // seconds. The list is explicit rather than a mode flag so a newly added tool
144
+ // cannot quietly re-open the same hole.
145
+ const NO_TOOLS = [
146
+ "Bash", "Read", "Write", "Edit", "NotebookEdit", "Glob", "Grep",
147
+ "Task", "Agent", "WebFetch", "WebSearch", "TodoWrite",
148
+ ].join(",");
149
+
139
150
  /** Ask a fresh Claude to do the rewrite. */
140
151
  function rewrite(text, cfg) {
141
152
  const prompt = `${RULES}\n\n--- REPLY TO REWRITE ---\n${text}`;
142
153
  const run = spawnSync("claude",
143
- ["-p", prompt, "--model", cfg.model, "--dangerously-skip-permissions"],
154
+ ["-p", prompt, "--model", cfg.model, "--dangerously-skip-permissions",
155
+ "--disallowed-tools", NO_TOOLS],
144
156
  { encoding: "utf8", timeout: cfg.timeoutMs, maxBuffer: 8 * 1024 * 1024 });
145
157
 
146
- if (run.error) return { ok: false, error: run.error.message };
158
+ if (run.error) {
159
+ // A timeout is the failure that hid for two days: every turn paid the full
160
+ // wait, produced nothing, and said nothing. Write it to stderr so the cost
161
+ // is visible even though the reply still goes through untouched.
162
+ const timedOut = run.error.code === "ETIMEDOUT";
163
+ if (timedOut) {
164
+ process.stderr.write(
165
+ `[gsd-t] the concise rewriter timed out after ${Math.round(cfg.timeoutMs / 1000)}s ` +
166
+ `and produced nothing — every turn is paying that wait. Switch it off with ` +
167
+ `.gsd-t/concise.json {"enabled": false} until it is fixed.\n`
168
+ );
169
+ }
170
+ return { ok: false, error: run.error.message, timedOut };
171
+ }
147
172
  if (run.status !== 0) return { ok: false, error: `claude exited ${run.status}: ${(run.stderr || "").slice(0, 200)}` };
148
173
  const out = (run.stdout || "").trim();
149
174
  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 };