@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 +21 -0
- package/README.md +2 -1
- package/bin/gsd-t-graph-use-gate.cjs +5 -2
- package/bin/gsd-t-logging-envelope-check.cjs +13 -5
- package/bin/gsd-t-migrate-logging.cjs +3 -14
- package/bin/gsd-t-testplan-halt.cjs +439 -0
- package/bin/gsd-t-testplan-lint.cjs +437 -0
- package/bin/gsd-t-testplan-rows.cjs +114 -0
- package/bin/gsd-t-traceability-gate.cjs +154 -21
- package/bin/gsd-t.js +42 -0
- package/commands/cpua.md +20 -2
- package/commands/gsd-t-help.md +9 -0
- package/commands/gsd-t-test-plan.md +85 -0
- package/commands/gsd.md +2 -1
- package/docs/requirements.md +18 -0
- package/package.json +1 -1
- package/templates/CLAUDE-global.md +1 -0
- package/templates/TestPlan-spec.md +78 -0
- package/templates/demo-videos/scripts/walkthrough-mux.mjs +8 -2
- package/templates/demo-videos/scripts/walkthrough-normalise.mjs +8 -2
- package/templates/demo-videos/scripts/walkthrough-trim.mjs +19 -6
- package/templates/demo-videos/scripts/walkthrough-voice-check.mjs +8 -2
- package/templates/demo-videos/scripts/walkthrough-voice-ensure.mjs +6 -2
- package/templates/demo-videos/scripts/walkthrough-voice.mjs +30 -50
- package/templates/prompts/test-plan-enumerator-subagent.md +230 -0
- package/templates/prompts/test-plan-evidence-classifier.md +106 -0
- package/templates/workflows/gsd-t-verify.workflow.js +126 -0
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.
|
|
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
|
-
|
|
171
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
//
|
|
114
|
-
//
|
|
115
|
-
|
|
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();
|