From a4c467677282e4c4ad1f70da569be23a22f97c90 Mon Sep 17 00:00:00 2001 From: mvoutov Date: Mon, 11 May 2026 10:22:53 -0700 Subject: [PATCH 1/7] feat: major changes to claude generration, skill generation --- .claude/agents/code-architecture-reviewer.md | 1 - .claude/agents/code-refactor-master.md | 1 - .claude/skills/agent-customization/skill.md | 2 +- .claude/skills/repo-scanning/skill.md | 5 +- bin/cli.js | 1 + src/commands/add.js | 10 + src/commands/customize.js | 75 ++++-- src/commands/doc-init.js | 36 +-- src/commands/doc-sync.js | 112 +++++++- src/lib/diff-classifier.js | 56 ++++ src/lib/frameworks/nextjs.js | 180 +++++++++++++ src/lib/graph-builder.js | 229 ++++------------ src/lib/graph-persistence.js | 49 +++- src/lib/impact.js | 128 ++++++++- src/lib/parsers/python.js | 72 +++++ src/lib/parsers/typescript.js | 184 +++++++++++++ src/lib/path-resolver.js | 195 ++++++++++++++ src/lib/target-transform.js | 245 ++++++++++++++---- src/prompts/customize-agents.md | 10 +- src/prompts/doc-init-claudemd.md | 16 +- src/prompts/doc-init-domain.md | 8 + src/prompts/doc-init.md | 7 +- src/prompts/doc-sync-refresh.md | 3 + src/prompts/doc-sync.md | 4 +- src/prompts/partials/examples.md | 3 - src/prompts/partials/guideline-format.md | 36 --- .../partials/preservation-contract-refresh.md | 7 + src/prompts/partials/preservation-contract.md | 10 + src/prompts/partials/skill-format.md | 21 +- src/templates/agents/auto-error-resolver.md | 10 +- .../agents/code-architecture-reviewer.md | 11 +- src/templates/agents/code-refactor-master.md | 11 +- .../agents/documentation-architect.md | 9 +- src/templates/agents/execute.md | 7 + src/templates/agents/ghost-writer.md | 7 + src/templates/agents/plan-reviewer.md | 9 +- src/templates/agents/plan.md | 7 + src/templates/agents/refactor-planner.md | 9 +- src/templates/agents/ux-ui-designer.md | 10 +- .../agents/web-research-specialist.md | 7 + src/templates/commands/dev-docs.md | 1 - .../hooks/skill-activation-prompt.mjs | 14 +- tests/agent-skill-refs.test.js | 63 +++++ tests/agent-templates-project-context.test.js | 38 +++ tests/customize-skills-injection.test.js | 46 ++++ tests/diff-classifier.test.js | 70 +++++ tests/graph-nextjs-roots.test.js | 120 +++++++++ tests/graph-persistence.test.js | 10 +- tests/graph-python.test.js | 78 ++++++ tests/graph-ts-reexport.test.js | 80 ++++++ tests/hook-runtime.test.js | 17 ++ tests/impact-hub-coverage.test.js | 98 +++++++ tests/no-guidelines-refs.test.js | 42 +++ tests/path-alias-resolution.test.js | 76 ++++++ tests/prompt-loader.test.js | 26 ++ tests/target-parity.test.js | 118 +++++++++ tests/target-transform.test.js | 81 +++++- 57 files changed, 2386 insertions(+), 395 deletions(-) create mode 100644 src/lib/diff-classifier.js create mode 100644 src/lib/frameworks/nextjs.js create mode 100644 src/lib/parsers/python.js create mode 100644 src/lib/parsers/typescript.js create mode 100644 src/lib/path-resolver.js delete mode 100644 src/prompts/partials/guideline-format.md create mode 100644 src/prompts/partials/preservation-contract-refresh.md create mode 100644 src/prompts/partials/preservation-contract.md create mode 100644 tests/agent-skill-refs.test.js create mode 100644 tests/agent-templates-project-context.test.js create mode 100644 tests/customize-skills-injection.test.js create mode 100644 tests/diff-classifier.test.js create mode 100644 tests/graph-nextjs-roots.test.js create mode 100644 tests/graph-python.test.js create mode 100644 tests/graph-ts-reexport.test.js create mode 100644 tests/impact-hub-coverage.test.js create mode 100644 tests/no-guidelines-refs.test.js create mode 100644 tests/path-alias-resolution.test.js create mode 100644 tests/target-parity.test.js diff --git a/.claude/agents/code-architecture-reviewer.md b/.claude/agents/code-architecture-reviewer.md index 8aeae9b..7254aaa 100644 --- a/.claude/agents/code-architecture-reviewer.md +++ b/.claude/agents/code-architecture-reviewer.md @@ -24,7 +24,6 @@ You are a senior code reviewer. You examine code for quality, architectural cons - Read `CLAUDE.md` for top-level conventions and commands - Read `.claude/skills/base/skill.md` for full architecture map, module inventory, and critical conventions - Read domain-specific skills in `.claude/skills/` when reviewing code in a particular area (e.g., `claude-runner/skill.md` for `runner.js` changes) -- No `.claude/guidelines/` directory exists yet — skip checking for it - If reviewing a task with plans, check `dev/active/[task-name]/` for context **How to Review:** diff --git a/.claude/agents/code-refactor-master.md b/.claude/agents/code-refactor-master.md index 8d0320d..aae9d7f 100644 --- a/.claude/agents/code-refactor-master.md +++ b/.claude/agents/code-refactor-master.md @@ -13,7 +13,6 @@ You execute refactoring systematically — reorganizing code, extracting compone **Context (read on-demand):** - Read `CLAUDE.md` and `.claude/skills/base/skill.md` for project conventions -- No `.claude/guidelines/` directory exists — skip checking for it - If a refactoring plan exists, check `dev/active/[task-name]/` for the plan (create `dev/` dirs as needed) **Key Conventions:** diff --git a/.claude/skills/agent-customization/skill.md b/.claude/skills/agent-customization/skill.md index c32b8a4..206b3d2 100644 --- a/.claude/skills/agent-customization/skill.md +++ b/.claude/skills/agent-customization/skill.md @@ -22,7 +22,7 @@ You are working on **agent customization** — the feature that reads a project' ## Key Concepts - **Claude-only feature:** Customize command reads `.aspens.json` and throws `CliError` if repo is configured for Codex-only (`targets: ['codex']`). Codex CLI has no agent concept. -- **Context gathering:** `gatherProjectContext()` reads CLAUDE.md (truncated at 3000 chars), all `.claude/skills/**/*.md` in full, and lists `.claude/guidelines/` paths without reading their contents. +- **Context gathering:** `gatherProjectContext()` reads CLAUDE.md (truncated at 3000 chars) and all `.claude/skills/**/*.md` in full. Skills are the single source of truth for project context. - **Agent discovery:** `findAgents()` recursively walks `.claude/agents/`, reads `.md` files, extracts `name:` via regex — falls back to filename if no frontmatter match. - **Read-only tools:** Claude is invoked with `allowedTools: ['Read', 'Glob', 'Grep']` and no maxTokens cap (unlike doc-init which sets per-call limits). - **Output parsing:** Claude returns `content` XML tags, parsed by `parseFileOutput()`. Only `.claude/` paths are allowed. diff --git a/.claude/skills/repo-scanning/skill.md b/.claude/skills/repo-scanning/skill.md index 5509474..bbb095b 100644 --- a/.claude/skills/repo-scanning/skill.md +++ b/.claude/skills/repo-scanning/skill.md @@ -42,8 +42,5 @@ You are working on **aspens' repo scanning system** — a fully deterministic an - **Tests use real filesystem fixtures**, not mocks — create fixtures with `createFixture(name, files)` pattern, always clean up - **`detectEntryPoints` is exported** and reused by `graph-builder.js` — changing its signature breaks the graph builder -## References -- **No guidelines directory** — `.claude/guidelines/` does not exist yet for this domain - --- -**Last Updated:** 2026-04-16 +**Last Updated:** 2026-05-10 diff --git a/bin/cli.js b/bin/cli.js index fa0a855..19a7a3c 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -234,6 +234,7 @@ program .description('Inject project-specific context into agents') .argument('', 'What to customize: agents') .option('--dry-run', 'Preview without writing files') + .option('--reset', 'Re-customize existing agents (apply v0.8 upgrades like skills: [base])') .option('--timeout ', 'Claude timeout in seconds', parseTimeout, 300) .option('--model ', 'Claude model to use (e.g., sonnet, opus, haiku)') .option('--verbose', 'Show what Claude is reading/doing in real time') diff --git a/src/commands/add.js b/src/commands/add.js index 44dc319..8988a92 100644 --- a/src/commands/add.js +++ b/src/commands/add.js @@ -220,6 +220,16 @@ function addResource(repoPath, resourceType, name, available) { copyFileSync(sourceFile, targetFile); console.log(` ${pc.green('+')} ${resourceType.targetDir}/${resource.fileName}`); + // Phase 6: agents reference the base skill via conditional reads (and via + // `skills: [base]` if Claude Code supports it). Warn — non-fatal — when the + // base skill is not on disk so users know to run `aspens doc init`. + if (resourceType.targetDir === '.claude/agents') { + const baseSkillPath = join(repoPath, '.claude', 'skills', 'base', 'skill.md'); + if (!existsSync(baseSkillPath)) { + console.log(pc.yellow(' ! Base skill missing.') + pc.dim(" Run 'aspens doc init' before this agent can fully self-contextualize. The agent will still install.")); + } + } + // Plan/execute agents need dev/ gitignored for plan storage if (name === 'plan' || name === 'execute') { ensureDevGitignore(repoPath); diff --git a/src/commands/customize.js b/src/commands/customize.js index d0b4d48..482b4a5 100644 --- a/src/commands/customize.js +++ b/src/commands/customize.js @@ -38,6 +38,17 @@ export async function customizeCommand(what, options) { p.intro(pc.cyan('aspens customize agents')); + if (options.reset) { + p.log.info('--reset: re-customizing all agents (applies v0.8 upgrades like skills: [base]).'); + } + + // Phase 6: pre-flight — base skill is required for full subagent context. + const baseSkillPath = join(repoPath, '.claude', 'skills', 'base', 'skill.md'); + if (!existsSync(baseSkillPath)) { + throw new CliError("Run 'aspens doc init' first — base skill is required for agent context."); + } + const baseSkillExists = true; + // Step 1: Find agents in the repo const agentsDir = join(repoPath, '.claude', 'agents'); if (!existsSync(agentsDir)) { @@ -86,7 +97,14 @@ export async function customizeCommand(what, options) { const files = parseFileOutput(text); if (files.length > 0) { - allFiles.push(...files); + // Phase 6: inject `skills: [base]` into customized agent frontmatter + // when (a) base skill exists on disk, AND (b) the agent doesn't + // already declare a `skills:` line OR --reset was passed. + const injected = files.map(file => ({ + ...file, + content: maybeInjectBaseSkill(file.content, baseSkillExists, !!options.reset), + })); + allFiles.push(...injected); agentSpinner.stop(pc.green(`${agent.name} customized`)); } else { agentSpinner.stop(pc.dim(`${agent.name} — no changes needed`)); @@ -143,6 +161,41 @@ export async function customizeCommand(what, options) { // --- Helpers --- +/** + * Inject `skills: [base]` into an agent's YAML frontmatter when the base + * skill exists on disk AND the agent doesn't already declare `skills:` (or + * `--reset` was passed, in which case we override). + * + * Templates intentionally do NOT carry `skills:` — that line is added here so + * agents stay valid in any install state (including `aspens add agent` runs + * without a prior `doc init`). + * + * @param {string} content - agent .md content (with frontmatter) + * @param {boolean} baseSkillExists + * @param {boolean} reset + * @returns {string} + */ +export function maybeInjectBaseSkill(content, baseSkillExists, reset) { + if (!baseSkillExists) return content; + + const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); + if (!fmMatch) return content; // no frontmatter — leave alone + + const frontmatter = fmMatch[1]; + const hasSkillsLine = /^skills:\s*/m.test(frontmatter); + + if (hasSkillsLine && !reset) return content; + + let newFrontmatter; + if (hasSkillsLine && reset) { + newFrontmatter = frontmatter.replace(/^skills:\s*.*$/m, 'skills: [base]'); + } else { + newFrontmatter = frontmatter.trimEnd() + '\nskills: [base]'; + } + + return content.replace(/^---\n[\s\S]*?\n---/, '---\n' + newFrontmatter + '\n---'); +} + function findAgents(agentsDir, repoPath) { const agents = []; @@ -197,25 +250,5 @@ function gatherProjectContext(repoPath) { walkSkills(skillsDir); } - // Guidelines paths (just list them, don't read) - const guidelinesDir = join(repoPath, '.claude', 'guidelines'); - if (existsSync(guidelinesDir)) { - const paths = []; - function walkGuidelines(dir) { - for (const entry of readdirSync(dir)) { - const full = join(dir, entry); - if (statSync(full).isDirectory()) { - walkGuidelines(full); - } else if (entry.endsWith('.md')) { - paths.push(relative(repoPath, full)); - } - } - } - walkGuidelines(guidelinesDir); - if (paths.length > 0) { - parts.push(`### Available Guidelines\n${paths.map(p => `- ${p}`).join('\n')}`); - } - } - return parts.length > 0 ? parts.join('\n\n') : null; } diff --git a/src/commands/doc-init.js b/src/commands/doc-init.js index 1e3a618..edb853a 100644 --- a/src/commands/doc-init.js +++ b/src/commands/doc-init.js @@ -13,7 +13,7 @@ import { CliError } from '../lib/errors.js'; import { resolveTimeout } from '../lib/timeout.js'; import { TARGETS, resolveTarget, getAllowedPaths, writeConfig, loadConfig, mergeConfiguredTargets } from '../lib/target.js'; import { detectAvailableBackends, resolveBackend } from '../lib/backend.js'; -import { transformForTarget, validateTransformedFiles, ensureRootKeyFilesSection } from '../lib/target-transform.js'; +import { transformForTarget, validateTransformedFiles, ensureRootKeyFilesSection, syncSkillsSection, syncBehaviorSection } from '../lib/target-transform.js'; import { findSkillFiles } from '../lib/skill-reader.js'; import { getGitRoot } from '../lib/git-helpers.js'; import { installSaveTokensRecommended } from './save-tokens.js'; @@ -1124,18 +1124,12 @@ function buildGraphContext(graph) { return sections.join('\n'); } -function buildRootInstructionsGraphContext(graph) { - if (!graph?.hubs?.length) return ''; - - const sections = ['## Key Files To Surface In Root Context', '']; - sections.push('Add a concise `## Key Files` section to the root instructions file and mention these hub files explicitly:'); - for (const hub of graph.hubs.slice(0, 5)) { - const fileInfo = graph.files?.[hub.path]; - sections.push(`- \`${hub.path}\` — ${hub.fanIn} dependents${fileInfo?.lines ? `, ${fileInfo.lines} lines` : ''}`); - } - sections.push(''); - - return sections.join('\n'); +function buildRootInstructionsGraphContext(/* graph */) { + // Phase 1: stability — no longer instruct the LLM to emit a `## Key Files` + // section in CLAUDE.md/AGENTS.md from hub data. Hub-counts/rankings live in + // code-map and graph metadata only. The function is preserved for caller + // stability and intentionally returns an empty context string. + return ''; } /** @@ -1704,9 +1698,19 @@ async function generateChunked(repoPath, scan, repoGraph, domains, baseOnly, tim claudeMdSpinner.stop(pc.yellow(`${instructionsArtifactLabel()} — failed after retries`)); p.log.warn(`Could not generate ${instructionsArtifactLabel()}. Try: aspens doc init --strategy rewrite --mode base-only`); } else { - files = files.map(file => file.path === 'CLAUDE.md' - ? { ...file, content: ensureRootKeyFilesSection(file.content, repoGraph) } - : file); + const claudeSkillsDirPrefix = TARGETS.claude.skillsDir + '/'; + const claudeBaseSkillPrefix = TARGETS.claude.skillsDir + '/base/'; + const baseSkillForList = allFiles.find(f => f.path.startsWith(claudeBaseSkillPrefix)); + const domainSkillsForList = allFiles.filter(f => + f.path.startsWith(claudeSkillsDirPrefix) && !f.path.startsWith(claudeBaseSkillPrefix) + ); + files = files.map(file => { + if (file.path !== 'CLAUDE.md') return file; + let content = ensureRootKeyFilesSection(file.content); + content = syncSkillsSection(content, baseSkillForList, domainSkillsForList, TARGETS.claude, false); + content = syncBehaviorSection(content); + return { ...file, content }; + }); files = validateGeneratedChunk(files, repoPath); allFiles.push(...files); if (writeIncrementally && files.length > 0) { diff --git a/src/commands/doc-sync.js b/src/commands/doc-sync.js index 011b644..87a908a 100644 --- a/src/commands/doc-sync.js +++ b/src/commands/doc-sync.js @@ -15,7 +15,8 @@ import { installGitHook, removeGitHook } from '../lib/git-hook.js'; import { isGitRepo, getGitRoot, getGitDiff, getGitLog, getChangedFiles } from '../lib/git-helpers.js'; import { TARGETS, getAllowedPaths, loadConfig } from '../lib/target.js'; import { getSelectedFilesDiff, buildPrioritizedDiff, truncate } from '../lib/diff-helpers.js'; -import { projectCodexDomainDocs, transformForTarget } from '../lib/target-transform.js'; +import { projectCodexDomainDocs, transformForTarget, assertTargetParity } from '../lib/target-transform.js'; +import { isNoOpDiff } from '../lib/diff-classifier.js'; const READ_ONLY_TOOLS = ['Read', 'Glob', 'Grep']; const PARALLEL_LIMIT = 3; @@ -62,24 +63,85 @@ function chooseSyncSourceTarget(repoPath, targets) { return targets[0] || TARGETS.claude; } +/** + * Backwards-compat helper (Phase 1: stability). v0.7 emitted a `## Key Files` + * block with hub counts in CLAUDE.md/AGENTS.md. Phase 1 removes that block. + * On the first sync after upgrade we surface a one-line notice so the resulting + * diff isn't alarming. + */ +const LEGACY_HUB_BLOCK_RE = /^## Key Files\b[\s\S]*?(?:Hub files|most depended-on|N dependents|\d+ dependents)/m; +const LEGACY_CODE_MAP_HUB_RE = /^\*\*Hub files\b/m; + +/** + * Force-regenerate `.claude/code-map.md` when it carries the v0.7 hub-files + * block. Phase 6 agents read code-map; we don't want them seeing stale format + * even on a no-op sync. + */ +async function regenerateStaleCodeMap(repoPath, sourceTarget, scan) { + const codeMapPath = join(repoPath, '.claude', 'code-map.md'); + let needsRegen = false; + try { + const content = readFileSync(codeMapPath, 'utf8'); + if (LEGACY_CODE_MAP_HUB_RE.test(content)) needsRegen = true; + } catch { + return; // no code-map present — nothing to do + } + if (!needsRegen) return; + + try { + const rawGraph = await buildRepoGraph(repoPath, scan.languages); + persistGraphArtifacts(repoPath, rawGraph, { target: sourceTarget }); + p.log.info('Regenerated .claude/code-map.md (legacy hub-files block detected)'); + } catch { + // best-effort — surface nothing on failure + } +} + +function notifyLegacyHubBlockIfPresent(repoPath) { + const candidates = ['CLAUDE.md', 'AGENTS.md']; + for (const file of candidates) { + try { + const content = readFileSync(join(repoPath, file), 'utf8'); + if (LEGACY_HUB_BLOCK_RE.test(content)) { + p.log.info(`First sync after upgrade: removing legacy hub-counts block from ${file} (no longer needed)`); + return; + } + } catch {} + } +} + +/** + * Build the per-target file map. Returns Map so callers + * can route writes per target and so the parity validator can compare slots + * across targets. Use `flattenPublishedMap` when a flat list is needed. + */ function publishFilesForTargets(baseFiles, sourceTarget, publishTargets, scan, graphSerialized = null, repoPath = null) { - const published = []; + const perTarget = new Map(); for (const target of publishTargets) { + let files; if (target.id === sourceTarget.id) { - published.push(...baseFiles, ...buildDerivedCodexFiles(baseFiles, target, scan)); - continue; + files = [...baseFiles, ...buildDerivedCodexFiles(baseFiles, target, scan)]; + } else { + files = transformForTarget(baseFiles, sourceTarget, target, { + scanResult: scan, + graphSerialized, + repoPath, + }); } - - const transformed = transformForTarget(baseFiles, sourceTarget, target, { - scanResult: scan, - graphSerialized, - repoPath, - }); - published.push(...transformed); + perTarget.set(target.id, dedupeFiles(files)); } - return dedupeFiles(published); + assertTargetParity(perTarget); + return perTarget; +} + +function flattenPublishedMap(perTarget) { + const all = []; + for (const files of perTarget.values()) { + all.push(...files); + } + return dedupeFiles(all); } export async function docSyncCommand(path, options) { @@ -142,6 +204,26 @@ export async function docSyncCommand(path, options) { return; } + // Phase 1: changetype filter — skip the LLM call entirely on lockfile-only + // diffs and diffs that touch zero code-bearing files. + if (isNoOpDiff(changedFiles)) { + if (verbose) { + p.log.info('Skipped: no code-bearing changes (lockfile bumps / non-code files only)'); + } else { + p.log.info('Skipped: no code-bearing changes'); + } + // If a stale-format code-map is on disk (legacy `## Hub files` block), + // force a graph rebuild so subsequent reads see the modern format. + await regenerateStaleCodeMap(repoPath, sourceTarget, scanRepo(repoPath)); + p.outro('No sync needed'); + return; + } + + // Backwards-compat notice (Phase 1): if the user has a v0.7-era hub block + // in CLAUDE.md/AGENTS.md, the upcoming sync will remove it. Surface this + // once so the diff isn't surprising. + notifyLegacyHubBlockIfPresent(repoPath); + const diff = getSelectedFilesDiff(gitRoot, changedFiles.map(file => withProjectPrefix(file, projectPrefix)), actualCommits); // Show what changed @@ -314,7 +396,8 @@ ${truncate(instructionsContent, 5000)} p.log.warn('LLM responded without tags — treating as no updates needed.'); } } - const files = publishFilesForTargets(baseFiles, sourceTarget, publishTargets, scan, graphSerialized, repoPath); + const perTarget = publishFilesForTargets(baseFiles, sourceTarget, publishTargets, scan, graphSerialized, repoPath); + const files = flattenPublishedMap(perTarget); if (files.length === 0) { syncSpinner.stop('No updates needed'); @@ -647,7 +730,8 @@ async function refreshAllSkills(repoPath, options, sourceTarget, publishTargets return; } - const filesToWrite = publishFilesForTargets(allUpdatedFiles, sourceTarget, publishTargets, scan, graphSerialized, repoPath); + const refreshPerTarget = publishFilesForTargets(allUpdatedFiles, sourceTarget, publishTargets, scan, graphSerialized, repoPath); + const filesToWrite = flattenPublishedMap(refreshPerTarget); const directWriteFiles = filesToWrite.filter(f => !(f.path.endsWith('/AGENTS.md') && f.path !== 'AGENTS.md')); const dirScopedFiles = filesToWrite.filter(f => f.path.endsWith('/AGENTS.md') && f.path !== 'AGENTS.md'); const results = [ diff --git a/src/lib/diff-classifier.js b/src/lib/diff-classifier.js new file mode 100644 index 0000000..5b0cafb --- /dev/null +++ b/src/lib/diff-classifier.js @@ -0,0 +1,56 @@ +/** + * diff-classifier — change classification (vs diff-helpers, which shapes diffs). + * + * This module owns predicates that answer "what KIND of change is this?" so + * the doc-sync pipeline can decide whether a commit needs an LLM call at all. + * + * Layering: leaf module. graph-builder imports LOCK_FILES from here; nothing + * here imports from graph-builder. Future home for `isFormattingOnlyDiff`, + * `isCommentOnlyDiff`, etc. + */ + +import { extname, basename } from 'path'; + +/** + * Filenames that represent dependency lockfiles. Edits to these files alone + * never warrant a doc-sync (the source of truth — the manifest — drives sync, + * not the resolved lock). + */ +export const LOCK_FILES = new Set([ + 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', + 'Cargo.lock', 'Gemfile.lock', 'poetry.lock', 'go.sum', + 'composer.lock', 'Pipfile.lock', +]); + +/** + * File extensions that may carry application logic worth re-evaluating. + * Anything outside this set is considered non-code-bearing for sync purposes. + */ +const CODE_BEARING_EXTS = new Set([ + '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', + '.py', '.go', '.rs', '.rb', '.java', '.kt', '.swift', +]); + +/** + * Returns true when the changed file set is "no-op" for doc-sync purposes: + * - Every file is a known lockfile (e.g. dependency bump only), OR + * - No file has a code-bearing extension (e.g. only docs/configs touched). + * + * Skipping the LLM call entirely on these diffs is the changetype filter + * that paired with the prompt churn-suppression rules forms Phase 1's noise + * floor. + * + * @param {string[]} changedFiles - paths relative to the repo root + * @returns {boolean} + */ +export function isNoOpDiff(changedFiles) { + if (!Array.isArray(changedFiles) || changedFiles.length === 0) return true; + + const allLockFiles = changedFiles.every(file => LOCK_FILES.has(basename(file))); + if (allLockFiles) return true; + + const anyCodeBearing = changedFiles.some(file => CODE_BEARING_EXTS.has(extname(file))); + if (!anyCodeBearing) return true; + + return false; +} diff --git a/src/lib/frameworks/nextjs.js b/src/lib/frameworks/nextjs.js new file mode 100644 index 0000000..467a529 --- /dev/null +++ b/src/lib/frameworks/nextjs.js @@ -0,0 +1,180 @@ +/** + * Next.js framework detector — implicit entry points + path-alias defaults. + * + * App Router files (`page`, `layout`, `route`, `loading`, `error`, + * `not-found`, `template`, `default`, `global-error`), Pages Router files + * (legacy), and special files (`middleware`, `instrumentation`) are + * "entry points" — Next.js runs them implicitly so they have no static + * importer. We tag them so the import-graph priority ranker treats them as + * roots. + * + * Code-bearing extensions only — metadata routes can also use png/jpg, but + * those don't enter the JS/TS import graph. + */ + +import { existsSync, readdirSync, statSync } from 'fs'; +import { join, basename, extname, relative } from 'path'; + +const APP_ROUTER_FILE_NAMES = new Set([ + 'page', 'layout', 'route', 'loading', 'error', + 'not-found', 'template', 'default', 'global-error', +]); + +const METADATA_ROUTE_NAMES = new Set([ + 'opengraph-image', 'twitter-image', 'icon', 'apple-icon', + 'sitemap', 'robots', 'manifest', +]); + +const SPECIAL_TOPLEVEL_NAMES = new Set([ + 'middleware', 'instrumentation', +]); + +const CODE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx']); + +const APP_DIR_CANDIDATES = ['app', 'src/app']; +const PAGES_DIR_CANDIDATES = ['pages', 'src/pages']; + +/** + * Returns true if this repo looks like a Next.js project. + * + * @param {{ frameworks?: string[], dependencies?: object }} scan + * @returns {boolean} + */ +export function isNextjsProject(scan) { + if (!scan) return false; + if (Array.isArray(scan.frameworks) && scan.frameworks.some(f => /next/i.test(f))) return true; + const deps = scan.dependencies || {}; + if (deps.next) return true; + return false; +} + +/** + * Detect Next.js entry-point files. + * Returns an array of { path, kind } where path is repo-relative and kind + * is one of: 'nextjs-app', 'nextjs-pages', 'nextjs-middleware'. + * + * @param {string} repoPath + * @returns {Array<{path: string, kind: string}>} + */ +export function detectNextjsEntryPoints(repoPath) { + const found = []; + + for (const candidate of APP_DIR_CANDIDATES) { + const full = join(repoPath, candidate); + if (existsSync(full) && safeIsDir(full)) { + walkAppDir(repoPath, full, found); + } + } + + for (const candidate of PAGES_DIR_CANDIDATES) { + const full = join(repoPath, candidate); + if (existsSync(full) && safeIsDir(full)) { + walkPagesDir(repoPath, full, found); + } + } + + for (const name of SPECIAL_TOPLEVEL_NAMES) { + for (const ext of CODE_EXTS) { + // Try root and src/ + for (const dir of ['', 'src']) { + const full = dir ? join(repoPath, dir, name + ext) : join(repoPath, name + ext); + if (existsSync(full) && safeIsFile(full)) { + found.push({ + path: relative(repoPath, full), + kind: 'nextjs-middleware', + }); + } + } + } + } + + return dedupe(found); +} + +function walkAppDir(repoPath, dir, out, depth = 0) { + if (depth > 12) return; + let entries; + try { entries = readdirSync(dir); } catch { return; } + + for (const entry of entries) { + if (entry.startsWith('.')) continue; + const full = join(dir, entry); + let stat; + try { stat = statSync(full); } catch { continue; } + + if (stat.isDirectory()) { + walkAppDir(repoPath, full, out, depth + 1); + continue; + } + + const ext = extname(entry); + if (!CODE_EXTS.has(ext)) continue; + + const stem = basename(entry, ext); + if (APP_ROUTER_FILE_NAMES.has(stem) || METADATA_ROUTE_NAMES.has(stem)) { + out.push({ path: relative(repoPath, full), kind: 'nextjs-app' }); + } + } +} + +function walkPagesDir(repoPath, dir, out, depth = 0) { + if (depth > 12) return; + let entries; + try { entries = readdirSync(dir); } catch { return; } + + for (const entry of entries) { + if (entry.startsWith('.') || entry.startsWith('_document') || entry.startsWith('_app')) { + // _app and _document are entry-equivalents — keep them too. + } + const full = join(dir, entry); + let stat; + try { stat = statSync(full); } catch { continue; } + + if (stat.isDirectory()) { + walkPagesDir(repoPath, full, out, depth + 1); + continue; + } + + const ext = extname(entry); + if (!CODE_EXTS.has(ext)) continue; + + out.push({ path: relative(repoPath, full), kind: 'nextjs-pages' }); + } +} + +function dedupe(items) { + const seen = new Set(); + const out = []; + for (const item of items) { + if (seen.has(item.path)) continue; + seen.add(item.path); + out.push(item); + } + return out; +} + +function safeIsDir(p) { + try { return statSync(p).isDirectory(); } catch { return false; } +} + +function safeIsFile(p) { + try { return statSync(p).isFile(); } catch { return false; } +} + +/** + * Returns the implicit Next.js path alias when no tsconfig paths are configured. + * Modern Next.js projects default to `@/*` → `./src/*` (or `./*` if no src). + * + * @param {string} repoPath + * @returns {Array<{prefix: string, replacement: string}>} + */ +export function nextjsImplicitAliases(repoPath) { + const aliases = []; + const srcDir = join(repoPath, 'src'); + if (existsSync(srcDir) && safeIsDir(srcDir)) { + aliases.push({ prefix: '@/', replacement: srcDir }); + } else { + aliases.push({ prefix: '@/', replacement: repoPath }); + } + return aliases; +} diff --git a/src/lib/graph-builder.js b/src/lib/graph-builder.js index 38da257..3016177 100644 --- a/src/lib/graph-builder.js +++ b/src/lib/graph-builder.js @@ -1,21 +1,36 @@ import { readFileSync, existsSync, readdirSync, statSync } from 'fs'; import { join, basename, extname, relative, dirname, resolve } from 'path'; import { execSync } from 'child_process'; -import { init, parse } from 'es-module-lexer'; -import { detectEntryPoints } from './scanner.js'; +import { detectEntryPoints, scanRepo } from './scanner.js'; +import { LOCK_FILES } from './diff-classifier.js'; +import { parseJsImports } from './parsers/typescript.js'; +import { parsePyImports, extractPythonExports } from './parsers/python.js'; +import { detectNextjsEntryPoints, isNextjsProject, nextjsImplicitAliases } from './frameworks/nextjs.js'; +import { loadPathAliases, resolveAliasImport } from './path-resolver.js'; /** * Build the import graph for a repository. * Returns adjacency list, edges, file metrics, hub files, and domain clusters. */ export async function buildRepoGraph(repoPath, languages = []) { - await init; + // (es-module-lexer init lives in parsers/typescript.js — Phase 3 split) - const entryPoints = detectEntryPoints(repoPath); + const baseEntryPoints = detectEntryPoints(repoPath); + + // Framework-aware entry points (Next.js implicit roots — Q4) + const scan = safeScanForFrameworks(repoPath); + const frameworkEntries = isNextjsProject(scan) ? detectNextjsEntryPoints(repoPath) : []; + + const entryPoints = dedupeStrings([...baseEntryPoints, ...frameworkEntries.map(e => e.path)]); const entryPointSet = new Set(entryPoints); + const frameworkEntryByPath = new Map(frameworkEntries.map(e => [e.path, e.kind])); - // Load path aliases from tsconfig.json (e.g. "@/*": ["./src/*"]) - const pathAliases = loadPathAliases(repoPath); + // Load path aliases from tsconfig.json (handles `extends` chains). + // If no explicit paths and Next.js detected, fall back to the implicit `@/*` alias. + let pathAliases = loadPathAliases(repoPath); + if (pathAliases.length === 0 && isNextjsProject(scan)) { + pathAliases = nextjsImplicitAliases(repoPath); + } // Detect Python package roots (directories containing pyproject.toml, setup.py, or __init__.py at top level) const pythonRoots = detectPythonRoots(repoPath); @@ -47,6 +62,7 @@ export async function buildRepoGraph(repoPath, languages = []) { exportNames = parsed.exports; } else if (ext === '.py') { rawImports = parsePyImports(content); + exportNames = extractPythonExports(content); } // Resolve imports @@ -95,6 +111,9 @@ export async function buildRepoGraph(repoPath, languages = []) { exports: exportNames, externalImports, lines, + ...(frameworkEntryByPath.has(relPath) + ? { entryPoint: true, entryPointKind: frameworkEntryByPath.get(relPath) } + : {}), }; } @@ -165,6 +184,7 @@ export async function buildRepoGraph(repoPath, languages = []) { clusters, hotspots, entryPoints, + frameworkEntryPoints: frameworkEntries, stats: { totalFiles: Object.keys(files).length, totalEdges: edges.length, @@ -190,11 +210,8 @@ const SKIP_DIRS = new Set([ // Vendored/generated file patterns — skip these from the import graph const VENDORED_FILE_RE = /\.min\.(js|css)$|\.bundle\.js$|[-.]generated\.|_generated\.|_pb2\.py$|\.pb\.go$|\.g\.dart$/; const GENERATED_FIRST_LINE_RE = /^\s*\/\/\s*(Code generated|AUTO-GENERATED|This file is auto)|^\s*#\s*(This file is autogenerated|AUTO-GENERATED|Generated by)/i; -const LOCK_FILES = new Set([ - 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', - 'Cargo.lock', 'Gemfile.lock', 'poetry.lock', 'go.sum', - 'composer.lock', 'Pipfile.lock', -]); +// LOCK_FILES is now owned by `./diff-classifier.js` (single source of truth +// for change-classification predicates). Imported above. function isVendoredOrGenerated(entry, content) { if (LOCK_FILES.has(entry)) return true; @@ -234,167 +251,10 @@ function walkSourceFiles(repoPath, maxDepth = 12) { return results; } -// --- JS/TS import parsing --- - -async function parseJsImports(content, relPath) { - await init; - const result = { imports: [], exports: [] }; - - try { - const [imports, exports] = parse(content); - - for (const imp of imports) { - // imp.n is the import specifier (null for dynamic imports without a string literal) - if (imp.n) { - result.imports.push(imp.n); - } - } - - for (const exp of exports) { - if (exp.n) { - result.exports.push(exp.n); - } - } - } catch { - // File couldn't be parsed — skip silently - } - - return result; -} - -// --- Python import parsing --- - -const PY_FROM_IMPORT_RE = /^from\s+(\.+[\w.]*|[\w.]+)\s+import\s+/gm; -const PY_IMPORT_RE = /^import\s+([\w.]+)/gm; - -function parsePyImports(content) { - const imports = []; - - // Strip triple-quoted strings to avoid matching imports in docstrings - const stripped = content.replace(/('{3}|"{3})[\s\S]*?\1/g, ''); - - let match; - - // Reset lastIndex for global regexes - PY_FROM_IMPORT_RE.lastIndex = 0; - while ((match = PY_FROM_IMPORT_RE.exec(stripped)) !== null) { - imports.push(match[1]); - } - - PY_IMPORT_RE.lastIndex = 0; - while ((match = PY_IMPORT_RE.exec(stripped)) !== null) { - imports.push(match[1]); - } - - return imports; -} - -// --- Path alias loading --- - -/** - * Load path aliases from tsconfig.json (e.g. "@/*": ["./src/*"]). - * Returns array of { prefix, replacement } for resolution. - */ -function loadPathAliases(repoPath) { - const aliases = []; - - // Collect candidate tsconfig/jsconfig locations: - // 1. Repo root - // 2. Common subdirectories (frontend/, apps/*, packages/*) - const configLocations = [repoPath]; - const subdirCandidates = ['frontend', 'web', 'client', 'app', 'src']; - for (const sub of subdirCandidates) { - const full = join(repoPath, sub); - if (existsSync(full) && statSync(full).isDirectory()) { - configLocations.push(full); - } - } - // Also check apps/* and packages/* for monorepos - for (const mono of ['apps', 'packages']) { - const monoDir = join(repoPath, mono); - if (!existsSync(monoDir)) continue; - try { - for (const entry of readdirSync(monoDir)) { - const full = join(monoDir, entry); - if (statSync(full).isDirectory()) configLocations.push(full); - } - } catch { /* skip */ } - } - - for (const configDir of configLocations) { - for (const configName of ['tsconfig.json', 'jsconfig.json']) { - const configPath = join(configDir, configName); - if (!existsSync(configPath)) continue; - - try { - const content = readFileSafe(configPath); - if (!content) continue; - - // Strip comments for JSON parsing — careful not to mangle strings - const stripped = content - .replace(/("(?:[^"\\]|\\.)*")|\/\/.*$/gm, (m, str) => str || '') - .replace(/("(?:[^"\\]|\\.)*")|\/\*[\s\S]*?\*\//g, (m, str) => str || '') - .replace(/,\s*([\]}])/g, '$1'); // trailing commas - - const config = JSON.parse(stripped); - const paths = config?.compilerOptions?.paths; - const baseUrl = config?.compilerOptions?.baseUrl || '.'; - - if (paths) { - for (const [pattern, targets] of Object.entries(paths)) { - if (!targets || targets.length === 0) continue; - // "@/*" → prefix "@/", "*" is the wildcard - const prefix = pattern.replace(/\*$/, ''); - const target = targets[0].replace(/\*$/, ''); - // Resolve relative to the tsconfig's directory, not repo root - const replacement = join(configDir, baseUrl, target); - aliases.push({ prefix, replacement }); - } - } - } catch { /* malformed config — skip */ } - } - } - - return aliases; -} - -/** - * Resolve an aliased import (e.g. @/components/Button) to a repo-relative path. - */ -function resolveAliasImport(repoPath, specifier, aliases) { - for (const { prefix, replacement } of aliases) { - if (!specifier.startsWith(prefix)) continue; - - const rest = specifier.slice(prefix.length); - const targetBase = join(replacement, rest); - - // Try with extension - if (extname(rest)) { - if (existsSync(targetBase)) { - return relative(repoPath, targetBase); - } - continue; - } - - // Try extensions - for (const ext of ['.ts', '.tsx', '.js', '.jsx', '.mjs']) { - const candidate = targetBase + ext; - if (existsSync(candidate)) { - return relative(repoPath, candidate); - } - } - - // Try /index variants - for (const ext of ['.ts', '.tsx', '.js', '.jsx']) { - const candidate = join(targetBase, 'index' + ext); - if (existsSync(candidate)) { - return relative(repoPath, candidate); - } - } - } - - return null; -} +// --- JS/TS and Python parsers --- +// Implementations moved to src/lib/parsers/typescript.js and src/lib/parsers/python.js +// (Phase 3 split — keeps graph-builder focused on orchestration). They are +// re-exported from this module's bottom for backwards-compat with callers. // --- Python package root detection --- @@ -682,6 +542,31 @@ function readFileSafe(filePath) { } } +function dedupeStrings(items) { + const seen = new Set(); + const out = []; + for (const item of items) { + if (seen.has(item)) continue; + seen.add(item); + out.push(item); + } + return out; +} + +/** + * Best-effort `scanRepo()` for framework detection. Returns minimal scan + * shape on failure so framework detectors can no-op. + */ +function safeScanForFrameworks(repoPath) { + try { + return scanRepo(repoPath); + } catch { + return { frameworks: [], dependencies: {} }; + } +} + +// Re-export moved parsers for backwards-compat with callers that import from +// graph-builder.js (e.g. tests/graph-builder.test.js). export { parseJsImports, parsePyImports, diff --git a/src/lib/graph-persistence.js b/src/lib/graph-persistence.js index 9149ca9..6044257 100644 --- a/src/lib/graph-persistence.js +++ b/src/lib/graph-persistence.js @@ -75,6 +75,7 @@ export function serializeGraph(rawGraph, repoPath) { })), coupling: rawGraph.clusters?.coupling || [], hotspots: rawGraph.hotspots, + frameworkEntryPoints: rawGraph.frameworkEntryPoints || [], clusterIndex, }; } @@ -347,6 +348,24 @@ function shortPath(p) { return parts.length > 2 ? parts.slice(-2).join('/') : p; } +/** + * Group framework-entry-point objects by their `kind` field. + * Returns a Map preserving deterministic insertion order so the resulting + * code-map sections render consistently across runs. + * + * @param {Array<{path: string, kind: string}>} entries + * @returns {Map>} + */ +export function groupFrameworkEntries(entries) { + const grouped = new Map(); + for (const entry of entries || []) { + const list = grouped.get(entry.kind) || []; + list.push(entry); + grouped.set(entry.kind, list); + } + return grouped; +} + // --------------------------------------------------------------------------- // Code-map generation — standalone overview, independent of skills // --------------------------------------------------------------------------- @@ -366,15 +385,9 @@ const MAX_MAP_HOTSPOTS = 5; export function generateCodeMap(serializedGraph) { const lines = ['## Codebase Structure\n']; - // Hub files - if (serializedGraph.hubs?.length > 0) { - lines.push('**Hub files (most depended-on — prioritize reading these):**'); - for (const h of serializedGraph.hubs.slice(0, MAX_MAP_HUBS)) { - const exports = (h.exports || []).slice(0, 6).join(', '); - lines.push(`- \`${h.path}\` — ${h.fanIn} dependents${exports ? ' | exports: ' + exports : ''}`); - } - lines.push(''); - } + // Hub files block intentionally removed — counts/rankings move to code-map only + // (removed in Phase 1: stability — see dev/active/aspens-stability/plan.md). + // The graph hook still surfaces hubs at prompt-injection time when needed. // Domain clusters if (serializedGraph.clusters?.length > 0) { @@ -412,6 +425,21 @@ export function generateCodeMap(serializedGraph) { lines.push(''); } + // Framework entry points (Phase 3 — Next.js implicit roots) + if (serializedGraph.frameworkEntryPoints?.length > 0) { + const grouped = groupFrameworkEntries(serializedGraph.frameworkEntryPoints); + for (const [kind, entries] of grouped) { + lines.push(`**Framework entry points (${kind}):**`); + for (const entry of entries.slice(0, 20)) { + lines.push(`- \`${entry.path}\``); + } + if (entries.length > 20) { + lines.push(`- ... +${entries.length - 20} more`); + } + lines.push(''); + } + } + lines.push(`*${serializedGraph.meta.totalFiles} files, ${serializedGraph.meta.totalEdges} edges — updated ${serializedGraph.meta.generatedAt.split('T')[0]}*`); lines.push(''); @@ -446,6 +474,9 @@ export function generateGraphIndex(serializedGraph) { const exports = {}; for (const [path, info] of Object.entries(serializedGraph.files)) { for (const exp of (info.exports || [])) { + // Skip synthetic re-export markers (e.g. "re-export:./foo") — they are + // structural edges, not identifiers a user would mention. + if (typeof exp === 'string' && exp.startsWith('re-export:')) continue; // Skip very short or generic exports (1-2 chars like 'x', 'a') if (exp.length > 2) { if (!exports[exp]) { diff --git a/src/lib/impact.js b/src/lib/impact.js index a2c518c..5b93737 100644 --- a/src/lib/impact.js +++ b/src/lib/impact.js @@ -68,7 +68,7 @@ export function summarizeTarget(repoPath, target, scan, graph, sourceState, conf ...skillFiles.map(skill => skill.path), ]); const domainCoverage = computeDomainCoverage(scan.domains, skillFiles); - const hubCoverage = computeHubCoverage(topHubs, contextText); + const hubCoverage = computeHubCoverage(topHubs, contextText, { repoPath, target }); const drift = computeDrift(sourceState, lastUpdated, scan.domains); const status = computeTargetStatus({ instructionExists, @@ -150,16 +150,62 @@ export function computeDomainCoverage(domains, skills) { }; } -export function computeHubCoverage(hubPaths, contextText) { - const haystack = (contextText || '').toLowerCase(); +/** + * Hub coverage check (Phase 4: relocated from CLAUDE.md to code-map). + * + * Phase 1 removed the `## Key Files` block from CLAUDE.md/AGENTS.md, so the + * old check (look for hub paths in `contextText` built from CLAUDE.md + base + * skill) always reported 0/N. Hub coverage is now delegated to code-map: + * - Claude target: `.claude/code-map.md` + * - Codex target: `.agents/skills/architecture/references/code-map.md` + * + * If the relevant code-map file is missing, we report a single signal + * (`codeMapMissing: true`) rather than spurious "missing hub" warnings. + * + * @param {string[]} hubPaths + * @param {string} contextText - kept as a fallback haystack + * @param {{ repoPath?: string, target?: object }} [opts] + */ +export function computeHubCoverage(hubPaths, contextText, opts = {}) { + const { repoPath, target } = opts; + const total = hubPaths?.length || 0; + + let codeMapText = null; + let codeMapMissing = false; + if (repoPath && target) { + const codeMapPath = resolveCodeMapPath(repoPath, target); + if (codeMapPath && existsSync(codeMapPath)) { + try { + codeMapText = readFileSync(codeMapPath, 'utf8'); + } catch { codeMapText = null; } + } else { + codeMapMissing = true; + } + } + + // Code-map is the canonical surface for hub paths post-Phase 1. + // If it's present, that's our haystack. Otherwise (or if no repoPath was + // passed — older callers/tests), fall back to the old contextText behavior. + const haystack = (codeMapText ?? contextText ?? '').toLowerCase(); const mentioned = (hubPaths || []).filter(path => haystack.includes(path.toLowerCase())); + return { mentioned: mentioned.length, - total: hubPaths?.length || 0, + total, paths: mentioned, + codeMapMissing, }; } +function resolveCodeMapPath(repoPath, target) { + // Codex puts the code-map under the architecture skill's references dir; + // Claude has a top-level `.claude/code-map.md`. + if (target?.id === 'codex') { + return join(repoPath, '.agents', 'skills', 'architecture', 'references', 'code-map.md'); + } + return join(repoPath, '.claude', 'code-map.md'); +} + export function computeHealthScore(input, target) { let score = 100; @@ -355,6 +401,17 @@ export function summarizeOpportunities(repoPath, targets, config = null) { }); } + // Phase 6: warn when an agent's `skills:` frontmatter references a missing + // skill file. This catches manually-broken agents and stale upgrades. + const brokenAgentSkillRefs = checkAgentSkillReferences(repoPath); + for (const ref of brokenAgentSkillRefs) { + opportunities.push({ + kind: 'agent-skill-refs', + message: `${ref.agent}: skills [${ref.missing.join(', ')}] not found in .claude/skills/`, + command: `aspens doc init # generate the missing skill, or remove the frontmatter ref`, + }); + } + return opportunities; } @@ -436,8 +493,25 @@ export function summarizeMissing(targets) { }); } + // Phase 4: code-map presence is the single signal we surface when missing. + // Hub-coverage warnings only fire when the code-map exists but doesn't list + // the hubs (which usually means the code-map is stale and a `doc graph` + // would fix it). + const codeMapMissingTargets = targets.filter(target => target.hubCoverage?.codeMapMissing); + if (codeMapMissingTargets.length > 0) { + items.push({ + kind: 'code-map-missing', + severity: 'low', + message: `code-map missing — run \`aspens doc graph\` (${codeMapMissingTargets.map(t => t.label).join(', ')})`, + }); + } + const weakRootContext = targets - .filter(target => target.hubCoverage?.total > 0 && target.hubCoverage.mentioned < target.hubCoverage.total) + .filter(target => + target.hubCoverage?.total > 0 && + target.hubCoverage.mentioned < target.hubCoverage.total && + !target.hubCoverage.codeMapMissing + ) .map(target => ({ label: target.label, missing: target.hubCoverage.total - target.hubCoverage.mentioned, @@ -447,7 +521,7 @@ export function summarizeMissing(targets) { kind: 'root-context', severity: 'low', message: weakRootContext - .map(item => `${item.label} is missing ${item.missing} top hub file${item.missing === 1 ? '' : 's'} from root context`) + .map(item => `${item.label} is missing ${item.missing} top hub file${item.missing === 1 ? '' : 's'} from code-map`) .join(' | '), }); } @@ -466,6 +540,48 @@ export function summarizeMissing(targets) { return items; } +/** + * Phase 6: scan `.claude/agents/*.md` for `skills: [name1, name2]` frontmatter + * lines and verify each named skill file exists at `.claude/skills//skill.md`. + * + * @param {string} repoPath + * @returns {Array<{agent: string, missing: string[]}>} + */ +export function checkAgentSkillReferences(repoPath) { + const agentsDir = join(repoPath, '.claude', 'agents'); + const skillsDir = join(repoPath, '.claude', 'skills'); + if (!existsSync(agentsDir)) return []; + + const broken = []; + let entries; + try { entries = readdirSync(agentsDir); } catch { return []; } + + for (const entry of entries) { + if (!entry.endsWith('.md')) continue; + const fullPath = join(agentsDir, entry); + let content; + try { content = readFileSync(fullPath, 'utf8'); } catch { continue; } + + const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); + if (!fmMatch) continue; + const skillsMatch = fmMatch[1].match(/^skills:\s*\[([^\]]*)\]/m); + if (!skillsMatch) continue; + + const declared = skillsMatch[1] + .split(',') + .map(s => s.trim()) + .filter(Boolean); + const missing = declared.filter(name => + !existsSync(join(skillsDir, name, 'skill.md')) + ); + if (missing.length > 0) { + broken.push({ agent: entry, missing }); + } + } + + return broken; +} + export function evaluateSaveTokensHealth(repoPath, saveTokensConfig = null) { const configured = !!saveTokensConfig?.enabled && saveTokensConfig?.claude?.enabled !== false; const settingsPath = join(repoPath, '.claude', 'settings.json'); diff --git a/src/lib/parsers/python.js b/src/lib/parsers/python.js new file mode 100644 index 0000000..9648d50 --- /dev/null +++ b/src/lib/parsers/python.js @@ -0,0 +1,72 @@ +/** + * Python parser — extracts top-level imports and exports (def/class). + * + * Regex-based by design (Q3): line-anchored, top-level only. SCREAMING_SNAKE + * constants are intentionally excluded — they rarely act as import targets and + * produce false positives that would flow into code-map. + */ + +const PY_FROM_IMPORT_RE = /^from\s+(\.+[\w.]*|[\w.]+)\s+import\s+/gm; +const PY_IMPORT_RE = /^import\s+([\w.]+)/gm; + +// Top-level only — no leading whitespace allowed. +const PY_DEF_RE = /^(?:async\s+)?def\s+([A-Za-z_]\w*)/gm; +const PY_CLASS_RE = /^class\s+([A-Za-z_]\w*)/gm; + +/** + * Strip triple-quoted strings to avoid matching imports/defs inside docstrings. + */ +function stripTripleQuoted(content) { + return content.replace(/('{3}|"{3})[\s\S]*?\1/g, ''); +} + +/** + * Extract top-level Python imports (both `import x` and `from x import y` forms). + * Relative imports (`.`, `..foo`) are returned verbatim — resolution happens upstream. + * + * @param {string} content + * @returns {string[]} + */ +export function parsePyImports(content) { + const imports = []; + const stripped = stripTripleQuoted(content); + let match; + + PY_FROM_IMPORT_RE.lastIndex = 0; + while ((match = PY_FROM_IMPORT_RE.exec(stripped)) !== null) { + imports.push(match[1]); + } + + PY_IMPORT_RE.lastIndex = 0; + while ((match = PY_IMPORT_RE.exec(stripped)) !== null) { + imports.push(match[1]); + } + + return imports; +} + +/** + * Extract top-level Python exports — function and class names defined at + * module scope. Methods inside classes are skipped (line-anchored regex + * rejects any leading whitespace). + * + * @param {string} content + * @returns {string[]} + */ +export function extractPythonExports(content) { + const names = new Set(); + const stripped = stripTripleQuoted(content); + let match; + + PY_DEF_RE.lastIndex = 0; + while ((match = PY_DEF_RE.exec(stripped)) !== null) { + names.add(match[1]); + } + + PY_CLASS_RE.lastIndex = 0; + while ((match = PY_CLASS_RE.exec(stripped)) !== null) { + names.add(match[1]); + } + + return [...names]; +} diff --git a/src/lib/parsers/typescript.js b/src/lib/parsers/typescript.js new file mode 100644 index 0000000..2729dcb --- /dev/null +++ b/src/lib/parsers/typescript.js @@ -0,0 +1,184 @@ +/** + * TypeScript / JavaScript parser — wraps es-module-lexer with two extensions: + * + * 1. Default-export name resolution: es-module-lexer reports `e.n === 'default'` + * but doesn't expose the source identifier. We post-pass for inline default + * exports (`export default function Foo`, `export default class Bar`, + * `export default Baz`). + * + * 2. `export * from '...'` re-exports: lexer reports them as exports with + * no useful name. We emit a `re-export from ` synthetic entry + * so downstream graph traversal can follow the edge. + * + * Known limitation: `const Foo = ...; export default Foo` (reassignment + * pattern) is detected only when the identifier is a single token — we then + * grep backward for `const|let|var Foo =` and capture that name. If neither + * pattern matches, the default export remains anonymous. + */ + +import { init, parse } from 'es-module-lexer'; + +const DEFAULT_EXPORT_INLINE_RE = + /export\s+default\s+(?:async\s+)?(?:function\s*\*?\s*([A-Za-z_$][\w$]*)|class\s+([A-Za-z_$][\w$]*)|([A-Za-z_$][\w$]*)\s*[;\n])/g; + +const REEXPORT_STAR_RE = /export\s*\*\s*(?:as\s+[A-Za-z_$][\w$]*\s+)?from\s*['"]([^'"]+)['"]/g; + +const VAR_DECL_RE_TEMPLATE = (name) => + new RegExp('(?:const|let|var)\\s+' + escapeRegex(name) + '\\s*[:=]', 'm'); + +function escapeRegex(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Parse a JS/TS file's imports + exports. + * Returns { imports: string[], exports: string[] }. + * + * `imports` contains import specifiers (e.g. './foo', '@/lib/bar', 'react'). + * `exports` contains exported names. Each `export * from ''` adds a + * synthetic entry of the form `re-export:` so callers can resolve the + * downstream edge. + * + * @param {string} content + * @param {string} relPath - for error reporting only + * @returns {Promise<{imports: string[], exports: string[]}>} + */ +export async function parseJsImports(content, _relPath) { + await init; + const result = { imports: [], exports: [] }; + let lexerSucceeded = false; + + try { + const [imports, exports] = parse(content); + lexerSucceeded = true; + + for (const imp of imports) { + if (imp.n) result.imports.push(imp.n); + } + + for (const exp of exports) { + if (!exp.n) continue; + if (exp.n === 'default') { + const resolved = resolveDefaultExportName(content); + if (resolved) { + result.exports.push(resolved); + } else { + result.exports.push('default'); + } + } else { + result.exports.push(exp.n); + } + } + } catch { + // es-module-lexer can't handle JSX in some files. Fall back to a + // regex-based scan below so we still get usable export names. + } + + if (!lexerSucceeded) { + fallbackRegexExtract(content, result); + } + + // `export * from` — synthetic re-export edges. + // (es-module-lexer already records the import on success; either way we + // add the synthetic marker so downstream code knows this is a re-export, + // not a first-class export.) + REEXPORT_STAR_RE.lastIndex = 0; + const importsSet = new Set(result.imports); + let m; + while ((m = REEXPORT_STAR_RE.exec(content)) !== null) { + const spec = m[1]; + result.exports.push(`re-export:${spec}`); + if (!importsSet.has(spec)) { + result.imports.push(spec); + importsSet.add(spec); + } + } + + return result; +} + +/** + * Best-effort regex extraction used only when es-module-lexer fails (e.g. + * JSX in a .tsx Next.js page). Catches `import ... from '...'`, `export + * default function/class/identifier`, and named `export const/function/class`. + * + * Less precise than the lexer (no scope analysis, no template-string handling) + * but lets graph coverage degrade gracefully on JSX-heavy code. + */ +const FALLBACK_IMPORT_RE = /^\s*import\s+(?:[\s\S]*?)\s+from\s+['"]([^'"]+)['"]/gm; +const FALLBACK_BARE_IMPORT_RE = /^\s*import\s+['"]([^'"]+)['"]/gm; +const FALLBACK_DEFAULT_FN_RE = /^\s*export\s+default\s+(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/m; +const FALLBACK_DEFAULT_CLASS_RE = /^\s*export\s+default\s+class\s+([A-Za-z_$][\w$]*)/m; +const FALLBACK_DEFAULT_IDENT_RE = /^\s*export\s+default\s+([A-Za-z_$][\w$]*)\s*;?\s*$/m; +const FALLBACK_NAMED_RE = /^\s*export\s+(?:async\s+)?(?:const|let|var|function\s*\*?|class|interface|type|enum)\s+([A-Za-z_$][\w$]*)/gm; +const FALLBACK_NAMED_LIST_RE = /^\s*export\s*\{([^}]+)\}/gm; + +function fallbackRegexExtract(content, result) { + let m; + + FALLBACK_IMPORT_RE.lastIndex = 0; + while ((m = FALLBACK_IMPORT_RE.exec(content)) !== null) { + result.imports.push(m[1]); + } + FALLBACK_BARE_IMPORT_RE.lastIndex = 0; + while ((m = FALLBACK_BARE_IMPORT_RE.exec(content)) !== null) { + if (!result.imports.includes(m[1])) result.imports.push(m[1]); + } + + FALLBACK_NAMED_RE.lastIndex = 0; + while ((m = FALLBACK_NAMED_RE.exec(content)) !== null) { + result.exports.push(m[1]); + } + + FALLBACK_NAMED_LIST_RE.lastIndex = 0; + while ((m = FALLBACK_NAMED_LIST_RE.exec(content)) !== null) { + for (const part of m[1].split(',')) { + const name = part.trim().split(/\s+as\s+/i).pop(); + if (name && /^[A-Za-z_$][\w$]*$/.test(name)) { + result.exports.push(name); + } + } + } + + const fnMatch = content.match(FALLBACK_DEFAULT_FN_RE); + if (fnMatch) { + result.exports.push(fnMatch[1]); + } else { + const classMatch = content.match(FALLBACK_DEFAULT_CLASS_RE); + if (classMatch) { + result.exports.push(classMatch[1]); + } else { + const identMatch = content.match(FALLBACK_DEFAULT_IDENT_RE); + if (identMatch) result.exports.push(identMatch[1]); + } + } +} + +/** + * Find the source identifier for a `default` export. + * Handles inline forms (`export default function Foo`, `export default class Bar`) + * directly. For the reassignment pattern (`const Foo = ...; export default Foo`) + * we capture the identifier from the trailing `export default ` and rely + * on its declaration appearing earlier in the file. + * + * Returns null if no name can be resolved. + */ +function resolveDefaultExportName(content) { + DEFAULT_EXPORT_INLINE_RE.lastIndex = 0; + let match; + while ((match = DEFAULT_EXPORT_INLINE_RE.exec(content)) !== null) { + // First capture group with content wins. + const name = match[1] || match[2] || match[3]; + if (!name) continue; + if (match[3]) { + // bare identifier — verify there's a declaration upstream so we don't + // pick up a reserved word or accidental match. + const before = content.slice(0, match.index); + if (VAR_DECL_RE_TEMPLATE(name).test(before)) return name; + // Not declared locally → still a real export name (could be re-exported) + return name; + } + return name; + } + return null; +} diff --git a/src/lib/path-resolver.js b/src/lib/path-resolver.js new file mode 100644 index 0000000..3c4e8a8 --- /dev/null +++ b/src/lib/path-resolver.js @@ -0,0 +1,195 @@ +/** + * Path-alias resolver for TypeScript / JavaScript projects. + * + * Reads `compilerOptions.paths` and `baseUrl` from `tsconfig.json` / + * `jsconfig.json`, follows `extends` chains (capped at 5 levels), and resolves + * aliased import specifiers (`@/components/Foo`, `~/utils`, etc.) to + * repo-relative file paths. + * + * Standalone module: `graph-builder.js` is the orchestrator and should not + * own path-resolution logic. + */ + +import { existsSync, readFileSync, readdirSync, statSync } from 'fs'; +import { join, dirname, extname, resolve, relative } from 'path'; + +const ALIAS_RESOLUTION_EXTS = ['.ts', '.tsx', '.js', '.jsx', '.mjs']; +const ALIAS_INDEX_EXTS = ['.ts', '.tsx', '.js', '.jsx']; + +/** + * Load path aliases from tsconfig.json / jsconfig.json. Walks the repo root, + * common subdirectories, and monorepo `apps/*` / `packages/*` for configs. + * + * @param {string} repoPath + * @returns {Array<{prefix: string, replacement: string}>} + */ +export function loadPathAliases(repoPath) { + const aliases = []; + + const configLocations = [repoPath]; + const subdirCandidates = ['frontend', 'web', 'client', 'app', 'src']; + for (const sub of subdirCandidates) { + const full = join(repoPath, sub); + if (existsSync(full) && safeIsDir(full)) { + configLocations.push(full); + } + } + for (const mono of ['apps', 'packages']) { + const monoDir = join(repoPath, mono); + if (!existsSync(monoDir)) continue; + try { + for (const entry of readdirSync(monoDir)) { + const full = join(monoDir, entry); + if (safeIsDir(full)) configLocations.push(full); + } + } catch { /* skip */ } + } + + for (const configDir of configLocations) { + for (const configName of ['tsconfig.json', 'jsconfig.json']) { + const configPath = join(configDir, configName); + if (!existsSync(configPath)) continue; + + try { + const merged = loadTsconfigWithExtends(configPath); + if (!merged) continue; + + const paths = merged?.compilerOptions?.paths; + const baseUrl = merged?.compilerOptions?.baseUrl || '.'; + + if (paths) { + for (const [pattern, targets] of Object.entries(paths)) { + if (!targets || targets.length === 0) continue; + const prefix = pattern.replace(/\*$/, ''); + const target = targets[0].replace(/\*$/, ''); + const replacement = join(configDir, baseUrl, target); + aliases.push({ prefix, replacement }); + } + } + } catch { /* malformed config — skip */ } + } + } + + return aliases; +} + +/** + * Recursively load a tsconfig and merge `compilerOptions.paths` / + * `compilerOptions.baseUrl` from `extends` parents. Capped at 5 levels. + * Child paths win on conflict. + * + * @param {string} configPath + * @param {number} depth + * @returns {object|null} + */ +export function loadTsconfigWithExtends(configPath, depth = 0) { + if (depth > 5) return null; + const content = readFileSafe(configPath); + if (!content) return null; + + let config; + try { + const stripped = content + .replace(/("(?:[^"\\]|\\.)*")|\/\/.*$/gm, (m, str) => str || '') + .replace(/("(?:[^"\\]|\\.)*")|\/\*[\s\S]*?\*\//g, (m, str) => str || '') + .replace(/,\s*([\]}])/g, '$1'); + config = JSON.parse(stripped); + } catch { + return null; + } + + const ext = config?.extends; + if (!ext) return config; + + const configDir = dirname(configPath); + const candidates = []; + if (ext.startsWith('.')) { + const candidate = resolve(configDir, ext); + candidates.push(candidate); + if (!extname(candidate)) candidates.push(candidate + '.json'); + } else { + const nm = join(configDir, 'node_modules', ext); + candidates.push(nm); + if (!extname(nm)) candidates.push(nm + '.json'); + } + + let parent = null; + for (const candidate of candidates) { + if (existsSync(candidate)) { + parent = loadTsconfigWithExtends(candidate, depth + 1); + if (parent) break; + } + } + if (!parent) return config; + + return { + ...parent, + ...config, + compilerOptions: { + ...(parent.compilerOptions || {}), + ...(config.compilerOptions || {}), + paths: { + ...(parent.compilerOptions?.paths || {}), + ...(config.compilerOptions?.paths || {}), + }, + }, + }; +} + +/** + * Resolve an aliased import (e.g. `@/components/Button`) to a repo-relative + * path. Tries the exact match, then known JS/TS extensions, then `/index` + * variants. + * + * @param {string} repoPath + * @param {string} specifier + * @param {Array<{prefix: string, replacement: string}>} aliases + * @returns {string|null} + */ +export function resolveAliasImport(repoPath, specifier, aliases) { + for (const { prefix, replacement } of aliases) { + if (!specifier.startsWith(prefix)) continue; + + const rest = specifier.slice(prefix.length); + const targetBase = join(replacement, rest); + + if (extname(rest)) { + if (existsSync(targetBase)) { + return relative(repoPath, targetBase); + } + continue; + } + + for (const ext of ALIAS_RESOLUTION_EXTS) { + const candidate = targetBase + ext; + if (existsSync(candidate)) { + return relative(repoPath, candidate); + } + } + + for (const ext of ALIAS_INDEX_EXTS) { + const candidate = join(targetBase, 'index' + ext); + if (existsSync(candidate)) { + return relative(repoPath, candidate); + } + } + } + + return null; +} + +function readFileSafe(filePath) { + try { + return readFileSync(filePath, 'utf8'); + } catch { + return null; + } +} + +function safeIsDir(p) { + try { + return statSync(p).isDirectory(); + } catch { + return false; + } +} diff --git a/src/lib/target-transform.js b/src/lib/target-transform.js index 194ecef..6dd1b73 100644 --- a/src/lib/target-transform.js +++ b/src/lib/target-transform.js @@ -7,6 +7,8 @@ import { join } from 'path'; import { readFileSync } from 'fs'; +import { TARGETS } from './target.js'; +import { CliError } from './errors.js'; export function transformForTarget(files, sourceTarget, destTarget, context) { if (sourceTarget.id === destTarget.id) return files; @@ -153,7 +155,7 @@ function buildRootInstructions(baseSkill, instructionsFile, domainSkills, graphS content = remapContentPaths(content, { instructionsFile: 'CLAUDE.md', skillsDir: '.claude/skills', skillFilename: 'skill.md', configDir: '.claude' }, destTarget); if (destTarget.id === 'codex') { content = sanitizeCodexInstructions(content); - content = syncCodexSkillsSection(content, baseSkill, domainSkills, destTarget, !!graphSerialized); + content = syncSkillsSection(content, baseSkill, domainSkills, destTarget, !!graphSerialized); } sections.push(content.trim()); } else if (baseSkill) { @@ -162,7 +164,7 @@ function buildRootInstructions(baseSkill, instructionsFile, domainSkills, graphS content = remapContentPaths(content, { instructionsFile: 'CLAUDE.md', skillsDir: '.claude/skills', skillFilename: 'skill.md', configDir: '.claude' }, destTarget); if (destTarget.id === 'codex') { content = sanitizeCodexInstructions(content); - content = syncCodexSkillsSection(content, baseSkill, domainSkills, destTarget, !!graphSerialized); + content = syncSkillsSection(content, baseSkill, domainSkills, destTarget, !!graphSerialized); } sections.push(content.trim()); } @@ -189,41 +191,63 @@ function buildRootInstructions(baseSkill, instructionsFile, domainSkills, graphS return result; } -export function ensureRootKeyFilesSection(content, graphSerialized) { - if (!content || !graphSerialized?.hubs?.length) return content; - - const section = buildHubFilesSection(graphSerialized); - if (!section) return content; +/** + * Stripped in Phase 1: stability. Previously injected a `## Key Files` hub-count + * block into CLAUDE.md/AGENTS.md after LLM generation. That post-processing is + * now removed — hub-counts/rankings live only in code-map and graph metadata. + * + * Function kept for API stability (callers were updated in the same commit) but + * is now an identity transform that also removes legacy hub blocks if present. + * + * @param {string} content + * @returns {string} + */ +export function ensureRootKeyFilesSection(content /*, graphSerialized */) { + if (!content) return content; + // Strip any legacy `## Key Files` block left over from older versions. + const legacyHubBlockRegex = /\n## Key Files\s*\n[\s\S]*?(?=\n## |\n\*\*Last Updated|$)/; + return content.replace(legacyHubBlockRegex, '\n').replace(/(\n){3,}/g, '\n\n'); +} - const trimmed = content.trimEnd(); - const keyFilesSectionRegex = /## Key Files\s*\n[\s\S]*?(?=\n## |\n\*\*Last Updated|$)/; +const BEHAVIOR_RULES = [ + '- **Verify before claiming** — Never state that something is configured, running, scheduled, or complete without confirming it first. If you haven\'t verified it in this session, say so rather than assuming.', + '- **Make sure code is running** — If you suggest code changes, ensure the code is running and tested before claiming the task is done.', + '- **Ask clarifying questions** — If the task is ambiguous, ask for clarification rather than making assumptions. Don\'t imply or guess at requirements or constraints that aren\'t explicitly stated.', + '- **Simplicity first** — Write the minimum code that solves the problem. No speculative features, abstractions for single-use code, or error handling for impossible scenarios.', + '- **Surgical changes** — Touch only what the task requires. Don\'t refactor adjacent code, fix unrelated formatting, or "improve" things that aren\'t broken.', +]; - if (keyFilesSectionRegex.test(trimmed)) { - return trimmed.replace(keyFilesSectionRegex, section).replace(/(\n){3,}/g, '\n\n') + '\n'; +/** + * Deterministically inject/replace the `## Behavior` section in a root instructions + * file so the same coding guardrails ship with every generated CLAUDE.md/AGENTS.md. + */ +export function syncBehaviorSection(content) { + if (!content) return content; + const section = ['## Behavior', '', ...BEHAVIOR_RULES].join('\n'); + if (/## Behavior\s*\n/i.test(content)) { + return content.replace(/## Behavior\s*\n[\s\S]*?(?=\n## |\n\*\*Last Updated|$)/, section + '\n'); } - const behaviorIndex = trimmed.search(/\n## Behavior\b/); - const lastUpdatedIndex = trimmed.search(/\n\*\*Last Updated\b/); - const insertAt = behaviorIndex >= 0 - ? behaviorIndex - : lastUpdatedIndex >= 0 - ? lastUpdatedIndex - : trimmed.length; - - const before = trimmed.slice(0, insertAt).trimEnd(); - const after = trimmed.slice(insertAt).trimStart(); + const lastUpdatedMatch = content.match(/\n\*\*Last Updated[^\n]*/); + if (lastUpdatedMatch) { + const idx = lastUpdatedMatch.index; + return content.slice(0, idx).trimEnd() + '\n\n' + section + '\n' + content.slice(idx); + } - return ( - before + - '\n\n' + - section + - (after ? '\n\n' + after : '') + - '\n' - ).replace(/(\n){3,}/g, '\n\n'); + return content.trimEnd() + '\n\n' + section + '\n'; } -function syncCodexSkillsSection(content, baseSkill, domainSkills, destTarget, hasGraph = false) { - const skillRefs = buildCodexSkillRefs(baseSkill, domainSkills, destTarget, hasGraph); +/** + * Deterministically inject/replace the `## Skills` section in a root instructions + * file (CLAUDE.md or AGENTS.md) so it always lists every generated skill. + * + * Caller is responsible for picking the right `destTarget` (paths + filename) and + * whether to advertise the architecture skill ref (`hasArchitectureSkill`). The + * architecture skill file is only written for Codex today, so Claude callers + * should pass `false`. + */ +export function syncSkillsSection(content, baseSkill, domainSkills, destTarget, hasArchitectureSkill = false) { + const skillRefs = buildSkillRefs(baseSkill, domainSkills, destTarget, hasArchitectureSkill); if (skillRefs.length === 0) return content; const section = ['## Skills', '', ...skillRefs].join('\n'); @@ -238,7 +262,7 @@ function syncCodexSkillsSection(content, baseSkill, domainSkills, destTarget, ha return content.slice(0, insertAt) + '\n' + section + '\n\n' + content.slice(insertAt).trimStart(); } -function buildCodexSkillRefs(baseSkill, domainSkills, destTarget, hasGraph = false) { +function buildSkillRefs(baseSkill, domainSkills, destTarget, hasArchitectureSkill = false) { const refs = []; if (baseSkill) { @@ -253,24 +277,12 @@ function buildCodexSkillRefs(baseSkill, domainSkills, destTarget, hasGraph = fal refs.push('- `' + join(destTarget.skillsDir, domainName, destTarget.skillFilename) + '`' + suffix); } - if (hasGraph) { + if (hasArchitectureSkill) { refs.push('- `' + join(destTarget.skillsDir, 'architecture', destTarget.skillFilename) + '` — Import graph and code-map reference for structural changes.'); } return refs; } -function buildHubFilesSection(serializedGraph) { - if (!serializedGraph?.hubs?.length) return null; - - const lines = ['## Key Files', '', '**Hub files (most depended-on):**']; - for (const hub of serializedGraph.hubs.slice(0, 5)) { - lines.push('- `' + hub.path + '` - ' + hub.fanIn + ' dependents'); - } - lines.push(''); - - return lines.join('\n'); -} - function extractFrontmatterField(content, field) { const match = content.match(new RegExp('^' + escapeRegex(field) + ':\\s*(.+)$', 'm')); return match ? match[1].trim() : ''; @@ -279,15 +291,9 @@ function extractFrontmatterField(content, field) { function generateCondensedCodeMap(serializedGraph) { const lines = []; - if (serializedGraph?.hubs?.length > 0) { - lines.push('## Key Files'); - lines.push(''); - lines.push('**Hub files (most depended-on):**'); - for (const hub of serializedGraph.hubs.slice(0, 5)) { - lines.push('- `' + hub.path + '` - ' + hub.fanIn + ' dependents'); - } - lines.push(''); - } + // Hub-files block intentionally removed — Phase 1: stability. + // Hub counts/rankings no longer flow into AGENTS.md/CLAUDE.md; + // they remain available via graph metadata + code-map. if (serializedGraph?.clusters?.length > 0) { const multiFileClusters = serializedGraph.clusters.filter(cluster => cluster.size > 1); @@ -318,9 +324,40 @@ function generateCondensedCodeMap(serializedGraph) { lines.push(''); } + // Phase 3 — Codex parity for framework entry points (Next.js implicit roots) + const frameworkSection = condenseFrameworkEntryPoints(serializedGraph); + if (frameworkSection) { + lines.push(frameworkSection); + } + return lines.length > 0 ? lines.join('\n') : null; } +function condenseFrameworkEntryPoints(serializedGraph) { + const entries = serializedGraph?.frameworkEntryPoints; + if (!Array.isArray(entries) || entries.length === 0) return ''; + + const grouped = new Map(); + for (const entry of entries) { + const list = grouped.get(entry.kind) || []; + list.push(entry); + grouped.set(entry.kind, list); + } + + const out = []; + for (const [kind, items] of grouped) { + out.push('**Framework entry points (' + kind + '):**'); + for (const item of items.slice(0, 10)) { + out.push('- `' + item.path + '`'); + } + if (items.length > 10) { + out.push('- ... +' + (items.length - 10) + ' more'); + } + out.push(''); + } + return out.join('\n'); +} + function shortPath(filePath) { const parts = filePath.split('/'); return parts.length > 3 ? parts.slice(-3).join('/') : filePath; @@ -499,6 +536,108 @@ function escapeRegex(str) { return result; } +/** + * Map a Claude-format path (CLAUDE.md, .claude/skills//skill.md) to the + * corresponding path under another target. Returns the input unchanged when + * the destination target is Claude itself, or `null` when the path doesn't + * correspond to a known target slot. + * + * @param {string} targetId — destination target id ('claude' | 'codex') + * @param {string} claudePath — source path in Claude format + * @returns {string|null} + */ +export function transformPathForTarget(targetId, claudePath) { + const claude = TARGETS.claude; + const dest = TARGETS[targetId]; + if (!dest) return null; + if (dest.id === claude.id) return claudePath; + + if (claudePath === claude.instructionsFile) return dest.instructionsFile; + + const skillsPrefix = claude.skillsDir + '/'; + if (claudePath.startsWith(skillsPrefix)) { + const rest = claudePath.slice(skillsPrefix.length); + const parts = rest.split('/'); + if (parts.length >= 2) { + const domain = parts[0]; + return join(dest.skillsDir, domain, dest.skillFilename); + } + } + + return null; +} + +/** + * Logical role of a published file under a given target — used by parity checks + * so we compare logical slots (root-instructions, per-domain skill) rather than + * raw paths that differ across targets. + * + * Returns null for codex-only derived files (directory-scoped AGENTS.md inside + * a domain dir): those have no Claude counterpart by design. + */ +function logicalKeyForFile(filePath, target) { + if (filePath === target.instructionsFile) return 'INSTRUCTIONS'; + + const skillsPrefix = (target.skillsDir || '') + '/'; + if (target.skillsDir && filePath.startsWith(skillsPrefix)) { + const rest = filePath.slice(skillsPrefix.length); + const domain = rest.split('/')[0]; + return `SKILL:${domain}`; + } + + // Codex-only directory-scoped AGENTS.md inside a domain dir (not the root) + if ( + target.directoryDocFile && + filePath.endsWith('/' + target.directoryDocFile) && + filePath !== target.directoryDocFile + ) { + return null; + } + + return `OTHER:${filePath}`; +} + +/** + * Phase 4 parity validator. Asserts that every configured target publishes the + * same set of logical files (root instructions + per-domain skills). Codex + * directory-scoped AGENTS.md files are excluded from the check — they have no + * Claude counterpart by design. + * + * @param {Map>} perTargetMap + * @throws {CliError} when targets diverge + */ +export function assertTargetParity(perTargetMap) { + const targetIds = [...perTargetMap.keys()]; + if (targetIds.length < 2) return; + + const keysByTarget = new Map(); + for (const targetId of targetIds) { + const target = TARGETS[targetId]; + if (!target) continue; + const keys = new Set(); + for (const file of perTargetMap.get(targetId) || []) { + const key = logicalKeyForFile(file.path, target); + if (key) keys.add(key); + } + keysByTarget.set(targetId, keys); + } + + const [firstId, ...restIds] = targetIds; + const firstKeys = keysByTarget.get(firstId) || new Set(); + + for (const otherId of restIds) { + const otherKeys = keysByTarget.get(otherId) || new Set(); + const missingInOther = [...firstKeys].filter(k => !otherKeys.has(k)); + const missingInFirst = [...otherKeys].filter(k => !firstKeys.has(k)); + if (missingInOther.length === 0 && missingInFirst.length === 0) continue; + + const lines = [`Target parity violation between '${firstId}' and '${otherId}':`]; + if (missingInOther.length) lines.push(` Missing in ${otherId}: ${missingInOther.join(', ')}`); + if (missingInFirst.length) lines.push(` Missing in ${firstId}: ${missingInFirst.join(', ')}`); + throw new CliError(lines.join('\n')); + } +} + export function validateTransformedFiles(files) { const issues = []; diff --git a/src/prompts/customize-agents.md b/src/prompts/customize-agents.md index bf7485e..fe56ab7 100644 --- a/src/prompts/customize-agents.md +++ b/src/prompts/customize-agents.md @@ -3,9 +3,15 @@ Inject project-specific context into a generic agent definition. Read the projec - **Tech stack** line after the role statement - **Key Conventions** (3-5 project-specific bullets) - **Actual commands** (replace generic placeholders with real lint/test/build commands) -- **Actual guideline paths** (replace "check if exists" with real paths) -Keep it to 10-20 lines of additions. Do NOT rewrite the agent's core logic, remove instructions, change YAML frontmatter, or add business details. +Keep it to 10-20 lines of additions. Do NOT rewrite the agent's core logic and do NOT remove existing instructions. + +## Preservation rules (Phase 4 + Phase 6 — strict) + +- **Preserve YAML frontmatter verbatim.** Do NOT add, remove, reorder, or edit any frontmatter fields. In particular, do NOT add a `skills:` line — that is handled by the calling code post-LLM. +- **Preserve the `## Project context` block verbatim** if it is present in the input. It contains the conditional read instructions for code-map and domain skills. Do not rephrase, reorder its bullets, or fold it into another section. +- Skills (`.claude/skills/**`) are the single source of truth for project context — do not invent other context directories. +- Do NOT introduce file inventories, hub-count rankings, dependency tallies, or "most-depended-on" lists. The graph hook supplies that dynamically. ## Output format diff --git a/src/prompts/doc-init-claudemd.md b/src/prompts/doc-init-claudemd.md index 7094155..6d80ab0 100644 --- a/src/prompts/doc-init-claudemd.md +++ b/src/prompts/doc-init-claudemd.md @@ -1,8 +1,12 @@ Generate the root project instructions file at `{{instructionsFile}}`. Keep it concise since it is loaded frequently. +{{preservation-contract}} + ## Your task -From the scan results and generated skills, create the root project instructions file covering: repo summary + tech stack, available skills, key commands (dev/test/lint), critical conventions, and when graph data is provided, a short `## Key Files` section surfacing the top hub files. +From the scan results and generated skills, create the root project instructions file covering: repo summary + tech stack, key commands (dev/test/lint), and critical conventions. + +**Do NOT generate a `## Skills` section.** aspens injects it deterministically after your output, listing every generated skill. If you write one it will be overwritten. ## Output format @@ -15,10 +19,8 @@ Return exactly one file: ## Rules 1. Keep it concise — this file is loaded often, so shorter is better. -2. Reference skills by their path (e.g., `{{skillsDir}}/billing/{{skillFilename}}`). +2. Do NOT emit a `## Skills` section. aspens injects the full skill list deterministically; anything you write will be overwritten. 3. Include actual commands from the scan data, not placeholders. -4. Do NOT duplicate what's already in the skills — just reference them. -5. Always include a `## Behavior` section with these rules verbatim: - - **Verify before claiming** — Never state that something is configured, running, scheduled, or complete without confirming it first. If you haven't verified it in this session, say so rather than assuming. - - **Make sure code is running** — If you suggest code changes, ensure the code is running and tested before claiming the task is done. -6. If hub files are provided in the prompt, include a concise `## Key Files` section that mentions them explicitly by path. +4. Do NOT duplicate what's already in the skills — just reference them by name in prose where useful. +5. Do NOT emit a `## Behavior` section — aspens injects a fixed set of coding guardrails deterministically. Anything you write will be overwritten. +6. **Do NOT emit file counts, hub lists, dependency tallies, or "most-depended-on" rankings.** The graph hook supplies these dynamically at prompt-injection time. Counts/percentages/file totals/hub rankings/dependency version bumps belong in code-map.md and graph metadata, not in `{{instructionsFile}}`. diff --git a/src/prompts/doc-init-domain.md b/src/prompts/doc-init-domain.md index 14a3c4c..be7802f 100644 --- a/src/prompts/doc-init-domain.md +++ b/src/prompts/doc-init-domain.md @@ -1,5 +1,7 @@ Generate ONE skill file for the **{{domainName}}** domain. Use Read/Glob/Grep to explore the actual source files before writing. +{{preservation-contract}} + {{skill-format}} ## Your task @@ -23,3 +25,9 @@ Return exactly one file wrapped in XML tags: 5. **Critical rules matter most.** What breaks if done wrong? 6. Do NOT include product-specific details you're guessing at. Only what you verified by reading code. 7. If there isn't enough substance for a meaningful skill, return an empty response instead of generating filler. +8. **Lead with what this domain DOES for the business** and what rules apply that aren't obvious from the code. Examples: + - "Stripe subscriptions cancel at period end, never immediately." + - "Auth callback must redirect to onboarding if profile is incomplete." + - "Course enrollment is idempotent — repeat POSTs return the same enrollment, no duplicate side effects." + - "Refund window is 14 days from charge date, then locked." +9. **Forbidden:** file inventories, import lists, line counts, hub names, dependency tallies, "most depended-on" rankings — the graph supplies these dynamically. Skills are about WHAT the code does for the business and WHY, not metadata about the code. diff --git a/src/prompts/doc-init.md b/src/prompts/doc-init.md index c7cdfe8..f25aa4b 100644 --- a/src/prompts/doc-init.md +++ b/src/prompts/doc-init.md @@ -1,5 +1,7 @@ Generate concise project context docs for a codebase. Use your tools (Read, Glob, Grep) to explore the actual code before writing anything. +{{preservation-contract}} + {{skill-format}} {{examples}} @@ -29,7 +31,7 @@ description: ... -[{{instructionsFile}} content referencing all skills] +[{{instructionsFile}} content — do NOT include a `## Skills` section; aspens injects it deterministically] **Important:** Use `` and `` tags exactly as shown. Content between tags is written verbatim. Code blocks inside skills are fine — they won't break the parsing. @@ -42,7 +44,8 @@ description: ... 4. **Non-obvious knowledge only.** Don't explain what the framework is. Explain how THIS project uses it. 5. **Critical rules matter most.** What breaks if done wrong? What conventions are enforced? 6. Do NOT generate skills for areas with insufficient information. Fewer high-quality skills beat many shallow ones. -7. Do NOT reference guidelines that don't exist. Only link to files actually present in the repo. +7. Skills (`.claude/skills/**`) are the single source of truth for project context — do not invent other context directories. 8. Do NOT include product-specific details you're guessing at. Only what you verified by reading code. 9. Include actual dev/build/test commands from package.json scripts, Makefile, etc. 10. **Read before writing.** Always read the actual source files for a domain before generating its skill. Do not rely solely on the scan results. +11. **Do NOT emit file counts, hub lists, dependency tallies, or "most-depended-on" rankings** in skills or in `{{instructionsFile}}`. The graph hook supplies these dynamically at prompt-injection time. Counts/percentages/file totals/hub rankings/dependency version bumps belong in code-map.md and graph metadata. diff --git a/src/prompts/doc-sync-refresh.md b/src/prompts/doc-sync-refresh.md index 4573ea2..32ea324 100644 --- a/src/prompts/doc-sync-refresh.md +++ b/src/prompts/doc-sync-refresh.md @@ -1,5 +1,7 @@ Refresh an existing **skill file** to match the current codebase. Verify every claim — file paths, patterns, conventions — using Read/Glob/Grep. Fix stale references, add missing coverage. +{{preservation-contract-refresh}} + {{skill-format}} ## Your task @@ -28,3 +30,4 @@ Return ONLY the files that need updating, wrapped in XML tags: 5. **Update timestamps.** Change `Last Updated` to today's date on any skill you modify. 6. **Keep skills concise.** 30-60 lines. Every line earns its place. 7. **Don't fabricate.** Only document what you can verify exists in the codebase right now. +8. **Update-trigger allowlist (refresh mode).** Same allowlist as `doc-sync`: only changes to exported symbols, files, public-boundary signatures, frameworks, or major feature areas warrant edits. Refresh mode has no diff to anchor "removed" — instead, remove a line ONLY if its referenced file/export no longer exists in the current codebase (verify by reading or grepping). Stat-style content (counts, hub rankings, dependency tallies, freshness timestamps) is OUT OF SCOPE for refresh — never add, never update. diff --git a/src/prompts/doc-sync.md b/src/prompts/doc-sync.md index 4235d38..a6c7c23 100644 --- a/src/prompts/doc-sync.md +++ b/src/prompts/doc-sync.md @@ -1,5 +1,7 @@ Update existing **skill files** based on a git diff. If the diff is truncated, use Read to get full file contents. +{{preservation-contract}} + {{skill-format}} ## Your task @@ -31,5 +33,5 @@ Return ONLY the files that need updating, wrapped in XML tags: 3. **Be minimal.** Only update what the diff affects. A change to billing code should not trigger updates to the auth skill. 4. **Update timestamps.** Change `Last Updated` to today's date on any skill you modify. 5. **Be specific.** Reference actual file paths and patterns from the diff and codebase. -6. **Skip trivial changes.** Typo fixes, comment changes, import reordering — these don't warrant skill updates. +6. **Update-trigger allowlist (strict).** Trigger an update ONLY for: new/removed exported symbol; new/removed file; changed function signature in a public boundary; new framework or major dependency adopted; new feature area introduced. Counts, percentages, file totals, hub rankings, freshness timestamps, dependency version bumps, and reordered import lines MUST NOT trigger updates. Typo fixes, comment-only edits, formatting-only changes, and pure whitespace changes also MUST NOT trigger updates. 7. If a diff adds a completely new feature area with significant code, create a new domain skill for it. diff --git a/src/prompts/partials/examples.md b/src/prompts/partials/examples.md index 23978b6..6788a09 100644 --- a/src/prompts/partials/examples.md +++ b/src/prompts/partials/examples.md @@ -33,9 +33,6 @@ You are working on **billing, Stripe integration, and usage limits**. - Webhook endpoint has NO JWT auth — Stripe signature verification only - Cancel = `cancel_at_period_end=True` (access until period end) -## References -- **Patterns:** `{{configDir}}/guidelines/billing/patterns.md` - --- **Last Updated:** 2026-03-18 ``` diff --git a/src/prompts/partials/guideline-format.md b/src/prompts/partials/guideline-format.md deleted file mode 100644 index 83d3f5d..0000000 --- a/src/prompts/partials/guideline-format.md +++ /dev/null @@ -1,36 +0,0 @@ -## Guideline File Format - -A guideline is a longer reference document in `{{configDir}}/guidelines/{name}.md`. Guidelines are NOT auto-triggered — they're linked from skills when deeper context is needed. - -### Format - -```markdown ---- -name: [guideline-name] -description: [One-line description] ---- - -# [Title] - -## [Section 1] -[Detailed patterns, code examples, data flows] - -## [Section 2] -[More detail] - ---- -**Last Updated:** [DATE] -``` - -### When to create a guideline - -- When a domain skill needs more than 60 lines of context -- For cross-cutting concerns (error handling, testing patterns, architecture) -- For detailed code examples that don't fit in a skill - -### Rules - -1. **150-500 lines.** Detailed but focused. -2. **Always linked from a skill.** A guideline without a referencing skill won't be discovered. -3. **Code examples are valuable.** Show actual patterns from the codebase. -4. **Only create when needed.** Most repos need skills, not guidelines. diff --git a/src/prompts/partials/preservation-contract-refresh.md b/src/prompts/partials/preservation-contract-refresh.md new file mode 100644 index 0000000..ea6a120 --- /dev/null +++ b/src/prompts/partials/preservation-contract-refresh.md @@ -0,0 +1,7 @@ +## Preservation contract — refresh mode + +You may NEVER delete an existing line of instructions unless the referenced file, function, class, or feature no longer exists in the current codebase. Verify by reading the file or grepping for the symbol before deleting. If you cannot confirm absence, keep the line. + +- Never reword for style. Never reorder lists. Never restructure sections. Never "clean up" or "consolidate" content. +- Hand-written tone, voice, and idiosyncratic phrasing must be preserved verbatim outside the specific words being updated. +- Stat-style content (counts, hub rankings, dependency tallies, freshness timestamps) is OUT OF SCOPE for refresh — never add, never update. diff --git a/src/prompts/partials/preservation-contract.md b/src/prompts/partials/preservation-contract.md new file mode 100644 index 0000000..636f1f0 --- /dev/null +++ b/src/prompts/partials/preservation-contract.md @@ -0,0 +1,10 @@ +## Preservation contract — strictly enforced + +You may NEVER delete an existing line of instructions, business rule, convention, or note unless the diff PROVES the underlying code/feature/file is gone. The diff is your only evidence. + +- If functionality CHANGED: keep the line, update the wording (e.g., "tutor now reads X" → "tutor now reads Y"). +- If functionality was ADDED: add a line. Do not rewrite the surrounding section. +- If functionality was REMOVED (file/export/feature explicitly deleted in the diff): delete only the lines that referenced it. Leave the rest untouched. +- If you are NOT SURE whether something is gone: keep the line. Default to preservation. +- Never reword for style. Never reorder lists. Never restructure sections. Never "clean up" or "consolidate" content. +- Hand-written tone, voice, and idiosyncratic phrasing must be preserved verbatim outside the specific words being updated. diff --git a/src/prompts/partials/skill-format.md b/src/prompts/partials/skill-format.md index f7cbbb2..34ad1ef 100644 --- a/src/prompts/partials/skill-format.md +++ b/src/prompts/partials/skill-format.md @@ -53,18 +53,23 @@ Keywords: keyword1, keyword2 You are working on **[domain description]**. -## Key Files -- `[file]` — [what it does] +## Domain purpose +[One paragraph: what this domain DOES for the business and what users/systems rely on it.] -## Key Concepts -- **[Concept]:** [Brief explanation] +## Business rules / invariants +- [Concrete rule that must always hold — e.g. "Stripe subscriptions cancel at period end, never immediately"] +- [Authorization/data invariant — e.g. "Only the request owner or staff can mutate this resource"] + +## Non-obvious behaviors +- [Behavior the code wouldn't reveal at a glance — e.g. "Auth callback redirects to onboarding if profile is incomplete"] +- [Edge case the implementation handles silently] + +## Critical files (purpose, not inventory) +- `[file]` — [what role it plays in the domain — not "exports X, Y, Z"] ## Critical Rules - [Rule that would break things if violated] -## References -- **Patterns:** `{{configDir}}/guidelines/{domain}/patterns.md` - --- **Last Updated:** [DATE] ``` @@ -75,4 +80,4 @@ You are working on **[domain description]**. - Be specific: real file paths, real commands, real patterns. - Non-obvious knowledge only — don't explain the framework, explain THIS project's usage. - Activation: file patterns as `- \`glob\`` lines; `Keywords:` comma-separated. Base skill uses "always loads" sentence instead. -- References section required on domain skills — bold label + backtick path to guideline files. +- **Lead with business behavior, not file inventory.** Forbidden in skills: file counts, hub names, dependency tallies, line counts, "most depended on" rankings — the graph supplies these dynamically. Skills are about WHAT the code does for the business and WHY, not metadata about the code. diff --git a/src/templates/agents/auto-error-resolver.md b/src/templates/agents/auto-error-resolver.md index dcf71cd..d80a004 100644 --- a/src/templates/agents/auto-error-resolver.md +++ b/src/templates/agents/auto-error-resolver.md @@ -9,9 +9,13 @@ You systematically identify, analyze, and fix errors — compilation errors, bui > **Brevity rule:** Minimize output. Show what you did, not what you thought about. Actions over explanations. -**Context (read on-demand):** -- Check CLAUDE.md and `.claude/skills/` for project conventions and commands -- Check `.claude/guidelines/` if it exists for error handling and architecture patterns +## Project context + +Before responding: +- If your task touches architecture, hub files, framework entry points, or import structure, read `.claude/code-map.md`. +- If your task is scoped to a functional domain (auth, billing, courses, etc.), read `.claude/skills//skill.md` for that domain. +- If you have a base skill loaded via frontmatter, it covers stack and conventions. The conditional reads above cover specifics — fetch them when relevant. +- For lint/typecheck/build commands, check CLAUDE.md, package.json scripts, or Makefile. **How to Resolve Errors:** diff --git a/src/templates/agents/code-architecture-reviewer.md b/src/templates/agents/code-architecture-reviewer.md index 7f33edf..7f17777 100644 --- a/src/templates/agents/code-architecture-reviewer.md +++ b/src/templates/agents/code-architecture-reviewer.md @@ -9,10 +9,13 @@ You are a senior code reviewer. You examine code for quality, architectural cons > **Brevity rule:** Minimize output. Show what you found, not what you checked. No preamble, no filler. -**Context (read on-demand, not all upfront):** -- Check CLAUDE.md and `.claude/skills/` for project conventions -- Check `.claude/guidelines/` if it exists for architecture, error handling, testing patterns -- If reviewing a task with plans, check `dev/active/[task-name]/` for context +## Project context + +Before responding: +- If your task touches architecture, hub files, framework entry points, or import structure, read `.claude/code-map.md`. +- If your task is scoped to a functional domain (auth, billing, courses, etc.), read `.claude/skills//skill.md` for that domain. +- If you have a base skill loaded via frontmatter, it covers stack and conventions. The conditional reads above cover specifics — fetch them when relevant. +- If reviewing a task with plans, check `dev/active/[task-name]/` for context. **How to Review:** diff --git a/src/templates/agents/code-refactor-master.md b/src/templates/agents/code-refactor-master.md index e5d4a93..dad46ab 100644 --- a/src/templates/agents/code-refactor-master.md +++ b/src/templates/agents/code-refactor-master.md @@ -9,10 +9,13 @@ You execute refactoring systematically — reorganizing code, extracting compone > **Brevity rule:** Minimize output. Show what you changed, not what you considered. Actions over explanations. -**Context (read on-demand):** -- Check CLAUDE.md and `.claude/skills/` for project conventions -- Check `.claude/guidelines/` if it exists for architecture and testing patterns -- If a refactoring plan exists, check `dev/active/[task-name]/` for the plan +## Project context + +Before responding: +- If your task touches architecture, hub files, framework entry points, or import structure, read `.claude/code-map.md`. +- If your task is scoped to a functional domain (auth, billing, courses, etc.), read `.claude/skills//skill.md` for that domain. +- If you have a base skill loaded via frontmatter, it covers stack and conventions. The conditional reads above cover specifics — fetch them when relevant. +- If a refactoring plan exists, check `dev/active/[task-name]/` for the plan. **How to Refactor:** diff --git a/src/templates/agents/documentation-architect.md b/src/templates/agents/documentation-architect.md index 99e1113..83df6d3 100644 --- a/src/templates/agents/documentation-architect.md +++ b/src/templates/agents/documentation-architect.md @@ -9,9 +9,12 @@ You create concise, actionable documentation by reading the actual code first. N > **Brevity rule:** Minimize conversational output. Write docs directly to files. Report only what was created/updated and where. -**Context (read on-demand):** -- Check CLAUDE.md and `.claude/skills/` for project conventions -- Check `.claude/guidelines/` if it exists for documentation and architecture standards +## Project context + +Before responding: +- If your task touches architecture, hub files, framework entry points, or import structure, read `.claude/code-map.md`. +- If your task is scoped to a functional domain (auth, billing, courses, etc.), read `.claude/skills//skill.md` for that domain. +- If you have a base skill loaded via frontmatter, it covers stack and conventions. The conditional reads above cover specifics — fetch them when relevant. **How to Document:** diff --git a/src/templates/agents/execute.md b/src/templates/agents/execute.md index 619339e..4f6f518 100644 --- a/src/templates/agents/execute.md +++ b/src/templates/agents/execute.md @@ -9,6 +9,13 @@ You are an execution agent. You execute development plans created by the `plan` **Your job:** Read the plan, spawn executor subagents for each task, verify results, and ship. +## Project context + +Before responding: +- If your task touches architecture, hub files, framework entry points, or import structure, read `.claude/code-map.md`. +- If your task is scoped to a functional domain (auth, billing, courses, etc.), read `.claude/skills//skill.md` for that domain. +- If you have a base skill loaded via frontmatter, it covers stack and conventions. The conditional reads above cover specifics — fetch them when relevant. + ## Step 0 — Load plan 1. Find the plan: diff --git a/src/templates/agents/ghost-writer.md b/src/templates/agents/ghost-writer.md index 3b911fb..5e2fa98 100644 --- a/src/templates/agents/ghost-writer.md +++ b/src/templates/agents/ghost-writer.md @@ -9,6 +9,13 @@ You write content that sounds like a real person wrote it — not AI. Your job i > **Brevity rule:** Deliver the content, not commentary about the content. Minimal preamble. No meta-discussion unless asked. +## Project context + +Before responding: +- If your task touches architecture, hub files, framework entry points, or import structure, read `.claude/code-map.md`. +- If your task is scoped to a functional domain (auth, billing, courses, etc.), read `.claude/skills//skill.md` for that domain. +- If you have a base skill loaded via frontmatter, it covers stack and conventions. The conditional reads above cover specifics — fetch them when relevant. + **Your Strengths:** - Landing page copy that converts - Developer documentation that's actually helpful diff --git a/src/templates/agents/plan-reviewer.md b/src/templates/agents/plan-reviewer.md index b49f432..f547ea9 100644 --- a/src/templates/agents/plan-reviewer.md +++ b/src/templates/agents/plan-reviewer.md @@ -9,9 +9,12 @@ You review development plans to catch issues before implementation begins. Your > **Brevity rule:** Minimize output. State problems and gaps directly. No restating the plan back. -**Context (read on-demand, not all upfront):** -- Check CLAUDE.md and `.claude/skills/` for project conventions -- Check `.claude/guidelines/` if it exists for architecture and testing patterns +## Project context + +Before responding: +- If your task touches architecture, hub files, framework entry points, or import structure, read `.claude/code-map.md`. +- If your task is scoped to a functional domain (auth, billing, courses, etc.), read `.claude/skills//skill.md` for that domain. +- If you have a base skill loaded via frontmatter, it covers stack and conventions. The conditional reads above cover specifics — fetch them when relevant. **How to Review:** diff --git a/src/templates/agents/plan.md b/src/templates/agents/plan.md index 39ad9d4..3e676bd 100644 --- a/src/templates/agents/plan.md +++ b/src/templates/agents/plan.md @@ -9,6 +9,13 @@ You are a planning agent. You analyze codebases and create development plans. Yo **Your job:** Create a clear, phased plan and iterate on it with the user until they are satisfied. +## Project context + +Before responding: +- If your task touches architecture, hub files, framework entry points, or import structure, read `.claude/code-map.md`. +- If your task is scoped to a functional domain (auth, billing, courses, etc.), read `.claude/skills//skill.md` for that domain. +- If you have a base skill loaded via frontmatter, it covers stack and conventions. The conditional reads above cover specifics — fetch them when relevant. + ## Step 0 — Setup 1. Derive a short kebab-case task name from the user's request (e.g., `auth-refactor`, `add-webhooks`). diff --git a/src/templates/agents/refactor-planner.md b/src/templates/agents/refactor-planner.md index 1781b37..aa874e2 100644 --- a/src/templates/agents/refactor-planner.md +++ b/src/templates/agents/refactor-planner.md @@ -9,9 +9,12 @@ You analyze code structure and create detailed, phased refactoring plans. You pl > **Brevity rule:** Minimize output. Plans should be actionable lists, not essays. Target 100-200 lines for the plan file. -**Context (read on-demand, not all upfront):** -- Check CLAUDE.md and `.claude/skills/` for project conventions -- Check `.claude/guidelines/` if it exists for architecture and testing patterns +## Project context + +Before responding: +- If your task touches architecture, hub files, framework entry points, or import structure, read `.claude/code-map.md`. +- If your task is scoped to a functional domain (auth, billing, courses, etc.), read `.claude/skills//skill.md` for that domain. +- If you have a base skill loaded via frontmatter, it covers stack and conventions. The conditional reads above cover specifics — fetch them when relevant. **Your Process:** diff --git a/src/templates/agents/ux-ui-designer.md b/src/templates/agents/ux-ui-designer.md index 0ce8261..9b77826 100644 --- a/src/templates/agents/ux-ui-designer.md +++ b/src/templates/agents/ux-ui-designer.md @@ -9,9 +9,13 @@ You provide UX/UI design guidance for developers building interfaces. You think > **Brevity rule:** Minimize output. Specs over commentary. Deliver buildable specs, not design philosophy. -**Context (read on-demand):** -- Check CLAUDE.md and `.claude/skills/` for existing design system, component library, styling approach -- Search the codebase for existing components before designing new ones +## Project context + +Before responding: +- If your task touches architecture, hub files, framework entry points, or import structure, read `.claude/code-map.md`. +- If your task is scoped to a functional domain (auth, billing, courses, etc.), read `.claude/skills//skill.md` for that domain. +- If you have a base skill loaded via frontmatter, it covers stack and conventions. The conditional reads above cover specifics — fetch them when relevant. +- Search the codebase for existing components before designing new ones. **How to Design:** diff --git a/src/templates/agents/web-research-specialist.md b/src/templates/agents/web-research-specialist.md index bb41102..d8dc4e0 100644 --- a/src/templates/agents/web-research-specialist.md +++ b/src/templates/agents/web-research-specialist.md @@ -9,6 +9,13 @@ You research technical topics by searching the web and synthesizing findings fro > **Brevity rule:** Minimize output. Lead with the answer, then evidence. No narrative — just findings. +## Project context + +Before responding: +- If your task touches architecture, hub files, framework entry points, or import structure, read `.claude/code-map.md`. +- If your task is scoped to a functional domain (auth, billing, courses, etc.), read `.claude/skills//skill.md` for that domain. +- If you have a base skill loaded via frontmatter, it covers stack and conventions. The conditional reads above cover specifics — fetch them when relevant. + **How to Research:** 1. **Generate search queries** — Don't use one query. Try multiple angles: diff --git a/src/templates/commands/dev-docs.md b/src/templates/commands/dev-docs.md index 175a7fb..a13c1fb 100644 --- a/src/templates/commands/dev-docs.md +++ b/src/templates/commands/dev-docs.md @@ -32,6 +32,5 @@ Create a focused, actionable plan for: $ARGUMENTS ## Context References - Check CLAUDE.md and `.claude/skills/` for project conventions -- Check `.claude/guidelines/` if it exists for architecture patterns **Tip**: Use the `plan` agent to both plan AND execute. This command is for when you only need the plan. \ No newline at end of file diff --git a/src/templates/hooks/skill-activation-prompt.mjs b/src/templates/hooks/skill-activation-prompt.mjs index 221469e..725ad4d 100644 --- a/src/templates/hooks/skill-activation-prompt.mjs +++ b/src/templates/hooks/skill-activation-prompt.mjs @@ -50,7 +50,7 @@ export function readSkillContent(projectDir, skillName) { if (rel.startsWith('..') || resolve(rel) === rel) continue; if (existsSync(candidate)) { try { - return readFileSync(candidate, 'utf-8'); + return stripActivationBlock(readFileSync(candidate, 'utf-8')); } catch { // Continue to next path } @@ -60,6 +60,18 @@ export function readSkillContent(projectDir, skillName) { return null; } +// The `## Activation` section is the source-of-truth for skill-rules.json +// (file patterns + keywords). Once a skill has been selected for activation, +// re-stating *when* it activates inside the injected content is wasted tokens. +// Strip the block before injection — the on-disk file is unchanged so the +// rules generator still reads it. +function stripActivationBlock(content) { + if (!content) return content; + return content + .replace(/\n## Activation\s*\r?\n[\s\S]*?(?:\r?\n---\s*\r?\n|(?=\r?\n## )|$)/, '\n') + .replace(/(\r?\n){3,}/g, '\n\n'); +} + /** * Detect which repository we're currently in. * Checks .claude/repo-config.json first, falls back to directory basename. diff --git a/tests/agent-skill-refs.test.js b/tests/agent-skill-refs.test.js new file mode 100644 index 0000000..f1f7ff3 --- /dev/null +++ b/tests/agent-skill-refs.test.js @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { checkAgentSkillReferences } from '../src/lib/impact.js'; + +describe('checkAgentSkillReferences (Phase 6)', () => { + let dir; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'aspens-agent-refs-')); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('returns empty when there is no agents directory', () => { + expect(checkAgentSkillReferences(dir)).toEqual([]); + }); + + it('returns empty when agents declare no skills frontmatter', () => { + mkdirSync(join(dir, '.claude', 'agents'), { recursive: true }); + writeFileSync( + join(dir, '.claude', 'agents', 'foo.md'), + '---\nname: foo\n---\n\nbody\n', + ); + expect(checkAgentSkillReferences(dir)).toEqual([]); + }); + + it('returns empty when every declared skill exists on disk', () => { + mkdirSync(join(dir, '.claude', 'agents'), { recursive: true }); + mkdirSync(join(dir, '.claude', 'skills', 'base'), { recursive: true }); + writeFileSync(join(dir, '.claude', 'skills', 'base', 'skill.md'), '---\nname: base\n---\n'); + writeFileSync( + join(dir, '.claude', 'agents', 'foo.md'), + '---\nname: foo\nskills: [base]\n---\n', + ); + expect(checkAgentSkillReferences(dir)).toEqual([]); + }); + + it('reports missing skills referenced from frontmatter', () => { + mkdirSync(join(dir, '.claude', 'agents'), { recursive: true }); + writeFileSync( + join(dir, '.claude', 'agents', 'broken.md'), + '---\nname: broken\nskills: [does-not-exist]\n---\n', + ); + const out = checkAgentSkillReferences(dir); + expect(out).toEqual([{ agent: 'broken.md', missing: ['does-not-exist'] }]); + }); + + it('reports a partial mismatch when some skills exist and others don\'t', () => { + mkdirSync(join(dir, '.claude', 'agents'), { recursive: true }); + mkdirSync(join(dir, '.claude', 'skills', 'base'), { recursive: true }); + writeFileSync(join(dir, '.claude', 'skills', 'base', 'skill.md'), 'x'); + writeFileSync( + join(dir, '.claude', 'agents', 'partial.md'), + '---\nname: partial\nskills: [base, ghost]\n---\n', + ); + const out = checkAgentSkillReferences(dir); + expect(out).toEqual([{ agent: 'partial.md', missing: ['ghost'] }]); + }); +}); diff --git a/tests/agent-templates-project-context.test.js b/tests/agent-templates-project-context.test.js new file mode 100644 index 0000000..29dba06 --- /dev/null +++ b/tests/agent-templates-project-context.test.js @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync } from 'fs'; +import { join, resolve } from 'path'; + +describe('agent templates (Phase 6: subagent context layering)', () => { + const templatesDir = resolve(import.meta.dirname, '..', 'src', 'templates', 'agents'); + const agentFiles = readdirSync(templatesDir).filter(f => f.endsWith('.md')); + + it('every bundled agent template has a `## Project context` block', () => { + const missing = []; + for (const file of agentFiles) { + const content = readFileSync(join(templatesDir, file), 'utf8'); + if (!content.includes('## Project context')) missing.push(file); + } + expect(missing, `Missing project-context block in: ${missing.join(', ')}`).toEqual([]); + }); + + it('no bundled template hardcodes a `skills:` frontmatter line (injection happens in customize.js)', () => { + const offenders = []; + for (const file of agentFiles) { + const content = readFileSync(join(templatesDir, file), 'utf8'); + // Match `skills:` only inside the frontmatter block. + const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); + if (fmMatch && /^skills:\s*/m.test(fmMatch[1])) { + offenders.push(file); + } + } + expect(offenders, `Templates must NOT carry a skills: line: ${offenders.join(', ')}`).toEqual([]); + }); + + it('the conditional read instructions reference code-map and the domain skill pattern', () => { + for (const file of agentFiles) { + const content = readFileSync(join(templatesDir, file), 'utf8'); + expect(content, `${file}: missing code-map reference`).toMatch(/\.claude\/code-map\.md/); + expect(content, `${file}: missing domain skill pattern`).toMatch(/\.claude\/skills\//); + } + }); +}); diff --git a/tests/customize-skills-injection.test.js b/tests/customize-skills-injection.test.js new file mode 100644 index 0000000..3c9e33f --- /dev/null +++ b/tests/customize-skills-injection.test.js @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { maybeInjectBaseSkill } from '../src/commands/customize.js'; + +describe('maybeInjectBaseSkill (Phase 6: skills frontmatter injection)', () => { + const sampleAgent = '---\nname: my-agent\nmodel: sonnet\n---\n\nBody of the agent.\n'; + + it('does nothing when base skill is absent', () => { + expect(maybeInjectBaseSkill(sampleAgent, false, false)).toBe(sampleAgent); + }); + + it('does nothing when content has no frontmatter', () => { + const noFm = 'Just a body, no frontmatter.\n'; + expect(maybeInjectBaseSkill(noFm, true, false)).toBe(noFm); + }); + + it('appends `skills: [base]` when base exists and no skills line is present', () => { + const out = maybeInjectBaseSkill(sampleAgent, true, false); + expect(out).toContain('skills: [base]'); + // Frontmatter still well-formed + expect(out).toMatch(/^---\nname: my-agent\nmodel: sonnet\nskills: \[base\]\n---/); + // Body preserved + expect(out).toContain('Body of the agent.'); + }); + + it('does NOT overwrite an existing skills line without --reset', () => { + const withSkills = '---\nname: my-agent\nskills: [custom]\n---\n\nBody.\n'; + expect(maybeInjectBaseSkill(withSkills, true, false)).toBe(withSkills); + }); + + it('overwrites an existing skills line when --reset is set', () => { + const withSkills = '---\nname: my-agent\nskills: [custom]\n---\n\nBody.\n'; + const out = maybeInjectBaseSkill(withSkills, true, true); + expect(out).toContain('skills: [base]'); + expect(out).not.toContain('skills: [custom]'); + }); + + it('preserves all other frontmatter fields exactly', () => { + const multi = '---\nname: x\ndescription: A description\nmodel: opus\ncolor: green\n---\n\nbody\n'; + const out = maybeInjectBaseSkill(multi, true, false); + expect(out).toContain('name: x'); + expect(out).toContain('description: A description'); + expect(out).toContain('model: opus'); + expect(out).toContain('color: green'); + expect(out).toContain('skills: [base]'); + }); +}); diff --git a/tests/diff-classifier.test.js b/tests/diff-classifier.test.js new file mode 100644 index 0000000..12af206 --- /dev/null +++ b/tests/diff-classifier.test.js @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest'; +import { isNoOpDiff, LOCK_FILES } from '../src/lib/diff-classifier.js'; + +describe('diff-classifier', () => { + describe('isNoOpDiff', () => { + it('returns true for empty input', () => { + expect(isNoOpDiff([])).toBe(true); + }); + + it('returns true for non-array input', () => { + expect(isNoOpDiff(null)).toBe(true); + expect(isNoOpDiff(undefined)).toBe(true); + }); + + it('returns true when every changed file is a known lockfile', () => { + expect(isNoOpDiff(['package-lock.json'])).toBe(true); + expect(isNoOpDiff(['yarn.lock', 'package-lock.json'])).toBe(true); + expect(isNoOpDiff(['frontend/package-lock.json'])).toBe(true); + expect(isNoOpDiff(['poetry.lock', 'Pipfile.lock'])).toBe(true); + }); + + it('returns true when no file has a code-bearing extension', () => { + expect(isNoOpDiff(['README.md'])).toBe(true); + expect(isNoOpDiff(['docs/architecture.md', 'CHANGELOG.md'])).toBe(true); + expect(isNoOpDiff(['package.json'])).toBe(true); + expect(isNoOpDiff(['.gitignore'])).toBe(true); + }); + + it('returns false when at least one file has a code-bearing extension', () => { + expect(isNoOpDiff(['src/index.ts'])).toBe(false); + expect(isNoOpDiff(['package-lock.json', 'src/index.ts'])).toBe(false); + expect(isNoOpDiff(['README.md', 'src/foo.py'])).toBe(false); + }); + + it('returns false for a single code-bearing file', () => { + const exts = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.rb', '.java', '.kt', '.swift']; + for (const ext of exts) { + expect(isNoOpDiff([`src/file${ext}`])).toBe(false); + } + }); + + it('treats nested-path lockfiles as lockfiles', () => { + expect(isNoOpDiff(['frontend/package-lock.json', 'backend/poetry.lock'])).toBe(true); + }); + + it('lockfile + non-code-bearing diff is still no-op', () => { + // No code-bearing file present → no-op via the second predicate. + expect(isNoOpDiff(['package-lock.json', 'README.md'])).toBe(true); + }); + }); + + describe('LOCK_FILES', () => { + it('exposes the canonical lockfile set', () => { + expect(LOCK_FILES.has('package-lock.json')).toBe(true); + expect(LOCK_FILES.has('yarn.lock')).toBe(true); + expect(LOCK_FILES.has('pnpm-lock.yaml')).toBe(true); + expect(LOCK_FILES.has('Cargo.lock')).toBe(true); + expect(LOCK_FILES.has('Gemfile.lock')).toBe(true); + expect(LOCK_FILES.has('poetry.lock')).toBe(true); + expect(LOCK_FILES.has('go.sum')).toBe(true); + expect(LOCK_FILES.has('composer.lock')).toBe(true); + expect(LOCK_FILES.has('Pipfile.lock')).toBe(true); + }); + + it('does not include source files', () => { + expect(LOCK_FILES.has('package.json')).toBe(false); + expect(LOCK_FILES.has('Cargo.toml')).toBe(false); + }); + }); +}); diff --git a/tests/graph-nextjs-roots.test.js b/tests/graph-nextjs-roots.test.js new file mode 100644 index 0000000..46e4fcf --- /dev/null +++ b/tests/graph-nextjs-roots.test.js @@ -0,0 +1,120 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { detectNextjsEntryPoints, isNextjsProject, nextjsImplicitAliases } from '../src/lib/frameworks/nextjs.js'; + +describe('frameworks/nextjs', () => { + let dir; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'aspens-nextjs-')); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + describe('isNextjsProject', () => { + it('detects via scan.frameworks containing "nextjs"', () => { + expect(isNextjsProject({ frameworks: ['nextjs'] })).toBe(true); + }); + + it('detects via dependencies.next', () => { + expect(isNextjsProject({ frameworks: [], dependencies: { next: '^14.0.0' } })).toBe(true); + }); + + it('returns false for non-Next.js scans', () => { + expect(isNextjsProject({ frameworks: ['vite'], dependencies: {} })).toBe(false); + expect(isNextjsProject({})).toBe(false); + expect(isNextjsProject(null)).toBe(false); + }); + }); + + describe('detectNextjsEntryPoints', () => { + it('detects App Router files (page, layout, route)', () => { + mkdirSync(join(dir, 'app', 'about'), { recursive: true }); + writeFileSync(join(dir, 'app', 'page.tsx'), 'export default function Home(){}\n'); + writeFileSync(join(dir, 'app', 'layout.tsx'), 'export default function Layout(){}\n'); + writeFileSync(join(dir, 'app', 'about', 'page.tsx'), 'export default function About(){}\n'); + mkdirSync(join(dir, 'app', 'api', 'users'), { recursive: true }); + writeFileSync(join(dir, 'app', 'api', 'users', 'route.ts'), 'export function GET(){}\n'); + + const entries = detectNextjsEntryPoints(dir); + const paths = entries.map(e => e.path).sort(); + expect(paths).toContain('app/page.tsx'); + expect(paths).toContain('app/layout.tsx'); + expect(paths).toContain('app/about/page.tsx'); + expect(paths).toContain('app/api/users/route.ts'); + for (const e of entries) { + if (e.path.startsWith('app/')) expect(e.kind).toBe('nextjs-app'); + } + }); + + it('detects special files like middleware.ts and instrumentation.ts', () => { + writeFileSync(join(dir, 'middleware.ts'), 'export function middleware(){}\n'); + writeFileSync(join(dir, 'instrumentation.ts'), 'export function register(){}\n'); + + const entries = detectNextjsEntryPoints(dir); + const paths = entries.map(e => e.path).sort(); + expect(paths).toContain('middleware.ts'); + expect(paths).toContain('instrumentation.ts'); + const middleware = entries.find(e => e.path === 'middleware.ts'); + expect(middleware.kind).toBe('nextjs-middleware'); + }); + + it('detects metadata route files (sitemap.ts, robots.ts)', () => { + mkdirSync(join(dir, 'app'), { recursive: true }); + writeFileSync(join(dir, 'app', 'sitemap.ts'), 'export default function sitemap(){}\n'); + writeFileSync(join(dir, 'app', 'robots.ts'), 'export default function robots(){}\n'); + + const entries = detectNextjsEntryPoints(dir); + const paths = entries.map(e => e.path); + expect(paths).toContain('app/sitemap.ts'); + expect(paths).toContain('app/robots.ts'); + }); + + it('detects Pages Router files alongside App Router', () => { + mkdirSync(join(dir, 'pages', 'api'), { recursive: true }); + mkdirSync(join(dir, 'app'), { recursive: true }); + writeFileSync(join(dir, 'pages', 'index.tsx'), 'export default function(){}\n'); + writeFileSync(join(dir, 'pages', 'api', 'hello.ts'), 'export default function(){}\n'); + writeFileSync(join(dir, 'app', 'page.tsx'), 'export default function(){}\n'); + + const entries = detectNextjsEntryPoints(dir); + const pagesEntries = entries.filter(e => e.kind === 'nextjs-pages'); + expect(pagesEntries.length).toBeGreaterThanOrEqual(2); + expect(pagesEntries.some(e => e.path === 'pages/index.tsx')).toBe(true); + expect(pagesEntries.some(e => e.path === 'pages/api/hello.ts')).toBe(true); + }); + + it('skips non-code-bearing extensions for metadata routes', () => { + mkdirSync(join(dir, 'app'), { recursive: true }); + writeFileSync(join(dir, 'app', 'icon.png'), 'binary'); + writeFileSync(join(dir, 'app', 'icon.ts'), 'export default function(){}\n'); + + const entries = detectNextjsEntryPoints(dir); + const paths = entries.map(e => e.path); + expect(paths).toContain('app/icon.ts'); + expect(paths).not.toContain('app/icon.png'); + }); + + it('returns empty array for non-Next.js repos', () => { + writeFileSync(join(dir, 'index.js'), 'console.log("plain")'); + expect(detectNextjsEntryPoints(dir)).toEqual([]); + }); + }); + + describe('nextjsImplicitAliases', () => { + it('points @/ at src/ when present', () => { + mkdirSync(join(dir, 'src'), { recursive: true }); + const aliases = nextjsImplicitAliases(dir); + expect(aliases).toEqual([{ prefix: '@/', replacement: join(dir, 'src') }]); + }); + + it('points @/ at the repo root when src/ is missing', () => { + const aliases = nextjsImplicitAliases(dir); + expect(aliases).toEqual([{ prefix: '@/', replacement: dir }]); + }); + }); +}); diff --git a/tests/graph-persistence.test.js b/tests/graph-persistence.test.js index d9413b5..9be9c6e 100644 --- a/tests/graph-persistence.test.js +++ b/tests/graph-persistence.test.js @@ -375,9 +375,15 @@ describe('generateCodeMap', () => { expect(map).toContain('## Codebase Structure'); }); - it('includes hub files', () => { + it('Phase 1: stability — does NOT include a `Hub files` block (hubs live only in clusters/hotspots/graph metadata)', () => { const map = generateCodeMap(graph); - expect(map).toContain('Hub files'); + expect(map).not.toMatch(/^\*\*Hub files/m); + }); + + it('still surfaces hub-file paths via domain clusters', () => { + const map = generateCodeMap(graph); + // scanner.js still appears via the cluster listing even though the + // dedicated `Hub files` block was removed. expect(map).toContain('src/lib/scanner.js'); }); diff --git a/tests/graph-python.test.js b/tests/graph-python.test.js new file mode 100644 index 0000000..1c5d637 --- /dev/null +++ b/tests/graph-python.test.js @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { extractPythonExports, parsePyImports } from '../src/lib/parsers/python.js'; + +describe('parsers/python', () => { + describe('extractPythonExports', () => { + it('captures top-level def names', () => { + const src = [ + 'def foo():', + ' pass', + '', + 'def bar(a, b):', + ' return a + b', + ].join('\n'); + expect(extractPythonExports(src).sort()).toEqual(['bar', 'foo']); + }); + + it('captures async def names', () => { + const src = 'async def fetch():\n pass\n'; + expect(extractPythonExports(src)).toEqual(['fetch']); + }); + + it('captures top-level class names', () => { + const src = 'class Cache:\n pass\n\nclass Service(Cache):\n pass\n'; + expect(extractPythonExports(src).sort()).toEqual(['Cache', 'Service']); + }); + + it('rejects nested defs (methods inside a class)', () => { + const src = [ + 'class Foo:', + ' def method(self):', + ' pass', + '', + 'def top_level():', + ' pass', + ].join('\n'); + const out = extractPythonExports(src).sort(); + expect(out).toContain('Foo'); + expect(out).toContain('top_level'); + expect(out).not.toContain('method'); + }); + + it('ignores defs inside docstrings', () => { + const src = '"""\ndef inside_doc():\n pass\n"""\n\ndef real_def():\n pass\n'; + expect(extractPythonExports(src)).toEqual(['real_def']); + }); + + it('does NOT extract SCREAMING_SNAKE constants (intentional drop)', () => { + const src = 'API_URL = "https://example.com"\n\ndef foo():\n pass\n'; + expect(extractPythonExports(src)).toEqual(['foo']); + }); + + it('returns empty for empty content', () => { + expect(extractPythonExports('')).toEqual([]); + }); + + it('dedupes (same name twice)', () => { + const src = 'def foo():\n pass\n\ndef foo():\n pass\n'; + expect(extractPythonExports(src)).toEqual(['foo']); + }); + }); + + describe('parsePyImports', () => { + it('captures `from x import y` form', () => { + const src = 'from app.services import db\nfrom .helpers import bar\n'; + expect(parsePyImports(src)).toEqual(['app.services', '.helpers']); + }); + + it('captures `import x` form', () => { + const src = 'import os\nimport app.config\n'; + expect(parsePyImports(src)).toEqual(['os', 'app.config']); + }); + + it('ignores imports inside docstrings', () => { + const src = '"""\nimport this_is_in_a_docstring\n"""\nimport real\n'; + expect(parsePyImports(src)).toEqual(['real']); + }); + }); +}); diff --git a/tests/graph-ts-reexport.test.js b/tests/graph-ts-reexport.test.js new file mode 100644 index 0000000..e3665be --- /dev/null +++ b/tests/graph-ts-reexport.test.js @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { parseJsImports } from '../src/lib/parsers/typescript.js'; + +describe('parsers/typescript', () => { + describe('default exports', () => { + it('resolves `export default function Foo`', async () => { + const src = 'export default function Foo() { return 1; }'; + const { exports } = await parseJsImports(src, 'test.ts'); + expect(exports).toContain('Foo'); + expect(exports).not.toContain('default'); + }); + + it('resolves `export default class Bar`', async () => { + const src = 'export default class Bar {}'; + const { exports } = await parseJsImports(src, 'test.ts'); + expect(exports).toContain('Bar'); + }); + + it('resolves `export default async function fetch`', async () => { + const src = 'export default async function fetch() {}'; + const { exports } = await parseJsImports(src, 'test.ts'); + expect(exports).toContain('fetch'); + }); + + it('resolves the reassignment pattern `const Foo = ...; export default Foo`', async () => { + const src = 'const Foo = () => 1;\nexport default Foo;\n'; + const { exports } = await parseJsImports(src, 'test.ts'); + expect(exports).toContain('Foo'); + }); + + it('falls back to `default` when no resolvable name is found', async () => { + const src = 'export default { foo: 1 };'; + const { exports } = await parseJsImports(src, 'test.ts'); + // No identifier resolvable for an object literal — keep `default`. + expect(exports).toContain('default'); + }); + }); + + describe('export * from re-exports', () => { + it('emits a synthetic re-export marker and an import edge', async () => { + const src = "export * from './foo';\n"; + const { imports, exports } = await parseJsImports(src, 'index.ts'); + expect(imports).toContain('./foo'); + expect(exports).toContain('re-export:./foo'); + }); + + it('handles named re-export-as form', async () => { + const src = "export * as helpers from './helpers';\n"; + const { imports, exports } = await parseJsImports(src, 'index.ts'); + expect(imports).toContain('./helpers'); + expect(exports).toContain('re-export:./helpers'); + }); + + it('handles multiple `export * from` lines', async () => { + const src = "export * from './a';\nexport * from './b';\nexport * from './c';\n"; + const { imports, exports } = await parseJsImports(src, 'index.ts'); + expect(imports.sort()).toEqual(['./a', './b', './c']); + expect(exports).toContain('re-export:./a'); + expect(exports).toContain('re-export:./b'); + expect(exports).toContain('re-export:./c'); + }); + }); + + describe('regular exports', () => { + it('captures named exports', async () => { + const src = 'export const foo = 1;\nexport function bar() {}\nexport class Baz {}'; + const { exports } = await parseJsImports(src, 'test.ts'); + expect(exports).toContain('foo'); + expect(exports).toContain('bar'); + expect(exports).toContain('Baz'); + }); + + it('captures imports', async () => { + const src = "import { foo } from './foo';\nimport bar from 'bar';\n"; + const { imports } = await parseJsImports(src, 'test.ts'); + expect(imports).toContain('./foo'); + expect(imports).toContain('bar'); + }); + }); +}); diff --git a/tests/hook-runtime.test.js b/tests/hook-runtime.test.js index d6467ac..80546ca 100644 --- a/tests/hook-runtime.test.js +++ b/tests/hook-runtime.test.js @@ -72,4 +72,21 @@ describe('skill activation hook runtime', () => { expect(result.stdout).toContain('ACTIVE SKILLS'); expect(result.stdout).toContain('Base content'); }); + + it('strips the ## Activation block from injected skill content', () => { + const scriptPath = join(HOOKS_DIR, 'skill-activation-prompt.sh'); + const result = spawnSync('bash', [scriptPath], { + input: JSON.stringify({ prompt: 'anything' }), + encoding: 'utf8', + env: { + ...process.env, + CLAUDE_PROJECT_DIR: MONOREPO_ROOT, + }, + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('Base content'); + expect(result.stdout).not.toContain('## Activation'); + expect(result.stdout).not.toContain('always loads when working in this repository'); + }); }); diff --git a/tests/impact-hub-coverage.test.js b/tests/impact-hub-coverage.test.js new file mode 100644 index 0000000..be870fa --- /dev/null +++ b/tests/impact-hub-coverage.test.js @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { computeHubCoverage } from '../src/lib/impact.js'; + +describe('computeHubCoverage (Phase 4: code-map check)', () => { + let dir; + const claudeTarget = { id: 'claude' }; + const codexTarget = { id: 'codex' }; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'aspens-impact-')); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('reports mentioned hubs when code-map.md contains them', () => { + mkdirSync(join(dir, '.claude'), { recursive: true }); + writeFileSync( + join(dir, '.claude', 'code-map.md'), + '## Codebase Structure\n\nstuff `src/lib/scanner.js` and `src/commands/foo.js`\n', + ); + const result = computeHubCoverage( + ['src/lib/scanner.js', 'src/commands/foo.js', 'src/missing.js'], + 'irrelevant context', + { repoPath: dir, target: claudeTarget }, + ); + expect(result.total).toBe(3); + expect(result.mentioned).toBe(2); + expect(result.codeMapMissing).toBe(false); + expect(result.paths).toContain('src/lib/scanner.js'); + expect(result.paths).toContain('src/commands/foo.js'); + }); + + it('reports zero mentioned when code-map.md exists but contains no hubs', () => { + mkdirSync(join(dir, '.claude'), { recursive: true }); + writeFileSync(join(dir, '.claude', 'code-map.md'), '## Codebase Structure\n\nNo hubs here.\n'); + const result = computeHubCoverage( + ['src/lib/scanner.js'], + '', + { repoPath: dir, target: claudeTarget }, + ); + expect(result.total).toBe(1); + expect(result.mentioned).toBe(0); + expect(result.codeMapMissing).toBe(false); + }); + + it('flags codeMapMissing when code-map.md is absent (Claude)', () => { + const result = computeHubCoverage( + ['src/lib/scanner.js'], + '', + { repoPath: dir, target: claudeTarget }, + ); + expect(result.codeMapMissing).toBe(true); + expect(result.mentioned).toBe(0); + }); + + it('checks the Codex-specific code-map path when target is codex', () => { + mkdirSync(join(dir, '.agents', 'skills', 'architecture', 'references'), { recursive: true }); + writeFileSync( + join(dir, '.agents', 'skills', 'architecture', 'references', 'code-map.md'), + '# Code Map\n\n`src/lib/codex_hub.py`\n', + ); + const result = computeHubCoverage( + ['src/lib/codex_hub.py'], + '', + { repoPath: dir, target: codexTarget }, + ); + expect(result.codeMapMissing).toBe(false); + expect(result.mentioned).toBe(1); + }); + + it('flags codeMapMissing for codex when its code-map is absent', () => { + const result = computeHubCoverage( + ['x'], '', + { repoPath: dir, target: codexTarget }, + ); + expect(result.codeMapMissing).toBe(true); + }); + + it('falls back to contextText when no opts are passed (back-compat)', () => { + const result = computeHubCoverage( + ['src/lib/scanner.js'], + 'CLAUDE.md content references `src/lib/scanner.js` directly', + ); + expect(result.mentioned).toBe(1); + expect(result.codeMapMissing).toBe(false); + }); + + it('returns total=0 with no hub paths', () => { + const result = computeHubCoverage([], '', { repoPath: dir, target: claudeTarget }); + expect(result.total).toBe(0); + expect(result.mentioned).toBe(0); + }); +}); diff --git a/tests/no-guidelines-refs.test.js b/tests/no-guidelines-refs.test.js new file mode 100644 index 0000000..82b64f9 --- /dev/null +++ b/tests/no-guidelines-refs.test.js @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { execSync } from 'child_process'; +import { resolve } from 'path'; + +/** + * Phase 4: guidelines purge — no functional source path or repo doc may + * reference the legacy `.claude/guidelines/` directory. Skills supersede it. + * + * `ghost-writer.md` uses the phrase "Project guidelines" with a different + * meaning (style guidance) — it is not matched by these patterns. + */ +const REPO_ROOT = resolve(import.meta.dirname, '..'); + +function gitGrep(pattern, paths) { + try { + const output = execSync( + `git grep -nE "${pattern}" -- ${paths.join(' ')}`, + { cwd: REPO_ROOT, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }, + ); + return output.split('\n').filter(Boolean); + } catch (e) { + if (e.status === 1) return []; + throw e; + } +} + +describe('guidelines purge (Phase 4)', () => { + it('no source or repo doc references `.claude/guidelines/`', () => { + const matches = gitGrep('\\.claude/guidelines', ['src', '.claude']); + expect(matches, `Unexpected guideline refs:\n${matches.join('\n')}`).toEqual([]); + }); + + it('no source path constructs a literal "guidelines" segment', () => { + const matches = gitGrep("'guidelines'", ['src']); + expect(matches, `Unexpected dynamic 'guidelines' constructions:\n${matches.join('\n')}`).toEqual([]); + }); + + it('no `guidelines/` path references in src or .claude', () => { + const matches = gitGrep('guidelines/[a-z]', ['src', '.claude']); + expect(matches, `Unexpected guidelines/ refs:\n${matches.join('\n')}`).toEqual([]); + }); +}); diff --git a/tests/path-alias-resolution.test.js b/tests/path-alias-resolution.test.js new file mode 100644 index 0000000..a2efe71 --- /dev/null +++ b/tests/path-alias-resolution.test.js @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { buildRepoGraph } from '../src/lib/graph-builder.js'; + +describe('path alias resolution', () => { + let dir; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'aspens-aliases-')); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('resolves "@/lib/foo" via tsconfig paths', async () => { + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'foo.ts'), 'export const foo = 1;\n'); + writeFileSync(join(dir, 'index.ts'), "import { foo } from '@/lib/foo';\nexport const x = foo;\n"); + writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { baseUrl: '.', paths: { '@/*': ['./*'] } } + }, null, 2)); + + const graph = await buildRepoGraph(dir); + expect(graph.files['index.ts'].imports).toContain('lib/foo.ts'); + }); + + it('follows tsconfig `extends` chains (single level)', async () => { + mkdirSync(join(dir, 'src'), { recursive: true }); + writeFileSync(join(dir, 'src', 'foo.ts'), 'export const foo = 1;\n'); + writeFileSync(join(dir, 'src', 'index.ts'), "import { foo } from '@/foo';\nexport const x = foo;\n"); + writeFileSync(join(dir, 'tsconfig.base.json'), JSON.stringify({ + compilerOptions: { baseUrl: '.', paths: { '@/*': ['./src/*'] } } + }, null, 2)); + writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify({ + extends: './tsconfig.base.json', + compilerOptions: {} + }, null, 2)); + + const graph = await buildRepoGraph(dir); + expect(graph.files['src/index.ts'].imports).toContain('src/foo.ts'); + }); + + it('child config overrides parent paths', async () => { + mkdirSync(join(dir, 'a'), { recursive: true }); + mkdirSync(join(dir, 'b'), { recursive: true }); + writeFileSync(join(dir, 'a', 'thing.ts'), 'export const thing = 1;\n'); + writeFileSync(join(dir, 'b', 'thing.ts'), 'export const thing = 2;\n'); + writeFileSync(join(dir, 'index.ts'), "import { thing } from '@/thing';\nexport const x = thing;\n"); + writeFileSync(join(dir, 'tsconfig.base.json'), JSON.stringify({ + compilerOptions: { baseUrl: '.', paths: { '@/*': ['./a/*'] } } + }, null, 2)); + writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify({ + extends: './tsconfig.base.json', + compilerOptions: { baseUrl: '.', paths: { '@/*': ['./b/*'] } } + }, null, 2)); + + const graph = await buildRepoGraph(dir); + expect(graph.files['index.ts'].imports).toContain('b/thing.ts'); + }); + + it('falls back to the implicit Next.js @/ alias when tsconfig has no paths', async () => { + mkdirSync(join(dir, 'src', 'lib'), { recursive: true }); + writeFileSync(join(dir, 'src', 'lib', 'foo.ts'), 'export const foo = 1;\n'); + writeFileSync(join(dir, 'src', 'index.ts'), "import { foo } from '@/lib/foo';\n"); + writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify({ compilerOptions: {} }, null, 2)); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + name: 'demo', dependencies: { next: '^14.0.0' } + }, null, 2)); + + const graph = await buildRepoGraph(dir); + expect(graph.files['src/index.ts'].imports).toContain('src/lib/foo.ts'); + }); +}); diff --git a/tests/prompt-loader.test.js b/tests/prompt-loader.test.js index b30c65a..0520cbe 100644 --- a/tests/prompt-loader.test.js +++ b/tests/prompt-loader.test.js @@ -47,4 +47,30 @@ describe('loadPrompt', () => { it('throws on non-existent prompt', () => { expect(() => loadPrompt('does-not-exist')).toThrow(); }); + + // Phase 2: shared preservation-contract partials + it('resolves {{preservation-contract}} in doc-sync', () => { + const prompt = loadPrompt('doc-sync'); + expect(prompt).toContain('Preservation contract'); + expect(prompt).toContain('NEVER delete an existing line of instructions'); + expect(prompt).not.toContain('{{preservation-contract}}'); + }); + + it('resolves {{preservation-contract-refresh}} in doc-sync-refresh', () => { + const prompt = loadPrompt('doc-sync-refresh'); + expect(prompt).toContain('Preservation contract — refresh mode'); + expect(prompt).toContain('refresh mode'); + expect(prompt).not.toContain('{{preservation-contract-refresh}}'); + }); + + it('resolves {{preservation-contract}} in doc-init, doc-init-claudemd, doc-init-domain', () => { + for (const name of ['doc-init', 'doc-init-claudemd']) { + const prompt = loadPrompt(name); + expect(prompt, `${name}.md should embed preservation-contract`).toContain('Preservation contract'); + expect(prompt).not.toContain('{{preservation-contract}}'); + } + const domain = loadPrompt('doc-init-domain', { domainName: 'auth' }); + expect(domain).toContain('Preservation contract'); + expect(domain).not.toContain('{{preservation-contract}}'); + }); }); diff --git a/tests/target-parity.test.js b/tests/target-parity.test.js new file mode 100644 index 0000000..38d5cf2 --- /dev/null +++ b/tests/target-parity.test.js @@ -0,0 +1,118 @@ +import { describe, it, expect } from 'vitest'; +import { assertTargetParity, transformPathForTarget } from '../src/lib/target-transform.js'; + +/** + * Phase 4 — parity validator. Asserts that multi-target publishes don't + * silently drop or add a logical file slot (root instructions, per-domain + * skill). Codex directory-scoped AGENTS.md files are excluded by design. + */ + +describe('transformPathForTarget', () => { + it('returns the path unchanged when target is claude', () => { + expect(transformPathForTarget('claude', 'CLAUDE.md')).toBe('CLAUDE.md'); + expect(transformPathForTarget('claude', '.claude/skills/billing/skill.md')) + .toBe('.claude/skills/billing/skill.md'); + }); + + it('maps CLAUDE.md to AGENTS.md for codex', () => { + expect(transformPathForTarget('codex', 'CLAUDE.md')).toBe('AGENTS.md'); + }); + + it('maps .claude/skills//skill.md to .agents/skills//SKILL.md', () => { + expect(transformPathForTarget('codex', '.claude/skills/billing/skill.md')) + .toBe('.agents/skills/billing/SKILL.md'); + expect(transformPathForTarget('codex', '.claude/skills/base/skill.md')) + .toBe('.agents/skills/base/SKILL.md'); + }); + + it('returns null for unknown target ids', () => { + expect(transformPathForTarget('made-up', 'CLAUDE.md')).toBeNull(); + }); + + it('returns null for paths that do not match a target slot', () => { + expect(transformPathForTarget('codex', '.claude/agents/custom.md')).toBeNull(); + }); +}); + +describe('assertTargetParity', () => { + it('is a no-op for single-target publishes', () => { + const single = new Map([ + ['claude', [ + { path: 'CLAUDE.md', content: '' }, + { path: '.claude/skills/base/skill.md', content: '' }, + ]], + ]); + expect(() => assertTargetParity(single)).not.toThrow(); + }); + + it('passes when claude and codex have parallel files', () => { + const map = new Map([ + ['claude', [ + { path: 'CLAUDE.md', content: '' }, + { path: '.claude/skills/base/skill.md', content: '' }, + { path: '.claude/skills/billing/skill.md', content: '' }, + ]], + ['codex', [ + { path: 'AGENTS.md', content: '' }, + { path: '.agents/skills/base/SKILL.md', content: '' }, + { path: '.agents/skills/billing/SKILL.md', content: '' }, + ]], + ]); + expect(() => assertTargetParity(map)).not.toThrow(); + }); + + it('ignores codex directory-scoped AGENTS.md inside domain dirs', () => { + const map = new Map([ + ['claude', [ + { path: 'CLAUDE.md', content: '' }, + { path: '.claude/skills/billing/skill.md', content: '' }, + ]], + ['codex', [ + { path: 'AGENTS.md', content: '' }, + { path: '.agents/skills/billing/SKILL.md', content: '' }, + { path: 'src/services/billing/AGENTS.md', content: '' }, + ]], + ]); + expect(() => assertTargetParity(map)).not.toThrow(); + }); + + it('throws when codex is missing a domain skill that exists in claude', () => { + const map = new Map([ + ['claude', [ + { path: 'CLAUDE.md', content: '' }, + { path: '.claude/skills/billing/skill.md', content: '' }, + { path: '.claude/skills/auth/skill.md', content: '' }, + ]], + ['codex', [ + { path: 'AGENTS.md', content: '' }, + { path: '.agents/skills/billing/SKILL.md', content: '' }, + // auth skill is missing — parity violation + ]], + ]); + expect(() => assertTargetParity(map)).toThrow(/parity violation/); + expect(() => assertTargetParity(map)).toThrow(/SKILL:auth/); + }); + + it('throws when claude is missing the root instructions file', () => { + const map = new Map([ + ['claude', [ + { path: '.claude/skills/billing/skill.md', content: '' }, + ]], + ['codex', [ + { path: 'AGENTS.md', content: '' }, + { path: '.agents/skills/billing/SKILL.md', content: '' }, + ]], + ]); + expect(() => assertTargetParity(map)).toThrow(/INSTRUCTIONS/); + }); + + it('skips unknown target ids without throwing', () => { + const map = new Map([ + ['claude', [{ path: 'CLAUDE.md', content: '' }]], + ['unknown', [{ path: 'whatever', content: '' }]], + ]); + // unknown target collapses to an empty key set; claude has INSTRUCTIONS; + // assertion fires on the difference. This is the documented behavior. + expect(() => assertTargetParity(map)).toThrow(/parity violation/); + }); +}); diff --git a/tests/target-transform.test.js b/tests/target-transform.test.js index 0f71151..3512d0d 100644 --- a/tests/target-transform.test.js +++ b/tests/target-transform.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { transformForTarget, validateTransformedFiles, projectCodexDomainDocs, ensureRootKeyFilesSection } from '../src/lib/target-transform.js'; +import { transformForTarget, validateTransformedFiles, projectCodexDomainDocs, ensureRootKeyFilesSection, syncSkillsSection, syncBehaviorSection } from '../src/lib/target-transform.js'; import { TARGETS } from '../src/lib/target.js'; const mockScanResult = { @@ -139,24 +139,28 @@ describe('projectCodexDomainDocs', () => { }); }); -describe('ensureRootKeyFilesSection', () => { - it('inserts a key files section before behavior when missing', () => { +describe('ensureRootKeyFilesSection (Phase 1: legacy-only stripper)', () => { + it('does NOT insert a key files section when missing — hub blocks are no longer emitted into root instructions', () => { const content = '# Backend\n\n## Commands\n\nuv run pytest\n\n## Behavior\n\n- Verify before claiming\n'; const result = ensureRootKeyFilesSection(content, mockGraph); - expect(result).toContain('## Key Files'); - expect(result).toContain('`app/core/cache_service.py`'); - expect(result.indexOf('## Key Files')).toBeLessThan(result.indexOf('## Behavior')); + expect(result).not.toContain('## Key Files'); + expect(result).toContain('## Commands'); + expect(result).toContain('## Behavior'); }); - it('replaces an incomplete key files section with all top hubs', () => { + it('strips a legacy hub block if present (backwards-compat one-shot)', () => { const content = '# Backend\n\n## Key Files\n\n- `app/core/db.py` - 9 dependents\n\n## Behavior\n'; const result = ensureRootKeyFilesSection(content, mockGraph); - expect(result).toContain('`app/core/db.py` - 9 dependents'); - expect(result).toContain('`app/core/cache_service.py` - 7 dependents'); - expect(result).toContain('`app/middleware/rate_limit.py` - 6 dependents'); - expect(result.match(/## Key Files/g)).toHaveLength(1); + expect(result).not.toContain('## Key Files'); + expect(result).not.toContain('9 dependents'); + expect(result).toContain('## Behavior'); + }); + + it('returns content unchanged when no legacy block is present', () => { + const content = '# Backend\n\n## Commands\n\nrun me\n'; + expect(ensureRootKeyFilesSection(content, mockGraph)).toBe(content); }); }); @@ -213,3 +217,58 @@ describe('validateTransformedFiles', () => { expect(issues).toHaveLength(2); }); }); + +describe('syncSkillsSection', () => { + const baseSkill = { path: '.claude/skills/base/skill.md', content: '---\nname: base\ndescription: Base skill desc\n---\n' }; + const domainSkills = [ + { path: '.claude/skills/auth/skill.md', content: '---\nname: auth\ndescription: Auth handling\n---\n' }, + { path: '.claude/skills/billing/skill.md', content: '---\nname: billing\ndescription: Billing flows\n---\n' }, + ]; + + it('injects a Skills section listing every generated skill for Claude', () => { + const claudeMd = '# Project\n\nIntro.\n'; + const out = syncSkillsSection(claudeMd, baseSkill, domainSkills, TARGETS.claude, false); + expect(out).toContain('## Skills'); + expect(out).toContain('.claude/skills/base/skill.md'); + expect(out).toContain('.claude/skills/auth/skill.md'); + expect(out).toContain('.claude/skills/billing/skill.md'); + expect(out).toContain('Auth handling'); + expect(out).toContain('Billing flows'); + }); + + it('overwrites an existing Skills section rather than duplicating it', () => { + const claudeMd = '# Project\n\n## Skills\n\n- only-one-stale-entry\n\n## Conventions\n\nstuff\n'; + const out = syncSkillsSection(claudeMd, baseSkill, domainSkills, TARGETS.claude, false); + expect(out.match(/## Skills/g)).toHaveLength(1); + expect(out).not.toContain('only-one-stale-entry'); + expect(out).toContain('.claude/skills/auth/skill.md'); + expect(out).toContain('## Conventions'); + }); + + it('uses Codex paths and casing when destTarget is codex', () => { + const agentsMd = '# Project\n'; + const out = syncSkillsSection(agentsMd, baseSkill, domainSkills, TARGETS.codex, true); + expect(out).toContain('.agents/skills/base/SKILL.md'); + expect(out).toContain('.agents/skills/auth/SKILL.md'); + expect(out).toContain('.agents/skills/architecture/SKILL.md'); + }); +}); + +describe('syncBehaviorSection', () => { + it('appends a Behavior section with the canonical rules when missing', () => { + const out = syncBehaviorSection('# Project\n\nIntro.\n'); + expect(out).toContain('## Behavior'); + expect(out).toContain('Verify before claiming'); + expect(out).toContain('Simplicity first'); + expect(out).toContain('Surgical changes'); + }); + + it('overwrites an existing Behavior section, never duplicating it', () => { + const input = '# Project\n\n## Behavior\n\n- stale rule\n\n## Conventions\n\nstuff\n'; + const out = syncBehaviorSection(input); + expect(out.match(/## Behavior/g)).toHaveLength(1); + expect(out).not.toContain('stale rule'); + expect(out).toContain('Surgical changes'); + expect(out).toContain('## Conventions'); + }); +}); From 00afc4d2baaf43b35e80b679fc0f8bb55a87e48b Mon Sep 17 00:00:00 2001 From: mvoutov Date: Mon, 11 May 2026 11:58:28 -0700 Subject: [PATCH 2/7] feat: add heuristic doc to claude/codex on doc sync --- src/commands/doc-sync.js | 25 ++++++++++++++++++++++++- src/lib/target-transform.js | 11 ++++++++++- tests/target-transform.test.js | 11 +++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/commands/doc-sync.js b/src/commands/doc-sync.js index 87a908a..5dfae0c 100644 --- a/src/commands/doc-sync.js +++ b/src/commands/doc-sync.js @@ -15,7 +15,7 @@ import { installGitHook, removeGitHook } from '../lib/git-hook.js'; import { isGitRepo, getGitRoot, getGitDiff, getGitLog, getChangedFiles } from '../lib/git-helpers.js'; import { TARGETS, getAllowedPaths, loadConfig } from '../lib/target.js'; import { getSelectedFilesDiff, buildPrioritizedDiff, truncate } from '../lib/diff-helpers.js'; -import { projectCodexDomainDocs, transformForTarget, assertTargetParity } from '../lib/target-transform.js'; +import { projectCodexDomainDocs, transformForTarget, assertTargetParity, syncSkillsSection, syncBehaviorSection } from '../lib/target-transform.js'; import { isNoOpDiff } from '../lib/diff-classifier.js'; const READ_ONLY_TOOLS = ['Read', 'Glob', 'Grep']; @@ -700,6 +700,29 @@ async function refreshAllSkills(repoPath, options, sourceTarget, publishTargets } } + // Step 5b: Deterministically (re)inject `## Skills` and `## Behavior`, even + // when the LLM didn't propose an update — guarantees drift repair every sync. + if (existsSync(instrPath)) { + const pending = allUpdatedFiles.find(f => f.path === instrFile); + const startContent = pending ? pending.content : readFileSync(instrPath, 'utf8'); + const baseSkillForList = existingSkills.find(s => s.name === 'base') || null; + const domainSkillsForList = existingSkills.filter(s => s.name !== 'base'); + + let updated = syncSkillsSection( + startContent, + baseSkillForList, + domainSkillsForList, + sourceTarget, + false + ); + updated = syncBehaviorSection(updated); + + if (updated !== startContent) { + if (pending) pending.content = updated; + else allUpdatedFiles.push({ path: instrFile, content: updated }); + } + } + // Step 6: Check for uncovered domains const coveredNames = new Set(existingSkills.map(s => s.name.toLowerCase())); const uncoveredDomains = (scan.domains || []).filter(d => diff --git a/src/lib/target-transform.js b/src/lib/target-transform.js index 6dd1b73..b6e5842 100644 --- a/src/lib/target-transform.js +++ b/src/lib/target-transform.js @@ -270,7 +270,7 @@ function buildSkillRefs(baseSkill, domainSkills, destTarget, hasArchitectureSkil } for (const skill of domainSkills) { - const domainName = extractDomainName(skill.path, { skillsDir: '.claude/skills' }); + const domainName = extractDomainFromAnyPath(skill.path); if (!domainName) continue; const description = extractFrontmatterField(skill.content, 'description'); const suffix = description ? ' — ' + description : ''; @@ -514,6 +514,15 @@ function extractDomainName(skillPath, target) { return rest.split('/')[0]; } +// Source-skillsDir-agnostic version. Skill files always live at +// `//` so the domain name is the +// second-to-last path segment regardless of whether the source is +// Claude (`.claude/skills/...`) or Codex (`.agents/skills/...`). +function extractDomainFromAnyPath(skillPath) { + const parts = skillPath.split('/').filter(Boolean); + return parts.length >= 2 ? parts[parts.length - 2] : null; +} + function resolveDomainDirectory(domainName, scanResult) { if (!scanResult?.domains) return null; diff --git a/tests/target-transform.test.js b/tests/target-transform.test.js index 3512d0d..962c0bc 100644 --- a/tests/target-transform.test.js +++ b/tests/target-transform.test.js @@ -252,6 +252,17 @@ describe('syncSkillsSection', () => { expect(out).toContain('.agents/skills/auth/SKILL.md'); expect(out).toContain('.agents/skills/architecture/SKILL.md'); }); + + it('extracts domain names when source skill paths use the Codex skillsDir', () => { + const codexBase = { path: '.agents/skills/base/SKILL.md', content: '---\nname: base\ndescription: Base\n---\n' }; + const codexDomains = [ + { path: '.agents/skills/auth/SKILL.md', content: '---\nname: auth\ndescription: Auth\n---\n' }, + { path: '.agents/skills/billing/SKILL.md', content: '---\nname: billing\ndescription: Billing\n---\n' }, + ]; + const out = syncSkillsSection('# Project\n', codexBase, codexDomains, TARGETS.codex, false); + expect(out).toContain('.agents/skills/auth/SKILL.md'); + expect(out).toContain('.agents/skills/billing/SKILL.md'); + }); }); describe('syncBehaviorSection', () => { From dc8d2e2cc231013675d97917791e4c746280c138 Mon Sep 17 00:00:00 2001 From: mvoutov Date: Mon, 11 May 2026 15:28:12 -0700 Subject: [PATCH 3/7] doc sync issues --- package-lock.json | 6 +- src/commands/doc-sync.js | 88 ++++++++- src/lib/graph-persistence.js | 105 +++++----- src/lib/skill-reader.js | 110 ++++++++++- src/lib/skill-writer.js | 5 +- src/lib/target-transform.js | 209 ++++++++++++++++---- src/lib/target.js | 2 +- src/prompts/add-skill.md | 17 +- src/prompts/doc-init-claudemd.md | 4 +- src/prompts/doc-init-domain.md | 2 +- src/prompts/doc-init.md | 4 +- src/prompts/partials/examples.md | 42 ++-- src/prompts/partials/skill-format.md | 28 ++- tests/graph-persistence.test.js | 36 +++- tests/skill-reader-triggers.test.js | 281 +++++++++++++++++++++++++++ tests/target-transform.test.js | 110 ++++++++++- 16 files changed, 893 insertions(+), 156 deletions(-) create mode 100644 tests/skill-reader-triggers.test.js diff --git a/package-lock.json b/package-lock.json index 0096ad1..30a4b6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -969,9 +969,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { diff --git a/src/commands/doc-sync.js b/src/commands/doc-sync.js index 5dfae0c..42bbd3b 100644 --- a/src/commands/doc-sync.js +++ b/src/commands/doc-sync.js @@ -15,7 +15,7 @@ import { installGitHook, removeGitHook } from '../lib/git-hook.js'; import { isGitRepo, getGitRoot, getGitDiff, getGitLog, getChangedFiles } from '../lib/git-helpers.js'; import { TARGETS, getAllowedPaths, loadConfig } from '../lib/target.js'; import { getSelectedFilesDiff, buildPrioritizedDiff, truncate } from '../lib/diff-helpers.js'; -import { projectCodexDomainDocs, transformForTarget, assertTargetParity, syncSkillsSection, syncBehaviorSection } from '../lib/target-transform.js'; +import { projectCodexDomainDocs, transformForTarget, assertTargetParity, syncSkillsSection, syncBehaviorSection, ensureRootKeyFilesSection } from '../lib/target-transform.js'; import { isNoOpDiff } from '../lib/diff-classifier.js'; const READ_ONLY_TOOLS = ['Read', 'Glob', 'Grep']; @@ -115,6 +115,40 @@ function notifyLegacyHubBlockIfPresent(repoPath) { * can route writes per target and so the parity validator can compare slots * across targets. Use `flattenPublishedMap` when a flat list is needed. */ +/** + * No-LLM repair pass: re-injects the deterministic `## Skills` + `## Behavior` + * sections into the root instructions file from on-disk state. Runs from the + * no-op / "up to date" sync paths so missing-section drift is fixed every time + * the user invokes sync, even when no diff or LLM update happens. + * + * Returns the list of written file results (empty when nothing needed updating). + */ +function repairDeterministicSections(repoPath, sourceTarget, publishTargets, scan, graphSerialized = null) { + const instructionsFile = sourceTarget?.instructionsFile || 'CLAUDE.md'; + const instrPath = join(repoPath, instructionsFile); + if (!existsSync(instrPath)) return []; + + const existingSkills = findExistingSkills(repoPath, sourceTarget); + const baseSkillForList = existingSkills.find(s => s.name === 'base') || null; + const domainSkillsForList = existingSkills.filter(s => s.name !== 'base'); + const startContent = readFileSync(instrPath, 'utf8'); + + let updated = ensureRootKeyFilesSection(startContent); + updated = syncSkillsSection(updated, baseSkillForList, domainSkillsForList, sourceTarget, false); + updated = syncBehaviorSection(updated); + if (updated === startContent) return []; + + const baseFiles = [{ path: instructionsFile, content: updated }]; + const perTarget = publishFilesForTargets(baseFiles, sourceTarget, publishTargets, scan, graphSerialized, repoPath); + const files = flattenPublishedMap(perTarget); + const directWriteFiles = files.filter(f => !(f.path.endsWith('/AGENTS.md') && f.path !== 'AGENTS.md')); + const dirScopedFiles = files.filter(f => f.path.endsWith('/AGENTS.md') && f.path !== 'AGENTS.md'); + return [ + ...writeSkillFiles(repoPath, directWriteFiles, { force: true }), + ...writeTransformedFiles(repoPath, dirScopedFiles, { force: true }), + ]; +} + function publishFilesForTargets(baseFiles, sourceTarget, publishTargets, scan, graphSerialized = null, repoPath = null) { const perTarget = new Map(); @@ -200,7 +234,12 @@ export async function docSyncCommand(path, options) { diffSpinner.stop(`${changedFiles.length} files changed`); if (changedFiles.length === 0) { - p.outro('Nothing to sync'); + const repairs = repairDeterministicSections(repoPath, sourceTarget, publishTargets, scanRepo(repoPath)); + if (repairs.length > 0) { + console.log(); + for (const wr of repairs) console.log(` ${pc.yellow('~')} ${wr.path} ${pc.dim('(deterministic section repair)')}`); + } + p.outro(repairs.length > 0 ? 'No diffs — repaired deterministic sections' : 'Nothing to sync'); return; } @@ -215,7 +254,12 @@ export async function docSyncCommand(path, options) { // If a stale-format code-map is on disk (legacy `## Hub files` block), // force a graph rebuild so subsequent reads see the modern format. await regenerateStaleCodeMap(repoPath, sourceTarget, scanRepo(repoPath)); - p.outro('No sync needed'); + const repairs = repairDeterministicSections(repoPath, sourceTarget, publishTargets, scanRepo(repoPath)); + if (repairs.length > 0) { + console.log(); + for (const wr of repairs) console.log(` ${pc.yellow('~')} ${wr.path} ${pc.dim('(deterministic section repair)')}`); + } + p.outro(repairs.length > 0 ? 'No code changes — repaired deterministic sections' : 'No sync needed'); return; } @@ -396,6 +440,39 @@ ${truncate(instructionsContent, 5000)} p.log.warn('LLM responded without tags — treating as no updates needed.'); } } + + // Deterministic `## Skills` + `## Behavior` injection on the canonical + // instructions file. Runs whether or not the LLM updated it, so drift + // gets repaired every sync. Source target paths flow through unchanged; + // transformForTarget handles the codex/claude projection downstream. + { + const instrPath = join(repoPath, instructionsFile); + const pending = baseFiles.find(f => f.path === instructionsFile); + const startContent = pending + ? pending.content + : (existsSync(instrPath) ? readFileSync(instrPath, 'utf8') : null); + + if (startContent != null) { + const baseSkillForList = existingSkills.find(s => s.name === 'base') || null; + const domainSkillsForList = existingSkills.filter(s => s.name !== 'base'); + + let updated = ensureRootKeyFilesSection(startContent); + updated = syncSkillsSection( + updated, + baseSkillForList, + domainSkillsForList, + sourceTarget, + false + ); + updated = syncBehaviorSection(updated); + + if (updated !== startContent) { + if (pending) pending.content = updated; + else baseFiles.push({ path: instructionsFile, content: updated }); + } + } + } + const perTarget = publishFilesForTargets(baseFiles, sourceTarget, publishTargets, scan, graphSerialized, repoPath); const files = flattenPublishedMap(perTarget); @@ -708,8 +785,9 @@ async function refreshAllSkills(repoPath, options, sourceTarget, publishTargets const baseSkillForList = existingSkills.find(s => s.name === 'base') || null; const domainSkillsForList = existingSkills.filter(s => s.name !== 'base'); - let updated = syncSkillsSection( - startContent, + let updated = ensureRootKeyFilesSection(startContent); + updated = syncSkillsSection( + updated, baseSkillForList, domainSkillsForList, sourceTarget, diff --git a/src/lib/graph-persistence.js b/src/lib/graph-persistence.js index 6044257..0bbfbec 100644 --- a/src/lib/graph-persistence.js +++ b/src/lib/graph-persistence.js @@ -385,47 +385,16 @@ const MAX_MAP_HOTSPOTS = 5; export function generateCodeMap(serializedGraph) { const lines = ['## Codebase Structure\n']; - // Hub files block intentionally removed — counts/rankings move to code-map only - // (removed in Phase 1: stability — see dev/active/aspens-stability/plan.md). - // The graph hook still surfaces hubs at prompt-injection time when needed. - - // Domain clusters - if (serializedGraph.clusters?.length > 0) { - const multiFileClusters = serializedGraph.clusters.filter(c => c.size > 1); - if (multiFileClusters.length > 0) { - lines.push('**Domain clusters:**'); - for (const c of multiFileClusters) { - const topFiles = c.files - .filter(f => serializedGraph.files[f]) - .sort((a, b) => (serializedGraph.files[b].priority || 0) - (serializedGraph.files[a].priority || 0)) - .slice(0, 5) - .map(f => `\`${shortPath(f)}\``) - .join(', '); - lines.push(`- **${c.label}** (${c.size} files): ${topFiles}`); - } - lines.push(''); - } - } - - // Cross-domain coupling - if (serializedGraph.coupling?.length > 0) { - lines.push('**Cross-domain dependencies:**'); - for (const c of serializedGraph.coupling.slice(0, 5)) { - lines.push(`- ${c.from} \u2192 ${c.to} (${c.edges} imports)`); - } - lines.push(''); - } - - // Hotspots - if (serializedGraph.hotspots?.length > 0) { - lines.push('**Hotspots (high churn):**'); - for (const h of serializedGraph.hotspots.slice(0, MAX_MAP_HOTSPOTS)) { - lines.push(`- \`${h.path}\` — ${h.churn} changes, ${h.lines} lines`); - } + const clusterBlock = formatDomainClusters( + serializedGraph.clusters, + serializedGraph.files, + ); + if (clusterBlock) { + lines.push(clusterBlock); lines.push(''); } - // Framework entry points (Phase 3 — Next.js implicit roots) + // Framework entry points (Next.js implicit roots, etc.) if (serializedGraph.frameworkEntryPoints?.length > 0) { const grouped = groupFrameworkEntries(serializedGraph.frameworkEntryPoints); for (const [kind, entries] of grouped) { @@ -433,19 +402,67 @@ export function generateCodeMap(serializedGraph) { for (const entry of entries.slice(0, 20)) { lines.push(`- \`${entry.path}\``); } - if (entries.length > 20) { - lines.push(`- ... +${entries.length - 20} more`); - } lines.push(''); } } - lines.push(`*${serializedGraph.meta.totalFiles} files, ${serializedGraph.meta.totalEdges} edges — updated ${serializedGraph.meta.generatedAt.split('T')[0]}*`); - lines.push(''); - return lines.join('\n'); } +const MAX_CLUSTER_FILES = 5; + +/** + * Format the Domain clusters block. Stable across syncs: cluster files are + * sorted by fanIn (descending), then path (ascending), capped at + * MAX_CLUSTER_FILES per cluster. No counts, no totals, no hotspots. + */ +export function formatDomainClusters(clusters, files) { + if (!Array.isArray(clusters) || clusters.length === 0) return null; + if (!files) return null; + + const merged = mergeClustersByLabel(clusters); + if (merged.length === 0) return null; + + const out = ['**Domain clusters:**']; + let emitted = 0; + for (const cluster of merged) { + if (cluster.files.length < 2) continue; + const topFiles = cluster.files + .filter(path => files[path]) + .sort((a, b) => { + const fA = files[a].fanIn || 0; + const fB = files[b].fanIn || 0; + if (fB !== fA) return fB - fA; + return a.localeCompare(b); + }) + .slice(0, MAX_CLUSTER_FILES); + if (topFiles.length === 0) continue; + const formatted = topFiles.map(f => '`' + f + '`').join(', '); + out.push(`- **${cluster.label}**: ${formatted}`); + emitted++; + } + return emitted > 0 ? out.join('\n') : null; +} + +/** + * Merge clusters that share the same label. The graph builder produces one + * cluster per connected component, so isolated files (e.g., independent test + * files) become separate single-file clusters. For display purposes we collapse + * those into a single per-label entry. + */ +function mergeClustersByLabel(clusters) { + const byLabel = new Map(); + for (const cluster of clusters) { + const existing = byLabel.get(cluster.label); + if (existing) { + for (const file of (cluster.files || [])) existing.files.push(file); + } else { + byLabel.set(cluster.label, { label: cluster.label, files: [...(cluster.files || [])] }); + } + } + return [...byLabel.values()]; +} + /** * Write code-map to .claude/code-map.md. */ diff --git a/src/lib/skill-reader.js b/src/lib/skill-reader.js index 254e785..2118e4b 100644 --- a/src/lib/skill-reader.js +++ b/src/lib/skill-reader.js @@ -67,14 +67,104 @@ export function parseFrontmatter(content) { } /** - * Extract file patterns from ## Activation section. - * Returns string[] of patterns like ["src/lib/*.js", "src/prompts/**\/*"] - * Parses lines starting with `- \`` wrapped in backticks. + * Parse `triggers:` block from YAML frontmatter. + * Supports: + * triggers: + * files: + * - app/deps.py + * keywords: [auth, jwt] # inline array + * keywords: # block array + * - auth + * alwaysActivate: true + * + * Returns { filePatterns: string[], keywords: string[], alwaysActivate: boolean } + * Returns null when no `triggers:` key is present in frontmatter. + */ +export function parseTriggersFrontmatter(content) { + if (!content || typeof content !== 'string') return null; + + // Extract the frontmatter block (between first --- and second ---) + const fmMatch = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---/); + if (!fmMatch) return null; + + const block = fmMatch[1]; + + // Check if triggers: key exists at all + if (!/^triggers:/m.test(block)) return null; + + // Extract the triggers sub-block: from "triggers:" to the next top-level key or end + // Top-level YAML keys start at column 0 with no leading spaces + const triggersMatch = block.match(/^triggers:\s*\r?\n((?:[ \t]+[^\r\n]*\r?\n?)*)/m); + if (!triggersMatch) { + // "triggers:" with no sub-block — treat as empty triggers present + return { filePatterns: [], keywords: [], alwaysActivate: false }; + } + + const triggersBlock = triggersMatch[1]; + + // --- parse files: sub-key --- + const filePatterns = []; + const filesSubMatch = triggersBlock.match(/[ \t]+files:\s*\r?\n((?:[ \t]+-[^\r\n]*\r?\n?)*)/); + if (filesSubMatch) { + const listBlock = filesSubMatch[1]; + const itemRegex = /[ \t]+-\s*(.+)/g; + let m; + while ((m = itemRegex.exec(listBlock)) !== null) { + const val = m[1].trim().replace(/^['"]|['"]$/g, ''); + if (val) filePatterns.push(val); + } + } + + // --- parse keywords: sub-key (inline array OR block list) --- + const keywords = []; + // Inline: keywords: [auth, jwt, token] + const kwInlineMatch = triggersBlock.match(/[ \t]+keywords:\s*\[([^\]]*)\]/); + if (kwInlineMatch) { + kwInlineMatch[1] + .split(',') + .map(k => k.trim().replace(/^['"]|['"]$/g, '')) + .filter(Boolean) + .forEach(k => keywords.push(k)); + } else { + // Block list: keywords:\n - auth + const kwBlockMatch = triggersBlock.match(/[ \t]+keywords:\s*\r?\n((?:[ \t]+-[^\r\n]*\r?\n?)*)/); + if (kwBlockMatch) { + const listBlock = kwBlockMatch[1]; + const itemRegex = /[ \t]+-\s*(.+)/g; + let m; + while ((m = itemRegex.exec(listBlock)) !== null) { + const val = m[1].trim().replace(/^['"]|['"]$/g, ''); + if (val) keywords.push(val); + } + } + } + + // --- parse alwaysActivate: --- + let alwaysActivate = false; + const aaMatch = triggersBlock.match(/[ \t]+alwaysActivate:\s*(true|false)/i); + if (aaMatch) { + alwaysActivate = aaMatch[1].toLowerCase() === 'true'; + } + + return { filePatterns, keywords, alwaysActivate }; +} + +/** + * Extract file patterns from a skill file. + * Prefers `triggers.files` from YAML frontmatter when present; + * falls back to parsing `## Activation` section for backwards compatibility. + * Returns string[] of glob patterns. */ export function parseActivationPatterns(content) { if (!content || typeof content !== 'string') return []; - // Match the Activation section: from "## Activation" to the next "---" or "##" + // Prefer frontmatter triggers + const fromFrontmatter = parseTriggersFrontmatter(content); + if (fromFrontmatter !== null) { + return fromFrontmatter.filePatterns; + } + + // Fallback: parse ## Activation section (legacy) const activationMatch = content.match(/## Activation[\s\S]*?(?=\n---|\n## (?!Activation)|$)/); if (!activationMatch) return []; @@ -127,13 +217,21 @@ export function fileMatchesActivation(filePath, activationBlock, genericSegments } /** - * Extract keywords from ## Activation Keywords: line. + * Extract keywords from a skill file. + * Prefers `triggers.keywords` from YAML frontmatter when present; + * falls back to parsing `Keywords:` line in the `## Activation` section. * Returns string[] or empty array. */ export function parseKeywords(content) { if (!content || typeof content !== 'string') return []; - // Look for "Keywords:" line within the Activation section or as a standalone line + // Prefer frontmatter triggers + const fromFrontmatter = parseTriggersFrontmatter(content); + if (fromFrontmatter !== null) { + return fromFrontmatter.keywords; + } + + // Fallback: look for "Keywords:" line within ## Activation or as standalone const keywordsMatch = content.match(/Keywords:\s*(.+)/i); if (!keywordsMatch) return []; diff --git a/src/lib/skill-writer.js b/src/lib/skill-writer.js index c52a7ce..3840ada 100644 --- a/src/lib/skill-writer.js +++ b/src/lib/skill-writer.js @@ -1,6 +1,7 @@ import { mkdirSync, writeFileSync, existsSync } from 'fs'; import { join, dirname, basename } from 'path'; import { findSkillFiles, parseKeywords } from './skill-reader.js'; +import { sanitizePublishedContent } from './target-transform.js'; /** * Write parsed skill files to the target repo. @@ -27,7 +28,7 @@ export function writeSkillFiles(repoPath, files, options = {}) { // Create directories mkdirSync(dirname(fullPath), { recursive: true }); - writeFileSync(fullPath, file.content, 'utf8'); + writeFileSync(fullPath, sanitizePublishedContent(file.content, file.path), 'utf8'); results.push({ path: file.path, status: exists ? 'overwritten' : 'created' }); } @@ -71,7 +72,7 @@ export function writeTransformedFiles(repoPath, files, options = {}) { } mkdirSync(dirname(fullPath), { recursive: true }); - writeFileSync(fullPath, file.content, 'utf8'); + writeFileSync(fullPath, sanitizePublishedContent(file.content, file.path), 'utf8'); results.push({ path: file.path, status: exists ? 'overwritten' : 'created' }); } diff --git a/src/lib/target-transform.js b/src/lib/target-transform.js index b6e5842..efd7203 100644 --- a/src/lib/target-transform.js +++ b/src/lib/target-transform.js @@ -5,10 +5,11 @@ * Other targets are projected from that canonical output. */ -import { join } from 'path'; +import { join, relative } from 'path'; import { readFileSync } from 'fs'; import { TARGETS } from './target.js'; import { CliError } from './errors.js'; +import { findSkillFiles } from './skill-reader.js'; export function transformForTarget(files, sourceTarget, destTarget, context) { if (sourceTarget.id === destTarget.id) return files; @@ -66,7 +67,15 @@ function transformToDirectoryScoped(files, sourceTarget, destTarget, context) { file.path.startsWith(sourceTarget.skillsDir + '/') ); - const rootContent = buildRootInstructions(baseSkill, instructionsFile, domainSkills, graphSerialized, destTarget); + // The Skills section in the root instructions file (AGENTS.md/CLAUDE.md) + // must list every on-disk skill, not just the ones that changed in this + // sync. doc-sync passes only changed skills in `files`, so we re-read the + // full skill set from disk and let pending changes override on-disk content. + const { baseSkillForList, domainSkillsForList } = collectSkillsForList( + files, baseSkill, instructionsFile, sourceTarget, repoPath, + ); + + const rootContent = buildRootInstructions(baseSkillForList, instructionsFile, domainSkillsForList, graphSerialized, destTarget); if (rootContent) { result.push({ path: destTarget.instructionsFile, content: rootContent }); } @@ -103,6 +112,57 @@ function transformToDirectoryScoped(files, sourceTarget, destTarget, context) { return result; } +/** + * Build the full skill list for the Skills section of the root instructions + * file. Reads every skill from disk under `sourceTarget.skillsDir`, then + * overlays pending changes from `files` (by path) so newly-written content + * wins over stale on-disk content. + * + * Returns `{ baseSkillForList, domainSkillsForList }` in the shape + * `{ path, content }` expected by `buildSkillRefs`. + */ +function collectSkillsForList(files, pendingBaseSkill, instructionsFile, sourceTarget, repoPath) { + const baseSkillPrefix = sourceTarget.skillsDir + '/base/'; + const skillsPrefix = sourceTarget.skillsDir + '/'; + + const merged = new Map(); + + // Layer 1: on-disk skills (so unchanged skills survive a partial sync). + if (repoPath && sourceTarget.skillsDir) { + try { + const skillsDirAbs = join(repoPath, sourceTarget.skillsDir); + const onDisk = findSkillFiles(skillsDirAbs, { skillFilename: sourceTarget.skillFilename }); + for (const skill of onDisk) { + const relPath = relative(repoPath, skill.path).split('\\').join('/'); + merged.set(relPath, { path: relPath, content: skill.content }); + } + } catch { /* skills dir unreadable — fall through to pending-only */ } + } + + // Layer 2: pending changes override on-disk content. + for (const file of files) { + if (file === instructionsFile) continue; + if (!file.path.startsWith(skillsPrefix)) continue; + merged.set(file.path, file); + } + + let baseSkillForList = null; + const domainSkillsForList = []; + for (const entry of merged.values()) { + if (entry.path.startsWith(baseSkillPrefix)) { + baseSkillForList = entry; + } else { + domainSkillsForList.push(entry); + } + } + + if (!baseSkillForList && pendingBaseSkill) baseSkillForList = pendingBaseSkill; + + domainSkillsForList.sort((a, b) => a.path.localeCompare(b.path)); + + return { baseSkillForList, domainSkillsForList }; +} + function generateCodexSkillReferences(destTarget, graphSerialized) { const files = []; const skillsDir = destTarget.skillsDir; @@ -250,16 +310,23 @@ export function syncSkillsSection(content, baseSkill, domainSkills, destTarget, const skillRefs = buildSkillRefs(baseSkill, domainSkills, destTarget, hasArchitectureSkill); if (skillRefs.length === 0) return content; + // Strip Skill-variant headings the LLM may emit as a workaround for the + // "do not emit ## Skills" rule (e.g., ## Skills Reference, ## Skills Overview). + // The canonical `## Skills` (no trailing words) is preserved and handled below. + let working = content + .replace(/\n## Skills [^\n]+\r?\n[\s\S]*?(?=\r?\n## |\r?\n\*\*Last Updated|$)/gi, '\n') + .replace(/(\r?\n){3,}/g, '\n\n'); + const section = ['## Skills', '', ...skillRefs].join('\n'); - if (/## Skills\s*\n/i.test(content)) { - return content.replace(/## Skills\s*\n[\s\S]*?(?=\n## |\n\*\*Last Updated|$)/, section + '\n'); + if (/## Skills\s*\n/i.test(working)) { + return working.replace(/## Skills\s*\n[\s\S]*?(?=\n## |\n\*\*Last Updated|$)/, section + '\n'); } - const headingMatch = content.match(/^# .+\n?/); - if (!headingMatch) return section + '\n\n' + content; + const headingMatch = working.match(/^# .+\n?/); + if (!headingMatch) return section + '\n\n' + working; const insertAt = headingMatch[0].length; - return content.slice(0, insertAt) + '\n' + section + '\n\n' + content.slice(insertAt).trimStart(); + return working.slice(0, insertAt) + '\n' + section + '\n\n' + working.slice(insertAt).trimStart(); } function buildSkillRefs(baseSkill, domainSkills, destTarget, hasArchitectureSkill = false) { @@ -291,40 +358,11 @@ function extractFrontmatterField(content, field) { function generateCondensedCodeMap(serializedGraph) { const lines = []; - // Hub-files block intentionally removed — Phase 1: stability. - // Hub counts/rankings no longer flow into AGENTS.md/CLAUDE.md; - // they remain available via graph metadata + code-map. - - if (serializedGraph?.clusters?.length > 0) { - const multiFileClusters = serializedGraph.clusters.filter(cluster => cluster.size > 1); - if (multiFileClusters.length > 0) { - lines.push('**Domain clusters:**'); - lines.push(''); - lines.push('| Domain | Files | Top entries |'); - lines.push('|--------|-------|-------------|'); - for (const cluster of multiFileClusters.slice(0, 10)) { - const topFiles = cluster.files - .filter(file => serializedGraph.files[file]) - .sort((a, b) => (serializedGraph.files[b]?.priority || 0) - (serializedGraph.files[a]?.priority || 0)) - .slice(0, 3) - .map(shortPath) - .map(file => '`' + file + '`') - .join(', '); - lines.push('| ' + cluster.label + ' | ' + cluster.size + ' | ' + topFiles + ' |'); - } - lines.push(''); - } + const clusterBlock = condenseDomainClusters(serializedGraph); + if (clusterBlock) { + lines.push(clusterBlock); } - if (serializedGraph?.hotspots?.length > 0) { - lines.push('**High-churn hotspots:**'); - for (const hotspot of serializedGraph.hotspots.slice(0, 3)) { - lines.push('- `' + hotspot.path + '` - ' + hotspot.churn + ' changes'); - } - lines.push(''); - } - - // Phase 3 — Codex parity for framework entry points (Next.js implicit roots) const frameworkSection = condenseFrameworkEntryPoints(serializedGraph); if (frameworkSection) { lines.push(frameworkSection); @@ -333,6 +371,46 @@ function generateCondensedCodeMap(serializedGraph) { return lines.length > 0 ? lines.join('\n') : null; } +const CONDENSED_CLUSTER_FILES = 5; + +function condenseDomainClusters(serializedGraph) { + const clusters = serializedGraph?.clusters; + const files = serializedGraph?.files; + if (!Array.isArray(clusters) || clusters.length === 0) return ''; + if (!files) return ''; + + const merged = new Map(); + for (const cluster of clusters) { + const existing = merged.get(cluster.label); + if (existing) { + for (const file of (cluster.files || [])) existing.files.push(file); + } else { + merged.set(cluster.label, { label: cluster.label, files: [...(cluster.files || [])] }); + } + } + + const out = ['**Domain clusters:**']; + let emitted = 0; + for (const cluster of merged.values()) { + if (cluster.files.length < 2) continue; + const topFiles = cluster.files + .filter(p => files[p]) + .sort((a, b) => { + const fA = files[a].fanIn || 0; + const fB = files[b].fanIn || 0; + if (fB !== fA) return fB - fA; + return a.localeCompare(b); + }) + .slice(0, CONDENSED_CLUSTER_FILES); + if (topFiles.length === 0) continue; + out.push('- **' + cluster.label + '**: ' + topFiles.map(f => '`' + f + '`').join(', ')); + emitted++; + } + if (emitted === 0) return ''; + out.push(''); + return out.join('\n'); +} + function condenseFrameworkEntryPoints(serializedGraph) { const entries = serializedGraph?.frameworkEntryPoints; if (!Array.isArray(entries) || entries.length === 0) return ''; @@ -350,9 +428,7 @@ function condenseFrameworkEntryPoints(serializedGraph) { for (const item of items.slice(0, 10)) { out.push('- `' + item.path + '`'); } - if (items.length > 10) { - out.push('- ... +' + (items.length - 10) + ' more'); - } + // No "+N more" suffix — N varies between syncs and produces diff noise. out.push(''); } return out.join('\n'); @@ -399,6 +475,51 @@ function stripActivationSection(content) { ).replace(/(\r?\n){3,}/g, '\n\n'); } +/** + * Single-chokepoint sanitizer applied by the writers. Strips two classes of + * forbidden content that must never appear in any published file: + * + * 1. `## Activation` blocks (activation is removed from the system). + * 2. `## Key Files` blocks AND standalone hub/cluster/hotspot tables + * (count-bearing graph data belongs in code-map.md only). + * + * Defense-in-depth: callers may strip earlier, but every disk write also + * runs through this so a leak upstream can't reach the user. + */ +export function sanitizePublishedContent(content, filePath = '') { + if (!content) return content; + let result = content; + + // Always strip ## Activation blocks — they don't belong anywhere. + result = result.replace( + /\n## Activation\s*\r?\n[\s\S]*?(?=\r?\n## |\r?\n---|\r?\n\*\*Last Updated|$)/gi, + '\n' + ); + + // Always strip ## Key Files blocks — count-bearing data belongs in code-map only. + result = result.replace( + /\n## Key Files\s*\r?\n[\s\S]*?(?=\r?\n## |\r?\n\*\*Last Updated|$)/gi, + '\n' + ); + + // Domain clusters / framework entries are legitimate content in code-map.md. + // For instruction / skill files, strip them so graph data doesn't leak in. + const isCodeMap = filePath.endsWith('code-map.md'); + if (!isCodeMap) { + const forbiddenBlockHeadings = [ + /\n\*\*Hub files[^*\n]*\*\*[\s\S]*?(?=\r?\n## |\r?\n\*\*[A-Z]|\r?\n\*\*Last Updated|$)/gi, + /\n\*\*Domain clusters:\*\*[\s\S]*?(?=\r?\n## |\r?\n\*\*[A-Z]|\r?\n\*\*Last Updated|$)/gi, + /\n\*\*High-churn hotspots:\*\*[\s\S]*?(?=\r?\n## |\r?\n\*\*[A-Z]|\r?\n\*\*Last Updated|$)/gi, + /\n\*\*Framework entry points[^*\n]*\*\*[\s\S]*?(?=\r?\n## |\r?\n\*\*[A-Z]|\r?\n\*\*Last Updated|$)/gi, + ]; + for (const regex of forbiddenBlockHeadings) { + result = result.replace(regex, '\n'); + } + } + + return result.replace(/(\r?\n){3,}/g, '\n\n'); +} + function remapContentPaths(content, sourceTarget, destTarget) { let result = content; @@ -591,6 +712,10 @@ function logicalKeyForFile(filePath, target) { if (target.skillsDir && filePath.startsWith(skillsPrefix)) { const rest = filePath.slice(skillsPrefix.length); const domain = rest.split('/')[0]; + // The codex 'architecture' skill is synthetic — generated from graph + // data — and has no Claude counterpart by design (Claude reads the + // graph directly via .claude/graph.json + code-map.md). + if (target.id === 'codex' && domain === 'architecture') return null; return `SKILL:${domain}`; } diff --git a/src/lib/target.js b/src/lib/target.js index d5a0ae9..1562224 100644 --- a/src/lib/target.js +++ b/src/lib/target.js @@ -61,7 +61,7 @@ export const TARGETS = { supportsSkills: true, supportsMCP: false, needsActivationSection: false, - needsCodeMapEmbed: true, + needsCodeMapEmbed: false, maxInstructionsBytes: 32768, }, }; diff --git a/src/prompts/add-skill.md b/src/prompts/add-skill.md index e26265d..076f934 100644 --- a/src/prompts/add-skill.md +++ b/src/prompts/add-skill.md @@ -18,6 +18,17 @@ Return exactly one file: 1. **Extract actionable knowledge.** Focus on what an AI needs to write correct code or follow correct processes — not background reading. 2. **Be specific.** Use actual file paths, commands, and patterns from the reference doc and codebase. -3. **Write activation patterns.** Include file patterns and keywords that should trigger this skill. -4. **Keep it concise.** 30-60 lines. Distill the reference document down to its essential rules and patterns. -5. **Use the exact output format.** One `` tag with the path shown above. +3. **Do NOT emit a `## Activation` section.** Trigger metadata belongs in the `triggers:` frontmatter field, not in a markdown section. +4. **Emit `triggers:` in the frontmatter** with `files:` (array of globs matching the key files for this domain) and `keywords:` (array of terms that signal this skill is relevant). Example: + ```yaml + triggers: + files: + - app/deps.py + - app/api/v1/auth.py + keywords: + - auth + - jwt + - token + ``` +5. **Keep it concise.** 30-60 lines. Distill the reference document down to its essential rules and patterns. +6. **Use the exact output format.** One `` tag with the path shown above. diff --git a/src/prompts/doc-init-claudemd.md b/src/prompts/doc-init-claudemd.md index 6d80ab0..ce440b9 100644 --- a/src/prompts/doc-init-claudemd.md +++ b/src/prompts/doc-init-claudemd.md @@ -6,7 +6,7 @@ Generate the root project instructions file at `{{instructionsFile}}`. Keep it c From the scan results and generated skills, create the root project instructions file covering: repo summary + tech stack, key commands (dev/test/lint), and critical conventions. -**Do NOT generate a `## Skills` section.** aspens injects it deterministically after your output, listing every generated skill. If you write one it will be overwritten. +**Do NOT generate a `## Skills` section, or any Skills variant** (`## Skills Reference`, `## Skills Overview`, `## Skill Inventory`, etc.). aspens injects the canonical skill list deterministically after your output. Variants will be stripped. ## Output format @@ -19,7 +19,7 @@ Return exactly one file: ## Rules 1. Keep it concise — this file is loaded often, so shorter is better. -2. Do NOT emit a `## Skills` section. aspens injects the full skill list deterministically; anything you write will be overwritten. +2. Do NOT emit `## Skills` or any Skills-variant heading (`## Skills Reference`, `## Skills Overview`, `## Skill Inventory`, `## Available Skills`, etc.). aspens owns this section; variants will be stripped. 3. Include actual commands from the scan data, not placeholders. 4. Do NOT duplicate what's already in the skills — just reference them by name in prose where useful. 5. Do NOT emit a `## Behavior` section — aspens injects a fixed set of coding guardrails deterministically. Anything you write will be overwritten. diff --git a/src/prompts/doc-init-domain.md b/src/prompts/doc-init-domain.md index be7802f..3c4b706 100644 --- a/src/prompts/doc-init-domain.md +++ b/src/prompts/doc-init-domain.md @@ -18,7 +18,7 @@ Return exactly one file wrapped in XML tags: ## Rules -1. **Use YAML frontmatter** with `name` and `description` fields. +1. **Use YAML frontmatter** with `name`, `description`, and `triggers:` fields (see skill-format above for the `triggers:` schema). 2. **30-60 lines.** Concise and actionable. 3. **Be specific.** Use actual file paths, actual patterns from the code you read. 4. **Non-obvious knowledge only.** The base skill already covers the tech stack and general conventions. Focus on what's unique to THIS domain. diff --git a/src/prompts/doc-init.md b/src/prompts/doc-init.md index f25aa4b..1abc0b9 100644 --- a/src/prompts/doc-init.md +++ b/src/prompts/doc-init.md @@ -31,14 +31,14 @@ description: ... -[{{instructionsFile}} content — do NOT include a `## Skills` section; aspens injects it deterministically] +[{{instructionsFile}} content — do NOT include a `## Skills` section or any Skills variant (`## Skills Reference`, `## Skills Overview`, `## Available Skills`, etc.); aspens injects the canonical Skills list deterministically and strips variants] **Important:** Use `` and `` tags exactly as shown. Content between tags is written verbatim. Code blocks inside skills are fine — they won't break the parsing. ## Rules -1. **Use YAML frontmatter** with `name` and `description` fields. +1. **Use YAML frontmatter** with `name`, `description`, and `triggers:` fields (see skill-format above for the `triggers:` schema). 2. **30-60 lines per skill.** Concise and actionable. 3. **Be specific.** Use actual file paths, actual commands, actual patterns you found by reading the code. 4. **Non-obvious knowledge only.** Don't explain what the framework is. Explain how THIS project uses it. diff --git a/src/prompts/partials/examples.md b/src/prompts/partials/examples.md index 6788a09..5527bec 100644 --- a/src/prompts/partials/examples.md +++ b/src/prompts/partials/examples.md @@ -4,34 +4,40 @@ --- name: billing description: Stripe billing — subscriptions, usage tracking, webhooks +triggers: + files: + - stripe_service.py + - billing_service.py + - app/api/billing/** + keywords: + - billing + - stripe + - subscription + - webhook --- -## Activation +You are working on **billing, Stripe integration, and usage limits**. -This skill triggers when editing these files: -- `**/billing*.py` -- `**/stripe*.py` -- `**/usage*.py` +## Domain purpose +Handle paid subscriptions, usage quotas, and Stripe-driven account state. Every paid feature gates on a successful subscription check; usage limits prevent runaway costs on metered features. -Keywords: billing, stripe, subscription, usage limits +## Business rules / invariants +- Cancel = period-end, never immediate. Customers retain access until renewal date. +- Webhook endpoint has NO JWT auth — Stripe signature verification only. +- Usage limit hits return structured 429 with retry-after metadata; never silently degrade. ---- +## Non-obvious behaviors +- Subscription state is webhook-driven, not API-poll driven. Don't trust local DB without a recent webhook timestamp. +- All Stripe SDK calls run via `run_in_threadpool` (sync SDK, async app). -You are working on **billing, Stripe integration, and usage limits**. - -## Key Files +## Critical files - `stripe_service.py` — Stripe SDK wrapper (customer, checkout, webhook verify) -- `billing_service.py` — Subscription state (activate, cancel, plan switch) +- `billing_service.py` — Subscription state machine (activate, cancel, plan switch) - `usage_service.py` — Usage counters and limit checks -## Key Concepts -- **Webhook-driven:** State changes come from Stripe webhooks, not API calls -- **Usage gating:** `check_limit(user_id, limit_type)` returns structured 429 data - ## Critical Rules -- All Stripe SDK calls use `run_in_threadpool` (sync SDK, async app) -- Webhook endpoint has NO JWT auth — Stripe signature verification only -- Cancel = `cancel_at_period_end=True` (access until period end) +- Never set `cancel_at_period_end=False` on cancel — that immediately voids access. +- Webhook signature verification must run before any state mutation. --- **Last Updated:** 2026-03-18 diff --git a/src/prompts/partials/skill-format.md b/src/prompts/partials/skill-format.md index 34ad1ef..dc23212 100644 --- a/src/prompts/partials/skill-format.md +++ b/src/prompts/partials/skill-format.md @@ -8,12 +8,8 @@ Skill = markdown file at `{{skillsDir}}/{domain}/{{skillFilename}}` with YAML fr --- name: base description: Core conventions, tech stack, and project structure for [repo-name] ---- - -## Activation - -This is a **base skill** that always loads when working in this repository. - +triggers: + alwaysActivate: true --- You are working in **[repo-name]**. @@ -40,15 +36,12 @@ You are working in **[repo-name]**. --- name: [domain-name] description: [One-line description] ---- - -## Activation - -This skill triggers when editing these files: -- `[file pattern]` - -Keywords: keyword1, keyword2 - +triggers: + files: + - [glob-pattern-matching-key-files] + keywords: + - [primary-keyword] + - [secondary-keyword] --- You are working on **[domain description]**. @@ -79,5 +72,6 @@ You are working on **[domain description]**. - 30-60 lines max. Only what an AI needs to write correct code. - Be specific: real file paths, real commands, real patterns. - Non-obvious knowledge only — don't explain the framework, explain THIS project's usage. -- Activation: file patterns as `- \`glob\`` lines; `Keywords:` comma-separated. Base skill uses "always loads" sentence instead. -- **Lead with business behavior, not file inventory.** Forbidden in skills: file counts, hub names, dependency tallies, line counts, "most depended on" rankings — the graph supplies these dynamically. Skills are about WHAT the code does for the business and WHY, not metadata about the code. +- **Do NOT emit a `## Activation` section.** Trigger metadata belongs in the `triggers:` frontmatter field, not in a markdown section. Hub/graph data lives in code-map and graph metadata, not in skills. +- **Emit `triggers:` in frontmatter** with `files:` (array of globs matching key files for this domain) and `keywords:` (array of terms that signal this skill is relevant). For the base skill, use `triggers:\n alwaysActivate: true` instead. Do NOT emit `## Key Files` hub counts, file counts, hub rankings, dependency tallies, line counts, or "most depended on" lists. +- **Lead with business behavior, not file inventory.** Skills are about WHAT the code does for the business and WHY. diff --git a/tests/graph-persistence.test.js b/tests/graph-persistence.test.js index 9be9c6e..72aff9c 100644 --- a/tests/graph-persistence.test.js +++ b/tests/graph-persistence.test.js @@ -380,25 +380,45 @@ describe('generateCodeMap', () => { expect(map).not.toMatch(/^\*\*Hub files/m); }); - it('still surfaces hub-file paths via domain clusters', () => { + it('includes Domain clusters block (language-agnostic structural data)', () => { const map = generateCodeMap(graph); - // scanner.js still appears via the cluster listing even though the - // dedicated `Hub files` block was removed. + expect(map).toContain('**Domain clusters:**'); + expect(map).toContain('**src**'); expect(map).toContain('src/lib/scanner.js'); }); - it('includes hotspots', () => { + it('drops single-file clusters (config/declaration noise)', () => { + // Fixture's `tests` cluster has only 1 file — should be filtered out. const map = generateCodeMap(graph); - expect(map).toContain('Hotspots'); + expect(map).not.toContain('**tests**'); }); - it('includes graph stats', () => { + it('omits per-cluster (N files) counts (churn-prone on every sync)', () => { const map = generateCodeMap(graph); - expect(map).toContain('6 files'); - expect(map).toContain('5 edges'); + // No "(5 files)" / "(1 file)" suffixes after cluster labels. + expect(map).not.toMatch(/\*\*\s*\(\d+\s+files?\)/); + expect(map).not.toMatch(/-\s+\w+\s+\(\d+\s+files?\):/); + }); + + it('omits cross-domain dependencies block', () => { + const map = generateCodeMap(graph); + expect(map).not.toContain('**Cross-domain dependencies:**'); + }); + + it('omits hotspots block (churn counts change every sync)', () => { + const map = generateCodeMap(graph); + expect(map).not.toContain('Hotspots'); + expect(map).not.toMatch(/\d+ changes/); + }); + + it('omits totals/date footer (single biggest source of sync diff noise)', () => { + const map = generateCodeMap(graph); + expect(map).not.toMatch(/\d+ files, \d+ edges/); + expect(map).not.toMatch(/updated \d{4}-\d{2}-\d{2}/); }); }); + // --------------------------------------------------------------------------- // writeCodeMap // --------------------------------------------------------------------------- diff --git a/tests/skill-reader-triggers.test.js b/tests/skill-reader-triggers.test.js new file mode 100644 index 0000000..47b5fb8 --- /dev/null +++ b/tests/skill-reader-triggers.test.js @@ -0,0 +1,281 @@ +import { describe, it, expect } from 'vitest'; +import { + parseTriggersFrontmatter, + parseActivationPatterns, + parseKeywords, +} from '../src/lib/skill-reader.js'; + +// ─── parseTriggersFrontmatter ─────────────────────────────────────────────── + +describe('parseTriggersFrontmatter', () => { + it('returns null when no frontmatter present', () => { + expect(parseTriggersFrontmatter('No frontmatter here')).toBeNull(); + }); + + it('returns null when frontmatter has no triggers: key', () => { + const content = `--- +name: auth +description: Auth domain +--- + +Content here.`; + expect(parseTriggersFrontmatter(content)).toBeNull(); + }); + + it('parses files as block list', () => { + const content = `--- +name: auth +description: JWT auth +triggers: + files: + - app/deps.py + - app/api/v1/auth.py + keywords: + - auth + - jwt +--- + +Content.`; + const result = parseTriggersFrontmatter(content); + expect(result).not.toBeNull(); + expect(result.filePatterns).toEqual(['app/deps.py', 'app/api/v1/auth.py']); + expect(result.keywords).toEqual(['auth', 'jwt']); + expect(result.alwaysActivate).toBe(false); + }); + + it('parses keywords as inline array', () => { + const content = `--- +name: billing +description: Stripe billing +triggers: + files: + - stripe_service.py + keywords: [billing, stripe, subscription] +--- + +Content.`; + const result = parseTriggersFrontmatter(content); + expect(result).not.toBeNull(); + expect(result.keywords).toEqual(['billing', 'stripe', 'subscription']); + expect(result.filePatterns).toEqual(['stripe_service.py']); + }); + + it('parses alwaysActivate: true for base skill', () => { + const content = `--- +name: base +description: Core conventions +triggers: + alwaysActivate: true +--- + +Content.`; + const result = parseTriggersFrontmatter(content); + expect(result).not.toBeNull(); + expect(result.alwaysActivate).toBe(true); + expect(result.filePatterns).toEqual([]); + expect(result.keywords).toEqual([]); + }); + + it('returns empty arrays when triggers: key is present but empty', () => { + const content = `--- +name: orphan +description: No sub-fields +triggers: +name2: something +--- + +Content.`; + const result = parseTriggersFrontmatter(content); + expect(result).not.toBeNull(); + expect(result.filePatterns).toEqual([]); + expect(result.keywords).toEqual([]); + expect(result.alwaysActivate).toBe(false); + }); + + it('handles glob patterns in files', () => { + const content = `--- +name: payments +description: Payment processing +triggers: + files: + - app/payments/** + - src/lib/stripe*.js + keywords: + - payment +--- + +Content.`; + const result = parseTriggersFrontmatter(content); + expect(result.filePatterns).toEqual(['app/payments/**', 'src/lib/stripe*.js']); + }); + + it('returns null for empty content', () => { + expect(parseTriggersFrontmatter('')).toBeNull(); + expect(parseTriggersFrontmatter(null)).toBeNull(); + expect(parseTriggersFrontmatter(undefined)).toBeNull(); + }); +}); + +// ─── parseActivationPatterns — frontmatter path ───────────────────────────── + +describe('parseActivationPatterns with frontmatter triggers', () => { + it('returns filePatterns from triggers: when present', () => { + const content = `--- +name: auth +description: JWT auth +triggers: + files: + - app/deps.py + - app/api/v1/auth.py + keywords: + - auth +--- + +Content here.`; + const patterns = parseActivationPatterns(content); + expect(patterns).toEqual(['app/deps.py', 'app/api/v1/auth.py']); + }); + + it('returns empty array when triggers: present but no files', () => { + const content = `--- +name: base +description: Base skill +triggers: + alwaysActivate: true +--- + +Content.`; + const patterns = parseActivationPatterns(content); + expect(patterns).toEqual([]); + }); + + it('frontmatter triggers takes precedence over ## Activation section', () => { + // Both present — frontmatter wins + const content = `--- +name: auth +description: Auth +triggers: + files: + - new/path.py + keywords: + - auth +--- + +## Activation + +- \`old/legacy.py\` + +Content.`; + const patterns = parseActivationPatterns(content); + expect(patterns).toEqual(['new/path.py']); + expect(patterns).not.toContain('old/legacy.py'); + }); +}); + +// ─── parseActivationPatterns — legacy ## Activation fallback ──────────────── + +describe('parseActivationPatterns legacy ## Activation fallback', () => { + it('falls back to ## Activation when no triggers: in frontmatter', () => { + const content = `--- +name: scanner +description: Scanner +--- + +## Activation + +- \`src/lib/scanner.js\` +- \`src/commands/doc-init.js\` + +Keywords: scanning + +--- + +Content.`; + const patterns = parseActivationPatterns(content); + expect(patterns).toContain('src/lib/scanner.js'); + expect(patterns).toContain('src/commands/doc-init.js'); + }); + + it('returns empty array when neither triggers: nor ## Activation present', () => { + const content = `--- +name: bare +description: No trigger info +--- + +Just content.`; + expect(parseActivationPatterns(content)).toEqual([]); + }); +}); + +// ─── parseKeywords — frontmatter path ─────────────────────────────────────── + +describe('parseKeywords with frontmatter triggers', () => { + it('returns keywords from triggers: when present (block list)', () => { + const content = `--- +name: auth +description: Auth +triggers: + keywords: + - auth + - jwt + - token +--- + +Content.`; + expect(parseKeywords(content)).toEqual(['auth', 'jwt', 'token']); + }); + + it('returns keywords from triggers: when present (inline array)', () => { + const content = `--- +name: billing +description: Billing +triggers: + keywords: [billing, stripe, webhook] +--- + +Content.`; + expect(parseKeywords(content)).toEqual(['billing', 'stripe', 'webhook']); + }); + + it('returns empty array when triggers: present but no keywords', () => { + const content = `--- +name: base +description: Base +triggers: + alwaysActivate: true +--- + +Content.`; + expect(parseKeywords(content)).toEqual([]); + }); + + it('falls back to Keywords: line when no triggers:', () => { + const content = `--- +name: scanner +description: Scanner +--- + +## Activation + +- \`src/lib/scanner.js\` + +Keywords: scanning, graph-builder + +--- + +Content.`; + const keywords = parseKeywords(content); + expect(keywords).toContain('scanning'); + expect(keywords).toContain('graph-builder'); + }); + + it('returns empty array when no triggers: and no Keywords: line', () => { + const content = `--- +name: bare +description: No keywords +--- + +Content.`; + expect(parseKeywords(content)).toEqual([]); + }); +}); diff --git a/tests/target-transform.test.js b/tests/target-transform.test.js index 962c0bc..ac04d82 100644 --- a/tests/target-transform.test.js +++ b/tests/target-transform.test.js @@ -1,5 +1,8 @@ -import { describe, it, expect } from 'vitest'; -import { transformForTarget, validateTransformedFiles, projectCodexDomainDocs, ensureRootKeyFilesSection, syncSkillsSection, syncBehaviorSection } from '../src/lib/target-transform.js'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { transformForTarget, validateTransformedFiles, projectCodexDomainDocs, ensureRootKeyFilesSection, syncSkillsSection, syncBehaviorSection, assertTargetParity } from '../src/lib/target-transform.js'; import { TARGETS } from '../src/lib/target.js'; const mockScanResult = { @@ -124,6 +127,90 @@ describe('transformForTarget', () => { }); }); +// Regression: doc-sync passes only the CHANGED skills in `files`. The Skills +// section of AGENTS.md/CLAUDE.md must still list every on-disk skill — earlier +// builds were truncating the list to just the in-flight subset. +describe('transformForTarget — Skills section completeness (regression)', () => { + const __dirname = dirname(fileURLToPath(import.meta.url)); + const fixtureRoot = join(__dirname, 'fixtures', 'skills-completeness'); + + beforeAll(() => { + rmSync(fixtureRoot, { recursive: true, force: true }); + const skillsDir = join(fixtureRoot, '.claude', 'skills'); + mkdirSync(skillsDir, { recursive: true }); + + const skills = [ + ['base', '---\nname: base\ndescription: Core conventions\n---\n\nBase content.\n'], + ['billing', '---\nname: billing\ndescription: Stripe billing flows\n---\n\nBilling.\n'], + ['auth', '---\nname: auth\ndescription: JWT + Supabase auth\n---\n\nAuth.\n'], + ['payments', '---\nname: payments\ndescription: Webhook + checkout handling\n---\n\nPayments.\n'], + ['profile', '---\nname: profile\ndescription: User profile, settings, and stats\n---\n\nProfile.\n'], + ['platform', '---\nname: platform\ndescription: Cross-cutting infra and middleware\n---\n\nPlatform.\n'], + ]; + + for (const [name, body] of skills) { + mkdirSync(join(skillsDir, name), { recursive: true }); + writeFileSync(join(skillsDir, name, 'skill.md'), body, 'utf8'); + } + + writeFileSync( + join(fixtureRoot, 'CLAUDE.md'), + '# Test Repo\n\nOverview.\n\n## Skills\n\n- `.claude/skills/base/skill.md` — old listing\n', + 'utf8', + ); + }); + + afterAll(() => { + if (existsSync(fixtureRoot)) rmSync(fixtureRoot, { recursive: true, force: true }); + }); + + it('lists every on-disk skill in AGENTS.md even when files only contains one changed skill', () => { + // doc-sync's typical call shape: only the changed skill flows through `files`. + const files = [ + { path: '.claude/skills/billing/skill.md', content: '---\nname: billing\ndescription: Stripe billing flows (updated)\n---\n\nBilling (new content).\n' }, + ]; + + const result = transformForTarget( + files, + TARGETS.claude, + TARGETS.codex, + { scanResult: { domains: [] }, repoPath: fixtureRoot } + ); + + const rootAgents = result.find(f => f.path === 'AGENTS.md'); + expect(rootAgents).toBeDefined(); + + // Every on-disk skill should appear in the Skills section. + expect(rootAgents.content).toContain('.agents/skills/base/SKILL.md'); + expect(rootAgents.content).toContain('.agents/skills/billing/SKILL.md'); + expect(rootAgents.content).toContain('.agents/skills/auth/SKILL.md'); + expect(rootAgents.content).toContain('.agents/skills/payments/SKILL.md'); + expect(rootAgents.content).toContain('.agents/skills/profile/SKILL.md'); + expect(rootAgents.content).toContain('.agents/skills/platform/SKILL.md'); + + // Pending changes should win for descriptions. + expect(rootAgents.content).toContain('Stripe billing flows (updated)'); + }); + + it('falls back to pending-only Skills list when repoPath is unavailable', () => { + const files = [ + { path: '.claude/skills/billing/skill.md', content: '---\nname: billing\ndescription: Billing\n---\n\nBilling.\n' }, + { path: '.claude/skills/base/skill.md', content: '---\nname: base\ndescription: Base\n---\n\nBase.\n' }, + ]; + + const result = transformForTarget( + files, + TARGETS.claude, + TARGETS.codex, + { scanResult: { domains: [] } } + ); + + const rootAgents = result.find(f => f.path === 'AGENTS.md'); + expect(rootAgents.content).toContain('.agents/skills/base/SKILL.md'); + expect(rootAgents.content).toContain('.agents/skills/billing/SKILL.md'); + }); +}); + describe('projectCodexDomainDocs', () => { it('projects codex skills into directory AGENTS docs', () => { const files = [ @@ -253,6 +340,25 @@ describe('syncSkillsSection', () => { expect(out).toContain('.agents/skills/architecture/SKILL.md'); }); + it('does not raise a parity violation for the codex-only synthetic architecture skill', () => { + const claudeFiles = [ + { path: 'CLAUDE.md', content: '# x' }, + { path: '.claude/skills/base/skill.md', content: '---\nname: base\n---\n' }, + { path: '.claude/skills/auth/skill.md', content: '---\nname: auth\n---\n' }, + ]; + const codexFiles = [ + { path: 'AGENTS.md', content: '# x' }, + { path: '.agents/skills/base/SKILL.md', content: '---\nname: base\n---\n' }, + { path: '.agents/skills/auth/SKILL.md', content: '---\nname: auth\n---\n' }, + { path: '.agents/skills/architecture/SKILL.md', content: '---\nname: architecture\n---\n' }, + ]; + const perTarget = new Map([ + ['claude', claudeFiles], + ['codex', codexFiles], + ]); + expect(() => assertTargetParity(perTarget)).not.toThrow(); + }); + it('extracts domain names when source skill paths use the Codex skillsDir', () => { const codexBase = { path: '.agents/skills/base/SKILL.md', content: '---\nname: base\ndescription: Base\n---\n' }; const codexDomains = [ From 125144faf714cdac67d41a8acc61be8627b83c01 Mon Sep 17 00:00:00 2001 From: mvoutov Date: Mon, 11 May 2026 15:46:49 -0700 Subject: [PATCH 4/7] update docs --- .agents/skills/agent-customization/SKILL.md | 59 +++-- .../architecture/references/code-map.md | 20 +- .agents/skills/base/SKILL.md | 31 ++- .agents/skills/claude-runner/SKILL.md | 43 ++-- .agents/skills/cli-shell/SKILL.md | 53 ++++ .agents/skills/codex-support/SKILL.md | 49 ++-- .agents/skills/doc-impact/SKILL.md | 90 ++++--- .agents/skills/doc-sync/SKILL.md | 54 ++--- .agents/skills/import-graph/SKILL.md | 49 ++-- .agents/skills/repo-scanning/SKILL.md | 50 ++-- .agents/skills/save-tokens/SKILL.md | 95 ++++---- .agents/skills/skill-generation/SKILL.md | 72 +++--- .agents/skills/template-library/SKILL.md | 42 ++-- .claude/hooks/graph-context-prompt.sh | 6 +- .claude/hooks/post-tool-use-tracker.sh | 47 ++-- .claude/hooks/skill-activation-prompt.mjs | 14 +- .claude/hooks/skill-activation-prompt.sh | 4 +- .claude/settings.json | 12 +- .claude/settings.json.bak | 22 ++ .claude/skills/agent-customization/skill.md | 59 +++-- .claude/skills/base/skill.md | 31 ++- .claude/skills/claude-runner/skill.md | 43 ++-- .claude/skills/cli-shell/skill.md | 53 ++++ .claude/skills/codex-support/skill.md | 51 ++-- .claude/skills/doc-impact/skill.md | 90 ++++--- .claude/skills/doc-sync/skill.md | 54 ++--- .claude/skills/import-graph/skill.md | 49 ++-- .claude/skills/repo-scanning/skill.md | 47 ++-- .claude/skills/save-tokens/skill.md | 95 ++++---- .claude/skills/skill-generation/skill.md | 72 +++--- .claude/skills/skill-rules.json | 175 +++++++++---- .claude/skills/template-library/skill.md | 42 ++-- AGENTS.md | 36 ++- CLAUDE.md | 20 +- src/commands/doc-sync.js | 5 +- src/lib/graph-builder.js | 4 +- src/lib/target-transform.js | 8 +- tests/target-transform.test.js | 229 +++++++++++++++++- 38 files changed, 1237 insertions(+), 738 deletions(-) create mode 100644 .agents/skills/cli-shell/SKILL.md create mode 100644 .claude/skills/cli-shell/skill.md diff --git a/.agents/skills/agent-customization/SKILL.md b/.agents/skills/agent-customization/SKILL.md index 26f5204..a7f308f 100644 --- a/.agents/skills/agent-customization/SKILL.md +++ b/.agents/skills/agent-customization/SKILL.md @@ -1,39 +1,46 @@ --- name: agent-customization description: LLM-powered injection of project context into installed agent templates via `aspens customize agents` +triggers: + files: + - src/commands/customize.js + - src/prompts/customize-agents.md + keywords: + - customize + - agents + - subagent + - agent customization --- -## Activation - -This skill triggers when editing agent-customization files: -- `src/commands/customize.js` -- `src/prompts/customize-agents.md` +You are working on **agent customization** — the feature that reads a project's skills and AGENTS.md, then uses Claude CLI to inject project-specific context into generic agent files in `.claude/agents/`. ---- +## Domain purpose +`aspens customize agents` makes generic, bundled agent templates project-aware. It pulls the repo's skills + AGENTS.md as ground truth and asks Claude to add a tech-stack line, 3-5 project conventions, and real commands into each agent — without touching the agent's core logic. -You are working on **agent customization** — the feature that reads a project's skills and AGENTS.md, then uses Claude CLI to inject project-specific context into generic agent files in `.claude/agents/`. +## Business rules / invariants +- **Claude-only feature.** Throws `CliError` for Codex-only repos (`config.targets === ['codex']`). Codex CLI has no agent concept. +- **Base skill is required.** Pre-flight throws `CliError("Run 'aspens doc init' first — base skill is required for agent context.")` if `.agents/skills/base/SKILL.md` is missing. +- **Skills (`.claude/skills/**`) are the single source of truth** for project context. The prompt must not invent other context directories. +- **Read-only tools only.** Claude is invoked with `allowedTools: ['Read', 'Glob', 'Grep']` — no edits/writes from the LLM itself. +- **Output paths restricted to `.claude/`.** `parseFileOutput()` rejects anything else; `writeSkillFiles(..., { force: true })` does the actual write. -## Key Files -- `src/commands/customize.js` — Main command: finds agents, gathers context, calls Claude, writes results -- `src/prompts/customize-agents.md` — System prompt telling Claude how to customize agents -- `src/lib/runner.js` — `runClaude()`, `loadPrompt()`, `parseFileOutput()` shared across commands -- `src/lib/skill-writer.js` — `writeSkillFiles()` writes parsed output to disk -- `src/lib/timeout.js` — `resolveTimeout()` for timeout handling (default 300s) +## Non-obvious behaviors +- **Frontmatter preservation is split across LLM + code.** The prompt instructs Claude to preserve YAML frontmatter verbatim (including NOT adding a `skills:` line). Then `maybeInjectBaseSkill()` post-processes each returned file to add `skills: [base]` into the frontmatter — this keeps agents valid even when installed via `aspens add agent` without a prior `doc init`. +- **`--reset` semantics:** without `--reset`, agents that already declare `skills:` are left alone; with `--reset`, any existing `skills:` line is overwritten to `skills: [base]`. Used to roll out v0.8 upgrades to previously-customized agents. +- **`## Project context` block is verbatim-preserved** by the prompt — it carries conditional read instructions for code-map / domain skills. +- **AGENTS.md is truncated at 3000 chars** in `gatherProjectContext()`; skills are passed in full. +- **Agent discovery:** `findAgents()` recursively walks `.claude/agents/`, extracts `name:` via regex, falls back to filename if missing. +- **Default timeout 300s** via `resolveTimeout(options.timeout, 300)`; `ASPENS_TIMEOUT` env var honored with warning on invalid value. -## Key Concepts -- **Claude-only feature:** Customize command reads `.aspens.json` and throws `CliError` if repo is configured for Codex-only (`targets: ['codex']`). Codex CLI has no agent concept. -- **Context gathering:** `gatherProjectContext()` reads AGENTS.md (truncated at 3000 chars), all `.claude/skills/**/*.md` in full, and lists `.claude/guidelines/` paths without reading their contents. -- **Agent discovery:** `findAgents()` recursively walks `.claude/agents/`, reads `.md` files, extracts `name:` via regex — falls back to filename if no frontmatter match. -- **Read-only tools:** Claude is invoked with `allowedTools: ['Read', 'Glob', 'Grep']` and no maxTokens cap (unlike doc-init which sets per-call limits). -- **Output parsing:** Claude returns `content` XML tags, parsed by `parseFileOutput()`. Only `.claude/` paths are allowed. +## Critical files (purpose, not inventory) +- `src/commands/customize.js` — orchestrator: preflight, agent discovery, context gathering, per-agent Claude calls, post-LLM `skills: [base]` injection, write. +- `src/prompts/customize-agents.md` — system prompt; enforces frontmatter + `## Project context` preservation and bans file-inventory / hub-ranking output. ## Critical Rules -- **Claude-only** — throws `CliError` for Codex-only repos. Checks `readConfig(repoPath)` for target config. -- **Read-only tools only** — Claude agents never get write tools. All output goes through `parseFileOutput()` → `writeSkillFiles()`. -- **Context truncation** — AGENTS.md is capped at 3000 chars to avoid blowing up prompt size. Skills are read in full. -- **Path safety** — `parseFileOutput()` only allows writes to `.claude/` prefixed paths. Customized agents stay in `.claude/agents/`. -- **Dry-run support** — `--dry-run` flag previews output without writing. Confirmation prompt shown before writes. -- **Model override** — `--model` flag passed through to `runClaude()` for model selection. +- **Never let the LLM emit a `skills:` line** — the prompt forbids it and the code adds it. If you change one, change both. +- **Never weaken path sanitization** — only `.claude/` paths may be written. +- **Never duplicate file-inventory or hub-ranking output** in customized agents — the graph hook supplies that dynamically. +- **Do not bypass the base-skill preflight** — agents without base context regress to generic behavior. --- -**Last Updated:** 2026-04-02 +**Last Updated:** 2026-05-11 diff --git a/.agents/skills/architecture/references/code-map.md b/.agents/skills/architecture/references/code-map.md index c2fbb48..a132744 100644 --- a/.agents/skills/architecture/references/code-map.md +++ b/.agents/skills/architecture/references/code-map.md @@ -1,22 +1,6 @@ # Code Map -## Key Files - -**Hub files (most depended-on):** -- `src/lib/runner.js` - 9 dependents -- `src/lib/target.js` - 9 dependents -- `src/lib/errors.js` - 8 dependents -- `src/lib/scanner.js` - 8 dependents -- `src/lib/skill-writer.js` - 7 dependents - **Domain clusters:** - -| Domain | Files | Top entries | -|--------|-------|-------------| -| src | 45 | `src/commands/doc-init.js`, `src/lib/runner.js`, `src/lib/target.js` | - -**High-churn hotspots:** -- `src/commands/doc-init.js` - 35 changes -- `src/commands/doc-sync.js` - 21 changes -- `src/lib/runner.js` - 17 changes +- **src**: `src/lib/target.js`, `src/lib/errors.js`, `src/lib/runner.js`, `src/lib/scanner.js`, `src/lib/skill-reader.js` +- **tests**: `tests/agent-templates-project-context.test.js`, `tests/hook-runtime.test.js`, `tests/no-guidelines-refs.test.js`, `tests/save-tokens-hook-lib.test.js`, `tests/save-tokens-prompt-guard.test.js` diff --git a/.agents/skills/base/SKILL.md b/.agents/skills/base/SKILL.md index b788d5a..460a104 100644 --- a/.agents/skills/base/SKILL.md +++ b/.agents/skills/base/SKILL.md @@ -1,18 +1,14 @@ --- name: base description: Core conventions, tech stack, and project structure for aspens ---- - -## Activation - -This is a **base skill** that always loads when working in this repository. - +triggers: + alwaysActivate: true --- You are working in **aspens** — a CLI that keeps coding-agent context accurate as your codebase changes. Scans repos, generates project-specific instructions and skills for Claude Code and Codex CLI, and keeps them fresh. ## Tech Stack -Node.js (ESM) | Commander | Vitest | es-module-lexer | @clack/prompts | picocolors +Node.js 20+ (ESM) | Commander | Vitest | es-module-lexer | @clack/prompts | picocolors ## Commands - `npm test` — Run vitest suite @@ -25,6 +21,8 @@ Node.js (ESM) | Commander | Vitest | es-module-lexer | @clack/prompts | picocolo - `aspens add [name]` — Install templates (agents, commands, hooks) - `aspens customize agents` — Inject project context into installed agents - `aspens save-tokens [path]` — Install token-saving session settings (`--recommended` for no-prompt install, `--remove` to uninstall) +- **Debug:** `ASPENS_DEBUG=1` dumps raw stream events to `$TMPDIR/aspens-debug-{stream,codex-stream}.json` +- **Env knob:** `ASPENS_TIMEOUT` (seconds) overrides default LLM timeout when `--timeout` not passed ## Architecture CLI entry (`bin/cli.js`) → command handlers (`src/commands/`) → lib modules (`src/lib/`) @@ -32,10 +30,11 @@ CLI entry (`bin/cli.js`) → command handlers (`src/commands/`) → lib modules - `src/lib/scanner.js` — Deterministic repo scanner (languages, frameworks, domains, structure) - `src/lib/graph-builder.js` — Static import analysis via es-module-lexer (hub files, clusters, priority) - `src/lib/graph-persistence.js` — Graph serialization, subgraph extraction, code-map + index generation -- `src/lib/runner.js` — Claude/Codex CLI wrapper (`runClaude` for stream-json, `runCodex` for Codex JSONL) +- `src/lib/runner.js` — Claude/Codex CLI wrapper (`runClaude` for stream-json, `runCodex` for Codex JSONL); also hosts `loadPrompt` (partial substitution) and `parseFileOutput`/`validateSkillFiles` - `src/lib/context-builder.js` — Assembles repo files into prompt-friendly context - `src/lib/skill-writer.js` — Writes skill files and directory-scoped files, generates skill-rules.json, merges settings -- `src/lib/skill-reader.js` — Parses skill files, frontmatter, activation patterns, keywords +- `src/lib/skill-reader.js` — Parses skill files, frontmatter, `triggers:` blocks, legacy activation patterns, keywords +- `src/lib/diff-classifier.js` — Maps changed files to affected skills for doc-sync - `src/lib/diff-helpers.js` — Targeted file diffs and prioritized diff truncation for doc-sync - `src/lib/git-helpers.js` — Git repo detection, git root resolution, diff retrieval, log formatting - `src/lib/git-hook.js` — Post-commit git hook installation/removal for auto doc-sync (monorepo-aware) @@ -43,9 +42,12 @@ CLI entry (`bin/cli.js`) → command handlers (`src/commands/`) → lib modules - `src/lib/save-tokens.js` — Save-tokens config defaults, settings builders, gitignore/readme generators - `src/lib/timeout.js` — Timeout resolution (`--timeout` flag > `ASPENS_TIMEOUT` env > default) - `src/lib/errors.js` — `CliError` class (structured errors caught by CLI top-level handler) -- `src/lib/target.js` — Target definitions (claude/codex), config persistence (`.aspens.json`) with `saveTokens` feature config +- `src/lib/target.js` — Target definitions (claude/codex), config persistence (`.aspens.json`) with `saveTokens` feature config; `getAllowedPaths` for multi-target sanitization - `src/lib/target-transform.js` — Transforms Claude-format output to other target formats - `src/lib/backend.js` — Backend detection and resolution (which CLI generates content) +- `src/lib/path-resolver.js` / `src/lib/source-exts.js` — Source-file extension and path resolution helpers shared by scanner/graph +- `src/lib/parsers/` — Language-specific import parsers (TypeScript, Python) +- `src/lib/frameworks/` — Framework-specific detectors (e.g. Next.js) - `src/prompts/` — Prompt templates with `{{partial}}` and `{{variable}}` substitution - `src/templates/` — Bundled agents, commands, hooks, and settings for `aspens add` / `doc init` / `save-tokens` @@ -54,12 +56,15 @@ CLI entry (`bin/cli.js`) → command handlers (`src/commands/`) → lib modules - **es-module-lexer WASM** — must `await init` before calling `parse()` in graph-builder - **Claude CLI execution** — `runClaude()` spawns `claude -p` with stream-json; always use `--verbose` flag with stream-json - **Codex CLI execution** — `runCodex()` spawns `codex exec --json --sandbox read-only --ask-for-approval never --ephemeral`; returns `{ text, usage }` matching `runClaude` interface -- **Path sanitization** — `parseFileOutput()` restricts writes to `.claude/` and `AGENTS.md` by default; accepts `allowedPaths` override for multi-target +- **Stdin with backpressure** — `runClaude`/`runCodex` pipe prompts via stdin and respect `drain` when `write()` returns false; never rewrite to use args (shell length limits) +- **Path sanitization** — `parseFileOutput()` restricts writes to `.claude/` and `AGENTS.md` by default; accepts `allowedPaths` override for multi-target via `getAllowedPaths(targets)` +- **Read-only LLM tools** — customize-style commands pass `allowedTools: ['Read', 'Glob', 'Grep']`; never broaden without review - **Prompt partials** — `{{name}}` in prompt files resolves to `src/prompts/partials/name.md` first, then falls back to template variables -- **Target/Backend distinction** — Target = output format/location; Backend = which LLM CLI generates content. Config persisted in `.aspens.json` +- **Target/Backend distinction** — Target = output format/location; Backend = which LLM CLI generates content. Config persisted in `.aspens.json`. Customize is Claude-only (`CliError` if `targets: ['codex']`) - **Scanner is deterministic** — no LLM calls; pure filesystem analysis - **CliError pattern** — command handlers throw `CliError` instead of calling `process.exit()`; caught at top level in `bin/cli.js` - **Monorepo support** — `getGitRoot()` resolves the actual git root; hooks, sync, and impact scope to the subdirectory project path +- **Verify before claiming** — Never state something is configured/running/done without confirming in-session ## Structure - `bin/` — CLI entry point (commander setup, CliError handler) @@ -70,4 +75,4 @@ CLI entry (`bin/cli.js`) → command handlers (`src/commands/`) → lib modules - `tests/` — Vitest test files --- -**Last Updated:** 2026-04-09 +**Last Updated:** 2026-05-11 diff --git a/.agents/skills/claude-runner/SKILL.md b/.agents/skills/claude-runner/SKILL.md index cd0c4d3..098f73d 100644 --- a/.agents/skills/claude-runner/SKILL.md +++ b/.agents/skills/claude-runner/SKILL.md @@ -1,29 +1,30 @@ --- name: claude-runner description: Claude/Codex CLI execution layer — prompt loading, stream-json parsing, file output extraction, path sanitization, skill file writing, and skill rule generation ---- - -## Activation - -This skill triggers when editing claude-runner files: -- `src/lib/runner.js` -- `src/lib/skill-writer.js` -- `src/lib/skill-reader.js` -- `src/lib/timeout.js` -- `src/prompts/**/*.md` -- `tests/*extract*`, `tests/*parse*`, `tests/*prompt*`, `tests/*skill-writer*`, `tests/*skill-mapper*`, `tests/*timeout*` - +triggers: + files: + - src/lib/runner.js + - src/lib/timeout.js + - src/lib/skill-writer.js + - src/lib/skill-reader.js + - src/prompts/**/*.md + keywords: + - runClaude + - runCodex + - runLLM + - stream-json + - codex exec + - parseFileOutput + - sanitizePath + - loadPrompt + - writeSkillFiles + - skill-rules + - mergeSettings + - resolveTimeout --- You are working on the **CLI execution layer** — the bridge between assembled prompts and the `claude -p` / `codex exec` CLIs, plus skill file I/O. -## Key Files -- `src/lib/runner.js` — `runClaude()`, `runCodex()`, `runLLM()`, `loadPrompt()`, `parseFileOutput()`, `validateSkillFiles()`, `extractResultFromStream()` (exported); `extractResultFromCodexStream()`, `normalizeCodexItemType()`, `collectCodexText()`, `handleStreamEvent()`, `sanitizePath()`, `getCodexExecCapabilities()` (internal) -- `src/lib/skill-writer.js` — `writeSkillFiles()`, `writeTransformedFiles()`, `extractRulesFromSkills()`, `generateDomainPatterns()`, `mergeSettings()` -- `src/lib/skill-reader.js` — `findSkillFiles()`, `parseFrontmatter()`, `parseActivationPatterns()`, `parseKeywords()`, `fileMatchesActivation()`, `getActivationBlock()`, `GENERIC_PATH_SEGMENTS` -- `src/lib/timeout.js` — `resolveTimeout()` — priority: `--timeout` flag > `ASPENS_TIMEOUT` env var > caller fallback -- `src/prompts/` — Markdown prompt templates; `partials/` subdir holds `skill-format.md`, `guideline-format.md`, `examples.md` - ## Key Concepts - **Stream-JSON protocol (Claude):** `runClaude()` always passes `--verbose --output-format stream-json`. Output is NDJSON: `type: 'result'` has final text + usage; `type: 'assistant'` has text/tool_use blocks; `type: 'user'` has tool_result blocks. - **JSONL protocol (Codex):** `runCodex()` spawns `codex exec --json --sandbox read-only --ephemeral`. The `--ask-for-approval never` flag is **conditionally included** based on capability detection (see below). Prompt is passed via **stdin** (`'-'` placeholder arg) to avoid shell arg length limits. Stdin write happens **after** event handlers are attached so fast failures are captured. Events: `item.completed`/`item.updated` with normalized types. @@ -36,6 +37,7 @@ You are working on the **CLI execution layer** — the bridge between assembled - **Validation:** `validateSkillFiles()` checks for truncation (XML tag collisions), missing frontmatter, missing sections, bad file path references. - **Skill rules generation:** `extractRulesFromSkills()` reads all skills via `skill-reader.js`, produces `skill-rules.json` (v2.0) with file patterns, keywords, and intent patterns. - **Domain patterns:** `generateDomainPatterns()` converts file patterns to bash `detect_skill_domain()` function using `BEGIN/END` markers. +- **Trigger parsing precedence:** `parseTriggersFrontmatter(content)` returns `{ filePatterns, keywords, alwaysActivate }` parsed from a `triggers:` block in YAML frontmatter (supports block lists, inline arrays, and `alwaysActivate: true` for the base skill); returns `null` when no `triggers:` key exists. `parseActivationPatterns` and `parseKeywords` prefer this frontmatter when present and fall back to legacy `## Activation` / `Keywords:` line parsing for older skills. - **Settings merge:** `mergeSettings()` merges aspens hook config into existing `settings.json`. Detects aspens-managed hooks by `ASPENS_HOOK_MARKERS` (`skill-activation-prompt`, `graph-context-prompt`, `post-tool-use-tracker`, `save-tokens-statusline`, `save-tokens-prompt-guard`, `save-tokens-precompact`). Also handles `statusLine` merging — replaces existing statusLine only if the current one is aspens-managed (detected by `isAspensHook`), preserving user-custom statusLine configs. After merging hooks, `dedupeAspensHookEntries()` removes duplicate aspens-managed entries per event type. - **Directory-scoped writes:** `writeTransformedFiles()` handles files outside `.claude/` (e.g., `src/billing/AGENTS.md`) with explicit path allowlist — only `AGENTS.md`, `AGENTS.md` exact files and `.claude/`, `.agents/`, `.codex/` prefixes are permitted. - **`findSkillFiles` matching:** Only matches the exact `skillFilename` (e.g., `skill.md` or `SKILL.md`), not arbitrary `.md` files in the skills directory. @@ -47,8 +49,9 @@ You are working on the **CLI execution layer** — the bridge between assembled - **Path sanitization is non-negotiable** — `sanitizePath()` blocks `..` traversal, absolute paths, and any path not in the allowed set. - **Prompt partials resolve before variables** — `{{skill-format}}` resolves to `partials/skill-format.md` first. If no file, falls through to variable substitution. - **Timeout resolution:** `resolveTimeout(flagValue, fallbackSeconds)` — `--timeout` flag wins, then `ASPENS_TIMEOUT` env, then caller-provided fallback. Size-based defaults (small: 120s, medium: 300s, large: 600s, very-large: 900s) are set by command handlers, not runner. +- **Disk writes are sanitized** — `writeSkillFiles` and `writeTransformedFiles` pass every payload through `sanitizePublishedContent` so forbidden blocks (`## Activation`, `## Key Files`, hub/cluster/hotspot tables outside `code-map.md`) cannot leak to disk even if an earlier stage missed them. - **`mergeSettings` preserves non-aspens hooks and statusLine** — identifies aspens hooks by `ASPENS_HOOK_MARKERS` (now includes save-tokens markers), replaces matching entries, preserves everything else. StatusLine only replaced if current one is aspens-managed. Post-merge deduplication ensures no duplicate aspens entries accumulate. - **Debug mode:** Set `ASPENS_DEBUG=1` to dump raw stream-json to `$TMPDIR/aspens-debug-stream.json` (Claude) or `$TMPDIR/aspens-debug-codex-stream.json` (Codex). Codex also logs exit code and output length to stderr. --- -**Last Updated:** 2026-04-10 +**Last Updated:** 2026-05-11 diff --git a/.agents/skills/cli-shell/SKILL.md b/.agents/skills/cli-shell/SKILL.md new file mode 100644 index 0000000..a187338 --- /dev/null +++ b/.agents/skills/cli-shell/SKILL.md @@ -0,0 +1,53 @@ +--- +name: cli-shell +description: Top-level Commander wiring, welcome screen, missing-hook warning, CliError exit handling, and the public programmatic API surface +triggers: + files: + - bin/cli.js + - src/index.js + - src/lib/errors.js + keywords: + - CliError + - commander + - bin/cli.js + - welcome + - checkMissingHooks + - parsePositiveInt + - parseTimeout + - SIGINT + - SIGTERM + - aspens public api +--- + +You are working on the **CLI shell** — the entry point that wires Commander subcommands, prints the welcome screen, warns about missing Claude hooks, dispatches to handlers, and translates `CliError` into a clean exit. Also the public programmatic surface re-exported from `src/index.js`. + +## Domain purpose +This layer is what a user actually invokes (`aspens …`) and what programmatic consumers import. It owns argument parsing, top-level error handling, and the welcome UX. All real work lives in `src/commands/*.js` — the shell only routes. + +## Business rules / invariants +- **Handlers must throw `CliError`, never call `process.exit()`.** The top-level handler in `bin/cli.js:250` catches it, prints `Error: ` (unless `logged: true`) in red, and exits with `err.exitCode` (default 1). Plain `Error` falls through to the same printer but always exits 1. +- **`logged: true` means "I already printed a user-friendly message"** — top level then exits silently with the given code. Use it when the handler rendered a clack `outro` or multi-line failure already. +- **`checkMissingHooks(repoPath)` runs before `doc sync`, `add`, and `customize`** — warns (does not throw) when `.claude/skills/` exists but `.claude/hooks/skill-activation-prompt.sh` or `.claude/skills/skill-rules.json` is absent. Skipped entirely when `.claude/skills/` is missing (nothing to activate). +- **No-command invocation shows `showWelcome()`** — listing essential commands, generate/sync, Claude add-ons, utilities, options, typical workflow, and target notes. Adding a new subcommand requires updating this screen too. +- **Template counts in the welcome are filesystem-derived** — `countTemplates(subdir)` reads `src/templates/{agents,commands,hooks}` and filters dotfiles; returns `'?'` on read failure (never throws). +- **Version comes from `package.json`** at runtime via `readFileSync`; falls back to `'0.0.0'` silently if parse/read fails. Do not hardcode. +- **Numeric option parsers throw `InvalidArgumentError`** (Commander-native) — `parsePositiveInt` rejects ≤0/NaN; `parseCommits` additionally caps at 50. +- **Signal handlers exit with conventional codes** — SIGINT→130, SIGTERM→143. Used to clean up spawned `claude -p` / `codex exec` children. + +## Non-obvious behaviors +- **Action wrappers chain `checkMissingHooks` before the handler** for `doc sync`, `add`, `customize` — done inline via arrow `(args, options) => { checkMissingHooks(resolve(path)); return handler(...) }`. Don't move this into the handler — the warning should fire even if the handler later fails or short-circuits. +- **`program.parseAsync()` is required** (not `.parse()`) — handlers are async; `.catch()` on the returned promise is the only place plain errors are surfaced. +- **`src/index.js` is the public programmatic API** — only re-exports `scanRepo`, `runClaude`, `loadPrompt`, `parseFileOutput`, `writeSkillFiles`, `buildContext`, `buildBaseContext`, `buildDomainContext`, `analyzeImpact`. Adding/removing a re-export is a breaking change for embedders; treat it as such. + +## Critical files +- `bin/cli.js` — Commander setup, option parsers, welcome screen, signal handlers, top-level `CliError` catch. +- `src/lib/errors.js` — `CliError` class with `exitCode` and `logged` options (plus optional `cause`). +- `src/index.js` — Stable programmatic surface for library consumers. + +## Critical Rules +- New subcommand → register on `program` (or the `doc` subgroup) **and** add it to `showWelcome()` so users discover it. +- Never swallow a handler error in the action wrapper — let it bubble to `program.parseAsync().catch()`. +- When a handler renders its own failure UX (clack/picocolors), throw `new CliError(msg, { logged: true, exitCode })` so the top level does not double-print. + +--- +**Last Updated:** 2026-05-11 diff --git a/.agents/skills/codex-support/SKILL.md b/.agents/skills/codex-support/SKILL.md index 7ba45da..df7e371 100644 --- a/.agents/skills/codex-support/SKILL.md +++ b/.agents/skills/codex-support/SKILL.md @@ -1,58 +1,63 @@ --- name: codex-support description: Multi-target output system — target abstraction, backend routing, content transforms for Codex CLI and future targets +triggers: + files: + - src/lib/target.js + - src/lib/target-transform.js + - src/lib/backend.js + - AGENTS.md + - .aspens.json + keywords: + - codex + - target + - backend + - AGENTS.md + - .aspens.json + - sanitize + - transform + - multi-target + - parity --- -## Activation - -This skill triggers when editing codex-support files: -- `src/lib/target.js` -- `src/lib/target-transform.js` -- `src/lib/backend.js` -- `tests/target.test.js` -- `tests/target-transform.test.js` -- `tests/backend.test.js` - -Keywords: codex, target, backend, AGENTS.md, directory-scoped, transform, multi-target - --- You are working on **multi-target output support** — the system that lets aspens generate documentation for Claude Code, Codex CLI, or both simultaneously. -## Key Files -- `src/lib/target.js` — Target definitions (`TARGETS`), `getAllowedPaths()`, `mergeConfiguredTargets()`, path helpers, config persistence (`.aspens.json`) with feature config support (`saveTokens`) -- `src/lib/target-transform.js` — Transforms Claude-format output to other target formats; `projectCodexDomainDocs()`, `validateTransformedFiles()`, `ensureRootKeyFilesSection()`, content sanitization -- `src/lib/backend.js` — Backend detection (`detectAvailableBackends`) and resolution (`resolveBackend`) with fallback logic - ## Key Concepts - **Target vs Backend:** Target = where output goes (claude → `.claude/skills/`, codex → `.agents/skills/` + directory-scoped `AGENTS.md`). Backend = which LLM CLI generates the content (`claude -p` or `codex exec`). -- **Target definitions:** `TARGETS.claude` (centralized) and `TARGETS.codex` (directory-scoped). Each defines paths and capability flags: `supportsHooks`, `supportsSettings`, `supportsGraph`, `supportsSkills`, `needsActivationSection`, `needsCodeMapEmbed`, `supportsMCP`. Codex also has `maxInstructionsBytes` (32 KiB) and `userSkillsDir`. +- **Target definitions:** `TARGETS.claude` (centralized) and `TARGETS.codex` (directory-scoped). Each defines paths and capability flags: `supportsHooks`, `supportsSettings`, `supportsGraph`, `supportsSkills`, `needsActivationSection`, `needsCodeMapEmbed`, `supportsMCP`. Codex also has `maxInstructionsBytes` (32 KiB) and `userSkillsDir`. Codex's `needsCodeMapEmbed` is `false` — condensed cluster/framework data goes into the synthetic `.agents/skills/architecture/` skill instead of the root AGENTS.md. - **Canonical generation:** Generation always produces Claude-canonical format first. Prompts always receive `CANONICAL_VARS` (hardcoded Claude paths from `doc-init.js`). Transforms run **after** generation to produce other target formats. - **Content transform:** `transformForTarget()` remaps paths and content. For Codex: base skill → root `AGENTS.md`, domain skills → both `.agents/skills/{domain}/SKILL.md` and source directory `AGENTS.md`. `generateCodexSkillReferences()` creates `.agents/skills/architecture/` with code-map data. -- **Instructions file disk fallback:** `transformToDirectoryScoped` loads `instructionsFile` from disk via `repoPath` context parameter when it's not in the canonical files array (e.g., during `doc init --strategy skip-existing` or incremental `doc sync`). Uses `existsSync`/`readFileSync` from `fs`. +- **Skills section completeness:** `collectSkillsForList()` (internal) reads every skill from disk under `sourceTarget.skillsDir` and overlays pending in-flight changes (`files` passed to the transform) so the root instructions file's `## Skills` section always lists every on-disk skill — not just the subset that changed in this sync. Pending changes win for descriptions; on-disk content survives for unchanged skills. +- **Instructions file disk fallback:** `transformToDirectoryScoped` loads `instructionsFile` from disk via `repoPath` context parameter when it's not in the canonical files array (e.g., during `doc init --strategy skip-existing` or incremental `doc sync`). Uses a single `readFileSync` from `fs` wrapped in try/catch (no separate `existsSync` check). - **Content sanitization:** `sanitizeCodexInstructions()` and `sanitizeCodexSkill()` strip Claude-specific references (hooks, skill-rules.json, Claude Code mentions) from Codex output. +- **`sanitizePublishedContent(content, filePath)`** — Single-chokepoint sanitizer invoked by `skill-writer.js` on every disk write. Always strips `## Activation` blocks and `## Key Files` blocks. Outside `code-map.md`, also strips count-bearing blocks: `**Hub files…**`, `**Domain clusters:**`, `**High-churn hotspots:**`, `**Framework entry points…**`. Defense in depth — upstream leaks can't reach the user. +- **Skills-variant stripping:** `syncSkillsSection()` removes LLM-emitted Skill-section variants (`## Skills Reference`, `## Skills Overview`, etc.) before injecting the canonical `## Skills` list. Doc-init and doc-sync prompts also forbid such headings. - **`ensureRootKeyFilesSection(content, graphSerialized)`** — Post-processes root instructions file to guarantee a `## Key Files` section with top hub files from the graph. - **`mergeConfiguredTargets(existing, next)`** — Merges target arrays to avoid dropping previously configured targets during narrower runs. Validates against `TARGETS` keys, deduplicates. - **`getAllowedPaths(targets)`** — Returns `{ dirPrefixes, exactFiles }` union across all active targets. - **Backend detection:** `detectAvailableBackends()` checks if `claude` and `codex` CLIs are installed. `resolveBackend()` picks best match: explicit flag > target match > fallback. - **Config persistence:** `.aspens.json` at repo root stores `{ targets, backend, version, saveTokens? }`. `readConfig()` returns `null` if missing **or if the config is structurally invalid**. `isValidConfig()` validates targets, backend, version, and `saveTokens` (via `isValidSaveTokensConfig()`). +- **`loadConfig(repoPath, { persist })`** — Reads `.aspens.json` and, if missing, recovers via `inferConfig()` from on-disk artifacts. Returns `{ config, recovered }`. Persists inferred config to disk by default unless `persist: false` is passed. - **Feature config (`saveTokens`):** Optional object in `.aspens.json` validated by `isValidSaveTokensConfig()` — checks `enabled` (boolean), `warnAtTokens`/`compactAtTokens` (positive integers, compact > warn unless either is `MAX_SAFE_INTEGER`), `saveHandoff`/`sessionRotation` (booleans), optional `claude`/`codex` sub-objects with `enabled` and `mode`. - **`writeConfig` preserves feature config:** `writeConfig()` reads existing config and merges — `saveTokens` preserved unless explicitly set to `null` (intentional removal) or `undefined` (keep existing). Targets and backend also merge with existing. - **Multi-target publish:** `doc-sync` uses `publishFilesForTargets()` to generate output for all configured targets from a single LLM run. `repoPath` is passed through to the transform context. - **Codex inference tightened:** `inferConfig()` only adds `'codex'` to inferred targets when `.codex/` config dir or `.agents/skills/` dir exists. - **Conditional architecture ref:** Codex `buildCodexSkillRefs()` only includes the architecture skill reference when a graph was actually serialized. +- **Architecture skill is codex-only synthetic:** The codex `architecture` skill is generated from graph data and has no Claude counterpart by design. `logicalKeyForFile()` returns `null` for codex `architecture` paths so `assertTargetParity()` won't raise a parity violation for the missing Claude side. ## Critical Rules - **Generation always targets Claude canonical format first** — transforms run after, never during. Prompts always receive `CANONICAL_VARS`. -- **Split write logic:** `writeSkillFiles()` handles direct-write files. `writeTransformedFiles()` handles directory-scoped `AGENTS.md` with an explicit path allowlist and warn-and-skip policy. +- **Split write logic:** `writeSkillFiles()` handles direct-write files. `writeTransformedFiles()` handles directory-scoped `AGENTS.md` with an explicit path allowlist and warn-and-skip policy. Both writers run their payloads through `sanitizePublishedContent` before touching disk. - **Path safety:** `validateTransformedFiles()` rejects absolute paths, traversal, and unexpected filenames. `writeTransformedFiles()` enforces the same checks. - **Codex-only restrictions:** `add agent/command/hook` and `customize agents` throw `CliError` for Codex-only repos. `add skill` works for both targets. - **Graph/hooks are Claude-only** — `persistGraphArtifacts()` returns data without writing files when `target.supportsGraph === false`. Hook installation skipped when `supportsHooks === false`. - **Config validation is defensive** — `readConfig()` treats malformed but parseable JSON (e.g., wrong types for `targets`/`backend`/`version`/`saveTokens`) as invalid and returns `null`, same as missing config. -- **`repoPath` context is required for disk fallback** — callers of `transformForTarget` must pass `repoPath` in the context object for `instructionsFile` to load from disk when not in canonical files. +- **`repoPath` context is required for disk fallback** — callers of `transformForTarget` must pass `repoPath` in the context object for `instructionsFile` to load from disk when not in canonical files, and for `collectSkillsForList` to enumerate on-disk skills. ## References - **Patterns:** See `src/lib/target.js` for all target property definitions --- -**Last Updated:** 2026-04-25 +**Last Updated:** 2026-05-11 diff --git a/.agents/skills/doc-impact/SKILL.md b/.agents/skills/doc-impact/SKILL.md index 47a5fca..72b3dea 100644 --- a/.agents/skills/doc-impact/SKILL.md +++ b/.agents/skills/doc-impact/SKILL.md @@ -1,60 +1,54 @@ --- name: doc-impact description: Context health analysis — freshness, domain coverage, hub surfacing, drift detection, LLM-powered interpretation, and auto-repair for generated agent context ---- - -## Activation - -This skill triggers when editing doc-impact files: -- `src/commands/doc-impact.js` -- `src/lib/impact.js` -- `src/prompts/impact-analyze.md` -- `tests/impact.test.js` -- `tests/doc-impact.test.js` - -Keywords: impact, freshness, coverage, drift, health score, context health, hook health, usefulness - +triggers: + files: + - src/commands/doc-impact.js + - src/lib/impact.js + - src/prompts/impact-analyze.md + - tests/impact.test.js + - tests/doc-impact.test.js + keywords: + - impact + - freshness + - coverage + - drift + - health score + - context health + - hook health + - usefulness --- You are working on **doc impact** — the command that shows whether generated agent context is keeping up with the codebase, optionally interprets results via LLM, and can interactively apply recommended repairs. -## Key Files -- `src/commands/doc-impact.js` — CLI command: calls `analyzeImpact()`, renders per-target report with health scores, coverage, drift, usefulness, hook health, save-tokens health, LLM interpretation, opportunities, and interactive apply confirmation -- `src/lib/impact.js` — Core analysis: `analyzeImpact()` orchestrates scan + config + graph + per-target summarization; exports `evaluateHookHealth()`, `evaluateSaveTokensHealth()`, `summarizeOpportunities()`, `summarizeValueComparison()`, `summarizeMissing()` -- `src/prompts/impact-analyze.md` — System prompt for LLM-powered impact interpretation (returns JSON with `bottom_line`, `improves`, `risks`, `next_step`) -- `tests/impact.test.js` — Unit tests for coverage, drift, health score, status, report summarization, value comparison, missing rollup, hook health, save-tokens health, opportunities -- `tests/doc-impact.test.js` — Unit tests for `buildApplyPlan()` and `buildApplyConfirmationMessage()` +## Domain purpose +Audits the agent context generated by aspens against the live source tree, reports per-target health (Claude, Codex), and offers an interactive `--apply` flow that re-runs the right `aspens doc init/sync` variant to repair gaps. The LLM interpretation is a thin layer on top of deterministic metrics — the metrics are the contract. + +## Business rules / invariants +- **Target inference:** If `.aspens.json` is absent, targets are inferred from scan results (`.claude/` → claude, `.agents/` → codex). Falls back to `['claude']`. +- **LLM is optional and tool-less.** Runs only when a CLI backend is detected. `runLLM` is invoked with `disableTools: true`; prompt must return pure JSON (`bottom_line`, `improves`, `risks`, `next_step`). Failure is caught and reported as "Analysis unavailable" — never fatal. +- **Graph failure is non-fatal.** If `buildRepoGraph` throws (or `--no-graph` is passed), `graph` is `null` and hub coverage is skipped/`n/a`. +- **Hub coverage haystack is the code-map, not AGENTS.md.** Post-Phase 1, `## Key Files` no longer lives in root instructions. `computeHubCoverage` reads `.claude/code-map.md` (claude) or `.agents/skills/architecture/references/code-map.md` (codex). If the code-map file is missing, it reports `codeMapMissing: true` instead of spurious "missing hub" warnings. Older callers without `repoPath` fall back to the legacy `contextText` haystack. +- **Health score deductions** (start 100): missing instructions −35; no skills −25; domain gaps proportional up to −25; missed hubs −4 each; drift −3 per file (cap −20); unhealthy hooks −10 (hook-capable targets only); broken save-tokens −5 (claude only). +- **`LOW_SIGNAL_DOMAIN_NAMES`** (`config`, `test`, `tests`, `__tests__`, `spec`, `e2e`) are excluded from coverage scoring but tracked in `excluded`. +- **`SOURCE_EXTS`** extends the scanner set with `.scala`, `.clj`, `.elm`, `.vue`, `.svelte` for drift detection. Adding a language for drift requires updating this set. +- **Walk depth capped at 5** — deep nested source files won't appear in drift analysis. -## Key Concepts -- **`analyzeImpact(repoPath, options)`** — Main entry point. Runs `scanRepo()`, loads config from `.aspens.json`, infers targets if not configured, collects source file state, optionally builds import graph, then produces per-target reports. Now also computes `summary.opportunities`. -- **Target inference:** If no `.aspens.json` config, infers targets from scan results (`.claude/` → claude, `.agents/` → codex). Falls back to `['claude']`. -- **`summarizeTarget()`** — Per-target analysis: finds skills, evaluates hook health, evaluates save-tokens health (Claude only), checks instruction file existence, computes domain coverage, hub coverage, drift, usefulness, status, health score, and recommended actions. -- **Domain coverage:** `computeDomainCoverage()` matches scan-detected domains against installed skills. Filters out `LOW_SIGNAL_DOMAIN_NAMES` (config, test, tests, __tests__, spec, e2e) from scoring — tracked in `excluded` field. -- **Hub coverage:** `computeHubCoverage()` checks if top 5 graph hub file paths appear in the instruction file + base skill text. -- **Drift detection:** `computeDrift()` finds source files modified after the latest generated context mtime. Maps changed files to affected domains via directory matching. -- **Health score:** `computeHealthScore()` starts at 100, deducts for: missing instructions (-35), no skills (-25), domain gaps (up to -25), missed hubs (-4 each), drift (-3 per file, max -20), unhealthy hooks (-10 for Claude), broken save-tokens (-5 for Claude). -- **Hook health:** `evaluateHookHealth(repoPath)` checks for required hook scripts, validates `settings.json` hook commands resolve to existing files. -- **Save-tokens health:** `evaluateSaveTokensHealth(repoPath, saveTokensConfig)` checks if configured save-tokens installation is complete — validates required hook files, command files, legacy file cleanup, and settings.json entries. Returns `{ configured, healthy, issues, missingHookFiles, missingCommandFiles, invalidCommands, installedLegacyHookFiles }`. -- **Opportunities:** `summarizeOpportunities(repoPath, targets, config)` identifies optional aspens features not yet installed: save-tokens, agents, agent customization, doc-sync hook. Each returns `{ kind, message, command }`. Displayed in the "Missing Aspens Setup" section. -- **Usefulness summary:** `summarizeUsefulness()` produces `{ strengths, blindSpots, activationExamples }` per target. -- **Value comparison:** `summarizeValueComparison(targets)` computes before/after metrics for the report header. -- **Missing rollup:** `summarizeMissing(targets)` aggregates cross-target gaps including broken save-tokens installations with severity levels. -- **LLM interpretation:** If CLI backend is available, sends report + comparison as JSON to `impact-analyze` prompt. `saveTokensHealth` included in the analysis payload. -- **Interactive apply:** `buildApplyPlan(targets)` collects all recommended actions across targets with interactive confirmation. +## Non-obvious behaviors +- **Agent skill-ref check (Phase 6):** `checkAgentSkillReferences()` scans `.claude/agents/*.md` frontmatter for `skills: [a, b]` and verifies each `.claude/skills//skill.md` exists. Broken refs surface as `agent-skill-refs` opportunities. +- **Save-tokens health is Claude-only** and only activates when `config.saveTokens.enabled` is true and `claude.enabled !== false`. Required hook/command files vary by sub-config (`saveHandoff`, `warnAtTokens`/`compactAtTokens` thresholds). Legacy `.mjs` siblings of the `.sh` hooks must be cleaned up — their presence is an issue. +- **`buildApplyPlan` dedupes** with `aspens doc sync` as a target-agnostic key; everything else is keyed `${target.id}:${action}`. +- **`applyRecommendedAction` is a hand-maintained dispatch table** mapping action strings to `docInitCommand`/`docSyncCommand` option shapes. Adding a new recommendation in `recommendActions()` requires a matching branch here, otherwise it warns "Cannot apply automatically". -## Critical Rules -- **LLM interpretation is optional** — runs only if a CLI backend is detected. Failure is caught and reported as "Analysis unavailable". -- **LLM gets no tools** — `disableTools: true` passed to `runLLM()`. The prompt expects pure JSON output. -- **`--no-graph` flag** — skips import graph build; hub coverage section shows `n/a`. -- **Graph failure is non-fatal** — if `buildRepoGraph` throws, graph is set to null and analysis continues without hub data. -- **`SOURCE_EXTS` set** — only these extensions count as source files for drift detection. Adding a language requires updating this set. -- **Walk depth capped at 5** — deep nested source files won't appear in drift analysis. -- **`LOW_SIGNAL_DOMAIN_NAMES`** — `config`, `test`, `tests`, `__tests__`, `spec`, `e2e` are excluded from domain coverage scoring but tracked in `excluded` array. -- **Exported functions** — `computeDomainCoverage`, `computeHubCoverage`, `computeDrift`, `evaluateHookHealth`, `evaluateSaveTokensHealth`, `computeHealthScore`, `computeTargetStatus`, `recommendActions`, `summarizeReport`, `summarizeMissing`, `summarizeOpportunities`, `summarizeValueComparison` from `impact.js`; `buildApplyPlan`, `buildApplyConfirmationMessage` from `doc-impact.js`. +## Critical files (purpose, not inventory) +- `src/commands/doc-impact.js` — CLI rendering, LLM prompt assembly, `buildApplyPlan`, and the action dispatch into `docInitCommand`/`docSyncCommand`. +- `src/lib/impact.js` — All deterministic analysis (`analyzeImpact`, target summarization, scoring, drift, hub/code-map coverage, hook/save-tokens health, opportunities, missing rollup, value comparison). +- `src/prompts/impact-analyze.md` — Strict JSON contract the LLM must honor; do not change shape without updating `parseAnalysis`. -## References -- **Patterns:** `src/lib/skill-reader.js` — `findSkillFiles()` used for skill discovery per target -- **Prompt:** `src/prompts/impact-analyze.md` +## Critical Rules +- Skills/`activationPatterns` are matched via `findMatchingSkill` (substring or `/domain/` path hit). Renaming the skill-reader contract breaks coverage scoring. +- Don't surface root-context hub warnings when `codeMapMissing` is true — emit the `code-map-missing` item instead and tell the user to run `aspens doc graph`. +- Exported surface used by tests/consumers: `computeDomainCoverage`, `computeHubCoverage`, `computeDrift`, `evaluateHookHealth`, `evaluateSaveTokensHealth`, `computeHealthScore`, `computeTargetStatus`, `recommendActions`, `summarizeReport`, `summarizeMissing`, `summarizeOpportunities`, `summarizeValueComparison`, `checkAgentSkillReferences` from `impact.js`; `buildApplyPlan`, `buildApplyConfirmationMessage` from `doc-impact.js`. --- -**Last Updated:** 2026-04-09 +**Last Updated:** 2026-05-11 diff --git a/.agents/skills/doc-sync/SKILL.md b/.agents/skills/doc-sync/SKILL.md index 9a4f04e..1acbb67 100644 --- a/.agents/skills/doc-sync/SKILL.md +++ b/.agents/skills/doc-sync/SKILL.md @@ -1,44 +1,39 @@ --- name: doc-sync description: Incremental skill updater that maps git diffs to affected skills and optionally auto-syncs via a post-commit hook ---- - -## Activation - -This skill triggers when editing doc-sync-related files: -- `src/commands/doc-sync.js` -- `src/prompts/doc-sync.md` -- `src/prompts/doc-sync-refresh.md` -- `src/lib/git-helpers.js` -- `src/lib/diff-helpers.js` -- `src/lib/git-hook.js` - -Keywords: doc-sync, refresh, sync, git-hook - +triggers: + files: + - src/commands/doc-sync.js + - src/lib/diff-classifier.js + - src/lib/diff-helpers.js + - src/lib/git-hook.js + - src/lib/git-helpers.js + - src/prompts/doc-sync.md + - src/prompts/doc-sync-refresh.md + - src/prompts/partials/preservation-contract-refresh.md + keywords: + - doc sync + - doc-sync + - refresh + - post-commit hook + - install-hook + - diff classifier + - changetype filter --- You are working on **doc-sync**, the incremental skill update command (`aspens doc sync`). -## Key Files -- `src/commands/doc-sync.js` — Main command: git diff → graph rebuild → skill mapping → LLM update → publish for targets → write. Also contains refresh mode and `skillToDomain()` export. -- `src/prompts/doc-sync.md` — System prompt for diff-based sync (uses `{{skill-format}}` partial, target-specific path variables) -- `src/prompts/doc-sync-refresh.md` — System prompt for `--refresh` mode (full skill review) -- `src/lib/git-helpers.js` — `getGitRoot()`, `isGitRepo()`, `getGitDiff()`, `getGitLog()`, `getChangedFiles()` — git primitives -- `src/lib/diff-helpers.js` — `getSelectedFilesDiff()`, `buildPrioritizedDiff()`, `truncateDiff()`, `truncate()` — diff budgeting -- `src/lib/git-hook.js` — `installGitHook()` / `removeGitHook()` for post-commit auto-sync (monorepo-aware) -- `src/lib/context-builder.js` — `buildDomainContext()`, `buildBaseContext()` used by refresh mode -- `src/lib/runner.js` — `runLLM()`, `loadPrompt()`, `parseFileOutput()` shared across commands -- `src/lib/skill-writer.js` — `writeSkillFiles()`, `writeTransformedFiles()`, `extractRulesFromSkills()` for output -- `src/lib/target-transform.js` — `projectCodexDomainDocs()`, `transformForTarget()` for multi-target publish - ## Key Concepts - **Monorepo-aware:** `getGitRoot(repoPath)` resolves the actual git root. `projectPrefix` (`toGitRelative`) computes the subdirectory offset. `scopeProjectFiles()` filters changed files to the project subdirectory. Diffs are fetched from `gitRoot` but file paths are project-relative. - **Multi-target publish:** `configuredTargets()` reads `.aspens.json` for all configured targets. `chooseSyncSourceTarget()` picks the best source (prefers Claude if both exist). LLM generates for the source target; `publishFilesForTargets()` transforms output for all other configured targets. `graphSerialized` and `repoPath` are passed through to the transform context for conditional architecture references and disk-based instructions file loading. - **Backend routing:** `runLLM()` from `runner.js` dispatches to `runClaude()` or `runCodex()` based on `config.backend` (defaults to source target's id). - **Diff-based flow:** Gets `git diff HEAD~N..HEAD` from git root, scopes changed files to project prefix, then feeds diff plus existing skill contents and graph context to the selected backend. +- **Changetype filter (Phase 1):** `isNoOpDiff()` from `diff-classifier.js` skips the LLM call entirely on lockfile-only diffs and diffs touching zero code-bearing files. `LOCK_FILES` and `CODE_BEARING_EXTS` are the source of truth — extend them here, not at call sites. - **Prompt path variables:** Passes `{ skillsDir, skillFilename, instructionsFile, configDir }` from source target to `loadPrompt()` for path substitution in prompts. -- **Refresh mode (`--refresh`):** Skips diff entirely. Reviews every skill against the current codebase. Base skill refreshed first, then domain skills in parallel batches of `PARALLEL_LIMIT` (3). Also refreshes instructions file and reports uncovered domains. +- **Refresh mode (`--refresh`):** Skips diff entirely. Reviews every skill against the current codebase. Base skill refreshed first, then domain skills in parallel batches of `PARALLEL_LIMIT` (3). Also refreshes instructions file and reports uncovered domains. Refresh mode runs `ensureRootKeyFilesSection` before `syncSkillsSection` so the root file always carries a current Key Files block. +- **Deterministic section repair:** `repairDeterministicSections()` runs a no-LLM pass that re-injects `## Skills`, `## Behavior`, and `## Key Files` into the root instructions file from on-disk state. Called from the no-op / "up to date" sync paths so missing-section drift is fixed every invocation. The normal sync flow also runs the same Skills + Behavior + Key Files injection block on the canonical instructions file after the LLM step, so drift gets repaired whether or not the LLM produced an update. - **Graph rebuild on every sync:** Calls `buildRepoGraph` + `persistGraphArtifacts` (with source target) to keep graph fresh. `graphSerialized` return value is captured and forwarded to `publishFilesForTargets` for conditional Codex architecture refs. Graph failure is non-fatal. +- **Legacy v0.7 hub-block cleanup:** `notifyLegacyHubBlockIfPresent()` surfaces a one-line notice on the first sync after upgrade when `AGENTS.md`/`AGENTS.md` still carries the legacy `## Key Files` hub-counts block, so the diff that strips it isn't alarming. `regenerateStaleCodeMap()` force-rebuilds `.claude/code-map.md` on no-op syncs when it still carries the legacy `**Hub files**` block. - **Graceful response handling:** After LLM returns, if output has content but no `` tags, treats it as "no updates needed" with a verbose-only warning. The prompt explicitly requests an empty response when nothing needs updating. - **Graph-aware skill mapping:** `mapChangesToSkills()` checks direct file matches via `fileMatchesActivation()` (from `skill-reader.js`) and also whether changed files are imported by files matching a skill's activation block. - **Interactive file picker:** When diff exceeds 80k chars and TTY is available, offers multiselect with skill-relevant files pre-selected. @@ -47,7 +42,7 @@ You are working on **doc-sync**, the incremental skill update command (`aspens d - **Split writes:** Direct-write files (`.claude/`, `AGENTS.md`, root `AGENTS.md`) use `writeSkillFiles()`. Directory-scoped `AGENTS.md` files (e.g. `src/AGENTS.md`) use `writeTransformedFiles()`. - **Skill-rules regeneration:** After writing, regenerates `skill-rules.json` via `extractRulesFromSkills()` — only for targets with `supportsHooks: true` (Claude). Uses `hookTarget` from publish targets list. - **`findExistingSkills` is target-aware:** Uses `target.skillsDir` and `target.skillFilename` to locate skills for any target. -- **Git hook (monorepo-aware):** `installGitHook()` installs at the git root with per-project scoping. Hook uses `PROJECT_PATH` derived from project-relative offset. Each subproject gets its own labeled hook block (`# >>> aspens doc-sync hook (label) >>>`) with a unique function name (`__aspens_doc_sync_`). Multiple subprojects can coexist in one post-commit hook. Hook skips aspens-only commits scoped to the project prefix. +- **Git hook (monorepo-aware):** `installGitHook()` installs at the git root with per-project scoping. Hook uses `PROJECT_PATH` derived from project-relative offset. Each subproject gets its own labeled hook block (`# >>> aspens doc-sync hook (label) >>>`) with a unique function name (`__aspens_doc_sync_`). Multiple subprojects can coexist in one post-commit hook. Hook skips aspens-only commits scoped to the project prefix. 5-minute per-project cooldown via `/tmp/aspens-sync-.lock`; logs to `/tmp/aspens-sync-.log` (truncated to last 100 lines past 200). Unlabeled v0.6-era blocks are auto-upgraded on re-install. - **Force writes:** doc-sync always calls `writeSkillFiles` with `force: true`. ## Critical Rules @@ -59,9 +54,10 @@ You are working on **doc-sync**, the incremental skill update command (`aspens d - `checkMissingHooks()` in `bin/cli.js` only checks for Claude skills (not Codex — Codex doesn't use hooks). - `dedupeFiles()` ensures no duplicate paths when publishing across multiple targets. - **Git operations use `gitRoot`** — diffs, logs, and changed files are fetched from git root, not `repoPath`. File paths are then scoped via `projectPrefix`. +- **`diff-classifier.js` is a leaf module** — `graph-builder.js` imports `LOCK_FILES` from it; never import from `graph-builder` back into the classifier. ## References - **Patterns:** `src/lib/skill-reader.js` — `GENERIC_PATH_SEGMENTS`, `fileMatchesActivation()`, `getActivationBlock()` --- -**Last Updated:** 2026-04-25 +**Last Updated:** 2026-05-11 diff --git a/.agents/skills/import-graph/SKILL.md b/.agents/skills/import-graph/SKILL.md index ecc7a8f..0da64c7 100644 --- a/.agents/skills/import-graph/SKILL.md +++ b/.agents/skills/import-graph/SKILL.md @@ -1,33 +1,30 @@ --- name: import-graph description: Static import analysis that builds dependency graphs, domain clusters, hub files, git churn hotspots, and file priority rankings ---- - -## Activation - -This skill triggers when editing import-graph-related files: -- `src/lib/graph-builder.js` -- `src/lib/graph-persistence.js` -- `src/commands/doc-graph.js` -- `src/templates/hooks/graph-context-prompt.mjs` -- `src/templates/hooks/graph-context-prompt.sh` -- `tests/graph-builder.test.js` -- `tests/graph-persistence.test.js` - -Keywords: graph, import graph, dependency, hub files, clustering, code-map, graph-index, subgraph - +triggers: + files: + - src/lib/graph-builder.js + - src/lib/graph-persistence.js + - src/lib/parsers/** + - src/commands/doc-graph.js + - src/templates/hooks/graph-context-prompt.mjs + keywords: + - graph + - import-graph + - hubs + - clusters + - hotspots + - code-map + - graph.json + - subgraph + - priority + - fanIn --- You are working on the **import graph system** — static analysis that parses JS/TS and Python source files to produce dependency graphs, plus persistence/query layers for runtime use. -## Key Files -- `src/lib/graph-builder.js` — Core graph logic: walk, parse, metrics, ranking, clustering (690 lines) -- `src/lib/graph-persistence.js` — Serialize, persist, load, subgraph extraction, code-map, graph-index -- `src/commands/doc-graph.js` — Standalone `aspens doc graph` command -- `src/lib/scanner.js` — Provides `detectEntryPoints()`, only internal dependency of graph-builder -- `src/templates/hooks/graph-context-prompt.mjs` — Standalone hook mirroring `extractSubgraph` logic -- `tests/graph-builder.test.js` — Graph builder tests using temp fixture directories -- `tests/graph-persistence.test.js` — Persistence layer tests +## Domain purpose +The graph turns raw source into a queryable map of "what depends on what" so other aspens features (doc-init, doc-sync, doc-impact, the graph context hook) can rank files by importance, surface hubs, detect domain clusters, and inject just the relevant neighborhood into prompts. It is the substrate that makes context generation deterministic and code-aware rather than guess-based. ## Key Concepts **graph-builder.js** — `buildRepoGraph(repoPath, languages?)` runs a 9-step pipeline: @@ -39,7 +36,8 @@ You are working on the **import graph system** — static analysis that parses J - `extractSubgraph(graph, filePaths)` returns 1-hop neighborhood of mentioned files + relevant hubs/hotspots/clusters - `formatNavigationContext(subgraph)` renders compact markdown (~50 line budget) for prompt injection - `extractFileReferences(prompt, graph)` tiered extraction: explicit paths → bare filenames → cluster keywords -- `generateCodeMap()` / `writeCodeMap()` standalone overview for graph hook consumption +- `generateCodeMap()` / `writeCodeMap()` standalone overview for graph hook consumption — emits a Domain clusters block (via `formatDomainClusters`) and framework entry points only; cross-domain coupling, hotspots, and the totals/date footer are intentionally omitted because they churn on every sync +- `formatDomainClusters(clusters, files)` — exported helper that renders the canonical Domain clusters block: clusters are merged by label, single-file clusters dropped, files per cluster capped at 5 and sorted by `fanIn` desc then path asc for sync stability; no per-cluster `(N files)` counts - `generateGraphIndex()` / `saveGraphIndex()` tiny inverted index (export names → files, hub basenames, cluster labels) **doc-graph.js** — Target-aware: reads `.aspens.json` config, passes target to `persistGraphArtifacts()`. Shows different completion message for Codex target (artifacts not written). @@ -53,9 +51,10 @@ You are working on the **import graph system** — static analysis that parses J - **Errors are swallowed, not thrown** in graph-builder — parse failures return empty/null. The graph must always complete. - **`extractSubgraph` logic is mirrored** in `graph-context-prompt.mjs` (`buildNeighborhood()`). Keep both in sync. - **doc-sync rebuilds graph on every sync** — calls `buildRepoGraph` + `persistGraphArtifacts` (with target) to keep it fresh. +- **Code-map output is sync-stable** — no totals, no dates, no hotspot churn counts, no `+N more` suffixes. Anything that varies between syncs without a real code change must stay out of generated context. ## References - **Hook mirror:** `src/templates/hooks/graph-context-prompt.mjs` --- -**Last Updated:** 2026-04-02 +**Last Updated:** 2026-05-11 diff --git a/.agents/skills/repo-scanning/SKILL.md b/.agents/skills/repo-scanning/SKILL.md index 8aaf52c..8920c12 100644 --- a/.agents/skills/repo-scanning/SKILL.md +++ b/.agents/skills/repo-scanning/SKILL.md @@ -1,26 +1,31 @@ --- name: repo-scanning description: Deterministic repo analysis — language/framework detection, structure mapping, domain discovery, health checks, and import graph integration ---- - -## Activation - -This skill triggers when editing repo-scanning files: -- `src/lib/scanner.js` -- `src/commands/scan.js` -- `tests/scanner.test.js` - -Keywords: scanRepo, detectLanguages, detectFrameworks, detectDomains, detectEntryPoints, health check - +triggers: + files: + - src/lib/scanner.js + - src/lib/source-exts.js + - src/lib/path-resolver.js + - src/lib/parsers/typescript.js + - src/lib/parsers/python.js + - src/lib/frameworks/nextjs.js + - src/commands/scan.js + - tests/scanner.test.js + keywords: + - scanRepo + - detectLanguages + - detectFrameworks + - detectDomains + - detectEntryPoints + - health check + - SOURCE_EXTS + - SKIP_DIR_NAMES --- You are working on **aspens' repo scanning system** — a fully deterministic analyzer (no LLM calls) that detects languages, frameworks, structure, domains, entry points, size, and health issues for any repository. -## Key Files -- `src/lib/scanner.js` — Core `scanRepo()` function and all detection logic (languages, frameworks, structure, domains, entry points, size, health) -- `src/commands/scan.js` — CLI command that calls `scanRepo()`, optionally builds import graph via `graph-builder.js`, and renders pretty or JSON output. Contains `formatGraphForDisplay()` which transforms raw graph data into display-ready shape -- `src/lib/graph-builder.js` — Builds import graph; imports `detectEntryPoints` from scanner. Called by `scanCommand` but graph failure is non-fatal -- `tests/scanner.test.js` — Uses temporary fixture directories created in `tests/fixtures/scanner/`, cleaned up in `afterAll` +## Domain purpose +Scanning is the foundation every other command builds on. `scanRepo()` must produce stable, reproducible results from any repo on disk — even a freshly-cloned one with no manifests parsed yet — so `doc init`, `doc sync`, `doc impact`, and `doc graph` can decide what to generate, which target (Claude / Codex) is appropriate, and which domains warrant skills. Determinism is the contract: the same repo at the same commit must always produce the same scan. ## Key Concepts - **scanRepo() return shape:** `{ path, name, languages[], frameworks[], structure, domains[], entryPoints[], hasClaudeConfig, hasClaudeMd, hasCodexConfig, hasAgentsMd, repoType, size, health }` — order matters: `repoType` and `health` depend on prior fields @@ -29,21 +34,22 @@ You are working on **aspens' repo scanning system** — a fully deterministic an - **Framework detection:** JS/TS from `package.json` deps, Python from `requirements.txt`/`pyproject.toml`/`Pipfile`, Go from `go.mod` contents, Ruby from `Gemfile` - **Domain detection:** Scans dirs under source root + repo root, skips `SKIP_DIR_NAMES` set (structural/build/IDE/.NET/Java/Rust build dirs), requires at least one source file via `collectModules()` - **extraDomains:** User-specified domains merged via `mergeExtraDomains()` — marked with `userSpecified: true`, resolved against source root then repo root -- **Source root:** First match of `src`, `app`, `lib`, `server`, `pages` via `findSourceRoot()` +- **Source root:** First match of `src`, `app`, `lib`, `server`, `pages` via `findSourceRoot()`; for nested-project layouts (e.g. `~/apps/MyApp/MyApp/MyApp.csproj`), if repo root has exactly one non-skip child with a project manifest, that child is promoted as the source root and excluded from domain scanning at repo root to avoid double-counting - **Size estimation:** Lines estimated at ~40 bytes/line from `stat.size`, walk capped at depth 5, skips `bin`/`obj`/`target` build output alongside `node_modules`/`dist`/etc. - **Graph is opt-out:** `scanCommand` builds graph by default (`options.graph !== false`); errors are caught and only logged with `--verbose` +- **Health checks are language-aware:** `.gitignore` checks for missing `node_modules/`, `__pycache__/`, `target/`, virtualenv dirs, and uncommitted `.env` files are gated on detected languages ## Critical Rules -- **`SOURCE_EXTS`**: `.py`, `.ts`, `.js`, `.tsx`, `.jsx`, `.mjs`, `.cjs`, `.rb`, `.go`, `.rs`, `.java`, `.kt`, `.kts`, `.cs`, `.fs`, `.fsx`, `.swift`, `.php`, `.ex`, `.exs` — adding a language requires updating this set AND the `detectLanguages` indicators. Import graph / hub / cluster detection remains JS/TS/Python-only; other languages get domain discovery but a minimal atlas. +- **`SOURCE_EXTS`** (in `src/lib/source-exts.js`): `.py`, `.ts`, `.js`, `.tsx`, `.jsx`, `.mjs`, `.cjs`, `.rb`, `.go`, `.rs`, `.java`, `.kt`, `.kts`, `.cs`, `.fs`, `.fsx`, `.swift`, `.php`, `.ex`, `.exs` — adding a language requires updating this set AND the `detectLanguages` indicators. Import graph / hub / cluster detection remains JS/TS/Python-only; other languages get domain discovery but a minimal atlas. - **`SKIP_DIR_NAMES`**: Includes `src`, `app`, `bin`, `obj`, `dist`, `target`, `node_modules`, etc. — skipped in domain detection. `bin`/`obj`/`target` added to avoid .NET/Java/Rust build artifacts. - **`BOILERPLATE_STEMS`**: `__init__`, `index`, `mod` are excluded from module collection — don't add real module names here - **TypeScript implies JavaScript**: TS detection in `detectLanguages()` automatically adds JS to the languages array - **Graph failure is non-fatal**: `buildRepoGraph` errors in `scanCommand()` are caught and silently ignored unless `--verbose` - **Tests use real filesystem fixtures**, not mocks — create fixtures with `createFixture(name, files)` pattern, always clean up - **`detectEntryPoints` is exported** and reused by `graph-builder.js` — changing its signature breaks the graph builder - -## References -- **No guidelines directory** — `.claude/guidelines/` does not exist yet for this domain +- **`es-module-lexer` must be initialized**: `parseJsImports()` awaits `init` before calling `parse()`. The lexer can fail on JSX-heavy files; the regex fallback in `parsers/typescript.js` is intentional graceful degradation, not dead code. +- **Python parser skips SCREAMING_SNAKE constants** by design — they produced false positives in code-map; do not re-add them. +- **Next.js entry points feed the import-graph priority ranker** — `app/`, `pages/`, and `middleware`/`instrumentation` files are roots Next.js runs implicitly with no static importer. --- -**Last Updated:** 2026-04-16 +**Last Updated:** 2026-05-11 diff --git a/.agents/skills/save-tokens/SKILL.md b/.agents/skills/save-tokens/SKILL.md index 2bd8e88..404dd95 100644 --- a/.agents/skills/save-tokens/SKILL.md +++ b/.agents/skills/save-tokens/SKILL.md @@ -1,62 +1,63 @@ --- name: save-tokens description: Token-saving session automation — statusline, prompt guard, precompact handoffs, session rotation, and handoff commands for Claude Code ---- - -## Activation - -This skill triggers when editing save-tokens files: -- `src/commands/save-tokens.js` -- `src/lib/save-tokens.js` -- `src/templates/hooks/save-tokens*.sh` -- `src/templates/hooks/save-tokens.mjs` -- `src/templates/commands/save-handoff.md` -- `src/templates/commands/resume-handoff*.md` -- `tests/save-tokens*.test.js` - -Keywords: save-tokens, handoff, statusline, prompt-guard, precompact, session rotation, token warning - +triggers: + files: + - src/commands/save-tokens.js + - src/lib/save-tokens.js + - src/templates/hooks/save-tokens*.sh + - src/templates/hooks/save-tokens.mjs + - src/templates/commands/save-handoff.md + - src/templates/commands/resume-handoff*.md + - tests/save-tokens*.test.js + keywords: + - save-tokens + - handoff + - statusline + - prompt-guard + - precompact + - session rotation + - token warning --- You are working on **save-tokens** — the feature that installs Claude Code hooks and commands to warn about token usage, auto-save handoffs before compaction, and support session rotation. -## Key Files -- `src/commands/save-tokens.js` — Main command: interactive or `--recommended` install, `--remove` uninstall, installs hooks + commands + settings -- `src/lib/save-tokens.js` — Config defaults (`DEFAULT_SAVE_TOKENS_CONFIG`), `buildSaveTokensConfig()`, `buildSaveTokensSettings()`, `buildSaveTokensGitignore()`, `buildSaveTokensReadme()` -- `src/templates/hooks/save-tokens.mjs` — Runtime hook: `runStatusline()`, `runPromptGuard()`, `runPrecompact()`, telemetry recording, handoff saving/pruning -- `src/templates/hooks/save-tokens-statusline.sh` — Shell wrapper for statusline hook -- `src/templates/hooks/save-tokens-prompt-guard.sh` — Shell wrapper for prompt guard hook -- `src/templates/hooks/save-tokens-precompact.sh` — Shell wrapper for precompact hook -- `src/templates/commands/save-handoff.md` — Slash command to save a rich handoff summary -- `src/templates/commands/resume-handoff-latest.md` — Slash command to resume from most recent handoff -- `src/templates/commands/resume-handoff.md` — Slash command to list and pick a handoff to resume - -## Key Concepts -- **Claude-only feature:** Save-tokens hooks and statusline only work with Claude Code. Config is stored in `.aspens.json` under `saveTokens`. -- **Three hook entry points:** Shell wrappers (`*.sh`) read stdin, resolve project dir, and call `save-tokens.mjs` with a subcommand (`statusline`, `prompt-guard`, `precompact`). -- **Statusline:** Records Claude context telemetry to `.aspens/sessions/claude-context.json` on every status update. Displays `save-tokens Xk/Yk` in the Claude status bar. -- **Prompt guard:** Checks token count against `warnAtTokens` (175k default) and `compactAtTokens` (200k default). Above compact threshold: saves a handoff and recommends starting a fresh session then running `/resume-handoff-latest`. Above warn threshold: suggests `/save-handoff`. -- **Precompact:** Auto-saves a handoff before Claude compaction when `saveHandoff` is enabled. -- **Handoff files:** Saved to `.aspens/sessions/-claude-handoff.md`. Structured with: metadata (tokens, working dir, branch), task summary, files modified, git commits, recent prompts, current state, next steps. Content extracted from JSONL transcript via `extractSessionFacts()`. Pruned to keep max 10. -- **`extractSessionFacts(input)`:** Parses the session's JSONL transcript to extract: `originalTask` (first user message), `recentPrompts` (last 3 user messages, 200 char max each), `filesModified` (from Edit/Write tool_use blocks), `gitCommits` (from Bash git commit commands), `branch` (from user record `gitBranch` field). Falls back to `input.prompt` as task summary when no transcript is available. Task summary capped at 500 chars. -- **Telemetry:** `recordClaudeContextTelemetry()` sums input/output/cache tokens from Claude's `context_window.current_usage`. Stale telemetry (>5 min) is ignored. -- **Config thresholds:** `warnAtTokens` and `compactAtTokens` can be `Number.MAX_SAFE_INTEGER` as disabled sentinel. -- **Settings merge:** `buildSaveTokensSettings()` produces `statusLine` + `hooks` config. Merged into existing `settings.json` via `mergeSettings()` which treats save-tokens hooks as aspens-managed. -- **`--recommended` install:** Called standalone or from `doc init --recommended`. Installs hooks, commands, sessions dir, settings — no prompts. -- **`--remove` uninstall:** Removes hook files (including legacy `.mjs` variants), commands, cleans settings.json entries, nulls `saveTokens` in `.aspens.json`. +## Domain purpose +`aspens save-tokens` installs three Claude Code hooks (statusline, prompt-guard, precompact) plus `/save-handoff`, `/resume-handoff-latest`, and `/resume-handoff` slash commands. The hooks observe Claude's own token telemetry and inject system messages telling Claude to rotate sessions before context blow-up. Handoff files persist enough state in `.aspens/sessions/` to resume seamlessly in a fresh session. -## Critical Rules -- **Shell wrappers resolve project dir from script location** — `SCRIPT_DIR` → `PROJECT_DIR` via `cd "$SCRIPT_DIR/../.." && pwd`. `ASPENS_PROJECT_DIR` env var overrides `CLAUDE_PROJECT_DIR`. -- **Config validation in `target.js`** — `isValidSaveTokensConfig()` validates shape, types, and threshold ordering. Invalid config causes `readConfig()` to return `null`. +## Business rules / invariants +- **Claude-only feature.** Hooks and statusline only work with Claude Code; Codex has no save-tokens integration. Config lives in `.aspens.json` under `saveTokens`. +- **Config thresholds default 175k warn / 200k compact.** `Number.MAX_SAFE_INTEGER` is the disabled sentinel for either threshold; `target.js#isValidSaveTokensConfig()` validates shape, types, and threshold ordering — invalid config causes `readConfig()` to return `null`. - **`writeConfig` preserves feature config** — `saveTokens` is preserved across `writeConfig` calls unless explicitly set to `null`. -- **Handoff pruning** — `pruneOldHandoffs()` keeps newest 10, deletes older. Only touches `*-handoff.md` files. - **Sessions dir gitignored** — `.aspens/sessions/.gitignore` excludes everything except `.gitignore` and `README.md`. -- **Settings backup** — First install creates `.claude/settings.json.bak` if settings exist and no backup exists yet. -- **`doc init --recommended`** — Calls `installSaveTokensRecommended()` from `save-tokens.js`, also installs agents and doc-sync git hook. -- **Transcript parsing is best-effort** — `extractSessionFacts()` catches all errors and returns empty facts on failure. Invalid JSON lines are silently skipped. +- **Settings backup** — first install creates `.claude/settings.json.bak` if settings exist and no backup exists yet. +- **StatusLine pre-existence guard** — `canInstallSaveTokensStatusLine()` refuses to overwrite an unrelated custom `statusLine.command`; when refused, `applyStatusLineAvailability()` forces both thresholds to `MAX_SAFE_INTEGER` and disables `sessionRotation`. + +## Non-obvious behaviors +- **Three hook entry points dispatched by subcommand.** Shell wrappers (`*.sh`) read stdin, resolve project dir, and call `save-tokens.mjs` with `statusline`, `prompt-guard`, or `precompact`. `ASPENS_PROJECT_DIR` env var overrides `CLAUDE_PROJECT_DIR`; both fall back to `cwd`. +- **Statusline doubles as telemetry recorder.** `recordClaudeContextTelemetry()` writes `.aspens/sessions/claude-context.json` summing `input_tokens + cache_creation_input_tokens + cache_read_input_tokens + output_tokens` from `context_window.current_usage`. Telemetry older than 5 minutes is ignored by the prompt guard. +- **Prompt-guard speaks to Claude, not the user.** Above `compactAtTokens`: saves a handoff (reason `rotation-threshold` if `sessionRotation`, else `compact-threshold`) and stdout-prints an "IMPORTANT — you must tell the user" block instructing fresh session + `/resume-handoff-latest`. Above `warnAtTokens`: suggests `/save-handoff`. When telemetry is missing, prints a one-time link to the aspens issues page. +- **Precompact hook only fires when `saveHandoff` is enabled** and `claude.enabled !== false`; reason is `precompact`. +- **`extractSessionFacts()` parses the JSONL transcript** to extract `originalTask` (first user message, 500 char cap), `recentPrompts` (last 3, 200 char cap each), `filesModified` (Edit/Write tool_use `file_path`), `gitCommits` (regex on Bash `git commit -m "..."`), and `branch` (from `gitBranch` on user records). Transcript path is validated to be inside `projectDir`; falls back to `input.prompt` when transcript is missing or outside the project. +- **Transcript parsing is best-effort** — all errors caught, invalid JSON lines silently skipped; returns empty facts on failure. +- **`pruneOldHandoffs()` keeps newest 10** `*-handoff.md` files by lexicographic (timestamp) sort; older are unlinked. `latestHandoff()` uses the same sort. +- **`--recommended` install is non-interactive** — used standalone or invoked by `doc init --recommended` (which also installs agents and the doc-sync git hook). +- **`--remove` cleans legacy artifacts too** — removes both `.sh` and historic `.mjs` variants of each hook, plus the legacy `save-tokens-resume.md` command; strips `statusLine` if it points at save-tokens and filters any hook entry whose command contains `save-tokens-`; nulls `saveTokens` in `.aspens.json`. + +## Critical files (purpose, not inventory) +- `src/commands/save-tokens.js` — install/remove orchestration; interactive multiselect (`warnings`, `handoffs`), `--recommended`, `--remove`. Exports `installSaveTokensRecommended()` consumed by `doc-init.js`. +- `src/lib/save-tokens.js` — config + settings + template content builders (`DEFAULT_SAVE_TOKENS_CONFIG`, `buildSaveTokensConfig`, `buildSaveTokensSettings`, `buildSaveTokensGitignore`, `buildSaveTokensReadme`, `buildSaveTokensRecommendations`). +- `src/templates/hooks/save-tokens.mjs` — runtime library: `runStatusline`, `runPromptGuard`, `runPrecompact`, `saveHandoff`, `extractSessionFacts`, telemetry record/read, handoff pruning, `main()` subcommand dispatch. +- `src/templates/hooks/save-tokens-{statusline,prompt-guard,precompact}.sh` — shell wrappers; resolve `PROJECT_DIR` via `cd "$SCRIPT_DIR/../.." && pwd` and exec the `.mjs` with the matching subcommand. +- `src/templates/commands/{save-handoff,resume-handoff-latest,resume-handoff}.md` — slash command bodies installed under `.claude/commands/`. + +## Critical Rules +- **Settings merge uses `mergeSettings()`** from `skill-writer.js` — save-tokens hooks are treated as aspens-managed; do not hand-edit `.claude/settings.json` to add/remove save-tokens entries. +- **Hooks write to stdout to inject into Claude's context** — comments in `runPromptGuard()` flag this; keep those messages addressed to Claude ("Tell the user...") not to a human reader. +- **Token sum includes cache tokens** — do not switch to just `input_tokens + output_tokens`; that under-counts Claude's effective context use. ## References - **Impact integration:** `src/lib/impact.js` — `evaluateSaveTokensHealth()` validates installed state --- -**Last Updated:** 2026-04-10 +**Last Updated:** 2026-05-11 diff --git a/.agents/skills/skill-generation/SKILL.md b/.agents/skills/skill-generation/SKILL.md index a4e5595..ee307e6 100644 --- a/.agents/skills/skill-generation/SKILL.md +++ b/.agents/skills/skill-generation/SKILL.md @@ -1,64 +1,64 @@ --- name: skill-generation description: LLM-powered generation pipeline for Claude Code skills and AGENTS.md — doc-init command, prompt system, context building, and output parsing ---- - -## Activation - -This skill triggers when editing skill-generation files: -- `src/commands/doc-init.js` -- `src/lib/runner.js` -- `src/lib/skill-writer.js` -- `src/lib/skill-reader.js` -- `src/lib/git-hook.js` -- `src/lib/timeout.js` -- `src/prompts/**/*` - -Keywords: doc-init, generate skills, discovery agents, chunked generation, recommended - +triggers: + files: + - src/commands/doc-init.js + - src/lib/context-builder.js + - src/prompts/**/* + keywords: + - doc-init + - generate skills + - discovery agents + - chunked generation + - recommended --- You are working on **aspens' skill generation pipeline** — the system that scans repos and uses Claude/Codex CLI to generate skills, hooks, and instructions files. -## Key Files -- `src/commands/doc-init.js` — Main pipeline: backend selection → target selection → scan → graph → discovery → strategy → mode → generate → validate → transform → write → hooks → recommended extras → config -- `src/lib/runner.js` — `runClaude()`, `runCodex()`, `runLLM()`, `loadPrompt()`, `parseFileOutput()`, `validateSkillFiles()` -- `src/lib/skill-writer.js` — Writes files, generates `skill-rules.json`, domain bash patterns, merges `settings.json` -- `src/lib/skill-reader.js` — Parses skill frontmatter, activation patterns, keywords (used by skill-writer) +## Domain purpose +`aspens doc init` orchestrates a multi-step LLM pipeline that turns a scanned repo + import graph into a base skill, per-domain skills, and an instructions file (`AGENTS.md` or `AGENTS.md`). Generation is always done in Claude-canonical format and transformed for other targets afterwards. The end product is what other coding agents (and aspens' own hooks) consume to stay grounded in the repo. + +## Critical files (purpose, not inventory) +- `src/commands/doc-init.js` — the pipeline orchestrator (backend → target → scan → graph → discovery → strategy → mode → generate → validate → transform → write → hooks → recommended extras → config) +- `src/lib/runner.js` — `runLLM()`, `loadPrompt()`, `parseFileOutput()`, `validateSkillFiles()` shared across all LLM-driven commands +- `src/lib/skill-writer.js` — writes parsed files, generates `skill-rules.json`, injects domain bash patterns, merges `settings.json` +- `src/lib/skill-reader.js` — parses skill frontmatter, activation patterns, keywords (consumed by skill-writer) - `src/lib/git-hook.js` — `installGitHook()` / `removeGitHook()` for post-commit auto-sync (monorepo-aware) - `src/lib/timeout.js` — `resolveTimeout()` for auto-scaled + user-override timeouts -- `src/lib/target.js` — Target definitions, `resolveTarget()`, `getAllowedPaths()`, `writeConfig()`, `loadConfig()`, `mergeConfiguredTargets()` -- `src/lib/backend.js` — Backend detection/resolution (`detectAvailableBackends()`, `resolveBackend()`) -- `src/lib/target-transform.js` — `transformForTarget()`, `ensureRootKeyFilesSection()` converts Claude output to other target formats -- `src/prompts/` — `doc-init.md` (base), `doc-init-domain.md`, `doc-init-claudemd.md`, `discover-domains.md`, `discover-architecture.md` +- `src/lib/target.js` / `src/lib/backend.js` / `src/lib/target-transform.js` — target/backend resolution and Claude→other-target transform +- `src/prompts/` — `doc-init.md`, `doc-init-domain.md`, `doc-init-claudemd.md`, `discover-domains.md`, `discover-architecture.md`, plus `partials/` (skill-format, preservation-contract, examples) ## Key Concepts - **Pipeline steps:** (1) detect backends (2) **backend selection** (3) **target selection** (4) scan + graph (5) existing docs discovery check (6) parallel discovery agents (7) strategy (8) mode (9) generate (10) validate (11) transform for non-Claude targets (12) show files + dry-run (13) write (14) install hooks (Claude-only) (15) **recommended extras** (save-tokens, agents, git hook) (16) persist config to `.aspens.json` - **Early config persistence:** Target/backend config is written to `.aspens.json` **before** generation starts (after step 4), so a failed generation run still records the user's explicit target/backend choice. `saveTokens` from existing config is preserved. Final `writeConfig` at step 16 adds `saveTokens` from recommended install. - **`--recommended` flag:** Skips interactive prompts with smart defaults. Reuses existing target config from `.aspens.json`. Auto-selects backend from target. Defaults strategy to `improve` when existing docs found. Auto-picks discovery skip when docs exist. Auto-selects generation mode based on repo size. **Also installs save-tokens, bundled Claude agents, `dev/` gitignore entry, and doc-sync git hook** (step 15). -- **Recommended extras (step 15):** When `--recommended` and not `--dry-run`: calls `installSaveTokensRecommended()` from `save-tokens.js` (if Claude target), copies all bundled agent templates to `.claude/agents/` (skips existing), adds `dev/` to `.gitignore`, installs doc-sync git hook if not present. Summary lines printed after. +- **Recommended extras (step 15):** When `--recommended` and not `--dry-run`: calls `installSaveTokensRecommended()` from `save-tokens.js` (if Claude target), copies all bundled agent templates to `.claude/agents/` (skips existing) via `installRecommendedClaudeAgents()`, adds `dev/` to `.gitignore`, installs doc-sync git hook if not present. Summary lines printed after. - **Backend before target:** Backend selection (step 2) happens before target selection (step 3). If both CLIs available, user picks backend first, then targets. Pre-selects matching target in the multiselect. With `--recommended`, backend is inferred from existing target config. -- **Canonical generation:** All prompts receive `CANONICAL_VARS` (hardcoded Claude paths). Generation always produces Claude-canonical format regardless of target. Non-Claude targets are produced by post-generation transform. +- **Canonical generation:** All prompts receive `CANONICAL_VARS` (hardcoded Claude paths: `.claude/skills`, `skill.md`, `AGENTS.md`, `.claude`). Generation always produces Claude-canonical format regardless of target. Non-Claude targets are produced by post-generation transform via `transformForTarget()`. - **Incremental writing (chunked mode):** When `mode === 'chunked'` and not dry-run, generated files are written to disk as each chunk completes instead of waiting until the end. User is prompted to confirm incremental writes before generation starts. Helper functions: `validateGeneratedChunk()` validates and strips truncated files per chunk; `buildOutputFilesForTargets()` handles multi-target transform; `writeIncrementalOutputs()` deduplicates and writes changed files. Tracks written content via `incrementalWriteState` (`contentsByPath` + `resultsByPath` Maps). When incremental mode is active, post-generation validation/transform/confirm/write steps are skipped (already done per-chunk). - **`parseLLMOutput` with strict single-file fallback:** Codex often returns plain markdown without `` tags. `parseLLMOutput(text, allowedPaths, expectedPath)` only wraps tagless text as the expected file for **true single-file prompts** (exactly one `exactFile` in allowedPaths, no `dirPrefixes`). Multi-file prompts require proper `` tags. -- **Existing docs reuse:** When existing Claude docs are found and strategy is `improve`, reuse is handled as improvement context without a separate loading spinner. Supports cross-target reuse. -- **Domain reuse helpers:** `loadReusableDomains()` tries `loadReusableDomainsFromRules()` first, falls back to `findSkillFiles()` with `extractKeyFilePatterns()`. +- **Existing docs reuse:** When existing Claude docs are found and strategy is `improve`, `loadExistingDocsContext()` inlines them as `## Existing Docs (improve these — preserve hand-written rules...)` into the prompt. `chooseReuseSourceTarget()` decides whether Claude or Codex docs are the source. Supports cross-target reuse (e.g. Claude docs → Codex output). +- **Domain reuse helpers:** `loadReusableDomains()` tries `loadReusableDomainsFromRules()` first (reads `skill-rules.json`), falls back to `findSkillFiles()` with `extractKeyFilePatterns()` parsing `## Key Files` blocks. - **Config persistence with target merging:** Uses `mergeConfiguredTargets()` to avoid dropping previously configured targets. `writeConfig` now also persists `saveTokens` config from the recommended install. -- **Hook installation:** Only for targets with `supportsHooks: true` (Claude). Generates `skill-rules.json`, copies hook scripts, merges `settings.json`. -- **Git hook offer:** With `--recommended`, git hook is auto-installed (no prompt). Without `--recommended`, interactive prompt offered. +- **Hook installation:** Only for targets with `supportsHooks: true` (Claude). `installHooks()` generates `skill-rules.json`, copies hook scripts, injects generated domain patterns into `post-tool-use-tracker.sh` via `# BEGIN/END detect_skill_domain` markers, merges `settings.json` (backs up existing to `.bak`). +- **Git hook offer:** With `--recommended`, git hook is auto-installed (no prompt). Without `--recommended`, interactive prompt offered. Detection looks for the marker string `aspens doc-sync hook ()` in `.git/hooks/post-commit`. +- **Discovery agents:** Two LLM calls run in parallel — `discover-domains` (hub files + domain clusters) and `discover-architecture` (hub files + ranked + hotspots). Findings are merged into `discoveryFindings` and parsed; domain-specific slices are injected into each domain prompt as `## Discovery Findings for {domain}`. ## Critical Rules -- **Base skill + instructions file are essential** — pipeline retries automatically with format correction. Domain skill failures are acceptable (user retries with `--domains`). -- **`improve` strategy preserves hand-written content** — LLM must read existing skills first and not discard human-authored rules. -- **Discovery runs before user prompt** — domain picker shows discovered domains, not scanner directory names. +- **Base skill + instructions file are essential** — pipeline retries up to 2× with format-correction prompts when `` tags are missing. Domain skill failures are acceptable (user retries with `--domains`). +- **`improve` strategy preserves hand-written content** — LLM must read existing skills first and not discard human-authored rules. The preservation-contract partial enforces this in every prompt. +- **Discovery runs before user prompt** — domain picker shows discovered domains, not scanner directory names. Falls back to scanner domains if discovery fails. - **PARALLEL_LIMIT = 3** — domain skills generate in batches of 3 concurrent calls. Base skill always sequential first. Instructions file always sequential last. - **CliError, not process.exit()** — all error exits throw `CliError`; cancellations `return` early. - **`--hooks-only` is Claude-only** — hardcoded to `TARGETS.claude` regardless of config. -- **Incremental write deduplication** — `writeIncrementalOutputs()` skips files whose content hasn't changed since last write, using `contentsByPath` Map for tracking. +- **Incremental write deduplication** — `writeIncrementalOutputs()` skips files whose content hasn't changed since last write, using `contentsByPath` Map for tracking. Directory-scoped `AGENTS.md` files (path ends with `/AGENTS.md` but not the root `AGENTS.md`) go through `writeTransformedFiles()`, all others through `writeSkillFiles()`. +- **Read-only LLM tools** — generation calls always pass `allowedTools: ['Read', 'Glob', 'Grep']`. The LLM explores the repo itself; aspens never lets it write. +- **`AGENTS.md` post-processing** — generated instructions files are run through `ensureRootKeyFilesSection()`, `syncSkillsSection()`, and `syncBehaviorSection()` so aspens owns the Skills list and Behavior block deterministically; prompts explicitly forbid the LLM from emitting these sections. ## References - **Prompts:** `src/prompts/doc-init*.md`, `src/prompts/discover-*.md` -- **Partials:** `src/prompts/partials/skill-format.md`, `src/prompts/partials/examples.md` +- **Partials:** `src/prompts/partials/skill-format.md`, `src/prompts/partials/preservation-contract.md`, `src/prompts/partials/examples.md` --- -**Last Updated:** 2026-04-10 +**Last Updated:** 2026-05-11 diff --git a/.agents/skills/template-library/SKILL.md b/.agents/skills/template-library/SKILL.md index 251eea1..83722e3 100644 --- a/.agents/skills/template-library/SKILL.md +++ b/.agents/skills/template-library/SKILL.md @@ -1,27 +1,23 @@ --- name: template-library description: Bundled agents, commands, hooks, and settings that users install via `aspens add`, `aspens doc init`, and `aspens save-tokens` into their .claude/ directories ---- - -## Activation - -This skill triggers when editing template-library files: -- `src/commands/add.js` -- `src/templates/**/*` - -Keywords: template, add agent, add command, add hook, add skill - +triggers: + files: + - src/commands/add.js + - src/prompts/add-skill.md + - src/templates/**/* + keywords: + - template + - add agent + - add command + - add hook + - add skill --- You are working on the **template library** — bundled agents, slash commands, hooks, and settings that users browse and install into their repos. -## Key Files -- `src/commands/add.js` — Core `aspens add [name]` command; copies templates to `.claude/` dirs, scaffolds/generates custom skills -- `src/templates/agents/*.md` — Agent persona templates (11 bundled) -- `src/templates/commands/*.md` — Slash command templates (5 bundled: save-handoff, resume-handoff, resume-handoff-latest, plus 2 original) -- `src/templates/hooks/` — Hook scripts: `skill-activation-prompt.sh/mjs`, `graph-context-prompt.sh/mjs`, `post-tool-use-tracker.sh`, `save-tokens.mjs`, `save-tokens-statusline.sh`, `save-tokens-prompt-guard.sh`, `save-tokens-precompact.sh` -- `src/templates/settings/settings.json` — Default settings with hook configuration (commands are double-quoted for shell safety) -- `src/prompts/add-skill.md` — System prompt for LLM-powered skill generation from reference docs +## Domain purpose +`aspens add [name]` copies curated templates into a consumer repo's `.claude/` directories so users get working agents, slash commands, hooks, and settings without authoring them. The same template tree is reused by `aspens doc init` (hook installation, recommended agents) and `aspens save-tokens` (handoff tooling). Custom skills can also be scaffolded blank or LLM-generated from a reference doc. ## Key Concepts - **Four resource types for `add`:** `agent` → `.claude/agents`, `command` → `.claude/commands`, `hook` → `.claude/hooks`. A fourth type `skill` is handled separately (not template-based). @@ -36,6 +32,15 @@ You are working on the **template library** — bundled agents, slash commands, - **Template discovery:** `listAvailable()` reads template dir, filters `.md`/`.sh` files, regex-parses `name:` and `description:`. - **No-overwrite policy:** `addResource()` skips files that already exist. Same for `addSkillCommand`. - **Plan/execute gitignore:** Adding `plan` or `execute` agents auto-adds `dev/` to `.gitignore` for plan storage. `doc init --recommended` also ensures `dev/` in `.gitignore`. +- **Base-skill warning for agents:** `addResource()` prints a non-fatal yellow warning when installing an agent if `.agents/skills/base/SKILL.md` is missing, prompting the user to run `aspens doc init`. The agent still installs. + +## Critical files (purpose, not inventory) +- `src/commands/add.js` — Entry point for `aspens add`; dispatches to resource copy, blank skill scaffold, or LLM skill generation. +- `src/templates/agents/*.md` — Agent persona templates copied as-is into `.claude/agents/`. +- `src/templates/commands/*.md` — Slash command templates (includes handoff commands installed by `save-tokens`). +- `src/templates/hooks/` — Hook scripts (`skill-activation-prompt.{sh,mjs}`, `graph-context-prompt.{sh,mjs}`, `post-tool-use-tracker.sh`, `save-tokens.mjs`, `save-tokens-{statusline,prompt-guard,precompact}.sh`). +- `src/templates/settings/settings.json` — Default Claude Code settings with hook wiring; merged into the consumer repo's settings. +- `src/prompts/add-skill.md` — System prompt for `add skill --from ` LLM generation. ## Critical Rules - Template files **must** contain `name: ` and `description: ` lines parseable by regex. @@ -43,10 +48,11 @@ You are working on the **template library** — bundled agents, slash commands, - The templates dir resolves from `src/commands/` via `join(__dirname, '..', 'templates')` — moving `add.js` breaks template resolution. - Skill names are sanitized to lowercase alphanumeric + hyphens. Invalid names throw `CliError`. - Commands throw `CliError` for expected failures instead of calling `process.exit()`. +- Reference docs passed to `add skill --from` are truncated to 50,000 chars before being handed to the LLM. ## References - **Customize flow:** `.agents/skills/agent-customization/SKILL.md` - **Save-tokens install:** `.agents/skills/save-tokens/SKILL.md` --- -**Last Updated:** 2026-04-09 +**Last Updated:** 2026-05-11 diff --git a/.claude/hooks/graph-context-prompt.sh b/.claude/hooks/graph-context-prompt.sh index b66cd33..181df4c 100755 --- a/.claude/hooks/graph-context-prompt.sh +++ b/.claude/hooks/graph-context-prompt.sh @@ -33,7 +33,9 @@ get_script_dir() { } SCRIPT_DIR="$(get_script_dir)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" log_debug "SCRIPT_DIR=$SCRIPT_DIR" +log_debug "ASPENS_PROJECT_DIR=$PROJECT_DIR" cd "$SCRIPT_DIR" || { echo "[Graph] Failed to cd to $SCRIPT_DIR" >&2; exit 0; } @@ -50,7 +52,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" | ASPENS_PROJECT_DIR="$PROJECT_DIR" NODE_NO_WARNINGS=1 node graph-context-prompt.mjs \ >"$STDOUT_FILE" 2>"$STDERR_FILE" EXIT_CODE=$? @@ -64,7 +66,7 @@ if [ $EXIT_CODE -ne 0 ]; then log_debug "ERROR: Hook failed with exit code $EXIT_CODE" fi -GRAPH_LINE=$(grep -o '\[Graph\] .*' "$STDERR_FILE" | head -1) +GRAPH_LINE=$(grep -o '\[Graph\] [^"]*' "$STDERR_FILE" | head -1) if [ -n "$GRAPH_LINE" ]; then echo "$GRAPH_LINE" >&2 fi diff --git a/.claude/hooks/post-tool-use-tracker.sh b/.claude/hooks/post-tool-use-tracker.sh index bb83a42..1a58e0d 100755 --- a/.claude/hooks/post-tool-use-tracker.sh +++ b/.claude/hooks/post-tool-use-tracker.sh @@ -222,31 +222,36 @@ fi # and persist it in session state for sticky behavior # BEGIN detect_skill_domain -# STUB: replaced during installation by generateDomainPatterns() detect_skill_domain() { local file="$1" local detected_skills="" - # ----------------------------------------------- - # Add your domain-specific patterns here. - # Uses independent if statements (not elif) so a single - # file can activate multiple skills (e.g. shared files). - # - # Examples (uncomment and customize): - # - # if [[ "$file" =~ /courses/ ]] || [[ "$file" =~ useCourse ]]; then - # detected_skills="$detected_skills frontend/courses" - # fi - # if [[ "$file" =~ /dashboard/ ]] || [[ "$file" =~ useDashboard ]]; then - # detected_skills="$detected_skills frontend/dashboard" - # fi - # if [[ "$file" =~ /payments/ ]] || [[ "$file" =~ payment.*\.py ]]; then - # detected_skills="$detected_skills backend/payments" - # fi - # ----------------------------------------------- - - # Deduplicate and trim - echo "$detected_skills" | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/^ *//;s/ *$//' + # Generated by aspens from skill-rules.json filePatterns + if [[ "$file" =~ /customize ]] || [[ "$file" =~ /customize-agents ]]; then + detected_skills="agent-customization" + elif [[ "$file" =~ /runner ]] || [[ "$file" =~ /timeout ]] || [[ "$file" =~ /skill-writer ]] || [[ "$file" =~ /skill-reader ]] || [[ "$file" =~ /prompts/ ]]; then + detected_skills="claude-runner" + elif [[ "$file" =~ /cli ]] || [[ "$file" =~ /index ]] || [[ "$file" =~ /errors ]]; then + detected_skills="cli-shell" + elif [[ "$file" =~ /target ]] || [[ "$file" =~ /target-transform ]] || [[ "$file" =~ /backend ]] || [[ "$file" =~ /AGENTS ]] || [[ "$file" =~ /.aspens ]]; then + detected_skills="codex-support" + elif [[ "$file" =~ /doc-impact ]] || [[ "$file" =~ /impact ]] || [[ "$file" =~ /impact-analyze ]] || [[ "$file" =~ /impact.test ]] || [[ "$file" =~ /doc-impact.test ]]; then + detected_skills="doc-impact" + elif [[ "$file" =~ /doc-sync ]] || [[ "$file" =~ /diff-classifier ]] || [[ "$file" =~ /diff-helpers ]] || [[ "$file" =~ /git-hook ]] || [[ "$file" =~ /git-helpers ]] || [[ "$file" =~ /doc-sync-refresh ]] || [[ "$file" =~ /preservation-contract-refresh ]]; then + detected_skills="doc-sync" + elif [[ "$file" =~ /graph-builder ]] || [[ "$file" =~ /graph-persistence ]] || [[ "$file" =~ /parsers/ ]] || [[ "$file" =~ /doc-graph ]] || [[ "$file" =~ /graph-context-prompt ]]; then + detected_skills="import-graph" + elif [[ "$file" =~ /scanner ]] || [[ "$file" =~ /source-exts ]] || [[ "$file" =~ /path-resolver ]] || [[ "$file" =~ /typescript ]] || [[ "$file" =~ /python ]] || [[ "$file" =~ /nextjs ]] || [[ "$file" =~ /scan ]] || [[ "$file" =~ /scanner.test ]]; then + detected_skills="repo-scanning" + elif [[ "$file" =~ /save-tokens ]] || [[ "$file" =~ /hooks/ ]] || [[ "$file" =~ /save-handoff ]] || [[ "$file" =~ /commands/ ]] || [[ "$file" =~ /tests/ ]]; then + detected_skills="save-tokens" + elif [[ "$file" =~ /doc-init ]] || [[ "$file" =~ /context-builder ]] || [[ "$file" =~ /prompts/ ]]; then + detected_skills="skill-generation" + elif [[ "$file" =~ /add ]] || [[ "$file" =~ /add-skill ]] || [[ "$file" =~ /templates/ ]]; then + detected_skills="template-library" + fi + + echo "$detected_skills" } # END detect_skill_domain diff --git a/.claude/hooks/skill-activation-prompt.mjs b/.claude/hooks/skill-activation-prompt.mjs index 221469e..725ad4d 100644 --- a/.claude/hooks/skill-activation-prompt.mjs +++ b/.claude/hooks/skill-activation-prompt.mjs @@ -50,7 +50,7 @@ export function readSkillContent(projectDir, skillName) { if (rel.startsWith('..') || resolve(rel) === rel) continue; if (existsSync(candidate)) { try { - return readFileSync(candidate, 'utf-8'); + return stripActivationBlock(readFileSync(candidate, 'utf-8')); } catch { // Continue to next path } @@ -60,6 +60,18 @@ export function readSkillContent(projectDir, skillName) { return null; } +// The `## Activation` section is the source-of-truth for skill-rules.json +// (file patterns + keywords). Once a skill has been selected for activation, +// re-stating *when* it activates inside the injected content is wasted tokens. +// Strip the block before injection — the on-disk file is unchanged so the +// rules generator still reads it. +function stripActivationBlock(content) { + if (!content) return content; + return content + .replace(/\n## Activation\s*\r?\n[\s\S]*?(?:\r?\n---\s*\r?\n|(?=\r?\n## )|$)/, '\n') + .replace(/(\r?\n){3,}/g, '\n\n'); +} + /** * Detect which repository we're currently in. * Checks .claude/repo-config.json first, falls back to directory basename. diff --git a/.claude/hooks/skill-activation-prompt.sh b/.claude/hooks/skill-activation-prompt.sh index 266fe60..9eb801f 100755 --- a/.claude/hooks/skill-activation-prompt.sh +++ b/.claude/hooks/skill-activation-prompt.sh @@ -31,8 +31,10 @@ get_script_dir() { } SCRIPT_DIR="$(get_script_dir)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" log_debug "SCRIPT_DIR=$SCRIPT_DIR" log_debug "CLAUDE_PROJECT_DIR=$CLAUDE_PROJECT_DIR" +log_debug "ASPENS_PROJECT_DIR=$PROJECT_DIR" cd "$SCRIPT_DIR" || { echo "⚡ [Skills] Failed to cd to $SCRIPT_DIR" >&2; exit 0; } @@ -49,7 +51,7 @@ STDOUT_FILE=$(mktemp) STDERR_FILE=$(mktemp) trap 'rm -f "$STDOUT_FILE" "$STDERR_FILE"' EXIT -printf '%s' "$INPUT" | NODE_NO_WARNINGS=1 node skill-activation-prompt.mjs \ +printf '%s' "$INPUT" | ASPENS_PROJECT_DIR="$PROJECT_DIR" NODE_NO_WARNINGS=1 node skill-activation-prompt.mjs \ >"$STDOUT_FILE" 2>"$STDERR_FILE" EXIT_CODE=$? diff --git a/.claude/settings.json b/.claude/settings.json index e9307a1..f167275 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/skill-activation-prompt.sh" + "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-activation-prompt.sh\"" } ] }, @@ -16,6 +16,14 @@ "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/save-tokens-prompt-guard.sh\"" } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/graph-context-prompt.sh\"" + } + ] } ], "PostToolUse": [ @@ -24,7 +32,7 @@ "hooks": [ { "type": "command", - "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/post-tool-use-tracker.sh" + "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/post-tool-use-tracker.sh\"" } ] } diff --git a/.claude/settings.json.bak b/.claude/settings.json.bak index 0ef7711..e9307a1 100644 --- a/.claude/settings.json.bak +++ b/.claude/settings.json.bak @@ -8,6 +8,14 @@ "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/skill-activation-prompt.sh" } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/save-tokens-prompt-guard.sh\"" + } + ] } ], "PostToolUse": [ @@ -20,6 +28,20 @@ } ] } + ], + "PreCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/save-tokens-precompact.sh\"" + } + ] + } ] + }, + "statusLine": { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/save-tokens-statusline.sh\"" } } diff --git a/.claude/skills/agent-customization/skill.md b/.claude/skills/agent-customization/skill.md index 206b3d2..0c969e0 100644 --- a/.claude/skills/agent-customization/skill.md +++ b/.claude/skills/agent-customization/skill.md @@ -1,39 +1,46 @@ --- name: agent-customization description: LLM-powered injection of project context into installed agent templates via `aspens customize agents` +triggers: + files: + - src/commands/customize.js + - src/prompts/customize-agents.md + keywords: + - customize + - agents + - subagent + - agent customization --- -## Activation - -This skill triggers when editing agent-customization files: -- `src/commands/customize.js` -- `src/prompts/customize-agents.md` +You are working on **agent customization** — the feature that reads a project's skills and CLAUDE.md, then uses Claude CLI to inject project-specific context into generic agent files in `.claude/agents/`. ---- +## Domain purpose +`aspens customize agents` makes generic, bundled agent templates project-aware. It pulls the repo's skills + CLAUDE.md as ground truth and asks Claude to add a tech-stack line, 3-5 project conventions, and real commands into each agent — without touching the agent's core logic. -You are working on **agent customization** — the feature that reads a project's skills and CLAUDE.md, then uses Claude CLI to inject project-specific context into generic agent files in `.claude/agents/`. +## Business rules / invariants +- **Claude-only feature.** Throws `CliError` for Codex-only repos (`config.targets === ['codex']`). Codex CLI has no agent concept. +- **Base skill is required.** Pre-flight throws `CliError("Run 'aspens doc init' first — base skill is required for agent context.")` if `.claude/skills/base/skill.md` is missing. +- **Skills (`.claude/skills/**`) are the single source of truth** for project context. The prompt must not invent other context directories. +- **Read-only tools only.** Claude is invoked with `allowedTools: ['Read', 'Glob', 'Grep']` — no edits/writes from the LLM itself. +- **Output paths restricted to `.claude/`.** `parseFileOutput()` rejects anything else; `writeSkillFiles(..., { force: true })` does the actual write. -## Key Files -- `src/commands/customize.js` — Main command: finds agents, gathers context, calls Claude, writes results -- `src/prompts/customize-agents.md` — System prompt telling Claude how to customize agents -- `src/lib/runner.js` — `runClaude()`, `loadPrompt()`, `parseFileOutput()` shared across commands -- `src/lib/skill-writer.js` — `writeSkillFiles()` writes parsed output to disk -- `src/lib/timeout.js` — `resolveTimeout()` for timeout handling (default 300s) +## Non-obvious behaviors +- **Frontmatter preservation is split across LLM + code.** The prompt instructs Claude to preserve YAML frontmatter verbatim (including NOT adding a `skills:` line). Then `maybeInjectBaseSkill()` post-processes each returned file to add `skills: [base]` into the frontmatter — this keeps agents valid even when installed via `aspens add agent` without a prior `doc init`. +- **`--reset` semantics:** without `--reset`, agents that already declare `skills:` are left alone; with `--reset`, any existing `skills:` line is overwritten to `skills: [base]`. Used to roll out v0.8 upgrades to previously-customized agents. +- **`## Project context` block is verbatim-preserved** by the prompt — it carries conditional read instructions for code-map / domain skills. +- **CLAUDE.md is truncated at 3000 chars** in `gatherProjectContext()`; skills are passed in full. +- **Agent discovery:** `findAgents()` recursively walks `.claude/agents/`, extracts `name:` via regex, falls back to filename if missing. +- **Default timeout 300s** via `resolveTimeout(options.timeout, 300)`; `ASPENS_TIMEOUT` env var honored with warning on invalid value. -## Key Concepts -- **Claude-only feature:** Customize command reads `.aspens.json` and throws `CliError` if repo is configured for Codex-only (`targets: ['codex']`). Codex CLI has no agent concept. -- **Context gathering:** `gatherProjectContext()` reads CLAUDE.md (truncated at 3000 chars) and all `.claude/skills/**/*.md` in full. Skills are the single source of truth for project context. -- **Agent discovery:** `findAgents()` recursively walks `.claude/agents/`, reads `.md` files, extracts `name:` via regex — falls back to filename if no frontmatter match. -- **Read-only tools:** Claude is invoked with `allowedTools: ['Read', 'Glob', 'Grep']` and no maxTokens cap (unlike doc-init which sets per-call limits). -- **Output parsing:** Claude returns `content` XML tags, parsed by `parseFileOutput()`. Only `.claude/` paths are allowed. +## Critical files (purpose, not inventory) +- `src/commands/customize.js` — orchestrator: preflight, agent discovery, context gathering, per-agent Claude calls, post-LLM `skills: [base]` injection, write. +- `src/prompts/customize-agents.md` — system prompt; enforces frontmatter + `## Project context` preservation and bans file-inventory / hub-ranking output. ## Critical Rules -- **Claude-only** — throws `CliError` for Codex-only repos. Checks `readConfig(repoPath)` for target config. -- **Read-only tools only** — Claude agents never get write tools. All output goes through `parseFileOutput()` → `writeSkillFiles()`. -- **Context truncation** — CLAUDE.md is capped at 3000 chars to avoid blowing up prompt size. Skills are read in full. -- **Path safety** — `parseFileOutput()` only allows writes to `.claude/` prefixed paths. Customized agents stay in `.claude/agents/`. -- **Dry-run support** — `--dry-run` flag previews output without writing. Confirmation prompt shown before writes. -- **Model override** — `--model` flag passed through to `runClaude()` for model selection. +- **Never let the LLM emit a `skills:` line** — the prompt forbids it and the code adds it. If you change one, change both. +- **Never weaken path sanitization** — only `.claude/` paths may be written. +- **Never duplicate file-inventory or hub-ranking output** in customized agents — the graph hook supplies that dynamically. +- **Do not bypass the base-skill preflight** — agents without base context regress to generic behavior. --- -**Last Updated:** 2026-04-02 +**Last Updated:** 2026-05-11 diff --git a/.claude/skills/base/skill.md b/.claude/skills/base/skill.md index dca6545..3fb8029 100644 --- a/.claude/skills/base/skill.md +++ b/.claude/skills/base/skill.md @@ -1,18 +1,14 @@ --- name: base description: Core conventions, tech stack, and project structure for aspens ---- - -## Activation - -This is a **base skill** that always loads when working in this repository. - +triggers: + alwaysActivate: true --- You are working in **aspens** — a CLI that keeps coding-agent context accurate as your codebase changes. Scans repos, generates project-specific instructions and skills for Claude Code and Codex CLI, and keeps them fresh. ## Tech Stack -Node.js (ESM) | Commander | Vitest | es-module-lexer | @clack/prompts | picocolors +Node.js 20+ (ESM) | Commander | Vitest | es-module-lexer | @clack/prompts | picocolors ## Commands - `npm test` — Run vitest suite @@ -25,6 +21,8 @@ Node.js (ESM) | Commander | Vitest | es-module-lexer | @clack/prompts | picocolo - `aspens add [name]` — Install templates (agents, commands, hooks) - `aspens customize agents` — Inject project context into installed agents - `aspens save-tokens [path]` — Install token-saving session settings (`--recommended` for no-prompt install, `--remove` to uninstall) +- **Debug:** `ASPENS_DEBUG=1` dumps raw stream events to `$TMPDIR/aspens-debug-{stream,codex-stream}.json` +- **Env knob:** `ASPENS_TIMEOUT` (seconds) overrides default LLM timeout when `--timeout` not passed ## Architecture CLI entry (`bin/cli.js`) → command handlers (`src/commands/`) → lib modules (`src/lib/`) @@ -32,10 +30,11 @@ CLI entry (`bin/cli.js`) → command handlers (`src/commands/`) → lib modules - `src/lib/scanner.js` — Deterministic repo scanner (languages, frameworks, domains, structure) - `src/lib/graph-builder.js` — Static import analysis via es-module-lexer (hub files, clusters, priority) - `src/lib/graph-persistence.js` — Graph serialization, subgraph extraction, code-map + index generation -- `src/lib/runner.js` — Claude/Codex CLI wrapper (`runClaude` for stream-json, `runCodex` for Codex JSONL) +- `src/lib/runner.js` — Claude/Codex CLI wrapper (`runClaude` for stream-json, `runCodex` for Codex JSONL); also hosts `loadPrompt` (partial substitution) and `parseFileOutput`/`validateSkillFiles` - `src/lib/context-builder.js` — Assembles repo files into prompt-friendly context - `src/lib/skill-writer.js` — Writes skill files and directory-scoped files, generates skill-rules.json, merges settings -- `src/lib/skill-reader.js` — Parses skill files, frontmatter, activation patterns, keywords +- `src/lib/skill-reader.js` — Parses skill files, frontmatter, `triggers:` blocks, legacy activation patterns, keywords +- `src/lib/diff-classifier.js` — Maps changed files to affected skills for doc-sync - `src/lib/diff-helpers.js` — Targeted file diffs and prioritized diff truncation for doc-sync - `src/lib/git-helpers.js` — Git repo detection, git root resolution, diff retrieval, log formatting - `src/lib/git-hook.js` — Post-commit git hook installation/removal for auto doc-sync (monorepo-aware) @@ -43,9 +42,12 @@ CLI entry (`bin/cli.js`) → command handlers (`src/commands/`) → lib modules - `src/lib/save-tokens.js` — Save-tokens config defaults, settings builders, gitignore/readme generators - `src/lib/timeout.js` — Timeout resolution (`--timeout` flag > `ASPENS_TIMEOUT` env > default) - `src/lib/errors.js` — `CliError` class (structured errors caught by CLI top-level handler) -- `src/lib/target.js` — Target definitions (claude/codex), config persistence (`.aspens.json`) with `saveTokens` feature config +- `src/lib/target.js` — Target definitions (claude/codex), config persistence (`.aspens.json`) with `saveTokens` feature config; `getAllowedPaths` for multi-target sanitization - `src/lib/target-transform.js` — Transforms Claude-format output to other target formats - `src/lib/backend.js` — Backend detection and resolution (which CLI generates content) +- `src/lib/path-resolver.js` / `src/lib/source-exts.js` — Source-file extension and path resolution helpers shared by scanner/graph +- `src/lib/parsers/` — Language-specific import parsers (TypeScript, Python) +- `src/lib/frameworks/` — Framework-specific detectors (e.g. Next.js) - `src/prompts/` — Prompt templates with `{{partial}}` and `{{variable}}` substitution - `src/templates/` — Bundled agents, commands, hooks, and settings for `aspens add` / `doc init` / `save-tokens` @@ -54,12 +56,15 @@ CLI entry (`bin/cli.js`) → command handlers (`src/commands/`) → lib modules - **es-module-lexer WASM** — must `await init` before calling `parse()` in graph-builder - **Claude CLI execution** — `runClaude()` spawns `claude -p` with stream-json; always use `--verbose` flag with stream-json - **Codex CLI execution** — `runCodex()` spawns `codex exec --json --sandbox read-only --ask-for-approval never --ephemeral`; returns `{ text, usage }` matching `runClaude` interface -- **Path sanitization** — `parseFileOutput()` restricts writes to `.claude/` and `CLAUDE.md` by default; accepts `allowedPaths` override for multi-target +- **Stdin with backpressure** — `runClaude`/`runCodex` pipe prompts via stdin and respect `drain` when `write()` returns false; never rewrite to use args (shell length limits) +- **Path sanitization** — `parseFileOutput()` restricts writes to `.claude/` and `CLAUDE.md` by default; accepts `allowedPaths` override for multi-target via `getAllowedPaths(targets)` +- **Read-only LLM tools** — customize-style commands pass `allowedTools: ['Read', 'Glob', 'Grep']`; never broaden without review - **Prompt partials** — `{{name}}` in prompt files resolves to `src/prompts/partials/name.md` first, then falls back to template variables -- **Target/Backend distinction** — Target = output format/location; Backend = which LLM CLI generates content. Config persisted in `.aspens.json` +- **Target/Backend distinction** — Target = output format/location; Backend = which LLM CLI generates content. Config persisted in `.aspens.json`. Customize is Claude-only (`CliError` if `targets: ['codex']`) - **Scanner is deterministic** — no LLM calls; pure filesystem analysis - **CliError pattern** — command handlers throw `CliError` instead of calling `process.exit()`; caught at top level in `bin/cli.js` - **Monorepo support** — `getGitRoot()` resolves the actual git root; hooks, sync, and impact scope to the subdirectory project path +- **Verify before claiming** — Never state something is configured/running/done without confirming in-session ## Structure - `bin/` — CLI entry point (commander setup, CliError handler) @@ -70,4 +75,4 @@ CLI entry (`bin/cli.js`) → command handlers (`src/commands/`) → lib modules - `tests/` — Vitest test files --- -**Last Updated:** 2026-04-09 +**Last Updated:** 2026-05-11 diff --git a/.claude/skills/claude-runner/skill.md b/.claude/skills/claude-runner/skill.md index 9f89c6e..1a6642e 100644 --- a/.claude/skills/claude-runner/skill.md +++ b/.claude/skills/claude-runner/skill.md @@ -1,29 +1,30 @@ --- name: claude-runner description: Claude/Codex CLI execution layer — prompt loading, stream-json parsing, file output extraction, path sanitization, skill file writing, and skill rule generation ---- - -## Activation - -This skill triggers when editing claude-runner files: -- `src/lib/runner.js` -- `src/lib/skill-writer.js` -- `src/lib/skill-reader.js` -- `src/lib/timeout.js` -- `src/prompts/**/*.md` -- `tests/*extract*`, `tests/*parse*`, `tests/*prompt*`, `tests/*skill-writer*`, `tests/*skill-mapper*`, `tests/*timeout*` - +triggers: + files: + - src/lib/runner.js + - src/lib/timeout.js + - src/lib/skill-writer.js + - src/lib/skill-reader.js + - src/prompts/**/*.md + keywords: + - runClaude + - runCodex + - runLLM + - stream-json + - codex exec + - parseFileOutput + - sanitizePath + - loadPrompt + - writeSkillFiles + - skill-rules + - mergeSettings + - resolveTimeout --- You are working on the **CLI execution layer** — the bridge between assembled prompts and the `claude -p` / `codex exec` CLIs, plus skill file I/O. -## Key Files -- `src/lib/runner.js` — `runClaude()`, `runCodex()`, `runLLM()`, `loadPrompt()`, `parseFileOutput()`, `validateSkillFiles()`, `extractResultFromStream()` (exported); `extractResultFromCodexStream()`, `normalizeCodexItemType()`, `collectCodexText()`, `handleStreamEvent()`, `sanitizePath()`, `getCodexExecCapabilities()` (internal) -- `src/lib/skill-writer.js` — `writeSkillFiles()`, `writeTransformedFiles()`, `extractRulesFromSkills()`, `generateDomainPatterns()`, `mergeSettings()` -- `src/lib/skill-reader.js` — `findSkillFiles()`, `parseFrontmatter()`, `parseActivationPatterns()`, `parseKeywords()`, `fileMatchesActivation()`, `getActivationBlock()`, `GENERIC_PATH_SEGMENTS` -- `src/lib/timeout.js` — `resolveTimeout()` — priority: `--timeout` flag > `ASPENS_TIMEOUT` env var > caller fallback -- `src/prompts/` — Markdown prompt templates; `partials/` subdir holds `skill-format.md`, `guideline-format.md`, `examples.md` - ## Key Concepts - **Stream-JSON protocol (Claude):** `runClaude()` always passes `--verbose --output-format stream-json`. Output is NDJSON: `type: 'result'` has final text + usage; `type: 'assistant'` has text/tool_use blocks; `type: 'user'` has tool_result blocks. - **JSONL protocol (Codex):** `runCodex()` spawns `codex exec --json --sandbox read-only --ephemeral`. The `--ask-for-approval never` flag is **conditionally included** based on capability detection (see below). Prompt is passed via **stdin** (`'-'` placeholder arg) to avoid shell arg length limits. Stdin write happens **after** event handlers are attached so fast failures are captured. Events: `item.completed`/`item.updated` with normalized types. @@ -36,6 +37,7 @@ You are working on the **CLI execution layer** — the bridge between assembled - **Validation:** `validateSkillFiles()` checks for truncation (XML tag collisions), missing frontmatter, missing sections, bad file path references. - **Skill rules generation:** `extractRulesFromSkills()` reads all skills via `skill-reader.js`, produces `skill-rules.json` (v2.0) with file patterns, keywords, and intent patterns. - **Domain patterns:** `generateDomainPatterns()` converts file patterns to bash `detect_skill_domain()` function using `BEGIN/END` markers. +- **Trigger parsing precedence:** `parseTriggersFrontmatter(content)` returns `{ filePatterns, keywords, alwaysActivate }` parsed from a `triggers:` block in YAML frontmatter (supports block lists, inline arrays, and `alwaysActivate: true` for the base skill); returns `null` when no `triggers:` key exists. `parseActivationPatterns` and `parseKeywords` prefer this frontmatter when present and fall back to legacy `## Activation` / `Keywords:` line parsing for older skills. - **Settings merge:** `mergeSettings()` merges aspens hook config into existing `settings.json`. Detects aspens-managed hooks by `ASPENS_HOOK_MARKERS` (`skill-activation-prompt`, `graph-context-prompt`, `post-tool-use-tracker`, `save-tokens-statusline`, `save-tokens-prompt-guard`, `save-tokens-precompact`). Also handles `statusLine` merging — replaces existing statusLine only if the current one is aspens-managed (detected by `isAspensHook`), preserving user-custom statusLine configs. After merging hooks, `dedupeAspensHookEntries()` removes duplicate aspens-managed entries per event type. - **Directory-scoped writes:** `writeTransformedFiles()` handles files outside `.claude/` (e.g., `src/billing/AGENTS.md`) with explicit path allowlist — only `CLAUDE.md`, `AGENTS.md` exact files and `.claude/`, `.agents/`, `.codex/` prefixes are permitted. - **`findSkillFiles` matching:** Only matches the exact `skillFilename` (e.g., `skill.md` or `SKILL.md`), not arbitrary `.md` files in the skills directory. @@ -47,8 +49,9 @@ You are working on the **CLI execution layer** — the bridge between assembled - **Path sanitization is non-negotiable** — `sanitizePath()` blocks `..` traversal, absolute paths, and any path not in the allowed set. - **Prompt partials resolve before variables** — `{{skill-format}}` resolves to `partials/skill-format.md` first. If no file, falls through to variable substitution. - **Timeout resolution:** `resolveTimeout(flagValue, fallbackSeconds)` — `--timeout` flag wins, then `ASPENS_TIMEOUT` env, then caller-provided fallback. Size-based defaults (small: 120s, medium: 300s, large: 600s, very-large: 900s) are set by command handlers, not runner. +- **Disk writes are sanitized** — `writeSkillFiles` and `writeTransformedFiles` pass every payload through `sanitizePublishedContent` so forbidden blocks (`## Activation`, `## Key Files`, hub/cluster/hotspot tables outside `code-map.md`) cannot leak to disk even if an earlier stage missed them. - **`mergeSettings` preserves non-aspens hooks and statusLine** — identifies aspens hooks by `ASPENS_HOOK_MARKERS` (now includes save-tokens markers), replaces matching entries, preserves everything else. StatusLine only replaced if current one is aspens-managed. Post-merge deduplication ensures no duplicate aspens entries accumulate. - **Debug mode:** Set `ASPENS_DEBUG=1` to dump raw stream-json to `$TMPDIR/aspens-debug-stream.json` (Claude) or `$TMPDIR/aspens-debug-codex-stream.json` (Codex). Codex also logs exit code and output length to stderr. --- -**Last Updated:** 2026-04-10 +**Last Updated:** 2026-05-11 diff --git a/.claude/skills/cli-shell/skill.md b/.claude/skills/cli-shell/skill.md new file mode 100644 index 0000000..a187338 --- /dev/null +++ b/.claude/skills/cli-shell/skill.md @@ -0,0 +1,53 @@ +--- +name: cli-shell +description: Top-level Commander wiring, welcome screen, missing-hook warning, CliError exit handling, and the public programmatic API surface +triggers: + files: + - bin/cli.js + - src/index.js + - src/lib/errors.js + keywords: + - CliError + - commander + - bin/cli.js + - welcome + - checkMissingHooks + - parsePositiveInt + - parseTimeout + - SIGINT + - SIGTERM + - aspens public api +--- + +You are working on the **CLI shell** — the entry point that wires Commander subcommands, prints the welcome screen, warns about missing Claude hooks, dispatches to handlers, and translates `CliError` into a clean exit. Also the public programmatic surface re-exported from `src/index.js`. + +## Domain purpose +This layer is what a user actually invokes (`aspens …`) and what programmatic consumers import. It owns argument parsing, top-level error handling, and the welcome UX. All real work lives in `src/commands/*.js` — the shell only routes. + +## Business rules / invariants +- **Handlers must throw `CliError`, never call `process.exit()`.** The top-level handler in `bin/cli.js:250` catches it, prints `Error: ` (unless `logged: true`) in red, and exits with `err.exitCode` (default 1). Plain `Error` falls through to the same printer but always exits 1. +- **`logged: true` means "I already printed a user-friendly message"** — top level then exits silently with the given code. Use it when the handler rendered a clack `outro` or multi-line failure already. +- **`checkMissingHooks(repoPath)` runs before `doc sync`, `add`, and `customize`** — warns (does not throw) when `.claude/skills/` exists but `.claude/hooks/skill-activation-prompt.sh` or `.claude/skills/skill-rules.json` is absent. Skipped entirely when `.claude/skills/` is missing (nothing to activate). +- **No-command invocation shows `showWelcome()`** — listing essential commands, generate/sync, Claude add-ons, utilities, options, typical workflow, and target notes. Adding a new subcommand requires updating this screen too. +- **Template counts in the welcome are filesystem-derived** — `countTemplates(subdir)` reads `src/templates/{agents,commands,hooks}` and filters dotfiles; returns `'?'` on read failure (never throws). +- **Version comes from `package.json`** at runtime via `readFileSync`; falls back to `'0.0.0'` silently if parse/read fails. Do not hardcode. +- **Numeric option parsers throw `InvalidArgumentError`** (Commander-native) — `parsePositiveInt` rejects ≤0/NaN; `parseCommits` additionally caps at 50. +- **Signal handlers exit with conventional codes** — SIGINT→130, SIGTERM→143. Used to clean up spawned `claude -p` / `codex exec` children. + +## Non-obvious behaviors +- **Action wrappers chain `checkMissingHooks` before the handler** for `doc sync`, `add`, `customize` — done inline via arrow `(args, options) => { checkMissingHooks(resolve(path)); return handler(...) }`. Don't move this into the handler — the warning should fire even if the handler later fails or short-circuits. +- **`program.parseAsync()` is required** (not `.parse()`) — handlers are async; `.catch()` on the returned promise is the only place plain errors are surfaced. +- **`src/index.js` is the public programmatic API** — only re-exports `scanRepo`, `runClaude`, `loadPrompt`, `parseFileOutput`, `writeSkillFiles`, `buildContext`, `buildBaseContext`, `buildDomainContext`, `analyzeImpact`. Adding/removing a re-export is a breaking change for embedders; treat it as such. + +## Critical files +- `bin/cli.js` — Commander setup, option parsers, welcome screen, signal handlers, top-level `CliError` catch. +- `src/lib/errors.js` — `CliError` class with `exitCode` and `logged` options (plus optional `cause`). +- `src/index.js` — Stable programmatic surface for library consumers. + +## Critical Rules +- New subcommand → register on `program` (or the `doc` subgroup) **and** add it to `showWelcome()` so users discover it. +- Never swallow a handler error in the action wrapper — let it bubble to `program.parseAsync().catch()`. +- When a handler renders its own failure UX (clack/picocolors), throw `new CliError(msg, { logged: true, exitCode })` so the top level does not double-print. + +--- +**Last Updated:** 2026-05-11 diff --git a/.claude/skills/codex-support/skill.md b/.claude/skills/codex-support/skill.md index 7ba45da..b75b248 100644 --- a/.claude/skills/codex-support/skill.md +++ b/.claude/skills/codex-support/skill.md @@ -1,58 +1,63 @@ --- name: codex-support description: Multi-target output system — target abstraction, backend routing, content transforms for Codex CLI and future targets +triggers: + files: + - src/lib/target.js + - src/lib/target-transform.js + - src/lib/backend.js + - AGENTS.md + - .aspens.json + keywords: + - codex + - target + - backend + - AGENTS.md + - .aspens.json + - sanitize + - transform + - multi-target + - parity --- -## Activation - -This skill triggers when editing codex-support files: -- `src/lib/target.js` -- `src/lib/target-transform.js` -- `src/lib/backend.js` -- `tests/target.test.js` -- `tests/target-transform.test.js` -- `tests/backend.test.js` - -Keywords: codex, target, backend, AGENTS.md, directory-scoped, transform, multi-target - --- You are working on **multi-target output support** — the system that lets aspens generate documentation for Claude Code, Codex CLI, or both simultaneously. -## Key Files -- `src/lib/target.js` — Target definitions (`TARGETS`), `getAllowedPaths()`, `mergeConfiguredTargets()`, path helpers, config persistence (`.aspens.json`) with feature config support (`saveTokens`) -- `src/lib/target-transform.js` — Transforms Claude-format output to other target formats; `projectCodexDomainDocs()`, `validateTransformedFiles()`, `ensureRootKeyFilesSection()`, content sanitization -- `src/lib/backend.js` — Backend detection (`detectAvailableBackends`) and resolution (`resolveBackend`) with fallback logic - ## Key Concepts - **Target vs Backend:** Target = where output goes (claude → `.claude/skills/`, codex → `.agents/skills/` + directory-scoped `AGENTS.md`). Backend = which LLM CLI generates the content (`claude -p` or `codex exec`). -- **Target definitions:** `TARGETS.claude` (centralized) and `TARGETS.codex` (directory-scoped). Each defines paths and capability flags: `supportsHooks`, `supportsSettings`, `supportsGraph`, `supportsSkills`, `needsActivationSection`, `needsCodeMapEmbed`, `supportsMCP`. Codex also has `maxInstructionsBytes` (32 KiB) and `userSkillsDir`. +- **Target definitions:** `TARGETS.claude` (centralized) and `TARGETS.codex` (directory-scoped). Each defines paths and capability flags: `supportsHooks`, `supportsSettings`, `supportsGraph`, `supportsSkills`, `needsActivationSection`, `needsCodeMapEmbed`, `supportsMCP`. Codex also has `maxInstructionsBytes` (32 KiB) and `userSkillsDir`. Codex's `needsCodeMapEmbed` is `false` — condensed cluster/framework data goes into the synthetic `.agents/skills/architecture/` skill instead of the root AGENTS.md. - **Canonical generation:** Generation always produces Claude-canonical format first. Prompts always receive `CANONICAL_VARS` (hardcoded Claude paths from `doc-init.js`). Transforms run **after** generation to produce other target formats. - **Content transform:** `transformForTarget()` remaps paths and content. For Codex: base skill → root `AGENTS.md`, domain skills → both `.agents/skills/{domain}/SKILL.md` and source directory `AGENTS.md`. `generateCodexSkillReferences()` creates `.agents/skills/architecture/` with code-map data. -- **Instructions file disk fallback:** `transformToDirectoryScoped` loads `instructionsFile` from disk via `repoPath` context parameter when it's not in the canonical files array (e.g., during `doc init --strategy skip-existing` or incremental `doc sync`). Uses `existsSync`/`readFileSync` from `fs`. +- **Skills section completeness:** `collectSkillsForList()` (internal) reads every skill from disk under `sourceTarget.skillsDir` and overlays pending in-flight changes (`files` passed to the transform) so the root instructions file's `## Skills` section always lists every on-disk skill — not just the subset that changed in this sync. Pending changes win for descriptions; on-disk content survives for unchanged skills. +- **Instructions file disk fallback:** `transformToDirectoryScoped` loads `instructionsFile` from disk via `repoPath` context parameter when it's not in the canonical files array (e.g., during `doc init --strategy skip-existing` or incremental `doc sync`). Uses a single `readFileSync` from `fs` wrapped in try/catch (no separate `existsSync` check). - **Content sanitization:** `sanitizeCodexInstructions()` and `sanitizeCodexSkill()` strip Claude-specific references (hooks, skill-rules.json, Claude Code mentions) from Codex output. -- **`ensureRootKeyFilesSection(content, graphSerialized)`** — Post-processes root instructions file to guarantee a `## Key Files` section with top hub files from the graph. +- **`sanitizePublishedContent(content, filePath)`** — Single-chokepoint sanitizer invoked by `skill-writer.js` on every disk write. Always strips `## Activation` blocks and `## Key Files` blocks. Outside `code-map.md`, also strips count-bearing blocks: `**Hub files…**`, `**Domain clusters:**`, `**High-churn hotspots:**`, `**Framework entry points…**`. Defense in depth — upstream leaks can't reach the user. +- **Skills-variant stripping:** `syncSkillsSection()` removes LLM-emitted Skill-section variants (`## Skills Reference`, `## Skills Overview`, etc.) before injecting the canonical `## Skills` list. Doc-init and doc-sync prompts also forbid such headings. +- **`ensureRootKeyFilesSection(content)`** — Backwards-compat shim only. Strips legacy `## Key Files` hub blocks left in older docs and collapses extra blank lines. **Does not insert anything** — hub rankings live in `code-map.md` / `graph.json` exclusively. - **`mergeConfiguredTargets(existing, next)`** — Merges target arrays to avoid dropping previously configured targets during narrower runs. Validates against `TARGETS` keys, deduplicates. - **`getAllowedPaths(targets)`** — Returns `{ dirPrefixes, exactFiles }` union across all active targets. - **Backend detection:** `detectAvailableBackends()` checks if `claude` and `codex` CLIs are installed. `resolveBackend()` picks best match: explicit flag > target match > fallback. - **Config persistence:** `.aspens.json` at repo root stores `{ targets, backend, version, saveTokens? }`. `readConfig()` returns `null` if missing **or if the config is structurally invalid**. `isValidConfig()` validates targets, backend, version, and `saveTokens` (via `isValidSaveTokensConfig()`). +- **`loadConfig(repoPath, { persist })`** — Reads `.aspens.json` and, if missing, recovers via `inferConfig()` from on-disk artifacts. Returns `{ config, recovered }`. Persists inferred config to disk by default unless `persist: false` is passed. - **Feature config (`saveTokens`):** Optional object in `.aspens.json` validated by `isValidSaveTokensConfig()` — checks `enabled` (boolean), `warnAtTokens`/`compactAtTokens` (positive integers, compact > warn unless either is `MAX_SAFE_INTEGER`), `saveHandoff`/`sessionRotation` (booleans), optional `claude`/`codex` sub-objects with `enabled` and `mode`. - **`writeConfig` preserves feature config:** `writeConfig()` reads existing config and merges — `saveTokens` preserved unless explicitly set to `null` (intentional removal) or `undefined` (keep existing). Targets and backend also merge with existing. - **Multi-target publish:** `doc-sync` uses `publishFilesForTargets()` to generate output for all configured targets from a single LLM run. `repoPath` is passed through to the transform context. - **Codex inference tightened:** `inferConfig()` only adds `'codex'` to inferred targets when `.codex/` config dir or `.agents/skills/` dir exists. - **Conditional architecture ref:** Codex `buildCodexSkillRefs()` only includes the architecture skill reference when a graph was actually serialized. +- **Architecture skill is codex-only synthetic:** The codex `architecture` skill is generated from graph data and has no Claude counterpart by design. `logicalKeyForFile()` returns `null` for codex `architecture` paths so `assertTargetParity()` won't raise a parity violation for the missing Claude side. ## Critical Rules - **Generation always targets Claude canonical format first** — transforms run after, never during. Prompts always receive `CANONICAL_VARS`. -- **Split write logic:** `writeSkillFiles()` handles direct-write files. `writeTransformedFiles()` handles directory-scoped `AGENTS.md` with an explicit path allowlist and warn-and-skip policy. +- **Split write logic:** `writeSkillFiles()` handles direct-write files. `writeTransformedFiles()` handles directory-scoped `AGENTS.md` with an explicit path allowlist and warn-and-skip policy. Both writers run their payloads through `sanitizePublishedContent` before touching disk. - **Path safety:** `validateTransformedFiles()` rejects absolute paths, traversal, and unexpected filenames. `writeTransformedFiles()` enforces the same checks. - **Codex-only restrictions:** `add agent/command/hook` and `customize agents` throw `CliError` for Codex-only repos. `add skill` works for both targets. - **Graph/hooks are Claude-only** — `persistGraphArtifacts()` returns data without writing files when `target.supportsGraph === false`. Hook installation skipped when `supportsHooks === false`. - **Config validation is defensive** — `readConfig()` treats malformed but parseable JSON (e.g., wrong types for `targets`/`backend`/`version`/`saveTokens`) as invalid and returns `null`, same as missing config. -- **`repoPath` context is required for disk fallback** — callers of `transformForTarget` must pass `repoPath` in the context object for `instructionsFile` to load from disk when not in canonical files. +- **`repoPath` context is required for disk fallback** — callers of `transformForTarget` must pass `repoPath` in the context object for `instructionsFile` to load from disk when not in canonical files, and for `collectSkillsForList` to enumerate on-disk skills. ## References - **Patterns:** See `src/lib/target.js` for all target property definitions --- -**Last Updated:** 2026-04-25 +**Last Updated:** 2026-05-11 diff --git a/.claude/skills/doc-impact/skill.md b/.claude/skills/doc-impact/skill.md index 47a5fca..6859afd 100644 --- a/.claude/skills/doc-impact/skill.md +++ b/.claude/skills/doc-impact/skill.md @@ -1,60 +1,54 @@ --- name: doc-impact description: Context health analysis — freshness, domain coverage, hub surfacing, drift detection, LLM-powered interpretation, and auto-repair for generated agent context ---- - -## Activation - -This skill triggers when editing doc-impact files: -- `src/commands/doc-impact.js` -- `src/lib/impact.js` -- `src/prompts/impact-analyze.md` -- `tests/impact.test.js` -- `tests/doc-impact.test.js` - -Keywords: impact, freshness, coverage, drift, health score, context health, hook health, usefulness - +triggers: + files: + - src/commands/doc-impact.js + - src/lib/impact.js + - src/prompts/impact-analyze.md + - tests/impact.test.js + - tests/doc-impact.test.js + keywords: + - impact + - freshness + - coverage + - drift + - health score + - context health + - hook health + - usefulness --- You are working on **doc impact** — the command that shows whether generated agent context is keeping up with the codebase, optionally interprets results via LLM, and can interactively apply recommended repairs. -## Key Files -- `src/commands/doc-impact.js` — CLI command: calls `analyzeImpact()`, renders per-target report with health scores, coverage, drift, usefulness, hook health, save-tokens health, LLM interpretation, opportunities, and interactive apply confirmation -- `src/lib/impact.js` — Core analysis: `analyzeImpact()` orchestrates scan + config + graph + per-target summarization; exports `evaluateHookHealth()`, `evaluateSaveTokensHealth()`, `summarizeOpportunities()`, `summarizeValueComparison()`, `summarizeMissing()` -- `src/prompts/impact-analyze.md` — System prompt for LLM-powered impact interpretation (returns JSON with `bottom_line`, `improves`, `risks`, `next_step`) -- `tests/impact.test.js` — Unit tests for coverage, drift, health score, status, report summarization, value comparison, missing rollup, hook health, save-tokens health, opportunities -- `tests/doc-impact.test.js` — Unit tests for `buildApplyPlan()` and `buildApplyConfirmationMessage()` +## Domain purpose +Audits the agent context generated by aspens against the live source tree, reports per-target health (Claude, Codex), and offers an interactive `--apply` flow that re-runs the right `aspens doc init/sync` variant to repair gaps. The LLM interpretation is a thin layer on top of deterministic metrics — the metrics are the contract. + +## Business rules / invariants +- **Target inference:** If `.aspens.json` is absent, targets are inferred from scan results (`.claude/` → claude, `.agents/` → codex). Falls back to `['claude']`. +- **LLM is optional and tool-less.** Runs only when a CLI backend is detected. `runLLM` is invoked with `disableTools: true`; prompt must return pure JSON (`bottom_line`, `improves`, `risks`, `next_step`). Failure is caught and reported as "Analysis unavailable" — never fatal. +- **Graph failure is non-fatal.** If `buildRepoGraph` throws (or `--no-graph` is passed), `graph` is `null` and hub coverage is skipped/`n/a`. +- **Hub coverage haystack is the code-map, not CLAUDE.md.** Post-Phase 1, `## Key Files` no longer lives in root instructions. `computeHubCoverage` reads `.claude/code-map.md` (claude) or `.agents/skills/architecture/references/code-map.md` (codex). If the code-map file is missing, it reports `codeMapMissing: true` instead of spurious "missing hub" warnings. Older callers without `repoPath` fall back to the legacy `contextText` haystack. +- **Health score deductions** (start 100): missing instructions −35; no skills −25; domain gaps proportional up to −25; missed hubs −4 each; drift −3 per file (cap −20); unhealthy hooks −10 (hook-capable targets only); broken save-tokens −5 (claude only). +- **`LOW_SIGNAL_DOMAIN_NAMES`** (`config`, `test`, `tests`, `__tests__`, `spec`, `e2e`) are excluded from coverage scoring but tracked in `excluded`. +- **`SOURCE_EXTS`** extends the scanner set with `.scala`, `.clj`, `.elm`, `.vue`, `.svelte` for drift detection. Adding a language for drift requires updating this set. +- **Walk depth capped at 5** — deep nested source files won't appear in drift analysis. -## Key Concepts -- **`analyzeImpact(repoPath, options)`** — Main entry point. Runs `scanRepo()`, loads config from `.aspens.json`, infers targets if not configured, collects source file state, optionally builds import graph, then produces per-target reports. Now also computes `summary.opportunities`. -- **Target inference:** If no `.aspens.json` config, infers targets from scan results (`.claude/` → claude, `.agents/` → codex). Falls back to `['claude']`. -- **`summarizeTarget()`** — Per-target analysis: finds skills, evaluates hook health, evaluates save-tokens health (Claude only), checks instruction file existence, computes domain coverage, hub coverage, drift, usefulness, status, health score, and recommended actions. -- **Domain coverage:** `computeDomainCoverage()` matches scan-detected domains against installed skills. Filters out `LOW_SIGNAL_DOMAIN_NAMES` (config, test, tests, __tests__, spec, e2e) from scoring — tracked in `excluded` field. -- **Hub coverage:** `computeHubCoverage()` checks if top 5 graph hub file paths appear in the instruction file + base skill text. -- **Drift detection:** `computeDrift()` finds source files modified after the latest generated context mtime. Maps changed files to affected domains via directory matching. -- **Health score:** `computeHealthScore()` starts at 100, deducts for: missing instructions (-35), no skills (-25), domain gaps (up to -25), missed hubs (-4 each), drift (-3 per file, max -20), unhealthy hooks (-10 for Claude), broken save-tokens (-5 for Claude). -- **Hook health:** `evaluateHookHealth(repoPath)` checks for required hook scripts, validates `settings.json` hook commands resolve to existing files. -- **Save-tokens health:** `evaluateSaveTokensHealth(repoPath, saveTokensConfig)` checks if configured save-tokens installation is complete — validates required hook files, command files, legacy file cleanup, and settings.json entries. Returns `{ configured, healthy, issues, missingHookFiles, missingCommandFiles, invalidCommands, installedLegacyHookFiles }`. -- **Opportunities:** `summarizeOpportunities(repoPath, targets, config)` identifies optional aspens features not yet installed: save-tokens, agents, agent customization, doc-sync hook. Each returns `{ kind, message, command }`. Displayed in the "Missing Aspens Setup" section. -- **Usefulness summary:** `summarizeUsefulness()` produces `{ strengths, blindSpots, activationExamples }` per target. -- **Value comparison:** `summarizeValueComparison(targets)` computes before/after metrics for the report header. -- **Missing rollup:** `summarizeMissing(targets)` aggregates cross-target gaps including broken save-tokens installations with severity levels. -- **LLM interpretation:** If CLI backend is available, sends report + comparison as JSON to `impact-analyze` prompt. `saveTokensHealth` included in the analysis payload. -- **Interactive apply:** `buildApplyPlan(targets)` collects all recommended actions across targets with interactive confirmation. +## Non-obvious behaviors +- **Agent skill-ref check (Phase 6):** `checkAgentSkillReferences()` scans `.claude/agents/*.md` frontmatter for `skills: [a, b]` and verifies each `.claude/skills//skill.md` exists. Broken refs surface as `agent-skill-refs` opportunities. +- **Save-tokens health is Claude-only** and only activates when `config.saveTokens.enabled` is true and `claude.enabled !== false`. Required hook/command files vary by sub-config (`saveHandoff`, `warnAtTokens`/`compactAtTokens` thresholds). Legacy `.mjs` siblings of the `.sh` hooks must be cleaned up — their presence is an issue. +- **`buildApplyPlan` dedupes** with `aspens doc sync` as a target-agnostic key; everything else is keyed `${target.id}:${action}`. +- **`applyRecommendedAction` is a hand-maintained dispatch table** mapping action strings to `docInitCommand`/`docSyncCommand` option shapes. Adding a new recommendation in `recommendActions()` requires a matching branch here, otherwise it warns "Cannot apply automatically". -## Critical Rules -- **LLM interpretation is optional** — runs only if a CLI backend is detected. Failure is caught and reported as "Analysis unavailable". -- **LLM gets no tools** — `disableTools: true` passed to `runLLM()`. The prompt expects pure JSON output. -- **`--no-graph` flag** — skips import graph build; hub coverage section shows `n/a`. -- **Graph failure is non-fatal** — if `buildRepoGraph` throws, graph is set to null and analysis continues without hub data. -- **`SOURCE_EXTS` set** — only these extensions count as source files for drift detection. Adding a language requires updating this set. -- **Walk depth capped at 5** — deep nested source files won't appear in drift analysis. -- **`LOW_SIGNAL_DOMAIN_NAMES`** — `config`, `test`, `tests`, `__tests__`, `spec`, `e2e` are excluded from domain coverage scoring but tracked in `excluded` array. -- **Exported functions** — `computeDomainCoverage`, `computeHubCoverage`, `computeDrift`, `evaluateHookHealth`, `evaluateSaveTokensHealth`, `computeHealthScore`, `computeTargetStatus`, `recommendActions`, `summarizeReport`, `summarizeMissing`, `summarizeOpportunities`, `summarizeValueComparison` from `impact.js`; `buildApplyPlan`, `buildApplyConfirmationMessage` from `doc-impact.js`. +## Critical files (purpose, not inventory) +- `src/commands/doc-impact.js` — CLI rendering, LLM prompt assembly, `buildApplyPlan`, and the action dispatch into `docInitCommand`/`docSyncCommand`. +- `src/lib/impact.js` — All deterministic analysis (`analyzeImpact`, target summarization, scoring, drift, hub/code-map coverage, hook/save-tokens health, opportunities, missing rollup, value comparison). +- `src/prompts/impact-analyze.md` — Strict JSON contract the LLM must honor; do not change shape without updating `parseAnalysis`. -## References -- **Patterns:** `src/lib/skill-reader.js` — `findSkillFiles()` used for skill discovery per target -- **Prompt:** `src/prompts/impact-analyze.md` +## Critical Rules +- Skills/`activationPatterns` are matched via `findMatchingSkill` (substring or `/domain/` path hit). Renaming the skill-reader contract breaks coverage scoring. +- Don't surface root-context hub warnings when `codeMapMissing` is true — emit the `code-map-missing` item instead and tell the user to run `aspens doc graph`. +- Exported surface used by tests/consumers: `computeDomainCoverage`, `computeHubCoverage`, `computeDrift`, `evaluateHookHealth`, `evaluateSaveTokensHealth`, `computeHealthScore`, `computeTargetStatus`, `recommendActions`, `summarizeReport`, `summarizeMissing`, `summarizeOpportunities`, `summarizeValueComparison`, `checkAgentSkillReferences` from `impact.js`; `buildApplyPlan`, `buildApplyConfirmationMessage` from `doc-impact.js`. --- -**Last Updated:** 2026-04-09 +**Last Updated:** 2026-05-11 diff --git a/.claude/skills/doc-sync/skill.md b/.claude/skills/doc-sync/skill.md index 0242bc8..7a8b193 100644 --- a/.claude/skills/doc-sync/skill.md +++ b/.claude/skills/doc-sync/skill.md @@ -1,44 +1,39 @@ --- name: doc-sync description: Incremental skill updater that maps git diffs to affected skills and optionally auto-syncs via a post-commit hook ---- - -## Activation - -This skill triggers when editing doc-sync-related files: -- `src/commands/doc-sync.js` -- `src/prompts/doc-sync.md` -- `src/prompts/doc-sync-refresh.md` -- `src/lib/git-helpers.js` -- `src/lib/diff-helpers.js` -- `src/lib/git-hook.js` - -Keywords: doc-sync, refresh, sync, git-hook - +triggers: + files: + - src/commands/doc-sync.js + - src/lib/diff-classifier.js + - src/lib/diff-helpers.js + - src/lib/git-hook.js + - src/lib/git-helpers.js + - src/prompts/doc-sync.md + - src/prompts/doc-sync-refresh.md + - src/prompts/partials/preservation-contract-refresh.md + keywords: + - doc sync + - doc-sync + - refresh + - post-commit hook + - install-hook + - diff classifier + - changetype filter --- You are working on **doc-sync**, the incremental skill update command (`aspens doc sync`). -## Key Files -- `src/commands/doc-sync.js` — Main command: git diff → graph rebuild → skill mapping → LLM update → publish for targets → write. Also contains refresh mode and `skillToDomain()` export. -- `src/prompts/doc-sync.md` — System prompt for diff-based sync (uses `{{skill-format}}` partial, target-specific path variables) -- `src/prompts/doc-sync-refresh.md` — System prompt for `--refresh` mode (full skill review) -- `src/lib/git-helpers.js` — `getGitRoot()`, `isGitRepo()`, `getGitDiff()`, `getGitLog()`, `getChangedFiles()` — git primitives -- `src/lib/diff-helpers.js` — `getSelectedFilesDiff()`, `buildPrioritizedDiff()`, `truncateDiff()`, `truncate()` — diff budgeting -- `src/lib/git-hook.js` — `installGitHook()` / `removeGitHook()` for post-commit auto-sync (monorepo-aware) -- `src/lib/context-builder.js` — `buildDomainContext()`, `buildBaseContext()` used by refresh mode -- `src/lib/runner.js` — `runLLM()`, `loadPrompt()`, `parseFileOutput()` shared across commands -- `src/lib/skill-writer.js` — `writeSkillFiles()`, `writeTransformedFiles()`, `extractRulesFromSkills()` for output -- `src/lib/target-transform.js` — `projectCodexDomainDocs()`, `transformForTarget()` for multi-target publish - ## Key Concepts - **Monorepo-aware:** `getGitRoot(repoPath)` resolves the actual git root. `projectPrefix` (`toGitRelative`) computes the subdirectory offset. `scopeProjectFiles()` filters changed files to the project subdirectory. Diffs are fetched from `gitRoot` but file paths are project-relative. - **Multi-target publish:** `configuredTargets()` reads `.aspens.json` for all configured targets. `chooseSyncSourceTarget()` picks the best source (prefers Claude if both exist). LLM generates for the source target; `publishFilesForTargets()` transforms output for all other configured targets. `graphSerialized` and `repoPath` are passed through to the transform context for conditional architecture references and disk-based instructions file loading. - **Backend routing:** `runLLM()` from `runner.js` dispatches to `runClaude()` or `runCodex()` based on `config.backend` (defaults to source target's id). - **Diff-based flow:** Gets `git diff HEAD~N..HEAD` from git root, scopes changed files to project prefix, then feeds diff plus existing skill contents and graph context to the selected backend. +- **Changetype filter (Phase 1):** `isNoOpDiff()` from `diff-classifier.js` skips the LLM call entirely on lockfile-only diffs and diffs touching zero code-bearing files. `LOCK_FILES` and `CODE_BEARING_EXTS` are the source of truth — extend them here, not at call sites. - **Prompt path variables:** Passes `{ skillsDir, skillFilename, instructionsFile, configDir }` from source target to `loadPrompt()` for path substitution in prompts. -- **Refresh mode (`--refresh`):** Skips diff entirely. Reviews every skill against the current codebase. Base skill refreshed first, then domain skills in parallel batches of `PARALLEL_LIMIT` (3). Also refreshes instructions file and reports uncovered domains. +- **Refresh mode (`--refresh`):** Skips diff entirely. Reviews every skill against the current codebase. Base skill refreshed first, then domain skills in parallel batches of `PARALLEL_LIMIT` (3). Also refreshes instructions file and reports uncovered domains. Refresh mode runs `ensureRootKeyFilesSection` (legacy stripper) before `syncSkillsSection` so any leftover `## Key Files` blocks from old docs are removed. +- **Deterministic section repair:** `repairDeterministicSections()` runs a no-LLM pass that re-injects `## Skills` and `## Behavior` into the root instructions file from on-disk state and strips any legacy `## Key Files` block via `ensureRootKeyFilesSection`. Called from the no-op / "up to date" sync paths so missing-section drift is fixed every invocation. The normal sync flow also runs the same Skills + Behavior + legacy-strip block on the canonical instructions file after the LLM step. - **Graph rebuild on every sync:** Calls `buildRepoGraph` + `persistGraphArtifacts` (with source target) to keep graph fresh. `graphSerialized` return value is captured and forwarded to `publishFilesForTargets` for conditional Codex architecture refs. Graph failure is non-fatal. +- **Legacy v0.7 hub-block cleanup:** `notifyLegacyHubBlockIfPresent()` surfaces a one-line notice on the first sync after upgrade when `CLAUDE.md`/`AGENTS.md` still carries the legacy `## Key Files` hub-counts block, so the diff that strips it isn't alarming. `regenerateStaleCodeMap()` force-rebuilds `.claude/code-map.md` on no-op syncs when it still carries the legacy `**Hub files**` block. - **Graceful response handling:** After LLM returns, if output has content but no `` tags, treats it as "no updates needed" with a verbose-only warning. The prompt explicitly requests an empty response when nothing needs updating. - **Graph-aware skill mapping:** `mapChangesToSkills()` checks direct file matches via `fileMatchesActivation()` (from `skill-reader.js`) and also whether changed files are imported by files matching a skill's activation block. - **Interactive file picker:** When diff exceeds 80k chars and TTY is available, offers multiselect with skill-relevant files pre-selected. @@ -47,7 +42,7 @@ You are working on **doc-sync**, the incremental skill update command (`aspens d - **Split writes:** Direct-write files (`.claude/`, `CLAUDE.md`, root `AGENTS.md`) use `writeSkillFiles()`. Directory-scoped `AGENTS.md` files (e.g. `src/AGENTS.md`) use `writeTransformedFiles()`. - **Skill-rules regeneration:** After writing, regenerates `skill-rules.json` via `extractRulesFromSkills()` — only for targets with `supportsHooks: true` (Claude). Uses `hookTarget` from publish targets list. - **`findExistingSkills` is target-aware:** Uses `target.skillsDir` and `target.skillFilename` to locate skills for any target. -- **Git hook (monorepo-aware):** `installGitHook()` installs at the git root with per-project scoping. Hook uses `PROJECT_PATH` derived from project-relative offset. Each subproject gets its own labeled hook block (`# >>> aspens doc-sync hook (label) >>>`) with a unique function name (`__aspens_doc_sync_`). Multiple subprojects can coexist in one post-commit hook. Hook skips aspens-only commits scoped to the project prefix. +- **Git hook (monorepo-aware):** `installGitHook()` installs at the git root with per-project scoping. Hook uses `PROJECT_PATH` derived from project-relative offset. Each subproject gets its own labeled hook block (`# >>> aspens doc-sync hook (label) >>>`) with a unique function name (`__aspens_doc_sync_`). Multiple subprojects can coexist in one post-commit hook. Hook skips aspens-only commits scoped to the project prefix. 5-minute per-project cooldown via `/tmp/aspens-sync-.lock`; logs to `/tmp/aspens-sync-.log` (truncated to last 100 lines past 200). Unlabeled v0.6-era blocks are auto-upgraded on re-install. - **Force writes:** doc-sync always calls `writeSkillFiles` with `force: true`. ## Critical Rules @@ -59,9 +54,10 @@ You are working on **doc-sync**, the incremental skill update command (`aspens d - `checkMissingHooks()` in `bin/cli.js` only checks for Claude skills (not Codex — Codex doesn't use hooks). - `dedupeFiles()` ensures no duplicate paths when publishing across multiple targets. - **Git operations use `gitRoot`** — diffs, logs, and changed files are fetched from git root, not `repoPath`. File paths are then scoped via `projectPrefix`. +- **`diff-classifier.js` is a leaf module** — `graph-builder.js` imports `LOCK_FILES` from it; never import from `graph-builder` back into the classifier. ## References - **Patterns:** `src/lib/skill-reader.js` — `GENERIC_PATH_SEGMENTS`, `fileMatchesActivation()`, `getActivationBlock()` --- -**Last Updated:** 2026-04-25 +**Last Updated:** 2026-05-11 diff --git a/.claude/skills/import-graph/skill.md b/.claude/skills/import-graph/skill.md index ecc7a8f..0da64c7 100644 --- a/.claude/skills/import-graph/skill.md +++ b/.claude/skills/import-graph/skill.md @@ -1,33 +1,30 @@ --- name: import-graph description: Static import analysis that builds dependency graphs, domain clusters, hub files, git churn hotspots, and file priority rankings ---- - -## Activation - -This skill triggers when editing import-graph-related files: -- `src/lib/graph-builder.js` -- `src/lib/graph-persistence.js` -- `src/commands/doc-graph.js` -- `src/templates/hooks/graph-context-prompt.mjs` -- `src/templates/hooks/graph-context-prompt.sh` -- `tests/graph-builder.test.js` -- `tests/graph-persistence.test.js` - -Keywords: graph, import graph, dependency, hub files, clustering, code-map, graph-index, subgraph - +triggers: + files: + - src/lib/graph-builder.js + - src/lib/graph-persistence.js + - src/lib/parsers/** + - src/commands/doc-graph.js + - src/templates/hooks/graph-context-prompt.mjs + keywords: + - graph + - import-graph + - hubs + - clusters + - hotspots + - code-map + - graph.json + - subgraph + - priority + - fanIn --- You are working on the **import graph system** — static analysis that parses JS/TS and Python source files to produce dependency graphs, plus persistence/query layers for runtime use. -## Key Files -- `src/lib/graph-builder.js` — Core graph logic: walk, parse, metrics, ranking, clustering (690 lines) -- `src/lib/graph-persistence.js` — Serialize, persist, load, subgraph extraction, code-map, graph-index -- `src/commands/doc-graph.js` — Standalone `aspens doc graph` command -- `src/lib/scanner.js` — Provides `detectEntryPoints()`, only internal dependency of graph-builder -- `src/templates/hooks/graph-context-prompt.mjs` — Standalone hook mirroring `extractSubgraph` logic -- `tests/graph-builder.test.js` — Graph builder tests using temp fixture directories -- `tests/graph-persistence.test.js` — Persistence layer tests +## Domain purpose +The graph turns raw source into a queryable map of "what depends on what" so other aspens features (doc-init, doc-sync, doc-impact, the graph context hook) can rank files by importance, surface hubs, detect domain clusters, and inject just the relevant neighborhood into prompts. It is the substrate that makes context generation deterministic and code-aware rather than guess-based. ## Key Concepts **graph-builder.js** — `buildRepoGraph(repoPath, languages?)` runs a 9-step pipeline: @@ -39,7 +36,8 @@ You are working on the **import graph system** — static analysis that parses J - `extractSubgraph(graph, filePaths)` returns 1-hop neighborhood of mentioned files + relevant hubs/hotspots/clusters - `formatNavigationContext(subgraph)` renders compact markdown (~50 line budget) for prompt injection - `extractFileReferences(prompt, graph)` tiered extraction: explicit paths → bare filenames → cluster keywords -- `generateCodeMap()` / `writeCodeMap()` standalone overview for graph hook consumption +- `generateCodeMap()` / `writeCodeMap()` standalone overview for graph hook consumption — emits a Domain clusters block (via `formatDomainClusters`) and framework entry points only; cross-domain coupling, hotspots, and the totals/date footer are intentionally omitted because they churn on every sync +- `formatDomainClusters(clusters, files)` — exported helper that renders the canonical Domain clusters block: clusters are merged by label, single-file clusters dropped, files per cluster capped at 5 and sorted by `fanIn` desc then path asc for sync stability; no per-cluster `(N files)` counts - `generateGraphIndex()` / `saveGraphIndex()` tiny inverted index (export names → files, hub basenames, cluster labels) **doc-graph.js** — Target-aware: reads `.aspens.json` config, passes target to `persistGraphArtifacts()`. Shows different completion message for Codex target (artifacts not written). @@ -53,9 +51,10 @@ You are working on the **import graph system** — static analysis that parses J - **Errors are swallowed, not thrown** in graph-builder — parse failures return empty/null. The graph must always complete. - **`extractSubgraph` logic is mirrored** in `graph-context-prompt.mjs` (`buildNeighborhood()`). Keep both in sync. - **doc-sync rebuilds graph on every sync** — calls `buildRepoGraph` + `persistGraphArtifacts` (with target) to keep it fresh. +- **Code-map output is sync-stable** — no totals, no dates, no hotspot churn counts, no `+N more` suffixes. Anything that varies between syncs without a real code change must stay out of generated context. ## References - **Hook mirror:** `src/templates/hooks/graph-context-prompt.mjs` --- -**Last Updated:** 2026-04-02 +**Last Updated:** 2026-05-11 diff --git a/.claude/skills/repo-scanning/skill.md b/.claude/skills/repo-scanning/skill.md index bbb095b..b394700 100644 --- a/.claude/skills/repo-scanning/skill.md +++ b/.claude/skills/repo-scanning/skill.md @@ -1,26 +1,31 @@ --- name: repo-scanning description: Deterministic repo analysis — language/framework detection, structure mapping, domain discovery, health checks, and import graph integration ---- - -## Activation - -This skill triggers when editing repo-scanning files: -- `src/lib/scanner.js` -- `src/commands/scan.js` -- `tests/scanner.test.js` - -Keywords: scanRepo, detectLanguages, detectFrameworks, detectDomains, detectEntryPoints, health check - +triggers: + files: + - src/lib/scanner.js + - src/lib/source-exts.js + - src/lib/path-resolver.js + - src/lib/parsers/typescript.js + - src/lib/parsers/python.js + - src/lib/frameworks/nextjs.js + - src/commands/scan.js + - tests/scanner.test.js + keywords: + - scanRepo + - detectLanguages + - detectFrameworks + - detectDomains + - detectEntryPoints + - health check + - SOURCE_EXTS + - SKIP_DIR_NAMES --- You are working on **aspens' repo scanning system** — a fully deterministic analyzer (no LLM calls) that detects languages, frameworks, structure, domains, entry points, size, and health issues for any repository. -## Key Files -- `src/lib/scanner.js` — Core `scanRepo()` function and all detection logic (languages, frameworks, structure, domains, entry points, size, health) -- `src/commands/scan.js` — CLI command that calls `scanRepo()`, optionally builds import graph via `graph-builder.js`, and renders pretty or JSON output. Contains `formatGraphForDisplay()` which transforms raw graph data into display-ready shape -- `src/lib/graph-builder.js` — Builds import graph; imports `detectEntryPoints` from scanner. Called by `scanCommand` but graph failure is non-fatal -- `tests/scanner.test.js` — Uses temporary fixture directories created in `tests/fixtures/scanner/`, cleaned up in `afterAll` +## Domain purpose +Scanning is the foundation every other command builds on. `scanRepo()` must produce stable, reproducible results from any repo on disk — even a freshly-cloned one with no manifests parsed yet — so `doc init`, `doc sync`, `doc impact`, and `doc graph` can decide what to generate, which target (Claude / Codex) is appropriate, and which domains warrant skills. Determinism is the contract: the same repo at the same commit must always produce the same scan. ## Key Concepts - **scanRepo() return shape:** `{ path, name, languages[], frameworks[], structure, domains[], entryPoints[], hasClaudeConfig, hasClaudeMd, hasCodexConfig, hasAgentsMd, repoType, size, health }` — order matters: `repoType` and `health` depend on prior fields @@ -29,18 +34,22 @@ You are working on **aspens' repo scanning system** — a fully deterministic an - **Framework detection:** JS/TS from `package.json` deps, Python from `requirements.txt`/`pyproject.toml`/`Pipfile`, Go from `go.mod` contents, Ruby from `Gemfile` - **Domain detection:** Scans dirs under source root + repo root, skips `SKIP_DIR_NAMES` set (structural/build/IDE/.NET/Java/Rust build dirs), requires at least one source file via `collectModules()` - **extraDomains:** User-specified domains merged via `mergeExtraDomains()` — marked with `userSpecified: true`, resolved against source root then repo root -- **Source root:** First match of `src`, `app`, `lib`, `server`, `pages` via `findSourceRoot()` +- **Source root:** First match of `src`, `app`, `lib`, `server`, `pages` via `findSourceRoot()`; for nested-project layouts (e.g. `~/apps/MyApp/MyApp/MyApp.csproj`), if repo root has exactly one non-skip child with a project manifest, that child is promoted as the source root and excluded from domain scanning at repo root to avoid double-counting - **Size estimation:** Lines estimated at ~40 bytes/line from `stat.size`, walk capped at depth 5, skips `bin`/`obj`/`target` build output alongside `node_modules`/`dist`/etc. - **Graph is opt-out:** `scanCommand` builds graph by default (`options.graph !== false`); errors are caught and only logged with `--verbose` +- **Health checks are language-aware:** `.gitignore` checks for missing `node_modules/`, `__pycache__/`, `target/`, virtualenv dirs, and uncommitted `.env` files are gated on detected languages ## Critical Rules -- **`SOURCE_EXTS`**: `.py`, `.ts`, `.js`, `.tsx`, `.jsx`, `.mjs`, `.cjs`, `.rb`, `.go`, `.rs`, `.java`, `.kt`, `.kts`, `.cs`, `.fs`, `.fsx`, `.swift`, `.php`, `.ex`, `.exs` — adding a language requires updating this set AND the `detectLanguages` indicators. Import graph / hub / cluster detection remains JS/TS/Python-only; other languages get domain discovery but a minimal atlas. +- **`SOURCE_EXTS`** (in `src/lib/source-exts.js`): `.py`, `.ts`, `.js`, `.tsx`, `.jsx`, `.mjs`, `.cjs`, `.rb`, `.go`, `.rs`, `.java`, `.kt`, `.kts`, `.cs`, `.fs`, `.fsx`, `.swift`, `.php`, `.ex`, `.exs` — adding a language requires updating this set AND the `detectLanguages` indicators. Import graph / hub / cluster detection remains JS/TS/Python-only; other languages get domain discovery but a minimal atlas. - **`SKIP_DIR_NAMES`**: Includes `src`, `app`, `bin`, `obj`, `dist`, `target`, `node_modules`, etc. — skipped in domain detection. `bin`/`obj`/`target` added to avoid .NET/Java/Rust build artifacts. - **`BOILERPLATE_STEMS`**: `__init__`, `index`, `mod` are excluded from module collection — don't add real module names here - **TypeScript implies JavaScript**: TS detection in `detectLanguages()` automatically adds JS to the languages array - **Graph failure is non-fatal**: `buildRepoGraph` errors in `scanCommand()` are caught and silently ignored unless `--verbose` - **Tests use real filesystem fixtures**, not mocks — create fixtures with `createFixture(name, files)` pattern, always clean up - **`detectEntryPoints` is exported** and reused by `graph-builder.js` — changing its signature breaks the graph builder +- **`es-module-lexer` must be initialized**: `parseJsImports()` awaits `init` before calling `parse()`. The lexer can fail on JSX-heavy files; the regex fallback in `parsers/typescript.js` is intentional graceful degradation, not dead code. +- **Python parser skips SCREAMING_SNAKE constants** by design — they produced false positives in code-map; do not re-add them. +- **Next.js entry points feed the import-graph priority ranker** — `app/`, `pages/`, and `middleware`/`instrumentation` files are roots Next.js runs implicitly with no static importer. --- -**Last Updated:** 2026-05-10 +**Last Updated:** 2026-05-11 diff --git a/.claude/skills/save-tokens/skill.md b/.claude/skills/save-tokens/skill.md index 2bd8e88..404dd95 100644 --- a/.claude/skills/save-tokens/skill.md +++ b/.claude/skills/save-tokens/skill.md @@ -1,62 +1,63 @@ --- name: save-tokens description: Token-saving session automation — statusline, prompt guard, precompact handoffs, session rotation, and handoff commands for Claude Code ---- - -## Activation - -This skill triggers when editing save-tokens files: -- `src/commands/save-tokens.js` -- `src/lib/save-tokens.js` -- `src/templates/hooks/save-tokens*.sh` -- `src/templates/hooks/save-tokens.mjs` -- `src/templates/commands/save-handoff.md` -- `src/templates/commands/resume-handoff*.md` -- `tests/save-tokens*.test.js` - -Keywords: save-tokens, handoff, statusline, prompt-guard, precompact, session rotation, token warning - +triggers: + files: + - src/commands/save-tokens.js + - src/lib/save-tokens.js + - src/templates/hooks/save-tokens*.sh + - src/templates/hooks/save-tokens.mjs + - src/templates/commands/save-handoff.md + - src/templates/commands/resume-handoff*.md + - tests/save-tokens*.test.js + keywords: + - save-tokens + - handoff + - statusline + - prompt-guard + - precompact + - session rotation + - token warning --- You are working on **save-tokens** — the feature that installs Claude Code hooks and commands to warn about token usage, auto-save handoffs before compaction, and support session rotation. -## Key Files -- `src/commands/save-tokens.js` — Main command: interactive or `--recommended` install, `--remove` uninstall, installs hooks + commands + settings -- `src/lib/save-tokens.js` — Config defaults (`DEFAULT_SAVE_TOKENS_CONFIG`), `buildSaveTokensConfig()`, `buildSaveTokensSettings()`, `buildSaveTokensGitignore()`, `buildSaveTokensReadme()` -- `src/templates/hooks/save-tokens.mjs` — Runtime hook: `runStatusline()`, `runPromptGuard()`, `runPrecompact()`, telemetry recording, handoff saving/pruning -- `src/templates/hooks/save-tokens-statusline.sh` — Shell wrapper for statusline hook -- `src/templates/hooks/save-tokens-prompt-guard.sh` — Shell wrapper for prompt guard hook -- `src/templates/hooks/save-tokens-precompact.sh` — Shell wrapper for precompact hook -- `src/templates/commands/save-handoff.md` — Slash command to save a rich handoff summary -- `src/templates/commands/resume-handoff-latest.md` — Slash command to resume from most recent handoff -- `src/templates/commands/resume-handoff.md` — Slash command to list and pick a handoff to resume - -## Key Concepts -- **Claude-only feature:** Save-tokens hooks and statusline only work with Claude Code. Config is stored in `.aspens.json` under `saveTokens`. -- **Three hook entry points:** Shell wrappers (`*.sh`) read stdin, resolve project dir, and call `save-tokens.mjs` with a subcommand (`statusline`, `prompt-guard`, `precompact`). -- **Statusline:** Records Claude context telemetry to `.aspens/sessions/claude-context.json` on every status update. Displays `save-tokens Xk/Yk` in the Claude status bar. -- **Prompt guard:** Checks token count against `warnAtTokens` (175k default) and `compactAtTokens` (200k default). Above compact threshold: saves a handoff and recommends starting a fresh session then running `/resume-handoff-latest`. Above warn threshold: suggests `/save-handoff`. -- **Precompact:** Auto-saves a handoff before Claude compaction when `saveHandoff` is enabled. -- **Handoff files:** Saved to `.aspens/sessions/-claude-handoff.md`. Structured with: metadata (tokens, working dir, branch), task summary, files modified, git commits, recent prompts, current state, next steps. Content extracted from JSONL transcript via `extractSessionFacts()`. Pruned to keep max 10. -- **`extractSessionFacts(input)`:** Parses the session's JSONL transcript to extract: `originalTask` (first user message), `recentPrompts` (last 3 user messages, 200 char max each), `filesModified` (from Edit/Write tool_use blocks), `gitCommits` (from Bash git commit commands), `branch` (from user record `gitBranch` field). Falls back to `input.prompt` as task summary when no transcript is available. Task summary capped at 500 chars. -- **Telemetry:** `recordClaudeContextTelemetry()` sums input/output/cache tokens from Claude's `context_window.current_usage`. Stale telemetry (>5 min) is ignored. -- **Config thresholds:** `warnAtTokens` and `compactAtTokens` can be `Number.MAX_SAFE_INTEGER` as disabled sentinel. -- **Settings merge:** `buildSaveTokensSettings()` produces `statusLine` + `hooks` config. Merged into existing `settings.json` via `mergeSettings()` which treats save-tokens hooks as aspens-managed. -- **`--recommended` install:** Called standalone or from `doc init --recommended`. Installs hooks, commands, sessions dir, settings — no prompts. -- **`--remove` uninstall:** Removes hook files (including legacy `.mjs` variants), commands, cleans settings.json entries, nulls `saveTokens` in `.aspens.json`. +## Domain purpose +`aspens save-tokens` installs three Claude Code hooks (statusline, prompt-guard, precompact) plus `/save-handoff`, `/resume-handoff-latest`, and `/resume-handoff` slash commands. The hooks observe Claude's own token telemetry and inject system messages telling Claude to rotate sessions before context blow-up. Handoff files persist enough state in `.aspens/sessions/` to resume seamlessly in a fresh session. -## Critical Rules -- **Shell wrappers resolve project dir from script location** — `SCRIPT_DIR` → `PROJECT_DIR` via `cd "$SCRIPT_DIR/../.." && pwd`. `ASPENS_PROJECT_DIR` env var overrides `CLAUDE_PROJECT_DIR`. -- **Config validation in `target.js`** — `isValidSaveTokensConfig()` validates shape, types, and threshold ordering. Invalid config causes `readConfig()` to return `null`. +## Business rules / invariants +- **Claude-only feature.** Hooks and statusline only work with Claude Code; Codex has no save-tokens integration. Config lives in `.aspens.json` under `saveTokens`. +- **Config thresholds default 175k warn / 200k compact.** `Number.MAX_SAFE_INTEGER` is the disabled sentinel for either threshold; `target.js#isValidSaveTokensConfig()` validates shape, types, and threshold ordering — invalid config causes `readConfig()` to return `null`. - **`writeConfig` preserves feature config** — `saveTokens` is preserved across `writeConfig` calls unless explicitly set to `null`. -- **Handoff pruning** — `pruneOldHandoffs()` keeps newest 10, deletes older. Only touches `*-handoff.md` files. - **Sessions dir gitignored** — `.aspens/sessions/.gitignore` excludes everything except `.gitignore` and `README.md`. -- **Settings backup** — First install creates `.claude/settings.json.bak` if settings exist and no backup exists yet. -- **`doc init --recommended`** — Calls `installSaveTokensRecommended()` from `save-tokens.js`, also installs agents and doc-sync git hook. -- **Transcript parsing is best-effort** — `extractSessionFacts()` catches all errors and returns empty facts on failure. Invalid JSON lines are silently skipped. +- **Settings backup** — first install creates `.claude/settings.json.bak` if settings exist and no backup exists yet. +- **StatusLine pre-existence guard** — `canInstallSaveTokensStatusLine()` refuses to overwrite an unrelated custom `statusLine.command`; when refused, `applyStatusLineAvailability()` forces both thresholds to `MAX_SAFE_INTEGER` and disables `sessionRotation`. + +## Non-obvious behaviors +- **Three hook entry points dispatched by subcommand.** Shell wrappers (`*.sh`) read stdin, resolve project dir, and call `save-tokens.mjs` with `statusline`, `prompt-guard`, or `precompact`. `ASPENS_PROJECT_DIR` env var overrides `CLAUDE_PROJECT_DIR`; both fall back to `cwd`. +- **Statusline doubles as telemetry recorder.** `recordClaudeContextTelemetry()` writes `.aspens/sessions/claude-context.json` summing `input_tokens + cache_creation_input_tokens + cache_read_input_tokens + output_tokens` from `context_window.current_usage`. Telemetry older than 5 minutes is ignored by the prompt guard. +- **Prompt-guard speaks to Claude, not the user.** Above `compactAtTokens`: saves a handoff (reason `rotation-threshold` if `sessionRotation`, else `compact-threshold`) and stdout-prints an "IMPORTANT — you must tell the user" block instructing fresh session + `/resume-handoff-latest`. Above `warnAtTokens`: suggests `/save-handoff`. When telemetry is missing, prints a one-time link to the aspens issues page. +- **Precompact hook only fires when `saveHandoff` is enabled** and `claude.enabled !== false`; reason is `precompact`. +- **`extractSessionFacts()` parses the JSONL transcript** to extract `originalTask` (first user message, 500 char cap), `recentPrompts` (last 3, 200 char cap each), `filesModified` (Edit/Write tool_use `file_path`), `gitCommits` (regex on Bash `git commit -m "..."`), and `branch` (from `gitBranch` on user records). Transcript path is validated to be inside `projectDir`; falls back to `input.prompt` when transcript is missing or outside the project. +- **Transcript parsing is best-effort** — all errors caught, invalid JSON lines silently skipped; returns empty facts on failure. +- **`pruneOldHandoffs()` keeps newest 10** `*-handoff.md` files by lexicographic (timestamp) sort; older are unlinked. `latestHandoff()` uses the same sort. +- **`--recommended` install is non-interactive** — used standalone or invoked by `doc init --recommended` (which also installs agents and the doc-sync git hook). +- **`--remove` cleans legacy artifacts too** — removes both `.sh` and historic `.mjs` variants of each hook, plus the legacy `save-tokens-resume.md` command; strips `statusLine` if it points at save-tokens and filters any hook entry whose command contains `save-tokens-`; nulls `saveTokens` in `.aspens.json`. + +## Critical files (purpose, not inventory) +- `src/commands/save-tokens.js` — install/remove orchestration; interactive multiselect (`warnings`, `handoffs`), `--recommended`, `--remove`. Exports `installSaveTokensRecommended()` consumed by `doc-init.js`. +- `src/lib/save-tokens.js` — config + settings + template content builders (`DEFAULT_SAVE_TOKENS_CONFIG`, `buildSaveTokensConfig`, `buildSaveTokensSettings`, `buildSaveTokensGitignore`, `buildSaveTokensReadme`, `buildSaveTokensRecommendations`). +- `src/templates/hooks/save-tokens.mjs` — runtime library: `runStatusline`, `runPromptGuard`, `runPrecompact`, `saveHandoff`, `extractSessionFacts`, telemetry record/read, handoff pruning, `main()` subcommand dispatch. +- `src/templates/hooks/save-tokens-{statusline,prompt-guard,precompact}.sh` — shell wrappers; resolve `PROJECT_DIR` via `cd "$SCRIPT_DIR/../.." && pwd` and exec the `.mjs` with the matching subcommand. +- `src/templates/commands/{save-handoff,resume-handoff-latest,resume-handoff}.md` — slash command bodies installed under `.claude/commands/`. + +## Critical Rules +- **Settings merge uses `mergeSettings()`** from `skill-writer.js` — save-tokens hooks are treated as aspens-managed; do not hand-edit `.claude/settings.json` to add/remove save-tokens entries. +- **Hooks write to stdout to inject into Claude's context** — comments in `runPromptGuard()` flag this; keep those messages addressed to Claude ("Tell the user...") not to a human reader. +- **Token sum includes cache tokens** — do not switch to just `input_tokens + output_tokens`; that under-counts Claude's effective context use. ## References - **Impact integration:** `src/lib/impact.js` — `evaluateSaveTokensHealth()` validates installed state --- -**Last Updated:** 2026-04-10 +**Last Updated:** 2026-05-11 diff --git a/.claude/skills/skill-generation/skill.md b/.claude/skills/skill-generation/skill.md index 4a09628..a4dc18e 100644 --- a/.claude/skills/skill-generation/skill.md +++ b/.claude/skills/skill-generation/skill.md @@ -1,64 +1,64 @@ --- name: skill-generation description: LLM-powered generation pipeline for Claude Code skills and CLAUDE.md — doc-init command, prompt system, context building, and output parsing ---- - -## Activation - -This skill triggers when editing skill-generation files: -- `src/commands/doc-init.js` -- `src/lib/runner.js` -- `src/lib/skill-writer.js` -- `src/lib/skill-reader.js` -- `src/lib/git-hook.js` -- `src/lib/timeout.js` -- `src/prompts/**/*` - -Keywords: doc-init, generate skills, discovery agents, chunked generation, recommended - +triggers: + files: + - src/commands/doc-init.js + - src/lib/context-builder.js + - src/prompts/**/* + keywords: + - doc-init + - generate skills + - discovery agents + - chunked generation + - recommended --- You are working on **aspens' skill generation pipeline** — the system that scans repos and uses Claude/Codex CLI to generate skills, hooks, and instructions files. -## Key Files -- `src/commands/doc-init.js` — Main pipeline: backend selection → target selection → scan → graph → discovery → strategy → mode → generate → validate → transform → write → hooks → recommended extras → config -- `src/lib/runner.js` — `runClaude()`, `runCodex()`, `runLLM()`, `loadPrompt()`, `parseFileOutput()`, `validateSkillFiles()` -- `src/lib/skill-writer.js` — Writes files, generates `skill-rules.json`, domain bash patterns, merges `settings.json` -- `src/lib/skill-reader.js` — Parses skill frontmatter, activation patterns, keywords (used by skill-writer) +## Domain purpose +`aspens doc init` orchestrates a multi-step LLM pipeline that turns a scanned repo + import graph into a base skill, per-domain skills, and an instructions file (`CLAUDE.md` or `AGENTS.md`). Generation is always done in Claude-canonical format and transformed for other targets afterwards. The end product is what other coding agents (and aspens' own hooks) consume to stay grounded in the repo. + +## Critical files (purpose, not inventory) +- `src/commands/doc-init.js` — the pipeline orchestrator (backend → target → scan → graph → discovery → strategy → mode → generate → validate → transform → write → hooks → recommended extras → config) +- `src/lib/runner.js` — `runLLM()`, `loadPrompt()`, `parseFileOutput()`, `validateSkillFiles()` shared across all LLM-driven commands +- `src/lib/skill-writer.js` — writes parsed files, generates `skill-rules.json`, injects domain bash patterns, merges `settings.json` +- `src/lib/skill-reader.js` — parses skill frontmatter, activation patterns, keywords (consumed by skill-writer) - `src/lib/git-hook.js` — `installGitHook()` / `removeGitHook()` for post-commit auto-sync (monorepo-aware) - `src/lib/timeout.js` — `resolveTimeout()` for auto-scaled + user-override timeouts -- `src/lib/target.js` — Target definitions, `resolveTarget()`, `getAllowedPaths()`, `writeConfig()`, `loadConfig()`, `mergeConfiguredTargets()` -- `src/lib/backend.js` — Backend detection/resolution (`detectAvailableBackends()`, `resolveBackend()`) -- `src/lib/target-transform.js` — `transformForTarget()`, `ensureRootKeyFilesSection()` converts Claude output to other target formats -- `src/prompts/` — `doc-init.md` (base), `doc-init-domain.md`, `doc-init-claudemd.md`, `discover-domains.md`, `discover-architecture.md` +- `src/lib/target.js` / `src/lib/backend.js` / `src/lib/target-transform.js` — target/backend resolution and Claude→other-target transform +- `src/prompts/` — `doc-init.md`, `doc-init-domain.md`, `doc-init-claudemd.md`, `discover-domains.md`, `discover-architecture.md`, plus `partials/` (skill-format, preservation-contract, examples) ## Key Concepts - **Pipeline steps:** (1) detect backends (2) **backend selection** (3) **target selection** (4) scan + graph (5) existing docs discovery check (6) parallel discovery agents (7) strategy (8) mode (9) generate (10) validate (11) transform for non-Claude targets (12) show files + dry-run (13) write (14) install hooks (Claude-only) (15) **recommended extras** (save-tokens, agents, git hook) (16) persist config to `.aspens.json` - **Early config persistence:** Target/backend config is written to `.aspens.json` **before** generation starts (after step 4), so a failed generation run still records the user's explicit target/backend choice. `saveTokens` from existing config is preserved. Final `writeConfig` at step 16 adds `saveTokens` from recommended install. - **`--recommended` flag:** Skips interactive prompts with smart defaults. Reuses existing target config from `.aspens.json`. Auto-selects backend from target. Defaults strategy to `improve` when existing docs found. Auto-picks discovery skip when docs exist. Auto-selects generation mode based on repo size. **Also installs save-tokens, bundled Claude agents, `dev/` gitignore entry, and doc-sync git hook** (step 15). -- **Recommended extras (step 15):** When `--recommended` and not `--dry-run`: calls `installSaveTokensRecommended()` from `save-tokens.js` (if Claude target), copies all bundled agent templates to `.claude/agents/` (skips existing), adds `dev/` to `.gitignore`, installs doc-sync git hook if not present. Summary lines printed after. +- **Recommended extras (step 15):** When `--recommended` and not `--dry-run`: calls `installSaveTokensRecommended()` from `save-tokens.js` (if Claude target), copies all bundled agent templates to `.claude/agents/` (skips existing) via `installRecommendedClaudeAgents()`, adds `dev/` to `.gitignore`, installs doc-sync git hook if not present. Summary lines printed after. - **Backend before target:** Backend selection (step 2) happens before target selection (step 3). If both CLIs available, user picks backend first, then targets. Pre-selects matching target in the multiselect. With `--recommended`, backend is inferred from existing target config. -- **Canonical generation:** All prompts receive `CANONICAL_VARS` (hardcoded Claude paths). Generation always produces Claude-canonical format regardless of target. Non-Claude targets are produced by post-generation transform. +- **Canonical generation:** All prompts receive `CANONICAL_VARS` (hardcoded Claude paths: `.claude/skills`, `skill.md`, `CLAUDE.md`, `.claude`). Generation always produces Claude-canonical format regardless of target. Non-Claude targets are produced by post-generation transform via `transformForTarget()`. - **Incremental writing (chunked mode):** When `mode === 'chunked'` and not dry-run, generated files are written to disk as each chunk completes instead of waiting until the end. User is prompted to confirm incremental writes before generation starts. Helper functions: `validateGeneratedChunk()` validates and strips truncated files per chunk; `buildOutputFilesForTargets()` handles multi-target transform; `writeIncrementalOutputs()` deduplicates and writes changed files. Tracks written content via `incrementalWriteState` (`contentsByPath` + `resultsByPath` Maps). When incremental mode is active, post-generation validation/transform/confirm/write steps are skipped (already done per-chunk). - **`parseLLMOutput` with strict single-file fallback:** Codex often returns plain markdown without `` tags. `parseLLMOutput(text, allowedPaths, expectedPath)` only wraps tagless text as the expected file for **true single-file prompts** (exactly one `exactFile` in allowedPaths, no `dirPrefixes`). Multi-file prompts require proper `` tags. -- **Existing docs reuse:** When existing Claude docs are found and strategy is `improve`, reuse is handled as improvement context without a separate loading spinner. Supports cross-target reuse. -- **Domain reuse helpers:** `loadReusableDomains()` tries `loadReusableDomainsFromRules()` first, falls back to `findSkillFiles()` with `extractKeyFilePatterns()`. +- **Existing docs reuse:** When existing Claude docs are found and strategy is `improve`, `loadExistingDocsContext()` inlines them as `## Existing Docs (improve these — preserve hand-written rules...)` into the prompt. `chooseReuseSourceTarget()` decides whether Claude or Codex docs are the source. Supports cross-target reuse (e.g. Claude docs → Codex output). +- **Domain reuse helpers:** `loadReusableDomains()` tries `loadReusableDomainsFromRules()` first (reads `skill-rules.json`), falls back to `findSkillFiles()` with `extractKeyFilePatterns()` parsing `## Key Files` blocks. - **Config persistence with target merging:** Uses `mergeConfiguredTargets()` to avoid dropping previously configured targets. `writeConfig` now also persists `saveTokens` config from the recommended install. -- **Hook installation:** Only for targets with `supportsHooks: true` (Claude). Generates `skill-rules.json`, copies hook scripts, merges `settings.json`. -- **Git hook offer:** With `--recommended`, git hook is auto-installed (no prompt). Without `--recommended`, interactive prompt offered. +- **Hook installation:** Only for targets with `supportsHooks: true` (Claude). `installHooks()` generates `skill-rules.json`, copies hook scripts, injects generated domain patterns into `post-tool-use-tracker.sh` via `# BEGIN/END detect_skill_domain` markers, merges `settings.json` (backs up existing to `.bak`). +- **Git hook offer:** With `--recommended`, git hook is auto-installed (no prompt). Without `--recommended`, interactive prompt offered. Detection looks for the marker string `aspens doc-sync hook ()` in `.git/hooks/post-commit`. +- **Discovery agents:** Two LLM calls run in parallel — `discover-domains` (hub files + domain clusters) and `discover-architecture` (hub files + ranked + hotspots). Findings are merged into `discoveryFindings` and parsed; domain-specific slices are injected into each domain prompt as `## Discovery Findings for {domain}`. ## Critical Rules -- **Base skill + instructions file are essential** — pipeline retries automatically with format correction. Domain skill failures are acceptable (user retries with `--domains`). -- **`improve` strategy preserves hand-written content** — LLM must read existing skills first and not discard human-authored rules. -- **Discovery runs before user prompt** — domain picker shows discovered domains, not scanner directory names. +- **Base skill + instructions file are essential** — pipeline retries up to 2× with format-correction prompts when `` tags are missing. Domain skill failures are acceptable (user retries with `--domains`). +- **`improve` strategy preserves hand-written content** — LLM must read existing skills first and not discard human-authored rules. The preservation-contract partial enforces this in every prompt. +- **Discovery runs before user prompt** — domain picker shows discovered domains, not scanner directory names. Falls back to scanner domains if discovery fails. - **PARALLEL_LIMIT = 3** — domain skills generate in batches of 3 concurrent calls. Base skill always sequential first. Instructions file always sequential last. - **CliError, not process.exit()** — all error exits throw `CliError`; cancellations `return` early. - **`--hooks-only` is Claude-only** — hardcoded to `TARGETS.claude` regardless of config. -- **Incremental write deduplication** — `writeIncrementalOutputs()` skips files whose content hasn't changed since last write, using `contentsByPath` Map for tracking. +- **Incremental write deduplication** — `writeIncrementalOutputs()` skips files whose content hasn't changed since last write, using `contentsByPath` Map for tracking. Directory-scoped `AGENTS.md` files (path ends with `/AGENTS.md` but not the root `AGENTS.md`) go through `writeTransformedFiles()`, all others through `writeSkillFiles()`. +- **Read-only LLM tools** — generation calls always pass `allowedTools: ['Read', 'Glob', 'Grep']`. The LLM explores the repo itself; aspens never lets it write. +- **`CLAUDE.md` post-processing** — generated instructions files are run through `syncSkillsSection()` and `syncBehaviorSection()` (and `ensureRootKeyFilesSection()` as a backwards-compat stripper of legacy `## Key Files` blocks) so aspens owns the Skills list and Behavior block deterministically; prompts explicitly forbid the LLM from emitting these sections. ## References - **Prompts:** `src/prompts/doc-init*.md`, `src/prompts/discover-*.md` -- **Partials:** `src/prompts/partials/skill-format.md`, `src/prompts/partials/examples.md` +- **Partials:** `src/prompts/partials/skill-format.md`, `src/prompts/partials/preservation-contract.md`, `src/prompts/partials/examples.md` --- -**Last Updated:** 2026-04-10 +**Last Updated:** 2026-05-11 diff --git a/.claude/skills/skill-rules.json b/.claude/skills/skill-rules.json index e71a98a..f43676d 100644 --- a/.claude/skills/skill-rules.json +++ b/.claude/skills/skill-rules.json @@ -13,16 +13,18 @@ ], "promptTriggers": { "keywords": [ + "customize", + "agents", + "subagent", + "agent customization", "agent", "customization", - "agent customization", "llm-powered", "injection", "project", "context", "installed", "commands", - "customize", "prompts", "customize-agents" ], @@ -59,14 +61,25 @@ "alwaysActivate": false, "filePatterns": [ "src/lib/runner.js", + "src/lib/timeout.js", "src/lib/skill-writer.js", "src/lib/skill-reader.js", - "src/lib/timeout.js", - "src/prompts/**/*.md", - "tests/*extract*" + "src/prompts/**/*.md" ], "promptTriggers": { "keywords": [ + "runClaude", + "runCodex", + "runLLM", + "stream-json", + "codex exec", + "parseFileOutput", + "sanitizePath", + "loadPrompt", + "writeSkillFiles", + "skill-rules", + "mergeSettings", + "resolveTimeout", "claude", "runner", "claude runner", @@ -75,20 +88,62 @@ "layer", "prompt", "loading", + "timeout", "skill-writer", "skill-reader", - "timeout", - "prompts", - "tests", - "extract" + "prompts" ], "intentPatterns": [ + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*codex exec", + "codex exec.*(create|update|fix|add|modify|change|debug|refactor|implement|build)", "(create|update|fix|add|modify|change|debug|refactor|implement|build).*claude runner", "claude runner.*(create|update|fix|add|modify|change|debug|refactor|implement|build)", "(create|update|fix|add|modify|change|debug|refactor|implement|build).*claude.*runner" ] } }, + "cli-shell": { + "type": "domain", + "enforcement": "suggest", + "priority": "high", + "scope": "all", + "alwaysActivate": false, + "filePatterns": [ + "bin/cli.js", + "src/index.js", + "src/lib/errors.js" + ], + "promptTriggers": { + "keywords": [ + "CliError", + "commander", + "bin/cli.js", + "welcome", + "checkMissingHooks", + "parsePositiveInt", + "parseTimeout", + "SIGINT", + "SIGTERM", + "aspens public api", + "cli", + "shell", + "cli shell", + "top-level", + "wiring", + "screen", + "bin", + "index", + "errors" + ], + "intentPatterns": [ + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*aspens public api", + "aspens public api.*(create|update|fix|add|modify|change|debug|refactor|implement|build)", + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*cli shell", + "cli shell.*(create|update|fix|add|modify|change|debug|refactor|implement|build)", + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*cli.*shell" + ] + } + }, "codex-support": { "type": "domain", "enforcement": "suggest", @@ -99,9 +154,8 @@ "src/lib/target.js", "src/lib/target-transform.js", "src/lib/backend.js", - "tests/target.test.js", - "tests/target-transform.test.js", - "tests/backend.test.js" + "AGENTS.md", + ".aspens.json" ], "promptTriggers": { "keywords": [ @@ -109,19 +163,19 @@ "target", "backend", "AGENTS.md", - "directory-scoped", + ".aspens.json", + "sanitize", "transform", "multi-target", + "parity", "support", "codex support", "output", "system", "abstraction", "target-transform", - "tests", - "target.test", - "target-transform.test", - "backend.test" + "AGENTS", + ".aspens" ], "intentPatterns": [ "(create|update|fix|add|modify|change|debug|refactor|implement|build).*codex support", @@ -185,35 +239,47 @@ "alwaysActivate": false, "filePatterns": [ "src/commands/doc-sync.js", + "src/lib/diff-classifier.js", + "src/lib/diff-helpers.js", + "src/lib/git-hook.js", + "src/lib/git-helpers.js", "src/prompts/doc-sync.md", "src/prompts/doc-sync-refresh.md", - "src/lib/git-helpers.js", - "src/lib/diff-helpers.js", - "src/lib/git-hook.js" + "src/prompts/partials/preservation-contract-refresh.md" ], "promptTriggers": { "keywords": [ + "doc sync", "doc-sync", "refresh", - "sync", - "git-hook", + "post-commit hook", + "install-hook", + "diff classifier", + "changetype filter", "doc", - "doc sync", + "sync", "incremental", "skill", "updater", "maps", "diffs", "commands", + "diff-classifier", + "diff-helpers", + "git-hook", + "git-helpers", "prompts", "doc-sync-refresh", - "git-helpers", - "diff-helpers" + "partials", + "preservation-contract-refresh" ], "intentPatterns": [ "(create|update|fix|add|modify|change|debug|refactor|implement|build).*doc sync", "doc sync.*(create|update|fix|add|modify|change|debug|refactor|implement|build)", - "(create|update|fix|add|modify|change|debug|refactor|implement|build).*doc.*sync" + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*post-commit hook", + "post-commit hook.*(create|update|fix|add|modify|change|debug|refactor|implement|build)", + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*diff classifier", + "diff classifier.*(create|update|fix|add|modify|change|debug|refactor|implement|build)" ] } }, @@ -226,42 +292,40 @@ "filePatterns": [ "src/lib/graph-builder.js", "src/lib/graph-persistence.js", + "src/lib/parsers/**", "src/commands/doc-graph.js", - "src/templates/hooks/graph-context-prompt.mjs", - "src/templates/hooks/graph-context-prompt.sh", - "tests/graph-builder.test.js", - "tests/graph-persistence.test.js" + "src/templates/hooks/graph-context-prompt.mjs" ], "promptTriggers": { "keywords": [ "graph", - "import graph", - "dependency", - "hub files", - "clustering", + "import-graph", + "hubs", + "clusters", + "hotspots", "code-map", - "graph-index", + "graph.json", "subgraph", + "priority", + "fanIn", "import", + "import graph", "static", "analysis", "builds", + "dependency", "graph-builder", "graph-persistence", + "parsers", "commands", "doc-graph", "templates", "hooks", - "graph-context-prompt", - "tests", - "graph-builder.test", - "graph-persistence.test" + "graph-context-prompt" ], "intentPatterns": [ "(create|update|fix|add|modify|change|debug|refactor|implement|build).*import graph", "import graph.*(create|update|fix|add|modify|change|debug|refactor|implement|build)", - "(create|update|fix|add|modify|change|debug|refactor|implement|build).*hub files", - "hub files.*(create|update|fix|add|modify|change|debug|refactor|implement|build)", "(create|update|fix|add|modify|change|debug|refactor|implement|build).*import.*graph" ] } @@ -274,6 +338,11 @@ "alwaysActivate": false, "filePatterns": [ "src/lib/scanner.js", + "src/lib/source-exts.js", + "src/lib/path-resolver.js", + "src/lib/parsers/typescript.js", + "src/lib/parsers/python.js", + "src/lib/frameworks/nextjs.js", "src/commands/scan.js", "tests/scanner.test.js" ], @@ -285,6 +354,8 @@ "detectDomains", "detectEntryPoints", "health check", + "SOURCE_EXTS", + "SKIP_DIR_NAMES", "repo", "scanning", "repo scanning", @@ -293,6 +364,13 @@ "language/framework", "detection", "scanner", + "source-exts", + "path-resolver", + "parsers", + "typescript", + "python", + "frameworks", + "nextjs", "commands", "scan", "tests", @@ -364,11 +442,7 @@ "alwaysActivate": false, "filePatterns": [ "src/commands/doc-init.js", - "src/lib/runner.js", - "src/lib/skill-writer.js", - "src/lib/skill-reader.js", - "src/lib/git-hook.js", - "src/lib/timeout.js", + "src/lib/context-builder.js", "src/prompts/**/*" ], "promptTriggers": { @@ -386,11 +460,7 @@ "claude", "code", "commands", - "runner", - "skill-writer", - "skill-reader", - "git-hook", - "timeout", + "context-builder", "prompts" ], "intentPatterns": [ @@ -411,6 +481,7 @@ "alwaysActivate": false, "filePatterns": [ "src/commands/add.js", + "src/prompts/add-skill.md", "src/templates/**/*" ], "promptTriggers": { @@ -428,6 +499,8 @@ "hooks", "settings", "add", + "prompts", + "add-skill", "templates" ], "intentPatterns": [ diff --git a/.claude/skills/template-library/skill.md b/.claude/skills/template-library/skill.md index 83c28c8..7e07c91 100644 --- a/.claude/skills/template-library/skill.md +++ b/.claude/skills/template-library/skill.md @@ -1,27 +1,23 @@ --- name: template-library description: Bundled agents, commands, hooks, and settings that users install via `aspens add`, `aspens doc init`, and `aspens save-tokens` into their .claude/ directories ---- - -## Activation - -This skill triggers when editing template-library files: -- `src/commands/add.js` -- `src/templates/**/*` - -Keywords: template, add agent, add command, add hook, add skill - +triggers: + files: + - src/commands/add.js + - src/prompts/add-skill.md + - src/templates/**/* + keywords: + - template + - add agent + - add command + - add hook + - add skill --- You are working on the **template library** — bundled agents, slash commands, hooks, and settings that users browse and install into their repos. -## Key Files -- `src/commands/add.js` — Core `aspens add [name]` command; copies templates to `.claude/` dirs, scaffolds/generates custom skills -- `src/templates/agents/*.md` — Agent persona templates (11 bundled) -- `src/templates/commands/*.md` — Slash command templates (5 bundled: save-handoff, resume-handoff, resume-handoff-latest, plus 2 original) -- `src/templates/hooks/` — Hook scripts: `skill-activation-prompt.sh/mjs`, `graph-context-prompt.sh/mjs`, `post-tool-use-tracker.sh`, `save-tokens.mjs`, `save-tokens-statusline.sh`, `save-tokens-prompt-guard.sh`, `save-tokens-precompact.sh` -- `src/templates/settings/settings.json` — Default settings with hook configuration (commands are double-quoted for shell safety) -- `src/prompts/add-skill.md` — System prompt for LLM-powered skill generation from reference docs +## Domain purpose +`aspens add [name]` copies curated templates into a consumer repo's `.claude/` directories so users get working agents, slash commands, hooks, and settings without authoring them. The same template tree is reused by `aspens doc init` (hook installation, recommended agents) and `aspens save-tokens` (handoff tooling). Custom skills can also be scaffolded blank or LLM-generated from a reference doc. ## Key Concepts - **Four resource types for `add`:** `agent` → `.claude/agents`, `command` → `.claude/commands`, `hook` → `.claude/hooks`. A fourth type `skill` is handled separately (not template-based). @@ -36,6 +32,15 @@ You are working on the **template library** — bundled agents, slash commands, - **Template discovery:** `listAvailable()` reads template dir, filters `.md`/`.sh` files, regex-parses `name:` and `description:`. - **No-overwrite policy:** `addResource()` skips files that already exist. Same for `addSkillCommand`. - **Plan/execute gitignore:** Adding `plan` or `execute` agents auto-adds `dev/` to `.gitignore` for plan storage. `doc init --recommended` also ensures `dev/` in `.gitignore`. +- **Base-skill warning for agents:** `addResource()` prints a non-fatal yellow warning when installing an agent if `.claude/skills/base/skill.md` is missing, prompting the user to run `aspens doc init`. The agent still installs. + +## Critical files (purpose, not inventory) +- `src/commands/add.js` — Entry point for `aspens add`; dispatches to resource copy, blank skill scaffold, or LLM skill generation. +- `src/templates/agents/*.md` — Agent persona templates copied as-is into `.claude/agents/`. +- `src/templates/commands/*.md` — Slash command templates (includes handoff commands installed by `save-tokens`). +- `src/templates/hooks/` — Hook scripts (`skill-activation-prompt.{sh,mjs}`, `graph-context-prompt.{sh,mjs}`, `post-tool-use-tracker.sh`, `save-tokens.mjs`, `save-tokens-{statusline,prompt-guard,precompact}.sh`). +- `src/templates/settings/settings.json` — Default Claude Code settings with hook wiring; merged into the consumer repo's settings. +- `src/prompts/add-skill.md` — System prompt for `add skill --from ` LLM generation. ## Critical Rules - Template files **must** contain `name: ` and `description: ` lines parseable by regex. @@ -43,10 +48,11 @@ You are working on the **template library** — bundled agents, slash commands, - The templates dir resolves from `src/commands/` via `join(__dirname, '..', 'templates')` — moving `add.js` breaks template resolution. - Skill names are sanitized to lowercase alphanumeric + hyphens. Invalid names throw `CliError`. - Commands throw `CliError` for expected failures instead of calling `process.exit()`. +- Reference docs passed to `add skill --from` are truncated to 50,000 chars before being handed to the LLM. ## References - **Customize flow:** `.claude/skills/agent-customization/skill.md` - **Save-tokens install:** `.claude/skills/save-tokens/skill.md` --- -**Last Updated:** 2026-04-09 +**Last Updated:** 2026-05-11 diff --git a/AGENTS.md b/AGENTS.md index fe6fde2..f0f8548 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,8 +2,18 @@ ## Skills -- `.agents/skills/doc-sync/SKILL.md` — Incremental skill updater that maps git diffs to affected skills and optionally auto-syncs via a post-commit hook +- `.agents/skills/base/SKILL.md` — Base repo skill; load whenever working in this repo. +- `.agents/skills/agent-customization/SKILL.md` — LLM-powered injection of project context into installed agent templates via `aspens customize agents` +- `.agents/skills/claude-runner/SKILL.md` — Claude/Codex CLI execution layer — prompt loading, stream-json parsing, file output extraction, path sanitization, skill file writing, and skill rule generation +- `.agents/skills/cli-shell/SKILL.md` — Top-level Commander wiring, welcome screen, missing-hook warning, CliError exit handling, and the public programmatic API surface - `.agents/skills/codex-support/SKILL.md` — Multi-target output system — target abstraction, backend routing, content transforms for Codex CLI and future targets +- `.agents/skills/doc-impact/SKILL.md` — Context health analysis — freshness, domain coverage, hub surfacing, drift detection, LLM-powered interpretation, and auto-repair for generated agent context +- `.agents/skills/doc-sync/SKILL.md` — Incremental skill updater that maps git diffs to affected skills and optionally auto-syncs via a post-commit hook +- `.agents/skills/import-graph/SKILL.md` — Static import analysis that builds dependency graphs, domain clusters, hub files, git churn hotspots, and file priority rankings +- `.agents/skills/repo-scanning/SKILL.md` — Deterministic repo analysis — language/framework detection, structure mapping, domain discovery, health checks, and import graph integration +- `.agents/skills/save-tokens/SKILL.md` — Token-saving session automation — statusline, prompt guard, precompact handoffs, session rotation, and handoff commands for Claude Code +- `.agents/skills/skill-generation/SKILL.md` — LLM-powered generation pipeline for Claude Code skills and CLAUDE.md — doc-init command, prompt system, context building, and output parsing +- `.agents/skills/template-library/SKILL.md` — Bundled agents, commands, hooks, and settings that users install via `aspens add`, `aspens doc init`, and `aspens save-tokens` into their .claude/ directories - `.agents/skills/architecture/SKILL.md` — Import graph and code-map reference for structural changes. ## Commands @@ -35,24 +45,6 @@ - **Verify before claiming** — Never state that something is configured, running, scheduled, or complete without confirming it first. If you haven't verified it in this session, say so rather than assuming. - **Make sure code is running** — If you suggest code changes, ensure the code is running and tested before claiming the task is done. - -## Key Files - -**Hub files (most depended-on):** -- `src/lib/runner.js` - 9 dependents -- `src/lib/target.js` - 9 dependents -- `src/lib/errors.js` - 8 dependents -- `src/lib/scanner.js` - 8 dependents -- `src/lib/skill-writer.js` - 7 dependents - -**Domain clusters:** - -| Domain | Files | Top entries | -|--------|-------|-------------| -| src | 45 | `src/commands/doc-init.js`, `src/lib/runner.js`, `src/lib/target.js` | - -**High-churn hotspots:** -- `src/commands/doc-init.js` - 35 changes -- `src/commands/doc-sync.js` - 21 changes -- `src/lib/runner.js` - 17 changes - +- **Ask clarifying questions** — If the task is ambiguous, ask for clarification rather than making assumptions. Don't imply or guess at requirements or constraints that aren't explicitly stated. +- **Simplicity first** — Write the minimum code that solves the problem. No speculative features, abstractions for single-use code, or error handling for impossible scenarios. +- **Surgical changes** — Touch only what the task requires. Don't refactor adjacent code, fix unrelated formatting, or "improve" things that aren't broken. diff --git a/CLAUDE.md b/CLAUDE.md index 70806c4..8a82e96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,10 +1,21 @@ # aspens -CLI for keeping coding-agent context accurate as your codebase changes. Supports Claude Code and Codex CLI. Stack: Node.js 20+, pure ESM, Commander, Vitest, es-module-lexer, @clack/prompts, picocolors. Entry point: `src/index.js` and CLI at `bin/cli.js`. - ## Skills -- `.claude/skills/base/skill.md` — Base repo skill; load whenever working in this repo. Use it for project structure, architecture notes, and repo-specific conventions. +- `.claude/skills/base/skill.md` — Base repo skill; load whenever working in this repo. +- `.claude/skills/repo-scanning/skill.md` — Deterministic repo analysis — language/framework detection, structure mapping, domain discovery, health checks, and import graph integration +- `.claude/skills/skill-generation/skill.md` — LLM-powered generation pipeline for Claude Code skills and CLAUDE.md — doc-init command, prompt system, context building, and output parsing +- `.claude/skills/doc-sync/skill.md` — Incremental skill updater that maps git diffs to affected skills and optionally auto-syncs via a post-commit hook +- `.claude/skills/doc-impact/skill.md` — Context health analysis — freshness, domain coverage, hub surfacing, drift detection, LLM-powered interpretation, and auto-repair for generated agent context +- `.claude/skills/import-graph/skill.md` — Static import analysis that builds dependency graphs, domain clusters, hub files, git churn hotspots, and file priority rankings +- `.claude/skills/claude-runner/skill.md` — Claude/Codex CLI execution layer — prompt loading, stream-json parsing, file output extraction, path sanitization, skill file writing, and skill rule generation +- `.claude/skills/codex-support/skill.md` — Multi-target output system — target abstraction, backend routing, content transforms for Codex CLI and future targets +- `.claude/skills/agent-customization/skill.md` — LLM-powered injection of project context into installed agent templates via `aspens customize agents` +- `.claude/skills/template-library/skill.md` — Bundled agents, commands, hooks, and settings that users install via `aspens add`, `aspens doc init`, and `aspens save-tokens` into their .claude/ directories +- `.claude/skills/save-tokens/skill.md` — Token-saving session automation — statusline, prompt guard, precompact handoffs, session rotation, and handoff commands for Claude Code +- `.claude/skills/cli-shell/skill.md` — Top-level Commander wiring, welcome screen, missing-hook warning, CliError exit handling, and the public programmatic API surface + +CLI for keeping coding-agent context accurate as your codebase changes. Supports Claude Code and Codex CLI. Stack: Node.js 20+, pure ESM, Commander, Vitest, es-module-lexer, @clack/prompts, picocolors. Entry point: `src/index.js` and CLI at `bin/cli.js`. ## Commands @@ -36,3 +47,6 @@ CLI for keeping coding-agent context accurate as your codebase changes. Supports - **Verify before claiming** — Never state that something is configured, running, scheduled, or complete without confirming it first. If you haven't verified it in this session, say so rather than assuming. - **Make sure code is running** — If you suggest code changes, ensure the code is running and tested before claiming the task is done. +- **Ask clarifying questions** — If the task is ambiguous, ask for clarification rather than making assumptions. Don't imply or guess at requirements or constraints that aren't explicitly stated. +- **Simplicity first** — Write the minimum code that solves the problem. No speculative features, abstractions for single-use code, or error handling for impossible scenarios. +- **Surgical changes** — Touch only what the task requires. Don't refactor adjacent code, fix unrelated formatting, or "improve" things that aren't broken. diff --git a/src/commands/doc-sync.js b/src/commands/doc-sync.js index 42bbd3b..0d89aef 100644 --- a/src/commands/doc-sync.js +++ b/src/commands/doc-sync.js @@ -253,8 +253,9 @@ export async function docSyncCommand(path, options) { } // If a stale-format code-map is on disk (legacy `## Hub files` block), // force a graph rebuild so subsequent reads see the modern format. - await regenerateStaleCodeMap(repoPath, sourceTarget, scanRepo(repoPath)); - const repairs = repairDeterministicSections(repoPath, sourceTarget, publishTargets, scanRepo(repoPath)); + const noOpScan = scanRepo(repoPath); + await regenerateStaleCodeMap(repoPath, sourceTarget, noOpScan); + const repairs = repairDeterministicSections(repoPath, sourceTarget, publishTargets, noOpScan); if (repairs.length > 0) { console.log(); for (const wr of repairs) console.log(` ${pc.yellow('~')} ${wr.path} ${pc.dim('(deterministic section repair)')}`); diff --git a/src/lib/graph-builder.js b/src/lib/graph-builder.js index 3016177..fd43649 100644 --- a/src/lib/graph-builder.js +++ b/src/lib/graph-builder.js @@ -197,8 +197,8 @@ export async function buildRepoGraph(repoPath, languages = []) { // --- File walking --- -const SOURCE_EXTS = new Set(['.py', '.ts', '.js', '.tsx', '.jsx', '.rb', '.go', '.rs']); -const JS_EXTS = new Set(['.js', '.ts', '.tsx', '.jsx', '.mjs']); +const SOURCE_EXTS = new Set(['.py', '.ts', '.js', '.tsx', '.jsx', '.mjs', '.cjs', '.rb', '.go', '.rs']); +const JS_EXTS = new Set(['.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs']); const SKIP_DIRS = new Set([ 'node_modules', '__pycache__', 'dist', 'build', '.git', diff --git a/src/lib/target-transform.js b/src/lib/target-transform.js index efd7203..482f98e 100644 --- a/src/lib/target-transform.js +++ b/src/lib/target-transform.js @@ -563,6 +563,11 @@ function remapContentPaths(content, sourceTarget, destTarget) { } function sanitizeCodexInstructions(content) { + // Line-level drops are reserved for content that has no analogue in AGENTS.md + // (Claude Code hooks, skill-rules.json, customize-agents). Generic `CLAUDE.md` + // mentions are rewritten by the substitution pass below, not dropped — line- + // level filtering on `CLAUDE.md` deletes self-documenting context that the + // substitution would have handled correctly. const filteredLines = content .split('\n') .filter(line => @@ -572,8 +577,7 @@ function sanitizeCodexInstructions(content) { !/\.claude\/hooks/i.test(line) && !/\.codex\/hooks/i.test(line) && !/skill-rules\.json/i.test(line) && - !/hook compatibility/i.test(line) && - !/CLAUDE\.md/i.test(line) + !/hook compatibility/i.test(line) ); return filteredLines diff --git a/tests/target-transform.test.js b/tests/target-transform.test.js index ac04d82..4214482 100644 --- a/tests/target-transform.test.js +++ b/tests/target-transform.test.js @@ -2,7 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; -import { transformForTarget, validateTransformedFiles, projectCodexDomainDocs, ensureRootKeyFilesSection, syncSkillsSection, syncBehaviorSection, assertTargetParity } from '../src/lib/target-transform.js'; +import { transformForTarget, validateTransformedFiles, projectCodexDomainDocs, ensureRootKeyFilesSection, syncSkillsSection, syncBehaviorSection, assertTargetParity, sanitizePublishedContent } from '../src/lib/target-transform.js'; import { TARGETS } from '../src/lib/target.js'; const mockScanResult = { @@ -389,3 +389,230 @@ describe('syncBehaviorSection', () => { expect(out).toContain('## Conventions'); }); }); + +describe('sanitizePublishedContent', () => { + const skillBody = [ + '# Skill', + '', + '## Activation', + '', + '- src/foo/**', + '', + '## Key Files', + '', + '- src/foo/bar.js', + '', + '## Real Content', + '', + 'kept.', + ].join('\n'); + + it('strips ## Activation blocks regardless of path', () => { + const out = sanitizePublishedContent('\n' + skillBody, '.claude/skills/foo/skill.md'); + expect(out).not.toContain('## Activation'); + expect(out).toContain('## Real Content'); + }); + + it('strips ## Key Files blocks regardless of path', () => { + const out = sanitizePublishedContent('\n' + skillBody, '.claude/skills/foo/skill.md'); + expect(out).not.toContain('## Key Files'); + expect(out).toContain('## Real Content'); + }); + + it('strips data-block leaks (Hub files, Domain clusters, Hotspots, Framework entries) from skill files', () => { + const polluted = [ + '# Skill', + '', + '**Hub files (most depended-on):**', + '- src/lib/foo.js (10 dependents)', + '', + '**Domain clusters:**', + '- src: src/lib/a.js, src/lib/b.js', + '', + '**High-churn hotspots:**', + '- src/lib/c.js (5 changes)', + '', + '**Framework entry points (nextjs-app):**', + '- src/app/page.tsx', + '', + '## Real Content', + '', + 'kept.', + ].join('\n'); + + const out = sanitizePublishedContent('\n' + polluted, '.claude/skills/foo/skill.md'); + expect(out).not.toContain('**Hub files'); + expect(out).not.toContain('**Domain clusters:**'); + expect(out).not.toContain('**High-churn hotspots:**'); + expect(out).not.toContain('**Framework entry points'); + expect(out).toContain('## Real Content'); + }); + + it('preserves data-block content when filePath ends with code-map.md', () => { + const codeMap = [ + '## Codebase Structure', + '', + '**Domain clusters:**', + '- **src**: `src/lib/foo.js`, `src/lib/bar.js`', + '', + '**Framework entry points (nextjs-app):**', + '- `src/app/page.tsx`', + ].join('\n'); + + const out = sanitizePublishedContent('\n' + codeMap, '.claude/code-map.md'); + expect(out).toContain('**Domain clusters:**'); + expect(out).toContain('**Framework entry points'); + expect(out).toContain('src/lib/foo.js'); + }); + + it('also preserves data blocks in codex references/code-map.md', () => { + const codeMap = '\n**Domain clusters:**\n- **app**: `app/main.py`\n'; + const out = sanitizePublishedContent(codeMap, '.agents/skills/architecture/references/code-map.md'); + expect(out).toContain('**Domain clusters:**'); + }); + + it('still strips Activation from code-map paths (Activation never legitimate anywhere)', () => { + const polluted = '\n## Activation\n\nshould-go\n\n## Other\n\nkept\n'; + const out = sanitizePublishedContent(polluted, '.claude/code-map.md'); + expect(out).not.toContain('## Activation'); + expect(out).toContain('## Other'); + }); + + it('returns empty content unchanged', () => { + expect(sanitizePublishedContent('', 'whatever.md')).toBe(''); + expect(sanitizePublishedContent(null, 'whatever.md')).toBe(null); + }); + + it('works without a filePath argument (defaults to non-codemap stripping)', () => { + const polluted = '\n**Domain clusters:**\n- thing\n'; + const out = sanitizePublishedContent(polluted); + expect(out).not.toContain('**Domain clusters:**'); + }); +}); + +// Regression: repairDeterministicSections feeds a Claude-shaped baseFiles array +// (just CLAUDE.md, no skills) into transformForTarget for each non-source target. +// The codex side adds a synthetic `architecture` skill when a graph is present, +// which the Claude side has no counterpart for. assertTargetParity has a +// carve-out — this test pins that behavior so the no-op repair path stays safe. +describe('Multi-target parity through the no-op repair flow', () => { + const __dirname2 = dirname(fileURLToPath(import.meta.url)); + const fixtureRoot = join(__dirname2, 'fixtures', 'multi-target-parity'); + + beforeAll(() => { + rmSync(fixtureRoot, { recursive: true, force: true }); + const skillsDir = join(fixtureRoot, '.claude', 'skills'); + mkdirSync(skillsDir, { recursive: true }); + + const skills = [ + ['base', '---\nname: base\ndescription: Core conventions\n---\n\nBase content.\n'], + ['billing', '---\nname: billing\ndescription: Stripe flows\n---\n\nBilling.\n'], + ['auth', '---\nname: auth\ndescription: JWT auth\n---\n\nAuth.\n'], + ]; + for (const [name, body] of skills) { + mkdirSync(join(skillsDir, name), { recursive: true }); + writeFileSync(join(skillsDir, name, 'skill.md'), body, 'utf8'); + } + + writeFileSync( + join(fixtureRoot, 'CLAUDE.md'), + '# Test\n\n## Skills\n\n- old stub\n\n## Behavior\n\n- placeholder\n', + 'utf8', + ); + }); + + afterAll(() => { + if (existsSync(fixtureRoot)) rmSync(fixtureRoot, { recursive: true, force: true }); + }); + + function buildFakeGraph() { + return { + version: '1.0', + meta: { generatedAt: new Date().toISOString(), gitHash: '', totalFiles: 0, totalEdges: 0 }, + files: {}, + hubs: [], + clusters: [], + coupling: [], + hotspots: [], + frameworkEntryPoints: [], + clusterIndex: {}, + }; + } + + it('produces a parity-clean perTarget map when graph is present (codex adds architecture skill)', () => { + const baseFiles = [{ + path: 'CLAUDE.md', + content: '# Test\n\n## Skills\n\n- list will be replaced by syncSkillsSection\n\n## Behavior\n\n- placeholder\n', + }]; + + const claudeFiles = baseFiles; // source == dest path + const codexFiles = transformForTarget(baseFiles, TARGETS.claude, TARGETS.codex, { + scanResult: { domains: [] }, + graphSerialized: buildFakeGraph(), + repoPath: fixtureRoot, + }); + + const perTarget = new Map([ + ['claude', claudeFiles], + ['codex', codexFiles], + ]); + + // assertTargetParity must not throw — the architecture carve-out keeps the + // codex-only synthetic skill from creating a parity violation. + expect(() => assertTargetParity(perTarget)).not.toThrow(); + + // Codex output must contain all on-disk skills in AGENTS.md, not just the + // (empty) pending skill set passed in. + const agents = codexFiles.find(f => f.path === 'AGENTS.md'); + expect(agents).toBeDefined(); + expect(agents.content).toContain('.agents/skills/base/SKILL.md'); + expect(agents.content).toContain('.agents/skills/billing/SKILL.md'); + expect(agents.content).toContain('.agents/skills/auth/SKILL.md'); + expect(agents.content).toContain('.agents/skills/architecture/SKILL.md'); + }); + + it('produces a parity-clean perTarget map when no graph is present (no architecture skill)', () => { + const baseFiles = [{ + path: 'CLAUDE.md', + content: '# Test\n\nBody.\n', + }]; + + const claudeFiles = baseFiles; + const codexFiles = transformForTarget(baseFiles, TARGETS.claude, TARGETS.codex, { + scanResult: { domains: [] }, + // no graphSerialized → no architecture skill + repoPath: fixtureRoot, + }); + + const perTarget = new Map([ + ['claude', claudeFiles], + ['codex', codexFiles], + ]); + + expect(() => assertTargetParity(perTarget)).not.toThrow(); + + const agents = codexFiles.find(f => f.path === 'AGENTS.md'); + expect(agents.content).not.toContain('.agents/skills/architecture/SKILL.md'); + expect(agents.content).toContain('.agents/skills/billing/SKILL.md'); + }); +}); + +// Regression: sanitizeCodexInstructions used to filter out any line containing +// `CLAUDE.md`, deleting context that the substitution pass would have rewritten. +describe('sanitizeCodexInstructions (via transformForTarget) — CLAUDE.md mention rewrite', () => { + it('rewrites CLAUDE.md mentions to AGENTS.md instead of deleting the line', () => { + const files = [ + { path: '.claude/skills/base/skill.md', content: '---\nname: base\n---\n\nBase.\n' }, + { path: 'CLAUDE.md', content: '# Project\n\nThis project ships with CLAUDE.md describing conventions.\n\n## Notes\n\nSee CLAUDE.md sections above.\n' }, + ]; + + const result = transformForTarget(files, TARGETS.claude, TARGETS.codex, { scanResult: { domains: [] } }); + const agents = result.find(f => f.path === 'AGENTS.md'); + expect(agents).toBeDefined(); + // The original sentence survives — `CLAUDE.md` is rewritten, not the line dropped. + expect(agents.content).toContain('AGENTS.md describing conventions'); + expect(agents.content).toContain('See AGENTS.md sections above'); + // And the original token isn't left behind. + expect(agents.content).not.toContain('CLAUDE.md'); + }); +}); From 007167f0b185108944418527053a955d84ca5c35 Mon Sep 17 00:00:00 2001 From: mvoutov Date: Mon, 11 May 2026 16:09:59 -0700 Subject: [PATCH 5/7] code rabbit fix --- .../skills/architecture/references/code-map.md | 2 +- CLAUDE.md | 18 ++++++++---------- src/lib/frameworks/nextjs.js | 5 ++--- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/.agents/skills/architecture/references/code-map.md b/.agents/skills/architecture/references/code-map.md index a132744..1399c7d 100644 --- a/.agents/skills/architecture/references/code-map.md +++ b/.agents/skills/architecture/references/code-map.md @@ -2,5 +2,5 @@ **Domain clusters:** - **src**: `src/lib/target.js`, `src/lib/errors.js`, `src/lib/runner.js`, `src/lib/scanner.js`, `src/lib/skill-reader.js` -- **tests**: `tests/agent-templates-project-context.test.js`, `tests/hook-runtime.test.js`, `tests/no-guidelines-refs.test.js`, `tests/save-tokens-hook-lib.test.js`, `tests/save-tokens-prompt-guard.test.js` +- **tests**: `tests/agent-templates-project-context.test.js`, `tests/hook-runtime.test.js`, `tests/no-guidelines-refs.test.js`, `tests/save-tokens-prompt-guard.test.js` diff --git a/CLAUDE.md b/CLAUDE.md index 8a82e96..0430948 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,19 +3,17 @@ ## Skills - `.claude/skills/base/skill.md` — Base repo skill; load whenever working in this repo. -- `.claude/skills/repo-scanning/skill.md` — Deterministic repo analysis — language/framework detection, structure mapping, domain discovery, health checks, and import graph integration -- `.claude/skills/skill-generation/skill.md` — LLM-powered generation pipeline for Claude Code skills and CLAUDE.md — doc-init command, prompt system, context building, and output parsing -- `.claude/skills/doc-sync/skill.md` — Incremental skill updater that maps git diffs to affected skills and optionally auto-syncs via a post-commit hook -- `.claude/skills/doc-impact/skill.md` — Context health analysis — freshness, domain coverage, hub surfacing, drift detection, LLM-powered interpretation, and auto-repair for generated agent context -- `.claude/skills/import-graph/skill.md` — Static import analysis that builds dependency graphs, domain clusters, hub files, git churn hotspots, and file priority rankings +- `.claude/skills/agent-customization/skill.md` — LLM-powered injection of project context into installed agent templates via `aspens customize agents` - `.claude/skills/claude-runner/skill.md` — Claude/Codex CLI execution layer — prompt loading, stream-json parsing, file output extraction, path sanitization, skill file writing, and skill rule generation +- `.claude/skills/cli-shell/skill.md` — Top-level Commander wiring, welcome screen, missing-hook warning, CliError exit handling, and the public programmatic API surface - `.claude/skills/codex-support/skill.md` — Multi-target output system — target abstraction, backend routing, content transforms for Codex CLI and future targets -- `.claude/skills/agent-customization/skill.md` — LLM-powered injection of project context into installed agent templates via `aspens customize agents` -- `.claude/skills/template-library/skill.md` — Bundled agents, commands, hooks, and settings that users install via `aspens add`, `aspens doc init`, and `aspens save-tokens` into their .claude/ directories +- `.claude/skills/doc-impact/skill.md` — Context health analysis — freshness, domain coverage, hub surfacing, drift detection, LLM-powered interpretation, and auto-repair for generated agent context +- `.claude/skills/doc-sync/skill.md` — Incremental skill updater that maps git diffs to affected skills and optionally auto-syncs via a post-commit hook +- `.claude/skills/import-graph/skill.md` — Static import analysis that builds dependency graphs, domain clusters, hub files, git churn hotspots, and file priority rankings +- `.claude/skills/repo-scanning/skill.md` — Deterministic repo analysis — language/framework detection, structure mapping, domain discovery, health checks, and import graph integration - `.claude/skills/save-tokens/skill.md` — Token-saving session automation — statusline, prompt guard, precompact handoffs, session rotation, and handoff commands for Claude Code -- `.claude/skills/cli-shell/skill.md` — Top-level Commander wiring, welcome screen, missing-hook warning, CliError exit handling, and the public programmatic API surface - -CLI for keeping coding-agent context accurate as your codebase changes. Supports Claude Code and Codex CLI. Stack: Node.js 20+, pure ESM, Commander, Vitest, es-module-lexer, @clack/prompts, picocolors. Entry point: `src/index.js` and CLI at `bin/cli.js`. +- `.claude/skills/skill-generation/skill.md` — LLM-powered generation pipeline for Claude Code skills and CLAUDE.md — doc-init command, prompt system, context building, and output parsing +- `.claude/skills/template-library/skill.md` — Bundled agents, commands, hooks, and settings that users install via `aspens add`, `aspens doc init`, and `aspens save-tokens` into their .claude/ directories ## Commands diff --git a/src/lib/frameworks/nextjs.js b/src/lib/frameworks/nextjs.js index 467a529..7067d7c 100644 --- a/src/lib/frameworks/nextjs.js +++ b/src/lib/frameworks/nextjs.js @@ -123,9 +123,8 @@ function walkPagesDir(repoPath, dir, out, depth = 0) { try { entries = readdirSync(dir); } catch { return; } for (const entry of entries) { - if (entry.startsWith('.') || entry.startsWith('_document') || entry.startsWith('_app')) { - // _app and _document are entry-equivalents — keep them too. - } + if (entry.startsWith('.')) continue; // skip hidden files/dirs (.DS_Store, .next/, etc.) + // `_app` and `_document` are Pages Router entry equivalents — fall through. const full = join(dir, entry); let stat; try { stat = statSync(full); } catch { continue; } From bb87a5d02a6aae57a05e7301f288b48adc5ab7aa Mon Sep 17 00:00:00 2001 From: mvoutov Date: Mon, 11 May 2026 16:38:53 -0700 Subject: [PATCH 6/7] fixes --- CLAUDE.md | 2 +- src/commands/customize.js | 16 +++-- src/commands/doc-sync.js | 2 +- src/lib/graph-builder.js | 4 +- src/lib/path-resolver.js | 20 ++++-- src/lib/skill-reader.js | 6 ++ src/lib/target-transform.js | 19 +++-- tests/doc-sync-repair.test.js | 118 ++++++++++++++++++++++++++++++++ tests/graph-persistence.test.js | 94 +++++++++++++++++++++++++ 9 files changed, 263 insertions(+), 18 deletions(-) create mode 100644 tests/doc-sync-repair.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 0430948..08bed78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ ## Release -- Release workflow: `/Users/MV/aspenkit/dev/release.md` +- Release workflow: `../dev/release.md` ## Conventions diff --git a/src/commands/customize.js b/src/commands/customize.js index 482b4a5..b2f45c0 100644 --- a/src/commands/customize.js +++ b/src/commands/customize.js @@ -200,9 +200,13 @@ function findAgents(agentsDir, repoPath) { const agents = []; function walk(dir) { - for (const entry of readdirSync(dir)) { + let entries; + try { entries = readdirSync(dir); } catch { return; } + for (const entry of entries) { const full = join(dir, entry); - if (statSync(full).isDirectory()) { + let stat; + try { stat = statSync(full); } catch { continue; } // broken symlink, race, etc. + if (stat.isDirectory()) { walk(full); } else if (entry.endsWith('.md')) { const content = readFileSync(full, 'utf8'); @@ -236,9 +240,13 @@ function gatherProjectContext(repoPath) { const skillsDir = join(repoPath, '.claude', 'skills'); if (existsSync(skillsDir)) { function walkSkills(dir) { - for (const entry of readdirSync(dir)) { + let entries; + try { entries = readdirSync(dir); } catch { return; } + for (const entry of entries) { const full = join(dir, entry); - if (statSync(full).isDirectory()) { + let stat; + try { stat = statSync(full); } catch { continue; } + if (stat.isDirectory()) { walkSkills(full); } else if (entry.endsWith('.md')) { const content = readFileSync(full, 'utf8'); diff --git a/src/commands/doc-sync.js b/src/commands/doc-sync.js index 0d89aef..409cd33 100644 --- a/src/commands/doc-sync.js +++ b/src/commands/doc-sync.js @@ -123,7 +123,7 @@ function notifyLegacyHubBlockIfPresent(repoPath) { * * Returns the list of written file results (empty when nothing needed updating). */ -function repairDeterministicSections(repoPath, sourceTarget, publishTargets, scan, graphSerialized = null) { +export function repairDeterministicSections(repoPath, sourceTarget, publishTargets, scan, graphSerialized = null) { const instructionsFile = sourceTarget?.instructionsFile || 'CLAUDE.md'; const instrPath = join(repoPath, instructionsFile); if (!existsSync(instrPath)) return []; diff --git a/src/lib/graph-builder.js b/src/lib/graph-builder.js index fd43649..666648d 100644 --- a/src/lib/graph-builder.js +++ b/src/lib/graph-builder.js @@ -311,7 +311,7 @@ function resolveRelativeImport(repoPath, fromFile, specifier) { } // Try extensions - const extensions = ['.js', '.ts', '.tsx', '.jsx', '.mjs']; + const extensions = ['.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs']; for (const ext of extensions) { const candidate = targetBase + ext; if (existsSync(candidate)) { @@ -320,7 +320,7 @@ function resolveRelativeImport(repoPath, fromFile, specifier) { } // Try /index variants (directory import) - const indexExts = ['.js', '.ts', '.tsx', '.jsx']; + const indexExts = ['.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs']; for (const ext of indexExts) { const candidate = join(targetBase, 'index' + ext); if (existsSync(candidate)) { diff --git a/src/lib/path-resolver.js b/src/lib/path-resolver.js index 3c4e8a8..db66d68 100644 --- a/src/lib/path-resolver.js +++ b/src/lib/path-resolver.js @@ -13,8 +13,8 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'fs'; import { join, dirname, extname, resolve, relative } from 'path'; -const ALIAS_RESOLUTION_EXTS = ['.ts', '.tsx', '.js', '.jsx', '.mjs']; -const ALIAS_INDEX_EXTS = ['.ts', '.tsx', '.js', '.jsx']; +const ALIAS_RESOLUTION_EXTS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']; +const ALIAS_INDEX_EXTS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']; /** * Load path aliases from tsconfig.json / jsconfig.json. Walks the repo root, @@ -154,7 +154,7 @@ export function resolveAliasImport(repoPath, specifier, aliases) { const targetBase = join(replacement, rest); if (extname(rest)) { - if (existsSync(targetBase)) { + if (existsSync(targetBase) && isInsideRepo(repoPath, targetBase)) { return relative(repoPath, targetBase); } continue; @@ -162,14 +162,14 @@ export function resolveAliasImport(repoPath, specifier, aliases) { for (const ext of ALIAS_RESOLUTION_EXTS) { const candidate = targetBase + ext; - if (existsSync(candidate)) { + if (existsSync(candidate) && isInsideRepo(repoPath, candidate)) { return relative(repoPath, candidate); } } for (const ext of ALIAS_INDEX_EXTS) { const candidate = join(targetBase, 'index' + ext); - if (existsSync(candidate)) { + if (existsSync(candidate) && isInsideRepo(repoPath, candidate)) { return relative(repoPath, candidate); } } @@ -178,6 +178,16 @@ export function resolveAliasImport(repoPath, specifier, aliases) { return null; } +/** + * Guard against malformed/malicious `tsconfig.json` `paths:` entries that + * resolve outside the repo (e.g. `"@/*": ["../../../outside/*"]`). Graph + * nodes for out-of-repo files corrupt cluster analysis and code-map output. + */ +function isInsideRepo(repoPath, candidate) { + const rel = relative(repoPath, candidate); + return !!rel && !rel.startsWith('..') && !rel.startsWith('/'); +} + function readFileSafe(filePath) { try { return readFileSync(filePath, 'utf8'); diff --git a/src/lib/skill-reader.js b/src/lib/skill-reader.js index 2118e4b..0cd57ef 100644 --- a/src/lib/skill-reader.js +++ b/src/lib/skill-reader.js @@ -79,6 +79,12 @@ export function parseFrontmatter(content) { * * Returns { filePatterns: string[], keywords: string[], alwaysActivate: boolean } * Returns null when no `triggers:` key is present in frontmatter. + * + * Sentinel note: a bare `triggers:` (or `triggers: {}` with no sub-keys) is + * treated as "triggers present but empty" — it returns the empty-fields object, + * not null. Callers using `=== null` to mean "no triggers key" will see that + * as "triggers configured", which is intentional: an author who typed the key + * is opting in to the empty contract over the legacy `## Activation` fallback. */ export function parseTriggersFrontmatter(content) { if (!content || typeof content !== 'string') return null; diff --git a/src/lib/target-transform.js b/src/lib/target-transform.js index 482f98e..4e09ea8 100644 --- a/src/lib/target-transform.js +++ b/src/lib/target-transform.js @@ -322,11 +322,20 @@ export function syncSkillsSection(content, baseSkill, domainSkills, destTarget, return working.replace(/## Skills\s*\n[\s\S]*?(?=\n## |\n\*\*Last Updated|$)/, section + '\n'); } - const headingMatch = working.match(/^# .+\n?/); - if (!headingMatch) return section + '\n\n' + working; - - const insertAt = headingMatch[0].length; - return working.slice(0, insertAt) + '\n' + section + '\n\n' + working.slice(insertAt).trimStart(); + // Fresh insert: place the Skills section just BEFORE the first existing + // `## ` heading so prose between the H1 and that heading isn't pushed + // "into" the Skills section. Otherwise the next sync's regex (which spans + // from `## Skills` until the next `## `) would eat that prose. + const nextSectionMatch = working.match(/\n## [^\n]+/); + if (nextSectionMatch) { + const idx = nextSectionMatch.index; // index of the '\n' before the heading + const head = working.slice(0, idx).replace(/\s+$/, ''); + const tail = working.slice(idx).replace(/^\s+/, ''); + return head + '\n\n' + section + '\n\n' + tail + (working.endsWith('\n') ? '' : ''); + } + + // No other `## ` heading: append at the end so we don't trap any prose. + return working.replace(/\s+$/, '') + '\n\n' + section + '\n'; } function buildSkillRefs(baseSkill, domainSkills, destTarget, hasArchitectureSkill = false) { diff --git a/tests/doc-sync-repair.test.js b/tests/doc-sync-repair.test.js new file mode 100644 index 0000000..e09f749 --- /dev/null +++ b/tests/doc-sync-repair.test.js @@ -0,0 +1,118 @@ +/** + * Regression coverage for repairDeterministicSections. + * + * The function runs on every "no diffs" or "no code-bearing changes" sync to + * heal `## Skills` / `## Behavior` drift in CLAUDE.md/AGENTS.md. Two paths + * matter: + * 1. Write-skip — when on-disk content already matches the deterministic + * output, the function must return `[]` and write nothing. + * 2. Repair — when sections are missing or stale, the function must rebuild + * and write across every configured target. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +import { repairDeterministicSections } from '../src/commands/doc-sync.js'; +import { TARGETS } from '../src/lib/target.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixtureRoot = join(__dirname, 'fixtures', 'doc-sync-repair'); + +function seedSkillsAndInstructions(opts = {}) { + rmSync(fixtureRoot, { recursive: true, force: true }); + mkdirSync(fixtureRoot, { recursive: true }); + const skillsDir = join(fixtureRoot, '.claude', 'skills'); + mkdirSync(skillsDir, { recursive: true }); + + const skills = [ + ['base', '---\nname: base\ndescription: Core conventions\n---\n\nBase.\n'], + ['billing', '---\nname: billing\ndescription: Stripe billing flows\n---\n\nBilling.\n'], + ['auth', '---\nname: auth\ndescription: JWT auth\n---\n\nAuth.\n'], + ]; + for (const [name, body] of skills) { + mkdirSync(join(skillsDir, name), { recursive: true }); + writeFileSync(join(skillsDir, name, 'skill.md'), body, 'utf8'); + } + + const instructionsPath = join(fixtureRoot, 'CLAUDE.md'); + writeFileSync(instructionsPath, opts.instructionsContent ?? '# Test\n\nOverview.\n', 'utf8'); + + return { instructionsPath }; +} + +afterAll(() => { + if (existsSync(fixtureRoot)) rmSync(fixtureRoot, { recursive: true, force: true }); +}); + +describe('repairDeterministicSections', () => { + it('returns [] and writes nothing on a second call once the file is already canonical', () => { + seedSkillsAndInstructions(); + + // First call: heals the stub CLAUDE.md, must write. + const first = repairDeterministicSections( + fixtureRoot, + TARGETS.claude, + [TARGETS.claude], + { domains: [] }, + ); + expect(first.length).toBeGreaterThan(0); + + // Second call: content already matches deterministic output → zero writes. + const canonical = readFileSync(join(fixtureRoot, 'CLAUDE.md'), 'utf8'); + const second = repairDeterministicSections( + fixtureRoot, + TARGETS.claude, + [TARGETS.claude], + { domains: [] }, + ); + expect(second).toEqual([]); + expect(readFileSync(join(fixtureRoot, 'CLAUDE.md'), 'utf8')).toBe(canonical); + }); + + it('returns [] when the instructions file does not exist', () => { + rmSync(fixtureRoot, { recursive: true, force: true }); + mkdirSync(fixtureRoot, { recursive: true }); + + const result = repairDeterministicSections( + fixtureRoot, + TARGETS.claude, + [TARGETS.claude], + { domains: [] }, + ); + + expect(result).toEqual([]); + }); + + it('rewrites CLAUDE.md when the Skills section is stale', () => { + seedSkillsAndInstructions({ + instructionsContent: [ + '# Test', + '', + '## Skills', + '', + '- old stale entry', + '', + '## Behavior', + '', + '- something', + ].join('\n'), + }); + + const result = repairDeterministicSections( + fixtureRoot, + TARGETS.claude, + [TARGETS.claude], + { domains: [] }, + ); + + expect(result.length).toBeGreaterThan(0); + const claudeMd = readFileSync(join(fixtureRoot, 'CLAUDE.md'), 'utf8'); + expect(claudeMd).toContain('.claude/skills/base/skill.md'); + expect(claudeMd).toContain('.claude/skills/billing/skill.md'); + expect(claudeMd).toContain('.claude/skills/auth/skill.md'); + expect(claudeMd).not.toContain('old stale entry'); + }); +}); diff --git a/tests/graph-persistence.test.js b/tests/graph-persistence.test.js index 72aff9c..4af6b97 100644 --- a/tests/graph-persistence.test.js +++ b/tests/graph-persistence.test.js @@ -14,6 +14,7 @@ import { generateGraphIndex, saveGraphIndex, persistGraphArtifacts, + formatDomainClusters, } from '../src/lib/graph-persistence.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -493,6 +494,99 @@ describe('saveGraphIndex', () => { }); }); +// --------------------------------------------------------------------------- +// formatDomainClusters (direct unit coverage) +// --------------------------------------------------------------------------- +describe('formatDomainClusters', () => { + const sampleFiles = { + 'src/a.js': { fanIn: 3 }, + 'src/b.js': { fanIn: 5 }, + 'src/c.js': { fanIn: 1 }, + 'src/d.js': { fanIn: 5 }, + 'src/e.js': { fanIn: 0 }, + 'src/f.js': { fanIn: 0 }, + 'tests/x.test.js': { fanIn: 0 }, + 'tests/y.test.js': { fanIn: 0 }, + }; + + it('returns null for null/empty input', () => { + expect(formatDomainClusters(null, sampleFiles)).toBeNull(); + expect(formatDomainClusters([], sampleFiles)).toBeNull(); + expect(formatDomainClusters([{ label: 'src', files: ['src/a.js'] }], null)).toBeNull(); + }); + + it('drops single-file clusters', () => { + const out = formatDomainClusters( + [{ label: 'singleton', files: ['src/a.js'] }], + sampleFiles, + ); + expect(out).toBeNull(); + }); + + it('skips clusters where every file is missing from files map', () => { + const out = formatDomainClusters( + [ + { label: 'ghost', files: ['ghost/a.js', 'ghost/b.js'] }, + { label: 'src', files: ['src/a.js', 'src/b.js'] }, + ], + sampleFiles, + ); + expect(out).not.toContain('**ghost**'); + expect(out).toContain('**src**'); + }); + + it('sorts files within a cluster by fanIn desc then path asc', () => { + const out = formatDomainClusters( + [{ label: 'src', files: ['src/a.js', 'src/b.js', 'src/c.js', 'src/d.js'] }], + sampleFiles, + ); + // b.js (fanIn 5) and d.js (fanIn 5) tie → alphabetical: b before d + const bIdx = out.indexOf('src/b.js'); + const dIdx = out.indexOf('src/d.js'); + const aIdx = out.indexOf('src/a.js'); + const cIdx = out.indexOf('src/c.js'); + expect(bIdx).toBeLessThan(dIdx); + expect(dIdx).toBeLessThan(aIdx); + expect(aIdx).toBeLessThan(cIdx); + }); + + it('caps top files per cluster at MAX_CLUSTER_FILES (5)', () => { + const out = formatDomainClusters( + [{ label: 'src', files: ['src/a.js', 'src/b.js', 'src/c.js', 'src/d.js', 'src/e.js', 'src/f.js'] }], + sampleFiles, + ); + expect(out).toContain('src/a.js'); + expect(out).toContain('src/b.js'); + expect(out).toContain('src/c.js'); + expect(out).toContain('src/d.js'); + expect(out).toContain('src/e.js'); + expect(out).not.toContain('src/f.js'); + }); + + it('merges clusters with the same label (graph builder emits one component per disconnected island)', () => { + const out = formatDomainClusters( + [ + { label: 'tests', files: ['tests/x.test.js'] }, + { label: 'tests', files: ['tests/y.test.js'] }, + ], + sampleFiles, + ); + expect(out).toContain('**tests**'); + expect(out).toContain('tests/x.test.js'); + expect(out).toContain('tests/y.test.js'); + // single header for the merged cluster + expect(out.match(/\*\*tests\*\*/g)).toHaveLength(1); + }); + + it('omits per-cluster (N files) counts', () => { + const out = formatDomainClusters( + [{ label: 'src', files: ['src/a.js', 'src/b.js'] }], + sampleFiles, + ); + expect(out).not.toMatch(/\(\d+\s+files?\)/); + }); +}); + // --------------------------------------------------------------------------- // persistGraphArtifacts // --------------------------------------------------------------------------- From 209136d98933f42ccf2705964ae5d60cee2bdb3f Mon Sep 17 00:00:00 2001 From: mvoutov Date: Mon, 11 May 2026 16:41:41 -0700 Subject: [PATCH 7/7] chore: release v0.8.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump version 0.7.3 → 0.8.0 (minor — many new features) - Update postinstall notice for the triggers/code-map/AGENTS.md changes - Sync package-lock.json - Draft CHANGELOG entry covering: triggers frontmatter migration, language-agnostic code-map clusters, Next.js entry-point detection, Python/TypeScript parsers, path-resolver, diff classifier, sanitizePublishedContent chokepoint, AGENTS.md skill-list truncation fix, syncSkillsSection idempotence fix, .cjs/.mjs parity, path-traversal guard in alias resolver, Pages Router dot-file walker fix, statSync guards in customize, ensureRootKeyFilesSection reduction to legacy stripper. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ package-lock.json | 4 ++-- package.json | 4 ++-- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd7aa90..fbba5f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## [0.8.0] - 2026-05-11 + +### Added +- **`triggers:` YAML frontmatter for skills** — skill activation rules (file globs, keywords, `alwaysActivate`) now live in frontmatter instead of a free-form `## Activation` section. The activation hook reads frontmatter first; legacy `## Activation` sections still parse for backwards compatibility. New skills generated by `doc init` / `doc sync` emit the frontmatter form. +- **Language-agnostic Domain clusters in `.claude/code-map.md`** — clusters (cluster name + top files per cluster, sorted by fanIn then path) now render for any language, not just Next.js repos. Single-file clusters are dropped; merged clusters collapse duplicate labels emitted by the import graph's connected-component builder. +- **Next.js entry-point detection** — `src/lib/frameworks/nextjs.js` recognizes App Router files (`page`, `layout`, `route`, `loading`, `error`, `not-found`, `template`, `default`, `global-error`), Pages Router files, and special top-level files (`middleware`, `instrumentation`) as implicit graph roots. Surfaced in `code-map.md` under **Framework entry points**. +- **Python and TypeScript import parsers** — dedicated modules under `src/lib/parsers/` for richer import extraction (re-exports, barrels, relative imports), plus `src/lib/path-resolver.js` for `tsconfig.json` / `jsconfig.json` `paths:` alias resolution (with `extends` chain support). +- **`src/lib/diff-classifier.js`** — `isNoOpDiff()` classifier that lets `doc sync` skip the LLM call entirely on lockfile-only or non-code-bearing diffs. +- **`sanitizePublishedContent()` chokepoint sanitizer** — every disk write goes through it; strips forbidden blocks (`## Activation`, `## Key Files`, count-bearing data tables) from skill/instruction files while preserving them in `code-map.md` where they belong. +- **Skills section completeness contract** — root instructions files (CLAUDE.md, AGENTS.md) now list every on-disk skill on every sync via the new `collectSkillsForList()` helper; pending changes overlay on-disk content so unchanged skills survive partial syncs. +- **Multi-target parity validator** — `assertTargetParity()` checks that every configured target publishes the same set of logical files (root instructions + per-domain skills), with a carve-out for the Codex-only synthetic `architecture` skill. + +### Changed +- **`code-map.md` is now churn-stable** — removed file counts, edge counts, hub-file rankings, hotspots, totals/date footer, and "+N more" suffixes. Only structural data (clusters + framework entries) survives. +- **`ensureRootKeyFilesSection()` reduced to a legacy stripper** — it no longer inserts `## Key Files` content; it only removes legacy blocks left in older docs. Hub rankings live in `code-map.md` / `graph.json` exclusively. +- **`syncSkillsSection` is now idempotent** — fresh inserts place the Skills section before any existing `## ` heading instead of immediately after the H1, so prose between the title and the next section is no longer pushed "into" the Skills block and eaten on the next sync. +- **`.cjs` / `.mjs` extension parity** — the import graph, source-extension set, and alias resolver now treat `.cjs` and `.mjs` as first-class so CommonJS-heavy and ESM-explicit repos get full graph coverage. + +### Fixed +- **AGENTS.md skill-list truncation on partial `doc sync`** — when only one skill changed, the codex transform was rebuilding the AGENTS.md Skills section from the in-flight subset and silently dropping every unchanged skill. The transform now reads all on-disk skills and overlays pending changes for descriptions. +- **`sanitizeCodexInstructions` deleting `CLAUDE.md`-mentioning lines** — the line-level filter dropped any line containing `CLAUDE.md` before the substitution pass could rewrite it. Removed the filter; substitution downstream handles `CLAUDE.md → AGENTS.md` correctly. +- **Path-traversal in `resolveAliasImport()`** — a crafted `tsconfig.json` with `paths: { "@/*": ["../../../outside/*"] }` could produce graph nodes outside the repo (`../etc/passwd`). Added `isInsideRepo()` bound-check before recording any resolved path. +- **Next.js Pages Router walker recursing into hidden directories** — the dot-file guard had an empty `if` block, so `.next/`, `.vercel/`, and `.DS_Store` were getting walked. Now correctly `continue`s on dot entries while keeping `_app` / `_document` as valid entries. +- **`scanRepo()` called multiple times in the no-op sync path** — hoisted into a single `noOpScan` variable so the filesystem scan runs once per sync invocation. +- **Unguarded `statSync` in `customize.js`** — broken symlinks in `.claude/agents/` or `.claude/skills/` no longer abort the entire `aspens customize agents` command; the offending entry is skipped. +- **Stale skill descriptions for `ensureRootKeyFilesSection`** — `.claude/skills/codex-support/skill.md`, `skill-generation/skill.md`, and `doc-sync/skill.md` updated to reflect the function's current legacy-stripper role. + +### Internal +- Test suite grew from ~360 to 439 tests covering: triggers frontmatter parsing, agent-skill refs, customize skills injection, diff classifier, graph Next.js roots, graph Python parsing, graph TS re-exports, impact hub coverage, no-guidelines refs, path-alias resolution, target parity, multi-target parity through the no-op repair flow, `sanitizePublishedContent` path-awareness, `formatDomainClusters` unit coverage, and `repairDeterministicSections` idempotence. +- Deleted `src/prompts/partials/guideline-format.md` (concept retired). All `{{guideline-format}}` references removed from prompts and templates. + ## [0.7.3] - 2026-04-25 ### Fixed diff --git a/package-lock.json b/package-lock.json index 30a4b6c..f13fe65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aspens", - "version": "0.7.3", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aspens", - "version": "0.7.3", + "version": "0.8.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index e20a11a..c1f493f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aspens", - "version": "0.7.3", + "version": "0.8.0", "description": "Keep coding-agent context accurate as your codebase changes", "type": "module", "bin": { @@ -23,7 +23,7 @@ "test": "vitest run", "start": "node bin/cli.js", "lint": "echo 'No linter configured yet' && exit 0", - "postinstall": "echo '\n 📌 aspens v0.7.2: Nested-project layouts (e.g. `.NET ~/apps/MyApp/MyApp/MyApp.csproj`) now yield first-class domains instead of a single wrapper domain. Pretty-printed `scan` also shows domains for C#/Java/Swift/PHP/Elixir projects.\n Re-run `aspens doc init` if a prior scan came up empty or under-detailed.\n\n 🌲 aspens is in active development — please keep it up to date.\n Run into issues? Let us know: https://github.com/aspenkit/aspens/issues\n'" + "postinstall": "echo '\n 📌 aspens v0.8.0: Skill triggers moved from `## Activation` to YAML frontmatter `triggers:` (back-compat preserved). Domain clusters restored to `code-map.md` (language-agnostic). AGENTS.md no longer drops unchanged skills on partial `doc sync`. Code-map.md is now churn-stable across syncs.\n Re-run `aspens doc sync` to regenerate skills with the new triggers format.\n\n 🌲 aspens is in active development — please keep it up to date.\n Run into issues? Let us know: https://github.com/aspenkit/aspens/issues\n'" }, "engines": { "node": ">=20"