Merge branch 'main' into codex/debug-human-verify-gate-630

Resolve CHANGELOG.md conflict — keep both PR entry and v1.20.6 release notes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lex Christopherson
2026-02-23 09:28:03 -06:00
73 changed files with 8749 additions and 7476 deletions

8
.gitignore vendored
View File

@@ -18,3 +18,11 @@ animation/
# Internal planning documents
reports/
RAILROAD_ARCHITECTURE.md
.planning/
analysis/
docs/GSD-MASTER-ARCHITECTURE.md
docs/GSD-RUST-IMPLEMENTATION-GUIDE.md
docs/GSD-SYSTEM-SPECIFICATION.md
gaps.md
improve.md
philosophy.md

View File

@@ -9,6 +9,37 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Changed
- `/gsd:debug` flow now requires a `human-verify` checkpoint after self-verification before marking debug sessions `resolved` and moving files to `.planning/debug/resolved/`
## [1.20.6] - 2025-02-23
### Added
- Context window monitor hook with WARNING/CRITICAL alerts when agent context usage exceeds thresholds
- Nyquist validation layer in plan-phase pipeline to catch quality issues before execution
- Option highlighting and gray area looping in discuss-phase for clearer preference capture
### Changed
- Refactored installer tools into 11 domain modules for maintainability
### Fixed
- Auto-advance chain no longer breaks when skills fail to resolve inside Task subagents
- Gemini CLI workflows and templates no longer incorrectly convert to TOML format
- Universal phase number parsing handles all formats consistently (decimal phases, plain numbers)
## [1.20.5] - 2026-02-19
### Fixed
- `/gsd:health --repair` now creates timestamped backup before regenerating STATE.md (#657)
### Changed
- Subagents now discover and load project CLAUDE.md and skills at spawn time for better project context (#671, #672)
- Improved context loading reliability in spawned agents
## [1.20.4] - 2026-02-17
### Fixed
- Executor agents now update ROADMAP.md and REQUIREMENTS.md after each plan completes — previously both documents stayed unchecked throughout milestone execution
- New `requirements mark-complete` CLI command enables per-plan requirement tracking instead of waiting for phase completion
- Executor final commit includes ROADMAP.md and REQUIREMENTS.md
## [1.20.3] - 2026-02-16
### Fixed
@@ -1306,7 +1337,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- YOLO mode for autonomous execution
- Interactive mode with checkpoints
[Unreleased]: https://github.com/glittercowboy/get-shit-done/compare/v1.20.3...HEAD
[Unreleased]: https://github.com/glittercowboy/get-shit-done/compare/v1.20.6...HEAD
[1.20.6]: https://github.com/glittercowboy/get-shit-done/releases/tag/v1.20.6
[1.20.5]: https://github.com/glittercowboy/get-shit-done/releases/tag/v1.20.5
[1.20.4]: https://github.com/glittercowboy/get-shit-done/releases/tag/v1.20.4
[1.20.3]: https://github.com/glittercowboy/get-shit-done/releases/tag/v1.20.3
[1.20.2]: https://github.com/glittercowboy/get-shit-done/releases/tag/v1.20.2
[1.20.1]: https://github.com/glittercowboy/get-shit-done/releases/tag/v1.20.1

View File

@@ -15,6 +15,9 @@ You are spawned by `/gsd:map-codebase` with one of four focus areas:
- **concerns**: Identify technical debt and issues → write CONCERNS.md
Your job: Explore thoroughly, then write document(s) directly. Return confirmation only.
**CRITICAL: Mandatory Initial Read**
If the prompt contains a `<files_to_read>` block, you MUST use the `Read` tool to load every file listed there before performing any other actions. This is your primary context.
</role>
<why_this_matters>

View File

@@ -15,6 +15,9 @@ You are spawned by:
Your job: Find the root cause through hypothesis testing, maintain debug file state, optionally fix and verify (depending on mode).
**CRITICAL: Mandatory Initial Read**
If the prompt contains a `<files_to_read>` block, you MUST use the `Read` tool to load every file listed there before performing any other actions. This is your primary context.
**Core responsibilities:**
- Investigate autonomously (user reports symptoms, you find cause)
- Maintain persistent debug file state (survives context resets)

View File

@@ -11,8 +11,26 @@ You are a GSD plan executor. You execute PLAN.md files atomically, creating per-
Spawned by `/gsd:execute-phase` orchestrator.
Your job: Execute the plan completely, commit each task, create SUMMARY.md, update STATE.md.
**CRITICAL: Mandatory Initial Read**
If the prompt contains a `<files_to_read>` block, you MUST use the `Read` tool to load every file listed there before performing any other actions. This is your primary context.
</role>
<project_context>
Before executing, discover project context:
**Project instructions:** Read `./CLAUDE.md` if it exists in the working directory. Follow all project-specific guidelines, security requirements, and coding conventions.
**Project skills:** Check `.agents/skills/` directory if it exists:
1. List available skills (subdirectories)
2. Read `SKILL.md` for each skill (lightweight index ~130 lines)
3. Load specific `rules/*.md` files as needed during implementation
4. Do NOT load full `AGENTS.md` files (100KB+ context cost)
5. Follow skill rules relevant to your current task
This ensures project-specific patterns, conventions, and best practices are applied during execution.
</project_context>
<execution_flow>
<step name="load_project_state" priority="first">
@@ -382,12 +400,25 @@ node ~/.claude/get-shit-done/bin/gsd-tools.cjs state record-session \
--stopped-at "Completed ${PHASE}-${PLAN}-PLAN.md"
```
```bash
# Update ROADMAP.md progress for this phase (plan counts, status)
node ~/.claude/get-shit-done/bin/gsd-tools.cjs roadmap update-plan-progress "${PHASE_NUMBER}"
# Mark completed requirements from PLAN.md frontmatter
# Extract the `requirements` array from the plan's frontmatter, then mark each complete
node ~/.claude/get-shit-done/bin/gsd-tools.cjs requirements mark-complete ${REQ_IDS}
```
**Requirement IDs:** Extract from the PLAN.md frontmatter `requirements:` field (e.g., `requirements: [AUTH-01, AUTH-02]`). Pass all IDs to `requirements mark-complete`. If the plan has no requirements field, skip this step.
**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
- `roadmap update-plan-progress`: Updates ROADMAP.md progress table row with PLAN vs SUMMARY counts
- `requirements mark-complete`: Checks off requirement checkboxes and updates traceability table in REQUIREMENTS.md
**Extract decisions from SUMMARY.md:** Parse key-decisions from frontmatter or "Decisions Made" section → add each via `state add-decision`.
@@ -399,7 +430,7 @@ node ~/.claude/get-shit-done/bin/gsd-tools.cjs state add-blocker "Blocker descri
<final_commit>
```bash
node ~/.claude/get-shit-done/bin/gsd-tools.cjs commit "docs({phase}-{plan}): complete [plan-name] plan" --files .planning/phases/XX-name/{phase}-{plan}-SUMMARY.md .planning/STATE.md
node ~/.claude/get-shit-done/bin/gsd-tools.cjs commit "docs({phase}-{plan}): complete [plan-name] plan" --files .planning/phases/XX-name/{phase}-{plan}-SUMMARY.md .planning/STATE.md .planning/ROADMAP.md .planning/REQUIREMENTS.md
```
Separate from per-task commits — captures execution results only.
@@ -432,6 +463,7 @@ Plan execution complete when:
- [ ] Authentication gates handled and documented
- [ ] SUMMARY.md created with substantive content
- [ ] STATE.md updated (position, decisions, issues, session)
- [ ] Final metadata commit made
- [ ] ROADMAP.md updated with plan progress (via `roadmap update-plan-progress`)
- [ ] Final metadata commit made (includes SUMMARY.md, STATE.md, ROADMAP.md)
- [ ] Completion format returned to orchestrator
</success_criteria>

View File

@@ -10,6 +10,9 @@ You are an integration checker. You verify that phases work together as a system
Your job: Check cross-phase wiring (exports used, APIs called, data flows) and verify E2E user flows complete without breaks.
**CRITICAL: Mandatory Initial Read**
If the prompt contains a `<files_to_read>` block, you MUST use the `Read` tool to load every file listed there before performing any other actions. This is your primary context.
**Critical mindset:** Individual phases can pass while the system fails. A component can exist without being imported. An API can exist without being called. Focus on connections, not existence.
</role>

View File

@@ -10,6 +10,9 @@ You are a GSD phase researcher. You answer "What do I need to know to PLAN this
Spawned by `/gsd:plan-phase` (integrated) or `/gsd:research-phase` (standalone).
**CRITICAL: Mandatory Initial Read**
If the prompt contains a `<files_to_read>` block, you MUST use the `Read` tool to load every file listed there before performing any other actions. This is your primary context.
**Core responsibilities:**
- Investigate the phase's technical domain
- Identify standard stack, patterns, and pitfalls
@@ -18,6 +21,21 @@ Spawned by `/gsd:plan-phase` (integrated) or `/gsd:research-phase` (standalone).
- Return structured result to orchestrator
</role>
<project_context>
Before researching, discover project context:
**Project instructions:** Read `./CLAUDE.md` if it exists in the working directory. Follow all project-specific guidelines, security requirements, and coding conventions.
**Project skills:** Check `.agents/skills/` directory if it exists:
1. List available skills (subdirectories)
2. Read `SKILL.md` for each skill (lightweight index ~130 lines)
3. Load specific `rules/*.md` files as needed during research
4. Do NOT load full `AGENTS.md` files (100KB+ context cost)
5. Research should account for project skill patterns
This ensures research aligns with project-specific conventions and libraries.
</project_context>
<upstream_input>
**CONTEXT.md** (if exists) — User decisions from `/gsd:discuss-phase`
@@ -278,6 +296,37 @@ Verified patterns from official sources:
- What's unclear: [the gap]
- Recommendation: [how to handle]
## Validation Architecture
> Skip this section entirely if workflow.nyquist_validation is false in .planning/config.json
### Test Framework
| Property | Value |
|----------|-------|
| Framework | {framework name + version} |
| Config file | {path or "none — see Wave 0"} |
| Quick run command | `{command}` |
| Full suite command | `{command}` |
| Estimated runtime | ~{N} seconds |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| REQ-XX | {behavior description} | unit | `pytest tests/test_{module}.py::test_{name} -x` | ✅ yes / ❌ Wave 0 gap |
### Nyquist Sampling Rate
- **Minimum sample interval:** After every committed task → run: `{quick run command}`
- **Full suite trigger:** Before merging final task of any plan wave
- **Phase-complete gate:** Full suite green before `/gsd:verify-work` runs
- **Estimated feedback latency per task:** ~{N} seconds
### Wave 0 Gaps (must be created before implementation)
- [ ] `{tests/test_file.py}` — covers REQ-{XX}
- [ ] `{tests/conftest.py}` — shared fixtures for phase {N}
- [ ] Framework install: `{command}` — if no framework detected
*(If no gaps: "None — existing test infrastructure covers all phase requirements")*
## Sources
### Primary (HIGH confidence)
@@ -317,6 +366,8 @@ INIT=$(node ~/.claude/get-shit-done/bin/gsd-tools.cjs init phase-op "${PHASE}")
Extract from init JSON: `phase_dir`, `padded_phase`, `phase_number`, `commit_docs`.
Also check Nyquist validation config — read `.planning/config.json` and check if `workflow.nyquist_validation` is `true`. If `true`, include the Validation Architecture section in RESEARCH.md output (scan for test frameworks, map requirements to test types, identify Wave 0 gaps). If `false`, skip the Validation Architecture section entirely and omit it from output.
Then read CONTEXT.md if exists:
```bash
cat "$phase_dir"/*-CONTEXT.md 2>/dev/null
@@ -349,7 +400,33 @@ Based on phase description, identify what needs investigating:
For each domain: Context7 first → Official docs → WebSearch → Cross-verify. Document findings with confidence levels as you go.
## Step 4: Quality Check
## Step 4: Validation Architecture Research (if nyquist_validation enabled)
**Skip this step if** workflow.nyquist_validation is false in config.
This step answers: "How will Claude's executor know, within seconds of committing each task, whether the output is correct?"
### Detect Test Infrastructure
Scan the codebase for test configuration:
- Look for test config files: pytest.ini, pyproject.toml, jest.config.*, vitest.config.*, etc.
- Look for test directories: test/, tests/, __tests__/
- Look for test files: *.test.*, *.spec.*
- Check package.json scripts for test commands
### Map Requirements to Tests
For each requirement in <phase_requirements>:
- Identify the behavior to verify
- Determine test type: unit / integration / contract / smoke / e2e / manual-only
- Specify the automated command to run that test in < 30 seconds
- Flag if only verifiable manually (justify why)
### Identify Wave 0 Gaps
List test files, fixtures, or utilities that must be created BEFORE implementation:
- Missing test files for phase requirements
- Missing test framework configuration
- Missing shared fixtures or test utilities
## Step 5: Quality Check
- [ ] All domains investigated
- [ ] Negative claims verified
@@ -357,7 +434,7 @@ For each domain: Context7 first → Official docs → WebSearch → Cross-verify
- [ ] Confidence levels assigned honestly
- [ ] "What might I have missed?" review
## Step 5: Write RESEARCH.md
## Step 6: Write RESEARCH.md
**ALWAYS use Write tool to persist to disk** — mandatory regardless of `commit_docs` setting.
@@ -396,13 +473,13 @@ Write to: `$PHASE_DIR/$PADDED_PHASE-RESEARCH.md`
⚠️ `commit_docs` controls git only, NOT file writing. Always write first.
## Step 6: Commit Research (optional)
## Step 7: Commit Research (optional)
```bash
node ~/.claude/get-shit-done/bin/gsd-tools.cjs commit "docs($PHASE): research phase domain" --files "$PHASE_DIR/$PADDED_PHASE-RESEARCH.md"
```
## Step 7: Return Structured Result
## Step 8: Return Structured Result
</execution_flow>

View File

@@ -12,6 +12,9 @@ Spawned by `/gsd:plan-phase` orchestrator (after planner creates PLAN.md) or re-
Goal-backward verification of PLANS before execution. Start from what the phase SHOULD deliver, verify plans address it.
**CRITICAL: Mandatory Initial Read**
If the prompt contains a `<files_to_read>` block, you MUST use the `Read` tool to load every file listed there before performing any other actions. This is your primary context.
**Critical mindset:** Plans describe intent. You verify they deliver. A plan can have all tasks filled in but still miss the goal if:
- Key requirements have no tasks
- Tasks exist but don't actually achieve the requirement
@@ -23,6 +26,21 @@ Goal-backward verification of PLANS before execution. Start from what the phase
You are NOT the executor or verifier — you verify plans WILL work before execution burns context.
</role>
<project_context>
Before verifying, discover project context:
**Project instructions:** Read `./CLAUDE.md` if it exists in the working directory. Follow all project-specific guidelines, security requirements, and coding conventions.
**Project skills:** Check `.agents/skills/` directory if it exists:
1. List available skills (subdirectories)
2. Read `SKILL.md` for each skill (lightweight index ~130 lines)
3. Load specific `rules/*.md` files as needed during verification
4. Do NOT load full `AGENTS.md` files (100KB+ context cost)
5. Verify plans account for project skill patterns
This ensures verification checks that plans follow project-specific conventions.
</project_context>
<upstream_input>
**CONTEXT.md** (if exists) — User decisions from `/gsd:discuss-phase`
@@ -294,6 +312,105 @@ issue:
fix_hint: "Remove search task - belongs in future phase per user decision"
```
## Dimension 8: Nyquist Compliance
<dimension_8_skip_condition>
Skip this entire dimension if:
- workflow.nyquist_validation is false in .planning/config.json
- The phase being checked has no RESEARCH.md (researcher was skipped)
- The RESEARCH.md has no "Validation Architecture" section (researcher ran without Nyquist)
If skipped, output: "Dimension 8: SKIPPED (nyquist_validation disabled or not applicable)"
</dimension_8_skip_condition>
<dimension_8_context>
This dimension enforces the Nyquist-Shannon Sampling Theorem for AI code generation:
if Claude's executor produces output at high frequency (one task per commit), feedback
must run at equally high frequency. A plan that produces code without pre-defined
automated verification is under-sampled — errors will be statistically missed.
The gsd-phase-researcher already determined WHAT to test. This dimension verifies
that the planner correctly incorporated that information into the actual task plans.
</dimension_8_context>
### Check 8a — Automated Verify Presence
For EACH `<task>` element in EACH plan file for this phase:
1. Does `<verify>` contain an `<automated>` command (or structured equivalent)?
2. If `<automated>` is absent or empty:
- Is there a Wave 0 dependency that creates the test before this task runs?
- If no Wave 0 dependency exists → **BLOCKING FAIL**
3. If `<automated>` says "MISSING":
- A Wave 0 task must reference the same test file path → verify this link is present
- If the link is broken → **BLOCKING FAIL**
**PASS criteria:** Every task either has an `<automated>` verify command, OR explicitly
references a Wave 0 task that creates the test scaffold it depends on.
### Check 8b — Feedback Latency Assessment
Review each `<automated>` command in the plans:
1. Does the command appear to be a full E2E suite (playwright, cypress, selenium)?
- If yes: **WARNING** (non-blocking) — suggest adding a faster unit/smoke test as primary verify
2. Does the command include `--watchAll` or equivalent watch mode flags?
- If yes: **BLOCKING FAIL** — watch mode is not suitable for CI/post-commit sampling
3. Does the command include `sleep`, `wait`, or arbitrary delays > 30 seconds?
- If yes: **WARNING** — flag as latency risk
### Check 8c — Sampling Continuity
Review ALL tasks across ALL plans for this phase in wave order:
1. Map each task to its wave number
2. For each consecutive window of 3 tasks in the same wave: at least 2 must have
an `<automated>` verify command (not just Wave 0 scaffolding)
3. If any 3 consecutive implementation tasks all lack automated verify: **BLOCKING FAIL**
### Check 8d — Wave 0 Completeness
If any plan contains `<automated>MISSING</automated>` or references Wave 0:
1. Does a Wave 0 task exist for every MISSING reference?
2. Does the Wave 0 task's `<files>` match the path referenced in the MISSING automated command?
3. Is the Wave 0 task in a plan that executes BEFORE the dependent task?
**FAIL condition:** Any MISSING automated verify without a matching Wave 0 task.
### Dimension 8 Output Block
Include this block in the plan-checker report:
```
## Dimension 8: Nyquist Compliance
### Automated Verify Coverage
| Task | Plan | Wave | Automated Command | Latency | Status |
|------|------|------|-------------------|---------|--------|
| {task name} | {plan} | {wave} | `{command}` | ~{N}s | ✅ PASS / ❌ FAIL |
### Sampling Continuity Check
Wave {N}: {X}/{Y} tasks verified → ✅ PASS / ❌ FAIL
### Wave 0 Completeness
- {test file} → Wave 0 task present ✅ / MISSING ❌
### Overall Nyquist Status: ✅ PASS / ❌ FAIL
### Revision Instructions (if FAIL)
Return to planner with the following required changes:
{list of specific fixes needed}
```
### Revision Loop Behavior
If Dimension 8 FAILS:
- Return to `gsd-planner` with the specific revision instructions above
- The planner must address ALL failing checks before returning
- This follows the same loop behavior as existing dimensions
- Maximum 3 revision loops for Dimension 8 before escalating to user
</verification_dimensions>
<verification_process>
@@ -311,6 +428,8 @@ Orchestrator provides CONTEXT.md content in the verification prompt. If provided
```bash
ls "$phase_dir"/*-PLAN.md 2>/dev/null
# Read research for Nyquist validation data
cat "$phase_dir"/*-RESEARCH.md 2>/dev/null
node ~/.claude/get-shit-done/bin/gsd-tools.cjs roadmap get-phase "$phase_number"
ls "$phase_dir"/*-BRIEF.md 2>/dev/null
```

View File

@@ -15,6 +15,9 @@ Spawned by:
Your job: Produce PLAN.md files that Claude executors can implement without interpretation. Plans are prompts, not documents that become prompts.
**CRITICAL: Mandatory Initial Read**
If the prompt contains a `<files_to_read>` block, you MUST use the `Read` tool to load every file listed there before performing any other actions. This is your primary context.
**Core responsibilities:**
- **FIRST: Parse and honor user decisions from CONTEXT.md** (locked decisions are NON-NEGOTIABLE)
- Decompose phases into parallel-optimized plans with 2-3 tasks each
@@ -25,6 +28,21 @@ Your job: Produce PLAN.md files that Claude executors can implement without inte
- Return structured results to orchestrator
</role>
<project_context>
Before planning, discover project context:
**Project instructions:** Read `./CLAUDE.md` if it exists in the working directory. Follow all project-specific guidelines, security requirements, and coding conventions.
**Project skills:** Check `.agents/skills/` directory if it exists:
1. List available skills (subdirectories)
2. Read `SKILL.md` for each skill (lightweight index ~130 lines)
3. Load specific `rules/*.md` files as needed during planning
4. Do NOT load full `AGENTS.md` files (100KB+ context cost)
5. Ensure plans account for project skill patterns and conventions
This ensures task actions reference the correct patterns and libraries for this project.
</project_context>
<context_fidelity>
## CRITICAL: User Decision Fidelity
@@ -139,9 +157,21 @@ Every task has four required fields:
- Good: "Create POST endpoint accepting {email, password}, validates using bcrypt against User table, returns JWT in httpOnly cookie with 15-min expiry. Use jose library (not jsonwebtoken - CommonJS issues with Edge runtime)."
- Bad: "Add authentication", "Make login work"
**<verify>:** How to prove the task is complete.
- Good: `npm test` passes, `curl -X POST /api/auth/login` returns 200 with Set-Cookie header
- Bad: "It works", "Looks good"
**<verify>:** How to prove the task is complete. Supports structured format:
```xml
<verify>
<automated>pytest tests/test_module.py::test_behavior -x</automated>
<manual>Optional: human-readable description of what to check</manual>
<sampling_rate>run after this task commits, before next task begins</sampling_rate>
</verify>
```
- Good: Specific automated command that runs in < 60 seconds
- Bad: "It works", "Looks good", manual-only verification
- Simple format also accepted: `npm test` passes, `curl -X POST /api/auth/login` returns 200 with Set-Cookie header
**Nyquist Rule:** Every `<verify>` must include an `<automated>` command. If no test exists yet for this behavior, set `<automated>MISSING — Wave 0 must create {test_file} first</automated>` and create a Wave 0 task that generates the test scaffold.
**<done>:** Acceptance criteria - measurable state of completion.
- Good: "Valid credentials return 200 + JWT cookie, invalid credentials return 401"

View File

@@ -10,6 +10,9 @@ You are a GSD project researcher spawned by `/gsd:new-project` or `/gsd:new-mile
Answer "What does this domain ecosystem look like?" Write research files in `.planning/research/` that inform roadmap creation.
**CRITICAL: Mandatory Initial Read**
If the prompt contains a `<files_to_read>` block, you MUST use the `Read` tool to load every file listed there before performing any other actions. This is your primary context.
Your files feed the roadmap:
| File | How Roadmap Uses It |

View File

@@ -14,6 +14,9 @@ You are spawned by:
Your job: Create a unified research summary that informs roadmap creation. Extract key findings, identify patterns across research files, and produce roadmap implications.
**CRITICAL: Mandatory Initial Read**
If the prompt contains a `<files_to_read>` block, you MUST use the `Read` tool to load every file listed there before performing any other actions. This is your primary context.
**Core responsibilities:**
- Read all 4 research files (STACK.md, FEATURES.md, ARCHITECTURE.md, PITFALLS.md)
- Synthesize findings into executive summary

View File

@@ -14,6 +14,9 @@ You are spawned by:
Your job: Transform requirements into a phase structure that delivers the project. Every v1 requirement maps to exactly one phase. Every phase has observable success criteria.
**CRITICAL: Mandatory Initial Read**
If the prompt contains a `<files_to_read>` block, you MUST use the `Read` tool to load every file listed there before performing any other actions. This is your primary context.
**Core responsibilities:**
- Derive phases from requirements (not impose arbitrary structure)
- Validate 100% requirement coverage (no orphans)

View File

@@ -10,6 +10,9 @@ You are a GSD phase verifier. You verify that a phase achieved its GOAL, not jus
Your job: Goal-backward verification. Start from what the phase SHOULD deliver, verify it actually exists and works in the codebase.
**CRITICAL: Mandatory Initial Read**
If the prompt contains a `<files_to_read>` block, you MUST use the `Read` tool to load every file listed there before performing any other actions. This is your primary context.
**Critical mindset:** Do NOT trust SUMMARY.md claims. SUMMARYs document what Claude SAID it did. You verify what ACTUALLY exists in the code. These often differ.
</role>

View File

@@ -680,7 +680,7 @@ function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) {
* @param {string} pathPrefix - Path prefix for file references
* @param {string} runtime - Target runtime ('claude', 'opencode', 'gemini')
*/
function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime) {
function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand = false) {
const isOpencode = runtime === 'opencode';
const dirName = getDirName(runtime);
@@ -697,7 +697,7 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime) {
const destPath = path.join(destDir, entry.name);
if (entry.isDirectory()) {
copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime);
copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime, isCommand);
} else if (entry.name.endsWith('.md')) {
// Replace ~/.claude/ and ./.claude/ with runtime-appropriate paths
let content = fs.readFileSync(srcPath, 'utf8');
@@ -712,12 +712,16 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime) {
content = convertClaudeToOpencodeFrontmatter(content);
fs.writeFileSync(destPath, content);
} else if (runtime === 'gemini') {
// Convert to TOML for Gemini (strip <sub> tags — terminals can't render subscript)
content = stripSubTags(content);
const tomlContent = convertClaudeToGeminiToml(content);
// Replace extension with .toml
const tomlPath = destPath.replace(/\.md$/, '.toml');
fs.writeFileSync(tomlPath, tomlContent);
if (isCommand) {
// Convert to TOML for Gemini (strip <sub> tags — terminals can't render subscript)
content = stripSubTags(content);
const tomlContent = convertClaudeToGeminiToml(content);
// Replace extension with .toml
const tomlPath = destPath.replace(/\.md$/, '.toml');
fs.writeFileSync(tomlPath, tomlContent);
} else {
fs.writeFileSync(destPath, content);
}
} else {
fs.writeFileSync(destPath, content);
}
@@ -888,7 +892,7 @@ function uninstall(isGlobal, runtime = 'claude') {
// 4. Remove GSD hooks
const hooksDir = path.join(targetDir, 'hooks');
if (fs.existsSync(hooksDir)) {
const gsdHooks = ['gsd-statusline.js', 'gsd-check-update.js', 'gsd-check-update.sh'];
const gsdHooks = ['gsd-statusline.js', 'gsd-check-update.js', 'gsd-check-update.sh', 'gsd-context-monitor.js'];
let hookCount = 0;
for (const hook of gsdHooks) {
const hookPath = path.join(hooksDir, hook);
@@ -954,10 +958,32 @@ function uninstall(isGlobal, runtime = 'claude') {
if (settings.hooks.SessionStart.length === 0) {
delete settings.hooks.SessionStart;
}
// Clean up empty hooks object
if (Object.keys(settings.hooks).length === 0) {
delete settings.hooks;
}
// Remove GSD hooks from PostToolUse
if (settings.hooks && settings.hooks.PostToolUse) {
const before = settings.hooks.PostToolUse.length;
settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(entry => {
if (entry.hooks && Array.isArray(entry.hooks)) {
const hasGsdHook = entry.hooks.some(h =>
h.command && h.command.includes('gsd-context-monitor')
);
return !hasGsdHook;
}
return true;
});
if (settings.hooks.PostToolUse.length < before) {
settingsModified = true;
console.log(` ${green}✓${reset} Removed context monitor hook from settings`);
}
if (settings.hooks.PostToolUse.length === 0) {
delete settings.hooks.PostToolUse;
}
}
// Clean up empty hooks object
if (settings.hooks && Object.keys(settings.hooks).length === 0) {
delete settings.hooks;
}
if (settingsModified) {
@@ -1386,7 +1412,7 @@ function install(isGlobal, runtime = 'claude') {
const gsdSrc = path.join(src, 'commands', 'gsd');
const gsdDest = path.join(commandsDir, 'gsd');
copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime);
copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime, true);
if (verifyInstalled(gsdDest, 'commands/gsd')) {
console.log(` ${green}✓${reset} Installed commands/gsd`);
} else {
@@ -1516,6 +1542,9 @@ function install(isGlobal, runtime = 'claude') {
const updateCheckCommand = isGlobal
? buildHookCommand(targetDir, 'gsd-check-update.js')
: 'node ' + dirName + '/hooks/gsd-check-update.js';
const contextMonitorCommand = isGlobal
? buildHookCommand(targetDir, 'gsd-context-monitor.js')
: 'node ' + dirName + '/hooks/gsd-context-monitor.js';
// Enable experimental agents for Gemini CLI (required for custom sub-agents)
if (isGemini) {
@@ -1552,6 +1581,27 @@ function install(isGlobal, runtime = 'claude') {
});
console.log(` ${green}✓${reset} Configured update check hook`);
}
// Configure PostToolUse hook for context window monitoring
if (!settings.hooks.PostToolUse) {
settings.hooks.PostToolUse = [];
}
const hasContextMonitorHook = settings.hooks.PostToolUse.some(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-context-monitor'))
);
if (!hasContextMonitorHook) {
settings.hooks.PostToolUse.push({
hooks: [
{
type: 'command',
command: contextMonitorCommand
}
]
});
console.log(` ${green}✓${reset} Configured context window monitor hook`);
}
}
// Write file manifest for future modification detection

View File

@@ -19,11 +19,15 @@ Routes to the add-phase workflow which handles:
</objective>
<execution_context>
@.planning/ROADMAP.md
@.planning/STATE.md
@~/.claude/get-shit-done/workflows/add-phase.md
</execution_context>
<context>
Arguments: $ARGUMENTS (phase description)
Roadmap and state are resolved in-workflow via `init phase-op` and targeted tool calls.
</context>
<process>
**Follow the add-phase workflow** from `@~/.claude/get-shit-done/workflows/add-phase.md`.

View File

@@ -23,10 +23,15 @@ Routes to the add-todo workflow which handles:
</objective>
<execution_context>
@.planning/STATE.md
@~/.claude/get-shit-done/workflows/add-todo.md
</execution_context>
<context>
Arguments: $ARGUMENTS (optional todo description)
State is resolved in-workflow via `init todos` and targeted reads.
</context>
<process>
**Follow the add-todo workflow** from `@~/.claude/get-shit-done/workflows/add-todo.md`.

View File

@@ -23,13 +23,7 @@ Verify milestone achieved its definition of done. Check requirements coverage, c
<context>
Version: $ARGUMENTS (optional — defaults to current milestone)
**Original Intent:**
@.planning/PROJECT.md
@.planning/REQUIREMENTS.md
**Planned Work:**
@.planning/ROADMAP.md
@.planning/config.json (if exists)
Core planning files are resolved in-workflow (`init milestone-op`) and loaded only as needed.
**Completed Work:**
Glob: .planning/phases/*/*-SUMMARY.md

View File

@@ -21,11 +21,15 @@ Routes to the check-todos workflow which handles:
</objective>
<execution_context>
@.planning/STATE.md
@.planning/ROADMAP.md
@~/.claude/get-shit-done/workflows/check-todos.md
</execution_context>
<context>
Arguments: $ARGUMENTS (optional area filter)
Todo state and roadmap correlation are loaded in-workflow using `init todos` and targeted reads.
</context>
<process>
**Follow the check-todos workflow** from `@~/.claude/get-shit-done/workflows/check-todos.md`.

View File

@@ -132,7 +132,9 @@ Continue debugging {slug}. Evidence is in the debug file.
</objective>
<prior_state>
Debug file: @.planning/debug/{slug}.md
<files_to_read>
- .planning/debug/{slug}.md (Debug session state)
</files_to_read>
</prior_state>
<checkpoint_response>

View File

@@ -32,11 +32,7 @@ Extract implementation decisions that downstream agents need — researcher and
<context>
Phase number: $ARGUMENTS (required)
**Load project state:**
@.planning/STATE.md
**Load roadmap:**
@.planning/ROADMAP.md
Context files are resolved in-workflow using `init phase-op` and roadmap/state tool calls.
</context>
<process>

View File

@@ -32,8 +32,7 @@ Phase: $ARGUMENTS
**Flags:**
- `--gaps-only` — Execute only gap closure plans (plans with `gap_closure: true` in frontmatter). Use after verify-work creates fix plans.
@.planning/ROADMAP.md
@.planning/STATE.md
Context files are resolved inside the workflow via `gsd-tools init execute-phase` and per-subagent `<files_to_read>` blocks.
</context>
<process>

View File

@@ -23,8 +23,7 @@ Purpose: Handle urgent work discovered during execution without renumbering enti
<context>
Arguments: $ARGUMENTS (format: <after-phase-number> <description>)
@.planning/ROADMAP.md
@.planning/STATE.md
Roadmap and state are resolved in-workflow via `init phase-op` and targeted tool calls.
</context>
<process>

View File

@@ -23,11 +23,7 @@ Output: Conversational output only (no file creation) - ends with "What do you t
<context>
Phase number: $ARGUMENTS (required)
**Load project state first:**
@.planning/STATE.md
**Load roadmap:**
@.planning/ROADMAP.md
Project state and roadmap are loaded in-workflow using targeted reads.
</context>
<process>

View File

@@ -35,14 +35,7 @@ Brownfield equivalent of new-project. Project exists, PROJECT.md has history. Ga
<context>
Milestone name: $ARGUMENTS (optional - will prompt if not provided)
**Load project context:**
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/MILESTONES.md
@.planning/config.json
**Load milestone context (if exists, from /gsd:discuss-milestone):**
@.planning/MILESTONE-CONTEXT.md
Project and milestone context files are resolved inside the workflow (`init new-milestone`) and delegated via `<files_to_read>` blocks where subagents are used.
</context>
<process>

View File

@@ -19,10 +19,13 @@ Routes to the pause-work workflow which handles:
</objective>
<execution_context>
@.planning/STATE.md
@~/.claude/get-shit-done/workflows/pause-work.md
</execution_context>
<context>
State and phase progress are gathered in-workflow with targeted reads.
</context>
<process>
**Follow the pause-work workflow** from `@~/.claude/get-shit-done/workflows/pause-work.md`.

View File

@@ -25,13 +25,7 @@ One command creates all fix phases — no manual `/gsd:add-phase` per gap.
**Audit results:**
Glob: .planning/v*-MILESTONE-AUDIT.md (use most recent)
**Original intent (for prioritization):**
@.planning/PROJECT.md
@.planning/REQUIREMENTS.md
**Current state:**
@.planning/ROADMAP.md
@.planning/STATE.md
Original intent and current planning state are loaded on demand inside the workflow.
</context>
<process>

View File

@@ -30,8 +30,9 @@ Quick mode is the same system with a shorter path:
</execution_context>
<context>
@.planning/STATE.md
$ARGUMENTS
Context files are resolved inside the workflow (`init quick`) and delegated via `<files_to_read>` blocks.
</context>
<process>

View File

@@ -22,8 +22,7 @@ Output: Phase deleted, all subsequent phases renumbered, git commit as historica
<context>
Phase: $ARGUMENTS
@.planning/ROADMAP.md
@.planning/STATE.md
Roadmap and state are resolved in-workflow via `init phase-op` and targeted reads.
</context>
<process>

View File

@@ -37,7 +37,7 @@ Normalize phase input in step 1 before any directory lookups.
INIT=$(node ~/.claude/get-shit-done/bin/gsd-tools.cjs init phase-op "$ARGUMENTS")
```
Extract from init JSON: `phase_dir`, `phase_number`, `phase_name`, `phase_found`, `commit_docs`, `has_research`.
Extract from init JSON: `phase_dir`, `phase_number`, `phase_name`, `phase_found`, `commit_docs`, `has_research`, `state_path`, `requirements_path`, `context_path`, `research_path`.
Resolve researcher model:
```bash
@@ -64,15 +64,12 @@ ls .planning/phases/${PHASE}-*/RESEARCH.md 2>/dev/null
## 3. Gather Phase Context
```bash
# Phase section already loaded in PHASE_INFO
echo "$PHASE_INFO" | jq -r '.section'
cat .planning/REQUIREMENTS.md 2>/dev/null
cat .planning/phases/${PHASE}-*/*-CONTEXT.md 2>/dev/null
grep -A30 "### Decisions Made" .planning/STATE.md 2>/dev/null
```
Use paths from INIT (do not inline file contents in orchestrator context):
- `requirements_path`
- `context_path`
- `state_path`
Present summary with phase description, requirements, prior decisions.
Present summary with phase description and what files the researcher will load.
## 4. Spawn gsd-phase-researcher Agent
@@ -101,12 +98,15 @@ Research implementation approach for Phase {phase_number}: {phase_name}
Mode: ecosystem
</objective>
<context>
<files_to_read>
- {requirements_path} (Requirements)
- {context_path} (Phase context from discuss-phase, if exists)
- {state_path} (Prior project decisions and blockers)
</files_to_read>
<additional_context>
**Phase description:** {phase_description}
**Requirements:** {requirements_list}
**Prior decisions:** {decisions_if_any}
**Phase context:** {context_md_content}
</context>
</additional_context>
<downstream_consumer>
Your RESEARCH.md will be loaded by `/gsd:plan-phase` which uses specific sections:
@@ -158,7 +158,9 @@ Continue research for Phase {phase_number}: {phase_name}
</objective>
<prior_state>
Research file: @.planning/phases/${PHASE}-{slug}/${PHASE}-RESEARCH.md
<files_to_read>
- .planning/phases/${PHASE}-{slug}/${PHASE}-RESEARCH.md (Existing research)
</files_to_read>
</prior_state>
<checkpoint_response>

View File

@@ -29,8 +29,7 @@ Phase: $ARGUMENTS (optional)
- If provided: Test specific phase (e.g., "4")
- If not provided: Check for active sessions or prompt for phase
@.planning/STATE.md
@.planning/ROADMAP.md
Context files are resolved inside the workflow (`init verify-work`) and delegated via `<files_to_read>` blocks.
</context>
<process>

View File

@@ -97,6 +97,24 @@ A detailed reference for workflows, troubleshooting, and configuration. For quic
└── Done
```
### Validation Architecture (Nyquist Layer)
During plan-phase research, GSD now maps automated test coverage to each phase
requirement before any code is written. This ensures that when Claude's executor
commits a task, a feedback mechanism already exists to verify it within seconds.
The researcher detects your existing test infrastructure, maps each requirement to
a specific test command, and identifies any test scaffolding that must be created
before implementation begins (Wave 0 tasks).
The plan-checker enforces this as an 8th verification dimension: plans where tasks
lack automated verify commands will not be approved.
**Output:** `{phase}-VALIDATION.md` -- the feedback contract for the phase.
**Disable:** Set `workflow.nyquist_validation: false` in `/gsd:settings` for
rapid prototyping phases where test infrastructure isn't the focus.
### Execution Wave Coordination
```
@@ -206,7 +224,8 @@ GSD stores project settings in `.planning/config.json`. Configure during `/gsd:n
"workflow": {
"research": true,
"plan_check": true,
"verifier": true
"verifier": true,
"nyquist_validation": true
},
"git": {
"branching_strategy": "none",
@@ -240,6 +259,7 @@ GSD stores project settings in `.planning/config.json`. Configure during `/gsd:n
| `workflow.research` | `true`, `false` | `true` | Domain investigation before planning |
| `workflow.plan_check` | `true`, `false` | `true` | Plan verification loop (up to 3 iterations) |
| `workflow.verifier` | `true`, `false` | `true` | Post-execution verification against phase goals |
| `workflow.nyquist_validation` | `true`, `false` | `true` | Validation architecture research during plan-phase; 8th plan-check dimension |
Disable these to speed up phases in familiar domains or when conserving tokens.

96
docs/context-monitor.md Normal file
View File

@@ -0,0 +1,96 @@
# Context Window Monitor
A PostToolUse hook that warns the agent when context window usage is high.
## Problem
The statusline shows context usage to the **user**, but the **agent** has no awareness of context limits. When context runs low, the agent continues working until it hits the wall — potentially mid-task with no state saved.
## How It Works
1. The statusline hook writes context metrics to `/tmp/claude-ctx-{session_id}.json`
2. After each tool use, the context monitor reads these metrics
3. When remaining context drops below thresholds, it injects a warning as `additionalContext`
4. The agent receives the warning in its conversation and can act accordingly
## Thresholds
| Level | Remaining | Agent Behavior |
|-------|-----------|----------------|
| Normal | > 35% | No warning |
| WARNING | <= 35% | Wrap up current task, avoid starting new complex work |
| CRITICAL | <= 25% | Stop immediately, save state (`/gsd:pause-work`) |
## Debounce
To avoid spamming the agent with repeated warnings:
- First warning always fires immediately
- Subsequent warnings require 5 tool uses between them
- Severity escalation (WARNING -> CRITICAL) bypasses debounce
## Architecture
```
Statusline Hook (gsd-statusline.js)
| writes
v
/tmp/claude-ctx-{session_id}.json
^ reads
|
Context Monitor (gsd-context-monitor.js, PostToolUse)
| injects
v
additionalContext -> Agent sees warning
```
The bridge file is a simple JSON object:
```json
{
"session_id": "abc123",
"remaining_percentage": 28.5,
"used_pct": 71,
"timestamp": 1708200000
}
```
## Integration with GSD
GSD's `/gsd:pause-work` command saves execution state. The WARNING message suggests using it. The CRITICAL message instructs immediate state save.
## Setup
Both hooks are automatically registered during `npx get-shit-done-cc` installation:
- **Statusline** (writes bridge file): Registered as `statusLine` in settings.json
- **Context Monitor** (reads bridge file): Registered as `PostToolUse` hook in settings.json
Manual registration in `~/.claude/settings.json`:
```json
{
"statusLine": {
"type": "command",
"command": "node ~/.claude/hooks/gsd-statusline.js"
},
"hooks": {
"PostToolUse": [
{
"hooks": [
{
"type": "command",
"command": "node ~/.claude/hooks/gsd-context-monitor.js"
}
]
}
]
}
}
```
## Safety
- The hook wraps everything in try/catch and exits silently on error
- It never blocks tool execution — a broken monitor should not break the agent's workflow
- Stale metrics (older than 60s) are ignored
- Missing bridge files are handled gracefully (subagents, fresh sessions)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,556 @@
/**
* Commands — Standalone utility commands
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const { safeReadFile, loadConfig, isGitIgnored, execGit, normalizePhaseName, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, resolveModelInternal, MODEL_PROFILES, output, error, findPhaseInternal } = require('./core.cjs');
const { extractFrontmatter } = require('./frontmatter.cjs');
function cmdGenerateSlug(text, raw) {
if (!text) {
error('text required for slug generation');
}
const slug = text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
const result = { slug };
output(result, raw, slug);
}
function cmdCurrentTimestamp(format, raw) {
const now = new Date();
let result;
switch (format) {
case 'date':
result = now.toISOString().split('T')[0];
break;
case 'filename':
result = now.toISOString().replace(/:/g, '-').replace(/\..+/, '');
break;
case 'full':
default:
result = now.toISOString();
break;
}
output({ timestamp: result }, raw, result);
}
function cmdListTodos(cwd, area, raw) {
const pendingDir = path.join(cwd, '.planning', 'todos', 'pending');
let count = 0;
const todos = [];
try {
const files = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md'));
for (const file of files) {
try {
const content = fs.readFileSync(path.join(pendingDir, file), 'utf-8');
const createdMatch = content.match(/^created:\s*(.+)$/m);
const titleMatch = content.match(/^title:\s*(.+)$/m);
const areaMatch = content.match(/^area:\s*(.+)$/m);
const todoArea = areaMatch ? areaMatch[1].trim() : 'general';
// Apply area filter if specified
if (area && todoArea !== area) continue;
count++;
todos.push({
file,
created: createdMatch ? createdMatch[1].trim() : 'unknown',
title: titleMatch ? titleMatch[1].trim() : 'Untitled',
area: todoArea,
path: path.join('.planning', 'todos', 'pending', file),
});
} catch {}
}
} catch {}
const result = { count, todos };
output(result, raw, count.toString());
}
function cmdVerifyPathExists(cwd, targetPath, raw) {
if (!targetPath) {
error('path required for verification');
}
const fullPath = path.isAbsolute(targetPath) ? targetPath : path.join(cwd, targetPath);
try {
const stats = fs.statSync(fullPath);
const type = stats.isDirectory() ? 'directory' : stats.isFile() ? 'file' : 'other';
const result = { exists: true, type };
output(result, raw, 'true');
} catch {
const result = { exists: false, type: null };
output(result, raw, 'false');
}
}
function cmdHistoryDigest(cwd, raw) {
const phasesDir = path.join(cwd, '.planning', 'phases');
const digest = { phases: {}, decisions: [], tech_stack: new Set() };
// Collect all phase directories: archived + current
const allPhaseDirs = [];
// Add archived phases first (oldest milestones first)
const archived = getArchivedPhaseDirs(cwd);
for (const a of archived) {
allPhaseDirs.push({ name: a.name, fullPath: a.fullPath, milestone: a.milestone });
}
// Add current phases
if (fs.existsSync(phasesDir)) {
try {
const currentDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
.sort();
for (const dir of currentDirs) {
allPhaseDirs.push({ name: dir, fullPath: path.join(phasesDir, dir), milestone: null });
}
} catch {}
}
if (allPhaseDirs.length === 0) {
digest.tech_stack = [];
output(digest, raw);
return;
}
try {
for (const { name: dir, fullPath: dirPath } of allPhaseDirs) {
const summaries = fs.readdirSync(dirPath).filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
for (const summary of summaries) {
try {
const content = fs.readFileSync(path.join(dirPath, summary), 'utf-8');
const fm = extractFrontmatter(content);
const phaseNum = fm.phase || dir.split('-')[0];
if (!digest.phases[phaseNum]) {
digest.phases[phaseNum] = {
name: fm.name || dir.split('-').slice(1).join(' ') || 'Unknown',
provides: new Set(),
affects: new Set(),
patterns: new Set(),
};
}
// Merge provides
if (fm['dependency-graph'] && fm['dependency-graph'].provides) {
fm['dependency-graph'].provides.forEach(p => digest.phases[phaseNum].provides.add(p));
} else if (fm.provides) {
fm.provides.forEach(p => digest.phases[phaseNum].provides.add(p));
}
// Merge affects
if (fm['dependency-graph'] && fm['dependency-graph'].affects) {
fm['dependency-graph'].affects.forEach(a => digest.phases[phaseNum].affects.add(a));
}
// Merge patterns
if (fm['patterns-established']) {
fm['patterns-established'].forEach(p => digest.phases[phaseNum].patterns.add(p));
}
// Merge decisions
if (fm['key-decisions']) {
fm['key-decisions'].forEach(d => {
digest.decisions.push({ phase: phaseNum, decision: d });
});
}
// Merge tech stack
if (fm['tech-stack'] && fm['tech-stack'].added) {
fm['tech-stack'].added.forEach(t => digest.tech_stack.add(typeof t === 'string' ? t : t.name));
}
} catch (e) {
// Skip malformed summaries
}
}
}
// Convert Sets to Arrays for JSON output
Object.keys(digest.phases).forEach(p => {
digest.phases[p].provides = [...digest.phases[p].provides];
digest.phases[p].affects = [...digest.phases[p].affects];
digest.phases[p].patterns = [...digest.phases[p].patterns];
});
digest.tech_stack = [...digest.tech_stack];
output(digest, raw);
} catch (e) {
error('Failed to generate history digest: ' + e.message);
}
}
function cmdResolveModel(cwd, agentType, raw) {
if (!agentType) {
error('agent-type required');
}
const config = loadConfig(cwd);
const profile = config.model_profile || 'balanced';
const agentModels = MODEL_PROFILES[agentType];
if (!agentModels) {
const result = { model: 'sonnet', profile, unknown_agent: true };
output(result, raw, 'sonnet');
return;
}
const resolved = agentModels[profile] || agentModels['balanced'] || 'sonnet';
const model = resolved === 'opus' ? 'inherit' : resolved;
const result = { model, profile };
output(result, raw, model);
}
function cmdCommit(cwd, message, files, raw, amend) {
if (!message && !amend) {
error('commit message required');
}
const config = loadConfig(cwd);
// Check commit_docs config
if (!config.commit_docs) {
const result = { committed: false, hash: null, reason: 'skipped_commit_docs_false' };
output(result, raw, 'skipped');
return;
}
// Check if .planning is gitignored
if (isGitIgnored(cwd, '.planning')) {
const result = { committed: false, hash: null, reason: 'skipped_gitignored' };
output(result, raw, 'skipped');
return;
}
// Stage files
const filesToStage = files && files.length > 0 ? files : ['.planning/'];
for (const file of filesToStage) {
execGit(cwd, ['add', file]);
}
// Commit
const commitArgs = amend ? ['commit', '--amend', '--no-edit'] : ['commit', '-m', message];
const commitResult = execGit(cwd, commitArgs);
if (commitResult.exitCode !== 0) {
if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
const result = { committed: false, hash: null, reason: 'nothing_to_commit' };
output(result, raw, 'nothing');
return;
}
const result = { committed: false, hash: null, reason: 'nothing_to_commit', error: commitResult.stderr };
output(result, raw, 'nothing');
return;
}
// Get short hash
const hashResult = execGit(cwd, ['rev-parse', '--short', 'HEAD']);
const hash = hashResult.exitCode === 0 ? hashResult.stdout : null;
const result = { committed: true, hash, reason: 'committed' };
output(result, raw, hash || 'committed');
}
function cmdSummaryExtract(cwd, summaryPath, fields, raw) {
if (!summaryPath) {
error('summary-path required for summary-extract');
}
const fullPath = path.join(cwd, summaryPath);
if (!fs.existsSync(fullPath)) {
output({ error: 'File not found', path: summaryPath }, raw);
return;
}
const content = fs.readFileSync(fullPath, 'utf-8');
const fm = extractFrontmatter(content);
// Parse key-decisions into structured format
const parseDecisions = (decisionsList) => {
if (!decisionsList || !Array.isArray(decisionsList)) return [];
return decisionsList.map(d => {
const colonIdx = d.indexOf(':');
if (colonIdx > 0) {
return {
summary: d.substring(0, colonIdx).trim(),
rationale: d.substring(colonIdx + 1).trim(),
};
}
return { summary: d, rationale: null };
});
};
// Build full result
const fullResult = {
path: summaryPath,
one_liner: fm['one-liner'] || null,
key_files: fm['key-files'] || [],
tech_added: (fm['tech-stack'] && fm['tech-stack'].added) || [],
patterns: fm['patterns-established'] || [],
decisions: parseDecisions(fm['key-decisions']),
};
// If fields specified, filter to only those fields
if (fields && fields.length > 0) {
const filtered = { path: summaryPath };
for (const field of fields) {
if (fullResult[field] !== undefined) {
filtered[field] = fullResult[field];
}
}
output(filtered, raw);
return;
}
output(fullResult, raw);
}
async function cmdWebsearch(query, options, raw) {
const apiKey = process.env.BRAVE_API_KEY;
if (!apiKey) {
// No key = silent skip, agent falls back to built-in WebSearch
output({ available: false, reason: 'BRAVE_API_KEY not set' }, raw, '');
return;
}
if (!query) {
output({ available: false, error: 'Query required' }, raw, '');
return;
}
const params = new URLSearchParams({
q: query,
count: String(options.limit || 10),
country: 'us',
search_lang: 'en',
text_decorations: 'false'
});
if (options.freshness) {
params.set('freshness', options.freshness);
}
try {
const response = await fetch(
`https://api.search.brave.com/res/v1/web/search?${params}`,
{
headers: {
'Accept': 'application/json',
'X-Subscription-Token': apiKey
}
}
);
if (!response.ok) {
output({ available: false, error: `API error: ${response.status}` }, raw, '');
return;
}
const data = await response.json();
const results = (data.web?.results || []).map(r => ({
title: r.title,
url: r.url,
description: r.description,
age: r.age || null
}));
output({
available: true,
query,
count: results.length,
results
}, raw, results.map(r => `${r.title}\n${r.url}\n${r.description}`).join('\n\n'));
} catch (err) {
output({ available: false, error: err.message }, raw, '');
}
}
function cmdProgressRender(cwd, format, raw) {
const phasesDir = path.join(cwd, '.planning', 'phases');
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
const milestone = getMilestoneInfo(cwd);
const phases = [];
let totalPlans = 0;
let totalSummaries = 0;
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => {
const aNum = parseFloat(a.match(/^(\d+(?:\.\d+)?)/)?.[1] || '0');
const bNum = parseFloat(b.match(/^(\d+(?:\.\d+)?)/)?.[1] || '0');
return aNum - bNum;
});
for (const dir of dirs) {
const dm = dir.match(/^(\d+(?:\.\d+)?)-?(.*)/);
const phaseNum = dm ? dm[1] : dir;
const phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
totalPlans += plans;
totalSummaries += summaries;
let status;
if (plans === 0) status = 'Pending';
else if (summaries >= plans) status = 'Complete';
else if (summaries > 0) status = 'In Progress';
else status = 'Planned';
phases.push({ number: phaseNum, name: phaseName, plans, summaries, status });
}
} catch {}
const percent = totalPlans > 0 ? Math.round((totalSummaries / totalPlans) * 100) : 0;
if (format === 'table') {
// Render markdown table
const barWidth = 10;
const filled = Math.round((percent / 100) * barWidth);
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
let out = `# ${milestone.version} ${milestone.name}\n\n`;
out += `**Progress:** [${bar}] ${totalSummaries}/${totalPlans} plans (${percent}%)\n\n`;
out += `| Phase | Name | Plans | Status |\n`;
out += `|-------|------|-------|--------|\n`;
for (const p of phases) {
out += `| ${p.number} | ${p.name} | ${p.summaries}/${p.plans} | ${p.status} |\n`;
}
output({ rendered: out }, raw, out);
} else if (format === 'bar') {
const barWidth = 20;
const filled = Math.round((percent / 100) * barWidth);
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
const text = `[${bar}] ${totalSummaries}/${totalPlans} plans (${percent}%)`;
output({ bar: text, percent, completed: totalSummaries, total: totalPlans }, raw, text);
} else {
// JSON format
output({
milestone_version: milestone.version,
milestone_name: milestone.name,
phases,
total_plans: totalPlans,
total_summaries: totalSummaries,
percent,
}, raw);
}
}
function cmdTodoComplete(cwd, filename, raw) {
if (!filename) {
error('filename required for todo complete');
}
const pendingDir = path.join(cwd, '.planning', 'todos', 'pending');
const completedDir = path.join(cwd, '.planning', 'todos', 'completed');
const sourcePath = path.join(pendingDir, filename);
if (!fs.existsSync(sourcePath)) {
error(`Todo not found: ${filename}`);
}
// Ensure completed directory exists
fs.mkdirSync(completedDir, { recursive: true });
// Read, add completion timestamp, move
let content = fs.readFileSync(sourcePath, 'utf-8');
const today = new Date().toISOString().split('T')[0];
content = `completed: ${today}\n` + content;
fs.writeFileSync(path.join(completedDir, filename), content, 'utf-8');
fs.unlinkSync(sourcePath);
output({ completed: true, file: filename, date: today }, raw, 'completed');
}
function cmdScaffold(cwd, type, options, raw) {
const { phase, name } = options;
const padded = phase ? normalizePhaseName(phase) : '00';
const today = new Date().toISOString().split('T')[0];
// Find phase directory
const phaseInfo = phase ? findPhaseInternal(cwd, phase) : null;
const phaseDir = phaseInfo ? path.join(cwd, phaseInfo.directory) : null;
if (phase && !phaseDir && type !== 'phase-dir') {
error(`Phase ${phase} directory not found`);
}
let filePath, content;
switch (type) {
case 'context': {
filePath = path.join(phaseDir, `${padded}-CONTEXT.md`);
content = `---\nphase: "${padded}"\nname: "${name || phaseInfo?.phase_name || 'Unnamed'}"\ncreated: ${today}\n---\n\n# Phase ${phase}: ${name || phaseInfo?.phase_name || 'Unnamed'} — Context\n\n## Decisions\n\n_Decisions will be captured during /gsd:discuss-phase ${phase}_\n\n## Discretion Areas\n\n_Areas where the executor can use judgment_\n\n## Deferred Ideas\n\n_Ideas to consider later_\n`;
break;
}
case 'uat': {
filePath = path.join(phaseDir, `${padded}-UAT.md`);
content = `---\nphase: "${padded}"\nname: "${name || phaseInfo?.phase_name || 'Unnamed'}"\ncreated: ${today}\nstatus: pending\n---\n\n# Phase ${phase}: ${name || phaseInfo?.phase_name || 'Unnamed'} — User Acceptance Testing\n\n## Test Results\n\n| # | Test | Status | Notes |\n|---|------|--------|-------|\n\n## Summary\n\n_Pending UAT_\n`;
break;
}
case 'verification': {
filePath = path.join(phaseDir, `${padded}-VERIFICATION.md`);
content = `---\nphase: "${padded}"\nname: "${name || phaseInfo?.phase_name || 'Unnamed'}"\ncreated: ${today}\nstatus: pending\n---\n\n# Phase ${phase}: ${name || phaseInfo?.phase_name || 'Unnamed'} — Verification\n\n## Goal-Backward Verification\n\n**Phase Goal:** [From ROADMAP.md]\n\n## Checks\n\n| # | Requirement | Status | Evidence |\n|---|------------|--------|----------|\n\n## Result\n\n_Pending verification_\n`;
break;
}
case 'phase-dir': {
if (!phase || !name) {
error('phase and name required for phase-dir scaffold');
}
const slug = generateSlugInternal(name);
const dirName = `${padded}-${slug}`;
const phasesParent = path.join(cwd, '.planning', 'phases');
fs.mkdirSync(phasesParent, { recursive: true });
const dirPath = path.join(phasesParent, dirName);
fs.mkdirSync(dirPath, { recursive: true });
output({ created: true, directory: `.planning/phases/${dirName}`, path: dirPath }, raw, dirPath);
return;
}
default:
error(`Unknown scaffold type: ${type}. Available: context, uat, verification, phase-dir`);
}
if (fs.existsSync(filePath)) {
output({ created: false, reason: 'already_exists', path: filePath }, raw, 'exists');
return;
}
fs.writeFileSync(filePath, content, 'utf-8');
const relPath = path.relative(cwd, filePath);
output({ created: true, path: relPath }, raw, relPath);
}
module.exports = {
cmdGenerateSlug,
cmdCurrentTimestamp,
cmdListTodos,
cmdVerifyPathExists,
cmdHistoryDigest,
cmdResolveModel,
cmdCommit,
cmdSummaryExtract,
cmdWebsearch,
cmdProgressRender,
cmdTodoComplete,
cmdScaffold,
};

View File

@@ -0,0 +1,162 @@
/**
* Config — Planning config CRUD operations
*/
const fs = require('fs');
const path = require('path');
const { output, error } = require('./core.cjs');
function cmdConfigEnsureSection(cwd, raw) {
const configPath = path.join(cwd, '.planning', 'config.json');
const planningDir = path.join(cwd, '.planning');
// Ensure .planning directory exists
try {
if (!fs.existsSync(planningDir)) {
fs.mkdirSync(planningDir, { recursive: true });
}
} catch (err) {
error('Failed to create .planning directory: ' + err.message);
}
// Check if config already exists
if (fs.existsSync(configPath)) {
const result = { created: false, reason: 'already_exists' };
output(result, raw, 'exists');
return;
}
// Detect Brave Search API key availability
const homedir = require('os').homedir();
const braveKeyFile = path.join(homedir, '.gsd', 'brave_api_key');
const hasBraveSearch = !!(process.env.BRAVE_API_KEY || fs.existsSync(braveKeyFile));
// Load user-level defaults from ~/.gsd/defaults.json if available
const globalDefaultsPath = path.join(homedir, '.gsd', 'defaults.json');
let userDefaults = {};
try {
if (fs.existsSync(globalDefaultsPath)) {
userDefaults = JSON.parse(fs.readFileSync(globalDefaultsPath, 'utf-8'));
}
} catch (err) {
// Ignore malformed global defaults, fall back to hardcoded
}
// Create default config (user-level defaults override hardcoded defaults)
const hardcoded = {
model_profile: 'balanced',
commit_docs: true,
search_gitignored: false,
branching_strategy: 'none',
phase_branch_template: 'gsd/phase-{phase}-{slug}',
milestone_branch_template: 'gsd/{milestone}-{slug}',
workflow: {
research: true,
plan_check: true,
verifier: true,
nyquist_validation: false,
},
parallelization: true,
brave_search: hasBraveSearch,
};
const defaults = {
...hardcoded,
...userDefaults,
workflow: { ...hardcoded.workflow, ...(userDefaults.workflow || {}) },
};
try {
fs.writeFileSync(configPath, JSON.stringify(defaults, null, 2), 'utf-8');
const result = { created: true, path: '.planning/config.json' };
output(result, raw, 'created');
} catch (err) {
error('Failed to create config.json: ' + err.message);
}
}
function cmdConfigSet(cwd, keyPath, value, raw) {
const configPath = path.join(cwd, '.planning', 'config.json');
if (!keyPath) {
error('Usage: config-set <key.path> <value>');
}
// Parse value (handle booleans and numbers)
let parsedValue = value;
if (value === 'true') parsedValue = true;
else if (value === 'false') parsedValue = false;
else if (!isNaN(value) && value !== '') parsedValue = Number(value);
// Load existing config or start with empty object
let config = {};
try {
if (fs.existsSync(configPath)) {
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
}
} catch (err) {
error('Failed to read config.json: ' + err.message);
}
// Set nested value using dot notation (e.g., "workflow.research")
const keys = keyPath.split('.');
let current = config;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (current[key] === undefined || typeof current[key] !== 'object') {
current[key] = {};
}
current = current[key];
}
current[keys[keys.length - 1]] = parsedValue;
// Write back
try {
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
const result = { updated: true, key: keyPath, value: parsedValue };
output(result, raw, `${keyPath}=${parsedValue}`);
} catch (err) {
error('Failed to write config.json: ' + err.message);
}
}
function cmdConfigGet(cwd, keyPath, raw) {
const configPath = path.join(cwd, '.planning', 'config.json');
if (!keyPath) {
error('Usage: config-get <key.path>');
}
let config = {};
try {
if (fs.existsSync(configPath)) {
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
} else {
error('No config.json found at ' + configPath);
}
} catch (err) {
if (err.message.startsWith('No config.json')) throw err;
error('Failed to read config.json: ' + err.message);
}
// Traverse dot-notation path (e.g., "workflow.auto_advance")
const keys = keyPath.split('.');
let current = config;
for (const key of keys) {
if (current === undefined || current === null || typeof current !== 'object') {
error(`Key not found: ${keyPath}`);
}
current = current[key];
}
if (current === undefined) {
error(`Key not found: ${keyPath}`);
}
output(current, raw, String(current));
}
module.exports = {
cmdConfigEnsureSection,
cmdConfigSet,
cmdConfigGet,
};

View File

@@ -0,0 +1,398 @@
/**
* Core — Shared utilities, constants, and internal helpers
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
// ─── Model Profile Table ─────────────────────────────────────────────────────
const MODEL_PROFILES = {
'gsd-planner': { quality: 'opus', balanced: 'opus', budget: 'sonnet' },
'gsd-roadmapper': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet' },
'gsd-executor': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet' },
'gsd-phase-researcher': { quality: 'opus', balanced: 'sonnet', budget: 'haiku' },
'gsd-project-researcher': { quality: 'opus', balanced: 'sonnet', budget: 'haiku' },
'gsd-research-synthesizer': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' },
'gsd-debugger': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet' },
'gsd-codebase-mapper': { quality: 'sonnet', balanced: 'haiku', budget: 'haiku' },
'gsd-verifier': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' },
'gsd-plan-checker': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' },
'gsd-integration-checker': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' },
};
// ─── Output helpers ───────────────────────────────────────────────────────────
function output(result, raw, rawValue) {
if (raw && rawValue !== undefined) {
process.stdout.write(String(rawValue));
} else {
const json = JSON.stringify(result, null, 2);
// Large payloads exceed Claude Code's Bash tool buffer (~50KB).
// Write to tmpfile and output the path prefixed with @file: so callers can detect it.
if (json.length > 50000) {
const tmpPath = path.join(require('os').tmpdir(), `gsd-${Date.now()}.json`);
fs.writeFileSync(tmpPath, json, 'utf-8');
process.stdout.write('@file:' + tmpPath);
} else {
process.stdout.write(json);
}
}
process.exit(0);
}
function error(message) {
process.stderr.write('Error: ' + message + '\n');
process.exit(1);
}
// ─── File & Config utilities ──────────────────────────────────────────────────
function safeReadFile(filePath) {
try {
return fs.readFileSync(filePath, 'utf-8');
} catch {
return null;
}
}
function loadConfig(cwd) {
const configPath = path.join(cwd, '.planning', 'config.json');
const defaults = {
model_profile: 'balanced',
commit_docs: true,
search_gitignored: false,
branching_strategy: 'none',
phase_branch_template: 'gsd/phase-{phase}-{slug}',
milestone_branch_template: 'gsd/{milestone}-{slug}',
research: true,
plan_checker: true,
verifier: true,
parallelization: true,
brave_search: false,
};
try {
const raw = fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(raw);
const get = (key, nested) => {
if (parsed[key] !== undefined) return parsed[key];
if (nested && parsed[nested.section] && parsed[nested.section][nested.field] !== undefined) {
return parsed[nested.section][nested.field];
}
return undefined;
};
const parallelization = (() => {
const val = get('parallelization');
if (typeof val === 'boolean') return val;
if (typeof val === 'object' && val !== null && 'enabled' in val) return val.enabled;
return defaults.parallelization;
})();
return {
model_profile: get('model_profile') ?? defaults.model_profile,
commit_docs: get('commit_docs', { section: 'planning', field: 'commit_docs' }) ?? defaults.commit_docs,
search_gitignored: get('search_gitignored', { section: 'planning', field: 'search_gitignored' }) ?? defaults.search_gitignored,
branching_strategy: get('branching_strategy', { section: 'git', field: 'branching_strategy' }) ?? defaults.branching_strategy,
phase_branch_template: get('phase_branch_template', { section: 'git', field: 'phase_branch_template' }) ?? defaults.phase_branch_template,
milestone_branch_template: get('milestone_branch_template', { section: 'git', field: 'milestone_branch_template' }) ?? defaults.milestone_branch_template,
research: get('research', { section: 'workflow', field: 'research' }) ?? defaults.research,
plan_checker: get('plan_checker', { section: 'workflow', field: 'plan_check' }) ?? defaults.plan_checker,
verifier: get('verifier', { section: 'workflow', field: 'verifier' }) ?? defaults.verifier,
parallelization,
brave_search: get('brave_search') ?? defaults.brave_search,
};
} catch {
return defaults;
}
}
// ─── Git utilities ────────────────────────────────────────────────────────────
function isGitIgnored(cwd, targetPath) {
try {
execSync('git check-ignore -q -- ' + targetPath.replace(/[^a-zA-Z0-9._\-/]/g, ''), {
cwd,
stdio: 'pipe',
});
return true;
} catch {
return false;
}
}
function execGit(cwd, args) {
try {
const escaped = args.map(a => {
if (/^[a-zA-Z0-9._\-/=:@]+$/.test(a)) return a;
return "'" + a.replace(/'/g, "'\\''") + "'";
});
const stdout = execSync('git ' + escaped.join(' '), {
cwd,
stdio: 'pipe',
encoding: 'utf-8',
});
return { exitCode: 0, stdout: stdout.trim(), stderr: '' };
} catch (err) {
return {
exitCode: err.status ?? 1,
stdout: (err.stdout ?? '').toString().trim(),
stderr: (err.stderr ?? '').toString().trim(),
};
}
}
// ─── Phase utilities ──────────────────────────────────────────────────────────
function normalizePhaseName(phase) {
const match = phase.match(/^(\d+)([A-Z])?(\.\d+)?/i);
if (!match) return phase;
const padded = match[1].padStart(2, '0');
const letter = match[2] ? match[2].toUpperCase() : '';
const decimal = match[3] || '';
return padded + letter + decimal;
}
function comparePhaseNum(a, b) {
const pa = String(a).match(/^(\d+)([A-Z])?(\.\d+)?/i);
const pb = String(b).match(/^(\d+)([A-Z])?(\.\d+)?/i);
if (!pa || !pb) return String(a).localeCompare(String(b));
const intDiff = parseInt(pa[1], 10) - parseInt(pb[1], 10);
if (intDiff !== 0) return intDiff;
// No letter sorts before letter: 12 < 12A < 12B
const la = (pa[2] || '').toUpperCase();
const lb = (pb[2] || '').toUpperCase();
if (la !== lb) {
if (!la) return -1;
if (!lb) return 1;
return la < lb ? -1 : 1;
}
// No decimal sorts before decimal: 12A < 12A.1 < 12A.2
const da = pa[3] ? parseFloat(pa[3]) : -1;
const db = pb[3] ? parseFloat(pb[3]) : -1;
return da - db;
}
function searchPhaseInDir(baseDir, relBase, normalized) {
try {
const entries = fs.readdirSync(baseDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
const match = dirs.find(d => d.startsWith(normalized));
if (!match) return null;
const dirMatch = match.match(/^(\d+[A-Z]?(?:\.\d+)?)-?(.*)/i);
const phaseNumber = dirMatch ? dirMatch[1] : normalized;
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
const phaseDir = path.join(baseDir, match);
const phaseFiles = fs.readdirSync(phaseDir);
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').sort();
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').sort();
const hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
const hasContext = phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md');
const hasVerification = phaseFiles.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md');
const completedPlanIds = new Set(
summaries.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''))
);
const incompletePlans = plans.filter(p => {
const planId = p.replace('-PLAN.md', '').replace('PLAN.md', '');
return !completedPlanIds.has(planId);
});
return {
found: true,
directory: path.join(relBase, match),
phase_number: phaseNumber,
phase_name: phaseName,
phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null,
plans,
summaries,
incomplete_plans: incompletePlans,
has_research: hasResearch,
has_context: hasContext,
has_verification: hasVerification,
};
} catch {
return null;
}
}
function findPhaseInternal(cwd, phase) {
if (!phase) return null;
const phasesDir = path.join(cwd, '.planning', 'phases');
const normalized = normalizePhaseName(phase);
// Search current phases first
const current = searchPhaseInDir(phasesDir, path.join('.planning', 'phases'), normalized);
if (current) return current;
// Search archived milestone phases (newest first)
const milestonesDir = path.join(cwd, '.planning', 'milestones');
if (!fs.existsSync(milestonesDir)) return null;
try {
const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
const archiveDirs = milestoneEntries
.filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
.map(e => e.name)
.sort()
.reverse();
for (const archiveName of archiveDirs) {
const version = archiveName.match(/^(v[\d.]+)-phases$/)[1];
const archivePath = path.join(milestonesDir, archiveName);
const relBase = path.join('.planning', 'milestones', archiveName);
const result = searchPhaseInDir(archivePath, relBase, normalized);
if (result) {
result.archived = version;
return result;
}
}
} catch {}
return null;
}
function getArchivedPhaseDirs(cwd) {
const milestonesDir = path.join(cwd, '.planning', 'milestones');
const results = [];
if (!fs.existsSync(milestonesDir)) return results;
try {
const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
// Find v*-phases directories, sort newest first
const phaseDirs = milestoneEntries
.filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
.map(e => e.name)
.sort()
.reverse();
for (const archiveName of phaseDirs) {
const version = archiveName.match(/^(v[\d.]+)-phases$/)[1];
const archivePath = path.join(milestonesDir, archiveName);
const entries = fs.readdirSync(archivePath, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
for (const dir of dirs) {
results.push({
name: dir,
milestone: version,
basePath: path.join('.planning', 'milestones', archiveName),
fullPath: path.join(archivePath, dir),
});
}
}
} catch {}
return results;
}
// ─── Roadmap & model utilities ────────────────────────────────────────────────
function getRoadmapPhaseInternal(cwd, phaseNum) {
if (!phaseNum) return null;
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
if (!fs.existsSync(roadmapPath)) return null;
try {
const content = fs.readFileSync(roadmapPath, 'utf-8');
const escapedPhase = phaseNum.toString().replace(/\./g, '\\.');
const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`, 'i');
const headerMatch = content.match(phasePattern);
if (!headerMatch) return null;
const phaseName = headerMatch[1].trim();
const headerIndex = headerMatch.index;
const restOfContent = content.slice(headerIndex);
const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i);
const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index : content.length;
const section = content.slice(headerIndex, sectionEnd).trim();
const goalMatch = section.match(/\*\*Goal:\*\*\s*([^\n]+)/i);
const goal = goalMatch ? goalMatch[1].trim() : null;
return {
found: true,
phase_number: phaseNum.toString(),
phase_name: phaseName,
goal,
section,
};
} catch {
return null;
}
}
function resolveModelInternal(cwd, agentType) {
const config = loadConfig(cwd);
// Check per-agent override first
const override = config.model_overrides?.[agentType];
if (override) {
return override === 'opus' ? 'inherit' : override;
}
// Fall back to profile lookup
const profile = config.model_profile || 'balanced';
const agentModels = MODEL_PROFILES[agentType];
if (!agentModels) return 'sonnet';
const resolved = agentModels[profile] || agentModels['balanced'] || 'sonnet';
return resolved === 'opus' ? 'inherit' : resolved;
}
// ─── Misc utilities ───────────────────────────────────────────────────────────
function pathExistsInternal(cwd, targetPath) {
const fullPath = path.isAbsolute(targetPath) ? targetPath : path.join(cwd, targetPath);
try {
fs.statSync(fullPath);
return true;
} catch {
return false;
}
}
function generateSlugInternal(text) {
if (!text) return null;
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}
function getMilestoneInfo(cwd) {
try {
const roadmap = fs.readFileSync(path.join(cwd, '.planning', 'ROADMAP.md'), 'utf-8');
const versionMatch = roadmap.match(/v(\d+\.\d+)/);
const nameMatch = roadmap.match(/## .*v\d+\.\d+[:\s]+([^\n(]+)/);
return {
version: versionMatch ? versionMatch[0] : 'v1.0',
name: nameMatch ? nameMatch[1].trim() : 'milestone',
};
} catch {
return { version: 'v1.0', name: 'milestone' };
}
}
module.exports = {
MODEL_PROFILES,
output,
error,
safeReadFile,
loadConfig,
isGitIgnored,
execGit,
normalizePhaseName,
comparePhaseNum,
searchPhaseInDir,
findPhaseInternal,
getArchivedPhaseDirs,
getRoadmapPhaseInternal,
resolveModelInternal,
pathExistsInternal,
generateSlugInternal,
getMilestoneInfo,
};

View File

@@ -0,0 +1,299 @@
/**
* Frontmatter — YAML frontmatter parsing, serialization, and CRUD commands
*/
const fs = require('fs');
const path = require('path');
const { safeReadFile, output, error } = require('./core.cjs');
// ─── Parsing engine ───────────────────────────────────────────────────────────
function extractFrontmatter(content) {
const frontmatter = {};
const match = content.match(/^---\n([\s\S]+?)\n---/);
if (!match) return frontmatter;
const yaml = match[1];
const lines = yaml.split('\n');
// Stack to track nested objects: [{obj, key, indent}]
// obj = object to write to, key = current key collecting array items, indent = indentation level
let stack = [{ obj: frontmatter, key: null, indent: -1 }];
for (const line of lines) {
// Skip empty lines
if (line.trim() === '') continue;
// Calculate indentation (number of leading spaces)
const indentMatch = line.match(/^(\s*)/);
const indent = indentMatch ? indentMatch[1].length : 0;
// Pop stack back to appropriate level
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
stack.pop();
}
const current = stack[stack.length - 1];
// Check for key: value pattern
const keyMatch = line.match(/^(\s*)([a-zA-Z0-9_-]+):\s*(.*)/);
if (keyMatch) {
const key = keyMatch[2];
const value = keyMatch[3].trim();
if (value === '' || value === '[') {
// Key with no value or opening bracket — could be nested object or array
// We'll determine based on next lines, for now create placeholder
current.obj[key] = value === '[' ? [] : {};
current.key = null;
// Push new context for potential nested content
stack.push({ obj: current.obj[key], key: null, indent });
} else if (value.startsWith('[') && value.endsWith(']')) {
// Inline array: key: [a, b, c]
current.obj[key] = value.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
current.key = null;
} else {
// Simple key: value
current.obj[key] = value.replace(/^["']|["']$/g, '');
current.key = null;
}
} else if (line.trim().startsWith('- ')) {
// Array item
const itemValue = line.trim().slice(2).replace(/^["']|["']$/g, '');
// If current context is an empty object, convert to array
if (typeof current.obj === 'object' && !Array.isArray(current.obj) && Object.keys(current.obj).length === 0) {
// Find the key in parent that points to this object and convert it
const parent = stack.length > 1 ? stack[stack.length - 2] : null;
if (parent) {
for (const k of Object.keys(parent.obj)) {
if (parent.obj[k] === current.obj) {
parent.obj[k] = [itemValue];
current.obj = parent.obj[k];
break;
}
}
}
} else if (Array.isArray(current.obj)) {
current.obj.push(itemValue);
}
}
}
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;
}
// ─── Frontmatter CRUD commands ────────────────────────────────────────────────
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 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');
}
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');
}
module.exports = {
extractFrontmatter,
reconstructFrontmatter,
spliceFrontmatter,
parseMustHavesBlock,
FRONTMATTER_SCHEMAS,
cmdFrontmatterGet,
cmdFrontmatterSet,
cmdFrontmatterMerge,
cmdFrontmatterValidate,
};

View File

@@ -0,0 +1,694 @@
/**
* Init — Compound init commands for workflow bootstrapping
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, normalizePhaseName, output, error } = require('./core.cjs');
function cmdInitExecutePhase(cwd, phase, raw) {
if (!phase) {
error('phase required for init execute-phase');
}
const config = loadConfig(cwd);
const phaseInfo = findPhaseInternal(cwd, phase);
const milestone = getMilestoneInfo(cwd);
const result = {
// Models
executor_model: resolveModelInternal(cwd, 'gsd-executor'),
verifier_model: resolveModelInternal(cwd, 'gsd-verifier'),
// Config flags
commit_docs: config.commit_docs,
parallelization: config.parallelization,
branching_strategy: config.branching_strategy,
phase_branch_template: config.phase_branch_template,
milestone_branch_template: config.milestone_branch_template,
verifier_enabled: config.verifier,
// Phase info
phase_found: !!phaseInfo,
phase_dir: phaseInfo?.directory || null,
phase_number: phaseInfo?.phase_number || null,
phase_name: phaseInfo?.phase_name || null,
phase_slug: phaseInfo?.phase_slug || null,
// Plan inventory
plans: phaseInfo?.plans || [],
summaries: phaseInfo?.summaries || [],
incomplete_plans: phaseInfo?.incomplete_plans || [],
plan_count: phaseInfo?.plans?.length || 0,
incomplete_count: phaseInfo?.incomplete_plans?.length || 0,
// Branch name (pre-computed)
branch_name: config.branching_strategy === 'phase' && phaseInfo
? config.phase_branch_template
.replace('{phase}', phaseInfo.phase_number)
.replace('{slug}', phaseInfo.phase_slug || 'phase')
: config.branching_strategy === 'milestone'
? config.milestone_branch_template
.replace('{milestone}', milestone.version)
.replace('{slug}', generateSlugInternal(milestone.name) || 'milestone')
: null,
// Milestone info
milestone_version: milestone.version,
milestone_name: milestone.name,
milestone_slug: generateSlugInternal(milestone.name),
// File existence
state_exists: pathExistsInternal(cwd, '.planning/STATE.md'),
roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'),
config_exists: pathExistsInternal(cwd, '.planning/config.json'),
// File paths
state_path: '.planning/STATE.md',
roadmap_path: '.planning/ROADMAP.md',
config_path: '.planning/config.json',
};
output(result, raw);
}
function cmdInitPlanPhase(cwd, phase, raw) {
if (!phase) {
error('phase required for init plan-phase');
}
const config = loadConfig(cwd);
const phaseInfo = findPhaseInternal(cwd, phase);
const result = {
// Models
researcher_model: resolveModelInternal(cwd, 'gsd-phase-researcher'),
planner_model: resolveModelInternal(cwd, 'gsd-planner'),
checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'),
// Workflow flags
research_enabled: config.research,
plan_checker_enabled: config.plan_checker,
nyquist_validation_enabled: config.nyquist_validation,
commit_docs: config.commit_docs,
// Phase info
phase_found: !!phaseInfo,
phase_dir: phaseInfo?.directory || null,
phase_number: phaseInfo?.phase_number || null,
phase_name: phaseInfo?.phase_name || null,
phase_slug: phaseInfo?.phase_slug || null,
padded_phase: phaseInfo?.phase_number?.padStart(2, '0') || null,
// Existing artifacts
has_research: phaseInfo?.has_research || false,
has_context: phaseInfo?.has_context || false,
has_plans: (phaseInfo?.plans?.length || 0) > 0,
plan_count: phaseInfo?.plans?.length || 0,
// Environment
planning_exists: pathExistsInternal(cwd, '.planning'),
roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'),
// File paths
state_path: '.planning/STATE.md',
roadmap_path: '.planning/ROADMAP.md',
requirements_path: '.planning/REQUIREMENTS.md',
};
if (phaseInfo?.directory) {
// Find *-CONTEXT.md in phase directory
const phaseDirFull = path.join(cwd, phaseInfo.directory);
try {
const files = fs.readdirSync(phaseDirFull);
const contextFile = files.find(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md');
if (contextFile) {
result.context_path = path.join(phaseInfo.directory, contextFile);
}
const researchFile = files.find(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
if (researchFile) {
result.research_path = path.join(phaseInfo.directory, researchFile);
}
const verificationFile = files.find(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md');
if (verificationFile) {
result.verification_path = path.join(phaseInfo.directory, verificationFile);
}
const uatFile = files.find(f => f.endsWith('-UAT.md') || f === 'UAT.md');
if (uatFile) {
result.uat_path = path.join(phaseInfo.directory, uatFile);
}
} catch {}
}
output(result, raw);
}
function cmdInitNewProject(cwd, raw) {
const config = loadConfig(cwd);
// Detect Brave Search API key availability
const homedir = require('os').homedir();
const braveKeyFile = path.join(homedir, '.gsd', 'brave_api_key');
const hasBraveSearch = !!(process.env.BRAVE_API_KEY || fs.existsSync(braveKeyFile));
// Detect existing code
let hasCode = false;
let hasPackageFile = false;
try {
const files = execSync('find . -maxdepth 3 \\( -name "*.ts" -o -name "*.js" -o -name "*.py" -o -name "*.go" -o -name "*.rs" -o -name "*.swift" -o -name "*.java" \\) 2>/dev/null | grep -v node_modules | grep -v .git | head -5', {
cwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
hasCode = files.trim().length > 0;
} catch {}
hasPackageFile = pathExistsInternal(cwd, 'package.json') ||
pathExistsInternal(cwd, 'requirements.txt') ||
pathExistsInternal(cwd, 'Cargo.toml') ||
pathExistsInternal(cwd, 'go.mod') ||
pathExistsInternal(cwd, 'Package.swift');
const result = {
// Models
researcher_model: resolveModelInternal(cwd, 'gsd-project-researcher'),
synthesizer_model: resolveModelInternal(cwd, 'gsd-research-synthesizer'),
roadmapper_model: resolveModelInternal(cwd, 'gsd-roadmapper'),
// Config
commit_docs: config.commit_docs,
// Existing state
project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
has_codebase_map: pathExistsInternal(cwd, '.planning/codebase'),
planning_exists: pathExistsInternal(cwd, '.planning'),
// Brownfield detection
has_existing_code: hasCode,
has_package_file: hasPackageFile,
is_brownfield: hasCode || hasPackageFile,
needs_codebase_map: (hasCode || hasPackageFile) && !pathExistsInternal(cwd, '.planning/codebase'),
// Git state
has_git: pathExistsInternal(cwd, '.git'),
// Enhanced search
brave_search_available: hasBraveSearch,
// File paths
project_path: '.planning/PROJECT.md',
};
output(result, raw);
}
function cmdInitNewMilestone(cwd, raw) {
const config = loadConfig(cwd);
const milestone = getMilestoneInfo(cwd);
const result = {
// Models
researcher_model: resolveModelInternal(cwd, 'gsd-project-researcher'),
synthesizer_model: resolveModelInternal(cwd, 'gsd-research-synthesizer'),
roadmapper_model: resolveModelInternal(cwd, 'gsd-roadmapper'),
// Config
commit_docs: config.commit_docs,
research_enabled: config.research,
// Current milestone
current_milestone: milestone.version,
current_milestone_name: milestone.name,
// File existence
project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'),
state_exists: pathExistsInternal(cwd, '.planning/STATE.md'),
// File paths
project_path: '.planning/PROJECT.md',
roadmap_path: '.planning/ROADMAP.md',
state_path: '.planning/STATE.md',
};
output(result, raw);
}
function cmdInitQuick(cwd, description, raw) {
const config = loadConfig(cwd);
const now = new Date();
const slug = description ? generateSlugInternal(description)?.substring(0, 40) : null;
// Find next quick task number
const quickDir = path.join(cwd, '.planning', 'quick');
let nextNum = 1;
try {
const existing = fs.readdirSync(quickDir)
.filter(f => /^\d+-/.test(f))
.map(f => parseInt(f.split('-')[0], 10))
.filter(n => !isNaN(n));
if (existing.length > 0) {
nextNum = Math.max(...existing) + 1;
}
} catch {}
const result = {
// Models
planner_model: resolveModelInternal(cwd, 'gsd-planner'),
executor_model: resolveModelInternal(cwd, 'gsd-executor'),
checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'),
verifier_model: resolveModelInternal(cwd, 'gsd-verifier'),
// Config
commit_docs: config.commit_docs,
// Quick task info
next_num: nextNum,
slug: slug,
description: description || null,
// Timestamps
date: now.toISOString().split('T')[0],
timestamp: now.toISOString(),
// Paths
quick_dir: '.planning/quick',
task_dir: slug ? `.planning/quick/${nextNum}-${slug}` : null,
// File existence
roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'),
planning_exists: pathExistsInternal(cwd, '.planning'),
};
output(result, raw);
}
function cmdInitResume(cwd, raw) {
const config = loadConfig(cwd);
// Check for interrupted agent
let interruptedAgentId = null;
try {
interruptedAgentId = fs.readFileSync(path.join(cwd, '.planning', 'current-agent-id.txt'), 'utf-8').trim();
} catch {}
const result = {
// File existence
state_exists: pathExistsInternal(cwd, '.planning/STATE.md'),
roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'),
project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
planning_exists: pathExistsInternal(cwd, '.planning'),
// File paths
state_path: '.planning/STATE.md',
roadmap_path: '.planning/ROADMAP.md',
project_path: '.planning/PROJECT.md',
// Agent state
has_interrupted_agent: !!interruptedAgentId,
interrupted_agent_id: interruptedAgentId,
// Config
commit_docs: config.commit_docs,
};
output(result, raw);
}
function cmdInitVerifyWork(cwd, phase, raw) {
if (!phase) {
error('phase required for init verify-work');
}
const config = loadConfig(cwd);
const phaseInfo = findPhaseInternal(cwd, phase);
const result = {
// Models
planner_model: resolveModelInternal(cwd, 'gsd-planner'),
checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'),
// Config
commit_docs: config.commit_docs,
// Phase info
phase_found: !!phaseInfo,
phase_dir: phaseInfo?.directory || null,
phase_number: phaseInfo?.phase_number || null,
phase_name: phaseInfo?.phase_name || null,
// Existing artifacts
has_verification: phaseInfo?.has_verification || false,
};
output(result, raw);
}
function cmdInitPhaseOp(cwd, phase, raw) {
const config = loadConfig(cwd);
let phaseInfo = findPhaseInternal(cwd, phase);
// Fallback to ROADMAP.md if no directory exists (e.g., Plans: TBD)
if (!phaseInfo) {
const roadmapPhase = getRoadmapPhaseInternal(cwd, phase);
if (roadmapPhase?.found) {
const phaseName = roadmapPhase.phase_name;
phaseInfo = {
found: true,
directory: null,
phase_number: roadmapPhase.phase_number,
phase_name: phaseName,
phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null,
plans: [],
summaries: [],
incomplete_plans: [],
has_research: false,
has_context: false,
has_verification: false,
};
}
}
const result = {
// Config
commit_docs: config.commit_docs,
brave_search: config.brave_search,
// Phase info
phase_found: !!phaseInfo,
phase_dir: phaseInfo?.directory || null,
phase_number: phaseInfo?.phase_number || null,
phase_name: phaseInfo?.phase_name || null,
phase_slug: phaseInfo?.phase_slug || null,
padded_phase: phaseInfo?.phase_number?.padStart(2, '0') || null,
// Existing artifacts
has_research: phaseInfo?.has_research || false,
has_context: phaseInfo?.has_context || false,
has_plans: (phaseInfo?.plans?.length || 0) > 0,
has_verification: phaseInfo?.has_verification || false,
plan_count: phaseInfo?.plans?.length || 0,
// File existence
roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'),
planning_exists: pathExistsInternal(cwd, '.planning'),
// File paths
state_path: '.planning/STATE.md',
roadmap_path: '.planning/ROADMAP.md',
requirements_path: '.planning/REQUIREMENTS.md',
};
if (phaseInfo?.directory) {
const phaseDirFull = path.join(cwd, phaseInfo.directory);
try {
const files = fs.readdirSync(phaseDirFull);
const contextFile = files.find(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md');
if (contextFile) {
result.context_path = path.join(phaseInfo.directory, contextFile);
}
const researchFile = files.find(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
if (researchFile) {
result.research_path = path.join(phaseInfo.directory, researchFile);
}
const verificationFile = files.find(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md');
if (verificationFile) {
result.verification_path = path.join(phaseInfo.directory, verificationFile);
}
const uatFile = files.find(f => f.endsWith('-UAT.md') || f === 'UAT.md');
if (uatFile) {
result.uat_path = path.join(phaseInfo.directory, uatFile);
}
} catch {}
}
output(result, raw);
}
function cmdInitTodos(cwd, area, raw) {
const config = loadConfig(cwd);
const now = new Date();
// List todos (reuse existing logic)
const pendingDir = path.join(cwd, '.planning', 'todos', 'pending');
let count = 0;
const todos = [];
try {
const files = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md'));
for (const file of files) {
try {
const content = fs.readFileSync(path.join(pendingDir, file), 'utf-8');
const createdMatch = content.match(/^created:\s*(.+)$/m);
const titleMatch = content.match(/^title:\s*(.+)$/m);
const areaMatch = content.match(/^area:\s*(.+)$/m);
const todoArea = areaMatch ? areaMatch[1].trim() : 'general';
if (area && todoArea !== area) continue;
count++;
todos.push({
file,
created: createdMatch ? createdMatch[1].trim() : 'unknown',
title: titleMatch ? titleMatch[1].trim() : 'Untitled',
area: todoArea,
path: path.join('.planning', 'todos', 'pending', file),
});
} catch {}
}
} catch {}
const result = {
// Config
commit_docs: config.commit_docs,
// Timestamps
date: now.toISOString().split('T')[0],
timestamp: now.toISOString(),
// Todo inventory
todo_count: count,
todos,
area_filter: area || null,
// Paths
pending_dir: '.planning/todos/pending',
completed_dir: '.planning/todos/completed',
// File existence
planning_exists: pathExistsInternal(cwd, '.planning'),
todos_dir_exists: pathExistsInternal(cwd, '.planning/todos'),
pending_dir_exists: pathExistsInternal(cwd, '.planning/todos/pending'),
};
output(result, raw);
}
function cmdInitMilestoneOp(cwd, raw) {
const config = loadConfig(cwd);
const milestone = getMilestoneInfo(cwd);
// Count phases
let phaseCount = 0;
let completedPhases = 0;
const phasesDir = path.join(cwd, '.planning', 'phases');
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
phaseCount = dirs.length;
// Count phases with summaries (completed)
for (const dir of dirs) {
try {
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
const hasSummary = phaseFiles.some(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
if (hasSummary) completedPhases++;
} catch {}
}
} catch {}
// Check archive
const archiveDir = path.join(cwd, '.planning', 'archive');
let archivedMilestones = [];
try {
archivedMilestones = fs.readdirSync(archiveDir, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name);
} catch {}
const result = {
// Config
commit_docs: config.commit_docs,
// Current milestone
milestone_version: milestone.version,
milestone_name: milestone.name,
milestone_slug: generateSlugInternal(milestone.name),
// Phase counts
phase_count: phaseCount,
completed_phases: completedPhases,
all_phases_complete: phaseCount > 0 && phaseCount === completedPhases,
// Archive
archived_milestones: archivedMilestones,
archive_count: archivedMilestones.length,
// File existence
project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'),
state_exists: pathExistsInternal(cwd, '.planning/STATE.md'),
archive_exists: pathExistsInternal(cwd, '.planning/archive'),
phases_dir_exists: pathExistsInternal(cwd, '.planning/phases'),
};
output(result, raw);
}
function cmdInitMapCodebase(cwd, raw) {
const config = loadConfig(cwd);
// Check for existing codebase maps
const codebaseDir = path.join(cwd, '.planning', 'codebase');
let existingMaps = [];
try {
existingMaps = fs.readdirSync(codebaseDir).filter(f => f.endsWith('.md'));
} catch {}
const result = {
// Models
mapper_model: resolveModelInternal(cwd, 'gsd-codebase-mapper'),
// Config
commit_docs: config.commit_docs,
search_gitignored: config.search_gitignored,
parallelization: config.parallelization,
// Paths
codebase_dir: '.planning/codebase',
// Existing maps
existing_maps: existingMaps,
has_maps: existingMaps.length > 0,
// File existence
planning_exists: pathExistsInternal(cwd, '.planning'),
codebase_dir_exists: pathExistsInternal(cwd, '.planning/codebase'),
};
output(result, raw);
}
function cmdInitProgress(cwd, raw) {
const config = loadConfig(cwd);
const milestone = getMilestoneInfo(cwd);
// Analyze phases
const phasesDir = path.join(cwd, '.planning', 'phases');
const phases = [];
let currentPhase = null;
let nextPhase = null;
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
for (const dir of dirs) {
const match = dir.match(/^(\d+(?:\.\d+)?)-?(.*)/);
const phaseNumber = match ? match[1] : dir;
const phaseName = match && match[2] ? match[2] : null;
const phasePath = path.join(phasesDir, dir);
const phaseFiles = fs.readdirSync(phasePath);
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
const hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
const status = summaries.length >= plans.length && plans.length > 0 ? 'complete' :
plans.length > 0 ? 'in_progress' :
hasResearch ? 'researched' : 'pending';
const phaseInfo = {
number: phaseNumber,
name: phaseName,
directory: path.join('.planning', 'phases', dir),
status,
plan_count: plans.length,
summary_count: summaries.length,
has_research: hasResearch,
};
phases.push(phaseInfo);
// Find current (first incomplete with plans) and next (first pending)
if (!currentPhase && (status === 'in_progress' || status === 'researched')) {
currentPhase = phaseInfo;
}
if (!nextPhase && status === 'pending') {
nextPhase = phaseInfo;
}
}
} catch {}
// Check for paused work
let pausedAt = null;
try {
const state = fs.readFileSync(path.join(cwd, '.planning', 'STATE.md'), 'utf-8');
const pauseMatch = state.match(/\*\*Paused At:\*\*\s*(.+)/);
if (pauseMatch) pausedAt = pauseMatch[1].trim();
} catch {}
const result = {
// Models
executor_model: resolveModelInternal(cwd, 'gsd-executor'),
planner_model: resolveModelInternal(cwd, 'gsd-planner'),
// Config
commit_docs: config.commit_docs,
// Milestone
milestone_version: milestone.version,
milestone_name: milestone.name,
// Phase overview
phases,
phase_count: phases.length,
completed_count: phases.filter(p => p.status === 'complete').length,
in_progress_count: phases.filter(p => p.status === 'in_progress').length,
// Current state
current_phase: currentPhase,
next_phase: nextPhase,
paused_at: pausedAt,
has_work_in_progress: !!currentPhase,
// File existence
project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'),
state_exists: pathExistsInternal(cwd, '.planning/STATE.md'),
// File paths
state_path: '.planning/STATE.md',
roadmap_path: '.planning/ROADMAP.md',
project_path: '.planning/PROJECT.md',
config_path: '.planning/config.json',
};
output(result, raw);
}
module.exports = {
cmdInitExecutePhase,
cmdInitPlanPhase,
cmdInitNewProject,
cmdInitNewMilestone,
cmdInitQuick,
cmdInitResume,
cmdInitVerifyWork,
cmdInitPhaseOp,
cmdInitTodos,
cmdInitMilestoneOp,
cmdInitMapCodebase,
cmdInitProgress,
};

View File

@@ -0,0 +1,215 @@
/**
* Milestone — Milestone and requirements lifecycle operations
*/
const fs = require('fs');
const path = require('path');
const { output, error } = require('./core.cjs');
const { extractFrontmatter } = require('./frontmatter.cjs');
function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) {
if (!reqIdsRaw || reqIdsRaw.length === 0) {
error('requirement IDs required. Usage: requirements mark-complete REQ-01,REQ-02 or REQ-01 REQ-02');
}
// Accept comma-separated, space-separated, or bracket-wrapped: [REQ-01, REQ-02]
const reqIds = reqIdsRaw
.join(' ')
.replace(/[\[\]]/g, '')
.split(/[,\s]+/)
.map(r => r.trim())
.filter(Boolean);
if (reqIds.length === 0) {
error('no valid requirement IDs found');
}
const reqPath = path.join(cwd, '.planning', 'REQUIREMENTS.md');
if (!fs.existsSync(reqPath)) {
output({ updated: false, reason: 'REQUIREMENTS.md not found', ids: reqIds }, raw, 'no requirements file');
return;
}
let reqContent = fs.readFileSync(reqPath, 'utf-8');
const updated = [];
const notFound = [];
for (const reqId of reqIds) {
let found = false;
// Update checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqId}\\*\\*)`, 'gi');
if (checkboxPattern.test(reqContent)) {
reqContent = reqContent.replace(checkboxPattern, '$1x$2');
found = true;
}
// Update traceability table: | REQ-ID | Phase N | Pending | → | REQ-ID | Phase N | Complete |
const tablePattern = new RegExp(`(\\|\\s*${reqId}\\s*\\|[^|]+\\|)\\s*Pending\\s*(\\|)`, 'gi');
if (tablePattern.test(reqContent)) {
// Re-read since test() advances lastIndex for global regex
reqContent = reqContent.replace(
new RegExp(`(\\|\\s*${reqId}\\s*\\|[^|]+\\|)\\s*Pending\\s*(\\|)`, 'gi'),
'$1 Complete $2'
);
found = true;
}
if (found) {
updated.push(reqId);
} else {
notFound.push(reqId);
}
}
if (updated.length > 0) {
fs.writeFileSync(reqPath, reqContent, 'utf-8');
}
output({
updated: updated.length > 0,
marked_complete: updated,
not_found: notFound,
total: reqIds.length,
}, raw, `${updated.length}/${reqIds.length} requirements marked complete`);
}
function cmdMilestoneComplete(cwd, version, options, raw) {
if (!version) {
error('version required for milestone complete (e.g., v1.0)');
}
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
const reqPath = path.join(cwd, '.planning', 'REQUIREMENTS.md');
const statePath = path.join(cwd, '.planning', 'STATE.md');
const milestonesPath = path.join(cwd, '.planning', 'MILESTONES.md');
const archiveDir = path.join(cwd, '.planning', 'milestones');
const phasesDir = path.join(cwd, '.planning', 'phases');
const today = new Date().toISOString().split('T')[0];
const milestoneName = options.name || version;
// Ensure archive directory exists
fs.mkdirSync(archiveDir, { recursive: true });
// Gather stats from phases
let phaseCount = 0;
let totalPlans = 0;
let totalTasks = 0;
const accomplishments = [];
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
for (const dir of dirs) {
phaseCount++;
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
totalPlans += plans.length;
// Extract one-liners from summaries
for (const s of summaries) {
try {
const content = fs.readFileSync(path.join(phasesDir, dir, s), 'utf-8');
const fm = extractFrontmatter(content);
if (fm['one-liner']) {
accomplishments.push(fm['one-liner']);
}
// Count tasks
const taskMatches = content.match(/##\s*Task\s*\d+/gi) || [];
totalTasks += taskMatches.length;
} catch {}
}
}
} catch {}
// Archive ROADMAP.md
if (fs.existsSync(roadmapPath)) {
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
fs.writeFileSync(path.join(archiveDir, `${version}-ROADMAP.md`), roadmapContent, 'utf-8');
}
// Archive REQUIREMENTS.md
if (fs.existsSync(reqPath)) {
const reqContent = fs.readFileSync(reqPath, 'utf-8');
const archiveHeader = `# Requirements Archive: ${version} ${milestoneName}\n\n**Archived:** ${today}\n**Status:** SHIPPED\n\nFor current requirements, see \`.planning/REQUIREMENTS.md\`.\n\n---\n\n`;
fs.writeFileSync(path.join(archiveDir, `${version}-REQUIREMENTS.md`), archiveHeader + reqContent, 'utf-8');
}
// Archive audit file if exists
const auditFile = path.join(cwd, '.planning', `${version}-MILESTONE-AUDIT.md`);
if (fs.existsSync(auditFile)) {
fs.renameSync(auditFile, path.join(archiveDir, `${version}-MILESTONE-AUDIT.md`));
}
// Create/append MILESTONES.md entry
const accomplishmentsList = accomplishments.map(a => `- ${a}`).join('\n');
const milestoneEntry = `## ${version} ${milestoneName} (Shipped: ${today})\n\n**Phases completed:** ${phaseCount} phases, ${totalPlans} plans, ${totalTasks} tasks\n\n**Key accomplishments:**\n${accomplishmentsList || '- (none recorded)'}\n\n---\n\n`;
if (fs.existsSync(milestonesPath)) {
const existing = fs.readFileSync(milestonesPath, 'utf-8');
fs.writeFileSync(milestonesPath, existing + '\n' + milestoneEntry, 'utf-8');
} else {
fs.writeFileSync(milestonesPath, `# Milestones\n\n${milestoneEntry}`, 'utf-8');
}
// Update STATE.md
if (fs.existsSync(statePath)) {
let stateContent = fs.readFileSync(statePath, 'utf-8');
stateContent = stateContent.replace(
/(\*\*Status:\*\*\s*).*/,
`$1${version} milestone complete`
);
stateContent = stateContent.replace(
/(\*\*Last Activity:\*\*\s*).*/,
`$1${today}`
);
stateContent = stateContent.replace(
/(\*\*Last Activity Description:\*\*\s*).*/,
`$1${version} milestone completed and archived`
);
fs.writeFileSync(statePath, stateContent, 'utf-8');
}
// Archive phase directories if requested
let phasesArchived = false;
if (options.archivePhases) {
try {
const phaseArchiveDir = path.join(archiveDir, `${version}-phases`);
fs.mkdirSync(phaseArchiveDir, { recursive: true });
const phaseEntries = fs.readdirSync(phasesDir, { withFileTypes: true });
const phaseDirNames = phaseEntries.filter(e => e.isDirectory()).map(e => e.name);
for (const dir of phaseDirNames) {
fs.renameSync(path.join(phasesDir, dir), path.join(phaseArchiveDir, dir));
}
phasesArchived = phaseDirNames.length > 0;
} catch {}
}
const result = {
version,
name: milestoneName,
date: today,
phases: phaseCount,
plans: totalPlans,
tasks: totalTasks,
accomplishments,
archived: {
roadmap: fs.existsSync(path.join(archiveDir, `${version}-ROADMAP.md`)),
requirements: fs.existsSync(path.join(archiveDir, `${version}-REQUIREMENTS.md`)),
audit: fs.existsSync(path.join(archiveDir, `${version}-MILESTONE-AUDIT.md`)),
phases: phasesArchived,
},
milestones_updated: true,
state_updated: fs.existsSync(statePath),
};
output(result, raw);
}
module.exports = {
cmdRequirementsMarkComplete,
cmdMilestoneComplete,
};

View File

@@ -0,0 +1,873 @@
/**
* Phase — Phase CRUD, query, and lifecycle operations
*/
const fs = require('fs');
const path = require('path');
const { normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, output, error } = require('./core.cjs');
const { extractFrontmatter } = require('./frontmatter.cjs');
function cmdPhasesList(cwd, options, raw) {
const phasesDir = path.join(cwd, '.planning', 'phases');
const { type, phase, includeArchived } = options;
// If no phases directory, return empty
if (!fs.existsSync(phasesDir)) {
if (type) {
output({ files: [], count: 0 }, raw, '');
} else {
output({ directories: [], count: 0 }, raw, '');
}
return;
}
try {
// Get all phase directories
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
let dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
// Include archived phases if requested
if (includeArchived) {
const archived = getArchivedPhaseDirs(cwd);
for (const a of archived) {
dirs.push(`${a.name} [${a.milestone}]`);
}
}
// Sort numerically (handles integers, decimals, letter-suffix, hybrids)
dirs.sort((a, b) => comparePhaseNum(a, b));
// If filtering by phase number
if (phase) {
const normalized = normalizePhaseName(phase);
const match = dirs.find(d => d.startsWith(normalized));
if (!match) {
output({ files: [], count: 0, phase_dir: null, error: 'Phase not found' }, raw, '');
return;
}
dirs = [match];
}
// If listing files of a specific type
if (type) {
const files = [];
for (const dir of dirs) {
const dirPath = path.join(phasesDir, dir);
const dirFiles = fs.readdirSync(dirPath);
let filtered;
if (type === 'plans') {
filtered = dirFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
} else if (type === 'summaries') {
filtered = dirFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
} else {
filtered = dirFiles;
}
files.push(...filtered.sort());
}
const result = {
files,
count: files.length,
phase_dir: phase ? dirs[0].replace(/^\d+(?:\.\d+)?-?/, '') : null,
};
output(result, raw, files.join('\n'));
return;
}
// Default: list directories
output({ directories: dirs, count: dirs.length }, raw, dirs.join('\n'));
} catch (e) {
error('Failed to list phases: ' + e.message);
}
}
function cmdPhaseNextDecimal(cwd, basePhase, raw) {
const phasesDir = path.join(cwd, '.planning', 'phases');
const normalized = normalizePhaseName(basePhase);
// Check if phases directory exists
if (!fs.existsSync(phasesDir)) {
output(
{
found: false,
base_phase: normalized,
next: `${normalized}.1`,
existing: [],
},
raw,
`${normalized}.1`
);
return;
}
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
// Check if base phase exists
const baseExists = dirs.some(d => d.startsWith(normalized + '-') || d === normalized);
// Find existing decimal phases for this base
const decimalPattern = new RegExp(`^${normalized}\\.(\\d+)`);
const existingDecimals = [];
for (const dir of dirs) {
const match = dir.match(decimalPattern);
if (match) {
existingDecimals.push(`${normalized}.${match[1]}`);
}
}
// Sort numerically
existingDecimals.sort((a, b) => {
const aNum = parseFloat(a);
const bNum = parseFloat(b);
return aNum - bNum;
});
// Calculate next decimal
let nextDecimal;
if (existingDecimals.length === 0) {
nextDecimal = `${normalized}.1`;
} else {
const lastDecimal = existingDecimals[existingDecimals.length - 1];
const lastNum = parseInt(lastDecimal.split('.')[1], 10);
nextDecimal = `${normalized}.${lastNum + 1}`;
}
output(
{
found: baseExists,
base_phase: normalized,
next: nextDecimal,
existing: existingDecimals,
},
raw,
nextDecimal
);
} catch (e) {
error('Failed to calculate next decimal phase: ' + e.message);
}
}
function cmdFindPhase(cwd, phase, raw) {
if (!phase) {
error('phase identifier required');
}
const phasesDir = path.join(cwd, '.planning', 'phases');
const normalized = normalizePhaseName(phase);
const notFound = { found: false, directory: null, phase_number: null, phase_name: null, plans: [], summaries: [] };
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
const match = dirs.find(d => d.startsWith(normalized));
if (!match) {
output(notFound, raw, '');
return;
}
const dirMatch = match.match(/^(\d+[A-Z]?(?:\.\d+)?)-?(.*)/i);
const phaseNumber = dirMatch ? dirMatch[1] : normalized;
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
const phaseDir = path.join(phasesDir, match);
const phaseFiles = fs.readdirSync(phaseDir);
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').sort();
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').sort();
const result = {
found: true,
directory: path.join('.planning', 'phases', match),
phase_number: phaseNumber,
phase_name: phaseName,
plans,
summaries,
};
output(result, raw, result.directory);
} catch {
output(notFound, raw, '');
}
}
function cmdPhasePlanIndex(cwd, phase, raw) {
if (!phase) {
error('phase required for phase-plan-index');
}
const phasesDir = path.join(cwd, '.planning', 'phases');
const normalized = normalizePhaseName(phase);
// Find phase directory
let phaseDir = null;
let phaseDirName = null;
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
const match = dirs.find(d => d.startsWith(normalized));
if (match) {
phaseDir = path.join(phasesDir, match);
phaseDirName = match;
}
} catch {
// phases dir doesn't exist
}
if (!phaseDir) {
output({ phase: normalized, error: 'Phase not found', plans: [], waves: {}, incomplete: [], has_checkpoints: false }, raw);
return;
}
// Get all files in phase directory
const phaseFiles = fs.readdirSync(phaseDir);
const planFiles = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').sort();
const summaryFiles = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
// Build set of plan IDs with summaries
const completedPlanIds = new Set(
summaryFiles.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''))
);
const plans = [];
const waves = {};
const incomplete = [];
let hasCheckpoints = false;
for (const planFile of planFiles) {
const planId = planFile.replace('-PLAN.md', '').replace('PLAN.md', '');
const planPath = path.join(phaseDir, planFile);
const content = fs.readFileSync(planPath, 'utf-8');
const fm = extractFrontmatter(content);
// Count tasks (## Task N patterns)
const taskMatches = content.match(/##\s*Task\s*\d+/gi) || [];
const taskCount = taskMatches.length;
// Parse wave as integer
const wave = parseInt(fm.wave, 10) || 1;
// Parse autonomous (default true if not specified)
let autonomous = true;
if (fm.autonomous !== undefined) {
autonomous = fm.autonomous === 'true' || fm.autonomous === true;
}
if (!autonomous) {
hasCheckpoints = true;
}
// Parse files-modified
let filesModified = [];
if (fm['files-modified']) {
filesModified = Array.isArray(fm['files-modified']) ? fm['files-modified'] : [fm['files-modified']];
}
const hasSummary = completedPlanIds.has(planId);
if (!hasSummary) {
incomplete.push(planId);
}
const plan = {
id: planId,
wave,
autonomous,
objective: fm.objective || null,
files_modified: filesModified,
task_count: taskCount,
has_summary: hasSummary,
};
plans.push(plan);
// Group by wave
const waveKey = String(wave);
if (!waves[waveKey]) {
waves[waveKey] = [];
}
waves[waveKey].push(planId);
}
const result = {
phase: normalized,
plans,
waves,
incomplete,
has_checkpoints: hasCheckpoints,
};
output(result, raw);
}
function cmdPhaseAdd(cwd, description, raw) {
if (!description) {
error('description required for phase add');
}
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
if (!fs.existsSync(roadmapPath)) {
error('ROADMAP.md not found');
}
const content = fs.readFileSync(roadmapPath, 'utf-8');
const slug = generateSlugInternal(description);
// Find highest integer phase number
const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)?:/gi;
let maxPhase = 0;
let m;
while ((m = phasePattern.exec(content)) !== null) {
const num = parseInt(m[1], 10);
if (num > maxPhase) maxPhase = num;
}
const newPhaseNum = maxPhase + 1;
const paddedNum = String(newPhaseNum).padStart(2, '0');
const dirName = `${paddedNum}-${slug}`;
const dirPath = path.join(cwd, '.planning', 'phases', dirName);
// Create directory with .gitkeep so git tracks empty folders
fs.mkdirSync(dirPath, { recursive: true });
fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
// Build phase entry
const phaseEntry = `\n### Phase ${newPhaseNum}: ${description}\n\n**Goal:** [To be planned]\n**Depends on:** Phase ${maxPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${newPhaseNum} to break down)\n`;
// Find insertion point: before last "---" or at end
let updatedContent;
const lastSeparator = content.lastIndexOf('\n---');
if (lastSeparator > 0) {
updatedContent = content.slice(0, lastSeparator) + phaseEntry + content.slice(lastSeparator);
} else {
updatedContent = content + phaseEntry;
}
fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
const result = {
phase_number: newPhaseNum,
padded: paddedNum,
name: description,
slug,
directory: `.planning/phases/${dirName}`,
};
output(result, raw, paddedNum);
}
function cmdPhaseInsert(cwd, afterPhase, description, raw) {
if (!afterPhase || !description) {
error('after-phase and description required for phase insert');
}
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
if (!fs.existsSync(roadmapPath)) {
error('ROADMAP.md not found');
}
const content = fs.readFileSync(roadmapPath, 'utf-8');
const slug = generateSlugInternal(description);
// Normalize input then strip leading zeros for flexible matching
const normalizedAfter = normalizePhaseName(afterPhase);
const unpadded = normalizedAfter.replace(/^0+/, '');
const afterPhaseEscaped = unpadded.replace(/\./g, '\\.');
const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:`, 'i');
if (!targetPattern.test(content)) {
error(`Phase ${afterPhase} not found in ROADMAP.md`);
}
// Calculate next decimal using existing logic
const phasesDir = path.join(cwd, '.planning', 'phases');
const normalizedBase = normalizePhaseName(afterPhase);
let existingDecimals = [];
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
const decimalPattern = new RegExp(`^${normalizedBase}\\.(\\d+)`);
for (const dir of dirs) {
const dm = dir.match(decimalPattern);
if (dm) existingDecimals.push(parseInt(dm[1], 10));
}
} catch {}
const nextDecimal = existingDecimals.length === 0 ? 1 : Math.max(...existingDecimals) + 1;
const decimalPhase = `${normalizedBase}.${nextDecimal}`;
const dirName = `${decimalPhase}-${slug}`;
const dirPath = path.join(cwd, '.planning', 'phases', dirName);
// Create directory with .gitkeep so git tracks empty folders
fs.mkdirSync(dirPath, { recursive: true });
fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
// Build phase entry
const phaseEntry = `\n### Phase ${decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${decimalPhase} to break down)\n`;
// Insert after the target phase section
const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:[^\\n]*\\n)`, 'i');
const headerMatch = content.match(headerPattern);
if (!headerMatch) {
error(`Could not find Phase ${afterPhase} header`);
}
const headerIdx = content.indexOf(headerMatch[0]);
const afterHeader = content.slice(headerIdx + headerMatch[0].length);
const nextPhaseMatch = afterHeader.match(/\n#{2,4}\s+Phase\s+\d/i);
let insertIdx;
if (nextPhaseMatch) {
insertIdx = headerIdx + headerMatch[0].length + nextPhaseMatch.index;
} else {
insertIdx = content.length;
}
const updatedContent = content.slice(0, insertIdx) + phaseEntry + content.slice(insertIdx);
fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
const result = {
phase_number: decimalPhase,
after_phase: afterPhase,
name: description,
slug,
directory: `.planning/phases/${dirName}`,
};
output(result, raw, decimalPhase);
}
function cmdPhaseRemove(cwd, targetPhase, options, raw) {
if (!targetPhase) {
error('phase number required for phase remove');
}
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
const phasesDir = path.join(cwd, '.planning', 'phases');
const force = options.force || false;
if (!fs.existsSync(roadmapPath)) {
error('ROADMAP.md not found');
}
// Normalize the target
const normalized = normalizePhaseName(targetPhase);
const isDecimal = targetPhase.includes('.');
// Find and validate target directory
let targetDir = null;
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
targetDir = dirs.find(d => d.startsWith(normalized + '-') || d === normalized);
} catch {}
// Check for executed work (SUMMARY.md files)
if (targetDir && !force) {
const targetPath = path.join(phasesDir, targetDir);
const files = fs.readdirSync(targetPath);
const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
if (summaries.length > 0) {
error(`Phase ${targetPhase} has ${summaries.length} executed plan(s). Use --force to remove anyway.`);
}
}
// Delete target directory
if (targetDir) {
fs.rmSync(path.join(phasesDir, targetDir), { recursive: true, force: true });
}
// Renumber subsequent phases
const renamedDirs = [];
const renamedFiles = [];
if (isDecimal) {
// Decimal removal: renumber sibling decimals (e.g., removing 06.2 → 06.3 becomes 06.2)
const baseParts = normalized.split('.');
const baseInt = baseParts[0];
const removedDecimal = parseInt(baseParts[1], 10);
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
// Find sibling decimals with higher numbers
const decPattern = new RegExp(`^${baseInt}\\.(\\d+)-(.+)$`);
const toRename = [];
for (const dir of dirs) {
const dm = dir.match(decPattern);
if (dm && parseInt(dm[1], 10) > removedDecimal) {
toRename.push({ dir, oldDecimal: parseInt(dm[1], 10), slug: dm[2] });
}
}
// Sort descending to avoid conflicts
toRename.sort((a, b) => b.oldDecimal - a.oldDecimal);
for (const item of toRename) {
const newDecimal = item.oldDecimal - 1;
const oldPhaseId = `${baseInt}.${item.oldDecimal}`;
const newPhaseId = `${baseInt}.${newDecimal}`;
const newDirName = `${baseInt}.${newDecimal}-${item.slug}`;
// Rename directory
fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName));
renamedDirs.push({ from: item.dir, to: newDirName });
// Rename files inside
const dirFiles = fs.readdirSync(path.join(phasesDir, newDirName));
for (const f of dirFiles) {
// Files may have phase prefix like "06.2-01-PLAN.md"
if (f.includes(oldPhaseId)) {
const newFileName = f.replace(oldPhaseId, newPhaseId);
fs.renameSync(
path.join(phasesDir, newDirName, f),
path.join(phasesDir, newDirName, newFileName)
);
renamedFiles.push({ from: f, to: newFileName });
}
}
}
} catch {}
} else {
// Integer removal: renumber all subsequent integer phases
const removedInt = parseInt(normalized, 10);
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
// Collect directories that need renumbering (integer phases > removed, and their decimals/letters)
const toRename = [];
for (const dir of dirs) {
const dm = dir.match(/^(\d+)([A-Z])?(?:\.(\d+))?-(.+)$/i);
if (!dm) continue;
const dirInt = parseInt(dm[1], 10);
if (dirInt > removedInt) {
toRename.push({
dir,
oldInt: dirInt,
letter: dm[2] ? dm[2].toUpperCase() : '',
decimal: dm[3] ? parseInt(dm[3], 10) : null,
slug: dm[4],
});
}
}
// Sort descending to avoid conflicts
toRename.sort((a, b) => {
if (a.oldInt !== b.oldInt) return b.oldInt - a.oldInt;
return (b.decimal || 0) - (a.decimal || 0);
});
for (const item of toRename) {
const newInt = item.oldInt - 1;
const newPadded = String(newInt).padStart(2, '0');
const oldPadded = String(item.oldInt).padStart(2, '0');
const letterSuffix = item.letter || '';
const decimalSuffix = item.decimal !== null ? `.${item.decimal}` : '';
const oldPrefix = `${oldPadded}${letterSuffix}${decimalSuffix}`;
const newPrefix = `${newPadded}${letterSuffix}${decimalSuffix}`;
const newDirName = `${newPrefix}-${item.slug}`;
// Rename directory
fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName));
renamedDirs.push({ from: item.dir, to: newDirName });
// Rename files inside
const dirFiles = fs.readdirSync(path.join(phasesDir, newDirName));
for (const f of dirFiles) {
if (f.startsWith(oldPrefix)) {
const newFileName = newPrefix + f.slice(oldPrefix.length);
fs.renameSync(
path.join(phasesDir, newDirName, f),
path.join(phasesDir, newDirName, newFileName)
);
renamedFiles.push({ from: f, to: newFileName });
}
}
}
} catch {}
}
// Update ROADMAP.md
let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
// Remove the target phase section
const targetEscaped = targetPhase.replace(/\./g, '\\.');
const sectionPattern = new RegExp(
`\\n?#{2,4}\\s*Phase\\s+${targetEscaped}\\s*:[\\s\\S]*?(?=\\n#{2,4}\\s+Phase\\s+\\d|$)`,
'i'
);
roadmapContent = roadmapContent.replace(sectionPattern, '');
// Remove from phase list (checkbox)
const checkboxPattern = new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${targetEscaped}[:\\s][^\\n]*`, 'gi');
roadmapContent = roadmapContent.replace(checkboxPattern, '');
// Remove from progress table
const tableRowPattern = new RegExp(`\\n?\\|\\s*${targetEscaped}\\.?\\s[^|]*\\|[^\\n]*`, 'gi');
roadmapContent = roadmapContent.replace(tableRowPattern, '');
// Renumber references in ROADMAP for subsequent phases
if (!isDecimal) {
const removedInt = parseInt(normalized, 10);
// Collect all integer phases > removedInt
const maxPhase = 99; // reasonable upper bound
for (let oldNum = maxPhase; oldNum > removedInt; oldNum--) {
const newNum = oldNum - 1;
const oldStr = String(oldNum);
const newStr = String(newNum);
const oldPad = oldStr.padStart(2, '0');
const newPad = newStr.padStart(2, '0');
// Phase headings: ## Phase 18: or ### Phase 18: → ## Phase 17: or ### Phase 17:
roadmapContent = roadmapContent.replace(
new RegExp(`(#{2,4}\\s*Phase\\s+)${oldStr}(\\s*:)`, 'gi'),
`$1${newStr}$2`
);
// Checkbox items: - [ ] **Phase 18:** → - [ ] **Phase 17:**
roadmapContent = roadmapContent.replace(
new RegExp(`(Phase\\s+)${oldStr}([:\\s])`, 'g'),
`$1${newStr}$2`
);
// Plan references: 18-01 → 17-01
roadmapContent = roadmapContent.replace(
new RegExp(`${oldPad}-(\\d{2})`, 'g'),
`${newPad}-$1`
);
// Table rows: | 18. → | 17.
roadmapContent = roadmapContent.replace(
new RegExp(`(\\|\\s*)${oldStr}\\.\\s`, 'g'),
`$1${newStr}. `
);
// Depends on references
roadmapContent = roadmapContent.replace(
new RegExp(`(Depends on:\\*\\*\\s*Phase\\s+)${oldStr}\\b`, 'gi'),
`$1${newStr}`
);
}
}
fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
// Update STATE.md phase count
const statePath = path.join(cwd, '.planning', 'STATE.md');
if (fs.existsSync(statePath)) {
let stateContent = fs.readFileSync(statePath, 'utf-8');
// Update "Total Phases" field
const totalPattern = /(\*\*Total Phases:\*\*\s*)(\d+)/;
const totalMatch = stateContent.match(totalPattern);
if (totalMatch) {
const oldTotal = parseInt(totalMatch[2], 10);
stateContent = stateContent.replace(totalPattern, `$1${oldTotal - 1}`);
}
// Update "Phase: X of Y" pattern
const ofPattern = /(\bof\s+)(\d+)(\s*(?:\(|phases?))/i;
const ofMatch = stateContent.match(ofPattern);
if (ofMatch) {
const oldTotal = parseInt(ofMatch[2], 10);
stateContent = stateContent.replace(ofPattern, `$1${oldTotal - 1}$3`);
}
fs.writeFileSync(statePath, stateContent, 'utf-8');
}
const result = {
removed: targetPhase,
directory_deleted: targetDir || null,
renamed_directories: renamedDirs,
renamed_files: renamedFiles,
roadmap_updated: true,
state_updated: fs.existsSync(statePath),
};
output(result, raw);
}
function cmdPhaseComplete(cwd, phaseNum, raw) {
if (!phaseNum) {
error('phase number required for phase complete');
}
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
const statePath = path.join(cwd, '.planning', 'STATE.md');
const phasesDir = path.join(cwd, '.planning', 'phases');
const normalized = normalizePhaseName(phaseNum);
const today = new Date().toISOString().split('T')[0];
// Verify phase info
const phaseInfo = findPhaseInternal(cwd, phaseNum);
if (!phaseInfo) {
error(`Phase ${phaseNum} not found`);
}
const planCount = phaseInfo.plans.length;
const summaryCount = phaseInfo.summaries.length;
// Update ROADMAP.md: mark phase complete
if (fs.existsSync(roadmapPath)) {
let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
// Checkbox: - [ ] Phase N: → - [x] Phase N: (...completed DATE)
const checkboxPattern = new RegExp(
`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseNum.replace('.', '\\.')}[:\\s][^\\n]*)`,
'i'
);
roadmapContent = roadmapContent.replace(checkboxPattern, `$1x$2 (completed ${today})`);
// Progress table: update Status to Complete, add date
const phaseEscaped = phaseNum.replace('.', '\\.');
const tablePattern = new RegExp(
`(\\|\\s*${phaseEscaped}\\.?\\s[^|]*\\|[^|]*\\|)\\s*[^|]*(\\|)\\s*[^|]*(\\|)`,
'i'
);
roadmapContent = roadmapContent.replace(
tablePattern,
`$1 Complete $2 ${today} $3`
);
// Update plan count in phase section
const planCountPattern = new RegExp(
`(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
'i'
);
roadmapContent = roadmapContent.replace(
planCountPattern,
`$1${summaryCount}/${planCount} plans complete`
);
fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
// Update REQUIREMENTS.md traceability for this phase's requirements
const reqPath = path.join(cwd, '.planning', 'REQUIREMENTS.md');
if (fs.existsSync(reqPath)) {
// Extract Requirements line from roadmap for this phase
const reqMatch = roadmapContent.match(
new RegExp(`Phase\\s+${phaseNum.replace('.', '\\.')}[\\s\\S]*?\\*\\*Requirements:\\*\\*\\s*([^\\n]+)`, 'i')
);
if (reqMatch) {
const reqIds = reqMatch[1].replace(/[\[\]]/g, '').split(/[,\s]+/).map(r => r.trim()).filter(Boolean);
let reqContent = fs.readFileSync(reqPath, 'utf-8');
for (const reqId of reqIds) {
// Update checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
reqContent = reqContent.replace(
new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqId}\\*\\*)`, 'gi'),
'$1x$2'
);
// Update traceability table: | REQ-ID | Phase N | Pending | → | REQ-ID | Phase N | Complete |
reqContent = reqContent.replace(
new RegExp(`(\\|\\s*${reqId}\\s*\\|[^|]+\\|)\\s*Pending\\s*(\\|)`, 'gi'),
'$1 Complete $2'
);
}
fs.writeFileSync(reqPath, reqContent, 'utf-8');
}
}
}
// Find next phase
let nextPhaseNum = null;
let nextPhaseName = null;
let isLastPhase = true;
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
// Find the next phase directory after current
for (const dir of dirs) {
const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)?)-?(.*)/i);
if (dm) {
if (comparePhaseNum(dm[1], phaseNum) > 0) {
nextPhaseNum = dm[1];
nextPhaseName = dm[2] || null;
isLastPhase = false;
break;
}
}
}
} catch {}
// Update STATE.md
if (fs.existsSync(statePath)) {
let stateContent = fs.readFileSync(statePath, 'utf-8');
// Update Current Phase
stateContent = stateContent.replace(
/(\*\*Current Phase:\*\*\s*).*/,
`$1${nextPhaseNum || phaseNum}`
);
// Update Current Phase Name
if (nextPhaseName) {
stateContent = stateContent.replace(
/(\*\*Current Phase Name:\*\*\s*).*/,
`$1${nextPhaseName.replace(/-/g, ' ')}`
);
}
// Update Status
stateContent = stateContent.replace(
/(\*\*Status:\*\*\s*).*/,
`$1${isLastPhase ? 'Milestone complete' : 'Ready to plan'}`
);
// Update Current Plan
stateContent = stateContent.replace(
/(\*\*Current Plan:\*\*\s*).*/,
`$1Not started`
);
// Update Last Activity
stateContent = stateContent.replace(
/(\*\*Last Activity:\*\*\s*).*/,
`$1${today}`
);
// Update Last Activity Description
stateContent = stateContent.replace(
/(\*\*Last Activity Description:\*\*\s*).*/,
`$1Phase ${phaseNum} complete${nextPhaseNum ? `, transitioned to Phase ${nextPhaseNum}` : ''}`
);
fs.writeFileSync(statePath, stateContent, 'utf-8');
}
const result = {
completed_phase: phaseNum,
phase_name: phaseInfo.phase_name,
plans_executed: `${summaryCount}/${planCount}`,
next_phase: nextPhaseNum,
next_phase_name: nextPhaseName,
is_last_phase: isLastPhase,
date: today,
roadmap_updated: fs.existsSync(roadmapPath),
state_updated: fs.existsSync(statePath),
};
output(result, raw);
}
module.exports = {
cmdPhasesList,
cmdPhaseNextDecimal,
cmdFindPhase,
cmdPhasePlanIndex,
cmdPhaseAdd,
cmdPhaseInsert,
cmdPhaseRemove,
cmdPhaseComplete,
};

