docutrack 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +116 -0
  2. package/bin/docutrack.js +67 -0
  3. package/package.json +38 -0
  4. package/src/analyzer/complexity.js +145 -0
  5. package/src/analyzer/detect.js +124 -0
  6. package/src/analyzer/index.js +121 -0
  7. package/src/analyzer/parsers/express.js +110 -0
  8. package/src/analyzer/parsers/fastapi.js +89 -0
  9. package/src/commands/analyze.js +47 -0
  10. package/src/commands/badge.js +79 -0
  11. package/src/commands/check.js +187 -0
  12. package/src/commands/clear.js +17 -0
  13. package/src/commands/export.js +182 -0
  14. package/src/commands/init.js +182 -0
  15. package/src/commands/onboard.js +288 -0
  16. package/src/commands/scan.js +121 -0
  17. package/src/commands/serve.js +48 -0
  18. package/src/commands/status.js +94 -0
  19. package/src/utils/drift.js +167 -0
  20. package/src/utils/queue.js +62 -0
  21. package/src/utils/settings.js +69 -0
  22. package/src/utils/stale.js +80 -0
  23. package/src/viewer/index.html +1411 -0
  24. package/src/viewer/server.js +652 -0
  25. package/templates/ARCHITECTURE.md +51 -0
  26. package/templates/agents/documentalista.md +113 -0
  27. package/templates/claude-snippet.md +39 -0
  28. package/templates/commands/adr-new.md +58 -0
  29. package/templates/commands/arch-review.md +59 -0
  30. package/templates/commands/ask-docs.md +26 -0
  31. package/templates/commands/doc-map.md +50 -0
  32. package/templates/docs/api/.gitkeep +0 -0
  33. package/templates/docs/decisions/.gitkeep +0 -0
  34. package/templates/docs/modules/.gitkeep +0 -0
  35. package/templates/docutrack.config.json +13 -0
  36. package/templates/github/workflows/docutrack-docs.yml +42 -0
  37. package/templates/github/workflows/docutrack-gate.yml +31 -0
  38. package/templates/github/workflows/docutrack-pr.yml +93 -0
  39. package/templates/hooks/on-stop.js +39 -0
  40. package/templates/hooks/post-tool-use.js +52 -0
  41. package/templates/stacks/express/ARCHITECTURE.md +67 -0
  42. package/templates/stacks/express/documentalista.md +63 -0
  43. package/templates/stacks/fastapi/ARCHITECTURE.md +68 -0
  44. package/templates/stacks/fastapi/documentalista.md +88 -0
  45. package/templates/stacks/go/ARCHITECTURE.md +68 -0
  46. package/templates/stacks/go/documentalista.md +89 -0
  47. package/templates/stacks/monorepo/ARCHITECTURE.md +60 -0
  48. package/templates/stacks/monorepo/documentalista.md +59 -0
  49. package/templates/stacks/nextjs/ARCHITECTURE.md +76 -0
  50. package/templates/stacks/nextjs/documentalista.md +93 -0
