Merge pull request #2214 from open-gsd/fix/2133-fast-md-log-to-state-schema-gate

fix(#2133): correct fast.md log_to_state column-count gate (NF-2)
This commit is contained in:
Tom Boucher
2026-07-12 12:58:46 -04:00
committed by GitHub
22 changed files with 221 additions and 30 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 2214
---
**`/gsd-fast` now appends Quick Task rows to STATE.md again** — the log_to_state column-count guard used an off-by-one awk formula (`NF-1`) that was always one too high, so the schema gate rejected the very table quick.md creates and silently skipped the STATE.md update. Also now supports the 6-column validate-mode table. (#2133)

View File

@@ -72,22 +72,30 @@ malformed row.
# Detect whether STATE.md has a Quick Tasks Completed table
if grep -q "Quick Tasks Completed" .planning/STATE.md 2>/dev/null; then
# Read the table header line to determine the column schema.
# quick.md Step 7 creates a 5-column table:
# | # | Description | Date | Commit | Directory |
# Count pipe characters in the header to determine column count.
# quick.md Step 7b writes two shapes:
# 5-column (non-validate): | # | Description | Date | Commit | Directory |
# 6-column (validate): | # | Description | Date | Commit | Status | Directory |
HEADER_LINE=$(grep -A2 "Quick Tasks Completed" .planning/STATE.md 2>/dev/null | grep "^|" | head -1)
# Count columns: number of | separators minus 1 gives column count
COL_COUNT=$(echo "$HEADER_LINE" | awk -F'|' '{print NF-1}')
# Count REAL columns: a markdown header has a leading and a trailing pipe, so
# awk's NF counts (real columns + 2). NF-2 yields the real column count.
# (NF-1 was the off-by-one root cause of #2133: it returned the pipe count,
# making the `-eq 5` test unsatisfiable for the very header quick.md writes.)
COL_COUNT=$(echo "$HEADER_LINE" | awk -F'|' '{print NF-2}')
# Next row number + latest commit hash are schema-independent.
NEXT_NUM=$(awk '/Quick Tasks Completed/{found=1} found && /^\|/ && !/^[|][-: |]*[|]$/ && !/Description/{count++} END{print count+1}' .planning/STATE.md 2>/dev/null || echo "1")
COMMIT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo "—")
# Select the appended row's template by the detected column count so its cell
# count always matches the header (prevents the malformed-row symptom of #27).
if [ "$COL_COUNT" -eq 5 ] && echo "$HEADER_LINE" | grep -qi "Description" && echo "$HEADER_LINE" | grep -qi "Commit" && echo "$HEADER_LINE" | grep -qi "Directory"; then
# 5-column schema from quick.md Step 7: | # | Description | Date | Commit | Directory |
# Determine the next row number by counting existing data rows (non-separator, non-header).
NEXT_NUM=$(awk '/Quick Tasks Completed/{found=1} found && /^\|/ && !/^[|][-: |]*[|]$/ && !/Description/{count++} END{print count+1}' .planning/STATE.md 2>/dev/null || echo "1")
# Get the latest commit hash (short)
COMMIT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo "—")
# 5-column schema from quick.md Step 7b (non-validate).
echo "| $NEXT_NUM | $TASK | $(date +%Y-%m-%d) | $COMMIT_HASH | — |" >> .planning/STATE.md
elif [ "$COL_COUNT" -eq 6 ] && echo "$HEADER_LINE" | grep -qi "Status" && echo "$HEADER_LINE" | grep -qi "Directory"; then
# 6-column schema from quick.md Step 7b (validate, with Status).
echo "| $NEXT_NUM | $TASK | $(date +%Y-%m-%d) | $COMMIT_HASH | — | — |" >> .planning/STATE.md
else
# Unrecognized table schema — skip to avoid appending a malformed row.
# Unrecognized table schema — skip to avoid appending a malformed row (#27).
echo "⚠ fast.md log_to_state: Quick Tasks Completed table has unrecognized schema (${COL_COUNT} columns); skipping STATE.md update."
fi
fi

