@tekyzinc/gsd-t 5.17.14 → 5.18.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,27 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [5.18.10] - 2026-09-03
6
+
7
+ ### Added — M115 Test-Plan-First Requirements Interrogation (`/gsd-t-test-plan`)
8
+
9
+ Before any code exists, enumerate every test case a milestone's requirements imply into a
10
+ reviewable sequence-table document. Every row nobody can fill in is a requirements gap.
11
+
12
+ - `commands/gsd-t-test-plan.md` + `/gsd` router case: the front door (in-session, judgement-driven).
13
+ - `templates/prompts/test-plan-enumerator-subagent.md`: the eight enumeration rules (E1-E8), generic
14
+ worked examples; `templates/TestPlan-spec.md`: the mold; `templates/prompts/test-plan-evidence-classifier.md`:
15
+ the `--after` classifier (three arms, no default).
16
+ - `bin/gsd-t-testplan-rows.cjs`: the ONE plan reader (both fence styles, exact six-cell rows, one classifier).
17
+ - `bin/gsd-t-testplan-lint.cjs` (0/4/64), `bin/gsd-t-testplan-halt.cjs` (three-round cap + repeated-symptom
18
+ cap over the loop ledger, per plan and per round), `bin/gsd-t-traceability-gate.cjs` (additive `**Plan-Row**`
19
+ binding; GAP / malformed / duplicate / escaped rows never clear).
20
+ - Verify-gate wiring with a NAMED skip when no plan exists; discovery failure halts.
21
+ - Contract `.gsd-t/contracts/test-plan-first-contract.md` 1.1.0 STABLE; `integration-points.md`.
22
+ - Held-out fixture `test/fixtures/m115-blind-replay/` (the TimeTracking rate-ledger answer key) and the
23
+ clean-room replay artifacts under `.gsd-t/scan/`.
24
+ - 120 milestone tests, of which 29 pin verify findings across seven verify runs.
25
+
5
26
  ## [5.17.14] - 2026-09-03
6
27
 
7
28
  ### Fixed — two more gate defects surfaced by TimeTracking (TD-395 round 2), plus a silent library and a crashing finalizer
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v5.17.14** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v5.18.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.
@@ -197,6 +197,7 @@ This will replace changed command files, back up your CLAUDE.md if customized, a
197
197
  | `/gsd-t-milestone` | Define new milestone | Manual |
198
198
  | `/gsd-t-partition` | Decompose into domains + contracts | In wave |
199
199
  | `/gsd-t-plan` | Create atomic task lists per domain (tasks auto-split to fit one context window) | In wave |
200
+ | `/gsd-t-test-plan` | Enumerate every test case a requirements doc implies, before any code or tests exist (`--after` re-enumerates against built code, classifying failures by cited evidence) | Manual |
200
201
  | `/gsd-t-impact` | Analyze downstream effects | In wave |
201
202
  | `/gsd-t-architect` | Interviews you first (shows its read of current behavior for confirmation, researches when unsure — max 3 cycles), then runs the Architect's Oversight Six-Stage Pass — simplest solution + reuse + traps, as plain-English pseudocode (plan-only; `--build` to auto-build, `--no-interview`/`--no-research` to skip a stage) | Manual |
202
203
  | `/gsd-t-execute` | Run tasks — task-level fresh dispatch, worktree isolation, adaptive replanning | In wave |
