runlist 0.99.2 → 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 +414 -46
  3. package/bin/runlist.mjs +402 -51
  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/prompts.mjs CHANGED
@@ -1,13 +1,14 @@
1
- import { readFileSync, existsSync } from 'node:fs';
1
+ import { readFileSync, existsSync, openSync, readSync, closeSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
4
  import { agentSessionSource, asString, toRepoPath, die, resolveDocPath, resolveRefPath, isArchivedPath } from './util.mjs';
5
- import { buildIndex, resolveDocArg } from './index.mjs';
5
+ import { buildIndex, collectDocFiles, parseDocFile, resolveDocArg } from './index.mjs';
6
6
  import { runQuery } from './query.mjs';
7
7
  import { completePlanClaim, regenIndex, renderLifecycleMutation, runArchive } from './lifecycle.mjs';
8
8
  import { runNew } from './new.mjs';
9
9
  import { green, dim, yellow } from './color.mjs';
10
10
  import { authorizeManagedSource } from './managed-path.mjs';
11
+ import { isHandoffsMode, managesFile, outsideHandoffs, outsideHandoffsLine } from './handoffs-scope.mjs';
11
12
  import {
12
13
  authoritativeSessionId,
13
14
  classifyPlanPickup,
@@ -17,7 +18,7 @@ import {
17
18
  } from './pickup.mjs';
18
19
  import { actionablePromptStatuses, comparePromptDocs } from './status-metadata.mjs';
19
20
  import { LEGACY_STATE_DIR, STATE_DIR } from './naming.mjs';
20
- import { formatPlanChanges, planChangesSince } from './plan-changes.mjs';
21
+ import { formatPlanChanges, planChangesSince, summarizePlanChanges } from './plan-changes.mjs';
21
22
  import { groupPrompts, parentPromptName } from './prompt-handoff.mjs';
22
23
  import { mutateFileSet } from './atomic-mutation.mjs';
23
24
 
@@ -36,6 +37,7 @@ export async function runPrompts(argv, config, opts = {}) {
36
37
  const sub = argv[0];
37
38
 
38
39
  if (sub && REMOVED_SUBCOMMANDS.has(sub)) {
40
+ if (isHandoffsMode(config)) die(`\`runlist prompts ${sub}\` was removed — a prompt is pending or archived, and nothing else.\nTo set one aside, \`runlist prompts archive <file>\`.`);
39
41
  die(`\`runlist prompts ${sub}\` was removed — a prompt is pending or archived, and nothing else.\n`
40
42
  + 'A prompt directs work, so parking one instead of archiving it leaves work state outside the\n'
41
43
  + 'plan that owns it. Lift its content into that plan, then `runlist prompts archive <file>`.');
@@ -121,29 +123,60 @@ function childHandoffs(repoPath, config, index = null) {
121
123
  if (!children.length) return { paths: [], notice: '' };
122
124
  const parent = path.basename(repoPath, '.md');
123
125
  const names = children.map(doc => `- \`${doc.path}\``).join('\n');
124
- return { paths: children.map(doc => doc.path), notice: `\n\n## Saved child handoffs\n\n${children.length} child handoff(s) remain pending:\n\n${names}\n\nInspect a child with \`rl prompts show <name>\`. Review its current state and authorization before resuming its lane. Fold completed results into the owning plan, then archive the completed handoff explicitly.\n\nPass \`--parent ${parent}\` when saving another child, or pass \`RUNLIST_PARENT_PROMPT=${parent}\` into its environment.\n` };
126
+ return { paths: children.map(doc => doc.path), notice: `\n\n## Saved child handoffs\n\n${children.length} child handoff(s) remain pending:\n\n${names}\n\nInspect a child with \`rl prompts show <name>\`. Review its current state and authorization before resuming its lane. ${isHandoffsMode(config) ? 'Fold completed results into the parent\'s work' : 'Fold completed results into the owning plan'}, then archive the completed handoff explicitly.\n\nPass \`--parent ${parent}\` when saving another child, or pass \`RUNLIST_PARENT_PROMPT=${parent}\` into its environment.\n` };
127
+ }
128
+
129
+ function readPromptHeader(file) {
130
+ let fd;
131
+ try {
132
+ fd = openSync(file, 'r');
133
+ const byte = Buffer.alloc(1), bytes = [];
134
+ let line = '', lines = 0;
135
+ while (bytes.length < 16 * 1024 && readSync(fd, byte, 0, 1, null)) {
136
+ bytes.push(byte[0]);
137
+ line += String.fromCharCode(byte[0]);
138
+ if (byte[0] !== 10) continue;
139
+ const marker = line.trimEnd() === '---';
140
+ if (lines++ === 0 && !marker) return '';
141
+ if (lines > 1 && marker) return Buffer.from(bytes).toString('utf8');
142
+ line = '';
143
+ }
144
+ } catch { /* an unreadable or disappearing header cannot supply metadata */ }
145
+ finally { if (fd !== undefined) closeSync(fd); }
146
+ return '';
147
+ }
148
+
149
+ function promptListIndex(config) {
150
+ const docs = [];
151
+ // Discovery stays inside the configured roots. Type filtering happens on
152
+ // the header below, including in handoffs mode, without its whole-file read.
153
+ for (const file of collectDocFiles({ ...config, mode: 'planning' })) {
154
+ const source = readPromptHeader(file);
155
+ const fm = parseSimpleFrontmatter(extractFrontmatter(source).frontmatter);
156
+ if (asString(fm.type) === 'prompt') {
157
+ docs.push({ ...parseDocFile(file, config, { fast: true, source }), plan: asString(fm.plan) });
158
+ }
159
+ }
160
+ return { docs };
125
161
  }
126
162
 
127
163
  function runPromptsList(argv, config, opts = {}) {
128
- // Listing prompts renders slug / status / updated / target — nothing any
129
- // validating pass produces, and `runQuery` never reads warnings either. A
130
- // full `buildIndex` here parsed every body in the repo through the ref,
131
- // git-staleness and hub passes just to print a small prompt list,
132
- // and then `pendingPromptsOldestFirst` scanned the tree a second time. One
133
- // fast index, shared, on the same terms hud reads on.
134
- const index = buildIndex(config, { fast: true, invokeHooks: false });
135
164
  const hasStatusFlag = argv.includes('--status');
136
165
  const includeArchived = argv.includes('--include-archived');
137
166
  const sub = argv[0];
138
167
  const json = argv.includes('--json');
139
168
 
169
+ const hasPositionalFilter = argv.some(a => !a.startsWith('-') && a !== 'list');
170
+ const defaultList = !json && !hasStatusFlag && !includeArchived && !hasPositionalFilter && sub !== 'status'
171
+ && !argv.some(a => a.startsWith('--sort') || a.startsWith('--limit') || a === '--all');
172
+ const index = defaultList && !opts.verbose ? promptListIndex(config) : buildIndex(config, { fast: true, invokeHooks: false });
173
+
140
174
  if (opts.verbose && !json) {
141
175
  renderPromptsVerbose(index, config, { hasStatusFlag, includeArchived });
142
176
  return;
143
177
  }
144
178
 
145
- const hasPositionalFilter = argv.some(a => !a.startsWith('-') && a !== 'list');
146
- if (!json && !hasStatusFlag && !includeArchived && !hasPositionalFilter && sub !== 'status' && !argv.some(a => a.startsWith('--sort') || a.startsWith('--limit') || a === '--all')) {
179
+ if (defaultList) {
147
180
  renderPromptQueueList(index, config);
148
181
  return;
149
182
  }
@@ -180,37 +213,67 @@ function renderPromptQueueList(index, config) {
180
213
  process.stdout.write(dim(`${prompts.length} prompts · ${summary}`) + '\n\n');
181
214
 
182
215
  const groups = groupPrompts(prompts);
216
+ const freshness = promptFreshness(prompts, config);
183
217
  const isAgentHandoff = doc => !path.basename(doc.path).startsWith('resume-')
184
218
  && (doc.parentPrompt || /^(?:subagent|agent|session)-/.test(path.basename(doc.path)));
185
219
  const standalone = groups.filter(group => isAgentHandoff(group.doc));
186
220
  const visible = groups.filter(group => !isAgentHandoff(group.doc));
187
221
  const maxSlug = Math.min(36, Math.max(0, ...visible.map(({ doc }) => path.basename(doc.path, '.md').length)));
188
- const nextPath = queue[0]?.doc.path;
189
222
  const childCount = children => `[+${children.length} subagent]`;
223
+ const childFreshness = children => {
224
+ const states = new Map();
225
+ for (const child of children) {
226
+ const state = freshness.get(child).change;
227
+ states.set(state, (states.get(state) ?? 0) + 1);
228
+ }
229
+ const ages = children.map(child => freshness.get(child).age).filter(Number.isFinite);
230
+ const age = ages.length === children.length ? `${Math.min(...ages)}-${Math.max(...ages)}d old` : 'age unknown';
231
+ return `${age}; targets: ${[...states].map(([state, count]) => `${count} ${state}`).join(', ')}`;
232
+ };
190
233
  for (const { doc: p, children } of visible) {
191
234
  const slug = path.basename(p.path, '.md').padEnd(maxSlug);
192
- const marker = p.path === nextPath ? green('[NEXT]') : ' ';
193
235
  const status = `[${(p.status ?? 'unknown').toUpperCase()}]`;
194
- const childMarker = children.some(doc => doc.path === nextPath) ? `${green('[NEXT]')} ` : '';
195
- const badge = children.length ? ` ${childMarker}${dim(childCount(children))}` : '';
196
- process.stdout.write(` ${marker} ${slug} ${status}${badge}\n`);
236
+ const badge = children.length ? ` ${dim(childCount(children))}` : '';
237
+ process.stdout.write(` ${slug} ${status}${badge} · ${freshness.get(p).text}\n`);
238
+ if (children.length) process.stdout.write(dim(` children: ${childFreshness(children)}\n`));
197
239
  }
198
240
  if (standalone.length) {
199
241
  const children = standalone.map(group => group.doc);
200
- const marker = children.some(doc => doc.path === nextPath) ? green('[NEXT]') : ' ';
201
- process.stdout.write(` ${marker} ${childCount(children)}\n`);
242
+ process.stdout.write(` ${childCount(children)} · ${childFreshness(children)}\n`);
202
243
  }
203
244
  if (standalone.length || visible.some(group => group.children.length)) {
204
245
  process.stdout.write(dim('\nUse `rl prompts --verbose` to expand subagent prompts.\n'));
205
246
  }
206
247
  }
207
248
 
208
- // Resolve a prompt's "target plan" for `prompts list --verbose`. Order:
209
- // 1. frontmatter `related_plans:` (first entry — assumed plan slug)
210
- // 2. frontmatter `parent_plan:`
211
- // 3. first body markdown link to a .md file
249
+ function promptFreshness(prompts, config) {
250
+ const resolved = new Map();
251
+ const targets = prompts.map(doc => {
252
+ if (!doc.plan) return findPromptTarget(doc, config);
253
+ const key = `${path.dirname(doc.path)}\0${doc.plan}`;
254
+ if (!resolved.has(key)) resolved.set(key, findPromptTarget(doc, config));
255
+ return resolved.get(key);
256
+ });
257
+ const changes = summarizePlanChanges(prompts.map((doc, i) => ({ plan: targets[i], since: doc.created })), config);
258
+ const now = Date.now();
259
+ return new Map(prompts.map((doc, i) => {
260
+ const time = doc.created ? new Date(doc.created).getTime() : NaN;
261
+ const age = Number.isFinite(time) && time <= now ? Math.floor((now - time) / 86_400_000) : null;
262
+ const target = targets[i]?.replace(/\s/g, ' ');
263
+ return [doc, { age, change: changes[i], text: `${age === null ? 'age unknown' : `${age}d old`} · ${target ?? 'target unknown'}: ${changes[i]}` }];
264
+ }));
265
+ }
266
+
267
+ // Explicit baton links win, then related_plans and parent_plan. Verbose
268
+ // listings also retain the first body link fallback from the full index.
212
269
  // Returns a repo-relative display path or null.
213
270
  function findPromptTarget(promptDoc, config) {
271
+ if (promptDoc.plan) {
272
+ const promptPath = path.resolve(config.repoRoot, promptDoc.path);
273
+ const fallbackRoot = promptDoc.plan.startsWith(config.docsRootPrefix || 'docs/') ? config.repoRoot : path.dirname(promptPath);
274
+ return linkedPlanRepoPath(promptDoc.plan, promptPath, config)
275
+ ?? toRepoPath(path.resolve(fallbackRoot, promptDoc.plan), config.repoRoot);
276
+ }
214
277
  const refs = promptDoc.refFields ?? {};
215
278
  const fmTargets = [...(refs.related_plans ?? []), ...(refs.parent_plan ?? [])];
216
279
  for (const t of fmTargets) {
@@ -245,6 +308,11 @@ function renderPromptsVerbose(index, config, { hasStatusFlag, includeArchived })
245
308
  if (!hasStatusFlag && !includeArchived) {
246
309
  prompts = prompts.filter(d => d.status !== 'archived');
247
310
  }
311
+ prompts = prompts.map(doc => {
312
+ const header = readPromptHeader(path.resolve(config.repoRoot, doc.path));
313
+ const fm = parseSimpleFrontmatter(extractFrontmatter(header).frontmatter);
314
+ return { ...doc, plan: asString(fm.plan) };
315
+ });
248
316
  if (prompts.length === 0) {
249
317
  process.stdout.write('No prompts.\n');
250
318
  return;
@@ -256,6 +324,7 @@ function renderPromptsVerbose(index, config, { hasStatusFlag, includeArchived })
256
324
  for (const p of prompts) counts[p.status ?? 'unknown'] = (counts[p.status ?? 'unknown'] ?? 0) + 1;
257
325
  const summary = Object.entries(counts).map(([s, n]) => `${n} ${s}`).join(' · ');
258
326
  process.stdout.write(`${prompts.length} prompt${prompts.length === 1 ? '' : 's'} · ${summary}\n\n`);
327
+ const freshness = promptFreshness(prompts, config);
259
328
 
260
329
  const render = (p, child) => {
261
330
  const slug = path.basename(p.path, '.md');
@@ -263,7 +332,7 @@ function renderPromptsVerbose(index, config, { hasStatusFlag, includeArchived })
263
332
  const status = (p.status ?? 'unknown').toUpperCase();
264
333
  const arrow = target ? ` ${dim('→')} ${target}` : ` ${dim('→ (no target plan)')}`;
265
334
  const indent = child ? ' - ' : ' ';
266
- process.stdout.write(`${indent}${green(slug)} [${status}]\n${child ? ' ' : ''}${arrow}\n`);
335
+ process.stdout.write(`${indent}${green(slug)} [${status}] · ${freshness.get(p).text}\n${child ? ' ' : ''}${arrow}\n`);
267
336
  };
268
337
  for (const {doc,children} of groupPrompts(prompts)) {
269
338
  render(doc, false);
@@ -305,12 +374,17 @@ async function runPromptsNext(argv, config, opts = {}) {
305
374
  // helpful message (no match / ambiguous match).
306
375
  export function resolvePromptInput(input, config, options = {}) {
307
376
  const dieOnMiss = options.dieOnMiss !== false;
377
+ // A handoffs-only project's other Markdown is never a prompt to resolve.
378
+ if (outsideHandoffs(input, config)) {
379
+ if (dieOnMiss) die(outsideHandoffsLine(input));
380
+ return null;
381
+ }
308
382
  const direct = resolveDocPath(input, config);
309
- if (direct) return direct;
383
+ if (direct && managesFile(direct, config)) return direct;
310
384
 
311
385
  if (!input.endsWith('.md')) {
312
386
  const withExt = resolveDocPath(input + '.md', config);
313
- if (withExt) return withExt;
387
+ if (withExt && managesFile(withExt, config)) return withExt;
314
388
  }
315
389
 
316
390
  const index = buildIndex(config, { fast: true, invokeHooks: false });
package/src/rename.mjs CHANGED
@@ -11,6 +11,9 @@ import { availableSessionId, prepareOwnershipMigration } from './pickup.mjs';
11
11
  import { moveFileAtomic } from './atomic-mutation.mjs';
12
12
  import { configuredReferenceFields, createReferenceIdentitySet, planReferenceMove, rewriteDocumentReferences } from './reference-planner.mjs';
13
13
  import { reportMovedCodeRefs } from './code-refs.mjs';
14
+ import { isHandoffsMode, readsAsPrompt } from './handoffs-scope.mjs';
15
+ import { promptDirectory } from './new.mjs';
16
+ import { PROMPT_DIR } from './naming.mjs';
14
17
 
15
18
  export async function runRename(argv, config, opts = {}) {
16
19
  const { dryRun } = opts;
@@ -35,6 +38,16 @@ export async function runRename(argv, config, opts = {}) {
35
38
  } else {
36
39
  newPath = path.join(path.dirname(oldPath), newInput.endsWith('.md') ? newInput : `${newInput}.md`);
37
40
  }
41
+ // One prompt location (src/naming.mjs): before any config and where
42
+ // planning is off, a prompt moves within the prompt folder at the project's
43
+ // top and nowhere else, so a rename never starts a second docs/prompts/.
44
+ if ((!config.configFound || isHandoffsMode(config)) && readsAsPrompt(oldPath)) {
45
+ const folder = config.configFound ? promptDirectory(config) : path.join(config.repoRoot, ...PROMPT_DIR.split('/'));
46
+ const relative = path.relative(folder, newPath);
47
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
48
+ die(`Nothing renamed: prompts live in ${toRepoPath(folder, config.repoRoot)}/, and ${toRepoPath(newPath, config.repoRoot)} is outside it. Choose a name inside ${toRepoPath(folder, config.repoRoot)}/.`);
49
+ }
50
+ }
38
51
  if (existsSync(newPath)) die(`Target already exists: ${toRepoPath(newPath, config.repoRoot)}`);
39
52
  newPath = authorizeManagedDestination(newPath, config, { root: sourceAuthorization.root, kind: 'Rename destination' }).path;
40
53
 
@@ -0,0 +1,291 @@
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmdirSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { promptDirectory, planDirectory } from './new.mjs';
4
+ import { archiveBaseFor } from './lifecycle.mjs';
5
+ import { collectDocFiles } from './index.mjs';
6
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
7
+ import { authorizeManagedSource, authorizeManagedSweep, authorizeRepoGeneratedPath, inLegacyPromptFolder } from './managed-path.mjs';
8
+ import { moveFileAtomic } from './atomic-mutation.mjs';
9
+ import { configuredReferenceFields, planReferenceMove } from './reference-planner.mjs';
10
+ import { availableSessionId, prepareOwnershipMigration } from './pickup.mjs';
11
+ import { listPendingHandoffs } from './handoff-record.mjs';
12
+ import { captureGitIndexGeneration, isTracked } from './git.mjs';
13
+ import { toRepoPath } from './util.mjs';
14
+ import { locateConfig, resolveConfig } from './config.mjs';
15
+ import { CONFIG_FILENAMES, LEGACY_PROMPT_DIR, RETIRED_PROMPT_STATUSES, rootIsWorkingFolder } from './naming.mjs';
16
+ import { statusMetadataFor } from './status-metadata.mjs';
17
+
18
+ function markdownFiles(directory, config) {
19
+ authorizeRepoGeneratedPath(directory, config, { kind: 'Setup carry-over folder' });
20
+ if (!existsSync(directory)) return [];
21
+ if (!lstatSync(directory).isDirectory()) throw new Error(`Carry-over folder must be a regular directory: ${directory}`);
22
+ return readdirSync(directory, { withFileTypes: true }).filter(entry => entry.isFile() && entry.name.endsWith('.md'))
23
+ .map(entry => path.join(directory, entry.name)).sort();
24
+ }
25
+
26
+ function directories(config, type) {
27
+ const live = type === 'prompt' ? promptDirectory(config) : planDirectory(config);
28
+ const archive = path.join(archiveBaseFor(path.join(live, 'document.md'), config.docsRoot, type, config), config.archiveDir);
29
+ return [live, archive];
30
+ }
31
+
32
+ // Live/archive pairs to carry from: the resolver's pre-config folders.
33
+ function sourceDirectories(config, type) {
34
+ return directories(config, type);
35
+ }
36
+
37
+ // The root prompts/ older builds saved prompts in before any config. Nothing
38
+ // writes there now (src/naming.mjs); every prompt below it, at any depth, is
39
+ // carried with its folder relative to prompts/ kept.
40
+ export function legacyPromptRoot(config) {
41
+ return rootIsWorkingFolder(config) ? path.join(config.docsRoot, ...LEGACY_PROMPT_DIR.split('/')) : null;
42
+ }
43
+
44
+ function legacyMarkdownFiles(root, config) {
45
+ authorizeRepoGeneratedPath(root, config, { kind: 'Setup carry-over folder' });
46
+ if (!existsSync(root)) return [];
47
+ if (!lstatSync(root).isDirectory()) throw new Error(`Carry-over folder must be a regular directory: ${root}`);
48
+ const files = [];
49
+ // A folder's own files before its subfolders, so live prompts are planned
50
+ // ahead of the archive below them, as they always were.
51
+ const walk = directory => {
52
+ const entries = readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
53
+ for (const entry of entries) if (entry.isFile() && entry.name.endsWith('.md')) files.push(path.join(directory, entry.name));
54
+ for (const entry of entries) if (entry.isDirectory()) walk(path.join(directory, entry.name));
55
+ };
56
+ walk(root);
57
+ return files;
58
+ }
59
+
60
+ function carryKey(target) {
61
+ return target.normalize('NFC').toLowerCase().normalize('NFC');
62
+ }
63
+
64
+ // The project as it read before any config: the docs root is the project's
65
+ // top, so the folders an older runlist saved plans and prompts in are the
66
+ // carry's sources. Setup turning planning on in a handoffs-only project
67
+ // carries from here too, under the same roots a fresh setup uses.
68
+ export async function preConfigView(top) {
69
+ const view = await resolveConfig(top, path.join(top, `${CONFIG_FILENAMES[0]}.pre-config`));
70
+ return { ...view, configFound: false, configPath: null, mode: 'plans', planning: true };
71
+ }
72
+
73
+ // Only the resolver's pre-config locations qualify. An unrelated prompts
74
+ // library, arbitrary Markdown, and files under an existing config are not
75
+ // adoption candidates. Status vocabularies come from the resolved defaults.
76
+ export function planSetupCarry(before, after, enabled = true, { types = ['prompt', 'plan'] } = {}) {
77
+ const result = { moves: [], outstanding: [], remaining: [] };
78
+ if (before.configFound) return result;
79
+ const candidates = new Set();
80
+ const seen = new Set();
81
+ const targets = new Set();
82
+ const consider = ({ source, target, type, expectedSource }) => {
83
+ if (source === target) return;
84
+ authorizeManagedSource(source, before, { kind: 'Setup carry-over source' });
85
+ authorizeRepoGeneratedPath(target, after, { kind: 'Setup carry-over destination' });
86
+ const from = toRepoPath(source, before.repoRoot), to = toRepoPath(target, before.repoRoot);
87
+ if (!enabled) return;
88
+ const targetKey = carryKey(target);
89
+ if (existsSync(target) || targets.has(targetKey)) {
90
+ result.outstanding.push({ description: `Carry-over collision: ${from} → ${to}; ${targets.has(targetKey) ? 'collides with another file being carried' : 'destination exists'}, source left in place`, path: source });
91
+ return;
92
+ }
93
+ try { if (type === 'plan') prepareOwnershipMigration(from, target, before, { sessionId: availableSessionId() }); }
94
+ catch (error) {
95
+ result.outstanding.push({ description: `Carry-over refused for ${from}: ${error.message}`, path: source });
96
+ return;
97
+ }
98
+ result.moves.push({ source, target, from, to, type, expectedSource });
99
+ targets.add(targetKey);
100
+ };
101
+ for (const type of types) {
102
+ const oldDirs = sourceDirectories(before, type);
103
+ const newDirs = directories(after, type);
104
+ for (const [i, directory] of oldDirs.entries()) {
105
+ if (directory === newDirs[i % 2]) continue;
106
+ for (const source of markdownFiles(directory, before)) {
107
+ candidates.add(source);
108
+ if (seen.has(source)) continue;
109
+ const expectedSource = readFileSync(source, 'utf8');
110
+ const parsed = parseSimpleFrontmatter(extractFrontmatter(expectedSource).frontmatter ?? '');
111
+ if (parsed.type !== type || !before.typeStatuses.get(type)?.has(parsed.status)) continue;
112
+ const archiveStatus = statusMetadataFor(before, type, parsed.status)?.archive;
113
+ // The shared root-level archive is also a common user folder. Its
114
+ // location alone is no evidence that runlist archived this document.
115
+ if (directory === path.join(before.repoRoot, before.archiveDir) && !archiveStatus) continue;
116
+ seen.add(source);
117
+ const archived = i % 2 === 1 || archiveStatus;
118
+ consider({ source, target: path.join(newDirs[archived ? 1 : 0], path.basename(source)), type, expectedSource });
119
+ }
120
+ }
121
+ // A `type: prompt` file anywhere below the root prompts/, with a status
122
+ // runlist gives prompts or one an older build did, is one runlist saved
123
+ // there; everything else there is the user's and stays.
124
+ const legacy = type === 'prompt' ? legacyPromptRoot(before) : null;
125
+ if (!legacy) continue;
126
+ for (const source of legacyMarkdownFiles(legacy, before)) {
127
+ candidates.add(source);
128
+ if (seen.has(source)) continue;
129
+ const expectedSource = readFileSync(source, 'utf8');
130
+ const parsed = parseSimpleFrontmatter(extractFrontmatter(expectedSource).frontmatter ?? '');
131
+ if (parsed.type !== 'prompt' || !(before.typeStatuses.get(type)?.has(parsed.status) || RETIRED_PROMPT_STATUSES.has(parsed.status))) continue;
132
+ seen.add(source);
133
+ const relative = path.relative(legacy, source);
134
+ const archivedAlready = relative.split(path.sep).slice(0, -1).includes(before.archiveDir);
135
+ const archiveStatus = before.typeStatuses.get(type)?.has(parsed.status) && statusMetadataFor(before, type, parsed.status)?.archive;
136
+ consider({ source, target: path.join(newDirs[archiveStatus && !archivedAlready ? 1 : 0], relative), type, expectedSource });
137
+ }
138
+ }
139
+ result.remaining = [...candidates].filter(file => !result.moves.some(move => move.source === file));
140
+ return result;
141
+ }
142
+
143
+ // Everything left in the root prompts/ (what the carry does not move) is the
144
+ // user's, by its path or through a symlinked folder: the carry never rewrites
145
+ // it, and a link there to a carried file is reported instead.
146
+ function staysInLegacyFolder(file, config, carried) {
147
+ return !carried.has(path.resolve(file)) && inLegacyPromptFolder(file, config.repoRoot);
148
+ }
149
+
150
+ function carryReferenceFields(config) {
151
+ return [...new Set([...configuredReferenceFields(config), 'plan', 'parent_prompt'])];
152
+ }
153
+
154
+ // Links to a carried file from Markdown runlist does not manage, which the
155
+ // move's reference sweep never rewrites: a README with no frontmatter that
156
+ // points at prompts/older.md, say, or any document that stays in the root
157
+ // prompts/. Reported for the user to update, never edited. Read only on the
158
+ // explicit save, init and setup paths.
159
+ export function unmanagedCarryReferences(config, moves) {
160
+ if (!moves.length) return [];
161
+ const carried = new Set(moves.map(move => path.resolve(move.source)));
162
+ const plain = [];
163
+ for (const file of collectDocFiles(config)) {
164
+ if (carried.has(path.resolve(file))) continue;
165
+ let content;
166
+ try { content = readFileSync(file, 'utf8'); } catch { continue; }
167
+ if (extractFrontmatter(content).frontmatter) {
168
+ if (staysInLegacyFolder(file, config, carried)) plain.push({ path: file, content });
169
+ continue;
170
+ }
171
+ // Lent a frontmatter block so the planner reads the body; never written.
172
+ plain.push({ path: file, content: `---\nrunlist-probe: true\n---\n${content}` });
173
+ }
174
+ if (!plain.length) return [];
175
+ const referenceFields = carryReferenceFields(config);
176
+ const found = [];
177
+ for (const move of moves) {
178
+ const documents = [{ path: move.source, content: move.expectedSource }, ...plain];
179
+ const { updates } = planReferenceMove({ documents, oldPath: move.source, newPath: move.target, repoRoot: config.repoRoot, referenceFields });
180
+ for (const update of updates) found.push({ file: toRepoPath(update.path, config.repoRoot), from: move.from, to: move.to });
181
+ }
182
+ return found.sort((a, b) => a.file.localeCompare(b.file) || a.from.localeCompare(b.from));
183
+ }
184
+
185
+ // Only clean parents of sources moved by this run or its recovery. An empty
186
+ // default folder alone does not establish that setup owns its cleanup.
187
+ export function cleanupSetupSourceDirectories(config, movedSources) {
188
+ if (config.configFound) return;
189
+ const legacy = legacyPromptRoot(config);
190
+ const legacyFolders = legacy ? movedSources.flatMap(source => {
191
+ const folders = [];
192
+ for (let directory = path.dirname(source); directory.startsWith(`${legacy}${path.sep}`); directory = path.dirname(directory)) folders.push(directory);
193
+ return path.dirname(source) === legacy || folders.length ? [...folders, legacy] : [];
194
+ }) : [];
195
+ const sources = [...new Set([...['prompt', 'plan'].flatMap(type => sourceDirectories(config, type)), ...legacyFolders])].filter(directory =>
196
+ movedSources.some(source => path.dirname(source) === directory || path.dirname(source).startsWith(`${directory}${path.sep}`)));
197
+ for (const directory of sources.sort((a, b) => b.length - a.length)) {
198
+ if (directory === config.repoRoot) continue;
199
+ authorizeRepoGeneratedPath(directory, config, { kind: 'Setup source folder' });
200
+ try { rmdirSync(directory); }
201
+ catch (error) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error; }
202
+ }
203
+ }
204
+
205
+ // Carry before publishing the first config, so a retained move transaction is
206
+ // recoverable under the same (unconfigured) roots that authorized its source.
207
+ // Each move is transactional; setup itself remains a sequence of stages.
208
+ // `expectedConfig` ({ path, content }) is the handoffs-only config setup is
209
+ // about to upgrade: it must stay exactly as previewed, and no other config may
210
+ // appear, for the whole carry.
211
+ export function applySetupCarry(moves, config, { testHooks, expectedConfig = null } = {}) {
212
+ const completed = [];
213
+ const createdDirectories = [];
214
+ const expectedSources = new Map(moves.map(move => [move.source, move.expectedSource]));
215
+ const carried = new Set(moves.map(move => path.resolve(move.source)));
216
+ const configUnchanged = () => {
217
+ if (!expectedConfig) return !config.configFound && !locateConfig(config.configDir).configPath;
218
+ try { return locateConfig(config.configDir).configPath === expectedConfig.path && readFileSync(expectedConfig.path, 'utf8') === expectedConfig.content; }
219
+ catch { return false; }
220
+ };
221
+ const configGuards = CONFIG_FILENAMES.map(name => path.join(config.configDir, name)).map(file =>
222
+ expectedConfig && path.resolve(file) === path.resolve(expectedConfig.path) ? { path: file, expectedContent: expectedConfig.content } : { path: file, absent: true });
223
+ try {
224
+ const checkConfig = () => {
225
+ if (!configUnchanged()) throw new Error(expectedConfig ? 'The config changed after the carry-over preview; rerun setup.' : 'A config appeared after the carry-over preview; rerun setup.');
226
+ };
227
+ checkConfig();
228
+ for (const move of moves) {
229
+ if (readFileSync(move.source, 'utf8') !== move.expectedSource) throw new Error(`Carry-over source changed after preview: ${move.from}`);
230
+ }
231
+ for (const move of moves) {
232
+ // What stays in the root prompts/ is the user's and is never rewritten;
233
+ // unmanagedCarryReferences lists its links to a carried file.
234
+ const files = collectDocFiles(config).filter(file => !staysInLegacyFolder(file, config, carried));
235
+ authorizeManagedSweep(files, config, { kind: 'Setup carry-over reference source' });
236
+ const documents = files.map(file => ({ path: file, content: readFileSync(file, 'utf8') }));
237
+ const references = planReferenceMove({ documents, oldPath: move.source, newPath: move.target, repoRoot: config.repoRoot, referenceFields: carryReferenceFields(config) });
238
+ const ownership = move.type === 'plan' ? prepareOwnershipMigration(move.from, move.target, config, { sessionId: availableSessionId() }) : null;
239
+ const handoffs = listPendingHandoffs(config).flatMap(record => {
240
+ const expectedContent = readFileSync(record.recordFile, 'utf8');
241
+ const data = JSON.parse(expectedContent);
242
+ let changed = false;
243
+ for (const field of ['prompt', 'plan']) if (data[field] === move.from) { data[field] = move.to; changed = true; }
244
+ if (!changed) return [];
245
+ authorizeRepoGeneratedPath(record.recordFile, config, { kind: 'Setup handoff record' });
246
+ return [{ path: record.recordFile, expectedContent, content: JSON.stringify(data, null, 2) + '\n', label: 'handoff' }];
247
+ });
248
+ const tracked = isTracked(move.source, config.repoRoot);
249
+ // Scaffold directories belong to setup, never to the move transaction.
250
+ // Keep failed carry cleanup separate from transaction recovery; recovery
251
+ // evidence must be resolved under the old roots before setup writes config.
252
+ for (const directory of [path.dirname(move.target), ...(ownership ? [path.dirname(ownership.newRecordPath)] : [])]) {
253
+ authorizeRepoGeneratedPath(directory, config, { kind: 'Setup carry-over folder' });
254
+ const missing = [];
255
+ for (let next = directory; !existsSync(next); next = path.dirname(next)) missing.push(next);
256
+ for (const next of missing.reverse()) {
257
+ try { mkdirSync(next); }
258
+ catch (error) { if (error.code === 'EEXIST') continue; throw error; }
259
+ createdDirectories.push({ path: next, identity: lstatSync(next) });
260
+ }
261
+ }
262
+ checkConfig();
263
+ moveFileAtomic(move.source, move.target, references.movedContent, {
264
+ repoRoot: config.repoRoot, config, expectedSourceContent: expectedSources.get(move.source),
265
+ updates: [...references.updates, ...handoffs],
266
+ creations: ownership ? [{ path: ownership.newRecordPath, content: ownership.newContent, label: 'ownership' }] : [],
267
+ deletions: ownership ? [{ path: ownership.oldRecordPath, expectedContent: ownership.oldContent, label: 'ownership' }] : [],
268
+ guards: configGuards,
269
+ gitMove: tracked, gitIndex: tracked ? captureGitIndexGeneration(config.repoRoot) : null,
270
+ operation: 'move', sessionId: availableSessionId(), testHooks: { ...testHooks, beforeMoveSnapshot(info) {
271
+ testHooks?.beforeMoveSnapshot?.(info);
272
+ checkConfig();
273
+ } },
274
+ });
275
+ for (const update of references.updates) if (expectedSources.has(update.path)) expectedSources.set(update.path, update.content);
276
+ completed.push(move);
277
+ cleanupSetupSourceDirectories(config, [move.source]);
278
+ }
279
+ return { completed };
280
+ } catch (error) {
281
+ if (configUnchanged()) {
282
+ for (const entry of createdDirectories.reverse()) {
283
+ try {
284
+ const stat = lstatSync(entry.path);
285
+ if (stat.isDirectory() && stat.dev === entry.identity.dev && stat.ino === entry.identity.ino) rmdirSync(entry.path);
286
+ } catch { /* Keep nonempty/replaced directories and preserve the original failure. */ }
287
+ }
288
+ }
289
+ return { completed, error: error.message };
290
+ }
291
+ }
@@ -0,0 +1,20 @@
1
+ import { resolveConfig } from './config.mjs';
2
+ import { planHandoffsUpgrade } from './handoffs-mode.mjs';
3
+ import { planSetupCarry, preConfigView } from './setup-carry.mjs';
4
+
5
+ // In a handoffs-only project, the plans and prompts an older runlist saved
6
+ // before there was a config, which `runlist setup` carries in when it turns
7
+ // planning on (src/setup-carry.mjs): counted by setup's own carry planner, so
8
+ // the planning-off line says exactly what setup will bring in. The carry reads
9
+ // the old default folders only (plans/, the root archive, the root prompts/),
10
+ // never the whole project, and nothing here runs on a hook path. Counting
11
+ // never stops a command: anything it cannot read counts as nothing.
12
+ export async function documentsWaitingForSetup(config) {
13
+ if (config?.mode !== 'handoffs' || !config.configPath) return 0;
14
+ try {
15
+ const upgrade = planHandoffsUpgrade(config);
16
+ const after = await resolveConfig(config.configDir, config.configPath, { overrides: upgrade.overrides });
17
+ const carry = planSetupCarry(await preConfigView(config.repoRoot), after, true);
18
+ return carry.moves.length + carry.outstanding.length;
19
+ } catch { return 0; }
20
+ }