merge: resolve conflicts with main (plan_bounce + code_review_command)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-04-11 10:32:20 -04:00
25 changed files with 1870 additions and 144 deletions

View File

@@ -276,6 +276,12 @@ Priority: Context7 > Exa (verified) > Firecrawl (official docs) > Official GitHu
**Primary recommendation:** [one-liner actionable guidance]
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| [capability] | [tier] | [tier or —] | [why this tier owns it] |
## Standard Stack
### Core
@@ -520,6 +526,33 @@ cat "$phase_dir"/*-CONTEXT.md 2>/dev/null
- User decided "simple UI, no animations" → don't research animation libraries
- Marked as Claude's discretion → research options and recommend
## Step 1.5: Architectural Responsibility Mapping
Before diving into framework-specific research, map each capability in this phase to its standard architectural tier owner. This is a pure reasoning step — no tool calls needed.
**For each capability in the phase description:**
1. Identify what the capability does (e.g., "user authentication", "data visualization", "file upload")
2. Determine which architectural tier owns the primary responsibility:
| Tier | Examples |
|------|----------|
| **Browser / Client** | DOM manipulation, client-side routing, local storage, service workers |
| **Frontend Server (SSR)** | Server-side rendering, hydration, middleware, auth cookies |
| **API / Backend** | REST/GraphQL endpoints, business logic, auth, data validation |
| **CDN / Static** | Static assets, edge caching, image optimization |
| **Database / Storage** | Persistence, queries, migrations, caching layers |
3. Record the mapping in a table:
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| [capability] | [tier] | [tier or —] | [why this tier owns it] |
**Output:** Include an `## Architectural Responsibility Map` section in RESEARCH.md immediately after the Summary section. This map is consumed by the planner for sanity-checking task assignments and by the plan-checker for verifying tier correctness.
**Why this matters:** Multi-tier applications frequently have capabilities misassigned during planning — e.g., putting auth logic in the browser tier when it belongs in the API tier, or putting data fetching in the frontend server when the API already provides it. Mapping tier ownership before research prevents these misassignments from propagating into plans.
## Step 2: Identify Research Domains
Based on phase description, identify what needs investigating:

View File

@@ -338,6 +338,8 @@ issue:
- `"future enhancement"`, `"placeholder"`, `"basic version"`, `"minimal"`
- `"will be wired later"`, `"dynamic in future"`, `"skip for now"`
- `"not wired to"`, `"not connected to"`, `"stub"`
- `"too complex"`, `"too difficult"`, `"challenging"`, `"non-trivial"` (when used to justify omission)
- Time estimates used as scope justification: `"would take"`, `"hours"`, `"days"`, `"minutes"` (in sizing context)
2. For each match, cross-reference with the CONTEXT.md decision it claims to implement
3. Compare: does the task deliver what D-XX actually says, or a reduced version?
4. If reduced: BLOCKER — the planner must either deliver fully or propose phase split
@@ -369,6 +371,54 @@ Plans reduce {N} user decisions. Options:
2. Split phase: [suggested grouping of D-XX into sub-phases]
```
## Dimension 7c: Architectural Tier Compliance
**Question:** Do plan tasks assign capabilities to the correct architectural tier as defined in the Architectural Responsibility Map?
**Skip if:** No RESEARCH.md exists for this phase, or RESEARCH.md has no `## Architectural Responsibility Map` section. Output: "Dimension 7c: SKIPPED (no responsibility map found)"
**Process:**
1. Read the phase's RESEARCH.md and extract the `## Architectural Responsibility Map` table
2. For each plan task, identify which capability it implements and which tier it targets (inferred from file paths, action description, and artifacts)
3. Cross-reference against the responsibility map — does the task place work in the tier that owns the capability?
4. Flag any tier mismatch where a task assigns logic to a tier that doesn't own the capability
**Red flags:**
- Auth validation logic placed in browser/client tier when responsibility map assigns it to API tier
- Data persistence logic in frontend server when it belongs in database tier
- Business rule enforcement in CDN/static tier when it belongs in API tier
- Server-side rendering logic assigned to API tier when frontend server owns it
**Severity:** WARNING for potential tier mismatches. BLOCKER if a security-sensitive capability (auth, access control, input validation) is assigned to a less-trusted tier than the responsibility map specifies.
**Example — tier mismatch:**
```yaml
issue:
dimension: architectural_tier_compliance
severity: blocker
description: "Task places auth token validation in browser tier, but Architectural Responsibility Map assigns auth to API tier"
plan: "01"
task: 2
capability: "Authentication token validation"
expected_tier: "API / Backend"
actual_tier: "Browser / Client"
fix_hint: "Move token validation to API route handler per Architectural Responsibility Map"
```
**Example — non-security mismatch (warning):**
```yaml
issue:
dimension: architectural_tier_compliance
severity: warning
description: "Task places data formatting in API tier, but Architectural Responsibility Map assigns it to Frontend Server"
plan: "02"
task: 1
capability: "Date/currency formatting for display"
expected_tier: "Frontend Server (SSR)"
actual_tier: "API / Backend"
fix_hint: "Consider moving display formatting to frontend server per Architectural Responsibility Map"
```
## Dimension 8: Nyquist Compliance
Skip if: `workflow.nyquist_validation` is explicitly set to `false` in config.json (absent key = enabled), phase has no RESEARCH.md, or RESEARCH.md has no "Validation Architecture" section. Output: "Dimension 8: SKIPPED (nyquist_validation disabled or not applicable)"
@@ -859,6 +909,7 @@ Plan verification complete when:
- [ ] No tasks contradict locked decisions
- [ ] Deferred ideas not included in plans
- [ ] Overall status determined (passed | issues_found)
- [ ] Architectural tier compliance checked (tasks match responsibility map tiers)
- [ ] Cross-plan data contracts checked (no conflicting transforms on shared data)
- [ ] CLAUDE.md compliance checked (plans respect project conventions)
- [ ] Structured issues returned (if any found)

View File

@@ -98,38 +98,47 @@ The orchestrator provides user decisions in `<user_decisions>` tags from `/gsd-d
- "v1", "v2", "simplified version", "static for now", "hardcoded for now"
- "future enhancement", "placeholder", "basic version", "minimal implementation"
- "will be wired later", "dynamic in future phase", "skip for now"
- Any language that reduces a CONTEXT.md decision to less than what the user decided
- Any language that reduces a source artifact decision to less than what was specified
**The rule:** If D-XX says "display cost calculated from billing table in impulses", the plan MUST deliver cost calculated from billing table in impulses. NOT "static label /min" as a "v1".
**When the phase is too complex to implement ALL decisions:**
**When the plan set cannot cover all source items within context budget:**
Do NOT silently simplify decisions. Instead:
Do NOT silently omit features. Instead:
1. **Create a decision coverage matrix** mapping every D-XX to a plan/task
2. **If any D-XX cannot fit** within the plan budget (too many tasks, too complex):
1. **Create a multi-source coverage audit** (see below) covering ALL four artifact types
2. **If any item cannot fit** within the plan budget (context cost exceeds capacity):
- Return `## PHASE SPLIT RECOMMENDED` to the orchestrator
- Propose how to split: which D-XX groups form natural sub-phases
- Example: "D-01 to D-19 = Phase 17a (processing core), D-20 to D-27 = Phase 17b (billing + config UX)"
3. The orchestrator will present the split to the user for approval
- Propose how to split: which item groups form natural sub-phases
3. The orchestrator presents the split to the user for approval
4. After approval, plan each sub-phase within budget
**Why this matters:** The user spent time making decisions. Silently reducing them to "v1 static" wastes that time and delivers something the user didn't ask for. Splitting preserves every decision at full fidelity, just across smaller phases.
## Multi-Source Coverage Audit (MANDATORY in every plan set)
**Decision coverage matrix (MANDATORY in every plan set):**
@planner-source-audit.md for full format, examples, and gap-handling rules.
Before finalizing plans, produce internally:
Audit ALL four source types before finalizing: **GOAL** (ROADMAP phase goal), **REQ** (phase_req_ids from REQUIREMENTS.md), **RESEARCH** (RESEARCH.md features/constraints), **CONTEXT** (D-XX decisions from CONTEXT.md).
```
D-XX | Plan | Task | Full/Partial | Notes
D-01 | 01 | 1 | Full |
D-02 | 01 | 2 | Full |
D-23 | 03 | 1 | PARTIAL | ← BLOCKER: must be Full or split phase
```
Every item must be COVERED by a plan. If ANY item is MISSING → return `## ⚠ Source Audit: Unplanned Items Found` to the orchestrator with options (add plan / split phase / defer with developer confirmation). Never finalize silently with gaps.
If ANY decision is "Partial" → either fix the task to deliver fully, or return PHASE SPLIT RECOMMENDED.
Exclusions (not gaps): Deferred Ideas in CONTEXT.md, items scoped to other phases, RESEARCH.md "out of scope" items.
</scope_reduction_prohibition>
<planner_authority_limits>
## The Planner Does Not Decide What Is Too Hard
@planner-source-audit.md for constraint examples.
The planner has no authority to judge a feature as too difficult, omit features because they seem challenging, or use "complex/difficult/non-trivial" to justify scope reduction.
**Only three legitimate reasons to split or flag:**
1. **Context cost:** implementation would consume >50% of a single agent's context window
2. **Missing information:** required data not present in any source artifact
3. **Dependency conflict:** feature cannot be built until another phase ships
If a feature has none of these three constraints, it gets planned. Period.
</planner_authority_limits>
<philosophy>
## Solo Developer + Claude Workflow
@@ -137,7 +146,7 @@ If ANY decision is "Partial" → either fix the task to deliver fully, or return
Planning for ONE person (the user) and ONE implementer (Claude).
- No teams, stakeholders, ceremonies, coordination overhead
- User = visionary/product owner, Claude = builder
- Estimate effort in Claude execution time, not human dev time
- Estimate effort in context window cost, not time
## Plans Are Prompts
@@ -165,7 +174,8 @@ Plan -> Execute -> Ship -> Learn -> Repeat
**Anti-enterprise patterns (delete if seen):**
- Team structures, RACI matrices, stakeholder management
- Sprint ceremonies, change management processes
- Human dev time estimates (hours, days, weeks)
- Time estimates in human units (see `<planner_authority_limits>`)
- Complexity/difficulty as scope justification (see `<planner_authority_limits>`)
- Documentation for documentation's sake
</philosophy>
@@ -246,13 +256,19 @@ Every task has four required fields:
## Task Sizing
Each task: **15-60 minutes** Claude execution time.
Each task targets **1030% context consumption**.
| Duration | Action |
|----------|--------|
| < 15 min | Too small — combine with related task |
| 15-60 min | Right size |
| > 60 min | Too large — split |
| Context Cost | Action |
|--------------|--------|
| < 10% context | Too small — combine with a related task |
| 10-30% context | Right size — proceed |
| > 30% context | Too large — split into two tasks |
**Context cost signals (use these, not time estimates):**
- Files modified: 0-3 = ~10-15%, 4-6 = ~20-30%, 7+ = ~40%+ (split)
- New subsystem: ~25-35%
- Migration + data transform: ~30-40%
- Pure config/wiring: ~5-10%
**Too large signals:** Touches >3-5 files, multiple distinct chunks, action section >1 paragraph.
@@ -336,49 +352,9 @@ Record in `user_setup` frontmatter. Only include what Claude literally cannot do
- `creates`: What this produces
- `has_checkpoint`: Requires user interaction?
**Example with 6 tasks:**
**Example:** A→C, B→D, C+D→E, E→F(checkpoint). Waves: {A,B} → {C,D} → {E} → {F}.
```
Task A (User model): needs nothing, creates src/models/user.ts
Task B (Product model): needs nothing, creates src/models/product.ts
Task C (User API): needs Task A, creates src/api/users.ts
Task D (Product API): needs Task B, creates src/api/products.ts
Task E (Dashboard): needs Task C + D, creates src/components/Dashboard.tsx
Task F (Verify UI): checkpoint:human-verify, needs Task E
Graph:
A --> C --\
--> E --> F
B --> D --/
Wave analysis:
Wave 1: A, B (independent roots)
Wave 2: C, D (depend only on Wave 1)
Wave 3: E (depends on Wave 2)
Wave 4: F (checkpoint, depends on Wave 3)
```
## Vertical Slices vs Horizontal Layers
**Vertical slices (PREFER):**
```
Plan 01: User feature (model + API + UI)
Plan 02: Product feature (model + API + UI)
Plan 03: Order feature (model + API + UI)
```
Result: All three run parallel (Wave 1)
**Horizontal layers (AVOID):**
```
Plan 01: Create User model, Product model, Order model
Plan 02: Create User API, Product API, Order API
Plan 03: Create User UI, Product UI, Order UI
```
Result: Fully sequential (02 needs 01, 03 needs 02)
**When vertical slices work:** Features are independent, self-contained, no cross-feature dependencies.
**When horizontal layers necessary:** Shared foundation required (auth before protected features), genuine type dependencies, infrastructure setup.
**Prefer vertical slices** (User feature: model+API+UI) over horizontal layers (all models → all APIs → all UIs). Vertical = parallel. Horizontal = sequential. Use horizontal only when shared foundation is required.
## File Ownership for Parallel Execution
@@ -404,11 +380,11 @@ Plans should complete within ~50% context (not 80%). No context anxiety, quality
**Each plan: 2-3 tasks maximum.**
| Task Complexity | Tasks/Plan | Context/Task | Total |
|-----------------|------------|--------------|-------|
| Simple (CRUD, config) | 3 | ~10-15% | ~30-45% |
| Complex (auth, payments) | 2 | ~20-30% | ~40-50% |
| Very complex (migrations) | 1-2 | ~30-40% | ~30-50% |
| Context Weight | Tasks/Plan | Context/Task | Total |
|----------------|------------|--------------|-------|
| Light (CRUD, config) | 3 | ~10-15% | ~30-45% |
| Medium (auth, payments) | 2 | ~20-30% | ~40-50% |
| Heavy (migrations, multi-subsystem) | 1-2 | ~30-40% | ~30-50% |
## Split Signals
@@ -419,7 +395,7 @@ Plans should complete within ~50% context (not 80%). No context anxiety, quality
- Checkpoint + implementation in same plan
- Discovery + implementation in same plan
**CONSIDER splitting:** >5 files total, complex domains, uncertainty about approach, natural semantic boundaries.
**CONSIDER splitting:** >5 files total, natural semantic boundaries, context cost estimate exceeds 40% for a single plan. See `<planner_authority_limits>` for prohibited split reasons.
## Granularity Calibration
@@ -429,22 +405,7 @@ Plans should complete within ~50% context (not 80%). No context anxiety, quality
| Standard | 3-5 | 2-3 |
| Fine | 5-10 | 2-3 |
Derive plans from actual work. Granularity determines compression tolerance, not a target. Don't pad small work to hit a number. Don't compress complex work to look efficient.
## Context Per Task Estimates
| Files Modified | Context Impact |
|----------------|----------------|
| 0-3 files | ~10-15% (small) |
| 4-6 files | ~20-30% (medium) |
| 7+ files | ~40%+ (split) |
| Complexity | Context/Task |
|------------|--------------|
| Simple CRUD | ~15% |
| Business logic | ~25% |
| Complex algorithms | ~40% |
| Domain modeling | ~35% |
Derive plans from actual work. Granularity determines compression tolerance, not a target.
</scope_estimation>
@@ -1026,6 +987,8 @@ cat "$phase_dir"/*-DISCOVERY.md 2>/dev/null # From mandatory discovery
**If CONTEXT.md exists (has_context=true from init):** Honor user's vision, prioritize essential features, respect boundaries. Locked decisions — do not revisit.
**If RESEARCH.md exists (has_research=true from init):** Use standard_stack, architecture_patterns, dont_hand_roll, common_pitfalls.
**Architectural Responsibility Map sanity check:** If RESEARCH.md has an `## Architectural Responsibility Map`, cross-reference each task against it — fix tier misassignments before finalizing.
</step>
<step name="break_into_tasks">

View File

@@ -14,7 +14,9 @@ No arguments needed — reads STATE.md, ROADMAP.md, and phase directories to det
Designed for rapid multi-project workflows where remembering which phase/step you're on is overhead.
Supports `--force` flag to bypass safety gates (checkpoint, error state, verification failures).
Supports `--force` flag to bypass safety gates (checkpoint, error state, verification failures, and prior-phase completeness scan).
Before routing to the next step, scans all prior phases for incomplete work: plans that ran without producing summaries, verification failures without overrides, and phases where discussion happened but planning never ran. When incomplete work is found, shows a structured report and offers three options: defer the gaps to the backlog and continue, stop and resolve manually, or force advance without recording. When prior phases are clean, routes silently with no interruption.
</objective>
<execution_context>

View File

@@ -638,6 +638,11 @@ async function runCommand(command, args, cwd, raw, defaultValue) {
break;
}
case 'skill-manifest': {
init.cmdSkillManifest(cwd, args, raw);
break;
}
case 'history-digest': {
commands.cmdHistoryDigest(cwd, raw);
break;

View File

@@ -26,6 +26,9 @@ const VALID_CONFIG_KEYS = new Set([
'workflow.code_review',
'workflow.code_review_depth',
'workflow.code_review_command',
'workflow.plan_bounce',
'workflow.plan_bounce_script',
'workflow.plan_bounce_passes',
'git.branching_strategy', 'git.base_branch', 'git.phase_branch_template', 'git.milestone_branch_template', 'git.quick_branch_template',
'planning.commit_docs', 'planning.search_gitignored',
'workflow.subagent_timeout',
@@ -38,6 +41,7 @@ const VALID_CONFIG_KEYS = new Set([
'manager.flags.discuss', 'manager.flags.plan', 'manager.flags.execute',
'response_language',
'intel.enabled',
'claude_md_path',
]);
/**
@@ -157,6 +161,9 @@ function buildNewProjectConfig(userChoices) {
code_review: true,
code_review_depth: 'standard',
code_review_command: null,
plan_bounce: false,
plan_bounce_script: null,
plan_bounce_passes: 2,
},
hooks: {
context_warnings: true,
@@ -164,6 +171,7 @@ function buildNewProjectConfig(userChoices) {
project_code: null,
phase_naming: 'sequential',
agent_skills: {},
claude_md_path: './CLAUDE.md',
};
// Three-level deep merge: hardcoded <- userDefaults <- choices

View File

@@ -159,14 +159,25 @@ function findProjectRoot(startDir) {
* @param {number} opts.maxAgeMs - max age in ms before removal (default: 5 min)
* @param {boolean} opts.dirsOnly - if true, only remove directories (default: false)
*/
/**
* Dedicated GSD temp directory: path.join(os.tmpdir(), 'gsd').
* Created on first use. Keeps GSD temp files isolated from the system
* temp directory so reap scans only GSD files (#1975).
*/
const GSD_TEMP_DIR = path.join(require('os').tmpdir(), 'gsd');
function ensureGsdTempDir() {
fs.mkdirSync(GSD_TEMP_DIR, { recursive: true });
}
function reapStaleTempFiles(prefix = 'gsd-', { maxAgeMs = 5 * 60 * 1000, dirsOnly = false } = {}) {
try {
const tmpDir = require('os').tmpdir();
ensureGsdTempDir();
const now = Date.now();
const entries = fs.readdirSync(tmpDir);
const entries = fs.readdirSync(GSD_TEMP_DIR);
for (const entry of entries) {
if (!entry.startsWith(prefix)) continue;
const fullPath = path.join(tmpDir, entry);
const fullPath = path.join(GSD_TEMP_DIR, entry);
try {
const stat = fs.statSync(fullPath);
if (now - stat.mtimeMs > maxAgeMs) {
@@ -195,7 +206,8 @@ function output(result, raw, rawValue) {
// Write to tmpfile and output the path prefixed with @file: so callers can detect it.
if (json.length > 50000) {
reapStaleTempFiles();
const tmpPath = path.join(require('os').tmpdir(), `gsd-${Date.now()}.json`);
ensureGsdTempDir();
const tmpPath = path.join(GSD_TEMP_DIR, `gsd-${Date.now()}.json`);
fs.writeFileSync(tmpPath, json, 'utf-8');
data = '@file:' + tmpPath;
} else {
@@ -313,7 +325,7 @@ function loadConfig(cwd) {
// Section containers that hold nested sub-keys
'git', 'workflow', 'planning', 'hooks', 'features',
// Internal keys loadConfig reads but config-set doesn't expose
'model_overrides', 'agent_skills', 'context_window', 'resolve_model_ids',
'model_overrides', 'agent_skills', 'context_window', 'resolve_model_ids', 'claude_md_path',
// Deprecated keys (still accepted for migration, not in config-set)
'depth', 'multiRepo',
]);
@@ -374,6 +386,7 @@ function loadConfig(cwd) {
agent_skills: parsed.agent_skills || {},
manager: parsed.manager || {},
response_language: get('response_language') || null,
claude_md_path: get('claude_md_path') || null,
};
} catch {
// Fall back to ~/.gsd/defaults.json only for truly pre-project contexts (#1683)
@@ -1578,6 +1591,7 @@ module.exports = {
findProjectRoot,
detectSubRepos,
reapStaleTempFiles,
GSD_TEMP_DIR,
MODEL_ALIAS_MAP,
CONFIG_DEFAULTS,
planningDir,

View File

@@ -1513,6 +1513,105 @@ function cmdAgentSkills(cwd, agentType, raw) {
process.exit(0);
}
/**
* Generate a skill manifest from a skills directory.
*
* Scans the given skills directory for subdirectories containing SKILL.md,
* extracts frontmatter (name, description) and trigger conditions from the
* body text, and returns an array of skill descriptors.
*
* @param {string} skillsDir - Absolute path to the skills directory
* @returns {Array<{name: string, description: string, triggers: string[], path: string}>}
*/
function buildSkillManifest(skillsDir) {
const { extractFrontmatter } = require('./frontmatter.cjs');
if (!fs.existsSync(skillsDir)) return [];
let entries;
try {
entries = fs.readdirSync(skillsDir, { withFileTypes: true });
} catch {
return [];
}
const manifest = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillMdPath = path.join(skillsDir, entry.name, 'SKILL.md');
if (!fs.existsSync(skillMdPath)) continue;
let content;
try {
content = fs.readFileSync(skillMdPath, 'utf-8');
} catch {
continue;
}
const frontmatter = extractFrontmatter(content);
const name = frontmatter.name || entry.name;
const description = frontmatter.description || '';
// Extract trigger lines from body text (after frontmatter)
const triggers = [];
const bodyMatch = content.match(/^---[\s\S]*?---\s*\n([\s\S]*)$/);
if (bodyMatch) {
const body = bodyMatch[1];
const triggerLines = body.match(/^TRIGGER\s+when:\s*(.+)$/gmi);
if (triggerLines) {
for (const line of triggerLines) {
const m = line.match(/^TRIGGER\s+when:\s*(.+)$/i);
if (m) triggers.push(m[1].trim());
}
}
}
manifest.push({
name,
description,
triggers,
path: entry.name,
});
}
// Sort by name for deterministic output
manifest.sort((a, b) => a.name.localeCompare(b.name));
return manifest;
}
/**
* Command: generate skill manifest JSON.
*
* Options:
* --skills-dir <path> Path to skills directory (required)
* --write Also write to .planning/skill-manifest.json
*/
function cmdSkillManifest(cwd, args, raw) {
const skillsDirIdx = args.indexOf('--skills-dir');
const skillsDir = skillsDirIdx >= 0 && args[skillsDirIdx + 1]
? args[skillsDirIdx + 1]
: null;
if (!skillsDir) {
output([], raw);
return;
}
const manifest = buildSkillManifest(skillsDir);
// Optionally write to .planning/skill-manifest.json
if (args.includes('--write')) {
const planningDir = path.join(cwd, '.planning');
if (fs.existsSync(planningDir)) {
const manifestPath = path.join(planningDir, 'skill-manifest.json');
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8');
}
}
output(manifest, raw);
}
module.exports = {
cmdInitExecutePhase,
cmdInitPlanPhase,
@@ -1533,4 +1632,6 @@ module.exports = {
detectChildRepos,
buildAgentSkillsBlock,
cmdAgentSkills,
buildSkillManifest,
cmdSkillManifest,
};

View File

@@ -12,7 +12,7 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const { output, error, safeReadFile } = require('./core.cjs');
const { output, error, safeReadFile, loadConfig } = require('./core.cjs');
// ─── Constants ────────────────────────────────────────────────────────────────
@@ -870,7 +870,13 @@ function cmdGenerateClaudeProfile(cwd, options, raw) {
} else if (options.output) {
targetPath = path.isAbsolute(options.output) ? options.output : path.join(cwd, options.output);
} else {
targetPath = path.join(cwd, 'CLAUDE.md');
// Read claude_md_path from config, default to ./CLAUDE.md
let configClaudeMdPath = './CLAUDE.md';
try {
const config = loadConfig(cwd);
if (config.claude_md_path) configClaudeMdPath = config.claude_md_path;
} catch { /* use default */ }
targetPath = path.isAbsolute(configClaudeMdPath) ? configClaudeMdPath : path.join(cwd, configClaudeMdPath);
}
let action;
@@ -944,7 +950,13 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
let outputPath = options.output;
if (!outputPath) {
outputPath = path.join(cwd, 'CLAUDE.md');
// Read claude_md_path from config, default to ./CLAUDE.md
let configClaudeMdPath = './CLAUDE.md';
try {
const config = loadConfig(cwd);
if (config.claude_md_path) configClaudeMdPath = config.claude_md_path;
} catch { /* use default */ }
outputPath = path.isAbsolute(configClaudeMdPath) ? configClaudeMdPath : path.join(cwd, configClaudeMdPath);
} else if (!path.isAbsolute(outputPath)) {
outputPath = path.join(cwd, outputPath);
}

View File

@@ -0,0 +1,73 @@
# Planner Source Audit & Authority Limits
Reference for `agents/gsd-planner.md` — extended rules for multi-source coverage audits and planner authority constraints.
## Multi-Source Coverage Audit Format
Before finalizing plans, produce a **source audit** covering ALL four artifact types:
```
SOURCE | ID | Feature/Requirement | Plan | Status | Notes
--------- | ------- | ---------------------------- | ----- | --------- | ------
GOAL | — | {phase goal from ROADMAP.md} | 01-03 | COVERED |
REQ | REQ-14 | OAuth login with Google + GH | 02 | COVERED |
REQ | REQ-22 | Email verification flow | 03 | COVERED |
RESEARCH | — | Rate limiting on auth routes | 01 | COVERED |
RESEARCH | — | Refresh token rotation | NONE | ⚠ MISSING | No plan covers this
CONTEXT | D-01 | Use jose library for JWT | 02 | COVERED |
CONTEXT | D-04 | 15min access / 7day refresh | 02 | COVERED |
```
### Four Source Types
1. **GOAL** — The `goal:` field from ROADMAP.md for this phase. The primary success condition.
2. **REQ** — Every REQ-ID in `phase_req_ids`. Cross-reference REQUIREMENTS.md for descriptions.
3. **RESEARCH** — Technical approaches, discovered constraints, and features identified in RESEARCH.md. Exclude items explicitly marked "out of scope" or "future work" by the researcher.
4. **CONTEXT** — Every D-XX decision from CONTEXT.md `<decisions>` section.
### What is NOT a Gap
Do not flag these as MISSING:
- Items in `## Deferred Ideas` in CONTEXT.md — developer chose to defer these
- Items scoped to a different phase via `phase_req_ids` — not assigned to this phase
- Items in RESEARCH.md explicitly marked "out of scope" or "future work" by the researcher
### Handling MISSING Items
If ANY row is `⚠ MISSING`, do NOT finalize the plan set silently. Return to the orchestrator:
```
## ⚠ Source Audit: Unplanned Items Found
The following items from source artifacts have no corresponding plan:
1. **{SOURCE}: {item description}** (from {artifact file}, section "{section}")
- {why this was identified as required}
Options:
A) Add a plan to cover this item
B) Split phase: move to a sub-phase
C) Defer explicitly: add to backlog with developer confirmation
→ Awaiting developer decision before finalizing plan set.
```
If ALL rows are COVERED → return `## PLANNING COMPLETE` as normal.
---
## Authority Limits — Constraint Examples
The planner's only legitimate reasons to split or flag a feature are **constraints**, not judgments about difficulty:
**Valid (constraints):**
- ✓ "This task touches 9 files and would consume ~45% context — split into two tasks"
- ✓ "No API key or endpoint is defined in any source artifact — need developer input"
- ✓ "This feature depends on the auth system built in Phase 03, which is not yet complete"
**Invalid (difficulty judgments):**
- ✗ "This is complex and would be difficult to implement correctly"
- ✗ "Integrating with an external service could take a long time"
- ✗ "This is a challenging feature that might be better left to a future phase"
If a feature has none of the three legitimate constraints (context cost, missing information, dependency conflict), it gets planned. Period.

View File

@@ -12,7 +12,10 @@
"security_block_on": "high",
"discuss_mode": "discuss",
"research_before_questions": false,
"code_review_command": null
"code_review_command": null,
"plan_bounce": false,
"plan_bounce_script": null,
"plan_bounce_passes": 2
},
"planning": {
"commit_docs": true,
@@ -45,5 +48,6 @@
"context_warnings": true
},
"project_code": null,
"agent_skills": {}
"agent_skills": {},
"claude_md_path": "./CLAUDE.md"
}

View File

@@ -38,6 +38,18 @@ Template for `.planning/phases/XX-name/{phase_num}-RESEARCH.md` - comprehensive
**If no CONTEXT.md exists:** Write "No user constraints - all decisions at Claude's discretion"
</user_constraints>
<architectural_responsibility_map>
## Architectural Responsibility Map
Map each phase capability to its standard architectural tier owner before diving into framework research. This prevents tier misassignment from propagating into plans.
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| [capability from phase description] | [Browser/Client, Frontend Server, API/Backend, CDN/Static, or Database/Storage] | [secondary tier or —] | [why this tier owns it] |
**If single-tier application:** Write "Single-tier application — all capabilities reside in [tier]" and omit the table.
</architectural_responsibility_map>
<research_summary>
## Summary

View File

@@ -113,6 +113,15 @@ Phase: "API documentation"
<answer_validation>
**IMPORTANT: Answer validation** — After every AskUserQuestion call, check if the response is empty or whitespace-only. If so:
**Exception — "Other" with empty text:** If the user selected "Other" (or "Chat more") and the response body is empty or whitespace-only, this is NOT an empty answer — it is a signal that the user wants to type freeform input. In this case:
1. Output a single plain-text line: "What would you like to discuss?"
2. STOP generating. Do not call any tools. Do not output any further text.
3. Wait for the user's next message.
4. After receiving their message, reflect it back and continue.
Do NOT retry the AskUserQuestion or generate more questions when "Other" is selected with empty text.
**All other empty responses:** If the response is empty or whitespace-only (and the user did NOT select "Other"):
1. Retry the question once with the same parameters
2. If still empty, present the options as a plain-text numbered list and ask the user to type their choice number
Never proceed with an empty answer.

View File

@@ -202,7 +202,7 @@ Workspace created: $TARGET_PATH
Branch: $BRANCH_NAME
Next steps:
cd $TARGET_PATH
cd "$TARGET_PATH"
/gsd-new-project # Initialize GSD in the workspace
```
@@ -215,7 +215,7 @@ Workspace created with $SUCCESS_COUNT of $TOTAL_COUNT repos: $TARGET_PATH
Failed: repo3 (branch already exists), repo4 (not a git repo)
Next steps:
cd $TARGET_PATH
cd "$TARGET_PATH"
/gsd-new-project # Initialize GSD in the workspace
```
@@ -225,7 +225,7 @@ Use AskUserQuestion:
- header: "Initialize GSD"
- question: "Would you like to initialize a GSD project in the new workspace?"
- options:
- "Yes — run /gsd-new-project" → tell user to `cd $TARGET_PATH` first, then run `/gsd-new-project`
- "Yes — run /gsd-new-project" → tell user to `cd "$TARGET_PATH"` first, then run `/gsd-new-project`
- "No — I'll set it up later" → done
</process>

View File

@@ -82,12 +82,56 @@ Use `--force` to bypass this check.
```
Exit.
**Consecutive-call guard:**
After passing all gates, check a counter file `.planning/.next-call-count`:
- If file exists and count >= 6: prompt "You've called /gsd-next {N} times consecutively. Continue? [y/N]"
- If user says no, exit
- Increment the counter
- The counter file is deleted by any non-`/gsd-next` command (convention — other workflows don't need to implement this, the note here is sufficient)
**Prior-phase completeness scan:**
After passing all three hard-stop gates, scan all phases that precede the current phase in ROADMAP.md order for incomplete work. Use the existing `gsd-tools.cjs phase json <N>` output to inspect each prior phase.
Detect three categories of incomplete work:
1. **Plans without summaries** — a PLAN.md exists in a prior phase directory but no matching SUMMARY.md exists (execution started but not completed).
2. **Verification failures not overridden** — a prior phase has a VERIFICATION.md with `FAIL` items that have no override annotation.
3. **CONTEXT.md without plans** — a prior phase directory has a CONTEXT.md but no PLAN.md files (discussion happened, planning never ran).
If no incomplete prior work is found, continue to `determine_next_action` silently with no interruption.
If incomplete prior work is found, show a structured completeness report:
```
⚠ Prior phase has incomplete work
Phase {N} — "{name}" has unresolved items:
• Plan {N}-{M} ({slug}): executed but no SUMMARY.md
[... additional items ...]
Advancing before resolving these may cause:
• Verification gaps — future phase verification won't have visibility into what prior phases shipped
• Context loss — plans that ran without summaries leave no record for future agents
Options:
[C] Continue and defer these items to backlog
[S] Stop and resolve manually (recommended)
[F] Force advance without recording deferral
Choice [S]:
```
**If the user chooses "Stop" (S or Enter/default):** Exit without routing.
**If the user chooses "Continue and defer" (C):**
1. For each incomplete item, create a backlog entry in `ROADMAP.md` under `## Backlog` using the existing `999.x` numbering scheme:
```markdown
### Phase 999.{N}: Follow-up — Phase {src} incomplete plans (BACKLOG)
**Goal:** Resolve plans that ran without producing summaries during Phase {src} execution
**Source phase:** {src}
**Deferred at:** {date} during /gsd-next advancement to Phase {dest}
**Plans:**
- [ ] {N}-{M}: {slug} (ran, no SUMMARY.md)
```
2. Commit the deferral record:
```bash
node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" commit "docs: defer incomplete Phase {src} items to backlog"
```
3. Continue routing to `determine_next_action` immediately — no second prompt.
**If the user chooses "Force" (F):** Continue to `determine_next_action` without recording deferral.
</step>
<step name="determine_next_action">

View File

@@ -46,7 +46,7 @@ Parse JSON for: `researcher_model`, `planner_model`, `checker_model`, `research_
## 2. Parse and Normalize Arguments
Extract from $ARGUMENTS: phase number (integer or decimal like `2.1`), flags (`--research`, `--skip-research`, `--gaps`, `--skip-verify`, `--skip-ui`, `--prd <filepath>`, `--reviews`, `--text`).
Extract from $ARGUMENTS: phase number (integer or decimal like `2.1`), flags (`--research`, `--skip-research`, `--gaps`, `--skip-verify`, `--skip-ui`, `--prd <filepath>`, `--reviews`, `--text`, `--bounce`, `--skip-bounce`).
Set `TEXT_MODE=true` if `--text` is present in $ARGUMENTS OR `text_mode` from init JSON is `true`. When `TEXT_MODE` is active, replace every `AskUserQuestion` call with a plain-text numbered list and ask the user to type their choice number. This is required for Claude Code remote sessions (`/rc` mode) where TUI menus don't work through the Claude App.
@@ -719,41 +719,70 @@ Task(
## 9. Handle Planner Return
- **`## PLANNING COMPLETE`:** Display plan count. If `--skip-verify` or `plan_checker_enabled` is false (from init): skip to step 13. Otherwise: step 10.
- **`## PHASE SPLIT RECOMMENDED`:** The planner determined the phase is too complex to implement all user decisions without simplifying them. Handle in step 9b.
- **`## PHASE SPLIT RECOMMENDED`:** The planner determined the phase exceeds the context budget for full-fidelity implementation of all source items. Handle in step 9b.
- **`## ⚠ Source Audit: Unplanned Items Found`:** The planner's multi-source coverage audit found items from REQUIREMENTS.md, RESEARCH.md, ROADMAP goal, or CONTEXT.md decisions that are not covered by any plan. Handle in step 9c.
- **`## CHECKPOINT REACHED`:** Present to user, get response, spawn continuation (step 12)
- **`## PLANNING INCONCLUSIVE`:** Show attempts, offer: Add context / Retry / Manual
## 9b. Handle Phase Split Recommendation
When the planner returns `## PHASE SPLIT RECOMMENDED`, it means the phase has too many decisions to implement at full fidelity within the plan budget. The planner proposes groupings.
When the planner returns `## PHASE SPLIT RECOMMENDED`, it means the phase's source items exceed the context budget for full-fidelity implementation. The planner proposes groupings.
**Extract from planner return:**
- Proposed sub-phases (e.g., "17a: processing core (D-01 to D-19)", "17b: billing + config UX (D-20 to D-27)")
- Which D-XX decisions go in each sub-phase
- Why the split is necessary (decision count, complexity estimate)
- Which source items (REQ-IDs, D-XX decisions, RESEARCH items) go in each sub-phase
- Why the split is necessary (context cost estimate, file count)
**Present to user:**
```
## Phase {X} is too complex for full-fidelity implementation
## Phase {X} exceeds context budget for full-fidelity implementation
The planner found {N} decisions that cannot all be implemented without
simplifying some. Instead of reducing your decisions, we recommend splitting:
The planner found {N} source items that exceed the context budget when
planned at full fidelity. Instead of reducing scope, we recommend splitting:
**Option 1: Split into sub-phases**
- Phase {X}a: {name} — {D-XX to D-YY} ({N} decisions)
- Phase {X}b: {name} — {D-XX to D-YY} ({M} decisions)
- Phase {X}a: {name} — {items} ({N} source items, ~{P}% context)
- Phase {X}b: {name} — {items} ({M} source items, ~{Q}% context)
**Option 2: Proceed anyway** (planner will attempt all, quality may degrade)
**Option 2: Proceed anyway** (planner will attempt all, quality may degrade past 50% context)
**Option 3: Prioritize** — you choose which decisions to implement now,
**Option 3: Prioritize** — you choose which items to implement now,
rest become a follow-up phase
```
Use AskUserQuestion with these 3 options.
**If "Split":** Use `/gsd-insert-phase` to create the sub-phases, then replan each.
**If "Proceed":** Return to planner with instruction to attempt all decisions at full fidelity, accepting more plans/tasks.
**If "Prioritize":** Use AskUserQuestion (multiSelect) to let user pick which D-XX are "now" vs "later". Create CONTEXT.md for each sub-phase with the selected decisions.
**If "Proceed":** Return to planner with instruction to attempt all items at full fidelity, accepting more plans/tasks.
**If "Prioritize":** Use AskUserQuestion (multiSelect) to let user pick which items are "now" vs "later". Create CONTEXT.md for each sub-phase with the selected items.
## 9c. Handle Source Audit Gaps
When the planner returns `## ⚠ Source Audit: Unplanned Items Found`, it means items from REQUIREMENTS.md, RESEARCH.md, ROADMAP goal, or CONTEXT.md decisions have no corresponding plan.
**Extract from planner return:**
- Each unplanned item with its source artifact and section
- The planner's suggested options (A: add plan, B: split phase, C: defer with confirmation)
**Present each gap to user.** For each unplanned item:
```
## ⚠ Unplanned: {item description}
Source: {RESEARCH.md / REQUIREMENTS.md / ROADMAP goal / CONTEXT.md}
Details: {why the planner flagged this}
Options:
1. Add a plan to cover this item (recommended)
2. Split phase — move to a sub-phase with related items
3. Defer — add to backlog (developer confirms this is intentional)
```
Use AskUserQuestion for each gap (or batch if multiple gaps).
**If "Add plan":** Return to planner (step 8) with instruction to add plans covering the missing items, preserving existing plans.
**If "Split":** Use `/gsd-insert-phase` for overflow items, then replan.
**If "Defer":** Record in CONTEXT.md `## Deferred Ideas` with developer's confirmation. Proceed to step 10.
## 10. Spawn gsd-plan-checker Agent
@@ -901,6 +930,77 @@ Display: `Max iterations reached. {N} issues remain:` + issue list
Offer: 1) Force proceed, 2) Provide guidance and retry, 3) Abandon
## 12.5. Plan Bounce (Optional External Refinement)
**Skip if:** `--skip-bounce` flag, `--gaps` flag, or bounce is not activated.
**Activation:** Bounce runs when `--bounce` flag is present OR `workflow.plan_bounce` config is `true`. The `--skip-bounce` flag always wins (disables bounce even if config enables it). The `--gaps` flag also disables bounce (gap-closure mode should not modify plans externally).
**Prerequisites:** `workflow.plan_bounce_script` must be set to a valid script path. If bounce is activated but no script is configured, display warning and skip:
```
⚠ Plan bounce activated but no script configured.
Set workflow.plan_bounce_script to the path of your refinement script.
Skipping bounce step.
```
**Read pass count:**
```bash
BOUNCE_PASSES=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-get workflow.plan_bounce_passes --default 2)
BOUNCE_SCRIPT=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-get workflow.plan_bounce_script)
```
Display banner:
```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
GSD ► BOUNCING PLANS (External Refinement)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Script: ${BOUNCE_SCRIPT}
Max passes: ${BOUNCE_PASSES}
```
**For each PLAN.md file in the phase directory:**
1. **Backup:** Copy `*-PLAN.md` to `*-PLAN.pre-bounce.md`
```bash
cp "${PLAN_FILE}" "${PLAN_FILE%.md}.pre-bounce.md"
```
2. **Invoke bounce script:**
```bash
"${BOUNCE_SCRIPT}" "${PLAN_FILE}" "${BOUNCE_PASSES}"
```
3. **Validate bounced plan — YAML frontmatter integrity:**
After the script returns, check that the bounced file still has valid YAML frontmatter (opening and closing `---` delimiters with parseable content between them). If the bounced plan breaks YAML frontmatter validation, restore the original from the pre-bounce.md backup and continue to the next plan:
```
⚠ Bounced plan ${PLAN_FILE} has broken YAML frontmatter — restoring original from pre-bounce backup.
```
4. **Handle script failure:** If the bounce script exits non-zero, restore the original plan from the pre-bounce.md backup and continue to the next plan:
```
⚠ Bounce script failed for ${PLAN_FILE} (exit code ${EXIT_CODE}) — restoring original from pre-bounce backup.
```
**After all plans are bounced:**
5. **Re-run plan checker on bounced plans:** Spawn gsd-plan-checker (same as step 10) on all modified plans. If a bounced plan fails the checker, restore original from its pre-bounce.md backup:
```
⚠ Bounced plan ${PLAN_FILE} failed checker validation — restoring original from pre-bounce backup.
```
6. **Commit surviving bounced plans:** If at least one plan survived both the frontmatter validation and the checker re-run, commit the changes:
```bash
node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" commit "refactor(${padded_phase}): bounce plans through external refinement" --files "${PHASE_DIR}/*-PLAN.md"
```
Display summary:
```
Plan bounce complete: {survived}/{total} plans refined
```
**Clean up:** Remove all `*-PLAN.pre-bounce.md` backup files after the bounce step completes (whether plans survived or were restored).
## 13. Requirements Coverage Gate
After plans pass the checker (or checker is skipped), verify that all phase requirements are covered by at least one plan.

View File

@@ -43,7 +43,7 @@ Cannot remove workspace "$WORKSPACE_NAME" — the following repos have uncommitt
- repo2
Commit or stash changes in these repos before removing the workspace:
cd $WORKSPACE_PATH/repo1
cd "$WORKSPACE_PATH/repo1"
git stash # or git commit
```

View File

@@ -0,0 +1,201 @@
/**
* Tests for configurable claude_md_path setting (#2010)
*/
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
describe('claude_md_path config key', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('claude_md_path is in VALID_CONFIG_KEYS', () => {
const { VALID_CONFIG_KEYS } = require('../get-shit-done/bin/lib/config.cjs');
assert.ok(VALID_CONFIG_KEYS.has('claude_md_path'));
});
test('config template includes claude_md_path', () => {
const templatePath = path.join(__dirname, '..', 'get-shit-done', 'templates', 'config.json');
const template = JSON.parse(fs.readFileSync(templatePath, 'utf-8'));
assert.strictEqual(template.claude_md_path, './CLAUDE.md');
});
test('config-get claude_md_path returns default value when not set', () => {
// Create a config.json without claude_md_path
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify({ mode: 'interactive' }), 'utf-8');
const result = runGsdTools('config-get claude_md_path --default ./CLAUDE.md', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Expected success but got error: ${result.error}`);
assert.strictEqual(JSON.parse(result.output), './CLAUDE.md');
});
test('config-set claude_md_path works', () => {
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify({ mode: 'interactive' }), 'utf-8');
const setResult = runGsdTools('config-set claude_md_path .claude/CLAUDE.md', tmpDir, { HOME: tmpDir });
assert.ok(setResult.success, `Expected success but got error: ${setResult.error}`);
const getResult = runGsdTools('config-get claude_md_path', tmpDir, { HOME: tmpDir });
assert.ok(getResult.success, `Expected success but got error: ${getResult.error}`);
assert.strictEqual(JSON.parse(getResult.output), '.claude/CLAUDE.md');
});
test('buildNewProjectConfig includes claude_md_path default', () => {
// Use config-new-project which calls buildNewProjectConfig
const result = runGsdTools('config-new-project', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Expected success but got error: ${result.error}`);
const configPath = path.join(tmpDir, '.planning', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
assert.strictEqual(config.claude_md_path, './CLAUDE.md');
});
});
describe('cmdGenerateClaudeProfile reads claude_md_path from config', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('uses claude_md_path from config when no --output or --global', () => {
// Set up config with custom claude_md_path
const configPath = path.join(tmpDir, '.planning', 'config.json');
const customPath = '.claude/CLAUDE.md';
fs.writeFileSync(configPath, JSON.stringify({ claude_md_path: customPath }), 'utf-8');
// Create the target directory
fs.mkdirSync(path.join(tmpDir, '.claude'), { recursive: true });
// Create a minimal analysis file
const analysisPath = path.join(tmpDir, '.planning', 'analysis.json');
const analysis = {
dimensions: {
communication_style: { rating: 'terse-direct', confidence: 'HIGH' },
},
data_source: 'test',
};
fs.writeFileSync(analysisPath, JSON.stringify(analysis), 'utf-8');
const result = runGsdTools(
['generate-claude-profile', '--analysis', analysisPath],
tmpDir,
{ HOME: tmpDir }
);
assert.ok(result.success, `Expected success but got error: ${result.error}`);
const parsed = JSON.parse(result.output);
const realTmpDir = fs.realpathSync(tmpDir);
const expectedPath = path.join(realTmpDir, customPath);
assert.strictEqual(parsed.claude_md_path, expectedPath);
assert.ok(fs.existsSync(expectedPath), `Expected file at ${expectedPath}`);
});
test('--output flag overrides claude_md_path from config', () => {
// Set up config with custom claude_md_path
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify({ claude_md_path: '.claude/CLAUDE.md' }), 'utf-8');
// Create analysis file
const analysisPath = path.join(tmpDir, '.planning', 'analysis.json');
const analysis = {
dimensions: {
communication_style: { rating: 'terse-direct', confidence: 'HIGH' },
},
data_source: 'test',
};
fs.writeFileSync(analysisPath, JSON.stringify(analysis), 'utf-8');
const outputFile = 'custom-output.md';
const result = runGsdTools(
['generate-claude-profile', '--analysis', analysisPath, '--output', outputFile],
tmpDir,
{ HOME: tmpDir }
);
assert.ok(result.success, `Expected success but got error: ${result.error}`);
const parsed = JSON.parse(result.output);
const realTmpDir = fs.realpathSync(tmpDir);
assert.strictEqual(parsed.claude_md_path, path.join(realTmpDir, outputFile));
});
});
describe('cmdGenerateClaudeMd reads claude_md_path from config', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
// Create minimal project files so generate-claude-md has something to read
fs.writeFileSync(
path.join(tmpDir, '.planning', 'PROJECT.md'),
['# Test Project', '', 'A test project.'].join('\n'),
'utf-8'
);
});
afterEach(() => {
cleanup(tmpDir);
});
test('uses claude_md_path from config when no --output', () => {
// Set up config with custom claude_md_path
const configPath = path.join(tmpDir, '.planning', 'config.json');
const customPath = '.claude/CLAUDE.md';
fs.writeFileSync(configPath, JSON.stringify({ claude_md_path: customPath }), 'utf-8');
// Create the target directory
fs.mkdirSync(path.join(tmpDir, '.claude'), { recursive: true });
const result = runGsdTools('generate-claude-md', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Expected success but got error: ${result.error}`);
const parsed = JSON.parse(result.output);
const realTmpDir = fs.realpathSync(tmpDir);
const expectedPath = path.join(realTmpDir, customPath);
assert.strictEqual(parsed.claude_md_path, expectedPath);
assert.ok(fs.existsSync(expectedPath), `Expected file at ${expectedPath}`);
});
test('--output flag overrides claude_md_path from config', () => {
// Set up config with custom claude_md_path
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify({ claude_md_path: '.claude/CLAUDE.md' }), 'utf-8');
const outputFile = 'my-custom.md';
const result = runGsdTools(['generate-claude-md', '--output', outputFile], tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Expected success but got error: ${result.error}`);
const parsed = JSON.parse(result.output);
const realTmpDir = fs.realpathSync(tmpDir);
assert.strictEqual(parsed.claude_md_path, path.join(realTmpDir, outputFile));
});
test('defaults to ./CLAUDE.md when config has no claude_md_path', () => {
// Set up config without claude_md_path
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify({ mode: 'interactive' }), 'utf-8');
const result = runGsdTools('generate-claude-md', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Expected success but got error: ${result.error}`);
const parsed = JSON.parse(result.output);
const realTmpDir = fs.realpathSync(tmpDir);
assert.strictEqual(parsed.claude_md_path, path.join(realTmpDir, 'CLAUDE.md'));
});
});