@@ -167,8 +167,11 @@ function evaluate(consumers) {
167
167
  if (c.wiringModes.length === 0) continue; // never declared; nothing claimed, nothing to prove
168
168
 
169
169
  const claimedWired = c.wiringModes.some((m) => m.toLowerCase() === 'wired');
170
- const since = c.firstWiringTs || '';
171
- const attributed = unlabelledQueryTs.filter((ts) => ts && ts >= since).length;
170
+ // A claim with no usable timestamp cannot be placed in time, so nothing
171
+ // can be attributed to it the one `|| ''` here made EVERY query evidence,
172
+ // queries before the claim included (code-review M115 run 7).
173
+ const since = c.firstWiringTs;
174
+ const attributed = since ? unlabelledQueryTs.filter((ts) => ts && ts >= since).length : 0;
172
175
  const evidenceCount = c.queryCount + attributed;
173
176
  checked.push({ consumer: id, wiringModes: c.wiringModes, queryCount: c.queryCount, attributedQueryCount: attributed });
174
177
 
@@ -440,10 +440,10 @@ function _readManifest(projectDir) {
440
440
  if (!fs.existsSync(p)) return { present: false, manifest: null, error: null };
441
441
  try {
442
442
  const m = JSON.parse(fs.readFileSync(p, 'utf8'));
443
- if (!m || typeof m !== 'object' || Array.isArray(m)) return { present: true, manifest: null, error: 'manifest is not a JSON object' };
443
+ if (!m || typeof m !== 'object' || Array.isArray(m)) return { present: true, manifest: null, error: '.gsd-t/logging-manifest.json is not a JSON object' };
444
444
  return { present: true, manifest: m, error: null };
445
445
  } catch (err) {
446
- return { present: true, manifest: null, error: 'manifest is not valid JSON: ' + (err && err.message ? err.message : String(err)) };
446
+ return { ok: false, present: true, manifest: null, error: '.gsd-t/logging-manifest.json is not valid JSON: ' + (err && err.message ? err.message : String(err)) };
447
447
  }
448
448
  }
449
449
 
@@ -455,8 +455,16 @@ function _declaredStream(projectDir, manifest, stream, failures, notes) {
455
455
  failures.push({ rule: 'logging-manifest-invalid', stream, detail: 'manifest.' + stream + ' must be an object' });
456
456
  return out;
457
457
  }
458
+ // A declared path must stay inside the project (same containment the plan-row
459
+ // loader enforces — review M115 run 7).
460
+ const inside = (rel) => {
461
+ if (typeof rel !== 'string' || !rel) return null;
462
+ const root = path.resolve(projectDir);
463
+ const abs = path.resolve(root, rel);
464
+ return abs.startsWith(root + path.sep) ? abs : null;
465
+ };
458
466
  if (d.module !== undefined) {
459
- const abs = typeof d.module === 'string' && d.module ? path.join(projectDir, d.module) : null;
467
+ const abs = inside(d.module);
460
468
  if (!abs || !fs.existsSync(abs)) {
461
469
  failures.push({ rule: 'logging-manifest-invalid', stream, detail: 'declared ' + stream + ' module not found: ' + String(d.module) });
462
470
  } else {
@@ -465,8 +473,8 @@ function _declaredStream(projectDir, manifest, stream, failures, notes) {
465
473
  }
466
474
  if (d.store !== undefined) {
467
475
  if (typeof d.store === 'string' && d.store) {
468
- const abs = path.join(projectDir, d.store);
469
- if (!fs.existsSync(abs)) failures.push({ rule: 'logging-manifest-invalid', stream, detail: 'declared ' + stream + ' store not found: ' + d.store });
476
+ const abs = inside(d.store);
477
+ if (!abs || !fs.existsSync(abs)) failures.push({ rule: 'logging-manifest-invalid', stream, detail: 'declared ' + stream + ' store not found (or outside the project): ' + d.store });
470
478
  else out.storePath = abs;
471
479
  } else if (d.store && typeof d.store === 'object' && typeof d.store.kind === 'string' && d.store.kind) {
472
480
  out.externalStore = d.store;
@@ -110,20 +110,9 @@ function writeDistilledSchemaIfAbsent(projectDir, planPath) {
110
110
  * a file that already exists in the target project is left byte-for-byte
111
111
  * untouched (recorded in `skipped`, never in `created`).
112
112
  */
113
- // .gsd-t/logging-manifest.json same reader the envelope checker uses (kept in
114
- // step with bin/gsd-t-logging-envelope-check.cjs _readManifest; a stream is
115
- // "declared" when its `module` is named).
116
- function readManifest(projectDir) {
117
- const p = path.join(projectDir, '.gsd-t', 'logging-manifest.json');
118
- if (!fs.existsSync(p)) return { present: false, manifest: null, error: null };
119
- try {
120
- const m = JSON.parse(fs.readFileSync(p, 'utf8'));
121
- if (!m || typeof m !== 'object' || Array.isArray(m)) return { present: true, manifest: null, error: '.gsd-t/logging-manifest.json is not a JSON object' };
122
- return { present: true, manifest: m, error: null };
123
- } catch (err) {
124
- return { present: true, manifest: null, error: '.gsd-t/logging-manifest.json is not valid JSON: ' + (err && err.message ? err.message : String(err)) };
125
- }
126
- }
113
+ // The manifest reader lives in ONE place — the envelope checker and is reused
114
+ // here (review M115 run 7: two byte-identical copies had already appeared).
115
+ const { _readManifest: readManifest } = require("./gsd-t-logging-envelope-check.cjs");
127
116
 
128
117
  function migrateLogging(projectDir, opts) {
129
118
  opts = opts || {};
@@ -0,0 +1,439 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gsd-t-testplan-halt — the A5 non-convergence halt.
4
+ *
5
+ * Contract: .gsd-t/contracts/test-plan-first-contract.md §4 (exit codes/envelope/module
6
+ * shape), §5 (verb name), §6 (loop-ledger reuse, READ-ONLY).
7
+ *
8
+ * WHY THIS EXISTS
9
+ * ---------------
10
+ * A test plan's open rows (`GAP` / `GAP:CONTRADICTION` in column 6, §2.1) get closed by
11
+ * asking the human questions. Two ways that loop can go wrong instead of converging:
12
+ *
13
+ * [RULE] enumeration-loop-cap-three — three question rounds pass and rows are still
14
+ * open. Continuing to ask is not the failure mode this catches; FILLING those rows
15
+ * with something plausible after round three is. So round three HALTS instead.
16
+ *
17
+ * [RULE] same-symptom-twice-halts — the same failure signature recurs across two
18
+ * consecutive rounds. A repeat means the belief behind the last fix was wrong, not
19
+ * that the fix needs a third attempt — so this halts toward re-examining the belief,
20
+ * not toward trying again.
21
+ *
22
+ * Both are a HALT, never a fallback: on non-convergence this tool refuses to continue and
23
+ * says so. It never fills an open row and it never silently passes a repeated failure
24
+ * through on a third try.
25
+ *
26
+ * ONE HALT POINT
27
+ * --------------
28
+ * `checkConvergence` runs its steps (read the doc, parse it, ask the loop ledger) without
29
+ * a try/catch of its own — a thrown error from any step propagates untouched. Exactly one
30
+ * try/catch exists, at the CLI entry point (`main`). Its catch ends the process with the
31
+ * same `halt(...)` envelope as every ordinary bad-input path — a stated refusal to
32
+ * continue, never a guessed value, and never a bare stack trace.
33
+ *
34
+ * READ-ONLY REUSE (§6, non-negotiable)
35
+ * -------------------------------------
36
+ * The repeated-signature cap is entirely the loop ledger's (`bin/gsd-t-loop-ledger.cjs`)
37
+ * existing job — `computeSignature` / `appendCycle` / `readExitState`. This file maps an
38
+ * enumeration round onto one ledger cycle and reads the ledger's own verdict; it does not
39
+ * reimplement signature comparison. The ledger is imported, never edited, never forked.
40
+ *
41
+ * Deterministic, zero LLM judgment. Never throws past the CLI boundary — bad input HALTS
42
+ * with exitCode 64.
43
+ *
44
+ * Input: --doc <path> --round <n> [--milestone <name>]
45
+ * [--assertion <text> --surface <text> --fileClass <text>] (repeated-symptom cap;
46
+ * when omitted, the signature is derived from the doc's own open-row set so the
47
+ * cap still works from --doc alone)
48
+ * [--projectDir <path>]
49
+ * Output: JSON envelope { ok, exitCode, doc, round, openRows, halted, haltReason, violations }
50
+ * Exit: 0 clean (not halted) · 4 halted (non-convergence) · 64 bad input — a HALT, not a pass
51
+ *
52
+ * module.exports: { checkConvergence, parseOpenRows, roundToCycleSignature }
53
+ */
54
+
55
+ "use strict";
56
+
57
+ const fs = require("fs");
58
+ const { walkSections, parseRows, rowState, tableName, REQUIRED_COLUMN_COUNT } = require("./gsd-t-testplan-rows.cjs");
59
+ const path = require("path");
60
+ const loopLedger = require("./gsd-t-loop-ledger.cjs");
61
+
62
+ /** [RULE] enumeration-loop-cap-three — this many rounds without closure halts. */
63
+ const ROUND_CAP = 3;
64
+
65
+ /**
66
+ * [RULE] same-symptom-twice-halts — this many consecutive occurrences of the SAME
67
+ * signature halts. Read from the ledger's own `cycles` count on every `appendCycle` call
68
+ * (never from the ledger's own `halted` flag, which fires at ITS fixed internal threshold
69
+ * of 3 — a different cap for a different rule, per the ledger's debug-loop use case). This
70
+ * is still full delegation, not a fork: the ledger computes and persists the signature and
71
+ * the count; this constant only says how many of THIS gate's own occurrences is "twice".
72
+ */
73
+ const SYMPTOM_REPEAT_CAP = 2;
74
+
75
+ /** Column-6 gap markers, matched case-sensitively per contract §2.2. */
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Row parsing — §2.1 sequence-table schema, read-only interpretation
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /**
82
+ * Parse every sequence table in a test-plan Markdown document and return the rows still
83
+ * `open` (column 6 carries a `GAP` or `GAP:CONTRADICTION` marker).
84
+ *
85
+ * This reads the frozen §2 schema; it does not lint it (that is `testplan-lint`'s job,
86
+ * owned by `deterministic-gates`) — a malformed row is simply not recognised as closed,
87
+ * which is the conservative direction (a row this cannot parse is never silently treated
88
+ * as answered).
89
+ *
90
+ * @param {string} text — the document's raw content
91
+ * @returns {{ table: string, seq: string, reason: string }[]}
92
+ */
93
+ function parseOpenRows(text) {
94
+ // Through the ONE shared plan reader and the ONE classifier. Everything that is
95
+ // not a settled answer is OPEN — the halting direction: a gap, a cell that looks
96
+ // like a marker but is not one (`GAPX:`), a row with no Seq (nothing can cite or
97
+ // name it), a row that is not six cells. Red Team M115 run 6: a blank-Seq gap
98
+ // was silently dropped and the round cap never saw it.
99
+ const openRows = [];
100
+ for (const sec of walkSections(text)) {
101
+ const currentTable = tableName(sec.heading);
102
+ if (!currentTable) continue;
103
+ for (const row of parseRows(sec.lines, sec.startLine)) {
104
+ const seq = row.cells[0] && row.cells[0].trim() ? row.cells[0].trim() : "(blank Seq)";
105
+ if (row.width !== REQUIRED_COLUMN_COUNT) {
106
+ openRows.push({ table: currentTable, seq, source: `MALFORMED-ROW (${row.width} cells, expected ${REQUIRED_COLUMN_COUNT})`, line: row.line });
107
+ continue;
108
+ }
109
+ const source = row.cells[5];
110
+ const st = rowState(source);
111
+ if (seq === "(blank Seq)") { openRows.push({ table: currentTable, seq, source: `BLANK-SEQ (${source})`, line: row.line }); continue; }
112
+ if (st === "gap") openRows.push({ table: currentTable, seq, source, line: row.line });
113
+ else if (st === "malformed") openRows.push({ table: currentTable, seq, source: `UNKNOWN-MARKER (${source})`, line: row.line });
114
+ else if (st === "empty") openRows.push({ table: currentTable, seq, source: "BLANK-SOURCE", line: row.line });
115
+ }
116
+ }
117
+ return openRows;
118
+ }
119
+
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Round → ledger-cycle mapping (§6, D3-T1) — teaches the ledger what a round is
123
+ // ---------------------------------------------------------------------------
124
+
125
+ /**
126
+ * Build the ledger's `computeSignature` inputs for one enumeration round, running in
127
+ * `--after` mode (i.e. after the round's questions were asked and answered).
128
+ *
129
+ * What identifies a round, by contract: which rows are still open, and what the failing
130
+ * signature is with those rows unresolved. When the caller supplies an explicit
131
+ * assertion/surface/fileClass, that identity is used as given (an explicit input, not a
132
+ * fallback). Only when NONE is supplied does the signature come from the doc's own open-row
133
+ * set: two rounds with the SAME open rows are the same symptom; a round that closed even
134
+ * one row, or opened a different one, is a different symptom — mirroring the ledger's own
135
+ * variant-spawning rule that closing one thing while a new thing opens still does not repeat.
136
+ *
137
+ * @param {{ doc: string, openRows: {table:string, seq:string}[], assertion?: string,
138
+ * surface?: string, fileClass?: string }} opts
139
+ * @returns {{ assertion: string, surface: string, fileClass: string }}
140
+ */
141
+ function roundToCycleSignature({ doc, openRows, assertion, surface, fileClass }) {
142
+ let openRowSignature = "no-open-rows";
143
+ if (openRows.length > 0) {
144
+ const sortedIds = openRows.map((r) => `${r.table}::${r.seq}`).sort();
145
+ openRowSignature = sortedIds.join("|");
146
+ }
147
+
148
+ // The ledger keys on assertion + fileClass and DROPS surface on purpose
149
+ // (R-LOOP-1), so two plans with the same open-row set collided on one
150
+ // signature — plan B's first round halted on plan A's history (code-review
151
+ // M115 run 6). The doc is therefore part of the ASSERTION.
152
+ const docKey = path.resolve(String(doc || ""));
153
+ let effectiveAssertion = `${docKey}::${openRowSignature}`;
154
+ if (assertion) effectiveAssertion = `${docKey}::${assertion}`;
155
+
156
+ let effectiveSurface = doc;
157
+ if (surface) effectiveSurface = surface;
158
+
159
+ let effectiveFileClass = "testplan";
160
+ if (fileClass) effectiveFileClass = fileClass;
161
+
162
+ return { assertion: effectiveAssertion, surface: effectiveSurface, fileClass: effectiveFileClass };
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // The two caps
167
+ // ---------------------------------------------------------------------------
168
+
169
+ /**
170
+ * Build the exitCode-64 halt envelope for bad/unreadable input. This is the "gate cannot
171
+ * decide" branch of contract §4 — a stated refusal, not a value standing in for a result.
172
+ * Named `halt` (not a generic helper name) so it reads, structurally and at a glance, as
173
+ * the same halt-shape as `deny`/`fail`/`block`/`abort` elsewhere in this codebase.
174
+ */
175
+ function halt(reason, extra) {
176
+ const envelope = {
177
+ ok: false,
178
+ exitCode: 64,
179
+ reason,
180
+ openRows: [],
181
+ halted: false,
182
+ haltReason: null,
183
+ violations: [],
184
+ };
185
+ Object.assign(envelope, extra);
186
+ return envelope;
187
+ }
188
+
189
+ /**
190
+ * Run both convergence caps for one round against one test-plan document.
191
+ *
192
+ * Runs no try/catch of its own — see "ONE HALT POINT" above. Any step here that cannot
193
+ * proceed (missing --doc, bad --round, an unreadable doc, a rejecting ledger call) returns
194
+ * an explicit `halt(...)` envelope; nothing here throws on the ordinary bad-input path, so
195
+ * `main`'s single outer catch exists only for a truly unanticipated error.
196
+ *
197
+ * @param {object} opts
198
+ * @param {string} opts.docPath
199
+ * @param {number} opts.round — 1-indexed round number just completed
200
+ * @param {string} [opts.milestone]
201
+ * @param {string} [opts.assertion]
202
+ * @param {string} [opts.surface]
203
+ * @param {string} [opts.fileClass]
204
+ * @param {string} [opts.projectDir]
205
+ * @returns {{ ok, exitCode, doc, round, openRows, halted, haltReason, violations }}
206
+ */
207
+ // Per-project record of which round last fed each signature to the ledger.
208
+ // Read fail-closed: corrupt → null (the caller HALTS), never an empty object.
209
+ function roundsRecordPath(projectDir) {
210
+ return path.join(path.resolve(projectDir || "."), ".gsd-t", "testplan-halt-rounds.json");
211
+ }
212
+ function readRoundsRecord(projectDir) {
213
+ const p = roundsRecordPath(projectDir);
214
+ if (!fs.existsSync(p)) return {};
215
+ let parsed;
216
+ try { parsed = JSON.parse(fs.readFileSync(p, "utf8")); } catch (_e) { return null; }
217
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
218
+ return parsed;
219
+ }
220
+ function writeRoundsRecord(projectDir, record) {
221
+ const p = roundsRecordPath(projectDir);
222
+ try {
223
+ fs.mkdirSync(path.dirname(p), { recursive: true });
224
+ const tmp = `${p}.${process.pid}.tmp`;
225
+ fs.writeFileSync(tmp, JSON.stringify(record, null, 2) + "\n");
226
+ fs.renameSync(tmp, p);
227
+ return { ok: true };
228
+ } catch (e) {
229
+ return { ok: false, error: e && e.message ? e.message : String(e) };
230
+ }
231
+ }
232
+
233
+ function checkConvergence(opts) {
234
+ const o = opts || {};
235
+ const docPath = o.docPath;
236
+ const round = o.round;
237
+ const milestone = o.milestone;
238
+ const assertion = o.assertion;
239
+ const surface = o.surface;
240
+ const fileClass = o.fileClass;
241
+ const projectDir = o.projectDir;
242
+
243
+ const docPathIsGiven = !!docPath && typeof docPath === "string";
244
+ if (!docPathIsGiven) {
245
+ return halt("--doc is required", { doc: docPath, round: round });
246
+ }
247
+
248
+ // A valueless `--round` arrives as boolean true and Number(true) is 1, which
249
+ // would pass a numeric check and defeat the three-round cap (code-review M115,
250
+ // important). Only a digit string (or an actual integer from a module caller)
251
+ // counts.
252
+ const roundStr = typeof round === "number" ? String(round) : round;
253
+ const roundIsPositiveInteger = typeof roundStr === "string" && /^[1-9][0-9]*$/.test(roundStr);
254
+ const roundNum = roundIsPositiveInteger ? Number(roundStr) : NaN;
255
+ if (!roundIsPositiveInteger) {
256
+ return halt("--round must be a positive integer", { doc: docPath, round: round });
257
+ }
258
+
259
+ const docExists = fs.existsSync(docPath);
260
+ if (!docExists) {
261
+ return halt(`cannot read doc: no such file: ${docPath}`, { doc: docPath, round: roundNum });
262
+ }
263
+ const stat = fs.statSync(docPath);
264
+ const docIsFile = stat.isFile();
265
+ if (!docIsFile) {
266
+ return halt(`cannot read doc: not a file: ${docPath}`, { doc: docPath, round: roundNum });
267
+ }
268
+
269
+ const text = fs.readFileSync(docPath, "utf8");
270
+ const openRows = parseOpenRows(text);
271
+
272
+ // --- Cap 1: enumeration-loop-cap-three -----------------------------------
273
+ const roundCapFired = roundNum >= ROUND_CAP && openRows.length > 0;
274
+ if (roundCapFired) {
275
+ const namedOpenRows = openRows
276
+ .map((r) => `${r.table} Seq ${r.seq} (${r.source})`)
277
+ .join("; ");
278
+ return {
279
+ ok: false,
280
+ exitCode: 4,
281
+ doc: docPath,
282
+ round: roundNum,
283
+ openRows: openRows,
284
+ halted: true,
285
+ haltReason:
286
+ `enumeration-loop-cap-three: ${roundNum} question rounds have passed and ` +
287
+ `${openRows.length} row(s) are still open. HALT — blocked-needs-human. ` +
288
+ `Still-open: ${namedOpenRows}`,
289
+ violations: openRows.map((r) => ({
290
+ kind: "enumeration-loop-cap-three",
291
+ detail: `${r.table} Seq ${r.seq}: ${r.source}`,
292
+ })),
293
+ };
294
+ }
295
+
296
+ // --- Cap 2: same-symptom-twice-halts (delegates to the loop ledger) ------
297
+ // Gated on open rows remaining: a "failure signature" presumes a failure. A round
298
+ // that closed everything has nothing to repeat, so it is not fed to the ledger at
299
+ // all — neither to check a repeat nor to accumulate a cycle count. This is what lets
300
+ // a converged plan pass through untouched no matter how many times it is re-checked.
301
+ const roundHasOpenRows = openRows.length > 0;
302
+ if (roundHasOpenRows) {
303
+ const sig = roundToCycleSignature({ doc: docPath, openRows: openRows, assertion: assertion, surface: surface, fileClass: fileClass });
304
+ // The ledger counts CALLS. A re-check of the same round is not a new round
305
+ // (code-review M115 run 6: re-running round 2 fired the repeat cap). This
306
+ // tool keeps its own record of which round last fed each signature; a
307
+ // repeat of that round re-uses the recorded count instead of appending.
308
+ const roundsRecord = readRoundsRecord(projectDir);
309
+ if (roundsRecord === null) {
310
+ return halt("testplan-halt rounds record is corrupt — fix or remove .gsd-t/testplan-halt-rounds.json (a silent reset would hide a loop)", { doc: docPath, round: roundNum });
311
+ }
312
+ const sigKey = `${sig.assertion}\u0000${sig.fileClass}`;
313
+ const prior = roundsRecord[sigKey];
314
+ let ledgerResult;
315
+ if (prior && prior.lastRound === roundNum) {
316
+ ledgerResult = { ok: true, cycles: prior.cycles, signature: prior.signature };
317
+ } else {
318
+ const ledgerCallOpts = { assertion: sig.assertion, surface: sig.surface, fileClass: sig.fileClass, projectDir: projectDir, milestone: milestone };
319
+ ledgerResult = loopLedger.appendCycle(ledgerCallOpts);
320
+ const ledgerAccepted = !!ledgerResult && ledgerResult.ok === true;
321
+ if (!ledgerAccepted) {
322
+ let ledgerError = "unknown error";
323
+ if (ledgerResult && ledgerResult.error) ledgerError = ledgerResult.error;
324
+ return halt(`loop-ledger rejected the cycle: ${ledgerError}`, { doc: docPath, round: roundNum });
325
+ }
326
+ roundsRecord[sigKey] = { lastRound: roundNum, cycles: ledgerResult.cycles, signature: ledgerResult.signature };
327
+ const written = writeRoundsRecord(projectDir, roundsRecord);
328
+ if (written.ok !== true) return halt(`could not write the rounds record: ${written.error}`, { doc: docPath, round: roundNum });
329
+ }
330
+
331
+ const symptomRepeated = ledgerResult.cycles >= SYMPTOM_REPEAT_CAP;
332
+ if (symptomRepeated) {
333
+ return {
334
+ ok: false,
335
+ exitCode: 4,
336
+ doc: docPath,
337
+ round: roundNum,
338
+ openRows: openRows,
339
+ halted: true,
340
+ haltReason:
341
+ `same-symptom-twice-halts: the same failure signature has appeared ` +
342
+ `${ledgerResult.cycles} times running. The belief behind the fix is wrong, not the ` +
343
+ `fix insufficient — re-examine the premise, do not attempt a third fix. ` +
344
+ `signature=${ledgerResult.signature}`,
345
+ violations: [
346
+ {
347
+ kind: "same-symptom-twice-halts",
348
+ detail: `signature ${ledgerResult.signature} repeated ${ledgerResult.cycles} times`,
349
+ },
350
+ ],
351
+ };
352
+ }
353
+ }
354
+
355
+ // --- Converged for this round: neither cap fired -------------------------
356
+ return {
357
+ ok: true,
358
+ exitCode: 0,
359
+ doc: docPath,
360
+ round: roundNum,
361
+ openRows: openRows,
362
+ halted: false,
363
+ haltReason: null,
364
+ violations: [],
365
+ };
366
+ }
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // module.exports (§4 module shape — testable before front-door-wiring registers it)
370
+ // ---------------------------------------------------------------------------
371
+
372
+ module.exports = { checkConvergence, parseOpenRows, roundToCycleSignature };
373
+
374
+ // ---------------------------------------------------------------------------
375
+ // CLI entry point — `node bin/gsd-t-testplan-halt.cjs check ...`
376
+ // ---------------------------------------------------------------------------
377
+
378
+ function parseFlags(argv) {
379
+ const flags = {};
380
+ for (let i = 0; i < argv.length; i++) {
381
+ const a = argv[i];
382
+ if (a.startsWith("--")) {
383
+ const key = a.slice(2);
384
+ const next = argv[i + 1];
385
+ const hasValue = next !== undefined && !next.startsWith("--");
386
+ if (hasValue) {
387
+ flags[key] = next;
388
+ i++;
389
+ } else {
390
+ flags[key] = true;
391
+ }
392
+ }
393
+ }
394
+ return flags;
395
+ }
396
+
397
+ function dispatch(argv) {
398
+ const subcommand = argv[0];
399
+ const rest = argv.slice(1);
400
+ const isCheck = subcommand === "check";
401
+ if (!isCheck) {
402
+ let reason = "Subcommand required: check";
403
+ if (subcommand) reason = `Unknown subcommand: ${subcommand}. Valid: check`;
404
+ return { ok: false, exitCode: 64, reason: reason, violations: [] };
405
+ }
406
+ const flags = parseFlags(rest);
407
+ let projectDir = process.cwd();
408
+ if (flags.projectDir) projectDir = flags.projectDir;
409
+ return checkConvergence({
410
+ docPath: flags.doc,
411
+ round: flags.round,
412
+ milestone: flags.milestone,
413
+ assertion: flags.assertion,
414
+ surface: flags.surface,
415
+ fileClass: flags.fileClass,
416
+ projectDir: projectDir,
417
+ });
418
+ }
419
+
420
+ /** Write the one JSON envelope and stop the process with its exit code. */
421
+ function emitAndExit(res) {
422
+ process.stdout.write(JSON.stringify(res) + "\n");
423
+ process.exit(res.exitCode);
424
+ }
425
+
426
+ /**
427
+ * The single halt point for this file (see "ONE HALT POINT" above). One try/catch; the
428
+ * catch itself ends the process with the same `halt(...)` envelope shape — never lets an
429
+ * escaped error surface as a bare stack trace.
430
+ */
431
+ function main() {
432
+ try {
433
+ emitAndExit(dispatch(process.argv.slice(2)));
434
+ } catch (e) {
435
+ emitAndExit(halt(`gate-error: ${e && e.message}`, {}));
436
+ }
437
+ }
438
+
439
+ if (require.main === module) main();