View File

@@ -0,0 +1,178 @@
/**
* #2133 — fast.md log_to_state schema gate is unreachable.
*
* PR #85 added a column-count guard to fast.md's log_to_state step that used
* `awk -F'|' '{print NF-1}'`. A markdown table header has both a leading and a
* trailing pipe, so NF counts (real columns + 2) and NF-1 is always one too
* high. The `-eq 5` test could therefore never hold for the 5-column header
* quick.md writes, so /gsd-fast has never appended a Quick Task row since #85.
*
* This test does what the removed prose-regex test (bug-3805-*) did not: it
* EXTRACTS the actual bash block deployed in fast.md and EXECUTES it against
* real STATE.md fixtures, asserting on the filesystem result (row appended,
* cell count aligned with header). It fails on the NF-1 bug and passes once
* the count uses NF-2 and both 5/6-column schemas are accepted.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { createTempProject, cleanup } = require('./helpers.cjs');
const FAST_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'fast.md');
const HEADER_5COL = '| # | Description | Date | Commit | Directory |';
const SEP_5COL = '|---|-------------|------|--------|-----------|';
const HEADER_6COL = '| # | Description | Date | Commit | Status | Directory |';
const SEP_6COL = '|---|-------------|------|--------|--------|-----------|';
/**
* Extract the ```bash block embedded in fast.md's <step name="log_to_state">.
* This is the exact program the workflow runs — executing it is a behavioral
* test of the deployed product, not a source-grep over its prose.
*/
function extractLogToStateBash() {
const content = fs.readFileSync(FAST_MD, 'utf8');
const stepTag = '<step name="log_to_state">';
const stepStart = content.indexOf(stepTag);
assert.notEqual(stepStart, -1, 'fast.md must contain a log_to_state step');
const stepEnd = content.indexOf('</step>', stepStart);
assert.notEqual(stepEnd, -1, 'log_to_state step must close');
const step = content.slice(stepStart, stepEnd);
const fenceStart = step.indexOf('```bash');
assert.notEqual(fenceStart, -1, 'log_to_state step must contain a bash block');
const codeStart = step.indexOf('\n', fenceStart) + 1;
const fenceEnd = step.indexOf('\n```', codeStart);
assert.notEqual(fenceEnd, -1, 'log_to_state bash block must close');
return step.slice(codeStart, fenceEnd);
}
function makeStateMd(headerLine, separatorLine, existingRows) {
return [
'# Project State',
'',
'### Blockers/Concerns',
'',
'None.',
'',
'### Quick Tasks Completed',
'',
headerLine,
separatorLine,
...existingRows,
'',
].join('\n');
}
/** Count `|` chars on a line — the invariant cell-count signal. */
function pipeCount(line) {
return (line.match(/\|/g) || []).length;
}
/** Data rows = lines starting with `|` that are not the separator or header. */
function dataRows(content) {
return content.split(/\r?\n/).filter((l) => {
if (!l.startsWith('|')) return false;
if (/^[|][-: |]*[|]$/.test(l)) return false; // separator
if (/Description/.test(l)) return false; // header
return true;
});
}
describe('#2133 fast.md log_to_state schema gate', () => {
const bashBlock = extractLogToStateBash();
const TASK_DESC = 'sample inline fix';
/**
* Run the extracted log_to_state bash against a temp project's STATE.md and
* return the post-run file content + captured stdout.
*/
function runAgainst(headerLine, separatorLine, existingRows) {
const tmpDir = createTempProject('fix-2133-');
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, makeStateMd(headerLine, separatorLine, existingRows));
// Cleanup is bound by each caller via t.after(tmpDir).
const stdout = execFileSync('bash', ['-c', bashBlock], {
cwd: tmpDir,
env: { ...process.env, TASK: TASK_DESC },
encoding: 'utf8',
});
const after = fs.readFileSync(statePath, 'utf8');
return { tmpDir, stdout, after };
}
test('appends a 5-cell row to the 5-column (non-validate) schema (#27 stays fixed)', (t) => {
const before = makeStateMd(HEADER_5COL, SEP_5COL, ['| 1 | earlier task | 2026-07-01 | deadbee | — |']);
const { tmpDir, after } = runAgainst(HEADER_5COL, SEP_5COL, ['| 1 | earlier task | 2026-07-01 | deadbee | — |']);
t.after(() => cleanup(tmpDir));
const rowsBefore = dataRows(before).length;
const rowsAfter = dataRows(after).length;
assert.equal(rowsAfter - rowsBefore, 1, 'exactly one row must be appended');
const appended = dataRows(after).slice(-1)[0];
assert.equal(pipeCount(appended), pipeCount(HEADER_5COL),
'appended row pipe-count must match the 5-column header (no malformed row)');
assert.ok(appended.includes(TASK_DESC), 'appended row must carry the task description');
});
test('appends a 6-cell row to the 6-column (validate, with Status) schema', (t) => {
const before = makeStateMd(HEADER_6COL, SEP_6COL, []);
const { tmpDir, after } = runAgainst(HEADER_6COL, SEP_6COL, []);
t.after(() => cleanup(tmpDir));
const rowsBefore = dataRows(before).length;
const rowsAfter = dataRows(after).length;
assert.equal(rowsAfter - rowsBefore, 1, 'exactly one row must be appended to the 6-column table');
const appended = dataRows(after).slice(-1)[0];
assert.equal(pipeCount(appended), pipeCount(HEADER_6COL),
'appended row pipe-count must match the 6-column header (cell count aligned with header)');
assert.ok(appended.includes(TASK_DESC), 'appended row must carry the task description');
});
test('column count awk uses NF-2 (real columns — the off-by-one root cause)', () => {
// The deployed bash must compute the real column count. A 5-column header
// split on '|' yields NF=7; the correct real-column formula is NF-2=5.
// (NF-1 was the off-by-one bug: it returned 6, making `-eq 5` unsatisfiable.)
// Match the executable COL_COUNT assignment specifically — the explanatory
// comment may still reference "NF-1" to document the history.
assert.match(
bashBlock,
/COL_COUNT=\$\(.+awk -F'\|' '\{print NF-2\}'\)/,
'COL_COUNT must be derived via awk NF-2 (NF-1 was the off-by-one bug)'
);
});
test('unrecognized schema still skips with a warning (safety guard intact)', (t) => {
// A 3-column table quick.md never writes must NOT receive a row.
const weirdHeader = '| Alpha | Beta | Gamma |';
const weirdSep = '|-------|------|-------|';
const before = makeStateMd(weirdHeader, weirdSep, []);
const { tmpDir, stdout, after } = runAgainst(weirdHeader, weirdSep, []);
t.after(() => cleanup(tmpDir));
assert.equal(dataRows(after).length, dataRows(before).length,
'no row may be appended for an unrecognized schema');
assert.ok(/unrecognized schema/i.test(stdout),
'the unrecognized-schema warning must be emitted');
});
test('no Quick Tasks table → silent no-op', (t) => {
const tmpDir = createTempProject('fix-2133-noop-');
t.after(() => cleanup(tmpDir));
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const before = '# Project State\n\n### Blockers/Concerns\n\nNone.\n';
fs.writeFileSync(statePath, before);
const stdout = execFileSync('bash', ['-c', bashBlock], {
cwd: tmpDir,
env: { ...process.env, TASK: TASK_DESC },
encoding: 'utf8',
});
const after = fs.readFileSync(statePath, 'utf8');
assert.equal(after, before, 'STATE.md must be untouched when no Quick Tasks table exists');
assert.equal(stdout, '', 'no output when there is no table to update');
});
});

