From 53d0611c115d9a11c6f2eda160ca532ef1aad79b Mon Sep 17 00:00:00 2001 From: mvoutov Date: Mon, 23 Mar 2026 14:42:25 -0700 Subject: [PATCH 1/4] fix: resolve claude ENOENT on Windows Node's spawn() can't locate .cmd wrappers without shell: true. Scoped to win32 only. Closes #25 --- src/lib/runner.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/runner.js b/src/lib/runner.js index ddd5318..3058987 100644 --- a/src/lib/runner.js +++ b/src/lib/runner.js @@ -52,6 +52,7 @@ export function runClaude(prompt, options = {}) { return new Promise((resolve, reject) => { const child = spawn('claude', args, { stdio: ['pipe', 'pipe', 'pipe'], + shell: process.platform === 'win32', }); const chunks = []; From fbdb3cd2b0c5f1ba8d00ca3830a391c46be748ab Mon Sep 17 00:00:00 2001 From: mvoutov Date: Mon, 23 Mar 2026 14:45:38 -0700 Subject: [PATCH 2/4] feat: add --domains flag to doc init for targeted domain retry Allows regenerating specific domains without touching the base skill or CLAUDE.md. Skips discovery and shows actionable retry hint when domains fail. --- src/commands/doc-init.js | 44 ++++++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/src/commands/doc-init.js b/src/commands/doc-init.js index aa85e4e..1fa41a5 100644 --- a/src/commands/doc-init.js +++ b/src/commands/doc-init.js @@ -124,7 +124,8 @@ export async function docInitCommand(path, options) { let discoveryFindings = null; let discoveredDomains = []; - if (repoGraph && repoGraph.stats.totalFiles > 0 && options.mode !== 'base-only') { + const skipDiscovery = options.mode === 'base-only' || (options.mode && extraDomains && extraDomains.length > 0); + if (repoGraph && repoGraph.stats.totalFiles > 0 && !skipDiscovery) { console.log(pc.dim(' Running 2 discovery agents in parallel...')); console.log(); const discoverSpinner = p.spinner(); @@ -231,7 +232,7 @@ export async function docInitCommand(path, options) { if (!['improve', 'rewrite', 'skip-existing', 'fresh'].includes(existingDocsStrategy)) { throw new CliError(`Unknown strategy: ${options.strategy}. Use: improve, rewrite, or skip`); } - } else if ((scan.hasClaudeConfig || scan.hasClaudeMd) && !options.force) { + } else if ((scan.hasClaudeConfig || scan.hasClaudeMd) && !options.force && !skipDiscovery) { const strategy = await p.select({ message: 'Existing CLAUDE.md and/or skills detected. How to proceed:', options: [ @@ -263,6 +264,18 @@ export async function docInitCommand(path, options) { if (!['all-at-once', 'chunked', 'base-only'].includes(mode)) { throw new CliError(`Unknown mode: ${options.mode}. Use: all, chunked, or base-only`); } + // --domains flag filters which domains to generate (useful for retrying failed domains) + if (extraDomains && extraDomains.length > 0 && mode === 'chunked') { + selectedDomains = effectiveDomains.filter(d => + extraDomains.includes(d.name.toLowerCase()) + ); + if (selectedDomains.length === 0) { + p.log.warn(`No matching domains found for: ${extraDomains.join(', ')}`); + p.log.info(`Available: ${effectiveDomains.map(d => d.name).join(', ')}`); + return; + } + p.log.info(`Generating ${selectedDomains.length} domain(s): ${selectedDomains.map(d => d.name).join(', ')}`); + } } else if (effectiveDomains.length === 0) { p.log.info('No domains detected — generating base skill only.'); mode = 'base-only'; @@ -320,7 +333,8 @@ export async function docInitCommand(path, options) { if (mode === 'all-at-once') { allFiles = await generateAllAtOnce(repoPath, scan, repoGraph, selectedDomains, timeoutMs, existingDocsStrategy, verbose, model, discoveryFindings); } else { - allFiles = await generateChunked(repoPath, scan, repoGraph, selectedDomains, mode === 'base-only', timeoutMs, existingDocsStrategy, verbose, model, discoveryFindings); + const domainsOnly = skipDiscovery; // retrying specific domains — skip base + CLAUDE.md + allFiles = await generateChunked(repoPath, scan, repoGraph, selectedDomains, mode === 'base-only', timeoutMs, existingDocsStrategy, verbose, model, discoveryFindings, domainsOnly); } if (allFiles.length === 0) { @@ -737,23 +751,30 @@ async function generateAllAtOnce(repoPath, scan, repoGraph, selectedDomains, tim } } -async function generateChunked(repoPath, scan, repoGraph, domains, baseOnly, timeoutMs, strategy, verbose, model, findings) { +async function generateChunked(repoPath, scan, repoGraph, domains, baseOnly, timeoutMs, strategy, verbose, model, findings, domainsOnly = false) { const allFiles = []; const skippedDomains = []; const today = new Date().toISOString().split('T')[0]; const scanSummary = buildScanSummary(scan); const graphContext = buildGraphContext(repoGraph); const findingsSection = findings ? `\n\n## Architecture Analysis (from discovery pass)\n\n${findings}` : ''; + const strategyNote = buildStrategyInstruction(strategy); - // 1. Generate base skill + // 1. Generate base skill (skip when retrying specific domains) + let baseSkillContent = null; + if (domainsOnly) { + // Load existing base skill for context (used in domain prompts) + const existingBase = join(repoPath, '.claude', 'skills', 'base', 'skill.md'); + if (existsSync(existingBase)) { + baseSkillContent = readFileSync(existingBase, 'utf8'); + } + } else { const baseSpinner = p.spinner(); baseSpinner.start('Generating base skill...'); - const strategyNote = buildStrategyInstruction(strategy); const basePrompt = loadPrompt('doc-init') + strategyNote + `\n\n---\n\nGenerate ONLY the base skill for this repository at ${repoPath} (no domain skills, no CLAUDE.md). Today's date is ${today}.\n\n${scanSummary}\n\n${graphContext}${findingsSection}`; - let baseSkillContent = null; try { let { text, usage } = await runClaude(basePrompt, makeClaudeOptions(timeoutMs, verbose, model, baseSpinner)); trackUsage(usage, basePrompt.length); @@ -783,6 +804,7 @@ async function generateChunked(repoPath, scan, repoGraph, domains, baseOnly, tim p.log.error(err.message); return allFiles; } + } // end if (!domainsOnly) // 2. Generate domain skills (in parallel batches for speed) if (!baseOnly) { @@ -849,13 +871,13 @@ async function generateChunked(repoPath, scan, repoGraph, domains, baseOnly, tim if (skippedDomains.length > 0) { console.log(); p.log.warn(`${skippedDomains.length} domain(s) skipped: ${skippedDomains.join(', ')}`); - console.log(pc.dim(' Retry with: aspens doc init --mode chunked --timeout 600')); - console.log(pc.dim(' Or pick just these: aspens doc init (select "Pick specific domains")')); + console.log(pc.dim(` Retry just these: aspens doc init --mode chunked --domains "${skippedDomains.join(',')}"`)); + console.log(pc.dim(' Or retry all: aspens doc init --mode chunked --timeout 600')); } - // 3. Generate CLAUDE.md (skip if it already exists and strategy says so) + // 3. Generate CLAUDE.md (skip when retrying specific domains, or if strategy says so) const claudeMdExists = existsSync(join(repoPath, 'CLAUDE.md')); - if (allFiles.length > 0 && !(strategy === 'skip-existing' && claudeMdExists)) { + if (allFiles.length > 0 && !domainsOnly && !(strategy === 'skip-existing' && claudeMdExists)) { const claudeMdSpinner = p.spinner(); claudeMdSpinner.start('Generating CLAUDE.md...'); From 178297129346fb98e2744fc08290080e38feeb4f Mon Sep 17 00:00:00 2001 From: mvoutov Date: Mon, 23 Mar 2026 14:56:49 -0700 Subject: [PATCH 3/4] fix: graph issues + claude tweak --- CLAUDE.md | 18 +++++++++++++++++- src/commands/doc-graph.js | 4 ++-- src/commands/doc-init.js | 6 +++--- src/commands/doc-sync.js | 4 +++- src/templates/hooks/graph-context-prompt.sh | 5 +++-- tests/graph-persistence.test.js | 6 ++++-- 6 files changed, 32 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 04ac55e..b5c85e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,10 +47,26 @@ The project ships as both a CLI and a set of Claude Code skills registered in th | skill-generation | LLM generation pipeline for skills and CLAUDE.md | | template-library | Bundled agents, commands, hooks installed via `aspens add` | +## Dev docs + +Extended dev documentation lives outside this repo at `../dev/`: + +- `release.md` — release workflow, publish steps, git tagging, GitHub Discussions +- `roadmap.md` — planned features and direction + + +## Code review + +```bash +cr review --plain # run CodeRabbit review from CLI +``` + +Or comment `@coderabbitai review` on any open PR. + ## Conventions - **ESM only** — `"type": "module"` everywhere, no CommonJS -- **Node >= 18** required +- **Node >= 20** required - No linter configured yet; `npm run lint` is a no-op - Dependencies: commander, es-module-lexer, picocolors, @clack/prompts - Tests live in `tests/` and use vitest — run with `npm test` diff --git a/src/commands/doc-graph.js b/src/commands/doc-graph.js index e604fbc..2c0c8ab 100644 --- a/src/commands/doc-graph.js +++ b/src/commands/doc-graph.js @@ -39,7 +39,7 @@ export async function docGraphCommand(path, options) { console.log(pc.dim(' Files: ') + repoGraph.stats.totalFiles); console.log(pc.dim(' Edges: ') + repoGraph.stats.totalEdges); console.log(pc.dim(' Hubs: ') + repoGraph.hubs.length); - console.log(pc.dim(' Clusters: ') + repoGraph.clusters.components.length); + console.log(pc.dim(' Clusters: ') + (repoGraph.clusters?.components?.length ?? 0)); console.log(pc.dim(' Hotspots: ') + repoGraph.hotspots.length); console.log(); @@ -51,5 +51,5 @@ export async function docGraphCommand(path, options) { console.log(); } - p.outro(pc.dim('Saved to .claude/graph.json + .claude/skills/code-map/skill.md')); + p.outro(pc.dim('Saved to .claude/graph.json + .claude/code-map.md')); } diff --git a/src/commands/doc-init.js b/src/commands/doc-init.js index 1fa41a5..3385568 100644 --- a/src/commands/doc-init.js +++ b/src/commands/doc-init.js @@ -124,7 +124,7 @@ export async function docInitCommand(path, options) { let discoveryFindings = null; let discoveredDomains = []; - const skipDiscovery = options.mode === 'base-only' || (options.mode && extraDomains && extraDomains.length > 0); + const skipDiscovery = options.mode === 'base-only' || (options.mode === 'chunked' && extraDomains && extraDomains.length > 0); if (repoGraph && repoGraph.stats.totalFiles > 0 && !skipDiscovery) { console.log(pc.dim(' Running 2 discovery agents in parallel...')); console.log(); @@ -664,7 +664,7 @@ function buildGraphContext(graph) { } // File ranking (top files Claude should prioritize reading) - if (graph.ranked.length > 0) { + if (graph.ranked?.length > 0) { sections.push('### File Priority Ranking (read in this order)\n'); for (const file of graph.ranked.slice(0, 15)) { sections.push(`- \`${file.path}\` — priority ${file.priority.toFixed(1)} (${file.fanIn} dependents, ${file.exportCount} exports, ${file.lines} lines)`); @@ -695,7 +695,7 @@ function buildDomainGraphContext(graph, domain) { } // External deps used by this domain - const externalDeps = new Set(domainFiles.flatMap(([, info]) => info.externalImports)); + const externalDeps = new Set(domainFiles.flatMap(([, info]) => info.externalImports || [])); if (externalDeps.size > 0) { sections.push(`\nExternal dependencies: ${[...externalDeps].join(', ')}`); } diff --git a/src/commands/doc-sync.js b/src/commands/doc-sync.js index 2d9267d..ddac6b4 100644 --- a/src/commands/doc-sync.js +++ b/src/commands/doc-sync.js @@ -81,7 +81,9 @@ export async function docSyncCommand(path, options) { const subgraph = extractSubgraph(repoGraph, changedFiles); graphContext = formatNavigationContext(subgraph); } - } catch { /* proceed without graph */ } + } catch (err) { + p.log.warn(`Graph context unavailable — proceeding without it. (${err.message})`); + } const affectedSkills = mapChangesToSkills(changedFiles, existingSkills, scan, repoGraph); diff --git a/src/templates/hooks/graph-context-prompt.sh b/src/templates/hooks/graph-context-prompt.sh index cb09ec5..6233c13 100644 --- a/src/templates/hooks/graph-context-prompt.sh +++ b/src/templates/hooks/graph-context-prompt.sh @@ -24,7 +24,8 @@ log_debug "HOOK SCRIPT STARTED - PID $$" get_script_dir() { local source="${BASH_SOURCE[0]}" while [ -h "$source" ]; do - local dir="$(cd -P "$(dirname "$source")" && pwd)" + local dir + dir="$(cd -P "$(dirname "$source")" && pwd)" || return 1 source="$(readlink "$source")" [[ $source != /* ]] && source="$dir/$source" done @@ -49,7 +50,7 @@ STDOUT_FILE=$(mktemp) STDERR_FILE=$(mktemp) trap 'rm -f "$STDOUT_FILE" "$STDERR_FILE"' EXIT -printf '%s' "$INPUT" | NODE_NO_WARNINGS=1 node graph-context-prompt.mjs \ +printf '%s' "$INPUT" | NODE_NO_WARNINGS=1 timeout 5s node graph-context-prompt.mjs \ >"$STDOUT_FILE" 2>"$STDERR_FILE" EXIT_CODE=$? diff --git a/tests/graph-persistence.test.js b/tests/graph-persistence.test.js index 516ec5c..d9413b5 100644 --- a/tests/graph-persistence.test.js +++ b/tests/graph-persistence.test.js @@ -1,6 +1,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'fs'; -import { join } from 'path'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; import { serializeGraph, saveGraph, @@ -15,7 +16,8 @@ import { persistGraphArtifacts, } from '../src/lib/graph-persistence.js'; -const FIXTURES_DIR = join(import.meta.dirname, 'fixtures', 'graph-persistence'); +const __dirname = dirname(fileURLToPath(import.meta.url)); +const FIXTURES_DIR = join(__dirname, 'fixtures', 'graph-persistence'); beforeAll(() => { mkdirSync(FIXTURES_DIR, { recursive: true }); From 31938b5a2645e2ffbb11ad114d04b1dd979c2ba2 Mon Sep 17 00:00:00 2001 From: mvoutov Date: Mon, 23 Mar 2026 15:22:12 -0700 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20graph=20hardening=20=E2=80=94=20proc?= =?UTF-8?q?ess=20kill,=20timeout=20portability,=20base-only=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/doc-init.js | 13 +++++++------ src/lib/runner.js | 6 +++++- src/templates/hooks/graph-context-prompt.mjs | 6 +++++- src/templates/hooks/graph-context-prompt.sh | 2 +- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/commands/doc-init.js b/src/commands/doc-init.js index 3385568..43ea39a 100644 --- a/src/commands/doc-init.js +++ b/src/commands/doc-init.js @@ -124,8 +124,9 @@ export async function docInitCommand(path, options) { let discoveryFindings = null; let discoveredDomains = []; - const skipDiscovery = options.mode === 'base-only' || (options.mode === 'chunked' && extraDomains && extraDomains.length > 0); - if (repoGraph && repoGraph.stats.totalFiles > 0 && !skipDiscovery) { + const isBaseOnly = options.mode === 'base-only'; + const isDomainsOnly = options.mode === 'chunked' && extraDomains && extraDomains.length > 0; + if (repoGraph && repoGraph.stats.totalFiles > 0 && !isBaseOnly && !isDomainsOnly) { console.log(pc.dim(' Running 2 discovery agents in parallel...')); console.log(); const discoverSpinner = p.spinner(); @@ -232,7 +233,7 @@ export async function docInitCommand(path, options) { if (!['improve', 'rewrite', 'skip-existing', 'fresh'].includes(existingDocsStrategy)) { throw new CliError(`Unknown strategy: ${options.strategy}. Use: improve, rewrite, or skip`); } - } else if ((scan.hasClaudeConfig || scan.hasClaudeMd) && !options.force && !skipDiscovery) { + } else if ((scan.hasClaudeConfig || scan.hasClaudeMd) && !options.force && !isDomainsOnly) { const strategy = await p.select({ message: 'Existing CLAUDE.md and/or skills detected. How to proceed:', options: [ @@ -333,7 +334,7 @@ export async function docInitCommand(path, options) { if (mode === 'all-at-once') { allFiles = await generateAllAtOnce(repoPath, scan, repoGraph, selectedDomains, timeoutMs, existingDocsStrategy, verbose, model, discoveryFindings); } else { - const domainsOnly = skipDiscovery; // retrying specific domains — skip base + CLAUDE.md + const domainsOnly = isDomainsOnly; // retrying specific domains — skip base + CLAUDE.md allFiles = await generateChunked(repoPath, scan, repoGraph, selectedDomains, mode === 'base-only', timeoutMs, existingDocsStrategy, verbose, model, discoveryFindings, domainsOnly); } @@ -871,8 +872,8 @@ async function generateChunked(repoPath, scan, repoGraph, domains, baseOnly, tim if (skippedDomains.length > 0) { console.log(); p.log.warn(`${skippedDomains.length} domain(s) skipped: ${skippedDomains.join(', ')}`); - console.log(pc.dim(` Retry just these: aspens doc init --mode chunked --domains "${skippedDomains.join(',')}"`)); - console.log(pc.dim(' Or retry all: aspens doc init --mode chunked --timeout 600')); + console.log(pc.dim(` Retry just these: aspens doc init --mode chunked --domains "${skippedDomains.join(',')}" ${repoPath}`)); + console.log(pc.dim(` Or retry all: aspens doc init --mode chunked --timeout 600 ${repoPath}`)); } // 3. Generate CLAUDE.md (skip when retrying specific domains, or if strategy says so) diff --git a/src/lib/runner.js b/src/lib/runner.js index 3058987..97feef9 100644 --- a/src/lib/runner.js +++ b/src/lib/runner.js @@ -81,7 +81,11 @@ export function runClaude(prompt, options = {}) { let timedOut = false; const timer = setTimeout(() => { timedOut = true; - child.kill('SIGTERM'); + if (process.platform === 'win32' && child.pid) { + try { execSync(`taskkill /pid ${child.pid} /t /f`, { stdio: 'ignore' }); } catch { /* ignore */ } + } else { + child.kill('SIGTERM'); + } }, timeout); child.on('close', (code, signal) => { diff --git a/src/templates/hooks/graph-context-prompt.mjs b/src/templates/hooks/graph-context-prompt.mjs index 33651c3..457e2e8 100644 --- a/src/templates/hooks/graph-context-prompt.mjs +++ b/src/templates/hooks/graph-context-prompt.mjs @@ -405,5 +405,9 @@ async function main() { } if (process.argv[1] === fileURLToPath(import.meta.url)) { - main(); + const timer = setTimeout(() => { + process.stderr.write('[Graph] Timeout after 5s\n'); + process.exit(0); + }, 5000); + main().finally(() => clearTimeout(timer)); } diff --git a/src/templates/hooks/graph-context-prompt.sh b/src/templates/hooks/graph-context-prompt.sh index 6233c13..2e53c56 100644 --- a/src/templates/hooks/graph-context-prompt.sh +++ b/src/templates/hooks/graph-context-prompt.sh @@ -50,7 +50,7 @@ STDOUT_FILE=$(mktemp) STDERR_FILE=$(mktemp) trap 'rm -f "$STDOUT_FILE" "$STDERR_FILE"' EXIT -printf '%s' "$INPUT" | NODE_NO_WARNINGS=1 timeout 5s node graph-context-prompt.mjs \ +printf '%s' "$INPUT" | NODE_NO_WARNINGS=1 node graph-context-prompt.mjs \ >"$STDOUT_FILE" 2>"$STDERR_FILE" EXIT_CODE=$?