runlist 0.99.1 → 0.100.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 (58) hide show
  1. package/GETTING_STARTED.md +249 -0
  2. package/README.md +433 -51
  3. package/bin/runlist.mjs +404 -53
  4. package/package.json +3 -1
  5. package/plugins/runlist-codex/.codex-plugin/plugin.json +1 -1
  6. package/plugins/runlist-codex/bin/consume-prompt.mjs +45 -0
  7. package/plugins/runlist-codex/bin/runlist-hook +29 -11
  8. package/plugins/runlist-codex/bin/session-start.mjs +16 -0
  9. package/plugins/runlist-codex/bin/subagent-start.mjs +16 -0
  10. package/plugins/runlist-codex/hooks/hooks.json +11 -0
  11. package/plugins/runlist-codex/skills/runlist/SKILL.md +27 -11
  12. package/plugins/runlist-codex/skills/runlist-setup/SKILL.md +109 -0
  13. package/runlist.config.example.mjs +6 -0
  14. package/src/agent-context.mjs +8 -2
  15. package/src/agent-hosts.mjs +305 -0
  16. package/src/app-create.mjs +11 -1
  17. package/src/atomic-mutation.mjs +140 -35
  18. package/src/baton.mjs +127 -33
  19. package/src/codex-integration.mjs +57 -7
  20. package/src/commands.mjs +92 -38
  21. package/src/config-command.mjs +115 -0
  22. package/src/config-setting.mjs +163 -0
  23. package/src/config.mjs +57 -12
  24. package/src/doctor-hosts.mjs +192 -0
  25. package/src/doctor.mjs +16 -6
  26. package/src/export.mjs +11 -0
  27. package/src/git.mjs +58 -2
  28. package/src/guard.mjs +41 -13
  29. package/src/handoff-record.mjs +290 -30
  30. package/src/handoffs-help.mjs +143 -0
  31. package/src/handoffs-mode.mjs +486 -0
  32. package/src/handoffs-scope.mjs +98 -0
  33. package/src/help.mjs +72 -0
  34. package/src/host-integration.mjs +7 -1
  35. package/src/hud.mjs +174 -29
  36. package/src/index.mjs +11 -4
  37. package/src/init.mjs +255 -160
  38. package/src/install.mjs +35 -18
  39. package/src/installation.mjs +110 -27
  40. package/src/journal.mjs +2 -2
  41. package/src/lifecycle.mjs +22 -11
  42. package/src/managed-path.mjs +68 -4
  43. package/src/migrate.mjs +7 -0
  44. package/src/naming.mjs +26 -0
  45. package/src/new.mjs +202 -91
  46. package/src/pickup-card.mjs +1 -1
  47. package/src/plan-changes.mjs +49 -0
  48. package/src/prompt-handoff.mjs +16 -0
  49. package/src/prompts.mjs +101 -27
  50. package/src/rename.mjs +13 -0
  51. package/src/setup-carry.mjs +291 -0
  52. package/src/setup-waiting.mjs +20 -0
  53. package/src/setup-worker.mjs +257 -0
  54. package/src/setup.mjs +404 -0
  55. package/src/statuses.mjs +5 -2
  56. package/src/update.mjs +239 -79
  57. package/src/use.mjs +4 -2
  58. package/src/validate.mjs +7 -0
