Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
4 changes: 2 additions & 2 deletions src/commands/doc-graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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'));
}
49 changes: 36 additions & 13 deletions src/commands/doc-init.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,9 @@ export async function docInitCommand(path, options) {
let discoveryFindings = null;
let discoveredDomains = [];

if (repoGraph && repoGraph.stats.totalFiles > 0 && options.mode !== 'base-only') {
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();
Expand Down Expand Up @@ -231,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) {
} 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: [
Expand Down Expand Up @@ -263,6 +265,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';
Expand Down Expand Up @@ -320,7 +334,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 = isDomainsOnly; // 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) {
Expand Down Expand Up @@ -650,7 +665,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)`);
Expand Down Expand Up @@ -681,7 +696,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(', ')}`);
}
Expand Down Expand Up @@ -737,23 +752,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);
Expand Down Expand Up @@ -783,6 +805,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) {
Expand Down Expand Up @@ -849,13 +872,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(',')}" ${repoPath}`));
console.log(pc.dim(` Or retry all: aspens doc init --mode chunked --timeout 600 ${repoPath}`));
Comment on lines +875 to +876
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Quote the repo path to handle spaces.

If repoPath contains spaces, these commands will fail. Wrap it in quotes for robustness.

Proposed fix
-    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}`));
+    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}"`));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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}`));
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}"`));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/doc-init.js` around lines 875 - 876, The printed retry commands
in src/commands/doc-init.js use the variable repoPath unquoted which will break
when the path contains spaces; update the two console.log calls that use pc.dim
(the lines referencing skippedDomains.join(',') and the timeout variant) to wrap
repoPath in quotes (use "${repoPath}") so the generated shell commands are
robust for paths with spaces; keep the rest of the string intact and ensure you
preserve the existing interpolation for skippedDomains.

}

// 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...');

Expand Down
4 changes: 3 additions & 1 deletion src/commands/doc-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
7 changes: 6 additions & 1 deletion src/lib/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down Expand Up @@ -80,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) => {
Expand Down
6 changes: 5 additions & 1 deletion src/templates/hooks/graph-context-prompt.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
3 changes: 2 additions & 1 deletion src/templates/hooks/graph-context-prompt.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions tests/graph-persistence.test.js
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 });
Expand Down
Loading