runlist 0.0.0 → 0.94.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.
Files changed (166) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1114 -6
  3. package/assets/app/app.css +283 -0
  4. package/assets/app/app.mjs +381 -0
  5. package/assets/app/block-editor.mjs +182 -0
  6. package/assets/app/block-model.mjs +52 -0
  7. package/assets/app/document-create.mjs +86 -0
  8. package/assets/app/document-lifecycle.mjs +36 -0
  9. package/assets/app/document-stages.mjs +22 -0
  10. package/assets/app/document-views.mjs +33 -0
  11. package/assets/app/document-yardstick.mjs +258 -0
  12. package/assets/app/editor-navigation.mjs +51 -0
  13. package/assets/app/filing-navigation.mjs +138 -0
  14. package/assets/app/git-changes.mjs +80 -0
  15. package/assets/app/git-commit.mjs +46 -0
  16. package/assets/app/index.html +70 -0
  17. package/assets/app/library-navigation.mjs +93 -0
  18. package/assets/app/outline.mjs +18 -0
  19. package/assets/app/quick-navigation.mjs +52 -0
  20. package/assets/app/record-navigation.mjs +78 -0
  21. package/assets/app/recovery-center.mjs +53 -0
  22. package/assets/app/semantic-search.mjs +14 -0
  23. package/assets/app/settings.mjs +15 -0
  24. package/assets/app/shared.mjs +104 -0
  25. package/assets/app/stage-source.mjs +75 -0
  26. package/assets/app/template-editor.mjs +47 -0
  27. package/assets/app/theme.mjs +25 -0
  28. package/assets/app/transport.mjs +43 -0
  29. package/assets/contracts/source-editing-v1.md +232 -0
  30. package/assets/opencode/plugin.js +162 -0
  31. package/assets/schemas/runlist-record-v1.schema.json +912 -0
  32. package/bin/dotmd.mjs +5 -0
  33. package/bin/runlist.mjs +2714 -0
  34. package/package.json +53 -6
  35. package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
  36. package/plugins/runlist-codex/bin/runlist-hook +86 -0
  37. package/plugins/runlist-codex/hooks/hooks.json +16 -0
  38. package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
  39. package/runlist.config.example.mjs +370 -0
  40. package/scripts/postinstall.mjs +56 -0
  41. package/src/agent-context.mjs +201 -0
  42. package/src/ai.mjs +30 -0
  43. package/src/app-create.mjs +169 -0
  44. package/src/app-filing.mjs +139 -0
  45. package/src/app-git-commits.mjs +48 -0
  46. package/src/app-git.mjs +180 -0
  47. package/src/app-library.mjs +152 -0
  48. package/src/app-lifecycle-worker.mjs +12 -0
  49. package/src/app-lifecycle.mjs +79 -0
  50. package/src/app-records.mjs +134 -0
  51. package/src/app-search.mjs +80 -0
  52. package/src/app-semantic-search.mjs +105 -0
  53. package/src/app-service.mjs +208 -0
  54. package/src/app-yardstick.mjs +269 -0
  55. package/src/app.mjs +102 -0
  56. package/src/atomic-mutation.mjs +1736 -0
  57. package/src/baton.mjs +435 -0
  58. package/src/body-link.mjs +48 -0
  59. package/src/bulk-tag.mjs +174 -0
  60. package/src/check-collapse.mjs +75 -0
  61. package/src/claude-commands.mjs +75 -0
  62. package/src/cli-editor.mjs +8 -0
  63. package/src/code-refs.mjs +545 -0
  64. package/src/codex-integration.mjs +81 -0
  65. package/src/color.mjs +14 -0
  66. package/src/commands.mjs +374 -0
  67. package/src/completions.mjs +93 -0
  68. package/src/config-edit.mjs +606 -0
  69. package/src/config.mjs +705 -0
  70. package/src/decision-action.mjs +58 -0
  71. package/src/decision.mjs +220 -0
  72. package/src/decisions.mjs +787 -0
  73. package/src/deps.mjs +317 -0
  74. package/src/desktop-install.mjs +158 -0
  75. package/src/desktop-runtime.mjs +4 -0
  76. package/src/desktop.mjs +25 -0
  77. package/src/diff.mjs +101 -0
  78. package/src/doctor.mjs +591 -0
  79. package/src/durable-rename.mjs +55 -0
  80. package/src/errors-read.mjs +44 -0
  81. package/src/export.mjs +496 -0
  82. package/src/extractors.mjs +101 -0
  83. package/src/filing.mjs +255 -0
  84. package/src/fix-membership.mjs +134 -0
  85. package/src/fix-refs.mjs +174 -0
  86. package/src/flags.mjs +318 -0
  87. package/src/frontmatter-fix.mjs +195 -0
  88. package/src/frontmatter.mjs +252 -0
  89. package/src/git-commit-filters.mjs +32 -0
  90. package/src/git-commit-protocol.mjs +198 -0
  91. package/src/git-commit-signing.mjs +75 -0
  92. package/src/git-commit-store.mjs +26 -0
  93. package/src/git-commit-support.mjs +16 -0
  94. package/src/git-commit-worker.mjs +32 -0
  95. package/src/git.mjs +909 -0
  96. package/src/glossary-check.mjs +35 -0
  97. package/src/glossary.mjs +243 -0
  98. package/src/graph.mjs +298 -0
  99. package/src/guard.mjs +488 -0
  100. package/src/health.mjs +212 -0
  101. package/src/hints.mjs +129 -0
  102. package/src/host-integration.mjs +339 -0
  103. package/src/hub-membership.mjs +284 -0
  104. package/src/hub.mjs +292 -0
  105. package/src/hud.mjs +436 -0
  106. package/src/index-file.mjs +163 -0
  107. package/src/index.mjs +478 -0
  108. package/src/init.mjs +512 -0
  109. package/src/install.mjs +222 -0
  110. package/src/journal-read.mjs +116 -0
  111. package/src/journal.mjs +472 -0
  112. package/src/lifecycle.mjs +1444 -0
  113. package/src/lint.mjs +386 -0
  114. package/src/managed-path.mjs +192 -0
  115. package/src/markdown-code-spans.mjs +58 -0
  116. package/src/migrate-prompts.mjs +171 -0
  117. package/src/migrate-template.mjs +170 -0
  118. package/src/migrate.mjs +90 -0
  119. package/src/misuse-read.mjs +61 -0
  120. package/src/model-request.mjs +33 -0
  121. package/src/model.mjs +523 -0
  122. package/src/modules.mjs +355 -0
  123. package/src/naming.mjs +55 -0
  124. package/src/native-action.mjs +59 -0
  125. package/src/native-record.mjs +244 -0
  126. package/src/new.mjs +1321 -0
  127. package/src/output-identity.mjs +111 -0
  128. package/src/parent-plan.mjs +58 -0
  129. package/src/parse-cache.mjs +99 -0
  130. package/src/path-containment.mjs +15 -0
  131. package/src/pickup-card.mjs +293 -0
  132. package/src/pickup.mjs +671 -0
  133. package/src/prompt.mjs +25 -0
  134. package/src/prompts.mjs +585 -0
  135. package/src/query.mjs +977 -0
  136. package/src/record-action.mjs +11 -0
  137. package/src/record-create.mjs +82 -0
  138. package/src/reference-planner.mjs +388 -0
  139. package/src/reference-values.mjs +11 -0
  140. package/src/rename.mjs +107 -0
  141. package/src/render.mjs +678 -0
  142. package/src/roadmap.mjs +184 -0
  143. package/src/runlist.mjs +944 -0
  144. package/src/section.mjs +225 -0
  145. package/src/semantic-search-client.mjs +58 -0
  146. package/src/ship.mjs +166 -0
  147. package/src/show.mjs +101 -0
  148. package/src/skill-drift.mjs +98 -0
  149. package/src/source-editor.mjs +491 -0
  150. package/src/stages.mjs +73 -0
  151. package/src/state-migration.mjs +123 -0
  152. package/src/stats.mjs +200 -0
  153. package/src/status-metadata.mjs +87 -0
  154. package/src/statuses.mjs +721 -0
  155. package/src/summary.mjs +74 -0
  156. package/src/surfaces.mjs +28 -0
  157. package/src/sync-status.mjs +316 -0
  158. package/src/template-store.mjs +62 -0
  159. package/src/update.mjs +338 -0
  160. package/src/use.mjs +58 -0
  161. package/src/util.mjs +337 -0
  162. package/src/validate.mjs +794 -0
  163. package/src/watch.mjs +56 -0
  164. package/src/xref.mjs +406 -0
  165. package/src/yardstick-command.mjs +178 -0
  166. package/src/yardstick.mjs +112 -0
