runlist 0.99.1 → 0.100.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 (58) hide show
  1. package/GETTING_STARTED.md +249 -0
  2. package/README.md +433 -51
  3. package/bin/runlist.mjs +404 -53
  4. package/package.json +3 -1
  5. package/plugins/runlist-codex/.codex-plugin/plugin.json +1 -1
  6. package/plugins/runlist-codex/bin/consume-prompt.mjs +45 -0
  7. package/plugins/runlist-codex/bin/runlist-hook +29 -11
  8. package/plugins/runlist-codex/bin/session-start.mjs +16 -0
  9. package/plugins/runlist-codex/bin/subagent-start.mjs +16 -0
  10. package/plugins/runlist-codex/hooks/hooks.json +11 -0
  11. package/plugins/runlist-codex/skills/runlist/SKILL.md +27 -11
  12. package/plugins/runlist-codex/skills/runlist-setup/SKILL.md +109 -0
  13. package/runlist.config.example.mjs +6 -0
  14. package/src/agent-context.mjs +8 -2
  15. package/src/agent-hosts.mjs +305 -0
  16. package/src/app-create.mjs +11 -1
  17. package/src/atomic-mutation.mjs +140 -35
  18. package/src/baton.mjs +127 -33
  19. package/src/codex-integration.mjs +57 -7
  20. package/src/commands.mjs +92 -38
  21. package/src/config-command.mjs +115 -0
  22. package/src/config-setting.mjs +163 -0
  23. package/src/config.mjs +57 -12
  24. package/src/doctor-hosts.mjs +192 -0
  25. package/src/doctor.mjs +16 -6
  26. package/src/export.mjs +11 -0
  27. package/src/git.mjs +58 -2
  28. package/src/guard.mjs +41 -13
  29. package/src/handoff-record.mjs +290 -30
  30. package/src/handoffs-help.mjs +143 -0
  31. package/src/handoffs-mode.mjs +486 -0
  32. package/src/handoffs-scope.mjs +98 -0
  33. package/src/help.mjs +72 -0
  34. package/src/host-integration.mjs +7 -1
  35. package/src/hud.mjs +174 -29
  36. package/src/index.mjs +11 -4
  37. package/src/init.mjs +255 -160
  38. package/src/install.mjs +35 -18
  39. package/src/installation.mjs +110 -27
  40. package/src/journal.mjs +2 -2
  41. package/src/lifecycle.mjs +22 -11
  42. package/src/managed-path.mjs +68 -4
  43. package/src/migrate.mjs +7 -0
  44. package/src/naming.mjs +26 -0
  45. package/src/new.mjs +202 -91
  46. package/src/pickup-card.mjs +1 -1
  47. package/src/plan-changes.mjs +49 -0
  48. package/src/prompt-handoff.mjs +16 -0
  49. package/src/prompts.mjs +101 -27
  50. package/src/rename.mjs +13 -0
  51. package/src/setup-carry.mjs +291 -0
  52. package/src/setup-waiting.mjs +20 -0
  53. package/src/setup-worker.mjs +257 -0
  54. package/src/setup.mjs +404 -0
  55. package/src/statuses.mjs +5 -2
  56. package/src/update.mjs +239 -79
  57. package/src/use.mjs +4 -2
  58. package/src/validate.mjs +7 -0
