runlist 0.96.0 → 0.97.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 (41) hide show
  1. package/README.md +170 -43
  2. package/assets/app/app.css +189 -3
  3. package/assets/app/app.mjs +49 -31
  4. package/assets/app/document-create.mjs +3 -2
  5. package/assets/app/document-lifecycle.mjs +17 -8
  6. package/assets/app/document-stages.mjs +3 -1
  7. package/assets/app/document-views.mjs +23 -1
  8. package/assets/app/editor-navigation.mjs +16 -23
  9. package/assets/app/filing-navigation.mjs +18 -16
  10. package/assets/app/git-changes.mjs +11 -2
  11. package/assets/app/index.html +34 -23
  12. package/assets/app/library-navigation.mjs +29 -16
  13. package/assets/app/record-navigation.mjs +68 -23
  14. package/assets/app/recovery-center.mjs +8 -7
  15. package/assets/app/settings.mjs +48 -10
  16. package/assets/app/template-editor.mjs +22 -12
  17. package/assets/app/transport.mjs +1 -2
  18. package/bin/runlist.mjs +64 -15
  19. package/package.json +1 -1
  20. package/plugins/runlist-codex/.codex-plugin/plugin.json +1 -1
  21. package/plugins/runlist-codex/skills/runlist/SKILL.md +3 -1
  22. package/src/app.mjs +9 -4
  23. package/src/baton.mjs +49 -11
  24. package/src/brief.mjs +145 -0
  25. package/src/commands.mjs +4 -3
  26. package/src/config.mjs +16 -26
  27. package/src/doctor.mjs +15 -0
  28. package/src/extractors.mjs +7 -14
  29. package/src/flags.mjs +5 -3
  30. package/src/hud.mjs +32 -63
  31. package/src/index.mjs +2 -0
  32. package/src/install.mjs +6 -4
  33. package/src/installation.mjs +117 -0
  34. package/src/new.mjs +27 -7
  35. package/src/prompt-handoff.mjs +132 -0
  36. package/src/prompts.mjs +112 -16
  37. package/src/section.mjs +46 -11
  38. package/src/show.mjs +132 -16
  39. package/src/template-store.mjs +16 -5
  40. package/src/update.mjs +7 -28
  41. package/src/util.mjs +7 -0
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
 
package/src/section.mjs CHANGED
@@ -3,26 +3,32 @@
3
3
  // and body-relative line numbers (1-indexed). Callers with stripped frontmatter
4
4
  // add extractFrontmatter().bodyLineOffset before exposing file coordinates.
5
5
 
6
- export function walkSections(body) {
7
- const lines = body.split('\n');
6
+ // Calls visit(line, index) for every line outside a fenced code block. A fence
7
+ // closes only on the character that opened it, so ``` inside ~~~ stays code.
8
+ function eachUnfencedLine(lines, visit) {
8
9
  const fenceRe = /^(`{3,}|~{3,})/;
9
- const headingRe = /^(#{1,6})\s+(.+?)\s*$/;
10
- const sections = [];
11
10
  let fenceChar = null;
12
- let h2Ancestor = null;
13
-
14
11
  for (let i = 0; i < lines.length; i++) {
15
- const line = lines[i];
16
- const fence = line.match(fenceRe);
12
+ const fence = lines[i].match(fenceRe);
17
13
  if (fence) {
18
14
  const tok = fence[1][0]; // ` or ~
19
15
  if (fenceChar === null) fenceChar = tok;
20
16
  else if (fenceChar === tok) fenceChar = null;
21
17
  continue;
22
18
  }
23
- if (fenceChar !== null) continue;
19
+ if (fenceChar === null) visit(lines[i], i);
20
+ }
21
+ }
22
+
23
+ export function walkSections(body) {
24
+ const lines = body.split('\n');
25
+ const headingRe = /^(#{1,6})\s+(.+?)\s*$/;
26
+ const sections = [];
27
+ let h2Ancestor = null;
28
+
29
+ eachUnfencedLine(lines, (line, i) => {
24
30
  const h = line.match(headingRe);
25
- if (!h) continue;
31
+ if (!h) return;
26
32
  const level = h[1].length;
27
33
  const heading = h[2];
28
34
  if (level === 1) h2Ancestor = null;
@@ -35,7 +41,7 @@ export function walkSections(body) {
35
41
  bodyLineStart: i + 2,
36
42
  });
37
43
  if (level === 2) h2Ancestor = heading;
38
- }
44
+ });
39
45
 
