runlist 0.95.6 → 0.96.1

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.
package/src/new.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { applyTemplateOverride, readTemplateStore, templateFile } from './template-store.mjs';
2
+ import { HANDOFF_TEMPLATES } from './prompt-handoff.mjs';
2
3
  import { spawnSync } from 'node:child_process';
3
4
  import { existsSync, readFileSync, mkdirSync, fstatSync } from 'node:fs';
4
5
  import path from 'node:path';
@@ -86,7 +87,7 @@ export function catchAllRoot(config) {
86
87
 
87
88
  const BUILTIN_TEMPLATES = {
88
89
  doc: {
89
- description: 'Reference doc, design note, module overview — build-up shape lite',
90
+ description: 'Reference document for context, guidance or design notes; active by default.',
90
91
  defaultStatus: 'active',
91
92
  // Body input optional. When passed (inline / --body / @file / stdin),
92
93
  // it lands in the Overview section. Without it, Overview is left blank
@@ -132,7 +133,7 @@ ${ctx?.bodyInput?.trim() ?? ''}
132
133
  },
133
134
  },
134
135
  plan: {
135
- description: 'Execution plan — build-up shape (Problem → Phases → Closeout) with phase status markers and Version History',
136
+ description: 'Execution plan with goals, constraints, decisions, phases and closeout; planned until work starts.',
136
137
  dir: 'plans',
137
138
  targetRoot: 'plans',
138
139
  // A scaffolded plan is scoped, not being worked; `runlist use` starts it.
@@ -227,7 +228,7 @@ Status markers (put in heading text):
227
228
  },
228
229
  },
