@tekyzinc/gsd-t 5.17.13 → 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 +47 -0
- package/README.md +2 -1
- package/bin/gsd-t-fallback-detect.cjs +31 -1
- package/bin/gsd-t-file-disjointness.cjs +11 -0
- package/bin/gsd-t-graph-use-gate.cjs +5 -2
- package/bin/gsd-t-logging-envelope-check.cjs +104 -13
- package/bin/gsd-t-migrate-logging.cjs +31 -5
- 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-migrate-logging.md +1 -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 +2 -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
|
@@ -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();
|