View File

@@ -0,0 +1,298 @@
/**
* Roadmap — Roadmap parsing and update operations
*/
const fs = require('fs');
const path = require('path');
const { normalizePhaseName, output, error, findPhaseInternal } = require('./core.cjs');
function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
if (!fs.existsSync(roadmapPath)) {
output({ found: false, error: 'ROADMAP.md not found' }, raw, '');
return;
}
try {
const content = fs.readFileSync(roadmapPath, 'utf-8');
// Escape special regex chars in phase number, handle decimal
const escapedPhase = phaseNum.replace(/\./g, '\\.');
// Match "## Phase X:", "### Phase X:", or "#### Phase X:" with optional name
const phasePattern = new RegExp(
`#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`,
'i'
);
const headerMatch = content.match(phasePattern);
if (!headerMatch) {
// Fallback: check if phase exists in summary list but missing detail section
const checklistPattern = new RegExp(
`-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${escapedPhase}:\\s*([^*]+)\\*\\*`,
'i'
);
const checklistMatch = content.match(checklistPattern);
if (checklistMatch) {
// Phase exists in summary but missing detail section - malformed ROADMAP
output({
found: false,
phase_number: phaseNum,
phase_name: checklistMatch[1].trim(),
error: 'malformed_roadmap',
message: `Phase ${phaseNum} exists in summary list but missing "### Phase ${phaseNum}:" detail section. ROADMAP.md needs both formats.`
}, raw, '');
return;
}
output({ found: false, phase_number: phaseNum }, raw, '');
return;
}
const phaseName = headerMatch[1].trim();
const headerIndex = headerMatch.index;
// Find the end of this section (next ## or ### phase header, or end of file)
const restOfContent = content.slice(headerIndex);
const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i);
const sectionEnd = nextHeaderMatch
? headerIndex + nextHeaderMatch.index
: content.length;
const section = content.slice(headerIndex, sectionEnd).trim();
// Extract goal if present
const goalMatch = section.match(/\*\*Goal:\*\*\s*([^\n]+)/i);
const goal = goalMatch ? goalMatch[1].trim() : null;
// Extract success criteria as structured array
const criteriaMatch = section.match(/\*\*Success Criteria\*\*[^\n]*:\s*\n((?:\s*\d+\.\s*[^\n]+\n?)+)/i);
const success_criteria = criteriaMatch
? criteriaMatch[1].trim().split('\n').map(line => line.replace(/^\s*\d+\.\s*/, '').trim()).filter(Boolean)
: [];
output(
{
found: true,
phase_number: phaseNum,
phase_name: phaseName,
goal,
success_criteria,
section,
},
raw,
section
);
} catch (e) {
error('Failed to read ROADMAP.md: ' + e.message);
}
}
function cmdRoadmapAnalyze(cwd, raw) {
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
if (!fs.existsSync(roadmapPath)) {
output({ error: 'ROADMAP.md not found', milestones: [], phases: [], current_phase: null }, raw);
return;
}
const content = fs.readFileSync(roadmapPath, 'utf-8');
const phasesDir = path.join(cwd, '.planning', 'phases');
// Extract all phase headings: ## Phase N: Name or ### Phase N: Name
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)?)\s*:\s*([^\n]+)/gi;
const phases = [];
let match;
while ((match = phasePattern.exec(content)) !== null) {
const phaseNum = match[1];
const phaseName = match[2].replace(/\(INSERTED\)/i, '').trim();
// Extract goal from the section
const sectionStart = match.index;
const restOfContent = content.slice(sectionStart);
const nextHeader = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i);
const sectionEnd = nextHeader ? sectionStart + nextHeader.index : content.length;
const section = content.slice(sectionStart, sectionEnd);
const goalMatch = section.match(/\*\*Goal:\*\*\s*([^\n]+)/i);
const goal = goalMatch ? goalMatch[1].trim() : null;
const dependsMatch = section.match(/\*\*Depends on:\*\*\s*([^\n]+)/i);
const depends_on = dependsMatch ? dependsMatch[1].trim() : null;
// Check completion on disk
const normalized = normalizePhaseName(phaseNum);
let diskStatus = 'no_directory';
let planCount = 0;
let summaryCount = 0;
let hasContext = false;
let hasResearch = false;
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
const dirMatch = dirs.find(d => d.startsWith(normalized + '-') || d === normalized);
if (dirMatch) {
const phaseFiles = fs.readdirSync(path.join(phasesDir, dirMatch));
planCount = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
summaryCount = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
hasContext = phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md');
hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
if (summaryCount >= planCount && planCount > 0) diskStatus = 'complete';
else if (summaryCount > 0) diskStatus = 'partial';
else if (planCount > 0) diskStatus = 'planned';
else if (hasResearch) diskStatus = 'researched';
else if (hasContext) diskStatus = 'discussed';
else diskStatus = 'empty';
}
} catch {}
// Check ROADMAP checkbox status
const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+${phaseNum.replace('.', '\\.')}`, 'i');
const checkboxMatch = content.match(checkboxPattern);
const roadmapComplete = checkboxMatch ? checkboxMatch[1] === 'x' : false;
phases.push({
number: phaseNum,
name: phaseName,
goal,
depends_on,
plan_count: planCount,
summary_count: summaryCount,
has_context: hasContext,
has_research: hasResearch,
disk_status: diskStatus,
roadmap_complete: roadmapComplete,
});
}
// Extract milestone info
const milestones = [];
const milestonePattern = /##\s*(.*v(\d+\.\d+)[^(\n]*)/gi;
let mMatch;
while ((mMatch = milestonePattern.exec(content)) !== null) {
milestones.push({
heading: mMatch[1].trim(),
version: 'v' + mMatch[2],
});
}
// Find current and next phase
const currentPhase = phases.find(p => p.disk_status === 'planned' || p.disk_status === 'partial') || null;
const nextPhase = phases.find(p => p.disk_status === 'empty' || p.disk_status === 'no_directory' || p.disk_status === 'discussed' || p.disk_status === 'researched') || null;
// Aggregated stats
const totalPlans = phases.reduce((sum, p) => sum + p.plan_count, 0);
const totalSummaries = phases.reduce((sum, p) => sum + p.summary_count, 0);
const completedPhases = phases.filter(p => p.disk_status === 'complete').length;
// Detect phases in summary list without detail sections (malformed ROADMAP)
const checklistPattern = /-\s*\[[ x]\]\s*\*\*Phase\s+(\d+[A-Z]?(?:\.\d+)?)/gi;
const checklistPhases = new Set();
let checklistMatch;
while ((checklistMatch = checklistPattern.exec(content)) !== null) {
checklistPhases.add(checklistMatch[1]);
}
const detailPhases = new Set(phases.map(p => p.number));
const missingDetails = [...checklistPhases].filter(p => !detailPhases.has(p));
const result = {
milestones,
phases,
phase_count: phases.length,
completed_phases: completedPhases,
total_plans: totalPlans,
total_summaries: totalSummaries,
progress_percent: totalPlans > 0 ? Math.round((totalSummaries / totalPlans) * 100) : 0,
current_phase: currentPhase ? currentPhase.number : null,
next_phase: nextPhase ? nextPhase.number : null,
missing_phase_details: missingDetails.length > 0 ? missingDetails : null,
};
output(result, raw);
}
function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
if (!phaseNum) {
error('phase number required for roadmap update-plan-progress');
}
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
const phaseInfo = findPhaseInternal(cwd, phaseNum);
if (!phaseInfo) {
error(`Phase ${phaseNum} not found`);
}
const planCount = phaseInfo.plans.length;
const summaryCount = phaseInfo.summaries.length;
if (planCount === 0) {
output({ updated: false, reason: 'No plans found', plan_count: 0, summary_count: 0 }, raw, 'no plans');
return;
}
const isComplete = summaryCount >= planCount;
const status = isComplete ? 'Complete' : summaryCount > 0 ? 'In Progress' : 'Planned';
const today = new Date().toISOString().split('T')[0];
if (!fs.existsSync(roadmapPath)) {
output({ updated: false, reason: 'ROADMAP.md not found', plan_count: planCount, summary_count: summaryCount }, raw, 'no roadmap');
return;
}
let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
const phaseEscaped = phaseNum.replace('.', '\\.');
// Progress table row: update Plans column (summaries/plans) and Status column
const tablePattern = new RegExp(
`(\\|\\s*${phaseEscaped}\\.?\\s[^|]*\\|)[^|]*(\\|)\\s*[^|]*(\\|)\\s*[^|]*(\\|)`,
'i'
);
const dateField = isComplete ? ` ${today} ` : ' ';
roadmapContent = roadmapContent.replace(
tablePattern,
`$1 ${summaryCount}/${planCount} $2 ${status.padEnd(11)}$3${dateField}$4`
);
// Update plan count in phase detail section
const planCountPattern = new RegExp(
`(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
'i'
);
const planCountText = isComplete
? `${summaryCount}/${planCount} plans complete`
: `${summaryCount}/${planCount} plans executed`;
roadmapContent = roadmapContent.replace(planCountPattern, `$1${planCountText}`);
// If complete: check checkbox
if (isComplete) {
const checkboxPattern = new RegExp(
`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}[:\\s][^\\n]*)`,
'i'
);
roadmapContent = roadmapContent.replace(checkboxPattern, `$1x$2 (completed ${today})`);
}
fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
output({
updated: true,
phase: phaseNum,
plan_count: planCount,
summary_count: summaryCount,
status,
complete: isComplete,
}, raw, `${summaryCount}/${planCount} ${status}`);
}
module.exports = {
cmdRoadmapGetPhase,
cmdRoadmapAnalyze,
cmdRoadmapUpdatePlanProgress,
};

