runlist 0.0.0 → 0.94.2

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 (167) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1112 -6
  3. package/assets/app/app.css +283 -0
  4. package/assets/app/app.mjs +381 -0
  5. package/assets/app/block-editor.mjs +182 -0
  6. package/assets/app/block-model.mjs +52 -0
  7. package/assets/app/document-create.mjs +86 -0
  8. package/assets/app/document-lifecycle.mjs +36 -0
  9. package/assets/app/document-stages.mjs +22 -0
  10. package/assets/app/document-views.mjs +33 -0
  11. package/assets/app/document-yardstick.mjs +258 -0
  12. package/assets/app/editor-navigation.mjs +51 -0
  13. package/assets/app/filing-navigation.mjs +138 -0
  14. package/assets/app/git-changes.mjs +80 -0
  15. package/assets/app/git-commit.mjs +46 -0
  16. package/assets/app/index.html +70 -0
  17. package/assets/app/library-navigation.mjs +93 -0
  18. package/assets/app/outline.mjs +18 -0
  19. package/assets/app/quick-navigation.mjs +52 -0
  20. package/assets/app/record-navigation.mjs +78 -0
  21. package/assets/app/recovery-center.mjs +53 -0
  22. package/assets/app/semantic-search.mjs +14 -0
  23. package/assets/app/settings.mjs +15 -0
  24. package/assets/app/shared.mjs +104 -0
  25. package/assets/app/stage-source.mjs +75 -0
  26. package/assets/app/template-editor.mjs +47 -0
  27. package/assets/app/theme.mjs +25 -0
  28. package/assets/app/transport.mjs +43 -0
  29. package/assets/contracts/source-editing-v1.md +232 -0
  30. package/assets/opencode/plugin.js +162 -0
  31. package/assets/schemas/runlist-record-v1.schema.json +912 -0
  32. package/bin/dotmd.mjs +5 -0
  33. package/bin/runlist.mjs +2714 -0
  34. package/package.json +53 -6
  35. package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
  36. package/plugins/runlist-codex/bin/runlist-hook +86 -0
  37. package/plugins/runlist-codex/hooks/hooks.json +16 -0
  38. package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
  39. package/runlist.config.example.mjs +370 -0
  40. package/scripts/postinstall.mjs +56 -0
  41. package/src/agent-context.mjs +201 -0
  42. package/src/ai.mjs +30 -0
  43. package/src/app-create.mjs +169 -0
  44. package/src/app-filing.mjs +139 -0
  45. package/src/app-git-commits.mjs +48 -0
  46. package/src/app-git.mjs +180 -0
  47. package/src/app-library.mjs +152 -0
  48. package/src/app-lifecycle-worker.mjs +12 -0
  49. package/src/app-lifecycle.mjs +79 -0
  50. package/src/app-records.mjs +134 -0
  51. package/src/app-search.mjs +80 -0
  52. package/src/app-semantic-search.mjs +105 -0
  53. package/src/app-service.mjs +208 -0
  54. package/src/app-yardstick.mjs +269 -0
  55. package/src/app.mjs +102 -0
  56. package/src/atomic-mutation.mjs +1736 -0
  57. package/src/baton.mjs +435 -0
  58. package/src/body-link.mjs +48 -0
  59. package/src/bulk-tag.mjs +174 -0
  60. package/src/check-collapse.mjs +75 -0
  61. package/src/claude-commands.mjs +75 -0
  62. package/src/cli-editor.mjs +8 -0
  63. package/src/code-refs.mjs +545 -0
  64. package/src/codex-integration.mjs +81 -0
  65. package/src/color.mjs +14 -0
  66. package/src/commands.mjs +374 -0
  67. package/src/completions.mjs +93 -0
  68. package/src/config-edit.mjs +606 -0
  69. package/src/config.mjs +705 -0
  70. package/src/decision-action.mjs +58 -0
  71. package/src/decision.mjs +220 -0
  72. package/src/decisions.mjs +787 -0
  73. package/src/deps.mjs +317 -0
  74. package/src/desktop-install.mjs +158 -0
  75. package/src/desktop-runtime.mjs +4 -0
  76. package/src/desktop.mjs +25 -0
  77. package/src/diff.mjs +101 -0
  78. package/src/doctor.mjs +591 -0
  79. package/src/durable-rename.mjs +55 -0
  80. package/src/errors-read.mjs +44 -0
  81. package/src/export.mjs +496 -0
  82. package/src/extractors.mjs +101 -0
  83. package/src/filing.mjs +255 -0
  84. package/src/fix-membership.mjs +134 -0
  85. package/src/fix-refs.mjs +174 -0
  86. package/src/flags.mjs +318 -0
  87. package/src/frontmatter-fix.mjs +195 -0
  88. package/src/frontmatter.mjs +252 -0
  89. package/src/git-commit-filters.mjs +32 -0
  90. package/src/git-commit-protocol.mjs +198 -0
  91. package/src/git-commit-signing.mjs +75 -0
  92. package/src/git-commit-store.mjs +26 -0
  93. package/src/git-commit-support.mjs +16 -0
  94. package/src/git-commit-worker.mjs +32 -0
  95. package/src/git.mjs +909 -0
  96. package/src/glossary-check.mjs +35 -0
  97. package/src/glossary.mjs +243 -0
  98. package/src/graph.mjs +298 -0
  99. package/src/guard.mjs +488 -0
  100. package/src/health.mjs +212 -0
  101. package/src/hints.mjs +129 -0
  102. package/src/host-integration.mjs +339 -0
  103. package/src/hub-membership.mjs +284 -0
  104. package/src/hub.mjs +296 -0
  105. package/src/hud.mjs +436 -0
  106. package/src/index-file.mjs +163 -0
  107. package/src/index.mjs +478 -0
  108. package/src/init.mjs +512 -0
  109. package/src/install.mjs +222 -0
  110. package/src/journal-read.mjs +116 -0
  111. package/src/journal.mjs +472 -0
  112. package/src/lifecycle.mjs +1444 -0
  113. package/src/lint.mjs +386 -0
  114. package/src/managed-path.mjs +192 -0
  115. package/src/markdown-code-spans.mjs +58 -0
  116. package/src/migrate-prompts.mjs +171 -0
  117. package/src/migrate-template.mjs +170 -0
  118. package/src/migrate.mjs +90 -0
  119. package/src/misuse-read.mjs +61 -0
  120. package/src/model-request.mjs +33 -0
  121. package/src/model.mjs +523 -0
  122. package/src/modules.mjs +355 -0
  123. package/src/naming.mjs +55 -0
  124. package/src/native-action.mjs +59 -0
  125. package/src/native-record.mjs +244 -0
  126. package/src/new.mjs +1321 -0
  127. package/src/output-identity.mjs +111 -0
  128. package/src/parent-plan.mjs +58 -0
  129. package/src/parse-cache.mjs +99 -0
  130. package/src/path-containment.mjs +15 -0
  131. package/src/pickup-card.mjs +293 -0
  132. package/src/pickup.mjs +671 -0
  133. package/src/prompt.mjs +25 -0
  134. package/src/prompts.mjs +585 -0
  135. package/src/public-inspection.mjs +19 -0
  136. package/src/query.mjs +977 -0
  137. package/src/record-action.mjs +11 -0
  138. package/src/record-create.mjs +82 -0
  139. package/src/reference-planner.mjs +388 -0
  140. package/src/reference-values.mjs +11 -0
  141. package/src/rename.mjs +107 -0
  142. package/src/render.mjs +678 -0
  143. package/src/roadmap.mjs +184 -0
  144. package/src/runlist.mjs +944 -0
  145. package/src/section.mjs +225 -0
  146. package/src/semantic-search-client.mjs +58 -0
  147. package/src/ship.mjs +166 -0
  148. package/src/show.mjs +101 -0
  149. package/src/skill-drift.mjs +98 -0
  150. package/src/source-editor.mjs +491 -0
  151. package/src/stages.mjs +73 -0
  152. package/src/state-migration.mjs +123 -0
  153. package/src/stats.mjs +200 -0
  154. package/src/status-metadata.mjs +87 -0
  155. package/src/statuses.mjs +721 -0
  156. package/src/summary.mjs +74 -0
  157. package/src/surfaces.mjs +28 -0
  158. package/src/sync-status.mjs +316 -0
  159. package/src/template-store.mjs +62 -0
  160. package/src/update.mjs +338 -0
  161. package/src/use.mjs +58 -0
  162. package/src/util.mjs +337 -0
  163. package/src/validate.mjs +795 -0
  164. package/src/watch.mjs +56 -0
  165. package/src/xref.mjs +406 -0
  166. package/src/yardstick-command.mjs +178 -0
  167. package/src/yardstick.mjs +112 -0