View File

@@ -242,7 +242,7 @@
"gsd-core/workflows/execute-plan.md": "907af77eafc3d97b",
"gsd-core/workflows/explore.md": "934c00f9f216dbdb",
"gsd-core/workflows/extract-learnings.md": "167ea7f0e23bf496",
"gsd-core/workflows/fast.md": "54fe93778b45a7eb",
"gsd-core/workflows/fast.md": "0242082ca646819a",
"gsd-core/workflows/forensics.md": "b64f0309b8c3fde1",
"gsd-core/workflows/graduation.md": "a9d8f15ba81a993f",
"gsd-core/workflows/health.md": "1ac4d567ee95acfb",

View File

@@ -313,7 +313,7 @@
"gsd-core/workflows/execute-plan.md": "2c412310dce31a0b",
"gsd-core/workflows/explore.md": "6c04f2e658d93261",
"gsd-core/workflows/extract-learnings.md": "b6f01ca3d8f58de4",
"gsd-core/workflows/fast.md": "94136fb570d20a9d",
"gsd-core/workflows/fast.md": "8878ec034b401f36",
"gsd-core/workflows/forensics.md": "0d500a3f5ab26913",
"gsd-core/workflows/graduation.md": "47f1594c88c08501",
"gsd-core/workflows/health.md": "107e3c72e76d9535",

