fix(#21): add YAML frontmatter to STATE.md template (#151)

* 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:
Tom Boucher
2026-05-23 14:56:35 -04:00
committed by GitHub
parent 75287effb9
commit 3f9eb43054
5 changed files with 237 additions and 2 deletions

View 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.

View File

@@ -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

View File

@@ -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" },

View File

@@ -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]

View 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.',
);
});
});