diff --git a/.agents/skills/architecture/references/code-map.md b/.agents/skills/architecture/references/code-map.md index 91cf850..47d21cc 100644 --- a/.agents/skills/architecture/references/code-map.md +++ b/.agents/skills/architecture/references/code-map.md @@ -3,20 +3,20 @@ ## Key Files **Hub files (most depended-on):** -- `src/lib/runner.js` - 8 dependents +- `src/lib/runner.js` - 9 dependents +- `src/lib/target.js` - 9 dependents +- `src/lib/scanner.js` - 8 dependents - `src/lib/errors.js` - 7 dependents -- `src/lib/scanner.js` - 7 dependents -- `src/lib/target.js` - 7 dependents -- `src/lib/skill-writer.js` - 6 dependents +- `src/lib/skill-writer.js` - 7 dependents **Domain clusters:** | Domain | Files | Top entries | |--------|-------|-------------| -| src | 37 | `src/lib/runner.js`, `src/commands/doc-init.js`, `src/commands/doc-sync.js` | +| src | 44 | `src/commands/doc-init.js`, `src/lib/runner.js`, `src/lib/target.js` | **High-churn hotspots:** -- `src/commands/doc-init.js` - 27 changes -- `src/commands/doc-sync.js` - 19 changes -- `src/lib/runner.js` - 16 changes +- `src/commands/doc-init.js` - 33 changes +- `src/commands/doc-sync.js` - 20 changes +- `src/lib/runner.js` - 17 changes diff --git a/.agents/skills/base/SKILL.md b/.agents/skills/base/SKILL.md index 04ef8c3..b788d5a 100644 --- a/.agents/skills/base/SKILL.md +++ b/.agents/skills/base/SKILL.md @@ -9,7 +9,7 @@ This is a **base skill** that always loads when working in this repository. --- -You are working in **aspens** — a CLI tool that generates and maintains AI-ready documentation (skill files + AGENTS.md) for any codebase. Supports multiple output targets (Claude Code, Codex CLI). +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 @@ -18,11 +18,13 @@ Node.js (ESM) | Commander | Vitest | es-module-lexer | @clack/prompts | picocolo - `npm test` — Run vitest suite - `npm start` / `node bin/cli.js` — Run CLI - `aspens scan [path]` — Deterministic repo analysis (no LLM) -- `aspens doc init [path]` — Generate skills + hooks + AGENTS.md (supports `--target claude|codex|all`, `--backend claude|codex`) +- `aspens doc init [path]` — Generate skills + hooks + AGENTS.md (`--target claude|codex|all`, `--recommended` for full recommended setup) +- `aspens doc impact [path]` — Show freshness, coverage, and drift of generated context (`--apply` for auto-repair, `--backend`/`--model`/`--timeout`/`--verbose` for LLM interpretation) - `aspens doc sync [path]` — Incremental skill updates from git diffs - `aspens doc graph [path]` — Rebuild import graph cache (`.claude/graph.json`) - `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) ## Architecture CLI entry (`bin/cli.js`) → command handlers (`src/commands/`) → lib modules (`src/lib/`) @@ -35,15 +37,17 @@ CLI entry (`bin/cli.js`) → command handlers (`src/commands/`) → lib modules - `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/diff-helpers.js` — Targeted file diffs and prioritized diff truncation for doc-sync -- `src/lib/git-helpers.js` — Git repo detection, diff retrieval, log formatting -- `src/lib/git-hook.js` — Post-commit git hook installation/removal for auto 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) +- `src/lib/impact.js` — Context health analysis: domain coverage, hub surfacing, drift detection, hook health, save-tokens health, usefulness summary, value comparison, opportunities +- `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`) +- `src/lib/target.js` — Target definitions (claude/codex), config persistence (`.aspens.json`) with `saveTokens` feature config - `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/prompts/` — Prompt templates with `{{partial}}` and `{{variable}}` substitution -- `src/templates/` — Bundled agents, commands, hooks, and settings for `aspens add` / `doc init` +- `src/templates/` — Bundled agents, commands, hooks, and settings for `aspens add` / `doc init` / `save-tokens` ## Critical Conventions - **Pure ESM** — `"type": "module"` throughout; use `import`/`export`, never `require()` @@ -55,14 +59,15 @@ CLI entry (`bin/cli.js`) → command handlers (`src/commands/`) → lib modules - **Target/Backend distinction** — Target = output format/location; Backend = which LLM CLI generates content. Config persisted in `.aspens.json` - **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 ## Structure - `bin/` — CLI entry point (commander setup, CliError handler) -- `src/commands/` — Command handlers (scan, doc-init, doc-sync, doc-graph, add, customize) +- `src/commands/` — Command handlers (scan, doc-init, doc-impact, doc-sync, doc-graph, add, customize, save-tokens) - `src/lib/` — Core library modules - `src/prompts/` — Prompt templates + partials - `src/templates/` — Installable agents, commands, hooks, settings - `tests/` — Vitest test files --- -**Last Updated:** 2026-04-02 +**Last Updated:** 2026-04-09 diff --git a/.agents/skills/claude-runner/SKILL.md b/.agents/skills/claude-runner/SKILL.md index 65234b6..cd0c4d3 100644 --- a/.agents/skills/claude-runner/SKILL.md +++ b/.agents/skills/claude-runner/SKILL.md @@ -18,7 +18,7 @@ This skill triggers when editing claude-runner files: 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()` (internal) +- `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 @@ -26,7 +26,8 @@ You are working on the **CLI execution layer** — the bridge between assembled ## 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 --ask-for-approval never --ephemeral`. 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. +- **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. +- **Codex capability detection:** `getCodexExecCapabilities()` (internal, cached) runs `codex exec --help` and checks if `--ask-for-approval` appears in the help text. Result is cached in module-level `codexExecCapabilities` variable. If the help check fails (e.g., codex not installed), capabilities default to `{ supportsAskForApproval: false }`. `runCodex()` only adds `--ask-for-approval never` when `supportsAskForApproval` is true. - **Unified routing:** `runLLM(prompt, options, backendId)` is the shared entry point — dispatches to `runClaude()` or `runCodex()` based on `backendId`. Exported from `runner.js` so command handlers no longer need local routing helpers. - **Codex internals (private):** `normalizeCodexItemType()` converts PascalCase/kebab-case to snake_case. `collectCodexText()` recursively extracts text from nested event content. Both are internal to runner.js. - **Prompt templating:** `loadPrompt(name, vars)` resolves `{{partial-name}}` from `src/prompts/partials/` first, then substitutes `{{varName}}` from `vars`. Target-specific vars (`skillsDir`, `skillFilename`, `instructionsFile`, `configDir`) are passed by command handlers. @@ -35,19 +36,19 @@ 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. -- **Settings merge:** `mergeSettings()` merges aspens hook config into existing `settings.json`, detecting aspens-managed hooks by `ASPENS_HOOK_MARKERS` (`skill-activation-prompt`, `post-tool-use-tracker`). +- **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. ## Critical Rules - **Both `--verbose` and `--output-format stream-json` are required for Claude** — omitting either breaks stream parsing. -- **Codex uses `--json --sandbox read-only --ask-for-approval never --ephemeral`** — `--sandbox read-only` restricts filesystem access, `--ask-for-approval never` skips prompts, `--ephemeral` avoids persisting conversation. Prompt goes via stdin, not as a CLI arg. +- **Codex uses `--json --sandbox read-only --ephemeral`** — `--sandbox read-only` restricts filesystem access, `--ephemeral` avoids persisting conversation. `--ask-for-approval never` is added only if `getCodexExecCapabilities()` confirms support. Prompt goes via stdin, not as a CLI arg. - **Codex stdin write order matters** — event handlers (`stdout`, `stderr`, `close`, `error`) must be attached before writing to stdin, so fast failures are captured. - **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. -- **`mergeSettings` preserves non-aspens hooks** — identifies aspens hooks by `ASPENS_HOOK_MARKERS`, replaces matching entries, preserves everything else. +- **`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-07 +**Last Updated:** 2026-04-10 diff --git a/.agents/skills/codex-support/SKILL.md b/.agents/skills/codex-support/SKILL.md index 8ea6c23..0c19f40 100644 --- a/.agents/skills/codex-support/SKILL.md +++ b/.agents/skills/codex-support/SKILL.md @@ -20,8 +20,8 @@ Keywords: codex, target, backend, AGENTS.md, directory-scoped, transform, multi- 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()`, path helpers, config persistence (`.aspens.json`) -- `src/lib/target-transform.js` — Transforms Claude-format output to other target formats; `projectCodexDomainDocs()`, `validateTransformedFiles()`, content sanitization +- `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 @@ -30,23 +30,27 @@ You are working on **multi-target output support** — the system that lets aspe - **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. - **Content sanitization:** `sanitizeCodexInstructions()` and `sanitizeCodexSkill()` strip Claude-specific references (hooks, skill-rules.json, Claude Code mentions) from Codex output. -- **`getAllowedPaths(targets)`** — Returns `{ dirPrefixes, exactFiles }` union across all active targets. Dir prefixes use **full** target paths (e.g., `.agents/skills/`, not `.agents/`), providing tighter path validation. +- **`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 }`. `readConfig()` returns `null` if missing **or if the config is structurally invalid** — callers default to `'claude'` target. Validation via internal `isValidConfig()` ensures `targets` is a non-empty array of known target keys, `backend` (if present) is a known target key, and `version` (if present) is a string. -- **Multi-target publish:** `doc-sync` uses `publishFilesForTargets()` to generate output for all configured targets from a single LLM run — source target files kept as-is, other targets get transforms applied. -- **Codex inference tightened:** `inferConfig()` only adds `'codex'` to inferred targets when `.codex/` config dir or `.agents/skills/` dir exists — a standalone `AGENTS.md` without either is not sufficient. -- **Conditional architecture ref:** Codex `buildCodexSkillRefs()` only includes the architecture skill reference when a graph was actually serialized (`hasGraph` parameter). +- **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()`). +- **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. +- **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. ## 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 (`.claude/`, `.agents/`, `AGENTS.md`, root `AGENTS.md`). `writeTransformedFiles()` handles directory-scoped `AGENTS.md` (e.g., `src/billing/AGENTS.md`) with an explicit path allowlist and warn-and-skip policy. -- **Path safety:** `validateTransformedFiles()` in `target-transform.js` rejects absolute paths, traversal, and unexpected filenames. `writeTransformedFiles()` enforces the same checks plus an allowlist (`AGENTS.md`/`AGENTS.md` exact, `.claude/`/`.agents/`/`.codex/` prefixes). +- **Split write logic:** `writeSkillFiles()` handles direct-write files. `writeTransformedFiles()` handles directory-scoped `AGENTS.md` with an explicit path allowlist and warn-and-skip policy. +- **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`) as invalid and returns `null`, same as missing config. +- **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. ## References - **Patterns:** See `src/lib/target.js` for all target property definitions --- -**Last Updated:** 2026-04-07 +**Last Updated:** 2026-04-09 diff --git a/.agents/skills/doc-impact/SKILL.md b/.agents/skills/doc-impact/SKILL.md new file mode 100644 index 0000000..47a5fca --- /dev/null +++ b/.agents/skills/doc-impact/SKILL.md @@ -0,0 +1,60 @@ +--- +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 + +--- + +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()` + +## 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. + +## 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`. + +## References +- **Patterns:** `src/lib/skill-reader.js` — `findSkillFiles()` used for skill discovery per target +- **Prompt:** `src/prompts/impact-analyze.md` + +--- +**Last Updated:** 2026-04-09 diff --git a/.agents/skills/doc-sync/SKILL.md b/.agents/skills/doc-sync/SKILL.md index a7f2f75..0b22073 100644 --- a/.agents/skills/doc-sync/SKILL.md +++ b/.agents/skills/doc-sync/SKILL.md @@ -23,18 +23,19 @@ You are working on **doc-sync**, the incremental skill update command (`aspens d - `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` — `isGitRepo()`, `getGitDiff()`, `getGitLog()`, `getChangedFiles()` — git primitives +- `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 +- `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` is passed through to control conditional architecture references. - **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` and `git log`, feeds them plus existing skill contents and graph context to the selected backend. +- **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. - **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. - **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. @@ -46,7 +47,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:** `installGitHook()` creates a `post-commit` hook with 5-minute cooldown lock file. Hook skips aspens-only commits (filters `.claude/`, `.codex/`, `.agents/`, `AGENTS.md`, `AGENTS.md`, `.aspens.json`). Works for all configured targets. +- **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. - **Force writes:** doc-sync always calls `writeSkillFiles` with `force: true`. ## Critical Rules @@ -57,9 +58,10 @@ You are working on **doc-sync**, the incremental skill update command (`aspens d - The command exits early with `CliError` if the source target's skills directory doesn't exist. - `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`. ## References - **Patterns:** `src/lib/skill-reader.js` — `GENERIC_PATH_SEGMENTS`, `fileMatchesActivation()`, `getActivationBlock()` --- -**Last Updated:** 2026-04-07 +**Last Updated:** 2026-04-08 diff --git a/.agents/skills/save-tokens/SKILL.md b/.agents/skills/save-tokens/SKILL.md new file mode 100644 index 0000000..2bd8e88 --- /dev/null +++ b/.agents/skills/save-tokens/SKILL.md @@ -0,0 +1,62 @@ +--- +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 + +--- + +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`. + +## 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`. +- **`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. + +## References +- **Impact integration:** `src/lib/impact.js` — `evaluateSaveTokensHealth()` validates installed state + +--- +**Last Updated:** 2026-04-10 diff --git a/.agents/skills/skill-generation/SKILL.md b/.agents/skills/skill-generation/SKILL.md index 6db7f3d..a4e5595 100644 --- a/.agents/skills/skill-generation/SKILL.md +++ b/.agents/skills/skill-generation/SKILL.md @@ -14,52 +14,51 @@ This skill triggers when editing skill-generation files: - `src/lib/timeout.js` - `src/prompts/**/*` -Keywords: doc-init, generate skills, discovery agents, chunked generation +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 → config +- `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) -- `src/lib/git-hook.js` — `installGitHook()` / `removeGitHook()` for post-commit auto-sync +- `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()` +- `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()` converts Claude output to other target formats +- `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` ## 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) persist config to `.aspens.json` -- **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. -- **Canonical generation:** All prompts receive `CANONICAL_VARS` (hardcoded Claude paths: `.claude/skills/`, `skill.md`, `AGENTS.md`). Generation always produces Claude-canonical format regardless of target. Non-Claude targets are produced by post-generation transform. +- **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. +- **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. +- **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 (e.g., existing Claude docs → generate Codex output). -- **Domain reuse helpers:** `loadReusableDomains()` tries `loadReusableDomainsFromRules()` (reads `skill-rules.json` from source target, falls back to `.claude/skills/` for non-Claude targets) first. Falls back to `findSkillFiles()` with `extractKeyFilePatterns()` to derive file patterns from `## Key Files` sections when activation patterns are missing. -- **Target selection:** `--target claude|codex|all` or interactive multiselect if both CLIs available. Stored in `.aspens.json`. -- **Backend routing:** `runLLM()` imported from `runner.js` dispatches to `runClaude()` or `runCodex()` based on `_backendId`. `--backend` flag overrides auto-detection. -- **Content transform (step 11):** Canonical files preserved as originals. Non-Claude targets get `transformForTarget()` applied. If Claude not in target list, canonical files are filtered out of final output. -- **Split writes:** Direct-write files (`.claude/`, `.agents/`, `AGENTS.md`, root `AGENTS.md`) use `writeSkillFiles()`. Directory-scoped files (e.g., `src/billing/AGENTS.md`) use `writeTransformedFiles()` with warn-and-skip policy. -- **Dynamic labels:** `baseArtifactLabel()` and `instructionsArtifactLabel()` return target-appropriate names ("base skill" vs "root AGENTS.md") for spinner messages. -- **Parallel discovery:** Two agents run via `Promise.all` — domain discovery and architecture analysis — before any user prompt. -- **Generation modes:** `all-at-once` = single call; `chunked` = base + per-domain (up to 3 parallel) + instructions file; `base-only` = just base skill; `pick` = interactive domain picker -- **Retry logic:** Base skill and instructions file retry up to 2 times if `parseLLMOutput` returns empty (format correction prompt asking for `` 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()`. +- **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. ## 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. Discovery can be skipped if existing docs are found and user opts to reuse. +- **Discovery runs before user prompt** — domain picker shows discovered domains, not scanner directory names. - **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. ## References - **Prompts:** `src/prompts/doc-init*.md`, `src/prompts/discover-*.md` - **Partials:** `src/prompts/partials/skill-format.md`, `src/prompts/partials/examples.md` --- -**Last Updated:** 2026-04-07 +**Last Updated:** 2026-04-10 diff --git a/.agents/skills/template-library/SKILL.md b/.agents/skills/template-library/SKILL.md index 9dbab7d..251eea1 100644 --- a/.agents/skills/template-library/SKILL.md +++ b/.agents/skills/template-library/SKILL.md @@ -1,6 +1,6 @@ --- name: template-library -description: Bundled agents, commands, hooks, and settings that users install via `aspens add` and `aspens doc init` into their .claude/ directories +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 @@ -18,33 +18,35 @@ You are working on the **template library** — bundled agents, slash commands, ## 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 (2 bundled) -- `src/templates/hooks/` — Hook scripts (5 bundled): `skill-activation-prompt.sh/mjs`, `graph-context-prompt.sh/mjs`, `post-tool-use-tracker.sh` -- `src/templates/settings/settings.json` — Default settings with hook configuration +- `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 ## 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). -- **Codex-only restriction:** `add agent`, `add command`, and `add hook` throw `CliError` for Codex-only repos (checked via `readConfig()`). Skills work with both targets — `add skill` is always available. -- **Target-aware skill commands:** `addSkillCommand` and `generateSkillFromDoc` resolve the active target via `resolveSkillTarget(config)`. Skill paths use `target.skillsDir` and `target.skillFilename` (not hardcoded `.claude/.agents/skills/skills/SKILL.md`). -- **Backend-aware generation:** `generateSkillFromDoc` uses `runLLM()` imported from `runner.js` to dispatch to Claude or Codex based on config. `getAllowedPaths([target])` provides path safety for `parseFileOutput`. -- **Skill subcommand:** `aspens add skill ` scaffolds a blank skill template. `--from ` generates a skill from a reference doc using the configured backend. `--list` shows installed skills. -- **Hook templates:** `skill-activation-prompt` reads `skill-rules.json` and injects relevant skills into prompts. `graph-context-prompt` loads graph data for code navigation. `post-tool-use-tracker` detects skill domains from file access patterns. -- **`doc init` hook installation (step 13):** Generates `skill-rules.json` from skills, copies hook files, generates `post-tool-use-tracker.sh` with domain patterns (via `BEGIN/END` markers), merges `settings.json` with backup. +- **Save-tokens templates:** `save-tokens.mjs` is the runtime entry point for all three hook entry points (statusline, prompt-guard, precompact). Shell wrappers (`save-tokens-*.sh`) resolve project dir and delegate to the `.mjs` file. Slash commands (`save-handoff.md`, `resume-handoff.md`, `resume-handoff-latest.md`) provide user-invokable handoff management. These are installed by `aspens save-tokens` or `aspens doc init --recommended`, not by `aspens add`. +- **Codex-only restriction:** `add agent`, `add command`, and `add hook` throw `CliError` for Codex-only repos. Skills work with both targets. +- **Target-aware skill commands:** `addSkillCommand` and `generateSkillFromDoc` resolve the active target via `resolveSkillTarget(config)`. Skill paths use `target.skillsDir` and `target.skillFilename`. +- **Backend-aware generation:** `generateSkillFromDoc` uses `runLLM()` to dispatch to Claude or Codex based on config. +- **Hook templates (monorepo-aware):** Shell hooks compute `PROJECT_DIR` from the script's own location (`cd "$SCRIPT_DIR/../.." && pwd`) and pass it as `ASPENS_PROJECT_DIR` to `.mjs` counterparts. Save-tokens shell hooks follow the same pattern. +- **Settings template quoting:** Hook commands in `settings.json` are wrapped in double quotes for shell safety. +- **`doc init` hook installation (step 13):** Generates `skill-rules.json`, copies hook files, generates `post-tool-use-tracker.sh` with domain patterns, merges `settings.json` with backup. +- **`doc init --recommended` extras (step 15):** Copies all bundled agent templates to `.claude/agents/` (skips existing), adds `dev/` to `.gitignore`. - **Template discovery:** `listAvailable()` reads template dir, filters `.md`/`.sh` files, regex-parses `name:` and `description:`. -- **No-overwrite policy:** `addResource()` skips files that already exist via `existsSync` check. Same for `addSkillCommand`. -- **Plan/execute gitignore:** Adding `plan` or `execute` agents auto-adds `dev/` to `.gitignore` for plan storage. -- **Conditional post-add tips:** Skill rules update and `--hooks-only` tip only shown for Claude target. Codex target gets no hook-related messaging. +- **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`. ## Critical Rules - Template files **must** contain `name: ` and `description: ` lines parseable by regex. -- Only `.md` and `.sh` extensions are discovered by `listAvailable()`. `.mjs` files are copied by `doc init` directly, not by `add`. +- Only `.md` and `.sh` extensions are discovered by `listAvailable()`. `.mjs` files are copied by `doc init` and `save-tokens` directly, not by `add`. - 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()`. ## References - **Customize flow:** `.agents/skills/agent-customization/SKILL.md` +- **Save-tokens install:** `.agents/skills/save-tokens/SKILL.md` --- -**Last Updated:** 2026-04-07 +**Last Updated:** 2026-04-09 diff --git a/.aspens.json b/.aspens.json index 31a9638..21bdaae 100644 --- a/.aspens.json +++ b/.aspens.json @@ -4,5 +4,16 @@ "codex" ], "backend": "claude", - "version": "1.0" + "version": "1.0", + "saveTokens": { + "enabled": true, + "warnAtTokens": 175000, + "compactAtTokens": 200000, + "saveHandoff": true, + "sessionRotation": true, + "claude": { + "enabled": true, + "mode": "automatic" + } + } } diff --git a/.aspens/sessions/.gitignore b/.aspens/sessions/.gitignore new file mode 100644 index 0000000..7c9d611 --- /dev/null +++ b/.aspens/sessions/.gitignore @@ -0,0 +1,3 @@ +* +!.gitignore +!README.md diff --git a/.aspens/sessions/README.md b/.aspens/sessions/README.md new file mode 100644 index 0000000..30bac53 --- /dev/null +++ b/.aspens/sessions/README.md @@ -0,0 +1,9 @@ +# save-tokens handoffs + +Aspens stores saved session handoffs here before Claude compaction or token-limit rotation. + +Handoff files are human-readable markdown. They are saved so you can inspect what was preserved before compaction or a fresh-session handoff. + +Claude automation is installed by `aspens save-tokens`. Codex does not have an aspens save-tokens runtime integration yet. + +This directory is gitignored by default. diff --git a/.claude/agents/auto-error-resolver.md b/.claude/agents/auto-error-resolver.md new file mode 100644 index 0000000..a7e3aaf --- /dev/null +++ b/.claude/agents/auto-error-resolver.md @@ -0,0 +1,60 @@ +--- +name: auto-error-resolver +description: Fix compilation errors, build failures, type errors, or test failures. Systematically identifies root causes and fixes them in order. +model: sonnet +color: red +--- + +You systematically identify, analyze, and fix errors — compilation errors, build failures, type errors, and test failures. + +**Tech stack:** Node.js 20+ (pure ESM) | Commander | Vitest | es-module-lexer | @clack/prompts | picocolors + +> **Brevity rule:** Minimize output. Show what you did, not what you thought about. Actions over explanations. + +**Context (read on-demand):** +- Read `CLAUDE.md` and `.claude/skills/base/skill.md` for project conventions and architecture +- Read `.claude/skills/` domain skills for area-specific patterns + +**Key Conventions:** +- Pure ESM — use `import`/`export`, never `require()`. `"type": "module"` throughout. +- Prefer `CliError` from `src/lib/errors.js` over `process.exit()` — top-level handler in `bin/cli.js` catches these. +- `es-module-lexer` WASM must be initialized (`await init`) before calling `parse()` in graph-builder. +- Path sanitization via `sanitizePath()` is non-negotiable — never bypass `parseFileOutput()` allowed-path checks. +- Config persisted in `.aspens.json` — target (output format) and backend (generating CLI) are distinct concepts. + +**How to Resolve Errors:** + +1. **Find the errors** — If not provided directly, run check commands: + - `npm test` — run Vitest suite (`vitest run`) + - `npm run lint` — currently a no-op (`echo 'No linter configured yet' && exit 0`) + - `node bin/cli.js` — verify CLI boots without errors + - If the user pasted error output, start from that instead + +2. **Analyze systematically** — Don't fix errors one by one blindly: + - Group errors by type (missing imports, type mismatches, undefined variables, etc.) + - Identify root causes — one broken import can cascade into 20 errors + - Prioritize: fix root causes first, cascading errors often resolve themselves + +3. **Fix in order:** + - Missing dependencies/packages first (`npm install`) + - Import errors and broken references next + - Type errors and interface mismatches + - Logic errors and remaining issues + - Fix the source, not the symptom — prefer proper types over `@ts-ignore` or `# type: ignore` + +4. **Verify each round of fixes:** + - Re-run the same check command that surfaced the errors + - If errors remain, continue fixing + - If NEW errors appear from your fixes, stop and reassess your approach + - Report completion only when the check passes clean + +**Critical Rules:** +- Fix root causes, not symptoms — no `@ts-ignore`, `any` casts, or `# type: ignore` unless truly justified +- Keep fixes minimal and focused — don't refactor unrelated code while fixing errors +- If a fix requires a design decision (not just a mechanical correction), flag it and ask before proceeding +- Don't change test expectations to make tests pass — fix the code that broke them + +**Output (keep under 20 lines total):** +- Errors found → fixes applied (one line per root cause) +- Verification result (pass/fail) +- Decisions needing human input (if any) diff --git a/.claude/agents/code-architecture-reviewer.md b/.claude/agents/code-architecture-reviewer.md new file mode 100644 index 0000000..8aeae9b --- /dev/null +++ b/.claude/agents/code-architecture-reviewer.md @@ -0,0 +1,72 @@ +--- +name: code-architecture-reviewer +description: Review code for quality, architectural consistency, and integration issues. Use after implementing features, refactoring, or before merging PRs. +model: sonnet +color: blue +--- + +You are a senior code reviewer. You examine code for quality, architectural consistency, and system integration issues. + +**Tech stack:** Node.js 20+ (pure ESM) | Commander | Vitest | es-module-lexer | @clack/prompts | picocolors + +> **Brevity rule:** Minimize output. Show what you found, not what you checked. No preamble, no filler. + +**Key Conventions:** +- Pure ESM — `import`/`export` only, never `require()`. `"type": "module"` throughout. +- Command handlers throw `CliError` from `src/lib/errors.js`; never call `process.exit()` directly. +- `parseFileOutput()` path sanitization is non-negotiable — all LLM-written files must go through it. +- Target (output format) vs Backend (generating CLI) are distinct concepts; don't conflate them. +- `es-module-lexer` WASM must be initialized (`await init`) before any `parse()` call. + +**Architecture layers:** CLI entry (`bin/cli.js`) → command handlers (`src/commands/`) → lib modules (`src/lib/`) → prompts (`src/prompts/`). Code should not skip layers (e.g., commands should not import from `bin/`, prompts should not contain logic). + +**Context (read on-demand, not all upfront):** +- 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:** + +1. **Understand scope** — If specific files are given, start there. If not, check recent git changes: + ``` + git diff --stat HEAD~1 + git log --oneline -5 + ``` +2. **Read the code** — Read each file being reviewed in full. Understand what it does before judging it. +3. **Check context** — Read sibling files and imports to understand how the code fits into the system. Does it follow the same patterns its neighbors use? +4. **Check for duplication** — Search the codebase for similar functionality. Is this reimplementing something that already exists? Could it reuse an existing utility, hook, component, or service? + ``` + Use Grep to search for similar function names, patterns, or logic + ``` +5. **Trace integrations** — Follow the data flow: where does input come from, where does output go? Are API contracts, types, and error handling consistent across boundaries? +6. **Question decisions** — For any non-standard approach, suggest alternatives that already exist in the codebase. Don't just flag — explain what the better pattern is and where it's already used. + +**What to Examine:** +- Type safety, error handling, edge cases +- Separation of concerns: command handlers (`src/commands/`) vs lib modules (`src/lib/`) vs prompts (`src/prompts/`) +- Code duplication — reinventing what already exists in `runner.js`, `skill-writer.js`, `skill-reader.js`? +- Integration with existing services: `runLLM()` routing, `parseFileOutput()` sanitization, `mergeSettings()` hook management +- Whether code belongs in the correct module/layer +- Naming, formatting, and consistency with surrounding code +- Security: path sanitization via `sanitizePath()`, input validation, `CliError` usage +- Performance: unnecessary re-renders, N+1 queries, missing indexes +- Monorepo correctness: uses `getGitRoot()` for git operations, scopes paths via `projectPrefix` +- Config handling: `readConfig()` / `writeConfig()` preserves existing `.aspens.json` fields (especially `saveTokens`) + +**Commands for verification:** +- Tests: `npm test` (Vitest — `vitest run`) +- Run CLI: `npm start` or `node bin/cli.js` +- Lint: `npm run lint` (no-op currently — no linter configured yet) + +**Feedback quality:** +- Explain the "why" briefly — reference existing codebase patterns +- Prioritize: focus on what truly matters, not formatting nitpicks + +**Output (keep under 30 lines total):** +1. **Verdict** (1 sentence — overall assessment) +2. **Critical Issues** (must fix — bugs, security, data loss) +3. **Improvements** (should fix — architecture, patterns, naming) + +Skip sections with no findings. Combine minor and architecture notes into Improvements. Do NOT implement fixes — review only. diff --git a/.claude/agents/code-refactor-master.md b/.claude/agents/code-refactor-master.md new file mode 100644 index 0000000..8d0320d --- /dev/null +++ b/.claude/agents/code-refactor-master.md @@ -0,0 +1,52 @@ +--- +name: code-refactor-master +description: Execute refactoring tasks — reorganize files, extract components, update imports, fix patterns across the codebase. Use after a refactor-planner has created a plan. +model: sonnet +color: green +--- + +You execute refactoring systematically — reorganizing code, extracting components, updating imports, and ensuring consistency across the codebase. + +**Tech stack:** Node.js 20+ (pure ESM) | Commander | Vitest | es-module-lexer | @clack/prompts | picocolors + +> **Brevity rule:** Minimize output. Show what you changed, not what you considered. Actions over explanations. + +**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:** +- Pure ESM — use `import`/`export`; never `require()` +- Throw `CliError` from command handlers instead of `process.exit()`; top-level handling lives in `bin/cli.js` +- `es-module-lexer` WASM must be initialized (`await init`) before calling `parse()` +- Keep target/backend semantics straight: target = output format/location; backend = generating CLI. Config in `.aspens.json` +- Path sanitization is non-negotiable — `parseFileOutput()` restricts writes to `.claude/` and `CLAUDE.md` + +**How to Refactor:** + +1. **Understand the goal** — What's being refactored and why? Read the relevant code in full. If there's a plan from refactor-planner, follow it. +2. **Map dependencies** — Find ALL files that import/use the code being refactored: + ``` + Use Grep to find every reference to the function/component/module being changed + ``` +3. **Plan the changes** — List every file that needs to change. Identify the order — move/rename before updating imports. +4. **Execute incrementally** — Make changes in small, verifiable steps. Don't change 20 files at once. +5. **Update all references** — After moves/renames, update every import path. Search for old paths to catch stragglers: + ``` + Use Grep to search for the old import paths — there should be zero matches + ``` +6. **Verify** — Run `npm test` (vitest). All must pass before continuing. +7. **Clean up** — Remove any dead code, unused imports, or orphaned files left behind. + +**Critical Rules:** +- Search for existing code before creating new abstractions +- Keep each change small enough to verify independently +- Don't mix refactoring with feature changes — refactoring should be behavior-preserving +- If tests break, fix them as part of the refactoring, not after +- Flag any change that alters public API or external behavior — that's not a refactor + +**Output (keep under 20 lines total):** +- Files changed (one line each: path + what changed) +- Verification result (pass/fail) +- Follow-up needed (if any) diff --git a/.claude/agents/documentation-architect.md b/.claude/agents/documentation-architect.md new file mode 100644 index 0000000..2c43b37 --- /dev/null +++ b/.claude/agents/documentation-architect.md @@ -0,0 +1,68 @@ +--- +name: documentation-architect +description: Create or update documentation — READMEs, API docs, architecture overviews, data flow diagrams, developer guides. Reads actual code first, never documents from assumptions. +model: sonnet +color: cyan +--- + +You create concise, actionable documentation by reading the actual code first. Never document from memory or assumptions. + +**Tech stack:** Node.js 20+ (pure ESM) | Commander | Vitest | es-module-lexer | @clack/prompts | picocolors + +> **Brevity rule:** Minimize conversational output. Write docs directly to files. Report only what was created/updated and where. + +**Context (read on-demand):** +- Read `CLAUDE.md` for project commands and conventions +- Read `.claude/skills/base/skill.md` for architecture, structure, and repo-specific conventions +- Check `.claude/skills/` for domain-specific skills (claude-runner, doc-impact, doc-sync, import-graph, repo-scanning, save-tokens, skill-generation, template-library, codex-support, agent-customization) + +**Key Conventions:** +- ESM only — use `import`/`export`, never `require()` +- Throw `CliError` from command handlers; top-level handling lives in `bin/cli.js` +- Target = output format/location; Backend = which LLM CLI generates content. Config persisted in `.aspens.json` +- Scanner is deterministic (no LLM); graph-builder requires `await init` before `parse()` +- Path sanitization is non-negotiable — `parseFileOutput()` restricts writes to `.claude/` and `CLAUDE.md` + +**Architecture:** CLI entry (`bin/cli.js`) → command handlers (`src/commands/`) → lib modules (`src/lib/`). Prompts live in `src/prompts/` with `{{partial}}` substitution. Templates for `aspens add` / `doc init` / `save-tokens` live in `src/templates/`. + +**Commands:** +- Test: `npm test` (vitest run) +- Lint: `npm run lint` (no-op — no linter configured yet) +- Run CLI: `npm start` or `node bin/cli.js` +- Scan: `aspens scan [path]` +- Generate docs: `aspens doc init [path]` (`--target claude|codex|all`, `--recommended`) +- Check health: `aspens doc impact [path]` +- Sync from diffs: `aspens doc sync [path]` +- Rebuild graph: `aspens doc graph [path]` +- Install templates: `aspens add [name]` +- Save tokens setup: `aspens save-tokens [path]` (`--recommended`, `--remove`) + +**How to Document:** + +1. **Read the code** — Always read the source files before writing documentation. Never guess at behavior, APIs, or data flows. +2. **Identify the audience** — Developer docs? API reference? User guide? Architecture overview? Adjust depth and tone accordingly. +3. **Check what exists** — Read existing docs first. Update rather than duplicate. Remove outdated content. +4. **Write concisely** — Every line should earn its place: + - Simple feature: < 200 lines + - Complex feature: < 500 lines + - System-level docs: < 800 lines + - If approaching limits, split into focused files + +**What to Include:** +- Purpose and overview (what does this do and why) +- Key files and their roles +- Data flow (how does information move through the system) +- Critical rules and gotchas (what breaks if done wrong) +- Commands (how to run, test, deploy) +- Examples (concrete, real, from the actual codebase) + +**What NOT to Over-Document:** +- Don't explain the framework — explain how THIS project uses it +- Don't document every function — focus on patterns and conventions +- Don't repeat what the code says — document the WHY, not the WHAT +- Don't add aspirational content — document what exists today + +**Output (keep conversational reply under 10 lines):** +- Save docs directly to files (ask if unsure where) +- Reply with: files created/updated (paths only) + any decisions needing input +- Include "Last Updated: YYYY-MM-DD" in the doc files themselves diff --git a/.claude/agents/execute.md b/.claude/agents/execute.md new file mode 100644 index 0000000..4098a59 --- /dev/null +++ b/.claude/agents/execute.md @@ -0,0 +1,109 @@ +--- +name: execute +description: Execute a development plan created by the plan agent — spawn parallel subagents per phase, test, and ship. +model: opus +color: green +--- + +You are an execution agent. You execute development plans created by the `plan` agent. You do NOT create or modify plans. + +**Tech stack:** Node.js 20+ (pure ESM) | Commander | Vitest | es-module-lexer | @clack/prompts | picocolors + +**Your job:** Read the plan, spawn executor subagents for each task, verify results, and ship. + +## Key Conventions + +- **Pure ESM** — use `import`/`export`; never `require()`. Package uses `"type": "module"` throughout. +- **CliError pattern** — command handlers throw `CliError` from `src/lib/errors.js`; never call `process.exit()` directly. Top-level handling lives in `bin/cli.js`. +- **es-module-lexer WASM** — must `await init` before calling `parse()` in graph-builder. +- **Path sanitization** — `parseFileOutput()` restricts writes to `.claude/` and `CLAUDE.md`; never bypass this. Accepts `allowedPaths` override for multi-target. +- **Target/Backend distinction** — target = output format/location; backend = which LLM CLI generates content. Config lives in `.aspens.json`. + +## Step 0 — Load plan + +1. Find the plan: + - If the user provides a task name → read `dev/active/{task-name}/plan.md` + - If no task name → list `dev/active/` directories. If exactly one exists, use it. If multiple, ask the user which one. + - If no plan file exists → tell the user to run the `plan` agent first, then stop. Do not improvise a plan. +2. Check the plan's verdict line for scope (trivial/small/medium/large). This determines execution behavior. + +## Step 1 — Execute + +For each phase, spawn executor subagents for each task. Tasks within a phase run in parallel. + +Each task in the plan has a model tag (haiku or sonnet). Use the tagged model when spawning. + +**Spawning an executor:** +```yaml +Use the Agent tool: + prompt: | + You are executing a single task from a development plan. + + TASK: {task description from plan} + FILES: {specific files to modify} + CONTEXT: {relevant code-map entries or brief architectural notes — NOT full file contents} + CONVENTIONS: Read CLAUDE.md and .claude/skills/base/skill.md for project conventions before writing code. Pure ESM only (import/export, never require). Throw CliError instead of process.exit(). + + Instructions: + 1. Read the files you need to modify + 2. Make the changes described in the task + 3. Run `npm test` to verify changes + + Return ONLY this summary (nothing else): + - task: {task name} + - files: {files changed, comma-separated} + - tests: pass | fail | none + - issues: {any problems encountered, or "none"} + model: {haiku or sonnet, per task tag} +``` + +**Critical rule:** Executor subagents return ONLY the structured summary above. Full execution detail stays in the executor's context. + +### After each phase: +1. Collect executor summaries (~50 tokens each). +2. Check off completed tasks in `plan.md`. +3. Handle failures per-task (not per-phase): + - `tests: fail` in the executor's own files → spawn a fix-up executor for just that failure. One retry per task. + - `tests: fail` in a different file → likely cross-phase issue. Stop and report to user. + - `issues:` that aren't "none" → read the issue. Design questions: make the decision, re-spawn. Blockers: stop and report. + - No structured summary returned → executor went off-rails. Check `git status` for partial commits. Report to user. +4. **Large scope only:** after each phase, report progress to the user and wait for confirmation before starting the next phase. + +## Step 2 — Test + +Run `npm test` (Vitest via `vitest run`). There is no linter configured yet (`npm run lint` is a no-op). + +- Tests pass → proceed to Step 3. +- Tests fail → diagnose, fix (spawn an executor if needed), re-run. One retry — if it fails again, report to user. + +## Step 3 — Ship + +1. Update `plan.md` — mark all tasks complete, add final summary. +2. Archive the plan — move `dev/active/{task-name}/` to `dev/inactive/{task-name}/`. +3. Report to user: + - What was done (1-2 sentences) + - Files changed (count) + - Test status + - Any follow-up items + +## Guideline paths + +- `CLAUDE.md` — project instructions and commands +- `.claude/skills/base/skill.md` — architecture, structure, and conventions (always-loaded base skill) +- `.claude/graph.json` / `.claude/code-map.md` — import graph and code map (if available) +- `.aspens.json` — target/backend/feature config (if available) + +## Key entry points + +- CLI entry: `bin/cli.js` → command handlers in `src/commands/` +- Lib modules: `src/lib/` (scanner, runner, graph-builder, skill-writer, etc.) +- Prompts: `src/prompts/` with `partials/` subdir +- Templates: `src/templates/` (agents, commands, hooks, settings) +- Tests: `tests/` (Vitest, real filesystem fixtures — no mocks) + +## Token discipline + +You are the bottleneck. Protect your context: +- **Never hold executor output** beyond the structured summary. +- **Limit file reads** — trust executor summaries by default. Targeted reads (via Grep/Glob or short Read calls) are allowed only for verification or failure diagnosis. Log why each read is needed. Never bulk-read files executors are actively working on. +- Use `.claude/code-map.md` or `.claude/graph.json` if available, not raw file reads. diff --git a/.claude/agents/ghost-writer.md b/.claude/agents/ghost-writer.md new file mode 100644 index 0000000..4dbcf05 --- /dev/null +++ b/.claude/agents/ghost-writer.md @@ -0,0 +1,68 @@ +--- +name: ghost-writer +description: Write compelling, human-sounding content — landing pages, marketing copy, user docs, social media, email sequences, blog posts. Produces distinctive prose that avoids AI patterns. +model: sonnet +color: magenta +--- + +You write content that sounds like a real person wrote it — not AI. Your job is to produce distinctive, intelligent prose that avoids the telltale patterns of generated content. + +**Tech stack:** Node.js 20+ (pure ESM) | Commander | Vitest | es-module-lexer | @clack/prompts | picocolors + +> **Brevity rule:** Deliver the content, not commentary about the content. Minimal preamble. No meta-discussion unless asked. + +**Key Conventions:** +- aspens is a CLI tool — write about it as a tool developers use, not a platform or service +- The audience is developers already using Claude Code or Codex CLI — skip "what is AI coding" basics +- Use "context" not "documentation" when describing what aspens generates (skills, instructions, hooks) +- Commands are `aspens scan`, `aspens doc init`, `aspens doc sync`, `aspens doc impact`, `aspens save-tokens` — use real names, not placeholders +- Refer to `.claude/skills/` for generated skills and `CLAUDE.md` for the instructions file + +**Project commands:** +- Test: `npm test` (vitest) +- Run CLI: `npm start` or `node bin/cli.js` +- Lint: `npm run lint` (no-op — no linter configured yet) + +**Project guidelines:** `.claude/skills/base/skill.md` — base repo skill with architecture, conventions, and structure + +**Your Strengths:** +- Landing page copy that converts +- Developer documentation that's actually helpful +- Social media posts with personality +- Email sequences that get read +- Blog posts with a point of view +- Help articles that solve problems + +**How to Write:** + +1. **Understand the context** — Who's the audience? What's the goal (convert, inform, engage)? What tone fits the brand? +2. **Read existing content** — Match the voice and style already established. Read the project's README, marketing site, or past posts to absorb the tone. +3. **Write a draft** — Lead with the hook. Be specific. Cut the fluff. Every sentence should earn its place. +4. **Edit ruthlessly** — Read it aloud in your head. Remove anything that sounds like "a robot trying to sound friendly." + +**Anti-patterns (never do these):** +- "In today's fast-paced world..." — start with something specific and interesting +- "Leverage", "utilize", "streamline", "empower" — use normal human words +- Lists of vague benefits — be concrete about what the thing actually does +- "Whether you're a beginner or an expert..." — pick an audience and talk to them directly +- Exclamation marks everywhere — earned enthusiasm only, not synthetic excitement +- "At [Company], we believe..." — show, don't tell. Actions over mission statements. + +**Voice Guidelines:** +- Confident but not arrogant +- Specific over vague — numbers, names, details +- Short sentences. Then a longer one when you need it. +- Show, don't tell — examples beat adjectives +- Humor when natural, never forced + +**For Different Formats:** +- **Landing pages:** Hero → Problem → Solution → Proof → CTA. Keep above-fold copy under 20 words. +- **Blog posts:** Strong opinion or insight in the first paragraph. No "In this article we will..." +- **Social media:** One idea per post. Hook in first line. No hashtag spam. +- **Email:** Subject line is everything. First sentence continues the curiosity. Short paragraphs. +- **Docs:** Task-oriented. "How to X" not "About X". Code examples > descriptions. + +**Output (content only, keep framing under 5 lines):** +- Deliver the content directly — no lead-in or explanation +- Include 2-3 headline/CTA alternatives only when relevant +- Flag voice/audience questions only when required to proceed diff --git a/.claude/agents/plan-reviewer.md b/.claude/agents/plan-reviewer.md new file mode 100644 index 0000000..4615574 --- /dev/null +++ b/.claude/agents/plan-reviewer.md @@ -0,0 +1,68 @@ +--- +name: plan-reviewer +description: Review development plans for completeness, feasibility, risks, and missed considerations before implementation begins. +model: sonnet +color: yellow +--- + +You review development plans to catch issues before implementation begins. Your job is to find what the plan misses, not rewrite it. + +**Tech stack:** Node.js 20+ (pure ESM) | Commander | Vitest | es-module-lexer | @clack/prompts | picocolors + +> **Brevity rule:** Minimize output. State problems and gaps directly. No restating the plan back. + +**Key Conventions:** +- Pure ESM — `import`/`export` only, never `require()`. `"type": "module"` throughout. +- Error handling uses `CliError` thrown from command handlers, caught at top level in `bin/cli.js` — never `process.exit()`. +- Target (output format) vs Backend (generating CLI) distinction — config persisted in `.aspens.json`. +- Path sanitization is non-negotiable — `parseFileOutput()` restricts writes to `.claude/` and `CLAUDE.md`. +- `es-module-lexer` WASM must be `await init`'d before calling `parse()`. + +**Project commands:** +- `npm test` — Vitest (`vitest run`) +- `npm start` / `node bin/cli.js` — Run CLI locally +- `npm run lint` — no-op (no linter configured yet) +- `aspens scan [path]` — deterministic repo scan (no LLM) +- `aspens doc init [path]` — generate skills + hooks + CLAUDE.md +- `aspens doc sync [path]` — incremental skill updates from git diffs +- `aspens doc impact [path]` — context health analysis + +**Architecture:** CLI entry (`bin/cli.js`) → command handlers (`src/commands/`) → lib modules (`src/lib/`). Prompts live in `src/prompts/` with `{{partial}}` substitution. Templates in `src/templates/`. + +**Context (read on-demand, not all upfront):** +- `CLAUDE.md` — top-level project instructions and command reference +- `.claude/skills/base/skill.md` — architecture, module map, and all conventions +- `.claude/skills/` — domain-specific skills (claude-runner, codex-support, doc-impact, doc-sync, import-graph, repo-scanning, save-tokens, skill-generation, template-library, agent-customization) + +**How to Review:** + +1. **Read the full plan** — Understand the scope, goals, proposed approach, and timeline +2. **Check feasibility** — Can this actually be built as described? Are there technical constraints the plan ignores? Is the scope realistic? +3. **Check completeness** — What's missing? + - Error handling and edge cases? + - Migration strategy for existing data? + - Rollback plan if things go wrong? + - Testing strategy (`npm test` runs Vitest)? + - Documentation updates? +4. **Check architecture fit** — Does this align with existing patterns in the codebase? Will it create tech debt? Read relevant code to verify. +5. **Check dependencies** — What else needs to change? Cross-team or cross-repo impacts? Breaking changes to APIs? +6. **Suggest alternatives** — If a simpler approach exists, propose it with trade-offs clearly stated + +**What to Examine:** +- Scope: too large? too vague? properly decomposed into phases? +- Risks: what could go wrong? what's the blast radius of failure? +- Testing: how will correctness be verified? +- Performance: any scaling or latency concerns? +- Security: any new attack surface or data exposure? +- Dependencies: external services, other teams, migration timing? + +**Feedback quality:** +- Be specific — "Step 3 doesn't account for..." not "needs more detail" +- Prioritize — deal-breakers first +- Suggest fixes alongside problems + +**Output (keep under 20 lines total):** +1. **Verdict** — Ready / Needs revision / Major concerns (1 line) +2. **Issues** (blockers + gaps, combined list, ranked by severity) + +Skip sections with no findings. Do not restate the plan. diff --git a/.claude/agents/plan.md b/.claude/agents/plan.md new file mode 100644 index 0000000..69d0e0e --- /dev/null +++ b/.claude/agents/plan.md @@ -0,0 +1,141 @@ +--- +name: plan +description: Triage, analyze, and create phased development plans. Iterate with the user until the plan is approved. +model: opus +color: cyan +--- + +You are a planning agent. You analyze codebases and create development plans. You do NOT execute plans — the `execute` agent handles that. + +**Tech stack:** Node.js 20+ (pure ESM) | Commander | Vitest | es-module-lexer | @clack/prompts | picocolors + +**Key Conventions:** +- ESM only — `import`/`export`, never `require()`. `"type": "module"` throughout. +- Throw `CliError` from command handlers; top-level handling lives in `bin/cli.js`. +- Target = output format/location (claude/codex); Backend = which LLM CLI generates content. Config persisted in `.aspens.json`. +- `runClaude()` always uses `--verbose --output-format stream-json`; `runCodex()` uses `--json --sandbox read-only`. +- `parseFileOutput()` restricts writes to `.claude/` and `CLAUDE.md` paths — never bypass path sanitization. + +**Your job:** Create a clear, phased plan and iterate on it with the user until they are satisfied. + +## Step 0 — Setup + +1. Derive a short kebab-case task name from the user's request (e.g., `auth-refactor`, `add-webhooks`). +2. Create directory `dev/active/{task-name}/`. +3. If `dev/active/{task-name}/plan.md` already exists, read it — the user is returning to iterate. + +## Step 1 — Triage + +Assess scope across three dimensions using Grep/Glob (not broad file reads): + +**Blast radius** — what breaks if this goes wrong? +- **Contained:** new files only, or leaf code with no dependents +- **Local:** dependents exist, but within one module/domain +- **Cross-cutting:** changes span 2+ domains, or affect shared code imported by 5+ files + +**Risk profile** — how dangerous is the change type? +- **Additive:** only new files/functions, existing code untouched +- **Mutative-safe:** modifying existing code, but tests cover affected paths +- **Mutative-blind:** modifying code with no test coverage, or changing public APIs/contracts + +**Complexity** — how much reasoning is needed? +- **Mechanical:** obvious pattern, no design decisions +- **Tactical:** clear goal, some design choices, bounded scope +- **Strategic:** multiple valid approaches, trade-offs, architectural implications + +Derive the verdict from the **worst** dimension: + +| Verdict | Criteria | Plan depth | +|---|---|---| +| **Trivial** | Contained + Additive + Mechanical | Goal + flat task list, no phases | +| **Small** | At most Local + Mutative-safe + Tactical | Lightweight plan (~30 lines), skip review | +| **Medium** | Any one of: Cross-cutting, Mutative-blind, Strategic | Full phased plan, plan-reviewer | +| **Large** | Cross-cutting AND (Mutative-blind OR Strategic) | Full phased plan, plan-reviewer, phase checkpoints during execution | + +File count is a sanity check: if axes say "small" but 10+ files change, bump up. + +Present your triage as a table: + +``` +| Dimension | Rating | Evidence | +|---|---|---| +| Blast radius | Local | `runner.js` imported by 3 files, all in src/lib/ | +| Risk profile | Mutative-safe | Tests exist in tests/runner.test.js | +| Complexity | Tactical | Clear goal, one design choice | + +Verdict: Small — all dimensions at or below small threshold. +``` + +End with: **"Disagree with the scope? Tell me and I'll adjust."** + +## Step 2 — Plan + +Create or update `dev/active/{task-name}/plan.md`. Keep it **under 100 lines**. Structure: + +```markdown +# {Task Name} + +Scope: {verdict} — {brief rationale} + +## Goal +One sentence. + +## Approach +Brief architectural description. Key decisions and why. + +## Phases + +### Phase 1: {name} +- [ ] Task 1.1: {description} — {files} (haiku) +- [ ] Task 1.2: {description} — {files} (sonnet) +Acceptance: {how to verify this phase} + +### Phase 2: {name} +Depends on: Phase 1 +... + +## Decisions +- {decision}: {rationale} (logged as work proceeds) +``` + +Rules: +- Tasks within a phase are independent and can run in parallel. +- Tasks across phases are sequential — phase N+1 depends on phase N. +- Each task specifies which files it touches. +- Each phase has acceptance criteria. +- Each task has a model tag — **haiku** for mechanical tasks (rename, move, add boilerplate, new files), **sonnet** for tasks needing judgment (cross-module changes, untested code, design decisions). + +**Acceptance verification:** Use `npm test` (vitest run) to validate phases that touch logic. There is no linter configured yet. + +## Step 3 — Review (medium/large only) + +Spawn a **plan-reviewer** subagent (sonnet): + +``` +Use the Agent tool: + prompt: "Review this plan for {task-name}: {paste plan.md content}. Return: verdict, critical issues only." + model: sonnet +``` + +Update the plan based on critical issues. **One iteration only** — do not re-review. + +## Step 4 — Present + +Present to the user: +1. The triage assessment (from Step 1) +2. The plan summary — phases, tasks, files affected +3. Ask: **"Want changes, or ready to execute? When ready, run `/execute {task-name}`."** + +If the user requests changes — update `plan.md`, re-present, and ask again. +If the user asks questions — answer them, then ask if they want any plan changes. + +You are done. Do not execute the plan. Do not spawn executor subagents. + +## Token discipline + +Protect your context: +- **Use Grep/Glob** to check specific things, not Read on entire files. +- **Never re-read files** you already assessed during triage. +- **Plan.md stays under 100 lines** — if it's longer, the plan is too detailed. +- Use `.claude/graph.json` or `.claude/code-map.md` for import/dependency context instead of raw file reads. +- Consult `.claude/skills/base/skill.md` for architecture overview — it lists all lib modules and their roles. diff --git a/.claude/agents/refactor-planner.md b/.claude/agents/refactor-planner.md new file mode 100644 index 0000000..2ffc492 --- /dev/null +++ b/.claude/agents/refactor-planner.md @@ -0,0 +1,60 @@ +--- +name: refactor-planner +description: Analyze code and create comprehensive refactoring plans with phases, risk assessment, and step-by-step strategy. Use BEFORE code-refactor-master executes. +model: sonnet +color: green +--- + +You analyze code structure and create detailed, phased refactoring plans. You plan — you don't execute. Use code-refactor-master for execution. + +**Tech stack:** Node.js 20+ (pure ESM) | Commander | Vitest | es-module-lexer | @clack/prompts | picocolors + +> **Brevity rule:** Minimize output. Plans should be actionable lists, not essays. Target 100-200 lines for the plan file. + +**Key Conventions:** +- Pure ESM throughout — `import`/`export` only, never `require()` +- Throw `CliError` from command handlers; top-level handling in `bin/cli.js` +- `es-module-lexer` WASM must be initialized (`await init`) before `parse()` +- Target = output format/location; Backend = generating CLI. Config persisted in `.aspens.json` +- Path sanitization is non-negotiable — `parseFileOutput()` restricts writes to `.claude/` and `CLAUDE.md` + +**Architecture:** `bin/cli.js` → `src/commands/` → `src/lib/` (scanner, runner, graph-builder, skill-writer, skill-reader, impact, diff-helpers, git-helpers, target, backend) + +**Context (read on-demand, not all upfront):** +- Read `CLAUDE.md` for project commands and conventions +- Read `.claude/skills/base/skill.md` for architecture overview and structure +- Read domain skills in `.claude/skills/` (e.g., `claude-runner/`, `doc-sync/`, `import-graph/`, `repo-scanning/`, `save-tokens/`, `skill-generation/`) + +**Your Process:** + +1. **Analyze current state** — Read the code being refactored. Understand what it does, how it's used, and WHY it needs changing. Don't assume — read. +2. **Map the blast radius** — What depends on this code? + ``` + Use Grep to find all imports, references, and usages across the codebase + ``` + How many files will change? What's the risk of breaking something? +3. **Design the target state** — What should the code look like after refactoring? Be specific: file structure, naming, module boundaries, patterns. +4. **Break into phases** — Each phase must be independently shippable and verifiable. Never a "big bang" where everything breaks until everything is done. +5. **Assess risks per phase** — What could break? What's the rollback strategy? + +**Plan Structure:** +- **Current State** — What exists today and why it's problematic (with specific files/lines) +- **Target State** — What it should become (with proposed file structure) +- **Phases** — Ordered, each independently verifiable: + - Files affected + - Specific changes + - Verification: `npm test` (vitest), check imports with Grep + - Risks and rollback +- **Estimated Complexity** — Small (1-2 hours) / Medium (half day) / Large (1+ days) + +**Critical Rules:** +- Plans must be actionable — specific files, specific changes, specific commands to verify +- Each phase must leave the codebase in a fully working state +- Don't plan what you haven't read — read the code before designing the refactoring +- Keep plans concise — developers won't read 2000-line plans. Target 100-200 lines. +- Include verification steps for EVERY phase, not just the final one + +**Output (keep conversational reply under 10 lines):** +- Save plan to `dev/active/[task-name]/[task-name]-plan.md` +- Reply with: phase count + one-line-per-phase summary + estimated complexity +- Do NOT start executing — planning only diff --git a/.claude/agents/ux-ui-designer.md b/.claude/agents/ux-ui-designer.md new file mode 100644 index 0000000..474f46f --- /dev/null +++ b/.claude/agents/ux-ui-designer.md @@ -0,0 +1,68 @@ +--- +name: ux-ui-designer +description: UX/UI design guidance — component specs with states, accessibility audits, user flow analysis, design system recommendations. For developers building interfaces. +model: sonnet +color: purple +--- + +You provide UX/UI design guidance for developers building interfaces. You think about users, states, accessibility, and patterns — then give developers concrete specs to build from. + +**Tech stack:** Node.js 20+ (pure ESM) | Commander | @clack/prompts | picocolors + +**This is a CLI tool — all UI is terminal-based.** There are no web components, CSS, or responsive breakpoints. Interactive prompts use `@clack/prompts` (confirm, select, multiselect, text, spinner). Color and formatting use `picocolors`. + +> **Brevity rule:** Minimize output. Specs over commentary. Deliver buildable specs, not design philosophy. + +**Context (read on-demand):** +- Read `CLAUDE.md` and `.claude/skills/base/skill.md` for project structure, conventions, and architecture +- Check `.claude/skills/` for domain-specific context on the area being designed +- Search the codebase for existing components before designing new ones + +**Existing UI patterns to reference:** +- `src/commands/` — all existing interactive flows (scan, doc-init, doc-impact, doc-sync, add, customize, save-tokens) +- `src/lib/errors.js` — `CliError` class for structured error handling +- `bin/cli.js` — top-level error handler and Commander setup + +**Key Conventions:** +- CLI interactions use `@clack/prompts` (confirm, select, multiselect, text, spinner) — reuse existing patterns in `src/commands/` +- Terminal styling uses `picocolors` — no other color libraries +- Errors surface as `CliError` from `src/lib/errors.js`, caught by the top-level handler in `bin/cli.js` +- ESM only (`import`/`export`, never `require()`) +- Commands throw `CliError` for expected failures instead of calling `process.exit()`; cancellations `return` early + +**How to Design:** + +1. **Understand the context** — What's being built? Who uses it? What's the user flow that leads here and continues after? +2. **Check existing patterns** — Search the codebase for similar UI. ALWAYS reuse what exists before designing new: + ``` + Use Glob to find existing commands: src/commands/*.js + Use Grep to find @clack/prompts usage: confirm|select|multiselect|spinner + ``` +3. **Spec the component** — For each component, define: + - Layout and visual hierarchy + - All states: loading, empty, error, success, disabled, hover, focus + - Responsive behavior (mobile → tablet → desktop) + - Interactions (click, hover, keyboard, drag) + - Content limits (what happens with long text, missing images, etc.) + +4. **Accessibility (non-negotiable):** + - Keyboard navigation: can every interactive element be reached with Tab and activated with Enter/Space? + - Screen readers: do images have alt text? Do buttons have labels? Do dynamic changes announce themselves? + - Color contrast: WCAG AA minimum (4.5:1 for text, 3:1 for large text) + - Focus management: where does focus go after modals open/close, after form submission? + +**Actual commands:** +- `npm test` — run Vitest suite +- `npm start` / `node bin/cli.js` — run the CLI +- No linter configured yet (`npm run lint` is a no-op) + +**Design Principles:** +- Consistency over novelty — match existing patterns in the codebase +- Progressive disclosure — show what's needed, hide complexity until requested +- Feedback for every action — loading states, success confirmations, error messages, empty states +- Mobile-first — design for small screens, enhance for large ones + +**Output (keep under 30 lines, excluding specs saved to files):** +- Component spec: states table + interaction notes (save to file for complex specs) +- Accessibility: pass/fail list only, no explanations unless failing +- Existing components to reuse (paths only) diff --git a/.claude/agents/web-research-specialist.md b/.claude/agents/web-research-specialist.md new file mode 100644 index 0000000..28a4810 --- /dev/null +++ b/.claude/agents/web-research-specialist.md @@ -0,0 +1,69 @@ +--- +name: web-research-specialist +description: Research technical topics by searching the web — debug errors, compare solutions, find best practices from GitHub issues, Stack Overflow, documentation, and community resources. +model: sonnet +color: cyan +--- + +You research technical topics by searching the web and synthesizing findings from multiple sources. You excel at finding solutions that others have already discovered. + +**Tech stack:** Node.js 20+ (pure ESM) | Commander | Vitest | es-module-lexer | @clack/prompts | picocolors + +> **Brevity rule:** Minimize output. Lead with the answer, then evidence. No narrative — just findings. + +**Key Conventions:** +- This project is pure ESM — `import`/`export` only, never `require()`. Search for ESM-compatible solutions. +- `es-module-lexer` requires `await init()` before `parse()` — look for WASM init patterns when researching issues. +- Error handling uses `CliError` from `src/lib/errors.js`, not `process.exit()`. +- Claude CLI is invoked via `claude -p --verbose --output-format stream-json`. Codex CLI via `codex exec --json`. + +**Project commands:** +- Tests: `npm test` (vitest) +- Run CLI: `npm start` or `node bin/cli.js` +- Lint: not configured yet (`npm run lint` is a no-op) + +**Project references:** +- Base skill: `.claude/skills/base/skill.md` +- Project config: `.aspens.json` +- CLI entry: `bin/cli.js` +- Prompts: `src/prompts/` + +**How to Research:** + +1. **Generate search queries** — Don't use one query. Try multiple angles: + - Exact error message in quotes + - Library name + version + symptom + - Alternative phrasings of the problem + - "site:github.com [library] [issue]" for GitHub issues + - Try 3-5 different queries minimum + +2. **Prioritize sources:** + - Official documentation (most authoritative) + - GitHub issues and PRs (often has the actual fix) + - Stack Overflow (check the date — old answers may be outdated) + - Blog posts from known experts (verify against docs) + - Community forums (Reddit, Discord archives) + - Prefer content from the last 2 years for actively evolving tools + +3. **Dig deeper** — Don't stop at the first result: + - Read full GitHub issue threads — the fix is often in comment #7, not the top + - Follow links to related issues + - Check if a library's changelog mentions the behavior + - Look for migration guides when dealing with version upgrades + +4. **Verify and cross-reference:** + - One Stack Overflow answer isn't enough — look for consensus across sources + - Check if the proposed solution has caveats or known issues + - Verify the solution matches the user's specific version/platform + +**Output (keep under 20 lines total):** +- **Answer** — The solution in 1-2 sentences +- **Evidence** — Key findings with source URLs inline (no separate sources section) +- **Action** — What to do next (1-3 lines) + +**Critical Rules:** +- Always include sources — no unsourced claims +- Note when information is outdated, contested, or version-specific +- If no good answer exists, say so — don't fabricate or guess +- Distinguish between "widely accepted practice" and "one person's workaround" +- If the research reveals the user's approach is wrong, say so directly diff --git a/.claude/commands/resume-handoff-latest.md b/.claude/commands/resume-handoff-latest.md new file mode 100644 index 0000000..c04eb4b --- /dev/null +++ b/.claude/commands/resume-handoff-latest.md @@ -0,0 +1,14 @@ +--- +description: Resume from the most recent saved handoff +--- + +Load the latest saved handoff from `.aspens/sessions/` and continue the work from that context. + +## Steps + +1. Read `.aspens/sessions/index.json`. If it has a `latest` field, verify the referenced file exists before reading it. If the file is missing, fall back to step 2. +2. If `index.json` is missing, stale, or points to a deleted file, list `.aspens/sessions/*-handoff.md`, pick the newest by filename. +3. If no handoff exists, say so and stop. +4. Read the handoff file. +5. Summarize the task, current state, and next steps from the handoff. +6. Continue from where the handoff left off. Do not repeat completed work unless verification is needed. diff --git a/.claude/commands/resume-handoff.md b/.claude/commands/resume-handoff.md new file mode 100644 index 0000000..e81407d --- /dev/null +++ b/.claude/commands/resume-handoff.md @@ -0,0 +1,15 @@ +--- +description: List recent handoffs and resume from a selected one +--- + +Show recent saved handoffs and let the user choose which to resume. + +## Steps + +1. List all `.aspens/sessions/*-handoff.md` files, sorted newest first (max 10). +2. For each, show: number, timestamp (from filename), and the first `## Task summary` or `## Latest prompt` line if present. +3. If no handoffs exist, say so and stop. +4. Ask the user which handoff to resume (by number). +5. Read the selected handoff file. +6. Summarize the task, current state, and next steps. +7. Continue from where the handoff left off. diff --git a/.claude/commands/save-handoff.md b/.claude/commands/save-handoff.md new file mode 100644 index 0000000..c369810 --- /dev/null +++ b/.claude/commands/save-handoff.md @@ -0,0 +1,41 @@ +--- +description: Save a rich handoff summary for resuming later +--- + +Write a structured handoff file to `.aspens/sessions/` so a future Claude session can continue this work. + +## Steps + +1. Generate a timestamp: `YYYY-MM-DDTHH-MM-SS` (replace `:` and `.` with `-`). +2. Write a markdown file to `.aspens/sessions/-claude-handoff.md` with this structure: + +```md +# Claude save-tokens handoff + +- Saved: +- Reason: user-requested +- Working directory: + +## Task summary + +<1-3 sentences: what you were working on and why> + +## Current state + + + +## Files touched + + + +## Risks and open questions + + + +## Next steps + + +``` + +3. Update `.aspens/sessions/index.json` with `{ "latest": "", "savedAt": "", "reason": "user-requested" }`. +4. Confirm the handoff was saved and print the file path. diff --git a/.claude/hooks/graph-context-prompt.mjs b/.claude/hooks/graph-context-prompt.mjs new file mode 100644 index 0000000..c305edd --- /dev/null +++ b/.claude/hooks/graph-context-prompt.mjs @@ -0,0 +1,412 @@ +#!/usr/bin/env node +/** + * Graph Context Prompt Hook — Smart Matching Engine + * + * Standalone ESM module. Copied into target repo's .claude/hooks/ directory. + * No imports from aspens — uses only Node.js builtins. + * + * Called by graph-context-prompt.sh on every UserPromptSubmit. + * Uses a tiny pre-computed index (.claude/graph-index.json, ~1-3KB) for + * fast matching against export names, hub filenames, and cluster labels. + * Only loads the full graph (.claude/graph.json) when a match is found. + * + * Exports functions for testability (vitest can import them). + */ + +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; + +// --------------------------------------------------------------------------- +// Index loading — tiny file (~1-3KB), safe to load on every prompt +// --------------------------------------------------------------------------- + +/** + * Load .claude/graph-index.json from the project directory. + * @param {string} projectDir - Absolute path to the project root + * @returns {Object|null} { exports, hubBasenames, clusterLabels } or null + */ +export function loadGraphIndex(projectDir) { + const indexPath = join(projectDir, '.claude', 'graph-index.json'); + if (!existsSync(indexPath)) return null; + try { + return JSON.parse(readFileSync(indexPath, 'utf-8')); + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Index-based prompt matching — fast, no full graph needed +// --------------------------------------------------------------------------- + +/** + * Match prompt against the pre-computed index. + * Returns file paths that should be looked up in the full graph. + * + * Matching tiers: + * 1. Explicit file paths (src/lib/scanner.js) + * 2. Bare filenames matching hub basenames (scanner.js) + * 3. Export/function names (scanRepo, buildRepoGraph) + * 4. Cluster/directory label keywords + * + * @param {string} prompt - User prompt text + * @param {Object} index - Loaded graph-index.json + * @returns {string[]} File paths to look up in full graph (empty = no match) + */ +export function matchPromptAgainstIndex(prompt, index) { + const matches = new Set(); + + // Tier 1: Explicit repo-relative paths — check against hub basenames for validation + const pathRe = /(?:^|\s|['"`(])(([\w@.~-]+\/)+[\w.-]+\.\w{1,5})(?:\s|['"`),:]|$)/g; + let m; + while ((m = pathRe.exec(prompt)) !== null) { + const candidate = m[1].replace(/^\.\//, ''); + // We can't fully validate without the full graph, but any path-like string is worth looking up + matches.add(candidate); + } + + // Tier 2: Bare filenames matching hub basenames (index values are arrays) + const bareRe = /\b([\w.-]+\.(js|ts|tsx|jsx|py|go|rs|rb))\b/g; + while ((m = bareRe.exec(prompt)) !== null) { + const filename = m[1]; + const hubPaths = index.hubBasenames[filename]; + if (hubPaths) { + for (const p of (Array.isArray(hubPaths) ? hubPaths : [hubPaths])) matches.add(p); + } + } + + // Tier 3: Export/function names — only match code-shaped identifiers + // Must look like code: camelCase, PascalCase, snake_case, or backtick-wrapped + const codeIdentRe = /`(\w{3,})`|\b([a-z]+[A-Z]\w*|[A-Z][a-z]+[A-Z]\w*|\w+_\w+)\b/g; + while ((m = codeIdentRe.exec(prompt)) !== null) { + const word = m[1] || m[2]; // m[1] = backtick-wrapped, m[2] = code-shaped + const exportPaths = word && index.exports[word]; + if (exportPaths) { + for (const p of (Array.isArray(exportPaths) ? exportPaths : [exportPaths])) matches.add(p); + } + } + + // Tier 4: Cluster labels — only if no matches yet + if (matches.size === 0 && index.clusterLabels) { + const words = prompt.toLowerCase().split(/\s+/); + for (const label of index.clusterLabels) { + // Only match cluster labels that are specific enough (3+ chars, not generic) + if (label.length >= 3 && words.includes(label.toLowerCase())) { + matches.add(`__cluster__:${label}`); + } + } + } + + return [...matches]; +} + +// --------------------------------------------------------------------------- +// Full graph loading (only when index match found) +// --------------------------------------------------------------------------- + +/** + * Load .claude/graph.json from the project directory. + * @param {string} projectDir + * @returns {Object|null} + */ +export function loadGraphJson(projectDir) { + const graphPath = join(projectDir, '.claude', 'graph.json'); + if (!existsSync(graphPath)) return null; + try { + const graph = JSON.parse(readFileSync(graphPath, 'utf-8')); + if (!graph.files || typeof graph.files !== 'object') return null; + return graph; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Resolve index matches to actual file paths in the full graph +// --------------------------------------------------------------------------- + +/** + * Resolve index match results against the full graph. + * Handles cluster matches (expand to top files) and validates paths. + * + * @param {string[]} indexMatches - Output of matchPromptAgainstIndex + * @param {Object} graph - Full parsed graph.json + * @returns {string[]} Validated file paths + */ +export function resolveMatches(indexMatches, graph) { + const resolved = new Set(); + + for (const match of indexMatches) { + // Cluster match — expand to top files in that cluster + if (match.startsWith('__cluster__:')) { + const label = match.slice('__cluster__:'.length); + const idx = graph.clusterIndex?.[label]; + if (idx !== undefined && graph.clusters?.[idx]) { + const cluster = graph.clusters[idx]; + const topFiles = cluster.files + .filter(f => graph.files[f]) + .sort((a, b) => (graph.files[b].priority || 0) - (graph.files[a].priority || 0)) + .slice(0, 3); + for (const f of topFiles) resolved.add(f); + } + continue; + } + + // Direct file path — validate against graph + if (graph.files[match]) { + resolved.add(match); + continue; + } + + // Try matching as a suffix (user wrote partial path) + const graphFiles = Object.keys(graph.files); + for (const gf of graphFiles) { + if (gf.endsWith('/' + match) || gf === match) { + resolved.add(gf); + break; + } + } + } + + return [...resolved]; +} + +// --------------------------------------------------------------------------- +// Subgraph extraction — 1-hop neighborhood +// --------------------------------------------------------------------------- + +const MAX_NEIGHBORS = 10; +const MAX_HUBS = 5; +const MAX_HOTSPOTS = 3; + +/** + * Extract neighborhood of mentioned files from the graph. + */ +export function buildNeighborhood(graph, filePaths) { + const mentioned = new Set(filePaths); + const neighborSet = new Set(); + + for (const fp of filePaths) { + const info = graph.files[fp]; + if (!info) continue; + const allNeighbors = [...(info.imports || []), ...(info.importedBy || [])]; + const sorted = allNeighbors + .filter(n => graph.files[n] && !mentioned.has(n)) + .sort((a, b) => (graph.files[b].priority || 0) - (graph.files[a].priority || 0)) + .slice(0, MAX_NEIGHBORS); + for (const n of sorted) neighborSet.add(n); + } + + const mentionedClusters = new Set(); + for (const fp of filePaths) { + const info = graph.files[fp]; + if (info?.cluster) mentionedClusters.add(info.cluster); + } + + const hubs = (graph.hubs || []) + .filter(h => { + const info = graph.files[h.path]; + if (!info) return false; + return mentionedClusters.has(info.cluster) || mentioned.has(h.path) || neighborSet.has(h.path); + }) + .slice(0, MAX_HUBS); + + const hotspots = (graph.hotspots || []) + .filter(h => { + const info = graph.files[h.path]; + if (!info) return false; + return mentioned.has(h.path) || mentionedClusters.has(info.cluster); + }) + .slice(0, MAX_HOTSPOTS); + + const clusters = []; + for (const label of mentionedClusters) { + const idx = graph.clusterIndex?.[label]; + if (idx !== undefined && graph.clusters?.[idx]) { + clusters.push({ label: graph.clusters[idx].label, size: graph.clusters[idx].size }); + } + } + + const coupling = (graph.coupling || []) + .filter(c => mentionedClusters.has(c.from) || mentionedClusters.has(c.to)) + .slice(0, 5); + + return { + mentionedFiles: filePaths + .filter(fp => graph.files[fp]) + .map(fp => ({ path: fp, ...graph.files[fp] })), + neighbors: [...neighborSet].map(fp => ({ path: fp, ...graph.files[fp] })), + hubs, + hotspots, + clusters, + coupling, + }; +} + +// --------------------------------------------------------------------------- +// Format navigation context as compact markdown +// --------------------------------------------------------------------------- + +function shortPath(p) { + const parts = p.split('/'); + return parts.length > 2 ? parts.slice(-2).join('/') : p; +} + +/** + * Format neighborhood as compact markdown for context injection. + */ +export function formatNavContext(neighborhood) { + if (!neighborhood || neighborhood.mentionedFiles.length === 0) return ''; + + const lines = ['## Code Navigation\n']; + + lines.push('**Referenced files:**'); + for (const f of neighborhood.mentionedFiles.slice(0, 10)) { + const hubTag = f.fanIn >= 3 ? `, hub: ${f.fanIn} dependents` : ''; + const imports = (f.imports || []).slice(0, 5).map(shortPath).join(', '); + const importedBy = (f.importedBy || []).slice(0, 5).map(shortPath).join(', '); + let detail = ''; + if (imports) detail += `imports: ${imports}`; + if (importedBy) detail += `${detail ? '; ' : ''}imported by: ${importedBy}`; + lines.push(`- \`${f.path}\` (${f.lines} lines${hubTag})${detail ? ' \u2014 ' + detail : ''}`); + } + lines.push(''); + + const nonMentionedHubs = neighborhood.hubs.filter( + h => !neighborhood.mentionedFiles.some(mf => mf.path === h.path) + ); + if (nonMentionedHubs.length > 0) { + lines.push('**Hubs (read first):**'); + for (const h of nonMentionedHubs) { + const exports = (h.exports || []).slice(0, 5).join(', '); + lines.push(`- \`${h.path}\` \u2014 ${h.fanIn} dependents${exports ? ', exports: ' + exports : ''}`); + } + lines.push(''); + } + + if (neighborhood.clusters.length > 0) { + const clusterStr = neighborhood.clusters.map(c => `${c.label} (${c.size} files)`).join(', '); + let line = `**Cluster:** ${clusterStr}`; + if (neighborhood.coupling && neighborhood.coupling.length > 0) { + const couplingStr = neighborhood.coupling + .slice(0, 3) + .map(c => `${c.from} \u2192 ${c.to} (${c.edges})`) + .join(', '); + line += ` | Cross-dep: ${couplingStr}`; + } + lines.push(line); + lines.push(''); + } + + if (neighborhood.hotspots.length > 0) { + lines.push('**Hotspots (high churn):**'); + for (const h of neighborhood.hotspots) { + lines.push(`- \`${h.path}\` \u2014 ${h.churn} changes, ${h.lines} lines`); + } + lines.push(''); + } + + return lines.join('\n'); +} + +// --------------------------------------------------------------------------- +// CLI entry point +// --------------------------------------------------------------------------- + +async function main() { + try { + const input = readFileSync(0, 'utf-8'); + + let data; + try { + data = JSON.parse(input); + } catch { + return; + } + + const prompt = data.prompt || ''; + if (!prompt) { + return; + } + + const projectDir = process.env.ASPENS_PROJECT_DIR || process.env.CLAUDE_PROJECT_DIR; + if (!projectDir) { + return; + } + + // Step 1: Always load code-map overview (~1ms) + const codeMapPath = join(projectDir, '.claude', 'code-map.md'); + let codeMap = ''; + if (existsSync(codeMapPath)) { + try { + codeMap = readFileSync(codeMapPath, 'utf-8'); + } catch { /* ignore */ } + } + + // If no code-map exists, nothing to do + if (!codeMap) { + return; + } + + // Step 2: Try to enrich with detailed neighborhood (best-effort) + let detailedContext = ''; + let debugInfo = null; + try { + const index = loadGraphIndex(projectDir); + if (index) { + const indexMatches = matchPromptAgainstIndex(prompt, index); + if (indexMatches.length > 0) { + const graph = loadGraphJson(projectDir); + if (graph) { + const filePaths = resolveMatches(indexMatches, graph); + if (filePaths.length > 0) { + const neighborhood = buildNeighborhood(graph, filePaths); + detailedContext = formatNavContext(neighborhood); + debugInfo = { indexMatches, filePaths, neighborhoodSize: neighborhood.mentionedFiles.length + neighborhood.neighbors.length }; + } + } + } + } + } catch { /* matching failed — still emit code-map */ } + + // Debug output + if (process.env.ASPENS_DEBUG === '1' && debugInfo) { + try { + const { writeFileSync: wfs } = await import('fs'); + wfs('/tmp/aspens-debug-graph-context.json', JSON.stringify({ + timestamp: new Date().toISOString(), + projectDir, + prompt: prompt.substring(0, 500), + ...debugInfo, + }, null, 2)); + } catch { /* ignore */ } + } + + // Emit: always code-map, optionally detailed neighborhood + let output = '\n'; + output += codeMap; + if (detailedContext) output += '\n' + detailedContext; + output += '\n'; + process.stdout.write(output); + + if (detailedContext) { + process.stderr.write(`[Graph] Code map + ${debugInfo.filePaths.length} matched files\n`); + } else { + process.stderr.write('[Graph] Code map loaded\n'); + } + + return; + } catch (err) { + // NEVER block the user's prompt + process.stderr.write(`[Graph] Error: ${err.message}\n`); + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const timer = setTimeout(() => { + process.stderr.write('[Graph] Timeout after 5s\n'); + process.exitCode = 0; + }, 5000); + main().finally(() => clearTimeout(timer)); +} diff --git a/.claude/hooks/graph-context-prompt.sh b/.claude/hooks/graph-context-prompt.sh old mode 100644 new mode 100755 diff --git a/.claude/hooks/post-tool-use-tracker.sh b/.claude/hooks/post-tool-use-tracker.sh index db4c169..bb83a42 100755 --- a/.claude/hooks/post-tool-use-tracker.sh +++ b/.claude/hooks/post-tool-use-tracker.sh @@ -12,8 +12,22 @@ if ! command -v jq &> /dev/null; then exit 0 fi -# Exit early if CLAUDE_PROJECT_DIR is not set -if [[ -z "$CLAUDE_PROJECT_DIR" ]]; then +get_script_dir() { + local source="${BASH_SOURCE[0]}" + while [ -h "$source" ]; do + local dir + dir="$(cd -P "$(dirname "$source")" && pwd)" || return 1 + source="$(readlink "$source")" + [[ $source != /* ]] && source="$dir/$source" + done + cd -P "$(dirname "$source")" && pwd +} + +SCRIPT_DIR="$(get_script_dir)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" + +# Exit early if neither CLAUDE_PROJECT_DIR nor PROJECT_DIR is set +if [[ -z "$CLAUDE_PROJECT_DIR" ]] && [[ -z "$PROJECT_DIR" ]]; then exit 0 fi @@ -38,13 +52,13 @@ if [[ "$file_path" =~ \.(md|markdown)$ ]]; then fi # Create cache directory in project -cache_dir="$CLAUDE_PROJECT_DIR/.claude/tsc-cache/${session_id:-default}" +cache_dir="$PROJECT_DIR/.claude/tsc-cache/${session_id:-default}" mkdir -p "$cache_dir" # Function to detect repo from file path detect_repo() { local file="$1" - local project_root="$CLAUDE_PROJECT_DIR" + local project_root="$PROJECT_DIR" # Remove project root from path local relative_path="${file#$project_root/}" @@ -100,7 +114,7 @@ detect_repo() { # Function to get build command for repo get_build_command() { local repo="$1" - local project_root="$CLAUDE_PROJECT_DIR" + local project_root="$PROJECT_DIR" # Map special repo names to actual paths local repo_path @@ -142,7 +156,7 @@ get_build_command() { # Function to get TSC command for repo get_tsc_command() { local repo="$1" - local project_root="$CLAUDE_PROJECT_DIR" + local project_root="$PROJECT_DIR" # Map special repo names to actual paths local repo_path @@ -207,29 +221,34 @@ fi # Detect which domain skill should be activated based on file path # 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="" - # Generated by aspens from skill-rules.json filePatterns - if [[ "$file" =~ /customize ]] || [[ "$file" =~ /customize-agents ]]; then - detected_skills="agent-customization" - elif [[ "$file" =~ /runner ]] || [[ "$file" =~ /skill-writer ]] || [[ "$file" =~ /prompts/ ]] || [[ "$file" =~ /tests/ ]]; then - detected_skills="claude-runner" - elif [[ "$file" =~ /doc-sync ]]; then - detected_skills="doc-sync" - elif [[ "$file" =~ /graph-builder ]] || [[ "$file" =~ /graph-builder.test ]]; then - detected_skills="import-graph" - elif [[ "$file" =~ /scanner ]] || [[ "$file" =~ /scan ]] || [[ "$file" =~ /scanner.test ]]; then - detected_skills="repo-scanning" - elif [[ "$file" =~ /doc-init ]] || [[ "$file" =~ /doc-sync ]] || [[ "$file" =~ /customize ]] || [[ "$file" =~ /context-builder ]] || [[ "$file" =~ /runner ]] || [[ "$file" =~ /skill-writer ]] || [[ "$file" =~ /prompts/ ]]; then - detected_skills="skill-generation" - elif [[ "$file" =~ /add ]] || [[ "$file" =~ /customize ]] || [[ "$file" =~ /templates/ ]]; then - detected_skills="template-library" - fi - - echo "$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/ *$//' } +# END detect_skill_domain # Create session file path based on project directory hash get_session_file() { @@ -248,45 +267,25 @@ add_skill_to_session() { return fi - # Create or update session file + # Create or update session file (jq required — checked at script entry) if [[ -f "$session_file" ]]; then - # Check if jq is available - if command -v jq &> /dev/null; then - # Add skill to array, keeping unique values - jq --arg skill "$skill" --arg time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - '.active_skills = ((.active_skills + [$skill]) | unique) | .last_updated = $time' \ - "$session_file" > "${session_file}.tmp" 2>/dev/null && \ - mv "${session_file}.tmp" "$session_file" - else - # Fallback: simple append check without jq - if ! grep -q "\"$skill\"" "$session_file" 2>/dev/null; then - # Read existing skills from file, append new one, rewrite - local existing_skills="" - if [[ -f "$session_file" ]]; then - # Extract skills array content: strip brackets, quotes, whitespace - existing_skills=$(grep -o '"active_skills":\[[^]]*\]' "$session_file" 2>/dev/null | sed 's/"active_skills":\[//;s/\]//;s/"//g;s/ //g') - fi - # Build new skills list - local new_skills="" - if [[ -n "$existing_skills" ]]; then - new_skills="\"$(echo "$existing_skills" | sed 's/,/","/g')\",\"$skill\"" - else - new_skills="\"$skill\"" - fi - echo "{\"repo\":\"$repo\",\"active_skills\":[$new_skills],\"last_updated\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" > "$session_file" - fi - fi + jq --arg skill "$skill" --arg time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '.active_skills = ((.active_skills + [$skill]) | unique) | .last_updated = $time' \ + "$session_file" > "${session_file}.tmp" 2>/dev/null && \ + mv "${session_file}.tmp" "$session_file" else # Create new session file echo "{\"repo\":\"$repo\",\"active_skills\":[\"$skill\"],\"last_updated\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" > "$session_file" fi } -# Track skill domain for session-sticky behavior -skill_domain=$(detect_skill_domain "$file_path") -if [[ -n "$skill_domain" ]]; then - session_file=$(get_session_file "$CLAUDE_PROJECT_DIR") - add_skill_to_session "$skill_domain" "$session_file" "$repo" +# Track skill domain(s) for session-sticky behavior +skill_domains=$(detect_skill_domain "$file_path") +if [[ -n "$skill_domains" ]]; then + session_file=$(get_session_file "$PROJECT_DIR") + for skill in $skill_domains; do + add_skill_to_session "$skill" "$session_file" "$repo" + done fi # Exit cleanly diff --git a/.claude/hooks/save-tokens-precompact.sh b/.claude/hooks/save-tokens-precompact.sh new file mode 100755 index 0000000..d06cc70 --- /dev/null +++ b/.claude/hooks/save-tokens-precompact.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +get_script_dir() { + local source="${BASH_SOURCE[0]}" + while [ -h "$source" ]; do + local dir + dir="$(cd -P "$(dirname "$source")" && pwd)" || return 1 + source="$(readlink "$source")" + [[ $source != /* ]] && source="$dir/$source" + done + cd -P "$(dirname "$source")" && pwd +} + +SCRIPT_DIR="$(get_script_dir)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" + +ASPENS_PROJECT_DIR="$PROJECT_DIR" NODE_NO_WARNINGS=1 node "$SCRIPT_DIR/save-tokens.mjs" precompact +exit 0 diff --git a/.claude/hooks/save-tokens-prompt-guard.sh b/.claude/hooks/save-tokens-prompt-guard.sh new file mode 100755 index 0000000..f5b294a --- /dev/null +++ b/.claude/hooks/save-tokens-prompt-guard.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +get_script_dir() { + local source="${BASH_SOURCE[0]}" + while [ -h "$source" ]; do + local dir + dir="$(cd -P "$(dirname "$source")" && pwd)" || return 1 + source="$(readlink "$source")" + [[ $source != /* ]] && source="$dir/$source" + done + cd -P "$(dirname "$source")" && pwd +} + +SCRIPT_DIR="$(get_script_dir)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" + +ASPENS_PROJECT_DIR="$PROJECT_DIR" NODE_NO_WARNINGS=1 node "$SCRIPT_DIR/save-tokens.mjs" prompt-guard +exit 0 diff --git a/.claude/hooks/save-tokens-statusline.sh b/.claude/hooks/save-tokens-statusline.sh new file mode 100755 index 0000000..459645f --- /dev/null +++ b/.claude/hooks/save-tokens-statusline.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +get_script_dir() { + local source="${BASH_SOURCE[0]}" + while [ -h "$source" ]; do + local dir + dir="$(cd -P "$(dirname "$source")" && pwd)" || return 1 + source="$(readlink "$source")" + [[ $source != /* ]] && source="$dir/$source" + done + cd -P "$(dirname "$source")" && pwd +} + +SCRIPT_DIR="$(get_script_dir)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" + +INPUT=$(cat) +printf '%s' "$INPUT" | ASPENS_PROJECT_DIR="$PROJECT_DIR" NODE_NO_WARNINGS=1 node "$SCRIPT_DIR/save-tokens.mjs" statusline diff --git a/.claude/hooks/save-tokens.mjs b/.claude/hooks/save-tokens.mjs new file mode 100644 index 0000000..37ed9aa --- /dev/null +++ b/.claude/hooks/save-tokens.mjs @@ -0,0 +1,332 @@ +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync } from 'fs'; +import { join, resolve, relative } from 'path'; +import { fileURLToPath } from 'url'; + +const DEFAULT_CONFIG = { + enabled: true, + warnAtTokens: 175000, + compactAtTokens: 200000, + saveHandoff: true, + sessionRotation: true, +}; + +export function getProjectDir() { + return process.env.ASPENS_PROJECT_DIR || process.env.CLAUDE_PROJECT_DIR || process.cwd(); +} + +export function loadSaveTokensConfig(projectDir) { + const path = join(projectDir, '.aspens.json'); + if (!existsSync(path)) return DEFAULT_CONFIG; + + try { + const parsed = JSON.parse(readFileSync(path, 'utf8')); + return { + ...DEFAULT_CONFIG, + ...(parsed?.saveTokens || {}), + }; + } catch { + return DEFAULT_CONFIG; + } +} + +export function readHookInput() { + try { + const raw = readFileSync(0, 'utf8'); + return raw ? JSON.parse(raw) : {}; + } catch { + return {}; + } +} + +export function readClaudeContextTelemetry(projectDir, maxAgeMs = 300000) { + const path = join(projectDir, '.aspens', 'sessions', 'claude-context.json'); + if (!existsSync(path)) return null; + + try { + const telemetry = JSON.parse(readFileSync(path, 'utf8')); + if (!telemetry?.recordedAt) return null; + if (Date.now() - Date.parse(telemetry.recordedAt) > maxAgeMs) return null; + if (!Number.isInteger(telemetry.currentContextTokens) || telemetry.currentContextTokens < 0) return null; + return telemetry; + } catch { + return null; + } +} + +export function recordClaudeContextTelemetry(projectDir, input = {}) { + const sessionsDir = join(projectDir, '.aspens', 'sessions'); + mkdirSync(sessionsDir, { recursive: true }); + + const currentUsage = input.context_window?.current_usage || null; + const currentContextTokens = currentUsage + ? sumInputContextTokens(currentUsage) + : 0; + + const telemetry = { + recordedAt: new Date().toISOString(), + sessionId: input.session_id || input.sessionId || null, + transcriptPath: input.transcript_path || input.transcriptPath || null, + contextWindowSize: input.context_window?.context_window_size || null, + usedPercentage: input.context_window?.used_percentage ?? null, + currentContextTokens, + exceeds200kTokens: !!input.exceeds_200k_tokens, + currentUsage, + }; + + writeFileSync(join(sessionsDir, 'claude-context.json'), JSON.stringify(telemetry, null, 2) + '\n', 'utf8'); + return telemetry; +} + +export function sessionTokenSnapshot(projectDir, input = {}) { + const telemetry = readClaudeContextTelemetry(projectDir); + if (telemetry) { + return { + tokens: telemetry.currentContextTokens, + source: 'claude-statusline', + telemetry, + }; + } + + return { + tokens: null, + source: 'missing-claude-statusline', + telemetry: null, + }; +} + +export function saveHandoff(projectDir, input = {}, reason = 'limit') { + const sessionsDir = join(projectDir, '.aspens', 'sessions'); + mkdirSync(sessionsDir, { recursive: true }); + + const now = new Date(); + const stamp = now.toISOString().replace(/[:.]/g, '-'); + const relativePath = join('.aspens', 'sessions', `${stamp}-claude-handoff.md`); + const handoffPath = join(projectDir, relativePath); + const snapshot = sessionTokenSnapshot(projectDir, input); + const tokenCount = Number.isInteger(snapshot.tokens) ? snapshot.tokens : null; + const tokenLabel = tokenCount === null ? 'unknown' : `~${tokenCount.toLocaleString()}`; + const prompt = extractPrompt(input); + const transcriptExcerpt = readTranscriptExcerpt(input, projectDir); + + const lines = [ + '# Claude save-tokens handoff', + '', + `- Saved: ${now.toISOString()}`, + `- Reason: ${reason}`, + `- Session tokens: ${tokenLabel} (${snapshot.source})`, + ]; + + if (input.cwd) { + lines.push(`- Working directory: ${input.cwd}`); + } + + lines.push(''); + + if (prompt) { + lines.push('## Latest prompt (quoted user input)'); + lines.push(''); + lines.push('```text'); + lines.push(prompt); + lines.push('```'); + lines.push(''); + } + + if (transcriptExcerpt) { + lines.push('## Recent transcript excerpt'); + lines.push(''); + lines.push('```text'); + lines.push(transcriptExcerpt); + lines.push('```'); + lines.push(''); + } + + writeFileSync(handoffPath, lines.join('\n'), 'utf8'); + writeLatestIndex(projectDir, relativePath, now.toISOString(), reason, tokenCount); + pruneOldHandoffs(projectDir); + return relativePath; +} + +export function latestHandoff(projectDir) { + const sessionsDir = join(projectDir, '.aspens', 'sessions'); + if (!existsSync(sessionsDir)) return null; + + const entries = readdirSync(sessionsDir) + .filter(name => name.endsWith('-handoff.md')) + .sort() + .reverse(); + + return entries[0] ? join('.aspens', 'sessions', entries[0]) : null; +} + +const MAX_HANDOFFS = 10; + +export function pruneOldHandoffs(projectDir, keep = MAX_HANDOFFS) { + const sessionsDir = join(projectDir, '.aspens', 'sessions'); + if (!existsSync(sessionsDir)) return; + + const handoffs = readdirSync(sessionsDir) + .filter(name => name.endsWith('-handoff.md')) + .sort() + .reverse(); + + for (const name of handoffs.slice(keep)) { + try { unlinkSync(join(sessionsDir, name)); } catch { /* ignore */ } + } +} + +export function runStatusline() { + const input = readHookInput(); + const projectDir = getProjectDir(); + const config = loadSaveTokensConfig(projectDir); + + if (!config.enabled) return; + + const telemetry = recordClaudeContextTelemetry(projectDir, input); + + if (telemetry.currentContextTokens > 0) { + process.stdout.write(`save-tokens ${formatTokens(telemetry.currentContextTokens)}/${formatTokens(config.compactAtTokens)}`); + } +} + +export function runPromptGuard() { + const input = readHookInput(); + const projectDir = getProjectDir(); + const config = loadSaveTokensConfig(projectDir); + + if (!config.enabled || config.claude?.enabled === false) { + return 0; + } + if (config.warnAtTokens === Number.MAX_SAFE_INTEGER && config.compactAtTokens === Number.MAX_SAFE_INTEGER) { + return 0; + } + + const snapshot = sessionTokenSnapshot(projectDir, input); + const currentTokens = snapshot.tokens; + + if (!Number.isInteger(currentTokens)) { + // stdout → injected into Claude's context as a system message + console.log( + 'save-tokens: Claude token telemetry is unavailable. ' + + 'Open an issue if this persists: https://github.com/aspenkit/aspens/issues' + ); + return 0; + } + + if (currentTokens >= config.compactAtTokens) { + const handoffPath = config.saveHandoff + ? saveHandoff(projectDir, input, config.sessionRotation ? 'rotation-threshold' : 'compact-threshold') + : null; + + const lines = [ + `save-tokens: current context is ${formatTokens(currentTokens)}/${formatTokens(config.compactAtTokens)}.`, + ]; + if (handoffPath) { + lines.push(`Handoff saved: ${handoffPath}`); + } + lines.push(''); + lines.push('IMPORTANT — you must tell the user:'); + lines.push('1. Run /save-handoff to save a rich handoff summary'); + lines.push('2. Start a fresh Claude session'); + lines.push('3. Run /resume-handoff-latest to continue'); + lines.push(''); + lines.push('Alternative: continue here, or run /compact to compact this session.'); + + // stdout → injected into Claude's context as a system message + console.log(lines.join('\n')); + return 0; + } + + if (currentTokens >= config.warnAtTokens) { + // stdout → injected into Claude's context as a system message + console.log( + `save-tokens: current context is ${formatTokens(currentTokens)}/${formatTokens(config.compactAtTokens)}. ` + + 'Tell the user to consider running /save-handoff soon.' + ); + } + + return 0; +} + +export function runPrecompact() { + const input = readHookInput(); + const projectDir = getProjectDir(); + const config = loadSaveTokensConfig(projectDir); + + if (!config.enabled || config.claude?.enabled === false || !config.saveHandoff) { + return 0; + } + + const handoffPath = saveHandoff(projectDir, input, 'precompact'); + console.log(`save-tokens: handoff saved before compact to ${handoffPath}.`); + return 0; +} + +function writeLatestIndex(projectDir, relativePath, savedAt, reason, tokens) { + const indexPath = join(projectDir, '.aspens', 'sessions', 'index.json'); + const payload = { + latest: relativePath, + savedAt, + reason, + tokens, + }; + writeFileSync(indexPath, JSON.stringify(payload, null, 2) + '\n', 'utf8'); +} + +function extractPrompt(input) { + return input.prompt || input.user_prompt || input.message || ''; +} + +function readTranscriptExcerpt(input, projectDir) { + const transcriptPath = input.transcript_path || input.transcriptPath || ''; + if (!transcriptPath) return ''; + + const safeTranscriptPath = safeProjectFilePath(projectDir, transcriptPath); + if (!safeTranscriptPath || !existsSync(safeTranscriptPath)) return ''; + + try { + const content = readFileSync(safeTranscriptPath, 'utf8'); + return content.slice(-4000); + } catch { + return ''; + } +} + +function safeProjectFilePath(projectDir, filePath) { + const projectRoot = resolve(projectDir); + const candidate = resolve(filePath); + const rel = relative(projectRoot, candidate); + if (rel === '' || (!rel.startsWith('..') && !rel.startsWith('/') && !rel.includes('..\\'))) { + return candidate; + } + return null; +} + +function sumInputContextTokens(currentUsage) { + return [ + currentUsage.input_tokens, + currentUsage.cache_creation_input_tokens, + currentUsage.cache_read_input_tokens, + currentUsage.output_tokens, + ].reduce((sum, value) => sum + (Number.isFinite(value) ? value : 0), 0); +} + +function formatTokens(value) { + if (value >= 1000) return `${Math.round(value / 1000)}k`; + return String(value); +} + +function main() { + const command = process.argv[2]; + if (command === 'statusline') { + runStatusline(); + return process.exit(0); + } + if (command === 'prompt-guard') return process.exit(runPromptGuard()); + if (command === 'precompact') return process.exit(runPrecompact()); + console.error('save-tokens: expected command: statusline, prompt-guard, or precompact'); + return process.exit(1); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main(); +} diff --git a/.claude/hooks/skill-activation-prompt.mjs b/.claude/hooks/skill-activation-prompt.mjs index 55e6e53..221469e 100644 --- a/.claude/hooks/skill-activation-prompt.mjs +++ b/.claude/hooks/skill-activation-prompt.mjs @@ -104,7 +104,8 @@ export function getSessionActiveSkills(projectDir, currentRepo) { if (currentRepo && session.repo && session.repo !== currentRepo) { return []; } - return session.active_skills || []; + const skills = session.active_skills || []; + return skills.filter(s => typeof s === 'string'); } } catch { // Session file doesn't exist or is invalid — that's fine @@ -169,8 +170,8 @@ export function matchSkills(prompt, rules, currentRepo, sessionSkills) { continue; } - // AUTO-ACTIVATE: alwaysActivate + scope matches (exact repo OR "all") - if (config.alwaysActivate && (config.scope === currentRepo || config.scope === 'all')) { + // AUTO-ACTIVATE: alwaysActivate (scope already filtered above) + if (config.alwaysActivate) { matched.push({ name: skillName, matchType: 'auto', config }); addedSkills.add(skillName); continue; @@ -224,7 +225,7 @@ export function matchSkills(prompt, rules, currentRepo, sessionSkills) { * @param {string} projectDir - Absolute path to the project root * @returns {string} Formatted output for stdout */ -export function formatOutput(matched, currentRepo, projectDir) { +export function formatOutput(matched, currentRepo) { if (matched.length === 0) { return ''; } @@ -287,25 +288,25 @@ async function main() { data = JSON.parse(input); } catch { // Invalid JSON — exit silently - process.exit(0); + return; } const prompt = data.prompt || ''; if (!prompt) { - process.exit(0); + return; } // Determine project directory - const projectDir = process.env.CLAUDE_PROJECT_DIR; + const projectDir = process.env.ASPENS_PROJECT_DIR || process.env.CLAUDE_PROJECT_DIR; if (!projectDir) { - process.exit(0); + return; } // Load skill rules const rulesPath = join(projectDir, '.claude', 'skills', 'skill-rules.json'); if (!existsSync(rulesPath)) { // No skill rules file — exit silently - process.exit(0); + return; } let rules; @@ -313,11 +314,11 @@ async function main() { rules = JSON.parse(readFileSync(rulesPath, 'utf-8')); } catch { // Invalid rules file — exit silently - process.exit(0); + return; } if (!rules.skills || typeof rules.skills !== 'object') { - process.exit(0); + return; } // Detect current repository @@ -363,7 +364,7 @@ async function main() { // Format and emit output if (matched.length > 0) { - const output = formatOutput(matched, currentRepo, projectDir); + const output = formatOutput(matched, currentRepo); // stderr: terminal status line const highPriority = matched.filter( @@ -378,11 +379,10 @@ async function main() { } } - process.exit(0); + return; } catch (err) { // NEVER block the user's prompt — log and exit cleanly process.stderr.write(`[Skills] Error: ${err.message}\n`); - process.exit(0); } } diff --git a/.claude/settings.json b/.claude/settings.json index 0ef7711..e9307a1 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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/base/skill.md b/.claude/skills/base/skill.md index bf3e0a6..dca6545 100644 --- a/.claude/skills/base/skill.md +++ b/.claude/skills/base/skill.md @@ -9,7 +9,7 @@ This is a **base skill** that always loads when working in this repository. --- -You are working in **aspens** — a CLI tool that generates and maintains AI-ready documentation (skill files + CLAUDE.md) for any codebase. Supports multiple output targets (Claude Code, Codex CLI). +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 @@ -18,11 +18,13 @@ Node.js (ESM) | Commander | Vitest | es-module-lexer | @clack/prompts | picocolo - `npm test` — Run vitest suite - `npm start` / `node bin/cli.js` — Run CLI - `aspens scan [path]` — Deterministic repo analysis (no LLM) -- `aspens doc init [path]` — Generate skills + hooks + CLAUDE.md (supports `--target claude|codex|all`, `--backend claude|codex`) +- `aspens doc init [path]` — Generate skills + hooks + CLAUDE.md (`--target claude|codex|all`, `--recommended` for full recommended setup) +- `aspens doc impact [path]` — Show freshness, coverage, and drift of generated context (`--apply` for auto-repair, `--backend`/`--model`/`--timeout`/`--verbose` for LLM interpretation) - `aspens doc sync [path]` — Incremental skill updates from git diffs - `aspens doc graph [path]` — Rebuild import graph cache (`.claude/graph.json`) - `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) ## Architecture CLI entry (`bin/cli.js`) → command handlers (`src/commands/`) → lib modules (`src/lib/`) @@ -35,15 +37,17 @@ CLI entry (`bin/cli.js`) → command handlers (`src/commands/`) → lib modules - `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/diff-helpers.js` — Targeted file diffs and prioritized diff truncation for doc-sync -- `src/lib/git-helpers.js` — Git repo detection, diff retrieval, log formatting -- `src/lib/git-hook.js` — Post-commit git hook installation/removal for auto 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) +- `src/lib/impact.js` — Context health analysis: domain coverage, hub surfacing, drift detection, hook health, save-tokens health, usefulness summary, value comparison, opportunities +- `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`) +- `src/lib/target.js` — Target definitions (claude/codex), config persistence (`.aspens.json`) with `saveTokens` feature config - `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/prompts/` — Prompt templates with `{{partial}}` and `{{variable}}` substitution -- `src/templates/` — Bundled agents, commands, hooks, and settings for `aspens add` / `doc init` +- `src/templates/` — Bundled agents, commands, hooks, and settings for `aspens add` / `doc init` / `save-tokens` ## Critical Conventions - **Pure ESM** — `"type": "module"` throughout; use `import`/`export`, never `require()` @@ -55,14 +59,15 @@ CLI entry (`bin/cli.js`) → command handlers (`src/commands/`) → lib modules - **Target/Backend distinction** — Target = output format/location; Backend = which LLM CLI generates content. Config persisted in `.aspens.json` - **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 ## Structure - `bin/` — CLI entry point (commander setup, CliError handler) -- `src/commands/` — Command handlers (scan, doc-init, doc-sync, doc-graph, add, customize) +- `src/commands/` — Command handlers (scan, doc-init, doc-impact, doc-sync, doc-graph, add, customize, save-tokens) - `src/lib/` — Core library modules - `src/prompts/` — Prompt templates + partials - `src/templates/` — Installable agents, commands, hooks, settings - `tests/` — Vitest test files --- -**Last Updated:** 2026-04-02 +**Last Updated:** 2026-04-09 diff --git a/.claude/skills/claude-runner/skill.md b/.claude/skills/claude-runner/skill.md index fcb0a67..9f89c6e 100644 --- a/.claude/skills/claude-runner/skill.md +++ b/.claude/skills/claude-runner/skill.md @@ -18,7 +18,7 @@ This skill triggers when editing claude-runner files: 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()` (internal) +- `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 @@ -26,7 +26,8 @@ You are working on the **CLI execution layer** — the bridge between assembled ## 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 --ask-for-approval never --ephemeral`. 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. +- **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. +- **Codex capability detection:** `getCodexExecCapabilities()` (internal, cached) runs `codex exec --help` and checks if `--ask-for-approval` appears in the help text. Result is cached in module-level `codexExecCapabilities` variable. If the help check fails (e.g., codex not installed), capabilities default to `{ supportsAskForApproval: false }`. `runCodex()` only adds `--ask-for-approval never` when `supportsAskForApproval` is true. - **Unified routing:** `runLLM(prompt, options, backendId)` is the shared entry point — dispatches to `runClaude()` or `runCodex()` based on `backendId`. Exported from `runner.js` so command handlers no longer need local routing helpers. - **Codex internals (private):** `normalizeCodexItemType()` converts PascalCase/kebab-case to snake_case. `collectCodexText()` recursively extracts text from nested event content. Both are internal to runner.js. - **Prompt templating:** `loadPrompt(name, vars)` resolves `{{partial-name}}` from `src/prompts/partials/` first, then substitutes `{{varName}}` from `vars`. Target-specific vars (`skillsDir`, `skillFilename`, `instructionsFile`, `configDir`) are passed by command handlers. @@ -35,19 +36,19 @@ 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. -- **Settings merge:** `mergeSettings()` merges aspens hook config into existing `settings.json`, detecting aspens-managed hooks by `ASPENS_HOOK_MARKERS` (`skill-activation-prompt`, `post-tool-use-tracker`). +- **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. ## Critical Rules - **Both `--verbose` and `--output-format stream-json` are required for Claude** — omitting either breaks stream parsing. -- **Codex uses `--json --sandbox read-only --ask-for-approval never --ephemeral`** — `--sandbox read-only` restricts filesystem access, `--ask-for-approval never` skips prompts, `--ephemeral` avoids persisting conversation. Prompt goes via stdin, not as a CLI arg. +- **Codex uses `--json --sandbox read-only --ephemeral`** — `--sandbox read-only` restricts filesystem access, `--ephemeral` avoids persisting conversation. `--ask-for-approval never` is added only if `getCodexExecCapabilities()` confirms support. Prompt goes via stdin, not as a CLI arg. - **Codex stdin write order matters** — event handlers (`stdout`, `stderr`, `close`, `error`) must be attached before writing to stdin, so fast failures are captured. - **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. -- **`mergeSettings` preserves non-aspens hooks** — identifies aspens hooks by `ASPENS_HOOK_MARKERS`, replaces matching entries, preserves everything else. +- **`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-07 +**Last Updated:** 2026-04-10 diff --git a/.claude/skills/codex-support/skill.md b/.claude/skills/codex-support/skill.md index a408480..0c19f40 100644 --- a/.claude/skills/codex-support/skill.md +++ b/.claude/skills/codex-support/skill.md @@ -20,8 +20,8 @@ Keywords: codex, target, backend, AGENTS.md, directory-scoped, transform, multi- 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()`, path helpers, config persistence (`.aspens.json`) -- `src/lib/target-transform.js` — Transforms Claude-format output to other target formats; `projectCodexDomainDocs()`, `validateTransformedFiles()`, content sanitization +- `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 @@ -30,23 +30,27 @@ You are working on **multi-target output support** — the system that lets aspe - **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. - **Content sanitization:** `sanitizeCodexInstructions()` and `sanitizeCodexSkill()` strip Claude-specific references (hooks, skill-rules.json, Claude Code mentions) from Codex output. -- **`getAllowedPaths(targets)`** — Returns `{ dirPrefixes, exactFiles }` union across all active targets. Dir prefixes use **full** target paths (e.g., `.agents/skills/`, not `.agents/`), providing tighter path validation. +- **`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 }`. `readConfig()` returns `null` if missing **or if the config is structurally invalid** — callers default to `'claude'` target. Validation via internal `isValidConfig()` ensures `targets` is a non-empty array of known target keys, `backend` (if present) is a known target key, and `version` (if present) is a string. -- **Multi-target publish:** `doc-sync` uses `publishFilesForTargets()` to generate output for all configured targets from a single LLM run — source target files kept as-is, other targets get transforms applied. -- **Codex inference tightened:** `inferConfig()` only adds `'codex'` to inferred targets when `.codex/` config dir or `.agents/skills/` dir exists — a standalone `AGENTS.md` without either is not sufficient. -- **Conditional architecture ref:** Codex `buildCodexSkillRefs()` only includes the architecture skill reference when a graph was actually serialized (`hasGraph` parameter). +- **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()`). +- **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. +- **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. ## 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 (`.claude/`, `.agents/`, `CLAUDE.md`, root `AGENTS.md`). `writeTransformedFiles()` handles directory-scoped `AGENTS.md` (e.g., `src/billing/AGENTS.md`) with an explicit path allowlist and warn-and-skip policy. -- **Path safety:** `validateTransformedFiles()` in `target-transform.js` rejects absolute paths, traversal, and unexpected filenames. `writeTransformedFiles()` enforces the same checks plus an allowlist (`CLAUDE.md`/`AGENTS.md` exact, `.claude/`/`.agents/`/`.codex/` prefixes). +- **Split write logic:** `writeSkillFiles()` handles direct-write files. `writeTransformedFiles()` handles directory-scoped `AGENTS.md` with an explicit path allowlist and warn-and-skip policy. +- **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`) as invalid and returns `null`, same as missing config. +- **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. ## References - **Patterns:** See `src/lib/target.js` for all target property definitions --- -**Last Updated:** 2026-04-07 +**Last Updated:** 2026-04-09 diff --git a/.claude/skills/doc-impact/skill.md b/.claude/skills/doc-impact/skill.md new file mode 100644 index 0000000..47a5fca --- /dev/null +++ b/.claude/skills/doc-impact/skill.md @@ -0,0 +1,60 @@ +--- +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 + +--- + +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()` + +## 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. + +## 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`. + +## References +- **Patterns:** `src/lib/skill-reader.js` — `findSkillFiles()` used for skill discovery per target +- **Prompt:** `src/prompts/impact-analyze.md` + +--- +**Last Updated:** 2026-04-09 diff --git a/.claude/skills/doc-sync/skill.md b/.claude/skills/doc-sync/skill.md index 91096f1..6ab1b4f 100644 --- a/.claude/skills/doc-sync/skill.md +++ b/.claude/skills/doc-sync/skill.md @@ -23,18 +23,19 @@ You are working on **doc-sync**, the incremental skill update command (`aspens d - `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` — `isGitRepo()`, `getGitDiff()`, `getGitLog()`, `getChangedFiles()` — git primitives +- `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 +- `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` is passed through to control conditional architecture references. - **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` and `git log`, feeds them plus existing skill contents and graph context to the selected backend. +- **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. - **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. - **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. @@ -46,7 +47,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:** `installGitHook()` creates a `post-commit` hook with 5-minute cooldown lock file. Hook skips aspens-only commits (filters `.claude/`, `.codex/`, `.agents/`, `CLAUDE.md`, `AGENTS.md`, `.aspens.json`). Works for all configured targets. +- **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. - **Force writes:** doc-sync always calls `writeSkillFiles` with `force: true`. ## Critical Rules @@ -57,9 +58,10 @@ You are working on **doc-sync**, the incremental skill update command (`aspens d - The command exits early with `CliError` if the source target's skills directory doesn't exist. - `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`. ## References - **Patterns:** `src/lib/skill-reader.js` — `GENERIC_PATH_SEGMENTS`, `fileMatchesActivation()`, `getActivationBlock()` --- -**Last Updated:** 2026-04-07 +**Last Updated:** 2026-04-08 diff --git a/.claude/skills/save-tokens/skill.md b/.claude/skills/save-tokens/skill.md new file mode 100644 index 0000000..2bd8e88 --- /dev/null +++ b/.claude/skills/save-tokens/skill.md @@ -0,0 +1,62 @@ +--- +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 + +--- + +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`. + +## 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`. +- **`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. + +## References +- **Impact integration:** `src/lib/impact.js` — `evaluateSaveTokensHealth()` validates installed state + +--- +**Last Updated:** 2026-04-10 diff --git a/.claude/skills/skill-generation/skill.md b/.claude/skills/skill-generation/skill.md index 32288e9..4a09628 100644 --- a/.claude/skills/skill-generation/skill.md +++ b/.claude/skills/skill-generation/skill.md @@ -14,52 +14,51 @@ This skill triggers when editing skill-generation files: - `src/lib/timeout.js` - `src/prompts/**/*` -Keywords: doc-init, generate skills, discovery agents, chunked generation +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 → config +- `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) -- `src/lib/git-hook.js` — `installGitHook()` / `removeGitHook()` for post-commit auto-sync +- `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()` +- `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()` converts Claude output to other target formats +- `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` ## 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) persist config to `.aspens.json` -- **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. -- **Canonical generation:** All prompts receive `CANONICAL_VARS` (hardcoded Claude paths: `.claude/skills/`, `skill.md`, `CLAUDE.md`). Generation always produces Claude-canonical format regardless of target. Non-Claude targets are produced by post-generation transform. +- **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. +- **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. +- **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 (e.g., existing Claude docs → generate Codex output). -- **Domain reuse helpers:** `loadReusableDomains()` tries `loadReusableDomainsFromRules()` (reads `skill-rules.json` from source target, falls back to `.claude/skills/` for non-Claude targets) first. Falls back to `findSkillFiles()` with `extractKeyFilePatterns()` to derive file patterns from `## Key Files` sections when activation patterns are missing. -- **Target selection:** `--target claude|codex|all` or interactive multiselect if both CLIs available. Stored in `.aspens.json`. -- **Backend routing:** `runLLM()` imported from `runner.js` dispatches to `runClaude()` or `runCodex()` based on `_backendId`. `--backend` flag overrides auto-detection. -- **Content transform (step 11):** Canonical files preserved as originals. Non-Claude targets get `transformForTarget()` applied. If Claude not in target list, canonical files are filtered out of final output. -- **Split writes:** Direct-write files (`.claude/`, `.agents/`, `CLAUDE.md`, root `AGENTS.md`) use `writeSkillFiles()`. Directory-scoped files (e.g., `src/billing/AGENTS.md`) use `writeTransformedFiles()` with warn-and-skip policy. -- **Dynamic labels:** `baseArtifactLabel()` and `instructionsArtifactLabel()` return target-appropriate names ("base skill" vs "root AGENTS.md") for spinner messages. -- **Parallel discovery:** Two agents run via `Promise.all` — domain discovery and architecture analysis — before any user prompt. -- **Generation modes:** `all-at-once` = single call; `chunked` = base + per-domain (up to 3 parallel) + instructions file; `base-only` = just base skill; `pick` = interactive domain picker -- **Retry logic:** Base skill and instructions file retry up to 2 times if `parseLLMOutput` returns empty (format correction prompt asking for `` 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()`. +- **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. ## 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. Discovery can be skipped if existing docs are found and user opts to reuse. +- **Discovery runs before user prompt** — domain picker shows discovered domains, not scanner directory names. - **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. ## References - **Prompts:** `src/prompts/doc-init*.md`, `src/prompts/discover-*.md` - **Partials:** `src/prompts/partials/skill-format.md`, `src/prompts/partials/examples.md` --- -**Last Updated:** 2026-04-07 +**Last Updated:** 2026-04-10 diff --git a/.claude/skills/skill-rules.json b/.claude/skills/skill-rules.json index 1aa67bd..e71a98a 100644 --- a/.claude/skills/skill-rules.json +++ b/.claude/skills/skill-rules.json @@ -130,6 +130,53 @@ ] } }, + "doc-impact": { + "type": "domain", + "enforcement": "suggest", + "priority": "high", + "scope": "all", + "alwaysActivate": false, + "filePatterns": [ + "src/commands/doc-impact.js", + "src/lib/impact.js", + "src/prompts/impact-analyze.md", + "tests/impact.test.js", + "tests/doc-impact.test.js" + ], + "promptTriggers": { + "keywords": [ + "impact", + "freshness", + "coverage", + "drift", + "health score", + "context health", + "hook health", + "usefulness", + "doc", + "doc impact", + "context", + "health", + "analysis", + "domain", + "commands", + "doc-impact", + "prompts", + "impact-analyze", + "tests", + "impact.test", + "doc-impact.test" + ], + "intentPatterns": [ + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*health score", + "health score.*(create|update|fix|add|modify|change|debug|refactor|implement|build)", + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*context health", + "context health.*(create|update|fix|add|modify|change|debug|refactor|implement|build)", + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*hook health", + "hook health.*(create|update|fix|add|modify|change|debug|refactor|implement|build)" + ] + } + }, "doc-sync": { "type": "domain", "enforcement": "suggest", @@ -260,6 +307,55 @@ ] } }, + "save-tokens": { + "type": "domain", + "enforcement": "suggest", + "priority": "high", + "scope": "all", + "alwaysActivate": false, + "filePatterns": [ + "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" + ], + "promptTriggers": { + "keywords": [ + "save-tokens", + "handoff", + "statusline", + "prompt-guard", + "precompact", + "session rotation", + "token warning", + "save", + "tokens", + "save tokens", + "token-saving", + "session", + "automation", + "prompt", + "commands", + "templates", + "hooks", + "save-handoff", + "resume-handoff", + "tests", + "save-tokens.test" + ], + "intentPatterns": [ + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*session rotation", + "session rotation.*(create|update|fix|add|modify|change|debug|refactor|implement|build)", + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*token warning", + "token warning.*(create|update|fix|add|modify|change|debug|refactor|implement|build)", + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*save tokens", + "save tokens.*(create|update|fix|add|modify|change|debug|refactor|implement|build)" + ] + } + }, "skill-generation": { "type": "domain", "enforcement": "suggest", @@ -281,6 +377,7 @@ "generate skills", "discovery agents", "chunked generation", + "recommended", "skill", "generation", "skill generation", diff --git a/.claude/skills/template-library/skill.md b/.claude/skills/template-library/skill.md index 69ef9dc..83c28c8 100644 --- a/.claude/skills/template-library/skill.md +++ b/.claude/skills/template-library/skill.md @@ -1,6 +1,6 @@ --- name: template-library -description: Bundled agents, commands, hooks, and settings that users install via `aspens add` and `aspens doc init` into their .claude/ directories +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 @@ -18,33 +18,35 @@ You are working on the **template library** — bundled agents, slash commands, ## 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 (2 bundled) -- `src/templates/hooks/` — Hook scripts (5 bundled): `skill-activation-prompt.sh/mjs`, `graph-context-prompt.sh/mjs`, `post-tool-use-tracker.sh` -- `src/templates/settings/settings.json` — Default settings with hook configuration +- `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 ## 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). -- **Codex-only restriction:** `add agent`, `add command`, and `add hook` throw `CliError` for Codex-only repos (checked via `readConfig()`). Skills work with both targets — `add skill` is always available. -- **Target-aware skill commands:** `addSkillCommand` and `generateSkillFromDoc` resolve the active target via `resolveSkillTarget(config)`. Skill paths use `target.skillsDir` and `target.skillFilename` (not hardcoded `.claude/skills/skill.md`). -- **Backend-aware generation:** `generateSkillFromDoc` uses `runLLM()` imported from `runner.js` to dispatch to Claude or Codex based on config. `getAllowedPaths([target])` provides path safety for `parseFileOutput`. -- **Skill subcommand:** `aspens add skill ` scaffolds a blank skill template. `--from ` generates a skill from a reference doc using the configured backend. `--list` shows installed skills. -- **Hook templates:** `skill-activation-prompt` reads `skill-rules.json` and injects relevant skills into prompts. `graph-context-prompt` loads graph data for code navigation. `post-tool-use-tracker` detects skill domains from file access patterns. -- **`doc init` hook installation (step 13):** Generates `skill-rules.json` from skills, copies hook files, generates `post-tool-use-tracker.sh` with domain patterns (via `BEGIN/END` markers), merges `settings.json` with backup. +- **Save-tokens templates:** `save-tokens.mjs` is the runtime entry point for all three hook entry points (statusline, prompt-guard, precompact). Shell wrappers (`save-tokens-*.sh`) resolve project dir and delegate to the `.mjs` file. Slash commands (`save-handoff.md`, `resume-handoff.md`, `resume-handoff-latest.md`) provide user-invokable handoff management. These are installed by `aspens save-tokens` or `aspens doc init --recommended`, not by `aspens add`. +- **Codex-only restriction:** `add agent`, `add command`, and `add hook` throw `CliError` for Codex-only repos. Skills work with both targets. +- **Target-aware skill commands:** `addSkillCommand` and `generateSkillFromDoc` resolve the active target via `resolveSkillTarget(config)`. Skill paths use `target.skillsDir` and `target.skillFilename`. +- **Backend-aware generation:** `generateSkillFromDoc` uses `runLLM()` to dispatch to Claude or Codex based on config. +- **Hook templates (monorepo-aware):** Shell hooks compute `PROJECT_DIR` from the script's own location (`cd "$SCRIPT_DIR/../.." && pwd`) and pass it as `ASPENS_PROJECT_DIR` to `.mjs` counterparts. Save-tokens shell hooks follow the same pattern. +- **Settings template quoting:** Hook commands in `settings.json` are wrapped in double quotes for shell safety. +- **`doc init` hook installation (step 13):** Generates `skill-rules.json`, copies hook files, generates `post-tool-use-tracker.sh` with domain patterns, merges `settings.json` with backup. +- **`doc init --recommended` extras (step 15):** Copies all bundled agent templates to `.claude/agents/` (skips existing), adds `dev/` to `.gitignore`. - **Template discovery:** `listAvailable()` reads template dir, filters `.md`/`.sh` files, regex-parses `name:` and `description:`. -- **No-overwrite policy:** `addResource()` skips files that already exist via `existsSync` check. Same for `addSkillCommand`. -- **Plan/execute gitignore:** Adding `plan` or `execute` agents auto-adds `dev/` to `.gitignore` for plan storage. -- **Conditional post-add tips:** Skill rules update and `--hooks-only` tip only shown for Claude target. Codex target gets no hook-related messaging. +- **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`. ## Critical Rules - Template files **must** contain `name: ` and `description: ` lines parseable by regex. -- Only `.md` and `.sh` extensions are discovered by `listAvailable()`. `.mjs` files are copied by `doc init` directly, not by `add`. +- Only `.md` and `.sh` extensions are discovered by `listAvailable()`. `.mjs` files are copied by `doc init` and `save-tokens` directly, not by `add`. - 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()`. ## References - **Customize flow:** `.claude/skills/agent-customization/skill.md` +- **Save-tokens install:** `.claude/skills/save-tokens/skill.md` --- -**Last Updated:** 2026-04-07 +**Last Updated:** 2026-04-09 diff --git a/.gitignore b/.gitignore index 64bcd5e..d6b0dc8 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ dist/ .claude/graph.json .claude/graph-index.json .claude/code-map.md +dev/ diff --git a/AGENTS.md b/AGENTS.md index d368bf0..0eec347 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,23 +1,20 @@ -## Release - -- Release workflow: `/Users/MV/aspenkit/dev/release.md` - ## Key Files **Hub files (most depended-on):** -- `src/lib/runner.js` - 8 dependents +- `src/lib/runner.js` - 9 dependents +- `src/lib/target.js` - 9 dependents +- `src/lib/scanner.js` - 8 dependents - `src/lib/errors.js` - 7 dependents -- `src/lib/scanner.js` - 7 dependents -- `src/lib/target.js` - 7 dependents -- `src/lib/skill-writer.js` - 6 dependents +- `src/lib/skill-writer.js` - 7 dependents **Domain clusters:** | Domain | Files | Top entries | |--------|-------|-------------| -| src | 37 | `src/lib/runner.js`, `src/commands/doc-init.js`, `src/commands/doc-sync.js` | +| src | 44 | `src/commands/doc-init.js`, `src/lib/runner.js`, `src/lib/target.js` | **High-churn hotspots:** -- `src/commands/doc-init.js` - 27 changes -- `src/commands/doc-sync.js` - 19 changes -- `src/lib/runner.js` - 16 changes +- `src/commands/doc-init.js` - 33 changes +- `src/commands/doc-sync.js` - 20 changes +- `src/lib/runner.js` - 17 changes + diff --git a/CHANGELOG.md b/CHANGELOG.md index b30d449..b793732 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ ## [Unreleased] +## [0.7.0] - 2026-04-10 + +### Added +- **`aspens doc impact`** — new context-health command that reports freshness, coverage, drift, hook status, save-tokens health, and recommended repair actions +- **Save-tokens setup** — installable session-optimization settings, prompt guards, precompact handoffs, statusline telemetry, and handoff commands for supported environments +- **Bundled agents and commands** — expanded agent library plus handoff/resume command templates for installed Claude workflows + +### Changed +- **Chunked generation durability** — `doc init --mode chunked` now asks for write approval up front and writes generated files incrementally so successful chunks survive later failures +- **Target persistence** — `doc init` now saves the selected target/backend earlier so an interrupted run still updates `.aspens.json` +- **README positioning** — tightened the README around stale agent context, scoped skills, and the one-command setup flow +- **Package metadata** — npm description and upgrade messaging now align with the current product focus on keeping coding-agent context accurate + +### Fixed +- **Codex CLI compatibility** — `runCodex()` now detects whether the installed Codex CLI supports `--ask-for-approval` instead of assuming an older flag contract +- **Subdirectory monorepo support** — improved handling for subdirectory projects across repo config and impact flows +- **Hook/runtime reliability** — follow-up fixes for hook execution errors, save-tokens plumbing, and generated skill/template consistency + ## [0.6.0] - 2026-04-07 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 2514f7a..70806c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # aspens -CLI for generating and maintaining AI-ready repo docs for 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`. +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 @@ -12,11 +12,13 @@ CLI for generating and maintaining AI-ready repo docs for Claude Code and Codex - `npm start` — run the CLI (`node bin/cli.js`) - `npm run lint` — no-op check (`echo 'No linter configured yet' && exit 0`) - `aspens scan [path]` — deterministic repo scan -- `aspens doc init [path]` — generate skills, hooks, and instructions file (`--target claude|codex|all`) +- `aspens doc init [path]` — generate skills, hooks, and instructions file (`--target claude|codex|all`, `--recommended` for full recommended setup including save-tokens, agents, and doc-sync hook) +- `aspens doc impact [path]` — show freshness, coverage, drift, and LLM interpretation of generated context (interactive apply for repairs) - `aspens doc sync [path]` — update docs from recent diffs - `aspens doc graph [path]` — rebuild `.claude/graph.json` - `aspens add [name]` — install bundled templates - `aspens customize agents` — inject project context into installed agents +- `aspens save-tokens [path]` — install token-saving session settings (`--recommended`, `--remove`) ## Release diff --git a/README.md b/README.md index 8728602..58fa0ac 100644 --- a/README.md +++ b/README.md @@ -4,201 +4,124 @@ # aspens -## Stop re-explaining your repo. Start shipping. +**Your CLAUDE.md stopped working. Here's why.** [![npm version](https://img.shields.io/npm/v/aspens.svg)](https://www.npmjs.com/package/aspens) [![npm downloads](https://img.shields.io/npm/dm/aspens.svg)](https://www.npmjs.com/package/aspens) [![GitHub stars](https://img.shields.io/github/stars/aspenkit/aspens)](https://github.com/aspenkit/aspens) [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -Claude, Codex, and other coding agents write better code when they start with the right repo context. -Aspens scans your repo, discovers what matters, and generates context that stays updated on every commit — so each session starts on track. - ---- - -**Why aspens?** +You started with 50 clean lines. Three months later it's 200, and Claude ignores half of them. Adding more rules doesn't fix it. The file got too big for the agent to follow, and it goes stale every time the code changes. -| Without aspens | With aspens | -|---|---| -| Agents ignore your conventions | Claude and Codex start with repo-specific instructions | -| Agents rebuild things that already exist | Skills and docs point them to the right abstractions | -| You manually maintain AI context files | Aspens generates and updates them for you | -| Agents spend half their tool calls searching for files | Import graph tells them which files actually matter | -| Your codebase gets fragmented and inconsistent over time | Domain-specific skills with critical rules and anti-patterns | -| Burns through tokens searching, reading, and rebuilding | Your AI tools already know what matters — dramatically fewer tool calls | +aspens replaces the monolith with scoped skill files (~35 lines each) generated from your actual import graph. Each skill activates only when the agent touches that part of the codebase. A post-commit hook keeps them in sync automatically. The agent reads 35 focused lines instead of 200 sprawling ones, and actually follows them. ---- +Works with Claude Code, Codex, or both. ```bash -npx aspens doc init . +npx aspens doc init --recommended ``` -![aspens demo](demo/demo-full.gif) - -**What are skills?** Concise markdown files (~35 lines) that give coding agents the context they need to write correct code: key files, patterns, conventions, and critical rules. - -## Quick Start +Then verify what it generated: ```bash -npx aspens scan . # See what's in your repo -npx aspens doc init . # Generate repo docs for the active target -npx aspens doc init --target codex # Generate AGENTS.md + .agents/skills -npx aspens doc sync --install-hook # Auto-update generated docs on every commit +npx aspens doc impact ``` -Requires [Node.js 20+](https://nodejs.org) and at least one supported backend CLI such as [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) or Codex CLI. - -## Target Support - -Aspens supports different AI tools through different output targets: - -- `claude`: `CLAUDE.md` + `.claude/skills` + Claude hooks -- `codex`: `AGENTS.md` + `.agents/skills` + directory `AGENTS.md` -- `all`: generate both sets together +![aspens demo](demo/demo-full.gif) -Short version: +## Before / After -- Claude support is hook-aware and document-aware -- Codex support is document-driven, not hook-driven +**Before aspens** — one file tries to cover everything: +- Agent starts cold, spends 10-20 tool calls exploring your codebase every session +- CLAUDE.md grows until the agent stops following it +- Documentation drifts out of date within days of any refactor +- Agent misses conventions, duplicates existing code, ignores architectural boundaries -Important distinction: +**After aspens** — scoped skills generated from your import graph: +- Agent loads only the skill for the domain it's working in (~35 lines, 100% followed) +- `doc sync` updates affected skills automatically on every commit +- `doc impact` proves freshness and coverage so you know context matches the code +- Agent reuses existing code because skills surface the right key files -- Claude activation hooks are Claude-only -- The git post-commit `aspens doc sync` hook works for all configured targets +## What Are Skills? -If your repo already has Claude docs and you want to add Codex, you do not need to start from zero: +Skills are short markdown files that give coding agents the repo context they actually need: key files, conventions, patterns, and critical rules. They activate when the agent works in that part of the codebase. -```bash -aspens doc init --target codex -``` +```markdown +--- +name: billing +description: Stripe billing integration — subscriptions, usage tracking, webhooks +--- -Or regenerate both targets together: +## Activation -```bash -aspens doc init --target all -``` +This skill triggers when editing billing/payment-related files: +- `**/billing*.ts` +- `**/stripe*.ts` -See [docs/target-support.md](docs/target-support.md) for the full target model and migration notes. +--- -## Commands +You are working on **billing, Stripe integration, and usage limits**. -### `aspens scan [path]` +## Key Files +- `src/services/billing/stripe.ts` — Stripe SDK wrapper +- `src/services/billing/usage.ts` — Usage counters and limit checks -Detect tech stack, frameworks, structure, and domains. Builds an import graph to identify hub files, domain coupling, and hotspots. No LLM calls — pure file system + git inspection. +## Key Concepts +- **Webhook-driven:** Subscription state changes come from Stripe webhooks, not API calls +- **Usage gating:** `checkLimit(userId, type)` returns structured 429 error data +## Critical Rules +- Webhook endpoint has NO auth middleware — verified by Stripe signature only +- Cancel = `cancel_at_period_end: true` (user keeps access until period end) ``` -$ aspens scan . - - my-app (fullstack) - /Users/you/my-app - - Languages: typescript, javascript - Frameworks: nextjs, react, tailwind, prisma - Entry points: src/index.ts - - Structure - src/ ← source root - tests/ - - Key directories - components → src/components/ - services → src/services/ - database → prisma/ - Import Graph (247 files, 892 edges) - Hub files: - src/lib/db.ts ← 31 dependents, 2 exports - src/auth/middleware.ts ← 18 dependents, 3 exports - src/lib/api-client.ts ← 15 dependents, 4 exports - - Domains (by imports) - components (src/components/) — 89 files - → depends on: lib, hooks, types - lib (src/lib/) — 12 files - ← depended on by: components, services, hooks - - Coupling - components → lib 45 imports - hooks → lib 23 imports - - Hotspots (high churn, last 6 months) - src/auth/session.ts — 19 changes, 210 lines +## Target Support - Claude Code - .claude/ no CLAUDE.md no -``` +Aspens supports multiple agent environments through output targets: -| Option | Description | -|--------|-------------| -| `--json` | Output as JSON | -| `--domains ` | Additional domains to include (comma-separated) | -| `--no-graph` | Skip import graph analysis | -| `--verbose` | Show diagnostic output | +- `claude`: `CLAUDE.md` + `.claude/skills` + Claude hooks +- `codex`: `AGENTS.md` + `.agents/skills` + directory `AGENTS.md` +- `all`: generate both sets together +- we are working on adding more agents and tools - ask or contribute! -### `aspens doc init [path]` +## Commands -Generate repo docs for Claude, Codex, or both. Runs parallel discovery calls through the selected backend to understand your architecture, then generates skills/docs based on what it found. +### `aspens doc init` -The flow: -1. **Scan + Import Graph** — builds dependency map, finds hub files -2. **Parallel Discovery** — 2 backend-guided discovery passes explore simultaneously (domains + architecture) -3. **User picks domains** — from the discovered feature domains -4. **Parallel Generation** — generates 3 domain skills at a time +Generate agent context from the repo. Scans the codebase, discovers architecture and feature domains, then writes instructions and skills. -Claude-target example: + `--recommended` is the fastest path to automatically generate the default settings but you can also do it manually: ``` -$ aspens doc init . +$ aspens doc init ◇ Scanned my-app (fullstack) - Languages: typescript, javascript Frameworks: nextjs, react, tailwind, prisma - Source modules: components, lib, hooks, services, types Import graph: 247 files, 892 edges - Size: 247 source files (medium) - Timeout: 300s per call - - Running 2 discovery agents in parallel... - - ◇ Discovery complete - Architecture: Layered frontend (Next.js 16 App Router) - Discovered 8 feature domains: - auth — User authentication, session management - courses — AI-powered course generation - billing — Stripe subscriptions, usage limits - profile — User profile, XP, badges - ... - - ◆ 8 domains detected. Generate skills: - ● One domain at a time - ○ Pick specific domains - ○ Base skill only - - ◇ Base skill generated - ◇ auth, courses, billing - ◇ profile, settings, onboarding - ◇ layout, landing + + ◇ Discovered 8 feature domains: + auth, courses, billing, profile, ... + .claude/skills/base/skill.md + .claude/skills/auth/skill.md - + .claude/skills/courses/skill.md + + .claude/skills/billing/skill.md ... - 11 call(s) | ~23,640 prompt | 35,180 output | 161 tool calls | 4m 32s - - 10 created + 10 created | 4m 32s ``` | Option | Description | |--------|-------------| +| `--recommended` | Use recommended target, strategy, and generation mode | | `--dry-run` | Preview without writing files | | `--force` | Overwrite existing skills | | `--timeout ` | Backend timeout (default: 300) | | `--mode ` | `all`, `chunked`, or `base-only` (skips interactive prompt) | -| `--strategy ` | `improve`, `rewrite`, or `skip` for existing docs (skips interactive prompt) | +| `--strategy ` | `improve`, `rewrite`, or `skip` for existing docs | | `--domains ` | Additional domains to include (comma-separated) | | `--no-graph` | Skip import graph analysis | | `--model ` | Model for the selected backend | @@ -206,14 +129,20 @@ $ aspens doc init . | `--target ` | Output target: `claude`, `codex`, or `all` | | `--backend ` | Generation backend: `claude` or `codex` | -### `aspens doc sync [path]` +### `aspens doc impact` -Update generated docs based on recent git commits. Reads the diff, maps changes to affected docs, and updates only what changed. +Check your context's health and coverage, keeping up with the codebase. Checks for: +- Instructions and skills present per target +- Domain coverage vs detected repo domains +- Top hub files surfaced in root guidance +- Whether generated context is older than the newest source changes -If your repo is configured for multiple targets, `doc sync` updates all configured outputs from one run. Claude activation hooks remain Claude-only, but the git post-commit sync hook can keep both Claude and Codex docs current. +### `aspens doc sync` + +**This may be the most important command.** Keeps generated context from drifting. Reads recent git changes, maps them to affected skills, and updates only what changed. ``` -$ aspens doc sync . +$ aspens doc sync ◆ aspens doc sync @@ -238,20 +167,20 @@ $ aspens doc sync . |--------|-------------| | `--commits ` | Number of commits to analyze (default: 1) | | `--refresh` | Review all skills against current codebase (no git diff needed) | -| `--no-graph` | Skip import graph analysis | -| `--install-hook` | Install git post-commit auto-sync for all configured targets | +| `--install-hook` | Install git post-commit auto-sync | | `--remove-hook` | Remove the git post-commit auto-sync hook | | `--dry-run` | Preview without writing files | +| `--no-graph` | Skip import graph analysis | | `--timeout ` | Backend timeout (default: 300) | | `--model ` | Model for the selected backend | | `--verbose` | Show backend reads/activity in real time | -### `aspens doc graph [path]` +### `aspens doc graph` Rebuild the import graph cache. Runs automatically during `doc init` and `doc sync`, but you can trigger it manually. ```bash -aspens doc graph . +aspens doc graph ``` ### `aspens add [name]` @@ -277,11 +206,11 @@ aspens add skill --list # Show existing skills ### `aspens customize agents` -Inject your project's tech stack, conventions, and file paths into installed Claude agents. Reads your skills and `CLAUDE.md`, then tailors each agent with project-specific context. +Inject your project's tech stack, conventions, and file paths into installed Claude agents. ```bash -aspens customize agents # Customize all installed agents -aspens customize agents --dry-run # Preview changes +aspens customize agents +aspens customize agents --dry-run ``` | Option | Description | @@ -291,66 +220,30 @@ aspens customize agents --dry-run # Preview changes | `--model ` | Claude model (e.g., sonnet, opus, haiku) | | `--verbose` | Show what Claude is doing | -## How It Works - -``` -Your Repo ──▶ Scanner ──▶ Import Graph ──▶ Discovery Passes ──▶ Skill Generation - (detect (parse imports, (2 parallel backend (3 domains at a - stack, hub files, calls: domains + time, guided by - domains) coupling) architecture) graph + findings) -``` - -1. **Scanner** detects your tech stack, frameworks, structure, and domains. Deterministic — no LLM, instant, free. -2. **Import Graph** parses imports across JS/TS/Python, resolves `@/` aliases from tsconfig, builds a dependency map with hub files, coupling analysis, git churn hotspots, and file priority ranking. -3. **Discovery Passes** (2 parallel backend calls) explore the codebase guided by the graph. One discovers feature domains, the other analyzes architecture and patterns. Results are merged. -4. **Skill Generation** uses the graph + discovery findings to write concise, actionable skills. Runs up to 3 domains in parallel. - -Doc sync keeps skills current: on each commit, it reads the diff, identifies affected skills, and updates them. - -## What a Skill Looks Like - -```markdown ---- -name: billing -description: Stripe billing integration — subscriptions, usage tracking, webhooks ---- - -## Activation +### `aspens save-tokens` -This skill triggers when editing billing/payment-related files: -- `**/billing*.ts` -- `**/stripe*.ts` +Install token-saving session settings — statusline telemetry, prompt guards, precompact handoffs, and session rotation. ---- - -You are working on **billing, Stripe integration, and usage limits**. - -## Key Files -- `src/services/billing/stripe.ts` — Stripe SDK wrapper -- `src/services/billing/usage.ts` — Usage counters and limit checks - -## Key Concepts -- **Webhook-driven:** Subscription state changes come from Stripe webhooks, not API calls -- **Usage gating:** `checkLimit(userId, type)` returns structured 429 error data - -## Critical Rules -- Webhook endpoint has NO auth middleware — verified by Stripe signature only -- Cancel = `cancel_at_period_end: true` (user keeps access until period end) +```bash +aspens save-tokens # Interactive install +aspens save-tokens --recommended # No-prompt install +aspens save-tokens --remove # Uninstall ``` -~35 lines. This is the kind of focused context aspens generates for agent-specific docs. - -## Save Tokens - -Without context, coding agents burn through usage searching for files, reading code they don't need, and rebuilding things that already exist. With aspens, they know your codebase structure before writing a single line — fewer tool calls, fewer wasted reads, fewer rewrites. +## How It Works -Less context searching. More code shipping. +1. **Scanner** — detects tech stack, frameworks, structure, and domains. Deterministic, no LLM, instant. +2. **Import Graph** — parses imports across JS/TS/Python, resolves aliases, finds hub files and coupling. +3. **Discovery** — 2 parallel LLM passes explore the codebase: one finds feature domains, the other analyzes architecture. +4. **Generation** — writes concise skills guided by the graph + discovery findings. Up to 3 domains in parallel. +5. **Sync** — on each commit, reads the diff, identifies affected skills, and updates only what changed. ## Requirements - **Node.js 20+** -- **Claude Code CLI** for Claude-target generation — `npm install -g @anthropic-ai/claude-code` -- **Codex CLI** for Codex-target generation +- At least one supported agent CLI: + - [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) — `npm install -g @anthropic-ai/claude-code` + - Codex CLI ## License diff --git a/bin/cli.js b/bin/cli.js index fbe7e66..fa0a855 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -9,8 +9,10 @@ import { scanCommand } from '../src/commands/scan.js'; import { docInitCommand } from '../src/commands/doc-init.js'; import { docSyncCommand } from '../src/commands/doc-sync.js'; import { docGraphCommand } from '../src/commands/doc-graph.js'; +import { docImpactCommand } from '../src/commands/doc-impact.js'; import { addCommand } from '../src/commands/add.js'; import { customizeCommand } from '../src/commands/customize.js'; +import { saveTokensCommand } from '../src/commands/save-tokens.js'; import { CliError } from '../src/lib/errors.js'; function parsePositiveInt(value, name) { @@ -39,33 +41,39 @@ function countTemplates(subdir) { function showWelcome() { console.log(` - ${pc.cyan(pc.bold('aspens'))} ${pc.dim(`v${VERSION}`)} — AI-ready documentation for your codebase + ${pc.cyan(pc.bold('aspens'))} ${pc.dim(`v${VERSION}`)} — keep agent context accurate as your repo changes - ${pc.bold('Quick Start')} - ${pc.green('aspens scan')} See your repo's tech stack and domains - ${pc.green('aspens doc init')} Generate target docs for Claude, Codex, or both - ${pc.green('aspens doc init --target codex')} Generate AGENTS.md + .agents/skills - ${pc.green('aspens doc sync --install-hook')} Auto-update Claude docs on every commit + ${pc.bold('Essential')} + ${pc.green('aspens doc init --recommended')} Install docs, hooks, agents, save-tokens, and doc-sync + ${pc.green('aspens doc impact')} Verify health and see what else aspens can add + ${pc.green('aspens doc sync --install-hook')} Enable/repair automatic doc updates after commits ${pc.bold('Generate & Sync')} - ${pc.green('aspens doc init')} ${pc.dim('[path]')} Generate docs from your code + ${pc.green('aspens doc init')} Generate docs from your code + ${pc.green('aspens doc init --recommended')} Install the full recommended setup ${pc.green('aspens doc init --dry-run')} Preview without writing ${pc.green('aspens doc init --mode chunked')} One domain at a time (large repos) ${pc.green('aspens doc init --target all')} Generate Claude + Codex docs together ${pc.green('aspens doc init --model haiku')} Use a specific backend model ${pc.green('aspens doc init --verbose')} See backend activity in real time - ${pc.green('aspens doc sync')} ${pc.dim('[path]')} Update generated docs from recent commits + ${pc.green('aspens doc sync')} Update generated docs from recent commits + ${pc.green('aspens doc impact')} Check freshness, coverage, and hooks + ${pc.green('aspens doc sync --install-hook')} Auto-update generated docs after git commits ${pc.green('aspens doc sync --commits 5')} Sync from last 5 commits ${pc.green('aspens doc sync --refresh')} Refresh all skills from current code - ${pc.bold('Add Components')} - ${pc.green('aspens add agent')} ${pc.dim('[name]')} Add AI agents ${pc.dim(`(${countTemplates('agents')} available)`)} - ${pc.green('aspens add command')} ${pc.dim('[name]')} Add slash commands ${pc.dim(`(${countTemplates('commands')} available)`)} - ${pc.green('aspens add hook')} ${pc.dim('[name]')} Add auto-triggering hooks ${pc.dim(`(${countTemplates('hooks')} available)`)} - ${pc.green('aspens add skill')} ${pc.dim('')} Add custom skills (conventions, workflows) + ${pc.bold('Claude Add-ons')} + ${pc.green('aspens save-tokens')} Install token warnings + handoff commands + ${pc.green('aspens add agent')} ${pc.dim('[name]')} Add Claude-side agents ${pc.dim(`(${countTemplates('agents')} available)`)} + ${pc.green('aspens add command')} ${pc.dim('[name]')} Add slash commands ${pc.dim(`(${countTemplates('commands')} available)`)} + ${pc.green('aspens add hook')} ${pc.dim('[name]')} Add auto-triggering hooks ${pc.dim(`(${countTemplates('hooks')} available)`)} ${pc.green('aspens add agent --list')} Browse the library ${pc.green('aspens customize agents')} Inject project context into agents + ${pc.bold('Utilities')} + ${pc.green('aspens scan')} Inspect tech stack, domains, and repo shape (no AI) + ${pc.green('aspens add skill')} ${pc.dim('')} Add custom skills (conventions, workflows) + ${pc.bold('Options')} ${pc.yellow('--dry-run')} Preview without writing ${pc.yellow('--verbose')} See backend activity ${pc.yellow('--force')} Overwrite existing files ${pc.yellow('--model')} ${pc.dim('')} Choose backend model @@ -76,11 +84,8 @@ function showWelcome() { ${pc.yellow('--no-graph')} Skip import graph analysis ${pc.bold('Typical Workflow')} - ${pc.dim('$')} aspens scan ${pc.dim('1. See what\'s in your repo')} - ${pc.dim('$')} aspens doc init --target all ${pc.dim('2. Generate CLAUDE.md + AGENTS.md outputs')} - ${pc.dim('$')} aspens add agent all ${pc.dim('3. Add Claude-side AI agents')} - ${pc.dim('$')} aspens customize agents ${pc.dim('4. Tailor Claude agents to your project')} - ${pc.dim('$')} aspens doc sync --install-hook ${pc.dim('5. Auto-update Claude docs on every commit')} + ${pc.dim('$')} aspens doc init --recommended ${pc.dim('1. Install the recommended setup')} + ${pc.dim('$')} aspens doc impact ${pc.dim('2. Verify health + discover optional upgrades')} ${pc.bold('Target Notes')} ${pc.dim('Claude:')} ${pc.cyan('CLAUDE.md + .claude/skills + hooks')} @@ -118,7 +123,7 @@ function checkMissingHooks(repoPath) { program .name('aspens') - .description('Generate and maintain AI-ready documentation for your codebase') + .description('Keep agent context accurate as your codebase changes') .version(VERSION) .action(() => { // No command given — show welcome @@ -145,6 +150,7 @@ doc .command('init') .description('Scan repo and generate skills + guidelines') .argument('[path]', 'Path to repo', '.') + .option('--recommended', 'Use the recommended target and install the full recommended aspens setup') .option('--dry-run', 'Preview without writing files') .option('--force', 'Overwrite existing skills') .option('--timeout ', 'Backend timeout in seconds', parseTimeout, 300) @@ -179,6 +185,18 @@ doc return docSyncCommand(path, options); }); +doc + .command('impact') + .description('Show generated context freshness and coverage') + .argument('[path]', 'Path to repo', '.') + .option('--apply', 'Apply recommended fixes after confirmation') + .option('--backend ', 'Interpretation backend: claude, codex (default: whichever is available)') + .option('--model ', 'Model to use for impact interpretation') + .option('--timeout ', 'Backend timeout in seconds', parseTimeout, 300) + .option('--verbose', 'Show backend reads/activity in real time') + .option('--no-graph', 'Skip import graph analysis') + .action(docImpactCommand); + doc .command('graph') .description('Rebuild the import graph cache') @@ -202,6 +220,14 @@ program return addCommand(type, name, options); }); +program + .command('save-tokens') + .description('Install recommended token-saving session settings') + .argument('[path]', 'Path to repo', '.') + .option('--recommended', 'Install the recommended save-tokens setup without prompts') + .option('--remove', 'Remove aspens save-tokens Claude hooks/statusLine/settings') + .action(saveTokensCommand); + // Customize command program .command('customize') diff --git a/package-lock.json b/package-lock.json index 60e3fd4..55ea24f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aspens", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aspens", - "version": "0.6.0", + "version": "0.7.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 83af9ad..7368058 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "aspens", - "version": "0.6.0", - "description": "Generate and maintain AI-ready documentation for any codebase", + "version": "0.7.0", + "description": "Keep coding-agent context accurate as your codebase changes", "type": "module", "bin": { "aspens": "bin/cli.js" diff --git a/src/commands/doc-impact.js b/src/commands/doc-impact.js new file mode 100644 index 0000000..d480304 --- /dev/null +++ b/src/commands/doc-impact.js @@ -0,0 +1,434 @@ +import { resolve } from 'path'; +import pc from 'picocolors'; +import * as p from '@clack/prompts'; +import { analyzeImpact, summarizeValueComparison } from '../lib/impact.js'; +import { detectAvailableBackends, resolveBackend } from '../lib/backend.js'; +import { loadPrompt, runLLM } from '../lib/runner.js'; +import { CliError } from '../lib/errors.js'; +import { docInitCommand } from './doc-init.js'; +import { docSyncCommand } from './doc-sync.js'; + +export async function docImpactCommand(path, options) { + const repoPath = resolve(path); + + p.intro(pc.cyan('aspens doc impact')); + + const spinner = p.spinner(); + spinner.start('Inspecting repo context coverage...'); + let report; + let comparison; + try { + report = await analyzeImpact(repoPath, options); + comparison = summarizeValueComparison(report.targets); + spinner.stop(pc.green('Impact report ready')); + } catch (err) { + spinner.stop(pc.red('Impact analysis failed')); + throw new CliError(`Failed to analyze impact for ${repoPath}. Try re-running with --no-graph if the repo is unusual.`, { cause: err }); + } + + console.log(); + console.log(pc.dim(' Repo: ') + pc.bold(report.scan.name)); + console.log(pc.dim(' Summary: ') + `${report.summary.repoStatus}, ${report.summary.changedFiles} changed file(s), ${report.summary.affectedTargets} target(s) affected`); + console.log(pc.dim(' Context health: ') + scoreLabel(report.summary.averageHealth)); + console.log(pc.dim(' Latest source change: ') + formatDate(report.summary.latestSourceMtime)); + console.log(pc.dim(' Without aspens: ') + comparison.withoutAspens); + console.log(pc.dim(' With aspens now: ') + comparison.withAspens); + console.log(pc.dim(' Freshness: ') + comparison.freshness); + console.log(pc.dim(' Automation: ') + comparison.automation); + if (report.summary.missing.length > 0) { + console.log(pc.dim(' What’s missing:')); + for (const item of report.summary.missing.slice(0, 4)) { + console.log(pc.dim(' ') + formatMissingItem(item)); + } + } + if (report.summary.opportunities?.length > 0) { + console.log(); + console.log(pc.bold(' Missing Aspens Setup')); + for (const item of report.summary.opportunities.slice(0, 4)) { + console.log(pc.dim(' ') + `${item.message}: ${pc.cyan(item.command)}`); + } + } + + let analysis = null; + const available = detectAvailableBackends(); + if (available.claude || available.codex) { + const { backend, warning } = resolveBackend({ + backendFlag: options.backend, + available, + }); + if (warning) p.log.warn(warning); + + const analysisSpinner = p.spinner(); + analysisSpinner.start(`Analyzing impact with ${backend.label}...`); + try { + const prompt = buildImpactAnalysisPrompt(repoPath, report, comparison); + const result = await runLLM(prompt, { + timeout: (options.timeout || 300) * 1000, + verbose: !!options.verbose, + model: options.model || null, + onActivity: options.verbose ? (msg) => analysisSpinner.message(pc.dim(msg)) : null, + disableTools: true, + cwd: repoPath, + }, backend.id); + analysis = parseAnalysis(result.text); + analysisSpinner.stop(pc.green(`Analysis complete (${backend.label})`)); + } catch (err) { + analysisSpinner.stop(pc.yellow('Analysis unavailable')); + p.log.warn(err.message); + } + } else { + p.log.warn('Impact interpretation unavailable: install Claude CLI or Codex CLI to enable it.'); + } + + for (const target of report.targets) { + console.log(); + console.log(pc.bold(` ${target.label}`)); + console.log(pc.dim(' Context health: ') + scoreLabel(target.health)); + console.log(pc.dim(' Status: ') + [ + `instructions ${statusLabel(target.status.instructions)}`, + `domains ${statusLabel(target.status.domains)}`, + target.status.hooks !== 'n/a' ? `hooks ${statusLabel(target.status.hooks)}` : null, + ].filter(Boolean).join(' | ')); + console.log(pc.dim(' Instructions: ') + `${target.instructionExists ? pc.green('present') : pc.yellow('missing')} (${target.instructionsFile})`); + console.log(pc.dim(' Skills: ') + target.skillCount); + + if (target.usefulness.strengths.length > 0) { + console.log(pc.dim(' Helpfulness: ') + target.usefulness.strengths[0]); + for (const line of target.usefulness.strengths.slice(1, 3)) { + console.log(pc.dim(' ') + line); + } + } + + if (target.usefulness.activationExamples.length > 0) { + console.log(pc.dim(' Examples: ')); + for (const example of target.usefulness.activationExamples) { + console.log(pc.dim(' ') + example); + } + } + + if (target.hubCoverage.total > 0) { + const missingHubs = target.hubCoverage.total - target.hubCoverage.mentioned; + console.log(pc.dim(' Hub files surfaced: ') + `${target.hubCoverage.mentioned}/${target.hubCoverage.total}${missingHubs > 0 ? `, ${missingHubs} missing from root context` : ''}`); + } else { + console.log(pc.dim(' Hub files surfaced: ') + pc.dim('n/a')); + } + + console.log(pc.dim(' Last generated: ') + (target.lastUpdated ? formatDate(target.lastUpdated) : pc.dim('not generated'))); + if (target.drift.changedCount > 0) { + console.log(pc.dim(' Context drift: ') + `${target.drift.changedCount} source file(s) changed since last update`); + if (target.drift.affectedDomains.length > 0) { + console.log(pc.dim(' Affected domains: ') + target.drift.affectedDomains.join(', ')); + } + for (const file of target.drift.changedFiles.slice(0, 4)) { + console.log(pc.dim(' ') + file.path); + } + if (target.drift.changedFiles.length > 4) { + console.log(pc.dim(' ...')); + } + if (target.drift.driftMs > 0) { + console.log(pc.dim(' Drift window: ') + formatDuration(target.drift.driftMs)); + } + } else { + console.log(pc.dim(' Context drift: ') + pc.green('none detected')); + } + + if (target.hookHealth?.issues?.length > 0) { + console.log(pc.dim(' Hook issues: ')); + for (const issue of target.hookHealth.issues.slice(0, 3)) { + console.log(pc.dim(' ') + issue); + } + } + + if (target.saveTokensHealth?.configured) { + console.log(pc.bold(' Save-tokens: ') + (target.saveTokensHealth.healthy ? pc.green('healthy') : pc.yellow('broken'))); + if (target.saveTokensHealth.healthy) { + console.log(pc.dim(' ') + 'statusLine + prompt guard + precompact + handoff commands installed'); + } else { + for (const issue of target.saveTokensHealth.issues.slice(0, 3)) { + console.log(pc.dim(' ') + issue); + } + } + } + + if (target.usefulness.blindSpots.length > 0) { + console.log(pc.dim(' Blind spots: ')); + for (const blindSpot of target.usefulness.blindSpots.slice(0, 3)) { + console.log(pc.dim(' ') + blindSpot); + } + } + + if (target.actions.length > 0) { + console.log(pc.dim(' Recommended: ') + target.actions.map(action => `\`${action}\``).join(' • ')); + } + } + + if (analysis) { + console.log(); + console.log(pc.bold(' Interpretation')); + renderAnalysis(analysis); + } + + console.log(); + const applyPlan = buildApplyPlan(report.targets); + + if (applyPlan.length > 0 && options.apply) { + const confirmApply = await p.confirm({ + message: buildApplyConfirmationMessage(), + initialValue: true, + }); + + if (!p.isCancel(confirmApply) && confirmApply) { + p.log.info(`Applying ${applyPlan.length} recommended action(s)...`); + for (const item of applyPlan) { + await applyRecommendedAction(repoPath, item.action, options, item.target); + } + } + } else if (applyPlan.length > 0) { + p.log.info(`Suggested fixes available. Re-run with ${pc.cyan('aspens doc impact --apply')} to apply: ${applyPlan.map(item => `\`${item.action}\``).join(' • ')}`); + } + + if (report.summary.actions.length === 0) { + p.outro(pc.green('Context looks current')); + return; + } + + p.outro(pc.yellow(`Recommended next step: ${report.summary.actions.map(action => `\`${action}\``).join(' • ')}`)); +} + +function scoreLabel(score) { + const color = score >= 85 ? pc.green : score >= 65 ? pc.yellow : pc.red; + return color(`${score}/100`); +} + +function statusLabel(status) { + if (status === 'healthy') return pc.green(status); + if (status === 'partial') return pc.yellow(status); + if (status === 'n/a') return pc.dim(status); + return pc.yellow(status); +} + +function formatDate(timestamp) { + if (!timestamp) return 'n/a'; + return new Date(timestamp).toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +} + +function formatDuration(ms) { + const totalMinutes = Math.round(ms / 60000); + if (totalMinutes < 60) return `${totalMinutes}m`; + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`; +} + +function formatMissingItem(item) { + const color = item.severity === 'high' ? pc.red + : item.severity === 'medium' ? pc.yellow + : pc.dim; + return color(item.message); +} + +function buildImpactAnalysisPrompt(repoPath, report, comparison) { + const payload = { + repoPath, + scan: { + name: report.scan.name, + repoType: report.scan.repoType, + languages: report.scan.languages, + frameworks: report.scan.frameworks, + domains: report.scan.domains, + size: report.scan.size, + }, + summary: report.summary, + comparison, + targets: report.targets.map(target => ({ + id: target.id, + label: target.label, + instructionsFile: target.instructionsFile, + instructionExists: target.instructionExists, + skillCount: target.skillCount, + hooksInstalled: target.hooksInstalled, + saveTokensHealth: target.saveTokensHealth, + lastUpdated: target.lastUpdated, + health: target.health, + status: target.status, + domainCoverage: target.domainCoverage, + hubCoverage: target.hubCoverage, + drift: { + changedCount: target.drift.changedCount, + affectedDomains: target.drift.affectedDomains, + }, + usefulness: target.usefulness, + actions: target.actions, + })), + }; + + return loadPrompt('impact-analyze') + '\n\n```json\n' + JSON.stringify(payload, null, 2) + '\n```'; +} + +function parseAnalysis(text) { + const parsed = JSON.parse(text); + if (!parsed || typeof parsed !== 'object') { + throw new Error('Analysis returned invalid JSON.'); + } + return { + bottomLine: String(parsed.bottom_line || '').trim(), + improves: Array.isArray(parsed.improves) ? parsed.improves.map(v => String(v).trim()).filter(Boolean) : [], + risks: Array.isArray(parsed.risks) ? parsed.risks.map(v => String(v).trim()).filter(Boolean) : [], + nextStep: String(parsed.next_step || '').trim(), + }; +} + +function renderAnalysis(analysis) { + if (analysis.bottomLine) { + console.log(pc.dim(' Summary: ') + analysis.bottomLine); + } + if (analysis.improves.length > 0) { + console.log(pc.dim(' Helps:')); + for (const item of analysis.improves.slice(0, 3)) { + console.log(pc.dim(' - ') + item); + } + } + if (analysis.risks.length > 0) { + console.log(pc.dim(' Risks:')); + for (const item of analysis.risks.slice(0, 3)) { + console.log(pc.dim(' - ') + item); + } + } + if (analysis.nextStep) { + console.log(pc.dim(' Next: ') + analysis.nextStep); + } +} + +export function buildApplyPlan(targets) { + const seen = new Set(); + const plan = []; + + for (const target of targets || []) { + for (const action of target.actions || []) { + const key = action === 'aspens doc sync' + ? action + : `${target.id}:${action}`; + if (seen.has(key)) continue; + seen.add(key); + plan.push({ action, target }); + } + } + + return plan; +} + +export function buildApplyConfirmationMessage() { + return 'Do you want to apply recommendations?'; +} + +async function applyRecommendedAction(repoPath, action, options, target = null) { + p.log.info(pc.dim(`Running: ${action}`)); + + if (action === 'aspens doc sync') { + await docSyncCommand(repoPath, { + commits: 1, + refresh: false, + installHook: false, + removeHook: false, + dryRun: false, + timeout: options.timeout || 300, + model: options.model || null, + verbose: !!options.verbose, + graph: options.graph !== false, + }); + return; + } + + if (action === 'aspens doc init --hooks-only') { + await docInitCommand(repoPath, { + hooksOnly: true, + dryRun: false, + force: false, + timeout: options.timeout || 300, + mode: null, + strategy: null, + domains: null, + model: options.model || null, + hook: true, + hooks: true, + verbose: !!options.verbose, + graph: options.graph !== false, + target: target?.id || null, + backend: options.backend || null, + recommended: false, + }); + return; + } + + if (action === 'aspens doc init --recommended' || action === 'aspens doc init --recommended --strategy improve') { + await docInitCommand(repoPath, { + recommended: true, + dryRun: false, + force: false, + timeout: options.timeout || 300, + mode: null, + strategy: action.includes('--strategy improve') ? 'improve' : null, + domains: null, + model: options.model || null, + hook: true, + hooks: true, + verbose: !!options.verbose, + graph: options.graph !== false, + target: target?.id || null, + backend: options.backend || null, + hooksOnly: false, + }); + return; + } + + if (action === 'aspens doc init --mode base-only --strategy improve' || action === 'aspens doc init --mode base-only --strategy rewrite') { + await docInitCommand(repoPath, { + recommended: false, + dryRun: false, + force: false, + timeout: options.timeout || 300, + mode: 'base-only', + strategy: action.includes('--strategy rewrite') ? 'rewrite' : 'improve', + domains: null, + model: options.model || null, + hook: true, + hooks: true, + verbose: !!options.verbose, + graph: options.graph !== false, + target: target?.id || null, + backend: options.backend || null, + hooksOnly: false, + }); + return; + } + + const domainMatch = action.match(/^aspens doc init --mode chunked --domains (.+)$/); + if (domainMatch) { + await docInitCommand(repoPath, { + recommended: false, + dryRun: false, + force: false, + timeout: options.timeout || 300, + mode: 'chunked', + strategy: 'improve', + domains: domainMatch[1], + model: options.model || null, + hook: true, + hooks: true, + verbose: !!options.verbose, + graph: options.graph !== false, + target: target?.id || null, + backend: options.backend || null, + hooksOnly: false, + }); + return; + } + + p.log.warn(`Cannot apply automatically: ${action}`); +} diff --git a/src/commands/doc-init.js b/src/commands/doc-init.js index e7eef22..c796e72 100644 --- a/src/commands/doc-init.js +++ b/src/commands/doc-init.js @@ -1,5 +1,5 @@ -import { resolve, join, dirname } from 'path'; -import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync, chmodSync } from 'fs'; +import { resolve, join, dirname, relative } from 'path'; +import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync, chmodSync, readdirSync } from 'fs'; import { fileURLToPath } from 'url'; import pc from 'picocolors'; import * as p from '@clack/prompts'; @@ -11,10 +11,12 @@ import { persistGraphArtifacts } from '../lib/graph-persistence.js'; import { installGitHook } from '../lib/git-hook.js'; import { CliError } from '../lib/errors.js'; import { resolveTimeout } from '../lib/timeout.js'; -import { TARGETS, resolveTarget, getAllowedPaths, writeConfig } from '../lib/target.js'; +import { TARGETS, resolveTarget, getAllowedPaths, writeConfig, loadConfig, mergeConfiguredTargets } from '../lib/target.js'; import { detectAvailableBackends, resolveBackend } from '../lib/backend.js'; -import { transformForTarget, validateTransformedFiles } from '../lib/target-transform.js'; +import { transformForTarget, validateTransformedFiles, ensureRootKeyFilesSection } 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'; const __dirname = dirname(fileURLToPath(import.meta.url)); const TEMPLATES_DIR = join(__dirname, '..', 'templates'); @@ -115,11 +117,86 @@ function trackUsage(usage, promptLength) { } } +function validateGeneratedChunk(files, repoPath) { + if (files.length === 0) return files; + + let validation = { valid: true, issues: [] }; + try { + validation = validateSkillFiles(files, repoPath); + } catch (err) { + p.log.warn(`Validation failed: ${err.message}`); + return files; + } + + const truncated = validation.issues.filter(issue => issue.issue === 'truncated').map(issue => issue.file); + if (truncated.length > 0) { + p.log.warn(`Removed ${truncated.length} truncated file(s) from this chunk.`); + files = files.filter(file => !truncated.includes(file.path)); + } + + return files; +} + +function buildOutputFilesForTargets(canonicalFiles, targets, scan, graphSerialized) { + let outputFiles = [...canonicalFiles]; + const nonClaudeTargets = targets.filter(target => target.id !== 'claude'); + + if (nonClaudeTargets.length > 0) { + for (const target of nonClaudeTargets) { + const transformed = transformForTarget(canonicalFiles, TARGETS.claude, target, { + scanResult: scan, + graphSerialized, + }); + + const transformValidation = validateTransformedFiles(transformed); + if (!transformValidation.valid) { + p.log.warn(`Transform issues for ${target.label}:`); + for (const issue of transformValidation.issues) { + console.log(pc.dim(' ') + pc.yellow('!') + ' ' + issue); + } + } + + const validTransformed = transformValidation.valid + ? transformed + : transformed.filter(file => validateTransformedFiles([file]).valid); + + outputFiles = [...outputFiles, ...validTransformed]; + } + + if (!targets.some(target => target.id === 'claude')) { + outputFiles = outputFiles.filter(file => !canonicalFiles.includes(file)); + } + } + + return outputFiles; +} + +function writeIncrementalOutputs(repoPath, files, shouldForce, writeState) { + const changedFiles = files.filter(file => writeState.contentsByPath.get(file.path) !== file.content); + if (changedFiles.length === 0) return; + + const directWriteFiles = changedFiles.filter(file => !(file.path.endsWith('/AGENTS.md') && file.path !== 'AGENTS.md')); + const dirScopedFiles = changedFiles.filter(file => file.path.endsWith('/AGENTS.md') && file.path !== 'AGENTS.md'); + const results = [ + ...writeSkillFiles(repoPath, directWriteFiles, { force: shouldForce }), + ...writeTransformedFiles(repoPath, dirScopedFiles, { force: shouldForce }), + ]; + + for (const file of changedFiles) { + writeState.contentsByPath.set(file.path, file.content); + } + for (const result of results) { + writeState.resultsByPath.set(result.path, result); + } +} + export async function docInitCommand(path, options) { const repoPath = resolve(path); _repoPath = repoPath; const verbose = !!options.verbose; const model = options.model || null; + const recommended = !!options.recommended; + const { config: existingConfig } = loadConfig(repoPath, { persist: false }); // --hooks-only: skip skill generation, just install/update hooks if (options.hooksOnly) { @@ -161,9 +238,25 @@ export async function docInitCommand(path, options) { // --- Step 1: Backend selection (which AI generates) --- let backendResult; + let recommendedTargetIds = null; + let recommendedBackendId = null; + if (recommended && !options.target) { + const { config } = loadConfig(repoPath, { persist: false }); + if (config?.targets?.length) { + recommendedTargetIds = config.targets; + } + if (config?.backend) { + recommendedBackendId = config.backend; + } + } + if (options.backend) { backendResult = resolveBackend({ backendFlag: options.backend, available }); - } else if (available.claude && available.codex) { + } else if (recommended && recommendedBackendId) { + backendResult = resolveBackend({ backendFlag: recommendedBackendId, available }); + } else if (recommended && recommendedTargetIds?.length === 1) { + backendResult = resolveBackend({ targetId: recommendedTargetIds[0], available }); + } else if (available.claude && available.codex && !recommended) { const backendChoice = await p.select({ message: 'Which AI should generate the docs?', options: [ @@ -185,6 +278,10 @@ export async function docInitCommand(path, options) { let targetIds; if (options.target) { targetIds = options.target === 'all' ? ['claude', 'codex'] : [options.target]; + } else if (recommendedTargetIds?.length) { + targetIds = recommendedTargetIds; + } else if (recommended) { + targetIds = [backend.id]; } else if (available.claude && available.codex) { const selected = await p.multiselect({ message: 'Generate docs for which coding agents?', @@ -205,9 +302,23 @@ export async function docInitCommand(path, options) { const primaryTarget = targets[0]; _primaryTarget = primaryTarget; _allowedPaths = null; // canonical generation uses defaults + const persistedTargets = mergeConfiguredTargets(existingConfig?.targets, targetIds); + + // Persist the selected target/backend up front so a failed generation run + // still updates repo config to reflect the user's explicit choice. + if (!options.dryRun) { + writeConfig(repoPath, { + targets: persistedTargets, + backend: backend.id, + saveTokens: existingConfig?.saveTokens, + }); + } console.log(pc.dim(` Target: ${targets.map(t => t.label).join(' + ')}`)); console.log(pc.dim(` Backend: ${backend.label}`)); + if (recommended) { + console.log(pc.dim(' Mode: ') + 'recommended defaults'); + } console.log(); // Step 1: Scan @@ -266,14 +377,18 @@ export async function docInitCommand(path, options) { _reuseSourceTarget = chooseReuseSourceTarget(targets, hasClaudeDocs, hasCodexDocs); let skipDiscovery = false; if (hasExistingDocs && !isBaseOnly && !isDomainsOnly && options.strategy !== 'rewrite') { - const existingSource = hasClaudeDocs && hasCodexDocs ? 'Claude + Codex' - : hasClaudeDocs ? 'Claude' : 'Codex'; - const reuse = await p.confirm({ - message: `Existing ${existingSource} docs found. Skip discovery and reuse existing domains?`, - initialValue: true, - }); - if (p.isCancel(reuse)) { p.cancel('Aborted'); return; } - skipDiscovery = reuse; + if (recommended) { + skipDiscovery = true; + } else { + const existingSource = hasClaudeDocs && hasCodexDocs ? 'Claude + Codex' + : hasClaudeDocs ? 'Claude' : 'Codex'; + const reuse = await p.confirm({ + message: `Existing ${existingSource} docs found. Skip discovery and reuse existing domains?`, + initialValue: true, + }); + if (p.isCancel(reuse)) { p.cancel('Aborted'); return; } + skipDiscovery = reuse; + } } if (repoGraph && repoGraph.stats.totalFiles > 0 && !isBaseOnly && !isDomainsOnly && !skipDiscovery) { console.log(pc.dim(' Running 2 discovery agents in parallel...')); @@ -394,6 +509,8 @@ export async function docInitCommand(path, options) { if (!['improve', 'rewrite', 'skip-existing', 'fresh'].includes(existingDocsStrategy)) { throw new CliError(`Unknown strategy: ${options.strategy}. Use: improve, rewrite, or skip`); } + } else if (recommended && hasExistingDocs) { + existingDocsStrategy = 'improve'; } else if ((scan.hasClaudeConfig || scan.hasClaudeMd || scan.hasAgentsMd) && !options.force && !isDomainsOnly) { // Detect what actually exists per-target const hasClaudeDocs = scan.hasClaudeConfig || scan.hasClaudeMd; @@ -463,6 +580,10 @@ export async function docInitCommand(path, options) { } else if (effectiveDomains.length === 0) { p.log.info(`No domains detected — generating ${baseArtifactLabel()} only.`); mode = 'base-only'; + } else if (recommended) { + const isLarge = scan.size && (scan.size.category === 'large' || scan.size.category === 'very-large'); + mode = isLarge || effectiveDomains.length > 6 ? 'chunked' : 'all-at-once'; + p.log.info(`Recommended mode: ${mode === 'chunked' ? 'one domain at a time' : 'all at once'}.`); } else { // Smart defaults based on repo size const isLarge = scan.size && (scan.size.category === 'large' || scan.size.category === 'very-large'); @@ -514,6 +635,11 @@ export async function docInitCommand(path, options) { // Step 5: Generate skills let allFiles = []; + const shouldForce = options.force || existingDocsStrategy === 'improve' || existingDocsStrategy === 'rewrite'; + const shouldWriteIncrementally = mode === 'chunked' && !options.dryRun; + const incrementalWriteState = shouldWriteIncrementally + ? { contentsByPath: new Map(), resultsByPath: new Map() } + : null; const reuseExistingCanonical = ( existingDocsStrategy === 'improve' && _reuseSourceTarget?.id === 'claude' @@ -522,11 +648,30 @@ export async function docInitCommand(path, options) { p.log.info(pc.dim(`Using existing ${_reuseSourceTarget.label} docs as improvement context.`)); } + if (shouldWriteIncrementally) { + console.log(); + const allowIncrementalWrite = await p.confirm({ + message: 'Allow aspens to write generated files incrementally during chunked generation? This includes skills, repo docs, and supported hook/config updates.', + initialValue: true, + }); + + if (p.isCancel(allowIncrementalWrite) || !allowIncrementalWrite) { + p.cancel('Aborted'); + return; + } + } + if (mode === 'all-at-once') { allFiles = await generateAllAtOnce(repoPath, scan, repoGraph, selectedDomains, timeoutMs, existingDocsStrategy, verbose, model, discoveryFindings, !!options.mode); } else { const domainsOnly = isDomainsOnly; // retrying specific domains — skip base + CLAUDE.md - allFiles = await generateChunked(repoPath, scan, repoGraph, selectedDomains, mode === 'base-only', timeoutMs, existingDocsStrategy, verbose, model, discoveryFindings, domainsOnly); + allFiles = await generateChunked(repoPath, scan, repoGraph, selectedDomains, mode === 'base-only', timeoutMs, existingDocsStrategy, verbose, model, discoveryFindings, domainsOnly, { + writeIncrementally: shouldWriteIncrementally, + targets, + graphSerialized, + shouldForce, + writeState: incrementalWriteState, + }); } if (allFiles.length === 0) { @@ -539,27 +684,29 @@ export async function docInitCommand(path, options) { // Step 6: Validate generated files let validation = { valid: true, issues: [] }; - try { - validation = validateSkillFiles(allFiles, repoPath); - } catch (err) { - p.log.warn(`Validation failed: ${err.message}`); - } - - if (!validation.valid) { - console.log(); - p.log.warn(`Found ${validation.issues.length} issue(s) in generated skills:`); - for (const issue of validation.issues) { - const icon = issue.issue === 'bad-path' ? pc.yellow('?') : pc.red('!'); - console.log(pc.dim(' ') + `${icon} ${pc.dim(issue.file)} — ${issue.detail}`); + if (!shouldWriteIncrementally) { + try { + validation = validateSkillFiles(allFiles, repoPath); + } catch (err) { + p.log.warn(`Validation failed: ${err.message}`); } - // Filter out truncated files — they'd be useless - const truncated = validation.issues.filter(i => i.issue === 'truncated').map(i => i.file); - if (truncated.length > 0) { - allFiles = allFiles.filter(f => !truncated.includes(f.path)); - p.log.warn(`Removed ${truncated.length} truncated file(s). Re-run to regenerate them.`); + if (!validation.valid) { + console.log(); + p.log.warn(`Found ${validation.issues.length} issue(s) in generated skills:`); + for (const issue of validation.issues) { + const icon = issue.issue === 'bad-path' ? pc.yellow('?') : pc.red('!'); + console.log(pc.dim(' ') + `${icon} ${pc.dim(issue.file)} — ${issue.detail}`); + } + + // Filter out truncated files — they'd be useless + const truncated = validation.issues.filter(i => i.issue === 'truncated').map(i => i.file); + if (truncated.length > 0) { + allFiles = allFiles.filter(f => !truncated.includes(f.path)); + p.log.warn(`Removed ${truncated.length} truncated file(s). Re-run to regenerate them.`); + } + console.log(); } - console.log(); } // Step 6.5: Transform canonical output for each target @@ -568,90 +715,62 @@ export async function docInitCommand(path, options) { // For non-Claude targets: transform canonical → target format. // For --target all: keep canonical + add transformed for each non-Claude target. const canonicalFiles = [...allFiles]; // preserve originals - const nonClaudeTargets = targets.filter(t => t.id !== 'claude'); - - if (nonClaudeTargets.length > 0) { - for (const target of nonClaudeTargets) { - const transformSpinner = p.spinner(); - transformSpinner.start(`Transforming output for ${target.label}...`); - - const transformed = transformForTarget(canonicalFiles, TARGETS.claude, target, { - scanResult: scan, - graphSerialized, - }); - - const transformValidation = validateTransformedFiles(transformed); - if (!transformValidation.valid) { - p.log.warn(`Transform issues for ${target.label}:`); - for (const issue of transformValidation.issues) { - console.log(pc.dim(' ') + pc.yellow('!') + ' ' + issue); - } - } - - const validTransformed = transformValidation.valid - ? transformed - : transformed.filter(f => validateTransformedFiles([f]).valid); + if (!shouldWriteIncrementally) { + allFiles = buildOutputFilesForTargets(canonicalFiles, targets, scan, graphSerialized); + } - allFiles = [...allFiles, ...validTransformed]; - transformSpinner.stop(`Transformed ${validTransformed.length} files for ${target.label}`); + let results = []; + if (!shouldWriteIncrementally) { + // Step 7: Show what will be written + console.log(); + p.log.info('Files to write:'); + for (const file of allFiles) { + const hasIssues = validation.issues?.some(i => i.file === file.path) ?? false; + const willOverwrite = existsSync(join(repoPath, file.path)); + const willWrite = shouldForce || !willOverwrite || hasIssues; + const icon = hasIssues ? pc.yellow('~') : willWrite && willOverwrite ? pc.yellow('~') : willWrite ? pc.green('+') : pc.dim('-'); + console.log(pc.dim(' ') + icon + ' ' + file.path); } + console.log(); - // If no Claude target requested, remove the canonical Claude files from output - if (!targets.some(t => t.id === 'claude')) { - allFiles = allFiles.filter(f => !canonicalFiles.includes(f)); + // Dry run + if (options.dryRun) { + p.log.info('Dry run — no files written. Preview:'); + for (const file of allFiles) { + console.log(pc.bold(`\n--- ${file.path} ---`)); + console.log(pc.dim(file.content)); + } + showTokenSummary(startTime); + p.outro('Dry run complete'); + return; } - } - // Step 7: Show what will be written - const shouldForce = options.force || existingDocsStrategy === 'improve' || existingDocsStrategy === 'rewrite'; - console.log(); - p.log.info('Files to write:'); - for (const file of allFiles) { - const hasIssues = validation.issues?.some(i => i.file === file.path) ?? false; - const willOverwrite = existsSync(join(repoPath, file.path)); - const willWrite = shouldForce || !willOverwrite || hasIssues; - const icon = hasIssues ? pc.yellow('~') : willWrite && willOverwrite ? pc.yellow('~') : willWrite ? pc.green('+') : pc.dim('-'); - console.log(pc.dim(' ') + icon + ' ' + file.path); - } - console.log(); + // Confirm + const proceed = await p.confirm({ + message: `Write ${allFiles.length} files to ${repoPath}?`, + initialValue: true, + }); - // Dry run - if (options.dryRun) { - p.log.info('Dry run — no files written. Preview:'); - for (const file of allFiles) { - console.log(pc.bold(`\n--- ${file.path} ---`)); - console.log(pc.dim(file.content)); + if (p.isCancel(proceed) || !proceed) { + p.cancel('Aborted'); + return; } - showTokenSummary(startTime); - p.outro('Dry run complete'); - return; - } - // Confirm - const proceed = await p.confirm({ - message: `Write ${allFiles.length} files to ${repoPath}?`, - initialValue: true, - }); + // Step 8: Write files + const writeSpinner = p.spinner(); + writeSpinner.start('Writing files...'); + const directWriteFiles = allFiles.filter(f => !(f.path.endsWith('/AGENTS.md') && f.path !== 'AGENTS.md')); + const dirScopedFiles = allFiles.filter(f => f.path.endsWith('/AGENTS.md') && f.path !== 'AGENTS.md'); - if (p.isCancel(proceed) || !proceed) { - p.cancel('Aborted'); - return; + results = [ + ...writeSkillFiles(repoPath, directWriteFiles, { force: shouldForce }), + ...writeTransformedFiles(repoPath, dirScopedFiles, { force: shouldForce }), + ]; + writeSpinner.stop('Done'); + } else { + results = [...incrementalWriteState.resultsByPath.values()]; } - // Step 8: Write files - // Split: Claude-target files use writeSkillFiles (standard paths), - // directory-scoped files (e.g., src/billing/AGENTS.md) use writeTransformedFiles (warn-and-skip) - const writeSpinner = p.spinner(); - writeSpinner.start('Writing files...'); - const directWriteFiles = allFiles.filter(f => !(f.path.endsWith('/AGENTS.md') && f.path !== 'AGENTS.md')); - const dirScopedFiles = allFiles.filter(f => f.path.endsWith('/AGENTS.md') && f.path !== 'AGENTS.md'); - - const results = [ - ...writeSkillFiles(repoPath, directWriteFiles, { force: shouldForce }), - ...writeTransformedFiles(repoPath, dirScopedFiles, { force: shouldForce }), - ]; - writeSpinner.stop('Done'); - // Summary console.log(); const created = results.filter(r => r.status === 'created').length; @@ -673,16 +792,52 @@ export async function docInitCommand(path, options) { await installHooks(repoPath, options); } + const recommendedSummaryLines = []; + let nextSaveTokensConfig = existingConfig?.saveTokens; + if (recommended && !options.dryRun) { + if (hasHookTarget) { + if (options.hooks !== false) { + nextSaveTokensConfig = installSaveTokensRecommended(repoPath, existingConfig, targets, recommendedSummaryLines); + } + installRecommendedClaudeAgents(repoPath, recommendedSummaryLines); + } + + const gitRoot = getGitRoot(repoPath); + if (gitRoot && options.hook !== false) { + const hookPath = join(gitRoot, '.git', 'hooks', 'post-commit'); + const hookInstalled = existsSync(hookPath) && + readFileSync(hookPath, 'utf8').includes(`aspens doc-sync hook (${toPosixRelative(gitRoot, repoPath) || '.'})`); + if (!hookInstalled) { + installGitHook(repoPath); + } + } + } + + if (recommendedSummaryLines.length > 0) { + console.log(); + console.log(pc.dim(' Recommended setup:')); + for (const line of recommendedSummaryLines) { + console.log(` ${line}`); + } + } + // Step 10: Persist target config - writeConfig(repoPath, { targets: targetIds, backend: backend.id }); + writeConfig(repoPath, { targets: persistedTargets, backend: backend.id, saveTokens: nextSaveTokensConfig }); + + console.log(pc.dim(' Verification: ') + [ + `${targets.map(t => t.label).join(' + ')} configured`, + `${effectiveDomains.length} domain${effectiveDomains.length === 1 ? '' : 's'} analyzed`, + hasHookTarget && options.hooks !== false ? 'hooks updated where supported' : 'no hook changes', + ].join(' | ')); showTokenSummary(startTime); // Offer auto-sync git hook (works for all targets — runs `aspens doc sync` on commit) - if (options.hook !== false && !options.dryRun && existsSync(join(repoPath, '.git'))) { - const hookPath = join(repoPath, '.git', 'hooks', 'post-commit'); + const gitRoot = getGitRoot(repoPath); + if (!recommended && options.hook !== false && !options.dryRun && gitRoot) { + const hookPath = join(gitRoot, '.git', 'hooks', 'post-commit'); const hookInstalled = existsSync(hookPath) && - readFileSync(hookPath, 'utf8').includes('aspens doc'); + readFileSync(hookPath, 'utf8').includes(`aspens doc-sync hook (${toPosixRelative(gitRoot, repoPath) || '.'})`); if (!hookInstalled) { console.log(); const wantHook = await p.confirm({ @@ -703,6 +858,45 @@ export async function docInitCommand(path, options) { ); } +function installRecommendedClaudeAgents(repoPath, summaryLines) { + const agentsSourceDir = join(TEMPLATES_DIR, 'agents'); + const agentsTargetDir = join(repoPath, '.claude', 'agents'); + if (!existsSync(agentsSourceDir)) return; + mkdirSync(agentsTargetDir, { recursive: true }); + + for (const filename of readdirSync(agentsSourceDir).filter(name => name.endsWith('.md')).sort()) { + const source = join(agentsSourceDir, filename); + const target = join(agentsTargetDir, filename); + if (existsSync(target)) { + summaryLines.push(`${pc.dim('-')} .claude/agents/${filename} (already exists)`); + continue; + } + copyFileSync(source, target); + summaryLines.push(`${pc.green('+')} .claude/agents/${filename}`); + } + + ensureRecommendedAgentGitignore(repoPath, summaryLines); +} + +function ensureRecommendedAgentGitignore(repoPath, summaryLines) { + const gitignorePath = join(repoPath, '.gitignore'); + const entry = 'dev/'; + if (existsSync(gitignorePath)) { + const content = readFileSync(gitignorePath, 'utf8'); + if (/^dev\/$/m.test(content)) return; + writeFileSync(gitignorePath, content.trimEnd() + `\n${entry}\n`, 'utf8'); + } else { + writeFileSync(gitignorePath, `${entry}\n`, 'utf8'); + } + summaryLines.push(`${pc.green('+')} .gitignore (dev/)`); +} + +function toPosixRelative(from, to) { + const rel = relative(from, to); + if (!rel || rel === '.') return ''; + return rel.split('\\').join('/'); +} + function showTokenSummary(startTime) { if (tokenTracker.calls > 0) { const elapsed = Math.round((Date.now() - startTime) / 1000); @@ -814,8 +1008,9 @@ async function installHooks(repoPath, options) { // 9d: Merge settings.json let templateSettings; try { - templateSettings = JSON.parse( - readFileSync(join(TEMPLATES_DIR, 'settings', 'settings.json'), 'utf8') + templateSettings = createHookSettings( + repoPath, + JSON.parse(readFileSync(join(TEMPLATES_DIR, 'settings', 'settings.json'), 'utf8')) ); } catch (err) { hookSpinner.stop(pc.yellow('Hook installation incomplete')); @@ -869,6 +1064,10 @@ async function installHooks(repoPath, options) { } } +function createHookSettings(repoPath, templateSettings) { + return JSON.parse(JSON.stringify(templateSettings)); +} + // --- Generation modes --- function buildScanSummary(scan) { @@ -924,6 +1123,20 @@ 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'); +} + /** * Build targeted graph context for discovery agents. * Each agent only gets the graph sections it needs, not the full context. @@ -1261,9 +1474,16 @@ async function generateAllAtOnce(repoPath, scan, repoGraph, selectedDomains, tim } } -async function generateChunked(repoPath, scan, repoGraph, domains, baseOnly, timeoutMs, strategy, verbose, model, findings, domainsOnly = false) { +async function generateChunked(repoPath, scan, repoGraph, domains, baseOnly, timeoutMs, strategy, verbose, model, findings, domainsOnly = false, incrementalOptions = {}) { const allFiles = []; const skippedDomains = []; + const { + writeIncrementally = false, + targets = [], + graphSerialized = null, + shouldForce = false, + writeState = null, + } = incrementalOptions; const today = new Date().toISOString().split('T')[0]; const scanSummary = buildScanSummary(scan); const graphContext = buildGraphContext(repoGraph); @@ -1320,8 +1540,17 @@ async function generateChunked(repoPath, scan, repoGraph, domains, baseOnly, tim baseSpinner.stop(pc.yellow(`${baseLabel} — failed after retries`)); p.log.warn(`Could not generate ${baseLabel}. Try again with: aspens doc init --strategy rewrite --mode base-only`); } else { + files = validateGeneratedChunk(files, repoPath); allFiles.push(...files); baseSkillContent = files.find(f => f.path.includes('/base/'))?.content; + if (writeIncrementally && files.length > 0) { + writeIncrementalOutputs( + repoPath, + buildOutputFilesForTargets(allFiles, targets, scan, graphSerialized), + shouldForce, + writeState + ); + } baseSpinner.stop(pc.green(`${baseLabel} generated`)); } } catch (err) { @@ -1393,8 +1622,19 @@ async function generateChunked(repoPath, scan, repoGraph, domains, baseOnly, tim const succeeded = []; for (const result of results) { if (result.success && result.files.length > 0) { - allFiles.push(...result.files); - succeeded.push(result.domain); + const validFiles = validateGeneratedChunk(result.files, repoPath); + if (validFiles.length > 0) { + allFiles.push(...validFiles); + if (writeIncrementally) { + writeIncrementalOutputs( + repoPath, + buildOutputFilesForTargets(allFiles, targets, scan, graphSerialized), + shouldForce, + writeState + ); + } + succeeded.push(result.domain); + } } else if (!result.success) { skippedDomains.push(result.domain); } @@ -1439,8 +1679,9 @@ async function generateChunked(repoPath, scan, repoGraph, domains, baseOnly, tim }); } + const rootGraphContext = buildRootInstructionsGraphContext(repoGraph); const claudeMdPrompt = loadPrompt('doc-init-claudemd', CANONICAL_VARS) + - `\n\n---\n\nRepository path: ${repoPath}\n\n## Scan Results\nRepo: ${scan.name} (${scan.repoType})\nLanguages: ${scan.languages.join(', ')}\nFrameworks: ${scan.frameworks.join(', ')}\nEntry points: ${scan.entryPoints.join(', ')}\n\n## Generated Skills\n${skillSummaries}${existingClaudeMdSection}`; + `\n\n---\n\nRepository path: ${repoPath}\n\n## Scan Results\nRepo: ${scan.name} (${scan.repoType})\nLanguages: ${scan.languages.join(', ')}\nFrameworks: ${scan.frameworks.join(', ')}\nEntry points: ${scan.entryPoints.join(', ')}\n\n## Generated Skills\n${skillSummaries}${rootGraphContext ? `\n\n${rootGraphContext}` : ''}${existingClaudeMdSection}`; try { let { text, usage } = await runLLM(claudeMdPrompt, makeClaudeOptions(timeoutMs, verbose, model, claudeMdSpinner), _backendId); @@ -1462,7 +1703,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); + files = validateGeneratedChunk(files, repoPath); allFiles.push(...files); + if (writeIncrementally && files.length > 0) { + writeIncrementalOutputs( + repoPath, + buildOutputFilesForTargets(allFiles, targets, scan, graphSerialized), + shouldForce, + writeState + ); + } claudeMdSpinner.stop(pc.green(`${instructionsArtifactLabel()} generated`)); } } catch (err) { diff --git a/src/commands/doc-sync.js b/src/commands/doc-sync.js index 707bdbe..f02a4c7 100644 --- a/src/commands/doc-sync.js +++ b/src/commands/doc-sync.js @@ -12,7 +12,7 @@ import { buildDomainContext, buildBaseContext } from '../lib/context-builder.js' import { CliError } from '../lib/errors.js'; import { resolveTimeout } from '../lib/timeout.js'; import { installGitHook, removeGitHook } from '../lib/git-hook.js'; -import { isGitRepo, getGitDiff, getGitLog, getChangedFiles } from '../lib/git-helpers.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'; @@ -83,6 +83,8 @@ function publishFilesForTargets(baseFiles, sourceTarget, publishTargets, scan, g export async function docSyncCommand(path, options) { const repoPath = resolve(path); + const gitRoot = getGitRoot(repoPath); + const projectPrefix = toGitRelative(gitRoot, repoPath); const verbose = !!options.verbose; const commits = typeof options.commits === 'number' ? options.commits : 1; @@ -114,7 +116,7 @@ export async function docSyncCommand(path, options) { p.intro(pc.cyan('aspens doc sync')); // Step 1: Check prerequisites - if (!isGitRepo(repoPath)) { + if (!gitRoot || !isGitRepo(repoPath)) { throw new CliError('Not a git repository. doc sync requires git history.'); } @@ -126,20 +128,20 @@ export async function docSyncCommand(path, options) { const diffSpinner = p.spinner(); diffSpinner.start(`Reading last ${commits} commit(s)...`); - const { diff, actualCommits } = getGitDiff(repoPath, commits); + const { actualCommits } = getGitDiff(gitRoot, commits); if (actualCommits < commits) { diffSpinner.message(`Only ${actualCommits} commit(s) available (requested ${commits})`); } - const commitLog = getGitLog(repoPath, actualCommits); + const commitLog = getGitLog(gitRoot, actualCommits); + const changedFiles = scopeProjectFiles(getChangedFiles(gitRoot, actualCommits), projectPrefix); + diffSpinner.stop(`${changedFiles.length} files changed`); - if (!diff.trim()) { - diffSpinner.stop('No changes found'); + if (changedFiles.length === 0) { p.outro('Nothing to sync'); return; } - const changedFiles = getChangedFiles(repoPath, actualCommits); - diffSpinner.stop(`${changedFiles.length} files changed`); + const diff = getSelectedFilesDiff(gitRoot, changedFiles.map(file => withProjectPrefix(file, projectPrefix)), actualCommits); // Show what changed console.log(); @@ -231,7 +233,7 @@ export async function docSyncCommand(path, options) { // Build diff from selected files only, or use full prioritized diff let activeDiff; if (selectedFiles.length < changedFiles.length) { - activeDiff = getSelectedFilesDiff(repoPath, selectedFiles, actualCommits); + activeDiff = getSelectedFilesDiff(gitRoot, selectedFiles.map(file => withProjectPrefix(file, projectPrefix)), actualCommits); if (activeDiff.includes('(diff truncated')) { p.log.warn('Selected files still exceed 80k — diff truncated. Claude will use Read tool for the rest.'); } @@ -367,6 +369,25 @@ ${truncate(instructionsContent, 5000)} p.outro(`${results.length} file(s) updated`); } +function toGitRelative(gitRoot, repoPath) { + if (!gitRoot) return ''; + const rel = relative(gitRoot, repoPath); + if (!rel || rel === '.') return ''; + return rel.split('\\').join('/'); +} + +function withProjectPrefix(file, projectPrefix) { + return projectPrefix ? `${projectPrefix}/${file}` : file; +} + +function scopeProjectFiles(files, projectPrefix) { + if (!projectPrefix) return files; + const prefix = `${projectPrefix}/`; + return files + .filter(file => file.startsWith(prefix)) + .map(file => file.slice(prefix.length)); +} + // --- Skill mapping --- function findExistingSkills(repoPath, target) { diff --git a/src/commands/save-tokens.js b/src/commands/save-tokens.js new file mode 100644 index 0000000..8a221fc --- /dev/null +++ b/src/commands/save-tokens.js @@ -0,0 +1,318 @@ +import { resolve, join, dirname } from 'path'; +import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, chmodSync, rmSync } from 'fs'; +import { fileURLToPath } from 'url'; +import pc from 'picocolors'; +import * as p from '@clack/prompts'; +import { loadConfig, mergeConfiguredTargets, writeConfig } from '../lib/target.js'; +import { + buildSaveTokensConfig, + buildSaveTokensGitignore, + buildSaveTokensReadme, + buildSaveTokensSettings, +} from '../lib/save-tokens.js'; +import { mergeSettings } from '../lib/skill-writer.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const TEMPLATES_DIR = join(__dirname, '..', 'templates'); + +const CLAUDE_SAVE_TOKENS_HOOKS = [ + { src: 'hooks/save-tokens.mjs', dest: '.claude/hooks/save-tokens.mjs', chmod: false }, + { src: 'hooks/save-tokens-statusline.sh', dest: '.claude/hooks/save-tokens-statusline.sh', chmod: true }, + { src: 'hooks/save-tokens-prompt-guard.sh', dest: '.claude/hooks/save-tokens-prompt-guard.sh', chmod: true }, + { src: 'hooks/save-tokens-precompact.sh', dest: '.claude/hooks/save-tokens-precompact.sh', chmod: true }, +]; + +export async function saveTokensCommand(path = '.', _options = {}) { + const repoPath = resolve(path); + const { config: existingConfig } = loadConfig(repoPath, { persist: false }); + const targets = existingConfig?.targets?.length ? existingConfig.targets : ['claude']; + const saveTokensConfig = buildSaveTokensConfig(existingConfig?.saveTokens); + + p.intro(pc.cyan('aspens save-tokens')); + + if (_options.remove) { + removeSaveTokens(repoPath, existingConfig); + p.outro(pc.green('save-tokens removed')); + return; + } + + if (_options.recommended) { + const summaryLines = []; + const finalConfig = installSaveTokensRecommended(repoPath, existingConfig, targets.map(id => ({ id })), summaryLines); + const persistedTargets = mergeConfiguredTargets(existingConfig?.targets, targets); + writeConfig(repoPath, { + targets: persistedTargets, + backend: existingConfig?.backend ?? null, + saveTokens: finalConfig, + }); + summaryLines.push(`${pc.yellow('~')} .aspens.json`); + renderInstallSummary(summaryLines, targets.includes('claude'), targets.includes('codex')); + p.outro(pc.green('save-tokens configured')); + return; + } + + const selectedFeatures = await selectSaveTokensFeatures(); + if (!selectedFeatures) return; + + const confirmInstall = await p.confirm({ + message: 'Install selected save-tokens settings?', + initialValue: true, + }); + + if (p.isCancel(confirmInstall) || !confirmInstall) { + p.cancel('Aborted'); + return; + } + + const finalConfig = configFromSelectedFeatures(saveTokensConfig, selectedFeatures); + + const sessionsDir = join(repoPath, '.aspens', 'sessions'); + mkdirSync(sessionsDir, { recursive: true }); + const readmePath = join(sessionsDir, 'README.md'); + const hadReadme = existsSync(readmePath); + writeFileSync(join(sessionsDir, '.gitignore'), buildSaveTokensGitignore(), 'utf8'); + if (!hadReadme) { + writeFileSync(readmePath, buildSaveTokensReadme(), 'utf8'); + } + + const summaryLines = []; + summaryLines.push(`${pc.green('+')} .aspens/sessions/.gitignore`); + if (!hadReadme) { + summaryLines.push(`${pc.green('+')} .aspens/sessions/README.md`); + } else { + summaryLines.push(`${pc.dim('-')} .aspens/sessions/README.md (already exists)`); + } + + const hasClaudeTarget = targets.includes('claude'); + if (hasClaudeTarget) { + const installResult = installClaudeSaveTokens(repoPath, summaryLines); + applyStatusLineAvailability(finalConfig, installResult.statusLineInstalled, summaryLines); + } + + const persistedTargets = mergeConfiguredTargets(existingConfig?.targets, targets); + writeConfig(repoPath, { + targets: persistedTargets, + backend: existingConfig?.backend ?? null, + saveTokens: finalConfig, + }); + summaryLines.push(`${pc.yellow('~')} .aspens.json`); + + renderInstallSummary(summaryLines, hasClaudeTarget, targets.includes('codex')); + + p.outro(pc.green('save-tokens configured')); +} + +function renderInstallSummary(summaryLines, hasClaudeTarget, hasCodexTarget) { + console.log(); + for (const line of summaryLines) { + console.log(` ${line}`); + } + console.log(); + console.log(pc.dim(' Claude: ') + (hasClaudeTarget ? 'automatic save-tokens hooks installed' : 'not configured for this repo')); + if (hasCodexTarget) { + console.log(pc.dim(' Codex: ') + 'no automatic save-tokens integration installed'); + } + console.log(); +} + +export function installSaveTokensRecommended(repoPath, existingConfig, targets, summaryLines = []) { + const saveTokensConfig = buildSaveTokensConfig(existingConfig?.saveTokens); + const sessionsDir = join(repoPath, '.aspens', 'sessions'); + mkdirSync(sessionsDir, { recursive: true }); + + const readmePath = join(sessionsDir, 'README.md'); + const hadReadme = existsSync(readmePath); + writeFileSync(join(sessionsDir, '.gitignore'), buildSaveTokensGitignore(), 'utf8'); + if (!hadReadme) { + writeFileSync(readmePath, buildSaveTokensReadme(), 'utf8'); + } + + summaryLines.push(`${pc.green('+')} .aspens/sessions/.gitignore`); + summaryLines.push(hadReadme + ? `${pc.dim('-')} .aspens/sessions/README.md (already exists)` + : `${pc.green('+')} .aspens/sessions/README.md`); + + const hasClaudeTarget = targets.some(target => target.id === 'claude'); + if (hasClaudeTarget) { + const installResult = installClaudeSaveTokens(repoPath, summaryLines); + applyStatusLineAvailability(saveTokensConfig, installResult.statusLineInstalled, summaryLines); + } + + return saveTokensConfig; +} + +async function selectSaveTokensFeatures() { + const selected = await p.multiselect({ + message: 'Select save-tokens settings:', + required: true, + initialValues: ['warnings', 'handoffs'], + options: [ + { value: 'warnings', label: 'Claude token warnings', hint: 'warns at 175k and strongly recommends fresh-session handoff at 200k' }, + { value: 'handoffs', label: 'Automatic handoff saves', hint: 'saves basic handoffs before compacting and at 200k' }, + ], + }); + + if (p.isCancel(selected)) { + p.cancel('Aborted'); + return null; + } + return selected; +} + +function configFromSelectedFeatures(baseConfig, selected) { + const enabled = selected.length > 0; + return { + ...baseConfig, + enabled, + warnAtTokens: selected.includes('warnings') ? baseConfig.warnAtTokens : Number.MAX_SAFE_INTEGER, + compactAtTokens: selected.includes('warnings') ? baseConfig.compactAtTokens : Number.MAX_SAFE_INTEGER, + saveHandoff: selected.includes('handoffs'), + sessionRotation: selected.includes('warnings'), + claude: { + ...baseConfig.claude, + enabled, + }, + }; +} + +function installClaudeSaveTokens(repoPath, summaryLines) { + const hooksDir = join(repoPath, '.claude', 'hooks'); + mkdirSync(hooksDir, { recursive: true }); + + for (const hook of CLAUDE_SAVE_TOKENS_HOOKS) { + const src = join(TEMPLATES_DIR, hook.src); + const dest = join(repoPath, hook.dest); + const existed = existsSync(dest); + mkdirSync(dirname(dest), { recursive: true }); + copyFileSync(src, dest); + if (hook.chmod) chmodSync(dest, 0o755); + summaryLines.push(`${existed ? pc.yellow('~') : pc.green('+')} ${hook.dest}`); + } + + const settingsPath = join(repoPath, '.claude', 'settings.json'); + const existingSettings = readJsonFile(settingsPath, summaryLines, '.claude/settings.json'); + if (existsSync(settingsPath) && existingSettings === null) { + p.log.error(`Invalid JSON in ${settingsPath}. Fix or restore the file before running aspens save-tokens.`); + process.exit(1); + } + const statusLineInstalled = canInstallSaveTokensStatusLine(existingSettings); + + if (existingSettings && !existsSync(settingsPath + '.bak')) { + writeFileSync(settingsPath + '.bak', JSON.stringify(existingSettings, null, 2) + '\n', 'utf8'); + summaryLines.push(`${pc.green('+')} .claude/settings.json.bak`); + } + + const merged = mergeSettings(existingSettings, buildSaveTokensSettings()); + writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n', 'utf8'); + summaryLines.push(`${existingSettings ? pc.yellow('~') : pc.green('+')} .claude/settings.json`); + + const commands = ['save-handoff.md', 'resume-handoff-latest.md', 'resume-handoff.md']; + for (const cmd of commands) { + const src = join(TEMPLATES_DIR, 'commands', cmd); + const dest = join(repoPath, '.claude', 'commands', cmd); + const existed = existsSync(dest); + mkdirSync(dirname(dest), { recursive: true }); + copyFileSync(src, dest); + summaryLines.push(`${existed ? pc.yellow('~') : pc.green('+')} .claude/commands/${cmd}`); + } + + return { statusLineInstalled }; +} + +function readJsonFile(path, summaryLines, label) { + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, 'utf8')); + } catch { + summaryLines.push(`${pc.yellow('!')} ${label} (invalid JSON; left unchanged)`); + return null; + } +} + +function canInstallSaveTokensStatusLine(settings) { + if (!settings?.statusLine) return true; + return String(settings.statusLine.command || '').includes('save-tokens-statusline'); +} + +function applyStatusLineAvailability(config, statusLineInstalled, summaryLines) { + if (statusLineInstalled) return config; + config.warnAtTokens = Number.MAX_SAFE_INTEGER; + config.compactAtTokens = Number.MAX_SAFE_INTEGER; + config.sessionRotation = false; + summaryLines.push(`${pc.yellow('!')} save-tokens token warnings disabled (custom Claude statusLine is already configured)`); + return config; +} + +function removeSaveTokens(repoPath, existingConfig) { + const summaryLines = []; + const filesToRemove = [ + '.claude/hooks/save-tokens-lib.mjs', + '.claude/hooks/save-tokens.mjs', + '.claude/hooks/save-tokens-statusline.sh', + '.claude/hooks/save-tokens-statusline.mjs', + '.claude/hooks/save-tokens-prompt-guard.sh', + '.claude/hooks/save-tokens-prompt-guard.mjs', + '.claude/hooks/save-tokens-precompact.sh', + '.claude/hooks/save-tokens-precompact.mjs', + '.claude/commands/save-handoff.md', + '.claude/commands/resume-handoff-latest.md', + '.claude/commands/resume-handoff.md', + '.claude/commands/save-tokens-resume.md', + ]; + + for (const rel of filesToRemove) { + const full = join(repoPath, rel); + if (!existsSync(full)) continue; + rmSync(full, { force: true }); + summaryLines.push(`${pc.red('-')} ${rel}`); + } + + const settingsPath = join(repoPath, '.claude', 'settings.json'); + if (existsSync(settingsPath)) { + try { + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')); + const cleaned = removeSaveTokensFromSettings(settings); + writeFileSync(settingsPath, JSON.stringify(cleaned, null, 2) + '\n', 'utf8'); + summaryLines.push(`${pc.yellow('~')} .claude/settings.json`); + } catch { + summaryLines.push(`${pc.yellow('!')} .claude/settings.json (invalid JSON; left unchanged)`); + } + } + + if (existingConfig) { + writeConfig(repoPath, { + targets: existingConfig.targets, + backend: existingConfig.backend, + saveTokens: null, + }); + summaryLines.push(`${pc.yellow('~')} .aspens.json`); + } + + console.log(); + for (const line of summaryLines) { + console.log(` ${line}`); + } + if (summaryLines.length === 0) { + console.log(pc.dim(' No save-tokens installation found.')); + } + console.log(); +} + +function removeSaveTokensFromSettings(settings) { + const cleaned = JSON.parse(JSON.stringify(settings)); + if (cleaned.statusLine?.command?.includes('save-tokens-statusline')) { + delete cleaned.statusLine; + } + + for (const [eventName, entries] of Object.entries(cleaned.hooks || {})) { + if (!Array.isArray(entries)) continue; + cleaned.hooks[eventName] = entries + .map(entry => ({ + ...entry, + hooks: (entry.hooks || []).filter(hook => !String(hook.command || '').includes('save-tokens-')), + })) + .filter(entry => (entry.hooks || []).length > 0); + } + + return cleaned; +} diff --git a/src/index.js b/src/index.js index 882a8bd..0efb6f9 100644 --- a/src/index.js +++ b/src/index.js @@ -2,3 +2,4 @@ export { scanRepo } from './lib/scanner.js'; export { runClaude, loadPrompt, parseFileOutput } from './lib/runner.js'; export { writeSkillFiles } from './lib/skill-writer.js'; export { buildContext, buildBaseContext, buildDomainContext } from './lib/context-builder.js'; +export { analyzeImpact } from './lib/impact.js'; diff --git a/src/lib/git-helpers.js b/src/lib/git-helpers.js index 8092d50..ced4fca 100644 --- a/src/lib/git-helpers.js +++ b/src/lib/git-helpers.js @@ -1,14 +1,22 @@ import { execFileSync } from 'child_process'; -export function isGitRepo(repoPath) { +export function getGitRoot(repoPath) { try { - execFileSync('git', ['rev-parse', '--git-dir'], { cwd: repoPath, stdio: 'pipe', timeout: 5000 }); - return true; + return execFileSync('git', ['rev-parse', '--show-toplevel'], { + cwd: repoPath, + encoding: 'utf8', + stdio: 'pipe', + timeout: 5000, + }).trim(); } catch { - return false; + return null; } } +export function isGitRepo(repoPath) { + return !!getGitRoot(repoPath); +} + export function getGitDiff(repoPath, commits) { // Try requested commit count, fall back to fewer for (let n = commits; n >= 1; n--) { diff --git a/src/lib/git-hook.js b/src/lib/git-hook.js index 59cbd92..039dd58 100644 --- a/src/lib/git-hook.js +++ b/src/lib/git-hook.js @@ -1,8 +1,9 @@ -import { join } from 'path'; +import { join, relative } from 'path'; import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, chmodSync } from 'fs'; import { execSync } from 'child_process'; import pc from 'picocolors'; import { CliError } from './errors.js'; +import { getGitRoot } from './git-helpers.js'; function resolveAspensPath() { const cmd = process.platform === 'win32' ? 'where aspens' : 'which aspens'; @@ -18,28 +19,38 @@ function resolveAspensPath() { } export function installGitHook(repoPath) { - const hookDir = join(repoPath, '.git', 'hooks'); - const hookPath = join(hookDir, 'post-commit'); - - if (!existsSync(join(repoPath, '.git'))) { + const gitRoot = getGitRoot(repoPath); + if (!gitRoot) { throw new CliError('Not a git repository.'); } + const projectRelative = toPosixRelative(gitRoot, repoPath); + const projectLabel = projectRelative || '.'; + const projectSlug = projectRelative + ? projectRelative.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, '') + : 'root'; + const generatedPrefix = projectRelative ? `${projectRelative}/` : ''; + const projectPathExpr = projectRelative ? `"\${REPO_ROOT}/${projectRelative}"` : '"${REPO_ROOT}"'; + const scopePrefix = projectRelative ? `grep '^${escapeForSingleQuotes(projectRelative)}/' | ` : ''; + + const hookDir = join(gitRoot, '.git', 'hooks'); + const hookPath = join(hookDir, 'post-commit'); mkdirSync(hookDir, { recursive: true }); const aspensCmd = resolveAspensPath(); const hookBlock = ` -# >>> aspens doc-sync hook (do not edit) >>> -__aspens_doc_sync() { +# >>> aspens doc-sync hook (${projectLabel}) (do not edit) >>> +__aspens_doc_sync_${projectSlug}() { REPO_ROOT="\$(git rev-parse --show-toplevel 2>/dev/null)" || return 0 - REPO_HASH="\$(echo "\$REPO_ROOT" | (shasum 2>/dev/null || sha1sum 2>/dev/null || md5sum 2>/dev/null) | cut -c1-8)" + PROJECT_PATH=${projectPathExpr} + REPO_HASH="\$(printf '%s' "\$PROJECT_PATH" | (shasum 2>/dev/null || sha1sum 2>/dev/null || md5sum 2>/dev/null) | cut -c1-8)" ASPENS_LOCK="/tmp/aspens-sync-\${REPO_HASH}.lock" ASPENS_LOG="/tmp/aspens-sync-\${REPO_HASH}.log" # Skip aspens-only commits (skills, CLAUDE.md, AGENTS.md, graph artifacts) CHANGED="\$(git diff-tree --no-commit-id --name-only -r HEAD 2>/dev/null)" - NON_ASPENS="\$(echo "\$CHANGED" | grep -v '^\.claude/' | grep -v '^\.codex/' | grep -v '^\.agents/' | grep -v '^CLAUDE\.md\$' | grep -v '^AGENTS\.md\$' | grep -v '/AGENTS\.md\$' | grep -v '^\.aspens\.json\$' || true)" + NON_ASPENS="\$(echo "\$CHANGED" | ${scopePrefix}grep -v '^${generatedPrefix}\\.claude/' | grep -v '^${generatedPrefix}\\.codex/' | grep -v '^${generatedPrefix}\\.agents/' | grep -v '^${generatedPrefix}CLAUDE\\.md\$' | grep -v '^${generatedPrefix}AGENTS\\.md\$' | grep -v '^${generatedPrefix}.*\\/AGENTS\\.md\$' | grep -v '^${generatedPrefix}\\.aspens\\.json\$' || true)" if [ -z "\$NON_ASPENS" ]; then return 0 fi @@ -63,26 +74,29 @@ __aspens_doc_sync() { fi # Run fully detached so git returns immediately (POSIX-compatible — no disown needed) - (echo "[sync] \$(date '+%Y-%m-%d %H:%M:%S') started" >> "\$ASPENS_LOG" && ${aspensCmd} doc sync --commits 1 "\$REPO_ROOT" >> "\$ASPENS_LOG" 2>&1; echo "[sync] \$(date '+%Y-%m-%d %H:%M:%S') finished (exit \$?)" >> "\$ASPENS_LOG") /dev/null 2>&1 & + (echo "[sync] \$(date '+%Y-%m-%d %H:%M:%S') started (${projectLabel})" >> "\$ASPENS_LOG" && ${aspensCmd} doc sync --commits 1 "\$PROJECT_PATH" >> "\$ASPENS_LOG" 2>&1; echo "[sync] \$(date '+%Y-%m-%d %H:%M:%S') finished (exit \$?)" >> "\$ASPENS_LOG") /dev/null 2>&1 & } -__aspens_doc_sync -# <<< aspens doc-sync hook <<< +__aspens_doc_sync_${projectSlug} +# <<< aspens doc-sync hook (${projectLabel}) <<< `; - // Check for existing hook if (existsSync(hookPath)) { const existing = readFileSync(hookPath, 'utf8'); - if (existing.includes('aspens doc-sync hook') || existing.includes('aspens doc sync')) { - console.log(pc.yellow('\n Hook already installed.\n')); + if (existing.includes(`# >>> aspens doc-sync hook (${projectLabel})`)) { + console.log(pc.yellow(`\n Hook already installed for ${projectLabel}.\n`)); + return; + } + if (hasUnlabeledAspensBlock(existing)) { + writeFileSync(hookPath, existing.replace(buildUnlabeledMarkerRegex(), '\n' + hookBlock).trim() + '\n', 'utf8'); + console.log(pc.green(`\n Upgraded aspens doc-sync hook for ${projectLabel}.\n`)); return; } - // Append to existing hook (outside shebang) writeFileSync(hookPath, existing + '\n' + hookBlock, 'utf8'); - console.log(pc.green('\n Appended aspens doc-sync to existing post-commit hook.\n')); + console.log(pc.green(`\n Appended aspens doc-sync for ${projectLabel} to existing post-commit hook.\n`)); } else { writeFileSync(hookPath, '#!/bin/sh\n' + hookBlock, 'utf8'); chmodSync(hookPath, 0o755); - console.log(pc.green('\n Installed post-commit hook.\n')); + console.log(pc.green(`\n Installed post-commit hook for ${projectLabel}.\n`)); } console.log(pc.dim(' Skills will auto-update after every commit.')); @@ -91,7 +105,14 @@ __aspens_doc_sync } export function removeGitHook(repoPath) { - const hookPath = join(repoPath, '.git', 'hooks', 'post-commit'); + const gitRoot = getGitRoot(repoPath); + if (!gitRoot) { + console.log(pc.yellow('\n No post-commit hook found.\n')); + return; + } + + const projectLabel = toPosixRelative(gitRoot, repoPath) || '.'; + const hookPath = join(gitRoot, '.git', 'hooks', 'post-commit'); if (!existsSync(hookPath)) { console.log(pc.yellow('\n No post-commit hook found.\n')); @@ -108,16 +129,19 @@ export function removeGitHook(repoPath) { } if (hasMarkers) { - const cleaned = content - .replace(/\n?# >>> aspens doc-sync hook \(do not edit\) >>>[\s\S]*?# <<< aspens doc-sync hook <<<\n?/, '') - .trim(); + const cleaned = content.replace(buildMarkerRegex(projectLabel), '').trim(); + + if (cleaned === content.trim()) { + console.log(pc.yellow(`\n No aspens hook found for ${projectLabel}.\n`)); + return; + } if (!cleaned || cleaned === '#!/bin/sh') { unlinkSync(hookPath); - console.log(pc.green('\n Removed post-commit hook.\n')); + console.log(pc.green(`\n Removed post-commit hook for ${projectLabel}.\n`)); } else { writeFileSync(hookPath, cleaned + '\n', 'utf8'); - console.log(pc.green('\n Removed aspens doc-sync from post-commit hook.\n')); + console.log(pc.green(`\n Removed aspens doc-sync for ${projectLabel} from post-commit hook.\n`)); } } else { console.log(pc.yellow('\n Legacy aspens hook detected (no removal markers).')); @@ -125,3 +149,32 @@ export function removeGitHook(repoPath) { console.log(pc.dim(' Or edit manually: .git/hooks/post-commit\n')); } } + +function toPosixRelative(from, to) { + const rel = relative(from, to); + if (!rel || rel === '.') return ''; + return rel.split('\\').join('/'); +} + +function escapeForSingleQuotes(value) { + return value.replace(/'/g, `'\\''`); +} + +function escapeForRegex(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function buildMarkerRegex(projectLabel) { + const escaped = escapeForRegex(projectLabel); + return new RegExp( + `\\n?# >>> aspens doc-sync hook \\(${escaped}\\) \\(do not edit\\) >>>[\\s\\S]*?# <<< aspens doc-sync hook \\(${escaped}\\) <<<\\n?` + ); +} + +function hasUnlabeledAspensBlock(content) { + return content.includes('# >>> aspens doc-sync hook (do not edit) >>>'); +} + +function buildUnlabeledMarkerRegex() { + return /\n?# >>> aspens doc-sync hook \(do not edit\) >>>[\s\S]*?# <<< aspens doc-sync hook << TARGETS[id]).filter(Boolean); + const sourceState = collectSourceState(repoPath); + + let graph = null; + if (options.graph !== false) { + try { + graph = await buildRepoGraph(repoPath, scan.languages); + } catch { + graph = null; + } + } + + const targetReports = targets.map(target => summarizeTarget(repoPath, target, scan, graph, sourceState, config)); + const summary = summarizeReport(targetReports, sourceState); + summary.opportunities = summarizeOpportunities(repoPath, targetReports, config); + + return { + scan, + sourceState, + targets: targetReports, + summary, + graph, + }; +} + +export function summarizeTarget(repoPath, target, scan, graph, sourceState, config = null) { + const skillFiles = findSkillFiles(join(repoPath, target.skillsDir), { + skillFilename: target.skillFilename, + }); + const hookHealth = target.supportsHooks ? evaluateHookHealth(repoPath) : null; + const saveTokensHealth = target.id === 'claude' ? evaluateSaveTokensHealth(repoPath, config?.saveTokens || null) : null; + const instructionPath = join(repoPath, target.instructionsFile); + const instructionExists = existsSync(instructionPath); + const contextText = buildContextText(repoPath, target, skillFiles); + const topHubs = Array.isArray(graph?.hubs) ? graph.hubs.slice(0, 5).map(hub => hub.path) : []; + const lastUpdated = latestMtime([ + ...(instructionExists ? [instructionPath] : []), + ...skillFiles.map(skill => skill.path), + ]); + const domainCoverage = computeDomainCoverage(scan.domains, skillFiles); + const hubCoverage = computeHubCoverage(topHubs, contextText); + const drift = computeDrift(sourceState, lastUpdated, scan.domains); + const status = computeTargetStatus({ + instructionExists, + skillCount: skillFiles.length, + hookHealth, + domainCoverage, + drift, + }, target); + const health = computeHealthScore({ + instructionExists, + skillCount: skillFiles.length, + hooksHealthy: status.hooks === 'healthy', + domainCoverage, + hubCoverage, + drift, + saveTokensHealth, + }, target); + const usefulness = summarizeUsefulness({ + target, + skillCount: skillFiles.length, + domainCoverage, + hubCoverage, + status, + }); + const actions = recommendActions({ + repoPath, + target, + status, + drift, + domainCoverage, + hubCoverage, + usefulness, + }); + + return { + id: target.id, + label: target.label, + instructionsFile: target.instructionsFile, + instructionExists, + skillCount: skillFiles.length, + hooksInstalled: status.hooksInstalled, + hookHealth, + saveTokensHealth, + lastUpdated, + drift, + domainCoverage, + hubCoverage, + status, + health, + usefulness, + actions, + }; +} + +export function computeDomainCoverage(domains, skills) { + const domainList = (domains || []) + .map(domain => domain?.name?.toLowerCase()) + .filter(Boolean); + const relevantDomains = domainList.filter(name => !LOW_SIGNAL_DOMAIN_NAMES.has(name)); + const excludedDomains = domainList.filter(name => LOW_SIGNAL_DOMAIN_NAMES.has(name)); + + const details = relevantDomains + .map(name => { + const match = findMatchingSkill(skills || [], name); + return { + domain: name, + status: match ? 'covered' : 'missing', + reason: match?.reason || 'no matching skill or activation rule', + skill: match?.skillName || null, + }; + }); + + return { + covered: details.filter(detail => detail.status === 'covered').length, + total: details.length, + missing: details.filter(detail => detail.status === 'missing').map(detail => detail.domain), + excluded: excludedDomains, + details, + }; +} + +export function computeHubCoverage(hubPaths, contextText) { + const haystack = (contextText || '').toLowerCase(); + const mentioned = (hubPaths || []).filter(path => haystack.includes(path.toLowerCase())); + return { + mentioned: mentioned.length, + total: hubPaths?.length || 0, + paths: mentioned, + }; +} + +export function computeHealthScore(input, target) { + let score = 100; + + if (!input.instructionExists) score -= 35; + if (input.skillCount === 0) score -= 25; + if (input.domainCoverage.total > 0) { + const missingRatio = (input.domainCoverage.total - input.domainCoverage.covered) / input.domainCoverage.total; + score -= Math.round(missingRatio * 25); + } + if (input.hubCoverage.total > 0) { + const missedHubs = input.hubCoverage.total - input.hubCoverage.mentioned; + score -= missedHubs * 4; + } + if (input.drift.changedFiles.length > 0) { + score -= Math.min(20, input.drift.changedFiles.length * 3); + } + if (target.supportsHooks && !input.hooksHealthy) { + score -= 10; + } + if (input.saveTokensHealth?.configured && !input.saveTokensHealth?.healthy) { + score -= 5; + } + + return Math.max(0, Math.min(100, score)); +} + +export function computeDrift(sourceState, lastUpdated, domains = []) { + const changedFiles = (sourceState?.files || []) + .filter(file => !lastUpdated || file.mtimeMs > lastUpdated) + .sort((a, b) => b.mtimeMs - a.mtimeMs); + + const affectedDomains = new Set(); + for (const file of changedFiles) { + const hit = (domains || []).find(domain => + (domain.directories || []).some(dir => file.path.startsWith(dir + '/') || file.path === dir) + ); + if (hit?.name) affectedDomains.add(hit.name.toLowerCase()); + } + + const latestChange = changedFiles[0]?.mtimeMs || sourceState?.newestSourceMtime || 0; + + return { + changedFiles, + changedCount: changedFiles.length, + affectedDomains: [...affectedDomains], + latestChange, + driftMs: lastUpdated && latestChange ? Math.max(0, latestChange - lastUpdated) : 0, + }; +} + +export function computeTargetStatus(input, target) { + const instructions = !input.instructionExists ? 'missing' + : input.drift.changedCount > 0 ? 'stale' + : 'healthy'; + const domains = input.domainCoverage.total === 0 ? 'n/a' + : input.domainCoverage.covered === input.domainCoverage.total ? 'healthy' + : input.domainCoverage.covered === 0 ? 'missing' + : 'partial'; + const hooksInstalled = target.supportsHooks ? !!input.hookHealth?.installed : false; + const hooks = !target.supportsHooks ? 'n/a' + : !input.hookHealth?.installed ? 'missing' + : input.hookHealth?.healthy ? 'healthy' + : 'broken'; + + return { + instructions, + domains, + hooks, + hooksInstalled, + }; +} + +export function recommendActions(target) { + const actions = []; + if (target.status.instructions === 'missing' || target.status.domains === 'missing') { + actions.push('aspens doc init --recommended'); + } else if (target.status.instructions === 'stale' || target.drift.changedCount > 0) { + actions.push('aspens doc sync'); + } + if (target.status.hooks === 'missing' || target.status.hooks === 'broken') { + actions.push('aspens doc init --hooks-only'); + } + if (target.status.domains === 'partial') { + const missing = target.domainCoverage?.missing || []; + if (missing.length > 0 && missing.length <= 3) { + actions.push(`aspens doc init --mode chunked --domains ${missing.join(',')}`); + } else if (!actions.includes('aspens doc init --recommended')) { + actions.push('aspens doc init --recommended'); + } + } + if ( + target.status.instructions === 'healthy' && + target.status.domains === 'healthy' && + (target.status.hooks === 'healthy' || target.status.hooks === 'n/a') && + target.hubCoverage?.total > 0 && + target.hubCoverage.mentioned < target.hubCoverage.total + ) { + actions.push('aspens doc init --mode base-only --strategy rewrite'); + } + return [...new Set(actions)]; +} + +export function summarizeReport(targets, sourceState) { + const staleTargets = targets.filter(target => target.status.instructions === 'stale'); + const missingTargets = targets.filter(target => + target.status.instructions === 'missing' || + target.status.domains === 'missing' || + target.status.hooks === 'missing' || + target.status.hooks === 'broken' + ); + const partialTargets = targets.filter(target => target.status.domains === 'partial'); + const actions = [...new Set(targets.flatMap(target => target.actions))]; + const missing = summarizeMissing(targets); + + return { + repoStatus: + missingTargets.length > 0 ? 'missing context' + : staleTargets.length > 0 ? 'partially stale' + : partialTargets.length > 0 ? 'partial coverage' + : 'healthy', + changedFiles: Math.max(...targets.map(target => target.drift.changedCount), 0), + affectedTargets: targets.filter(target => + target.drift.changedCount > 0 || + target.status.domains !== 'healthy' || + target.status.instructions !== 'healthy' || + (target.status.hooks !== 'healthy' && target.status.hooks !== 'n/a') + ).length, + actions, + averageHealth: targets.length > 0 + ? Math.round(targets.reduce((sum, target) => sum + target.health, 0) / targets.length) + : 0, + latestSourceMtime: sourceState.newestSourceMtime, + missing, + }; +} + +export function summarizeValueComparison(targets) { + const instructionFilesPresent = targets.filter(target => target.instructionExists).length; + const totalSkills = targets.reduce((sum, target) => sum + (target.skillCount || 0), 0); + const coveredDomains = Math.max(...targets.map(target => target.domainCoverage?.covered || 0), 0); + const totalDomains = Math.max(...targets.map(target => target.domainCoverage?.total || 0), 0); + const hookTargets = targets.filter(target => target.status?.hooks !== 'n/a').length; + const healthyHooks = targets.filter(target => target.status?.hooks === 'healthy').length; + const staleTargets = targets.filter(target => target.status?.instructions === 'stale'); + const driftCount = Math.max(...targets.map(target => target.drift?.changedCount || 0), 0); + const surfacedHubs = Math.max(...targets.map(target => target.hubCoverage?.mentioned || 0), 0); + const totalHubs = Math.max(...targets.map(target => target.hubCoverage?.total || 0), 0); + + return { + withoutAspens: `Without aspens artifacts, these targets would have 0 generated instruction files, 0 generated skills, and 0 surfaced hub files.`, + withAspens: `With aspens now: ${instructionFilesPresent}/${targets.length} instruction file${targets.length === 1 ? '' : 's'} present, ${totalSkills} generated skill${totalSkills === 1 ? '' : 's'}, ${coveredDomains}/${totalDomains || 0} meaningful source domain${totalDomains === 1 ? '' : 's'} mapped, ${totalHubs > 0 ? `${surfacedHubs}/${totalHubs} top hub files surfaced` : 'no hub data available'}.`, + freshness: staleTargets.length > 0 + ? `${staleTargets.length} target(s) are stale with ${driftCount} changed source file(s) since the last generation.` + : 'Generated docs are current against the source tree.', + automation: hookTargets > 0 + ? `${healthyHooks}/${hookTargets} hook-capable target${hookTargets === 1 ? '' : 's'} ${hookTargets === 1 ? 'has' : 'have'} automatic context loading installed.` + : 'No hook-capable targets detected.', + }; +} + +export function summarizeOpportunities(repoPath, targets, config = null) { + const opportunities = []; + const hasClaude = (targets || []).some(target => target.id === 'claude'); + + if (hasClaude && !config?.saveTokens?.enabled) { + opportunities.push({ + kind: 'save-tokens', + message: 'Save-tokens features are not installed', + command: 'aspens save-tokens', + }); + } + + const claudeAgentCount = hasClaude ? countClaudeAgentTemplates(repoPath) : 0; + if (hasClaude && claudeAgentCount === 0) { + opportunities.push({ + kind: 'agents', + message: 'Claude agents are not installed. Add all agents, then customize them to this codebase', + command: 'aspens add agent all && aspens customize agents', + }); + } else if (hasClaude && claudeAgentCount > 0) { + opportunities.push({ + kind: 'customize-agents', + message: 'Claude agents can be customized with this project’s generated context', + command: 'aspens customize agents', + }); + } + + if (!isDocSyncHookInstalled(repoPath)) { + opportunities.push({ + kind: 'doc-sync-hook', + message: 'Automatic context sync after git commits is not installed', + command: 'aspens doc sync --install-hook', + }); + } + + return opportunities; +} + +function countClaudeAgentTemplates(repoPath) { + const agentsDir = join(repoPath, '.claude', 'agents'); + if (!existsSync(agentsDir)) return 0; + try { + return readdirSync(agentsDir).filter(name => name.endsWith('.md')).length; + } catch { + return 0; + } +} + +function isDocSyncHookInstalled(repoPath) { + const gitRoot = getGitRoot(repoPath); + if (!gitRoot) return false; + const hookPath = join(gitRoot, '.git', 'hooks', 'post-commit'); + if (!existsSync(hookPath)) return false; + const projectLabel = toPosixRelative(gitRoot, repoPath) || '.'; + try { + const content = readFileSync(hookPath, 'utf8'); + return ( + content.includes(`# >>> aspens doc-sync hook (${projectLabel})`) || + content.includes('aspens doc sync') + ); + } catch { + return false; + } +} + +export function summarizeMissing(targets) { + const items = []; + + const missingInstructions = targets.filter(target => target.status.instructions === 'missing'); + if (missingInstructions.length > 0) { + items.push({ + kind: 'instructions', + severity: 'high', + message: `${missingInstructions.length} target(s) are missing root instruction files`, + }); + } + + const staleTargets = targets.filter(target => target.status.instructions === 'stale'); + if (staleTargets.length > 0) { + const changedFiles = Math.max(...staleTargets.map(target => target.drift.changedCount), 0); + items.push({ + kind: 'stale', + severity: 'high', + message: `${staleTargets.length} target(s) have stale docs with ${changedFiles} changed source file(s) since generation`, + }); + } + + const missingHooks = targets.filter(target => target.status.hooks === 'missing'); + if (missingHooks.length > 0) { + items.push({ + kind: 'hooks', + severity: 'medium', + message: `${missingHooks.length} hook-capable target(s) are missing automatic context loading`, + }); + } + + const brokenHooks = targets.filter(target => target.status.hooks === 'broken'); + if (brokenHooks.length > 0) { + items.push({ + kind: 'hook-errors', + severity: 'high', + message: brokenHooks + .map(target => `${target.label} hooks are configured but broken`) + .join(' | '), + }); + } + + const uncoveredDomains = [...new Set(targets.flatMap(target => target.domainCoverage?.missing || []))]; + if (uncoveredDomains.length > 0) { + items.push({ + kind: 'domains', + severity: 'medium', + message: `${uncoveredDomains.length} meaningful source domain(s) are not matched by dedicated skills or activation rules: ${uncoveredDomains.slice(0, 4).join(', ')}`, + }); + } + + const weakRootContext = targets + .filter(target => target.hubCoverage?.total > 0 && target.hubCoverage.mentioned < target.hubCoverage.total) + .map(target => ({ + label: target.label, + missing: target.hubCoverage.total - target.hubCoverage.mentioned, + })); + if (weakRootContext.length > 0) { + items.push({ + 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`) + .join(' | '), + }); + } + + const brokenSaveTokens = targets.filter(target => target.saveTokensHealth?.configured && !target.saveTokensHealth.healthy); + if (brokenSaveTokens.length > 0) { + items.push({ + kind: 'save-tokens', + severity: 'medium', + message: brokenSaveTokens + .map(target => `${target.label} save-tokens automation is configured but broken`) + .join(' | '), + }); + } + + return items; +} + +export function evaluateSaveTokensHealth(repoPath, saveTokensConfig = null) { + const configured = !!saveTokensConfig?.enabled && saveTokensConfig?.claude?.enabled !== false; + const settingsPath = join(repoPath, '.claude', 'settings.json'); + const hooksDir = join(repoPath, '.claude', 'hooks'); + const commandsDir = join(repoPath, '.claude', 'commands'); + const requiredHookFiles = [ + 'save-tokens.mjs', + ...(saveTokensNeedsTokenWarnings(saveTokensConfig) ? [ + 'save-tokens-statusline.sh', + 'save-tokens-prompt-guard.sh', + ] : []), + ...(saveTokensConfig?.saveHandoff !== false ? ['save-tokens-precompact.sh'] : []), + ]; + const legacyHookFiles = [ + 'save-tokens-lib.mjs', + 'save-tokens-statusline.mjs', + 'save-tokens-prompt-guard.mjs', + 'save-tokens-precompact.mjs', + ]; + const requiredCommandFiles = [ + 'save-handoff.md', + 'resume-handoff-latest.md', + 'resume-handoff.md', + ]; + const requiredSettingsCommands = [ + ...(saveTokensNeedsTokenWarnings(saveTokensConfig) ? [ + 'save-tokens-statusline.sh', + 'save-tokens-prompt-guard.sh', + ] : []), + ...(saveTokensConfig?.saveHandoff !== false ? ['save-tokens-precompact.sh'] : []), + ]; + + if (!configured) { + return { + configured: false, + healthy: true, + issues: [], + missingHookFiles: [], + missingCommandFiles: [], + invalidCommands: [], + }; + } + + const issues = []; + const missingHookFiles = requiredHookFiles.filter(file => !existsSync(join(hooksDir, file))); + const missingCommandFiles = requiredCommandFiles.filter(file => !existsSync(join(commandsDir, file))); + const installedLegacyHookFiles = legacyHookFiles.filter(file => existsSync(join(hooksDir, file))); + const invalidCommands = []; + const foundSettingsCommands = new Set(); + + if (missingHookFiles.length > 0) { + issues.push(`missing save-tokens hook files: ${missingHookFiles.join(', ')}`); + } + if (missingCommandFiles.length > 0) { + issues.push(`missing save-tokens slash commands: ${missingCommandFiles.join(', ')}`); + } + if (installedLegacyHookFiles.length > 0) { + issues.push(`legacy save-tokens hook files still installed: ${installedLegacyHookFiles.join(', ')}`); + } + + if (!existsSync(settingsPath)) { + issues.push('missing .claude/settings.json'); + } else { + try { + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')); + for (const command of extractAutomationCommandsFromSettings(settings)) { + for (const expected of requiredSettingsCommands) { + if (command.includes(expected)) foundSettingsCommands.add(expected); + } + if (!command.includes('.claude/hooks/')) continue; + if (!command.includes('save-tokens-')) continue; + const resolvedPath = commandToHookPath(command, repoPath); + if (!resolvedPath || !existsSync(resolvedPath)) { + invalidCommands.push(command); + } + } + } catch { + issues.push('invalid .claude/settings.json'); + } + } + + const missingSettingsCommands = requiredSettingsCommands.filter(command => !foundSettingsCommands.has(command)); + if (missingSettingsCommands.length > 0) { + issues.push(`missing save-tokens settings entries: ${missingSettingsCommands.join(', ')}`); + } + if (invalidCommands.length > 0) { + issues.push(`broken save-tokens settings commands: ${invalidCommands.length}`); + } + + return { + configured, + healthy: issues.length === 0, + issues, + missingHookFiles, + missingCommandFiles, + invalidCommands, + installedLegacyHookFiles, + }; +} + +function saveTokensNeedsTokenWarnings(config) { + return config?.warnAtTokens !== Number.MAX_SAFE_INTEGER || config?.compactAtTokens !== Number.MAX_SAFE_INTEGER; +} + +export function evaluateHookHealth(repoPath) { + const settingsPath = join(repoPath, '.claude', 'settings.json'); + const rulesPath = join(repoPath, '.claude', 'skills', 'skill-rules.json'); + const hooksDir = join(repoPath, '.claude', 'hooks'); + const requiredScripts = [ + 'skill-activation-prompt.sh', + 'graph-context-prompt.sh', + 'post-tool-use-tracker.sh', + ]; + + const installed = existsSync(join(hooksDir, 'skill-activation-prompt.sh')); + const missingScripts = requiredScripts.filter(file => !existsSync(join(hooksDir, file))); + const issues = []; + + if (!existsSync(settingsPath)) { + issues.push('missing .claude/settings.json'); + } + if (!existsSync(rulesPath)) { + issues.push('missing .claude/skills/skill-rules.json'); + } + if (missingScripts.length > 0) { + issues.push(`missing hook scripts: ${missingScripts.join(', ')}`); + } + + const invalidCommands = []; + if (existsSync(settingsPath)) { + try { + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')); + const commands = extractHookCommandsFromSettings(settings); + for (const command of commands) { + if (!command.includes('.claude/hooks/')) continue; + const resolvedPath = commandToHookPath(command, repoPath); + if (!resolvedPath || !existsSync(resolvedPath)) { + invalidCommands.push(command); + } + } + } catch { + issues.push('invalid .claude/settings.json'); + } + } + + if (invalidCommands.length > 0) { + issues.push(`broken hook commands: ${invalidCommands.length}`); + } + + return { + installed, + healthy: installed && issues.length === 0, + issues, + invalidCommands, + missingScripts, + }; +} + +function extractHookCommandsFromSettings(settings) { + const commands = []; + if (!settings?.hooks || typeof settings.hooks !== 'object') return commands; + for (const entries of Object.values(settings.hooks)) { + if (!Array.isArray(entries)) continue; + for (const entry of entries) { + if (!Array.isArray(entry?.hooks)) continue; + for (const hook of entry.hooks) { + if (typeof hook?.command === 'string') commands.push(hook.command); + } + } + } + return commands; +} + +function extractAutomationCommandsFromSettings(settings) { + const commands = extractHookCommandsFromSettings(settings); + if (typeof settings?.statusLine?.command === 'string') { + commands.push(settings.statusLine.command); + } + return commands; +} + +function commandToHookPath(command, repoPath) { + const match = command.match(/\$CLAUDE_PROJECT_DIR\/(.+?\.sh)\b/); + if (!match) return null; + return join(repoPath, ...match[1].split('/')); +} + +function toPosixRelative(from, to) { + const rel = relative(from, to); + if (!rel || rel === '.') return ''; + return rel.split('\\').join('/'); +} + +function inferTargetsFromScan(scan) { + const targets = []; + if (scan.hasClaudeConfig || scan.hasClaudeMd) targets.push('claude'); + if (scan.hasCodexConfig || scan.hasAgentsMd) targets.push('codex'); + return targets.length > 0 ? targets : ['claude']; +} + +function buildContextText(repoPath, target, skillFiles) { + const parts = []; + const instructionPath = join(repoPath, target.instructionsFile); + if (existsSync(instructionPath)) { + try { + parts.push(readFileSync(instructionPath, 'utf8')); + } catch { /* ignore unreadable artifact */ } + } + + for (const skill of skillFiles) { + const name = (skill.frontmatter?.name || skill.name || '').toLowerCase(); + if (name === 'base') { + parts.push(skill.content); + } + } + + return parts.join('\n\n'); +} + +function findMatchingSkill(skills, domainName) { + for (const skill of skills) { + const skillName = (skill.frontmatter?.name || skill.name || '').toLowerCase(); + if (skillName === domainName || skillName.includes(domainName)) { + return { skillName, reason: `skill "${skillName}"` }; + } + + const activationPatterns = Array.isArray(skill.activationPatterns) ? skill.activationPatterns : []; + const matchingPattern = activationPatterns.find(pattern => { + const lower = pattern.toLowerCase(); + return ( + lower.includes(`/${domainName}/`) || + lower.includes(`/${domainName}.`) || + lower.endsWith(`/${domainName}`) || + lower.includes(domainName) + ); + }); + if (matchingPattern) { + return { skillName, reason: `activation "${matchingPattern}"` }; + } + } + return null; +} + +function summarizeUsefulness(input) { + const strengths = []; + const blindSpots = []; + const activationExamples = []; + + strengths.push(`${input.skillCount} skill${input.skillCount === 1 ? '' : 's'} available to the agent`); + + if (input.domainCoverage.total > 0) { + strengths.push(`${input.domainCoverage.covered}/${input.domainCoverage.total} source modules map to skills or activation rules`); + } + + if (input.target.supportsHooks && input.status.hooks === 'healthy') { + strengths.push('hooks can auto-load relevant Claude context while you work'); + } + + if (input.hubCoverage.total > 0 && input.hubCoverage.mentioned > 0) { + strengths.push(`${input.hubCoverage.mentioned}/${input.hubCoverage.total} top hub files are surfaced in root context`); + } + + for (const detail of input.domainCoverage.details.filter(d => d.status === 'covered').slice(0, 3)) { + activationExamples.push(`${detail.domain} -> ${detail.reason}`); + } + + const missing = input.domainCoverage.details.filter(d => d.status === 'missing'); + if (missing.length > 0) { + blindSpots.push(`${missing.length} uncovered module${missing.length === 1 ? '' : 's'}: ${missing.slice(0, 3).map(d => d.domain).join(', ')}`); + } + + if ((input.domainCoverage.excluded || []).length > 0) { + strengths.push(`support buckets excluded from scoring: ${(input.domainCoverage.excluded || []).slice(0, 3).join(', ')}`); + } + + if (input.hubCoverage.total > 0 && input.hubCoverage.mentioned < input.hubCoverage.total) { + blindSpots.push(`${input.hubCoverage.total - input.hubCoverage.mentioned} top hub file${input.hubCoverage.total - input.hubCoverage.mentioned === 1 ? '' : 's'} still missing from root context`); + } + + return { + strengths, + blindSpots, + activationExamples, + }; +} + +function collectSourceState(repoPath) { + const files = []; + let newestSourceMtime = 0; + + function walk(dir) { + let entries = []; + try { + entries = readdirSync(dir); + } catch { + return; + } + + for (const entry of entries) { + if ( + entry.startsWith('.') || + entry === 'node_modules' || + entry === 'dist' || + entry === 'build' || + entry === 'coverage' || + entry === '.git' + ) { + continue; + } + + const full = join(dir, entry); + let stat; + try { + stat = statSync(full); + } catch { + continue; + } + + if (stat.isDirectory()) { + walk(full); + continue; + } + + if (!SOURCE_EXTS.has(extname(entry))) continue; + + const relPath = relative(repoPath, full); + files.push({ path: relPath, mtimeMs: stat.mtimeMs }); + if (stat.mtimeMs > newestSourceMtime) { + newestSourceMtime = stat.mtimeMs; + } + } + } + + walk(repoPath); + files.sort((a, b) => b.mtimeMs - a.mtimeMs); + return { + newestSourceMtime, + sourceFiles: files.length, + files, + }; +} + +function latestMtime(paths) { + let newest = 0; + for (const filePath of paths) { + try { + const mtime = statSync(filePath).mtimeMs; + if (mtime > newest) newest = mtime; + } catch { + // Ignore unreadable files. + } + } + return newest; +} diff --git a/src/lib/runner.js b/src/lib/runner.js index 389166c..1d5dbbb 100644 --- a/src/lib/runner.js +++ b/src/lib/runner.js @@ -7,6 +7,7 @@ import { tmpdir } from 'os'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PROMPTS_DIR = join(__dirname, '..', 'prompts'); const PARTIALS_DIR = join(PROMPTS_DIR, 'partials'); +let codexExecCapabilities = null; // Default paths that parseFileOutput is allowed to write to const DEFAULT_ALLOWED_DIR_PREFIXES = ['.claude/']; @@ -27,6 +28,28 @@ function checkClaude() { } } +function getCodexExecCapabilities() { + if (codexExecCapabilities) return codexExecCapabilities; + + try { + const help = execSync('codex exec --help', { + stdio: 'pipe', + timeout: 5000, + encoding: 'utf8', + }); + + codexExecCapabilities = { + supportsAskForApproval: help.includes('--ask-for-approval'), + }; + } catch { + codexExecCapabilities = { + supportsAskForApproval: false, + }; + } + + return codexExecCapabilities; +} + /** * Execute a prompt via Claude Code CLI (claude -p). * Always uses stream-json for token tracking. @@ -145,16 +168,18 @@ export function runLLM(prompt, options = {}, backendId = 'claude') { */ export function runCodex(prompt, options = {}) { const { timeout = 300000, verbose = false, onActivity = null, model = null, cwd = null } = options; + const capabilities = getCodexExecCapabilities(); const args = [ 'exec', '--json', '--sandbox', 'read-only', - '--ask-for-approval', - 'never', '--ephemeral', ]; + if (capabilities.supportsAskForApproval) { + args.push('--ask-for-approval', 'never'); + } if (model) args.push('--model', model); if (cwd) args.push('--cd', cwd); // Pass prompt via stdin (using '-' placeholder) to avoid shell arg length limits diff --git a/src/lib/save-tokens.js b/src/lib/save-tokens.js new file mode 100644 index 0000000..8df9457 --- /dev/null +++ b/src/lib/save-tokens.js @@ -0,0 +1,85 @@ +export const DEFAULT_SAVE_TOKENS_CONFIG = Object.freeze({ + enabled: true, + warnAtTokens: 175000, + compactAtTokens: 200000, + saveHandoff: true, + sessionRotation: true, + claude: { + enabled: true, + mode: 'automatic', + }, +}); + +export function buildSaveTokensConfig(existing = {}) { + return { + ...DEFAULT_SAVE_TOKENS_CONFIG, + ...existing, + claude: { + ...DEFAULT_SAVE_TOKENS_CONFIG.claude, + ...(existing?.claude || {}), + }, + }; +} + +export function buildSaveTokensSettings() { + return { + statusLine: { + type: 'command', + command: '"$CLAUDE_PROJECT_DIR/.claude/hooks/save-tokens-statusline.sh"', + }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: '"$CLAUDE_PROJECT_DIR/.claude/hooks/save-tokens-prompt-guard.sh"', + }, + ], + }, + ], + PreCompact: [ + { + hooks: [ + { + type: 'command', + command: '"$CLAUDE_PROJECT_DIR/.claude/hooks/save-tokens-precompact.sh"', + }, + ], + }, + ], + }, + }; +} + +export function buildSaveTokensGitignore() { + return '*\n!.gitignore\n!README.md\n'; +} + +export function buildSaveTokensReadme() { + return [ + '# save-tokens handoffs', + '', + 'Aspens stores saved session handoffs here before Claude compaction or token-limit rotation.', + '', + 'Handoff files are human-readable markdown. They are saved so you can inspect what was preserved before compaction or a fresh-session handoff.', + '', + 'Claude automation is installed by `aspens save-tokens`. Codex does not have an aspens save-tokens runtime integration yet.', + '', + 'This directory is gitignored by default.', + '', + ].join('\n'); +} + +export function buildSaveTokensRecommendations(config = DEFAULT_SAVE_TOKENS_CONFIG) { + return [ + `Claude warnings at ${formatCompactLabel(config.warnAtTokens)} and ${formatCompactLabel(config.compactAtTokens)} tokens`, + `Automatic handoff saves before compacting and at the ${formatCompactLabel(config.compactAtTokens)} warning`, + ]; +} + +function formatCompactLabel(value) { + return value >= 1000 && value % 1000 === 0 + ? `${value / 1000}k` + : String(value); +} diff --git a/src/lib/skill-writer.js b/src/lib/skill-writer.js index d342e01..c52a7ce 100644 --- a/src/lib/skill-writer.js +++ b/src/lib/skill-writer.js @@ -226,6 +226,12 @@ export function mergeSettings(existing, template) { // Clone to avoid mutations const merged = JSON.parse(JSON.stringify(existing)); + if (template?.statusLine) { + if (!merged.statusLine || isAspensHook(merged.statusLine.command || '')) { + merged.statusLine = template.statusLine; + } + } + if (!template || !template.hooks) return merged; // If existing has no hooks, add them wholesale @@ -301,6 +307,9 @@ export function mergeSettings(existing, template) { merged.hooks[eventType].push(templateEntry); } } + + // Remove duplicate aspens-managed entries while preserving non-aspens hooks. + merged.hooks[eventType] = dedupeAspensHookEntries(merged.hooks[eventType]); } return merged; @@ -308,7 +317,14 @@ export function mergeSettings(existing, template) { // --- Internal helpers --- -const ASPENS_HOOK_MARKERS = ['skill-activation-prompt', 'post-tool-use-tracker']; +const ASPENS_HOOK_MARKERS = [ + 'skill-activation-prompt', + 'graph-context-prompt', + 'post-tool-use-tracker', + 'save-tokens-statusline', + 'save-tokens-prompt-guard', + 'save-tokens-precompact', +]; /** * Check if a command string is an aspens-managed hook. @@ -336,6 +352,32 @@ function extractHookCommands(entry) { .filter(Boolean); } +function dedupeAspensHookEntries(entries) { + if (!Array.isArray(entries)) return entries; + + const seen = new Set(); + const result = []; + + for (const entry of entries) { + const commands = extractHookCommands(entry); + const aspensMarkers = ASPENS_HOOK_MARKERS.filter(marker => + commands.some(command => command.includes(marker)) + ); + + if (aspensMarkers.length === 0) { + result.push(entry); + continue; + } + + const key = `${aspensMarkers.sort().join('|')}::${entry.matcher || ''}`; + if (seen.has(key)) continue; + seen.add(key); + result.push(entry); + } + + return result; +} + /** * Derive keywords from skill name, description first sentence, and directory names in file patterns. */ diff --git a/src/lib/target-transform.js b/src/lib/target-transform.js index 290dc87..e95045a 100644 --- a/src/lib/target-transform.js +++ b/src/lib/target-transform.js @@ -180,6 +180,39 @@ 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; + + const trimmed = content.trimEnd(); + const keyFilesSectionRegex = /## Key Files\s*\n[\s\S]*?(?=\n## |\n\*\*Last Updated|$)/; + + if (keyFilesSectionRegex.test(trimmed)) { + return trimmed.replace(keyFilesSectionRegex, section).replace(/(\n){3,}/g, '\n\n') + '\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(); + + return ( + before + + '\n\n' + + section + + (after ? '\n\n' + after : '') + + '\n' + ).replace(/(\n){3,}/g, '\n\n'); +} + function syncCodexSkillsSection(content, baseSkill, domainSkills, destTarget, hasGraph = false) { const skillRefs = buildCodexSkillRefs(baseSkill, domainSkills, destTarget, hasGraph); if (skillRefs.length === 0) return content; @@ -217,6 +250,18 @@ function buildCodexSkillRefs(baseSkill, domainSkills, destTarget, hasGraph = fal 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() : ''; diff --git a/src/lib/target.js b/src/lib/target.js index 3ab2c2d..d5a0ae9 100644 --- a/src/lib/target.js +++ b/src/lib/target.js @@ -118,6 +118,25 @@ export function getAllowedPaths(targets) { }; } +export function mergeConfiguredTargets(existingTargets = [], nextTargets = []) { + const validIds = new Set(Object.keys(TARGETS)); + const merged = []; + + for (const target of existingTargets) { + if (validIds.has(target) && !merged.includes(target)) { + merged.push(target); + } + } + + for (const target of nextTargets) { + if (validIds.has(target) && !merged.includes(target)) { + merged.push(target); + } + } + + return merged; +} + /** * Shorthand — returns path info for a target. * @param {string} targetId @@ -149,10 +168,43 @@ export function readConfig(repoPath) { } } +function isValidSaveTokensConfig(config) { + if (!config || typeof config !== 'object' || Array.isArray(config)) return false; + const { + enabled, + warnAtTokens, + compactAtTokens, + saveHandoff, + sessionRotation, + claude, + codex, + } = config; + + if (typeof enabled !== 'boolean') return false; + if (!Number.isInteger(warnAtTokens) || warnAtTokens <= 0) return false; + if (!Number.isInteger(compactAtTokens) || compactAtTokens <= 0) return false; + // Allow either threshold to be MAX_SAFE_INTEGER (disabled sentinel) + if (warnAtTokens !== Number.MAX_SAFE_INTEGER && compactAtTokens !== Number.MAX_SAFE_INTEGER && compactAtTokens <= warnAtTokens) return false; + if (typeof saveHandoff !== 'boolean') return false; + if (typeof sessionRotation !== 'boolean') return false; + if (claude !== undefined) { + if (!claude || typeof claude !== 'object' || Array.isArray(claude)) return false; + if (claude.enabled !== undefined && typeof claude.enabled !== 'boolean') return false; + if (claude.mode !== undefined && !['automatic', 'manual'].includes(claude.mode)) return false; + } + if (codex !== undefined) { + if (!codex || typeof codex !== 'object' || Array.isArray(codex)) return false; + if (codex.enabled !== undefined && typeof codex.enabled !== 'boolean') return false; + if (codex.mode !== undefined && !['automatic', 'manual'].includes(codex.mode)) return false; + } + + return true; +} + function isValidConfig(config) { if (!config || typeof config !== 'object' || Array.isArray(config)) return false; - const { targets, backend, version } = config; + const { targets, backend, version, saveTokens } = config; if (!Array.isArray(targets) || targets.length === 0) return false; if (!targets.every(target => typeof target === 'string' && Object.prototype.hasOwnProperty.call(TARGETS, target))) { @@ -162,6 +214,7 @@ function isValidConfig(config) { return false; } if (version !== undefined && typeof version !== 'string') return false; + if (saveTokens !== undefined && !isValidSaveTokensConfig(saveTokens)) return false; return true; } @@ -173,11 +226,18 @@ function isValidConfig(config) { */ export function writeConfig(repoPath, config) { const configPath = join(repoPath, CONFIG_FILE); + const existing = readConfig(repoPath); const data = { - targets: config.targets, - backend: config.backend || null, + targets: config.targets || existing?.targets, + backend: config.backend ?? existing?.backend ?? null, version: '1.0', }; + // Preserve feature config by default. Commands that intentionally remove + // save-tokens must pass saveTokens: null. + const saveTokens = config.saveTokens === undefined ? existing?.saveTokens : config.saveTokens; + if (saveTokens !== undefined && saveTokens !== null) { + data.saveTokens = saveTokens; + } writeFileSync(configPath, JSON.stringify(data, null, 2) + '\n'); } diff --git a/src/prompts/doc-init-claudemd.md b/src/prompts/doc-init-claudemd.md index 2ad17e1..7094155 100644 --- a/src/prompts/doc-init-claudemd.md +++ b/src/prompts/doc-init-claudemd.md @@ -2,7 +2,7 @@ Generate the root project instructions file at `{{instructionsFile}}`. Keep it c ## 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), and critical conventions. +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. ## Output format @@ -21,3 +21,4 @@ Return exactly one file: 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. diff --git a/src/prompts/impact-analyze.md b/src/prompts/impact-analyze.md new file mode 100644 index 0000000..f8f7287 --- /dev/null +++ b/src/prompts/impact-analyze.md @@ -0,0 +1,26 @@ +You are evaluating the practical impact of generated aspens context for a code repository. + +Use only the structured evidence provided below. Do not invent files, workflows, or gaps. +Do not restate raw numbers unless they support a conclusion. +Do not recommend creating a domain skill unless the evidence suggests a real workflow gap. +Treat shared infrastructure directories carefully: they may already be covered by base/root context. + +Return exactly one JSON object with this shape: + +{ + "bottom_line": "1 short sentence", + "improves": ["bullet", "bullet"], + "risks": ["bullet", "bullet"], + "next_step": "1 short sentence" +} + +Rules: +- `improves` must have 1 to 3 items +- `risks` must have 1 to 3 items +- If nothing is stale, say that directly in one `risks` item +- `next_step` must recommend one best action, or say no action is needed +- Keep every string concise and terminal-friendly +- Prefer fragments and short sentences over paragraphs +- Output JSON only, no markdown fences, no extra text + +Evidence follows. diff --git a/src/templates/commands/resume-handoff-latest.md b/src/templates/commands/resume-handoff-latest.md new file mode 100644 index 0000000..c04eb4b --- /dev/null +++ b/src/templates/commands/resume-handoff-latest.md @@ -0,0 +1,14 @@ +--- +description: Resume from the most recent saved handoff +--- + +Load the latest saved handoff from `.aspens/sessions/` and continue the work from that context. + +## Steps + +1. Read `.aspens/sessions/index.json`. If it has a `latest` field, verify the referenced file exists before reading it. If the file is missing, fall back to step 2. +2. If `index.json` is missing, stale, or points to a deleted file, list `.aspens/sessions/*-handoff.md`, pick the newest by filename. +3. If no handoff exists, say so and stop. +4. Read the handoff file. +5. Summarize the task, current state, and next steps from the handoff. +6. Continue from where the handoff left off. Do not repeat completed work unless verification is needed. diff --git a/src/templates/commands/resume-handoff.md b/src/templates/commands/resume-handoff.md new file mode 100644 index 0000000..e81407d --- /dev/null +++ b/src/templates/commands/resume-handoff.md @@ -0,0 +1,15 @@ +--- +description: List recent handoffs and resume from a selected one +--- + +Show recent saved handoffs and let the user choose which to resume. + +## Steps + +1. List all `.aspens/sessions/*-handoff.md` files, sorted newest first (max 10). +2. For each, show: number, timestamp (from filename), and the first `## Task summary` or `## Latest prompt` line if present. +3. If no handoffs exist, say so and stop. +4. Ask the user which handoff to resume (by number). +5. Read the selected handoff file. +6. Summarize the task, current state, and next steps. +7. Continue from where the handoff left off. diff --git a/src/templates/commands/save-handoff.md b/src/templates/commands/save-handoff.md new file mode 100644 index 0000000..98eac09 --- /dev/null +++ b/src/templates/commands/save-handoff.md @@ -0,0 +1,43 @@ +--- +description: Save a rich handoff summary for resuming later +--- + +Write a structured handoff file to `.aspens/sessions/` so a future Claude session can continue this work. + +## Steps + +1. Generate a timestamp: `YYYY-MM-DDTHH-MM-SS` (replace `:` and `.` with `-`). +2. Write a markdown file to `.aspens/sessions/-claude-handoff.md` with this structure: + +```md +# Claude save-tokens handoff + +- Saved: +- Reason: user-requested +- Session tokens: +- Working directory: +- Branch: + +## Task summary + +<1-3 sentences: what you were working on and why> + +## Files modified + + + +## Git commits + + + +## Current state + + + +## Next steps + + +``` + +3. Update `.aspens/sessions/index.json` with `{ "latest": "", "savedAt": "", "reason": "user-requested" }`. +4. Confirm the handoff was saved and print the file path. diff --git a/src/templates/hooks/graph-context-prompt.mjs b/src/templates/hooks/graph-context-prompt.mjs index 457e2e8..c305edd 100644 --- a/src/templates/hooks/graph-context-prompt.mjs +++ b/src/templates/hooks/graph-context-prompt.mjs @@ -322,17 +322,17 @@ async function main() { try { data = JSON.parse(input); } catch { - process.exit(0); + return; } const prompt = data.prompt || ''; if (!prompt) { - process.exit(0); + return; } - const projectDir = process.env.CLAUDE_PROJECT_DIR; + const projectDir = process.env.ASPENS_PROJECT_DIR || process.env.CLAUDE_PROJECT_DIR; if (!projectDir) { - process.exit(0); + return; } // Step 1: Always load code-map overview (~1ms) @@ -346,7 +346,7 @@ async function main() { // If no code-map exists, nothing to do if (!codeMap) { - process.exit(0); + return; } // Step 2: Try to enrich with detailed neighborhood (best-effort) @@ -396,18 +396,17 @@ async function main() { process.stderr.write('[Graph] Code map loaded\n'); } - process.exit(0); + return; } catch (err) { // NEVER block the user's prompt process.stderr.write(`[Graph] Error: ${err.message}\n`); - process.exit(0); } } if (process.argv[1] === fileURLToPath(import.meta.url)) { const timer = setTimeout(() => { process.stderr.write('[Graph] Timeout after 5s\n'); - process.exit(0); + process.exitCode = 0; }, 5000); main().finally(() => clearTimeout(timer)); } diff --git a/src/templates/hooks/graph-context-prompt.sh b/src/templates/hooks/graph-context-prompt.sh index 2e53c56..181df4c 100644 --- a/src/templates/hooks/graph-context-prompt.sh +++ b/src/templates/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=$? diff --git a/src/templates/hooks/post-tool-use-tracker.sh b/src/templates/hooks/post-tool-use-tracker.sh index c4c9242..bb83a42 100755 --- a/src/templates/hooks/post-tool-use-tracker.sh +++ b/src/templates/hooks/post-tool-use-tracker.sh @@ -12,8 +12,22 @@ if ! command -v jq &> /dev/null; then exit 0 fi -# Exit early if CLAUDE_PROJECT_DIR is not set -if [[ -z "$CLAUDE_PROJECT_DIR" ]]; then +get_script_dir() { + local source="${BASH_SOURCE[0]}" + while [ -h "$source" ]; do + local dir + dir="$(cd -P "$(dirname "$source")" && pwd)" || return 1 + source="$(readlink "$source")" + [[ $source != /* ]] && source="$dir/$source" + done + cd -P "$(dirname "$source")" && pwd +} + +SCRIPT_DIR="$(get_script_dir)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" + +# Exit early if neither CLAUDE_PROJECT_DIR nor PROJECT_DIR is set +if [[ -z "$CLAUDE_PROJECT_DIR" ]] && [[ -z "$PROJECT_DIR" ]]; then exit 0 fi @@ -38,13 +52,13 @@ if [[ "$file_path" =~ \.(md|markdown)$ ]]; then fi # Create cache directory in project -cache_dir="$CLAUDE_PROJECT_DIR/.claude/tsc-cache/${session_id:-default}" +cache_dir="$PROJECT_DIR/.claude/tsc-cache/${session_id:-default}" mkdir -p "$cache_dir" # Function to detect repo from file path detect_repo() { local file="$1" - local project_root="$CLAUDE_PROJECT_DIR" + local project_root="$PROJECT_DIR" # Remove project root from path local relative_path="${file#$project_root/}" @@ -100,7 +114,7 @@ detect_repo() { # Function to get build command for repo get_build_command() { local repo="$1" - local project_root="$CLAUDE_PROJECT_DIR" + local project_root="$PROJECT_DIR" # Map special repo names to actual paths local repo_path @@ -142,7 +156,7 @@ get_build_command() { # Function to get TSC command for repo get_tsc_command() { local repo="$1" - local project_root="$CLAUDE_PROJECT_DIR" + local project_root="$PROJECT_DIR" # Map special repo names to actual paths local repo_path @@ -215,24 +229,24 @@ detect_skill_domain() { # ----------------------------------------------- # 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): # - # Frontend domain patterns: # if [[ "$file" =~ /courses/ ]] || [[ "$file" =~ useCourse ]]; then - # detected_skills="frontend/courses" - # elif [[ "$file" =~ /dashboard/ ]] || [[ "$file" =~ useDashboard ]]; then - # detected_skills="frontend/dashboard" + # detected_skills="$detected_skills frontend/courses" + # fi + # if [[ "$file" =~ /dashboard/ ]] || [[ "$file" =~ useDashboard ]]; then + # detected_skills="$detected_skills frontend/dashboard" # fi - # - # Backend domain patterns: # if [[ "$file" =~ /payments/ ]] || [[ "$file" =~ payment.*\.py ]]; then - # detected_skills="backend/payments" - # elif [[ "$file" =~ /auth/ ]] || [[ "$file" =~ auth.*\.py ]]; then - # detected_skills="backend/auth" + # detected_skills="$detected_skills backend/payments" # fi # ----------------------------------------------- - echo "$detected_skills" + # Deduplicate and trim + echo "$detected_skills" | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/^ *//;s/ *$//' } # END detect_skill_domain @@ -253,45 +267,25 @@ add_skill_to_session() { return fi - # Create or update session file + # Create or update session file (jq required — checked at script entry) if [[ -f "$session_file" ]]; then - # Check if jq is available - if command -v jq &> /dev/null; then - # Add skill to array, keeping unique values - jq --arg skill "$skill" --arg time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - '.active_skills = ((.active_skills + [$skill]) | unique) | .last_updated = $time' \ - "$session_file" > "${session_file}.tmp" 2>/dev/null && \ - mv "${session_file}.tmp" "$session_file" - else - # Fallback: simple append check without jq - if ! grep -q "\"$skill\"" "$session_file" 2>/dev/null; then - # Read existing skills from file, append new one, rewrite - local existing_skills="" - if [[ -f "$session_file" ]]; then - # Extract skills array content: strip brackets, quotes, whitespace - existing_skills=$(grep -o '"active_skills":\[[^]]*\]' "$session_file" 2>/dev/null | sed 's/"active_skills":\[//;s/\]//;s/"//g;s/ //g') - fi - # Build new skills list - local new_skills="" - if [[ -n "$existing_skills" ]]; then - new_skills="\"$(echo "$existing_skills" | sed 's/,/","/g')\",\"$skill\"" - else - new_skills="\"$skill\"" - fi - echo "{\"repo\":\"$repo\",\"active_skills\":[$new_skills],\"last_updated\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" > "$session_file" - fi - fi + jq --arg skill "$skill" --arg time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '.active_skills = ((.active_skills + [$skill]) | unique) | .last_updated = $time' \ + "$session_file" > "${session_file}.tmp" 2>/dev/null && \ + mv "${session_file}.tmp" "$session_file" else # Create new session file echo "{\"repo\":\"$repo\",\"active_skills\":[\"$skill\"],\"last_updated\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" > "$session_file" fi } -# Track skill domain for session-sticky behavior -skill_domain=$(detect_skill_domain "$file_path") -if [[ -n "$skill_domain" ]]; then - session_file=$(get_session_file "$CLAUDE_PROJECT_DIR") - add_skill_to_session "$skill_domain" "$session_file" "$repo" +# Track skill domain(s) for session-sticky behavior +skill_domains=$(detect_skill_domain "$file_path") +if [[ -n "$skill_domains" ]]; then + session_file=$(get_session_file "$PROJECT_DIR") + for skill in $skill_domains; do + add_skill_to_session "$skill" "$session_file" "$repo" + done fi # Exit cleanly diff --git a/src/templates/hooks/save-tokens-precompact.sh b/src/templates/hooks/save-tokens-precompact.sh new file mode 100755 index 0000000..c5139e2 --- /dev/null +++ b/src/templates/hooks/save-tokens-precompact.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +get_script_dir() { + local source="${BASH_SOURCE[0]}" + while [ -h "$source" ]; do + local dir + dir="$(cd -P "$(dirname "$source")" && pwd)" || return 1 + source="$(readlink "$source")" + [[ $source != /* ]] && source="$dir/$source" + done + cd -P "$(dirname "$source")" && pwd +} + +SCRIPT_DIR="$(get_script_dir)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" + +ASPENS_PROJECT_DIR="$PROJECT_DIR" NODE_NO_WARNINGS=1 node "$SCRIPT_DIR/save-tokens.mjs" precompact <&0 +exit 0 diff --git a/src/templates/hooks/save-tokens-prompt-guard.sh b/src/templates/hooks/save-tokens-prompt-guard.sh new file mode 100755 index 0000000..f2da0cc --- /dev/null +++ b/src/templates/hooks/save-tokens-prompt-guard.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +get_script_dir() { + local source="${BASH_SOURCE[0]}" + while [ -h "$source" ]; do + local dir + dir="$(cd -P "$(dirname "$source")" && pwd)" || return 1 + source="$(readlink "$source")" + [[ $source != /* ]] && source="$dir/$source" + done + cd -P "$(dirname "$source")" && pwd +} + +SCRIPT_DIR="$(get_script_dir)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" + +INPUT="$(cat)" +printf '%s' "$INPUT" | ASPENS_PROJECT_DIR="$PROJECT_DIR" NODE_NO_WARNINGS=1 node "$SCRIPT_DIR/save-tokens.mjs" prompt-guard +exit 0 diff --git a/src/templates/hooks/save-tokens-statusline.sh b/src/templates/hooks/save-tokens-statusline.sh new file mode 100644 index 0000000..459645f --- /dev/null +++ b/src/templates/hooks/save-tokens-statusline.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +get_script_dir() { + local source="${BASH_SOURCE[0]}" + while [ -h "$source" ]; do + local dir + dir="$(cd -P "$(dirname "$source")" && pwd)" || return 1 + source="$(readlink "$source")" + [[ $source != /* ]] && source="$dir/$source" + done + cd -P "$(dirname "$source")" && pwd +} + +SCRIPT_DIR="$(get_script_dir)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" + +INPUT=$(cat) +printf '%s' "$INPUT" | ASPENS_PROJECT_DIR="$PROJECT_DIR" NODE_NO_WARNINGS=1 node "$SCRIPT_DIR/save-tokens.mjs" statusline diff --git a/src/templates/hooks/save-tokens.mjs b/src/templates/hooks/save-tokens.mjs new file mode 100644 index 0000000..067e5b9 --- /dev/null +++ b/src/templates/hooks/save-tokens.mjs @@ -0,0 +1,425 @@ +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync } from 'fs'; +import { join, resolve, relative } from 'path'; +import { fileURLToPath } from 'url'; + +const DEFAULT_CONFIG = { + enabled: true, + warnAtTokens: 175000, + compactAtTokens: 200000, + saveHandoff: true, + sessionRotation: true, +}; + +export function getProjectDir() { + return process.env.ASPENS_PROJECT_DIR || process.env.CLAUDE_PROJECT_DIR || process.cwd(); +} + +export function loadSaveTokensConfig(projectDir) { + const path = join(projectDir, '.aspens.json'); + if (!existsSync(path)) return DEFAULT_CONFIG; + + try { + const parsed = JSON.parse(readFileSync(path, 'utf8')); + return { + ...DEFAULT_CONFIG, + ...(parsed?.saveTokens || {}), + }; + } catch { + return DEFAULT_CONFIG; + } +} + +export function readHookInput() { + try { + const raw = readFileSync(0, 'utf8'); + return raw ? JSON.parse(raw) : {}; + } catch { + return {}; + } +} + +export function readClaudeContextTelemetry(projectDir, maxAgeMs = 300000) { + const path = join(projectDir, '.aspens', 'sessions', 'claude-context.json'); + if (!existsSync(path)) return null; + + try { + const telemetry = JSON.parse(readFileSync(path, 'utf8')); + if (!telemetry?.recordedAt) return null; + if (Date.now() - Date.parse(telemetry.recordedAt) > maxAgeMs) return null; + if (!Number.isInteger(telemetry.currentContextTokens) || telemetry.currentContextTokens < 0) return null; + return telemetry; + } catch { + return null; + } +} + +export function recordClaudeContextTelemetry(projectDir, input = {}) { + const sessionsDir = join(projectDir, '.aspens', 'sessions'); + mkdirSync(sessionsDir, { recursive: true }); + + const currentUsage = input.context_window?.current_usage || null; + const currentContextTokens = currentUsage + ? sumInputContextTokens(currentUsage) + : 0; + + const telemetry = { + recordedAt: new Date().toISOString(), + sessionId: input.session_id || input.sessionId || null, + transcriptPath: input.transcript_path || input.transcriptPath || null, + contextWindowSize: input.context_window?.context_window_size || null, + usedPercentage: input.context_window?.used_percentage ?? null, + currentContextTokens, + exceeds200kTokens: !!input.exceeds_200k_tokens, + currentUsage, + }; + + writeFileSync(join(sessionsDir, 'claude-context.json'), JSON.stringify(telemetry, null, 2) + '\n', 'utf8'); + return telemetry; +} + +export function sessionTokenSnapshot(projectDir, input = {}) { + const telemetry = readClaudeContextTelemetry(projectDir); + if (telemetry) { + return { + tokens: telemetry.currentContextTokens, + source: 'claude-statusline', + telemetry, + }; + } + + return { + tokens: null, + source: 'missing-claude-statusline', + telemetry: null, + }; +} + +export function saveHandoff(projectDir, input = {}, reason = 'limit') { + const sessionsDir = join(projectDir, '.aspens', 'sessions'); + mkdirSync(sessionsDir, { recursive: true }); + + const now = new Date(); + const stamp = now.toISOString().replace(/[:.]/g, '-'); + const relativePath = join('.aspens', 'sessions', `${stamp}-claude-handoff.md`); + const handoffPath = join(projectDir, relativePath); + const snapshot = sessionTokenSnapshot(projectDir, input); + const tokenCount = Number.isInteger(snapshot.tokens) ? snapshot.tokens : null; + const tokenLabel = tokenCount === null ? 'unknown' : `~${tokenCount.toLocaleString()}`; + const facts = extractSessionFacts(projectDir, input); + + const lines = [ + '# Claude save-tokens handoff', + '', + `- Saved: ${now.toISOString()}`, + `- Reason: ${reason}`, + `- Session tokens: ${tokenLabel} (${snapshot.source})`, + ]; + + if (input.cwd) { + lines.push(`- Working directory: ${input.cwd}`); + } + if (facts.branch) { + lines.push(`- Branch: ${facts.branch}`); + } + + lines.push(''); + lines.push('## Task summary'); + lines.push(''); + if (facts.originalTask) { + lines.push(facts.originalTask); + } else { + lines.push('(no task captured)'); + } + + lines.push(''); + lines.push('## Files modified'); + lines.push(''); + if (facts.filesModified.length > 0) { + for (const f of facts.filesModified) lines.push(`- ${f}`); + } else { + lines.push('(none detected)'); + } + + lines.push(''); + lines.push('## Git commits'); + lines.push(''); + if (facts.gitCommits.length > 0) { + for (const c of facts.gitCommits) lines.push(`- ${c}`); + } else { + lines.push('(none)'); + } + + if (facts.recentPrompts.length > 0) { + lines.push(''); + lines.push('## Recent prompts'); + lines.push(''); + for (const p of facts.recentPrompts) { + lines.push(`- ${p}`); + } + } + + lines.push(''); + + writeFileSync(handoffPath, lines.join('\n'), 'utf8'); + writeLatestIndex(projectDir, relativePath, now.toISOString(), reason, tokenCount); + pruneOldHandoffs(projectDir); + return relativePath; +} + +export function latestHandoff(projectDir) { + const sessionsDir = join(projectDir, '.aspens', 'sessions'); + if (!existsSync(sessionsDir)) return null; + + const entries = readdirSync(sessionsDir) + .filter(name => name.endsWith('-handoff.md')) + .sort() + .reverse(); + + return entries[0] ? join('.aspens', 'sessions', entries[0]) : null; +} + +const MAX_HANDOFFS = 10; + +export function pruneOldHandoffs(projectDir, keep = MAX_HANDOFFS) { + const sessionsDir = join(projectDir, '.aspens', 'sessions'); + if (!existsSync(sessionsDir)) return; + + const handoffs = readdirSync(sessionsDir) + .filter(name => name.endsWith('-handoff.md')) + .sort() + .reverse(); + + for (const name of handoffs.slice(keep)) { + try { unlinkSync(join(sessionsDir, name)); } catch { /* ignore */ } + } +} + +export function runStatusline() { + const input = readHookInput(); + const projectDir = getProjectDir(); + const config = loadSaveTokensConfig(projectDir); + + if (!config.enabled) return; + + const telemetry = recordClaudeContextTelemetry(projectDir, input); + + if (telemetry.currentContextTokens > 0) { + process.stdout.write(`save-tokens ${formatTokens(telemetry.currentContextTokens)}/${formatTokens(config.compactAtTokens)}`); + } +} + +export function runPromptGuard() { + const input = readHookInput(); + const projectDir = getProjectDir(); + const config = loadSaveTokensConfig(projectDir); + + if (!config.enabled || config.claude?.enabled === false) { + return 0; + } + if (config.warnAtTokens === Number.MAX_SAFE_INTEGER && config.compactAtTokens === Number.MAX_SAFE_INTEGER) { + return 0; + } + + const snapshot = sessionTokenSnapshot(projectDir, input); + const currentTokens = snapshot.tokens; + + if (!Number.isInteger(currentTokens)) { + // stdout → injected into Claude's context as a system message + console.log( + 'save-tokens: Claude token telemetry is unavailable. ' + + 'Open an issue if this persists: https://github.com/aspenkit/aspens/issues' + ); + return 0; + } + + if (currentTokens >= config.compactAtTokens) { + const handoffPath = config.saveHandoff + ? saveHandoff(projectDir, input, config.sessionRotation ? 'rotation-threshold' : 'compact-threshold') + : null; + + const lines = [ + `save-tokens: current context is ${formatTokens(currentTokens)}/${formatTokens(config.compactAtTokens)}.`, + ]; + if (handoffPath) { + lines.push(`Handoff saved: ${handoffPath}`); + } + lines.push(''); + lines.push('IMPORTANT — you must tell the user:'); + lines.push('1. Start a fresh Claude session'); + lines.push('2. Run /resume-handoff-latest to continue'); + lines.push(''); + lines.push('Or run /save-handoff first for a richer summary with current state and next steps.'); + + // stdout → injected into Claude's context as a system message + console.log(lines.join('\n')); + return 0; + } + + if (currentTokens >= config.warnAtTokens) { + // stdout → injected into Claude's context as a system message + console.log( + `save-tokens: current context is ${formatTokens(currentTokens)}/${formatTokens(config.compactAtTokens)}. ` + + 'Tell the user to consider running /save-handoff soon.' + ); + } + + return 0; +} + +export function runPrecompact() { + const input = readHookInput(); + const projectDir = getProjectDir(); + const config = loadSaveTokensConfig(projectDir); + + if (!config.enabled || config.claude?.enabled === false || !config.saveHandoff) { + return 0; + } + + const handoffPath = saveHandoff(projectDir, input, 'precompact'); + console.log(`save-tokens: handoff saved before compact to ${handoffPath}.`); + return 0; +} + +function writeLatestIndex(projectDir, relativePath, savedAt, reason, tokens) { + const indexPath = join(projectDir, '.aspens', 'sessions', 'index.json'); + const payload = { + latest: relativePath, + savedAt, + reason, + tokens, + }; + writeFileSync(indexPath, JSON.stringify(payload, null, 2) + '\n', 'utf8'); +} + +const MAX_TASK_CHARS = 500; +const MAX_PROMPT_CHARS = 200; +const MAX_RECENT_PROMPTS = 3; + +function extractSessionFacts(projectDir, input) { + const facts = { + originalTask: '', + recentPrompts: [], + filesModified: [], + gitCommits: [], + branch: '', + }; + + const transcriptPath = input.transcript_path || input.transcriptPath || ''; + const resolvedTranscriptPath = transcriptPath ? resolve(projectDir, transcriptPath) : ''; + const transcriptRelPath = resolvedTranscriptPath ? relative(projectDir, resolvedTranscriptPath) : ''; + const transcriptInsideProject = transcriptRelPath === '' || (!transcriptRelPath.startsWith('..') && !transcriptRelPath.startsWith('/') && !transcriptRelPath.includes('..\\')); + + if (!resolvedTranscriptPath || !transcriptInsideProject || !existsSync(resolvedTranscriptPath)) { + // Fallback: use prompt field as task summary when no transcript available + const prompt = input.prompt || input.user_prompt || input.message || ''; + if (prompt) { + facts.originalTask = prompt.length > MAX_TASK_CHARS + ? prompt.slice(0, MAX_TASK_CHARS) + '...' + : prompt; + } + return facts; + } + + try { + const content = readFileSync(resolvedTranscriptPath, 'utf8'); + const lines = content.split('\n').filter(Boolean); + + const userMessages = []; + const editedFiles = new Set(); + const commits = []; + + for (const line of lines) { + let record; + try { record = JSON.parse(line); } catch { continue; } + + // Extract branch from user records + if (record.type === 'user' && record.gitBranch && !facts.branch) { + facts.branch = record.gitBranch; + } + + // Extract user messages + if (record.type === 'user' && record.message?.content) { + const text = typeof record.message.content === 'string' + ? record.message.content + : record.message.content + .filter(b => b.type === 'text') + .map(b => b.text) + .join('\n'); + if (text.trim()) userMessages.push(text.trim()); + } + + // Extract files modified from tool_use blocks in assistant messages + if (record.type === 'assistant' && Array.isArray(record.message?.content)) { + for (const block of record.message.content) { + if (block.type !== 'tool_use') continue; + if ((block.name === 'Edit' || block.name === 'Write') && block.input?.file_path) { + editedFiles.add(block.input.file_path); + } + } + } + + // Extract git commits from Bash tool calls + if (record.type === 'assistant' && Array.isArray(record.message?.content)) { + for (const block of record.message.content) { + if (block.type !== 'tool_use' || block.name !== 'Bash') continue; + const cmd = block.input?.command || ''; + if (/git\s+commit/.test(cmd)) { + const msgMatch = cmd.match(/-m\s+["']([^"']+)["']/); + commits.push(msgMatch ? msgMatch[1] : '(commit)'); + } + } + } + } + + // First user message = original task + if (userMessages.length > 0) { + const task = userMessages[0]; + facts.originalTask = task.length > MAX_TASK_CHARS + ? task.slice(0, MAX_TASK_CHARS) + '...' + : task; + } + + // Last N user messages as recent prompts (skip the first one since it's the task) + const recent = userMessages.slice(-MAX_RECENT_PROMPTS); + facts.recentPrompts = recent.map(p => + p.length > MAX_PROMPT_CHARS ? p.slice(0, MAX_PROMPT_CHARS) + '...' : p + ); + + facts.filesModified = [...editedFiles]; + facts.gitCommits = commits; + } catch { + // If transcript parsing fails, return empty facts + } + + return facts; +} + +function sumInputContextTokens(currentUsage) { + return [ + currentUsage.input_tokens, + currentUsage.cache_creation_input_tokens, + currentUsage.cache_read_input_tokens, + currentUsage.output_tokens, + ].reduce((sum, value) => sum + (Number.isFinite(value) ? value : 0), 0); +} + +function formatTokens(value) { + if (value >= 1000) return `${Math.round(value / 1000)}k`; + return String(value); +} + +function main() { + const command = process.argv[2]; + if (command === 'statusline') { + runStatusline(); + return process.exit(0); + } + if (command === 'prompt-guard') return process.exit(runPromptGuard()); + if (command === 'precompact') return process.exit(runPrecompact()); + console.error('save-tokens: expected command: statusline, prompt-guard, or precompact'); + return process.exit(1); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main(); +} diff --git a/src/templates/hooks/skill-activation-prompt.mjs b/src/templates/hooks/skill-activation-prompt.mjs index 55e6e53..221469e 100644 --- a/src/templates/hooks/skill-activation-prompt.mjs +++ b/src/templates/hooks/skill-activation-prompt.mjs @@ -104,7 +104,8 @@ export function getSessionActiveSkills(projectDir, currentRepo) { if (currentRepo && session.repo && session.repo !== currentRepo) { return []; } - return session.active_skills || []; + const skills = session.active_skills || []; + return skills.filter(s => typeof s === 'string'); } } catch { // Session file doesn't exist or is invalid — that's fine @@ -169,8 +170,8 @@ export function matchSkills(prompt, rules, currentRepo, sessionSkills) { continue; } - // AUTO-ACTIVATE: alwaysActivate + scope matches (exact repo OR "all") - if (config.alwaysActivate && (config.scope === currentRepo || config.scope === 'all')) { + // AUTO-ACTIVATE: alwaysActivate (scope already filtered above) + if (config.alwaysActivate) { matched.push({ name: skillName, matchType: 'auto', config }); addedSkills.add(skillName); continue; @@ -224,7 +225,7 @@ export function matchSkills(prompt, rules, currentRepo, sessionSkills) { * @param {string} projectDir - Absolute path to the project root * @returns {string} Formatted output for stdout */ -export function formatOutput(matched, currentRepo, projectDir) { +export function formatOutput(matched, currentRepo) { if (matched.length === 0) { return ''; } @@ -287,25 +288,25 @@ async function main() { data = JSON.parse(input); } catch { // Invalid JSON — exit silently - process.exit(0); + return; } const prompt = data.prompt || ''; if (!prompt) { - process.exit(0); + return; } // Determine project directory - const projectDir = process.env.CLAUDE_PROJECT_DIR; + const projectDir = process.env.ASPENS_PROJECT_DIR || process.env.CLAUDE_PROJECT_DIR; if (!projectDir) { - process.exit(0); + return; } // Load skill rules const rulesPath = join(projectDir, '.claude', 'skills', 'skill-rules.json'); if (!existsSync(rulesPath)) { // No skill rules file — exit silently - process.exit(0); + return; } let rules; @@ -313,11 +314,11 @@ async function main() { rules = JSON.parse(readFileSync(rulesPath, 'utf-8')); } catch { // Invalid rules file — exit silently - process.exit(0); + return; } if (!rules.skills || typeof rules.skills !== 'object') { - process.exit(0); + return; } // Detect current repository @@ -363,7 +364,7 @@ async function main() { // Format and emit output if (matched.length > 0) { - const output = formatOutput(matched, currentRepo, projectDir); + const output = formatOutput(matched, currentRepo); // stderr: terminal status line const highPriority = matched.filter( @@ -378,11 +379,10 @@ async function main() { } } - process.exit(0); + return; } catch (err) { // NEVER block the user's prompt — log and exit cleanly process.stderr.write(`[Skills] Error: ${err.message}\n`); - process.exit(0); } } diff --git a/src/templates/hooks/skill-activation-prompt.sh b/src/templates/hooks/skill-activation-prompt.sh index 266fe60..9eb801f 100755 --- a/src/templates/hooks/skill-activation-prompt.sh +++ b/src/templates/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/src/templates/settings/settings.json b/src/templates/settings/settings.json index 84db302..8396319 100644 --- a/src/templates/settings/settings.json +++ b/src/templates/settings/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\"" } ] }, @@ -13,7 +13,7 @@ "hooks": [ { "type": "command", - "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/graph-context-prompt.sh" + "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/graph-context-prompt.sh\"" } ] } @@ -24,7 +24,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/tests/doc-impact.test.js b/tests/doc-impact.test.js new file mode 100644 index 0000000..6d9a83d --- /dev/null +++ b/tests/doc-impact.test.js @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { buildApplyPlan, buildApplyConfirmationMessage } from '../src/commands/doc-impact.js'; + +describe('buildApplyPlan', () => { + it('preserves target context for target-specific init actions', () => { + const plan = buildApplyPlan([ + { + id: 'claude', + actions: [ + 'aspens doc init --hooks-only', + 'aspens doc init --mode base-only --strategy rewrite', + ], + }, + { + id: 'codex', + actions: [], + }, + ]); + + expect(plan).toEqual([ + { + action: 'aspens doc init --hooks-only', + target: { id: 'claude', actions: ['aspens doc init --hooks-only', 'aspens doc init --mode base-only --strategy rewrite'] }, + }, + { + action: 'aspens doc init --mode base-only --strategy rewrite', + target: { id: 'claude', actions: ['aspens doc init --hooks-only', 'aspens doc init --mode base-only --strategy rewrite'] }, + }, + ]); + }); + + it('deduplicates repo-wide sync actions across targets', () => { + const plan = buildApplyPlan([ + { id: 'claude', actions: ['aspens doc sync'] }, + { id: 'codex', actions: ['aspens doc sync'] }, + ]); + + expect(plan).toHaveLength(1); + expect(plan[0].action).toBe('aspens doc sync'); + }); +}); + +describe('buildApplyConfirmationMessage', () => { + it('uses the explicit apply confirmation prompt', () => { + expect(buildApplyConfirmationMessage()).toBe('Do you want to apply recommendations?'); + }); +}); diff --git a/tests/git-hook.test.js b/tests/git-hook.test.js index 1d44148..658f6b0 100644 --- a/tests/git-hook.test.js +++ b/tests/git-hook.test.js @@ -1,29 +1,33 @@ import { describe, it, expect, beforeEach, afterAll } from 'vitest'; import { existsSync, readFileSync, rmSync, mkdirSync, writeFileSync, statSync } from 'fs'; import { join } from 'path'; +import { execFileSync } from 'child_process'; import { installGitHook, removeGitHook } from '../src/lib/git-hook.js'; const TEST_DIR = join(import.meta.dirname, 'tmp-hook'); const HOOKS_DIR = join(TEST_DIR, '.git', 'hooks'); const HOOK_PATH = join(HOOKS_DIR, 'post-commit'); +const SUBPROJECT_DIR = join(TEST_DIR, 'backend'); beforeEach(() => { - if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); - mkdirSync(HOOKS_DIR, { recursive: true }); + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true }); + mkdirSync(TEST_DIR, { recursive: true }); + execFileSync('git', ['init'], { cwd: TEST_DIR, stdio: 'pipe' }); + mkdirSync(SUBPROJECT_DIR, { recursive: true }); }); afterAll(() => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); }); -describe('installGitHook', () => { +describe.sequential('installGitHook', () => { it('creates post-commit hook with shebang and markers', () => { installGitHook(TEST_DIR); const content = readFileSync(HOOK_PATH, 'utf8'); expect(content).toContain('#!/bin/sh'); - expect(content).toContain('# >>> aspens doc-sync hook (do not edit) >>>'); - expect(content).toContain('__aspens_doc_sync()'); - expect(content).toContain('# <<< aspens doc-sync hook <<<'); + expect(content).toContain('# >>> aspens doc-sync hook (.) (do not edit) >>>'); + expect(content).toContain('__aspens_doc_sync_root()'); + expect(content).toContain('# <<< aspens doc-sync hook (.) <<<'); }); it('makes hook executable', () => { @@ -63,9 +67,51 @@ describe('installGitHook', () => { // Only one shebang expect(content.match(/^#!\/bin\/sh/gm)).toHaveLength(1); }); + + it('upgrades old unlabeled aspens hook block instead of appending a duplicate', () => { + writeFileSync(HOOK_PATH, [ + '#!/bin/sh', + '# >>> aspens doc-sync hook (do not edit) >>>', + 'npx aspens doc sync --commits 1', + '# <<< aspens doc-sync hook <<<', + '', + ].join('\n'), 'utf8'); + + installGitHook(TEST_DIR); + + const content = readFileSync(HOOK_PATH, 'utf8'); + expect(content).toContain('# >>> aspens doc-sync hook (.) (do not edit) >>>'); + expect(content).not.toContain('# >>> aspens doc-sync hook (do not edit) >>>'); + expect(content.match(/aspens doc-sync hook/g)).toHaveLength(2); + }); + + it('ignores generated directory-scoped AGENTS.md files', () => { + installGitHook(TEST_DIR); + const content = readFileSync(HOOK_PATH, 'utf8'); + + expect(content).toContain("grep -v '^.*\\/AGENTS\\.md$'"); + }); + + it('installs subproject hooks at the git root and syncs the subproject path', () => { + installGitHook(SUBPROJECT_DIR); + const content = readFileSync(HOOK_PATH, 'utf8'); + expect(content).toContain('# >>> aspens doc-sync hook (backend) (do not edit) >>>'); + expect(content).toContain('PROJECT_PATH="${REPO_ROOT}/backend"'); + expect(content).toContain('doc sync --commits 1 "$PROJECT_PATH"'); + }); + + it('supports installing hooks for multiple subprojects', () => { + installGitHook(SUBPROJECT_DIR); + const frontendDir = join(TEST_DIR, 'frontend'); + mkdirSync(frontendDir, { recursive: true }); + installGitHook(frontendDir); + const content = readFileSync(HOOK_PATH, 'utf8'); + expect(content).toContain('# >>> aspens doc-sync hook (backend) (do not edit) >>>'); + expect(content).toContain('# >>> aspens doc-sync hook (frontend) (do not edit) >>>'); + }); }); -describe('removeGitHook', () => { +describe.sequential('removeGitHook', () => { it('removes hook file when it only contains aspens block', () => { installGitHook(TEST_DIR); expect(existsSync(HOOK_PATH)).toBe(true); @@ -102,4 +148,15 @@ describe('removeGitHook', () => { removeGitHook(TEST_DIR); expect(existsSync(HOOK_PATH)).toBe(true); }); + + it('removes only the matching subproject hook block', () => { + installGitHook(SUBPROJECT_DIR); + const frontendDir = join(TEST_DIR, 'frontend'); + mkdirSync(frontendDir, { recursive: true }); + installGitHook(frontendDir); + removeGitHook(SUBPROJECT_DIR); + const content = readFileSync(HOOK_PATH, 'utf8'); + expect(content).not.toContain('# >>> aspens doc-sync hook (backend) (do not edit) >>>'); + expect(content).toContain('# >>> aspens doc-sync hook (frontend) (do not edit) >>>'); + }); }); diff --git a/tests/hook-runtime.test.js b/tests/hook-runtime.test.js new file mode 100644 index 0000000..d6467ac --- /dev/null +++ b/tests/hook-runtime.test.js @@ -0,0 +1,75 @@ +import { describe, it, expect, beforeEach, afterAll } from 'vitest'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync } from 'fs'; +import { join } from 'path'; +import { spawnSync } from 'child_process'; + +const TEST_ROOT = join(import.meta.dirname, 'tmp-hook-runtime'); +const MONOREPO_ROOT = join(TEST_ROOT, 'tutor'); +const PROJECT_ROOT = join(MONOREPO_ROOT, 'frontend'); +const HOOKS_DIR = join(PROJECT_ROOT, '.claude', 'hooks'); +const SKILLS_DIR = join(PROJECT_ROOT, '.claude', 'skills'); + +beforeEach(() => { + if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true, force: true }); + mkdirSync(HOOKS_DIR, { recursive: true }); + mkdirSync(join(SKILLS_DIR, 'base'), { recursive: true }); + + const templatesDir = join(import.meta.dirname, '..', 'src', 'templates', 'hooks'); + for (const file of ['skill-activation-prompt.sh', 'skill-activation-prompt.mjs']) { + const src = join(templatesDir, file); + const dest = join(HOOKS_DIR, file); + writeFileSync(dest, readFileSync(src, 'utf8')); + if (file.endsWith('.sh')) chmodSync(dest, 0o755); + } + + writeFileSync(join(SKILLS_DIR, 'base', 'skill.md'), `--- +name: base +description: Base skill +--- + +## Activation + +This is a **base skill** that always loads when working in this repository. + +--- + +Base content +`, 'utf8'); + + writeFileSync(join(SKILLS_DIR, 'skill-rules.json'), JSON.stringify({ + version: '2.0', + skills: { + base: { + type: 'base', + priority: 'critical', + scope: 'all', + alwaysActivate: true, + filePatterns: [], + promptTriggers: { keywords: [], intentPatterns: [] }, + }, + }, + }, null, 2)); +}); + +afterAll(() => { + if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true, force: true }); +}); + +describe('skill activation hook runtime', () => { + it('runs successfully from a monorepo subproject', () => { + const scriptPath = join(HOOKS_DIR, 'skill-activation-prompt.sh'); + const result = spawnSync('bash', [scriptPath], { + input: JSON.stringify({ prompt: 'help me with auth' }), + encoding: 'utf8', + env: { + ...process.env, + CLAUDE_PROJECT_DIR: MONOREPO_ROOT, + }, + }); + + expect(result.status).toBe(0); + expect(result.stderr).not.toContain('Hook error'); + expect(result.stdout).toContain('ACTIVE SKILLS'); + expect(result.stdout).toContain('Base content'); + }); +}); diff --git a/tests/impact.test.js b/tests/impact.test.js new file mode 100644 index 0000000..ecf49b2 --- /dev/null +++ b/tests/impact.test.js @@ -0,0 +1,384 @@ +import { describe, it, expect, beforeEach, afterAll } from 'vitest'; +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { + computeDomainCoverage, + computeHubCoverage, + computeDrift, + evaluateSaveTokensHealth, + evaluateHookHealth, + computeHealthScore, + computeTargetStatus, + recommendActions, + summarizeReport, + summarizeMissing, + summarizeOpportunities, + summarizeValueComparison, +} from '../src/lib/impact.js'; + +const TEST_DIR = join(import.meta.dirname, 'tmp-impact'); + +beforeEach(() => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true }); +}); + +afterAll(() => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true }); +}); + +describe('computeDomainCoverage', () => { + it('counts covered and missing domains with reasons', () => { + const coverage = computeDomainCoverage( + [{ name: 'auth' }, { name: 'billing' }, { name: 'profile' }, { name: 'config' }], + [ + { name: 'base', activationPatterns: [] }, + { name: 'auth', activationPatterns: [] }, + { name: 'payments-skill', activationPatterns: ['src/billing/**'] }, + ] + ); + + expect(coverage.covered).toBe(2); + expect(coverage.total).toBe(3); + expect(coverage.missing).toEqual(['profile']); + expect(coverage.excluded).toEqual(['config']); + expect(coverage.details.find(d => d.domain === 'auth')?.reason).toContain('skill'); + expect(coverage.details.find(d => d.domain === 'billing')?.reason).toContain('activation'); + }); +}); + +describe('computeHubCoverage', () => { + it('counts hub paths mentioned in context text', () => { + const coverage = computeHubCoverage( + ['src/lib/runner.js', 'src/lib/target.js', 'src/lib/errors.js'], + 'Read src/lib/runner.js first. See src/lib/errors.js for failures.' + ); + + expect(coverage.mentioned).toBe(2); + expect(coverage.total).toBe(3); + expect(coverage.paths).toEqual(['src/lib/runner.js', 'src/lib/errors.js']); + }); +}); + +describe('computeDrift', () => { + it('finds changed files and affected domains since last update', () => { + const drift = computeDrift( + { + newestSourceMtime: 300, + files: [ + { path: 'src/auth/session.ts', mtimeMs: 300 }, + { path: 'src/lib/db.ts', mtimeMs: 250 }, + { path: 'src/billing/stripe.ts', mtimeMs: 200 }, + ], + }, + 225, + [ + { name: 'auth', directories: ['src/auth'] }, + { name: 'billing', directories: ['src/billing'] }, + ] + ); + + expect(drift.changedCount).toBe(2); + expect(drift.changedFiles.map(file => file.path)).toEqual(['src/auth/session.ts', 'src/lib/db.ts']); + expect(drift.affectedDomains).toEqual(['auth']); + expect(drift.driftMs).toBe(75); + }); +}); + +describe('target status and actions', () => { + it('marks stale partial context and recommends sync + init', () => { + const status = computeTargetStatus({ + instructionExists: true, + skillCount: 3, + hookHealth: { installed: true, healthy: true }, + domainCoverage: { covered: 2, total: 3 }, + drift: { changedCount: 4 }, + }, { supportsHooks: true }); + + expect(status.instructions).toBe('stale'); + expect(status.domains).toBe('partial'); + expect(status.hooks).toBe('healthy'); + + const actions = recommendActions({ + status, + drift: { changedCount: 4 }, + domainCoverage: { missing: ['profile'] }, + }); + expect(actions).toEqual(['aspens doc sync', 'aspens doc init --mode chunked --domains profile']); + }); + + it('recommends rewrite when only root hub coverage is incomplete', () => { + const actions = recommendActions({ + status: { + instructions: 'healthy', + domains: 'healthy', + hooks: 'healthy', + }, + drift: { changedCount: 0 }, + domainCoverage: { missing: [] }, + hubCoverage: { mentioned: 3, total: 5 }, + }); + + expect(actions).toEqual(['aspens doc init --mode base-only --strategy rewrite']); + }); +}); + +describe('computeHealthScore', () => { + it('penalizes missing instructions, coverage gaps, and drift', () => { + const score = computeHealthScore({ + instructionExists: false, + skillCount: 1, + hooksInstalled: false, + domainCoverage: { covered: 1, total: 4 }, + hubCoverage: { mentioned: 1, total: 3 }, + drift: { changedFiles: [{}, {}, {}] }, + }, { supportsHooks: true }); + + expect(score).toBeLessThan(50); + }); +}); + +describe('summarizeReport', () => { + it('summarizes repo status and deduplicates actions', () => { + const summary = summarizeReport([ + { + health: 70, + drift: { changedCount: 3 }, + status: { instructions: 'stale', domains: 'healthy', hooks: 'healthy' }, + actions: ['aspens doc sync'], + }, + { + health: 90, + drift: { changedCount: 0 }, + status: { instructions: 'healthy', domains: 'partial', hooks: 'n/a' }, + actions: ['aspens doc init --mode chunked --domains config'], + }, + ], { newestSourceMtime: 1234 }); + + expect(summary.repoStatus).toBe('partially stale'); + expect(summary.changedFiles).toBe(3); + expect(summary.averageHealth).toBe(80); + expect(summary.actions).toEqual(['aspens doc sync', 'aspens doc init --mode chunked --domains config']); + }); +}); + +describe('summarizeValueComparison', () => { + it('describes computed artifact coverage and freshness', () => { + const comparison = summarizeValueComparison([ + { + instructionExists: true, + skillCount: 17, + domainCoverage: { covered: 6, total: 7 }, + hubCoverage: { mentioned: 1, total: 5 }, + status: { instructions: 'healthy', hooks: 'healthy' }, + drift: { changedCount: 0 }, + }, + { + instructionExists: true, + skillCount: 18, + domainCoverage: { covered: 6, total: 7 }, + hubCoverage: { mentioned: 5, total: 5 }, + status: { instructions: 'healthy', hooks: 'n/a' }, + drift: { changedCount: 0 }, + }, + ]); + + expect(comparison.withoutAspens).toContain('0 generated instruction files'); + expect(comparison.withAspens).toContain('2/2 instruction files present'); + expect(comparison.withAspens).toContain('35 generated skills'); + expect(comparison.freshness).toContain('current'); + expect(comparison.automation).toContain('1/1 hook-capable target has'); + }); +}); + +describe('summarizeOpportunities', () => { + it('recommends optional aspens features that are not installed', () => { + const opportunities = summarizeOpportunities(TEST_DIR, [ + { id: 'claude' }, + ], { targets: ['claude'] }); + + expect(opportunities.map(item => item.command)).toEqual([ + 'aspens save-tokens', + 'aspens add agent all && aspens customize agents', + 'aspens doc sync --install-hook', + ]); + }); + + it('does not recommend save-tokens or agents when they are already installed', () => { + mkdirSync(join(TEST_DIR, '.claude', 'agents'), { recursive: true }); + writeFileSync(join(TEST_DIR, '.claude', 'agents', 'planner.md'), 'agent\n', 'utf8'); + + const opportunities = summarizeOpportunities(TEST_DIR, [ + { id: 'claude' }, + ], { + targets: ['claude'], + saveTokens: { enabled: true }, + }); + + expect(opportunities.map(item => item.command)).not.toContain('aspens save-tokens'); + expect(opportunities.map(item => item.command)).not.toContain('aspens add agent all && aspens customize agents'); + expect(opportunities.map(item => item.command)).toContain('aspens customize agents'); + }); +}); + +describe('summarizeMissing', () => { + it('rolls up missing hooks, stale docs, uncovered domains, and weak root context', () => { + const items = summarizeMissing([ + { + label: 'Claude Code', + status: { instructions: 'stale', hooks: 'missing' }, + drift: { changedCount: 3 }, + domainCoverage: { missing: ['core'] }, + hubCoverage: { mentioned: 2, total: 5 }, + }, + { + label: 'Codex CLI', + status: { instructions: 'healthy', hooks: 'n/a' }, + drift: { changedCount: 0 }, + domainCoverage: { missing: ['core', 'api'] }, + hubCoverage: { mentioned: 5, total: 5 }, + }, + ]); + + expect(items.some(item => item.kind === 'stale')).toBe(true); + expect(items.some(item => item.kind === 'hooks')).toBe(true); + expect(items.some(item => item.kind === 'domains' && item.message.includes('core'))).toBe(true); + expect(items.some(item => item.kind === 'root-context' && item.message.includes('Claude Code'))).toBe(true); + }); +}); + +describe('evaluateHookHealth', () => { + it('detects broken hook command paths', () => { + mkdirSync(join(TEST_DIR, '.claude', 'hooks'), { recursive: true }); + mkdirSync(join(TEST_DIR, '.claude', 'skills'), { recursive: true }); + writeFileSync(join(TEST_DIR, '.claude', 'hooks', 'skill-activation-prompt.sh'), '#!/bin/bash\n', 'utf8'); + writeFileSync(join(TEST_DIR, '.claude', 'skills', 'skill-rules.json'), '{}\n', 'utf8'); + writeFileSync(join(TEST_DIR, '.claude', 'settings.json'), JSON.stringify({ + hooks: { + UserPromptSubmit: [{ + hooks: [{ type: 'command', command: '"$CLAUDE_PROJECT_DIR/.claude/hooks/missing-hook.sh"' }], + }], + }, + }, null, 2)); + + const health = evaluateHookHealth(TEST_DIR); + expect(health.installed).toBe(true); + expect(health.healthy).toBe(false); + expect(health.invalidCommands).toHaveLength(1); + expect(health.issues.some(issue => issue.includes('broken hook commands'))).toBe(true); + }); + + it('treats subdirectory-prefixed hook commands as broken for the project root', () => { + mkdirSync(join(TEST_DIR, '.claude', 'hooks'), { recursive: true }); + mkdirSync(join(TEST_DIR, '.claude', 'skills'), { recursive: true }); + writeFileSync(join(TEST_DIR, '.claude', 'hooks', 'skill-activation-prompt.sh'), '#!/bin/bash\n', 'utf8'); + writeFileSync(join(TEST_DIR, '.claude', 'hooks', 'graph-context-prompt.sh'), '#!/bin/bash\n', 'utf8'); + writeFileSync(join(TEST_DIR, '.claude', 'hooks', 'post-tool-use-tracker.sh'), '#!/bin/bash\n', 'utf8'); + writeFileSync(join(TEST_DIR, '.claude', 'skills', 'skill-rules.json'), '{}\n', 'utf8'); + writeFileSync(join(TEST_DIR, '.claude', 'settings.json'), JSON.stringify({ + hooks: { + UserPromptSubmit: [{ + hooks: [{ type: 'command', command: '"$CLAUDE_PROJECT_DIR/backend/.claude/hooks/skill-activation-prompt.sh"' }], + }], + }, + }, null, 2)); + + const health = evaluateHookHealth(TEST_DIR); + expect(health.healthy).toBe(false); + expect(health.invalidCommands).toEqual(['"$CLAUDE_PROJECT_DIR/backend/.claude/hooks/skill-activation-prompt.sh"']); + }); +}); + +describe('evaluateSaveTokensHealth', () => { + const saveTokensConfig = { + enabled: true, + claude: { enabled: true }, + }; + + it('is not configured when save-tokens config is absent', () => { + const health = evaluateSaveTokensHealth(TEST_DIR, null); + + expect(health.configured).toBe(false); + expect(health.healthy).toBe(true); + }); + + it('reports healthy when save-tokens files, commands, and settings are installed', () => { + installSaveTokensFixture(); + + const health = evaluateSaveTokensHealth(TEST_DIR, saveTokensConfig); + + expect(health.configured).toBe(true); + expect(health.healthy).toBe(true); + expect(health.issues).toEqual([]); + }); + + it('detects broken save-tokens settings and missing slash commands', () => { + installSaveTokensFixture(); + rmSync(join(TEST_DIR, '.claude', 'commands', 'resume-handoff-latest.md')); + writeFileSync(join(TEST_DIR, '.claude', 'settings.json'), JSON.stringify({ + statusLine: { + type: 'command', + command: '"$CLAUDE_PROJECT_DIR/backend/.claude/hooks/save-tokens-statusline.sh"', + }, + hooks: {}, + }, null, 2), 'utf8'); + + const health = evaluateSaveTokensHealth(TEST_DIR, saveTokensConfig); + + expect(health.healthy).toBe(false); + expect(health.missingCommandFiles).toEqual(['resume-handoff-latest.md']); + expect(health.invalidCommands).toEqual(['"$CLAUDE_PROJECT_DIR/backend/.claude/hooks/save-tokens-statusline.sh"']); + expect(health.issues.some(issue => issue.includes('missing save-tokens slash commands'))).toBe(true); + expect(health.issues.some(issue => issue.includes('missing save-tokens settings entries'))).toBe(true); + }); + + it('reports legacy save-tokens hook payloads left behind', () => { + installSaveTokensFixture(); + writeFileSync(join(TEST_DIR, '.claude', 'hooks', 'save-tokens-lib.mjs'), 'old\n', 'utf8'); + + const health = evaluateSaveTokensHealth(TEST_DIR, saveTokensConfig); + + expect(health.healthy).toBe(false); + expect(health.installedLegacyHookFiles).toEqual(['save-tokens-lib.mjs']); + expect(health.issues.some(issue => issue.includes('legacy save-tokens hook files'))).toBe(true); + }); +}); + +function installSaveTokensFixture() { + mkdirSync(join(TEST_DIR, '.claude', 'hooks'), { recursive: true }); + mkdirSync(join(TEST_DIR, '.claude', 'commands'), { recursive: true }); + for (const file of [ + 'save-tokens.mjs', + 'save-tokens-statusline.sh', + 'save-tokens-prompt-guard.sh', + 'save-tokens-precompact.sh', + ]) { + writeFileSync(join(TEST_DIR, '.claude', 'hooks', file), '#!/bin/bash\n', 'utf8'); + } + for (const file of [ + 'save-handoff.md', + 'resume-handoff-latest.md', + 'resume-handoff.md', + ]) { + writeFileSync(join(TEST_DIR, '.claude', 'commands', file), 'command\n', 'utf8'); + } + writeFileSync(join(TEST_DIR, '.claude', 'settings.json'), JSON.stringify({ + statusLine: { + type: 'command', + command: '"$CLAUDE_PROJECT_DIR/.claude/hooks/save-tokens-statusline.sh"', + }, + hooks: { + UserPromptSubmit: [{ + hooks: [{ + type: 'command', + command: '"$CLAUDE_PROJECT_DIR/.claude/hooks/save-tokens-prompt-guard.sh"', + }], + }], + PreCompact: [{ + hooks: [{ + type: 'command', + command: '"$CLAUDE_PROJECT_DIR/.claude/hooks/save-tokens-precompact.sh"', + }], + }], + }, + }, null, 2), 'utf8'); +} diff --git a/tests/save-tokens-hook-lib.test.js b/tests/save-tokens-hook-lib.test.js new file mode 100644 index 0000000..28a454d --- /dev/null +++ b/tests/save-tokens-hook-lib.test.js @@ -0,0 +1,163 @@ +import { describe, it, expect, beforeEach, afterAll } from 'vitest'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { + latestHandoff, + pruneOldHandoffs, + readClaudeContextTelemetry, + recordClaudeContextTelemetry, + saveHandoff, + sessionTokenSnapshot, +} from '../src/templates/hooks/save-tokens.mjs'; + +const TEST_DIR = join(import.meta.dirname, 'tmp-save-tokens-hooks'); +const EXTERNAL_DIR = join(import.meta.dirname, 'tmp-save-tokens-external'); + +beforeEach(() => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true }); + if (existsSync(EXTERNAL_DIR)) rmSync(EXTERNAL_DIR, { recursive: true, force: true }); + mkdirSync(TEST_DIR, { recursive: true }); + mkdirSync(EXTERNAL_DIR, { recursive: true }); +}); + +afterAll(() => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true }); + if (existsSync(EXTERNAL_DIR)) rmSync(EXTERNAL_DIR, { recursive: true, force: true }); +}); + +describe('save-tokens hook telemetry', () => { + it('records current Claude context usage from statusLine input', () => { + const telemetry = recordClaudeContextTelemetry(TEST_DIR, { + session_id: 'session-1', + context_window: { + context_window_size: 200000, + used_percentage: 56.5, + current_usage: { + input_tokens: 100000, + cache_creation_input_tokens: 5000, + cache_read_input_tokens: 7000, + output_tokens: 3000, + }, + }, + }); + + expect(telemetry.currentContextTokens).toBe(115000); + expect(readClaudeContextTelemetry(TEST_DIR).currentContextTokens).toBe(115000); + }); + + it('treats stale Claude telemetry as unavailable', () => { + const sessionsDir = join(TEST_DIR, '.aspens', 'sessions'); + mkdirSync(sessionsDir, { recursive: true }); + writeFileSync(join(sessionsDir, 'claude-context.json'), JSON.stringify({ + recordedAt: new Date(Date.now() - 301000).toISOString(), + currentContextTokens: 112000, + }), 'utf8'); + + expect(readClaudeContextTelemetry(TEST_DIR)).toBeNull(); + }); + + it('prefers Claude statusLine telemetry for token snapshots', () => { + recordClaudeContextTelemetry(TEST_DIR, { + context_window: { + current_usage: { + input_tokens: 10, + cache_creation_input_tokens: 20, + cache_read_input_tokens: 30, + }, + }, + }); + + const snapshot = sessionTokenSnapshot(TEST_DIR, { prompt: 'ignored prompt text' }); + expect(snapshot.source).toBe('claude-statusline'); + expect(snapshot.tokens).toBe(60); + }); + + it('does not estimate tokens when telemetry is missing', () => { + const snapshot = sessionTokenSnapshot(TEST_DIR, { + prompt: 'large prompt', + }); + + expect(snapshot.source).toBe('missing-claude-statusline'); + expect(snapshot.tokens).toBeNull(); + }); + + it('saves handoffs with unknown tokens when telemetry is missing', () => { + const handoffPath = saveHandoff(TEST_DIR, { + prompt: 'continue the checkout task', + }, 'precompact'); + + const content = readFileSync(join(TEST_DIR, handoffPath), 'utf8'); + expect(content).toContain('- Session tokens: unknown (missing-claude-statusline)'); + expect(content).toContain('## Task summary'); + expect(content).toContain('## Files modified'); + expect(content).toContain('## Git commits'); + }); + + it('extracts session facts from JSONL transcript', () => { + const transcriptPath = join(TEST_DIR, 'transcript.jsonl'); + const jsonlLines = [ + JSON.stringify({ type: 'user', gitBranch: 'feat/handoff', message: { role: 'user', content: 'Fix the login bug in auth.js' } }), + JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [ + { type: 'tool_use', name: 'Edit', input: { file_path: 'src/auth.js' } }, + { type: 'tool_use', name: 'Bash', input: { command: 'git commit -m "fix login bug"' } }, + ] } }), + JSON.stringify({ type: 'user', message: { role: 'user', content: 'Now add tests' } }), + ]; + writeFileSync(transcriptPath, jsonlLines.join('\n') + '\n', 'utf8'); + + const handoffPath = saveHandoff(TEST_DIR, { + transcript_path: transcriptPath, + }, 'precompact'); + + const content = readFileSync(join(TEST_DIR, handoffPath), 'utf8'); + expect(content).toContain('## Task summary'); + expect(content).toContain('Fix the login bug in auth.js'); + expect(content).toContain('## Files modified'); + expect(content).toContain('src/auth.js'); + expect(content).toContain('## Git commits'); + expect(content).toContain('fix login bug'); + expect(content).toContain('feat/handoff'); + }); + + it('handles missing or invalid transcript gracefully', () => { + const handoffPath = saveHandoff(TEST_DIR, { + transcript_path: '/nonexistent/path.jsonl', + }, 'precompact'); + + const content = readFileSync(join(TEST_DIR, handoffPath), 'utf8'); + expect(content).toContain('## Task summary'); + expect(content).toContain('(no task captured)'); + }); + + it('finds the newest handoff and ignores README markdown', () => { + const sessionsDir = join(TEST_DIR, '.aspens', 'sessions'); + mkdirSync(sessionsDir, { recursive: true }); + writeFileSync(join(sessionsDir, 'README.md'), '# readme\n', 'utf8'); + writeFileSync(join(sessionsDir, '2026-01-01T00-00-00-000Z-claude-handoff.md'), 'old\n', 'utf8'); + writeFileSync(join(sessionsDir, '2026-02-01T00-00-00-000Z-claude-handoff.md'), 'new\n', 'utf8'); + + expect(latestHandoff(TEST_DIR)).toBe(join('.aspens', 'sessions', '2026-02-01T00-00-00-000Z-claude-handoff.md')); + }); + + it('prunes old handoffs without removing session support files', () => { + const sessionsDir = join(TEST_DIR, '.aspens', 'sessions'); + mkdirSync(sessionsDir, { recursive: true }); + writeFileSync(join(sessionsDir, '.gitignore'), '*\n', 'utf8'); + writeFileSync(join(sessionsDir, 'README.md'), '# readme\n', 'utf8'); + writeFileSync(join(sessionsDir, 'note.md'), '# user note\n', 'utf8'); + + for (let i = 0; i < 12; i += 1) { + const day = String(i + 1).padStart(2, '0'); + writeFileSync(join(sessionsDir, `2026-01-${day}T00-00-00-000Z-claude-handoff.md`), `${day}\n`, 'utf8'); + } + + pruneOldHandoffs(TEST_DIR, 10); + + expect(existsSync(join(sessionsDir, '.gitignore'))).toBe(true); + expect(existsSync(join(sessionsDir, 'README.md'))).toBe(true); + expect(existsSync(join(sessionsDir, 'note.md'))).toBe(true); + expect(existsSync(join(sessionsDir, '2026-01-01T00-00-00-000Z-claude-handoff.md'))).toBe(false); + expect(existsSync(join(sessionsDir, '2026-01-02T00-00-00-000Z-claude-handoff.md'))).toBe(false); + expect(existsSync(join(sessionsDir, '2026-01-12T00-00-00-000Z-claude-handoff.md'))).toBe(true); + }); +}); diff --git a/tests/save-tokens-prompt-guard.test.js b/tests/save-tokens-prompt-guard.test.js new file mode 100644 index 0000000..295b890 --- /dev/null +++ b/tests/save-tokens-prompt-guard.test.js @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach, afterAll } from 'vitest'; +import { existsSync, mkdirSync, rmSync } from 'fs'; +import { spawnSync } from 'child_process'; +import { join } from 'path'; + +const TEST_DIR = join(import.meta.dirname, 'tmp-save-tokens-prompt-guard'); +const SAVE_TOKENS_SCRIPT = join(import.meta.dirname, '..', 'src', 'templates', 'hooks', 'save-tokens.mjs'); + +beforeEach(() => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true }); + mkdirSync(TEST_DIR, { recursive: true }); +}); + +afterAll(() => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true }); +}); + +describe.sequential('save-tokens prompt guard', () => { + it('exits successfully when running the statusline command', () => { + const result = spawnSync(process.execPath, [SAVE_TOKENS_SCRIPT, 'statusline'], { + cwd: TEST_DIR, + env: { + ...process.env, + ASPENS_PROJECT_DIR: TEST_DIR, + }, + input: JSON.stringify({ + context_window: { + current_usage: { input_tokens: 123 }, + }, + }), + encoding: 'utf8', + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('save-tokens 123/200k'); + }); + + it('warns without blocking when Claude token telemetry is missing', () => { + const result = runGuard({ prompt: 'hello' }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('Claude token telemetry is unavailable'); + }); + + it('warns without blocking above the warning threshold', () => { + writeTelemetry(176000); + + const result = runGuard({ prompt: 'continue' }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('current context is 176k/200k'); + expect(result.stdout).toContain('/save-handoff'); + }); + + it('warns strongly and saves a handoff above the compact threshold', () => { + writeTelemetry(201000); + + const result = runGuard({ prompt: 'continue' }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('current context is 201k/200k'); + expect(result.stdout).toContain('Handoff saved: .aspens/sessions/'); + expect(result.stdout).toContain('/resume-handoff-latest'); + }); +}); + +function writeTelemetry(inputTokens) { + const result = spawnSync(process.execPath, [SAVE_TOKENS_SCRIPT, 'statusline'], { + cwd: TEST_DIR, + env: { + ...process.env, + ASPENS_PROJECT_DIR: TEST_DIR, + }, + input: JSON.stringify({ + context_window: { + current_usage: { + input_tokens: inputTokens, + }, + }, + }), + encoding: 'utf8', + }); + + expect(result.status).toBe(0); +} + +function runGuard(input) { + return spawnSync(process.execPath, [SAVE_TOKENS_SCRIPT, 'prompt-guard'], { + cwd: TEST_DIR, + env: { + ...process.env, + ASPENS_PROJECT_DIR: TEST_DIR, + }, + input: JSON.stringify(input), + encoding: 'utf8', + }); +} diff --git a/tests/save-tokens.test.js b/tests/save-tokens.test.js new file mode 100644 index 0000000..f55c81d --- /dev/null +++ b/tests/save-tokens.test.js @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { + DEFAULT_SAVE_TOKENS_CONFIG, + buildSaveTokensConfig, + buildSaveTokensRecommendations, + buildSaveTokensSettings, +} from '../src/lib/save-tokens.js'; + +describe('buildSaveTokensConfig', () => { + it('returns the recommended defaults', () => { + const config = buildSaveTokensConfig(); + expect(config).toEqual(DEFAULT_SAVE_TOKENS_CONFIG); + }); + + it('merges nested backend config without dropping defaults', () => { + const config = buildSaveTokensConfig({ + warnAtTokens: 160000, + claude: { mode: 'manual', enabled: false }, + }); + + expect(config.warnAtTokens).toBe(160000); + expect(config.compactAtTokens).toBe(200000); + expect(config.claude.enabled).toBe(false); + expect(config.claude.mode).toBe('manual'); + }); +}); + +describe('buildSaveTokensSettings', () => { + it('installs UserPromptSubmit and PreCompact hooks for Claude', () => { + const settings = buildSaveTokensSettings(); + expect(settings.statusLine.command).toContain('save-tokens-statusline.sh'); + expect(settings.hooks.UserPromptSubmit[0].hooks[0].command).toContain('save-tokens-prompt-guard.sh'); + expect(settings.hooks.PreCompact[0].hooks[0].command).toContain('save-tokens-precompact.sh'); + }); +}); + +describe('buildSaveTokensRecommendations', () => { + it('formats the recommended items for the installer prompt', () => { + expect(buildSaveTokensRecommendations()).toEqual([ + 'Claude warnings at 175k and 200k tokens', + 'Automatic handoff saves before compacting and at the 200k warning', + ]); + }); +}); diff --git a/tests/skill-writer.test.js b/tests/skill-writer.test.js index 42c1da7..a93bcde 100644 --- a/tests/skill-writer.test.js +++ b/tests/skill-writer.test.js @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterAll } from 'vitest'; import { existsSync, readFileSync, rmSync, mkdirSync, writeFileSync } from 'fs'; import { join } from 'path'; -import { writeSkillFiles } from '../src/lib/skill-writer.js'; +import { writeSkillFiles, mergeSettings } from '../src/lib/skill-writer.js'; const TEST_DIR = join(import.meta.dirname, 'tmp-writer'); @@ -76,3 +76,160 @@ describe('writeSkillFiles', () => { }); }); }); + +describe('mergeSettings', () => { + it('replaces stale graph-context hook commands during merge', () => { + const existing = { + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: '"$CLAUDE_PROJECT_DIR/.claude/hooks/graph-context-prompt.sh"', + }, + ], + }, + ], + }, + }; + const template = { + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: '"$CLAUDE_PROJECT_DIR/frontend/.claude/hooks/graph-context-prompt.sh"', + }, + ], + }, + ], + }, + }; + + const merged = mergeSettings(existing, template); + const commands = merged.hooks.UserPromptSubmit.flatMap(entry => entry.hooks.map(hook => hook.command)); + expect(commands).toContain('"$CLAUDE_PROJECT_DIR/frontend/.claude/hooks/graph-context-prompt.sh"'); + expect(commands).not.toContain('"$CLAUDE_PROJECT_DIR/.claude/hooks/graph-context-prompt.sh"'); + }); + + it('deduplicates duplicate aspens hook entries during merge', () => { + const existing = { + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: '"$CLAUDE_PROJECT_DIR/backend/.claude/hooks/graph-context-prompt.sh"', + }, + ], + }, + { + hooks: [ + { + type: 'command', + command: '"$CLAUDE_PROJECT_DIR/backend/.claude/hooks/graph-context-prompt.sh"', + }, + ], + }, + ], + }, + }; + const template = { + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: '"$CLAUDE_PROJECT_DIR/backend/.claude/hooks/graph-context-prompt.sh"', + }, + ], + }, + ], + }, + }; + + const merged = mergeSettings(existing, template); + expect(merged.hooks.UserPromptSubmit).toHaveLength(1); + }); + + it('treats save-tokens hooks as aspens-managed during merge', () => { + const existing = { + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: '"$CLAUDE_PROJECT_DIR/.claude/hooks/save-tokens-prompt-guard.sh"', + }, + ], + }, + ], + }, + }; + const template = { + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: '"$CLAUDE_PROJECT_DIR/.claude/hooks/save-tokens-prompt-guard.sh"', + }, + ], + }, + ], + }, + }; + + const merged = mergeSettings(existing, template); + expect(merged.hooks.UserPromptSubmit).toHaveLength(1); + }); + + it('adds aspens statusLine when none exists', () => { + const merged = mergeSettings({ hooks: {} }, { + statusLine: { + type: 'command', + command: '"$CLAUDE_PROJECT_DIR/.claude/hooks/save-tokens-statusline.sh"', + }, + }); + + expect(merged.statusLine.command).toContain('save-tokens-statusline.sh'); + }); + + it('preserves a non-aspens custom statusLine', () => { + const merged = mergeSettings({ + statusLine: { + type: 'command', + command: '"$CLAUDE_PROJECT_DIR/.claude/hooks/my-statusline.sh"', + }, + }, { + statusLine: { + type: 'command', + command: '"$CLAUDE_PROJECT_DIR/.claude/hooks/save-tokens-statusline.sh"', + }, + }); + + expect(merged.statusLine.command).toContain('my-statusline.sh'); + }); + + it('preserves a non-aspens custom statusLine even when the template has a different non-aspens statusLine', () => { + const merged = mergeSettings({ + statusLine: { + type: 'command', + command: '"$CLAUDE_PROJECT_DIR/.claude/hooks/my-statusline.sh"', + }, + }, { + statusLine: { + type: 'command', + command: '"$CLAUDE_PROJECT_DIR/.claude/hooks/vendor-statusline.sh"', + }, + }); + + expect(merged.statusLine.command).toContain('my-statusline.sh'); + }); +}); diff --git a/tests/target-transform.test.js b/tests/target-transform.test.js index 43fc939..0f71151 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 } from '../src/lib/target-transform.js'; +import { transformForTarget, validateTransformedFiles, projectCodexDomainDocs, ensureRootKeyFilesSection } from '../src/lib/target-transform.js'; import { TARGETS } from '../src/lib/target.js'; const mockScanResult = { @@ -15,6 +15,14 @@ const mockClaudeFiles = [ { path: 'CLAUDE.md', content: '# My Project\n\nProject overview.\n\n## Commands\n\nnpm test\n' }, ]; +const mockGraph = { + hubs: [ + { path: 'app/core/db.py', fanIn: 9 }, + { path: 'app/core/cache_service.py', fanIn: 7 }, + { path: 'app/middleware/rate_limit.py', fanIn: 6 }, + ], +}; + describe('transformForTarget', () => { it('returns files unchanged when source and dest are the same', () => { const result = transformForTarget( @@ -131,6 +139,27 @@ describe('projectCodexDomainDocs', () => { }); }); +describe('ensureRootKeyFilesSection', () => { + it('inserts a key files section before behavior when missing', () => { + 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')); + }); + + it('replaces an incomplete key files section with all top hubs', () => { + 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); + }); +}); + describe('validateTransformedFiles', () => { it('passes for valid relative paths with known filenames', () => { const { valid, issues } = validateTransformedFiles([ diff --git a/tests/target.test.js b/tests/target.test.js index 981bcd6..daa8e6c 100644 --- a/tests/target.test.js +++ b/tests/target.test.js @@ -6,6 +6,7 @@ import { resolveTarget, resolveTargets, getAllowedPaths, + mergeConfiguredTargets, readConfig, writeConfig, inferConfig, @@ -94,6 +95,16 @@ describe('getAllowedPaths', () => { }); }); +describe('mergeConfiguredTargets', () => { + it('preserves existing targets when a narrower run is persisted', () => { + expect(mergeConfiguredTargets(['claude', 'codex'], ['claude'])).toEqual(['claude', 'codex']); + }); + + it('adds newly requested targets without duplicates', () => { + expect(mergeConfiguredTargets(['claude'], ['claude', 'codex'])).toEqual(['claude', 'codex']); + }); +}); + describe('config persistence', () => { it('returns null for missing config file', () => { const result = readConfig(join(FIXTURES_DIR, 'nonexistent')); @@ -117,13 +128,67 @@ describe('config persistence', () => { const dir = join(FIXTURES_DIR, 'config-roundtrip'); mkdirSync(dir, { recursive: true }); - writeConfig(dir, { targets: ['claude', 'codex'], backend: 'claude' }); + writeConfig(dir, { + targets: ['claude', 'codex'], + backend: 'claude', + saveTokens: { + enabled: true, + warnAtTokens: 175000, + compactAtTokens: 200000, + saveHandoff: true, + sessionRotation: true, + claude: { enabled: true, mode: 'automatic' }, + }, + }); const config = readConfig(dir); expect(config).not.toBeNull(); expect(config.targets).toEqual(['claude', 'codex']); expect(config.backend).toBe('claude'); expect(config.version).toBe('1.0'); + expect(config.saveTokens.warnAtTokens).toBe(175000); + }); + + it('can remove saveTokens config without dropping targets', () => { + const dir = join(FIXTURES_DIR, 'config-remove-save-tokens'); + mkdirSync(dir, { recursive: true }); + + writeConfig(dir, { + targets: ['claude', 'codex'], + backend: 'claude', + saveTokens: { + enabled: true, + warnAtTokens: 175000, + compactAtTokens: 200000, + saveHandoff: true, + sessionRotation: true, + }, + }); + writeConfig(dir, { targets: ['claude', 'codex'], backend: 'claude', saveTokens: null }); + + const config = readConfig(dir); + expect(config.targets).toEqual(['claude', 'codex']); + expect(config.saveTokens).toBeUndefined(); + }); + + it('accepts MAX_SAFE_INTEGER as disabled-threshold sentinel', () => { + const dir = join(FIXTURES_DIR, 'config-disabled-warn'); + mkdirSync(dir, { recursive: true }); + + writeConfig(dir, { + targets: ['claude'], + saveTokens: { + enabled: true, + warnAtTokens: Number.MAX_SAFE_INTEGER, + compactAtTokens: 200000, + saveHandoff: true, + sessionRotation: true, + }, + }); + const config = readConfig(dir); + expect(config).not.toBeNull(); + expect(config.saveTokens.warnAtTokens).toBe(Number.MAX_SAFE_INTEGER); + expect(config.saveTokens.compactAtTokens).toBe(200000); }); it('defaults backend to null when not provided', () => {