View File

@@ -312,7 +312,7 @@
"gsd-core/workflows/execute-plan.md": "f17623fd47e795dd",
"gsd-core/workflows/explore.md": "95e463d4bdd6dadd",
"gsd-core/workflows/extract-learnings.md": "fd75072c339b58bd",
"gsd-core/workflows/fast.md": "54fe93778b45a7eb",
"gsd-core/workflows/fast.md": "0242082ca646819a",
"gsd-core/workflows/forensics.md": "857d7b064f4cca21",
"gsd-core/workflows/graduation.md": "ecf8da93e094fd2e",
"gsd-core/workflows/health.md": "551e63aa6f3df711",

View File

@@ -241,7 +241,7 @@
"gsd-core/workflows/execute-plan.md": "cce1a33fe9a0a32d",
"gsd-core/workflows/explore.md": "b9eea1bac358c9ce",
"gsd-core/workflows/extract-learnings.md": "d8177b0c13b7e5ee",
"gsd-core/workflows/fast.md": "54fe93778b45a7eb",
"gsd-core/workflows/fast.md": "0242082ca646819a",
"gsd-core/workflows/forensics.md": "c01da0178fb97b21",
"gsd-core/workflows/graduation.md": "47f1594c88c08501",
"gsd-core/workflows/health.md": "f934b1f1e9f3ae72",

View File

@@ -245,7 +245,7 @@
"gsd-core/workflows/execute-plan.md": "0c5551f99ee0a017",
"gsd-core/workflows/explore.md": "e83af8ceae314cf9",
"gsd-core/workflows/extract-learnings.md": "6f39375b7dc775f9",
"gsd-core/workflows/fast.md": "94136fb570d20a9d",
"gsd-core/workflows/fast.md": "8878ec034b401f36",
"gsd-core/workflows/forensics.md": "9fc65a8eed5d8bfc",
"gsd-core/workflows/graduation.md": "16fedecda36769eb",
"gsd-core/workflows/health.md": "788fad84fa42eb9f",

View File

@@ -313,7 +313,7 @@
"gsd-core/workflows/execute-plan.md": "933d10547116794a",
"gsd-core/workflows/explore.md": "6c04f2e658d93261",
"gsd-core/workflows/extract-learnings.md": "b6f01ca3d8f58de4",
"gsd-core/workflows/fast.md": "94136fb570d20a9d",
"gsd-core/workflows/fast.md": "8878ec034b401f36",
"gsd-core/workflows/forensics.md": "0d500a3f5ab26913",
"gsd-core/workflows/graduation.md": "47f1594c88c08501",
"gsd-core/workflows/health.md": "107e3c72e76d9535",

View File