40
46
  for (let i = 0; i < sections.length; i++) {
41
47
  const next = sections.find((s, j) => j > i && s.level <= sections[i].level);
@@ -57,6 +63,35 @@ export function findSection(sections, name) {
57
63
  return sections.find(s => norm(s.heading) === target) ?? null;
58
64
  }
59
65
 
66
+ // Every section whose heading matches `query`, best first: exact, then prefix,
67
+ // then substring, document order within each. Case-insensitive; leading `#`s
68
+ // and trailing markers are ignored on both sides, as in findSection.
69
+ export function matchSections(sections, query) {
70
+ const norm = (s) => s.toLowerCase().replace(/^#+\s*/, '').replace(/[^\w\s]+$/, '').replace(/\s+/g, ' ').trim();
71
+ const q = norm(query);
72
+ if (!q) return [];
73
+ const tiers = [[], [], []];
74
+ for (const s of sections) {
75
+ const h = norm(s.heading);
76
+ if (h === q) tiers[0].push(s);
77
+ else if (h.startsWith(q)) tiers[1].push(s);
78
+ else if (h.includes(q)) tiers[2].push(s);
79
+ }
80
+ return tiers.flat();
81
+ }
82
+
83
+ // Checklist boxes outside code fences, with body-relative 1-indexed lines.
84
+ // `[ ]` and `[~]` are open, as in phaseTally and the index's checklist counts.
85
+ const BOX_RE = /^\s*[-*]\s+\[([ xX~])\](?:\s+(.*?))?\s*$/;
86
+ export function walkChecklist(body) {
87
+ const boxes = [];
88
+ eachUnfencedLine(body.split('\n'), (line, i) => {
89
+ const m = line.match(BOX_RE);
90
+ if (m) boxes.push({ line: i + 1, open: m[1].toLowerCase() !== 'x', text: m[2] ?? '' });
91
+ });
92
+ return boxes;
93
+ }
94
+
60
95
  // Status marker detection for phase headings. Returns one of:
61
96
  // 'shipped' | 'skipped' | 'in-progress' | 'blocked' | 'todo' | null
62
97
  // Glyphs are checked BEFORE prose, not interleaved with it. A glyph is a
package/src/show.mjs CHANGED
@@ -1,13 +1,16 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
3
+ import { extractFrontmatter, normalizeEol, parseSimpleFrontmatter } from './frontmatter.mjs';
4
+ import { buildBrief, collapseFrontmatter, cut, LIMITS, readSection, SURVEY_LIMITS } from './brief.mjs';
4
5
  import { parseDocFile, resolveDocArg } from './index.mjs';
5
6
  import { resolveBodyLinkTarget } from './body-link.mjs';
6
- import { die, normalizeStringList, resolveRefPath, toRepoPath } from './util.mjs';
7
+ import { die, normalizeStringList, resolveRefPath, shellQuote, toRepoPath } from './util.mjs';
7
8
 
8
9
  // `runlist show <file...>` — one document's card as data: its title, status,
9
10
  // next step, blockers and checklist, the plans and docs its frontmatter names,
10
- // and the documents its body links to. Read-only; nothing is claimed.
11
+ // and the documents its body links to, followed by its brief (outline and open
12
+ // boxes with line numbers; src/brief.mjs). `--section` reads one section in
13
+ // place of the whole file. Read-only; nothing is claimed.
11
14
 
12
15
  // Frontmatter lists that name other documents. The configured reference fields
13
16
  // are read too; `related_docs` is read whether or not a repo configures it.
@@ -76,26 +79,139 @@ export function showDoc(abs, config) {
76
79
  };
77
80
  }
78
81
 
82
+ // Fields the text card prints. Everything else non-empty is named on one line
83
+ // so a reader knows it is there without printing the frontmatter.
84
+ const CARD_FIELDS = new Set(['title', 'status', 'next_step', 'blockers']);
85
+
86
+ function otherFields(abs, config) {
87
+ const raw = readFileSync(abs, 'utf8');
88
+ const fm = parseSimpleFrontmatter(extractFrontmatter(raw).frontmatter ?? '', []);
89
+ const shown = new Set([...CARD_FIELDS, ...relatedFields(config)]);
90
+ return Object.keys(fm).filter(k => !shown.has(k) && normalizeStringList(fm[k]).some(v => String(v).trim()));
91
+ }
92
+
93
+ function parseShowArgs(args) {
94
+ const opts = { json: false, card: false, all: false, frontmatter: false, section: null, from: null, targets: [] };
95
+ for (let i = 0; i < args.length; i++) {
96
+ const a = args[i];
97
+ if (a === '--json') opts.json = true;
98
+ else if (a === '--card') opts.card = true;
99
+ else if (a === '--all') opts.all = true;
100
+ else if (a === '--frontmatter') opts.frontmatter = true;
101
+ else if (a === '--section' || a === '-s') opts.section = args[++i] ?? die('--section needs a heading');
102
+ else if (a === '--from') opts.from = args[++i];
103
+ else if (a.startsWith('-')) die(`Unknown option for show: ${a}`);
104
+ else opts.targets.push(a);
105
+ }
106
+ if (opts.from != null) {
107
+ if (opts.section == null) die('--from continues a --section read; name the section too');
108
+ const n = Number(opts.from);
109
+ if (!Number.isInteger(n) || n < 1) die(`--from takes a line number, got: ${opts.from}`);
110
+ opts.from = n;
111
+ }
112
+ return opts;
113
+ }
114
+
115
+ const pad = (n, width) => String(n).padStart(width);
116
+
117
+ function runSection(target, opts, config) {
118
+ const abs = resolveDocArg(target, config);
119
+ const rel = toRepoPath(abs, config.repoRoot);
120
+ const r = readSection(readFileSync(abs, 'utf8'), opts.section, { from: opts.from });
121
+ const again = from => `runlist show ${shellQuote(rel)} --section ${shellQuote(opts.section)} --from ${from}`;
122
+ if (opts.json) {
123
+ const { headings, ...rest } = r;
124
+ const out = r.found
125
+ ? { path: rel, ...rest, ...(r.next ? { continue: again(r.next) } : {}) }
126
+ : { path: rel, error: 'no heading matches', query: r.query, headings: headings.map(({ line, level, heading }) => ({ line, level, heading })) };
127
+ process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
128
+ if (!r.found || r.past) process.exitCode = 1;
129
+ return;
130
+ }
131
+ if (!r.found) {
132
+ const list = r.headings.slice(0, 20).map(h => ` ${h.line} ${cut(`${'#'.repeat(h.level)} ${h.heading}`)}`);
133
+ if (r.headings.length > 20) list.push(` (+${r.headings.length - 20} more)`);
134
+ process.stderr.write([`No heading in ${rel} matches "${opts.section}". Headings:`, ...list].join('\n') + '\n');
135
+ process.exitCode = 1;
136
+ return;
137
+ }
138
+ const h = r.heading;
139
+ if (r.past) {
140
+ process.stderr.write(`${rel}: "${h.heading}" runs ${h.line} to ${h.end}; --from ${r.from} is past its end.\n`);
141
+ process.exitCode = 1;
142
+ return;
143
+ }
144
+ const width = String(r.to).length;
145
+ const span = r.from === h.line && !r.next ? `${h.line} to ${h.end}` : `${r.from} to ${r.to} of ${h.line} to ${h.end}`;
146
+ const out = [`${rel} lines ${span} (${'#'.repeat(h.level)} ${h.heading}):`];
147
+ for (const row of r.rows) out.push(`${pad(row.line, width)}\t${row.text}`);
148
+ if (r.next) out.push(`(cut at line ${r.to}; the section runs to ${h.end}. Next: ${again(r.next)})`);
149
+ if (r.others.length) out.push(`Also matching: ${r.others.map(o => `${o.line} ${cut(o.heading, 60)}`).join('; ')}`);
150
+ process.stdout.write(out.join('\n') + '\n');
151
+ }
152
+
153
+ function renderBrief(card, brief) {
154
+ const out = [];
155
+ const width = String(brief.lines).length;
156
+ const kb = Math.max(1, Math.round(brief.bytes / 1024));
157
+ out.push('', `Outline: ${brief.lines.toLocaleString('en-US')} lines, ${kb} KB (line, heading)`);
158
+ if (!brief.outline.length) out.push(' (no headings)');
159
+ for (const h of brief.outline) out.push(` ${pad(h.line, width)} ${cut(`${'#'.repeat(h.level)} ${h.heading}`)}`);
160
+ if (brief.outlineOmitted) out.push(` (+${brief.outlineOmitted} deeper headings not shown; a --section read shows them)`);
161
+ const total = brief.boxes.open + brief.boxes.done;
162
+ if (total) {
163
+ out.push(`Open boxes: ${brief.boxes.open} of ${total} (line, text)`);
164
+ for (const b of brief.open) out.push(` ${pad(b.line, width)} ${cut(b.text)}`);
165
+ if (brief.openOmitted) out.push(` (+${brief.openOmitted} more: add --all)`);
166
+ }
167
+ if (brief.frontmatter) {
168
+ out.push(`Frontmatter (lines 1 to ${brief.frontmatterLines}):`, ...brief.frontmatter.map(l => ` ${l}`));
169
+ } else if (card.otherFields?.length) {
170
+ out.push(`Other fields: ${card.otherFields.join(', ')} (--frontmatter prints them)`);
171
+ }
172
+ return out;
173
+ }
174
+
79
175
  export function runShow(args, config) {
80
- const json = args.includes('--json');
81
- const targets = args.filter(a => !a.startsWith('-'));
82
- if (!targets.length) die('Usage: runlist show <file...> [--json]');
176
+ const opts = parseShowArgs(args);
177
+ if (!opts.targets.length) die('Usage: runlist show <file...> [--section "<heading>" [--from <line>]] [--all] [--card] [--frontmatter] [--json]');
178
+ if (opts.section != null) {
179
+ if (opts.targets.length > 1) die('--section reads one file; name one');
180
+ runSection(opts.targets[0], opts, config);
181
+ return;
182
+ }
183
+ const limits = opts.targets.length > 1 ? SURVEY_LIMITS : LIMITS;
83
184
  const cards = [];
84
- for (const t of targets) {
85
- const abs = resolveDocArg(t, config, { dieOnMiss: !json });
86
- cards.push(abs ? showDoc(abs, config) : { path: t, error: 'not found' });
185
+ for (const t of opts.targets) {
186
+ const abs = resolveDocArg(t, config, { dieOnMiss: !opts.json });
187
+ if (!abs) { cards.push({ path: t, error: 'not found' }); continue; }
188
+ const card = showDoc(abs, config);
189
+ if (!opts.card) {
190
+ const raw = readFileSync(abs, 'utf8');
191
+ card.brief = buildBrief(raw, { all: opts.all, limits });
192
+ if (opts.frontmatter) card.brief.frontmatter = collapseFrontmatter(extractFrontmatter(normalizeEol(raw)).frontmatter, limits);
193
+ card.otherFields = otherFields(abs, config);
194
+ }
195
+ cards.push(card);
87
196
  }
88
- if (json) {
197
+ if (opts.json) {
89
198
  process.stdout.write(`${JSON.stringify(cards, null, 2)}\n`);
90
199
  return;
91
200
  }
92
201
  cards.forEach((c, n) => {
93
202
  if (n) process.stdout.write('\n');
94
- process.stdout.write(`${c.title} (${c.status ?? 'no status'})\n${c.path}\n`);
95
- if (c.nextStep) process.stdout.write(`Next: ${c.nextStep}\n`);
96
- for (const b of c.blockers) process.stdout.write(`Blocker: ${b}\n`);
97
- if (c.checklist?.total) process.stdout.write(`Checklist: ${c.checklist.completed} of ${c.checklist.total} done\n`);
98
- for (const r of c.related) process.stdout.write(`${r.field}: ${r.path ?? `${r.ref} (missing)`}${r.status ? ` (${r.status})` : ''}\n`);
99
- if (c.links.length) process.stdout.write(`Linked from the body: ${c.links.map(l => l.path).join(', ')}\n`);
203
+ const out = [`${c.title} (${c.status ?? 'no status'})`, c.path];
204
+ if (c.nextStep) out.push(`Next: ${c.nextStep}`);
205
+ for (const b of c.blockers) out.push(`Blocker: ${b}`);
206
+ // The brief's open-box line carries the same count, read outside code fences.
207
+ if (c.checklist?.total && !c.brief) out.push(`Checklist: ${c.checklist.completed} of ${c.checklist.total} done`);
208
+ for (const r of c.related) out.push(`${r.field}: ${r.path ?? `${r.ref} (missing)`}${r.status ? ` (${r.status})` : ''}`);
209
+ if (c.links.length) out.push(`Linked from the body: ${c.links.map(l => l.path).join(', ')}`);
210
+ if (c.brief) out.push(...renderBrief(c, c.brief));
211
+ process.stdout.write(out.join('\n') + '\n');
100
212
  });
213
+ if (!opts.card) {
214
+ const file = cards.length === 1 ? shellQuote(cards[0].path) : '<file>';
215
+ process.stdout.write(`\nOne section: runlist show ${file} --section "<heading>"\n`);
216
+ }
101
217
  }
@@ -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
  }
package/src/util.mjs CHANGED
@@ -306,6 +306,13 @@ export function suggestCandidates(query, candidates, max = 3) {
306
306
  .map(([cand]) => cand);
307
307
  }
308
308
 
309
+ // One argument as it must be typed into a POSIX shell: bare when it is plainly
310
+ // safe, otherwise double-quoted with `"`, `\`, `$` and backtick escaped.
311
+ export function shellQuote(value) {
312
+ const raw = String(value);
313
+ return /^[A-Za-z0-9_./:@+-]+$/.test(raw) ? raw : `"${raw.replace(/["\\$`]/g, '\\$&')}"`;
314
+ }
315
+
309
316
  export function resolveDocPath(input, config) {
310
317
  if (!input) return null;
311
318
  if (path.isAbsolute(input)) return existsSync(input) ? input : null;