@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.
@@ -51,6 +51,7 @@
51
51
  */
52
52
 
53
53
  const fs = require("node:fs");
54
+ const { walkSections, parseRows, rowState, tableName, REQUIRED_COLUMN_COUNT } = require("./gsd-t-testplan-rows.cjs");
54
55
  const path = require("node:path");
55
56
 
56
57
  // ─── tasks.md parsing ────────────────────────────────────────────────────
@@ -179,6 +180,15 @@ function _acBulletText(lines) {
179
180
  // bold forms both match, exactly like the M83 field scan.
180
181
  const PSEUDOCODE_SECTION_FIELD_RE = /^\s*[-*]?\s*pseudocode-section\s*:/i;
181
182
 
183
+ // ─── M115 A7: plan-row binding (test-plan-first-contract.md §2 + §7) ──────
184
+ //
185
+ // `**Plan-Row**: <TestPlan-doc-title>#<TableName>/Seq-<n>` — an ADDITIONAL way
186
+ // an acceptance line may clear, alongside the existing Files+Test binding.
187
+ // Recognized by the contract §2 row identity: the plan document, a table
188
+ // name, and a Seq. Matched emphasis-agnostically on the BARED line, exactly
189
+ // like every other field scan in this file — never a substring search.
190
+ const PLAN_ROW_FIELD_RE = /^\s*[-*]?\s*plan-row\s*:/i;
191
+
182
192
  /**
183
193
  * Parse the `**PseudoCode-Section**: <Title>#<anchor>` citation from a task's
184
194
  * lines. Returns { title, anchor } structured segments, or null if absent.
@@ -255,19 +265,94 @@ function docTitleFromFilename(filename) {
255
265
  return m ? m[1] : null;
256
266
  }
257
267
 
268
+ /**
269
+ * Parse a `**Plan-Row**: <doc>#<TableName>/Seq-<n>` citation (M115 A7,
270
+ * test-plan-first-contract.md §2 + §7 row identity: plan document + table
271
+ * name + Seq). Structured segments, never substring-matched — the value is
272
+ * split path-as-path on the FIRST `#`, then the anchor on the LAST `/`.
273
+ * Returns { doc, table, seq, raw } or null if the field is absent.
274
+ */
275
+ function parsePlanRowCitation(lines) {
276
+ for (const ln of lines) {
277
+ const bare = _bare(ln);
278
+ if (!PLAN_ROW_FIELD_RE.test(bare)) continue;
279
+ const idx = bare.indexOf(":");
280
+ if (idx < 0) continue;
281
+ const val = bare.slice(idx + 1).trim();
282
+ const hash = val.indexOf("#");
283
+ if (hash < 0) return { doc: val, table: "", seq: "", raw: val, malformed: true };
284
+ const doc = val.slice(0, hash).trim();
285
+ const anchor = val.slice(hash + 1).trim();
286
+ const slash = anchor.lastIndexOf("/");
287
+ if (slash < 0) return { doc, table: anchor, seq: "", raw: val, malformed: true };
288
+ const table = anchor.slice(0, slash).trim();
289
+ const seqPart = anchor.slice(slash + 1).trim();
290
+ const seqMatch = seqPart.match(/^Seq-(.+)$/i);
291
+ if (!seqMatch) return { doc, table, seq: "", raw: val, malformed: true };
292
+ return { doc, table, seq: seqMatch[1].trim(), raw: val, malformed: false };
293
+ }
294
+ return null;
295
+ }
296
+
297
+ /**
298
+ * Load a single TestPlan doc's real row identities: every `## Table: <name>`
299
+ * section's `Seq` column values, read structurally (positional table-cell
300
+ * parsing, never a substring search) — the same discipline as
301
+ * enumerateSections. Returns Set<"table::seq"> or null if the doc is absent.
302
+ */
303
+ function loadTestPlanRowIdentities(testPlanDir, docTitle) {
304
+ // TestPlan docs live in `.gsd-t/test-plans/` — the directory the front door
305
+ // (commands/gsd-t-test-plan.md) writes to and the verify gate globs
306
+ // (contract §7, ratified at M115 integrate).
307
+ //
308
+ // Red Team M115 (2026-09-03): a doc title is a single path SEGMENT. Anything
309
+ // else (`../`, a slash, an absolute path) could name a file outside the
310
+ // test-plans directory, so the resolved path must stay inside it.
311
+ if (typeof docTitle !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(docTitle)) return null;
312
+ const root = path.resolve(testPlanDir);
313
+ const candidate = path.resolve(root, `TestPlan-${docTitle}.md`);
314
+ if (!candidate.startsWith(root + path.sep)) return null;
315
+ let md;
316
+ try { md = fs.readFileSync(candidate, "utf8"); } catch { return null; }
317
+ // identity -> row state, read through the ONE shared plan reader (fence-aware
318
+ // for both fence styles, exact six-cell width). Red Team HIGH: a `GAP` row is
319
+ // an UNANSWERED requirement and must never clear an acceptance criterion;
320
+ // code-review run 4: a row with an EXTRA cell was read as sourced and cleared
321
+ // one anyway. A malformed row is its own state and never clears.
322
+ const identities = new Map();
323
+ for (const sec of walkSections(md)) {
324
+ const table = tableName(sec.heading);
325
+ if (!table) continue; // Open gaps, Sign-off, ... are not tables of identities
326
+ for (const row of parseRows(sec.lines, sec.startLine)) {
327
+ if (!row.cells[0]) continue;
328
+ const id = `${table}::${row.cells[0]}`;
329
+ // Two rows with one identity: nobody can say which one a citation means, so
330
+ // neither clears (code-review M115 run 6 — last-write-wins let a later
331
+ // sourced row overwrite an earlier GAP).
332
+ if (identities.has(id)) { identities.set(id, "duplicate"); continue; }
333
+ const state = row.width !== REQUIRED_COLUMN_COUNT ? "malformed" : rowState(row.cells[5]);
334
+ identities.set(id, state);
335
+ }
336
+ }
337
+ return identities;
338
+ }
339
+
258
340
  /**
259
341
  * A task is "behavioral" (subject to the gate) if it declares acceptance
260
342
  * criteria — i.e. it promises an observable behavior. Pure-scaffolding tasks
261
343
  * with no ACs are out of scope (nothing to trace).
262
344
  */
263
- function assessTask(task) {
345
+ function assessTask(task, opts = {}) {
264
346
  const lines = task.lines;
265
347
  // M87 D2: every task may carry a section citation, AC-bearing or not — capture
266
348
  // it regardless so non-behavioral coverage tasks still count toward citations.
267
349
  const sectionCitation = parseSectionCitation(lines);
350
+ // M115 A7: every task may ALSO carry a plan-row citation, AC-bearing or not,
351
+ // captured unconditionally for the same reason.
352
+ const planRowCitation = parsePlanRowCitation(lines);
268
353
  const hasAc = hasMultiField(lines, AC_FIELD_RE);
269
354
  if (!hasAc) {
270
- return { title: task.title, behavioral: false, violations: [], sectionCitation };
355
+ return { title: task.title, behavioral: false, violations: [], sectionCitation, planRowCitation };
271
356
  }
272
357
 
273
358
  // Underscore-preserving values for path/runner scans (Red Team recheck HIGH).
@@ -297,17 +382,33 @@ function assessTask(task) {
297
382
 
298
383
  const isHeadline = lines.some((ln) => HEADLINE_FIELD_RE.test(_bare(ln)));
299
384
 
385
+ // M115 A7 — ADDITIVE ONLY: an acceptance line may ALSO clear via a plan-row
386
+ // binding. This arm fires ONLY when a citation is actually present AND it
387
+ // resolves to a real row in a real loaded plan — never as a fallback the
388
+ // old Files+Test path drops into when it comes up short. `opts.resolvePlanRow`
389
+ // is supplied by the caller (runGate) already knowing which plan docs are in
390
+ // scope; when no resolver is supplied (e.g. a bare assessTask() call, or a
391
+ // milestone with no test plan), planRowClears stays false and every existing
392
+ // violation kind fires exactly as it did before this change.
393
+ let planRowClears = false;
394
+ if (planRowCitation && !planRowCitation.malformed && typeof opts.resolvePlanRow === "function") {
395
+ planRowClears = !!opts.resolvePlanRow(planRowCitation);
396
+ }
397
+
300
398
  const violations = [];
301
- if (!hasFiles) {
399
+ if (!hasFiles && !planRowClears) {
302
400
  violations.push({ kind: "ac-without-path", detail: "task declares acceptance criteria but no **Files** implementing path — an unbacked promise." });
303
401
  }
304
- if (!hasTest) {
402
+ if (!hasTest && !planRowClears) {
305
403
  violations.push({ kind: "ac-without-test", detail: "task declares acceptance criteria but names no test (Test field, test path, or runner) — the dead-code class: it can pass vacuously / never be exercised." });
306
404
  }
307
405
  if (isHeadline && !hasImplPath) {
308
406
  violations.push({ kind: "headline-without-impl", detail: "HEADLINE task has no non-test implementing path — the milestone's reason to exist is not bound to real code (the M5 AC-6 dead-code failure)." });
309
407
  }
310
- if (isHeadline && !hasTest) {
408
+ // A resolved plan row IS the headline's test (the suite is generated from the
409
+ // plan — contract §7.1); the impl-path rule above is untouched, a test row is
410
+ // not an implementation (Red Team M115 run 4, MEDIUM).
411
+ if (isHeadline && !hasTest && !planRowClears) {
311
412
  violations.push({ kind: "headline-without-test", detail: "HEADLINE task has no test proving the milestone's core capability is delivered (the missing >100MB-fixture failure)." });
312
413
  }
313
414
 
@@ -317,6 +418,8 @@ function assessTask(task) {
317
418
  isHeadline,
318
419
  hasFiles, hasTest, hasImplPath,
319
420
  sectionCitation,
421
+ planRowCitation,
422
+ planRowClears,
320
423
  violations,
321
424
  };
322
425
  }
@@ -518,6 +621,50 @@ function runGate({ projectDir = process.cwd(), milestone = null, tasksFile = nul
518
621
  return { ok: false, exitCode: 64, milestone, reason: "no-tasks-files", tasks: [], violations: [] };
519
622
  }
520
623
 
624
+ // M87 D2 dir resolution, hoisted ABOVE the task loop unchanged (M115 A7):
625
+ // this computation depends only on (projectDir, pseudocodeDir), never on
626
+ // taskResults, so moving it earlier changes nothing about what it resolves
627
+ // to — it only makes the resolved dir available to the plan-row resolver
628
+ // below. Containment: an explicit --pseudocode-dir must resolve INSIDE the
629
+ // project tree (the D2 test legitimately points it at test/fixtures/, also
630
+ // inside the project; an out-of-tree `../../evildocs` is refused). The
631
+ // default `.gsd-t/pseudocode/` is always in-tree. A refused dir → empty doc
632
+ // set (logged skip, never an out-of-tree read), same fail-closed shape as a
633
+ // missing dir.
634
+ let resolvedPcDir = pseudocodeDir || path.join(projectDir, ".gsd-t", "pseudocode");
635
+ if (pseudocodeDir) {
636
+ const resolvedProject = path.resolve(projectDir);
637
+ const candidate = path.resolve(resolvedProject, pseudocodeDir);
638
+ if (candidate !== resolvedProject && !candidate.startsWith(resolvedProject + path.sep)) {
639
+ resolvedPcDir = null; // out-of-tree → no docs loaded (fail-closed)
640
+ } else {
641
+ resolvedPcDir = candidate;
642
+ }
643
+ }
644
+
645
+ // M115 A7: resolve a plan-row citation against `.gsd-t/test-plans/` — the
646
+ // in-tree directory the front door writes plans to and the verify gate
647
+ // globs (contract §7). A milestone with no test plan (dir or doc simply
648
+ // absent) makes every citation resolve to false — planRowClears stays
649
+ // false and the task takes EXACTLY its pre-A7 path. Never a fallback: this
650
+ // only ever WIDENS what clears, it is not consulted unless a citation was
651
+ // actually written.
652
+ const testPlanDir = path.join(path.resolve(projectDir), ".gsd-t", "test-plans");
653
+ const planRowIdentityCache = new Map(); // docTitle -> Map<"table::seq", state>|null
654
+ function resolvePlanRow(citation) {
655
+ if (!citation.doc || !citation.table || !citation.seq) return false;
656
+ if (!planRowIdentityCache.has(citation.doc)) {
657
+ planRowIdentityCache.set(citation.doc, loadTestPlanRowIdentities(testPlanDir, citation.doc));
658
+ }
659
+ const identities = planRowIdentityCache.get(citation.doc);
660
+ if (!identities) return false;
661
+ const state = identities.get(`${citation.table}::${citation.seq}`);
662
+ // An open GAP row is the plan saying "nobody has answered this yet". Letting
663
+ // it clear an acceptance criterion would recreate the dead-deliverable shape
664
+ // this gate exists to catch (Red Team M115 HIGH).
665
+ return state === "sourced" || state === "decided";
666
+ }
667
+
521
668
  const taskResults = [];
522
669
  const violations = [];
523
670
  let behavioralCount = 0;
@@ -525,7 +672,7 @@ function runGate({ projectDir = process.cwd(), milestone = null, tasksFile = nul
525
672
  let md;
526
673
  try { md = fs.readFileSync(f.tasksPath, "utf8"); } catch { continue; }
527
674
  for (const t of parseTasks(md)) {
528
- const r = assessTask(t);
675
+ const r = assessTask(t, { resolvePlanRow });
529
676
  r.domain = f.domain;
530
677
  taskResults.push(r);
531
678
  if (r.behavioral) behavioralCount++;
@@ -536,21 +683,6 @@ function runGate({ projectDir = process.cwd(), milestone = null, tasksFile = nul
536
683
  }
537
684
 
538
685
  // M87 D2: section-citation coverage over the in-scope PseudoCode docs.
539
- // Containment: an explicit --pseudocode-dir must resolve INSIDE the project
540
- // tree (the D2 test legitimately points it at test/fixtures/, also inside the
541
- // project; an out-of-tree `../../evildocs` is refused). The default
542
- // `.gsd-t/pseudocode/` is always in-tree. A refused dir → empty doc set
543
- // (logged skip, never an out-of-tree read), same fail-closed shape as a missing dir.
544
- let resolvedPcDir = pseudocodeDir || path.join(projectDir, ".gsd-t", "pseudocode");
545
- if (pseudocodeDir) {
546
- const resolvedProject = path.resolve(projectDir);
547
- const candidate = path.resolve(resolvedProject, pseudocodeDir);
548
- if (candidate !== resolvedProject && !candidate.startsWith(resolvedProject + path.sep)) {
549
- resolvedPcDir = null; // out-of-tree → no docs loaded (fail-closed)
550
- } else {
551
- resolvedPcDir = candidate;
552
- }
553
- }
554
686
  const pseudocodeDocs = resolvedPcDir ? loadPseudocodeDocs(resolvedPcDir) : new Map();
555
687
  const coverage = assessSectionCoverage(taskResults, pseudocodeDocs);
556
688
  for (const v of coverage.violations) violations.push(v);
@@ -631,5 +763,6 @@ module.exports = {
631
763
  runGate, parseTasks, assessTask, listTasksFiles,
632
764
  parseSectionCitation, slugifyHeading, enumerateSections, docTitleFromFilename,
633
765
  loadPseudocodeDocs, assessSectionCoverage,
766
+ parsePlanRowCitation, loadTestPlanRowIdentities,
634
767
  _internal: { fieldValue, TEST_PATH_RE },
635
768
  };
package/bin/gsd-t.js CHANGED
@@ -1812,6 +1812,13 @@ const GLOBAL_BIN_TOOLS = [
1812
1812
  // M109 — project-history reader + rule miner, used by /gsd-t-setup to write a
1813
1813
  // project CLAUDE.md from what actually happened rather than from a template.
1814
1814
  "gsd-t-project-history.cjs", "gsd-t-rule-mine.cjs",
1815
+ // M115 (contract test-plan-first-contract.md §5) — the A2 shape gate and the A5
1816
+ // non-convergence halt for the test-plan-first command. Both are dispatched by
1817
+ // `bin/gsd-t.js` ("testplan-lint" / "testplan-halt" cases) AND by the verify
1818
+ // workflow's gate wiring, so an omission here is the same propagation-gap class
1819
+ // caught four times before ([[project_global_bin_propagation_gap]]). Also in
1820
+ // PROJECT_BIN_TOOLS below — both lists, both tools.
1821
+ "gsd-t-testplan-lint.cjs", "gsd-t-testplan-halt.cjs", "gsd-t-testplan-rows.cjs",
1815
1822
  ];
1816
1823
 
1817
1824
  // Directories under bin/ that must ship whole. A runner whose parts stay behind
@@ -3574,6 +3581,11 @@ const PROJECT_BIN_TOOLS = [
3574
3581
  // Plus the verify-gate lint (gsd-t-verify-gate.cjs invokes it via __dirname,
3575
3582
  // so it must sit alongside the gate in the project's bin/).
3576
3583
  "gsd-t-doc-marker.cjs", "gsd-t-env-registry.cjs", "gsd-t-env-registry-check.cjs",
3584
+ // M115 (contract test-plan-first-contract.md §5) — the A2 shape gate and the A5
3585
+ // non-convergence halt. The verify workflow's project-local runCli resolves these,
3586
+ // and /gsd-t-test-plan calls them directly — same propagation-gap class as the
3587
+ // graph tools above. Also in GLOBAL_BIN_TOOLS — both lists, both tools.
3588
+ "gsd-t-testplan-lint.cjs", "gsd-t-testplan-halt.cjs", "gsd-t-testplan-rows.cjs",
3577
3589
  ];
3578
3590
 
3579
3591
  // Files that older versions of this installer copied into project bin/ but
@@ -5999,6 +6011,36 @@ if (require.main === module) {
5999
6011
  });
6000
6012
  process.exit(res.status == null ? 1 : res.status);
6001
6013
  }
6014
+ case "testplan-lint": {
6015
+ // M115 (contract §5) — `gsd-t testplan-lint (--doc <f> | --dir <d>)` thin dispatcher
6016
+ // to the A2 test-plan shape gate (owner: deterministic-gates). Sibling of
6017
+ // `pseudocode-style` — same proven shape (0 clean / 4 violations / 64 bad input).
6018
+ const { spawnSync } = require("child_process");
6019
+ const js = path.join(__dirname, "gsd-t-testplan-lint.cjs");
6020
+ if (!require("node:fs").existsSync(js)) {
6021
+ error(`gsd-t-testplan-lint.cjs not found at ${js} — reinstall GSD-T (contract test-plan-first-contract.md §5)`);
6022
+ process.exit(1);
6023
+ }
6024
+ const res = spawnSync(process.execPath, [js, ...args.slice(1)], {
6025
+ stdio: "inherit",
6026
+ });
6027
+ process.exit(res.status == null ? 1 : res.status);
6028
+ }
6029
+ case "testplan-halt": {
6030
+ // M115 (contract §5) — `gsd-t testplan-halt check --doc <f> --round <n>` thin
6031
+ // dispatcher to the A5 non-convergence halt (owner: halt-convergence). Reuses
6032
+ // bin/gsd-t-loop-ledger.cjs read-only for the repeated-symptom cap.
6033
+ const { spawnSync } = require("child_process");
6034
+ const js = path.join(__dirname, "gsd-t-testplan-halt.cjs");
6035
+ if (!require("node:fs").existsSync(js)) {
6036
+ error(`gsd-t-testplan-halt.cjs not found at ${js} — reinstall GSD-T (contract test-plan-first-contract.md §5)`);
6037
+ process.exit(1);
6038
+ }
6039
+ const res = spawnSync(process.execPath, [js, ...args.slice(1)], {
6040
+ stdio: "inherit",
6041
+ });
6042
+ process.exit(res.status == null ? 1 : res.status);
6043
+ }
6002
6044
  case "archive-domains": {
6003
6045
  // Backlog #40 — `gsd-t archive-domains --domains a,b --archive <dir>` deterministic
6004
6046
  // archive+sweep of a completed milestone's domain dirs (complete-milestone Step 7).
package/commands/cpua.md CHANGED
@@ -115,11 +115,29 @@ Verify the output shows `+ @tekyzinc/gsd-t@{NEW_VERSION}`. If publish fails:
115
115
  ## Step 6: Update global install + propagate to projects
116
116
 
117
117
  ```bash
118
- npm install -g @tekyzinc/gsd-t@{NEW_VERSION}
118
+ G="$(npm root -g)/@tekyzinc/gsd-t"
119
+ # 1. Install by TARBALL URL, not by version spec. Right after `npm publish`, npm keeps
120
+ # the pre-publish package listing cached and `@tekyzinc/gsd-t@{NEW}` returns ETARGET
121
+ # for minutes while `npm view` already shows the version (v5.17.14, 2026-09-03).
122
+ # The tarball URL bypasses the cached listing and is deterministic.
123
+ npm install -g "https://registry.npmjs.org/@tekyzinc/gsd-t/-/gsd-t-{NEW_VERSION}.tgz"
124
+ # 2. VERIFY ON DISK — never trust npm's exit code for this step. On v5.17.13 `npm install -g`
125
+ # reported success and left the OLD version in place; `update-all` then ran from the
126
+ # stale global, told 32 projects they were "already current", and overwrote a
127
+ # hand-patched project file with the old build.
128
+ ON_DISK=$(node -p "require('$G/package.json').version")
129
+ if [ "$ON_DISK" != "{NEW_VERSION}" ]; then
130
+ echo "global install is $ON_DISK, expected {NEW_VERSION} — removing and reinstalling"
131
+ npm uninstall -g @tekyzinc/gsd-t; rm -rf "$G"; npm cache clean --force
132
+ npm install -g "https://registry.npmjs.org/@tekyzinc/gsd-t/-/gsd-t-{NEW_VERSION}.tgz"
133
+ ON_DISK=$(node -p "require('$G/package.json').version")
134
+ [ "$ON_DISK" = "{NEW_VERSION}" ] || { echo "HALT: global install still $ON_DISK"; exit 1; }
135
+ fi
136
+ # 3. Only now propagate.
119
137
  gsd-t update-all 2>&1 | tail -30
120
138
  ```
121
139
 
122
- Use `npm install -g @tekyzinc/gsd-t@{NEW_VERSION}` (NOT `npm update -g`) — npm rejects update for some legacy version strings (e.g., `3.19.00` is invalid semver because of the leading-zero patch). Pinning to the exact version sidesteps this.
140
+ Never `npm update -g` (npm rejects it for legacy version strings like `3.19.00`). After `update-all`, prove propagation by `ls`/`grep` of one NEW or CHANGED file in a real registered project — the "copied N tool(s)" line is a report, not proof. Note: `update-all` also overwrites `~/.claude/commands/*.md` from the package, so THIS file's source of truth is `commands/cpua.md` in the GSD-T repo; an edit made only in `~/.claude/commands/` is lost on the next propagation.
123
141
 
124
142
  ## Step 7: Report
125
143
 
@@ -31,6 +31,7 @@ MILESTONE WORKFLOW [auto] = in wave
31
31
  milestone Define a new milestone
32
32
  partition [auto] Decompose milestone into domains + contracts
33
33
  plan [auto] Create atomic task lists per domain
34
+ test-plan Enumerate the case space from requirements, before any code/tests exist
34
35
  impact [auto] Analyze downstream effects before execution
35
36
  execute [auto] Run tasks (solo or team mode)
36
37
  test-sync [auto] Sync tests with code changes
@@ -235,6 +236,14 @@ Use these when user asks for help on a specific command:
235
236
  - **Note (M26)**: Pre-mortem step now also reads rules.jsonl for historical failure patterns via getPreMortemRules
236
237
  - **Note (M38)**: Conversational use cases (formerly `/gsd-t-prompt`, `/gsd-t-brainstorm`, `/gsd-t-discuss`) are now handled by the Smart Router's conversational mode — just describe what you want via `/gsd` or plain text.
237
238
 
239
+ ### test-plan
240
+ - **Summary**: Enumerate every test case a requirements document implies, BEFORE any code or tests exist — an unfillable row is surfaced as a missing or wrong requirement, not smoothed over.
241
+ - **Auto-invoked**: No (standalone, on-demand; runs in-session like `architect`)
242
+ - **Args**: `/gsd-t-test-plan [requirements path]` (before-mode, default) or `/gsd-t-test-plan --after` (re-enumerate against built code, classify failures by cited evidence)
243
+ - **Creates**: `.gsd-t/test-plans/TestPlan-[FeatureArea].md`
244
+ - **Use when**: A requirements document exists (or a milestone is about to be planned) and you want its case space interrogated for gaps before any test is written; or after a milestone is built, to classify test failures as code-bug vs. wrong-requirement from cited evidence only.
245
+ - **Note (M115)**: Every open row is batched into ONE question round — never a drip. Three rounds without closure, or the same failure signature twice running, HALTs via `gsd-t testplan-halt` rather than filling an open row with anything plausible. `gsd-t testplan-lint` gates the plan's shape before it is presented, and again as a FAIL-blocking `verify` gate.
246
+
238
247
  ### impact
239
248
  - **Summary**: Analyze downstream effects of planned changes
240
249
  - **Auto-invoked**: Yes (in wave, between plan and execute)
@@ -4,6 +4,7 @@ Scaffolds the two framework-default logging streams — **trace** (transient deb
4
4
 
5
5
  ## What this does
6
6
 
7
+ - **Declared streams are skipped.** If `.gsd-t/logging-manifest.json` names a stream's `module` (e.g. `{ "audit": { "module": "server/src/audit.ts", "store": { "kind": "postgres", "table": "tb_audit_log" }, "retention": "indefinite" } }`), that stream already exists and is NOT scaffolded — reported under `declared`. This is how a project with a real audit table at a path the checker does not guess adds only the missing trace stream, with no second audit module written beside the first. An unreadable manifest is an error, not "no manifest".
7
8
  - Copies the trace module template (`templates/logging/trace-module.template.ts`) to `src/logging/trace.ts` — **only if that file does not already exist**.
8
9
  - Copies the audit module template (`templates/logging/audit-module.template.ts`) to `src/logging/audit.ts` — **only if that file does not already exist**.
9
10
  - Distills the per-project trace category / audit action schema from the project's own plan (when `--plan` is given) into `.gsd-t/logging-schema.json` — never confabulated; an unstated category/action is a gap, not a guess.
@@ -0,0 +1,85 @@
1
+ # GSD-T: Test-Plan-First — Enumerate the Case Space Before Tests Exist
2
+
3
+ You run the M115 test-plan-first interrogation, in-session, as the lead agent — the same
4
+ pattern as `/gsd-t-architect`. There is no separate Workflow phase script for this command:
5
+ every deliverable it orchestrates is a deterministic gate (`bin/gsd-t-testplan-lint.cjs`,
6
+ `bin/gsd-t-testplan-halt.cjs`) or a subagent protocol file you Read and follow directly, per
7
+ `.gsd-t/contracts/test-plan-first-contract.md`.
8
+
9
+ **Why this exists.** Every check GSD-T runs today reads something that already exists (code,
10
+ a plan) and can only judge what was built. This runs BEFORE any code exists: it enumerates
11
+ every case the requirements already imply, so a missing or wrong requirement is caught as a
12
+ gap in a row, not as a bug found later.
13
+
14
+ ## Argument Parsing
15
+
16
+ Parse `$ARGUMENTS`:
17
+ - **No flag (before-mode, default)** — enumerate from what is already held (requirements,
18
+ architecture, contracts, standing rules, any code that exists), before tests are written.
19
+ - **`--after`** — the same enumeration run against already-built code: run the plan's rows
20
+ as tests, classify each failure by cited evidence.
21
+ - **First positional** (either mode) — the requirements document or feature-area slice to
22
+ enumerate. Defaults to `docs/requirements.md`.
23
+
24
+ ## Before-mode (default)
25
+
26
+ 0. **Where the plan lives.** Write the plan to `.gsd-t/test-plans/TestPlan-[FeatureArea].md`
27
+ — the sibling location of `.gsd-t/pseudocode/PseudoCode-[Title].md`, one stage earlier in
28
+ the pipeline. This is what the verify workflow's gate globs.
29
+ 1. **Read the enumeration protocol.** `Read templates/prompts/test-plan-enumerator-subagent.md`
30
+ in full — the E1-E8 rules that define the case space. Do not restate it here; follow it.
31
+ 2. **Read the mold.** `Read templates/TestPlan-spec.md` — the section set and six-column
32
+ row schema a plan must have (frozen shape: `.gsd-t/contracts/test-plan-first-contract.md`
33
+ §2-§3).
34
+ 3. **Enumerate.** Apply E1-E8 to the target requirements document. Fill each row from named
35
+ evidence (`sourced`), or mark it `DECIDED-WITHOUT-YOU` (self-answered — also grouped
36
+ under `## Decided without you`), or leave it `GAP`/`GAP:CONTRADICTION` (open). Never fill
37
+ an unanswerable row with something plausible — that is the one failure this command
38
+ exists to prevent.
39
+ 4. **Batch every open row into ONE question round (A4).** Collect every `GAP`/
40
+ `GAP:CONTRADICTION` row from every table, ask them together in a single round, fold the
41
+ answers in together, then re-enumerate to confirm closure. **Never ask one question at a
42
+ time as each answer arrives — a drip defeats the batching the reviewer's attention
43
+ depends on.**
44
+ 5. **Round cap: three rounds, never a fourth.** After folding each round's answers,
45
+ re-enumerate and run `gsd-t testplan-halt check --doc <plan path> --round <n>` (falls
46
+ back to `node bin/gsd-t-testplan-halt.cjs check ...` locally). If it reports
47
+ `halted: true` — either round three still has open rows, or the same failure signature
48
+ has recurred across two consecutive rounds — STOP: hand back `blocked-needs-human`
49
+ naming every row the tool lists as never settled, rather than continuing with anything
50
+ left open. A repeated symptom means the belief behind an earlier answer is wrong, not
51
+ that the row needs a third try.
52
+ 6. **Fold closed answers into `docs/requirements.md`.**
53
+ 7. **Gate for shape.** Run `gsd-t testplan-lint --doc <plan path> --json` (falls back to
54
+ `node bin/gsd-t-testplan-lint.cjs --doc <plan path> --json` locally). A non-zero exit
55
+ means the plan is malformed — fix it before presenting; never present a plan that fails
56
+ its own gate.
57
+ 8. **Present for sign-off.** Show the plan (or a summary with the file path) and stop for
58
+ the user's review. **Tests are generated from the rows only after sign-off** — this
59
+ command does not write tests itself.
60
+
61
+ ## `--after` mode
62
+
63
+ 1. Read the same enumerator protocol and re-enumerate against the requirements as they
64
+ stand today, against the built code.
65
+ 2. Run the resulting rows as tests against the code.
66
+ 3. For each failure, `Read templates/prompts/test-plan-evidence-classifier.md` and follow it
67
+ to classify the failure as code-bug or wrong-requirement, from cited evidence only —
68
+ never a default arm.
69
+ 4. Fix code-bugs directly (small, local). A wider refactor a classification surfaces is
70
+ spilled to `.gsd-t/techdebt.md` rather than done in this pass.
71
+ 5. Re-run `gsd-t testplan-lint --doc <plan path> --json` on the updated plan before
72
+ presenting the result.
73
+
74
+ ## Document Ripple
75
+
76
+ Fold closed answers into `docs/requirements.md`. Log a Decision Log entry in
77
+ `.gsd-t/progress.md` for the enumeration round(s) run and their outcome. A wider refactor
78
+ found in `--after` mode goes to `.gsd-t/techdebt.md`, not into this pass's diff.
79
+
80
+ ## Next Up
81
+
82
+ `/gsd-t-plan` — turn the signed-off plan's rows into tasks.
83
+
84
+ **Also available:**
85
+ - `/gsd-t-execute` — when run in `--after` mode against a milestone already mid-build.
package/commands/gsd.md CHANGED
@@ -88,6 +88,7 @@ When the same request could fit multiple commands at different scales:
88
88
  - **Requires its own milestone with domains** → `milestone` or `project`
89
89
  - **Needs investigation before fixing** → `debug` (not `quick`)
90
90
  - **Spec/requirements to verify against code** → `gap-analysis` (not `scan`)
91
+ - **Enumerate every test case a requirements doc implies, BEFORE any code or tests exist** → `test-plan` (not `plan` — `plan` writes task lists from an already-partitioned milestone; `test-plan` interrogates the requirements themselves for gaps)
91
92
 
92
93
  ### Design-to-code routing:
93
94
 
@@ -179,7 +180,7 @@ Where `{last-command}` is:
179
180
 
180
181
  **CRITICAL: `{command}` and `{last-command}` MUST be a real GSD-T command slug — never a free-form description.**
181
182
 
182
- Valid command slugs: `quick`, `debug`, `feature`, `execute`, `milestone`, `project`, `scan`, `gap-analysis`, `plan`, `partition`, `impact`, `integrate`, `verify`, `test-sync`, `complete-milestone`, `wave`, `status`, `populate`, `setup`, `init`, `health`, `log`, `pause`, `resume`, `prd`, `backlog-add`, `backlog-list`, `backlog-promote`, `promote-debt`, `triage-and-merge`, `version-update`, `version-update-all`, `design-decompose`, `design-build`, `design-audit`, `design-review`
183
+ Valid command slugs: `quick`, `debug`, `feature`, `execute`, `milestone`, `project`, `scan`, `gap-analysis`, `plan`, `partition`, `impact`, `integrate`, `verify`, `test-sync`, `test-plan`, `complete-milestone`, `wave`, `status`, `populate`, `setup`, `init`, `health`, `log`, `pause`, `resume`, `prd`, `backlog-add`, `backlog-list`, `backlog-promote`, `promote-debt`, `triage-and-merge`, `version-update`, `version-update-all`, `design-decompose`, `design-build`, `design-audit`, `design-review`
183
184
 
184
185
  **WRONG ❌** — do not do this:
185
186
  ```
@@ -1001,6 +1001,24 @@ Contract: `.gsd-t/contracts/unproven-assumption-doctrine-contract.md` v1.0.0 STA
1001
1001
  | REQ-M90-06 (SC-SELF-OBEDIENCE) | M90's own artifacts show the doctrine applied to itself: discuss produced a sourced approach, pseudocode signed off before code, premises re-verified on disk, and any ≥3-cycle same-signature non-convergence triggered a recorded premise re-examination (not a variant patch). Every §6 [RULE] traces to an enforcement point (orphan rule FAILS the guard-map test). | m90-d-contract-doctrine-integrate | D4-T1/T7 | complete (M90) |
1002
1002
  | REQ-M90-07 (SC-RESEARCH-GATE) | Plan is mechanically blocked until discuss emits a sourced approach with ≥1 external citation per detection mechanism. | m90-d-contract-doctrine-integrate (doctrine contract) | D4-T1 | satisfied (discuss `.gsd-t/discuss/M90-approach-sourced.md`) |
1003
1003
 
1004
+ ## M115 Test-Plan-First Requirements Interrogation (complete - v5.18.10)
1005
+
1006
+ Before any code exists, enumerate every test case a milestone's requirements imply into a reviewable sequence-table document; every row nobody can fill in is a requirements gap, surfaced by trying to write the row rather than by reading code. Origin: the TimeTracking v1.27 rate-ledger review (2026-09-01/02), whose test plan found three unwritten requirements.
1007
+
1008
+ Contract: `.gsd-t/contracts/test-plan-first-contract.md` v1.1.0 STABLE · Pseudocode: `.gsd-t/pseudocode/PseudoCode-TestPlanFirst.md` · Command: `/gsd-t-test-plan`
1009
+
1010
+ | ID | Requirement | Domain | Tests | Status |
1011
+ |----|-------------|--------|-------|--------|
1012
+ | REQ-M115-A1 (BLIND-REPLAY) | Cold enumeration from the pre-review requirements alone (generic protocol, no answer-key material, memory-free context) surfaces the three gaps the human review found; scored against pre-registered hit conditions held out from the enumerator. Condition 1 re-written to the gap's shape (rate changes silently alter issued invoices) with a recorded ⚠ Divergence, David-approved 2026-09-03. | enumerator-core | `test/m115-a1-blind-replay.test.js` (6) over `.gsd-t/scan/m115-cold-enumeration-blind-scoped.md` | **complete 2026-09-03** |
1013
+ | REQ-M115-A2 (SHAPE-LINT) | `gsd-t testplan-lint (--doc \| --dir)` exits 0 clean / 4 violations / 64 bad input over the six-column mold; unknown markers, blank Seq, blank source, wrong width, duplicate identities, fenced fakes are violations; mandatory negative tests | deterministic-gates | `test/m115-a2-testplan-lint.test.js` (14), `test/m115-verify-fixes.test.js` | **complete 2026-09-03** |
1014
+ | REQ-M115-A3 (SELF-ANSWER-VISIBLE) | Every DECIDED-WITHOUT-YOU row is grouped under one `## Decided without you` heading exactly matching the rows, fence-aware, so a reviewer can overrule any at a glance | plan-visibility | `test/m115-a3-self-answer-visibility.test.js` (6) | **complete 2026-09-03** |
1015
+ | REQ-M115-A4 (ONE-ROUND) | Open rows are asked in ONE batched question round per loop, never one at a time | plan-visibility / front-door-wiring | `commands/gsd-t-test-plan.md` protocol; `test/m115-a8-front-door-test-plan.test.js` | **complete 2026-09-03** |
1016
+ | REQ-M115-A5 (NON-CONVERGENCE-HALT) | Three question rounds with rows still open, or the same open-row set twice running, HALTS with `blocked-needs-human` naming every open row; rounds are counted per plan and per round (idempotent re-check), through `gsd-t-loop-ledger.cjs` read-only | halt-convergence | `test/m115-a5-non-convergence-halt.test.js` (13), `test/m115-verify-fixes.test.js` | **complete 2026-09-03** |
1017
+ | REQ-M115-A6 (EVIDENCE-OR-HALT) | In `--after` mode every failing case is classified code-bug / wrong-requirement / cannot-tell from cited evidence — three arms, no default branch; cannot-tell joins the question round | plan-visibility | `test/m115-a6-evidence-or-halt.test.js` (21) | **complete 2026-09-03** |
1018
+ | REQ-M115-A7 (PLAN-ROW-BINDING) | An acceptance criterion may bind to `**Plan-Row**: <Plan>#<Table>/Seq-<n>` in `.gsd-t/test-plans/`; only a sourced or decided row of exactly six cells with a unique identity clears; GAP, malformed, duplicate or escaped-path rows never do; M83/M87 behaviour byte-identical | deterministic-gates | `test/m115-a7-traceability-plan-row.test.js` (17), `test/m83-*.test.js`, `test/m87-*.test.js` unchanged | **complete 2026-09-03** |
1019
+ | REQ-M115-A8 (FRONT-DOOR) | `/gsd-t-test-plan` is reachable: command file, `/gsd` router case, both bin-tool registries, CLI dispatch, verify-gate wiring with a NAMED skip distinguishable from a clean run; the front-door test was RED before wiring and GREEN after | front-door-wiring | `test/m115-a8-front-door-test-plan.test.js` (15) | **complete 2026-09-03** |
1020
+ | REQ-M115-R1 (ONE-READER) | Every consumer of a test plan (lint, halt, traceability gate) reads sections and rows through `bin/gsd-t-testplan-rows.cjs` — both fence styles, exact six-cell width, one classifier, one heading pattern — so the three cannot drift apart again | verify (7 runs) | `test/m115-verify-fixes.test.js` (29), `test/verify-gate-tools-propagated.test.js` (sibling requires ship) | **complete 2026-09-03** |
1021
+
1004
1022
  ## Updated Functional Requirements (scan findings - v4.0.27)
1005
1023
 
1006
1024
  The deep scan identified functional deficiencies not captured in previous requirements. These are recorded here for tracking:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "5.17.13",
3
+ "version": "5.18.10",
4
4
  "description": "GSD-T: Contract-Driven Development for Claude Code — 54 slash commands with headless-by-default workflow spawning, unattended supervisor relay with event stream, graph-powered code analysis, real-time agent dashboard, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
5
5
  "author": "Tekyz, Inc.",
6
6
  "license": "MIT",
@@ -256,6 +256,7 @@ Every GSD-T project gets TWO logging streams scaffolded by default at `gsd-t-ini
256
256
  - **Storage is stack-adaptive and human-approval-gated** — `bin/gsd-t-logging-scaffolder.cjs` detects the stack, presents real alternatives, and STOPS for approval; it never silently picks a backend (the one sanctioned pause against the Level-3 full-auto default).
257
257
  - **Trace and audit NEVER collapse into one stream.** A trace envelope carrying audit markers (`before`/`after`/`actor`/`action`) or vice-versa is a contract violation and a `gsd-t-verify` FAIL — see each contract's §no-collapse boundary.
258
258
  - **Brownfield migration**: `gsd-t migrate-logging <projectDir>` scaffolds both streams into an EXISTING project additively — it never modifies or deletes a pre-existing file. See `commands/gsd-t-migrate-logging.md`.
259
+ - **Streams that already exist somewhere the checker does not guess** (an audit table behind `server/src/audit.ts`, say) are DECLARED in `.gsd-t/logging-manifest.json` — `{ "audit": { "module": "server/src/audit.ts", "store": { "kind": "postgres", "table": "tb_audit_log" }, "retention": "indefinite" } }`. The declaration is checked (a path that does not exist FAILs), an external store's rows are not inspected offline (reported in `notes`, the module surface is the enforced evidence), and `migrate-logging` skips a declared stream so it never scaffolds a twin.
259
260
 
260
261
  ## Orthogonal Validation Triad (Mandatory)
261
262
 
@@ -607,6 +608,7 @@ Add `**Also available:**` with `- /gsd-t-{alt} — {desc}` lines if alternatives
607
608
  | `partition` | `plan` | `discuss` (if complex) |
608
609
  | `discuss` | `plan` | |
609
610
  | `plan` | `execute` | `impact` (if risky) |
611
+ | `test-plan` | `plan` | `execute` (if `--after` mode against a milestone already mid-build) |
610
612
  | `impact` | `execute` | |
611
613
  | `execute` | `test-sync` | |
612
614
  | `test-sync` | `verify` | `integrate` (if multi-domain) |
@@ -0,0 +1,78 @@
1
+ # TestPlan-{Title}
2
+
3
+ **Project:** {Project Name} · **Date:** {Date}
4
+
5
+ One sentence: what feature area this plan enumerates, and which requirements document it
6
+ was enumerated from.
7
+
8
+ ---
9
+
10
+ <!--
11
+ ─────────────────────────────────────────────────────────────────────────────
12
+ HOW TO WRITE THIS (delete this comment block in the real instance)
13
+ ─────────────────────────────────────────────────────────────────────────────
14
+
15
+ This is the blank mold every test plan (a document that enumerates every
16
+ case a feature area implies, so a reviewer approves DIRECTION before
17
+ TESTS exist) is filled from. It is the sibling of `PseudoCode-spec.md` —
18
+ same job, one section earlier in the pipeline: PseudoCode maps intended
19
+ BEHAVIOR before code; this maps intended CASES before tests.
20
+
21
+ Full row schema, marker literals, and self-answered-visibility rule:
22
+ `.gsd-t/contracts/test-plan-first-contract.md` §2–§3 (frozen — do not
23
+ restate or diverge from it here; this file shows the shape in place).
24
+
25
+ Section order below is FIXED. A plan missing any of the five, or with
26
+ them out of order, does not satisfy the contract's row schema.
27
+
28
+ Name the real file `TestPlan-[FeatureArea].md` — never a milestone id —
29
+ the way a PseudoCode doc is named for its subject, not its milestone.
30
+ ─────────────────────────────────────────────────────────────────────────────
31
+ -->
32
+
33
+ ## Decided without you
34
+
35
+ Every row anywhere in this document whose `Source` column carries the marker
36
+ `DECIDED-WITHOUT-YOU` is copied here a second time, so a reviewer can overrule any of them
37
+ by reading this one group and nowhere else. Present always — even with nothing under it.
38
+
39
+ - `{table name}` Seq `{n}` — {the decision made} — evidence: {what was used to decide it}
40
+
41
+ When there are none:
42
+
43
+ > None — every row is sourced.
44
+
45
+ ---
46
+
47
+ ## Table: {Feature or Capability Name}
48
+
49
+ | Seq | Setup / date | Action | Expected result | Effect on saved data | Source |
50
+ |---|---|---|---|---|---|
51
+ | 1 | {system state and date before the action} | {the one thing done} | {what must happen, stated so a test can fail it} | {what changes in data already stored — `none` is a real, written answer} | `docs/requirements.md#{anchor}` |
52
+ | 2 | {system state and date before the action} | {the one thing done} | {what must happen} | {effect, or `none`} | DECIDED-WITHOUT-YOU — {evidence used} |
53
+ | 3 | {system state and date before the action} | {the one thing done} | {what must happen} | {effect, or `none`} | GAP — {why it could not be filled} |
54
+
55
+ Add one `## Table:` section per coherent sub-area within this feature. Each keeps its own
56
+ `Seq` numbering starting at 1.
57
+
58
+ ---
59
+
60
+ ## Open gaps
61
+
62
+ Every `GAP` (and `GAP:CONTRADICTION`) row across every table, collected here in one list.
63
+ This is what the single question round is built from — a reviewer answers this list, not
64
+ the tables.
65
+
66
+ - `{table name}` Seq `{n}` — {why it could not be filled}
67
+
68
+ When there are none:
69
+
70
+ > None — every row is answered.
71
+
72
+ ---
73
+
74
+ ## Sign-off
75
+
76
+ | Signed by | Date |
77
+ |---|---|
78
+ | {name} | {Date} |
@@ -28,8 +28,14 @@ const FF = (() => {
28
28
  try {
29
29
  execFileSync(full, ['-version'], { stdio: 'ignore' });
30
30
  return full;
31
- } catch {
32
- /* installed but broken fall through to the plain build */
31
+ } catch (err) {
32
+ // Installed but broken (a Homebrew upgrade left a shared library missing).
33
+ // Rendering with a different binary would change the output silently —
34
+ // ffmpeg-full carries filters the plain build lacks. Halt with the fix.
35
+ throw new Error(
36
+ `ffmpeg-full is installed at ${full} but does not run (${String(err).slice(0, 120)}). ` +
37
+ 'Fix: brew reinstall ffmpeg-full',
38
+ );
33
39
  }
34
40
  }
35
41
  return 'ffmpeg';