package/src/init.mjs ADDED
@@ -0,0 +1,512 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
6
+ import { green, dim, yellow } from './color.mjs';
7
+ import { warn } from './util.mjs';
8
+ import { removeGeneratedSlashCommands } from './claude-commands.mjs';
9
+
10
+ // Subdirectories scaffolded under docsRoot and tracked separately during scans.
11
+ // Each maps to a builtin type (plan, prompt). New types added here should also
12
+ // have a matching builtin template so `runlist new <type>` lands files correctly.
13
+ const TYPE_SUBDIRS = ['plans', 'prompts'];
14
+
15
+ // Look for a `runlist hud` SessionStart hook already wired in either the project
16
+ // (.claude/settings{,.local}.json) or the user-global config (~/.claude/
17
+ // settings.json). User-global counts because Claude Code merges global hooks
18
+ // into every project — if the user has it wired globally, this project gets it
19
+ // for free and the init snippet would be noise. We only inspect — we do NOT
20
+ // mutate any file. Settings-merge logic is hostile to do silently (clobbering
21
+ // an existing SessionStart entry would surprise the user), so init just prints
22
+ // a paste-ready snippet when the hook isn't found.
23
+ function detectSessionStartHook(cwd) {
24
+ const candidates = [
25
+ path.join(cwd, '.claude', 'settings.json'),
26
+ path.join(cwd, '.claude', 'settings.local.json'),
27
+ path.join(os.homedir(), '.claude', 'settings.json'),
28
+ ];
29
+ for (const file of candidates) {
30
+ if (!existsSync(file)) continue;
31
+ let parsed;
32
+ try { parsed = JSON.parse(readFileSync(file, 'utf8')); }
33
+ catch { continue; }
34
+ const sessionStart = parsed?.hooks?.SessionStart;
35
+ if (!Array.isArray(sessionStart)) continue;
36
+ for (const entry of sessionStart) {
37
+ const inner = Array.isArray(entry?.hooks) ? entry.hooks : [];
38
+ for (const hook of inner) {
39
+ if (typeof hook?.command === 'string' && /\b(?:runlist|dotmd)\s+hud\b/.test(hook.command)) {
40
+ const rel = file.startsWith(cwd) ? path.relative(cwd, file) : file;
41
+ return { wired: true, file: rel };
42
+ }
43
+ }
44
+ }
45
+ }
46
+ return { wired: false };
47
+ }
48
+
49
+ const STARTER_CONFIG = `// runlist.config.mjs — document management configuration
50
+ // All exports are optional. See runlist.config.example.mjs for full reference.
51
+
52
+ export const root = 'docs';
53
+
54
+ export const index = {
55
+ path: 'docs/docs.md',
56
+ startMarker: '<!-- GENERATED:runlist:start -->',
57
+ endMarker: '<!-- GENERATED:runlist:end -->',
58
+ archivedLimit: 8,
59
+ };
60
+
61
+ // Frontmatter fields graph / deps / unblocks / pickup's Related: resolver
62
+ // traverse. Defaults match what the built-in plan/doc/prompt templates scaffold.
63
+ // Add field names here (and to your templates) to track more relationships.
64
+ export const referenceFields = {
65
+ bidirectional: ['related_plans', 'related_docs'],
66
+ unidirectional: ['parent_plan', 'runlist'],
67
+ };
68
+ `;
69
+
70
+ // A git repository cannot hold an empty directory, so scaffolding `docs/plans/`
71
+ // and `docs/prompts/` and stopping there means both vanish for the next clone —
72
+ // `runlist init` produced three committable files and two directories that existed
73
+ // only on the machine that ran it.
74
+ //
75
+ // plans/ gets a real sample rather than a keepfile: it is the one place a new user
76
+ // benefits from seeing the frontmatter shape before running `runlist new`. It is
77
+ // written `status: planned` so it sits quietly in the pipeline instead of posing
78
+ // as live work, and it says how to delete itself.
79
+ //
80
+ // prompts/ gets a keepfile instead, and cannot get a sample: the live queue is
81
+ // gitignored by the rule this same command writes, and a committed prompt at
82
+ // `status: pending` would be silently consumed by the next no-arg `runlist use`.
83
+ const SAMPLE_PLAN_NAME = 'example-plan.md';
84
+ const samplePlan = (today) => `---
85
+ type: plan
86
+ status: planned
87
+ created: ${today}
88
+ updated: ${today}
89
+ title: Example Plan
90
+ summary: A scaffolded sample showing what a runlist plan looks like — safe to delete.
91
+ current_state: "Scaffolded by \`runlist init\` as a shape reference. Nothing here is real work."
92
+ next_step: "Delete this file, or edit it into your first real plan."
93
+ ---
94
+
95
+ # Example Plan
96
+
97
+ > A scaffolded sample showing what a runlist plan looks like — safe to delete.
98
+
99
+ ## Problem
100
+
101
+ \`runlist init\` leaves this file behind so \`docs/plans/\` survives a clone (git cannot
102
+ track an empty directory) and so the frontmatter above has something to point at.
103
+
104
+ The fields that matter: \`status\` drives every listing, \`current_state\` and
105
+ \`next_step\` are what \`runlist briefing\` reads out, and \`updated\` drives staleness.
106
+ Never hand-edit \`status:\` — \`runlist set <status> <file>\` writes it, validates it
107
+ against the type, and runs the lifecycle hooks.
108
+
109
+ ## Phases
110
+
111
+ - [ ] Delete this file: \`runlist archive docs/plans/${SAMPLE_PLAN_NAME}\`
112
+ - [ ] Write a real one: \`runlist new plan <name>\`
113
+
114
+ ## Version History
115
+
116
+ - Scaffolded by \`runlist init\`.
117
+ `;
118
+
119
+ const STARTER_INDEX = `# Docs
120
+
121
+ <!-- GENERATED:runlist:start -->
122
+
123
+ _No docs yet. Run \`runlist list\` after creating your first document._
124
+
125
+ <!-- GENERATED:runlist:end -->
126
+ `;
127
+
128
+ function scanExistingDocs(dir) {
129
+ const statuses = new Set();
130
+ const surfaces = new Set();
131
+ const modules = new Set();
132
+ const refFieldNames = new Set();
133
+ let docCount = 0;
134
+ // Files without a frontmatter block, OR with a block that's missing `type:`
135
+ // or `status:`. Surfaced by the init "bulk-tag hint" to point users at the
136
+ // command that can fix them all in one shot.
137
+ let untaggedCount = 0;
138
+ // Track files per top-level subdir under `dir` (e.g. plans/, prompts/, "")
139
+ // so callers can report what's already there — including files without frontmatter,
140
+ // which are otherwise invisible to detection.
141
+ const subdirCounts = {};
142
+
143
+ function bump(subdir, hasFrontmatter) {
144
+ if (!subdirCounts[subdir]) subdirCounts[subdir] = { withFrontmatter: 0, withoutFrontmatter: 0 };
145
+ if (hasFrontmatter) subdirCounts[subdir].withFrontmatter++;
146
+ else subdirCounts[subdir].withoutFrontmatter++;
147
+ }
148
+
149
+ function walk(d, topSubdir) {
150
+ let entries;
151
+ try { entries = readdirSync(d, { withFileTypes: true }); } catch (err) { warn(`Could not read ${d}: ${err.message}`); return; }
152
+ for (const entry of entries) {
153
+ if (entry.isDirectory()) {
154
+ const nextTop = topSubdir === null ? entry.name : topSubdir;
155
+ walk(path.join(d, entry.name), nextTop);
156
+ continue;
157
+ }
158
+ if (!entry.name.endsWith('.md')) continue;
159
+ let raw;
160
+ try { raw = readFileSync(path.join(d, entry.name), 'utf8'); } catch (err) { warn(`Could not read ${entry.name}: ${err.message}`); continue; }
161
+ const { frontmatter } = extractFrontmatter(raw);
162
+ const subdir = topSubdir ?? '';
163
+ if (!frontmatter) { bump(subdir, false); untaggedCount++; continue; }
164
+ bump(subdir, true);
165
+ const parsed = parseSimpleFrontmatter(frontmatter);
166
+ docCount++;
167
+ const hasType = typeof parsed.type === 'string' && parsed.type.length > 0;
168
+ const hasStatus = typeof parsed.status === 'string' && parsed.status.length > 0;
169
+ if (!hasType || !hasStatus) untaggedCount++;
170
+ if (parsed.status) statuses.add(String(parsed.status).toLowerCase());
171
+ if (parsed.surface) surfaces.add(String(parsed.surface));
172
+ if (Array.isArray(parsed.surfaces)) parsed.surfaces.forEach(s => surfaces.add(String(s)));
173
+ if (parsed.module) modules.add(String(parsed.module));
174
+ if (Array.isArray(parsed.modules)) parsed.modules.forEach(m => modules.add(String(m)));
175
+ for (const [key, val] of Object.entries(parsed)) {
176
+ if (Array.isArray(val) && val.some(v => String(v).endsWith('.md'))) {
177
+ refFieldNames.add(key);
178
+ }
179
+ }
180
+ }
181
+ }
182
+
183
+ walk(dir, null);
184
+ return { docCount, statuses, surfaces, modules, refFieldNames, subdirCounts, untaggedCount };
185
+ }
186
+
187
+ // Count .md files (regardless of frontmatter) directly inside a single directory.
188
+ // Used to detect root-level plans/ or prompts/ siblings that aren't under docsRoot.
189
+ function countMarkdownFiles(dir) {
190
+ let withFrontmatter = 0;
191
+ let withoutFrontmatter = 0;
192
+ let entries;
193
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return { withFrontmatter, withoutFrontmatter }; }
194
+ for (const entry of entries) {
195
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
196
+ let raw;
197
+ try { raw = readFileSync(path.join(dir, entry.name), 'utf8'); } catch { continue; }
198
+ const { frontmatter } = extractFrontmatter(raw);
199
+ if (frontmatter) withFrontmatter++; else withoutFrontmatter++;
200
+ }
201
+ return { withFrontmatter, withoutFrontmatter };
202
+ }
203
+
204
+ // Sensible default stale thresholds (days) for statuses runlist recognizes, used
205
+ // only to scope the generated config's staleDays to detected statuses. Mirrors
206
+ // the global + per-type defaults in config.mjs DEFAULTS; the repo's own custom
207
+ // statuses are intentionally absent so we don't invent a threshold for vocab we
208
+ // don't understand.
209
+ const KNOWN_STALE_DAYS = {
210
+ 'in-session': 1, active: 14, ready: 14, planned: 30, blocked: 30,
211
+ scoping: 30, paused: 3, awaiting: 14, draft: 30, review: 14, pending: 30,
212
+ };
213
+
214
+ function generateDetectedConfig(scan, rootPath) {
215
+ const lines = [`// runlist.config.mjs — auto-detected from ${scan.docCount} existing docs`, ''];
216
+ lines.push(`export const root = '${rootPath}';`);
217
+ lines.push('');
218
+
219
+ const defaultOrder = ['active', 'ready', 'planned', 'scoping', 'blocked', 'reference', 'archived'];
220
+ const ordered = defaultOrder.filter(s => scan.statuses.has(s));
221
+ const extra = [...scan.statuses].filter(s => !defaultOrder.includes(s)).sort();
222
+ const allStatuses = [...ordered, ...extra];
223
+ if (allStatuses.length > 0) {
224
+ lines.push('export const statuses = {');
225
+ lines.push(` order: [${allStatuses.map(s => `'${s}'`).join(', ')}],`);
226
+ // Scope staleDays to the detected statuses. `statuses.staleDays` is a
227
+ // replace-key, so emitting it here stops the resolver from inheriting the
228
+ // default map (keyed by `ready`/`scoping`/… that this repo may not use) —
229
+ // which otherwise makes every command warn about statuses the user never
230
+ // wrote. Only statuses with a sensible known threshold get an entry;
231
+ // unrecognized ones (the repo's own vocab) are left for the user to tune.
232
+ const staleEntries = allStatuses.filter(s => s in KNOWN_STALE_DAYS);
233
+ if (staleEntries.length > 0) {
234
+ lines.push(' staleDays: {');
235
+ for (const s of staleEntries) lines.push(` '${s}': ${KNOWN_STALE_DAYS[s]},`);
236
+ lines.push(' },');
237
+ }
238
+ lines.push('};');
239
+ lines.push('');
240
+ }
241
+
242
+ if (scan.surfaces.size > 0 || scan.modules.size > 0) {
243
+ lines.push('export const taxonomy = {');
244
+ // Emit the full detected set so every existing doc passes — taxonomy
245
+ // enforcement only flags values outside the list, and the scan collected
246
+ // all of them. New surfaces/modules added later warn until appended here.
247
+ if (scan.surfaces.size > 0)
248
+ lines.push(` surfaces: [${[...scan.surfaces].sort().map(s => `'${s}'`).join(', ')}],`);
249
+ if (scan.modules.size > 0)
250
+ lines.push(` modules: [${[...scan.modules].sort().map(m => `'${m}'`).join(', ')}],`);
251
+ lines.push('};');
252
+ lines.push('');
253
+ }
254
+
255
+ if (scan.refFieldNames.size > 0) {
256
+ const names = [...scan.refFieldNames].sort();
257
+ lines.push('export const referenceFields = {');
258
+ lines.push(` bidirectional: [${names.map(n => `'${n}'`).join(', ')}],`);
259
+ lines.push(' unidirectional: [],');
260
+ lines.push('};');
261
+ lines.push('');
262
+ }
263
+
264
+ lines.push('export const index = {');
265
+ lines.push(` path: '${rootPath}/docs.md',`);
266
+ lines.push(` startMarker: '<!-- GENERATED:runlist:start -->',`);
267
+ lines.push(` endMarker: '<!-- GENERATED:runlist:end -->',`);
268
+ lines.push(` snapshot: 'status',`);
269
+ lines.push('};');
270
+ lines.push('');
271
+
272
+ return lines.join('\n');
273
+ }
274
+
275
+ export async function runInit(cwd, config, opts = {}) {
276
+ const { dryRun = false } = opts;
277
+ const configPath = path.join(cwd, 'runlist.config.mjs');
278
+ const docsDir = path.join(cwd, 'docs');
279
+ const indexPath = path.join(docsDir, 'docs.md');
280
+
281
+ // Prefix every reported line during dry-run so the user can't mistake the
282
+ // preview for a real run. Without this, every write below would silently
283
+ // execute — runInit previously ignored the `--dry-run` flag entirely.
284
+ const dryTag = dryRun ? `${dim('[dry-run]')} ` : '';
285
+
286
+ process.stdout.write('\n');
287
+
288
+ const scan = existsSync(docsDir) ? scanExistingDocs(docsDir) : null;
289
+
290
+ if (existsSync(configPath)) {
291
+ process.stdout.write(` ${dryTag}${dim('exists')} ${path.basename(config.configPath)}\n`);
292
+ } else {
293
+ if (scan && scan.docCount > 0) {
294
+ if (!dryRun) writeFileSync(configPath, generateDetectedConfig(scan, 'docs'), 'utf8');
295
+ process.stdout.write(` ${dryTag}${green('create')} runlist.config.mjs (detected ${scan.docCount} docs)\n`);
296
+ } else {
297
+ if (!dryRun) writeFileSync(configPath, STARTER_CONFIG, 'utf8');
298
+ process.stdout.write(` ${dryTag}${green('create')} runlist.config.mjs\n`);
299
+ }
300
+ }
301
+
302
+ if (existsSync(docsDir)) {
303
+ process.stdout.write(` ${dryTag}${dim('exists')} docs/\n`);
304
+ } else {
305
+ if (!dryRun) mkdirSync(docsDir, { recursive: true });
306
+ process.stdout.write(` ${dryTag}${green('create')} docs/\n`);
307
+ }
308
+
309
+ // Inspect root-level siblings (e.g. ./plans/, ./prompts/) before scaffolding.
310
+ // If a sibling already holds content, skip creating the matching docs/<sub>/
311
+ // so we don't quietly create a parallel dir the user has to reconcile.
312
+ const siblingsWithContent = [];
313
+ for (const sub of TYPE_SUBDIRS) {
314
+ const siblingPath = path.join(cwd, sub);
315
+ if (!existsSync(siblingPath)) continue;
316
+ const c = countMarkdownFiles(siblingPath);
317
+ const total = c.withFrontmatter + c.withoutFrontmatter;
318
+ if (total > 0) siblingsWithContent.push({ sub, total });
319
+ }
320
+ const siblingSet = new Set(siblingsWithContent.map(s => s.sub));
321
+
322
+ // Scaffold the canonical type subdirs (docs/plans/, docs/prompts/) so the
323
+ // builtin `runlist new plan` / `runlist new prompt` templates land somewhere
324
+ // sensible without extra config.
325
+ for (const sub of TYPE_SUBDIRS) {
326
+ const subPath = path.join(docsDir, sub);
327
+ const counts = scan?.subdirCounts?.[sub];
328
+ const total = counts ? counts.withFrontmatter + counts.withoutFrontmatter : 0;
329
+ if (siblingSet.has(sub) && !existsSync(subPath)) {
330
+ process.stdout.write(` ${dryTag}${yellow('skip')} docs/${sub}/ (root-level ./${sub}/ already holds content)\n`);
331
+ continue;
332
+ }
333
+ if (existsSync(subPath)) {
334
+ const detail = total > 0
335
+ ? ` (${counts.withFrontmatter} runlist-tracked, ${counts.withoutFrontmatter} plain .md)`
336
+ : '';
337
+ process.stdout.write(` ${dryTag}${dim('exists')} docs/${sub}/${detail}\n`);
338
+ } else {
339
+ if (!dryRun) mkdirSync(subPath, { recursive: true });
340
+ process.stdout.write(` ${dryTag}${green('create')} docs/${sub}/\n`);
341
+ }
342
+
343
+ // Give the directory something git can carry — but only when it is genuinely
344
+ // empty. Re-running init over a populated tree must never drop a sample plan
345
+ // into someone's real estate.
346
+ const keeper = sub === 'plans'
347
+ ? { file: SAMPLE_PLAN_NAME, body: samplePlan(new Date().toISOString().slice(0, 10)) }
348
+ : { file: '.gitkeep', body: '' };
349
+ const keeperPath = path.join(subPath, keeper.file);
350
+ const dirIsEmpty = !existsSync(subPath) || readdirSync(subPath).length === 0;
351
+ if (existsSync(keeperPath)) {
352
+ process.stdout.write(` ${dryTag}${dim('exists')} docs/${sub}/${keeper.file}\n`);
353
+ } else if (dirIsEmpty || dryRun) {
354
+ if (!dryRun) writeFileSync(keeperPath, keeper.body, 'utf8');
355
+ process.stdout.write(` ${dryTag}${green('create')} docs/${sub}/${keeper.file}\n`);
356
+ }
357
+ }
358
+
359
+ if (existsSync(indexPath)) {
360
+ process.stdout.write(` ${dryTag}${dim('exists')} docs/docs.md\n`);
361
+ } else {
362
+ if (!dryRun) writeFileSync(indexPath, STARTER_INDEX, 'utf8');
363
+ process.stdout.write(` ${dryTag}${green('create')} docs/docs.md\n`);
364
+ }
365
+
366
+ if (siblingsWithContent.length > 0) {
367
+ const list = siblingsWithContent
368
+ .map(({ sub, total }) => `${sub}/ (${total} .md file${total === 1 ? '' : 's'})`)
369
+ .join(', ');
370
+ const subs = siblingsWithContent.map(s => s.sub);
371
+ process.stdout.write(`\n ${yellow('notice')} found at repo root: ${list}\n`);
372
+ process.stdout.write(` these are NOT under docs/ and won't be tracked by the default config. Either:\n`);
373
+ for (const sub of subs) {
374
+ process.stdout.write(` • move into docs/: mv ./${sub}/* docs/${sub}/ && rmdir ./${sub}\n`);
375
+ }
376
+ process.stdout.write(` • or use a flat layout — set in runlist.config.mjs:\n`);
377
+ process.stdout.write(` export const root = [${subs.map(s => `'${s}'`).join(', ')}];\n`);
378
+ }
379
+
380
+ // .gitignore: three rules.
381
+ //
382
+ // .runlist/ — session ownership records
383
+ // .dotmd/ — legacy state during the compatibility window
384
+ // <docs>/prompts/*.md — the LIVE saved-prompt queue
385
+ //
386
+ // The second one is load-bearing. Saved prompts are session-local by design and
387
+ // the workflow docs say never to commit them, but nothing enforced that: a plain
388
+ // `git add -A` from any session swept the queue into the repo. Scoped with a
389
+ // single `*` so it stops at the directory — `prompts/archived/` is the committed
390
+ // historical record and must stay tracked. Anchored with a leading `/` so a
391
+ // `prompts/` directory elsewhere in the tree is unaffected.
392
+ const gitignorePath = path.join(cwd, '.gitignore');
393
+ const docsRel = path.relative(cwd, docsDir).split(path.sep).join('/');
394
+ const promptsIgnore = `/${docsRel ? `${docsRel}/` : ''}prompts/*.md`;
395
+ const ignoreRules = [
396
+ { line: '.runlist/', accepts: (l) => l === '.runlist/' || l === '.runlist' },
397
+ { line: '.dotmd/', accepts: (l) => l === '.dotmd/' || l === '.dotmd' },
398
+ { line: promptsIgnore, accepts: (l) => l === promptsIgnore || l === promptsIgnore.slice(1) },
399
+ ];
400
+ if (existsSync(gitignorePath)) {
401
+ const current = readFileSync(gitignorePath, 'utf8');
402
+ const present = new Set(current.split('\n').map(l => l.trim()));
403
+ const missing = ignoreRules.filter(r => ![...present].some(l => r.accepts(l)));
404
+ if (missing.length) {
405
+ const sep = current.endsWith('\n') ? '' : '\n';
406
+ const added = missing.map(r => `${r.line}\n`).join('');
407
+ if (!dryRun) writeFileSync(gitignorePath, `${current}${sep}${added}`, 'utf8');
408
+ process.stdout.write(` ${dryTag}${green('update')} .gitignore (+${missing.map(r => r.line).join(', ')})\n`);
409
+ } else {
410
+ process.stdout.write(` ${dryTag}${dim('exists')} .gitignore\n`);
411
+ }
412
+ } else {
413
+ if (!dryRun) writeFileSync(gitignorePath, ignoreRules.map(r => `${r.line}\n`).join(''), 'utf8');
414
+ process.stdout.write(` ${dryTag}${green('create')} .gitignore\n`);
415
+ }
416
+
417
+ // Warn when docs/ is gitignored — silently scaffolding into an ignored dir
418
+ // means every doc we manage falls outside git, which a doc-management tool
419
+ // should not leave the user guessing about. Three of gmax's six docs were
420
+ // force-added; the other three were untracked and the user had no way to
421
+ // know without `git ls-files docs/`.
422
+ if (existsSync(path.join(cwd, '.git'))) {
423
+ const probe = spawnSync('git', ['check-ignore', '-q', 'docs/'], {
424
+ cwd, encoding: 'utf8',
425
+ });
426
+ // Exit 0 → ignored. Exit 1 → not ignored. Exit 128 → not in repo / git error.
427
+ if (probe.status === 0) {
428
+ process.stdout.write(`\n ${yellow('notice')} docs/ is gitignored — files runlist manages will NOT be tracked.\n`);
429
+ process.stdout.write(` Add an exception to .gitignore so docs/ is tracked:\n`);
430
+ process.stdout.write(` !docs/\n`);
431
+ process.stdout.write(` Or run: echo '!docs/' >> .gitignore\n`);
432
+ }
433
+ }
434
+
435
+ // Bulk-tag hint — when init found pre-existing .md files without
436
+ // type/status, point at the command that can tag them in one shot. Init's
437
+ // job here is discovery; the per-file detail lives in `bulk-tag --dry-run`.
438
+ if (scan?.untaggedCount > 0) {
439
+ const n = scan.untaggedCount;
440
+ const noun = n === 1 ? 'file' : 'files';
441
+ process.stdout.write(`\n ${yellow('hint')} ${n} untagged .md ${noun} found — run \`runlist bulk-tag --dry-run\` to preview tagging.\n`);
442
+ }
443
+
444
+ // Claude Code integration. runlist no longer scaffolds per-repo
445
+ // `.claude/commands/*.md` slash commands — the runlist plugin's SKILL.md is the
446
+ // canonical agent-facing workflow now, and `runlist hud` injects this repo's
447
+ // status vocab at runtime.
448
+ const hasProjectClaude = existsSync(path.join(cwd, '.claude'));
449
+ // A project `.claude/` proves it; a user-global `~/.claude/` means they run
450
+ // Claude Code elsewhere, so the plugin nudge is still relevant before this
451
+ // repo has any `.claude/` of its own (the common greenfield case).
452
+ const likelyClaudeUser = hasProjectClaude || existsSync(path.join(os.homedir(), '.claude'));
453
+
454
+ // If a `.claude/` exists, sweep any retired generated command files
455
+ // (banner-gated, so hand-authored ones survive).
456
+ if (hasProjectClaude) {
457
+ const removed = removeGeneratedSlashCommands(cwd, { dryRun });
458
+ for (const r of removed) {
459
+ const verb = dryRun ? 'would remove' : 'removed';
460
+ process.stdout.write(` ${dryTag}${yellow('clean')} .claude/commands/${r.name} (retired — ${verb}; guidance ships via the runlist plugin)\n`);
461
+ }
462
+ }
463
+
464
+ if (likelyClaudeUser) {
465
+ const sessionStart = detectSessionStartHook(cwd);
466
+ if (sessionStart.wired) {
467
+ process.stdout.write(` ${dim('exists')} ${sessionStart.file} (SessionStart hook for \`runlist hud\` already wired)\n`);
468
+ } else {
469
+ process.stdout.write(`\n ${yellow('hint')} install the runlist Claude Code plugin so its hooks + workflow skill\n`);
470
+ process.stdout.write(` travel to every session and subagent automatically:\n\n`);
471
+ process.stdout.write(` /plugin marketplace add reowens/runlist\n`);
472
+ process.stdout.write(` /plugin install runlist@runlist\n\n`);
473
+ process.stdout.write(` The plugin's hooks call \`runlist\` on your PATH, so install the CLI\n`);
474
+ process.stdout.write(` globally too — ${green('npm i -g runlist')} (a project devDependency won't power them).\n\n`);
475
+ process.stdout.write(` Or, without the plugin, wire \`runlist hud\` at SessionStart by hand —\n`);
476
+ process.stdout.write(` add to .claude/settings.json (merge into any existing hooks):\n\n`);
477
+ process.stdout.write(` "hooks": { "SessionStart": [\n`);
478
+ process.stdout.write(` { "hooks": [{ "type": "command", "command": "runlist hud" }] }\n`);
479
+ process.stdout.write(` ] }\n`);
480
+ }
481
+ }
482
+
483
+ // Render the index against what we just scaffolded.
484
+ //
485
+ // `docs.md` ships a "no docs yet" placeholder, which stopped being true the
486
+ // moment this command also started writing a sample plan — so a fresh init
487
+ // committed an index that contradicted the tree beside it, and the user's first
488
+ // `runlist check` opened with a stale-index warning. The config passed into
489
+ // runInit predates the file we just wrote, so re-resolve before rendering.
490
+ //
491
+ // Best-effort: a repo that scaffolds correctly but cannot render its index is
492
+ // still a successful init, and the next command regenerates it anyway.
493
+ if (!dryRun) {
494
+ try {
495
+ const { resolveConfig } = await import('./config.mjs');
496
+ const freshConfig = await resolveConfig(cwd);
497
+ if (freshConfig.indexPath) {
498
+ const { buildIndex } = await import('./index.mjs');
499
+ const { writeRenderedIndex } = await import('./index-file.mjs');
500
+ writeRenderedIndex(() => buildIndex(freshConfig, { fast: true }), freshConfig);
501
+ }
502
+ } catch {
503
+ // Leave the placeholder; `runlist check` self-heals it on first run.
504
+ }
505
+ }
506
+
507
+ process.stdout.write(`\nReady. A few starting points:\n`);
508
+ process.stdout.write(` runlist new doc my-doc # scaffold a reference doc\n`);
509
+ process.stdout.write(` runlist new plan my-plan # scaffold an execution plan\n`);
510
+ process.stdout.write(` runlist list # see what you've got\n`);
511
+ process.stdout.write(` runlist hud # session-start triage\n\n`);
512
+ }