View File

@@ -1629,9 +1629,11 @@ describe('findProjectRoot', () => {
// ─── reapStaleTempFiles ─────────────────────────────────────────────────────
describe('reapStaleTempFiles', () => {
const gsdTmpDir = path.join(os.tmpdir(), 'gsd');
test('removes stale gsd-*.json files older than maxAgeMs', () => {
const tmpDir = os.tmpdir();
const stalePath = path.join(tmpDir, `gsd-reap-test-${Date.now()}.json`);
fs.mkdirSync(gsdTmpDir, { recursive: true });
const stalePath = path.join(gsdTmpDir, `gsd-reap-test-${Date.now()}.json`);
fs.writeFileSync(stalePath, '{}');
// Set mtime to 10 minutes ago
const oldTime = new Date(Date.now() - 10 * 60 * 1000);
@@ -1643,8 +1645,8 @@ describe('reapStaleTempFiles', () => {
});
test('preserves fresh gsd-*.json files', () => {
const tmpDir = os.tmpdir();
const freshPath = path.join(tmpDir, `gsd-reap-fresh-${Date.now()}.json`);
fs.mkdirSync(gsdTmpDir, { recursive: true });
const freshPath = path.join(gsdTmpDir, `gsd-reap-fresh-${Date.now()}.json`);
fs.writeFileSync(freshPath, '{}');
reapStaleTempFiles('gsd-reap-fresh-', { maxAgeMs: 5 * 60 * 1000 });
@@ -1655,8 +1657,8 @@ describe('reapStaleTempFiles', () => {
});
test('removes stale temp directories when present', () => {
const tmpDir = os.tmpdir();
const staleDir = fs.mkdtempSync(path.join(tmpDir, 'gsd-reap-dir-'));
fs.mkdirSync(gsdTmpDir, { recursive: true });
const staleDir = fs.mkdtempSync(path.join(gsdTmpDir, 'gsd-reap-dir-'));
fs.writeFileSync(path.join(staleDir, 'data.jsonl'), 'test');
// Set mtime to 10 minutes ago
const oldTime = new Date(Date.now() - 10 * 60 * 1000);

View File

@@ -1,11 +1,11 @@
/**
* GSD Tools Tests - /gsd-next safety gates and consecutive-call guard
* GSD Tools Tests - /gsd-next safety gates and prior-phase completeness scan
*
* Validates that the next workflow includes three hard-stop safety gates
* (checkpoint, error state, verification), a consecutive-call budget guard,
* and a --force bypass flag.
* (checkpoint, error state, verification), a prior-phase completeness scan
* replacing the old consecutive-call counter, and a --force bypass flag.
*
* Closes: #1732
* Closes: #1732, #2089
*/
const { test, describe } = require('node:test');
@@ -13,7 +13,7 @@ const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
describe('/gsd-next safety gates (#1732)', () => {
describe('/gsd-next safety gates (#1732, #2089)', () => {
const workflowPath = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'next.md');
const commandPath = path.join(__dirname, '..', 'commands', 'gsd', 'next.md');
@@ -79,19 +79,72 @@ describe('/gsd-next safety gates (#1732)', () => {
);
});
test('consecutive-call budget guard', () => {
test('prior-phase completeness scan replaces consecutive-call counter', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(
content.includes('.next-call-count'),
'workflow should reference .next-call-count counter file'
content.includes('Prior-phase completeness scan'),
'workflow should have a prior-phase completeness scan section'
);
assert.ok(
content.includes('6'),
'consecutive guard should trigger at count >= 6'
!content.includes('.next-call-count'),
'workflow must not reference the old .next-call-count counter file'
);
assert.ok(
content.includes('consecutively'),
'guard should mention consecutive calls'
!content.includes('consecutively'),
'workflow must not reference consecutive call counting'
);
});
test('completeness scan checks plans without summaries', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(
content.includes('Plans without summaries') || content.includes('no SUMMARY.md'),
'completeness scan should detect plans that ran without producing summaries'
);
});
test('completeness scan checks verification failures in prior phases', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(
content.includes('Verification failures not overridden') ||
content.includes('VERIFICATION.md with `FAIL`'),
'completeness scan should detect unoverridden FAIL items in prior phase VERIFICATION.md'
);
});
test('completeness scan checks CONTEXT.md without plans', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(
content.includes('CONTEXT.md without plans') ||
content.includes('CONTEXT.md but no PLAN.md'),
'completeness scan should detect phases with discussion but no planning'
);
});
test('completeness scan offers Continue, Stop, and Force options', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(content.includes('[C]'), 'completeness scan should offer [C] Continue option');
assert.ok(content.includes('[S]'), 'completeness scan should offer [S] Stop option');
assert.ok(content.includes('[F]'), 'completeness scan should offer [F] Force option');
});
test('deferral path creates backlog entry using 999.x scheme', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(
content.includes('999.'),
'deferral should use the 999.x backlog numbering scheme'
);
assert.ok(
content.includes('Backlog') || content.includes('BACKLOG'),
'deferral should write to the Backlog section of ROADMAP.md'
);
});
test('clean prior phases route silently with no interruption', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(
content.includes('silently') || content.includes('no interruption'),
'workflow should route without interruption when prior phases are clean'
);
});
@@ -107,7 +160,7 @@ describe('/gsd-next safety gates (#1732)', () => {
);
});
test('command definition documents --force flag', () => {
test('command definition documents --force flag and completeness scan', () => {
const content = fs.readFileSync(commandPath, 'utf8');
assert.ok(
content.includes('--force'),
@@ -117,6 +170,10 @@ describe('/gsd-next safety gates (#1732)', () => {
content.includes('bypass safety gates'),
'command definition should explain that --force bypasses safety gates'
);
assert.ok(
content.includes('completeness'),
'command definition should document the prior-phase completeness scan'
);
});
test('gates exit on first hit', () => {

View File

@@ -0,0 +1,177 @@
/**
* Phase Researcher Application-Aware Tests (#1988)
*
* Validates that gsd-phase-researcher maps capabilities to architectural
* tiers before diving into framework-specific research. Also validates
* that gsd-planner and gsd-plan-checker consume the Architectural
* Responsibility Map downstream.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const AGENTS_DIR = path.join(__dirname, '..', 'agents');
const TEMPLATES_DIR = path.join(__dirname, '..', 'get-shit-done', 'templates');
// ─── Phase Researcher: Architectural Responsibility Mapping ─────────────────
describe('phase-researcher: Architectural Responsibility Mapping', () => {
const researcherPath = path.join(AGENTS_DIR, 'gsd-phase-researcher.md');
const content = fs.readFileSync(researcherPath, 'utf-8');
test('contains Architectural Responsibility Mapping step', () => {
assert.ok(
content.includes('Architectural Responsibility Map'),
'gsd-phase-researcher.md must contain "Architectural Responsibility Map"'
);
});
test('Architectural Responsibility Mapping step comes after Step 1 and before Step 2', () => {
const step1Pos = content.indexOf('## Step 1:');
// Look for the step heading specifically (not the output format section)
const stepARMPos = content.indexOf('## Step 1.5:');
const step2Pos = content.indexOf('## Step 2:');
assert.ok(step1Pos !== -1, 'Step 1 must exist');
assert.ok(stepARMPos !== -1, 'Step 1.5 Architectural Responsibility Mapping step must exist');
assert.ok(step2Pos !== -1, 'Step 2 must exist');
assert.ok(
stepARMPos > step1Pos,
'Step 1.5 (Architectural Responsibility Mapping) must come after Step 1'
);
assert.ok(
stepARMPos < step2Pos,
'Step 1.5 (Architectural Responsibility Mapping) must come before Step 2'
);
});
test('step is a pure reasoning step with no tool calls', () => {
// Extract the ARM section content (between the ARM heading and the next ## Step heading)
const armHeadingMatch = content.match(/## Step 1\.5[^\n]*Architectural Responsibility Map/);
assert.ok(armHeadingMatch, 'Must have a Step 1.5 heading for Architectural Responsibility Mapping');
const armStart = content.indexOf(armHeadingMatch[0]);
const nextStepMatch = content.indexOf('## Step 2:', armStart);
const armSection = content.substring(armStart, nextStepMatch);
// Should not contain tool invocation patterns
const toolPatterns = [
/```bash/,
/node "\$HOME/,
/gsd-tools\.cjs/,
/WebSearch/,
/Context7/,
/mcp__/,
];
for (const pattern of toolPatterns) {
assert.ok(
!pattern.test(armSection),
`Architectural Responsibility Mapping step must be pure reasoning (no tool calls), but found: ${pattern}`
);
}
});
test('mentions standard architectural tiers', () => {
const armStart = content.indexOf('Architectural Responsibility Map');
const nextStep = content.indexOf('## Step 2:', armStart);
const armSection = content.substring(armStart, nextStep);
// Should reference standard tiers
const tiers = ['browser', 'frontend', 'API', 'database'];
const foundTiers = tiers.filter(tier =>
armSection.toLowerCase().includes(tier.toLowerCase())
);
assert.ok(
foundTiers.length >= 3,
`Must mention at least 3 standard architectural tiers, found: ${foundTiers.join(', ')}`
);
});
test('specifies output format as a table in RESEARCH.md', () => {
const armStart = content.indexOf('Architectural Responsibility Map');
const nextStep = content.indexOf('## Step 2:', armStart);
const armSection = content.substring(armStart, nextStep);
assert.ok(
armSection.includes('|') && armSection.includes('Capability'),
'ARM step must specify a table output format with Capability column'
);
});
});
// ─── Planner: Architectural Responsibility Map Sanity Check ─────────────────
describe('planner: Architectural Responsibility Map sanity check', () => {
const plannerPath = path.join(AGENTS_DIR, 'gsd-planner.md');
const content = fs.readFileSync(plannerPath, 'utf-8');
test('references Architectural Responsibility Map', () => {
assert.ok(
content.includes('Architectural Responsibility Map'),
'gsd-planner.md must reference the Architectural Responsibility Map'
);
});
test('includes sanity check against the map', () => {
// Must mention checking/verifying plan tasks against the responsibility map
assert.ok(
content.includes('sanity check') || content.includes('sanity-check'),
'gsd-planner.md must include a sanity check against the Architectural Responsibility Map'
);
});
});
// ─── Plan Checker: Architectural Tier Verification Dimension ────────────────
describe('plan-checker: Architectural Tier verification dimension', () => {
const checkerPath = path.join(AGENTS_DIR, 'gsd-plan-checker.md');
const content = fs.readFileSync(checkerPath, 'utf-8');
test('has verification dimension for architectural tier', () => {
assert.ok(
content.includes('Architectural Responsibility Map') ||
content.includes('Architectural Tier'),
'gsd-plan-checker.md must have a verification dimension for architectural tier mapping'
);
});
test('verification dimension checks plans against the map', () => {
// Should have a dimension that references tier/responsibility checking
assert.ok(
content.includes('tier owner') || content.includes('tier mismatch') || content.includes('responsibility map'),
'plan-checker verification dimension must check for tier mismatches against the responsibility map'
);
});
});
// ─── Research Template: Architectural Responsibility Map Section ─────────────
describe('research template: Architectural Responsibility Map section', () => {
const templatePath = path.join(TEMPLATES_DIR, 'research.md');
const content = fs.readFileSync(templatePath, 'utf-8');
test('mentions Architectural Responsibility Map section', () => {
assert.ok(
content.includes('Architectural Responsibility Map'),
'Research template must include an Architectural Responsibility Map section'
);
});
test('template includes tier table format', () => {
const armStart = content.indexOf('Architectural Responsibility Map');
assert.ok(armStart !== -1, 'ARM section must exist');
const sectionEnd = content.indexOf('##', armStart + 10);
const section = content.substring(armStart, sectionEnd !== -1 ? sectionEnd : armStart + 500);
assert.ok(
section.includes('|') && (section.includes('Tier') || section.includes('tier')),
'Research template ARM section must include a table format with Tier column'
);
});
});

170
tests/plan-bounce.test.cjs Normal file
View File

@@ -0,0 +1,170 @@
/**
* Plan Bounce Tests
*
* Validates plan bounce hook feature (step 12.5 in plan-phase):
* - Config key registration (workflow.plan_bounce, workflow.plan_bounce_script, workflow.plan_bounce_passes)
* - Config template defaults
* - Workflow step 12.5 content in plan-phase.md
* - Flag handling (--bounce, --skip-bounce)
* - Backup/restore pattern (pre-bounce.md)
* - Frontmatter integrity validation
* - Re-runs checker on bounced plans
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const GSD_ROOT = path.join(__dirname, '..', 'get-shit-done');
const CONFIG_CJS_PATH = path.join(GSD_ROOT, 'bin', 'lib', 'config.cjs');
const CONFIG_TEMPLATE_PATH = path.join(GSD_ROOT, 'templates', 'config.json');
const PLAN_PHASE_PATH = path.join(GSD_ROOT, 'workflows', 'plan-phase.md');
describe('Plan Bounce: config keys', () => {
test('workflow.plan_bounce is in VALID_CONFIG_KEYS', () => {
const content = fs.readFileSync(CONFIG_CJS_PATH, 'utf-8');
assert.ok(
content.includes("'workflow.plan_bounce'"),
'VALID_CONFIG_KEYS should contain workflow.plan_bounce'
);
});
test('workflow.plan_bounce_script is in VALID_CONFIG_KEYS', () => {
const content = fs.readFileSync(CONFIG_CJS_PATH, 'utf-8');
assert.ok(
content.includes("'workflow.plan_bounce_script'"),
'VALID_CONFIG_KEYS should contain workflow.plan_bounce_script'
);
});
test('workflow.plan_bounce_passes is in VALID_CONFIG_KEYS', () => {
const content = fs.readFileSync(CONFIG_CJS_PATH, 'utf-8');
assert.ok(
content.includes("'workflow.plan_bounce_passes'"),
'VALID_CONFIG_KEYS should contain workflow.plan_bounce_passes'
);
});
});
describe('Plan Bounce: config template defaults', () => {
test('config template has plan_bounce default (false)', () => {
const template = JSON.parse(fs.readFileSync(CONFIG_TEMPLATE_PATH, 'utf-8'));
assert.strictEqual(
template.workflow.plan_bounce,
false,
'config template workflow.plan_bounce should default to false'
);
});
test('config template has plan_bounce_script default (null)', () => {
const template = JSON.parse(fs.readFileSync(CONFIG_TEMPLATE_PATH, 'utf-8'));
assert.strictEqual(
template.workflow.plan_bounce_script,
null,
'config template workflow.plan_bounce_script should default to null'
);
});
test('config template has plan_bounce_passes default (2)', () => {
const template = JSON.parse(fs.readFileSync(CONFIG_TEMPLATE_PATH, 'utf-8'));
assert.strictEqual(
template.workflow.plan_bounce_passes,
2,
'config template workflow.plan_bounce_passes should default to 2'
);
});
});
describe('Plan Bounce: plan-phase.md step 12.5', () => {
let content;
test('plan-phase.md contains step 12.5', () => {
content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
assert.ok(
content.includes('## 12.5'),
'plan-phase.md should contain step 12.5'
);
});
test('step 12.5 references plan bounce', () => {
content = content || fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
// The step title should mention bounce
assert.ok(
/## 12\.5.*[Bb]ounce/i.test(content),
'step 12.5 should reference plan bounce in its title'
);
});
test('plan-phase.md has --bounce flag handling', () => {
content = content || fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
assert.ok(
content.includes('--bounce'),
'plan-phase.md should handle --bounce flag'
);
});
test('plan-phase.md has --skip-bounce flag handling', () => {
content = content || fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
assert.ok(
content.includes('--skip-bounce'),
'plan-phase.md should handle --skip-bounce flag'
);
});
test('plan-phase.md has backup pattern (pre-bounce.md)', () => {
content = content || fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
assert.ok(
content.includes('pre-bounce.md'),
'plan-phase.md should reference pre-bounce.md backup files'
);
});
test('plan-phase.md has frontmatter integrity validation for bounced plans', () => {
content = content || fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
// Should mention YAML frontmatter validation after bounce
assert.ok(
/frontmatter.*bounced|bounced.*frontmatter|YAML.*bounce|bounce.*YAML/i.test(content),
'plan-phase.md should validate frontmatter integrity on bounced plans'
);
});
test('plan-phase.md re-runs checker on bounced plans', () => {
content = content || fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
// Should mention re-running plan checker after bounce
assert.ok(
/[Rr]e-run.*checker.*bounce|bounce.*checker.*re-run|checker.*bounced/i.test(content),
'plan-phase.md should re-run plan checker on bounced plans'
);
});
test('plan-phase.md references plan_bounce config keys', () => {
content = content || fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
assert.ok(
content.includes('plan_bounce_script'),
'plan-phase.md should reference plan_bounce_script config'
);
assert.ok(
content.includes('plan_bounce_passes'),
'plan-phase.md should reference plan_bounce_passes config'
);
});
test('plan-phase.md disables bounce when --gaps flag is present', () => {
content = content || fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
// Should mention that --gaps disables bounce
assert.ok(
/--gaps.*bounce|bounce.*--gaps/i.test(content),
'plan-phase.md should disable bounce when --gaps flag is present'
);
});
test('plan-phase.md restores original on script failure', () => {
content = content || fs.readFileSync(PLAN_PHASE_PATH, 'utf-8');
// Should mention restoring from backup on failure
assert.ok(
/restore.*original|restore.*pre-bounce|original.*restore/i.test(content),
'plan-phase.md should restore original plan on script failure'
);
});
});

View File

@@ -0,0 +1,333 @@
'use strict';
/**
* Planner Language Regression Tests (#2091, #2092)
*
* Prevents time-based reasoning and complexity-as-scope-justification
* from leaking back into planning artifacts via future PRs.
*
* These tests scan agent definitions, workflow files, and references
* for prohibited patterns that import human-world constraints into
* an AI execution context where those constraints do not exist.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const AGENTS_DIR = path.join(ROOT, 'agents');
const WORKFLOWS_DIR = path.join(ROOT, 'get-shit-done', 'workflows');
const REFERENCES_DIR = path.join(ROOT, 'get-shit-done', 'references');
const TEMPLATES_DIR = path.join(ROOT, 'get-shit-done', 'templates');
/**
* Collect all .md files from a directory (non-recursive).
*/
function mdFiles(dir) {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir)
.filter(f => f.endsWith('.md'))
.map(f => ({ name: f, path: path.join(dir, f) }));
}
/**
* Collect all .md files recursively.
*/
function mdFilesRecursive(dir) {
if (!fs.existsSync(dir)) return [];
const results = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...mdFilesRecursive(full));
} else if (entry.name.endsWith('.md')) {
results.push({ name: entry.name, path: full });
}
}
return results;
}
/**
* Files that define planning behavior — agents, workflows, references.
* These are the files where time-based and complexity-based scope
* reasoning must never appear.
*/
const PLANNING_FILES = [
...mdFiles(AGENTS_DIR),
...mdFiles(WORKFLOWS_DIR),
...mdFiles(REFERENCES_DIR),
...mdFilesRecursive(TEMPLATES_DIR),
];
// -- Prohibited patterns --
/**
* Time-based task sizing patterns.
* Matches "15-60 minutes", "X minutes Claude execution time", etc.
* Does NOT match operational timeouts ("timeout: 5 minutes"),
* API docs examples ("100 requests per 15 minutes"),
* or human-readable timeout descriptions in workflow execution steps.
*/
const TIME_SIZING_PATTERNS = [
// "N-M minutes" in task sizing context (not timeout context)
/each task[:\s]*\*?\*?\d+[-]\d+\s*min/i,
// "minutes Claude execution time" or "minutes execution time"
/minutes?\s+(claude\s+)?execution\s+time/i,
// Duration-based sizing table rows: "< 15 min", "15-60 min", "> 60 min"
/[<>]\s*\d+\s*min\s*\|/i,
];
/**
* Complexity-as-scope-justification patterns.
* Matches "too complex to implement", "challenging feature", etc.
* Does NOT match legitimate uses like:
* - "complex domains" in research/discovery context (describing what to research)
* - "non-trivial" in verification context (confirming substantive code exists)
* - "challenging" in user-profiling context (quoting user reactions)
*/
const COMPLEXITY_SCOPE_PATTERNS = [
// "too complex to" — always a scope-reduction justification
/too\s+complex\s+to/i,
// "too difficult" — always a scope-reduction justification
/too\s+difficult/i,
// "is too complex for" — scope justification (e.g. "Phase X is too complex for")
/is\s+too\s+complex\s+for/i,
];
/**
* Files allowed to contain certain patterns because they document
* the prohibition itself, or use the terms in non-scope-reduction context.
*/
const ALLOWLIST = {
// Plan-checker scans FOR these patterns — it's a detection list, not usage
'gsd-plan-checker.md': ['complexity_scope', 'time_sizing'],
// Planner defines the prohibition and the authority limits — uses terms to explain what NOT to do
'gsd-planner.md': ['complexity_scope'],
// Debugger uses "30+ minutes" as anti-pattern detection, not task sizing
'gsd-debugger.md': ['time_sizing'],
// Doc-writer uses "15 minutes" in API rate limit example, "2 minutes" for doc quality
'gsd-doc-writer.md': ['time_sizing'],
// Discovery-phase uses time for level descriptions (operational, not scope)
'discovery-phase.md': ['time_sizing'],
// Explore uses "~30 seconds" as operational estimate
'explore.md': ['time_sizing'],
// Review uses "up to 5 minutes" for CodeRabbit timeout
'review.md': ['time_sizing'],
// Fast uses "under 2 minutes wall time" as operational constraint
'fast.md': ['time_sizing'],
// Execute-phase uses "timeout: 5 minutes" for test runner
'execute-phase.md': ['time_sizing'],
// Verify-phase uses "timeout: 5 minutes" for test runner
'verify-phase.md': ['time_sizing'],
// Map-codebase documents subagent_timeout
'map-codebase.md': ['time_sizing'],
// Help documents CodeRabbit timing
'help.md': ['time_sizing'],
};
function isAllowlisted(fileName, category) {
const entry = ALLOWLIST[fileName];
return entry && entry.includes(category);
}
// -- Tests --
describe('Planner language regression — time-based task sizing (#2092)', () => {
for (const file of PLANNING_FILES) {
test(`${file.name} must not use time-based task sizing`, () => {
if (isAllowlisted(file.name, 'time_sizing')) return;
const content = fs.readFileSync(file.path, 'utf-8');
for (const pattern of TIME_SIZING_PATTERNS) {
const match = content.match(pattern);
assert.ok(
!match,
[
`${file.name} contains time-based task sizing: "${match?.[0]}"`,
'Task sizing must use context-window percentage, not time units.',
'See issue #2092 for rationale.',
].join('\n')
);
}
});
}
});
describe('Planner language regression — complexity-as-scope-justification (#2092)', () => {
for (const file of PLANNING_FILES) {
test(`${file.name} must not use complexity to justify scope reduction`, () => {
if (isAllowlisted(file.name, 'complexity_scope')) return;
const content = fs.readFileSync(file.path, 'utf-8');
for (const pattern of COMPLEXITY_SCOPE_PATTERNS) {
const match = content.match(pattern);
assert.ok(
!match,
[
`${file.name} contains complexity-as-scope-justification: "${match?.[0]}"`,
'Scope decisions must be based on context cost, missing information,',
'or dependency conflicts — not perceived difficulty.',
'See issue #2092 for rationale.',
].join('\n')
);
}
});
}
});
describe('gsd-planner.md — required structural sections (#2091, #2092)', () => {
let plannerContent;
test('planner file exists and is readable', () => {
const plannerPath = path.join(AGENTS_DIR, 'gsd-planner.md');
assert.ok(fs.existsSync(plannerPath), 'agents/gsd-planner.md must exist');
plannerContent = fs.readFileSync(plannerPath, 'utf-8');
});
test('contains <planner_authority_limits> section', () => {
assert.ok(
plannerContent.includes('<planner_authority_limits>'),
'gsd-planner.md must contain a <planner_authority_limits> section defining what the planner cannot decide'
);
});
test('authority limits prohibit difficulty-based scope decisions', () => {
assert.ok(
plannerContent.includes('The planner has no authority to'),
'planner_authority_limits must explicitly state what the planner cannot decide'
);
});
test('authority limits list three legitimate split reasons: context cost, missing info, dependency', () => {
assert.ok(
plannerContent.includes('Context cost') || plannerContent.includes('context cost'),
'authority limits must list context cost as a legitimate split reason'
);
assert.ok(
plannerContent.includes('Missing information') || plannerContent.includes('missing information'),
'authority limits must list missing information as a legitimate split reason'
);
assert.ok(
plannerContent.includes('Dependency conflict') || plannerContent.includes('dependency conflict'),
'authority limits must list dependency conflict as a legitimate split reason'
);
});
test('task sizing uses context percentage, not time units', () => {
assert.ok(
plannerContent.includes('context consumption') || plannerContent.includes('context cost'),
'task sizing must reference context consumption, not time'
);
assert.ok(
!(/each task[:\s]*\*?\*?\d+[-]\d+\s*min/i.test(plannerContent)),
'task sizing must not use minutes as sizing unit'
);
});
test('contains multi-source coverage audit (not just D-XX decisions)', () => {
assert.ok(
plannerContent.includes('Multi-Source Coverage Audit') ||
plannerContent.includes('multi-source coverage audit'),
'gsd-planner.md must contain a multi-source coverage audit, not just D-XX decision matrix'
);
});
test('coverage audit includes all four source types: GOAL, REQ, RESEARCH, CONTEXT', () => {
// The planner file or its referenced planner-source-audit.md must define all four types.
// The inline compact version uses **GOAL**, **REQ**, **RESEARCH**, **CONTEXT**.
const refPath = path.join(ROOT, 'get-shit-done', 'references', 'planner-source-audit.md');
const combined = plannerContent + (fs.existsSync(refPath) ? fs.readFileSync(refPath, 'utf-8') : '');
const hasGoal = combined.includes('**GOAL**');
const hasReq = combined.includes('**REQ**');
const hasResearch = combined.includes('**RESEARCH**');
const hasContext = combined.includes('**CONTEXT**');
assert.ok(hasGoal, 'coverage audit must include GOAL source type (ROADMAP.md phase goal)');
assert.ok(hasReq, 'coverage audit must include REQ source type (REQUIREMENTS.md)');
assert.ok(hasResearch, 'coverage audit must include RESEARCH source type (RESEARCH.md)');
assert.ok(hasContext, 'coverage audit must include CONTEXT source type (CONTEXT.md decisions)');
});
test('coverage audit defines MISSING item handling with developer escalation', () => {
assert.ok(
plannerContent.includes('Source Audit: Unplanned Items Found') ||
plannerContent.includes('MISSING'),
'coverage audit must define handling for MISSING items'
);
assert.ok(
plannerContent.includes('Awaiting developer decision') ||
plannerContent.includes('developer confirmation'),
'MISSING items must escalate to developer, not be silently dropped'
);
});
});
describe('plan-phase.md — source audit orchestration (#2091)', () => {
let workflowContent;
test('plan-phase workflow exists and is readable', () => {
const workflowPath = path.join(WORKFLOWS_DIR, 'plan-phase.md');
assert.ok(fs.existsSync(workflowPath), 'workflows/plan-phase.md must exist');
workflowContent = fs.readFileSync(workflowPath, 'utf-8');
});
test('step 9 handles Source Audit return from planner', () => {
assert.ok(
workflowContent.includes('Source Audit: Unplanned Items Found'),
'plan-phase.md step 9 must handle the Source Audit return from the planner'
);
});
test('step 9c exists for source audit gap handling', () => {
assert.ok(
workflowContent.includes('9c') && workflowContent.includes('Source Audit'),
'plan-phase.md must have a step 9c for handling source audit gaps'
);
});
test('step 9b does not use "too complex" language', () => {
// Extract just step 9b content (between "## 9b" and "## 9c" or "## 10")
const step9bMatch = workflowContent.match(/## 9b\.([\s\S]*?)(?=## 9c|## 10)/);
if (step9bMatch) {
const step9b = step9bMatch[1];
assert.ok(
!step9b.includes('too complex'),
'step 9b must not use "too complex" — use context budget language instead'
);
}
});
test('phase split recommendation uses context budget framing', () => {
assert.ok(
workflowContent.includes('context budget') || workflowContent.includes('context cost'),
'phase split recommendation must be framed in terms of context budget, not complexity'
);
});
});
describe('gsd-plan-checker.md — scope reduction detection includes time/complexity (#2092)', () => {
let checkerContent;
test('plan-checker exists and is readable', () => {
const checkerPath = path.join(AGENTS_DIR, 'gsd-plan-checker.md');
assert.ok(fs.existsSync(checkerPath), 'agents/gsd-plan-checker.md must exist');
checkerContent = fs.readFileSync(checkerPath, 'utf-8');
});
test('scope reduction scan includes complexity-based justification patterns', () => {
assert.ok(
checkerContent.includes('too complex') || checkerContent.includes('too difficult'),
'plan-checker scope reduction scan must detect complexity-based justification language'
);
});
test('scope reduction scan includes time-based justification patterns', () => {
assert.ok(
checkerContent.includes('would take') || checkerContent.includes('hours') || checkerContent.includes('minutes'),
'plan-checker scope reduction scan must detect time-based justification language'
);
});
});

View File

@@ -0,0 +1,219 @@
/**
* Tests for skill-manifest command
* TDD: RED phase — tests written before implementation
*/
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
describe('skill-manifest', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('skill-manifest command exists and returns JSON', () => {
// Create a skills directory with one skill
const skillDir = path.join(tmpDir, '.claude', 'skills', 'test-skill');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [
'---',
'name: test-skill',
'description: A test skill',
'---',
'',
'# Test Skill',
].join('\n'));
const result = runGsdTools(['skill-manifest', '--skills-dir', path.join(tmpDir, '.claude', 'skills')], tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
const manifest = JSON.parse(result.output);
assert.ok(Array.isArray(manifest), 'Manifest should be an array');
});
test('generates manifest with correct structure from SKILL.md frontmatter', () => {
const skillDir = path.join(tmpDir, '.claude', 'skills', 'my-skill');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [
'---',
'name: my-skill',
'description: Does something useful',
'---',
'',
'# My Skill',
'',
'TRIGGER when: user asks about widgets',
].join('\n'));
const result = runGsdTools(['skill-manifest', '--skills-dir', path.join(tmpDir, '.claude', 'skills')], tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
const manifest = JSON.parse(result.output);
assert.strictEqual(manifest.length, 1);
assert.strictEqual(manifest[0].name, 'my-skill');
assert.strictEqual(manifest[0].description, 'Does something useful');
assert.strictEqual(manifest[0].path, 'my-skill');
});
test('empty skills directory produces empty manifest', () => {
const skillsDir = path.join(tmpDir, '.claude', 'skills');
fs.mkdirSync(skillsDir, { recursive: true });
const result = runGsdTools(['skill-manifest', '--skills-dir', skillsDir], tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
const manifest = JSON.parse(result.output);
assert.ok(Array.isArray(manifest), 'Manifest should be an array');
assert.strictEqual(manifest.length, 0);
});
test('skills without SKILL.md are skipped', () => {
const skillsDir = path.join(tmpDir, '.claude', 'skills');
// Skill with SKILL.md
const goodDir = path.join(skillsDir, 'good-skill');
fs.mkdirSync(goodDir, { recursive: true });
fs.writeFileSync(path.join(goodDir, 'SKILL.md'), [
'---',
'name: good-skill',
'description: Has a SKILL.md',
'---',
'',
'# Good Skill',
].join('\n'));
// Skill without SKILL.md (just a directory)
const badDir = path.join(skillsDir, 'bad-skill');
fs.mkdirSync(badDir, { recursive: true });
fs.writeFileSync(path.join(badDir, 'README.md'), '# No SKILL.md here');
const result = runGsdTools(['skill-manifest', '--skills-dir', skillsDir], tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
const manifest = JSON.parse(result.output);
assert.strictEqual(manifest.length, 1);
assert.strictEqual(manifest[0].name, 'good-skill');
});
test('manifest includes frontmatter fields from SKILL.md', () => {
const skillDir = path.join(tmpDir, '.claude', 'skills', 'rich-skill');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [
'---',
'name: rich-skill',
'description: A richly documented skill',
'---',
'',
'# Rich Skill',
'',
'TRIGGER when: user mentions databases',
'DO NOT TRIGGER when: user asks about frontend',
].join('\n'));
const result = runGsdTools(['skill-manifest', '--skills-dir', path.join(tmpDir, '.claude', 'skills')], tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
const manifest = JSON.parse(result.output);
assert.strictEqual(manifest.length, 1);
const skill = manifest[0];
assert.strictEqual(skill.name, 'rich-skill');
assert.strictEqual(skill.description, 'A richly documented skill');
assert.strictEqual(skill.path, 'rich-skill');
// triggers extracted from body text
assert.ok(Array.isArray(skill.triggers), 'triggers should be an array');
assert.ok(skill.triggers.length > 0, 'triggers should have at least one entry');
assert.ok(skill.triggers.some(t => t.includes('databases')), 'triggers should mention databases');
});
test('multiple skills are all included in manifest', () => {
const skillsDir = path.join(tmpDir, '.claude', 'skills');
for (const name of ['alpha', 'beta', 'gamma']) {
const dir = path.join(skillsDir, name);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'SKILL.md'), [
'---',
`name: ${name}`,
`description: The ${name} skill`,
'---',
'',
`# ${name}`,
].join('\n'));
}
const result = runGsdTools(['skill-manifest', '--skills-dir', skillsDir], tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
const manifest = JSON.parse(result.output);
assert.strictEqual(manifest.length, 3);
const names = manifest.map(s => s.name).sort();
assert.deepStrictEqual(names, ['alpha', 'beta', 'gamma']);
});
test('writes manifest to .planning/skill-manifest.json when --write flag is used', () => {
const skillDir = path.join(tmpDir, '.claude', 'skills', 'write-test');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [
'---',
'name: write-test',
'description: Tests write mode',
'---',
'',
'# Write Test',
].join('\n'));
const result = runGsdTools(['skill-manifest', '--skills-dir', path.join(tmpDir, '.claude', 'skills'), '--write'], tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
const manifestPath = path.join(tmpDir, '.planning', 'skill-manifest.json');
assert.ok(fs.existsSync(manifestPath), 'skill-manifest.json should be written to .planning/');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
assert.strictEqual(manifest.length, 1);
assert.strictEqual(manifest[0].name, 'write-test');
});
test('nonexistent skills directory returns empty manifest', () => {
const result = runGsdTools(['skill-manifest', '--skills-dir', path.join(tmpDir, 'nonexistent')], tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
const manifest = JSON.parse(result.output);
assert.ok(Array.isArray(manifest), 'Manifest should be an array');
assert.strictEqual(manifest.length, 0);
});
test('files in skills directory are ignored (only subdirectories scanned)', () => {
const skillsDir = path.join(tmpDir, '.claude', 'skills');
fs.mkdirSync(skillsDir, { recursive: true });
// A file, not a directory
fs.writeFileSync(path.join(skillsDir, 'not-a-skill.md'), '# Not a skill');
// A valid skill directory
const skillDir = path.join(skillsDir, 'real-skill');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [
'---',
'name: real-skill',
'description: A real skill',
'---',
'',
'# Real Skill',
].join('\n'));
const result = runGsdTools(['skill-manifest', '--skills-dir', skillsDir], tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
const manifest = JSON.parse(result.output);
assert.strictEqual(manifest.length, 1);
assert.strictEqual(manifest[0].name, 'real-skill');
});
});

136
tests/temp-subdir.test.cjs Normal file
View File

@@ -0,0 +1,136 @@
/**
* GSD Tools Tests - dedicated temp subdirectory
*
* Tests for issue #1975: GSD temp files should use a dedicated
* subdirectory (path.join(os.tmpdir(), 'gsd')) instead of writing
* directly to os.tmpdir().
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const {
reapStaleTempFiles,
} = require('../get-shit-done/bin/lib/core.cjs');
const GSD_TEMP_DIR = path.join(os.tmpdir(), 'gsd');
// ─── Dedicated temp subdirectory ────────────────────────────────────────────
describe('dedicated gsd temp subdirectory', () => {
describe('output() temp file placement', () => {
// output() writes to tmpfile when JSON > 50KB. We test indirectly by
// checking that reapStaleTempFiles scans the subdirectory.
test('gsd temp subdirectory path is os.tmpdir()/gsd', () => {
// The GSD_TEMP_DIR constant should resolve to <tmpdir>/gsd
assert.strictEqual(GSD_TEMP_DIR, path.join(os.tmpdir(), 'gsd'));
});
});
describe('reapStaleTempFiles with subdirectory', () => {
let testPrefix;
beforeEach(() => {
testPrefix = `gsd-tempsub-test-${Date.now()}-`;
// Ensure the gsd subdirectory exists for test setup
fs.mkdirSync(GSD_TEMP_DIR, { recursive: true });
});
test('removes stale files from gsd subdirectory', () => {
const stalePath = path.join(GSD_TEMP_DIR, `${testPrefix}stale.json`);
fs.writeFileSync(stalePath, '{}');
const oldTime = new Date(Date.now() - 10 * 60 * 1000);
fs.utimesSync(stalePath, oldTime, oldTime);
reapStaleTempFiles(testPrefix, { maxAgeMs: 5 * 60 * 1000 });
assert.ok(!fs.existsSync(stalePath), 'stale file in gsd subdir should be removed');
});
test('preserves fresh files in gsd subdirectory', () => {
const freshPath = path.join(GSD_TEMP_DIR, `${testPrefix}fresh.json`);
fs.writeFileSync(freshPath, '{}');
reapStaleTempFiles(testPrefix, { maxAgeMs: 5 * 60 * 1000 });
assert.ok(fs.existsSync(freshPath), 'fresh file in gsd subdir should be preserved');
// Clean up
fs.unlinkSync(freshPath);
});
test('removes stale directories from gsd subdirectory', () => {
const staleDir = path.join(GSD_TEMP_DIR, `${testPrefix}dir`);
fs.mkdirSync(staleDir, { recursive: true });
const oldTime = new Date(Date.now() - 10 * 60 * 1000);
fs.utimesSync(staleDir, oldTime, oldTime);
reapStaleTempFiles(testPrefix, { maxAgeMs: 5 * 60 * 1000 });
assert.ok(!fs.existsSync(staleDir), 'stale directory in gsd subdir should be removed');
});
test('creates gsd subdirectory if it does not exist', () => {
// Use a unique nested path to avoid interfering with other tests
const uniqueSubdir = path.join(os.tmpdir(), `gsd-creation-test-${Date.now()}`);
// Verify it does not exist
if (fs.existsSync(uniqueSubdir)) {
fs.rmSync(uniqueSubdir, { recursive: true, force: true });
}
assert.ok(!fs.existsSync(uniqueSubdir), 'test subdir should not exist before test');
// reapStaleTempFiles should not throw even if subdir does not exist
// (it gets created or handled gracefully)
assert.doesNotThrow(() => {
reapStaleTempFiles(`gsd-creation-test-${Date.now()}-`, { maxAgeMs: 0 });
});
});
test('does not scan system tmpdir root for gsd- files', () => {
// Place a stale file in the OLD location (system tmpdir root)
const oldLocationPath = path.join(os.tmpdir(), `${testPrefix}old-location.json`);
fs.writeFileSync(oldLocationPath, '{}');
const oldTime = new Date(Date.now() - 10 * 60 * 1000);
fs.utimesSync(oldLocationPath, oldTime, oldTime);
// reapStaleTempFiles should NOT remove files from the old location
// because it now only scans the gsd subdirectory
reapStaleTempFiles(testPrefix, { maxAgeMs: 5 * 60 * 1000 });
// The file in the old location should still exist (not scanned)
assert.ok(
fs.existsSync(oldLocationPath),
'files in system tmpdir root should NOT be scanned by reapStaleTempFiles'
);
// Clean up manually
fs.unlinkSync(oldLocationPath);
});
test('backward compat: reapStaleTempFilesLegacy cleans old location', () => {
// Place a stale file in the old location (system tmpdir root)
const oldLocationPath = path.join(os.tmpdir(), `${testPrefix}legacy.json`);
fs.writeFileSync(oldLocationPath, '{}');
const oldTime = new Date(Date.now() - 10 * 60 * 1000);
fs.utimesSync(oldLocationPath, oldTime, oldTime);
// The legacy reap function should still clean old-location files
// We import it if exported, or verify the main reap handles both
const core = require('../get-shit-done/bin/lib/core.cjs');
if (typeof core.reapStaleTempFilesLegacy === 'function') {
core.reapStaleTempFilesLegacy(testPrefix, { maxAgeMs: 5 * 60 * 1000 });
assert.ok(!fs.existsSync(oldLocationPath), 'legacy reap should clean old location');
} else {
// If no separate legacy function, the main output() should do a one-time
// migration sweep. We just verify the export shape is correct.
assert.ok(typeof core.reapStaleTempFiles === 'function');
// Clean up manually since we're not testing migration here
fs.unlinkSync(oldLocationPath);
}
});
});
});