package/src/init.mjs CHANGED
@@ -1,11 +1,13 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { appendFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { spawnSync } from 'node:child_process';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
6
6
  import { green, dim, yellow } from './color.mjs';
7
- import { warn } from './util.mjs';
8
- import { removeGeneratedSlashCommands } from './claude-commands.mjs';
7
+ import { die, warn } from './util.mjs';
8
+ import { catchAllRoot, promptDirectory } from './new.mjs';
9
+ import { authorizeRepoGeneratedPath, inLegacyPromptFolder, legacyPromptFolderPlace } from './managed-path.mjs';
10
+ import { LEGACY_PROMPT_DIR } from './naming.mjs';
9
11
 
10
12
  // Subdirectories scaffolded under docsRoot and tracked separately during scans.
11
13
  // Each maps to a builtin type (plan, prompt). New types added here should also
@@ -50,6 +52,7 @@ const STARTER_CONFIG = `// runlist.config.mjs — document management configurat
50
52
  // All exports are optional. See runlist.config.example.mjs for full reference.
51
53
 
52
54
  export const root = 'docs';
55
+ export const promptTracking = 'untracked';
53
56
 
54
57
  export const index = {
55
58
  path: 'docs/docs.md',
@@ -77,11 +80,10 @@ export const referenceFields = {
77
80
  // written `status: planned` so it sits quietly in the pipeline instead of posing
78
81
  // as live work, and it says how to delete itself.
79
82
  //
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
+ // prompts/ gets a keepfile instead of a live sample, which could otherwise be
84
+ // consumed by the next no-arg `runlist use`. Ignore rules are opt-in.
83
85
  const SAMPLE_PLAN_NAME = 'example-plan.md';
84
- const samplePlan = (today) => `---
86
+ const samplePlan = (today, planPath) => `---
85
87
  type: plan
86
88
  status: planned
87
89
  created: ${today}
@@ -98,7 +100,7 @@ next_step: "Delete this file, or edit it into your first real plan."
98
100
 
99
101
  ## Problem
100
102
 
101
- \`runlist init\` leaves this file behind so \`docs/plans/\` survives a clone (git cannot
103
+ \`runlist init\` leaves this file behind so \`${path.posix.dirname(planPath)}/\` survives a clone (git cannot
102
104
  track an empty directory) and so the frontmatter above has something to point at.
103
105
 
104
106
  The fields that matter: \`status\` drives every listing, \`current_state\` and
@@ -108,7 +110,7 @@ against the type, and runs the lifecycle hooks.
108
110
 
109
111
  ## Phases
110
112
 
111
- - [ ] Delete this file: \`runlist archive docs/plans/${SAMPLE_PLAN_NAME}\`
113
+ - [ ] Delete this file: \`runlist archive ${planPath}\`
112
114
  - [ ] Write a real one: \`runlist new plan <name>\`
113
115
 
114
116
  ## Version History
@@ -116,6 +118,16 @@ against the type, and runs the lifecycle hooks.
116
118
  - Scaffolded by \`runlist init\`.
117
119
  `;
118
120
 
121
+ // A config `runlist setup` writes spells out its prompt routing
122
+ // (SETUP_PROMPT_ROUTING in src/naming.mjs), so a root such as review-prompts/
123
+ // keeps its prompts in review-prompts/prompts/.
124
+ const SETUP_PROMPT_ROUTING_SOURCE = `
125
+ // Prompts are saved in the prompts/ folder inside the docs root.
126
+ export const templates = {
127
+ prompt: { dir: 'prompts', targetRoot: null },
128
+ };
129
+ `;
130
+
119
131
  const STARTER_INDEX = `# Docs
120
132
 
121
133
  <!-- GENERATED:runlist:start -->
@@ -125,7 +137,8 @@ _No docs yet. Run \`runlist list\` after creating your first document._
125
137
  <!-- GENERATED:runlist:end -->
126
138
  `;
127
139
 
128
- function scanExistingDocs(dir) {
140
+ function scanExistingDocs(roots, indexPath, excludeDirs = new Set()) {
141
+ const seen = new Set();
129
142
  const statuses = new Set();
130
143
  const surfaces = new Set();
131
144
  const modules = new Set();
@@ -147,15 +160,18 @@ function scanExistingDocs(dir) {
147
160
  }
148
161
 
149
162
  function walk(d, topSubdir) {
163
+ if (seen.has(d)) return;
164
+ seen.add(d);
150
165
  let entries;
151
166
  try { entries = readdirSync(d, { withFileTypes: true }); } catch (err) { warn(`Could not read ${d}: ${err.message}`); return; }
152
167
  for (const entry of entries) {
153
168
  if (entry.isDirectory()) {
169
+ if (['.git', 'node_modules'].includes(entry.name) || excludeDirs.has(entry.name)) continue;
154
170
  const nextTop = topSubdir === null ? entry.name : topSubdir;
155
171
  walk(path.join(d, entry.name), nextTop);
156
172
  continue;
157
173
  }
158
- if (!entry.name.endsWith('.md')) continue;
174
+ if (!entry.isFile() || !entry.name.endsWith('.md') || path.join(d, entry.name) === indexPath) continue;
159
175
  let raw;
160
176
  try { raw = readFileSync(path.join(d, entry.name), 'utf8'); } catch (err) { warn(`Could not read ${entry.name}: ${err.message}`); continue; }
161
177
  const { frontmatter } = extractFrontmatter(raw);
@@ -180,19 +196,19 @@ function scanExistingDocs(dir) {
180
196
  }
181
197
  }
182
198
 
183
- walk(dir, null);
199
+ for (const dir of roots) if (existsSync(dir)) walk(dir, null);
184
200
  return { docCount, statuses, surfaces, modules, refFieldNames, subdirCounts, untaggedCount };
185
201
  }
186
202
 
187
203
  // Count .md files (regardless of frontmatter) directly inside a single directory.
188
204
  // Used to detect root-level plans/ or prompts/ siblings that aren't under docsRoot.
189
- function countMarkdownFiles(dir) {
205
+ function countMarkdownFiles(dir, excludeFiles = new Set()) {
190
206
  let withFrontmatter = 0;
191
207
  let withoutFrontmatter = 0;
192
208
  let entries;
193
209
  try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return { withFrontmatter, withoutFrontmatter }; }
194
210
  for (const entry of entries) {
195
- if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
211
+ if (!entry.isFile() || !entry.name.endsWith('.md') || excludeFiles.has(path.join(dir, entry.name))) continue;
196
212
  let raw;
197
213
  try { raw = readFileSync(path.join(dir, entry.name), 'utf8'); } catch { continue; }
198
214
  const { frontmatter } = extractFrontmatter(raw);
@@ -211,9 +227,10 @@ const KNOWN_STALE_DAYS = {
211
227
  scoping: 30, paused: 3, awaiting: 14, draft: 30, review: 14, pending: 30,
212
228
  };
213
229
 
214
- function generateDetectedConfig(scan, rootPath) {
230
+ function generateDetectedConfig(scan, rootPath, promptTracking, setupRouting = false) {
215
231
  const lines = [`// runlist.config.mjs — auto-detected from ${scan.docCount} existing docs`, ''];
216
- lines.push(`export const root = '${rootPath}';`);
232
+ lines.push(`export const root = ${rootPath === 'docs' ? "'docs'" : JSON.stringify(rootPath)};`);
233
+ lines.push(`export const promptTracking = '${promptTracking}';`);
217
234
  lines.push('');
218
235
 
219
236
  const defaultOrder = ['active', 'ready', 'planned', 'scoping', 'blocked', 'reference', 'archived'];
@@ -262,180 +279,250 @@ function generateDetectedConfig(scan, rootPath) {
262
279
  }
263
280
 
264
281
  lines.push('export const index = {');
265
- lines.push(` path: '${rootPath}/docs.md',`);
282
+ lines.push(` path: ${rootPath === 'docs' ? "'docs/docs.md'" : JSON.stringify(path.posix.join(rootPath, 'docs.md'))},`);
266
283
  lines.push(` startMarker: '<!-- GENERATED:runlist:start -->',`);
267
284
  lines.push(` endMarker: '<!-- GENERATED:runlist:end -->',`);
268
285
  lines.push(` snapshot: 'status',`);
269
286
  lines.push('};');
270
287
  lines.push('');
271
288
 
272
- return lines.join('\n');
289
+ return lines.join('\n') + (setupRouting ? SETUP_PROMPT_ROUTING_SOURCE : '');
273
290
  }
274
291
 
275
292
  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;
293
+ const { dryRun = false, argv = [] } = opts;
294
+ let requestedPolicy;
295
+ for (let i = 0; i < argv.length; i++) {
296
+ if (argv[i] === '--prompt-tracking' || argv[i].startsWith('--prompt-tracking=')) {
297
+ requestedPolicy = argv[i] === '--prompt-tracking' ? argv[++i] : argv[i].slice('--prompt-tracking='.length);
298
+ if (!['untracked', 'tracked', 'local'].includes(requestedPolicy)) {
299
+ die('--prompt-tracking must be one of: untracked, tracked, local.');
300
+ }
301
+ }
302
+ }
303
+ const existingConfig = Boolean(config.configPath && existsSync(config.configPath));
304
+ // An existing planning config gets init as it always was. A new project, and
305
+ // a handoffs-only one setup is turning planning on in (`handoffsUpgrade`,
306
+ // internal), keep prompts out of the root prompts/ (src/naming.mjs).
307
+ const planningConfig = existingConfig && !opts.handoffsUpgrade;
308
+ // Internal setup input; the public init argv contract stays unchanged.
309
+ const selectedRoot = opts.docsRoot ?? 'docs';
310
+ if (typeof selectedRoot !== 'string' || !selectedRoot.trim() || path.isAbsolute(selectedRoot)
311
+ || /[\r\n\0\\]/.test(selectedRoot)
312
+ || selectedRoot.split('/').includes('..')) {
313
+ die('docsRoot must be a relative project path without traversal.');
314
+ }
315
+ if (existingConfig && opts.docsRoot !== undefined
316
+ && (config.docsRoots ?? [config.docsRoot]).some(root => path.resolve(config.repoRoot, selectedRoot) !== root)) {
317
+ die('Existing configured roots are preserved. Edit the root export manually to change the docs layout.');
318
+ }
319
+ const promptTracking = existingConfig ? (config.promptTracking ?? config.raw?.promptTracking ?? 'local') : (requestedPolicy ?? 'untracked');
320
+ if (!['untracked', 'tracked', 'local'].includes(promptTracking)) {
321
+ die('promptTracking must be one of: untracked, tracked, local.');
322
+ }
323
+ if (existingConfig && requestedPolicy !== undefined && requestedPolicy !== promptTracking) {
324
+ die(`Existing config preserved; requested policy was not applied. Use: runlist config set promptTracking ${requestedPolicy}. For a symlinked or computed config, edit the promptTracking export manually.`);
325
+ }
289
326
 
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`);
327
+ // Existing configs own their layout, including flat root arrays and an absent
328
+ // index. A fresh project gets the starter docs/ layout rather than the
329
+ // resolver's config-less root (which is the whole working directory).
330
+ const projectRoot = existingConfig ? config.repoRoot : cwd;
331
+ const configPath = existingConfig ? config.configPath : path.join(cwd, 'runlist.config.mjs');
332
+ const docsRoots = existingConfig ? (config.docsRoots ?? [config.docsRoot]) : [path.resolve(cwd, selectedRoot)];
333
+ const indexPath = existingConfig ? config.indexPath : path.join(docsRoots[0], 'docs.md');
334
+ const layout = existingConfig ? config : { ...config, repoRoot: cwd, docsRoot: docsRoots[0], docsRoots };
335
+ const label = file => path.relative(projectRoot, file).split(path.sep).join('/') || '.';
336
+ const dryTag = dryRun ? `${dim('[dry-run]')} ` : '';
337
+ const planTemplate = { dir: 'plans', targetRoot: 'plans', ...config.raw?.templates?.plan };
338
+ const planRoot = planTemplate.targetRoot && docsRoots.find(root => root.endsWith(planTemplate.targetRoot) || path.basename(root) === planTemplate.targetRoot);
339
+ const plansDir = planRoot || (planTemplate.dir ? path.join(catchAllRoot(layout), planTemplate.dir) : catchAllRoot(layout));
340
+ const promptsDir = promptDirectory(layout);
341
+ const typeDirs = new Map([['plans', plansDir], ['prompts', promptsDir]]);
342
+
343
+ // A new project's folders never lead into the root prompts/ older builds
344
+ // saved prompts in (a docs/ symlinked there, say): nothing new is written
345
+ // there (src/naming.mjs). Checked before anything is carried or written.
346
+ if (!existingConfig) {
347
+ for (const destination of [...docsRoots, ...typeDirs.values()]) {
348
+ if (!inLegacyPromptFolder(destination, projectRoot)) continue;
349
+ die(`Nothing initialized: ${label(destination)}/ ${legacyPromptFolderPlace(destination, projectRoot)}, and runlist no longer writes there. Change the link that leads there, then rerun.`);
299
350
  }
300
351
  }
301
352
 
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`);
353
+ // Preflight opt-in ignore writes before ANY scaffolding. Refuse unsafe
354
+ // destinations and ambiguous patterns rather than appending a misleading or
355
+ // broader rule. Never follow a .gitignore symlink, including a dangling one.
356
+ let ignorePlan = null;
357
+ if (argv.includes('--gitignore')) {
358
+ const gitignorePath = path.join(projectRoot, '.gitignore');
359
+ authorizeRepoGeneratedPath(gitignorePath, layout, { kind: 'Init .gitignore destination' });
360
+ const exists = existsSync(gitignorePath);
361
+ if (exists && !lstatSync(gitignorePath).isFile()) die('Init .gitignore destination must be a regular file.');
362
+ const ignoreRules = [
363
+ { line: '.runlist/', accepts: l => l === '.runlist/' || l === '.runlist' },
364
+ { line: '.dotmd/', accepts: l => l === '.dotmd/' || l === '.dotmd' },
365
+ ];
366
+ if (promptTracking !== 'tracked') {
367
+ authorizeRepoGeneratedPath(promptsDir, layout, { kind: 'Prompt ignore directory' });
368
+ const promptsRel = path.relative(projectRoot, promptsDir).split(path.sep).join('/');
369
+ if (/[\r\n\0\\*?\[\]]/.test(promptsRel)) {
370
+ die('Cannot add a prompt ignore rule: the configured prompt path contains a newline or Git pattern characters.');
371
+ }
372
+ const promptsIgnore = `/${promptsRel ? promptsRel + '/' : ''}*.md`;
373
+ ignoreRules.push({ line: promptsIgnore, accepts: l => l === promptsIgnore || l === promptsIgnore.slice(1) });
374
+ }
375
+ const current = exists ? readFileSync(gitignorePath, 'utf8') : '';
376
+ const present = current.split(/\r?\n/).map(l => l.trim());
377
+ const missing = ignoreRules.filter(rule => !present.some(rule.accepts));
378
+ ignorePlan = { gitignorePath, exists, current, missing };
307
379
  }
380
+ const scan = scanExistingDocs(docsRoots, indexPath, config.excludeDirs);
381
+
382
+ // Setup plans and runs its own carry and passes its moves. A new project
383
+ // started by init carries the prompts an older runlist saved in the root
384
+ // prompts/ into its prompt folder itself, with setup's transactional carry,
385
+ // before its config is written: refused on a collision with nothing
386
+ // written, and listed by --dry-run (src/handoffs-mode.mjs).
387
+ let initCarry = null;
388
+ if (!existingConfig && opts.carryMoves === undefined) {
389
+ const { planInitCarry } = await import('./handoffs-mode.mjs');
390
+ initCarry = await planInitCarry(projectRoot, config, layout, { dryRun, out: process.stdout });
391
+ if (initCarry.previewStopped) return;
392
+ }
393
+ const carryMoves = opts.carryMoves ?? initCarry?.carry.moves ?? [];
308
394
 
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.
395
+ // Detect content outside the selected roots before creating parallel type
396
+ // directories. Offer a config choice; never move or delete anyone's files.
312
397
  const siblingsWithContent = [];
313
398
  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;
399
+ const siblingPath = path.join(projectRoot, sub);
400
+ if (docsRoots.some(root => siblingPath === root || siblingPath.startsWith(root + path.sep))) continue;
401
+ const counts = countMarkdownFiles(siblingPath, new Set(carryMoves.map(move => move.source)));
402
+ const total = counts.withFrontmatter + counts.withoutFrontmatter;
318
403
  if (total > 0) siblingsWithContent.push({ sub, total });
319
404
  }
320
405
  const siblingSet = new Set(siblingsWithContent.map(s => s.sub));
321
406
 
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`);
407
+ if (initCarry) {
408
+ const { carryLegacyPrompts } = await import('./handoffs-mode.mjs');
409
+ await carryLegacyPrompts(initCarry, { dryRun, out: process.stdout, nothing: 'Nothing initialized' });
410
+ }
411
+
412
+ process.stdout.write('\n');
413
+ if (existingConfig) {
414
+ process.stdout.write(` ${dryTag}${dim('exists')} ${label(configPath)}\n`);
415
+ } else {
416
+ const body = scan.docCount > 0
417
+ ? generateDetectedConfig(scan, selectedRoot, promptTracking, opts.docsRoot !== undefined)
418
+ // Replacement callbacks: a `$&` or `$'` in a chosen folder name is text, not a pattern.
419
+ : STARTER_CONFIG.replace("root = 'docs'", () => `root = ${opts.docsRoot === undefined ? "'docs'" : JSON.stringify(selectedRoot)}`)
420
+ .replace("path: 'docs/docs.md'", () => `path: ${opts.docsRoot === undefined ? "'docs/docs.md'" : JSON.stringify(path.posix.join(selectedRoot, 'docs.md'))}`)
421
+ .replace("promptTracking = 'untracked'", () => `promptTracking = '${promptTracking}'`)
422
+ + (opts.docsRoot === undefined ? '' : SETUP_PROMPT_ROUTING_SOURCE);
423
+ if (!dryRun) writeFileSync(configPath, body, 'utf8');
424
+ const detail = scan.docCount > 0 ? ` (detected ${scan.docCount} docs)` : '';
425
+ process.stdout.write(` ${dryTag}${green('create')} runlist.config.mjs${detail}\n`);
426
+ }
427
+
428
+ // A root that is also a type folder (a handoffs project's prompts/ kept as a
429
+ // second root) is listed once, below, with its document counts.
430
+ const typePaths = new Set(typeDirs.values());
431
+ for (const dir of docsRoots.filter(root => !typePaths.has(root))) {
432
+ const exists = existsSync(dir);
433
+ if (!exists && !dryRun) mkdirSync(dir, { recursive: true });
434
+ process.stdout.write(` ${dryTag}${exists ? dim('exists') : green('create')} ${label(dir)}/\n`);
435
+ }
436
+
437
+ for (const [sub, subPath] of typeDirs) {
438
+ // Prompts never go to the root prompts/ outside an existing planning
439
+ // config, so the prompt folder is made whatever is left there.
440
+ if (siblingSet.has(sub) && (planningConfig || sub !== LEGACY_PROMPT_DIR) && !existsSync(subPath) && !carryMoves.some(move => path.dirname(move.target) === subPath)) {
441
+ process.stdout.write(` ${dryTag}${yellow('skip')} ${label(subPath)}/ (root-level ./${sub}/ already holds content)\n`);
331
442
  continue;
332
443
  }
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)) }
444
+ const exists = existsSync(subPath);
445
+ const counts = countMarkdownFiles(subPath);
446
+ const total = counts.withFrontmatter + counts.withoutFrontmatter;
447
+ const detail = total > 0 ? ` (${counts.withFrontmatter} runlist-tracked, ${counts.withoutFrontmatter} plain .md)` : '';
448
+ if (!exists && !dryRun) mkdirSync(subPath, { recursive: true });
449
+ process.stdout.write(` ${dryTag}${exists ? dim('exists') : green('create')} ${label(subPath)}/${detail}\n`);
450
+
451
+ const keeper = sub === 'plans' && opts.samplePlan !== false
452
+ ? { file: SAMPLE_PLAN_NAME, body: samplePlan(new Date().toISOString().slice(0, 10), label(path.join(subPath, SAMPLE_PLAN_NAME))) }
348
453
  : { file: '.gitkeep', body: '' };
349
454
  const keeperPath = path.join(subPath, keeper.file);
350
- const dirIsEmpty = !existsSync(subPath) || readdirSync(subPath).length === 0;
455
+ const dirIsEmpty = (!existsSync(subPath) || readdirSync(subPath).length === 0) && !carryMoves.some(move => move.target.startsWith(subPath + path.sep));
351
456
  if (existsSync(keeperPath)) {
352
- process.stdout.write(` ${dryTag}${dim('exists')} docs/${sub}/${keeper.file}\n`);
353
- } else if (dirIsEmpty || dryRun) {
457
+ process.stdout.write(` ${dryTag}${dim('exists')} ${label(keeperPath)}\n`);
458
+ } else if (dirIsEmpty) {
354
459
  if (!dryRun) writeFileSync(keeperPath, keeper.body, 'utf8');
355
- process.stdout.write(` ${dryTag}${green('create')} docs/${sub}/${keeper.file}\n`);
460
+ process.stdout.write(` ${dryTag}${green('create')} ${label(keeperPath)}\n`);
356
461
  }
357
462
  }
