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:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -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
|
||||
|
||||
36
CHANGELOG.md
36
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
96
docs/context-monitor.md
Normal 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
556
get-shit-done/bin/lib/commands.cjs
Normal file
556
get-shit-done/bin/lib/commands.cjs
Normal 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,
|
||||
};
|
||||
162
get-shit-done/bin/lib/config.cjs
Normal file
162
get-shit-done/bin/lib/config.cjs
Normal 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,
|
||||
};
|
||||
398
get-shit-done/bin/lib/core.cjs
Normal file
398
get-shit-done/bin/lib/core.cjs
Normal 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,
|
||||
};
|
||||
299
get-shit-done/bin/lib/frontmatter.cjs
Normal file
299
get-shit-done/bin/lib/frontmatter.cjs
Normal 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,
|
||||
};
|
||||
694
get-shit-done/bin/lib/init.cjs
Normal file
694
get-shit-done/bin/lib/init.cjs
Normal 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,
|
||||
};
|
||||
215
get-shit-done/bin/lib/milestone.cjs
Normal file
215
get-shit-done/bin/lib/milestone.cjs
Normal 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,
|
||||
};
|
||||
873
get-shit-done/bin/lib/phase.cjs
Normal file
873
get-shit-done/bin/lib/phase.cjs
Normal 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,
|
||||
};
|
||||
298
get-shit-done/bin/lib/roadmap.cjs
Normal file
298
get-shit-done/bin/lib/roadmap.cjs
Normal 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,
|
||||
};
|
||||
490
get-shit-done/bin/lib/state.cjs
Normal file
490
get-shit-done/bin/lib/state.cjs
Normal 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,
|
||||
};
|
||||
222
get-shit-done/bin/lib/template.cjs
Normal file
222
get-shit-done/bin/lib/template.cjs
Normal 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 };
|
||||
772
get-shit-done/bin/lib/verify.cjs
Normal file
772
get-shit-done/bin/lib/verify.cjs
Normal 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,
|
||||
};
|
||||
104
get-shit-done/templates/VALIDATION.md
Normal file
104
get-shit-done/templates/VALIDATION.md
Normal 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}
|
||||
@@ -5,7 +5,8 @@
|
||||
"research": true,
|
||||
"plan_check": true,
|
||||
"verifier": true,
|
||||
"auto_advance": false
|
||||
"auto_advance": false,
|
||||
"nyquist_validation": false
|
||||
},
|
||||
"planning": {
|
||||
"commit_docs": true,
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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:**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
122
hooks/gsd-context-monitor.js
Normal file
122
hooks/gsd-context-monitor.js
Normal 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);
|
||||
}
|
||||
});
|
||||
@@ -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
4
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
625
tests/commands.test.cjs
Normal 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
40
tests/helpers.cjs
Normal 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
105
tests/init.test.cjs
Normal 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
98
tests/milestone.test.cjs
Normal 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
1132
tests/phase.test.cjs
Normal file
File diff suppressed because it is too large
Load Diff
265
tests/roadmap.test.cjs
Normal file
265
tests/roadmap.test.cjs
Normal 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
156
tests/state.test.cjs
Normal 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
80
tests/verify.test.cjs
Normal 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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user