@@ -113,6 +113,11 @@ export function opencodeStatus(opts = {}) {
113
113
  const installed = generated?.version ?? oldGenerated?.version ?? null;
114
114
  const legacyGenerated = Boolean(oldGenerated);
115
115
  const legacyBanner = generated?.marker === LEGACY_GENERATED_MARKER || oldGenerated?.marker === LEGACY_GENERATED_MARKER;
116
+ let sourceParity = { state: 'unknown' };
117
+ if (installed && !legacyBanner && !legacyGenerated) {
118
+ try { sourceParity = { state: readFileSync(filePath, 'utf8') === renderOpencodePlugin(version ?? installed) ? 'match' : 'mismatch' }; }
119
+ catch { /* An unreadable file cannot establish parity. */ }
120
+ }
116
121
  return {
117
122
  path: filePath,
118
123
  legacyPath,
@@ -121,7 +126,8 @@ export function opencodeStatus(opts = {}) {
121
126
  // Generated legacy files migrate to runlist.js. Unmarked files remain user-owned.
122
127
  foreign: (currentExists && !generated) || (!currentExists && oldExists && !oldGenerated),
123
128
  foreignPath: currentExists && !generated ? filePath : oldExists && !oldGenerated ? legacyPath : null,
124
- stale: installed !== null && ((version !== undefined && installed !== version) || legacyBanner || legacyGenerated),
129
+ sourceParity,
130
+ stale: installed !== null && ((version !== undefined && installed !== version) || legacyBanner || legacyGenerated || sourceParity.state !== 'match'),
125
131
  legacyBanner,
126
132
  legacyGenerated,
127
133
  };
package/src/hud.mjs CHANGED
@@ -1,8 +1,9 @@
1
+ import { maskInlineCodeSpans } from './markdown-code-spans.mjs';
1
2
  import { flagsHudLine, openFlags } from './flags.mjs';
2
3
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
3
4
  import path from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
5
- import { asString, currentSessionId, isArchivedPath, relTime, toRepoPath } from './util.mjs';
6
+ import { asString, currentSessionId, isArchivedPath, relTime, shellQuote, toRepoPath } from './util.mjs';
6
7
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
7
8
  import { promptDirectory } from './new.mjs';
8
9
  import { dim, yellow } from './color.mjs';
@@ -13,6 +14,7 @@ import { findOwnedPlan } from './baton.mjs';
13
14
  import { listOwnedPlans, surveyOwnershipClaims } from './pickup.mjs';
14
15
  import { handoffNotDoneLine, pendingHandoffsForSession } from './handoff-record.mjs';
15
16
  import { actionablePromptStatuses, comparePromptDocs } from './status-metadata.mjs';
17
+ import { handoffsAnchor, isHandoffsMode, wouldCreateHandoffsConfig } from './handoffs-mode.mjs';
16
18
 
17
19
  export { actionablePromptStatuses } from './status-metadata.mjs';
18
20
 
@@ -80,12 +82,14 @@ export function findActionablePromptsInPromptDir(config) {
80
82
  export function buildHudFast(config) {
81
83
  let prompts = [];
82
84
  let owned = null;
85
+ let hasOwnedPlans = true; // Unknown ownership must not advertise a fresh session.
83
86
  try { prompts = findActionablePromptsInPromptDir(config); } catch { /* hud must not fail */ }
84
87
  try {
85
88
  const records = listOwnedPlans(config);
89
+ hasOwnedPlans = records.length > 0 || Boolean(records.diagnostics?.length);
86
90
  if (!records.diagnostics?.length && records.length === 1) owned = { path: records[0].plan, title: null, via: 'ownership' };
87
91
  } catch { /* hud must not fail */ }
88
- return { owned, prompts, misuseRecap: buildMisuseRecap(config), deadClaimsLine: deadClaimsHudLine(config) };
92
+ return { owned, hasOwnedPlans, prompts, misuseRecap: buildMisuseRecap(config), deadClaimsLine: deadClaimsHudLine(config) };
89
93
  }
90
94
 
91
95
  // A claim whose session is provably gone wedges `set`, `archive` and `baton` on
@@ -213,7 +217,7 @@ const MISUSE_CORRECTIONS = {
213
217
  'edit-status': 'never hand-edit `status:`; use `runlist set <status> <file>`',
214
218
  'cat-prompt': 'consume prompts with `runlist use <file>`; peek without consuming via `runlist prompts show <file>`',
215
219
  'read-prompt': 'consume prompts with `runlist use <file>`; peek without consuming via `runlist prompts show <file>`',
216
- 'commit-prompt': 'saved prompts are session-local; never git add/commit them',
220
+ 'commit-prompt': 'follow the project prompt Git policy: `runlist config get promptTracking`',
217
221
  };
218
222
 
219
223
  export function buildMisuseRecap(config, now = Date.now()) {
@@ -292,10 +296,18 @@ export function buildHud(config) {
292
296
  // again and again: the read verbs, the three wrong-moves the guard exists to
293
297
  // stop, and a pointer for the rare child handoff. Statuses are not listed —
294
298
  // `runlist set` names the valid ones when given a wrong one.
299
+ // Handoffs-only projects have no plans and no status lines to guard; a child
300
+ // needs only the prompt rules and its own handoff form.
301
+ const HANDOFFS_SUBAGENT_PRIMER = [
302
+ 'runlist keeps session handoffs (saved prompts) here; planning is off.',
303
+ 'Do NOT cat/read a saved prompt file (`runlist prompts show <name>` peeks, `runlist use <name>` consumes); follow `runlist config get promptTracking` for Git.',
304
+ 'Return completed results to the parent. Unfinished handoff: `runlist baton --subagent <task-slug> @<draft-file> --parent resume-<parent>`.',
305
+ ].join('\n');
306
+
295
307
  const SUBAGENT_PRIMER = [
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).',
308
+ 'runlist manages plans/docs/prompts. Orient: `runlist plans`. Read: `runlist show <file>`, then `runlist show <file> --section "<heading>"`, never whole.',
309
+ 'Do NOT: cat/read a docs/prompts/*.md (`runlist prompts show <name>` peeks, `runlist use <name>` consumes); ignore promptTracking (`runlist config get promptTracking`); hand-edit status (`runlist set <status> <file>`).',
310
+ "Return completed results to the parent. Unfinished handoff: `runlist baton --subagent <task-slug> @<draft-file> --parent resume-<parent>`. Use a distinct non-plan slug under a parent-owned plan; preserve its claim. `runlist baton --template` prints an outline; inspect a pending prompt before --replace.",
299
311
  ].join('\n');
300
312
 
301
313
  // The plugin's SessionStart/SubagentStart hooks fire in EVERY repo (it's enabled
@@ -315,9 +327,32 @@ const SUBAGENT_PRIMER = [
315
327
  // clause that opens as a question never counts, and "baton" counts only as a
316
328
  // verb (opening the clause, after "and"/"then", or "do a baton"), never as the
317
329
  // subject of a sentence ("baton prints its name first").
318
- 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;
319
- 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]*)*`;
330
+ const HANDOFF_PHRASE = /\bhand[\s-]?off\b|\bresume[\s-]prompt\b|\bsave (?:a |the )?resume\b|\bpick (?:it|this|that) (?:back )?up (?:next time|next session|later|tomorrow)\b/i;
331
+ const LEAD_WORDS = String.raw`(?:(?:ok(?:ay)?|alright|right|anyway|hey|now|then|and|also|but|so|yes|yep|great|good|cool|thanks|please|pls|go ahead and|let'?s|lets|time to)\b[,\s]*)*`;
320
332
  const POLITE = String.raw`(?:(?:can|could|would|will) you (?:please )?(?:mind )?)`;
333
+ const SESSION_REQUEST_START = String.raw`(?:^|,\s*)${LEAD_WORDS}${POLITE}?(?:(?:I want to|let'?s|lets|please) )?`;
334
+ // Save forms name the session's work and a continuation time, or explicitly
335
+ // end the workday. "Save where we are" is itself a session snapshot request;
336
+ // require a request at the clause start, including after a context-setting
337
+ // comma, and a clause end (also before a semicolon continuation).
338
+ // Keep the suffixes narrow: progress indicators and session objects are code.
339
+ const SESSION_SAVE = [
340
+ new RegExp(String.raw`${SESSION_REQUEST_START}save where we are\s*[.!?;]?$`, 'i'),
341
+ /\bsave (?:where we are|our progress) (?:for (?:later|tomorrow|now|today|next (?:time|session))|so (?:I|we) can continue (?:later|tomorrow|next (?:time|session)))\b/i,
342
+ new RegExp(String.raw`${SESSION_REQUEST_START}wrap up for (?:today|the day|tonight)\s*[.!?;]?$`, 'i'),
343
+ /\bstop here for now(?: and|,)? (?:continue|pick (?:it|this) up) (?:tomorrow|later|next (?:time|session))\s*[.!?;]?$/i,
344
+ new RegExp(String.raw`${SESSION_REQUEST_START}stop (?:here )?for (?:today|the day|tonight)\s*[.!?;]?$`, 'i'),
345
+ /\b(?:let'?s|lets|I think we can) call it a day\s*[.!?;]?$/i,
346
+ /\b(?:we'll|we will|I'll|I will|let'?s|lets) continue (?:in (?:a |the )?new session|(?:this )?tomorrow)\s*[.!?;]?$/i,
347
+ new RegExp(String.raw`${SESSION_REQUEST_START}save where we're at for tomorrow\s*[.!?;]?$`, 'i'),
348
+ new RegExp(String.raw`${SESSION_REQUEST_START}(?:let'?s|lets) pause here and continue tomorrow\s*[.!?;]?$`, 'i'),
349
+ ];
350
+ // Strip all explicit load/resume forms before the broad handoff save matcher.
351
+ const LOAD_HANDOFF = String.raw`\b(?:load|resume|open|use) (?:my|the|our|that) handoff\b`;
352
+ // A continuation can start another clause with "and then". Other words after
353
+ // stopped/left off describe code or another activity, rather than saved work.
354
+ const PICKUP_END = String.raw`(?: (?:yesterday|last (?:time|session))(?!'s)\b)?(?=\s*(?:$|[.!?;,]|and then\b))`;
355
+ const PICKUP_PHRASE = String.raw`(?:(?:pick up|continue|resume) (?:from )?where (?:we|I|you) (?:left off|stopped)\b${PICKUP_END}|(?:continue|resume|pick up) from (?:last (?:time|session)|yesterday)(?!'s)\b|${LOAD_HANDOFF}(?=\s*(?:$|[.!?;,]|and\b)))`;
321
356
  const BATON_AS_NOUN = String.raw`(?:(?:do|doing|make|write|leave|save|pass|run|drop) (?:a |the |your |another |one more )?)`;
322
357
  // A word here after "baton" makes it the subject or a noun ("baton is",
323
358
  // "baton prints", "baton command"), not a request.
@@ -326,21 +361,66 @@ const BATON_REQUEST = [
326
361
  new RegExp(String.raw`^${LEAD_WORDS}${POLITE}?(?:please )?${BATON_AS_NOUN}?batons?\b${NOT_A_VERB}`, 'i'),
327
362
  new RegExp(String.raw`(?:\band|\bthen|&)\s+(?:please )?${BATON_AS_NOUN}?batons?\b${NOT_A_VERB}`, 'i'),
328
363
  new RegExp(String.raw`\b(?:do|make|write|leave|save|pass) (?:a |your |another |one more )baton\b`, 'i'),
364
+ new RegExp(String.raw`\bcontext is getting tight,\s+(?:please )?baton\b${NOT_A_VERB}`, 'i'),
329
365
  ];
330
366
  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
367
 
332
368
  // The harness hands background-task events to UserPromptSubmit as if typed.
333
369
  // A finished agent's report that names its handoff file is not a request.
334
- const SYSTEM_EVENT = /<task-notification>|\[SYSTEM NOTIFICATION\b/;
370
+ const SYSTEM_EVENT = /\[SYSTEM NOTIFICATION\b/i;
371
+ const T3_RELAY_HEADER = /^[ \t]*Delegated task \S+(?: \([^\n]*?\))?(?: \[[a-z_]+\]| reached a terminal state)[:.]/im;
372
+ // This question explicitly asks to recover earlier work, rather than asking
373
+ // how the handoff mechanism works. Other question-led clauses stay silent.
374
+ const PICKUP_QUESTION = new RegExp(String.raw`^${LEAD_WORDS}(?:hmm[,\s]+)?(?:what was I working on (?:in the )?last session|where were we)[?.!]*$`, 'i');
375
+ const PICKUP_REQUEST = new RegExp(String.raw`^${LEAD_WORDS}${POLITE}?(?:please )?${PICKUP_PHRASE}`, 'i');
376
+
377
+ function requestText(prompt) {
378
+ if (typeof prompt !== 'string' || SYSTEM_EVENT.test(prompt)) return '';
379
+ prompt = prompt.replaceAll('’', "'");
380
+ prompt = prompt.replace(/<task-notification\b[^>]*>[\s\S]*?<\/task-notification>/gi, ' ');
381
+ let fence = null;
382
+ const prose = prompt.split('\n').map(line => {
383
+ const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1];
384
+ if (fence) {
385
+ if (marker && marker[0] === fence[0] && marker.length >= fence.length
386
+ && /^\s*$/.test(line.slice(line.indexOf(marker) + marker.length))) fence = null;
387
+ return '';
388
+ }
389
+ if (marker) { fence = marker; return ''; }
390
+ if (/^(?: {4}|\t)|^ {0,3}>/.test(line)) return '';
391
+ return line;
392
+ }).join('\n');
393
+ // T3 reports can contain blank lines and further relay headers. Everything
394
+ // from the first prose header onward belongs to the relay; only prior prose
395
+ // is the user's. A header inside code or a quote is not a relay envelope.
396
+ // Claude notifications have explicit closing tags instead.
397
+ const relay = T3_RELAY_HEADER.exec(prose);
398
+ const userProse = relay ? prose.slice(0, relay.index) : prose;
399
+ return maskInlineCodeSpans(userProse).replace(/"[^"\n]*"/g, ' ');
400
+ }
335
401
 
336
402
  function clauses(prompt) {
337
403
  return prompt.split(/(?<=[.!?;\n])/).map(clause => clause.trim()).filter(Boolean);
338
404
  }
339
405
 
340
406
  export function isHandoffAsk(prompt) {
341
- if (typeof prompt !== 'string' || SYSTEM_EVENT.test(prompt)) return false;
342
- return clauses(prompt).some(clause => !QUESTION_START.test(clause)
343
- && (BATON_REQUEST.some(re => re.test(clause)) || HANDOFF_PHRASE.test(clause)));
407
+ return clauses(requestText(prompt)).some(clause => !QUESTION_START.test(clause)
408
+ && (BATON_REQUEST.some(re => re.test(clause)) || SESSION_SAVE.some(re => re.test(clause))
409
+ // A request to load a handoff is the opposite of saving one.
410
+ || HANDOFF_PHRASE.test(clause.replace(new RegExp(LOAD_HANDOFF, 'gi'), ''))));
411
+ }
412
+
413
+ // Before a repository has any runlist config the hook only answers an explicit
414
+ // request to save the session: a baton, or one of the session-save phrases. The
415
+ // bare word "handoff" is common in code, and nothing here is runlist's yet.
416
+ export function isExplicitSaveAsk(prompt) {
417
+ return clauses(requestText(prompt)).some(clause => !QUESTION_START.test(clause)
418
+ && (BATON_REQUEST.some(re => re.test(clause)) || SESSION_SAVE.some(re => re.test(clause))));
419
+ }
420
+
421
+ export function isPickupAsk(prompt) {
422
+ return clauses(requestText(prompt)).some(clause => PICKUP_QUESTION.test(clause)
423
+ || (!QUESTION_START.test(clause) && PICKUP_REQUEST.test(clause)));
344
424
  }
345
425
 
346
426
  // The repo's own definition of baton (`templates.prompt.baton` in its config),
@@ -351,7 +431,21 @@ export function repoBatonDefinition(config) {
351
431
  return typeof text === 'string' && text.trim() ? text.trim() : null;
352
432
  }
353
433
 
434
+ const RESUME_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.';
435
+ const BATON_TAIL = 'Baton prints the prompt name it saved as its last line; tell the user that name. No `--help` needed.';
436
+
437
+ // Handoffs only — a repository with no config yet, or one in handoffs mode —
438
+ // has no plans, so the form is always a name the session picks itself.
439
+ export function buildHandoffsOnlyContext(config, { firstHandoff = false } = {}) {
440
+ const command = 'Save it with `runlist baton <slug> @<file>`, choosing a short slug that names the work (it saves resume-<slug>).';
441
+ const first = firstHandoff ? ' There is no runlist config here yet: baton also sets one up for handoffs (runlist.config.mjs, prompts in docs/prompts/) unless it finds runlist documents saved before setup, and prints what it did; relay that.' : '';
442
+ const definition = repoBatonDefinition(config);
443
+ if (definition) return `[runlist] Baton: ${command}${first} Where the definition below names a command, use this form. ${BATON_TAIL}\nThis repo defines baton (templates.prompt.baton in its runlist config):\n${definition}`;
444
+ return `[runlist] Baton: ${RESUME_DRAFT} ${command}${first} ${BATON_TAIL}`;
445
+ }
446
+
354
447
  export function buildHandoffContext(config) {
448
+ if (isHandoffsMode(config)) return buildHandoffsOnlyContext(config);
355
449
  // Ownership records name their plans and are checked against those files
356
450
  // alone; building the index here cost 9s in a repo of ~5k docs, past the
357
451
  // hook's timeout.
@@ -360,43 +454,66 @@ export function buildHandoffContext(config) {
360
454
  const records = listOwnedPlans(config);
361
455
  if (!records.diagnostics?.length) ownedPaths = records.map(record => record.plan);
362
456
  } catch { /* fall through to the no-plan form */ }
363
- 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';
457
+ 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 body first, then run';
364
458
  let who;
365
459
  let command;
366
460
  if (ownedPaths.length === 1) {
367
461
  who = `this session owns ${ownedPaths[0]}.`;
368
- 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\`.`;
462
+ 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 --next-step "<next move>"\`.`;
369
463
  } else if (ownedPaths.length > 1) {
370
464
  who = `this session owns ${ownedPaths.length} plans (${ownedPaths.join(', ')}), so name the one to hand off.`;
371
- command = `Save it with \`runlist baton <plan-file> @<file>\`. ${done} \`runlist baton --done <plan-file>\`.`;
465
+ command = `Save it with \`runlist baton <plan-file> @<file>\`. ${done} \`runlist baton --done <plan-file> --next-step "<next move>"\`.`;
372
466
  } else {
373
467
  who = 'this session owns no plan.';
374
468
  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.';
375
469
  }
376
- const tail = 'Baton prints the prompt name it saved as its last line; tell the user that name. No `--help` needed.';
470
+ const tail = BATON_TAIL;
377
471
  const definition = repoBatonDefinition(config);
378
472
  if (definition) {
379
473
  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}`;
380
474
  }
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.';
382
- return `[runlist] Baton: ${who} ${draft} ${command} ${tail}`;
475
+ return `[runlist] Baton: ${who} ${RESUME_DRAFT} ${command} ${tail}`;
383
476
  }
384
477
 
385
- export async function runPromptSubmitHud(config, { readStdin } = {}) {
386
- if (!isRunlistRepo(config)) return;
478
+ export async function runPromptSubmitHud(config, { readStdin, cwd = process.cwd() } = {}) {
479
+ // With no config, only a Git project can take a first handoff (baton refuses
480
+ // elsewhere); everything else stays silent, as in any repo runlist isn't in.
481
+ const configured = isRunlistRepo(config);
482
+ if (!configured && handoffsAnchor(cwd).refusal) return;
387
483
  let prompt = '';
388
484
  try {
389
485
  const raw = await readStdin();
390
486
  if (raw && raw.trim()) prompt = JSON.parse(raw).prompt ?? '';
391
487
  } catch { return; }
392
- if (!isHandoffAsk(prompt)) return;
393
- process.stdout.write(buildHandoffContext(config) + '\n');
488
+ if (!configured) {
489
+ if (!isExplicitSaveAsk(prompt)) return;
490
+ // Plans claimed before any config exist only where someone planned without
491
+ // setup; the claim records are the cheap way to know, as in a configured repo.
492
+ let owns = false;
493
+ try { owns = listOwnedPlans(config).length > 0; } catch { /* no claims to read */ }
494
+ process.stdout.write((owns ? buildHandoffContext(config) : buildHandoffsOnlyContext(config, { firstHandoff: wouldCreateHandoffsConfig(cwd, config) })) + '\n');
495
+ return;
496
+ }
497
+ // Saving wins when both intents are explicit: save now, resume later.
498
+ if (isHandoffAsk(prompt)) {
499
+ process.stdout.write(buildHandoffContext(config) + '\n');
500
+ } else if (isPickupAsk(prompt)) {
501
+ let prompts;
502
+ try { prompts = findActionablePromptsInPromptDir(config); } catch { return; }
503
+ if (prompts.length) process.stdout.write(`[runlist] Pickup: ${pendingPromptsLine(prompts, { pickup: true, planning: !isHandoffsMode(config) })}\n`);
504
+ }
394
505
  }
395
506
 
396
507
  // A count, not names: a queue of dozens printed a dozen names nobody asked
397
508
  // about, and `runlist prompts` lists them when the user names one.
398
- export function pendingPromptsLine(prompts) {
509
+ export function pendingPromptsLine(prompts, { pickup = false, planning = true } = {}) {
399
510
  const n = prompts.length;
511
+ if (pickup) {
512
+ const names = n <= 5 ? ` (${prompts.join(', ')})` : '';
513
+ const entry = hasPickupCommand() ? '`/pickup` or `runlist use <name>`' : '`runlist use <name>`';
514
+ const effect = planning ? 'archives the prompt and claims its linked plan' : 'archives the prompt and prints it';
515
+ return `If the user is asking to resume saved work: ${n} pending prompt${n === 1 ? '' : 's'}${names}; ${entry} consumes one (${effect}).${n > 1 ? ' Ask which one; never consume the oldest by default.' : ''}`;
516
+ }
400
517
  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.`;
401
518
  }
402
519
 
@@ -404,6 +521,30 @@ function isRunlistRepo(config) {
404
521
  return Boolean(config?.configFound);
405
522
  }
406
523
 
524
+ function hasPickupCommand() {
525
+ // Codex also exports CLAUDE_PLUGIN_ROOT for compatible hooks, but its plugin
526
+ // has no slash commands. Offer the command only when it is actually shipped.
527
+ const root = process.env.CLAUDE_PLUGIN_ROOT;
528
+ return Boolean(root?.trim() && existsSync(path.join(root, 'commands', 'pickup.md')));
529
+ }
530
+
531
+ // Directory entries only: any Markdown file is history, including held and
532
+ // archived prompts. Resolve the type directory via the same loader as hud;
533
+ // recursive listing includes config.archiveDir without assuming its spelling.
534
+ function handoffIntroLine(config, hud) {
535
+ // OpenCode injects hud into its system prompt every turn (plugin.js),
536
+ // including when RUNLIST_SESSION_ID overrides the host's identity source.
537
+ if (process.env.OPENCODE || process.env.OPENCODE_PID || hud.prompts.length || hud.hasOwnedPlans) return null;
538
+ try {
539
+ const dir = promptDirectory(config);
540
+ if (existsSync(dir) && readdirSync(dir, { withFileTypes: true, recursive: true })
541
+ .some(entry => entry.isFile() && entry.name.endsWith('.md'))) return null;
542
+ } catch { return null; } // Unreadable history cannot establish a fresh repo.
543
+ const pickup = hasPickupCommand()
544
+ ? '/pickup resumes it next time' : 'the next session lists saved handoffs at start';
545
+ return `[runlist] Handoffs: say "baton" to save where you are before you stop; ${pickup}.`;
546
+ }
547
+
407
548
  export function runHud(argv, config) {
408
549
  const json = argv.includes('--json');
409
550
 
@@ -415,7 +556,7 @@ export function runHud(argv, config) {
415
556
  // need the operator-facing machinery, just the verbs and the guardrails.
416
557
  if (argv.includes('--subagent')) {
417
558
  if (!runlistRepo) return; // silent in repos that don't use runlist
418
- process.stdout.write(dim(SUBAGENT_PRIMER) + '\n');
559
+ process.stdout.write(dim(isHandoffsMode(config) ? HANDOFFS_SUBAGENT_PRIMER : SUBAGENT_PRIMER) + '\n');
419
560
  if (drift) process.stdout.write(yellow(drift) + '\n');
420
561
  return;
421
562
  }
@@ -433,8 +574,8 @@ export function runHud(argv, config) {
433
574
  const hud = buildHudFast(config);
434
575
 
435
576
  // 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
577
+ // this session, plus a one-line introduction until the first handoff. No
578
+ // command sheet or status list: the runlist skill carries the verbs, and `runlist set` names
438
579
  // the valid statuses when given a wrong one. Passive state (error counts,
439
580
  // previous-self / fleet / recent-rejections) stays suppressed — it nudged
440
581
  // agents into phantom follow-up work and lives in its own commands and
@@ -450,6 +591,8 @@ export function runHud(argv, config) {
450
591
  // Global in-session counts never provide a fallback.
451
592
  // - an unfinished handoff, a repeat-offense misuse rule, and plans held by
452
593
  // sessions that are gone: each names the command that clears it.
594
+ const intro = handoffIntroLine(config, hud);
595
+ if (intro) process.stdout.write(dim(intro) + '\n');
453
596
  const queue = [];
454
597
  if (hud.prompts.length > 0) queue.push(pendingPromptsLine(hud.prompts));
455
598
  const flagsLine = flagsHudLine(config);
@@ -459,9 +602,11 @@ export function runHud(argv, config) {
459
602
  process.stdout.write(yellow(`[runlist] in-session (yours): ${hud.owned.path} — continue it; hand off with \`runlist baton @/tmp/draft.md\` before stopping.`) + '\n');
460
603
  }
461
604
  // A handoff this session started and did not finish (a compaction or a stop
462
- // between saving the prompt and updating the plan).
463
- try {
464
- for (const record of pendingHandoffsForSession(config)) process.stdout.write(yellow(`[runlist] ${handoffNotDoneLine(record, `runlist baton --done ${record.plan}`)}`) + '\n');
605
+ // between saving the prompt and updating the plan). Where planning is off
606
+ // there is no plan to update: a record carried in from before stays on disk
607
+ // for `runlist setup`, and the hook asks for nothing it cannot do.
608
+ if (!isHandoffsMode(config)) try {
609
+ for (const record of pendingHandoffsForSession(config)) process.stdout.write(yellow(`[runlist] ${handoffNotDoneLine(record, `runlist baton --done ${shellQuote(record.plan)}`)}`) + '\n');
465
610
  } catch { /* advisory */ }
466
611
  if (hud.misuseRecap) process.stdout.write(yellow(`[runlist] ${hud.misuseRecap}`) + '\n');
467
612
  if (hud.deadClaimsLine) process.stdout.write(yellow(hud.deadClaimsLine) + '\n');
package/src/index.mjs CHANGED
@@ -5,6 +5,7 @@ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
5
5
  import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts, extractBodyLinks } from './extractors.mjs';
6
6
  import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath, warn, die, resolveDocPath, suggestCandidates } from './util.mjs';
7
7
  import { findLexicalDocsRoot } from './managed-path.mjs';
8
+ import { managesFile, outsideHandoffs, outsideHandoffsLine } from './handoffs-scope.mjs';
8
9
  import { openParseCache, fileStamp, stampSize } from './parse-cache.mjs';
9
10
  import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalReferences, checkGitStaleness, checkRunlistBackPointers, checkCoordinationHubExecutionMode, checkRoadmapHubExecutionMode, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate, enrichRefErrorSuggestions } from './validate.mjs';
10
11
  import { checkIndex } from './index-file.mjs';
@@ -233,7 +234,9 @@ export function collectDocFiles(config, { onError = null } = {}) {
233
234
  for (const root of roots) {
234
235
  walkMarkdownFiles(root, files, config.excludeDirs, skipPaths, seen, onError);
235
236
  }
236
- return files.sort((a, b) => a.localeCompare(b));
237
+ // Every scanning verb collects here, so a handoffs-only project's other
238
+ // Markdown (src/handoffs-scope.mjs) drops out of all of them at once.
239
+ return files.filter(file => managesFile(file, config)).sort((a, b) => a.localeCompare(b));
237
240
  }
238
241
 
239
242
  // Shared resolver for CLI file arguments — the single path every file-taking
@@ -248,10 +251,10 @@ export function collectDocFiles(config, { onError = null } = {}) {
248
251
  export function resolveDocArg(input, config, { dieOnMiss = true } = {}) {
249
252
  if (!input) return null;
250
253
  const direct = resolveDocPath(input, config);
251
- if (direct) return direct;
254
+ if (direct && managesFile(direct, config)) return direct;
252
255
  if (!input.endsWith('.md')) {
253
256
  const withExt = resolveDocPath(input + '.md', config);
254
- if (withExt) return withExt;
257
+ if (withExt && managesFile(withExt, config)) return withExt;
255
258
  }
256
259
 
257
260
  const slug = input.replace(/\.md$/, '');
@@ -269,7 +272,11 @@ export function resolveDocArg(input, config, { dieOnMiss = true } = {}) {
269
272
  // `File not found` + the searched roots + up-to-3 did-you-mean candidates
270
273
  // matched on basename and printed as repo-relative paths. Exported so verbs
271
274
  // with their own resolution (e.g. interactive pickers) can reuse the message.
272
- export function docArgMissMessage(input, config, files = collectDocFiles(config)) {
275
+ // In a handoffs-only project a file that is not one of its prompts was not
276
+ // mistyped, so it hears that planning is off instead (src/handoffs-scope.mjs).
277
+ export function docArgMissMessage(input, config, files) {
278
+ if (outsideHandoffs(input, config)) return outsideHandoffsLine(input);
279
+ files ??= collectDocFiles(config);
273
280
  const roots = config.docsRoots || [config.docsRoot];
274
281
  const searched = [toRepoPath(config.repoRoot, config.repoRoot) || '.', ...roots.map(r => toRepoPath(r, config.repoRoot))].join(', ');
275
282
  const slug = String(input).split('/').pop().replace(/\.md$/, '');