runlist 0.96.0 → 0.97.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +170 -43
- 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 +64 -15
- 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 +49 -11
- package/src/brief.mjs +145 -0
- package/src/commands.mjs +4 -3
- package/src/config.mjs +16 -26
- package/src/doctor.mjs +15 -0
- package/src/extractors.mjs +7 -14
- package/src/flags.mjs +5 -3
- package/src/hud.mjs +32 -63
- 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/section.mjs +46 -11
- package/src/show.mjs +132 -16
- package/src/template-store.mjs +16 -5
- package/src/update.mjs +7 -28
- package/src/util.mjs +7 -0
package/src/hud.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { compareVersions } from './update.mjs';
|
|
|
12
12
|
import { findOwnedPlan } from './baton.mjs';
|
|
13
13
|
import { listOwnedPlans, surveyOwnershipClaims } from './pickup.mjs';
|
|
14
14
|
import { handoffNotDoneLine, pendingHandoffsForSession } from './handoff-record.mjs';
|
|
15
|
-
import { actionablePromptStatuses, comparePromptDocs
|
|
15
|
+
import { actionablePromptStatuses, comparePromptDocs } from './status-metadata.mjs';
|
|
16
16
|
|
|
17
17
|
export { actionablePromptStatuses } from './status-metadata.mjs';
|
|
18
18
|
|
|
@@ -286,39 +286,18 @@ export function buildHud(config) {
|
|
|
286
286
|
}
|
|
287
287
|
|
|
288
288
|
// Subagent primer: a spawned subagent (Explore, Plan, general-purpose) starts
|
|
289
|
-
// with ZERO project context and no SessionStart history
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
289
|
+
// with ZERO project context and no SessionStart history. Without this,
|
|
290
|
+
// subagents reflexively grep/cat/commit managed docs instead of using runlist.
|
|
291
|
+
// It fires on every SubagentStart and CwdChanged, so every byte here is paid
|
|
292
|
+
// again and again: the read verbs, the three wrong-moves the guard exists to
|
|
293
|
+
// stop, and a pointer for the rare child handoff. Statuses are not listed —
|
|
294
|
+
// `runlist set` names the valid ones when given a wrong one.
|
|
294
295
|
const SUBAGENT_PRIMER = [
|
|
295
|
-
'runlist manages this repo\'s plans/docs/prompts (markdown + YAML frontmatter).',
|
|
296
|
-
'
|
|
297
|
-
'
|
|
298
|
-
'Claims: `runlist doctor --claims` lists which session holds each plan; `--apply` releases the ones whose session is gone.',
|
|
299
|
-
'Do NOT: cat/read a docs/prompts/*.md (use `runlist use <file>` — archive/claim commits before at-most-once output);',
|
|
300
|
-
'git add/commit a prompt (they are session-local, often gitignored); hand-edit a `status:` field (use `runlist set`).',
|
|
296
|
+
'runlist manages this repo\'s plans/docs/prompts (markdown + YAML frontmatter). Read a plan or long doc with `runlist show <file>` (outline and open boxes with line numbers), then `runlist show <file> --section "<heading>"`, never whole. Orient: `runlist plans`.',
|
|
297
|
+
'Do NOT: cat/read a docs/prompts/*.md (use `runlist use <file>`); git add/commit a prompt (session-local); hand-edit a `status:` field (use `runlist set <status> <file>`).',
|
|
298
|
+
'Handing work back as a saved prompt: `runlist baton --subagent <task-slug> @<draft-file>` (`runlist help baton` for --parent and --template).',
|
|
301
299
|
].join('\n');
|
|
302
300
|
|
|
303
|
-
export function buildPlanStatusPrimer(config, { maxChars = 220 } = {}) {
|
|
304
|
-
const statuses = (resolveStatusMetadata(config).byType.plan ?? []).map(item => item.name);
|
|
305
|
-
const fallback = 'run `runlist statuses list --type plan`';
|
|
306
|
-
if (statuses.length === 0) return `Plan statuses unavailable; ${fallback}.`;
|
|
307
|
-
const prefix = 'Plan statuses: ';
|
|
308
|
-
const full = `${prefix}${statuses.join(', ')}`;
|
|
309
|
-
if (full.length <= maxChars) return full;
|
|
310
|
-
|
|
311
|
-
const shown = [];
|
|
312
|
-
for (const status of statuses) {
|
|
313
|
-
const omitted = statuses.length - shown.length - 1;
|
|
314
|
-
const candidate = `${prefix}${[...shown, status].join(', ')}, ... (+${omitted}; ${fallback})`;
|
|
315
|
-
if (candidate.length > maxChars) break;
|
|
316
|
-
shown.push(status);
|
|
317
|
-
}
|
|
318
|
-
const omitted = statuses.length - shown.length;
|
|
319
|
-
return `${prefix}${shown.join(', ')}${shown.length ? ', ' : ''}... (+${omitted}; ${fallback})`;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
301
|
// The plugin's SessionStart/SubagentStart hooks fire in EVERY repo (it's enabled
|
|
323
302
|
// globally), but the primer only helps where runlist is actually used. Gate on a
|
|
324
303
|
// discovered config: `runlist init` writes runlist.config.mjs, so "has a config" is
|
|
@@ -399,7 +378,7 @@ export function buildHandoffContext(config) {
|
|
|
399
378
|
if (definition) {
|
|
400
379
|
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}`;
|
|
401
380
|
}
|
|
402
|
-
const draft = 'Write the resume first (
|
|
381
|
+
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.';
|
|
403
382
|
return `[runlist] Baton: ${who} ${draft} ${command} ${tail}`;
|
|
404
383
|
}
|
|
405
384
|
|
|
@@ -414,14 +393,11 @@ export async function runPromptSubmitHud(config, { readStdin } = {}) {
|
|
|
414
393
|
process.stdout.write(buildHandoffContext(config) + '\n');
|
|
415
394
|
}
|
|
416
395
|
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
const PROMPT_NAMES_SHOWN = 12;
|
|
396
|
+
// A count, not names: a queue of dozens printed a dozen names nobody asked
|
|
397
|
+
// about, and `runlist prompts` lists them when the user names one.
|
|
420
398
|
export function pendingPromptsLine(prompts) {
|
|
421
399
|
const n = prompts.length;
|
|
422
|
-
|
|
423
|
-
const more = n > names.length ? ` (+${n - names.length} more: \`runlist prompts\`)` : '';
|
|
424
|
-
return `[runlist] ${n} pending prompt${n === 1 ? '' : 's'}: ${names.join(', ')}${more}. Resume one only when the user asks for it (\`runlist use <name>\`); never consume the oldest by default. Peek without consuming: \`runlist prompts show <name>\`.`;
|
|
400
|
+
return `${n} pending prompt${n === 1 ? '' : 's'}: resume one only when the user asks for it (\`runlist prompts\` lists them, \`runlist use <name>\` resumes one, \`runlist prompts show <name>\` peeks); never consume the oldest by default.`;
|
|
425
401
|
}
|
|
426
402
|
|
|
427
403
|
function isRunlistRepo(config) {
|
|
@@ -440,7 +416,6 @@ export function runHud(argv, config) {
|
|
|
440
416
|
if (argv.includes('--subagent')) {
|
|
441
417
|
if (!runlistRepo) return; // silent in repos that don't use runlist
|
|
442
418
|
process.stdout.write(dim(SUBAGENT_PRIMER) + '\n');
|
|
443
|
-
process.stdout.write(dim(buildPlanStatusPrimer(config)) + '\n');
|
|
444
419
|
if (drift) process.stdout.write(yellow(drift) + '\n');
|
|
445
420
|
return;
|
|
446
421
|
}
|
|
@@ -457,26 +432,29 @@ export function runHud(argv, config) {
|
|
|
457
432
|
|
|
458
433
|
const hud = buildHudFast(config);
|
|
459
434
|
|
|
460
|
-
// SessionStart contract:
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
// and says to resume one only when the user asks. It used to say
|
|
470
|
-
// "consume the oldest" unless told otherwise, but a new session nearly
|
|
435
|
+
// SessionStart contract: ONLY signals that carry a direct instruction for
|
|
436
|
+
// this session, and nothing at all in a clean repo. No command sheet and no
|
|
437
|
+
// status list: the runlist skill carries the verbs, and `runlist set` names
|
|
438
|
+
// the valid statuses when given a wrong one. Passive state (error counts,
|
|
439
|
+
// previous-self / fleet / recent-rejections) stays suppressed — it nudged
|
|
440
|
+
// agents into phantom follow-up work and lives in its own commands and
|
|
441
|
+
// `runlist hud --json`. What prints:
|
|
442
|
+
// - pending prompts, as a count: earlier sessions queued work, and the
|
|
443
|
+
// line says to resume one only when the user asks. A new session nearly
|
|
471
444
|
// always has a task from the user, the hook cannot see it, and the
|
|
472
445
|
// oldest prompt is rarely the useful one.
|
|
446
|
+
// - open flags, as a count: the person asked that every session start
|
|
447
|
+
// knowing where things stand. Worded as awareness, not an instruction.
|
|
473
448
|
// - an in-session plan attributed to this sid via durable ownership: this
|
|
474
449
|
// session (pre-compaction) owns it and should continue or hand it off.
|
|
475
450
|
// Global in-session counts never provide a fallback.
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
|
|
479
|
-
|
|
451
|
+
// - an unfinished handoff, a repeat-offense misuse rule, and plans held by
|
|
452
|
+
// sessions that are gone: each names the command that clears it.
|
|
453
|
+
const queue = [];
|
|
454
|
+
if (hud.prompts.length > 0) queue.push(pendingPromptsLine(hud.prompts));
|
|
455
|
+
const flagsLine = flagsHudLine(config);
|
|
456
|
+
if (flagsLine) queue.push(flagsLine);
|
|
457
|
+
if (queue.length) process.stdout.write(yellow(`[runlist] ${queue.join(' ')}`) + '\n');
|
|
480
458
|
if (hud.owned && hud.owned.via === 'ownership') {
|
|
481
459
|
process.stdout.write(yellow(`[runlist] in-session (yours): ${hud.owned.path} — continue it; hand off with \`runlist baton @/tmp/draft.md\` before stopping.`) + '\n');
|
|
482
460
|
}
|
|
@@ -485,16 +463,7 @@ export function runHud(argv, config) {
|
|
|
485
463
|
try {
|
|
486
464
|
for (const record of pendingHandoffsForSession(config)) process.stdout.write(yellow(`[runlist] ${handoffNotDoneLine(record, `runlist baton --done ${record.plan}`)}`) + '\n');
|
|
487
465
|
} catch { /* advisory */ }
|
|
488
|
-
if (hud.prompts.length > 0) {
|
|
489
|
-
const n = hud.prompts.length;
|
|
490
|
-
process.stdout.write(yellow(pendingPromptsLine(hud.prompts)) + '\n');
|
|
491
|
-
}
|
|
492
466
|
if (hud.misuseRecap) process.stdout.write(yellow(`[runlist] ${hud.misuseRecap}`) + '\n');
|
|
493
467
|
if (hud.deadClaimsLine) process.stdout.write(yellow(hud.deadClaimsLine) + '\n');
|
|
494
|
-
// Open flags are the one piece of passive state printed here: the person
|
|
495
|
-
// asked that every session start knowing where things stand. It is worded
|
|
496
|
-
// as awareness, not an instruction, so no session treats it as its task.
|
|
497
|
-
const flagsLine = flagsHudLine(config);
|
|
498
|
-
if (flagsLine) process.stdout.write(yellow(flagsLine) + '\n');
|
|
499
468
|
if (drift) process.stdout.write(yellow(drift) + '\n');
|
|
500
469
|
}
|
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
|
+
}
|
package/src/new.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { applyTemplateOverride, readTemplateStore, templateFile } from './template-store.mjs';
|
|
2
|
+
import { HANDOFF_TEMPLATES } from './prompt-handoff.mjs';
|
|
2
3
|
import { spawnSync } from 'node:child_process';
|
|
3
4
|
import { existsSync, readFileSync, mkdirSync, fstatSync } from 'node:fs';
|
|
4
5
|
import path from 'node:path';
|
|
@@ -86,7 +87,7 @@ export function catchAllRoot(config) {
|
|
|
86
87
|
|
|
87
88
|
const BUILTIN_TEMPLATES = {
|
|
88
89
|
doc: {
|
|
89
|
-
description: 'Reference
|
|
90
|
+
description: 'Reference document for context, guidance or design notes; active by default.',
|
|
90
91
|
defaultStatus: 'active',
|
|
91
92
|
// Body input optional. When passed (inline / --body / @file / stdin),
|
|
92
93
|
// it lands in the Overview section. Without it, Overview is left blank
|
|
@@ -132,7 +133,7 @@ ${ctx?.bodyInput?.trim() ?? ''}
|
|
|
132
133
|
},
|
|
133
134
|
},
|
|
134
135
|
plan: {
|
|
135
|
-
description: 'Execution plan
|
|
136
|
+
description: 'Execution plan with goals, constraints, decisions, phases and closeout; planned until work starts.',
|
|
136
137
|
dir: 'plans',
|
|
137
138
|
targetRoot: 'plans',
|
|
138
139
|
// A scaffolded plan is scoped, not being worked; `runlist use` starts it.
|
|
@@ -227,7 +228,7 @@ Status markers (put in heading text):
|
|
|
227
228
|
},
|
|
228
229
|
},
|
|
229
230
|
prompt: {
|
|
230
|
-
description: 'Saved prompt
|
|
231
|
+
description: 'Saved prompt wrapper: metadata and authored body. Use the resume or subagent draft to write a handoff.',
|
|
231
232
|
dir: 'prompts',
|
|
232
233
|
targetRoot: 'prompts',
|
|
233
234
|
defaultStatus: 'pending',
|
|
@@ -419,7 +420,14 @@ export function promptDirectory(config, template = resolveTemplate('prompt', con
|
|
|
419
420
|
return template.dir && !routed ? path.join(targetRoot, template.dir) : targetRoot;
|
|
420
421
|
}
|
|
421
422
|
|
|
422
|
-
|
|
423
|
+
function bindPromptParent(content, parentPrompt) {
|
|
424
|
+
if (!parentPrompt) return content;
|
|
425
|
+
const { frontmatter, body } = extractFrontmatter(content);
|
|
426
|
+
if (!frontmatter) throw new Error('Prompt template must return frontmatter for parent binding.');
|
|
427
|
+
return `---\n${mergeBodyFrontmatter(frontmatter, { parent_prompt: parentPrompt }, 'prompt')}\n---\n${body}`;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function preparePromptDocument(name, bodyInput, config, { plan = null, parentPrompt = null, dryRun = false } = {}) {
|
|
423
431
|
const template = resolveTemplate('prompt', config);
|
|
424
432
|
const typeStatuses = config.typeStatuses?.get('prompt');
|
|
425
433
|
const status = template.defaultStatus && (!typeStatuses || typeStatuses.has(template.defaultStatus))
|
|
@@ -451,7 +459,7 @@ export function preparePromptDocument(name, bodyInput, config, { plan = null, dr
|
|
|
451
459
|
if (plan) fm = mergeBodyFrontmatter(fm, { plan }, 'prompt');
|
|
452
460
|
content = `---\n${fm}\n---\n${template.body(title, ctx)}`;
|
|
453
461
|
}
|
|
454
|
-
return { slug, filePath, repoPath: toRepoPath(filePath, config.repoRoot), content };
|
|
462
|
+
return { slug, filePath, repoPath: toRepoPath(filePath, config.repoRoot), content: bindPromptParent(content, parentPrompt) };
|
|
455
463
|
}
|
|
456
464
|
|
|
457
465
|
// Resolve one `--runlist` token to a scaffolded child plan: a bare slug becomes
|
|
@@ -1081,6 +1089,8 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
1081
1089
|
return;
|
|
1082
1090
|
}
|
|
1083
1091
|
|
|
1092
|
+
if (typeName === 'prompt') content = bindPromptParent(content, opts.parentPrompt);
|
|
1093
|
+
|
|
1084
1094
|
// Ensure parent dir exists (templates with `dir:` may target a new subdirectory)
|
|
1085
1095
|
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
1086
1096
|
|
|
@@ -1314,8 +1324,18 @@ export function appTemplates(config) {
|
|
|
1314
1324
|
const ctx = {title:'{{title}}',status:'{{status}}',today:'{{date}}',bodyInput:'{{body}}',validSurfaces:config.raw?.taxonomy?.surfaces ?? (config.validSurfaces ? [...config.validSurfaces] : null),validModules:config.raw?.taxonomy?.modules ?? (config.validModules ? [...config.validModules] : null)};
|
|
1315
1325
|
const defaultSource = `---\n${builtin.frontmatter(ctx.status,ctx.today,ctx)}\n---\n${builtin.body(ctx.title,ctx)}`.replaceAll(pkg.version,'{{version}}');
|
|
1316
1326
|
const configured = config.raw?.templates?.[name];
|
|
1317
|
-
return {name,description:builtin.description,source:store.data.templates[name]?.source ?? defaultSource,defaultSource,overridden:!!store.data.templates[name],origin:store.data.templates[name] ? templateFile : configured ? `${configFile} (JavaScript)` : 'Built-in',configuredSource:configured ? code(configured) : null,editable:true};
|
|
1327
|
+
return {name,label:name==='prompt'?'Prompt wrapper':name==='plan'?'Execution plan':'Reference doc',kind:'document',description:builtin.description,source:store.data.templates[name]?.source ?? defaultSource,defaultSource,overridden:!!store.data.templates[name],origin:store.data.templates[name] ? templateFile : configured ? `${configFile} (JavaScript)` : 'Built-in',configuredSource:configured ? code(configured) : null,editable:true};
|
|
1328
|
+
});
|
|
1329
|
+
const baton = config.raw?.templates?.prompt?.baton;
|
|
1330
|
+
for (const [name,draft] of Object.entries(HANDOFF_TEMPLATES)) templates.push({
|
|
1331
|
+
name,label:draft.label,kind:'handoff',description:draft.description,
|
|
1332
|
+
source:store.data.templates[name]?.source ?? draft.source,defaultSource:draft.source,
|
|
1333
|
+
overridden:!!store.data.templates[name],origin:store.data.templates[name] ? templateFile : 'Built-in',
|
|
1334
|
+
configuredSource:typeof baton==='string'&&baton.trim()?baton.trim():null,
|
|
1335
|
+
configLabel:'Repository baton instructions',
|
|
1336
|
+
configNote:'These repository instructions still apply to handoffs. Saving a draft outline does not replace them or the saved prompt wrapper.',
|
|
1337
|
+
editable:true,
|
|
1318
1338
|
});
|
|
1319
|
-
for (const [name,value] of Object.entries(config.raw?.templates ?? {})) if (!BUILTIN_TEMPLATES[name]) templates.push({name,description:value.description ?? 'Repository template',origin:`${configFile} (JavaScript)`,source:code(value),editable:false});
|
|
1339
|
+
for (const [name,value] of Object.entries(config.raw?.templates ?? {})) if (!BUILTIN_TEMPLATES[name]&&!Object.hasOwn(HANDOFF_TEMPLATES,name)) templates.push({name,description:value.description ?? 'Repository template',origin:`${configFile} (JavaScript)`,source:code(value),editable:false});
|
|
1320
1340
|
return {templates,revision:store.revision,file:templateFile,version:pkg.version};
|
|
1321
1341
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
export function parentPromptName(value) {
|
|
4
|
+
if (typeof value !== 'string' || !/^resume-[a-z0-9]+(?:-[a-z0-9]+)*(?:\.md)?$/.test(value)) {
|
|
5
|
+
throw new Error('A parent resume prompt name is required, e.g. resume-session-name.');
|
|
6
|
+
}
|
|
7
|
+
return value.replace(/\.md$/, '');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const HANDOFF_TEMPLATES = {
|
|
11
|
+
'handoff:resume': {
|
|
12
|
+
label: 'Resume draft',
|
|
13
|
+
description: 'Cold-start handoff outline for baton --template; does not change saved prompt formatting or repository baton instructions.',
|
|
14
|
+
source: `<!-- Follow repository baton instructions. Keep this focused on what the next session needs. -->
|
|
15
|
+
## Resume
|
|
16
|
+
<!-- Work to resume and owning plan path + section, or a durable brief if there is no plan. -->
|
|
17
|
+
|
|
18
|
+
## State and next move
|
|
19
|
+
<!-- What is in hand, what remains, and the exact next action or blocker. -->
|
|
20
|
+
|
|
21
|
+
## Decisions and waiting items
|
|
22
|
+
<!-- Why this direction; rejected or failed approaches that should not be repeated. -->
|
|
23
|
+
<!-- Outstanding decisions, answers or approvals, with a durable record path for each. -->
|
|
24
|
+
|
|
25
|
+
## Evidence and working material
|
|
26
|
+
<!-- Relevant commits, checks that passed, and claims still unverified. -->
|
|
27
|
+
<!-- Repo-relative paths to briefs, reports and scripts; scratch paths are supplementary. -->
|
|
28
|
+
|
|
29
|
+
## Boundaries
|
|
30
|
+
<!-- Current user priorities, authorization limits and anything requiring a new go. -->
|
|
31
|
+
<!-- Review linked child handoffs and record completed results in the owning plan. -->
|
|
32
|
+
`,
|
|
33
|
+
},
|
|
34
|
+
'handoff:subagent': {
|
|
35
|
+
label: 'Subagent draft',
|
|
36
|
+
description: 'Bounded child handoff outline for baton --subagent --template; parent_prompt links it to a resume prompt.',
|
|
37
|
+
source: `---
|
|
38
|
+
parent_prompt: {{parent_prompt}}
|
|
39
|
+
---
|
|
40
|
+
<!-- Follow the repository's baton instructions alongside these child-specific fields. -->
|
|
41
|
+
## Task
|
|
42
|
+
<!-- One sentence: the bounded job and its owning plan or brief. -->
|
|
43
|
+
|
|
44
|
+
## State
|
|
45
|
+
<!-- Continue, blocked, or complete. If complete, say what the parent must record. -->
|
|
46
|
+
|
|
47
|
+
## Next
|
|
48
|
+
<!-- The exact next action, or the blocker and what clears it. No remaining work: say so. -->
|
|
49
|
+
<!-- Include outstanding decisions or approvals and where each is recorded. -->
|
|
50
|
+
|
|
51
|
+
## Evidence
|
|
52
|
+
<!-- Commits, checks that passed, and checks still unverified. -->
|
|
53
|
+
<!-- Keep the full record in a durable repo-relative path; scratch paths are supplementary. -->
|
|
54
|
+
|
|
55
|
+
## Boundaries
|
|
56
|
+
<!-- Owned files, shared claims, current authorization, and any required new go. -->
|
|
57
|
+
<!-- Do not release or claim the parent's plan. This handoff grants no new authority. -->
|
|
58
|
+
`,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Explicit links win, including missing/ambiguous parents. Legacy child names
|
|
63
|
+
// may share a parent stem; the longest whole-stem match is the most specific.
|
|
64
|
+
|
|
65
|
+
export function promptParentCandidates(doc, parents) {
|
|
66
|
+
if(doc.parentPromptInvalid)return [];
|
|
67
|
+
const slug = doc => path.basename(doc.path, '.md');
|
|
68
|
+
let matches;
|
|
69
|
+
if (doc.parentPrompt) {
|
|
70
|
+
const ref = doc.parentPrompt.replace(/\.md$/, '');
|
|
71
|
+
matches = parents.filter(parent => ref.includes('/')
|
|
72
|
+
? parent.path.replace(/\.md$/, '') === ref
|
|
73
|
+
: path.dirname(parent.path) === path.dirname(doc.path) && slug(parent) === ref);
|
|
74
|
+
} else {
|
|
75
|
+
const stem = slug(doc).replace(/^(?:subagent|agent|session)-/, '');
|
|
76
|
+
if (stem === slug(doc)) return [];
|
|
77
|
+
matches = parents.filter(parent => {
|
|
78
|
+
const parentStem = slug(parent).slice('resume-'.length);
|
|
79
|
+
return path.dirname(parent.path) === path.dirname(doc.path)
|
|
80
|
+
&& (stem === parentStem || stem.startsWith(`${parentStem}-`));
|
|
81
|
+
}).sort((a, b) => slug(b).length - slug(a).length);
|
|
82
|
+
if (matches.length > 1 && slug(matches[0]).length > slug(matches[1]).length) matches = matches.slice(0, 1);
|
|
83
|
+
}
|
|
84
|
+
return matches;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function groupPrompts(prompts) {
|
|
88
|
+
const slug = doc => path.basename(doc.path, '.md');
|
|
89
|
+
const parents = prompts.filter(doc => slug(doc).startsWith('resume-')&&!doc.parentPrompt&&!doc.parentPromptInvalid);
|
|
90
|
+
const children = new Map();
|
|
91
|
+
const parentFor = new Map();
|
|
92
|
+
for (const doc of prompts) {
|
|
93
|
+
if (slug(doc).startsWith('resume-')) continue;
|
|
94
|
+
const matches = promptParentCandidates(doc, parents);
|
|
95
|
+
if (matches.length !== 1) continue;
|
|
96
|
+
const parent = matches[0];
|
|
97
|
+
parentFor.set(doc, parent);
|
|
98
|
+
if (!children.has(parent)) children.set(parent, []);
|
|
99
|
+
children.get(parent).push(doc);
|
|
100
|
+
}
|
|
101
|
+
const groups = [], emitted = new Set();
|
|
102
|
+
for (const doc of prompts) {
|
|
103
|
+
const root = parentFor.get(doc) ?? doc;
|
|
104
|
+
if (emitted.has(root)) continue;
|
|
105
|
+
emitted.add(root);
|
|
106
|
+
groups.push({ doc: root, children: children.get(root) ?? [] });
|
|
107
|
+
}
|
|
108
|
+
return groups;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function diagnosePromptParents(prompts, config) {
|
|
112
|
+
const archived = doc => doc.status === 'archived' || doc.path.split('/').includes(config.archiveDir);
|
|
113
|
+
const parents = prompts.filter(doc => path.basename(doc.path).startsWith('resume-'));
|
|
114
|
+
const live = parents.filter(doc => !archived(doc)&&!doc.parentPrompt&&!doc.parentPromptInvalid);
|
|
115
|
+
const history = parents.filter(archived).map(doc => ({...doc, path:doc.path.split('/').filter(part=>part!==config.archiveDir).join('/'), archivedPath:doc.path}));
|
|
116
|
+
const links=[];
|
|
117
|
+
for(const doc of prompts.filter(doc=>!archived(doc))) {
|
|
118
|
+
const isResume=path.basename(doc.path).startsWith('resume-');
|
|
119
|
+
if(!doc.parentPrompt&&!doc.parentPromptInvalid&&(isResume||!/^(?:subagent|agent|session)-/.test(path.basename(doc.path))))continue;
|
|
120
|
+
let state,candidates=[];
|
|
121
|
+
if(doc.parentPromptInvalid||(isResume&&doc.parentPrompt))state='invalid';
|
|
122
|
+
else {
|
|
123
|
+
candidates=promptParentCandidates(doc,live);
|
|
124
|
+
if(candidates.length===1)state=doc.parentPrompt?'linked':'inferred';
|
|
125
|
+
else if(candidates.length>1)state='ambiguous';
|
|
126
|
+
else {candidates=promptParentCandidates(doc,history);state=candidates.length?'archived':'missing';}
|
|
127
|
+
}
|
|
128
|
+
links.push({path:doc.path,parentPrompt:doc.parentPrompt??null,state,candidates:candidates.map(parent=>parent.archivedPath??parent.path)});
|
|
129
|
+
}
|
|
130
|
+
const counts={};for(const link of links)counts[link.state]=(counts[link.state]??0)+1;
|
|
131
|
+
return {children:links.length,counts,links,findings:links.filter(link=>link.state!=='linked')};
|
|
132
|
+
}
|