|
export const meta = { |
|
name: 'docmap', |
|
description: 'Build or update a hierarchical LLM/agent guidance map for the current codebase. OVERWRITE TOOL: docmap owns every CLAUDE.md it writes β existing CLAUDE.md files are read as a dispensable hint and then overwritten. Auto-detects: no .claude/docmap.json manifest -> full build; manifest present -> cheap diff-driven update. Fully sub-agent driven; enumeration/delta + per-unit authoring run on Sonnet 5, migration + root-map/manifest synthesis on Opus 4.8. The dispatching model does no authoring.', |
|
phases: [ |
|
{ title: 'Assess', detail: 'Detect mode; enumerate units (build) or compute git delta (update). Small-repo guard.', model: 'sonnet' }, |
|
{ title: 'Author', detail: 'One Sonnet 5 worker per unit writes/updates <unit>/CLAUDE.md', model: 'sonnet' }, |
|
{ title: 'Migrate', detail: 'Opus 4.8 migrates >20-25 line single-concern sections into path-scoped rules', model: 'opus' }, |
|
{ title: 'Finalize', detail: 'Opus 4.8 writes/refreshes root map, resolves conflicts, writes manifest', model: 'opus' }, |
|
], |
|
} |
|
|
|
// ---- shared spec text (identical guidance across workers) -------------------- |
|
|
|
const AUDIENCE = ` |
|
AUDIENCE: the ONLY readers are LLMs and coding agents navigating this repo. Not humans |
|
onboarding, not marketing. Optimize for "where things live, how they connect, and the |
|
conventions/pitfalls an agent CANNOT infer by reading the code." |
|
|
|
HOW CLAUDE CODE LOADS THIS (why the structure is shaped this way): |
|
- Root CLAUDE.md loads IN FULL every session -> it must stay a lean always-on index. |
|
- A subfolder CLAUDE.md loads ON DEMAND, only when an agent reads a file in that folder. |
|
- .claude/rules/*.md with a "paths:" glob load ON DEMAND, only when matching files are touched. |
|
- A rule with NO "paths:" loads at launch (as heavy as root CLAUDE.md) -> avoid unless truly global. |
|
- HTML comments (<!-- ... -->) in CLAUDE.md are STRIPPED from context: maintainer notes at zero cost. |
|
|
|
WRITING RULES: |
|
- Factual and specific. NEVER speculate; if unsure, write less. |
|
- Concrete + verifiable ("2-space indent", "handlers live in ./handlers/") not vague ("format nicely"). |
|
- Do NOT restate what an agent can read from the code. Favor pitfalls, rationale, non-default conventions. |
|
- Keep the file under ~200 lines.` |
|
|
|
const UNIT_SECTIONS = ` |
|
Sections (omit any that genuinely don't apply): |
|
- Purpose: what this folder is responsible for (1-3 lines). |
|
- Layout: key files/subdirs and what each does (the local map). |
|
- Fits into: entry points, who calls in, what it depends on out. |
|
- Conventions: naming/patterns/idioms SPECIFIC to this folder that differ from repo/tool defaults. |
|
- Common tasks: "to add/change an X, edit Y" pointers. |
|
- Pitfalls: non-obvious constraints, footguns, ordering requirements. |
|
- Commands: build/test/run relevant to THIS folder, if any.` |
|
|
|
const slug = (s) => s.replace(/[^a-zA-Z0-9._-]/g, '-') |
|
|
|
// Self-maintenance footer appended VERBATIM as the last line of every file docmap writes. |
|
// Plain text (NOT an HTML comment) so agents actually see it β HTML comments are stripped from context. |
|
const FOOTER = |
|
'> NOTE FOR ANY AGENT: THIS FILE IS MAINTAINED BY THE `docmap` SKILL. IF YOU CHANGE CODE, ' + |
|
'BEHAVIOR, OR CONVENTIONS THAT THIS FILE (OR A RULE IT POINTS TO) DESCRIBES, UPDATE THE ' + |
|
'AFFECTED FILE/RULE IN THE SAME CHANGE SO THE GUIDANCE STAYS TRUE β OR RE-RUN `/docmap` TO RESYNC.' |
|
|
|
const FOOTER_RULE = |
|
`FOOTER: every CLAUDE.md and rule file you write MUST end with exactly this line as its final line ` + |
|
`(verbatim, unchanged):\n${FOOTER}\nWhen editing a file that already has this footer, keep it last and do not duplicate it.` |
|
|
|
// ---- schemas ----------------------------------------------------------------- |
|
|
|
const ASSESS_SCHEMA = { |
|
type: 'object', additionalProperties: false, |
|
required: ['mode', 'qualifies', 'reason', 'repoRoot', 'lastSyncCommit', 'nothingChanged', |
|
'rootRefreshNeeded', 'globalCommands', 'existingRootClaude', 'workItems', 'deletedUnits'], |
|
properties: { |
|
mode: { enum: ['build', 'update'] }, |
|
qualifies: { type: 'boolean', description: 'build mode: false for tiny repos where fan-out is overkill. update mode: always true.' }, |
|
reason: { type: 'string' }, |
|
repoRoot: { type: 'string' }, |
|
lastSyncCommit: { type: 'string', description: 'update mode: from manifest; "" in build mode' }, |
|
nothingChanged: { type: 'boolean', description: 'update mode only: true if no unit changed and no root refresh' }, |
|
rootRefreshNeeded: { type: 'boolean', description: 'build: always true. update: true if units added/removed or root build commands changed.' }, |
|
globalCommands: { type: 'string', description: 'primary build/test/run commands for the repo root' }, |
|
existingRootClaude: { type: 'boolean' }, |
|
workItems: { |
|
type: 'array', |
|
description: 'units to author. build: every unit with isNew=true. update: changed units (isNew=false) + new units (isNew=true).', |
|
items: { |
|
type: 'object', additionalProperties: false, required: ['name', 'path', 'isNew', 'changedFiles', 'ownedRules'], |
|
properties: { |
|
name: { type: 'string' }, |
|
path: { type: 'string', description: 'repo-relative folder path' }, |
|
isNew: { type: 'boolean', description: 'true = author from scratch; false = update in place from changedFiles' }, |
|
changedFiles: { type: 'array', items: { type: 'string' }, description: 'update mode: repo-relative changed paths in this unit; [] in build mode' }, |
|
ownedRules: { type: 'array', items: { type: 'string' }, description: 'existing rule files the manifest attributes to this unit; [] in build mode' }, |
|
}, |
|
}, |
|
}, |
|
deletedUnits: { |
|
type: 'array', |
|
description: 'update mode only: units whose folder no longer exists on disk', |
|
items: { |
|
type: 'object', additionalProperties: false, required: ['name', 'path', 'ownedRules'], |
|
properties: { name: { type: 'string' }, path: { type: 'string' }, ownedRules: { type: 'array', items: { type: 'string' } } }, |
|
}, |
|
}, |
|
}, |
|
} |
|
|
|
const AUTHOR_SCHEMA = { |
|
type: 'object', additionalProperties: false, |
|
required: ['unit', 'path', 'action', 'sections'], |
|
properties: { |
|
unit: { type: 'string' }, |
|
path: { type: 'string', description: 'path to the CLAUDE.md written/edited' }, |
|
action: { enum: ['created', 'updated', 'unchanged'] }, |
|
sections: { |
|
type: 'array', |
|
items: { |
|
type: 'object', additionalProperties: false, required: ['title', 'approxLines', 'scope', 'touched'], |
|
properties: { |
|
title: { type: 'string' }, |
|
approxLines: { type: 'integer' }, |
|
scope: { enum: ['folder', 'filetype', 'subpath'], description: 'folder = whole unit; filetype/subpath = candidate for path-scoped rule' }, |
|
candidateGlobs: { type: 'array', items: { type: 'string' } }, |
|
touched: { type: 'boolean', description: 'true if written/changed this run (build: all true; update: only edited sections). Migration re-checks only touched sections.' }, |
|
}, |
|
}, |
|
}, |
|
}, |
|
} |
|
|
|
const MIGRATE_SCHEMA = { |
|
type: 'object', additionalProperties: false, |
|
required: ['unit', 'rulesCreated', 'rulesRetired', 'rulesReScoped'], |
|
properties: { |
|
unit: { type: 'string' }, |
|
rulesCreated: { |
|
type: 'array', |
|
items: { |
|
type: 'object', additionalProperties: false, required: ['file', 'source', 'paths'], |
|
properties: { |
|
file: { type: 'string', description: '.claude/rules/... path written' }, |
|
source: { type: 'string', description: '<unit>/CLAUDE.md#section it came from' }, |
|
paths: { type: 'array', items: { type: 'string' }, description: 'globs; [] = always-on rule (no paths: frontmatter)' }, |
|
}, |
|
}, |
|
}, |
|
rulesRetired: { type: 'array', items: { type: 'string' }, description: 'rule files deleted (folded back inline or orphaned)' }, |
|
rulesReScoped: { |
|
type: 'array', |
|
items: { |
|
type: 'object', additionalProperties: false, required: ['file', 'paths'], |
|
properties: { file: { type: 'string' }, paths: { type: 'array', items: { type: 'string' } } }, |
|
}, |
|
}, |
|
}, |
|
} |
|
|
|
const FINAL_SCHEMA = { |
|
type: 'object', additionalProperties: false, |
|
required: ['rootClaudePath', 'manifestPath', 'rootChanged', 'conflictsResolved', 'summary'], |
|
properties: { |
|
rootClaudePath: { type: 'string' }, |
|
manifestPath: { type: 'string' }, |
|
rootChanged: { type: 'boolean' }, |
|
conflictsResolved: { type: 'array', items: { type: 'string' } }, |
|
summary: { type: 'string', description: 'one-line human summary of what changed' }, |
|
}, |
|
} |
|
|
|
// ---- prompts ----------------------------------------------------------------- |
|
|
|
const assessPrompt = (force) => `You are the ASSESS scout for a codebase guidance-map run. Work read-only; do NOT write files. |
|
Be cheap: fetch file NAMES, never diff bodies. |
|
|
|
STEP A - detect MODE: |
|
- Read ".claude/docmap.json" if it exists. |
|
- If it is MISSING${force ? ', OR force is set (it is)' : ''} -> mode="build". |
|
- Else -> mode="update" (use its lastSyncCommit, units[], rules[]). |
|
|
|
repoRoot = \`git rev-parse --show-toplevel\` (if not a git repo, use cwd and note it). |
|
|
|
IF mode="build": |
|
1. Enumerate TOP-LEVEL units from TRACKED files only so ignored dirs are auto-skipped: |
|
\`git ls-files | sed 's#/.*##' | sort -u\` (fallback to a plain listing if not git). |
|
Exclude .git and generated/vendored dirs: node_modules, dist, build, out, target, .venv, |
|
venv, vendor, coverage, .next, .cache, and similar. |
|
2. MONOREPO: if the top level is essentially just packages/ apps/ services/ libs/, descend ONE |
|
level and treat each package/app as a unit (path like "packages/api"). |
|
3. SMALL-REPO GUARD: if there are only a handful of folders / a few thousand lines total, set |
|
qualifies=false + reason (a single hand-written root CLAUDE.md beats fan-out). Otherwise qualifies=true. |
|
4. workItems = every unit with isNew=true, changedFiles=[], ownedRules=[]. |
|
deletedUnits=[]. nothingChanged=false. rootRefreshNeeded=true. lastSyncCommit="". |
|
Detect an existing root CLAUDE.md (./CLAUDE.md or ./.claude/CLAUDE.md) -> existingRootClaude. |
|
|
|
IF mode="update": |
|
1. qualifies=true. Compute changed files (NAMES ONLY): |
|
\`git diff --name-only <lastSyncCommit> HEAD\` (skip the range if lastSyncCommit empty/invalid) |
|
plus \`git status --porcelain\` for uncommitted/staged work. If lastSyncCommit is missing/invalid, |
|
do NOT silently full-rebuild: set qualifies=false + reason recommending a forced rebuild. |
|
2. Map changed files to units via units[]: |
|
- workItems: existing units with >=1 changed file (isNew=false, changedFiles filled, ownedRules |
|
from rules[] belonging to that unit) PLUS brand-new top-level folders/packages not in units[] |
|
(isNew=true, changedFiles=[], ownedRules=[]), applying the same ignore/generated-dir exclusions. |
|
- deletedUnits: units[] whose folder no longer exists on disk (+ ownedRules). |
|
3. rootRefreshNeeded = units added/removed OR root build files changed (package.json, lockfiles, |
|
Makefile, pyproject, cargo.toml, CI config). globalCommands = current root build/test/run commands. |
|
4. nothingChanged = true ONLY if workItems, deletedUnits are empty AND rootRefreshNeeded is false. |
|
|
|
Return the structured result. All paths repo-relative.` |
|
|
|
const authorPrompt = (item) => item.isNew |
|
? `You are a scoped AUTHOR worker for unit "${item.name}" at repo-relative path "${item.path}" (fresh doc). |
|
Read ONLY files under "${item.path}" (glance at root entry points/config for inbound/outbound edges). |
|
If the unit is large, sample by subdir; do not read everything. |
|
|
|
Write "${item.path}/CLAUDE.md" (Write tool β the file IS your deliverable). docmap OWNS this file: |
|
if a CLAUDE.md already exists here, read it FIRST as an optional, DISPENSABLE hint (salvage any |
|
still-accurate facts worth keeping), then OVERWRITE it with your clean version. Do not preserve stale |
|
or off-topic human prose β docmap is the source of truth for this file. |
|
${AUDIENCE} |
|
${UNIT_SECTIONS} |
|
${FOOTER_RULE} |
|
|
|
Longer single-concern sections are OK; a later Opus pass migrates any that qualify. Return the |
|
structured summary: sections with APPROX line counts, a scope flag (folder = whole unit; |
|
filetype/subpath = only certain file types/subpath, with candidateGlobs like ["${item.path}/**/*.ts"]), |
|
and touched=true for ALL sections (this is a fresh file). action="created". Return data, not prose.` |
|
: `You are a scoped UPDATE worker for unit "${item.name}" at "${item.path}". |
|
Files changed in this unit since the last sync: |
|
${JSON.stringify(item.changedFiles, null, 2)} |
|
|
|
Read ONLY those changed files, plus the current "${item.path}/CLAUDE.md" and this unit's owned rule |
|
files: ${JSON.stringify(item.ownedRules || [], null, 2)}. |
|
${AUDIENCE} |
|
|
|
DIFF MEANING, NOT TEXT. Update ONLY sections whose facts actually changed: layout entries for |
|
added/renamed/deleted files, conventions that shifted, commands that changed, pitfalls no longer |
|
true. Leave correct sections BYTE-FOR-BYTE untouched (stable = no token spend, no churn). Use the |
|
Edit tool for in-place edits; do not rewrite the whole file. Sections: ${UNIT_SECTIONS.trim()} |
|
${FOOTER_RULE} |
|
|
|
Return the structured summary. Mark touched=true ONLY on sections you actually changed (migration |
|
re-checks only those). action="updated" if you changed anything, else "unchanged".` |
|
|
|
const migratePrompt = (author, item) => `You are the MIGRATION worker (Opus-tier judgment) for unit "${item.name}" at "${item.path}". |
|
The author worker's section summary: |
|
${JSON.stringify(author.sections, null, 2)} |
|
|
|
Read "${item.path}/CLAUDE.md" and apply the migration rule to TOUCHED sections only (skip untouched |
|
sections β already settled). Migrate a section OUT into its own rule file when BOTH hold: |
|
(a) it exceeds ~20-25 lines, AND |
|
(b) it covers ONE specific, cohesive concern (testing, api-error-format, db-migrations, styling, |
|
auth, ...), self-contained β not a grab-bag. |
|
If a long section is actually several concerns, SPLIT it first, then migrate the qualifying pieces. |
|
|
|
For each migrated section: |
|
- Write it to ".claude/rules/${slug(item.name)}/<concern>.md" (kebab-case concern, one per file; |
|
nesting under the unit avoids clashes β rules are discovered recursively). |
|
- PATH-SCOPE whenever it only applies to certain file types/subpath. Narrowest correct glob(s) in |
|
YAML frontmatter: |
|
--- |
|
paths: |
|
- "${item.path}/**/*.ts" |
|
--- |
|
Brace expansion ("**/*.{ts,tsx}") and multiple patterns allowed. Prefer path-scoped (loads only |
|
when matching files are touched). Omit "paths:" ONLY for concerns relevant folder-wide+ every session. |
|
- In "${item.path}/CLAUDE.md", REPLACE the migrated section with a one-line navigation pointer, e.g. |
|
"- Testing: see .claude/rules/${item.name}/testing.md". (Rules load on their own; pointer is for browsing.) |
|
- Verify each glob matches >=1 real file (a glob matching nothing silently does nothing). |
|
|
|
ALSO reconcile existing rules for this unit: |
|
- A touched section that SHRANK back under threshold or lost single-concern focus -> optionally fold |
|
back inline and DELETE the orphaned rule (report in rulesRetired). |
|
- Any owned rule whose "paths:" glob no longer matches a real file (files moved/renamed) -> fix it |
|
(rulesReScoped) or delete it (rulesRetired). |
|
|
|
Use Edit for CLAUDE.md, Write for new rules, and actually delete retired rule files. |
|
${FOOTER_RULE} |
|
Return the result (rulesCreated paths=[] means a deliberate always-on rule).` |
|
|
|
const finalizePrompt = (assess, migrations, authored) => `You are the FINALIZER (Opus-tier). Mode="${assess.mode}". All units authored + migrated. Data: |
|
|
|
ASSESS: ${JSON.stringify({ repoRoot: assess.repoRoot, globalCommands: assess.globalCommands, existingRootClaude: assess.existingRootClaude, rootRefreshNeeded: assess.rootRefreshNeeded, workItems: assess.workItems.map(w => ({ name: w.name, path: w.path, isNew: w.isNew })), deletedUnits: assess.deletedUnits }, null, 2)} |
|
AUTHORED: ${JSON.stringify(authored, null, 2)} |
|
MIGRATIONS: ${JSON.stringify(migrations, null, 2)} |
|
|
|
1. DELETED UNITS (if any): delete each one's CLAUDE.md and owned rule files if not already gone. |
|
2. ROOT CLAUDE.md: docmap OWNS this file and (re)writes it in full. ${assess.mode === 'build' |
|
? 'Write it (prefer ./CLAUDE.md; use ./.claude/CLAUDE.md if that already exists).' |
|
: 'Rewrite it if rootRefreshNeeded is true; otherwise leave it as docmap last wrote it and set rootChanged=false.'} |
|
It loads IN FULL every session -> keep it a LEAN always-on INDEX, well under 200 lines: |
|
- one-line repo purpose + primary build/test/run commands, |
|
- a "Where things live" list: one line per unit -> its folder + its CLAUDE.md, |
|
- only genuinely GLOBAL "always do X" conventions. |
|
OVERWRITE SEMANTICS: if a root CLAUDE.md already exists, you may READ it as an optional, DISPENSABLE |
|
hint (salvage any still-accurate build commands or conventions worth keeping), then REPLACE the file |
|
with docmap's clean index. Do NOT try to preserve arbitrary human content β docmap is the source of |
|
truth for this file. For an update, ensure the "Where things live" list reflects added/removed units. |
|
${FOOTER_RULE} |
|
3. CONFLICT CHECK: scan unit CLAUDE.md files + .claude/rules/* for directly contradicting instructions |
|
(Claude picks arbitrarily on conflict). Resolve each; list them. Verify no navigation pointer |
|
references a missing rule file. |
|
4. MANIFEST ".claude/docmap.json" (NOT loaded into context β zero token cost): |
|
{ |
|
"schema": 2, |
|
"lastSyncCommit": "<git rev-parse HEAD>", // "" if not a git repo |
|
"root": { "file": "<repo-relative path to the root CLAUDE.md you wrote>" }, |
|
"units": [ ...current repo-relative unit paths (existing minus deleted plus new)... ], |
|
"rules": [ { "file": "...", "source": "...", "paths": [...] }, ... ] |
|
} |
|
${assess.mode === 'update' ? 'Start from the prior rules[] and root object, then add rulesCreated, drop rulesRetired, apply rulesReScoped globs.' : 'Build rules[] from MIGRATIONS.rulesCreated.'} |
|
Run \`git rev-parse HEAD\` yourself for lastSyncCommit. |
|
|
|
Return the structured result.` |
|
|
|
// ---- orchestration ----------------------------------------------------------- |
|
|
|
const opts = (typeof args === 'object' && args) ? args : {} |
|
const force = !!opts.force |
|
|
|
phase('Assess') |
|
const assess = await agent(assessPrompt(force), { |
|
label: 'assess', phase: 'Assess', model: 'sonnet', agentType: 'general-purpose', schema: ASSESS_SCHEMA, |
|
}) |
|
|
|
if (!assess) return { skipped: true, reason: 'assess stage failed' } |
|
if (!assess.qualifies) { |
|
log(`Skipping: ${assess.reason}`) |
|
return { skipped: true, mode: assess.mode, reason: assess.reason } |
|
} |
|
if (assess.mode === 'update' && assess.nothingChanged) { |
|
log('docmap already up to date β nothing changed since last sync.') |
|
return { upToDate: true, lastSyncCommit: assess.lastSyncCommit } |
|
} |
|
|
|
log('β οΈ docmap OWNS every CLAUDE.md it writes: existing CLAUDE.md files are treated as a dispensable ' + |
|
'hint and OVERWRITTEN. Salvageable facts are kept, arbitrary human prose is not.') |
|
|
|
const workItems = assess.workItems || [] |
|
log(`${assess.mode === 'build' ? 'Building' : 'Updating'} docmap: ${workItems.length} unit(s)` + |
|
(assess.deletedUnits && assess.deletedUnits.length ? `, ${assess.deletedUnits.length} deleted` : '') + |
|
(workItems.length ? ` β ${workItems.map(w => w.name).join(', ')}` : '')) |
|
|
|
// author (Sonnet 5) -> migrate (Opus 4.8), pipelined per unit: Opus migrates unit A the moment its |
|
// CLAUDE.md is written while Sonnet still authors unit B. Per-unit work is independent. |
|
const authored = [] |
|
const migrations = (await pipeline( |
|
workItems, |
|
(item) => agent(authorPrompt(item), { |
|
label: `author:${item.name}`, phase: 'Author', model: 'sonnet', agentType: 'general-purpose', schema: AUTHOR_SCHEMA, |
|
}), |
|
(author, item) => { |
|
if (!author) return null |
|
authored.push({ unit: author.unit, action: author.action }) |
|
if (author.action === 'unchanged') return null // nothing to migrate |
|
return agent(migratePrompt(author, item), { |
|
label: `migrate:${item.name}`, phase: 'Migrate', model: 'opus', agentType: 'general-purpose', schema: MIGRATE_SCHEMA, |
|
}) |
|
}, |
|
)).filter(Boolean) |
|
|
|
// Barrier: deletions, root map + cross-unit conflict resolution, and the manifest need every result. |
|
phase('Finalize') |
|
const final = await agent(finalizePrompt(assess, migrations, authored), { |
|
label: 'finalize', phase: 'Finalize', model: 'opus', agentType: 'general-purpose', schema: FINAL_SCHEMA, |
|
}) |
|
|
|
return { |
|
mode: assess.mode, |
|
units: workItems.map(w => w.name), |
|
newUnits: workItems.filter(w => w.isNew).map(w => w.name), |
|
deletedUnits: (assess.deletedUnits || []).map(w => w.name), |
|
migratedUnits: migrations.length, |
|
...final, |
|
} |