runlist 0.94.2 → 0.95.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/bin/runlist.mjs CHANGED
@@ -1337,8 +1337,9 @@ Usage (write the resume to a file first, then pick the form that matches):
1337
1337
  runlist baton @/tmp/draft.md # a plan is in-session: save resume-<plan-slug>, release the plan
1338
1338
  runlist baton <plan-file> @/tmp/draft.md # hand off a named plan
1339
1339
  runlist baton <slug> @/tmp/draft.md # no plan: save resume-<slug>, change nothing else
1340
+ runlist baton --done [<plan-file>] # after a plan handoff: confirm the plan was brought up to date
1340
1341
  The resume can also come from stdin (\`-\`, or a pipe) or --message "..." for one-liners.
1341
- Baton prints the prompt name it saved and, in plan mode, the git commit to run.
1342
+ Baton prints the prompt name it saved, as its last line too, and, in plan mode, the git commit to run.
1342
1343
 
1343
1344
  The resume (10–20 lines): the next concrete decision plus any gotchas, and the
1344
1345
  paths of the plans/docs it concerns — not a recap of the plan body.
@@ -1349,6 +1350,7 @@ Options:
1349
1350
  --message / --body Inline body (one-liners; prefer @path or stdin)
1350
1351
  --force Recover another session's plan (explicit path required)
1351
1352
  --replace Replace exactly one pending handoff; archive its prior text
1353
+ --done Report a plan handoff done once next_step changed after the prompt was saved
1352
1354
  --json Structured repository/session/generated file result
1353
1355
  --dry-run, -n Preview without writing
1354
1356
 
@@ -1376,6 +1378,11 @@ cooperating transaction:
1376
1378
  3. Defers the shared generated index and prints exact repository-only commit
1377
1379
  guidance. Prompt and ownership records stay session-local and OUT of the
1378
1380
  pathspec.
1381
+ 4. When the plan was in-session and has a next_step field, the handoff is not
1382
+ done yet: bring the plan up to date (the body, then next_step), then run
1383
+ \`runlist baton --done\`, which refuses while next_step still reads as it
1384
+ did when the prompt was saved. The prompt is saved first, so a stop between
1385
+ the two steps loses nothing, and \`--done <plan-file>\` works from any session.
1379
1386
  Which plan? Pass it explicitly, or baton resolves exactly one plan owned by
1380
1387
  this authoritative session. Journal entries and global in-session counts never
1381
1388
  grant ownership. A live pickup-hook delivery lease blocks release and force
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runlist",
3
- "version": "0.94.2",
3
+ "version": "0.95.0",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, lifecycle, and AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runlist-codex",
3
- "version": "0.94.2",
3
+ "version": "0.95.0",
4
4
  "description": "Runlist workflow skill, session orientation, and tool guard for Codex",
5
5
  "author": {
6
6
  "name": "Reo Owens"
package/src/baton.mjs CHANGED
@@ -5,7 +5,8 @@ import { asString, toRepoPath, die, warn, isArchivedPath, resolveRefPath, nowIso
5
5
  import { buildIndex, resolveDocArg } from './index.mjs';
6
6
  import { preparePromptDocument, runNew, readBodyInput, readPipedBodyInput } from './new.mjs';
7
7
  import { ensurePlanCompletionBeforeRelease, planHasPendingCompletion, renderLifecycleMutation, runSet } from './lifecycle.mjs';
8
- import { green, dim } from './color.mjs';
8
+ import { green, dim, yellow } from './color.mjs';
9
+ import { handoffNotDoneLine, recordPendingHandoff, runBatonDone } from './handoff-record.mjs';
9
10
  import { authorizeManagedDestination, authorizeManagedSource } from './managed-path.mjs';
10
11
  import { mutateFileSet } from './atomic-mutation.mjs';
11
12
  import { assertPlanMutationAuthorized, authoritativeSessionId, listOwnedPlans, readPlanOwnership } from './pickup.mjs';
@@ -99,6 +100,12 @@ function commitCommand(config, message, paths) {
99
100
  return argv.map(shellQuote).join(' ');
100
101
  }
101
102
 
103
+ // With no plan named, --done finds this session's record; a named plan is
104
+ // echoed back so the command works from any session.
105
+ function explicitPlanDone(planArg, record) {
106
+ return planArg ? `runlist baton --done ${record.plan}` : 'runlist baton --done';
107
+ }
108
+
102
109
  // Is this positional a filesystem reference (must resolve, typos die) or a
103
110
  // bare word (may be a plan slug, may be a brand-new handoff name)?
104
111
  function looksLikePath(arg) {
@@ -115,6 +122,7 @@ export async function runBaton(argv, config, opts = {}) {
115
122
  let bodyFlag = null;
116
123
  let force = false;
117
124
  let replace = false;
125
+ let done = false;
118
126
  const positionals = [];
119
127
  for (let i = 0; i < argv.length; i++) {
120
128
  const a = argv[i];
@@ -123,6 +131,7 @@ export async function runBaton(argv, config, opts = {}) {
123
131
  if ((a === '--body' || a === '--message') && argv[i + 1]) { bodyFlag = argv[++i]; continue; }
124
132
  if (a === '--force') { force = true; continue; }
125
133
  if (a === '--replace') { replace = true; continue; }
134
+ if (a === '--done') { done = true; continue; }
126
135
  if (a === '--json') continue;
127
136
  if (!a.startsWith('-') || a === '-' || a.startsWith('@')) { positionals.push(a); continue; }
128
137
  die(`Unknown flag for \`runlist baton\`: ${a}`);