@@ -348,7 +348,7 @@
"gsd-core/workflows/execute-plan.md": "ac1f1d9ada00a91e",
"gsd-core/workflows/explore.md": "2ef10d17c8864a04",
"gsd-core/workflows/extract-learnings.md": "f716aa03fcb5f8da",
"gsd-core/workflows/fast.md": "13252d545947354d",
"gsd-core/workflows/fast.md": "bbf1f8e219ec9031",
"gsd-core/workflows/forensics.md": "2e8a01b5b44e65f3",
"gsd-core/workflows/graduation.md": "47f1594c88c08501",
"gsd-core/workflows/health.md": "5b835fc606fd3e9b",

View File

@@ -243,7 +243,7 @@
"gsd-core/workflows/execute-plan.md": "c5e9dae726db15cc",
"gsd-core/workflows/explore.md": "5fd91a8510e1114b",
"gsd-core/workflows/extract-learnings.md": "f34d0b1927545b18",
"gsd-core/workflows/fast.md": "54fe93778b45a7eb",
"gsd-core/workflows/fast.md": "0242082ca646819a",
"gsd-core/workflows/forensics.md": "459644dce26ee2ef",
"gsd-core/workflows/graduation.md": "f013efc29096faf0",
"gsd-core/workflows/health.md": "f343fd32a0e398e4",

View File

@@ -313,7 +313,7 @@
"gsd-core/workflows/execute-plan.md": "83dc1bf7f73735c0",
"gsd-core/workflows/explore.md": "b9eea1bac358c9ce",
"gsd-core/workflows/extract-learnings.md": "d8177b0c13b7e5ee",
"gsd-core/workflows/fast.md": "0162075e44072447",
"gsd-core/workflows/fast.md": "91f126c4ef24217e",
"gsd-core/workflows/forensics.md": "a65f817d4a515291",
"gsd-core/workflows/graduation.md": "53a4a6fa3b4e6613",
"gsd-core/workflows/health.md": "e4d770484b5e7496",

View File

@@ -242,7 +242,7 @@
"gsd-core/workflows/execute-plan.md": "4dbe9b6f0c976245",
"gsd-core/workflows/explore.md": "48770d68e8b9c132",
"gsd-core/workflows/extract-learnings.md": "e9e167c718949c0b",
"gsd-core/workflows/fast.md": "54fe93778b45a7eb",
"gsd-core/workflows/fast.md": "0242082ca646819a",
"gsd-core/workflows/forensics.md": "91961b811917c5c4",
"gsd-core/workflows/graduation.md": "d1fd52bbe41dcf34",
"gsd-core/workflows/health.md": "c622a5ad0d347d30",

View File

@@ -313,7 +313,7 @@
"gsd-core/workflows/execute-plan.md": "8dc89b35582407f7",
"gsd-core/workflows/explore.md": "14242d36d4822df6",
"gsd-core/workflows/extract-learnings.md": "d8177b0c13b7e5ee",
"gsd-core/workflows/fast.md": "54fe93778b45a7eb",
"gsd-core/workflows/fast.md": "0242082ca646819a",
"gsd-core/workflows/forensics.md": "c01da0178fb97b21",
"gsd-core/workflows/graduation.md": "5cc8638dabcbfd40",
"gsd-core/workflows/health.md": "064b3668d5d9569a",

View File

@@ -306,7 +306,7 @@
"gsd-core/workflows/execute-plan.md": "c8502b7475d797a7",
"gsd-core/workflows/explore.md": "6c04f2e658d93261",
"gsd-core/workflows/extract-learnings.md": "b6f01ca3d8f58de4",
"gsd-core/workflows/fast.md": "94136fb570d20a9d",
"gsd-core/workflows/fast.md": "8878ec034b401f36",
"gsd-core/workflows/forensics.md": "0d500a3f5ab26913",
"gsd-core/workflows/graduation.md": "47f1594c88c08501",
"gsd-core/workflows/health.md": "107e3c72e76d9535",

View File