@@ -0,0 +1,182 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ const FORMATS = ['mintlify', 'docusaurus']
7
+
8
+ async function run(args) {
9
+ if (!fs.existsSync('.docutrack')) {
10
+ console.log('\nDocuTrack is not initialized. Run "npx docutrack init" first.\n')
11
+ process.exit(1)
12
+ }
13
+
14
+ const formatArg = args.find(a => a.startsWith('--format='))?.split('=')[1]
15
+ || (args.indexOf('--format') !== -1 ? args[args.indexOf('--format') + 1] : null)
16
+
17
+ const outArg = args.find(a => a.startsWith('--out='))?.split('=')[1]
18
+ || (args.indexOf('--out') !== -1 ? args[args.indexOf('--out') + 1] : null)
19
+
20
+ if (!formatArg || !FORMATS.includes(formatArg)) {
21
+ console.log(`\nUsage: docutrack export --format <${FORMATS.join('|')}> [--out <dir>]\n`)
22
+ process.exit(1)
23
+ }
24
+
25
+ const outDir = outArg || `docutrack-${formatArg}`
26
+
27
+ if (formatArg === 'mintlify') await exportMintlify(outDir)
28
+ else if (formatArg === 'docusaurus') await exportDocusaurus(outDir)
29
+ }
30
+
31
+ // ── Mintlify ──────────────────────────────────────────────────────────────
32
+ async function exportMintlify(outDir) {
33
+ console.log(`\nExporting to Mintlify format → ${outDir}/\n`)
34
+
35
+ const nav = []
36
+ fs.mkdirSync(outDir, { recursive: true })
37
+
38
+ // ARCHITECTURE.md → overview.mdx
39
+ if (fs.existsSync('ARCHITECTURE.md')) {
40
+ const content = fs.readFileSync('ARCHITECTURE.md', 'utf8')
41
+ fs.writeFileSync(path.join(outDir, 'overview.mdx'), `---\ntitle: "Architecture"\ndescription: "System architecture overview"\n---\n\n${content}`)
42
+ nav.push('overview')
43
+ console.log(' ✓ overview.mdx')
44
+ }
45
+
46
+ // docs/modules/ → modules/
47
+ const modulesNav = copySection('docs/modules', path.join(outDir, 'modules'), 'mdx')
48
+ if (modulesNav.length) {
49
+ nav.push({ group: 'Modules', pages: modulesNav.map(f => `modules/${f}`) })
50
+ console.log(` ✓ modules/ (${modulesNav.length} files)`)
51
+ }
52
+
53
+ // docs/decisions/ → decisions/
54
+ const decisionsNav = copySection('docs/decisions', path.join(outDir, 'decisions'), 'mdx')
55
+ if (decisionsNav.length) {
56
+ nav.push({ group: 'Decisions', pages: decisionsNav.map(f => `decisions/${f}`) })
57
+ console.log(` ✓ decisions/ (${decisionsNav.length} files)`)
58
+ }
59
+
60
+ // docs/api/ → api-reference/
61
+ const apiNav = copySection('docs/api', path.join(outDir, 'api-reference'), 'mdx')
62
+ if (apiNav.length) {
63
+ nav.push({ group: 'API Reference', pages: apiNav.map(f => `api-reference/${f}`) })
64
+ console.log(` ✓ api-reference/ (${apiNav.length} files)`)
65
+ }
66
+
67
+ // Copy openapi.json if exists
68
+ if (fs.existsSync('docs/api/openapi.json')) {
69
+ fs.copyFileSync('docs/api/openapi.json', path.join(outDir, 'openapi.json'))
70
+ console.log(' ✓ openapi.json')
71
+ }
72
+
73
+ // Generate mint.json
74
+ const mintJson = {
75
+ $schema: 'https://mintlify.com/schema.json',
76
+ name: getProjectName(),
77
+ logo: { light: '/logo/light.svg', dark: '/logo/dark.svg' },
78
+ favicon: '/favicon.svg',
79
+ colors: { primary: '#6366f1', light: '#818cf8', dark: '#4f46e5' },
80
+ topbarLinks: [],
81
+ topbarCtaButton: { name: 'GitHub', url: 'https://github.com/your-org/your-repo' },
82
+ tabs: [],
83
+ anchors: [],
84
+ navigation: nav,
85
+ footerSocials: {},
86
+ }
87
+ fs.writeFileSync(path.join(outDir, 'mint.json'), JSON.stringify(mintJson, null, 2))
88
+ console.log(' ✓ mint.json')
89
+ console.log(`\nDone. To preview:\n cd ${outDir} && npx mintlify dev\n`)
90
+ }
91
+
92
+ // ── Docusaurus ─────────────────────────────────────────────────────────────
93
+ async function exportDocusaurus(outDir) {
94
+ console.log(`\nExporting to Docusaurus format → ${outDir}/\n`)
95
+
96
+ const docsDir = path.join(outDir, 'docs')
97
+ fs.mkdirSync(docsDir, { recursive: true })
98
+
99
+ const sidebar = {}
100
+
101
+ // ARCHITECTURE.md → docs/overview.md
102
+ if (fs.existsSync('ARCHITECTURE.md')) {
103
+ const content = fs.readFileSync('ARCHITECTURE.md', 'utf8')
104
+ fs.writeFileSync(path.join(docsDir, 'overview.md'), `---\nsidebar_position: 1\n---\n\n${content}`)
105
+ console.log(' ✓ docs/overview.md')
106
+ }
107
+
108
+ // docs/modules/ → docs/modules/
109
+ const modulesItems = copySection('docs/modules', path.join(docsDir, 'modules'), 'md')
110
+ if (modulesItems.length) {
111
+ sidebar.modules = { label: 'Modules', items: modulesItems }
112
+ console.log(` ✓ docs/modules/ (${modulesItems.length} files)`)
113
+ }
114
+
115
+ // docs/decisions/ → docs/decisions/
116
+ const decisionsItems = copySection('docs/decisions', path.join(docsDir, 'decisions'), 'md')
117
+ if (decisionsItems.length) {
118
+ sidebar.decisions = { label: 'Decisions', items: decisionsItems }
119
+ console.log(` ✓ docs/decisions/ (${decisionsItems.length} files)`)
120
+ }
121
+
122
+ // docs/api/ → docs/api/
123
+ const apiItems = copySection('docs/api', path.join(docsDir, 'api'), 'md')
124
+ if (apiItems.length) {
125
+ sidebar.api = { label: 'API', items: apiItems }
126
+ console.log(` ✓ docs/api/ (${apiItems.length} files)`)
127
+ }
128
+
129
+ // sidebars.js
130
+ const sidebarJs = `/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
131
+ const sidebars = {
132
+ docs: [
133
+ 'overview',
134
+ ${sidebar.modules ? `{ type: 'category', label: 'Modules', items: ${JSON.stringify(sidebar.modules.items.map(f => `modules/${f}`))} },` : ''}
135
+ ${sidebar.decisions ? `{ type: 'category', label: 'Decisions', items: ${JSON.stringify(sidebar.decisions.items.map(f => `decisions/${f}`))} },` : ''}
136
+ ${sidebar.api ? `{ type: 'category', label: 'API', items: ${JSON.stringify(sidebar.api.items.map(f => `api/${f}`))} },` : ''}
137
+ ],
138
+ }
139
+ module.exports = sidebars`
140
+ fs.writeFileSync(path.join(outDir, 'sidebars.js'), sidebarJs)
141
+
142
+ // Minimal docusaurus.config.js
143
+ const name = getProjectName()
144
+ const config = `/** @type {import('@docusaurus/types').Config} */
145
+ const config = {
146
+ title: '${name}',
147
+ tagline: 'Generated by DocuTrack',
148
+ url: 'https://your-domain.com',
149
+ baseUrl: '/',
150
+ onBrokenLinks: 'warn',
151
+ onBrokenMarkdownLinks: 'warn',
152
+ i18n: { defaultLocale: 'en', locales: ['en'] },
153
+ presets: [['classic', { docs: { sidebarPath: require.resolve('./sidebars.js'), routeBasePath: '/' }, blog: false, theme: { customCss: [] } }]],
154
+ themeConfig: { navbar: { title: '${name}', items: [] }, footer: { style: 'dark', copyright: 'Generated by DocuTrack' } },
155
+ }
156
+ module.exports = config`
157
+ fs.writeFileSync(path.join(outDir, 'docusaurus.config.js'), config)
158
+ console.log(' ✓ docusaurus.config.js + sidebars.js')
159
+ console.log(`\nDone. To preview:\n cd ${outDir} && npx create-docusaurus@latest . classic --skip-install && npm install && npm start\n`)
160
+ }
161
+
162
+ // ── Helpers ────────────────────────────────────────────────────────────────
163
+ function copySection(srcDir, destDir, ext) {
164
+ const files = []
165
+ if (!fs.existsSync(srcDir)) return files
166
+ fs.mkdirSync(destDir, { recursive: true })
167
+ for (const entry of fs.readdirSync(srcDir)) {
168
+ if (!entry.endsWith('.md') || entry === '.gitkeep') continue
169
+ const base = entry.replace('.md', '')
170
+ const content = fs.readFileSync(path.join(srcDir, entry), 'utf8')
171
+ const destFile = `${base}.${ext}`
172
+ fs.writeFileSync(path.join(destDir, destFile), content)
173
+ files.push(base)
174
+ }
175
+ return files
176
+ }
177
+
178
+ function getProjectName() {
179
+ try { return JSON.parse(fs.readFileSync('package.json', 'utf8')).name || 'Project' } catch { return 'Project' }
180
+ }
181
+
182
+ module.exports = { run }
@@ -0,0 +1,182 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const { installHooks, SETTINGS_PATH } = require('../utils/settings')
6
+
7
+ const TEMPLATES_DIR = path.join(__dirname, '..', '..', 'templates')
8
+ const VALID_TEMPLATES = ['nextjs', 'fastapi', 'express', 'monorepo', 'go']
9
+
10
+ function copyFile(src, dest) {
11
+ const dir = path.dirname(dest)
12
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
13
+ fs.copyFileSync(src, dest)
14
+ }
15
+
16
+ function copyDir(src, dest) {
17
+ if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true })
18
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
19
+ const srcPath = path.join(src, entry.name)
20
+ const destPath = path.join(dest, entry.name)
21
+ if (entry.isDirectory()) copyDir(srcPath, destPath)
22
+ else copyFile(srcPath, destPath)
23
+ }
24
+ }
25
+
26
+ function step(msg) { process.stdout.write(` ${msg}\n`) }
27
+
28
+ function autoDetectTemplate() {
29
+ // Node.js: check package.json
30
+ try {
31
+ const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'))
32
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies }
33
+ if (deps.next) return 'nextjs'
34
+ if (pkg.workspaces || fs.existsSync('pnpm-workspace.yaml') || fs.existsSync('turbo.json')) return 'monorepo'
35
+ if (deps.express || deps['@types/express'] || deps.fastify || deps.koa) return 'express'
36
+ } catch { /* not a node project */ }
37
+
38
+ // Python: check for FastAPI
39
+ for (const f of ['requirements.txt', 'pyproject.toml', 'Pipfile']) {
40
+ if (!fs.existsSync(f)) continue
41
+ const content = fs.readFileSync(f, 'utf8').toLowerCase()
42
+ if (content.includes('fastapi')) return 'fastapi'
43
+ }
44
+
45
+ // Go
46
+ if (fs.existsSync('go.mod')) return 'go'
47
+
48
+ return null
49
+ }
50
+
51
+ async function run(args) {
52
+ console.log('\nDocuTrack — initializing in current project\n')
53
+
54
+ // Guard: already initialized
55
+ if (fs.existsSync('.docutrack')) {
56
+ console.log('DocuTrack is already initialized in this project.')
57
+ console.log('Run "docutrack status" to see the current queue.\n')
58
+ return
59
+ }
60
+
61
+ // Resolve template
62
+ const templateFlag = args?.find(a => a.startsWith('--template='))?.split('=')[1]
63
+ || (args?.indexOf('--template') !== -1 ? args[args.indexOf('--template') + 1] : null)
64
+ const template = (templateFlag && VALID_TEMPLATES.includes(templateFlag))
65
+ ? templateFlag
66
+ : autoDetectTemplate()
67
+
68
+ if (templateFlag && !VALID_TEMPLATES.includes(templateFlag)) {
69
+ console.log(`Unknown template: "${templateFlag}". Valid options: ${VALID_TEMPLATES.join(', ')}\n`)
70
+ process.exit(1)
71
+ }
72
+
73
+ // 1. .docutrack/ structure
74
+ fs.mkdirSync('.docutrack/hooks', { recursive: true })
75
+ step('Created .docutrack/')
76
+
77
+ // 2. Queue
78
+ fs.writeFileSync('.docutrack/queue.json', JSON.stringify({ pending: [], lastClear: null }, null, 2))
79
+ step('Created .docutrack/queue.json')
80
+
81
+ // 3. Hook scripts
82
+ copyFile(path.join(TEMPLATES_DIR, 'hooks', 'post-tool-use.js'), '.docutrack/hooks/post-tool-use.js')
83
+ copyFile(path.join(TEMPLATES_DIR, 'hooks', 'on-stop.js'), '.docutrack/hooks/on-stop.js')
84
+ step('Installed hooks → .docutrack/hooks/')
85
+
86
+ // 4. /docs structure
87
+ copyDir(path.join(TEMPLATES_DIR, 'docs'), 'docs')
88
+ step('Created docs/ (modules/, decisions/, api/)')
89
+
90
+ // 5. ARCHITECTURE.md — use stack template if available, else base
91
+ if (!fs.existsSync('ARCHITECTURE.md')) {
92
+ const stackArch = template && path.join(TEMPLATES_DIR, 'stacks', template, 'ARCHITECTURE.md')
93
+ const archSrc = (stackArch && fs.existsSync(stackArch))
94
+ ? stackArch
95
+ : path.join(TEMPLATES_DIR, 'ARCHITECTURE.md')
96
+ copyFile(archSrc, 'ARCHITECTURE.md')
97
+ step('Created ARCHITECTURE.md' + (template ? ` (${template} template)` : ''))
98
+ } else {
99
+ step('ARCHITECTURE.md already exists — skipped')
100
+ }
101
+
102
+ // 6. Slash commands
103
+ const commandsDir = path.join(TEMPLATES_DIR, 'commands')
104
+ for (const name of fs.readdirSync(commandsDir)) {
105
+ copyFile(path.join(commandsDir, name), path.join('.claude', 'commands', name))
106
+ }
107
+ step('Installed slash commands → .claude/commands/ (doc-map, arch-review, adr-new, ask-docs)')
108
+
109
+ // 7. Documentalista — use stack-specific version if available
110
+ const stackAgent = template && path.join(TEMPLATES_DIR, 'stacks', template, 'documentalista.md')
111
+ const agentSrc = (stackAgent && fs.existsSync(stackAgent))
112
+ ? stackAgent
113
+ : path.join(TEMPLATES_DIR, 'agents', 'documentalista.md')
114
+ copyFile(agentSrc, '.claude/agents/documentalista.md')
115
+ step('Installed documentalista subagent → .claude/agents/documentalista.md' + (template ? ` (${template})` : ''))
116
+
117
+ // 8. docutrack.config.json
118
+ if (!fs.existsSync('docutrack.config.json')) {
119
+ const cfgSrc = path.join(TEMPLATES_DIR, 'docutrack.config.json')
120
+ let cfg = JSON.parse(fs.readFileSync(cfgSrc, 'utf8'))
121
+ if (template) cfg.template = template
122
+ else delete cfg.template
123
+ fs.writeFileSync('docutrack.config.json', JSON.stringify(cfg, null, 2))
124
+ step('Created docutrack.config.json')
125
+ }
126
+
127
+ // 9. Hooks in .claude/settings.json
128
+ const installed = installHooks()
129
+ step(installed
130
+ ? `Registered hooks in ${SETTINGS_PATH}`
131
+ : `Hooks already registered in ${SETTINGS_PATH} — skipped`)
132
+
133
+ // 10. Detect if this is an existing project with source files
134
+ const hasExistingCode = detectExistingCode()
135
+
136
+ // 11. Print next steps
137
+ const snippetPath = path.join(TEMPLATES_DIR, 'claude-snippet.md')
138
+ const snippet = fs.readFileSync(snippetPath, 'utf8')
139
+ copyFile(snippetPath, '.docutrack/claude-snippet.md')
140
+
141
+ const templateLine = template
142
+ ? `\n Stack template : ${template}`
143
+ : ''
144
+
145
+ const existingProjectTip = hasExistingCode ? `
146
+ Existing project detected — bootstrap your docs:
147
+ 1. Run: docutrack scan (queues all source files at once)
148
+ 2. In Claude Code, say: "Run the documentalista to document all pending files"
149
+ 3. Run: docutrack serve (view the populated docs)
150
+ ` : `
151
+ What DocuTrack does from here:
152
+ • After every file edit → logs the file to .docutrack/queue.json
153
+ • When the session ends → the documentalista subagent updates the docs
154
+ • Run "docutrack serve" to open the documentation web viewer
155
+ • Run "docutrack status" to see coverage, pending, and stale docs
156
+ • Use /doc-map, /arch-review, /adr-new inside Claude Code sessions
157
+ `
158
+
159
+ console.log(`
160
+ Done. DocuTrack is active.${templateLine}
161
+ ${existingProjectTip}
162
+ Add this to your CLAUDE.md:
163
+ ${'─'.repeat(52)}
164
+ ${snippet}${'─'.repeat(52)}
165
+
166
+ (The snippet is also saved at .docutrack/claude-snippet.md)
167
+ `)
168
+ }
169
+
170
+ function detectExistingCode() {
171
+ const SOURCE_DIRS = ['src', 'lib', 'app', 'routes', 'controllers', 'handlers', 'api', 'pkg', 'internal']
172
+ const SOURCE_EXTS = ['.js', '.ts', '.jsx', '.tsx', '.py', '.go', '.mjs']
173
+ for (const dir of SOURCE_DIRS) {
174
+ if (!fs.existsSync(dir)) continue
175
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
176
+ if (entry.isFile() && SOURCE_EXTS.includes(path.extname(entry.name))) return true
177
+ }
178
+ }
179
+ return false
180
+ }
181
+
182
+ module.exports = { run }
@@ -0,0 +1,288 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const { findStale } = require('../utils/stale')
6
+
7
+ async function run(args) {
8
+ if (!fs.existsSync('.docutrack')) {
9
+ console.log('\nDocuTrack is not initialized. Run "npx docutrack init" first.\n')
10
+ process.exit(1)
11
+ }
12
+
13
+ const root = process.cwd()
14
+ const force = args.includes('--force')
15
+ const outPath = path.join(root, 'docs', 'ONBOARDING.md')
16
+
17
+ if (fs.existsSync(outPath) && !force) {
18
+ console.log(`\nONBOARDING.md already exists. Use --force to regenerate.\n`)
19
+ console.log(` Location: ${outPath}\n`)
20
+ return
21
+ }
22
+
23
+ console.log('\nDocuTrack — generating ONBOARDING.md\n')
24
+
25
+ const sections = []
26
+
27
+ // ── Project name + description ──────────────────────────
28
+ const meta = getProjectMeta(root)
29
+ sections.push(`# ${meta.name} — Onboarding Guide\n\n> Auto-generated by DocuTrack on ${new Date().toISOString().slice(0, 10)}.`)
30
+
31
+ // ── What is this project? ───────────────────────────────
32
+ const arch = readFile(root, 'ARCHITECTURE.md')
33
+ if (arch) {
34
+ // Extract intro paragraph (content before first ## header)
35
+ const intro = arch.split(/^##\s/m)[0].replace(/^#[^\n]*\n/, '').trim()
36
+ if (intro) {
37
+ sections.push(`## What Is This?\n\n${intro}`)
38
+ }
39
+ }
40
+
41
+ // ── Stack + framework ───────────────────────────────────
42
+ const stackInfo = getStackInfo(root)
43
+ if (stackInfo) sections.push(`## Stack\n\n${stackInfo}`)
44
+
45
+ // ── Setup ───────────────────────────────────────────────
46
+ const setup = getSetupInstructions(root, meta)
47
+ sections.push(`## Getting Started\n\n${setup}`)
48
+
49
+ // ── Module map ──────────────────────────────────────────
50
+ const moduleDocs = loadModuleDocs(root)
51
+ if (moduleDocs.length > 0) {
52
+ const table = buildModuleTable(moduleDocs)
53
+ sections.push(`## Module Map\n\n${table}`)
54
+ }
55
+
56
+ // ── API surface ─────────────────────────────────────────
57
+ const apiInfo = getApiSummary(root)
58
+ if (apiInfo) sections.push(`## API Surface\n\n${apiInfo}`)
59
+
60
+ // ── Architecture highlights ─────────────────────────────
61
+ if (arch) {
62
+ const highlights = extractArchSection(arch, ['## Architecture', '## System Design', '## Design'])
63
+ if (highlights) sections.push(`## Architecture Overview\n\n${highlights}`)
64
+ }
65
+
66
+ // ── ADRs ────────────────────────────────────────────────
67
+ const adrs = loadAdrs(root)
68
+ if (adrs.length > 0) {
69
+ const adrList = adrs.map(a => `- **${a.title}** — ${a.status} · \`${a.file}\``).join('\n')
70
+ sections.push(`## Key Decisions (ADRs)\n\n${adrList}\n\nRead the full ADRs in \`docs/decisions/\`.`)
71
+ }
72
+
73
+ // ── Documentation health ────────────────────────────────
74
+ const healthSection = getHealthSummary(root)
75
+ if (healthSection) sections.push(`## Documentation Health\n\n${healthSection}`)
76
+
77
+ // ── Workflow ────────────────────────────────────────────
78
+ sections.push(`## Development Workflow
79
+
80
+ 1. Make your changes in a feature branch
81
+ 2. Run \`docutrack status\` to see what needs documenting
82
+ 3. Use the \`/arch-review\` slash command to check coverage
83
+ 4. The documentalista subagent will update docs when the session ends
84
+ 5. Run \`docutrack check\` to validate doc health before opening a PR
85
+
86
+ ### Useful Commands
87
+
88
+ \`\`\`bash
89
+ docutrack status # Coverage, pending files, stale docs
90
+ docutrack check # Full health check: drift, complexity, stale
91
+ docutrack serve # Open docs in browser at localhost:4242
92
+ docutrack analyze # Re-scan API endpoints
93
+ docutrack badge # Regenerate README badge
94
+ \`\`\`
95
+
96
+ ### Slash Commands (in Claude Code)
97
+
98
+ | Command | Purpose |
99
+ |---------|---------|
100
+ | \`/doc-map\` | Visual map of all modules and endpoints |
101
+ | \`/arch-review\` | Coverage audit — which files lack docs |
102
+ | \`/adr-new\` | Guided ADR creation wizard |
103
+ | \`/ask-docs\` | Ask a question about the codebase using docs |
104
+ `)
105
+
106
+ // ── Write output ────────────────────────────────────────
107
+ fs.mkdirSync(path.dirname(outPath), { recursive: true })
108
+ fs.writeFileSync(outPath, sections.join('\n\n---\n\n'))
109
+
110
+ console.log(` ✓ Generated: ${path.relative(root, outPath)}`)
111
+ console.log(` ${moduleDocs.length} modules documented`)
112
+ if (adrs.length > 0) console.log(` ${adrs.length} ADRs referenced`)
113
+ console.log(`\nShare this with new contributors or serve it with "docutrack serve".\n`)
114
+ }
115
+
116
+ // ── Helpers ────────────────────────────────────────────────────────────────
117
+
118
+ function getProjectMeta(root) {
119
+ try {
120
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
121
+ return { name: pkg.name || path.basename(root), version: pkg.version, description: pkg.description }
122
+ } catch { /* ok */ }
123
+ try {
124
+ const mod = fs.readFileSync(path.join(root, 'go.mod'), 'utf8')
125
+ const m = mod.match(/^module\s+(\S+)/m)
126
+ return { name: m?.[1]?.split('/').pop() || path.basename(root), version: null }
127
+ } catch { /* ok */ }
128
+ return { name: path.basename(root), version: null }
129
+ }
130
+
131
+ function getStackInfo(root) {
132
+ const lines = []
133
+ try {
134
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
135
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies }
136
+ const runtime = `Node.js ${pkg.engines?.node || '18+'}`
137
+ lines.push(`| Runtime | ${runtime} |`)
138
+ const fw = deps.next ? 'Next.js' : deps.express ? 'Express' : deps.fastify ? 'Fastify' : deps.koa ? 'Koa' : 'Node.js'
139
+ lines.push(`| Framework | ${fw} |`)
140
+ if (pkg.scripts) {
141
+ const scripts = Object.entries(pkg.scripts).slice(0, 5).map(([k, v]) => `\`npm run ${k}\` — ${v}`).join('<br>')
142
+ lines.push(`| Scripts | ${scripts} |`)
143
+ }
144
+ } catch { /* ok */ }
145
+ try {
146
+ const req = fs.readFileSync(path.join(root, 'requirements.txt'), 'utf8')
147
+ if (req.toLowerCase().includes('fastapi')) {
148
+ lines.push('| Runtime | Python 3.10+ |')
149
+ lines.push('| Framework | FastAPI |')
150
+ }
151
+ } catch { /* ok */ }
152
+ try {
153
+ const mod = fs.readFileSync(path.join(root, 'go.mod'), 'utf8')
154
+ const ver = mod.match(/^go\s+([\d.]+)/m)?.[1]
155
+ if (ver) lines.push(`| Runtime | Go ${ver} |`)
156
+ } catch { /* ok */ }
157
+
158
+ if (!lines.length) return null
159
+ return `| | |\n|---|---|\n${lines.join('\n')}`
160
+ }
161
+
162
+ function getSetupInstructions(root, meta) {
163
+ const lines = []
164
+ const hasPkg = fs.existsSync(path.join(root, 'package.json'))
165
+ const hasPy = fs.existsSync(path.join(root, 'requirements.txt')) || fs.existsSync(path.join(root, 'pyproject.toml'))
166
+ const hasGo = fs.existsSync(path.join(root, 'go.mod'))
167
+
168
+ lines.push('```bash')
169
+ lines.push('# Clone the repo')
170
+ lines.push(`git clone <repo-url> && cd ${meta.name}`)
171
+
172
+ if (hasPkg) {
173
+ lines.push('')
174
+ lines.push('# Install dependencies')
175
+ const hasPnpm = fs.existsSync(path.join(root, 'pnpm-lock.yaml'))
176
+ const hasYarn = fs.existsSync(path.join(root, 'yarn.lock'))
177
+ lines.push(hasPnpm ? 'pnpm install' : hasYarn ? 'yarn install' : 'npm install')
178
+ }
179
+ if (hasPy) {
180
+ lines.push('')
181
+ lines.push('# Python setup')
182
+ lines.push('python -m venv .venv && source .venv/bin/activate')
183
+ lines.push(fs.existsSync(path.join(root, 'pyproject.toml')) ? 'pip install -e ".[dev]"' : 'pip install -r requirements.txt')
184
+ }
185
+ if (hasGo) {
186
+ lines.push('')
187
+ lines.push('go mod download')
188
+ }
189
+
190
+ lines.push('```')
191
+
192
+ // Check for .env.example
193
+ if (fs.existsSync(path.join(root, '.env.example'))) {
194
+ lines.push('\n**Environment variables:**\n```bash\ncp .env.example .env\n# Edit .env with your values\n```')
195
+ }
196
+
197
+ return lines.join('\n')
198
+ }
199
+
200
+ function loadModuleDocs(root) {
201
+ const docsDir = path.join(root, 'docs', 'modules')
202
+ if (!fs.existsSync(docsDir)) return []
203
+ const modules = []
204
+ for (const file of fs.readdirSync(docsDir)) {
205
+ if (!file.endsWith('.md')) continue
206
+ const content = fs.readFileSync(path.join(docsDir, file), 'utf8')
207
+ // Extract responsibility line
208
+ const resp = content.match(/\*\*Responsibility\*\*:?\s*(.+)/)?.[1]?.trim()
209
+ || content.match(/^##?\s*Responsibility\s*\n+(.+)/m)?.[1]?.trim()
210
+ || content.split('\n').filter(l => l.trim() && !l.startsWith('#'))[0]?.slice(0, 100)
211
+ modules.push({ name: file.replace('.md', ''), responsibility: resp || '—', file: `docs/modules/${file}` })
212
+ }
213
+ return modules
214
+ }
215
+
216
+ function buildModuleTable(modules) {
217
+ const rows = modules.map(m => `| \`${m.name}\` | ${m.responsibility} |`)
218
+ return `| Module | Responsibility |\n|--------|----------------|\n${rows.join('\n')}`
219
+ }
220
+
221
+ function getApiSummary(root) {
222
+ const specPath = path.join(root, 'docs', 'api', 'openapi.json')
223
+ if (!fs.existsSync(specPath)) return null
224
+ try {
225
+ const spec = JSON.parse(fs.readFileSync(specPath, 'utf8'))
226
+ const pathCount = Object.keys(spec.paths || {}).length
227
+ if (pathCount === 0) return null
228
+ const methods = {}
229
+ for (const p of Object.values(spec.paths)) {
230
+ for (const m of Object.keys(p)) methods[m] = (methods[m] || 0) + 1
231
+ }
232
+ const summary = Object.entries(methods).map(([m, n]) => `${n} ${m.toUpperCase()}`).join(', ')
233
+ const tags = [...new Set(Object.values(spec.paths).flatMap(p => Object.values(p).flatMap(op => op.tags || [])))]
234
+ return `**${pathCount} endpoints** (${summary})\\\nGroups: ${tags.map(t => `\`${t}\``).join(', ')}\n\nRun \`docutrack serve\` and open **API Explorer** to try endpoints interactively.`
235
+ } catch { return null }
236
+ }
237
+
238
+ function extractArchSection(content, headerNames) {
239
+ for (const header of headerNames) {
240
+ const re = new RegExp(`${header.replace('## ', '^## ')}[\\s\\S]*?(?=^## |$)`, 'm')
241
+ const match = content.match(re)
242
+ if (match) return match[0].replace(/^## [^\n]*\n/, '').trim().slice(0, 800)
243
+ }
244
+ return null
245
+ }
246
+
247
+ function loadAdrs(root) {
248
+ const dir = path.join(root, 'docs', 'decisions')
249
+ if (!fs.existsSync(dir)) return []
250
+ const adrs = []
251
+ for (const file of fs.readdirSync(dir)) {
252
+ if (!file.endsWith('.md') || file === '.gitkeep') continue
253
+ const content = fs.readFileSync(path.join(dir, file), 'utf8')
254
+ const title = content.match(/^#\s+(.+)/m)?.[1] || file.replace('.md', '')
255
+ const status = content.match(/##?\s*Status\s*:?\s*(.+)/i)?.[1]?.trim() || 'Unknown'
256
+ adrs.push({ file: `docs/decisions/${file}`, title, status })
257
+ }
258
+ return adrs.slice(0, 10)
259
+ }
260
+
261
+ function getHealthSummary(root) {
262
+ try {
263
+ const queue = JSON.parse(fs.readFileSync(path.join(root, '.docutrack', 'queue.json'), 'utf8'))
264
+ const stale = findStale(root)
265
+ const docCount = countDocs(root)
266
+ const pending = queue.pending?.length || 0
267
+ const lines = [
268
+ `| Metric | Value |`,
269
+ `|--------|-------|`,
270
+ `| Documented modules | ${docCount} |`,
271
+ `| Pending queue | ${pending} files |`,
272
+ `| Stale docs | ${stale.length} |`,
273
+ ]
274
+ return lines.join('\n')
275
+ } catch { return null }
276
+ }
277
+
278
+ function countDocs(root) {
279
+ const d = path.join(root, 'docs', 'modules')
280
+ if (!fs.existsSync(d)) return 0
281
+ return fs.readdirSync(d).filter(f => f.endsWith('.md')).length
282
+ }
283
+
284
+ function readFile(root, file) {
285
+ try { return fs.readFileSync(path.join(root, file), 'utf8') } catch { return null }
286
+ }
287
+
288
+ module.exports = { run }