@@ -136,6 +145,13 @@ export async function runBaton(argv, config, opts = {}) {
136
145
  if (bodyArg === null) bodyArg = p; // trailing inline body
137
146
  }
138
147
 
148
+ // The second half of a plan handoff: the prompt is already saved, and this
149
+ // confirms the plan was brought up to date after it (src/handoff-record.mjs).
150
+ if (done) {
151
+ if (dryRun) die('`runlist baton --done` changes nothing but its own record; run it without --dry-run.');
152
+ return runBatonDone(planArg, config, { json });
153
+ }
154
+
139
155
  // Body FIRST — it's the common failure (`new prompt` without a body was the
140
156
  // top real-world baton error), and nothing must mutate before it's secured.
141
157
  let body = null;
@@ -382,6 +398,14 @@ export async function runBaton(argv, config, opts = {}) {
382
398
  const repositoryFiles = [...new Set(touched.filter(candidate => candidate && candidate !== ownershipRepoPath && candidate !== promptRepoPath && candidate !== archivedPromptRepoPath && candidate !== normalizeRepoPath(config.indexPath)))];
383
399
  const sessionFiles = [...new Set([...(newResult?.sessionFiles ?? []), promptRepoPath, archivedPromptRepoPath, ownershipRepoPath].filter(Boolean))];
384
400
  const deferredGeneratedFiles = [...new Set(newResult?.deferredGeneratedFiles ?? (config.indexPath ? [normalizeRepoPath(config.indexPath)] : []))];
401
+ // A released in-session plan is not handed off until its next_step says
402
+ // where the work now stands. The prompt is already saved either way.
403
+ let pendingHandoff = null;
404
+ if (planPath && !dryRun && !planGuard && (oldStatus === 'in-session' || wouldReleaseClaim)) {
405
+ try {
406
+ pendingHandoff = recordPendingHandoff(config, { planRepoPath: normalizeRepoPath(archiveResult?.newRepoPath) ?? repoPath, promptRepoPath, promptName: createdSlug });
407
+ } catch (err) { warn(`Could not record the pending plan update (${err.message}); bring the plan up to date anyway.`); }
408
+ }
385
409
  const operationResult = {
386
410
  operation: 'baton',
387
411
  dryRun: Boolean(dryRun),
@@ -403,6 +427,8 @@ export async function runBaton(argv, config, opts = {}) {
403
427
  alreadyCurrent: replacement.alreadyCurrent,
404
428
  } : null,
405
429
  claimRelease: planPath ? { wouldRelease: wouldReleaseClaim, released: !dryRun && wouldReleaseClaim } : null,
430
+ handoffDone: !pendingHandoff,
431
+ savedPrompt: createdSlug,
406
432
  };
407
433
 
408
434
  const prefix = dryRun ? dim('[dry-run] ') : '';
@@ -410,7 +436,7 @@ export async function runBaton(argv, config, opts = {}) {
410
436
  process.stdout.write(JSON.stringify(operationResult, null, 2) + '\n');
411
437
  return operationResult;
412
438
  }
413
- const verdict = replacement?.alreadyCurrent ? '✓ Handoff kept' : replacement ? '✓ Baton replaced' : '✓ Baton passed';
439
+ const verdict = replacement?.alreadyCurrent ? '✓ Handoff kept' : pendingHandoff ? '✓ Prompt saved' : replacement ? '✓ Baton replaced' : '✓ Baton passed';
414
440
  process.stderr.write(`\n${prefix}${green(verdict)}: ${createdSlug} (the next session's hud surfaces it — nothing to paste into chat)\n`);
415
441
  if (archivedPromptRepoPath) process.stderr.write(dim(`${prefix}Previous handoff preserved: ${archivedPromptRepoPath}\n`));
416
442
  if (planGuard) {
@@ -431,5 +457,8 @@ export async function runBaton(argv, config, opts = {}) {
431
457
  }
432
458
  }
433
459
  if (operationResult.deferredGeneratedFiles.length) process.stderr.write(dim(`Generated index deferred: ${operationResult.deferredGeneratedFiles.join(', ')}\n`));
460
+ if (pendingHandoff) process.stderr.write(yellow(handoffNotDoneLine(pendingHandoff, explicitPlanDone(planArg, pendingHandoff))) + '\n');
461
+ // The name again, last, so a piped `| tail` still shows what was saved.
462
+ process.stderr.write(`${prefix}Saved prompt: ${createdSlug}\n`);
434
463
  return operationResult;
435
464
  }