229
230
  prompt: {
230
- description: 'Saved prompt to seed a future Claude session — body is required',
231
+ description: 'Saved prompt wrapper: metadata and authored body. Use the resume or subagent draft to write a handoff.',
231
232
  dir: 'prompts',
232
233
  targetRoot: 'prompts',
233
234
  defaultStatus: 'pending',
@@ -419,7 +420,14 @@ export function promptDirectory(config, template = resolveTemplate('prompt', con
419
420
  return template.dir && !routed ? path.join(targetRoot, template.dir) : targetRoot;
420
421
  }
421
422
 
422
- export function preparePromptDocument(name, bodyInput, config, { plan = null, dryRun = false } = {}) {
423
+ function bindPromptParent(content, parentPrompt) {
424
+ if (!parentPrompt) return content;
425
+ const { frontmatter, body } = extractFrontmatter(content);
426
+ if (!frontmatter) throw new Error('Prompt template must return frontmatter for parent binding.');
427
+ return `---\n${mergeBodyFrontmatter(frontmatter, { parent_prompt: parentPrompt }, 'prompt')}\n---\n${body}`;
428
+ }
429
+
430
+ export function preparePromptDocument(name, bodyInput, config, { plan = null, parentPrompt = null, dryRun = false } = {}) {
423
431
  const template = resolveTemplate('prompt', config);
424
432
  const typeStatuses = config.typeStatuses?.get('prompt');
425
433
  const status = template.defaultStatus && (!typeStatuses || typeStatuses.has(template.defaultStatus))
@@ -451,7 +459,7 @@ export function preparePromptDocument(name, bodyInput, config, { plan = null, dr
451
459
  if (plan) fm = mergeBodyFrontmatter(fm, { plan }, 'prompt');
452
460
  content = `---\n${fm}\n---\n${template.body(title, ctx)}`;
453
461
  }
454
- return { slug, filePath, repoPath: toRepoPath(filePath, config.repoRoot), content };
462
+ return { slug, filePath, repoPath: toRepoPath(filePath, config.repoRoot), content: bindPromptParent(content, parentPrompt) };
455
463
  }
456
464
 
457
465
  // Resolve one `--runlist` token to a scaffolded child plan: a bare slug becomes
@@ -1081,6 +1089,8 @@ export async function runNew(argv, config, opts = {}) {
1081
1089
  return;
1082
1090
  }
1083
1091
 
1092
+ if (typeName === 'prompt') content = bindPromptParent(content, opts.parentPrompt);
1093
+
1084
1094
  // Ensure parent dir exists (templates with `dir:` may target a new subdirectory)
1085
1095
  mkdirSync(path.dirname(filePath), { recursive: true });
1086
1096
 
@@ -1314,8 +1324,18 @@ export function appTemplates(config) {
1314
1324
  const ctx = {title:'{{title}}',status:'{{status}}',today:'{{date}}',bodyInput:'{{body}}',validSurfaces:config.raw?.taxonomy?.surfaces ?? (config.validSurfaces ? [...config.validSurfaces] : null),validModules:config.raw?.taxonomy?.modules ?? (config.validModules ? [...config.validModules] : null)};
1315
1325
  const defaultSource = `---\n${builtin.frontmatter(ctx.status,ctx.today,ctx)}\n---\n${builtin.body(ctx.title,ctx)}`.replaceAll(pkg.version,'{{version}}');
1316
1326
  const configured = config.raw?.templates?.[name];
1317
- return {name,description:builtin.description,source:store.data.templates[name]?.source ?? defaultSource,defaultSource,overridden:!!store.data.templates[name],origin:store.data.templates[name] ? templateFile : configured ? `${configFile} (JavaScript)` : 'Built-in',configuredSource:configured ? code(configured) : null,editable:true};
1327
+ return {name,label:name==='prompt'?'Prompt wrapper':name==='plan'?'Execution plan':'Reference doc',kind:'document',description:builtin.description,source:store.data.templates[name]?.source ?? defaultSource,defaultSource,overridden:!!store.data.templates[name],origin:store.data.templates[name] ? templateFile : configured ? `${configFile} (JavaScript)` : 'Built-in',configuredSource:configured ? code(configured) : null,editable:true};
1328
+ });
1329
+ const baton = config.raw?.templates?.prompt?.baton;
1330
+ for (const [name,draft] of Object.entries(HANDOFF_TEMPLATES)) templates.push({
1331
+ name,label:draft.label,kind:'handoff',description:draft.description,
1332
+ source:store.data.templates[name]?.source ?? draft.source,defaultSource:draft.source,
1333
+ overridden:!!store.data.templates[name],origin:store.data.templates[name] ? templateFile : 'Built-in',
1334
+ configuredSource:typeof baton==='string'&&baton.trim()?baton.trim():null,
1335
+ configLabel:'Repository baton instructions',
1336
+ configNote:'These repository instructions still apply to handoffs. Saving a draft outline does not replace them or the saved prompt wrapper.',
1337
+ editable:true,
1318
1338
  });
1319
- for (const [name,value] of Object.entries(config.raw?.templates ?? {})) if (!BUILTIN_TEMPLATES[name]) templates.push({name,description:value.description ?? 'Repository template',origin:`${configFile} (JavaScript)`,source:code(value),editable:false});
1339
+ for (const [name,value] of Object.entries(config.raw?.templates ?? {})) if (!BUILTIN_TEMPLATES[name]&&!Object.hasOwn(HANDOFF_TEMPLATES,name)) templates.push({name,description:value.description ?? 'Repository template',origin:`${configFile} (JavaScript)`,source:code(value),editable:false});
1320
1340
  return {templates,revision:store.revision,file:templateFile,version:pkg.version};
1321
1341
  }
@@ -0,0 +1,132 @@
1
+ import path from 'node:path';
2
+
3
+ export function parentPromptName(value) {
4
+ if (typeof value !== 'string' || !/^resume-[a-z0-9]+(?:-[a-z0-9]+)*(?:\.md)?$/.test(value)) {
5
+ throw new Error('A parent resume prompt name is required, e.g. resume-session-name.');
6
+ }
7
+ return value.replace(/\.md$/, '');
8
+ }
9
+
10
+ export const HANDOFF_TEMPLATES = {
11
+ 'handoff:resume': {
12
+ label: 'Resume draft',
13
+ description: 'Cold-start handoff outline for baton --template; does not change saved prompt formatting or repository baton instructions.',
14
+ source: `<!-- Follow repository baton instructions. Keep this focused on what the next session needs. -->
15
+ ## Resume
16
+ <!-- Work to resume and owning plan path + section, or a durable brief if there is no plan. -->
17
+
18
+ ## State and next move
19
+ <!-- What is in hand, what remains, and the exact next action or blocker. -->
20
+
21
+ ## Decisions and waiting items
22
+ <!-- Why this direction; rejected or failed approaches that should not be repeated. -->
23
+ <!-- Outstanding decisions, answers or approvals, with a durable record path for each. -->
24
+
25
+ ## Evidence and working material
26
+ <!-- Relevant commits, checks that passed, and claims still unverified. -->
27
+ <!-- Repo-relative paths to briefs, reports and scripts; scratch paths are supplementary. -->
28
+
29
+ ## Boundaries
30
+ <!-- Current user priorities, authorization limits and anything requiring a new go. -->
31
+ <!-- Review linked child handoffs and record completed results in the owning plan. -->
32
+ `,
33
+ },
34
+ 'handoff:subagent': {
35
+ label: 'Subagent draft',
36
+ description: 'Bounded child handoff outline for baton --subagent --template; parent_prompt links it to a resume prompt.',
37
+ source: `---
38
+ parent_prompt: {{parent_prompt}}
39
+ ---
40
+ <!-- Follow the repository's baton instructions alongside these child-specific fields. -->
41
+ ## Task
42
+ <!-- One sentence: the bounded job and its owning plan or brief. -->
43
+
44
+ ## State
45
+ <!-- Continue, blocked, or complete. If complete, say what the parent must record. -->
46
+
47
+ ## Next
48
+ <!-- The exact next action, or the blocker and what clears it. No remaining work: say so. -->
49
+ <!-- Include outstanding decisions or approvals and where each is recorded. -->
50
+
51
+ ## Evidence
52
+ <!-- Commits, checks that passed, and checks still unverified. -->
53
+ <!-- Keep the full record in a durable repo-relative path; scratch paths are supplementary. -->
54
+
55
+ ## Boundaries
56
+ <!-- Owned files, shared claims, current authorization, and any required new go. -->
57
+ <!-- Do not release or claim the parent's plan. This handoff grants no new authority. -->
58
+ `,
59
+ },
60
+ };
61
+
62
+ // Explicit links win, including missing/ambiguous parents. Legacy child names
63
+ // may share a parent stem; the longest whole-stem match is the most specific.
64
+
65
+ export function promptParentCandidates(doc, parents) {
66
+ if(doc.parentPromptInvalid)return [];
67
+ const slug = doc => path.basename(doc.path, '.md');
68
+ let matches;
69
+ if (doc.parentPrompt) {
70
+ const ref = doc.parentPrompt.replace(/\.md$/, '');
71
+ matches = parents.filter(parent => ref.includes('/')
72
+ ? parent.path.replace(/\.md$/, '') === ref
73
+ : path.dirname(parent.path) === path.dirname(doc.path) && slug(parent) === ref);
74
+ } else {
75
+ const stem = slug(doc).replace(/^(?:subagent|agent|session)-/, '');
76
+ if (stem === slug(doc)) return [];
77
+ matches = parents.filter(parent => {
78
+ const parentStem = slug(parent).slice('resume-'.length);
79
+ return path.dirname(parent.path) === path.dirname(doc.path)
80
+ && (stem === parentStem || stem.startsWith(`${parentStem}-`));
81
+ }).sort((a, b) => slug(b).length - slug(a).length);
82
+ if (matches.length > 1 && slug(matches[0]).length > slug(matches[1]).length) matches = matches.slice(0, 1);
83
+ }
84
+ return matches;
85
+ }
86
+
87
+ export function groupPrompts(prompts) {
88
+ const slug = doc => path.basename(doc.path, '.md');
89
+ const parents = prompts.filter(doc => slug(doc).startsWith('resume-')&&!doc.parentPrompt&&!doc.parentPromptInvalid);
90
+ const children = new Map();
91
+ const parentFor = new Map();
92
+ for (const doc of prompts) {
93
+ if (slug(doc).startsWith('resume-')) continue;
94
+ const matches = promptParentCandidates(doc, parents);
95
+ if (matches.length !== 1) continue;
96
+ const parent = matches[0];
97
+ parentFor.set(doc, parent);
98
+ if (!children.has(parent)) children.set(parent, []);
99
+ children.get(parent).push(doc);
100
+ }
101
+ const groups = [], emitted = new Set();
102
+ for (const doc of prompts) {
103
+ const root = parentFor.get(doc) ?? doc;
104
+ if (emitted.has(root)) continue;
105
+ emitted.add(root);
106
+ groups.push({ doc: root, children: children.get(root) ?? [] });
107
+ }
108
+ return groups;
109
+ }
110
+
111
+ export function diagnosePromptParents(prompts, config) {
112
+ const archived = doc => doc.status === 'archived' || doc.path.split('/').includes(config.archiveDir);
113
+ const parents = prompts.filter(doc => path.basename(doc.path).startsWith('resume-'));
114
+ const live = parents.filter(doc => !archived(doc)&&!doc.parentPrompt&&!doc.parentPromptInvalid);
115
+ const history = parents.filter(archived).map(doc => ({...doc, path:doc.path.split('/').filter(part=>part!==config.archiveDir).join('/'), archivedPath:doc.path}));
116
+ const links=[];
117
+ for(const doc of prompts.filter(doc=>!archived(doc))) {
118
+ const isResume=path.basename(doc.path).startsWith('resume-');
119
+ if(!doc.parentPrompt&&!doc.parentPromptInvalid&&(isResume||!/^(?:subagent|agent|session)-/.test(path.basename(doc.path))))continue;
120
+ let state,candidates=[];
121
+ if(doc.parentPromptInvalid||(isResume&&doc.parentPrompt))state='invalid';
122
+ else {
123
+ candidates=promptParentCandidates(doc,live);
124
+ if(candidates.length===1)state=doc.parentPrompt?'linked':'inferred';
125
+ else if(candidates.length>1)state='ambiguous';
126
+ else {candidates=promptParentCandidates(doc,history);state=candidates.length?'archived':'missing';}
127
+ }
128
+ links.push({path:doc.path,parentPrompt:doc.parentPrompt??null,state,candidates:candidates.map(parent=>parent.archivedPath??parent.path)});
129
+ }
130
+ const counts={};for(const link of links)counts[link.state]=(counts[link.state]??0)+1;
131
+ return {children:links.length,counts,links,findings:links.filter(link=>link.state!=='linked')};
132
+ }
package/src/prompts.mjs CHANGED
@@ -18,11 +18,13 @@ import {
18
18
  import { actionablePromptStatuses, comparePromptDocs } from './status-metadata.mjs';
19
19
  import { LEGACY_STATE_DIR, STATE_DIR } from './naming.mjs';
20
20
  import { formatPlanChanges, planChangesSince } from './plan-changes.mjs';
21
+ import { groupPrompts, parentPromptName } from './prompt-handoff.mjs';
22
+ import { mutateFileSet } from './atomic-mutation.mjs';
21
23
 
22
24
  // `resume` is an alias for `use` — agents reach for "resume" when continuing a
23
25
  // session; `use` reads as internal mechanics. Both names stay valid; the
24
26
  // canonical output ("Consumed: …") is unchanged.
25
- const SUBCOMMANDS = new Set(['list', 'next', 'use', 'resume', 'show', 'peek', 'archive', 'new']);
27
+ const SUBCOMMANDS = new Set(['list', 'next', 'use', 'resume', 'show', 'peek', 'archive', 'new', 'link']);
26
28
 
27
29
  // Removed 2026-08-30 with the `held`/`shelved` statuses. Named rather than left
28
30
  // to fall through to the list filter below, which would silently answer a
@@ -53,9 +55,75 @@ export async function runPrompts(argv, config, opts = {}) {
53
55
  case 'peek': return runPromptsShow(rest, config);
54
56
  case 'archive': return runPromptsArchive(rest, config, opts);
55
57
  case 'new': return runPromptsNew(rest, config, opts);
58
+ case 'link': return runPromptsLink(rest, config, opts);
56
59
  }
57
60
  }
58
61
 
62
+ function runPromptsLink(argv, config, opts) {
63
+ let parentInput = null;
64
+ const inputs = [];
65
+ for (let i = 0; i < argv.length; i++) {
66
+ if (argv[i] === '--parent') { parentInput = parentPromptName(argv[++i]); continue; }
67
+ if (argv[i] === '--json') continue;
68
+ if (argv[i].startsWith('-')) die(`Unknown link option: ${argv[i]}`);
69
+ inputs.push(argv[i]);
70
+ }
71
+ if (!parentInput || !inputs.length) die('Usage: runlist prompts link <child...> --parent resume-name');
72
+ const parentPath = authorizeManagedSource(resolvePromptInput(parentInput, config), config, { kind: 'Parent prompt' }).path;
73
+ const parentRaw = readFileSync(parentPath, 'utf8');
74
+ const parentFm = parseSimpleFrontmatter(extractFrontmatter(parentRaw).frontmatter);
75
+ if (asString(parentFm.type) !== 'prompt' || asString(parentFm.status) !== 'pending'
76
+ || isArchivedPath(toRepoPath(parentPath, config.repoRoot), config) || asString(parentFm.parent_prompt)
77
+ || path.basename(parentPath, '.md') !== parentInput) die('The parent must be an existing pending resume prompt without its own parent.');
78
+ const updates = [];
79
+ const paths = new Set();
80
+ for (const input of inputs) {
81
+ const file = authorizeManagedSource(resolvePromptInput(input, config), config, { kind: 'Child prompt' }).path;
82
+ if (paths.has(file)) continue;
83
+ paths.add(file);
84
+ const raw = readFileSync(file, 'utf8');
85
+ const fm = parseSimpleFrontmatter(extractFrontmatter(raw).frontmatter);
86
+ if (file === parentPath || path.basename(file).startsWith('resume-') || asString(fm.type) !== 'prompt'
87
+ || asString(fm.status) !== 'pending' || isArchivedPath(toRepoPath(file, config.repoRoot), config)) {
88
+ die(`Child must be a pending non-resume prompt: ${toRepoPath(file, config.repoRoot)}`);
89
+ }
90
+ const ref = path.dirname(file) === path.dirname(parentPath) ? parentInput : toRepoPath(parentPath, config.repoRoot);
91
+ const header = /^(---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))/.exec(raw);
92
+ if (!header || (header[2].match(/^parent_prompt:/gm) ?? []).length > 1) die(`Repair the parent_prompt frontmatter first: ${input}`);
93
+ if (asString(fm.parent_prompt) === ref) continue;
94
+ const eol = header[1].endsWith('\r\n') ? '\r\n' : '\n';
95
+ const field = `parent_prompt: ${JSON.stringify(ref)}`;
96
+ const parentField = /^parent_prompt:[^\r\n]*(?:\r?\n[ \t]+[^\r\n]*)*/m;
97
+ const frontmatter = parentField.test(header[2]) ? header[2].replace(parentField, field) : `${header[2]}${eol}${field}`;
98
+ updates.push({ path: file, expectedContent: raw, content: `${header[1]}${frontmatter}${header[3]}${raw.slice(header[0].length)}` });
99
+ }
100
+ if (!opts.dryRun && updates.length) mutateFileSet({ updates, guards: [{ path: parentPath, expectedContent: parentRaw }] }, {
101
+ repoRoot: config.repoRoot, testHooks: opts.testHooks,
102
+ });
103
+ const result = { operation: 'prompts-link', dryRun: Boolean(opts.dryRun), parent: toRepoPath(parentPath, config.repoRoot),
104
+ linked: updates.map(item => toRepoPath(item.path, config.repoRoot)) };
105
+ process.stdout.write(argv.includes('--json') ? JSON.stringify(result, null, 2) + '\n'
106
+ : `${opts.dryRun ? 'Would link' : 'Linked'} ${updates.length} child prompt(s) to ${parentInput}.\n`);
107
+ return result;
108
+ }
109
+
110
+ function childHandoffs(repoPath, config, index = null) {
111
+ if (!path.basename(repoPath).startsWith('resume-')) return { paths: [], notice: '' };
112
+ index ??= buildIndex(config, { fast: true, invokeHooks: false });
113
+ const docs = index.docs.filter(doc => doc.type === 'prompt' && doc.status !== 'archived' && !isArchivedPath(doc.path, config));
114
+ // Consumption removes a parent from the live index. A read-only peek at its
115
+ // archive can still discover children linked to its former live location.
116
+ const livePath = repoPath.split('/').filter(part => part !== config.archiveDir).join('/');
117
+ if (livePath !== repoPath && !docs.some(doc => doc.path === livePath)) {
118
+ docs.push({ path: livePath });
119
+ }
120
+ const children = groupPrompts(docs.sort(comparePromptDocs)).find(group => group.doc.path === livePath)?.children ?? [];
121
+ if (!children.length) return { paths: [], notice: '' };
122
+ const parent = path.basename(repoPath, '.md');
123
+ 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` };
125
+ }
126
+
59
127
  function runPromptsList(argv, config, opts = {}) {
60
128
  // Listing prompts renders slug / status / updated / target — nothing any
61
129
  // validating pass produces, and `runQuery` never reads warnings either. A
@@ -111,13 +179,29 @@ function renderPromptQueueList(index, config) {
111
179
  const summary = Object.entries(counts).map(([s, n]) => `${n} ${s}`).join(' · ');
112
180
  process.stdout.write(dim(`${prompts.length} prompts · ${summary}`) + '\n\n');
113
181
 
114
- const maxSlug = Math.min(36, Math.max(...prompts.map(p => path.basename(p.path, '.md').length)));
115
- for (let i = 0; i < prompts.length; i++) {
116
- const p = prompts[i];
182
+ const groups = groupPrompts(prompts);
183
+ const isAgentHandoff = doc => !path.basename(doc.path).startsWith('resume-')
184
+ && (doc.parentPrompt || /^(?:subagent|agent|session)-/.test(path.basename(doc.path)));
185
+ const standalone = groups.filter(group => isAgentHandoff(group.doc));
186
+ const visible = groups.filter(group => !isAgentHandoff(group.doc));
187
+ 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
+ const childCount = children => `[+ ${children.length} subagent prompt${children.length === 1 ? '' : 's'}]`;
190
+ for (const { doc: p, children } of visible) {
117
191
  const slug = path.basename(p.path, '.md').padEnd(maxSlug);
118
- const marker = i === 0 && p.status === 'pending' ? green('[NEXT]') : ' ';
192
+ const marker = p.path === nextPath ? green('[NEXT]') : ' ';
119
193
  const status = `[${(p.status ?? 'unknown').toUpperCase()}]`;
120
- process.stdout.write(` ${marker} ${slug} ${status}\n`);
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`);
197
+ }
198
+ if (standalone.length) {
199
+ 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`);
202
+ }
203
+ if (standalone.length || visible.some(group => group.children.length)) {
204
+ process.stdout.write(dim('\nUse `rl prompts --verbose` to expand subagent prompts.\n'));
121
205
  }
122
206
  }
123
207
 
@@ -173,12 +257,17 @@ function renderPromptsVerbose(index, config, { hasStatusFlag, includeArchived })
173
257
  const summary = Object.entries(counts).map(([s, n]) => `${n} ${s}`).join(' · ');
174
258
  process.stdout.write(`${prompts.length} prompt${prompts.length === 1 ? '' : 's'} · ${summary}\n\n`);
175
259
 
176
- for (const p of prompts) {
260
+ const render = (p, child) => {
177
261
  const slug = path.basename(p.path, '.md');
178
262
  const target = findPromptTarget(p, config);
179
263
  const status = (p.status ?? 'unknown').toUpperCase();
180
264
  const arrow = target ? ` ${dim('→')} ${target}` : ` ${dim('→ (no target plan)')}`;
181
- process.stdout.write(` ${green(slug)} [${status}]\n${arrow}\n`);
265
+ const indent = child ? ' - ' : ' ';
266
+ process.stdout.write(`${indent}${green(slug)} [${status}]\n${child ? ' ' : ''}${arrow}\n`);
267
+ };
268
+ for (const {doc,children} of groupPrompts(prompts)) {
269
+ render(doc, false);
270
+ for (const child of children) render(child, true);
182
271
  }
183
272
  }
184
273
 
@@ -281,6 +370,7 @@ export async function consumePrompt(filePath, config, opts) {
281
370
  if (status === 'archived' || isArchivedPath(repoPath, config)) {
282
371
  die(`Already consumed: ${repoPath}`);
283
372
  }
373
+ const family = childHandoffs(repoPath, config);
284
374
 
285
375
  const planRef = asString(parsed.plan);
286
376
  let linkedClaim = null;
@@ -297,12 +387,13 @@ export async function consumePrompt(filePath, config, opts) {
297
387
  const prefix = dim('[dry-run]');
298
388
  process.stderr.write(`${prefix} Would emit body and archive: ${repoPath} (${status ?? 'unknown'} → archived)\n`);
299
389
  if (claimSkipReason) process.stderr.write(`${prefix} Would NOT claim the linked plan: ${claimSkipReason}\n`);
300
- const bytes = Buffer.byteLength(body, 'utf8');
301
- const lines = body.split('\n').length;
390
+ const previewBody = body + family.notice;
391
+ const bytes = Buffer.byteLength(previewBody, 'utf8');
392
+ const lines = previewBody.split('\n').length;
302
393
  process.stderr.write(`${prefix} body preview (${bytes}B, ${lines} lines):\n`);
303
394
  process.stderr.write(`${dim('---8<---')}\n`);
304
- process.stderr.write(body);
305
- if (!body.endsWith('\n')) process.stderr.write('\n');
395
+ process.stderr.write(previewBody);
396
+ if (!previewBody.endsWith('\n')) process.stderr.write('\n');
306
397
  process.stderr.write(`${dim('--->8---')}\n`);
307
398
  runArchive([filePath], config, { dryRun: true, noIndex, out: process.stderr, skipInboundRefs: true });
308
399
  return;
@@ -334,7 +425,7 @@ export async function consumePrompt(filePath, config, opts) {
334
425
  // Consume output is at-most-once: archive/claim commits before stdout. A
335
426
  // downstream failure cannot roll the transaction back or make it consumable
336
427
  // again; the archived path remains available through `prompts show`.
337
- await writeConsumedBody(consumedBody, consumedPath, opts.writeBody, linkedClaim?.repoPath);
428
+ await writeConsumedBody(consumedBody + family.notice, consumedPath, opts.writeBody, linkedClaim?.repoPath);
338
429
  let completion = { indexRegenerated: false, ownershipChanged: false, hook: 'none', pending: false };
339
430
  if (linkedClaim) {
340
431
  try {
@@ -375,6 +466,7 @@ export async function consumePrompt(filePath, config, opts) {
375
466
  ].filter(Boolean).map(normalize))];
376
467
  return {
377
468
  operation: 'consume',
469
+ childPrompts: family.paths,
378
470
  status: { from: status ?? null, to: 'archived', changed: true },
379
471
  repositoryFiles,
380
472
  sessionFiles,
@@ -550,6 +642,7 @@ function runPromptsShow(argv, config) {
550
642
  // prompt keep working.
551
643
  const multi = targets.length > 1;
552
644
  let shown = 0;
645
+ let familyIndex = null;
553
646
  for (const filePath of targets) {
554
647
  const prompt = readPromptForShow(filePath, config, { strict: !all && !multi });
555
648
  if (!prompt) continue;
@@ -558,10 +651,13 @@ function runPromptsShow(argv, config) {
558
651
  if (shown > 0) process.stdout.write('\n');
559
652
  process.stdout.write(dim(`──── ${repoPath} [${status}] ────\n`));
560
653
  } else {
561
- process.stderr.write(dim(`${repoPath} [${status}] — read-only peek; \`runlist use ${repoPath}\` to consume\n`));
654
+ const hint = status === 'archived' || isArchivedPath(repoPath, config) ? 'archived prompt' : `\`runlist use ${repoPath}\` to consume`;
655
+ process.stderr.write(dim(`${repoPath} [${status}] — read-only peek; ${hint}\n`));
562
656
  }