358
463
 
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`);
464
+ if (indexPath) {
465
+ if (existsSync(indexPath)) {
466
+ process.stdout.write(` ${dryTag}${dim('exists')} ${label(indexPath)}\n`);
467
+ } else {
468
+ const body = STARTER_INDEX
469
+ .replace('<!-- GENERATED:runlist:start -->', config.indexStartMarker ?? '<!-- GENERATED:runlist:start -->')
470
+ .replace('<!-- GENERATED:runlist:end -->', config.indexEndMarker ?? '<!-- GENERATED:runlist:end -->');
471
+ if (!dryRun) {
472
+ mkdirSync(path.dirname(indexPath), { recursive: true });
473
+ writeFileSync(indexPath, body, 'utf8');
474
+ }
475
+ process.stdout.write(` ${dryTag}${green('create')} ${label(indexPath)}\n`);
476
+ }
364
477
  }
365
478
 
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);
479
+ // The root prompts/ older builds saved prompts in is never suggested as a
480
+ // root outside an existing planning config (src/naming.mjs): its prompts
481
+ // were carried and whatever else is there is the user's. An existing
482
+ // planning config is told, as before, how to include it.
483
+ const sibling = ({ sub, total }) => `${sub}/ (${total} .md file${total === 1 ? '' : 's'})`;
484
+ const legacySibling = planningConfig ? null : siblingsWithContent.find(({ sub }) => sub === LEGACY_PROMPT_DIR);
485
+ if (legacySibling) process.stdout.write(`\n ${yellow('notice')} ${sibling(legacySibling)} is left untouched: runlist keeps prompts in ${label(promptsDir)}/ and does not read or write ${LEGACY_PROMPT_DIR}/.\n`);
486
+ const otherSiblings = siblingsWithContent.filter(item => item !== legacySibling);
487
+ if (otherSiblings.length > 0) {
488
+ const list = otherSiblings.map(sibling).join(', ');
489
+ const roots = [...new Set([...docsRoots.map(label), ...otherSiblings.map(s => s.sub)])];
371
490
  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`);
