runlist 0.96.0 → 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/README.md +155 -16
- package/assets/app/app.css +189 -3
- package/assets/app/app.mjs +49 -31
- package/assets/app/document-create.mjs +3 -2
- package/assets/app/document-lifecycle.mjs +17 -8
- package/assets/app/document-stages.mjs +3 -1
- package/assets/app/document-views.mjs +23 -1
- package/assets/app/editor-navigation.mjs +16 -23
- package/assets/app/filing-navigation.mjs +18 -16
- package/assets/app/git-changes.mjs +11 -2
- package/assets/app/index.html +34 -23
- package/assets/app/library-navigation.mjs +29 -16
- package/assets/app/record-navigation.mjs +68 -23
- package/assets/app/recovery-center.mjs +8 -7
- package/assets/app/settings.mjs +48 -10
- package/assets/app/template-editor.mjs +22 -12
- package/assets/app/transport.mjs +1 -2
- package/bin/runlist.mjs +37 -2
- package/package.json +1 -1
- package/plugins/runlist-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/runlist-codex/skills/runlist/SKILL.md +3 -1
- package/src/app.mjs +9 -4
- package/src/baton.mjs +48 -5
- package/src/commands.mjs +3 -2
- package/src/config.mjs +16 -26
- package/src/doctor.mjs +15 -0
- package/src/hud.mjs +3 -2
- package/src/index.mjs +2 -0
- package/src/install.mjs +6 -4
- package/src/installation.mjs +117 -0
- package/src/new.mjs +27 -7
- package/src/prompt-handoff.mjs +132 -0
- package/src/prompts.mjs +112 -16
- package/src/template-store.mjs +16 -5
- package/src/update.mjs +7 -28
|
@@ -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
|
|
115
|
-
|
|
116
|
-
|
|
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 =
|
|
192
|
+
const marker = p.path === nextPath ? green('[NEXT]') : ' ';
|
|
119
193
|
const status = `[${(p.status ?? 'unknown').toUpperCase()}]`;
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
301
|
-
const
|
|
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(
|
|
305
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
564
|
-
|
|
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/template-store.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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('
|
|
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('
|
|
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('
|
|
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`') :
|
|
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
|
}
|