@@ -313,7 +313,7 @@
"gsd-core/workflows/execute-plan.md": "e8de8ea661c1fe81",
"gsd-core/workflows/explore.md": "7f5f9231cfd3089b",
"gsd-core/workflows/extract-learnings.md": "92b3c0979604b7d0",
"gsd-core/workflows/fast.md": "54fe93778b45a7eb",
"gsd-core/workflows/fast.md": "0242082ca646819a",
"gsd-core/workflows/forensics.md": "9354cb830152fd28",
"gsd-core/workflows/graduation.md": "13080b5c24eec27d",
"gsd-core/workflows/health.md": "a91e0a8e5d20b2e9",

View File

@@ -209,7 +209,7 @@
"gsd-core/workflows/execute-plan.md": "ff172c3540b52e9d",
"gsd-core/workflows/explore.md": "6c04f2e658d93261",
"gsd-core/workflows/extract-learnings.md": "b6f01ca3d8f58de4",
"gsd-core/workflows/fast.md": "94136fb570d20a9d",
"gsd-core/workflows/fast.md": "8878ec034b401f36",
"gsd-core/workflows/forensics.md": "0d500a3f5ab26913",
"gsd-core/workflows/graduation.md": "47f1594c88c08501",
"gsd-core/workflows/health.md": "107e3c72e76d9535",

View File

@@ -242,7 +242,7 @@
"gsd-core/workflows/execute-plan.md": "503b0ced0731dc38",
"gsd-core/workflows/explore.md": "e1a83a8982532e5b",
"gsd-core/workflows/extract-learnings.md": "dd4fdb88605de49a",
"gsd-core/workflows/fast.md": "54fe93778b45a7eb",
"gsd-core/workflows/fast.md": "0242082ca646819a",
"gsd-core/workflows/forensics.md": "82800a3138ac1da9",
"gsd-core/workflows/graduation.md": "40401655435beee3",
"gsd-core/workflows/health.md": "043de14edb6a9723",

View File

@@ -242,7 +242,7 @@
"gsd-core/workflows/execute-plan.md": "4910f75bab2040ab",
"gsd-core/workflows/explore.md": "8a5437aa0c239c38",
"gsd-core/workflows/extract-learnings.md": "3fcc858b20d0d0e6",
"gsd-core/workflows/fast.md": "0162075e44072447",
"gsd-core/workflows/fast.md": "91f126c4ef24217e",
"gsd-core/workflows/forensics.md": "665546666547875d",
"gsd-core/workflows/graduation.md": "1ca877cda258a5de",
"gsd-core/workflows/health.md": "1f9fd2deea45896f",

View File

@@ -242,7 +242,7 @@
"gsd-core/workflows/execute-plan.md": "c8567fb4438b4404",
"gsd-core/workflows/explore.md": "04e461ff8159a24e",
"gsd-core/workflows/extract-learnings.md": "af793bdf4ffd1c8a",
"gsd-core/workflows/fast.md": "0162075e44072447",
"gsd-core/workflows/fast.md": "91f126c4ef24217e",
"gsd-core/workflows/forensics.md": "3d1ce16b5f605592",
"gsd-core/workflows/graduation.md": "a766039ff6ca653f",
"gsd-core/workflows/health.md": "7b19d6e2c0357c3e",

View File

@@ -313,7 +313,7 @@
"gsd-core/workflows/execute-plan.md": "ed874410972d32b7",
"gsd-core/workflows/explore.md": "6c04f2e658d93261",
"gsd-core/workflows/extract-learnings.md": "b6f01ca3d8f58de4",
"gsd-core/workflows/fast.md": "94136fb570d20a9d",
"gsd-core/workflows/fast.md": "8878ec034b401f36",
"gsd-core/workflows/forensics.md": "0d500a3f5ab26913",
"gsd-core/workflows/graduation.md": "47f1594c88c08501",
"gsd-core/workflows/health.md": "107e3c72e76d9535",

View File

@@ -28,7 +28,7 @@
"execute-plan.md": 32655,
"explore.md": 10541,
"extract-learnings.md": 12893,
"fast.md": 4149,
"fast.md": 4790,
"forensics.md": 12531,
"graduation.md": 11622,
"health.md": 11868,