runlist 0.96.0 → 0.96.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/baton.mjs CHANGED
@@ -10,6 +10,8 @@ import { handoffNotDoneLine, recordPendingHandoff, runBatonDone } from './handof
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
@@ -124,6 +126,8 @@ export async function runBaton(argv, config, opts = {}) {
124
126
  let replace = false;
125
127
  let done = false;
126
128
  let subagent = false;
129
+ let parentPrompt = null;
130
+ let template = false;
127
131
  const positionals = [];
128
132
  for (let i = 0; i < argv.length; i++) {
129
133
  const a = argv[i];
@@ -134,10 +138,31 @@ export async function runBaton(argv, config, opts = {}) {
134
138
  if (a === '--replace') { replace = true; continue; }
135
139
  if (a === '--done') { done = true; continue; }
136
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
+ }
137
148
  if (a === '--json') continue;
138
149
  if (!a.startsWith('-') || a === '-' || a.startsWith('@')) { positionals.push(a); continue; }
139
150
  die(`Unknown flag for \`runlist baton\`: ${a}`);
140
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
+ }
141
166
 
142
167
  let planArg = null;
143
168
  let bodyArg = null;
@@ -164,6 +189,13 @@ export async function runBaton(argv, config, opts = {}) {
164
189
  body = readPipedBodyInput();
165
190
  }
166
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
+ }
167
199
 
168
200
  // Resolve what's being handed off. Two modes:
169
201
  // plan mode — a plan is released alongside the prompt (one status flip).
@@ -249,10 +281,14 @@ export async function runBaton(argv, config, opts = {}) {
249
281
  if (!planPath && priorPlanRef) {
250
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.`);
251
283
  }
284
+ const priorParent = asString(fm.parent_prompt);
285
+ parentPrompt ??= priorParent;
252
286
  replacement = {
253
- oldPath, oldRepoPath, oldRaw,
287
+ oldPath, oldRepoPath, oldRaw, priorParent,
254
288
  archivedPath: archivedCopyPath(oldPath, config),
255
- 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
256
292
  && (!planPath || (priorPlanPath && path.resolve(priorPlanPath) === path.resolve(planPath))),
257
293
  canonicalPath: target.filePath,
258
294
  };
@@ -324,6 +360,12 @@ export async function runBaton(argv, config, opts = {}) {
324
360
  if (note) warn(`--note ignored — no plan involved in this handoff (notes land in a plan's Version History).`);
325
361
  }
326
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
+
327
369
  const preparedReplacement = prepared => {
328
370
  if (!replacement || replacement.alreadyCurrent) return { updates: [], creations: [], guards: [] };
329
371
  let archivedContent = renderLifecycleMutation(replacement.oldRaw,
@@ -353,14 +395,14 @@ export async function runBaton(argv, config, opts = {}) {
353
395
  try {
354
396
  if (!planPath) {
355
397
  const promptName = replacement ? path.basename(replacement.oldPath, '.md') : slugBase;
356
- const prepared = preparePromptDocument(promptName, body, config, { dryRun });
398
+ const prepared = preparePromptDocument(promptName, body, config, { parentPrompt, dryRun });
357
399
  if (replacement) {
358
400
  const mutation = preparedReplacement(prepared);
359
401
  if (!dryRun && !replacement.alreadyCurrent) mutateFileSet(mutation, { repoRoot: config.repoRoot, testHooks: opts.testHooks });
360
402
  newResult = { sessionFiles: [replacement.oldRepoPath, ...(replacement.alreadyCurrent ? [] : [toRepoPath(replacement.archivedPath, config.repoRoot)])] };
361
403
  } else {
362
404
  try {
363
- newResult = await runNew(['prompt', slugBase, '--body', body], config, { dryRun, deferIndex: true });
405
+ newResult = await runNew(['prompt', slugBase, '--body', body], config, { parentPrompt, dryRun, deferIndex: true });
364
406
  } catch (err) {
365
407
  // Lost a race with another baton between the pending check and the write.
366
408
  if (/File already exists/.test(String(err?.message))) refusePendingHandoff([prepared.repoPath]);
@@ -371,7 +413,7 @@ export async function runBaton(argv, config, opts = {}) {
371
413
  createdSlug = path.basename(promptRepoPath, '.md');
372
414
  } else {
373
415
  const promptName = replacement ? path.basename(replacement.oldPath, '.md') : slugBase;
374
- const prepared = preparePromptDocument(promptName, body, config, { plan: repoPath, dryRun });
416
+ const prepared = preparePromptDocument(promptName, body, config, { plan: repoPath, parentPrompt, dryRun });
375
417
  const mutation = preparedReplacement(prepared);
376
418
  const setArgs = [status, planPath];
377
419
  if (force) setArgs.push('--force');
@@ -434,6 +476,7 @@ export async function runBaton(argv, config, opts = {}) {
434
476
  }
435
477
  const operationResult = {
436
478
  operation: 'baton',
479
+ parentPrompt,
437
480
  dryRun: Boolean(dryRun),
438
481
  disposition: replacement?.alreadyCurrent && !statusChanged && !wouldReleaseClaim ? 'already-current' : dryRun ? 'would-change' : 'applied',
439
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('--subagent'), 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
- // F15: per-status filing buckets (status → dirName). Built-in held/paused
92
- // statuses file under the owning type folder; archive remains a separate
93
- // primitive untouched.
94
- // Plans only. A paused plan files under <plansDir>/held/ so the live plan
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
- export async function resolveConfig(cwd, explicitConfigPath) {
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,7 +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>. Use a task slug under a parent-owned plan; release only your own plan. Parent batons default to resume-<name>.',
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>`.',
298
299
  'Claims: `runlist doctor --claims` lists which session holds each plan; `--apply` releases the ones whose session is gone.',
299
300
  'Do NOT: cat/read a docs/prompts/*.md (use `runlist use <file>` — archive/claim commits before at-most-once output);',
300
301
  'git add/commit a prompt (they are session-local, often gitignored); hand-edit a `status:` field (use `runlist set`).',
@@ -399,7 +400,7 @@ export function buildHandoffContext(config) {
399
400
  if (definition) {
400
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}`;
401
402
  }
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.';
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.';
403
404
  return `[runlist] Baton: ${who} ${draft} ${command} ${tail}`;
404
405
  }
405
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
+ }
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
  }