diff --git a/.changeset/eager-lynx-chatter.md b/.changeset/eager-lynx-chatter.md new file mode 100644 index 000000000..0b44725f1 --- /dev/null +++ b/.changeset/eager-lynx-chatter.md @@ -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. diff --git a/get-shit-done/templates/state.md b/get-shit-done/templates/state.md index 05c6aa117..7eb0b06c8 100644 --- a/get-shit-done/templates/state.md +++ b/get-shit-done/templates/state.md @@ -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 diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index 3f9e05b55..a35faef7d 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -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" }, diff --git a/sdk/prompts/templates/state.md b/sdk/prompts/templates/state.md index 2f7a7227b..d4476e852 100644 --- a/sdk/prompts/templates/state.md +++ b/sdk/prompts/templates/state.md @@ -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] diff --git a/tests/bug-21-state-md-template-frontmatter.test.cjs b/tests/bug-21-state-md-template-frontmatter.test.cjs new file mode 100644 index 000000000..3f533c0b1 --- /dev/null +++ b/tests/bug-21-state-md-template-frontmatter.test.cjs @@ -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} + */ +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} + */ +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.', + ); + }); +});