View File

@@ -0,0 +1,490 @@
/**
* State — STATE.md operations and progression engine
*/
const fs = require('fs');
const path = require('path');
const { loadConfig, output, error } = require('./core.cjs');
function cmdStateLoad(cwd, raw) {
const config = loadConfig(cwd);
const planningDir = path.join(cwd, '.planning');
let stateRaw = '';
try {
stateRaw = fs.readFileSync(path.join(planningDir, 'STATE.md'), 'utf-8');
} catch {}
const configExists = fs.existsSync(path.join(planningDir, 'config.json'));
const roadmapExists = fs.existsSync(path.join(planningDir, 'ROADMAP.md'));
const stateExists = stateRaw.length > 0;
const result = {
config,
state_raw: stateRaw,
state_exists: stateExists,
roadmap_exists: roadmapExists,
config_exists: configExists,
};
// For --raw, output a condensed key=value format
if (raw) {
const c = config;
const lines = [
`model_profile=${c.model_profile}`,
`commit_docs=${c.commit_docs}`,
`branching_strategy=${c.branching_strategy}`,
`phase_branch_template=${c.phase_branch_template}`,
`milestone_branch_template=${c.milestone_branch_template}`,
`parallelization=${c.parallelization}`,
`research=${c.research}`,
`plan_checker=${c.plan_checker}`,
`verifier=${c.verifier}`,
`config_exists=${configExists}`,
`roadmap_exists=${roadmapExists}`,
`state_exists=${stateExists}`,
];
process.stdout.write(lines.join('\n'));
process.exit(0);
}
output(result);
}
function cmdStateGet(cwd, section, raw) {
const statePath = path.join(cwd, '.planning', 'STATE.md');
try {
const content = fs.readFileSync(statePath, 'utf-8');
if (!section) {
output({ content }, raw, content);
return;
}
// Try to find markdown section or field
const fieldEscaped = section.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Check for **field:** value
const fieldPattern = new RegExp(`\\*\\*${fieldEscaped}:\\*\\*\\s*(.*)`, 'i');
const fieldMatch = content.match(fieldPattern);
if (fieldMatch) {
output({ [section]: fieldMatch[1].trim() }, raw, fieldMatch[1].trim());
return;
}
// Check for ## Section
const sectionPattern = new RegExp(`##\\s*${fieldEscaped}\\s*\n([\\s\\S]*?)(?=\\n##|$)`, 'i');
const sectionMatch = content.match(sectionPattern);
if (sectionMatch) {
output({ [section]: sectionMatch[1].trim() }, raw, sectionMatch[1].trim());
return;
}
output({ error: `Section or field "${section}" not found` }, raw, '');
} catch {
error('STATE.md not found');
}
}
function cmdStatePatch(cwd, patches, raw) {
const statePath = path.join(cwd, '.planning', 'STATE.md');
try {
let content = fs.readFileSync(statePath, 'utf-8');
const results = { updated: [], failed: [] };
for (const [field, value] of Object.entries(patches)) {
const fieldEscaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
if (pattern.test(content)) {
content = content.replace(pattern, `$1${value}`);
results.updated.push(field);
} else {
results.failed.push(field);
}
}
if (results.updated.length > 0) {
fs.writeFileSync(statePath, content, 'utf-8');
}
output(results, raw, results.updated.length > 0 ? 'true' : 'false');
} catch {
error('STATE.md not found');
}
}
function cmdStateUpdate(cwd, field, value) {
if (!field || value === undefined) {
error('field and value required for state update');
}
const statePath = path.join(cwd, '.planning', 'STATE.md');
try {
let content = fs.readFileSync(statePath, 'utf-8');
const fieldEscaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
if (pattern.test(content)) {
content = content.replace(pattern, `$1${value}`);
fs.writeFileSync(statePath, content, 'utf-8');
output({ updated: true });
} else {
output({ updated: false, reason: `Field "${field}" not found in STATE.md` });
}
} catch {
output({ updated: false, reason: 'STATE.md not found' });
}
}
// ─── 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 cmdStateSnapshot(cwd, raw) {
const statePath = path.join(cwd, '.planning', 'STATE.md');
if (!fs.existsSync(statePath)) {
output({ error: 'STATE.md not found' }, raw);
return;
}
const content = fs.readFileSync(statePath, 'utf-8');
// Helper to extract **Field:** value patterns
const extractField = (fieldName) => {
const pattern = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+)`, 'i');
const match = content.match(pattern);
return match ? match[1].trim() : null;
};
// Extract basic fields
const currentPhase = extractField('Current Phase');
const currentPhaseName = extractField('Current Phase Name');
const totalPhasesRaw = extractField('Total Phases');
const currentPlan = extractField('Current Plan');
const totalPlansRaw = extractField('Total Plans in Phase');
const status = extractField('Status');
const progressRaw = extractField('Progress');
const lastActivity = extractField('Last Activity');
const lastActivityDesc = extractField('Last Activity Description');
const pausedAt = extractField('Paused At');
// Parse numeric fields
const totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
const progressPercent = progressRaw ? parseInt(progressRaw.replace('%', ''), 10) : null;
// Extract decisions table
const decisions = [];
const decisionsMatch = content.match(/##\s*Decisions Made[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n([\s\S]*?)(?=\n##|\n$|$)/i);
if (decisionsMatch) {
const tableBody = decisionsMatch[1];
const rows = tableBody.trim().split('\n').filter(r => r.includes('|'));
for (const row of rows) {
const cells = row.split('|').map(c => c.trim()).filter(Boolean);
if (cells.length >= 3) {
decisions.push({
phase: cells[0],
summary: cells[1],
rationale: cells[2],
});
}
}
}
// Extract blockers list
const blockers = [];
const blockersMatch = content.match(/##\s*Blockers\s*\n([\s\S]*?)(?=\n##|$)/i);
if (blockersMatch) {
const blockersSection = blockersMatch[1];
const items = blockersSection.match(/^-\s+(.+)$/gm) || [];
for (const item of items) {
blockers.push(item.replace(/^-\s+/, '').trim());
}
}
// Extract session info
const session = {
last_date: null,
stopped_at: null,
resume_file: null,
};
const sessionMatch = content.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i);
if (sessionMatch) {
const sessionSection = sessionMatch[1];
const lastDateMatch = sessionSection.match(/\*\*Last Date:\*\*\s*(.+)/i);
const stoppedAtMatch = sessionSection.match(/\*\*Stopped At:\*\*\s*(.+)/i);
const resumeFileMatch = sessionSection.match(/\*\*Resume File:\*\*\s*(.+)/i);
if (lastDateMatch) session.last_date = lastDateMatch[1].trim();
if (stoppedAtMatch) session.stopped_at = stoppedAtMatch[1].trim();
if (resumeFileMatch) session.resume_file = resumeFileMatch[1].trim();
}
const result = {
current_phase: currentPhase,
current_phase_name: currentPhaseName,
total_phases: totalPhases,
current_plan: currentPlan,
total_plans_in_phase: totalPlansInPhase,
status,
progress_percent: progressPercent,
last_activity: lastActivity,
last_activity_desc: lastActivityDesc,
decisions,
blockers,
paused_at: pausedAt,
session,
};
output(result, raw);
}
module.exports = {
stateExtractField,
stateReplaceField,
cmdStateLoad,
cmdStateGet,
cmdStatePatch,
cmdStateUpdate,
cmdStateAdvancePlan,
cmdStateRecordMetric,
cmdStateUpdateProgress,
cmdStateAddDecision,
cmdStateAddBlocker,
cmdStateResolveBlocker,
cmdStateRecordSession,
cmdStateSnapshot,
};

View File

@@ -0,0 +1,222 @@
/**
* Template — Template selection and fill operations
*/
const fs = require('fs');
const path = require('path');
const { normalizePhaseName, findPhaseInternal, generateSlugInternal, output, error } = require('./core.cjs');
const { reconstructFrontmatter } = require('./frontmatter.cjs');
function cmdTemplateSelect(cwd, planPath, raw) {
if (!planPath) {
error('plan-path required');
}
try {
const fullPath = path.join(cwd, planPath);
const content = fs.readFileSync(fullPath, 'utf-8');
// Simple heuristics
const taskMatch = content.match(/###\s*Task\s*\d+/g) || [];
const taskCount = taskMatch.length;
const decisionMatch = content.match(/decision/gi) || [];
const hasDecisions = decisionMatch.length > 0;
// Count file mentions
const fileMentions = new Set();
const filePattern = /`([^`]+\.[a-zA-Z]+)`/g;
let m;
while ((m = filePattern.exec(content)) !== null) {
if (m[1].includes('/') && !m[1].startsWith('http')) {
fileMentions.add(m[1]);
}
}
const fileCount = fileMentions.size;
let template = 'templates/summary-standard.md';
let type = 'standard';
if (taskCount <= 2 && fileCount <= 3 && !hasDecisions) {
template = 'templates/summary-minimal.md';
type = 'minimal';
} else if (hasDecisions || fileCount > 6 || taskCount > 5) {
template = 'templates/summary-complex.md';
type = 'complex';
}
const result = { template, type, taskCount, fileCount, hasDecisions };
output(result, raw, template);
} catch (e) {
// Fallback to standard
output({ template: 'templates/summary-standard.md', type: 'standard', error: e.message }, raw, 'templates/summary-standard.md');
}
}
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 type="code">',
' <name>[Task name]</name>',
' <files>[file paths]</files>',
' <action>[What to do]</action>',
' <verify>[How to verify]</verify>',
' <done>[Definition of done]</done>',
'</task>',
'',
'## 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);
}
module.exports = { cmdTemplateSelect, cmdTemplateFill };