package/src/commands.mjs CHANGED
@@ -140,7 +140,7 @@ const definitions = [
140
140
  ], { aliases: ['prompt'] }),
141
141
  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
142
  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('--json')] })]),
143
+ command('baton', mutates('managed plan/prompt sources and managed prompt destination'), 'workflow', [form('[plan|slug] <@<file>|->', { args: positionals(0, 2), options: [value('--status'), value('--note'), value('--body', '--message'), flag('--force'), flag('--replace'), flag('--done'), flag('--json')] })]),
144
144
  command('runlist', mutates('managed hubs/children and managed scaffold destinations'), 'workflow', [
145
145
  form('<hub>', { args: positionals(1, 1), options: [flag('--json')] }),
146
146
  form('next <hub>', { subcommands: ['next'], args: positionals(1, 1), options: [flag('--json'), flag('--full'), flag('--no-index'), flag('--show-files')] }),
@@ -0,0 +1,107 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
+ import { die, nowIso, toRepoPath } from './util.mjs';
5
+ import { stateDir } from './naming.mjs';
6
+ import { resolveDocArg } from './index.mjs';
7
+ import { authoritativeSessionId } from './pickup.mjs';
8
+ import { green } from './color.mjs';
9
+
10
+ // A baton that releases an in-session plan saves the prompt first, so a usage
11
+ // stop partway through the handoff loses nothing. The plan update that follows
12
+ // is the part sessions skipped: they saved the prompt, reported the handoff
13
+ // done, and left next_step saying what it said before the work. So baton leaves
14
+ // a record of the plan's next_step as it stood when the prompt was saved, and
15
+ // `runlist baton --done` reports the handoff done only once next_step differs.
16
+ // The record is local state under the runlist state directory, one per plan,
17
+ // so a later session (or this one after a compaction) can still confirm it.
18
+
19
+ function recordsDir(config) {
20
+ return path.join(stateDir(config.repoRoot), 'handoffs');
21
+ }
22
+
23
+ function recordPath(config, planRepoPath) {
24
+ return path.join(recordsDir(config), `${path.basename(planRepoPath, '.md')}.json`);
25
+ }
26
+
27
+ function planNextStep(absPath) {
28
+ const { frontmatter } = extractFrontmatter(readFileSync(absPath, 'utf8'));
29
+ if (!frontmatter) return { present: false, value: null };
30
+ const fm = parseSimpleFrontmatter(frontmatter);
31
+ if (!Object.prototype.hasOwnProperty.call(fm, 'next_step')) return { present: false, value: null };
32
+ return { present: true, value: JSON.stringify(fm.next_step ?? null) };
33
+ }
34
+
35
+ // Called by baton after the prompt and the plan release are published. Returns
36
+ // the record when the plan carries a next_step to wait on, else null (a repo
37
+ // whose plans have no next_step field has nothing for --done to check).
38
+ export function recordPendingHandoff(config, { planRepoPath, promptRepoPath, promptName }) {
39
+ const abs = path.resolve(config.repoRoot, planRepoPath);
40
+ const { present, value } = planNextStep(abs);
41
+ if (!present) return null;
42
+ const record = {
43
+ plan: planRepoPath,
44
+ prompt: promptRepoPath,
45
+ name: promptName,
46
+ savedAt: nowIso(),
47
+ nextStep: value,
48
+ sessionId: authoritativeSessionId() ?? null,
49
+ };
50
+ mkdirSync(recordsDir(config), { recursive: true });
51
+ writeFileSync(recordPath(config, planRepoPath), JSON.stringify(record, null, 2) + '\n');
52
+ return record;
53
+ }
54
+
55
+ export function listPendingHandoffs(config) {
56
+ const dir = recordsDir(config);
57
+ if (!existsSync(dir)) return [];
58
+ const records = [];
59
+ for (const name of readdirSync(dir)) {
60
+ if (!name.endsWith('.json')) continue;
61
+ try { records.push({ ...JSON.parse(readFileSync(path.join(dir, name), 'utf8')), recordFile: path.join(dir, name) }); }
62
+ catch { /* a torn record is not a handoff to wait on */ }
63
+ }
64
+ return records;
65
+ }
66
+
67
+ export function pendingHandoffsForSession(config, sessionId = authoritativeSessionId()) {
68
+ if (!sessionId) return [];
69
+ return listPendingHandoffs(config).filter(record => record.sessionId === sessionId);
70
+ }
71
+
72
+ export function handoffNotDoneLine(record, doneCommand = 'runlist baton --done') {
73
+ return `Handoff not done yet: the prompt is saved, and ${record.plan} still needs bringing up to date (the body, then next_step). Then run \`${doneCommand}\`.`;
74
+ }
75
+
76
+ // `runlist baton --done [<plan>]`
77
+ export function runBatonDone(planArg, config, { json = false } = {}) {
78
+ let record;
79
+ if (planArg) {
80
+ const abs = resolveDocArg(planArg, config);
81
+ const repoPath = toRepoPath(abs, config.repoRoot);
82
+ record = listPendingHandoffs(config).find(r => r.plan === repoPath)
83
+ ?? listPendingHandoffs(config).find(r => path.basename(r.plan) === path.basename(repoPath));
84
+ if (!record) die(`No handoff waits on ${repoPath}: baton has not saved a prompt for it that needs the plan brought up to date.`);
85
+ } else {
86
+ const mine = pendingHandoffsForSession(config);
87
+ if (mine.length === 0) die('No handoff of this session waits on a plan update. Name the plan: runlist baton --done <plan-file>');
88
+ if (mine.length > 1) die(`This session has ${mine.length} handoffs waiting; name the plan:\n${mine.map(r => ` runlist baton --done ${r.plan}`).join('\n')}`);
89
+ record = mine[0];
90
+ }
91
+
92
+ const abs = path.resolve(config.repoRoot, record.plan);
93
+ if (!existsSync(abs)) die(`${record.plan} is gone (moved or archived?). Run \`runlist baton --done <its new path>\` once it is up to date.\nSaved prompt: ${record.name}`);
94
+ const { present, value } = planNextStep(abs);
95
+ if (present && value === record.nextStep) {
96
+ die(`Not done: ${record.plan} next_step is the same as when the prompt was saved (${record.savedAt}). Bring the plan up to date (the body, then next_step), then run \`runlist baton --done${planArg ? ` ${record.plan}` : ''}\` again.\nSaved prompt: ${record.name}`);
97
+ }
98
+ rmSync(record.recordFile, { force: true });
99
+ const result = { operation: 'baton-done', plan: record.plan, prompt: record.prompt, name: record.name, nextStepChanged: true };
100
+ if (json) {
101
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
102
+ return result;
103
+ }
104
+ process.stderr.write(`${green('✓ Handoff done')}: ${record.plan} next_step was updated after the prompt was saved.\n`);
105
+ process.stderr.write(`Saved prompt: ${record.name}\n`);
106
+ return result;
107
+ }
package/src/hud.mjs CHANGED
@@ -11,6 +11,7 @@ import { readJournalEntries, journalFilePath, readMisuseEntries } from './journa
11
11
  import { compareVersions } from './update.mjs';
12
12
  import { findOwnedPlan } from './baton.mjs';
13
13
  import { listOwnedPlans, surveyOwnershipClaims } from './pickup.mjs';
14
+ import { handoffNotDoneLine, pendingHandoffsForSession } from './handoff-record.mjs';
14
15
  import { actionablePromptStatuses, comparePromptDocs, resolveStatusMetadata } from './status-metadata.mjs';
15
16
 
16
17
  export { actionablePromptStatuses } from './status-metadata.mjs';
@@ -327,14 +328,47 @@ export function buildPlanStatusPrimer(config, { maxChars = 220 } = {}) {
327
328
  // form for its situation before it goes looking. Sessions ran `baton --help`
328
329
  // before nearly every baton even with the form in CLAUDE.md, because the right
329
330
  // form depends on whether this session owns a plan, which only runlist knows.
330
- const HANDOFF_ASK = /\bbaton\b|\bhand[\s-]?off\b|\bresume[\s-]prompt\b|\bsave (?:a |the )?resume\b|\bpick (?:it|this|that) (?:back )?up (?:next time|later|tomorrow)\b/i;
331
+ //
332
+ // Only a request fires it. Matching the word anywhere answered questions about
333
+ // batons ("what is the deal with the baton?") with handoff steps, and sessions
334
+ // read that as a go to hand off. So the prompt is read clause by clause: a
335
+ // clause that opens as a question never counts, and "baton" counts only as a
336
+ // verb (opening the clause, after "and"/"then", or "do a baton"), never as the
337
+ // subject of a sentence ("baton prints its name first").
338
+ const HANDOFF_PHRASE = /\bhand[\s-]?off\b|\bresume[\s-]prompt\b|\bsave (?:a |the )?resume\b|\bpick (?:it|this|that) (?:back )?up (?:next time|later|tomorrow)\b/i;
339
+ const LEAD_WORDS = String.raw`(?:(?:ok(?:ay)?|alright|right|now|then|and|also|but|so|yes|yep|great|good|cool|thanks|please|pls|go ahead and|let'?s|lets|time to)\b[,\s]*)*`;
340
+ const POLITE = String.raw`(?:(?:can|could|would|will) you (?:please )?(?:mind )?)`;
341
+ const BATON_AS_NOUN = String.raw`(?:(?:do|doing|make|write|leave|save|pass|run|drop) (?:a |the |your |another |one more )?)`;
342
+ // A word here after "baton" makes it the subject or a noun ("baton is",
343
+ // "baton prints", "baton command"), not a request.
344
+ const NOT_A_VERB = String.raw`(?!\s+(?:is|isn'?t|was|wasn'?t|are|were|does|doesn'?t|did|didn'?t|do|has|had|have|seems|should|shall|would|will|won'?t|can|can'?t|could|keeps|kept|works|worked|fails|failed|prints|printed|says|said|saves|saved|refuses|refused|output|outputs|command|commands|text|hint|hook|line|skill|tool|step|steps|definition|prompt|prompts)\b)(?!'s\b)`;
345
+ const BATON_REQUEST = [
346
+ new RegExp(String.raw`^${LEAD_WORDS}${POLITE}?(?:please )?${BATON_AS_NOUN}?batons?\b${NOT_A_VERB}`, 'i'),
347
+ new RegExp(String.raw`(?:\band|\bthen|&)\s+(?:please )?${BATON_AS_NOUN}?batons?\b${NOT_A_VERB}`, 'i'),
348
+ new RegExp(String.raw`\b(?:do|make|write|leave|save|pass) (?:a |your |another |one more )baton\b`, 'i'),
349
+ ];
350
+ const QUESTION_START = new RegExp(String.raw`^${LEAD_WORDS}(?:what'?s|whats|what|why|how|when|where|who|whose|which|is|isn'?t|are|aren'?t|was|wasn'?t|were|does|doesn'?t|did|didn'?t|has|have|had|should|shall|do(?! (?:a |the |your |another |one more )?baton)|(?:can|could|would|will)(?! you\b))\b`, 'i');
331
351
 
332
352
  // The harness hands background-task events to UserPromptSubmit as if typed.
333
353
  // A finished agent's report that names its handoff file is not a request.
334
354
  const SYSTEM_EVENT = /<task-notification>|\[SYSTEM NOTIFICATION\b/;
335
355
 
356
+ function clauses(prompt) {
357
+ return prompt.split(/(?<=[.!?;\n])/).map(clause => clause.trim()).filter(Boolean);
358
+ }
359
+
336
360
  export function isHandoffAsk(prompt) {
337
- return typeof prompt === 'string' && !SYSTEM_EVENT.test(prompt) && HANDOFF_ASK.test(prompt);
361
+ if (typeof prompt !== 'string' || SYSTEM_EVENT.test(prompt)) return false;
362
+ return clauses(prompt).some(clause => !QUESTION_START.test(clause)
363
+ && (BATON_REQUEST.some(re => re.test(clause)) || HANDOFF_PHRASE.test(clause)));
364
+ }
365
+
366
+ // The repo's own definition of baton (`templates.prompt.baton` in its config),
367
+ // when it has one. Read from the loaded config only: the hook path never builds
368
+ // the index.
369
+ export function repoBatonDefinition(config) {
370
+ const text = config?.raw?.templates?.prompt?.baton;
371
+ return typeof text === 'string' && text.trim() ? text.trim() : null;
338
372
  }
339
373
 
340
374
  export function buildHandoffContext(config) {
@@ -346,15 +380,26 @@ export function buildHandoffContext(config) {
346
380
  const records = listOwnedPlans(config);
347
381
  if (!records.diagnostics?.length) ownedPaths = records.map(record => record.plan);
348
382
  } catch { /* fall through to the no-plan form */ }
349
- 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.';
350
- const tail = 'Baton prints the prompt name it saved; tell the user that name. No `--help` needed.';
383
+ const done = 'Baton does not report the handoff done until the plan\'s next_step has been updated after the prompt was saved: update the plan, then run';
384
+ let who;
385
+ let command;
351
386
  if (ownedPaths.length === 1) {
352
- return `[runlist] Baton: this session owns ${ownedPaths[0]}. ${draft} Then run \`runlist baton @<file>\`: it saves the prompt, releases the plan to active (\`--status paused|awaiting|partial|blocked\` and \`--note "why"\` if that fits better), and prints the commit to run. ${tail}`;
387
+ who = `this session owns ${ownedPaths[0]}.`;
388
+ command = `Save the prompt with \`runlist baton @<file>\`: it releases the plan to active (\`--status paused|awaiting|partial|blocked\` and \`--note "why"\` if that fits better). ${done} \`runlist baton --done\`.`;
389
+ } else if (ownedPaths.length > 1) {
390
+ who = `this session owns ${ownedPaths.length} plans (${ownedPaths.join(', ')}), so name the one to hand off.`;
391
+ command = `Save it with \`runlist baton <plan-file> @<file>\`. ${done} \`runlist baton --done <plan-file>\`.`;
392
+ } else {
393
+ who = 'this session owns no plan.';
394
+ command = 'Save the prompt with `runlist baton <slug> @<file>` (saves resume-<slug>, changes nothing else), or `runlist baton <plan-file> @<file>` to hand off a plan by path.';
353
395
  }
354
- if (ownedPaths.length > 1) {
355
- return `[runlist] Baton: this session owns ${ownedPaths.length} plans (${ownedPaths.join(', ')}), so name the one to hand off. ${draft} Then run \`runlist baton <plan-file> @<file>\`. ${tail}`;
396
+ const tail = 'Baton prints the prompt name it saved as its last line; tell the user that name. No `--help` needed.';
397
+ const definition = repoBatonDefinition(config);
398
+ if (definition) {
399
+ 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}`;
356
400
  }
357
- return `[runlist] Baton: this session owns no plan. ${draft} Then run \`runlist baton <slug> @<file>\` (saves resume-<slug>, changes nothing else), or \`runlist baton <plan-file> @<file>\` to hand off a plan by path. ${tail}`;
401
+ 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.';
402
+ return `[runlist] Baton: ${who} ${draft} ${command} ${tail}`;
358
403
  }
359
404
 
360
405
  export async function runPromptSubmitHud(config, { readStdin } = {}) {
@@ -368,6 +413,16 @@ export async function runPromptSubmitHud(config, { readStdin } = {}) {
368
413
  process.stdout.write(buildHandoffContext(config) + '\n');
369
414
  }
370
415
 
416
+ // Names, not paths, and at most PROMPT_NAMES_SHOWN of them, so the line stays
417
+ // one line however long the queue grows.
418
+ const PROMPT_NAMES_SHOWN = 12;
419
+ export function pendingPromptsLine(prompts) {
420
+ const n = prompts.length;
421
+ const names = prompts.slice(0, PROMPT_NAMES_SHOWN).map(p => path.basename(p, '.md'));
422
+ const more = n > names.length ? ` (+${n - names.length} more: \`runlist prompts\`)` : '';
423
+ 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>\`.`;
424
+ }
425
+
371
426
  function isRunlistRepo(config) {
372
427
  return Boolean(config?.configFound);
373
428
  }
@@ -409,21 +464,29 @@ export function runHud(argv, config) {
409
464
  // `runlist hud --json`. Two signals ARE instructions and must print, because
410
465
  // the handoff loop dies without them (sessions were saving batons that no
411
466
  // next session ever picked up):
412
- // - pending prompts: the previous session queued work for THIS one;
413
- // consuming it is the very next action.
467
+ // - pending prompts: earlier sessions queued work. The line lists them
468
+ // and says to resume one only when the user asks. It used to say
469
+ // "consume the oldest" unless told otherwise, but a new session nearly
470
+ // always has a task from the user, the hook cannot see it, and the
471
+ // oldest prompt is rarely the useful one.
414
472
  // - an in-session plan attributed to this sid via durable ownership: this
415
473
  // session (pre-compaction) owns it and should continue or hand it off.
416
474
  // Global in-session counts never provide a fallback.
417
475
  // The misuse recap stays for the same reason: a repeat-offense rule means
418
476
  // the primer alone isn't landing, so name the habit to break.
419
- 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) (use [no-arg] → oldest pending prompt)') + '\n');
477
+ 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');
420
478
  process.stdout.write(dim(buildPlanStatusPrimer(config)) + '\n');
421
479
  if (hud.owned && hud.owned.via === 'ownership') {
422
480
  process.stdout.write(yellow(`[runlist] in-session (yours): ${hud.owned.path} — continue it; hand off with \`runlist baton @/tmp/draft.md\` before stopping.`) + '\n');
423
481
  }
482
+ // A handoff this session started and did not finish (a compaction or a stop
483
+ // between saving the prompt and updating the plan).
484
+ try {
485
+ for (const record of pendingHandoffsForSession(config)) process.stdout.write(yellow(`[runlist] ${handoffNotDoneLine(record, `runlist baton --done ${record.plan}`)}`) + '\n');
486
+ } catch { /* advisory */ }
424
487
  if (hud.prompts.length > 0) {
425
488
  const n = hud.prompts.length;
426
- process.stdout.write(yellow(`[runlist] ${n} pending prompt${n === 1 ? '' : 's'} queued for this session — unless the user asks for something else, start by running \`runlist use\` to consume the oldest (${hud.prompts[0]}) and act on it. Peek first: \`runlist prompts show <file>\`; list: \`runlist prompts\`.`) + '\n');
489
+ process.stdout.write(yellow(pendingPromptsLine(hud.prompts)) + '\n');
427
490
  }
428
491
  if (hud.misuseRecap) process.stdout.write(yellow(`[runlist] ${hud.misuseRecap}`) + '\n');
429
492
  if (hud.deadClaimsLine) process.stdout.write(yellow(hud.deadClaimsLine) + '\n');
@@ -0,0 +1,72 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, readFileSync, statSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
5
+ import { isGitIgnored } from './git.mjs';
6
+
7
+ // What happened to a plan since a prompt about it was written. A resumed
8
+ // prompt is a snapshot: when its plan moved on since, the session needs to
9
+ // know which parts may be stale before it acts on them. Read-only; every git
10
+ // failure degrades to "unknown" rather than blocking the consume.
11
+
12
+ const SHOWN_COMMITS = 10;
13
+
14
+ function git(args, repoRoot) {
15
+ const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' });
16
+ if (result.error || result.status !== 0) return null;
17
+ return result.stdout;
18
+ }
19
+
20
+ function nextStepOf(raw) {
21
+ const { frontmatter } = extractFrontmatter(raw);
22
+ if (!frontmatter) return undefined;
23
+ const fm = parseSimpleFrontmatter(frontmatter);
24
+ return Object.prototype.hasOwnProperty.call(fm, 'next_step') ? JSON.stringify(fm.next_step ?? null) : undefined;
25
+ }
26
+
27
+ export function planChangesSince(planRepoPath, since, config) {
28
+ const when = since ? new Date(since) : null;
29
+ if (!when || Number.isNaN(when.getTime())) return null;
30
+ const abs = path.resolve(config.repoRoot, planRepoPath);
31
+ if (!existsSync(abs)) return null;
32
+ const sinceIso = when.toISOString();
33
+
34
+ const tracked = !isGitIgnored(abs, config.repoRoot) && git(['ls-files', '--error-unmatch', '--', planRepoPath], config.repoRoot) !== null;
35
+ if (!tracked) {
36
+ // Not in git (a gitignored planning tree): the file's own clock is all there is.
37
+ return { plan: planRepoPath, since: sinceIso, tracked: false, commits: [], modifiedAfter: statSync(abs).mtime > when, nextStepChanged: null };
38
+ }
39
+
40
+ const log = git(['log', '--oneline', `--since=${sinceIso}`, '--', planRepoPath], config.repoRoot);
41
+ const commits = log ? log.split('\n').filter(Boolean) : [];
42
+ const current = nextStepOf(readFileSync(abs, 'utf8'));
43
+ let nextStepChanged = null;
44
+ const base = git(['log', '-1', '--format=%H', `--before=${sinceIso}`, '--', planRepoPath], config.repoRoot)?.trim();
45
+ if (base) {
46
+ const then = git(['show', `${base}:${planRepoPath}`], config.repoRoot);
47
+ if (then !== null) nextStepChanged = nextStepOf(then) !== current;
48
+ }
49
+ return { plan: planRepoPath, since: sinceIso, tracked: true, commits, nextStepChanged };
50
+ }
51
+
52
+ export function formatPlanChanges(changes) {
53
+ if (!changes) return [];
54
+ const lines = [];
55
+ if (!changes.tracked) {
56
+ lines.push(changes.modifiedAfter
57
+ ? `→ ${changes.plan} was edited after this prompt was written (${changes.since}); it is not in git, so read it for what changed before acting on the prompt.`
58
+ : `→ ${changes.plan} has not been edited since this prompt was written (${changes.since}).`);
59
+ return lines;
60
+ }
61
+ if (changes.commits.length === 0) {
62
+ lines.push(`→ ${changes.plan} has no commits since this prompt was written (${changes.since}).`);
63
+ } else {
64
+ const n = changes.commits.length;
65
+ lines.push(`→ ${changes.plan} changed in ${n} commit${n === 1 ? '' : 's'} since this prompt was written (${changes.since}); the prompt may be stale where they touch it:`);
66
+ for (const commit of changes.commits.slice(0, SHOWN_COMMITS)) lines.push(` ${commit}`);
67
+ if (n > SHOWN_COMMITS) lines.push(` +${n - SHOWN_COMMITS} more: git log --oneline --since=${changes.since} -- ${changes.plan}`);
68
+ }
69
+ if (changes.nextStepChanged === true) lines.push(' next_step changed since then: where the prompt and the plan disagree, the plan is newer.');
70
+ else if (changes.nextStepChanged === false) lines.push(' next_step is unchanged since then.');
71
+ return lines;
72
+ }
package/src/prompts.mjs CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  } from './pickup.mjs';
18
18
  import { actionablePromptStatuses, comparePromptDocs } from './status-metadata.mjs';
19
19
  import { LEGACY_STATE_DIR, STATE_DIR } from './naming.mjs';
20
+ import { formatPlanChanges, planChangesSince } from './plan-changes.mjs';
20
21
 
21
22
  // `resume` is an alias for `use` — agents reach for "resume" when continuing a
22
23
  // session; `use` reads as internal mechanics. Both names stay valid; the
@@ -234,6 +235,10 @@ export function resolvePromptInput(input, config, options = {}) {
234
235
 
235
236
  const byBasename = prompts.filter(d => path.basename(d.path, '.md') === slug);
236
237
  if (byBasename.length === 1) return path.resolve(config.repoRoot, byBasename[0].path);
238
+ // A name session start lists is the live prompt; its consumed copies under
239
+ // archived/ share the basename and are reached by path.
240
+ const live = byBasename.filter(d => d.status !== 'archived' && !isArchivedPath(d.path, config));
241
+ if (live.length === 1) return path.resolve(config.repoRoot, live[0].path);
237
242
  if (byBasename.length > 1) {
238
243
  die(`Multiple prompts match "${input}" by basename:\n${byBasename.map(d => ' ' + d.path).join('\n')}`);
239
244
  }
@@ -345,6 +350,12 @@ export async function consumePrompt(filePath, config, opts) {
345
350
  }
346
351
  if (claimSkipReason) process.stderr.write(`${yellow('→ Not claimed')}: ${claimSkipReason}\n`);
347
352
  process.stderr.write(`${green('✓ Consumed')}: ${consumedPath}\n`);
353
+ if (planRef) {
354
+ try {
355
+ const linked = linkedClaim?.repoPath ?? linkedPlanRepoPath(planRef, filePath, config);
356
+ for (const line of formatPlanChanges(linked ? planChangesSince(linked, asString(parsed.created), config) : null)) process.stderr.write(`${line}\n`);
357
+ } catch { /* what changed is advice; the consume already happened */ }
358
+ }
348
359
  const ownershipRecordPath = linkedClaim?.prepared?.recordPath ?? (linkedClaim ? readPlanOwnership(linkedClaim.repoPath, config)?.recordPath : null);
349
360
  const ownershipPath = ownershipRecordPath ? toRepoPath(ownershipRecordPath, config.repoRoot) : null;
350
361
  const normalize = candidate => path.isAbsolute(candidate) ? toRepoPath(candidate, config.repoRoot) : candidate.split(path.sep).join('/');
@@ -373,6 +384,11 @@ export async function consumePrompt(filePath, config, opts) {
373
384
  };
374
385
  }
375
386
 
387
+ function linkedPlanRepoPath(planRef, promptPath, config) {
388
+ const abs = resolveRefPath(planRef, path.dirname(promptPath), config.repoRoot);
389
+ return abs && existsSync(abs) ? toRepoPath(abs, config.repoRoot) : null;
390
+ }
391
+
376
392
  export async function writeConsumedBody(body, archivedPath, write = null, linkedPlan = null) {
377
393
  const content = body.endsWith('\n') ? body : `${body}\n`;
378
394
  try {