runlist 0.96.0 → 0.97.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +170 -43
  2. package/assets/app/app.css +189 -3
  3. package/assets/app/app.mjs +49 -31
  4. package/assets/app/document-create.mjs +3 -2
  5. package/assets/app/document-lifecycle.mjs +17 -8
  6. package/assets/app/document-stages.mjs +3 -1
  7. package/assets/app/document-views.mjs +23 -1
  8. package/assets/app/editor-navigation.mjs +16 -23
  9. package/assets/app/filing-navigation.mjs +18 -16
  10. package/assets/app/git-changes.mjs +11 -2
  11. package/assets/app/index.html +34 -23
  12. package/assets/app/library-navigation.mjs +29 -16
  13. package/assets/app/record-navigation.mjs +68 -23
  14. package/assets/app/recovery-center.mjs +8 -7
  15. package/assets/app/settings.mjs +48 -10
  16. package/assets/app/template-editor.mjs +22 -12
  17. package/assets/app/transport.mjs +1 -2
  18. package/bin/runlist.mjs +64 -15
  19. package/package.json +1 -1
  20. package/plugins/runlist-codex/.codex-plugin/plugin.json +1 -1
  21. package/plugins/runlist-codex/skills/runlist/SKILL.md +3 -1
  22. package/src/app.mjs +9 -4
  23. package/src/baton.mjs +49 -11
  24. package/src/brief.mjs +145 -0
  25. package/src/commands.mjs +4 -3
  26. package/src/config.mjs +16 -26
  27. package/src/doctor.mjs +15 -0
  28. package/src/extractors.mjs +7 -14
  29. package/src/flags.mjs +5 -3
  30. package/src/hud.mjs +32 -63
  31. package/src/index.mjs +2 -0
  32. package/src/install.mjs +6 -4
  33. package/src/installation.mjs +117 -0
  34. package/src/new.mjs +27 -7
  35. package/src/prompt-handoff.mjs +132 -0
  36. package/src/prompts.mjs +112 -16
  37. package/src/section.mjs +46 -11
  38. package/src/show.mjs +132 -16
  39. package/src/template-store.mjs +16 -5
  40. package/src/update.mjs +7 -28
  41. package/src/util.mjs +7 -0
package/src/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, resolveStatusMetadata } from './status-metadata.mjs';
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 — it has never seen the
290
- // command sheet the top-level session got. Without this, subagents reflexively
291
- // grep/cat/commit managed docs instead of using runlist. Keep it to a few dense
292
- // lines: the verbs + the three wrong-moves the guard exists to stop, so the
293
- // subagent self-corrects before the guard ever has to fire.
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
- '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>. Use a task slug under a parent-owned plan; release only your own plan. Parent batons default to resume-<name>.',
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 (the next concrete decision, any gotchas, the plan/doc paths it concerns; not a recap) to a file in your scratchpad.';
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
- // Names, not paths, and at most PROMPT_NAMES_SHOWN of them, so the line stays
418
- // one line however long the queue grows.
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
- const names = prompts.slice(0, PROMPT_NAMES_SHOWN).map(p => path.basename(p, '.md'));
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: the command primer, plus ONLY signals that carry a
461
- // direct instruction for this session. Passive state (error counts,
462
- // slash-command refresh notices, previous-self / fleet / recent-rejections)
463
- // stays suppressed — those nudged agents into phantom follow-up work (e.g.
464
- // "errors: 1" prompting a check run) and live in their proper commands and
465
- // `runlist hud --json`. Two signals ARE instructions and must print, because
466
- // the handoff loop dies without them (sessions were saving batons that no
467
- // next session ever picked up):
468
- // - pending prompts: earlier sessions queued work. The line lists them
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
- // The misuse recap stays for the same reason: a repeat-offense rule means
477
- // the primer alone isn't landing, so name the habit to break.
478
- process.stdout.write(dim('runlist: plans|briefing set <status> [<file>] new <type> <slug> use [<file>] archive <file> baton [<plan-or-slug>] @<draft-file> (save a resume prompt; releases the in-session plan if any)') + '\n');
479
- process.stdout.write(dim(buildPlanStatusPrimer(config)) + '\n');
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 doc, design note, module overview — build-up shape lite',
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 — build-up shape (Problem → Phases → Closeout) with phase status markers and Version History',
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 to seed a future Claude session — body is required',
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
- export function preparePromptDocument(name, bodyInput, config, { plan = null, dryRun = false } = {}) {
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
+ }