View File

@@ -0,0 +1,772 @@
/**
* Verify — Verification suite, consistency, and health validation
*/
const fs = require('fs');
const path = require('path');
const { safeReadFile, normalizePhaseName, execGit, findPhaseInternal, getMilestoneInfo, output, error } = require('./core.cjs');
const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs');
function cmdVerifySummary(cwd, summaryPath, checkFileCount, raw) {
if (!summaryPath) {
error('summary-path required');
}
const fullPath = path.join(cwd, summaryPath);
const checkCount = checkFileCount || 2;
// Check 1: Summary exists
if (!fs.existsSync(fullPath)) {
const result = {
passed: false,
checks: {
summary_exists: false,
files_created: { checked: 0, found: 0, missing: [] },
commits_exist: false,
self_check: 'not_found',
},
errors: ['SUMMARY.md not found'],
};
output(result, raw, 'failed');
return;
}
const content = fs.readFileSync(fullPath, 'utf-8');
const errors = [];
// Check 2: Spot-check files mentioned in summary
const mentionedFiles = new Set();
const patterns = [
/`([^`]+\.[a-zA-Z]+)`/g,
/(?:Created|Modified|Added|Updated|Edited):\s*`?([^\s`]+\.[a-zA-Z]+)`?/gi,
];
for (const pattern of patterns) {
let m;
while ((m = pattern.exec(content)) !== null) {
const filePath = m[1];
if (filePath && !filePath.startsWith('http') && filePath.includes('/')) {
mentionedFiles.add(filePath);
}
}
}
const filesToCheck = Array.from(mentionedFiles).slice(0, checkCount);
const missing = [];
for (const file of filesToCheck) {
if (!fs.existsSync(path.join(cwd, file))) {
missing.push(file);
}
}
// Check 3: Commits exist
const commitHashPattern = /\b[0-9a-f]{7,40}\b/g;
const hashes = content.match(commitHashPattern) || [];
let commitsExist = false;
if (hashes.length > 0) {
for (const hash of hashes.slice(0, 3)) {
const result = execGit(cwd, ['cat-file', '-t', hash]);
if (result.exitCode === 0 && result.stdout === 'commit') {
commitsExist = true;
break;
}
}
}
// Check 4: Self-check section
let selfCheck = 'not_found';
const selfCheckPattern = /##\s*(?:Self[- ]?Check|Verification|Quality Check)/i;
if (selfCheckPattern.test(content)) {
const passPattern = /(?:all\s+)?(?:pass|✓|✅|complete|succeeded)/i;
const failPattern = /(?:fail|✗|❌|incomplete|blocked)/i;
const checkSection = content.slice(content.search(selfCheckPattern));
if (failPattern.test(checkSection)) {
selfCheck = 'failed';
} else if (passPattern.test(checkSection)) {
selfCheck = 'passed';
}
}
if (missing.length > 0) errors.push('Missing files: ' + missing.join(', '));
if (!commitsExist && hashes.length > 0) errors.push('Referenced commit hashes not found in git history');
if (selfCheck === 'failed') errors.push('Self-check section indicates failure');
const checks = {
summary_exists: true,
files_created: { checked: filesToCheck.length, found: filesToCheck.length - missing.length, missing },
commits_exist: commitsExist,
self_check: selfCheck,
};
const passed = missing.length === 0 && selfCheck !== 'failed';
const result = { passed, checks, errors };
output(result, raw, passed ? 'passed' : 'failed');
}
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 = /<task[^>]*>([\s\S]*?)<\/task>/g;
const tasks = [];
let taskMatch;
while ((taskMatch = taskPattern.exec(content)) !== null) {
const taskContent = taskMatch[1];
const nameMatch = taskContent.match(/<name>([\s\S]*?)<\/name>/);
const taskName = nameMatch ? nameMatch[1].trim() : 'unnamed';
const hasFiles = /<files>/.test(taskContent);
const hasAction = /<action>/.test(taskContent);
const hasVerify = /<verify>/.test(taskContent);
const hasDone = /<done>/.test(taskContent);
if (!nameMatch) errors.push('Task missing <name> element');
if (!hasAction) errors.push(`Task '${taskName}' missing <action>`);
if (!hasVerify) warnings.push(`Task '${taskName}' missing <verify>`);
if (!hasDone) warnings.push(`Task '${taskName}' missing <done>`);
if (!hasFiles) warnings.push(`Task '${taskName}' missing <files>`);
tasks.push({ name: taskName, hasFiles, hasAction, hasVerify, hasDone });
}
if (tasks.length === 0) warnings.push('No <task> 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 = /<task\s+type=["']?checkpoint/.test(content);
if (hasCheckpoints && fm.autonomous !== 'false' && fm.autonomous !== false) {
errors.push('Has checkpoint tasks but autonomous is not false');
}
output({
valid: errors.length === 0,
errors,
warnings,
task_count: tasks.length,
tasks,
frontmatter_fields: Object.keys(fm),
}, raw, errors.length === 0 ? 'valid' : 'invalid');
}
function cmdVerifyPhaseCompleteness(cwd, phase, raw) {
if (!phase) { error('phase required'); }
const phaseInfo = findPhaseInternal(cwd, phase);
if (!phaseInfo || !phaseInfo.found) {
output({ error: 'Phase not found', phase }, raw);
return;
}
const errors = [];
const warnings = [];
const phaseDir = path.join(cwd, phaseInfo.directory);
// List plans and summaries
let files;
try { files = fs.readdirSync(phaseDir); } catch { output({ error: 'Cannot read phase directory' }, raw); return; }
const plans = files.filter(f => 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');
}
function cmdValidateConsistency(cwd, raw) {
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
const phasesDir = path.join(cwd, '.planning', 'phases');
const errors = [];
const warnings = [];
// Check for ROADMAP
if (!fs.existsSync(roadmapPath)) {
errors.push('ROADMAP.md not found');
output({ passed: false, errors, warnings }, raw, 'failed');
return;
}
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
// Extract phases from ROADMAP
const roadmapPhases = new Set();
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)?)\s*:/gi;
let m;
while ((m = phasePattern.exec(roadmapContent)) !== null) {
roadmapPhases.add(m[1]);
}
// Get phases on disk
const diskPhases = new Set();
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
for (const dir of dirs) {
const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)?)/i);
if (dm) diskPhases.add(dm[1]);
}
} catch {}
// Check: phases in ROADMAP but not on disk
for (const p of roadmapPhases) {
if (!diskPhases.has(p) && !diskPhases.has(normalizePhaseName(p))) {
warnings.push(`Phase ${p} in ROADMAP.md but no directory on disk`);
}
}
// Check: phases on disk but not in ROADMAP
for (const p of diskPhases) {
const unpadded = String(parseInt(p, 10));
if (!roadmapPhases.has(p) && !roadmapPhases.has(unpadded)) {
warnings.push(`Phase ${p} exists on disk but not in ROADMAP.md`);
}
}
// Check: sequential phase numbers (integers only)
const integerPhases = [...diskPhases]
.filter(p => !p.includes('.'))
.map(p => parseInt(p, 10))
.sort((a, b) => a - b);
for (let i = 1; i < integerPhases.length; i++) {
if (integerPhases[i] !== integerPhases[i - 1] + 1) {
warnings.push(`Gap in phase numbering: ${integerPhases[i - 1]} → ${integerPhases[i]}`);
}
}
// Check: plan numbering within phases
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
for (const dir of dirs) {
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md')).sort();
// Extract plan numbers
const planNums = plans.map(p => {
const pm = p.match(/-(\d{2})-PLAN\.md$/);
return pm ? parseInt(pm[1], 10) : null;
}).filter(n => n !== null);
for (let i = 1; i < planNums.length; i++) {
if (planNums[i] !== planNums[i - 1] + 1) {
warnings.push(`Gap in plan numbering in ${dir}: plan ${planNums[i - 1]} → ${planNums[i]}`);
}
}
// Check: plans without summaries (completed plans)
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md'));
const planIds = new Set(plans.map(p => p.replace('-PLAN.md', '')));
const summaryIds = new Set(summaries.map(s => s.replace('-SUMMARY.md', '')));
// Summary without matching plan is suspicious
for (const sid of summaryIds) {
if (!planIds.has(sid)) {
warnings.push(`Summary ${sid}-SUMMARY.md in ${dir} has no matching PLAN.md`);
}
}
}
} catch {}
// Check: frontmatter in plans has required fields
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
for (const dir of dirs) {
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md'));
for (const plan of plans) {
const content = fs.readFileSync(path.join(phasesDir, dir, plan), 'utf-8');
const fm = extractFrontmatter(content);
if (!fm.wave) {
warnings.push(`${dir}/${plan}: missing 'wave' in frontmatter`);
}
}
}
} catch {}
const passed = errors.length === 0;
output({ passed, errors, warnings, warning_count: warnings.length }, raw, passed ? 'passed' : 'failed');
}
function cmdValidateHealth(cwd, options, raw) {
const planningDir = path.join(cwd, '.planning');
const projectPath = path.join(planningDir, 'PROJECT.md');
const roadmapPath = path.join(planningDir, 'ROADMAP.md');
const statePath = path.join(planningDir, 'STATE.md');
const configPath = path.join(planningDir, 'config.json');
const phasesDir = path.join(planningDir, 'phases');
const errors = [];
const warnings = [];
const info = [];
const repairs = [];
// Helper to add issue
const addIssue = (severity, code, message, fix, repairable = false) => {
const issue = { code, message, fix, repairable };
if (severity === 'error') errors.push(issue);
else if (severity === 'warning') warnings.push(issue);
else info.push(issue);
};
// ─── Check 1: .planning/ exists ───────────────────────────────────────────
if (!fs.existsSync(planningDir)) {
addIssue('error', 'E001', '.planning/ directory not found', 'Run /gsd:new-project to initialize');
output({
status: 'broken',
errors,
warnings,
info,
repairable_count: 0,
}, raw);
return;
}
// ─── Check 2: PROJECT.md exists and has required sections ─────────────────
if (!fs.existsSync(projectPath)) {
addIssue('error', 'E002', 'PROJECT.md not found', 'Run /gsd:new-project to create');
} else {
const content = fs.readFileSync(projectPath, 'utf-8');
const requiredSections = ['## What This Is', '## Core Value', '## Requirements'];
for (const section of requiredSections) {
if (!content.includes(section)) {
addIssue('warning', 'W001', `PROJECT.md missing section: ${section}`, 'Add section manually');
}
}
}
// ─── Check 3: ROADMAP.md exists ───────────────────────────────────────────
if (!fs.existsSync(roadmapPath)) {
addIssue('error', 'E003', 'ROADMAP.md not found', 'Run /gsd:new-milestone to create roadmap');
}
// ─── Check 4: STATE.md exists and references valid phases ─────────────────
if (!fs.existsSync(statePath)) {
addIssue('error', 'E004', 'STATE.md not found', 'Run /gsd:health --repair to regenerate', true);
repairs.push('regenerateState');
} else {
const stateContent = fs.readFileSync(statePath, 'utf-8');
// Extract phase references from STATE.md
const phaseRefs = [...stateContent.matchAll(/[Pp]hase\s+(\d+(?:\.\d+)?)/g)].map(m => m[1]);
// Get disk phases
const diskPhases = new Set();
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
for (const e of entries) {
if (e.isDirectory()) {
const m = e.name.match(/^(\d+(?:\.\d+)?)/);
if (m) diskPhases.add(m[1]);
}
}
} catch {}
// Check for invalid references
for (const ref of phaseRefs) {
const normalizedRef = String(parseInt(ref, 10)).padStart(2, '0');
if (!diskPhases.has(ref) && !diskPhases.has(normalizedRef) && !diskPhases.has(String(parseInt(ref, 10)))) {
// Only warn if phases dir has any content (not just an empty project)
if (diskPhases.size > 0) {
addIssue('warning', 'W002', `STATE.md references phase ${ref}, but only phases ${[...diskPhases].sort().join(', ')} exist`, 'Run /gsd:health --repair to regenerate STATE.md', true);
if (!repairs.includes('regenerateState')) repairs.push('regenerateState');
}
}
}
}
// ─── Check 5: config.json valid JSON + valid schema ───────────────────────
if (!fs.existsSync(configPath)) {
addIssue('warning', 'W003', 'config.json not found', 'Run /gsd:health --repair to create with defaults', true);
repairs.push('createConfig');
} else {
try {
const raw = fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(raw);
// Validate known fields
const validProfiles = ['quality', 'balanced', 'budget'];
if (parsed.model_profile && !validProfiles.includes(parsed.model_profile)) {
addIssue('warning', 'W004', `config.json: invalid model_profile "${parsed.model_profile}"`, `Valid values: ${validProfiles.join(', ')}`);
}
} catch (err) {
addIssue('error', 'E005', `config.json: JSON parse error - ${err.message}`, 'Run /gsd:health --repair to reset to defaults', true);
repairs.push('resetConfig');
}
}
// ─── Check 6: Phase directory naming (NN-name format) ─────────────────────
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
for (const e of entries) {
if (e.isDirectory() && !e.name.match(/^\d{2}(?:\.\d+)?-[\w-]+$/)) {
addIssue('warning', 'W005', `Phase directory "${e.name}" doesn't follow NN-name format`, 'Rename to match pattern (e.g., 01-setup)');
}
}
} catch {}
// ─── Check 7: Orphaned plans (PLAN without SUMMARY) ───────────────────────
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
for (const e of entries) {
if (!e.isDirectory()) continue;
const phaseFiles = fs.readdirSync(path.join(phasesDir, e.name));
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
const summaryBases = new Set(summaries.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '')));
for (const plan of plans) {
const planBase = plan.replace('-PLAN.md', '').replace('PLAN.md', '');
if (!summaryBases.has(planBase)) {
addIssue('info', 'I001', `${e.name}/${plan} has no SUMMARY.md`, 'May be in progress');
}
}
}
} catch {}
// ─── Check 8: Run existing consistency checks ─────────────────────────────
// Inline subset of cmdValidateConsistency
if (fs.existsSync(roadmapPath)) {
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
const roadmapPhases = new Set();
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)?)\s*:/gi;
let m;
while ((m = phasePattern.exec(roadmapContent)) !== null) {
roadmapPhases.add(m[1]);
}
const diskPhases = new Set();
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
for (const e of entries) {
if (e.isDirectory()) {
const dm = e.name.match(/^(\d+[A-Z]?(?:\.\d+)?)/i);
if (dm) diskPhases.add(dm[1]);
}
}
} catch {}
// Phases in ROADMAP but not on disk
for (const p of roadmapPhases) {
const padded = String(parseInt(p, 10)).padStart(2, '0');
if (!diskPhases.has(p) && !diskPhases.has(padded)) {
addIssue('warning', 'W006', `Phase ${p} in ROADMAP.md but no directory on disk`, 'Create phase directory or remove from roadmap');
}
}
// Phases on disk but not in ROADMAP
for (const p of diskPhases) {
const unpadded = String(parseInt(p, 10));
if (!roadmapPhases.has(p) && !roadmapPhases.has(unpadded)) {
addIssue('warning', 'W007', `Phase ${p} exists on disk but not in ROADMAP.md`, 'Add to roadmap or remove directory');
}
}
}
// ─── Perform repairs if requested ─────────────────────────────────────────
const repairActions = [];
if (options.repair && repairs.length > 0) {
for (const repair of repairs) {
try {
switch (repair) {
case 'createConfig':
case 'resetConfig': {
const defaults = {
model_profile: 'balanced',
commit_docs: true,
search_gitignored: false,
branching_strategy: 'none',
research: true,
plan_checker: true,
verifier: true,
parallelization: true,
};
fs.writeFileSync(configPath, JSON.stringify(defaults, null, 2), 'utf-8');
repairActions.push({ action: repair, success: true, path: 'config.json' });
break;
}
case 'regenerateState': {
// Create timestamped backup before overwriting
if (fs.existsSync(statePath)) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const backupPath = `${statePath}.bak-${timestamp}`;
fs.copyFileSync(statePath, backupPath);
repairActions.push({ action: 'backupState', success: true, path: backupPath });
}
// Generate minimal STATE.md from ROADMAP.md structure
const milestone = getMilestoneInfo(cwd);
let stateContent = `# Session State\n\n`;
stateContent += `## Project Reference\n\n`;
stateContent += `See: .planning/PROJECT.md\n\n`;
stateContent += `## Position\n\n`;
stateContent += `**Milestone:** ${milestone.version} ${milestone.name}\n`;
stateContent += `**Current phase:** (determining...)\n`;
stateContent += `**Status:** Resuming\n\n`;
stateContent += `## Session Log\n\n`;
stateContent += `- ${new Date().toISOString().split('T')[0]}: STATE.md regenerated by /gsd:health --repair\n`;
fs.writeFileSync(statePath, stateContent, 'utf-8');
repairActions.push({ action: repair, success: true, path: 'STATE.md' });
break;
}
}
} catch (err) {
repairActions.push({ action: repair, success: false, error: err.message });
}
}
}
// ─── Determine overall status ─────────────────────────────────────────────
let status;
if (errors.length > 0) {
status = 'broken';
} else if (warnings.length > 0) {
status = 'degraded';
} else {
status = 'healthy';
}
const repairableCount = errors.filter(e => e.repairable).length +
warnings.filter(w => w.repairable).length;
output({
status,
errors,
warnings,
info,
repairable_count: repairableCount,
repairs_performed: repairActions.length > 0 ? repairActions : undefined,
}, raw);
}
module.exports = {
cmdVerifySummary,
cmdVerifyPlanStructure,
cmdVerifyPhaseCompleteness,
cmdVerifyReferences,
cmdVerifyCommits,
cmdVerifyArtifacts,
cmdVerifyKeyLinks,
cmdValidateConsistency,
cmdValidateHealth,
};