563
- process.stdout.write(body);
564
- if (!body.endsWith('\n')) process.stdout.write('\n');
657
+ if (path.basename(repoPath).startsWith('resume-')) familyIndex ??= buildIndex(config, { fast: true, invokeHooks: false });
658
+ const displayedBody = body + childHandoffs(repoPath, config, familyIndex).notice;
659
+ process.stdout.write(displayedBody);
660
+ if (!displayedBody.endsWith('\n')) process.stdout.write('\n');
565
661
  shown++;
566
662
  }
567
663
 
@@ -4,16 +4,24 @@ import path from 'node:path';
4
4
  import { authorizeRepoGeneratedPath } from './managed-path.mjs';
5
5
  import { mutateFileSet, MutationConflictError } from './atomic-mutation.mjs';
6
6
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
7
+ import { HANDOFF_TEMPLATES } from './prompt-handoff.mjs';
7
8
 
8
9
  export const templateFile = 'runlist.templates.json';
9
- export const templateNames = ['doc','plan','prompt'];
10
+ export const templateNames = ['doc','plan','prompt',...Object.keys(HANDOFF_TEMPLATES)];
10
11
  const tokens = new Set(['title','status','date','body','version']);
11
12
  const revision = text => createHash('sha256').update(text).digest('hex');