491
+ process.stdout.write(` These are outside the configured roots. To include them without moving files,\n`);
492
+ process.stdout.write(` choose the roots in ${label(configPath)}, for example:\n`);
493
+ process.stdout.write(` export const root = ${JSON.stringify(roots)};\n`);
378
494
  }
379
495
 
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)));
496
+ if (ignorePlan) {
497
+ const { gitignorePath, exists, current, missing } = ignorePlan;
404
498
  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`);
499
+ const newline = current.includes('\r\n') ? '\r\n' : '\n';
500
+ const sep = current.length === 0 || current.endsWith('\n') ? '' : newline;
501
+ if (!dryRun) {
502
+ authorizeRepoGeneratedPath(gitignorePath, layout, { kind: 'Init .gitignore destination' });
503
+ appendFileSync(gitignorePath, sep + missing.map(rule => rule.line + newline).join(''), 'utf8');
504
+ }
505
+ process.stdout.write(` ${dryTag}${green(exists ? 'update' : 'create')} .gitignore (+${missing.map(rule => rule.line).join(', ')})\n`);
409
506
  } else {
410
507
  process.stdout.write(` ${dryTag}${dim('exists')} .gitignore\n`);
411
508
  }
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
509
  }
416
510
 
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`);
511
+ if (existsSync(path.join(projectRoot, '.git'))) {
512
+ for (const dir of docsRoots) {
513
+ const rel = `${label(dir)}/`;
514
+ const probe = spawnSync('git', ['check-ignore', '-q', '--', rel], { cwd: projectRoot, encoding: 'utf8' });
515
+ if (probe.status === 0) {
516
+ process.stdout.write(`\n ${yellow('notice')} ${rel} is gitignored — files runlist manages will NOT be tracked.\n`);
517
+ process.stdout.write(` Review the existing ignore rules if you want Git to track this root.\n`);
518
+ }
432
519
  }
433
520
  }