View File

@@ -0,0 +1,104 @@
---
phase: {N}
slug: {phase-slug}
status: draft
nyquist_compliant: false
wave_0_complete: false
created: {date}
---
# Phase {N} — Validation Strategy
> Generated by `gsd-phase-researcher` during `/gsd:plan-phase {N}`.
> Updated by `gsd-plan-checker` after plan approval.
> Governs feedback sampling during `/gsd:execute-phase {N}`.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | {pytest 7.x / jest 29.x / vitest / go test / other} |
| **Config file** | {path/to/pytest.ini or "none — Wave 0 installs"} |
| **Quick run command** | `{e.g., pytest -x --tb=short}` |
| **Full suite command** | `{e.g., pytest tests/ --tb=short}` |
| **Estimated runtime** | ~{N} seconds |
| **CI pipeline** | {.github/workflows/test.yml — exists / needs creation} |
---
## Nyquist Sampling Rate
> The minimum feedback frequency required to reliably catch errors in this phase.
- **After every task commit:** Run `{quick run command}`
- **After every plan wave:** Run `{full suite command}`
- **Before `/gsd:verify-work`:** Full suite must be green
- **Maximum acceptable task feedback latency:** {N} seconds
---
## Per-Task Verification Map
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
| {N}-01-01 | 01 | 1 | REQ-{XX} | unit | `pytest tests/test_{module}.py::test_{name} -x` | ✅ / ❌ W0 | ⬜ pending |
| {N}-01-02 | 01 | 1 | REQ-{XX} | integration | `pytest tests/test_{flow}.py -x` | ✅ / ❌ W0 | ⬜ pending |
| {N}-02-01 | 02 | 2 | REQ-{XX} | smoke | `curl -s {endpoint} \| grep {expected}` | ✅ N/A | ⬜ pending |
*Status values: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
> Test scaffolding committed BEFORE any implementation task. Executor runs Wave 0 first.
- [ ] `{tests/test_file.py}` — stubs for REQ-{XX}, REQ-{XX}
- [ ] `{tests/conftest.py}` — shared fixtures
- [ ] `{framework install}` — if no framework detected
*If none required: "Existing infrastructure covers all phase requirements — no Wave 0 test tasks needed."*
---
## Manual-Only Verifications
> Behaviors that genuinely cannot be automated, with justification.
> These are surfaced during `/gsd:verify-work` UAT.
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| {behavior} | REQ-{XX} | {reason: visual, third-party auth, physical device...} | {step-by-step} |
*If none: "All phase behaviors have automated verification coverage."*
---
## Validation Sign-Off
Updated by `gsd-plan-checker` when plans are approved:
- [ ] All tasks have `<automated>` verify commands or Wave 0 dependencies
- [ ] No 3 consecutive implementation tasks without automated verify (sampling continuity)
- [ ] Wave 0 test files cover all MISSING references
- [ ] No watch-mode flags in any automated command
- [ ] Feedback latency per task: < {N}s ✅
- [ ] `nyquist_compliant: true` set in frontmatter
**Plan-checker approval:** {pending / approved on YYYY-MM-DD}
---
## Execution Tracking
Updated during `/gsd:execute-phase {N}`:
| Wave | Tasks | Tests Run | Pass | Fail | Sampling Status |
|------|-------|-----------|------|------|-----------------|
| 0 | {N} | — | — | — | scaffold |
| 1 | {N} | {command} | {N} | {N} | ✅ sampled |
| 2 | {N} | {command} | {N} | {N} | ✅ sampled |
**Phase validation complete:** {pending / YYYY-MM-DD HH:MM}