12
13
  function fail(code, message) { const error = new Error(message); error.code = code; throw error; }
13
14
  export function validateTemplate(name, source) {
14
- if (!templateNames.includes(name)) fail('invalid-template','Choose a doc, plan or prompt template.');
15
- if (typeof source !== 'string' || Buffer.byteLength(source) > 128 * 1024) fail('invalid-template','Template must be Markdown smaller than 128 KB.');
15
+ if (!templateNames.includes(name)) fail('invalid-template','Choose a doc, plan, prompt, resume or subagent template.');
16
+ if (typeof source !== 'string' || !source.trim() || Buffer.byteLength(source) > 128 * 1024) fail('invalid-template','Template must be nonempty Markdown smaller than 128 KB.');
16
17
  const parts = extractFrontmatter(source), metadata=parseSimpleFrontmatter(parts.frontmatter);
18
+ if (Object.hasOwn(HANDOFF_TEMPLATES,name)) {
19
+ for (const match of source.matchAll(/\{\{([^{}]+)\}\}/g)) if (name !== 'handoff:subagent' || match[1] !== 'parent_prompt') fail('invalid-template',`Unknown placeholder: {{${match[1]}}}.`);
20
+ if (name === 'handoff:subagent' && (!source.startsWith('---\n') || metadata.parent_prompt !== '{{parent_prompt}}' || (parts.frontmatter.match(/^parent_prompt:/gm)??[]).length !== 1)) fail('invalid-template','Keep parent_prompt: {{parent_prompt}} in the subagent frontmatter.');
21
+ if (name === 'handoff:resume' && Object.hasOwn(metadata,'parent_prompt')) fail('invalid-template','A resume draft cannot have its own parent_prompt; use the subagent draft for child handoffs.');
22
+ if (['type','status','created','updated'].some(key=>Object.hasOwn(metadata,key))) fail('invalid-template','Handoff drafts contain authored content; saved prompt metadata is supplied by the prompt scaffold.');
23
+ return parts;
24
+ }
17
25
  if (!source.startsWith('---\n') || metadata.type !== name) fail('invalid-template',`Keep the frontmatter and type: ${name}.`);
18
26
  for (const match of source.matchAll(/\{\{([^{}]+)\}\}/g)) if (!tokens.has(match[1])) fail('invalid-template',`Unknown placeholder: {{${match[1]}}}.`);
19
27
  if (!/\{\{title\}\}/.test(parts.body) && name !== 'prompt') fail('invalid-template','Keep {{title}} in the document body.');
@@ -31,7 +39,7 @@ export function readTemplateStore(config) {
31
39
  let data = {version:1,templates:{}};
32
40
  if (source !== null) {
33
41
  try { data = JSON.parse(source); } catch { fail('invalid-template','runlist.templates.json contains invalid JSON.'); }
34
- if (data.version !== 1 || !data.templates || typeof data.templates !== 'object' || Array.isArray(data.templates) || Object.keys(data).some(k=>!['version','templates'].includes(k))) fail('invalid-template','Unsupported template settings format.');
42
+ if (!data || typeof data !== 'object' || data.version !== 1 || !data.templates || typeof data.templates !== 'object' || Array.isArray(data.templates) || Object.keys(data).some(k=>!['version','templates'].includes(k))) fail('invalid-template','Unsupported template settings format.');
35
43
  for (const [name,value] of Object.entries(data.templates)) {
36
44
  if (!value || Object.keys(value).some(k=>k!=='source')) fail('invalid-template','Template settings accept a Markdown source field.');
37
45
  validateTemplate(name,value.source);
@@ -40,17 +48,20 @@ export function readTemplateStore(config) {
40
48
  return {file,source,data,revision:source === null ? null : revision(source)};
41
49
  }
42
50
  export function saveTemplate(config,{name,source,expectedRevision,reset=false}) {
43
- if (!templateNames.includes(name)) fail('invalid-template','Choose a doc, plan or prompt template.');
51
+ if (!templateNames.includes(name)) fail('invalid-template','Choose a doc, plan, prompt, resume or subagent template.');
44
52
  if (!reset) validateTemplate(name,source);
45
53
  const store = readTemplateStore(config);
46
54
  if (expectedRevision !== store.revision) fail('template-conflict','Templates changed in another tab or process. Your draft is kept; reload the saved templates before trying again.');
47
55
  if (reset) delete store.data.templates[name]; else store.data.templates[name] = {source};
48
56
  const content = JSON.stringify(store.data,null,2)+'\n';
57
+ if (Buffer.byteLength(content) > 512 * 1024) fail('invalid-template','Combined template settings must be smaller than 512 KB.');
49
58
  try { mutateFileSet(store.source === null ? {creations:[{path:store.file,content}]} : {updates:[{path:store.file,expectedContent:store.source,content}]},{repoRoot:config.repoRoot}); }
50
59
  catch (error) { if (error instanceof MutationConflictError) fail('template-conflict','Templates changed while saving. Your draft is kept.'); throw error; }
51
60
  return {revision:revision(content)};
52
61
  }
53
62
  export function applyTemplateOverride(name,base,config,version) {
63
+ // Markdown document overrides apply only to the three built-in types.
64
+ if (!['doc','plan','prompt'].includes(name)) return base;
54
65
  const source = readTemplateStore(config).data.templates[name]?.source;
55
66
  if (source === undefined) return base;
56
67
  const parts = validateTemplate(name,source);
package/src/update.mjs CHANGED
@@ -8,6 +8,8 @@ import { executableName, which } from './util.mjs';
8
8
  import { CLAUDE_MARKETPLACE, claudeMarketplaceRefusalHint, opencodeStatus } from './host-integration.mjs';
9
9
  import { codexStatus, installedCodexStatus } from './codex-integration.mjs';
10
10
  import { desktopLaunch } from './desktop.mjs';
11
+ import { installationReport, formatInstallationReport, readInstalledPluginRecords } from './installation.mjs';
12
+ export { readInstalledPluginRecords } from './installation.mjs';
11
13
  import { compareDesktopVersions, installedDesktopVersion } from './desktop-install.mjs';
12
14
 
13
15
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -32,30 +34,6 @@ export function compareVersions(a, b) {
32
34
  return 0;
33
35
  }
34
36
 
35
- // Read Claude Code's plugin install record to find the installed runlist plugin's
36
- // id + version. Network-free. `opts.home` is injectable for tests. Returns
37
- // { id, version } or null when nothing is installed / the file is absent.
38
- export function readInstalledPluginRecords(opts = {}) {
39
- const home = opts.home || os.homedir();
40
- const file = path.join(home, '.claude', 'plugins', 'installed_plugins.json');
41
- try {
42
- const j = JSON.parse(readFileSync(file, 'utf8'));
43
- const plugins = j.plugins || {};
44
- const id = opts.id
45
- ? (plugins[opts.id] ? opts.id : null)
46
- : plugins[DEFAULT_PLUGIN_ID]
47
- ? DEFAULT_PLUGIN_ID
48
- : plugins[LEGACY_PLUGIN_ID]
49
- ? LEGACY_PLUGIN_ID
50
- : Object.keys(plugins).find(k => /^(?:runlist|dotmd)@/.test(k));
51
- if (!id) return null;
52
- const entries = Array.isArray(plugins[id]) ? plugins[id] : [plugins[id]];
53
- return { id, entries: entries.filter(Boolean) };
54
- } catch {
55
- return null;
56
- }
57
- }
58
-
59
37
  // Claude Code's marketplace registry, keyed by marketplace name. A plugin id
60
38
  // is `<plugin>@<marketplace>`, and the plugin only loads while its marketplace
61
39
  // is registered here — an install record alone is not an installed plugin.
@@ -251,6 +229,7 @@ export async function runUpdate(argv, _config, opts = {}) {
251
229
  const desktop = process.platform === 'darwin' && process.arch === 'arm64' ? desktopLaunch([]).app : null;
252
230
 
253
231
  if (check) {
232
+ process.stdout.write(formatInstallationReport(installationReport(_config)));
254
233
  process.stdout.write(`runlist CLI: ${pkg.version}\n`);
255
234
  if (plugin) {
256
235
  if (plugin.id === LEGACY_PLUGIN_ID || (legacyPlugin && plugin.id !== LEGACY_PLUGIN_ID)) {
@@ -258,7 +237,7 @@ export async function runUpdate(argv, _config, opts = {}) {
258
237
  }
259
238
  for (const entry of plugin.entries) {
260
239
  const cmp = compareVersions(entry.version, pkg.version);
261
- const tag = cmp === 0 ? green('in sync')
240
+ const tag = cmp === 0 ? green('version matches')
262
241
  : cmp === null ? dim('(unknown)')
263
242
  : cmp < 0 ? yellow('behind — run `runlist update`')
264
243
  : yellow('ahead — CLI is behind');
@@ -273,14 +252,14 @@ export async function runUpdate(argv, _config, opts = {}) {
273
252
  const oc = opencodeStatus({ version: pkg.version });
274
253
  if (!oc.exists) process.stdout.write(dim('runlist opencode: not installed\n'));
275
254
  else if (oc.foreign) process.stdout.write(`runlist opencode: ${yellow('unmanaged file — not written by runlist')}\n`);
276
- else process.stdout.write(`runlist opencode: ${oc.version} ${oc.stale ? yellow('behind — run `runlist update`') : green('in sync')}\n`);
255
+ else process.stdout.write(`runlist opencode: ${oc.version} ${oc.stale ? yellow('behind — run `runlist update`') : green('version matches')}\n`);
277
256
  const cx = currentCodexStatus(pkg.version);
278
257
  if (!cx.exists) process.stdout.write(dim('runlist codex: not installed\n'));
279
258
  else if (cx.foreign) process.stdout.write(`runlist codex: ${yellow('unmanaged plugin directory')}\n`);
280
- else process.stdout.write(`runlist codex: source ${cx.version}; installed ${cx.installed?.version ?? 'unverified'} ${cx.stale ? yellow('out of sync — run `runlist update`') : green('in sync')}\n`);
259
+ else process.stdout.write(`runlist codex: source ${cx.version}; installed ${cx.installed?.version ?? 'unverified'} ${cx.stale ? yellow('out of sync — run `runlist update`') : green('version matches')}\n`);
281
260
  if (desktop) {
282
261
  const version = await installedDesktopVersion(desktop);
283
- process.stdout.write(`runlist desktop: ${version} ${compareDesktopVersions(version, pkg.version) < 0 ? yellow('behind — quit Runlist and run `runlist update`') : green('in sync')}\n`);
262
+ process.stdout.write(`runlist desktop: ${version} ${compareDesktopVersions(version, pkg.version) < 0 ? yellow('behind — quit Runlist and run `runlist update`') : dim('version current; see source comparison above')}\n`);
284
263
  }
285
264
  return;
286
265
  }