434
521
 
435
522
  // Bulk-tag hint — when init found pre-existing .md files without
436
523
  // type/status, point at the command that can tag them in one shot. Init's
437
524
  // job here is discovery; the per-file detail lives in `bulk-tag --dry-run`.
438
- if (scan?.untaggedCount > 0) {
525
+ if (opts.guidance !== false && scan?.untaggedCount > 0) {
439
526
  const n = scan.untaggedCount;
440
527
  const noun = n === 1 ? 'file' : 'files';
441
528
  process.stdout.write(`\n ${yellow('hint')} ${n} untagged .md ${noun} found — run \`runlist bulk-tag --dry-run\` to preview tagging.\n`);
@@ -451,17 +538,7 @@ export async function runInit(cwd, config, opts = {}) {
451
538
  // repo has any `.claude/` of its own (the common greenfield case).
452
539
  const likelyClaudeUser = hasProjectClaude || existsSync(path.join(os.homedir(), '.claude'));
453
540
 
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) {
541
+ if (opts.guidance !== false && likelyClaudeUser) {
465
542
  const sessionStart = detectSessionStartHook(cwd);
466
543
  if (sessionStart.wired) {
467
544
  process.stdout.write(` ${dim('exists')} ${sessionStart.file} (SessionStart hook for \`runlist hud\` already wired)\n`);
@@ -493,7 +570,7 @@ export async function runInit(cwd, config, opts = {}) {
493
570
  if (!dryRun) {
494
571
  try {
495
572
  const { resolveConfig } = await import('./config.mjs');
496
- const freshConfig = await resolveConfig(cwd);
573
+ const freshConfig = await resolveConfig(cwd, configPath);
497
574
  if (freshConfig.indexPath) {
498
575
  const { buildIndex } = await import('./index.mjs');
499
576
  const { writeRenderedIndex } = await import('./index-file.mjs');
@@ -504,9 +581,27 @@ export async function runInit(cwd, config, opts = {}) {
504
581
  }
505
582
  }
506
583
 
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`);
584
+ if (promptTracking === 'tracked') {
585
+ process.stdout.write('\n Prompt tracking: tracked — prompt Git operations are allowed.\n');
586
+ } else if (promptTracking === 'local') {
587
+ process.stdout.write('\n Prompt tracking: local — the guard denies prompt Git operations.\n');
588
+ process.stdout.write(' This policy does not itself add ignore rules; git add -A can still stage prompts that are not ignored.\n');
589
+ } else {
590
+ process.stdout.write('\n Prompt tracking: untracked. Leave pending prompts untracked to reduce commit churn.\n');
591
+ process.stdout.write(' `git add -A` can still stage any pending prompts that are not ignored.\n');
592
+ }
593
+ if (!argv.includes('--gitignore')) {
594
+ process.stdout.write(' Ignore files unchanged: runtime state in .runlist/ (and legacy .dotmd/) may also be staged by git add -A.\n');
595
+ process.stdout.write(' Review selected files, or preview explicit ignore rules with `runlist init --gitignore --dry-run`.\n');
596
+ }
597
+ if (opts.guidance !== false) {
598
+ process.stdout.write(dryRun
599
+ ? '\nReady to initialize the project. Host integration is separate.\n'
600
+ : '\nReady. Project initialized. Host integration is separate.\n');
601
+ process.stdout.write('A few starting points:\n');
602
+ process.stdout.write(` runlist new doc my-doc # scaffold a reference doc\n`);
603
+ process.stdout.write(` runlist new plan my-plan # scaffold an execution plan\n`);
604
+ process.stdout.write(` runlist list # see what you've got\n`);
605
+ process.stdout.write(` runlist hud # session-start triage\n\n`);
606
+ }
512
607
  }