View File

@@ -5,7 +5,8 @@
"research": true,
"plan_check": true,
"verifier": true,
"auto_advance": false
"auto_advance": false,
"nyquist_validation": false
},
"planning": {
"commit_docs": true,

View File

@@ -79,7 +79,7 @@ For each gap, fill the debug-subagent-prompt template and spawn:
```
Task(
prompt=filled_debug_subagent_prompt,
prompt=filled_debug_subagent_prompt + "\n\n<files_to_read>\n- {phase_dir}/{phase_num}-UAT.md\n- .planning/STATE.md\n</files_to_read>",
subagent_type="general-purpose",
description="Debug: {truth_short}"
)

View File

@@ -206,9 +206,10 @@ We'll clarify HOW to implement this.
**Then use AskUserQuestion (multiSelect: true):**
- header: "Discuss"
- question: "Which areas do you want to discuss for [phase name]?"
- options: Generate 3-4 phase-specific gray areas, each formatted as:
- options: Generate 3-4 phase-specific gray areas, each with:
- "[Specific area]" (label) — concrete, not generic
- [1-2 questions this covers] (description)
- **Highlight the recommended choice with brief explanation why**
**Do NOT include a "skip" or "you decide" option.** User ran this command to discuss — give them real choices.
@@ -258,7 +259,7 @@ Ask 4 questions per area before offering to continue or move on. Each answer oft
2. **Ask 4 questions using AskUserQuestion:**
- header: "[Area]" (max 12 chars — abbreviate if needed)
- question: Specific decision for this area
- options: 2-3 concrete choices (AskUserQuestion adds "Other" automatically)
- options: 2-3 concrete choices (AskUserQuestion adds "Other" automatically), with the recommended choice highlighted and brief explanation why
- Include "You decide" as an option when reasonable — captures Claude discretion
3. **After 4 questions, check:**
@@ -270,10 +271,17 @@ Ask 4 questions per area before offering to continue or move on. Each answer oft
If "Next area" → proceed to next selected area
If "Other" (free text) → interpret intent: continuation phrases ("chat more", "keep going", "yes", "more") map to "More questions"; advancement phrases ("done", "move on", "next", "skip") map to "Next area". If ambiguous, ask: "Continue with more questions about [area], or move to the next area?"
4. **After all areas complete:**
- header: "Done"
- question: "That covers [list areas]. Ready to create context?"
- options: "Create context" / "Revisit an area"
4. **After all initially-selected areas complete:**
- Summarize what was captured from the discussion so far
- AskUserQuestion:
- header: "Done"
- question: "We've discussed [list areas]. Which gray areas remain unclear?"
- options: "Explore more gray areas" / "I'm ready for context"
- If "Explore more gray areas":
- Identify 2-4 additional gray areas based on what was learned
- Return to present_gray_areas logic with these new areas
- Loop: discuss new areas, then prompt again
- If "I'm ready for context": Proceed to write_context
**Question design:**
- Options should be concrete, not abstract ("Cards" not "Option A")
@@ -452,23 +460,65 @@ Display banner:
Context captured. Spawning plan-phase...
```
Spawn plan-phase as Task:
Spawn plan-phase as Task with direct workflow file reference (do NOT use Skill tool — Skills don't resolve inside Task subagents):
```
Task(
prompt="Run /gsd:plan-phase ${PHASE} --auto",
prompt="
<objective>
You are the plan-phase orchestrator. Create executable plans for Phase ${PHASE}: ${PHASE_NAME}, then auto-advance to execution.
</objective>
<execution_context>
@~/.claude/get-shit-done/workflows/plan-phase.md
@~/.claude/get-shit-done/references/ui-brand.md
@~/.claude/get-shit-done/references/model-profile-resolution.md
</execution_context>
<arguments>
PHASE=${PHASE}
ARGUMENTS='${PHASE} --auto'
</arguments>
<instructions>
1. Read plan-phase.md from execution_context for your complete workflow
2. Follow ALL steps: initialize, validate, load context, research, plan, verify, auto-advance
3. When spawning agents (gsd-phase-researcher, gsd-planner, gsd-plan-checker), use Task with specified subagent_type and model
4. For step 14 (auto-advance to execute): spawn execute-phase as a Task with DIRECT file reference — tell it to read execute-phase.md. Include @file refs to execute-phase.md, checkpoints.md, tdd.md, model-profile-resolution.md. Pass --no-transition flag so execute-phase returns results instead of chaining further.
5. Do NOT use the Skill tool or /gsd: commands. Read workflow .md files directly.
6. Return: PHASE COMPLETE (full pipeline success), PLANNING COMPLETE (planning done but execute failed/skipped), PLANNING INCONCLUSIVE, or GAPS FOUND
</instructions>
",
subagent_type="general-purpose",
description="Plan Phase ${PHASE}"
)
```
**Handle plan-phase return:**
- **PLANNING COMPLETE** → Plan-phase handles chaining to execute-phase (via its own auto_advance step)
- **PLANNING INCONCLUSIVE / CHECKPOINT** → Display result, stop chain:
- **PHASE COMPLETE** → Full chain succeeded. Display:
```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
GSD ► PHASE ${PHASE} COMPLETE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Auto-advance pipeline finished: discuss → plan → execute
Next: /gsd:discuss-phase ${NEXT_PHASE} --auto
<sub>/clear first → fresh context window</sub>
```
- **PLANNING COMPLETE** → Planning done, execution didn't complete:
```
Auto-advance partial: Planning complete, execution did not finish.
Continue: /gsd:execute-phase ${PHASE}
```
- **PLANNING INCONCLUSIVE / CHECKPOINT** → Stop chain:
```
Auto-advance stopped: Planning needs input.
Review the output above and continue manually:
/gsd:plan-phase ${PHASE}
Continue: /gsd:plan-phase ${PHASE}
```
- **GAPS FOUND** → Stop chain:
```
Auto-advance stopped: Gaps found during execution.
Continue: /gsd:plan-phase ${PHASE} --gaps
```
**If neither `--auto` nor config enabled:**

View File

@@ -106,7 +106,7 @@ Execute each wave in sequence. Within a wave: parallel if `PARALLELIZATION=true`
prompt="
<objective>
Execute plan {plan_number} of phase {phase_number}-{phase_name}.
Commit each task atomically. Create SUMMARY.md. Update STATE.md.
Commit each task atomically. Create SUMMARY.md. Update STATE.md and ROADMAP.md.
</objective>
<execution_context>
@@ -118,9 +118,11 @@ Execute each wave in sequence. Within a wave: parallel if `PARALLELIZATION=true`
<files_to_read>
Read these files at execution start using the Read tool:
- Plan: {phase_dir}/{plan_file}
- State: .planning/STATE.md
- Config: .planning/config.json (if exists)
- {phase_dir}/{plan_file} (Plan)
- .planning/STATE.md (State)
- .planning/config.json (Config, if exists)
- ./CLAUDE.md (Project instructions, if exists — follow project-specific guidelines and coding conventions)
- .agents/skills/ (Project skills, if exists — list skills, read SKILL.md for each, follow relevant rules during implementation)
</files_to_read>
<success_criteria>
@@ -128,6 +130,7 @@ Execute each wave in sequence. Within a wave: parallel if `PARALLELIZATION=true`
- [ ] Each task committed individually
- [ ] SUMMARY.md created in plan directory
- [ ] STATE.md updated with position and decisions
- [ ] ROADMAP.md updated with plan progress (via `roadmap update-plan-progress`)
</success_criteria>
"
)
@@ -372,7 +375,7 @@ The CLI handles:
Extract from result: `next_phase`, `next_phase_name`, `is_last_phase`.
```bash
node ~/.claude/get-shit-done/bin/gsd-tools.cjs commit "docs(phase-{X}): complete phase execution" --files .planning/ROADMAP.md .planning/STATE.md .planning/REQUIREMENTS.md .planning/phases/{phase_dir}/*-VERIFICATION.md
node ~/.claude/get-shit-done/bin/gsd-tools.cjs commit "docs(phase-{X}): complete phase execution" --files .planning/ROADMAP.md .planning/STATE.md .planning/REQUIREMENTS.md {phase_dir}/*-VERIFICATION.md
```
</step>
@@ -380,6 +383,29 @@ node ~/.claude/get-shit-done/bin/gsd-tools.cjs commit "docs(phase-{X}): complete
**Exception:** If `gaps_found`, the `verify_phase_goal` step already presents the gap-closure path (`/gsd:plan-phase {X} --gaps`). No additional routing needed — skip auto-advance.
**No-transition check (spawned by auto-advance chain):**
Parse `--no-transition` flag from $ARGUMENTS.
**If `--no-transition` flag present:**
Execute-phase was spawned by plan-phase's auto-advance. Do NOT run transition.md.
After verification passes and roadmap is updated, return completion status to parent:
```
## PHASE COMPLETE
Phase: ${PHASE_NUMBER} - ${PHASE_NAME}
Plans: ${completed_count}/${total_count}
Verification: {Passed | Gaps Found}
[Include aggregate_results output]
```
STOP. Do not proceed to auto-advance or transition.
**If `--no-transition` flag is NOT present:**
**Auto-advance detection:**
1. Parse `--auto` flag from $ARGUMENTS

View File

@@ -12,19 +12,13 @@ Read config.json for planning behavior settings.
<process>
<step name="init_context" priority="first">
Load execution context (uses `init execute-phase` for full context, including file contents):
Load execution context (paths only to minimize orchestrator context):
```bash
INIT=$(node ~/.claude/get-shit-done/bin/gsd-tools.cjs init execute-phase "${PHASE}" --include state,config)
INIT=$(node ~/.claude/get-shit-done/bin/gsd-tools.cjs init execute-phase "${PHASE}")
```
Extract from init JSON: `executor_model`, `commit_docs`, `phase_dir`, `phase_number`, `plans`, `summaries`, `incomplete_plans`.
**File contents (from --include):** `state_content`, `config_content`. Access with:
```bash
STATE_CONTENT=$(echo "$INIT" | jq -r '.state_content // empty')
CONFIG_CONTENT=$(echo "$INIT" | jq -r '.config_content // empty')
```
Extract from init JSON: `executor_model`, `commit_docs`, `phase_dir`, `phase_number`, `plans`, `summaries`, `incomplete_plans`, `state_path`, `config_path`.
If `.planning/` missing: error.
</step>
@@ -40,7 +34,7 @@ Find first PLAN without matching SUMMARY. Decimal phases supported (`01.1-hotfix
```bash
PHASE=$(echo "$PLAN_PATH" | grep -oE '[0-9]+(\.[0-9]+)?-[0-9]+')
# config_content already loaded via --include config in init_context
# config settings can be fetched via gsd-tools config-get if needed
```
<if mode="yolo">
@@ -386,11 +380,21 @@ node ~/.claude/get-shit-done/bin/gsd-tools.cjs roadmap update-plan-progress "${P
Counts PLAN vs SUMMARY files on disk. Updates progress table row with correct count and status (`In Progress` or `Complete` with date).
</step>
<step name="update_requirements">
Mark completed requirements from the PLAN.md frontmatter `requirements:` field:
```bash
node ~/.claude/get-shit-done/bin/gsd-tools.cjs requirements mark-complete ${REQ_IDS}
```
Extract requirement IDs from the plan's frontmatter (e.g., `requirements: [AUTH-01, AUTH-02]`). If no requirements field, skip.
</step>
<step name="git_commit_metadata">
Task code already committed per-task. Commit plan metadata:
```bash
node ~/.claude/get-shit-done/bin/gsd-tools.cjs commit "docs({phase}-{plan}): complete [plan-name] plan" --files .planning/phases/XX-name/{phase}-{plan}-SUMMARY.md .planning/STATE.md .planning/ROADMAP.md
node ~/.claude/get-shit-done/bin/gsd-tools.cjs commit "docs({phase}-{plan}): complete [plan-name] plan" --files .planning/phases/XX-name/{phase}-{plan}-SUMMARY.md .planning/STATE.md .planning/ROADMAP.md .planning/REQUIREMENTS.md
```
</step>

View File

@@ -90,17 +90,13 @@ Use Task tool with `subagent_type="gsd-codebase-mapper"`, `model="{mapper_model}
**Agent 1: Tech Focus**
Task tool parameters:
```
subagent_type: "gsd-codebase-mapper"
model: "{mapper_model}"
run_in_background: true
description: "Map codebase tech stack"
```
Prompt:
```
Focus: tech
Task(
subagent_type="gsd-codebase-mapper",
model="{mapper_model}",
run_in_background=true,
description="Map codebase tech stack",
prompt="Focus: tech
Analyze this codebase for technology stack and external integrations.
@@ -108,22 +104,19 @@ Write these documents to .planning/codebase/:
- STACK.md - Languages, runtime, frameworks, dependencies, configuration
- INTEGRATIONS.md - External APIs, databases, auth providers, webhooks
Explore thoroughly. Write documents directly using templates. Return confirmation only.
Explore thoroughly. Write documents directly using templates. Return confirmation only."
)
```
**Agent 2: Architecture Focus**
Task tool parameters:
```
subagent_type: "gsd-codebase-mapper"
model: "{mapper_model}"
run_in_background: true
description: "Map codebase architecture"
```
Prompt:
```
Focus: arch
Task(
subagent_type="gsd-codebase-mapper",
model="{mapper_model}",
run_in_background=true,
description="Map codebase architecture",
prompt="Focus: arch
Analyze this codebase architecture and directory structure.
@@ -131,22 +124,19 @@ Write these documents to .planning/codebase/:
- ARCHITECTURE.md - Pattern, layers, data flow, abstractions, entry points
- STRUCTURE.md - Directory layout, key locations, naming conventions
Explore thoroughly. Write documents directly using templates. Return confirmation only.
Explore thoroughly. Write documents directly using templates. Return confirmation only."
)
```
**Agent 3: Quality Focus**
Task tool parameters:
```
subagent_type: "gsd-codebase-mapper"
model: "{mapper_model}"
run_in_background: true
description: "Map codebase conventions"
```
Prompt:
```
Focus: quality
Task(
subagent_type="gsd-codebase-mapper",
model="{mapper_model}",
run_in_background=true,
description="Map codebase conventions",
prompt="Focus: quality
Analyze this codebase for coding conventions and testing patterns.
@@ -154,29 +144,27 @@ Write these documents to .planning/codebase/:
- CONVENTIONS.md - Code style, naming, patterns, error handling
- TESTING.md - Framework, structure, mocking, coverage
Explore thoroughly. Write documents directly using templates. Return confirmation only.
Explore thoroughly. Write documents directly using templates. Return confirmation only."
)
```
**Agent 4: Concerns Focus**
Task tool parameters:
```
subagent_type: "gsd-codebase-mapper"
model: "{mapper_model}"
run_in_background: true
description: "Map codebase concerns"
```
Prompt:
```
Focus: concerns
Task(
subagent_type="gsd-codebase-mapper",
model="{mapper_model}",
run_in_background=true,
description="Map codebase concerns",
prompt="Focus: concerns
Analyze this codebase for technical debt, known issues, and areas of concern.
Write this document to .planning/codebase/:
- CONCERNS.md - Tech debt, bugs, security, performance, fragile areas
Explore thoroughly. Write document directly using template. Return confirmation only.
Explore thoroughly. Write document directly using template. Return confirmation only."
)
```
Continue to collect_confirmations.

View File

@@ -128,7 +128,9 @@ Focus ONLY on what's needed for the NEW features.
<question>{QUESTION}</question>
<project_context>[PROJECT.md summary]</project_context>
<files_to_read>
- .planning/PROJECT.md (Project context)
</files_to_read>
<downstream_consumer>{CONSUMER}</downstream_consumer>
@@ -157,7 +159,12 @@ After all 4 complete, spawn synthesizer:
Task(prompt="
Synthesize research outputs into SUMMARY.md.
Read: .planning/research/STACK.md, FEATURES.md, ARCHITECTURE.md, PITFALLS.md
<files_to_read>
- .planning/research/STACK.md
- .planning/research/FEATURES.md
- .planning/research/ARCHITECTURE.md
- .planning/research/PITFALLS.md
</files_to_read>
Write to: .planning/research/SUMMARY.md
Use template: ~/.claude/get-shit-done/templates/research-project/SUMMARY.md
@@ -264,11 +271,13 @@ node ~/.claude/get-shit-done/bin/gsd-tools.cjs commit "docs: define milestone v[
```
Task(prompt="
<planning_context>
@.planning/PROJECT.md
@.planning/REQUIREMENTS.md
@.planning/research/SUMMARY.md (if exists)
@.planning/config.json
@.planning/MILESTONES.md
<files_to_read>
- .planning/PROJECT.md
- .planning/REQUIREMENTS.md
- .planning/research/SUMMARY.md (if exists)
- .planning/config.json
- .planning/MILESTONES.md
</files_to_read>
</planning_context>
<instructions>

View File

@@ -49,7 +49,7 @@ The document should describe what you want to build.
INIT=$(node ~/.claude/get-shit-done/bin/gsd-tools.cjs init new-project)
```
Parse JSON for: `researcher_model`, `synthesizer_model`, `roadmapper_model`, `commit_docs`, `project_exists`, `has_codebase_map`, `planning_exists`, `has_existing_code`, `has_package_file`, `is_brownfield`, `needs_codebase_map`, `has_git`.
Parse JSON for: `researcher_model`, `synthesizer_model`, `roadmapper_model`, `commit_docs`, `project_exists`, `has_codebase_map`, `planning_exists`, `has_existing_code`, `has_package_file`, `is_brownfield`, `needs_codebase_map`, `has_git`, `project_path`.
**If `project_exists` is true:** Error — project already initialized. Use `/gsd:progress`.
@@ -541,7 +541,7 @@ Display spawning indicator:
→ Pitfalls research
```
Spawn 4 parallel gsd-project-researcher agents with rich context:
Spawn 4 parallel gsd-project-researcher agents with path references:
```
Task(prompt="First, read ~/.claude/agents/gsd-project-researcher.md for your role and instructions.
@@ -561,9 +561,9 @@ Subsequent: Research what's needed to add [target features] to an existing [doma
What's the standard 2025 stack for [domain]?
</question>
<project_context>
[PROJECT.md summary - core value, constraints, what they're building]
</project_context>
<files_to_read>
- {project_path} (Project context and goals)
</files_to_read>
<downstream_consumer>
Your STACK.md feeds into roadmap creation. Be prescriptive:
@@ -601,9 +601,9 @@ Subsequent: How do [target features] typically work? What's expected behavior?
What features do [domain] products have? What's table stakes vs differentiating?
</question>
<project_context>
[PROJECT.md summary]
</project_context>
<files_to_read>
- {project_path} (Project context)
</files_to_read>
<downstream_consumer>
Your FEATURES.md feeds into requirements definition. Categorize clearly:
@@ -641,9 +641,9 @@ Subsequent: How do [target features] integrate with existing [domain] architectu
How are [domain] systems typically structured? What are major components?
</question>
<project_context>
[PROJECT.md summary]
</project_context>
<files_to_read>
- {project_path} (Project context)
</files_to_read>
<downstream_consumer>
Your ARCHITECTURE.md informs phase structure in roadmap. Include:
@@ -681,9 +681,9 @@ Subsequent: What are common mistakes when adding [target features] to [domain]?
What do [domain] projects commonly get wrong? Critical mistakes?
</question>
<project_context>
[PROJECT.md summary]
</project_context>
<files_to_read>
- {project_path} (Project context)
</files_to_read>
<downstream_consumer>
Your PITFALLS.md prevents mistakes in roadmap/planning. For each pitfall:
@@ -713,13 +713,12 @@ Task(prompt="
Synthesize research outputs into SUMMARY.md.
</task>
<research_files>
Read these files:
<files_to_read>
- .planning/research/STACK.md
- .planning/research/FEATURES.md
- .planning/research/ARCHITECTURE.md
- .planning/research/PITFALLS.md
</research_files>
</files_to_read>
<output>
Write to: .planning/research/SUMMARY.md
@@ -902,23 +901,18 @@ Display stage banner:
◆ Spawning roadmapper...
```
Spawn gsd-roadmapper agent with context:
Spawn gsd-roadmapper agent with path references:
```
Task(prompt="
<planning_context>
**Project:**
@.planning/PROJECT.md
**Requirements:**
@.planning/REQUIREMENTS.md
**Research (if exists):**
@.planning/research/SUMMARY.md
**Config:**
@.planning/config.json
<files_to_read>
- .planning/PROJECT.md (Project context)
- .planning/REQUIREMENTS.md (v1 Requirements)
- .planning/research/SUMMARY.md (Research findings - if exists)
- .planning/config.json (Depth and mode settings)
</files_to_read>
</planning_context>
@@ -1006,7 +1000,9 @@ Use AskUserQuestion:
User feedback on roadmap:
[user's notes]
Current ROADMAP.md: @.planning/ROADMAP.md
<files_to_read>
- .planning/ROADMAP.md (Current roadmap to revise)
</files_to_read>
Update the roadmap based on feedback. Edit files in place.
Return ROADMAP REVISED with changes made.

View File

@@ -12,23 +12,15 @@ Read all files referenced by the invoking prompt's execution_context before star
## 1. Initialize
Load all context in one call (include file contents to avoid redundant reads):
Load all context in one call (paths only to minimize orchestrator context):
```bash
INIT_RAW=$(node ~/.claude/get-shit-done/bin/gsd-tools.cjs init plan-phase "$PHASE" --include state,roadmap,requirements,context,research,verification,uat)
# Large payloads are written to a tmpfile — output starts with @file:/path
if [[ "$INIT_RAW" == @file:* ]]; then
INIT_FILE="${INIT_RAW#@file:}"
INIT=$(cat "$INIT_FILE")
rm -f "$INIT_FILE"
else
INIT="$INIT_RAW"
fi
INIT=$(node ~/.claude/get-shit-done/bin/gsd-tools.cjs init plan-phase "$PHASE")
```
Parse JSON for: `researcher_model`, `planner_model`, `checker_model`, `research_enabled`, `plan_checker_enabled`, `commit_docs`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `has_research`, `has_context`, `has_plans`, `plan_count`, `planning_exists`, `roadmap_exists`.
Parse JSON for: `researcher_model`, `planner_model`, `checker_model`, `research_enabled`, `plan_checker_enabled`, `nyquist_validation_enabled`, `commit_docs`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `has_research`, `has_context`, `has_plans`, `plan_count`, `planning_exists`, `roadmap_exists`.
**File contents (from --include):** `state_content`, `roadmap_content`, `requirements_content`, `context_content`, `research_content`, `verification_content`, `uat_content`. These are null if files don't exist.
**File paths (for <files_to_read> blocks):** `state_path`, `roadmap_path`, `requirements_path`, `context_path`, `research_path`, `verification_path`, `uat_path`. These are null if files don't exist.
**If `planning_exists` is false:** Error — run `/gsd:new-project` first.
@@ -55,13 +47,11 @@ PHASE_INFO=$(node ~/.claude/get-shit-done/bin/gsd-tools.cjs roadmap get-phase "$
## 4. Load CONTEXT.md
Use `context_content` from init JSON (already loaded via `--include context`).
Check `context_path` from init JSON.
**CRITICAL:** Use `context_content` from INIT — pass to researcher, planner, checker, and revision agents.
If `context_path` is not null, display: `Using phase context from: ${context_path}`
If `context_content` is not null, display: `Using phase context from: ${PHASE_DIR}/*-CONTEXT.md`
**If `context_content` is null (no CONTEXT.md exists):**
**If `context_path` is null (no CONTEXT.md exists):**
Use AskUserQuestion:
- header: "No context"
@@ -94,11 +84,7 @@ Display banner:
```bash
PHASE_DESC=$(node ~/.claude/get-shit-done/bin/gsd-tools.cjs roadmap get-phase "${PHASE}" | jq -r '.section')
# Use requirements_content from INIT (already loaded via --include requirements)
REQUIREMENTS=$(echo "$INIT" | jq -r '.requirements_content // empty' | grep -A100 "## Requirements" | head -50)
PHASE_REQ_IDS=$(echo "$INIT" | jq -r '.roadmap_content // empty' | grep -i "Requirements:" | head -1 | sed 's/.*Requirements:\*\*\s*//' | sed 's/[\[\]]//g' | tr ',' '\n' | sed 's/^ *//;s/ *$//' | grep -v '^$' | tr '\n' ',' | sed 's/,$//')
STATE_SNAP=$(node ~/.claude/get-shit-done/bin/gsd-tools.cjs state-snapshot)
# Extract decisions from state-snapshot JSON: jq '.decisions[] | "\(.phase): \(.summary) - \(.rationale)"'
PHASE_REQ_IDS=$(node ~/.claude/get-shit-done/bin/gsd-tools.cjs roadmap get-phase "${PHASE}" | jq -r '.section // empty' | grep -i "Requirements:" | head -1 | sed 's/.*Requirements:\*\*\s*//' | sed 's/[\[\]]//g' | tr ',' '\n' | sed 's/^ *//;s/ *$//' | grep -v '^$' | tr '\n' ',' | sed 's/,$//')
```
Research prompt:
@@ -109,20 +95,18 @@ Research how to implement Phase {phase_number}: {phase_name}
Answer: "What do I need to know to PLAN this phase well?"
</objective>
<phase_context>
IMPORTANT: If CONTEXT.md exists below, it contains user decisions from /gsd:discuss-phase.
- **Decisions** = Locked — research THESE deeply, no alternatives
- **Claude's Discretion** = Freedom areas — research options, recommend
- **Deferred Ideas** = Out of scope — ignore
{context_content}
</phase_context>
<files_to_read>
- {context_path} (USER DECISIONS from /gsd:discuss-phase)
- {requirements_path} (Project requirements)
- {state_path} (Project decisions and history)
</files_to_read>
<additional_context>
**Phase description:** {phase_description}
**Phase requirement IDs (MUST address):** {phase_req_ids}
**Requirements:** {requirements}
**Prior decisions:** {decisions}
**Project instructions:** Read ./CLAUDE.md if exists — follow project-specific guidelines
**Project skills:** Check .agents/skills/ directory (if exists) — read SKILL.md files, research should account for project skill patterns
</additional_context>
<output>
@@ -144,6 +128,31 @@ Task(
- **`## RESEARCH COMPLETE`:** Display confirmation, continue to step 6
- **`## RESEARCH BLOCKED`:** Display blocker, offer: 1) Provide context, 2) Skip research, 3) Abort
## 5.5. Create Validation Strategy (if Nyquist enabled)
**Skip if:** `nyquist_validation_enabled` is false from INIT JSON.
After researcher completes, check if RESEARCH.md contains a Validation Architecture section:
```bash
grep -l "## Validation Architecture" "${PHASE_DIR}"/*-RESEARCH.md 2>/dev/null
```
**If found:**
1. Read validation template from `~/.claude/get-shit-done/templates/VALIDATION.md`
2. Write to `${PHASE_DIR}/${PADDED_PHASE}-VALIDATION.md`
3. Fill frontmatter: replace `{N}` with phase number, `{phase-slug}` with phase slug, `{date}` with current date
4. If `commit_docs` is true:
```bash
node ~/.claude/get-shit-done/bin/gsd-tools.cjs commit-docs "docs(phase-${PHASE}): add validation strategy"
```
**If not found (and nyquist enabled):** Display warning:
```
⚠ Nyquist validation enabled but researcher did not produce a Validation Architecture section.
Continuing without validation strategy. Plans may fail Dimension 8 check.
```
## 6. Check Existing Plans
```bash
@@ -152,19 +161,18 @@ ls "${PHASE_DIR}"/*-PLAN.md 2>/dev/null
**If exists:** Offer: 1) Add more plans, 2) View existing, 3) Replan from scratch.
## 7. Use Context Files from INIT
## 7. Use Context Paths from INIT
All file contents are already loaded via `--include` in step 1 (`@` syntax doesn't work across Task() boundaries):
Extract from INIT JSON:
```bash
# Extract from INIT JSON (no need to re-read files)
STATE_CONTENT=$(echo "$INIT" | jq -r '.state_content // empty')
ROADMAP_CONTENT=$(echo "$INIT" | jq -r '.roadmap_content // empty')
REQUIREMENTS_CONTENT=$(echo "$INIT" | jq -r '.requirements_content // empty')
RESEARCH_CONTENT=$(echo "$INIT" | jq -r '.research_content // empty')
VERIFICATION_CONTENT=$(echo "$INIT" | jq -r '.verification_content // empty')
UAT_CONTENT=$(echo "$INIT" | jq -r '.uat_content // empty')
CONTEXT_CONTENT=$(echo "$INIT" | jq -r '.context_content // empty')
STATE_PATH=$(echo "$INIT" | jq -r '.state_path // empty')
ROADMAP_PATH=$(echo "$INIT" | jq -r '.roadmap_path // empty')
REQUIREMENTS_PATH=$(echo "$INIT" | jq -r '.requirements_path // empty')
RESEARCH_PATH=$(echo "$INIT" | jq -r '.research_path // empty')
VERIFICATION_PATH=$(echo "$INIT" | jq -r '.verification_path // empty')
UAT_PATH=$(echo "$INIT" | jq -r '.uat_path // empty')
CONTEXT_PATH=$(echo "$INIT" | jq -r '.context_path // empty')
```
## 8. Spawn gsd-planner Agent
@@ -185,21 +193,20 @@ Planner prompt:
**Phase:** {phase_number}
**Mode:** {standard | gap_closure}
**Project State:** {state_content}
**Roadmap:** {roadmap_content}
<files_to_read>
- {state_path} (Project State)
- {roadmap_path} (Roadmap)
- {requirements_path} (Requirements)
- {context_path} (USER DECISIONS from /gsd:discuss-phase)
- {research_path} (Technical Research)
- {verification_path} (Verification Gaps - if --gaps)
- {uat_path} (UAT Gaps - if --gaps)
</files_to_read>
**Phase requirement IDs (every ID MUST appear in a plan's `requirements` field):** {phase_req_ids}
**Requirements:** {requirements_content}
**Phase Context:**
IMPORTANT: If context exists below, it contains USER DECISIONS from /gsd:discuss-phase.
- **Decisions** = LOCKED — honor exactly, do not revisit
- **Claude's Discretion** = Freedom — make implementation choices
- **Deferred Ideas** = Out of scope — do NOT include
{context_content}
**Research:** {research_content}
**Gap Closure (if --gaps):** {verification_content} {uat_content}
**Project instructions:** Read ./CLAUDE.md if exists — follow project-specific guidelines
**Project skills:** Check .agents/skills/ directory (if exists) — read SKILL.md files, plans should account for project skill rules
</planning_context>
<downstream_consumer>
@@ -246,10 +253,6 @@ Display banner:
◆ Spawning plan checker...
```
```bash
PLANS_CONTENT=$(cat "${PHASE_DIR}"/*-PLAN.md 2>/dev/null)
```
Checker prompt:
```markdown
@@ -257,17 +260,18 @@ Checker prompt:
**Phase:** {phase_number}
**Phase Goal:** {goal from ROADMAP}
**Plans to verify:** {plans_content}
<files_to_read>
- {PHASE_DIR}/*-PLAN.md (Plans to verify)
- {roadmap_path} (Roadmap)
- {requirements_path} (Requirements)
- {context_path} (USER DECISIONS from /gsd:discuss-phase)
- {research_path} (Technical Research — includes Validation Architecture)
</files_to_read>
**Phase requirement IDs (MUST ALL be covered):** {phase_req_ids}
**Requirements:** {requirements_content}
**Phase Context:**
IMPORTANT: Plans MUST honor user decisions. Flag as issue if plans contradict.
- **Decisions** = LOCKED — plans must implement exactly
- **Claude's Discretion** = Freedom areas — plans can choose approach
- **Deferred Ideas** = Out of scope — plans must NOT include
{context_content}
**Project instructions:** Read ./CLAUDE.md if exists — verify plans honor project guidelines
**Project skills:** Check .agents/skills/ directory (if exists) — verify plans account for project skill rules
</verification_context>
<expected_output>
@@ -298,10 +302,6 @@ Track `iteration_count` (starts at 1 after initial plan + check).
Display: `Sending back to planner for revision... (iteration {N}/3)`
```bash
PLANS_CONTENT=$(cat "${PHASE_DIR}"/*-PLAN.md 2>/dev/null)
```
Revision prompt:
```markdown
@@ -309,12 +309,12 @@ Revision prompt:
**Phase:** {phase_number}
**Mode:** revision
**Existing plans:** {plans_content}
**Checker issues:** {structured_issues_from_checker}
<files_to_read>
- {PHASE_DIR}/*-PLAN.md (Existing plans)
- {context_path} (USER DECISIONS from /gsd:discuss-phase)
</files_to_read>
**Phase Context:**
Revisions MUST still honor user decisions.
{context_content}
**Checker issues:** {structured_issues_from_checker}
</revision_context>
<instructions>
@@ -366,10 +366,36 @@ Display banner:
Plans ready. Spawning execute-phase...
```
Spawn execute-phase as Task:
Spawn execute-phase as Task with direct workflow file reference (do NOT use Skill tool — Skills don't resolve inside Task subagents):
```
Task(
prompt="Run /gsd:execute-phase ${PHASE} --auto",
prompt="
<objective>
You are the execute-phase orchestrator. Execute all plans for Phase ${PHASE}: ${PHASE_NAME}.
</objective>
<execution_context>
@~/.claude/get-shit-done/workflows/execute-phase.md
@~/.claude/get-shit-done/references/checkpoints.md
@~/.claude/get-shit-done/references/tdd.md
@~/.claude/get-shit-done/references/model-profile-resolution.md
</execution_context>
<arguments>
PHASE=${PHASE}
ARGUMENTS='${PHASE} --auto --no-transition'
</arguments>
<instructions>
1. Read execute-phase.md from execution_context for your complete workflow
2. Follow ALL steps: initialize, handle_branching, validate_phase, discover_and_group_plans, execute_waves, aggregate_results, close_parent_artifacts, verify_phase_goal, update_roadmap
3. The --no-transition flag means: after verification + roadmap update, STOP and return status. Do NOT run transition.md.
4. When spawning executor agents, use subagent_type='gsd-executor' with the existing @file pattern from the workflow
5. When spawning verifier agents, use subagent_type='gsd-verifier'
6. Preserve the classifyHandoffIfNeeded workaround (spot-check on that specific error)
7. Do NOT use the Skill tool or /gsd: commands
</instructions>
",
subagent_type="general-purpose",
description="Execute Phase ${PHASE}"
)

View File

@@ -9,23 +9,13 @@ Read all files referenced by the invoking prompt's execution_context before star
<process>
<step name="init_context">
**Load progress context (with file contents to avoid redundant reads):**
**Load progress context (paths only):**
```bash
INIT_RAW=$(node ~/.claude/get-shit-done/bin/gsd-tools.cjs init progress --include state,roadmap,project,config)
# Large payloads are written to a tmpfile — output starts with @file:/path
if [[ "$INIT_RAW" == @file:* ]]; then
INIT_FILE="${INIT_RAW#@file:}"
INIT=$(cat "$INIT_FILE")
rm -f "$INIT_FILE"
else
INIT="$INIT_RAW"
fi
INIT=$(node ~/.claude/get-shit-done/bin/gsd-tools.cjs init progress)
```
Extract from init JSON: `project_exists`, `roadmap_exists`, `state_exists`, `phases`, `current_phase`, `next_phase`, `milestone_version`, `completed_count`, `phase_count`, `paused_at`.
**File contents (from --include):** `state_content`, `roadmap_content`, `project_content`, `config_content`. These are null if files don't exist.
Extract from init JSON: `project_exists`, `roadmap_exists`, `state_exists`, `phases`, `current_phase`, `next_phase`, `milestone_version`, `completed_count`, `phase_count`, `paused_at`, `state_path`, `roadmap_path`, `project_path`, `config_path`.
If `project_exists` is false (no `.planning/` directory):
@@ -47,15 +37,13 @@ If missing both ROADMAP.md and PROJECT.md: suggest `/gsd:new-project`.
</step>
<step name="load">
**Use project context from INIT:**
**Use structured extraction from gsd-tools:**
All file contents are already loaded via `--include` in init_context step:
- `state_content` — living memory (position, decisions, issues)
- `roadmap_content` — phase structure and objectives
- `project_content` — current state (What This Is, Core Value, Requirements)
- `config_content` — settings (model_profile, workflow toggles)
Instead of reading full files, use targeted tools to get only the data needed for the report:
- `ROADMAP=$(node ~/.claude/get-shit-done/bin/gsd-tools.cjs roadmap analyze)`
- `STATE=$(node ~/.claude/get-shit-done/bin/gsd-tools.cjs state-snapshot)`
No additional file reads needed.
This minimizes orchestrator context usage.
</step>
<step name="analyze_roadmap">
@@ -89,9 +77,8 @@ Use this instead of manually reading/parsing ROADMAP.md.
<step name="position">
**Parse current position from init context and roadmap analysis:**
- Use `current_phase` and `next_phase` from roadmap analyze
- Use phase-level `has_context` and `has_research` flags from analyze
- Note `paused_at` if work was paused (from init context)
- Use `current_phase` and `next_phase` from `$ROADMAP`
- Note `paused_at` if work was paused (from `$STATE`)
- Count pending todos: use `init todos` or `list-todos`
- Check for active debug sessions: `ls .planning/debug/*.md 2>/dev/null | grep -v resolved | wc -l`
</step>
@@ -122,11 +109,12 @@ Plan [M] of [phase-total]: [status]
CONTEXT: [✓ if has_context | - if not]
## Key Decisions Made
- [decision 1 from STATE.md]
- [decision 2]
- [extract from $STATE.decisions[]]
- [e.g. jq -r '.decisions[].decision' from state-snapshot]
## Blockers/Concerns
- [any blockers or concerns from STATE.md]
- [extract from $STATE.blockers[]]
- [e.g. jq -r '.blockers[].text' from state-snapshot]
## Pending Todos
- [count] pending — /gsd:check-todos to review

View File

@@ -96,8 +96,12 @@ Task(
**Directory:** ${QUICK_DIR}
**Description:** ${DESCRIPTION}
**Project State:**
@.planning/STATE.md
<files_to_read>
- .planning/STATE.md (Project State)
- ./CLAUDE.md (if exists — follow project-specific guidelines)
</files_to_read>
**Project skills:** Check .agents/skills/ directory (if exists) — read SKILL.md files, plans should account for project skill rules
</planning_context>
@@ -143,10 +147,6 @@ Display banner:
◆ Spawning plan checker...
```
```bash
PLAN_CONTENT=$(cat "${QUICK_DIR}/${next_num}-PLAN.md" 2>/dev/null)
```
Checker prompt:
```markdown
@@ -154,7 +154,9 @@ Checker prompt:
**Mode:** quick-full
**Task Description:** ${DESCRIPTION}
**Plan to verify:** ${PLAN_CONTENT}
<files_to_read>
- ${QUICK_DIR}/${next_num}-PLAN.md (Plan to verify)
</files_to_read>
**Scope:** This is a quick task, not a full phase. Skip checks that require a ROADMAP phase goal.
</verification_context>
@@ -197,17 +199,16 @@ Track `iteration_count` (starts at 1 after initial plan + check).
Display: `Sending back to planner for revision... (iteration ${N}/2)`
```bash
PLAN_CONTENT=$(cat "${QUICK_DIR}/${next_num}-PLAN.md" 2>/dev/null)
```
Revision prompt:
```markdown
<revision_context>
**Mode:** quick-full (revision)
**Existing plan:** ${PLAN_CONTENT}
<files_to_read>
- ${QUICK_DIR}/${next_num}-PLAN.md (Existing plan)
</files_to_read>
**Checker issues:** ${structured_issues_from_checker}
</revision_context>
@@ -247,8 +248,12 @@ Task(
prompt="
Execute quick task ${next_num}.
Plan: @${QUICK_DIR}/${next_num}-PLAN.md
Project state: @.planning/STATE.md
<files_to_read>
- ${QUICK_DIR}/${next_num}-PLAN.md (Plan)
- .planning/STATE.md (Project state)
- ./CLAUDE.md (Project instructions, if exists)
- .agents/skills/ (Project skills, if exists — list skills, read SKILL.md for each, follow relevant rules during implementation)
</files_to_read>
<constraints>
- Execute all tasks in the plan
@@ -294,7 +299,11 @@ Task(
prompt="Verify quick task goal achievement.
Task directory: ${QUICK_DIR}
Task goal: ${DESCRIPTION}
Plan: @${QUICK_DIR}/${next_num}-PLAN.md
<files_to_read>
- ${QUICK_DIR}/${next_num}-PLAN.md (Plan)
</files_to_read>
Check must_haves against actual codebase. Create VERIFICATION.md at ${QUICK_DIR}/${next_num}-VERIFICATION.md.",
subagent_type="gsd-verifier",
model="{verifier_model}",

View File

@@ -34,12 +34,8 @@ If exists: Offer update/view/skip options.
## Step 3: Gather Phase Context
```bash
# Phase section from roadmap (already loaded in PHASE_INFO)
echo "$PHASE_INFO" | jq -r '.section'
cat .planning/REQUIREMENTS.md 2>/dev/null
cat .planning/phases/${PHASE}-*/*-CONTEXT.md 2>/dev/null
# Decisions from state-snapshot (structured JSON)
node ~/.claude/get-shit-done/bin/gsd-tools.cjs state-snapshot | jq '.decisions'
INIT=$(node ~/.claude/get-shit-done/bin/gsd-tools.cjs init phase-op "${PHASE}")
# Extract: phase_dir, padded_phase, phase_number, state_path, requirements_path, context_path
```
## Step 4: Spawn Researcher
@@ -50,12 +46,15 @@ Task(
Research implementation approach for Phase {phase}: {name}
</objective>
<context>
<files_to_read>
- {context_path} (USER DECISIONS from /gsd:discuss-phase)
- {requirements_path} (Project requirements)
- {state_path} (Project decisions and history)
</files_to_read>
<additional_context>
Phase description: {description}
Requirements: {requirements}
Prior decisions: {decisions}
Phase context: {context_md}
</context>
</additional_context>
<output>
Write to: .planning/phases/${PHASE}-{slug}/${PHASE}-RESEARCH.md

View File

@@ -28,6 +28,7 @@ Parse current values (default to `true` if not present):
- `workflow.research` — spawn researcher during plan-phase
- `workflow.plan_check` — spawn plan checker during plan-phase
- `workflow.verifier` — spawn verifier during execute-phase
- `workflow.nyquist_validation` — validation architecture research during plan-phase
- `model_profile` — which model each agent uses (default: `balanced`)
- `git.branching_strategy` — branching approach (default: `"none"`)
</step>
@@ -83,6 +84,15 @@ AskUserQuestion([
{ label: "Yes", description: "Chain stages via Task() subagents (same isolation)" }
]
},
{
question: "Enable Nyquist Validation? (researches test coverage during planning)",
header: "Nyquist",
multiSelect: false,
options: [
{ label: "Yes (Recommended)", description: "Research automated test coverage during plan-phase. Adds validation requirements to plans. Blocks approval if tasks lack automated verify." },
{ label: "No", description: "Skip validation research. Good for rapid prototyping or no-test phases." }
]
},
{
question: "Git branching strategy?",
header: "Branching",
@@ -108,7 +118,8 @@ Merge new settings into existing config.json:
"research": true/false,
"plan_check": true/false,
"verifier": true/false,
"auto_advance": true/false
"auto_advance": true/false,
"nyquist_validation": true/false
},
"git": {
"branching_strategy": "none" | "phase" | "milestone"
@@ -155,7 +166,8 @@ Write `~/.gsd/defaults.json` with:
"research": <current>,
"plan_check": <current>,
"verifier": <current>,
"auto_advance": <current>
"auto_advance": <current>,
"nyquist_validation": <current>
}
}
```
@@ -176,6 +188,7 @@ Display:
| Plan Checker | {On/Off} |
| Execution Verifier | {On/Off} |
| Auto-Advance | {On/Off} |
| Nyquist Validation | {On/Off} |
| Git Branching | {None/Per Phase/Per Milestone} |
| Saved as Defaults | {Yes/No} |
@@ -193,7 +206,7 @@ Quick commands:
<success_criteria>
- [ ] Current config read
- [ ] User presented with 6 settings (profile + 4 workflow toggles + git branching)
- [ ] User presented with 7 settings (profile + 5 workflow toggles + git branching)
- [ ] Config updated with model_profile, workflow, and git sections
- [ ] User offered to save as global defaults (~/.gsd/defaults.json)
- [ ] Changes confirmed to user

View File

@@ -365,14 +365,11 @@ Task(
**Phase:** {phase_number}
**Mode:** gap_closure
**UAT with diagnoses:**
@.planning/phases/{phase_dir}/{phase_num}-UAT.md
**Project State:**
@.planning/STATE.md
**Roadmap:**
@.planning/ROADMAP.md
<files_to_read>
- {phase_dir}/{phase_num}-UAT.md (UAT with diagnoses)
- .planning/STATE.md (Project State)
- .planning/ROADMAP.md (Roadmap)
</files_to_read>
</planning_context>
@@ -416,8 +413,9 @@ Task(
**Phase:** {phase_number}
**Phase Goal:** Close diagnosed gaps from UAT
**Plans to verify:**
@.planning/phases/{phase_dir}/*-PLAN.md
<files_to_read>
- {phase_dir}/*-PLAN.md (Plans to verify)
</files_to_read>
</verification_context>
@@ -455,8 +453,9 @@ Task(
**Phase:** {phase_number}
**Mode:** revision
**Existing plans:**
@.planning/phases/{phase_dir}/*-PLAN.md
<files_to_read>
- {phase_dir}/*-PLAN.md (Existing plans)
</files_to_read>
**Checker issues:**
{structured_issues_from_checker}

View File

@@ -0,0 +1,122 @@
#!/usr/bin/env node
// Context Monitor - PostToolUse hook
// Reads context metrics from the statusline bridge file and injects
// warnings when context usage is high. This makes the AGENT aware of
// context limits (the statusline only shows the user).
//
// How it works:
// 1. The statusline hook writes metrics to /tmp/claude-ctx-{session_id}.json
// 2. This hook reads those metrics after each tool use
// 3. When remaining context drops below thresholds, it injects a warning
// as additionalContext, which the agent sees in its conversation
//
// Thresholds:
// WARNING (remaining <= 35%): Agent should wrap up current task
// CRITICAL (remaining <= 25%): Agent should stop immediately and save state
//
// Debounce: 5 tool uses between warnings to avoid spam
// Severity escalation bypasses debounce (WARNING -> CRITICAL fires immediately)
const fs = require('fs');
const os = require('os');
const path = require('path');
const WARNING_THRESHOLD = 35; // remaining_percentage <= 35%
const CRITICAL_THRESHOLD = 25; // remaining_percentage <= 25%
const STALE_SECONDS = 60; // ignore metrics older than 60s
const DEBOUNCE_CALLS = 5; // min tool uses between warnings
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => input += chunk);
process.stdin.on('end', () => {
try {
const data = JSON.parse(input);
const sessionId = data.session_id;
if (!sessionId) {
process.exit(0);
}
const tmpDir = os.tmpdir();
const metricsPath = path.join(tmpDir, `claude-ctx-${sessionId}.json`);
// If no metrics file, this is a subagent or fresh session -- exit silently
if (!fs.existsSync(metricsPath)) {
process.exit(0);
}
const metrics = JSON.parse(fs.readFileSync(metricsPath, 'utf8'));
const now = Math.floor(Date.now() / 1000);
// Ignore stale metrics
if (metrics.timestamp && (now - metrics.timestamp) > STALE_SECONDS) {
process.exit(0);
}
const remaining = metrics.remaining_percentage;
const usedPct = metrics.used_pct;
// No warning needed
if (remaining > WARNING_THRESHOLD) {
process.exit(0);
}
// Debounce: check if we warned recently
const warnPath = path.join(tmpDir, `claude-ctx-${sessionId}-warned.json`);
let warnData = { callsSinceWarn: 0, lastLevel: null };
let firstWarn = true;
if (fs.existsSync(warnPath)) {
try {
warnData = JSON.parse(fs.readFileSync(warnPath, 'utf8'));
firstWarn = false;
} catch (e) {
// Corrupted file, reset
}
}
warnData.callsSinceWarn = (warnData.callsSinceWarn || 0) + 1;
const isCritical = remaining <= CRITICAL_THRESHOLD;
const currentLevel = isCritical ? 'critical' : 'warning';
// Emit immediately on first warning, then debounce subsequent ones
// Severity escalation (WARNING -> CRITICAL) bypasses debounce
const severityEscalated = currentLevel === 'critical' && warnData.lastLevel === 'warning';
if (!firstWarn && warnData.callsSinceWarn < DEBOUNCE_CALLS && !severityEscalated) {
// Update counter and exit without warning
fs.writeFileSync(warnPath, JSON.stringify(warnData));
process.exit(0);
}
// Reset debounce counter
warnData.callsSinceWarn = 0;
warnData.lastLevel = currentLevel;
fs.writeFileSync(warnPath, JSON.stringify(warnData));
// Build warning message
let message;
if (isCritical) {
message = `CONTEXT MONITOR CRITICAL: Usage at ${usedPct}%. Remaining: ${remaining}%. ` +
'STOP new work immediately. Save state NOW and inform the user that context is nearly exhausted. ' +
'If using GSD, run /gsd:pause-work to save execution state.';
} else {
message = `CONTEXT MONITOR WARNING: Usage at ${usedPct}%. Remaining: ${remaining}%. ` +
'Begin wrapping up current task. Do not start new complex work. ' +
'If using GSD, consider /gsd:pause-work to save state.';
}
const output = {
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: message
}
};
process.stdout.write(JSON.stringify(output));
} catch (e) {
// Silent fail -- never block tool execution
process.exit(0);
}
});

View File

@@ -27,6 +27,23 @@ process.stdin.on('end', () => {
// Scale: 80% real usage = 100% displayed
const used = Math.min(100, Math.round((rawUsed / 80) * 100));
// Write context metrics to bridge file for the context-monitor PostToolUse hook.
// The monitor reads this file to inject agent-facing warnings when context is low.
if (session) {
try {
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${session}.json`);
const bridgeData = JSON.stringify({
session_id: session,
remaining_percentage: remaining,
used_pct: used,
timestamp: Math.floor(Date.now() / 1000)
});
fs.writeFileSync(bridgePath, bridgeData);
} catch (e) {
// Silent fail -- bridge is best-effort, don't break statusline
}
}
// Build progress bar (10 segments)
const filled = Math.floor(used / 10);
const bar = '█'.repeat(filled) + '░'.repeat(10 - filled);

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "get-shit-done-cc",
"version": "1.20.3",
"version": "1.20.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "get-shit-done-cc",
"version": "1.20.3",
"version": "1.20.6",
"license": "MIT",
"bin": {
"get-shit-done-cc": "bin/install.js"

View File

@@ -1,6 +1,6 @@
{
"name": "get-shit-done-cc",
"version": "1.20.3",
"version": "1.20.6",
"description": "A meta-prompting, context engineering and spec-driven development system for Claude Code, OpenCode and Gemini by TÂCHES.",
"bin": {
"get-shit-done-cc": "bin/install.js"
@@ -43,6 +43,6 @@
"scripts": {
"build:hooks": "node scripts/build-hooks.js",
"prepublishOnly": "npm run build:hooks",
"test": "node --test get-shit-done/bin/gsd-tools.test.js"
"test": "node --test tests/*.test.cjs"
}
}

View File

@@ -12,6 +12,7 @@ const DIST_DIR = path.join(HOOKS_DIR, 'dist');
// Hooks to copy (pure Node.js, no bundling needed)
const HOOKS_TO_COPY = [
'gsd-check-update.js',
'gsd-context-monitor.js',
'gsd-statusline.js'
];

625
tests/commands.test.cjs Normal file
View File

@@ -0,0 +1,625 @@
/**
* GSD Tools Tests - Commands
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
describe('history-digest command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('empty phases directory returns valid schema', () => {
const result = runGsdTools('history-digest', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const digest = JSON.parse(result.output);
assert.deepStrictEqual(digest.phases, {}, 'phases should be empty object');
assert.deepStrictEqual(digest.decisions, [], 'decisions should be empty array');
assert.deepStrictEqual(digest.tech_stack, [], 'tech_stack should be empty array');
});
test('nested frontmatter fields extracted correctly', () => {
// Create phase directory with SUMMARY containing nested frontmatter
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(phaseDir, { recursive: true });
const summaryContent = `---
phase: "01"
name: "Foundation Setup"
dependency-graph:
provides:
- "Database schema"
- "Auth system"
affects:
- "API layer"
tech-stack:
added:
- "prisma"
- "jose"
patterns-established:
- "Repository pattern"
- "JWT auth flow"
key-decisions:
- "Use Prisma over Drizzle"
- "JWT in httpOnly cookies"
---
# Summary content here
`;
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), summaryContent);
const result = runGsdTools('history-digest', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const digest = JSON.parse(result.output);
// Check nested dependency-graph.provides
assert.ok(digest.phases['01'], 'Phase 01 should exist');
assert.deepStrictEqual(
digest.phases['01'].provides.sort(),
['Auth system', 'Database schema'],
'provides should contain nested values'
);
// Check nested dependency-graph.affects
assert.deepStrictEqual(
digest.phases['01'].affects,
['API layer'],
'affects should contain nested values'
);
// Check nested tech-stack.added
assert.deepStrictEqual(
digest.tech_stack.sort(),
['jose', 'prisma'],
'tech_stack should contain nested values'
);
// Check patterns-established (flat array)
assert.deepStrictEqual(
digest.phases['01'].patterns.sort(),
['JWT auth flow', 'Repository pattern'],
'patterns should be extracted'
);
// Check key-decisions
assert.strictEqual(digest.decisions.length, 2, 'Should have 2 decisions');
assert.ok(
digest.decisions.some(d => d.decision === 'Use Prisma over Drizzle'),
'Should contain first decision'
);
});
test('multiple phases merged into single digest', () => {
// Create phase 01
const phase01Dir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(phase01Dir, { recursive: true });
fs.writeFileSync(
path.join(phase01Dir, '01-01-SUMMARY.md'),
`---
phase: "01"
name: "Foundation"
provides:
- "Database"
patterns-established:
- "Pattern A"
key-decisions:
- "Decision 1"
---
`
);
// Create phase 02
const phase02Dir = path.join(tmpDir, '.planning', 'phases', '02-api');
fs.mkdirSync(phase02Dir, { recursive: true });
fs.writeFileSync(
path.join(phase02Dir, '02-01-SUMMARY.md'),
`---
phase: "02"
name: "API"
provides:
- "REST endpoints"
patterns-established:
- "Pattern B"
key-decisions:
- "Decision 2"
tech-stack:
added:
- "zod"
---
`
);
const result = runGsdTools('history-digest', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const digest = JSON.parse(result.output);
// Both phases present
assert.ok(digest.phases['01'], 'Phase 01 should exist');
assert.ok(digest.phases['02'], 'Phase 02 should exist');
// Decisions merged
assert.strictEqual(digest.decisions.length, 2, 'Should have 2 decisions total');
// Tech stack merged
assert.deepStrictEqual(digest.tech_stack, ['zod'], 'tech_stack should have zod');
});
test('malformed SUMMARY.md skipped gracefully', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(phaseDir, { recursive: true });
// Valid summary
fs.writeFileSync(
path.join(phaseDir, '01-01-SUMMARY.md'),
`---
phase: "01"
provides:
- "Valid feature"
---
`
);
// Malformed summary (no frontmatter)
fs.writeFileSync(
path.join(phaseDir, '01-02-SUMMARY.md'),
`# Just a heading
No frontmatter here
`
);
// Another malformed summary (broken YAML)
fs.writeFileSync(
path.join(phaseDir, '01-03-SUMMARY.md'),
`---
broken: [unclosed
---
`
);
const result = runGsdTools('history-digest', tmpDir);
assert.ok(result.success, `Command should succeed despite malformed files: ${result.error}`);
const digest = JSON.parse(result.output);
assert.ok(digest.phases['01'], 'Phase 01 should exist');
assert.ok(
digest.phases['01'].provides.includes('Valid feature'),
'Valid feature should be extracted'
);
});
test('flat provides field still works (backward compatibility)', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '01-01-SUMMARY.md'),
`---
phase: "01"
provides:
- "Direct provides"
---
`
);
const result = runGsdTools('history-digest', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const digest = JSON.parse(result.output);
assert.deepStrictEqual(
digest.phases['01'].provides,
['Direct provides'],
'Direct provides should work'
);
});
test('inline array syntax supported', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '01-01-SUMMARY.md'),
`---
phase: "01"
provides: [Feature A, Feature B]
patterns-established: ["Pattern X", "Pattern Y"]
---
`
);
const result = runGsdTools('history-digest', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const digest = JSON.parse(result.output);
assert.deepStrictEqual(
digest.phases['01'].provides.sort(),
['Feature A', 'Feature B'],
'Inline array should work'
);
assert.deepStrictEqual(
digest.phases['01'].patterns.sort(),
['Pattern X', 'Pattern Y'],
'Inline quoted array should work'
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// phases list command
// ─────────────────────────────────────────────────────────────────────────────
describe('summary-extract command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('missing file returns error', () => {
const result = runGsdTools('summary-extract .planning/phases/01-test/01-01-SUMMARY.md', tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.error, 'File not found', 'should report missing file');
});
test('extracts all fields from SUMMARY.md', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '01-01-SUMMARY.md'),
`---
one-liner: Set up Prisma with User and Project models
key-files:
- prisma/schema.prisma
- src/lib/db.ts
tech-stack:
added:
- prisma
- zod
patterns-established:
- Repository pattern
- Dependency injection
key-decisions:
- Use Prisma over Drizzle: Better DX and ecosystem
- Single database: Start simple, shard later
---
# Summary
Full summary content here.
`
);
const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.path, '.planning/phases/01-foundation/01-01-SUMMARY.md', 'path correct');
assert.strictEqual(output.one_liner, 'Set up Prisma with User and Project models', 'one-liner extracted');
assert.deepStrictEqual(output.key_files, ['prisma/schema.prisma', 'src/lib/db.ts'], 'key files extracted');
assert.deepStrictEqual(output.tech_added, ['prisma', 'zod'], 'tech added extracted');
assert.deepStrictEqual(output.patterns, ['Repository pattern', 'Dependency injection'], 'patterns extracted');
assert.strictEqual(output.decisions.length, 2, 'decisions extracted');
});
test('selective extraction with --fields', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '01-01-SUMMARY.md'),
`---
one-liner: Set up database
key-files:
- prisma/schema.prisma
tech-stack:
added:
- prisma
patterns-established:
- Repository pattern
key-decisions:
- Use Prisma: Better DX
---
`
);
const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md --fields one_liner,key_files', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.one_liner, 'Set up database', 'one_liner included');
assert.deepStrictEqual(output.key_files, ['prisma/schema.prisma'], 'key_files included');
assert.strictEqual(output.tech_added, undefined, 'tech_added excluded');
assert.strictEqual(output.patterns, undefined, 'patterns excluded');
assert.strictEqual(output.decisions, undefined, 'decisions excluded');
});
test('handles missing frontmatter fields gracefully', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '01-01-SUMMARY.md'),
`---
one-liner: Minimal summary
---
# Summary
`
);
const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.one_liner, 'Minimal summary', 'one-liner extracted');
assert.deepStrictEqual(output.key_files, [], 'key_files defaults to empty');
assert.deepStrictEqual(output.tech_added, [], 'tech_added defaults to empty');
assert.deepStrictEqual(output.patterns, [], 'patterns defaults to empty');
assert.deepStrictEqual(output.decisions, [], 'decisions defaults to empty');
});
test('parses key-decisions with rationale', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '01-01-SUMMARY.md'),
`---
key-decisions:
- Use Prisma: Better DX than alternatives
- JWT tokens: Stateless auth for scalability
---
`
);
const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.decisions[0].summary, 'Use Prisma', 'decision summary parsed');
assert.strictEqual(output.decisions[0].rationale, 'Better DX than alternatives', 'decision rationale parsed');
assert.strictEqual(output.decisions[1].summary, 'JWT tokens', 'second decision summary');
assert.strictEqual(output.decisions[1].rationale, 'Stateless auth for scalability', 'second decision rationale');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// init commands tests
// ─────────────────────────────────────────────────────────────────────────────
describe('progress command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('renders JSON progress', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0 MVP\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Done');
fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan 2');
const result = runGsdTools('progress json', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.total_plans, 2, '2 total plans');
assert.strictEqual(output.total_summaries, 1, '1 summary');
assert.strictEqual(output.percent, 50, '50%');
assert.strictEqual(output.phases.length, 1, '1 phase');
assert.strictEqual(output.phases[0].status, 'In Progress', 'phase in progress');
});
test('renders bar format', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Done');
const result = runGsdTools('progress bar --raw', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
assert.ok(result.output.includes('1/1'), 'should include count');
assert.ok(result.output.includes('100%'), 'should include 100%');
});
test('renders table format', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0 MVP\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
const result = runGsdTools('progress table --raw', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
assert.ok(result.output.includes('Phase'), 'should have table header');
assert.ok(result.output.includes('foundation'), 'should include phase name');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// todo complete command
// ─────────────────────────────────────────────────────────────────────────────
describe('todo complete command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('moves todo from pending to completed', () => {
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
fs.writeFileSync(
path.join(pendingDir, 'add-dark-mode.md'),
`title: Add dark mode\narea: ui\ncreated: 2025-01-01\n`
);
const result = runGsdTools('todo complete add-dark-mode.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.completed, true);
// Verify moved
assert.ok(
!fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'pending', 'add-dark-mode.md')),
'should be removed from pending'
);
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'completed', 'add-dark-mode.md')),
'should be in completed'
);
// Verify completion timestamp added
const content = fs.readFileSync(
path.join(tmpDir, '.planning', 'todos', 'completed', 'add-dark-mode.md'),
'utf-8'
);
assert.ok(content.startsWith('completed:'), 'should have completed timestamp');
});
test('fails for nonexistent todo', () => {
const result = runGsdTools('todo complete nonexistent.md', tmpDir);
assert.ok(!result.success, 'should fail');
assert.ok(result.error.includes('not found'), 'error mentions not found');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// scaffold command
// ─────────────────────────────────────────────────────────────────────────────
describe('scaffold command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('scaffolds context file', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true });
const result = runGsdTools('scaffold context --phase 3', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.created, true);
// Verify file content
const content = fs.readFileSync(
path.join(tmpDir, '.planning', 'phases', '03-api', '03-CONTEXT.md'),
'utf-8'
);
assert.ok(content.includes('Phase 3'), 'should reference phase number');
assert.ok(content.includes('Decisions'), 'should have decisions section');
assert.ok(content.includes('Discretion Areas'), 'should have discretion section');
});
test('scaffolds UAT file', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true });
const result = runGsdTools('scaffold uat --phase 3', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.created, true);
const content = fs.readFileSync(
path.join(tmpDir, '.planning', 'phases', '03-api', '03-UAT.md'),
'utf-8'
);
assert.ok(content.includes('User Acceptance Testing'), 'should have UAT heading');
assert.ok(content.includes('Test Results'), 'should have test results section');
});
test('scaffolds verification file', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true });
const result = runGsdTools('scaffold verification --phase 3', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.created, true);
const content = fs.readFileSync(
path.join(tmpDir, '.planning', 'phases', '03-api', '03-VERIFICATION.md'),
'utf-8'
);
assert.ok(content.includes('Goal-Backward Verification'), 'should have verification heading');
});
test('scaffolds phase directory', () => {
const result = runGsdTools('scaffold phase-dir --phase 5 --name User Dashboard', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.created, true);
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '05-user-dashboard')),
'directory should be created'
);
});
test('does not overwrite existing files', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '03-CONTEXT.md'), '# Existing content');
const result = runGsdTools('scaffold context --phase 3', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.created, false, 'should not overwrite');
assert.strictEqual(output.reason, 'already_exists');
});
});

40
tests/helpers.cjs Normal file
View File

@@ -0,0 +1,40 @@
/**
* GSD Tools Test Helpers
*/
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const TOOLS_PATH = path.join(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs');
// Helper to run gsd-tools command
function runGsdTools(args, cwd = process.cwd()) {
try {
const result = execSync(`node "${TOOLS_PATH}" ${args}`, {
cwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
return { success: true, output: result.trim() };
} catch (err) {
return {
success: false,
output: err.stdout?.toString().trim() || '',
error: err.stderr?.toString().trim() || err.message,
};
}
}
// Create temp directory structure
function createTempProject() {
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'gsd-test-'));
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
return tmpDir;
}
function cleanup(tmpDir) {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
module.exports = { runGsdTools, createTempProject, cleanup, TOOLS_PATH };

105
tests/init.test.cjs Normal file
View File

@@ -0,0 +1,105 @@
/**
* GSD Tools Tests - Init
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
describe('init commands', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('init execute-phase returns file paths', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '# Plan');
const result = runGsdTools('init execute-phase 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.state_path, '.planning/STATE.md');
assert.strictEqual(output.roadmap_path, '.planning/ROADMAP.md');
assert.strictEqual(output.config_path, '.planning/config.json');
});
test('init plan-phase returns file paths', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '03-CONTEXT.md'), '# Phase Context');
fs.writeFileSync(path.join(phaseDir, '03-RESEARCH.md'), '# Research Findings');
fs.writeFileSync(path.join(phaseDir, '03-VERIFICATION.md'), '# Verification');
fs.writeFileSync(path.join(phaseDir, '03-UAT.md'), '# UAT');
const result = runGsdTools('init plan-phase 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.state_path, '.planning/STATE.md');
assert.strictEqual(output.roadmap_path, '.planning/ROADMAP.md');
assert.strictEqual(output.requirements_path, '.planning/REQUIREMENTS.md');
assert.strictEqual(output.context_path, '.planning/phases/03-api/03-CONTEXT.md');
assert.strictEqual(output.research_path, '.planning/phases/03-api/03-RESEARCH.md');
assert.strictEqual(output.verification_path, '.planning/phases/03-api/03-VERIFICATION.md');
assert.strictEqual(output.uat_path, '.planning/phases/03-api/03-UAT.md');
});
test('init progress returns file paths', () => {
const result = runGsdTools('init progress', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.state_path, '.planning/STATE.md');
assert.strictEqual(output.roadmap_path, '.planning/ROADMAP.md');
assert.strictEqual(output.project_path, '.planning/PROJECT.md');
assert.strictEqual(output.config_path, '.planning/config.json');
});
test('init phase-op returns core and optional phase file paths', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '03-CONTEXT.md'), '# Phase Context');
fs.writeFileSync(path.join(phaseDir, '03-RESEARCH.md'), '# Research');
fs.writeFileSync(path.join(phaseDir, '03-VERIFICATION.md'), '# Verification');
fs.writeFileSync(path.join(phaseDir, '03-UAT.md'), '# UAT');
const result = runGsdTools('init phase-op 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.state_path, '.planning/STATE.md');
assert.strictEqual(output.roadmap_path, '.planning/ROADMAP.md');
assert.strictEqual(output.requirements_path, '.planning/REQUIREMENTS.md');
assert.strictEqual(output.context_path, '.planning/phases/03-api/03-CONTEXT.md');
assert.strictEqual(output.research_path, '.planning/phases/03-api/03-RESEARCH.md');
assert.strictEqual(output.verification_path, '.planning/phases/03-api/03-VERIFICATION.md');
assert.strictEqual(output.uat_path, '.planning/phases/03-api/03-UAT.md');
});
test('init plan-phase omits optional paths if files missing', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
fs.mkdirSync(phaseDir, { recursive: true });
const result = runGsdTools('init plan-phase 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.context_path, undefined);
assert.strictEqual(output.research_path, undefined);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// roadmap analyze command
// ─────────────────────────────────────────────────────────────────────────────

98
tests/milestone.test.cjs Normal file
View File

@@ -0,0 +1,98 @@
/**
* GSD Tools Tests - Milestone
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
describe('milestone complete command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('archives roadmap, requirements, creates MILESTONES.md', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0 MVP\n\n### Phase 1: Foundation\n**Goal:** Setup\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
`# Requirements\n\n- [ ] User auth\n- [ ] Dashboard\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(
path.join(p1, '01-01-SUMMARY.md'),
`---\none-liner: Set up project infrastructure\n---\n# Summary\n`
);
const result = runGsdTools('milestone complete v1.0 --name MVP Foundation', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.version, 'v1.0');
assert.strictEqual(output.phases, 1);
assert.ok(output.archived.roadmap, 'roadmap should be archived');
assert.ok(output.archived.requirements, 'requirements should be archived');
// Verify archive files exist
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-ROADMAP.md')),
'archived roadmap should exist'
);
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-REQUIREMENTS.md')),
'archived requirements should exist'
);
// Verify MILESTONES.md created
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'MILESTONES.md')),
'MILESTONES.md should be created'
);
const milestones = fs.readFileSync(path.join(tmpDir, '.planning', 'MILESTONES.md'), 'utf-8');
assert.ok(milestones.includes('v1.0 MVP Foundation'), 'milestone entry should contain name');
assert.ok(milestones.includes('Set up project infrastructure'), 'accomplishments should be listed');
});
test('appends to existing MILESTONES.md', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'MILESTONES.md'),
`# Milestones\n\n## v0.9 Alpha (Shipped: 2025-01-01)\n\n---\n\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const result = runGsdTools('milestone complete v1.0 --name Beta', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const milestones = fs.readFileSync(path.join(tmpDir, '.planning', 'MILESTONES.md'), 'utf-8');
assert.ok(milestones.includes('v0.9 Alpha'), 'existing entry should be preserved');
assert.ok(milestones.includes('v1.0 Beta'), 'new entry should be appended');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// validate consistency command
// ─────────────────────────────────────────────────────────────────────────────

1132
tests/phase.test.cjs Normal file

File diff suppressed because it is too large Load Diff

265
tests/roadmap.test.cjs Normal file
View File

@@ -0,0 +1,265 @@
/**
* GSD Tools Tests - Roadmap
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
describe('roadmap get-phase command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('extracts phase section from ROADMAP.md', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0
## Phases
### Phase 1: Foundation
**Goal:** Set up project infrastructure
**Plans:** 2 plans
Some description here.
### Phase 2: API
**Goal:** Build REST API
**Plans:** 3 plans
`
);
const result = runGsdTools('roadmap get-phase 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, true, 'phase should be found');
assert.strictEqual(output.phase_number, '1', 'phase number correct');
assert.strictEqual(output.phase_name, 'Foundation', 'phase name extracted');
assert.strictEqual(output.goal, 'Set up project infrastructure', 'goal extracted');
});
test('returns not found for missing phase', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0
### Phase 1: Foundation
**Goal:** Set up project
`
);
const result = runGsdTools('roadmap get-phase 5', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, false, 'phase should not be found');
});
test('handles decimal phase numbers', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 2: Main
**Goal:** Main work
### Phase 2.1: Hotfix
**Goal:** Emergency fix
`
);
const result = runGsdTools('roadmap get-phase 2.1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, true, 'decimal phase should be found');
assert.strictEqual(output.phase_name, 'Hotfix', 'phase name correct');
assert.strictEqual(output.goal, 'Emergency fix', 'goal extracted');
});
test('extracts full section content', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Setup
**Goal:** Initialize everything
This phase covers:
- Database setup
- Auth configuration
- CI/CD pipeline
### Phase 2: Build
**Goal:** Build features
`
);
const result = runGsdTools('roadmap get-phase 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.section.includes('Database setup'), 'section includes description');
assert.ok(output.section.includes('CI/CD pipeline'), 'section includes all bullets');
assert.ok(!output.section.includes('Phase 2'), 'section does not include next phase');
});
test('handles missing ROADMAP.md gracefully', () => {
const result = runGsdTools('roadmap get-phase 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, false, 'should return not found');
assert.strictEqual(output.error, 'ROADMAP.md not found', 'should explain why');
});
test('accepts ## phase headers (two hashes)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0
## Phase 1: Foundation
**Goal:** Set up project infrastructure
**Plans:** 2 plans
## Phase 2: API
**Goal:** Build REST API
`
);
const result = runGsdTools('roadmap get-phase 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, true, 'phase with ## header should be found');
assert.strictEqual(output.phase_name, 'Foundation', 'phase name extracted');
assert.strictEqual(output.goal, 'Set up project infrastructure', 'goal extracted');
});
test('detects malformed ROADMAP with summary list but no detail sections', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0
## Phases
- [ ] **Phase 1: Foundation** - Set up project
- [ ] **Phase 2: API** - Build REST API
`
);
const result = runGsdTools('roadmap get-phase 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, false, 'phase should not be found');
assert.strictEqual(output.error, 'malformed_roadmap', 'should identify malformed roadmap');
assert.ok(output.message.includes('missing'), 'should explain the issue');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// phase next-decimal command
// ─────────────────────────────────────────────────────────────────────────────
describe('roadmap analyze command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('missing ROADMAP.md returns error', () => {
const result = runGsdTools('roadmap analyze', tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.error, 'ROADMAP.md not found');
});
test('parses phases with goals and disk status', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0
### Phase 1: Foundation
**Goal:** Set up infrastructure
### Phase 2: Authentication
**Goal:** Add user auth
### Phase 3: Features
**Goal:** Build core features
`
);
// Create phase dirs with varying completion
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
const p2 = path.join(tmpDir, '.planning', 'phases', '02-authentication');
fs.mkdirSync(p2, { recursive: true });
fs.writeFileSync(path.join(p2, '02-01-PLAN.md'), '# Plan');
const result = runGsdTools('roadmap analyze', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_count, 3, 'should find 3 phases');
assert.strictEqual(output.phases[0].disk_status, 'complete', 'phase 1 complete');
assert.strictEqual(output.phases[1].disk_status, 'planned', 'phase 2 planned');
assert.strictEqual(output.phases[2].disk_status, 'no_directory', 'phase 3 no directory');
assert.strictEqual(output.completed_phases, 1, '1 phase complete');
assert.strictEqual(output.total_plans, 2, '2 total plans');
assert.strictEqual(output.total_summaries, 1, '1 total summary');
assert.strictEqual(output.progress_percent, 50, '50% complete');
assert.strictEqual(output.current_phase, '2', 'current phase is 2');
});
test('extracts goals and dependencies', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Setup
**Goal:** Initialize project
**Depends on:** Nothing
### Phase 2: Build
**Goal:** Build features
**Depends on:** Phase 1
`
);
const result = runGsdTools('roadmap analyze', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phases[0].goal, 'Initialize project');
assert.strictEqual(output.phases[0].depends_on, 'Nothing');
assert.strictEqual(output.phases[1].goal, 'Build features');
assert.strictEqual(output.phases[1].depends_on, 'Phase 1');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// phase add command
// ─────────────────────────────────────────────────────────────────────────────

156
tests/state.test.cjs Normal file
View File

@@ -0,0 +1,156 @@
/**
* GSD Tools Tests - State
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
describe('state-snapshot command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('missing STATE.md returns error', () => {
const result = runGsdTools('state-snapshot', tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.error, 'STATE.md not found', 'should report missing file');
});
test('extracts basic fields from STATE.md', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State
**Current Phase:** 03
**Current Phase Name:** API Layer
**Total Phases:** 6
**Current Plan:** 03-02
**Total Plans in Phase:** 3
**Status:** In progress
**Progress:** 45%
**Last Activity:** 2024-01-15
**Last Activity Description:** Completed 03-01-PLAN.md
`
);
const result = runGsdTools('state-snapshot', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.current_phase, '03', 'current phase extracted');
assert.strictEqual(output.current_phase_name, 'API Layer', 'phase name extracted');
assert.strictEqual(output.total_phases, 6, 'total phases extracted');
assert.strictEqual(output.current_plan, '03-02', 'current plan extracted');
assert.strictEqual(output.total_plans_in_phase, 3, 'total plans extracted');
assert.strictEqual(output.status, 'In progress', 'status extracted');
assert.strictEqual(output.progress_percent, 45, 'progress extracted');
assert.strictEqual(output.last_activity, '2024-01-15', 'last activity date extracted');
});
test('extracts decisions table', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State
**Current Phase:** 01
## Decisions Made
| Phase | Decision | Rationale |
|-------|----------|-----------|
| 01 | Use Prisma | Better DX than raw SQL |
| 02 | JWT auth | Stateless authentication |
`
);
const result = runGsdTools('state-snapshot', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.decisions.length, 2, 'should have 2 decisions');
assert.strictEqual(output.decisions[0].phase, '01', 'first decision phase');
assert.strictEqual(output.decisions[0].summary, 'Use Prisma', 'first decision summary');
assert.strictEqual(output.decisions[0].rationale, 'Better DX than raw SQL', 'first decision rationale');
});
test('extracts blockers list', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State
**Current Phase:** 03
## Blockers
- Waiting for API credentials
- Need design review for dashboard
`
);
const result = runGsdTools('state-snapshot', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.deepStrictEqual(output.blockers, [
'Waiting for API credentials',
'Need design review for dashboard',
], 'blockers extracted');
});
test('extracts session continuity info', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State
**Current Phase:** 03
## Session
**Last Date:** 2024-01-15
**Stopped At:** Phase 3, Plan 2, Task 1
**Resume File:** .planning/phases/03-api/03-02-PLAN.md
`
);
const result = runGsdTools('state-snapshot', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.session.last_date, '2024-01-15', 'session date extracted');
assert.strictEqual(output.session.stopped_at, 'Phase 3, Plan 2, Task 1', 'stopped at extracted');
assert.strictEqual(output.session.resume_file, '.planning/phases/03-api/03-02-PLAN.md', 'resume file extracted');
});
test('handles paused_at field', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State
**Current Phase:** 03
**Paused At:** Phase 3, Plan 1, Task 2 - mid-implementation
`
);
const result = runGsdTools('state-snapshot', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.paused_at, 'Phase 3, Plan 1, Task 2 - mid-implementation', 'paused_at extracted');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// summary-extract command
// ─────────────────────────────────────────────────────────────────────────────

80
tests/verify.test.cjs Normal file
View File

@@ -0,0 +1,80 @@
/**
* GSD Tools Tests - Verify
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
describe('validate consistency command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('passes for consistent project', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n### Phase 1: A\n### Phase 2: B\n### Phase 3: C\n`
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-b'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-c'), { recursive: true });
const result = runGsdTools('validate consistency', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.passed, true, 'should pass');
assert.strictEqual(output.warning_count, 0, 'no warnings');
});
test('warns about phase on disk but not in roadmap', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n### Phase 1: A\n`
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-orphan'), { recursive: true });
const result = runGsdTools('validate consistency', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.warning_count > 0, 'should have warnings');
assert.ok(
output.warnings.some(w => w.includes('disk but not in ROADMAP')),
'should warn about orphan directory'
);
});
test('warns about gaps in phase numbering', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n### Phase 1: A\n### Phase 3: C\n`
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-c'), { recursive: true });
const result = runGsdTools('validate consistency', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.warnings.some(w => w.includes('Gap in phase numbering')),
'should warn about gap'
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// progress command
// ─────────────────────────────────────────────────────────────────────────────