runlist 0.95.6 → 0.96.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +162 -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 +47 -3
- package/package.json +1 -1
- package/plugins/runlist-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/runlist-codex/skills/runlist/SKILL.md +3 -0
- package/src/app.mjs +9 -4
- package/src/baton.mjs +80 -11
- package/src/commands.mjs +3 -2
- package/src/config.mjs +16 -26
- package/src/doctor.mjs +15 -0
- package/src/hud.mjs +3 -1
- 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
package/src/app.mjs
CHANGED
|
@@ -7,6 +7,14 @@ import { SourceEditError } from './source-editor.mjs';
|
|
|
7
7
|
import { resolveDocArg } from './index.mjs';
|
|
8
8
|
|
|
9
9
|
const assets = new URL('../assets/app/', import.meta.url);
|
|
10
|
+
const staticFiles = new Map([
|
|
11
|
+
'app.mjs','theme.mjs','transport.mjs','shared.mjs','document-create.mjs',
|
|
12
|
+
'quick-navigation.mjs','recovery-center.mjs','settings.mjs','document-lifecycle.mjs',
|
|
13
|
+
'record-navigation.mjs','library-navigation.mjs','outline.mjs','editor-navigation.mjs',
|
|
14
|
+
'template-editor.mjs','block-model.mjs','block-editor.mjs','git-changes.mjs','git-commit.mjs',
|
|
15
|
+
'document-views.mjs','document-stages.mjs','document-yardstick.mjs',
|
|
16
|
+
'filing-navigation.mjs','semantic-search.mjs','stage-source.mjs','app.css',
|
|
17
|
+
].map(file => [`/${file}`, [file, file.endsWith('.css') ? 'text/css' : 'text/javascript']]));
|
|
10
18
|
const ttl = 12 * 60 * 60 * 1000;
|
|
11
19
|
const secret = () => randomBytes(32).toString('base64url');
|
|
12
20
|
function problem(code,message) { throw new SourceEditError(code,message); }
|
|
@@ -40,10 +48,7 @@ export async function startApp({ config, port = 5173, initialPath = null, actor
|
|
|
40
48
|
if (!origin || ![new URL(origin).host, `localhost:${server.address().port}`].includes(req.headers.host)) problem('forbidden', 'Invalid local application host.');
|
|
41
49
|
const url = new URL(req.url, origin);
|
|
42
50
|
if (req.method === 'GET' && url.pathname === '/') return send(200, readFileSync(new URL('index.html', assets)), 'text/html; charset=utf-8');
|
|
43
|
-
|
|
44
|
-
staticFiles['/git-changes.mjs']=['git-changes.mjs','text/javascript'];
|
|
45
|
-
staticFiles['/git-commit.mjs']=['git-commit.mjs','text/javascript'];
|
|
46
|
-
if (req.method === 'GET' && staticFiles[url.pathname]) { const [file, type] = staticFiles[url.pathname]; return send(200, readFileSync(new URL(file, assets)), `${type}; charset=utf-8`); }
|
|
51
|
+
if (req.method === 'GET' && staticFiles.has(url.pathname)) { const [file, type] = staticFiles.get(url.pathname); return send(200, readFileSync(new URL(file, assets)), `${type}; charset=utf-8`); }
|
|
47
52
|
const requestOrigin = req.headers.origin;
|
|
48
53
|
const permittedOrigins = [origin, `http://localhost:${server.address().port}`];
|
|
49
54
|
if (requestOrigin && !permittedOrigins.includes(requestOrigin)) problem('forbidden', 'Cross-origin requests are refused.');
|
package/src/baton.mjs
CHANGED
|
@@ -3,13 +3,15 @@ import path from 'node:path';
|
|
|
3
3
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
4
|
import { asString, toRepoPath, die, warn, isArchivedPath, resolveRefPath, nowIso } from './util.mjs';
|
|
5
5
|
import { buildIndex, resolveDocArg } from './index.mjs';
|
|
6
|
-
import { preparePromptDocument, runNew, readBodyInput, readPipedBodyInput } from './new.mjs';
|
|
6
|
+
import { preparePromptDocument, runNew, readBodyInput, readPipedBodyInput, slugify } from './new.mjs';
|
|
7
7
|
import { ensurePlanCompletionBeforeRelease, planHasPendingCompletion, renderLifecycleMutation, runSet } from './lifecycle.mjs';
|
|
8
8
|
import { green, dim, yellow } from './color.mjs';
|
|
9
9
|
import { handoffNotDoneLine, recordPendingHandoff, runBatonDone } from './handoff-record.mjs';
|
|
10
10
|
import { authorizeManagedDestination, authorizeManagedSource } from './managed-path.mjs';
|
|
11
11
|
import { mutateFileSet } from './atomic-mutation.mjs';
|
|
12
12
|
import { assertPlanMutationAuthorized, authoritativeSessionId, listOwnedPlans, readPlanOwnership } from './pickup.mjs';
|
|
13
|
+
import { HANDOFF_TEMPLATES, parentPromptName } from './prompt-handoff.mjs';
|
|
14
|
+
import { readTemplateStore, templateFile } from './template-store.mjs';
|
|
13
15
|
|
|
14
16
|
// `runlist baton` is the one-command handoff: save the resume prompt AND release
|
|
15
17
|
// the plan in a single atomic-ish verb. It exists because the three-step skill
|
|
@@ -123,6 +125,9 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
123
125
|
let force = false;
|
|
124
126
|
let replace = false;
|
|
125
127
|
let done = false;
|
|
128
|
+
let subagent = false;
|
|
129
|
+
let parentPrompt = null;
|
|
130
|
+
let template = false;
|
|
126
131
|
const positionals = [];
|
|
127
132
|
for (let i = 0; i < argv.length; i++) {
|
|
128
133
|
const a = argv[i];
|
|
@@ -132,10 +137,32 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
132
137
|
if (a === '--force') { force = true; continue; }
|
|
133
138
|
if (a === '--replace') { replace = true; continue; }
|
|
134
139
|
if (a === '--done') { done = true; continue; }
|
|
140
|
+
if (a === '--subagent') { subagent = true; continue; }
|
|
141
|
+
if (a === '--template') { template = true; continue; }
|
|
142
|
+
if (a === '--parent') {
|
|
143
|
+
const value = argv[++i];
|
|
144
|
+
try { parentPrompt = parentPromptName(value); }
|
|
145
|
+
catch { die('--parent requires a resume prompt name, e.g. --parent resume-session-name.'); }
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
135
148
|
if (a === '--json') continue;
|
|
136
149
|
if (!a.startsWith('-') || a === '-' || a.startsWith('@')) { positionals.push(a); continue; }
|
|
137
150
|
die(`Unknown flag for \`runlist baton\`: ${a}`);
|
|
138
151
|
}
|
|
152
|
+
if (parentPrompt && !subagent) die('--parent requires --subagent.');
|
|
153
|
+
if (template) {
|
|
154
|
+
if (done || replace || force || statusFlag || note !== null || positionals.length || bodyFlag !== null) {
|
|
155
|
+
die('Use `runlist baton --template` or `runlist baton --subagent --template [--parent resume-name]` to print a draft without saving.');
|
|
156
|
+
}
|
|
157
|
+
if (subagent && !parentPrompt && process.env.RUNLIST_PARENT_PROMPT) parentPrompt = parentPromptName(process.env.RUNLIST_PARENT_PROMPT);
|
|
158
|
+
const name = subagent ? 'subagent' : 'resume';
|
|
159
|
+
const key = `handoff:${name}`;
|
|
160
|
+
const override = config ? readTemplateStore(config).data.templates[key]?.source : null;
|
|
161
|
+
const source = override ?? HANDOFF_TEMPLATES[key].source;
|
|
162
|
+
const draft = source.replaceAll('{{parent_prompt}}', parentPrompt ?? '<resume-parent-name>');
|
|
163
|
+
process.stdout.write(json ? JSON.stringify({ operation: 'baton-template', name, origin: override ? templateFile : 'Built-in', parentPrompt, template: draft }, null, 2) + '\n' : draft);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
139
166
|
|
|
140
167
|
let planArg = null;
|
|
141
168
|
let bodyArg = null;
|
|
@@ -162,6 +189,13 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
162
189
|
body = readPipedBodyInput();
|
|
163
190
|
}
|
|
164
191
|
if (!body || !body.trim()) die(BODY_USAGE);
|
|
192
|
+
const draft = extractFrontmatter(body);
|
|
193
|
+
const draftFm = parseSimpleFrontmatter(draft.frontmatter);
|
|
194
|
+
if (!subagent && asString(draftFm.parent_prompt)) die('parent_prompt requires --subagent; a resume handoff cannot be its own child.');
|
|
195
|
+
if (subagent && !parentPrompt) {
|
|
196
|
+
const draftParent = asString(draftFm.parent_prompt);
|
|
197
|
+
if (draftParent) parentPrompt = parentPromptName(draftParent);
|
|
198
|
+
}
|
|
165
199
|
|
|
166
200
|
// Resolve what's being handed off. Two modes:
|
|
167
201
|
// plan mode — a plan is released alongside the prompt (one status flip).
|
|
@@ -199,12 +233,30 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
199
233
|
}
|
|
200
234
|
}
|
|
201
235
|
|
|
202
|
-
const nameBase = planPath ? path.basename(planPath, '.md') : promptSlug;
|
|
203
|
-
const
|
|
236
|
+
const nameBase = slugify(planPath ? path.basename(planPath, '.md') : promptSlug);
|
|
237
|
+
const handoffPrefix = subagent ? 'subagent-' : 'resume-';
|
|
238
|
+
const recognizedPrefix = /^(resume-|subagent-)/.exec(nameBase)?.[0];
|
|
239
|
+
if (recognizedPrefix && recognizedPrefix !== handoffPrefix) {
|
|
240
|
+
die(`Nothing saved: ${nameBase} conflicts with ${subagent ? '--subagent' : 'the parent handoff default'}. Use an unprefixed name or ${handoffPrefix}<name>${subagent ? ', or omit --subagent for a parent handoff' : ', or pass --subagent for a child handoff'}.`);
|
|
241
|
+
}
|
|
242
|
+
const unprefixed = recognizedPrefix ? nameBase.slice(handoffPrefix.length) : nameBase;
|
|
243
|
+
if (!unprefixed || /^(resume-|subagent-)/.test(unprefixed)) {
|
|
244
|
+
die('Nothing saved: use a nonempty handoff name with at most one resume- or subagent- prefix.');
|
|
245
|
+
}
|
|
246
|
+
const slugBase = `${handoffPrefix}${unprefixed}`;
|
|
204
247
|
let replacement = null;
|
|
248
|
+
let alternatePromptPath = null;
|
|
205
249
|
const resolvePending = () => {
|
|
206
250
|
const target = preparePromptDocument(slugBase, body, config, { dryRun: true });
|
|
207
251
|
const pending = pendingHandoffs(target.filePath, planPath, config);
|
|
252
|
+
if (planPath) {
|
|
253
|
+
const alternateSlug = `${subagent ? 'resume-' : 'subagent-'}${unprefixed}`;
|
|
254
|
+
alternatePromptPath = preparePromptDocument(alternateSlug, body, config, { dryRun: true }).filePath;
|
|
255
|
+
if (existsSync(alternatePromptPath)) {
|
|
256
|
+
const alternate = toRepoPath(alternatePromptPath, config.repoRoot);
|
|
257
|
+
if (!pending.includes(alternate)) pending.push(alternate);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
208
260
|
if (!replace) {
|
|
209
261
|
if (pending.length) refusePendingHandoff(pending);
|
|
210
262
|
return;
|
|
@@ -214,6 +266,9 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
214
266
|
die(`Nothing replaced: --replace requires exactly one pending handoff. ${detail}`);
|
|
215
267
|
}
|
|
216
268
|
const oldRepoPath = pending[0];
|
|
269
|
+
if (subagent && /^resume-/i.test(path.basename(oldRepoPath))) {
|
|
270
|
+
die(`Nothing replaced: ${oldRepoPath} is a parent handoff. Inspect it with \`runlist prompts show ${path.basename(oldRepoPath, '.md')}\`; use a separate task slug for the child handoff, or omit --subagent to refresh the parent handoff.`);
|
|
271
|
+
}
|
|
217
272
|
const oldPath = authorizeManagedSource(path.resolve(config.repoRoot, oldRepoPath), config, { kind: 'Baton replacement source' }).path;
|
|
218
273
|
const oldRaw = readFileSync(oldPath, 'utf8');
|
|
219
274
|
const { frontmatter, body: oldBody } = extractFrontmatter(oldRaw);
|
|
@@ -226,10 +281,14 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
226
281
|
if (!planPath && priorPlanRef) {
|
|
227
282
|
die(`Nothing replaced: ${oldRepoPath} links a plan (${priorPlanRef}). Use \`runlist baton <plan-file> @<draft-file> --replace\` so the plan link and release stay together.`);
|
|
228
283
|
}
|
|
284
|
+
const priorParent = asString(fm.parent_prompt);
|
|
285
|
+
parentPrompt ??= priorParent;
|
|
229
286
|
replacement = {
|
|
230
|
-
oldPath, oldRepoPath, oldRaw,
|
|
287
|
+
oldPath, oldRepoPath, oldRaw, priorParent,
|
|
231
288
|
archivedPath: archivedCopyPath(oldPath, config),
|
|
232
|
-
alreadyCurrent: oldBody.trim() === body.trim()
|
|
289
|
+
alreadyCurrent: oldBody.trim() === draft.body.trim()
|
|
290
|
+
&& Object.entries(draftFm).every(([key, value]) => key === 'parent_prompt' || JSON.stringify(fm[key]) === JSON.stringify(value))
|
|
291
|
+
&& (priorParent ?? null) === parentPrompt
|
|
233
292
|
&& (!planPath || (priorPlanPath && path.resolve(priorPlanPath) === path.resolve(planPath))),
|
|
234
293
|
canonicalPath: target.filePath,
|
|
235
294
|
};
|
|
@@ -301,6 +360,12 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
301
360
|
if (note) warn(`--note ignored — no plan involved in this handoff (notes land in a plan's Version History).`);
|
|
302
361
|
}
|
|
303
362
|
|
|
363
|
+
if (subagent) {
|
|
364
|
+
if (!parentPrompt && process.env.RUNLIST_PARENT_PROMPT) parentPrompt = parentPromptName(process.env.RUNLIST_PARENT_PROMPT);
|
|
365
|
+
if (replacement && (replacement.priorParent ?? null) !== parentPrompt) replacement.alreadyCurrent = false;
|
|
366
|
+
if (!parentPrompt) warn('Subagent handoff has no parent link. Pass --parent resume-name, include parent_prompt in the draft, or inherit RUNLIST_PARENT_PROMPT. Use `runlist prompts link <child> --parent <resume-name>` to repair an existing handoff.');
|
|
367
|
+
}
|
|
368
|
+
|
|
304
369
|
const preparedReplacement = prepared => {
|
|
305
370
|
if (!replacement || replacement.alreadyCurrent) return { updates: [], creations: [], guards: [] };
|
|
306
371
|
let archivedContent = renderLifecycleMutation(replacement.oldRaw,
|
|
@@ -330,25 +395,25 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
330
395
|
try {
|
|
331
396
|
if (!planPath) {
|
|
332
397
|
const promptName = replacement ? path.basename(replacement.oldPath, '.md') : slugBase;
|
|
333
|
-
const prepared = preparePromptDocument(promptName, body, config, { dryRun });
|
|
398
|
+
const prepared = preparePromptDocument(promptName, body, config, { parentPrompt, dryRun });
|
|
334
399
|
if (replacement) {
|
|
335
400
|
const mutation = preparedReplacement(prepared);
|
|
336
401
|
if (!dryRun && !replacement.alreadyCurrent) mutateFileSet(mutation, { repoRoot: config.repoRoot, testHooks: opts.testHooks });
|
|
337
402
|
newResult = { sessionFiles: [replacement.oldRepoPath, ...(replacement.alreadyCurrent ? [] : [toRepoPath(replacement.archivedPath, config.repoRoot)])] };
|
|
338
403
|
} else {
|
|
339
404
|
try {
|
|
340
|
-
newResult = await runNew(['prompt', slugBase, '--body', body], config, { dryRun, deferIndex: true });
|
|
405
|
+
newResult = await runNew(['prompt', slugBase, '--body', body], config, { parentPrompt, dryRun, deferIndex: true });
|
|
341
406
|
} catch (err) {
|
|
342
407
|
// Lost a race with another baton between the pending check and the write.
|
|
343
408
|
if (/File already exists/.test(String(err?.message))) refusePendingHandoff([prepared.repoPath]);
|
|
344
409
|
throw err;
|
|
345
410
|
}
|
|
346
411
|
}
|
|
347
|
-
createdSlug = promptName;
|
|
348
412
|
promptRepoPath = replacement?.oldRepoPath ?? prepared.repoPath;
|
|
413
|
+
createdSlug = path.basename(promptRepoPath, '.md');
|
|
349
414
|
} else {
|
|
350
415
|
const promptName = replacement ? path.basename(replacement.oldPath, '.md') : slugBase;
|
|
351
|
-
const prepared = preparePromptDocument(promptName, body, config, { plan: repoPath, dryRun });
|
|
416
|
+
const prepared = preparePromptDocument(promptName, body, config, { plan: repoPath, parentPrompt, dryRun });
|
|
352
417
|
const mutation = preparedReplacement(prepared);
|
|
353
418
|
const setArgs = [status, planPath];
|
|
354
419
|
if (force) setArgs.push('--force');
|
|
@@ -361,15 +426,18 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
361
426
|
testHooks: opts.testHooks,
|
|
362
427
|
additionalUpdates: dryRun ? [] : mutation.updates,
|
|
363
428
|
creations: dryRun ? [] : replacement ? mutation.creations : [{ path: prepared.filePath, content: prepared.content }],
|
|
364
|
-
guards: dryRun ? [] : [
|
|
429
|
+
guards: dryRun ? [] : [
|
|
430
|
+
...mutation.guards, ...(planGuard ? [planGuard] : []),
|
|
431
|
+
...(!replacement && alternatePromptPath ? [{ path: alternatePromptPath, absent: true }] : []),
|
|
432
|
+
],
|
|
365
433
|
deferIndex: true,
|
|
366
434
|
});
|
|
367
435
|
} catch (err) {
|
|
368
436
|
if (/Destination already exists|File already exists/.test(String(err?.message))) refusePendingHandoff([prepared.repoPath]);
|
|
369
437
|
throw err;
|
|
370
438
|
}
|
|
371
|
-
createdSlug = prepared.slug;
|
|
372
439
|
promptRepoPath = replacement?.oldRepoPath ?? prepared.repoPath;
|
|
440
|
+
createdSlug = path.basename(promptRepoPath, '.md');
|
|
373
441
|
statusChanged = oldStatus !== status;
|
|
374
442
|
if (!dryRun && !replacement) {
|
|
375
443
|
try { config.hooks.onNew?.({ path: prepared.repoPath, status: 'pending', title: prepared.slug, type: 'prompt' }); }
|
|
@@ -408,6 +476,7 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
408
476
|
}
|
|
409
477
|
const operationResult = {
|
|
410
478
|
operation: 'baton',
|
|
479
|
+
parentPrompt,
|
|
411
480
|
dryRun: Boolean(dryRun),
|
|
412
481
|
disposition: replacement?.alreadyCurrent && !statusChanged && !wouldReleaseClaim ? 'already-current' : dryRun ? 'would-change' : 'applied',
|
|
413
482
|
wouldChange: Boolean(dryRun),
|
package/src/commands.mjs
CHANGED
|
@@ -134,13 +134,14 @@ const definitions = [
|
|
|
134
134
|
form('use [file]', { subcommands: ['use', 'resume'], args: positionals(0, 1), options: [flag('--no-index'), flag('--show-files'), flag('--force'), flag('--no-claim')] }),
|
|
135
135
|
form('show [file...]', { subcommands: ['show', 'peek'], args: positionals(0, Infinity), options: [flag('--json'), flag('--all'), value('--limit')] }),
|
|
136
136
|
form('archive <file>', { subcommands: ['archive'], args: positionals(1, 1), options: [flag('--no-index'), flag('--show-files')] }),
|
|
137
|
+
form('link <child...> --parent <resume-name>', { subcommands: ['link'], args: positionals(1, Infinity), options: [value('--parent'), flag('--json')] }),
|
|
137
138
|
form('new <slug> [body...]', { subcommands: ['new'], args: positionals(1, Infinity), options: [value('--body', '--message'), value('--title'), value('--status')] }),
|
|
138
139
|
form('hold <file>', { subcommands: ['hold', 'shelve'], args: positionals(1, 1) }),
|
|
139
140
|
form('unhold <file>', { subcommands: ['unhold', 'unshelve'], args: positionals(1, 1) }),
|
|
140
141
|
], { aliases: ['prompt'] }),
|
|
141
142
|
command('use', mutates('managed source when starting/consuming; docs remain read-only'), 'workflow', [form('[file]', { args: positionals(0, 1), options: [flag('--json'), flag('--full'), flag('--no-index'), flag('--show-files'), flag('--force'), flag('--no-claim')] })]),
|
|
142
143
|
command('next', mutates('managed prompt source and same-root archive destination'), 'workflow', [form('', { options: [flag('--json'), flag('--no-index'), flag('--show-files'), flag('--force'), flag('--no-claim')] })]),
|
|
143
|
-
command('baton', mutates('managed plan/prompt sources and managed prompt destination'), 'workflow', [form('[plan|slug] <@<file>|->', { args: positionals(0, 2), options: [value('--status'), value('--note'), value('--body', '--message'), flag('--force'), flag('--replace'), flag('--done'), flag('--json')] })]),
|
|
144
|
+
command('baton', mutates('managed plan/prompt sources and managed prompt destination'), 'workflow', [form('[plan|slug] <@<file>|->', { args: positionals(0, 2), options: [value('--status'), value('--note'), value('--body', '--message'), value('--parent'), flag('--template'), flag('--force'), flag('--replace'), flag('--done'), flag('--subagent'), flag('--json')] })]),
|
|
144
145
|
command('runlist', mutates('managed hubs/children and managed scaffold destinations'), 'workflow', [
|
|
145
146
|
form('<hub>', { args: positionals(1, 1), options: [flag('--json')] }),
|
|
146
147
|
form('next <hub>', { subcommands: ['next'], args: positionals(1, 1), options: [flag('--json'), flag('--full'), flag('--no-index'), flag('--show-files')] }),
|
|
@@ -180,7 +181,7 @@ const definitions = [
|
|
|
180
181
|
]),
|
|
181
182
|
command('fix-membership', mutates('managed source sweep'), 'mutate', [form('[hubs...]', { args: positionals(0, Infinity), options: [flag('--json')] })]),
|
|
182
183
|
command('sync-status', mutates('managed source sweep'), 'mutate', [form('[hubs...]', { args: positionals(0, Infinity), options: [flag('--adopt'), flag('--json')] })]),
|
|
183
|
-
command('doctor', mutates('managed sweeps, repo index, and maintenance config paths by mode'), 'mutate', [form('[path]', { args: positionals(0, 1), options: [flag('--apply', '--yes'), flag('--statuses'), optionalValue('--migrate-template'), flag('--migrate-prompts'), flag('--frontmatter-fix'), flag('--project'), flag('--transactions'), flag('--claims'), flag('--session'), value('--older-than'), flag('--json'), flag('--include-archived')] })]),
|
|
184
|
+
command('doctor', mutates('managed sweeps, repo index, and maintenance config paths by mode'), 'mutate', [form('[path]', { args: positionals(0, 1), options: [flag('--apply', '--yes'), flag('--statuses'), optionalValue('--migrate-template'), flag('--migrate-prompts'), flag('--frontmatter-fix'), flag('--project'), flag('--prompts'), flag('--transactions'), flag('--claims'), flag('--session'), value('--older-than'), flag('--json'), flag('--include-archived')] })]),
|
|
184
185
|
command('statuses', mutates('project config path; document scan is read-only'), 'mutate', [
|
|
185
186
|
form('list', { subcommands: ['list'], options: [value('--type'), flag('--json')] }),
|
|
186
187
|
form('add <name>', { subcommands: ['add'], args: positionals(1, 1), options: STATUS_PROPERTY_OPTIONS }),
|
package/src/config.mjs
CHANGED
|
@@ -88,14 +88,10 @@ const DEFAULTS = {
|
|
|
88
88
|
skipStaleFor: ['archived', 'reference', 'partial', 'queued-after'],
|
|
89
89
|
skipWarningsFor: ['archived', 'partial', 'queued-after'],
|
|
90
90
|
terminalStatuses: ['archived', 'deprecated', 'reference'],
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
// list stays readable. Prompts were removed from this map 2026-08-30 along
|
|
96
|
-
// with their held/shelved statuses — `archived` is the only directory a
|
|
97
|
-
// prompt ever moves into.
|
|
98
|
-
filedStatuses: { paused: 'held' },
|
|
91
|
+
// Optional status → directory filing. Ordinary status changes keep the
|
|
92
|
+
// current path by default; archive remains a separate move. Repositories
|
|
93
|
+
// can opt in here or with per-status `filed: true` / `filed: 'held'`.
|
|
94
|
+
filedStatuses: {},
|
|
99
95
|
// Types whose archive nests under their own type dir (<typeDir>/<archiveDir>,
|
|
100
96
|
// e.g. docs/prompts/archived/) instead of the shared <root>/<archiveDir>.
|
|
101
97
|
// Prompts are session-local churn — keeping their archive out of the shared
|
|
@@ -465,14 +461,24 @@ function deepMerge(defaults, overrides, parentPath = '') {
|
|
|
465
461
|
return result;
|
|
466
462
|
}
|
|
467
463
|
|
|
468
|
-
|
|
464
|
+
// Locate repository data without importing or executing its JavaScript config.
|
|
465
|
+
export function locateConfig(cwd, explicitConfigPath) {
|
|
469
466
|
const configPath = explicitConfigPath
|
|
470
467
|
? path.resolve(cwd, explicitConfigPath)
|
|
471
468
|
: findConfigFile(cwd);
|
|
469
|
+
const configDir = configPath && existsSync(configPath) ? path.dirname(configPath) : cwd;
|
|
470
|
+
let repoRoot = configDir;
|
|
471
|
+
for (let dir = configDir; dir !== path.parse(dir).root; dir = path.dirname(dir)) {
|
|
472
|
+
if (existsSync(path.join(dir, '.git'))) { repoRoot = dir; break; }
|
|
473
|
+
}
|
|
474
|
+
return {configPath,configDir,repoRoot};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export async function resolveConfig(cwd, explicitConfigPath) {
|
|
478
|
+
const {configPath,configDir,repoRoot} = locateConfig(cwd, explicitConfigPath);
|
|
472
479
|
|
|
473
480
|
let userConfig = {};
|
|
474
481
|
let hooks = {};
|
|
475
|
-
let configDir = cwd;
|
|
476
482
|
|
|
477
483
|
if (configPath && existsSync(configPath)) {
|
|
478
484
|
const configUrl = pathToFileURL(configPath).href;
|
|
@@ -483,8 +489,6 @@ export async function resolveConfig(cwd, explicitConfigPath) {
|
|
|
483
489
|
die('Failed to load config: ' + configPath + '\n' + err.message + '\nCheck for syntax errors in your config file.');
|
|
484
490
|
}
|
|
485
491
|
|
|
486
|
-
configDir = path.dirname(configPath);
|
|
487
|
-
|
|
488
492
|
for (const [key, value] of Object.entries(mod)) {
|
|
489
493
|
if (key === 'default') continue;
|
|
490
494
|
if (typeof value === 'function') {
|
|
@@ -523,20 +527,6 @@ export async function resolveConfig(cwd, explicitConfigPath) {
|
|
|
523
527
|
}
|
|
524
528
|
}
|
|
525
529
|
|
|
526
|
-
// Find repo root by walking up looking for .git
|
|
527
|
-
let repoRoot = configDir;
|
|
528
|
-
{
|
|
529
|
-
let dir = configDir;
|
|
530
|
-
const fsRoot = path.parse(dir).root;
|
|
531
|
-
while (dir !== fsRoot) {
|
|
532
|
-
if (existsSync(path.join(dir, '.git'))) {
|
|
533
|
-
repoRoot = dir;
|
|
534
|
-
break;
|
|
535
|
-
}
|
|
536
|
-
dir = path.dirname(dir);
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
|
|
540
530
|
// Resolve document types
|
|
541
531
|
const typesConfig = config.types ?? {};
|
|
542
532
|
const validTypes = new Set(Object.keys(typesConfig));
|
package/src/doctor.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { installationReport, formatInstallationReport } from './installation.mjs';
|
|
3
|
+
import { diagnosePromptParents } from './prompt-handoff.mjs';
|
|
2
4
|
import path from 'node:path';
|
|
3
5
|
import { fixBrokenRefs } from './fix-refs.mjs';
|
|
4
6
|
import { runLint } from './lint.mjs';
|
|
@@ -235,6 +237,17 @@ function runDoctorSession(argv) {
|
|
|
235
237
|
}
|
|
236
238
|
|
|
237
239
|
export function runDoctor(argv, config, opts = {}) {
|
|
240
|
+
if (argv.includes('--prompts')) {
|
|
241
|
+
const index=buildIndex(config,{fast:true,invokeHooks:false});
|
|
242
|
+
const result=diagnosePromptParents(index.docs.filter(doc=>doc.type==='prompt'),config);
|
|
243
|
+
if(argv.includes('--json'))process.stdout.write(JSON.stringify(result,null,2)+'\n');
|
|
244
|
+
else {
|
|
245
|
+
process.stdout.write(`Prompt parent links: ${result.children} child handoffs · ${result.counts.linked??0} explicitly linked\n`);
|
|
246
|
+
for(const item of result.findings)process.stdout.write(` ${item.state}: ${item.path}${item.parentPrompt?` → ${item.parentPrompt}`:''}${item.candidates.length?` (${item.candidates.join(', ')})`:''}\n`);
|
|
247
|
+
if(result.findings.length)process.stdout.write('Inspect with `runlist prompts show <child>`. Repair an existing link with `runlist prompts link <child> --parent <resume-name>`; diagnostics change no prompts.\n');
|
|
248
|
+
}
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
238
251
|
if (argv.includes('--project')) {
|
|
239
252
|
runDoctorProject(config, { json: argv.includes('--json') });
|
|
240
253
|
return;
|
|
@@ -434,6 +447,7 @@ function runDoctorProject(config, { json = false } = {}) {
|
|
|
434
447
|
const skillDriftWarnings = checkSkillDrift(config);
|
|
435
448
|
const result = {
|
|
436
449
|
cliVersion: cliPackage?.version ?? null,
|
|
450
|
+
installations: installationReport(config),
|
|
437
451
|
packageDependency: depVersion,
|
|
438
452
|
claudeCommandWarnings,
|
|
439
453
|
deprecatedCommandMentions,
|
|
@@ -448,6 +462,7 @@ function runDoctorProject(config, { json = false } = {}) {
|
|
|
448
462
|
}
|
|
449
463
|
|
|
450
464
|
process.stdout.write(bold('runlist doctor --project') + '\n\n');
|
|
465
|
+
process.stdout.write(formatInstallationReport(result.installations)+'\n');
|
|
451
466
|
process.stdout.write(`- running CLI version: ${result.cliVersion ?? 'unknown'}\n`);
|
|
452
467
|
process.stdout.write(`- package dependency: ${result.packageDependency ?? '(none found)'}\n`);
|
|
453
468
|
process.stdout.write(`- stale Claude commands: ${claudeCommandWarnings.length}\n`);
|
package/src/hud.mjs
CHANGED
|
@@ -294,6 +294,8 @@ export function buildHud(config) {
|
|
|
294
294
|
const SUBAGENT_PRIMER = [
|
|
295
295
|
'runlist manages this repo\'s plans/docs/prompts (markdown + YAML frontmatter).',
|
|
296
296
|
'Verbs: plans|briefing | query <filters> | use [<file>] | set <status> <file> | new <type> <slug> | archive <file>.',
|
|
297
|
+
'Child handoff: `runlist baton --subagent <task-slug> @<draft-file>` saves subagent-<task-slug>. Add --parent resume-<name> when known to group it under its parent prompt. Use a task slug under a parent-owned plan; release only your own plan. Parent batons default to resume-<name>.',
|
|
298
|
+
'Child draft: `runlist baton --subagent --template --parent resume-<name>` prints the checkout’s child outline (built-in: Task, State, Next, Evidence and Boundaries). Carry parent_prompt in the draft or pass RUNLIST_PARENT_PROMPT into the child environment. Save full records at durable repo-relative paths. Repair old links with `runlist prompts link <child...> --parent resume-<name>`.',
|
|
297
299
|
'Claims: `runlist doctor --claims` lists which session holds each plan; `--apply` releases the ones whose session is gone.',
|
|
298
300
|
'Do NOT: cat/read a docs/prompts/*.md (use `runlist use <file>` — archive/claim commits before at-most-once output);',
|
|
299
301
|
'git add/commit a prompt (they are session-local, often gitignored); hand-edit a `status:` field (use `runlist set`).',
|
|
@@ -398,7 +400,7 @@ export function buildHandoffContext(config) {
|
|
|
398
400
|
if (definition) {
|
|
399
401
|
return `[runlist] Baton: ${who} ${command} Where the definition below names a command, use this form. ${tail}\nThis repo defines baton (templates.prompt.baton in its runlist config):\n${definition}`;
|
|
400
402
|
}
|
|
401
|
-
const draft = 'Write the resume first (
|
|
403
|
+
const draft = 'Write the resume first (current work and exact next move, decisions and waiting approvals, durable evidence paths, unverified claims and authorization boundaries; not a recap). `runlist baton --template` prints the checkout’s editable outline. Draft in a temporary file; keep working material at durable repo-relative paths.';
|
|
402
404
|
return `[runlist] Baton: ${who} ${draft} ${command} ${tail}`;
|
|
403
405
|
}
|
|
404
406
|
|
package/src/index.mjs
CHANGED
|
@@ -426,6 +426,8 @@ export function parseDocFile(filePath, config, opts = {}) {
|
|
|
426
426
|
path: relativePath,
|
|
427
427
|
root: rootLabel,
|
|
428
428
|
type: docType,
|
|
429
|
+
...(docType === 'prompt' ? { parentPrompt: asString(parsedFrontmatter.parent_prompt) ?? null,
|
|
430
|
+
parentPromptInvalid: (Object.hasOwn(parsedFrontmatter,'parent_prompt') && typeof parsedFrontmatter.parent_prompt !== 'string') || fmWarnings.some(w=>w.message.includes('`parent_prompt`')) } : {}),
|
|
429
431
|
status: asString(parsedFrontmatter.status) ?? null,
|
|
430
432
|
// Preserve ships without coercing lists/objects into apparently valid stages.
|
|
431
433
|
ships: parsedFrontmatter.ships ?? null,
|
package/src/install.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from './host-integration.mjs';
|
|
10
10
|
import { LEGACY_PLUGIN_ID, readInstalledPlugin, readInstalledPluginRecords } from './update.mjs';
|
|
11
11
|
import { executableName, which } from './util.mjs';
|
|
12
|
+
import { installationReport, formatInstallationReport } from './installation.mjs';
|
|
12
13
|
import { codexStatus, installCodexPlugin, installedCodexStatus } from './codex-integration.mjs';
|
|
13
14
|
|
|
14
15
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -38,12 +39,13 @@ function hostStates() {
|
|
|
38
39
|
};
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
function reportStatus(json) {
|
|
42
|
-
const states = hostStates();
|
|
42
|
+
function reportStatus(json, config) {
|
|
43
|
+
const states = hostStates(), installations = installationReport(config);
|
|
43
44
|
if (json) {
|
|
44
|
-
process.stdout.write(JSON.stringify({ cli: pkg.version, hosts: states }, null, 2) + '\n');
|
|
45
|
+
process.stdout.write(JSON.stringify({ cli: pkg.version, hosts: states, installations }, null, 2) + '\n');
|
|
45
46
|
return;
|
|
46
47
|
}
|
|
48
|
+
process.stdout.write(formatInstallationReport(installations)+'\n');
|
|
47
49
|
const { claude, codex, opencode } = states;
|
|
48
50
|
process.stdout.write(`${bold('runlist host integrations')} ${dim(`CLI ${pkg.version}`)}\n\n`);
|
|
49
51
|
|
|
@@ -210,7 +212,7 @@ export function runInstall(argv, _config, opts = {}) {
|
|
|
210
212
|
const json = argv.includes('--json');
|
|
211
213
|
const requested = argv.find(a => !a.startsWith('-'));
|
|
212
214
|
|
|
213
|
-
if (!requested) { reportStatus(json); return; }
|
|
215
|
+
if (!requested) { reportStatus(json, _config); return; }
|
|
214
216
|
const host = ALIASES[requested] ?? requested;
|
|
215
217
|
if (!HOSTS.includes(host)) {
|
|
216
218
|
process.stderr.write(`Unknown host "${requested}". Known: ${HOSTS.join(', ')}\n`);
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { desktopLaunch } from './desktop.mjs';
|
|
8
|
+
import { codexStatus } from './codex-integration.mjs';
|
|
9
|
+
import { opencodeStatus } from './host-integration.mjs';
|
|
10
|
+
|
|
11
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
|
+
function json(file) { try { return JSON.parse(readFileSync(file,'utf8')); } catch { return null; } }
|
|
13
|
+
function resolved(file) { try { return realpathSync(file); } catch { return null; } }
|
|
14
|
+
function executable(name, env) {
|
|
15
|
+
const suffixes = process.platform === 'win32' ? ['', '.cmd', '.exe'] : [''];
|
|
16
|
+
for (const dir of (env.PATH ?? '').split(path.delimiter).filter(Boolean)) for (const suffix of suffixes) {
|
|
17
|
+
const file = path.resolve(dir,name+suffix);
|
|
18
|
+
try { if (lstatSync(file).isFile() || lstatSync(file).isSymbolicLink()) return file; } catch {}
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Hash only the shared engine inputs, never plans, state, credentials or runtime
|
|
24
|
+
// binaries. Ignore the release label so a local candidate can prove code parity.
|
|
25
|
+
export function sourceFingerprint(root, entries = ['package.json','src','bin','assets','desktop/helper.mjs','desktop/trust.mjs','desktop/line-framer.mjs']) {
|
|
26
|
+
try {
|
|
27
|
+
const files=[],hash=createHash('sha256');let bytes=0;
|
|
28
|
+
const visit = name => {
|
|
29
|
+
const file=path.join(root,name),stat=lstatSync(file);
|
|
30
|
+
if(stat.isSymbolicLink())throw new Error('symlink');
|
|
31
|
+
if(stat.isDirectory()) { for(const child of readdirSync(file).sort())visit(`${name}/${child}`); }
|
|
32
|
+
else if(stat.isFile()) {
|
|
33
|
+
bytes+=stat.size;if(bytes>64*1024*1024||files.length>=2000)throw new Error('limit');
|
|
34
|
+
let content=readFileSync(file);
|
|
35
|
+
if(name==='package.json'){const pkg=JSON.parse(content);delete pkg.version;content=Buffer.from(JSON.stringify(pkg));}
|
|
36
|
+
hash.update(name+'\0').update(createHash('sha256').update(content).digest());files.push(name);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
for(const name of [...entries].sort())if(existsSync(path.join(root,name)))visit(name);
|
|
40
|
+
return files.length ? {state:'readable',sha256:hash.digest('hex'),files:files.length} : {state:'unavailable'};
|
|
41
|
+
} catch { return {state:'unreadable'}; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function identity(root) {
|
|
45
|
+
const realRoot=resolved(root),pkg=realRoot&&json(path.join(realRoot,'package.json'));
|
|
46
|
+
if(!pkg||typeof pkg!=='object'||Array.isArray(pkg))return {path:root,resolvedPath:realRoot,state:existsSync(root)?'unreadable':'missing'};
|
|
47
|
+
return {path:root,resolvedPath:realRoot,state:'readable',version:pkg.version??null,
|
|
48
|
+
kind:existsSync(path.join(realRoot,'.git'))?'checkout':'package',fingerprint:sourceFingerprint(realRoot)};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Shared with update; reading a host registry does not activate or modify it.
|
|
52
|
+
export function readInstalledPluginRecords({home=os.homedir(),id}={}) {
|
|
53
|
+
const plugins=json(path.join(home,'.claude/plugins/installed_plugins.json'))?.plugins??{};
|
|
54
|
+
id ??= plugins['runlist@runlist']?'runlist@runlist':plugins['dotmd@dotmd']?'dotmd@dotmd':Object.keys(plugins).find(k=>/^(?:runlist|dotmd)@/.test(k));
|
|
55
|
+
if(!id||!plugins[id])return null;
|
|
56
|
+
return {id,entries:(Array.isArray(plugins[id])?plugins[id]:[plugins[id]]).filter(Boolean)};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function installationReport(config={}, {root=packageRoot,home=os.homedir(),env=process.env,node=process.execPath,entry=process.argv[1],platform=process.platform,app}={}) {
|
|
60
|
+
config ??= {};
|
|
61
|
+
const cli=identity(root),nodeDirectory=path.dirname(node);
|
|
62
|
+
cli.launcher=entry?{path:path.resolve(entry),resolvedPath:resolved(path.resolve(entry))}:null;
|
|
63
|
+
const nodePrefix=path.basename(nodeDirectory)==='bin'?path.dirname(nodeDirectory):nodeDirectory;
|
|
64
|
+
const projectRoot=config.repoRoot??process.cwd(),projectPackage=json(path.join(projectRoot,'package.json'));
|
|
65
|
+
const dependency=projectPackage?.dependencies?.runlist??projectPackage?.devDependencies?.runlist??null;
|
|
66
|
+
let project={declaration:dependency,state:'not-resolved'};
|
|
67
|
+
try {
|
|
68
|
+
if(projectPackage?.name!=='runlist') {
|
|
69
|
+
let dir=path.dirname(createRequire(path.join(projectRoot,'package.json')).resolve('runlist'));
|
|
70
|
+
for(let n=0;n<8;n++,dir=path.dirname(dir))if(json(path.join(dir,'package.json'))?.name==='runlist'){project={...identity(dir),declaration:dependency};break;}
|
|
71
|
+
}
|
|
72
|
+
} catch {}
|
|
73
|
+
const appPath=app??desktopLaunch([],{platform,home,env}).app;
|
|
74
|
+
const desktop=appPath ? platform==='darwin'?{...identity(path.join(appPath,'Contents/Resources/engine')),application:appPath}: {application:appPath,state:'unverified'} : {state:'not-installed'};
|
|
75
|
+
desktop.sameEngineSource=cli.fingerprint?.sha256&&desktop.fingerprint?.sha256?cli.fingerprint.sha256===desktop.fingerprint.sha256:null;
|
|
76
|
+
const claude=readInstalledPluginRecords({home});
|
|
77
|
+
const codex=codexStatus({homedir:home,version:cli.version});
|
|
78
|
+
const market=json(codex.marketplace)?.name??'personal';
|
|
79
|
+
const cache=path.join(home,'.codex/plugins/cache',market,'runlist-codex');
|
|
80
|
+
let cached=[];
|
|
81
|
+
try {cached=readdirSync(cache).slice(0,20).map(version=>({path:path.join(cache,version),version,role:'cached copy; active host copy unverified'}));}catch{}
|
|
82
|
+
const pluginFiles=['skills','commands','hooks','.claude-plugin','.codex-plugin'];
|
|
83
|
+
const pluginCopy = (file,version,role,source) => {
|
|
84
|
+
const fingerprint=file?sourceFingerprint(file,pluginFiles):{state:'unavailable'},expected=sourceFingerprint(source,pluginFiles);
|
|
85
|
+
return {path:file,version:version??null,role,fingerprint,sameSource:fingerprint.sha256&&expected.sha256?fingerprint.sha256===expected.sha256:null};
|
|
86
|
+
};
|
|
87
|
+
const warnings=[];
|
|
88
|
+
const claudeRegistry=path.join(home,'.claude/plugins/installed_plugins.json');
|
|
89
|
+
if(existsSync(claudeRegistry)&&!json(claudeRegistry))warnings.push('Claude registry unreadable; installed copies are unknown.');
|
|
90
|
+
if(codex.foreign)warnings.push('Codex marketplace directory is unmanaged; its active installed copy is unverified.');
|
|
91
|
+
const integrations={
|
|
92
|
+
claude:(claude?.entries??[]).map(e=>pluginCopy(e.installPath,e.version,`${e.scope??'unknown'} registry entry`,path.join(root,'plugins/runlist'))),
|
|
93
|
+
codex:[...(codex.exists?[pluginCopy(codex.plugin,codex.version,'marketplace source; not proof of active host copy',path.join(root,'plugins/runlist-codex'))]:[]),...cached.map(e=>pluginCopy(e.path,e.version,e.role,path.join(root,'plugins/runlist-codex')))],
|
|
94
|
+
opencode:opencodeStatus({env,homedir:home,version:cli.version}),
|
|
95
|
+
};
|
|
96
|
+
return {cli,warnings,node:{path:node,version:process.version,prefix:nodePrefix,npm:executable('npm',env)},project,desktop,integrations,
|
|
97
|
+
updateEffect:'update installs the published CLI into the npm global scope selected by npm on PATH, replacing its local link if present. It does not edit checkout sources or a project dependency. Desktop and managed host copies are separate update steps.'};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function formatInstallationReport(report) {
|
|
101
|
+
const id = copy => `${copy.state}${copy.version?` ${copy.version}`:''}${copy.fingerprint?.sha256?` · source ${copy.fingerprint.sha256.slice(0,12)}`:''}`;
|
|
102
|
+
const lines=[`CLI: ${id(report.cli)} · ${report.cli.kind??'unknown'} · ${report.cli.resolvedPath??report.cli.path}`,
|
|
103
|
+
`Launcher: ${report.cli.launcher?.path??'unknown'} → ${report.cli.launcher?.resolvedPath??'unknown'}`,
|
|
104
|
+
`Node: ${report.node.version} · ${report.node.path} · prefix ${report.node.prefix}`,
|
|
105
|
+
`npm on PATH: ${report.node.npm??'not found'}`,
|
|
106
|
+
`Project dependency: ${report.project.declaration??'not declared'} · ${id(report.project)}${report.project.resolvedPath?` · ${report.project.resolvedPath}`:''}`,
|
|
107
|
+
`Desktop: ${id(report.desktop)}${report.desktop.application?` · ${report.desktop.application}`:''} · engine source ${report.desktop.sameEngineSource===null?'unverified':report.desktop.sameEngineSource?'matches CLI':'differs from CLI'}`];
|
|
108
|
+
for(const warning of report.warnings??[])lines.push(warning);
|
|
109
|
+
for(const host of ['claude','codex']) {
|
|
110
|
+
const copies=report.integrations[host];
|
|
111
|
+
if(!copies.length)lines.push(`${host}: no registered/cached copies found`);
|
|
112
|
+
for(const copy of copies)lines.push(`${host}: ${copy.version??'unknown'} · ${copy.role} · ${copy.path??'path unknown'} · source ${copy.sameSource===null?'unverified':copy.sameSource?'matches checkout':'differs from checkout'}`);
|
|
113
|
+
}
|
|
114
|
+
const oc=report.integrations.opencode;
|
|
115
|
+
lines.push(`opencode: ${oc.exists?`${oc.foreign?'unmanaged':oc.version??'unknown'} · ${oc.path}`:'not installed'}`,`Update effect: ${report.updateEffect}`);
|
|
116
|
+
return lines.join('\n')+'\n';
|
|
117
|
+
}
|