From 6a2d1f1bfbf403013b74d3d4fdd8b7a8be83c42f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Sun, 8 Feb 2026 09:28:50 -0600 Subject: [PATCH] feat(gsd-tools): frontmatter CRUD, verification suite, template fill, state progression (#485) * feat(gsd-tools): add frontmatter CRUD, verification suite, template fill, and state progression Four new command groups that delegate deterministic operations from AI agents to code: - frontmatter get/set/merge/validate: Safe YAML frontmatter manipulation with schema validation - verify plan-structure/phase-completeness/references/commits/artifacts/key-links: Structural checks agents previously burned context on - template fill summary/plan/verification: Pre-filled document skeletons so agents only fill creative content - state advance-plan/record-metric/update-progress/add-decision/add-blocker/resolve-blocker/record-session: Automate arithmetic and formatting in STATE.md Adds reconstructFrontmatter() + spliceFrontmatter() helpers for safe frontmatter roundtripping, and parseMustHavesBlock() for 3-level YAML parsing of must_haves structures. 20 new functions, ~1037 new lines. Co-Authored-By: Claude Opus 4.6 * feat: wire gsd-tools commands into agents and workflows - gsd-verifier: use `verify artifacts` and `verify key-links` instead of manual grep patterns for stub detection and wiring verification - gsd-executor: use `state advance-plan`, `state update-progress`, `state record-metric`, `state add-decision`, `state record-session` instead of manual STATE.md manipulation - gsd-plan-checker: use `verify plan-structure` and `frontmatter get` for structural validation and must_haves extraction - gsd-planner: add validation step using `frontmatter validate` and `verify plan-structure` after writing PLAN.md - execute-plan.md: use gsd-tools state commands for position/progress updates - verify-phase.md: use gsd-tools for must_haves extraction and artifact/link verification This makes the gsd-tools commands from PR #485 actually used by the system. --------- Co-authored-by: Claude Opus 4.6 --- agents/gsd-executor.md | 44 +- agents/gsd-plan-checker.md | 41 +- agents/gsd-planner.md | 28 + agents/gsd-verifier.md | 89 +- get-shit-done/bin/gsd-tools.js | 1038 ++++++++++++++++++++++- get-shit-done/workflows/execute-plan.md | 36 +- get-shit-done/workflows/verify-phase.md | 61 +- 7 files changed, 1255 insertions(+), 82 deletions(-) diff --git a/agents/gsd-executor.md b/agents/gsd-executor.md index 0473d4416..ab882ea60 100644 --- a/agents/gsd-executor.md +++ b/agents/gsd-executor.md @@ -323,22 +323,44 @@ Do NOT skip. Do NOT proceed to state updates if self-check fails. -After SUMMARY.md, update STATE.md: +After SUMMARY.md, update STATE.md using gsd-tools: -**Current Position:** -```markdown -Phase: [current] of [total] ([phase name]) -Plan: [just completed] of [total in phase] -Status: [In progress / Phase complete] -Last activity: [today] - Completed {phase}-{plan}-PLAN.md -Progress: [progress bar] +```bash +# Advance plan counter (handles edge cases automatically) +node ~/.claude/get-shit-done/bin/gsd-tools.js state advance-plan + +# Recalculate progress bar from disk state +node ~/.claude/get-shit-done/bin/gsd-tools.js state update-progress + +# Record execution metrics +node ~/.claude/get-shit-done/bin/gsd-tools.js state record-metric \ + --phase "${PHASE}" --plan "${PLAN}" --duration "${DURATION}" \ + --tasks "${TASK_COUNT}" --files "${FILE_COUNT}" + +# Add decisions (extract from SUMMARY.md key-decisions) +for decision in "${DECISIONS[@]}"; do + node ~/.claude/get-shit-done/bin/gsd-tools.js state add-decision \ + --phase "${PHASE}" --summary "${decision}" +done + +# Update session info +node ~/.claude/get-shit-done/bin/gsd-tools.js state record-session \ + --stopped-at "Completed ${PHASE}-${PLAN}-PLAN.md" ``` -**Progress bar:** Count total plans, count completed (SUMMARY.md files), render █ for complete, ░ for incomplete. +**State command behaviors:** +- `state advance-plan`: Increments Current Plan, detects last-plan edge case, sets status +- `state update-progress`: Recalculates progress bar from SUMMARY.md counts on disk +- `state record-metric`: Appends to Performance Metrics table +- `state add-decision`: Adds to Decisions section, removes placeholders +- `state record-session`: Updates Last session timestamp and Stopped At fields -**Extract from SUMMARY.md:** Decisions → add to STATE.md Decisions table. Next Phase Readiness blockers → add to STATE.md. +**Extract decisions from SUMMARY.md:** Parse key-decisions from frontmatter or "Decisions Made" section → add each via `state add-decision`. -**Session Continuity:** Last session date, stopped at, resume file path. +**For blockers found during execution:** +```bash +node ~/.claude/get-shit-done/bin/gsd-tools.js state add-blocker "Blocker description" +``` diff --git a/agents/gsd-plan-checker.md b/agents/gsd-plan-checker.md index 4fdbf037a..54d24d2ab 100644 --- a/agents/gsd-plan-checker.md +++ b/agents/gsd-plan-checker.md @@ -316,18 +316,35 @@ ls "$phase_dir"/*-BRIEF.md 2>/dev/null ## Step 2: Load All Plans +Use gsd-tools to validate plan structure: + ```bash for plan in "$PHASE_DIR"/*-PLAN.md; do echo "=== $plan ===" - cat "$plan" + PLAN_STRUCTURE=$(node ~/.claude/get-shit-done/bin/gsd-tools.js verify plan-structure "$plan") + echo "$PLAN_STRUCTURE" done ``` -**Parse:** Frontmatter (phase, plan, wave, depends_on, files_modified, autonomous, must_haves), objective, tasks (type, name, files, action, verify, done), verification/success criteria. +Parse JSON result: `{ valid, errors, warnings, task_count, tasks: [{name, hasFiles, hasAction, hasVerify, hasDone}], frontmatter_fields }` + +Map errors/warnings to verification dimensions: +- Missing frontmatter field → `task_completeness` or `must_haves_derivation` +- Task missing elements → `task_completeness` +- Wave/depends_on inconsistency → `dependency_correctness` +- Checkpoint/autonomous mismatch → `task_completeness` ## Step 3: Parse must_haves -Extract from each plan frontmatter: +Extract must_haves from each plan using gsd-tools: + +```bash +MUST_HAVES=$(node ~/.claude/get-shit-done/bin/gsd-tools.js frontmatter get "$PLAN_PATH" --field must_haves) +``` + +Returns JSON: `{ truths: [...], artifacts: [...], key_links: [...] }` + +**Expected structure:** ```yaml must_haves: @@ -362,12 +379,24 @@ For each requirement: find covering task(s), verify action is specific, flag gap ## Step 5: Validate Task Structure +Use gsd-tools plan-structure verification (already run in Step 2): + ```bash -grep -c "" "$PHASE_DIR"/*-PLAN.md | grep -v "" +PLAN_STRUCTURE=$(node ~/.claude/get-shit-done/bin/gsd-tools.js verify plan-structure "$PLAN_PATH") ``` -Check: valid task type (auto, checkpoint:*, tdd), auto tasks have files/action/verify/done, action is specific, verify is runnable, done is measurable. +The `tasks` array in the result shows each task's completeness: +- `hasFiles` — files element present +- `hasAction` — action element present +- `hasVerify` — verify element present +- `hasDone` — done element present + +**Check:** valid task type (auto, checkpoint:*, tdd), auto tasks have files/action/verify/done, action is specific, verify is runnable, done is measurable. + +**For manual validation of specificity** (gsd-tools checks structure, not content quality): +```bash +grep -B5 "" "$PHASE_DIR"/*-PLAN.md | grep -v "" +``` ## Step 6: Verify Dependency Graph diff --git a/agents/gsd-planner.md b/agents/gsd-planner.md index 6cc8189db..b41f9711e 100644 --- a/agents/gsd-planner.md +++ b/agents/gsd-planner.md @@ -1001,6 +1001,34 @@ Write to `.planning/phases/XX-name/{phase}-{NN}-PLAN.md` Include all frontmatter fields. + +Validate each created PLAN.md using gsd-tools: + +```bash +VALID=$(node ~/.claude/get-shit-done/bin/gsd-tools.js frontmatter validate "$PLAN_PATH" --schema plan) +``` + +Returns JSON: `{ valid, missing, present, schema }` + +**If `valid=false`:** Fix missing required fields before proceeding. + +Required plan frontmatter fields: +- `phase`, `plan`, `type`, `wave`, `depends_on`, `files_modified`, `autonomous`, `must_haves` + +Also validate plan structure: + +```bash +STRUCTURE=$(node ~/.claude/get-shit-done/bin/gsd-tools.js verify plan-structure "$PLAN_PATH") +``` + +Returns JSON: `{ valid, errors, warnings, task_count, tasks }` + +**If errors exist:** Fix before committing: +- Missing `` in task → add name element +- Missing `` → add action element +- Checkpoint/autonomous mismatch → update `autonomous: false` + + Update ROADMAP.md to finalize phase placeholders: diff --git a/agents/gsd-verifier.md b/agents/gsd-verifier.md index 5e32279e6..4b2e0babf 100644 --- a/agents/gsd-verifier.md +++ b/agents/gsd-verifier.md @@ -115,58 +115,38 @@ For each truth: ## Step 4: Verify Artifacts (Three Levels) -### Level 1: Existence +Use gsd-tools for artifact verification against must_haves in PLAN frontmatter: ```bash -[ -f "$path" ] && echo "EXISTS" || echo "MISSING" +ARTIFACT_RESULT=$(node ~/.claude/get-shit-done/bin/gsd-tools.js verify artifacts "$PLAN_PATH") ``` -If MISSING → artifact fails, record and continue. +Parse JSON result: `{ all_passed, passed, total, artifacts: [{path, exists, issues, passed}] }` -### Level 2: Substantive +For each artifact in result: +- `exists=false` → MISSING +- `issues` contains "Only N lines" or "Missing pattern" → STUB +- `passed=true` → VERIFIED -**Line count check** — minimums by type: -- Component: 15+ lines | API route: 10+ | Hook/util: 10+ | Schema: 5+ +**Artifact status mapping:** -**Stub pattern check:** - -```bash -check_stubs() { - local path="$1" - local stubs=$(grep -c -E "TODO|FIXME|placeholder|not implemented|coming soon" "$path" 2>/dev/null || echo 0) - local empty=$(grep -c -E "return null|return undefined|return \{\}|return \[\]" "$path" 2>/dev/null || echo 0) - local placeholder=$(grep -c -E "will be here|placeholder|lorem ipsum" "$path" 2>/dev/null || echo 0) - local total=$((stubs + empty + placeholder)) - [ "$total" -gt 0 ] && echo "STUB_PATTERNS ($total found)" || echo "NO_STUBS" -} -``` - -**Export check:** - -```bash -grep -E "^export (default )?(function|const|class)" "$path" && echo "HAS_EXPORTS" || echo "NO_EXPORTS" -``` - -**Combine Level 2:** -- SUBSTANTIVE: Adequate length + no stubs + has exports -- STUB: Too short OR has stub patterns OR no exports -- PARTIAL: Mixed signals - -### Level 3: Wired - -**Import check:** +| exists | issues empty | Status | +| ------ | ------------ | ----------- | +| true | true | ✓ VERIFIED | +| true | false | ✗ STUB | +| false | - | ✗ MISSING | + +**For wiring verification (Level 3)**, check imports/usage manually for artifacts that pass Levels 1-2: ```bash +# Import check grep -r "import.*$artifact_name" "${search_path:-src/}" --include="*.ts" --include="*.tsx" 2>/dev/null | wc -l -``` -**Usage check:** - -```bash +# Usage check (beyond imports) grep -r "$artifact_name" "${search_path:-src/}" --include="*.ts" --include="*.tsx" 2>/dev/null | grep -v "import" | wc -l ``` -**Combine Level 3:** +**Wiring status:** - WIRED: Imported AND used - ORPHANED: Exists but not imported/used - PARTIAL: Imported but not used (or vice versa) @@ -184,14 +164,25 @@ grep -r "$artifact_name" "${search_path:-src/}" --include="*.ts" --include="*.ts Key links are critical connections. If broken, the goal fails even with all artifacts present. -**For each link pattern, verify: (1) call exists, (2) response/result is used.** +Use gsd-tools for key link verification against must_haves in PLAN frontmatter: + +```bash +LINKS_RESULT=$(node ~/.claude/get-shit-done/bin/gsd-tools.js verify key-links "$PLAN_PATH") +``` + +Parse JSON result: `{ all_verified, verified, total, links: [{from, to, via, verified, detail}] }` + +For each link: +- `verified=true` → WIRED +- `verified=false` with "not found" in detail → NOT_WIRED +- `verified=false` with "Pattern not found" → PARTIAL + +**Fallback patterns** (if must_haves.key_links not defined in PLAN): ### Pattern: Component → API ```bash -# Check for fetch/axios call to the API grep -E "fetch\(['\"].*$api_path|axios\.(get|post).*$api_path" "$component" 2>/dev/null -# Check response handling grep -A 5 "fetch\|axios" "$component" | grep -E "await|\.then|setData|setState" 2>/dev/null ``` @@ -200,9 +191,7 @@ Status: WIRED (call + response handling) | PARTIAL (call, no response use) | NOT ### Pattern: API → Database ```bash -# Check for DB query grep -E "prisma\.$model|db\.$model|$model\.(find|create|update|delete)" "$route" 2>/dev/null -# Check result returned grep -E "return.*json.*\w+|res\.json\(\w+" "$route" 2>/dev/null ``` @@ -211,7 +200,6 @@ Status: WIRED (query + result returned) | PARTIAL (query, static return) | NOT_W ### Pattern: Form → Handler ```bash -# Check onSubmit handler exists and has real implementation grep -E "onSubmit=\{|handleSubmit" "$component" 2>/dev/null grep -A 10 "onSubmit.*=" "$component" | grep -E "fetch|axios|mutate|dispatch" 2>/dev/null ``` @@ -221,7 +209,6 @@ Status: WIRED (handler + API call) | STUB (only logs/preventDefault) | NOT_WIRED ### Pattern: State → Render ```bash -# Check state exists and is rendered in JSX grep -E "useState.*$state_var|\[$state_var," "$component" 2>/dev/null grep -E "\{.*$state_var.*\}|\{$state_var\." "$component" 2>/dev/null ``` @@ -244,9 +231,19 @@ For each requirement: parse description → identify supporting truths/artifacts ## Step 7: Scan for Anti-Patterns -Identify files modified in this phase: +Identify files modified in this phase from SUMMARY.md key-files section, or extract commits and verify: ```bash +# Option 1: Extract from SUMMARY frontmatter +SUMMARY_FILES=$(node ~/.claude/get-shit-done/bin/gsd-tools.js summary-extract "$PHASE_DIR"/*-SUMMARY.md --fields key-files) + +# Option 2: Verify commits exist (if commit hashes documented) +COMMIT_HASHES=$(grep -oE "[a-f0-9]{7,40}" "$PHASE_DIR"/*-SUMMARY.md | head -10) +if [ -n "$COMMIT_HASHES" ]; then + COMMITS_VALID=$(node ~/.claude/get-shit-done/bin/gsd-tools.js verify commits $COMMIT_HASHES) +fi + +# Fallback: grep for files grep -E "^\- \`" "$PHASE_DIR"/*-SUMMARY.md | sed 's/.*`\([^`]*\)`.*/\1/' | sort -u ``` diff --git a/get-shit-done/bin/gsd-tools.js b/get-shit-done/bin/gsd-tools.js index 6cd0a333b..4a5cc50fa 100755 --- a/get-shit-done/bin/gsd-tools.js +++ b/get-shit-done/bin/gsd-tools.js @@ -58,6 +58,47 @@ * scaffold phase-dir --phase Create phase directory * --name * + * Frontmatter CRUD: + * frontmatter get [--field k] Extract frontmatter as JSON + * frontmatter set --field k Update single frontmatter field + * --value jsonVal + * frontmatter merge Merge JSON into frontmatter + * --data '{json}' + * frontmatter validate Validate required fields + * --schema plan|summary|verification + * + * Verification Suite: + * verify plan-structure Check PLAN.md structure + tasks + * verify phase-completeness Check all plans have summaries + * verify references Check @-refs + paths resolve + * verify commits

[h2] ... Batch verify commit hashes + * verify artifacts Check must_haves.artifacts + * verify key-links Check must_haves.key_links + * + * Template Fill: + * template fill summary --phase N Create pre-filled SUMMARY.md + * [--plan M] [--name "..."] + * [--fields '{json}'] + * template fill plan --phase N Create pre-filled PLAN.md + * [--plan M] [--type execute|tdd] + * [--wave N] [--fields '{json}'] + * template fill verification Create pre-filled VERIFICATION.md + * --phase N [--fields '{json}'] + * + * State Progression: + * state advance-plan Increment plan counter + * state record-metric --phase N Record execution metrics + * --plan M --duration Xmin + * [--tasks N] [--files N] + * state update-progress Recalculate progress bar + * state add-decision --summary "..." Add decision to STATE.md + * [--phase N] [--rationale "..."] + * state add-blocker --text "..." Add blocker + * state resolve-blocker --text "..." Remove blocker + * state record-session Update session continuity + * --stopped-at "..." + * [--resume-file path] + * * Compound Commands (workflow-specific initialization): * init execute-phase All context for execute-phase workflow * init plan-phase All context for plan-phase workflow @@ -279,6 +320,145 @@ function extractFrontmatter(content) { return frontmatter; } +function reconstructFrontmatter(obj) { + const lines = []; + for (const [key, value] of Object.entries(obj)) { + if (value === null || value === undefined) continue; + if (Array.isArray(value)) { + if (value.length === 0) { + lines.push(`${key}: []`); + } else if (value.every(v => typeof v === 'string') && value.length <= 3 && value.join(', ').length < 60) { + lines.push(`${key}: [${value.join(', ')}]`); + } else { + lines.push(`${key}:`); + for (const item of value) { + lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#')) ? `"${item}"` : item}`); + } + } + } else if (typeof value === 'object') { + lines.push(`${key}:`); + for (const [subkey, subval] of Object.entries(value)) { + if (subval === null || subval === undefined) continue; + if (Array.isArray(subval)) { + if (subval.length === 0) { + lines.push(` ${subkey}: []`); + } else if (subval.every(v => typeof v === 'string') && subval.length <= 3 && subval.join(', ').length < 60) { + lines.push(` ${subkey}: [${subval.join(', ')}]`); + } else { + lines.push(` ${subkey}:`); + for (const item of subval) { + lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#')) ? `"${item}"` : item}`); + } + } + } else if (typeof subval === 'object') { + lines.push(` ${subkey}:`); + for (const [subsubkey, subsubval] of Object.entries(subval)) { + if (subsubval === null || subsubval === undefined) continue; + if (Array.isArray(subsubval)) { + if (subsubval.length === 0) { + lines.push(` ${subsubkey}: []`); + } else { + lines.push(` ${subsubkey}:`); + for (const item of subsubval) { + lines.push(` - ${item}`); + } + } + } else { + lines.push(` ${subsubkey}: ${subsubval}`); + } + } + } else { + const sv = String(subval); + lines.push(` ${subkey}: ${sv.includes(':') || sv.includes('#') ? `"${sv}"` : sv}`); + } + } + } else { + const sv = String(value); + if (sv.includes(':') || sv.includes('#') || sv.startsWith('[') || sv.startsWith('{')) { + lines.push(`${key}: "${sv}"`); + } else { + lines.push(`${key}: ${sv}`); + } + } + } + return lines.join('\n'); +} + +function spliceFrontmatter(content, newObj) { + const yamlStr = reconstructFrontmatter(newObj); + const match = content.match(/^---\n[\s\S]+?\n---/); + if (match) { + return `---\n${yamlStr}\n---` + content.slice(match[0].length); + } + return `---\n${yamlStr}\n---\n\n` + content; +} + +function parseMustHavesBlock(content, blockName) { + // Extract a specific block from must_haves in raw frontmatter YAML + // Handles 3-level nesting: must_haves > artifacts/key_links > [{path, provides, ...}] + const fmMatch = content.match(/^---\n([\s\S]+?)\n---/); + if (!fmMatch) return []; + + const yaml = fmMatch[1]; + // Find the block (e.g., "truths:", "artifacts:", "key_links:") + const blockPattern = new RegExp(`^\\s{4}${blockName}:\\s*$`, 'm'); + const blockStart = yaml.search(blockPattern); + if (blockStart === -1) return []; + + const afterBlock = yaml.slice(blockStart); + const blockLines = afterBlock.split('\n').slice(1); // skip the header line + + const items = []; + let current = null; + + for (const line of blockLines) { + // Stop at same or lower indent level (non-continuation) + if (line.trim() === '') continue; + const indent = line.match(/^(\s*)/)[1].length; + if (indent <= 4 && line.trim() !== '') break; // back to must_haves level or higher + + if (line.match(/^\s{6}-\s+/)) { + // New list item at 6-space indent + if (current) items.push(current); + current = {}; + // Check if it's a simple string item + const simpleMatch = line.match(/^\s{6}-\s+"?([^"]+)"?\s*$/); + if (simpleMatch && !line.includes(':')) { + current = simpleMatch[1]; + } else { + // Key-value on same line as dash: "- path: value" + const kvMatch = line.match(/^\s{6}-\s+(\w+):\s*"?([^"]*)"?\s*$/); + if (kvMatch) { + current = {}; + current[kvMatch[1]] = kvMatch[2]; + } + } + } else if (current && typeof current === 'object') { + // Continuation key-value at 8+ space indent + const kvMatch = line.match(/^\s{8,}(\w+):\s*"?([^"]*)"?\s*$/); + if (kvMatch) { + const val = kvMatch[2]; + // Try to parse as number + current[kvMatch[1]] = /^\d+$/.test(val) ? parseInt(val, 10) : val; + } + // Array items under a key + const arrMatch = line.match(/^\s{10,}-\s+"?([^"]+)"?\s*$/); + if (arrMatch) { + // Find the last key added and convert to array + const keys = Object.keys(current); + const lastKey = keys[keys.length - 1]; + if (lastKey && !Array.isArray(current[lastKey])) { + current[lastKey] = current[lastKey] ? [current[lastKey]] : []; + } + if (lastKey) current[lastKey].push(arrMatch[1]); + } + } + } + if (current) items.push(current); + + return items; +} + function output(result, raw, rawValue) { if (raw && rawValue !== undefined) { process.stdout.write(String(rawValue)); @@ -843,6 +1023,241 @@ function cmdStateUpdate(cwd, field, value) { } } +// ─── State Progression Engine ──────────────────────────────────────────────── + +function stateExtractField(content, fieldName) { + const pattern = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+)`, 'i'); + const match = content.match(pattern); + return match ? match[1].trim() : null; +} + +function stateReplaceField(content, fieldName, newValue) { + const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i'); + if (pattern.test(content)) { + return content.replace(pattern, `$1${newValue}`); + } + return null; +} + +function cmdStateAdvancePlan(cwd, raw) { + const statePath = path.join(cwd, '.planning', 'STATE.md'); + if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } + + let content = fs.readFileSync(statePath, 'utf-8'); + const currentPlan = parseInt(stateExtractField(content, 'Current Plan'), 10); + const totalPlans = parseInt(stateExtractField(content, 'Total Plans in Phase'), 10); + const today = new Date().toISOString().split('T')[0]; + + if (isNaN(currentPlan) || isNaN(totalPlans)) { + output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw); + return; + } + + if (currentPlan >= totalPlans) { + content = stateReplaceField(content, 'Status', 'Phase complete — ready for verification') || content; + content = stateReplaceField(content, 'Last Activity', today) || content; + fs.writeFileSync(statePath, content, 'utf-8'); + output({ advanced: false, reason: 'last_plan', current_plan: currentPlan, total_plans: totalPlans, status: 'ready_for_verification' }, raw, 'false'); + } else { + const newPlan = currentPlan + 1; + content = stateReplaceField(content, 'Current Plan', String(newPlan)) || content; + content = stateReplaceField(content, 'Status', 'Ready to execute') || content; + content = stateReplaceField(content, 'Last Activity', today) || content; + fs.writeFileSync(statePath, content, 'utf-8'); + output({ advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans }, raw, 'true'); + } +} + +function cmdStateRecordMetric(cwd, options, raw) { + const statePath = path.join(cwd, '.planning', 'STATE.md'); + if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } + + let content = fs.readFileSync(statePath, 'utf-8'); + const { phase, plan, duration, tasks, files } = options; + + if (!phase || !plan || !duration) { + output({ error: 'phase, plan, and duration required' }, raw); + return; + } + + // Find Performance Metrics section and its table + const metricsPattern = /(##\s*Performance Metrics[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n)([\s\S]*?)(?=\n##|\n$|$)/i; + const metricsMatch = content.match(metricsPattern); + + if (metricsMatch) { + const tableHeader = metricsMatch[1]; + let tableBody = metricsMatch[2].trimEnd(); + const newRow = `| Phase ${phase} P${plan} | ${duration} | ${tasks || '-'} tasks | ${files || '-'} files |`; + + if (tableBody.trim() === '' || tableBody.includes('None yet')) { + tableBody = newRow; + } else { + tableBody = tableBody + '\n' + newRow; + } + + content = content.replace(metricsPattern, `${tableHeader}${tableBody}\n`); + fs.writeFileSync(statePath, content, 'utf-8'); + output({ recorded: true, phase, plan, duration }, raw, 'true'); + } else { + output({ recorded: false, reason: 'Performance Metrics section not found in STATE.md' }, raw, 'false'); + } +} + +function cmdStateUpdateProgress(cwd, raw) { + const statePath = path.join(cwd, '.planning', 'STATE.md'); + if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } + + let content = fs.readFileSync(statePath, 'utf-8'); + + // Count summaries across all phases + const phasesDir = path.join(cwd, '.planning', 'phases'); + let totalPlans = 0; + let totalSummaries = 0; + + if (fs.existsSync(phasesDir)) { + const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true }) + .filter(e => e.isDirectory()).map(e => e.name); + for (const dir of phaseDirs) { + const files = fs.readdirSync(path.join(phasesDir, dir)); + totalPlans += files.filter(f => f.match(/-PLAN\.md$/i)).length; + totalSummaries += files.filter(f => f.match(/-SUMMARY\.md$/i)).length; + } + } + + const percent = totalPlans > 0 ? Math.round(totalSummaries / totalPlans * 100) : 0; + const barWidth = 10; + const filled = Math.round(percent / 100 * barWidth); + const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled); + const progressStr = `[${bar}] ${percent}%`; + + const progressPattern = /(\*\*Progress:\*\*\s*).*/i; + if (progressPattern.test(content)) { + content = content.replace(progressPattern, `$1${progressStr}`); + fs.writeFileSync(statePath, content, 'utf-8'); + output({ updated: true, percent, completed: totalSummaries, total: totalPlans, bar: progressStr }, raw, progressStr); + } else { + output({ updated: false, reason: 'Progress field not found in STATE.md' }, raw, 'false'); + } +} + +function cmdStateAddDecision(cwd, options, raw) { + const statePath = path.join(cwd, '.planning', 'STATE.md'); + if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } + + const { phase, summary, rationale } = options; + if (!summary) { output({ error: 'summary required' }, raw); return; } + + let content = fs.readFileSync(statePath, 'utf-8'); + const entry = `- [Phase ${phase || '?'}]: ${summary}${rationale ? ` — ${rationale}` : ''}`; + + // Find Decisions section (various heading patterns) + const sectionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i; + const match = content.match(sectionPattern); + + if (match) { + let sectionBody = match[2]; + // Remove placeholders + sectionBody = sectionBody.replace(/None yet\.?\s*\n?/gi, '').replace(/No decisions yet\.?\s*\n?/gi, ''); + sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n'; + content = content.replace(sectionPattern, `${match[1]}${sectionBody}`); + fs.writeFileSync(statePath, content, 'utf-8'); + output({ added: true, decision: entry }, raw, 'true'); + } else { + output({ added: false, reason: 'Decisions section not found in STATE.md' }, raw, 'false'); + } +} + +function cmdStateAddBlocker(cwd, text, raw) { + const statePath = path.join(cwd, '.planning', 'STATE.md'); + if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } + if (!text) { output({ error: 'text required' }, raw); return; } + + let content = fs.readFileSync(statePath, 'utf-8'); + const entry = `- ${text}`; + + const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i; + const match = content.match(sectionPattern); + + if (match) { + let sectionBody = match[2]; + sectionBody = sectionBody.replace(/None\.?\s*\n?/gi, '').replace(/None yet\.?\s*\n?/gi, ''); + sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n'; + content = content.replace(sectionPattern, `${match[1]}${sectionBody}`); + fs.writeFileSync(statePath, content, 'utf-8'); + output({ added: true, blocker: text }, raw, 'true'); + } else { + output({ added: false, reason: 'Blockers section not found in STATE.md' }, raw, 'false'); + } +} + +function cmdStateResolveBlocker(cwd, text, raw) { + const statePath = path.join(cwd, '.planning', 'STATE.md'); + if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } + if (!text) { output({ error: 'text required' }, raw); return; } + + let content = fs.readFileSync(statePath, 'utf-8'); + + const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i; + const match = content.match(sectionPattern); + + if (match) { + const sectionBody = match[2]; + const lines = sectionBody.split('\n'); + const filtered = lines.filter(line => { + if (!line.startsWith('- ')) return true; + return !line.toLowerCase().includes(text.toLowerCase()); + }); + + let newBody = filtered.join('\n'); + // If section is now empty, add placeholder + if (!newBody.trim() || !newBody.includes('- ')) { + newBody = 'None\n'; + } + + content = content.replace(sectionPattern, `${match[1]}${newBody}`); + fs.writeFileSync(statePath, content, 'utf-8'); + output({ resolved: true, blocker: text }, raw, 'true'); + } else { + output({ resolved: false, reason: 'Blockers section not found in STATE.md' }, raw, 'false'); + } +} + +function cmdStateRecordSession(cwd, options, raw) { + const statePath = path.join(cwd, '.planning', 'STATE.md'); + if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } + + let content = fs.readFileSync(statePath, 'utf-8'); + const now = new Date().toISOString(); + const updated = []; + + // Update Last session / Last Date + let result = stateReplaceField(content, 'Last session', now); + if (result) { content = result; updated.push('Last session'); } + result = stateReplaceField(content, 'Last Date', now); + if (result) { content = result; updated.push('Last Date'); } + + // Update Stopped at + if (options.stopped_at) { + result = stateReplaceField(content, 'Stopped At', options.stopped_at); + if (!result) result = stateReplaceField(content, 'Stopped at', options.stopped_at); + if (result) { content = result; updated.push('Stopped At'); } + } + + // Update Resume file + const resumeFile = options.resume_file || 'None'; + result = stateReplaceField(content, 'Resume File', resumeFile); + if (!result) result = stateReplaceField(content, 'Resume file', resumeFile); + if (result) { content = result; updated.push('Resume File'); } + + if (updated.length > 0) { + fs.writeFileSync(statePath, content, 'utf-8'); + output({ recorded: true, updated }, raw, 'true'); + } else { + output({ recorded: false, reason: 'No session fields found in STATE.md' }, raw, 'false'); + } +} + function cmdResolveModel(cwd, agentType, raw) { if (!agentType) { error('agent-type required'); @@ -1097,6 +1512,172 @@ function cmdTemplateSelect(cwd, planPath, raw) { } } +function cmdTemplateFill(cwd, templateType, options, raw) { + if (!templateType) { error('template type required: summary, plan, or verification'); } + if (!options.phase) { error('--phase required'); } + + const phaseInfo = findPhaseInternal(cwd, options.phase); + if (!phaseInfo || !phaseInfo.found) { output({ error: 'Phase not found', phase: options.phase }, raw); return; } + + const padded = normalizePhaseName(options.phase); + const today = new Date().toISOString().split('T')[0]; + const phaseName = options.name || phaseInfo.phase_name || 'Unnamed'; + const phaseSlug = phaseInfo.phase_slug || generateSlugInternal(phaseName); + const phaseId = `${padded}-${phaseSlug}`; + const planNum = (options.plan || '01').padStart(2, '0'); + const fields = options.fields || {}; + + let frontmatter, body, fileName; + + switch (templateType) { + case 'summary': { + frontmatter = { + phase: phaseId, + plan: planNum, + subsystem: '[primary category]', + tags: [], + provides: [], + affects: [], + 'tech-stack': { added: [], patterns: [] }, + 'key-files': { created: [], modified: [] }, + 'key-decisions': [], + 'patterns-established': [], + duration: '[X]min', + completed: today, + ...fields, + }; + body = [ + `# Phase ${options.phase}: ${phaseName} Summary`, + '', + '**[Substantive one-liner describing outcome]**', + '', + '## Performance', + '- **Duration:** [time]', + '- **Tasks:** [count completed]', + '- **Files modified:** [count]', + '', + '## Accomplishments', + '- [Key outcome 1]', + '- [Key outcome 2]', + '', + '## Task Commits', + '1. **Task 1: [task name]** - `hash`', + '', + '## Files Created/Modified', + '- `path/to/file.ts` - What it does', + '', + '## Decisions & Deviations', + '[Key decisions or "None - followed plan as specified"]', + '', + '## Next Phase Readiness', + '[What\'s ready for next phase]', + ].join('\n'); + fileName = `${padded}-${planNum}-SUMMARY.md`; + break; + } + case 'plan': { + const planType = options.type || 'execute'; + const wave = parseInt(options.wave) || 1; + frontmatter = { + phase: phaseId, + plan: planNum, + type: planType, + wave, + depends_on: [], + files_modified: [], + autonomous: true, + user_setup: [], + must_haves: { truths: [], artifacts: [], key_links: [] }, + ...fields, + }; + body = [ + `# Phase ${options.phase} Plan ${planNum}: [Title]`, + '', + '## Objective', + '- **What:** [What this plan builds]', + '- **Why:** [Why it matters for the phase goal]', + '- **Output:** [Concrete deliverable]', + '', + '## Context', + '@.planning/PROJECT.md', + '@.planning/ROADMAP.md', + '@.planning/STATE.md', + '', + '## Tasks', + '', + '', + ' [Task name]', + ' [file paths]', + ' [What to do]', + ' [How to verify]', + ' [Definition of done]', + '', + '', + '## Verification', + '[How to verify this plan achieved its objective]', + '', + '## Success Criteria', + '- [ ] [Criterion 1]', + '- [ ] [Criterion 2]', + ].join('\n'); + fileName = `${padded}-${planNum}-PLAN.md`; + break; + } + case 'verification': { + frontmatter = { + phase: phaseId, + verified: new Date().toISOString(), + status: 'pending', + score: '0/0 must-haves verified', + ...fields, + }; + body = [ + `# Phase ${options.phase}: ${phaseName} — Verification`, + '', + '## Observable Truths', + '| # | Truth | Status | Evidence |', + '|---|-------|--------|----------|', + '| 1 | [Truth] | pending | |', + '', + '## Required Artifacts', + '| Artifact | Expected | Status | Details |', + '|----------|----------|--------|---------|', + '| [path] | [what] | pending | |', + '', + '## Key Link Verification', + '| From | To | Via | Status | Details |', + '|------|----|----|--------|---------|', + '| [source] | [target] | [connection] | pending | |', + '', + '## Requirements Coverage', + '| Requirement | Status | Blocking Issue |', + '|-------------|--------|----------------|', + '| [req] | pending | |', + '', + '## Result', + '[Pending verification]', + ].join('\n'); + fileName = `${padded}-VERIFICATION.md`; + break; + } + default: + error(`Unknown template type: ${templateType}. Available: summary, plan, verification`); + return; + } + + const fullContent = `---\n${reconstructFrontmatter(frontmatter)}\n---\n\n${body}\n`; + const outPath = path.join(cwd, phaseInfo.directory, fileName); + + if (fs.existsSync(outPath)) { + output({ error: 'File already exists', path: path.relative(cwd, outPath) }, raw); + return; + } + + fs.writeFileSync(outPath, fullContent, 'utf-8'); + const relPath = path.relative(cwd, outPath); + output({ created: true, path: relPath, template: templateType }, raw, relPath); +} + function cmdPhasePlanIndex(cwd, phase, raw) { if (!phase) { error('phase required for phase-plan-index'); @@ -1361,6 +1942,362 @@ function cmdSummaryExtract(cwd, summaryPath, fields, raw) { output(fullResult, raw); } +// ─── Frontmatter CRUD ──────────────────────────────────────────────────────── + +function cmdFrontmatterGet(cwd, filePath, field, raw) { + if (!filePath) { error('file path required'); } + const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath); + const content = safeReadFile(fullPath); + if (!content) { output({ error: 'File not found', path: filePath }, raw); return; } + const fm = extractFrontmatter(content); + if (field) { + const value = fm[field]; + if (value === undefined) { output({ error: 'Field not found', field }, raw); return; } + output({ [field]: value }, raw, JSON.stringify(value)); + } else { + output(fm, raw); + } +} + +function cmdFrontmatterSet(cwd, filePath, field, value, raw) { + if (!filePath || !field || value === undefined) { error('file, field, and value required'); } + const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath); + if (!fs.existsSync(fullPath)) { output({ error: 'File not found', path: filePath }, raw); return; } + const content = fs.readFileSync(fullPath, 'utf-8'); + const fm = extractFrontmatter(content); + let parsedValue; + try { parsedValue = JSON.parse(value); } catch { parsedValue = value; } + fm[field] = parsedValue; + const newContent = spliceFrontmatter(content, fm); + fs.writeFileSync(fullPath, newContent, 'utf-8'); + output({ updated: true, field, value: parsedValue }, raw, 'true'); +} + +function cmdFrontmatterMerge(cwd, filePath, data, raw) { + if (!filePath || !data) { error('file and data required'); } + const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath); + if (!fs.existsSync(fullPath)) { output({ error: 'File not found', path: filePath }, raw); return; } + const content = fs.readFileSync(fullPath, 'utf-8'); + const fm = extractFrontmatter(content); + let mergeData; + try { mergeData = JSON.parse(data); } catch { error('Invalid JSON for --data'); return; } + Object.assign(fm, mergeData); + const newContent = spliceFrontmatter(content, fm); + fs.writeFileSync(fullPath, newContent, 'utf-8'); + output({ merged: true, fields: Object.keys(mergeData) }, raw, 'true'); +} + +const FRONTMATTER_SCHEMAS = { + plan: { required: ['phase', 'plan', 'type', 'wave', 'depends_on', 'files_modified', 'autonomous', 'must_haves'] }, + summary: { required: ['phase', 'plan', 'subsystem', 'tags', 'duration', 'completed'] }, + verification: { required: ['phase', 'verified', 'status', 'score'] }, +}; + +function cmdFrontmatterValidate(cwd, filePath, schemaName, raw) { + if (!filePath || !schemaName) { error('file and schema required'); } + const schema = FRONTMATTER_SCHEMAS[schemaName]; + if (!schema) { error(`Unknown schema: ${schemaName}. Available: ${Object.keys(FRONTMATTER_SCHEMAS).join(', ')}`); } + const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath); + const content = safeReadFile(fullPath); + if (!content) { output({ error: 'File not found', path: filePath }, raw); return; } + const fm = extractFrontmatter(content); + const missing = schema.required.filter(f => fm[f] === undefined); + const present = schema.required.filter(f => fm[f] !== undefined); + output({ valid: missing.length === 0, missing, present, schema: schemaName }, raw, missing.length === 0 ? 'valid' : 'invalid'); +} + +// ─── Verification Suite ────────────────────────────────────────────────────── + +function cmdVerifyPlanStructure(cwd, filePath, raw) { + if (!filePath) { error('file path required'); } + const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath); + const content = safeReadFile(fullPath); + if (!content) { output({ error: 'File not found', path: filePath }, raw); return; } + + const fm = extractFrontmatter(content); + const errors = []; + const warnings = []; + + // Check required frontmatter fields + const required = ['phase', 'plan', 'type', 'wave', 'depends_on', 'files_modified', 'autonomous', 'must_haves']; + for (const field of required) { + if (fm[field] === undefined) errors.push(`Missing required frontmatter field: ${field}`); + } + + // Parse and check task elements + const taskPattern = /]*>([\s\S]*?)<\/task>/g; + const tasks = []; + let taskMatch; + while ((taskMatch = taskPattern.exec(content)) !== null) { + const taskContent = taskMatch[1]; + const nameMatch = taskContent.match(/([\s\S]*?)<\/name>/); + const taskName = nameMatch ? nameMatch[1].trim() : 'unnamed'; + const hasFiles = //.test(taskContent); + const hasAction = //.test(taskContent); + const hasVerify = //.test(taskContent); + const hasDone = //.test(taskContent); + + if (!nameMatch) errors.push('Task missing element'); + if (!hasAction) errors.push(`Task '${taskName}' missing `); + if (!hasVerify) warnings.push(`Task '${taskName}' missing `); + if (!hasDone) warnings.push(`Task '${taskName}' missing `); + if (!hasFiles) warnings.push(`Task '${taskName}' missing `); + + tasks.push({ name: taskName, hasFiles, hasAction, hasVerify, hasDone }); + } + + if (tasks.length === 0) warnings.push('No elements found'); + + // Wave/depends_on consistency + if (fm.wave && parseInt(fm.wave) > 1 && (!fm.depends_on || (Array.isArray(fm.depends_on) && fm.depends_on.length === 0))) { + warnings.push('Wave > 1 but depends_on is empty'); + } + + // Autonomous/checkpoint consistency + const hasCheckpoints = / f.match(/-PLAN\.md$/i)); + const summaries = files.filter(f => f.match(/-SUMMARY\.md$/i)); + + // Extract plan IDs (everything before -PLAN.md) + const planIds = new Set(plans.map(p => p.replace(/-PLAN\.md$/i, ''))); + const summaryIds = new Set(summaries.map(s => s.replace(/-SUMMARY\.md$/i, ''))); + + // Plans without summaries + const incompletePlans = [...planIds].filter(id => !summaryIds.has(id)); + if (incompletePlans.length > 0) { + errors.push(`Plans without summaries: ${incompletePlans.join(', ')}`); + } + + // Summaries without plans (orphans) + const orphanSummaries = [...summaryIds].filter(id => !planIds.has(id)); + if (orphanSummaries.length > 0) { + warnings.push(`Summaries without plans: ${orphanSummaries.join(', ')}`); + } + + output({ + complete: errors.length === 0, + phase: phaseInfo.phase_number, + plan_count: plans.length, + summary_count: summaries.length, + incomplete_plans: incompletePlans, + orphan_summaries: orphanSummaries, + errors, + warnings, + }, raw, errors.length === 0 ? 'complete' : 'incomplete'); +} + +function cmdVerifyReferences(cwd, filePath, raw) { + if (!filePath) { error('file path required'); } + const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath); + const content = safeReadFile(fullPath); + if (!content) { output({ error: 'File not found', path: filePath }, raw); return; } + + const found = []; + const missing = []; + + // Find @-references: @path/to/file (must contain / to be a file path) + const atRefs = content.match(/@([^\s\n,)]+\/[^\s\n,)]+)/g) || []; + for (const ref of atRefs) { + const cleanRef = ref.slice(1); // remove @ + const resolved = cleanRef.startsWith('~/') + ? path.join(process.env.HOME || '', cleanRef.slice(2)) + : path.join(cwd, cleanRef); + if (fs.existsSync(resolved)) { + found.push(cleanRef); + } else { + missing.push(cleanRef); + } + } + + // Find backtick file paths that look like real paths (contain / and have extension) + const backtickRefs = content.match(/`([^`]+\/[^`]+\.[a-zA-Z]{1,10})`/g) || []; + for (const ref of backtickRefs) { + const cleanRef = ref.slice(1, -1); // remove backticks + if (cleanRef.startsWith('http') || cleanRef.includes('${') || cleanRef.includes('{{')) continue; + if (found.includes(cleanRef) || missing.includes(cleanRef)) continue; // dedup + const resolved = path.join(cwd, cleanRef); + if (fs.existsSync(resolved)) { + found.push(cleanRef); + } else { + missing.push(cleanRef); + } + } + + output({ + valid: missing.length === 0, + found: found.length, + missing, + total: found.length + missing.length, + }, raw, missing.length === 0 ? 'valid' : 'invalid'); +} + +function cmdVerifyCommits(cwd, hashes, raw) { + if (!hashes || hashes.length === 0) { error('At least one commit hash required'); } + + const valid = []; + const invalid = []; + for (const hash of hashes) { + const result = execGit(cwd, ['cat-file', '-t', hash]); + if (result.exitCode === 0 && result.stdout.trim() === 'commit') { + valid.push(hash); + } else { + invalid.push(hash); + } + } + + output({ + all_valid: invalid.length === 0, + valid, + invalid, + total: hashes.length, + }, raw, invalid.length === 0 ? 'valid' : 'invalid'); +} + +function cmdVerifyArtifacts(cwd, planFilePath, raw) { + if (!planFilePath) { error('plan file path required'); } + const fullPath = path.isAbsolute(planFilePath) ? planFilePath : path.join(cwd, planFilePath); + const content = safeReadFile(fullPath); + if (!content) { output({ error: 'File not found', path: planFilePath }, raw); return; } + + const artifacts = parseMustHavesBlock(content, 'artifacts'); + if (artifacts.length === 0) { + output({ error: 'No must_haves.artifacts found in frontmatter', path: planFilePath }, raw); + return; + } + + const results = []; + for (const artifact of artifacts) { + if (typeof artifact === 'string') continue; // skip simple string items + const artPath = artifact.path; + if (!artPath) continue; + + const artFullPath = path.join(cwd, artPath); + const exists = fs.existsSync(artFullPath); + const check = { path: artPath, exists, issues: [], passed: false }; + + if (exists) { + const fileContent = safeReadFile(artFullPath) || ''; + const lineCount = fileContent.split('\n').length; + + if (artifact.min_lines && lineCount < artifact.min_lines) { + check.issues.push(`Only ${lineCount} lines, need ${artifact.min_lines}`); + } + if (artifact.contains && !fileContent.includes(artifact.contains)) { + check.issues.push(`Missing pattern: ${artifact.contains}`); + } + if (artifact.exports) { + const exports = Array.isArray(artifact.exports) ? artifact.exports : [artifact.exports]; + for (const exp of exports) { + if (!fileContent.includes(exp)) check.issues.push(`Missing export: ${exp}`); + } + } + check.passed = check.issues.length === 0; + } else { + check.issues.push('File not found'); + } + + results.push(check); + } + + const passed = results.filter(r => r.passed).length; + output({ + all_passed: passed === results.length, + passed, + total: results.length, + artifacts: results, + }, raw, passed === results.length ? 'valid' : 'invalid'); +} + +function cmdVerifyKeyLinks(cwd, planFilePath, raw) { + if (!planFilePath) { error('plan file path required'); } + const fullPath = path.isAbsolute(planFilePath) ? planFilePath : path.join(cwd, planFilePath); + const content = safeReadFile(fullPath); + if (!content) { output({ error: 'File not found', path: planFilePath }, raw); return; } + + const keyLinks = parseMustHavesBlock(content, 'key_links'); + if (keyLinks.length === 0) { + output({ error: 'No must_haves.key_links found in frontmatter', path: planFilePath }, raw); + return; + } + + const results = []; + for (const link of keyLinks) { + if (typeof link === 'string') continue; + const check = { from: link.from, to: link.to, via: link.via || '', verified: false, detail: '' }; + + const sourceContent = safeReadFile(path.join(cwd, link.from || '')); + if (!sourceContent) { + check.detail = 'Source file not found'; + } else if (link.pattern) { + try { + const regex = new RegExp(link.pattern); + if (regex.test(sourceContent)) { + check.verified = true; + check.detail = 'Pattern found in source'; + } else { + const targetContent = safeReadFile(path.join(cwd, link.to || '')); + if (targetContent && regex.test(targetContent)) { + check.verified = true; + check.detail = 'Pattern found in target'; + } else { + check.detail = `Pattern "${link.pattern}" not found in source or target`; + } + } + } catch { + check.detail = `Invalid regex pattern: ${link.pattern}`; + } + } else { + // No pattern: just check source references target + if (sourceContent.includes(link.to || '')) { + check.verified = true; + check.detail = 'Target referenced in source'; + } else { + check.detail = 'Target not referenced in source'; + } + } + + results.push(check); + } + + const verified = results.filter(r => r.verified).length; + output({ + all_verified: verified === results.length, + verified, + total: results.length, + links: results, + }, raw, verified === results.length ? 'valid' : 'invalid'); +} + // ─── Roadmap Analysis ───────────────────────────────────────────────────────── function cmdRoadmapAnalyze(cwd, raw) { @@ -3156,7 +4093,7 @@ function main() { const cwd = process.cwd(); if (!command) { - error('Usage: gsd-tools [args] [--raw]\nCommands: state, resolve-model, find-phase, commit, verify-summary, generate-slug, current-timestamp, list-todos, verify-path-exists, config-ensure-section, init'); + error('Usage: gsd-tools [args] [--raw]\nCommands: state, resolve-model, find-phase, commit, verify-summary, verify, frontmatter, template, generate-slug, current-timestamp, list-todos, verify-path-exists, config-ensure-section, init'); } switch (command) { @@ -3176,6 +4113,45 @@ function main() { } } cmdStatePatch(cwd, patches, raw); + } else if (subcommand === 'advance-plan') { + cmdStateAdvancePlan(cwd, raw); + } else if (subcommand === 'record-metric') { + const phaseIdx = args.indexOf('--phase'); + const planIdx = args.indexOf('--plan'); + const durationIdx = args.indexOf('--duration'); + const tasksIdx = args.indexOf('--tasks'); + const filesIdx = args.indexOf('--files'); + cmdStateRecordMetric(cwd, { + phase: phaseIdx !== -1 ? args[phaseIdx + 1] : null, + plan: planIdx !== -1 ? args[planIdx + 1] : null, + duration: durationIdx !== -1 ? args[durationIdx + 1] : null, + tasks: tasksIdx !== -1 ? args[tasksIdx + 1] : null, + files: filesIdx !== -1 ? args[filesIdx + 1] : null, + }, raw); + } else if (subcommand === 'update-progress') { + cmdStateUpdateProgress(cwd, raw); + } else if (subcommand === 'add-decision') { + const phaseIdx = args.indexOf('--phase'); + const summaryIdx = args.indexOf('--summary'); + const rationaleIdx = args.indexOf('--rationale'); + cmdStateAddDecision(cwd, { + phase: phaseIdx !== -1 ? args[phaseIdx + 1] : null, + summary: summaryIdx !== -1 ? args[summaryIdx + 1] : null, + rationale: rationaleIdx !== -1 ? args[rationaleIdx + 1] : '', + }, raw); + } else if (subcommand === 'add-blocker') { + const textIdx = args.indexOf('--text'); + cmdStateAddBlocker(cwd, textIdx !== -1 ? args[textIdx + 1] : null, raw); + } else if (subcommand === 'resolve-blocker') { + const textIdx = args.indexOf('--text'); + cmdStateResolveBlocker(cwd, textIdx !== -1 ? args[textIdx + 1] : null, raw); + } else if (subcommand === 'record-session') { + const stoppedIdx = args.indexOf('--stopped-at'); + const resumeIdx = args.indexOf('--resume-file'); + cmdStateRecordSession(cwd, { + stopped_at: stoppedIdx !== -1 ? args[stoppedIdx + 1] : null, + resume_file: resumeIdx !== -1 ? args[resumeIdx + 1] : 'None', + }, raw); } else { cmdStateLoad(cwd, raw); } @@ -3214,6 +4190,66 @@ function main() { const subcommand = args[1]; if (subcommand === 'select') { cmdTemplateSelect(cwd, args[2], raw); + } else if (subcommand === 'fill') { + const templateType = args[2]; + const phaseIdx = args.indexOf('--phase'); + const planIdx = args.indexOf('--plan'); + const nameIdx = args.indexOf('--name'); + const typeIdx = args.indexOf('--type'); + const waveIdx = args.indexOf('--wave'); + const fieldsIdx = args.indexOf('--fields'); + cmdTemplateFill(cwd, templateType, { + phase: phaseIdx !== -1 ? args[phaseIdx + 1] : null, + plan: planIdx !== -1 ? args[planIdx + 1] : null, + name: nameIdx !== -1 ? args[nameIdx + 1] : null, + type: typeIdx !== -1 ? args[typeIdx + 1] : 'execute', + wave: waveIdx !== -1 ? args[waveIdx + 1] : '1', + fields: fieldsIdx !== -1 ? JSON.parse(args[fieldsIdx + 1]) : {}, + }, raw); + } else { + error('Unknown template subcommand. Available: select, fill'); + } + break; + } + + case 'frontmatter': { + const subcommand = args[1]; + const file = args[2]; + if (subcommand === 'get') { + const fieldIdx = args.indexOf('--field'); + cmdFrontmatterGet(cwd, file, fieldIdx !== -1 ? args[fieldIdx + 1] : null, raw); + } else if (subcommand === 'set') { + const fieldIdx = args.indexOf('--field'); + const valueIdx = args.indexOf('--value'); + cmdFrontmatterSet(cwd, file, fieldIdx !== -1 ? args[fieldIdx + 1] : null, valueIdx !== -1 ? args[valueIdx + 1] : undefined, raw); + } else if (subcommand === 'merge') { + const dataIdx = args.indexOf('--data'); + cmdFrontmatterMerge(cwd, file, dataIdx !== -1 ? args[dataIdx + 1] : null, raw); + } else if (subcommand === 'validate') { + const schemaIdx = args.indexOf('--schema'); + cmdFrontmatterValidate(cwd, file, schemaIdx !== -1 ? args[schemaIdx + 1] : null, raw); + } else { + error('Unknown frontmatter subcommand. Available: get, set, merge, validate'); + } + break; + } + + case 'verify': { + const subcommand = args[1]; + if (subcommand === 'plan-structure') { + cmdVerifyPlanStructure(cwd, args[2], raw); + } else if (subcommand === 'phase-completeness') { + cmdVerifyPhaseCompleteness(cwd, args[2], raw); + } else if (subcommand === 'references') { + cmdVerifyReferences(cwd, args[2], raw); + } else if (subcommand === 'commits') { + cmdVerifyCommits(cwd, args.slice(2), raw); + } else if (subcommand === 'artifacts') { + cmdVerifyArtifacts(cwd, args[2], raw); + } else if (subcommand === 'key-links') { + cmdVerifyKeyLinks(cwd, args[2], raw); + } else { + error('Unknown verify subcommand. Available: plan-structure, phase-completeness, references, commits, artifacts, key-links'); } break; } diff --git a/get-shit-done/workflows/execute-plan.md b/get-shit-done/workflows/execute-plan.md index 1c63ce085..648da9bf2 100644 --- a/get-shit-done/workflows/execute-plan.md +++ b/get-shit-done/workflows/execute-plan.md @@ -333,15 +333,45 @@ Next: more plans → "Ready for {next-plan}" | last → "Phase complete, ready f -Update STATE.md: Phase [current]/[total] ([name]) | Plan [completed]/[total] | Status | Last activity: [today] - Completed {phase}-{plan} | Progress bar (█/░). Calculate: (total SUMMARYs / total PLANs) × 100%. +Update STATE.md using gsd-tools: + +```bash +# Advance plan counter (handles last-plan edge case) +node ~/.claude/get-shit-done/bin/gsd-tools.js state advance-plan + +# Recalculate progress bar from disk state +node ~/.claude/get-shit-done/bin/gsd-tools.js state update-progress + +# Record execution metrics +node ~/.claude/get-shit-done/bin/gsd-tools.js state record-metric \ + --phase "${PHASE}" --plan "${PLAN}" --duration "${DURATION}" \ + --tasks "${TASK_COUNT}" --files "${FILE_COUNT}" +``` -From SUMMARY: "Decisions Made" (if not "None") → STATE.md Decisions: `| [phase] | [summary] | [rationale] |`. "Next Phase Readiness" blockers → STATE.md "Blockers/Concerns Carried Forward". +From SUMMARY: Extract decisions and add to STATE.md: + +```bash +# Add each decision from SUMMARY key-decisions +node ~/.claude/get-shit-done/bin/gsd-tools.js state add-decision \ + --phase "${PHASE}" --summary "${DECISION_TEXT}" --rationale "${RATIONALE}" + +# Add blockers if any found +node ~/.claude/get-shit-done/bin/gsd-tools.js state add-blocker "Blocker description" +``` -STATE.md Session: Last session [date/time] | Stopped at: Completed {phase}-{plan} | Resume file: [path or "None"]. Keep STATE.md under 150 lines. +Update session info using gsd-tools: + +```bash +node ~/.claude/get-shit-done/bin/gsd-tools.js state record-session \ + --stopped-at "Completed ${PHASE}-${PLAN}-PLAN.md" \ + --resume-file "None" +``` + +Keep STATE.md under 150 lines. diff --git a/get-shit-done/workflows/verify-phase.md b/get-shit-done/workflows/verify-phase.md index d03685db4..31d658d6a 100644 --- a/get-shit-done/workflows/verify-phase.md +++ b/get-shit-done/workflows/verify-phase.md @@ -46,15 +46,22 @@ Extract **phase goal** from ROADMAP.md (the outcome to verify, not tasks) and ** **Option A: Must-haves in PLAN frontmatter** +Use gsd-tools to extract must_haves from each PLAN: + ```bash -grep -l "must_haves:" "$PHASE_DIR"/*-PLAN.md 2>/dev/null +for plan in "$PHASE_DIR"/*-PLAN.md; do + MUST_HAVES=$(node ~/.claude/get-shit-done/bin/gsd-tools.js frontmatter get "$plan" --field must_haves) + echo "=== $plan ===" && echo "$MUST_HAVES" +done ``` -If found, extract truths, artifacts (with paths), and key_links (from/to/via). +Returns JSON: `{ truths: [...], artifacts: [...], key_links: [...] }` + +Aggregate all must_haves across plans for phase-level verification. **Option B: Derive from phase goal** -If no must_haves in frontmatter: +If no must_haves in frontmatter (MUST_HAVES returns error or empty): 1. State the goal from ROADMAP.md 2. Derive **truths** (3-7 observable behaviors, each testable) 3. Derive **artifacts** (concrete file paths for each truth) @@ -73,20 +80,28 @@ For each truth: identify supporting artifacts → check artifact status → chec -For each required artifact, verify three levels: +Use gsd-tools for artifact verification against must_haves in each PLAN: -**Level 1 — Existence:** File/directory exists. If MISSING → record and continue. +```bash +for plan in "$PHASE_DIR"/*-PLAN.md; do + ARTIFACT_RESULT=$(node ~/.claude/get-shit-done/bin/gsd-tools.js verify artifacts "$plan") + echo "=== $plan ===" && echo "$ARTIFACT_RESULT" +done +``` -**Level 2 — Substantive:** Real implementation, not a stub. -- Line minimums: Component 15+, API route 10+, Hook/util 10+, Schema 5+ -- Stub detection: `TODO|FIXME|placeholder|not implemented|coming soon`, empty returns (`return null|return {}|return []`), placeholder content -- Export check: `export (default )?(function|const|class)` exists -- SUBSTANTIVE = adequate length + no stubs + has exports. STUB = too short OR stub patterns OR no exports. PARTIAL = mixed. +Parse JSON result: `{ all_passed, passed, total, artifacts: [{path, exists, issues, passed}] }` -**Level 3 — Wired:** Connected to the system. -- Import: `grep -r "import.*$artifact_name" src/ --include="*.ts" --include="*.tsx"` → IMPORTED -- Usage: same grep excluding import lines → USED -- WIRED = imported AND used. ORPHANED = exists but not imported/used. PARTIAL = imported but unused. +**Artifact status from result:** +- `exists=false` → MISSING +- `issues` not empty → STUB (check issues for "Only N lines" or "Missing pattern") +- `passed=true` → VERIFIED (Levels 1-2 pass) + +**Level 3 — Wired (manual check for artifacts that pass Levels 1-2):** +```bash +grep -r "import.*$artifact_name" src/ --include="*.ts" --include="*.tsx" # IMPORTED +grep -r "$artifact_name" src/ --include="*.ts" --include="*.tsx" | grep -v "import" # USED +``` +WIRED = imported AND used. ORPHANED = exists but not imported/used. | Exists | Substantive | Wired | Status | |--------|-------------|-------|--------| @@ -97,7 +112,23 @@ For each required artifact, verify three levels: -Key links are critical connections — if broken, goal fails even with all artifacts present. +Use gsd-tools for key link verification against must_haves in each PLAN: + +```bash +for plan in "$PHASE_DIR"/*-PLAN.md; do + LINKS_RESULT=$(node ~/.claude/get-shit-done/bin/gsd-tools.js verify key-links "$plan") + echo "=== $plan ===" && echo "$LINKS_RESULT" +done +``` + +Parse JSON result: `{ all_verified, verified, total, links: [{from, to, via, verified, detail}] }` + +**Link status from result:** +- `verified=true` → WIRED +- `verified=false` with "not found" → NOT_WIRED +- `verified=false` with "Pattern not found" → PARTIAL + +**Fallback patterns (if key_links not in must_haves):** | Pattern | Check | Status | |---------|-------|--------|