* fix(#21): add YAML frontmatter to STATE.md File Template sections Both template files (get-shit-done/templates/state.md and sdk/prompts/templates/state.md) lacked a YAML frontmatter block in their File Template section. When an AI agent creates .planning/STATE.md from the template, the file had no frontmatter until the first state.* mutation ran syncStateFrontmatter — leaving the init→first-write window with nothing for frontmatter consumers (current_phase, status, progress.*) to read. Adds a minimal frontmatter block with gsd_state_version, status, and a zeroed progress skeleton matching the shape buildStateFrontmatter produces. syncStateFrontmatter will replace these placeholders on the first state write. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(#21): bump lint-test-file-count state ceiling for bug-21 test Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore(#21): add changeset fragment for STATE.md template frontmatter fix Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(#21): address review — dual-template equality guard, progress schema assertion, version annotation - Add inline comment to gsd_state_version in both templates documenting that syncStateFrontmatter overwrites the value on first state.* call - Sync sdk/prompts/templates/state.md File Template block to match get-shit-done/templates/state.md (add Deferred Items section, fix Pending Todos blurb) — templates were diverged - Add parseFrontmatter() helper to test file for value-aware parsing - Add per-template test: progress.total_plans === 0 and progress.completed_plans === 0 - Add cross-template byte-equality assertion to catch future drift - Add note to parseFrontmatterKeys that it does not handle list-valued fields Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/eager-lynx-chatter.md
Normal file
5
.changeset/eager-lynx-chatter.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 151
|
||||
---
|
||||
**STATE.md template now includes YAML frontmatter block** — both `get-shit-done/templates/state.md` and `sdk/prompts/templates/state.md` now ship with a `gsd_state_version`, `status`, and zeroed `progress.*` block so freshly initialised `STATE.md` files are immediately readable by frontmatter consumers before the first `state` mutation. Closes #21.
|
||||
@@ -7,6 +7,17 @@ Template for `.planning/STATE.md` — the project's living memory.
|
||||
## File Template
|
||||
|
||||
```markdown
|
||||
---
|
||||
gsd_state_version: '1.0' # placeholder; syncStateFrontmatter overwrites on first state.* call
|
||||
status: planning
|
||||
progress:
|
||||
total_phases: 0
|
||||
completed_phases: 0
|
||||
total_plans: 0
|
||||
completed_plans: 0
|
||||
percent: 0
|
||||
---
|
||||
|
||||
# Project State
|
||||
|
||||
## Project Reference
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"verify": { "current": 10, "issue": "3767" },
|
||||
"install": { "current": 9, "issue": "TBD" },
|
||||
"init": { "current": 8, "issue": "TBD" },
|
||||
"state": { "current": 9, "issue": "TBD" },
|
||||
"state": { "current": 10, "issue": "21" },
|
||||
"config": { "current": 8, "issue": "TBD" },
|
||||
"graphify": { "current": 7, "issue": "TBD" },
|
||||
"progress": { "current": 5, "issue": "TBD" },
|
||||
|
||||
@@ -7,6 +7,17 @@ Template for `.planning/STATE.md` — the project's living memory.
|
||||
## File Template
|
||||
|
||||
```markdown
|
||||
---
|
||||
gsd_state_version: '1.0' # placeholder; syncStateFrontmatter overwrites on first state.* call
|
||||
status: planning
|
||||
progress:
|
||||
total_phases: 0
|
||||
completed_phases: 0
|
||||
total_plans: 0
|
||||
completed_plans: 0
|
||||
percent: 0
|
||||
---
|
||||
|
||||
# Project State
|
||||
|
||||
## Project Reference
|
||||
@@ -56,7 +67,7 @@ Recent decisions affecting current work:
|
||||
|
||||
### Pending Todos
|
||||
|
||||
[Pending ideas captured during sessions]
|
||||
[From .planning/todos/pending/ — ideas captured during sessions]
|
||||
|
||||
None yet.
|
||||
|
||||
@@ -66,6 +77,14 @@ None yet.
|
||||
|
||||
None yet.
|
||||
|
||||
## Deferred Items
|
||||
|
||||
Items acknowledged and carried forward from previous milestone close:
|
||||
|
||||
| Category | Item | Status | Deferred At |
|
||||
|----------|------|--------|-------------|
|
||||
| *(none)* | | | |
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: [YYYY-MM-DD HH:MM]
|
||||
|
||||
200
tests/bug-21-state-md-template-frontmatter.test.cjs
Normal file
200
tests/bug-21-state-md-template-frontmatter.test.cjs
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Regression guard — Bug #21
|
||||
*
|
||||
* Both STATE.md template files must include a YAML frontmatter block in their
|
||||
* "File Template" section so that an AI agent creating .planning/STATE.md from
|
||||
* the template produces a file that frontmatter consumers can read immediately
|
||||
* (before the first `state.*` mutation calls syncStateFrontmatter).
|
||||
*
|
||||
* Prior to the fix, the template's File Template section began with
|
||||
* `# Project State` (no frontmatter), leaving the init→first-write window
|
||||
* without `gsd_state_version`, `status`, or `progress` keys.
|
||||
*
|
||||
* Acceptance criteria:
|
||||
* 1. The template body extracted from each state.md file's File Template code
|
||||
* block must begin with `---`.
|
||||
* 2. The frontmatter must contain at minimum: `gsd_state_version` and `status`.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
|
||||
const TEMPLATE_PATHS = [
|
||||
path.join(REPO_ROOT, 'get-shit-done', 'templates', 'state.md'),
|
||||
path.join(REPO_ROOT, 'sdk', 'prompts', 'templates', 'state.md'),
|
||||
];
|
||||
|
||||
/**
|
||||
* Extract the content of the first ```markdown ... ``` code block from a
|
||||
* template file. Returns the raw string (including any leading/trailing
|
||||
* whitespace within the block).
|
||||
*
|
||||
* @param {string} fileContent - Full text of the template file.
|
||||
* @returns {string} The extracted code block body.
|
||||
*/
|
||||
function extractFileTemplate(fileContent) {
|
||||
const match = fileContent.match(/```markdown\r?\n([\s\S]*?)```/);
|
||||
assert.ok(match, 'No ```markdown code block found in template file');
|
||||
return match[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal YAML frontmatter parser: returns the set of top-level keys present
|
||||
* in the first --- ... --- block at the start of `text`. Does not parse nested
|
||||
* keys — list-valued fields (e.g. `tags: [a, b]`) are recorded only by their
|
||||
* key name, not their value. Returns an empty Set when the text has no frontmatter.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
function parseFrontmatterKeys(text) {
|
||||
const keys = new Set();
|
||||
if (!text.trimStart().startsWith('---')) return keys;
|
||||
const lines = text.split(/\r?\n/);
|
||||
let inBlock = false;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!inBlock) {
|
||||
if (trimmed === '---') { inBlock = true; continue; }
|
||||
break; // frontmatter must be at the very start
|
||||
}
|
||||
if (trimmed === '---') break; // end of block
|
||||
const colonIdx = trimmed.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
keys.add(trimmed.slice(0, colonIdx).trim());
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal YAML frontmatter parser: returns a plain object of top-level keys
|
||||
* and their scalar or nested-object values from the first --- ... --- block.
|
||||
* Handles one level of indented nesting (e.g. progress.total_plans).
|
||||
* Does not handle YAML lists or multi-line values.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {Record<string, any>}
|
||||
*/
|
||||
function parseFrontmatter(text) {
|
||||
const result = {};
|
||||
if (!text.trimStart().startsWith('---')) return result;
|
||||
const lines = text.split(/\r?\n/);
|
||||
let inBlock = false;
|
||||
let currentKey = null;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!inBlock) {
|
||||
if (trimmed === '---') { inBlock = true; continue; }
|
||||
break;
|
||||
}
|
||||
if (trimmed === '---') break;
|
||||
// Detect indented (nested) line: starts with whitespace
|
||||
if (line.match(/^\s+\S/) && currentKey !== null) {
|
||||
const colonIdx = trimmed.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
const subKey = trimmed.slice(0, colonIdx).trim();
|
||||
const rawVal = trimmed.slice(colonIdx + 1).trim();
|
||||
const numVal = Number(rawVal);
|
||||
if (typeof result[currentKey] !== 'object') result[currentKey] = {};
|
||||
result[currentKey][subKey] = rawVal === '' ? null : (isNaN(numVal) ? rawVal : numVal);
|
||||
}
|
||||
} else {
|
||||
currentKey = null;
|
||||
const colonIdx = trimmed.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
const key = trimmed.slice(0, colonIdx).trim();
|
||||
const rawVal = trimmed.slice(colonIdx + 1).trim();
|
||||
if (rawVal === '') {
|
||||
result[key] = {};
|
||||
currentKey = key;
|
||||
} else {
|
||||
const numVal = Number(rawVal);
|
||||
result[key] = isNaN(numVal) ? rawVal.replace(/^'|'$/g, '') : numVal;
|
||||
currentKey = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
describe('bug #21 — STATE.md template must carry YAML frontmatter', () => {
|
||||
for (const templatePath of TEMPLATE_PATHS) {
|
||||
const label = path.relative(REPO_ROOT, templatePath);
|
||||
|
||||
test(`${label} — File Template block starts with frontmatter`, () => {
|
||||
const content = fs.readFileSync(templatePath, 'utf-8');
|
||||
const body = extractFileTemplate(content);
|
||||
|
||||
// The template body must open with a YAML frontmatter delimiter.
|
||||
assert.ok(
|
||||
body.trimStart().startsWith('---'),
|
||||
`${label}: File Template must start with '---' (YAML frontmatter), ` +
|
||||
`but starts with: ${JSON.stringify(body.slice(0, 60))}`,
|
||||
);
|
||||
});
|
||||
|
||||
test(`${label} — frontmatter contains gsd_state_version`, () => {
|
||||
const content = fs.readFileSync(templatePath, 'utf-8');
|
||||
const body = extractFileTemplate(content);
|
||||
const keys = parseFrontmatterKeys(body.trimStart());
|
||||
|
||||
assert.ok(
|
||||
keys.has('gsd_state_version'),
|
||||
`${label}: frontmatter must include 'gsd_state_version', found keys: ${[...keys].join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test(`${label} — frontmatter contains status`, () => {
|
||||
const content = fs.readFileSync(templatePath, 'utf-8');
|
||||
const body = extractFileTemplate(content);
|
||||
const keys = parseFrontmatterKeys(body.trimStart());
|
||||
|
||||
assert.ok(
|
||||
keys.has('status'),
|
||||
`${label}: frontmatter must include 'status', found keys: ${[...keys].join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test(`${label} — progress sub-schema has zeroed total_plans and completed_plans`, () => {
|
||||
const content = fs.readFileSync(templatePath, 'utf-8');
|
||||
const body = extractFileTemplate(content);
|
||||
const fm = parseFrontmatter(body.trimStart());
|
||||
|
||||
assert.ok(
|
||||
fm.progress && typeof fm.progress === 'object',
|
||||
`${label}: frontmatter must include a 'progress' sub-object`,
|
||||
);
|
||||
assert.strictEqual(
|
||||
fm.progress.total_plans,
|
||||
0,
|
||||
`${label}: progress.total_plans must be 0 in the template`,
|
||||
);
|
||||
assert.strictEqual(
|
||||
fm.progress.completed_plans,
|
||||
0,
|
||||
`${label}: progress.completed_plans must be 0 in the template`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('both templates produce byte-equal File Template blocks', () => {
|
||||
const [bodyA, bodyB] = TEMPLATE_PATHS.map((p) =>
|
||||
extractFileTemplate(fs.readFileSync(p, 'utf-8')),
|
||||
);
|
||||
assert.strictEqual(
|
||||
bodyA,
|
||||
bodyB,
|
||||
'File Template code blocks in get-shit-done/templates/state.md and ' +
|
||||
'sdk/prompts/templates/state.md must be identical. ' +
|
||||
'Edit both files together to keep them in sync.',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user