package/src/prompt.mjs ADDED
@@ -0,0 +1,25 @@
1
+ import { createInterface } from 'node:readline';
2
+
3
+ export function isInteractive() {
4
+ return Boolean(process.stdin.isTTY);
5
+ }
6
+
7
+ export async function promptText(question) {
8
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
9
+ return new Promise(resolve => {
10
+ rl.question(question, answer => {
11
+ rl.close();
12
+ resolve(answer.trim());
13
+ });
14
+ });
15
+ }
16
+
17
+ export async function promptChoice(question, options) {
18
+ process.stderr.write(question + '\n');
19
+ options.forEach((opt, i) => process.stderr.write(` ${i + 1}) ${opt}\n`));
20
+ const answer = await promptText('> ');
21
+ const idx = parseInt(answer, 10) - 1;
22
+ if (idx >= 0 && idx < options.length) return options[idx];
23
+ const match = options.find(o => o.toLowerCase() === answer.toLowerCase());
24
+ return match ?? null;
25
+ }
@@ -0,0 +1,585 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
+ import { asString, toRepoPath, die, resolveDocPath, resolveRefPath, isArchivedPath } from './util.mjs';
5
+ import { buildIndex, resolveDocArg } from './index.mjs';
6
+ import { runQuery } from './query.mjs';
7
+ import { completePlanClaim, regenIndex, renderLifecycleMutation, runArchive } from './lifecycle.mjs';
8
+ import { runNew } from './new.mjs';
9
+ import { green, dim, yellow } from './color.mjs';
10
+ import { authorizeManagedSource } from './managed-path.mjs';
11
+ import {
12
+ authoritativeSessionId,
13
+ classifyPlanPickup,
14
+ ownershipLiveness,
15
+ preparePlanClaim,
16
+ readPlanOwnership,
17
+ } from './pickup.mjs';
18
+ import { actionablePromptStatuses, comparePromptDocs } from './status-metadata.mjs';
19
+ import { LEGACY_STATE_DIR, STATE_DIR } from './naming.mjs';
20
+
21
+ // `resume` is an alias for `use` — agents reach for "resume" when continuing a
22
+ // session; `use` reads as internal mechanics. Both names stay valid; the
23
+ // canonical output ("Consumed: …") is unchanged.
24
+ const SUBCOMMANDS = new Set(['list', 'next', 'use', 'resume', 'show', 'peek', 'archive', 'new']);
25
+
26
+ // Removed 2026-08-30 with the `held`/`shelved` statuses. Named rather than left
27
+ // to fall through to the list filter below, which would silently answer a
28
+ // removed command with an empty list — the caller needs to be told the state is
29
+ // gone and what replaced it.
30
+ const REMOVED_SUBCOMMANDS = new Set(['hold', 'unhold', 'shelve', 'unshelve']);
31
+
32
+ export async function runPrompts(argv, config, opts = {}) {
33
+ const sub = argv[0];
34
+
35
+ if (sub && REMOVED_SUBCOMMANDS.has(sub)) {
36
+ die(`\`runlist prompts ${sub}\` was removed — a prompt is pending or archived, and nothing else.\n`
37
+ + 'A prompt directs work, so parking one instead of archiving it leaves work state outside the\n'
38
+ + 'plan that owns it. Lift its content into that plan, then `runlist prompts archive <file>`.');
39
+ }
40
+
41
+ if (!sub || !SUBCOMMANDS.has(sub)) {
42
+ return runPromptsList(argv, config, opts);
43
+ }
44
+
45
+ const rest = argv.slice(1);
46
+ switch (sub) {
47
+ case 'list': return runPromptsList(rest, config, opts);
48
+ case 'next': return runPromptsNext(rest, config, opts);
49
+ case 'use': return runPromptsUse(rest, config, opts);
50
+ case 'resume': return runPromptsUse(rest, config, opts);
51
+ case 'show': return runPromptsShow(rest, config);
52
+ case 'peek': return runPromptsShow(rest, config);
53
+ case 'archive': return runPromptsArchive(rest, config, opts);
54
+ case 'new': return runPromptsNew(rest, config, opts);
55
+ }
56
+ }
57
+
58
+ function runPromptsList(argv, config, opts = {}) {
59
+ // Listing prompts renders slug / status / updated / target — nothing any
60
+ // validating pass produces, and `runQuery` never reads warnings either. A
61
+ // full `buildIndex` here parsed every body in the repo through the ref,
62
+ // git-staleness and hub passes just to print a small prompt list,
63
+ // and then `pendingPromptsOldestFirst` scanned the tree a second time. One
64
+ // fast index, shared, on the same terms hud reads on.
65
+ const index = buildIndex(config, { fast: true, invokeHooks: false });
66
+ const hasStatusFlag = argv.includes('--status');
67
+ const includeArchived = argv.includes('--include-archived');
68
+ const sub = argv[0];
69
+ const json = argv.includes('--json');
70
+
71
+ if (opts.verbose && !json) {
72
+ renderPromptsVerbose(index, config, { hasStatusFlag, includeArchived });
73
+ return;
74
+ }
75
+
76
+ const hasPositionalFilter = argv.some(a => !a.startsWith('-') && a !== 'list');
77
+ if (!json && !hasStatusFlag && !includeArchived && !hasPositionalFilter && sub !== 'status' && !argv.some(a => a.startsWith('--sort') || a.startsWith('--limit') || a === '--all')) {
78
+ renderPromptQueueList(index, config);
79
+ return;
80
+ }
81
+
82
+ let defaults;
83
+ let extras = argv;
84
+ if (sub === 'status') {
85
+ defaults = ['--type', 'prompt', '--exclude-archived', '--sort', 'status', '--all'];
86
+ extras = argv.slice(1);
87
+ } else if (hasStatusFlag || includeArchived) {
88
+ defaults = ['--type', 'prompt', '--sort', 'updated', '--limit', '10'];
89
+ } else {
90
+ defaults = ['--type', 'prompt', '--exclude-archived', '--sort', 'updated', '--limit', '10'];
91
+ }
92
+ runQuery(index, [...defaults, ...extras], config, { preset: 'prompts' });
93
+ }
94
+
95
+ function renderPromptQueueList(index, config) {
96
+ const queue = pendingPromptsOldestFirst(config, index);
97
+ const queuedPaths = new Set(queue.map(q => q.doc.path));
98
+ const others = index.docs
99
+ .filter(d => d.type === 'prompt' && !queuedPaths.has(d.path) && !isArchivedPath(d.path, config) && d.status !== 'archived')
100
+ .sort((a, b) => (b.updated ?? '').localeCompare(a.updated ?? '') || (a.title ?? a.path).localeCompare(b.title ?? b.path));
101
+ const prompts = [...queue.map(q => q.doc), ...others];
102
+
103
+ if (prompts.length === 0) {
104
+ process.stdout.write('No prompts.\n');
105
+ return;
106
+ }
107
+
108
+ const counts = {};
109
+ for (const p of prompts) counts[p.status ?? 'unknown'] = (counts[p.status ?? 'unknown'] ?? 0) + 1;
110
+ const summary = Object.entries(counts).map(([s, n]) => `${n} ${s}`).join(' · ');
111
+ process.stdout.write(dim(`${prompts.length} prompts · ${summary}`) + '\n\n');
112
+
113
+ const maxSlug = Math.min(36, Math.max(...prompts.map(p => path.basename(p.path, '.md').length)));
114
+ for (let i = 0; i < prompts.length; i++) {
115
+ const p = prompts[i];
116
+ const slug = path.basename(p.path, '.md').padEnd(maxSlug);
117
+ const marker = i === 0 && p.status === 'pending' ? green('[NEXT]') : ' ';
118
+ const status = `[${(p.status ?? 'unknown').toUpperCase()}]`;
119
+ process.stdout.write(` ${marker} ${slug} ${status}\n`);
120
+ }
121
+ }
122
+
123
+ // Resolve a prompt's "target plan" for `prompts list --verbose`. Order:
124
+ // 1. frontmatter `related_plans:` (first entry — assumed plan slug)
125
+ // 2. frontmatter `parent_plan:`
126
+ // 3. first body markdown link to a .md file
127
+ // Returns a repo-relative display path or null.
128
+ function findPromptTarget(promptDoc, config) {
129
+ const refs = promptDoc.refFields ?? {};
130
+ const fmTargets = [...(refs.related_plans ?? []), ...(refs.parent_plan ?? [])];
131
+ for (const t of fmTargets) {
132
+ if (typeof t === 'string' && t.trim()) return slugToPlanPath(t.trim(), config);
133
+ }
134
+
135
+ const links = promptDoc.bodyLinks ?? [];
136
+ const mdLink = links.find(l => l.targetKind === 'document');
137
+ if (mdLink) return resolveBodyLink(mdLink.href, promptDoc.path);
138
+ return null;
139
+ }
140
+
141
+ // Plan slugs in frontmatter (e.g. `related_plans: [foo-bar]`) resolve to
142
+ // <docs-root>/plans/<slug>.md.
143
+ function slugToPlanPath(s, config) {
144
+ const cleaned = s.replace(/#.*$/, '').replace(/^\.\//, '');
145
+ if (cleaned.includes('/') || cleaned.endsWith('.md')) return cleaned;
146
+ return `${config.docsRootPrefix || 'docs/'}plans/${cleaned}.md`;
147
+ }
148
+
149
+ // Resolve a markdown body link relative to the prompt's location so e.g.
150
+ // `../plans/foo.md` from docs/prompts/x.md → docs/plans/foo.md.
151
+ function resolveBodyLink(link, promptRepoPath) {
152
+ const cleaned = link.replace(/#.*$/, '').replaceAll('\\', '/');
153
+ if (cleaned.startsWith('/')) return cleaned.replace(/^\/+/, '');
154
+ const promptDir = path.posix.dirname(promptRepoPath.replaceAll('\\', '/'));
155
+ return path.posix.normalize(path.posix.join(promptDir, cleaned));
156
+ }
157
+
158
+ function renderPromptsVerbose(index, config, { hasStatusFlag, includeArchived }) {
159
+ let prompts = index.docs.filter(d => d.type === 'prompt');
160
+ if (!hasStatusFlag && !includeArchived) {
161
+ prompts = prompts.filter(d => d.status !== 'archived');
162
+ }
163
+ if (prompts.length === 0) {
164
+ process.stdout.write('No prompts.\n');
165
+ return;
166
+ }
167
+
168
+ prompts.sort((a, b) => (b.updated ?? '').localeCompare(a.updated ?? ''));
169
+
170
+ const counts = {};
171
+ for (const p of prompts) counts[p.status ?? 'unknown'] = (counts[p.status ?? 'unknown'] ?? 0) + 1;
172
+ const summary = Object.entries(counts).map(([s, n]) => `${n} ${s}`).join(' · ');
173
+ process.stdout.write(`${prompts.length} prompt${prompts.length === 1 ? '' : 's'} · ${summary}\n\n`);
174
+
175
+ for (const p of prompts) {
176
+ const slug = path.basename(p.path, '.md');
177
+ const target = findPromptTarget(p, config);
178
+ const status = (p.status ?? 'unknown').toUpperCase();
179
+ const arrow = target ? ` ${dim('→')} ${target}` : ` ${dim('→ (no target plan)')}`;
180
+ process.stdout.write(` ${green(slug)} [${status}]\n${arrow}\n`);
181
+ }
182
+ }
183
+
184
+ // `index` lets a caller that already has a fast index (the prompt listings)
185
+ // share it rather than re-walking the tree. It must be a full index of the
186
+ // repo: the filter below narrows it, so a pre-filtered one would drop prompts
187
+ // from the queue.
188
+ export function pendingPromptsOldestFirst(config, index = null) {
189
+ index = index ?? buildIndex(config, { fast: true, invokeHooks: false });
190
+ const actionable = actionablePromptStatuses(config);
191
+ const prompts = index.docs.filter(d =>
192
+ d.type === 'prompt'
193
+ && actionable.has(d.status)
194
+ && !isArchivedPath(d.path, config),
195
+ );
196
+
197
+ return prompts
198
+ .sort(comparePromptDocs)
199
+ .map(d => ({ doc: d, abs: resolveDocPath(d.path, config), created: d.created ?? '' }));
200
+ }
201
+
202
+ async function runPromptsNext(argv, config, opts = {}) {
203
+ const queue = pendingPromptsOldestFirst(config);
204
+ if (queue.length === 0) {
205
+ die('No pending prompts.');
206
+ }
207
+ const head = queue[0];
208
+ if (!head.abs) die(`Could not resolve path: ${head.doc.path}`);
209
+ return consumePrompt(head.abs, config, { ...opts, noClaim: argv.includes('--no-claim') });
210
+ }
211
+
212
+ // Resolve user input to a prompt path. Tries (in order): exact path,
213
+ // path + '.md', exact basename match across type: prompt docs, substring
214
+ // match across type: prompt docs. Returns the absolute path or dies with a
215
+ // helpful message (no match / ambiguous match).
216
+ export function resolvePromptInput(input, config, options = {}) {
217
+ const dieOnMiss = options.dieOnMiss !== false;
218
+ const direct = resolveDocPath(input, config);
219
+ if (direct) return direct;
220
+
221
+ if (!input.endsWith('.md')) {
222
+ const withExt = resolveDocPath(input + '.md', config);
223
+ if (withExt) return withExt;
224
+ }
225
+
226
+ const index = buildIndex(config, { fast: true, invokeHooks: false });
227
+ const prompts = index.docs.filter(d => d.type === 'prompt');
228
+ if (prompts.length === 0) {
229
+ if (dieOnMiss) die(`No prompts in the index.`);
230
+ return null;
231
+ }
232
+
233
+ const slug = input.replace(/\.md$/, '');
234
+
235
+ const byBasename = prompts.filter(d => path.basename(d.path, '.md') === slug);
236
+ if (byBasename.length === 1) return path.resolve(config.repoRoot, byBasename[0].path);
237
+ if (byBasename.length > 1) {
238
+ die(`Multiple prompts match "${input}" by basename:\n${byBasename.map(d => ' ' + d.path).join('\n')}`);
239
+ }
240
+
241
+ const bySubstring = prompts.filter(d =>
242
+ d.path.includes(slug) || path.basename(d.path).includes(slug),
243
+ );
244
+ if (bySubstring.length === 1) return path.resolve(config.repoRoot, bySubstring[0].path);
245
+ if (bySubstring.length > 1) {
246
+ die(`Multiple prompts match "${input}":\n${bySubstring.map(d => ' ' + d.path).join('\n')}`);
247
+ }
248
+
249
+ if (dieOnMiss) die(`No prompt found matching: ${input}`);
250
+ return null;
251
+ }
252
+
253
+ async function runPromptsUse(argv, config, opts = {}) {
254
+ const input = argv.find(a => !a.startsWith('-'));
255
+ if (!input) die('Usage: runlist prompts use <file-or-slug>');
256
+ const noIndex = argv.includes('--no-index') || opts.noIndex;
257
+ const showFiles = argv.includes('--show-files') || opts.showFiles;
258
+ const noClaim = argv.includes('--no-claim');
259
+ const filePath = resolvePromptInput(input, config);
260
+ return consumePrompt(filePath, config, { ...opts, noIndex, showFiles, noClaim });
261
+ }
262
+
263
+ export async function consumePrompt(filePath, config, opts) {
264
+ const { dryRun, noIndex, showFiles } = opts;
265
+ filePath = authorizeManagedSource(filePath, config, { kind: 'Prompt consumption source' }).path;
266
+ const raw = readFileSync(filePath, 'utf8');
267
+ const { frontmatter, body } = extractFrontmatter(raw);
268
+ const parsed = parseSimpleFrontmatter(frontmatter);
269
+ const docType = asString(parsed.type);
270
+ const status = asString(parsed.status);
271
+ const repoPath = toRepoPath(filePath, config.repoRoot);
272
+
273
+ if (docType !== 'prompt') {
274
+ die(`Not a prompt (type: ${docType ?? 'unknown'}): ${repoPath}`);
275
+ }
276
+ if (status === 'archived' || isArchivedPath(repoPath, config)) {
277
+ die(`Already consumed: ${repoPath}`);
278
+ }
279
+
280
+ const planRef = asString(parsed.plan);
281
+ let linkedClaim = null;
282
+ let claimSkipReason = null;
283
+ if (planRef && opts.noClaim) {
284
+ claimSkipReason = `--no-claim, ${planRef} left as it is`;
285
+ } else if (planRef) {
286
+ const outcome = prepareLinkedPromptClaim(planRef, config, path.dirname(filePath));
287
+ if (outcome?.skipped) claimSkipReason = outcome.reason;
288
+ else linkedClaim = outcome;
289
+ }
290
+
291
+ if (dryRun) {
292
+ const prefix = dim('[dry-run]');
293
+ process.stderr.write(`${prefix} Would emit body and archive: ${repoPath} (${status ?? 'unknown'} → archived)\n`);
294
+ if (claimSkipReason) process.stderr.write(`${prefix} Would NOT claim the linked plan: ${claimSkipReason}\n`);
295
+ const bytes = Buffer.byteLength(body, 'utf8');
296
+ const lines = body.split('\n').length;
297
+ process.stderr.write(`${prefix} body preview (${bytes}B, ${lines} lines):\n`);
298
+ process.stderr.write(`${dim('---8<---')}\n`);
299
+ process.stderr.write(body);
300
+ if (!body.endsWith('\n')) process.stderr.write('\n');
301
+ process.stderr.write(`${dim('--->8---')}\n`);
302
+ runArchive([filePath], config, { dryRun: true, noIndex, out: process.stderr, skipInboundRefs: true });
303
+ return;
304
+ }
305
+
306
+ // Archive BEFORE emitting the body. If runArchive throws (git mv failure,
307
+ // hook crash, anything), the body must not have already gone to stdout —
308
+ // otherwise `claude "$(runlist prompts next)"` consumes the prompt without it
309
+ // ever being archived, and the next session sees the same prompt as pending.
310
+ const archiveResult = runArchive([filePath], config, {
311
+ noIndex,
312
+ showFiles,
313
+ out: process.stderr,
314
+ testHooks: opts.testHooks,
315
+ deferIndex: Boolean(linkedClaim),
316
+ // Saved prompts are gitignored session state, never durable reference
317
+ // targets. Avoid locking and replanning the entire corpus on consumption.
318
+ skipInboundRefs: true,
319
+ additionalUpdates: linkedClaim?.prepared?.updates,
320
+ creations: linkedClaim?.prepared?.creations,
321
+ // An adopt-shaped claim writes no plan content, so the plan file rides
322
+ // along as a read-only guard — the claim is only valid while the plan still
323
+ // says what the disposition was read from.
324
+ guards: linkedClaim?.prepared?.guards,
325
+ });
326
+ const consumedBody = archiveResult?.consumedBody ?? body;
327
+
328
+ const consumedPath = archiveResult?.newRepoPath ?? repoPath;
329
+ // Consume output is at-most-once: archive/claim commits before stdout. A
330
+ // downstream failure cannot roll the transaction back or make it consumable
331
+ // again; the archived path remains available through `prompts show`.
332
+ await writeConsumedBody(consumedBody, consumedPath, opts.writeBody, linkedClaim?.repoPath);
333
+ let completion = { indexRegenerated: false, ownershipChanged: false, hook: 'none', pending: false };
334
+ if (linkedClaim) {
335
+ try {
336
+ completion = completePlanClaim(linkedClaim.repoPath, config, opts);
337
+ if (!noIndex && config.indexPath && !completion.indexRegenerated) {
338
+ regenIndex(config, { throwOnError: true, testHooks: opts.testHooks });
339
+ completion.indexRegenerated = true;
340
+ }
341
+ } catch (err) {
342
+ throw new Error(`Prompt consumed and body delivered; linked claim completion remains pending for ${linkedClaim.repoPath}: ${err.message}`);
343
+ }
344
+ process.stderr.write(`${green('→ Claimed')}: ${linkedClaim.repoPath} (in-session)\n`);
345
+ }
346
+ if (claimSkipReason) process.stderr.write(`${yellow('→ Not claimed')}: ${claimSkipReason}\n`);
347
+ process.stderr.write(`${green('✓ Consumed')}: ${consumedPath}\n`);
348
+ const ownershipRecordPath = linkedClaim?.prepared?.recordPath ?? (linkedClaim ? readPlanOwnership(linkedClaim.repoPath, config)?.recordPath : null);
349
+ const ownershipPath = ownershipRecordPath ? toRepoPath(ownershipRecordPath, config.repoRoot) : null;
350
+ const normalize = candidate => path.isAbsolute(candidate) ? toRepoPath(candidate, config.repoRoot) : candidate.split(path.sep).join('/');
351
+ const resultPaths = [
352
+ ...(linkedClaim?.planChanged ? [linkedClaim.repoPath] : []),
353
+ ...(archiveResult?.referencePaths ?? []),
354
+ ].filter(Boolean).map(normalize);
355
+ const isOwnershipPath = candidate => [STATE_DIR, LEGACY_STATE_DIR]
356
+ .some(dir => candidate.startsWith(`${dir}/ownership/`));
357
+ const ownershipResultPaths = resultPaths.filter(isOwnershipPath);
358
+ const repositoryFiles = [...new Set(resultPaths.filter(candidate => !isOwnershipPath(candidate)))];
359
+ const sessionFiles = [...new Set([
360
+ repoPath,
361
+ consumedPath,
362
+ (linkedClaim?.prepared || completion.ownershipChanged) ? ownershipPath : null,
363
+ ...ownershipResultPaths,
364
+ ].filter(Boolean).map(normalize))];
365
+ return {
366
+ operation: 'consume',
367
+ status: { from: status ?? null, to: 'archived', changed: true },
368
+ repositoryFiles,
369
+ sessionFiles,
370
+ generatedFiles: config.indexPath && !noIndex && (completion.indexRegenerated || archiveResult?.indexRegenerated) ? [toRepoPath(config.indexPath, config.repoRoot)] : [],
371
+ deferredGeneratedFiles: config.indexPath && noIndex ? [toRepoPath(config.indexPath, config.repoRoot)] : [],
372
+ claim: linkedClaim ? { plan: linkedClaim.repoPath, changed: linkedClaim.planChanged, pendingCompletion: completion.pending, hook: completion.hook } : null,
373
+ };
374
+ }
375
+
376
+ export async function writeConsumedBody(body, archivedPath, write = null, linkedPlan = null) {
377
+ const content = body.endsWith('\n') ? body : `${body}\n`;
378
+ try {
379
+ if (write) {
380
+ const accepted = await write(content);
381
+ if (accepted === false) throw Object.assign(new Error('writer reported unresolved backpressure'), { code: 'BACKPRESSURE' });
382
+ } else {
383
+ await new Promise((resolve, reject) => process.stdout.write(content, err => err ? reject(err) : resolve()));
384
+ }
385
+ }
386
+ catch (err) {
387
+ const completion = linkedPlan ? ` Then complete the linked claim with \`runlist use ${linkedPlan}\`.` : '';
388
+ throw new Error(`Prompt was consumed but body output failed (${err.code ?? err.message}). It will not be emitted again; recover it with \`runlist prompts show ${archivedPath}\`.${completion}`);
389
+ }
390
+ }
391
+
392
+ // `planRef` is a reference written in the prompt's own frontmatter, so it obeys
393
+ // the same convention every other ref field does: doc-relative first (baton's
394
+ // `../plans/x.md` from `docs/prompts/`), repo-root-relative second. Resolving it
395
+ // with `resolveDocPath` alone read only the repo-root form, so a doc-relative
396
+ // link — the form nothing validates, since `plan` is not a `referenceFields`
397
+ // entry — died as "missing" while pointing at a file that was plainly there.
398
+ // Why a linked plan could not be claimed, phrased so the reader knows what to
399
+ // do next. Each of these used to abort consumption entirely (see the skip
400
+ // contract on prepareLinkedPromptClaim).
401
+ // `startable` comes from the repo's own lifecycle config rather than the
402
+ // built-in default: a repo that configures its own startable statuses would
403
+ // otherwise be told to run `runlist set active`, which its own validation
404
+ // rejects.
405
+ function explainUnclaimablePlan(disposition, repoPath, status, startable) {
406
+ switch (disposition.kind) {
407
+ case 'parked':
408
+ return `${repoPath} is ${status} — \`runlist set ${startable} ${repoPath}\` to unpark it, then \`runlist use ${repoPath}\``;
409
+ case 'busy':
410
+ return `${repoPath} is claimed by another session (${disposition.owner})`;
411
+ case 'terminal':
412
+ case 'physical-archive':
413
+ return `${repoPath} is already closed (${status})`;
414
+ case 'wrong-type':
415
+ return `${repoPath} is not a plan`;
416
+ case 'unconfigured-status':
417
+ return `${repoPath} has a status this repo does not configure (${status ?? 'none'})`;
418
+ case 'ownership-corrupt':
419
+ return `${repoPath} has an unreadable ownership record — \`runlist doctor --claims\``;
420
+ default:
421
+ return `${repoPath} cannot be claimed (${disposition.kind})`;
422
+ }
423
+ }
424
+
425
+ // Returns a claim, or `{ skipped, reason }` when the linked plan cannot be
426
+ // claimed. It never refuses the consumption itself.
427
+ //
428
+ // It used to `die` on all three of these paths, which deadlocked the handoff
429
+ // loop the feature exists to close: `runlist baton` stamps this link and parks
430
+ // the plan in the same breath, and five of the seven statuses it parks with are
431
+ // not startable — so baton routinely produced a prompt that `runlist use` would
432
+ // refuse forever, while the SessionStart hud kept telling every new session to
433
+ // run exactly that command. The body is the whole point of a saved prompt, and
434
+ // no reason to skip the claim is a reason to withhold it.
435
+ function prepareLinkedPromptClaim(planRef, config, promptDir) {
436
+ const skip = reason => ({ skipped: true, reason });
437
+ let planPath = resolveRefPath(planRef, promptDir, config.repoRoot)
438
+ ?? resolveDocPath(planRef, config)
439
+ ?? resolveDocArg(planRef, config, { dieOnMiss: false });
440
+ if (!planPath || !existsSync(planPath)) return skip(`linked plan is missing (moved or renamed): ${planRef}`);
441
+ planPath = authorizeManagedSource(planPath, config, { kind: 'Prompt linked plan source' }).path;
442
+ const raw = readFileSync(planPath, 'utf8');
443
+ let parsed;
444
+ try { parsed = parseSimpleFrontmatter(extractFrontmatter(raw).frontmatter); }
445
+ catch { return skip(`linked plan has malformed frontmatter: ${toRepoPath(planPath, config.repoRoot)}`); }
446
+ const repoPath = toRepoPath(planPath, config.repoRoot);
447
+ const sessionId = authoritativeSessionId();
448
+ const ownership = readPlanOwnership(repoPath, config);
449
+ const oldStatus = asString(parsed.status);
450
+ const disposition = classifyPlanPickup({
451
+ type: asString(parsed.type),
452
+ status: oldStatus,
453
+ ownerLiveness: ownershipLiveness(ownership),
454
+ validStatuses: config.typeStatuses?.get('plan') ?? config.validStatuses,
455
+ startableStatuses: config.lifecycle.startableStatuses,
456
+ terminalStatuses: config.lifecycle.terminalStatuses,
457
+ archiveStatuses: config.lifecycle.archiveStatuses,
458
+ physicallyArchived: isArchivedPath(repoPath, config),
459
+ ownership,
460
+ sessionId,
461
+ malformed: false,
462
+ });
463
+ if (!disposition.pickupable) {
464
+ const startable = [...(config.lifecycle.startableStatuses ?? [])][0] ?? 'active';
465
+ return skip(explainUnclaimablePlan(disposition, repoPath, oldStatus, startable));
466
+ }
467
+ if (disposition.kind === 'resume') return { planPath, repoPath, prepared: null, planChanged: false, disposition: disposition.kind };
468
+ const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
469
+ const rendered = disposition.kind === 'start'
470
+ ? renderLifecycleMutation(raw, { status: 'in-session', updated: now }, `Started (${oldStatus} → in-session).`, { createSection: true })
471
+ : null;
472
+ const prepared = preparePlanClaim({ filePath: planPath, sourceContent: raw, renderedContent: rendered,
473
+ ownership, sessionId, now, config });
474
+ return {
475
+ planPath,
476
+ repoPath,
477
+ prepared,
478
+ planChanged: prepared.updates.some(item => path.resolve(item.path) === path.resolve(planPath)),
479
+ disposition: disposition.kind,
480
+ };
481
+ }
482
+
483
+ // Read-only peek: print the body WITHOUT consuming. The sanctioned triage path
484
+ // — surveying pending prompts must not archive them (that's `use`'s job), and
485
+ // it must not require raw cat/Read (which the guard warns about).
486
+ // Read one prompt for peeking. Returns null rather than dying so a bulk sweep
487
+ // isn't aborted by a single bad doc.
488
+ function readPromptForShow(filePath, config, { strict }) {
489
+ const raw = readFileSync(filePath, 'utf8');
490
+ const { frontmatter, body } = extractFrontmatter(raw);
491
+ const parsed = parseSimpleFrontmatter(frontmatter);
492
+ const repoPath = toRepoPath(filePath, config.repoRoot);
493
+ if (asString(parsed.type) !== 'prompt') {
494
+ if (strict) die(`Not a prompt (type: ${asString(parsed.type) ?? 'unknown'}): ${repoPath}`);
495
+ return null;
496
+ }
497
+ return { repoPath, body, status: asString(parsed.status) ?? 'unknown' };
498
+ }
499
+
500
+ // Triage the queue without consuming it. Bulk mode exists because surveying a
501
+ // backlog was the one thing `show` could not do: sessions sweeping 25-28 saved
502
+ // prompts had to fall back to Read, which the guard then warned about once per
503
+ // file. `--all` and multiple paths make the supported route the cheap one.
504
+ function runPromptsShow(argv, config) {
505
+ const all = argv.includes('--all');
506
+ const limitIdx = argv.indexOf('--limit');
507
+ const limit = limitIdx >= 0 ? Number.parseInt(argv[limitIdx + 1], 10) : null;
508
+ if (limitIdx >= 0 && (!Number.isInteger(limit) || limit < 1)) {
509
+ die('--limit takes a positive integer.');
510
+ }
511
+ // Skip the value that belongs to --limit; it is not a prompt name. Guard the
512
+ // absent case explicitly — limitIdx is -1 there, and -1 + 1 would drop argv[0].
513
+ const limitValueIdx = limitIdx >= 0 ? limitIdx + 1 : -1;
514
+ const inputs = argv.filter((a, i) => !a.startsWith('-') && i !== limitValueIdx);
515
+
516
+ if (!inputs.length && !all) {
517
+ die('Usage: runlist prompts show <file-or-slug>...\n runlist prompts show --all [--limit N] # peek the whole pending queue');
518
+ }
519
+ if (inputs.length && all) die('Pass either prompt names or --all, not both.');
520
+
521
+ let targets;
522
+ if (all) {
523
+ const queue = pendingPromptsOldestFirst(config);
524
+ if (!queue.length) die('No pending prompts.');
525
+ targets = queue.filter(entry => entry.abs).map(entry => entry.abs);
526
+ } else {
527
+ targets = inputs.map(input => resolvePromptInput(input, config));
528
+ }
529
+ const total = targets.length;
530
+ if (limit !== null) targets = targets.slice(0, limit);
531
+
532
+ // Single-target output is unchanged: bare body on stdout, one dim header on
533
+ // stderr. Only a multi-target sweep adds separators, so scripts piping one
534
+ // prompt keep working.
535
+ const multi = targets.length > 1;
536
+ let shown = 0;
537
+ for (const filePath of targets) {
538
+ const prompt = readPromptForShow(filePath, config, { strict: !all && !multi });
539
+ if (!prompt) continue;
540
+ const { repoPath, body, status } = prompt;
541
+ if (multi) {
542
+ if (shown > 0) process.stdout.write('\n');
543
+ process.stdout.write(dim(`──── ${repoPath} [${status}] ────\n`));
544
+ } else {
545
+ process.stderr.write(dim(`${repoPath} [${status}] — read-only peek; \`runlist use ${repoPath}\` to consume\n`));
546
+ }
547
+ process.stdout.write(body);
548
+ if (!body.endsWith('\n')) process.stdout.write('\n');
549
+ shown++;
550
+ }
551
+
552
+ // Footer whenever the sweep showed more than one OR hid some: a truncated
553
+ // --limit 1 collapses to single-prompt formatting, and silently dropping the
554
+ // rest would read as "that's the whole queue".
555
+ const truncated = total > targets.length;
556
+ if (multi || truncated) {
557
+ const suffix = truncated ? ` of ${total} (use --limit to change)` : '';
558
+ process.stderr.write(dim(`\n${shown} prompt${shown === 1 ? '' : 's'}${suffix} — read-only peek; \`runlist use <file>\` to consume one\n`));
559
+ }
560
+ }
561
+
562
+ function runPromptsArchive(argv, config, opts = {}) {
563
+ const input = argv.find(a => !a.startsWith('-'));
564
+ if (!input) die('Usage: runlist prompts archive <file-or-slug>');
565
+ const noIndex = argv.includes('--no-index') || opts.noIndex;
566
+ const showFiles = argv.includes('--show-files') || opts.showFiles;
567
+ const filePath = resolvePromptInput(input, config);
568
+
569
+ const raw = readFileSync(filePath, 'utf8');
570
+ const { frontmatter } = extractFrontmatter(raw);
571
+ const parsed = parseSimpleFrontmatter(frontmatter);
572
+ if (asString(parsed.type) !== 'prompt') {
573
+ die(`Not a prompt: ${toRepoPath(filePath, config.repoRoot)}`);
574
+ }
575
+
576
+ runArchive([filePath], config, { ...opts, noIndex, showFiles });
577
+ }
578
+
579
+ async function runPromptsNew(argv, config, opts = {}) {
580
+ if (!argv[0] || argv[0].startsWith('-')) {
581
+ die('Usage: runlist prompts new <slug> [body]\n body: inline text | piped stdin (auto) | "@path" (file) | --body "..."');
582
+ }
583
+ return runNew(['prompt', ...argv], config, opts);
584
+ }
585
+