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
@@ -286,7 +286,7 @@ function validateTransactionArtifact(candidate, directory, label) {
286
286
  function validateParticipantPath(participant, repoRoot, options, label) {
287
287
  if (!participant || typeof participant.path !== 'string' || !path.isAbsolute(participant.path)
288
288
  || !['managed', 'generated'].includes(participant.policy)
289
- || !['source', 'target', 'referrer', 'ownership', 'creation', 'deletion'].includes(participant.label)) {
289
+ || !['source', 'target', 'referrer', 'ownership', 'handoff', 'creation', 'deletion'].includes(participant.label)) {
290
290
  throw new MutationConflictError(`Invalid transaction participant ${label}.`);
291
291
  }
292
292
  const txRoot = transactionRoot(repoRoot, options);
@@ -295,11 +295,27 @@ function validateParticipantPath(participant, repoRoot, options, label) {
295
295
  throw new MutationConflictError(`Transaction participant targets transaction/lock state: ${participant.path}`);
296
296
  }
297
297
  if (participant.policy === 'managed' && options.config) {
298
- if (existsSync(participant.path)) authorizeManagedSource(participant.path, options.config, { kind: 'Transaction participant' });
299
- else authorizeManagedDestination(participant.path, options.config, { kind: 'Transaction participant' });
298
+ try {
299
+ if (existsSync(participant.path)) authorizeManagedSource(participant.path, options.config, { kind: 'Transaction participant' });
300
+ else authorizeManagedDestination(participant.path, options.config, { kind: 'Transaction participant', recovery: true });
301
+ } catch (err) {
302
+ // A handoffs-only project manages its prompt folder alone, so a move
303
+ // reaching past it is the carry `runlist setup` makes when it turns
304
+ // planning on, stopped part-way; setup finishes it under its own roots.
305
+ if (options.config.mode === 'handoffs') err.message += '\n`runlist setup` stopped part-way through turning planning on here; run `runlist setup --yes` to finish the upgrade.';
306
+ throw err;
307
+ }
300
308
  } else {
301
309
  const authorized = safeGeneratedPath(participant.path, repoRoot, options, 'Transaction generated participant');
302
310
  if (options.config) {
311
+ // Setup carries the saved prompt/plan paths in pending handoffs in the
312
+ // same transaction as the document. Keep this permission as narrow as
313
+ // ownership state; arbitrary generated repository files remain refused.
314
+ if (participant.label === 'handoff') {
315
+ const root = path.join(stateDir(repoRoot), 'handoffs');
316
+ if (path.dirname(authorized) !== root || !authorized.endsWith('.json')) throw new MutationConflictError(`Generated handoff participant is outside handoff state: ${participant.path}`);
317
+ return;
318
+ }
303
319
  const ownershipRoot = path.join(stateDir(repoRoot), 'ownership');
304
320
  if (!contained(ownershipRoot, authorized)) throw new MutationConflictError(`Generated transaction participant is outside session ownership state: ${participant.path}`);
305
321
  if (participant.label !== 'ownership') throw new MutationConflictError(`Session-generated participant must be classified as ownership: ${participant.path}`);
@@ -328,7 +344,7 @@ function validateManifest(manifest, manifestPath, directory, repoRoot, options)
328
344
  validateGeneration(participant.new, directory, `participants[${index}].new`);
329
345
  const shape = `${participant.old.exists ? 1 : 0}${participant.new.exists ? 1 : 0}`;
330
346
  const expectedShapes = {
331
- source: ['10'], target: ['01'], referrer: ['11'], creation: ['01'], deletion: ['10'], ownership: ['01', '10'],
347
+ source: ['10'], target: ['01'], referrer: ['11'], creation: ['01'], deletion: ['10'], ownership: ['01', '10'], handoff: ['11'],
332
348
  }[participant.label];
333
349
  if (!expectedShapes.includes(shape)) throw new MutationConflictError(`Invalid generation shape for transaction ${participant.label} participant.`);
334
350
  if (!Array.isArray(participant.transientHashes) || !participant.transientHashes.every(hash => typeof hash === 'string' && /^[a-f\d]{64}$/.test(hash))) {
@@ -580,6 +596,60 @@ export function recoverAbandonedTransactions(repoRoot, options = {}) {
580
596
  return recovered;
581
597
  }
582
598
 
599
+ // Keep inspection and the locked doctor apply on the same conservative verdict.
600
+ function transactionResolution(manifest, directory) {
601
+ const present = file => {
602
+ try { lstatSync(file); return true; }
603
+ catch (error) { if (error.code === 'ENOENT') return false; throw error; }
604
+ };
605
+ const participants = manifest.participants.map(item => ({ path: item.path, label: item.label, state: participantState(item) }));
606
+ const states = new Set(participants.map(item => item.state));
607
+ const uniform = states.size === 1 && (states.has('old') || states.has('new'));
608
+ const retainedGitPaths = [...new Set([
609
+ ...manifest.gitIndex.retainedPaths,
610
+ manifest.gitIndex.prepared?.tempPath,
611
+ ].filter(item => item && present(item)))];
612
+ const retainedDirectories = manifest.createdDirectories.filter(item => present(item.path)).map(item => item.path);
613
+ const knownArtifacts = new Set([
614
+ 'manifest.json', ...manifest.participants.flatMap(item => [item.old.artifact, item.new.artifact]),
615
+ ...manifest.recoveryArtifacts,
616
+ ].filter(Boolean).map(item => path.basename(item)));
617
+ const otherArtifacts = readdirSync(directory).some(name => {
618
+ const artifact = path.join(directory, name);
619
+ return !knownArtifacts.has(name) || !lstatSync(artifact).isFile();
620
+ });
621
+ // Completion durably records commit or rollback. Later edits, consumption,
622
+ // or deletion need not match the historical participants to retire evidence.
623
+ const complete = manifest.status === 'complete';
624
+ const verifiedDirectories = complete
625
+ && manifest.createdDirectories.filter(item => retainedDirectories.includes(item.path)).every(item => {
626
+ try {
627
+ const stat = lstatSync(item.path);
628
+ if (!stat.isDirectory() || stat.isSymbolicLink() || !item.identity
629
+ || stat.dev !== item.identity.dev || stat.ino !== item.identity.ino) return false;
630
+ const entries = readdirSync(item.path);
631
+ if (entries.includes(path.basename(item.marker))) {
632
+ if (!lstatSync(item.marker).isFile() || readFileSync(item.marker, 'utf8') !== `${item.token}\n`) return false;
633
+ } else if (!['removing', 'removed'].includes(item.markerState)) return false;
634
+ return true;
635
+ } catch { return false; }
636
+ });
637
+ const resolvable = (complete ? verifiedDirectories : uniform && retainedDirectories.length === 0)
638
+ && retainedGitPaths.length === 0 && !otherArtifacts;
639
+ return { participants, generation: uniform ? [...states][0] : null, retainedGitPaths, retainedDirectories, resolvable,
640
+ reason: resolvable ? null : !complete && !uniform ? 'Canonical files do not agree on one generation' : 'Retained artifacts need manual review' };
641
+ }
642
+
643
+ // Shared by setup's preview and refusal, using the doctor's verdict and reason.
644
+ export function transactionResolutionAdvice(item) {
645
+ if (item.ownerLiveness === 'live' || item.lockHeld) return 'Another runlist process is mid-move; rerun setup once it finishes';
646
+ if (item.resolvable) return 'Run runlist doctor --transactions --apply, then rerun setup';
647
+ const retained = item.retainedGitPaths?.length ? ` Retained Git artifacts: ${item.retainedGitPaths.join(', ')}.` : '';
648
+ const reason = (item.reason ?? 'Recovery has not established a complete generation').replace(/\.+$/, '');
649
+ const generation = item.status === 'complete' ? '' : 'restore one complete generation, ';
650
+ return `${reason}.${retained} Manually inspect the canonical files and recovery artifacts in ${item.directory}; ${generation}preserve any unverified artifacts, then remove that transaction directory and rerun setup`;
651
+ }
652
+
583
653
  // Report on every transaction manifest without mutating anything, so a wedged
584
654
  // repo can be diagnosed. `recoverAbandonedTransactions` deliberately refuses to
585
655
  // guess and throws; that left no way to even SEE the state short of reading
@@ -592,7 +662,7 @@ export function inspectTransactions(repoRoot, options = {}) {
592
662
  const report = [];
593
663
  for (const entry of readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
594
664
  if (!entry.isDirectory() || entry.isSymbolicLink()) {
595
- report.push({ id: entry.name, readable: false, reason: 'Unsafe entry in transaction root', resolvable: false });
665
+ report.push({ id: entry.name, directory: path.join(root, entry.name), readable: false, reason: 'Unsafe entry in transaction root', resolvable: false });
596
666
  continue;
597
667
  }
598
668
  const directory = path.join(root, entry.name);
@@ -603,20 +673,20 @@ export function inspectTransactions(repoRoot, options = {}) {
603
673
  report.push({ id: entry.name, directory, manifestPath, readable: false, reason: err?.code === 'ENOENT' ? 'No manifest' : `Unreadable manifest: ${err.message}`, resolvable: false });
604
674
  continue;
605
675
  }
606
- try { validateManifest(manifest, manifestPath, directory, repoRoot, options); }
676
+ let resolution;
677
+ try {
678
+ validateManifest(manifest, manifestPath, directory, repoRoot, options);
679
+ resolution = transactionResolution(manifest, directory);
680
+ }
607
681
  catch (err) {
608
- report.push({ id: entry.name, directory, manifestPath, readable: false, reason: err.message, resolvable: false });
682
+ report.push({ id: entry.name, directory, manifestPath, status: manifest?.status, readable: false, reason: err.message, resolvable: false });
609
683
  continue;
610
684
  }
611
- const participants = manifest.participants.map(item => ({ path: item.path, state: participantState(item) }));
612
- const states = new Set(participants.map(item => item.state));
613
- const retainedGitPaths = (manifest.gitIndex?.retainedPaths ?? []).filter(item => existsSync(item));
614
- const retainedDirectories = manifest.createdDirectories.filter(item => existsSync(item.path)).map(item => item.path);
615
- // Safe to clear only when the filesystem already shows one uniform
616
- // generation: that IS the atomicity the transaction existed to guarantee,
617
- // so the manifest is bookkeeping for work that is fully done or fully
618
- // undone. Anything mixed, unknown, or with retained artifacts stays put.
619
- const uniform = states.size === 1 && (states.has('old') || states.has('new'));
685
+ const lockHeld = manifest.participants.some(item => {
686
+ const lockPath = lockPathFor(canonicalPath(item.path), repoRoot);
687
+ return existsSync(lockPath) && processOwnerLiveness(lockOwner(lockPath)) !== 'dead';
688
+ });
689
+ const ownerLiveness = processOwnerLiveness(manifest.owner);
620
690
  report.push({
621
691
  id: manifest.id,
622
692
  directory,
@@ -627,35 +697,54 @@ export function inspectTransactions(repoRoot, options = {}) {
627
697
  result: manifest.result,
628
698
  operation: manifest.operation,
629
699
  createdAt: manifest.createdAt,
630
- ownerLiveness: processOwnerLiveness(manifest.owner),
631
- participants,
632
- generation: uniform ? [...states][0] : null,
633
- retainedGitPaths,
634
- retainedDirectories,
635
- resolvable: uniform && retainedGitPaths.length === 0 && retainedDirectories.length === 0,
636
- reason: uniform
637
- ? (retainedGitPaths.length || retainedDirectories.length ? 'Retained artifacts need manual review' : null)
638
- : 'Canonical files do not agree on one generation',
700
+ ownerLiveness,
701
+ lockHeld,
702
+ ...resolution,
703
+ ...(lockHeld || ownerLiveness === 'live' ? { resolvable: false, reason: 'Another runlist process is mid-move; rerun later once it finishes' } : {}),
639
704
  });
640
705
  }
641
706
  return report;
642
707
  }
643
708
 
644
709
  // Clear the manifests inspectTransactions marked resolvable. Touches no
645
- // canonical file — those already agree on one generation; this only removes the
646
- // bookkeeping that is wedging later mutations.
710
+ // canonical file. Complete manifests need only verified cleanup evidence;
711
+ // non-complete manifests still require one uniform generation.
647
712
  export function resolveTransactions(repoRoot, options = {}) {
648
713
  const cleared = [];
649
714
  for (const item of inspectTransactions(repoRoot, options)) {
650
715
  if (!item.resolvable) continue;
651
- let manifest;
652
- try { manifest = JSON.parse(readFileSync(item.manifestPath, 'utf8')); } catch { continue; }
716
+ let manifest, manifestContent;
717
+ try {
718
+ manifestContent = readFileSync(item.manifestPath, 'utf8');
719
+ manifest = JSON.parse(manifestContent);
720
+ } catch { continue; }
653
721
  withPathLocks(manifest.participants.map(entry => entry.path), { repoRoot, ...options }, () => {
654
722
  // Re-check under the lock: nothing may have moved since inspection.
655
- const states = new Set(manifest.participants.map(participantState));
656
- if (states.size !== 1 || !(states.has('old') || states.has('new'))) return;
723
+ try {
724
+ if (readFileSync(item.manifestPath, 'utf8') !== manifestContent) return;
725
+ validateManifest(manifest, item.manifestPath, item.directory, repoRoot, options);
726
+ } catch { return; }
727
+ const verdict = transactionResolution(manifest, item.directory);
728
+ if (!verdict.resolvable) return;
729
+ // As on commit, remove verified markers deepest first, and remove only
730
+ // folders that become empty. A crash leaves the marker state resumable.
731
+ for (const entry of [...manifest.createdDirectories].sort((a, b) => b.path.length - a.path.length)) {
732
+ if (!verdict.retainedDirectories.includes(entry.path)) continue;
733
+ if (existsSync(entry.marker)) {
734
+ entry.markerState = 'removing';
735
+ durableJson(item.manifestPath, manifest, options);
736
+ unlinkSync(entry.marker);
737
+ fsyncDirectory(entry.path, options, 'transaction-directory-marker-delete');
738
+ entry.markerState = 'removed';
739
+ durableJson(item.manifestPath, manifest, options);
740
+ }
741
+ if (readdirSync(entry.path).length === 0) {
742
+ rmdirSync(entry.path);
743
+ fsyncDirectory(path.dirname(entry.path), options, 'transaction-directory-delete');
744
+ }
745
+ }
657
746
  cleanupTransactionDirectory(item.directory, manifest, options);
658
- cleared.push({ id: item.id, generation: [...states][0] });
747
+ cleared.push({ id: item.id, generation: verdict.generation });
659
748
  });
660
749
  }
661
750
  return cleared;
@@ -699,7 +788,7 @@ function beginMoveManifest({ repoRoot, transactionId, operation, sessionId, sour
699
788
  add(targetPath, { exists: false }, { exists: true, content: targetContent, mode: source.identity.mode }, 'target');
700
789
  for (const item of updates) add(item.path,
701
790
  { exists: true, content: item.snapshot.content, hash: item.snapshot.hash, mode: item.snapshot.identity.mode },
702
- { exists: true, content: item.content, mode: item.snapshot.identity.mode }, item.label === 'ownership' || !String(item.path).endsWith('.md') ? 'ownership' : 'referrer');
791
+ { exists: true, content: item.content, mode: item.snapshot.identity.mode }, item.label === 'handoff' ? 'handoff' : item.label === 'ownership' || !String(item.path).endsWith('.md') ? 'ownership' : 'referrer');
703
792
  for (const item of creations) add(item.path, { exists: false }, { exists: true, content: item.content, mode: item.mode ?? (0o666 & ~process.umask()) }, item.label === 'ownership' || !String(item.path).endsWith('.md') ? 'ownership' : 'creation');
704
793
  for (const item of deletions) add(item.path,
705
794
  { exists: true, content: item.snapshot.content, hash: item.snapshot.hash, mode: item.snapshot.identity.mode }, { exists: false }, item.label === 'ownership' || !String(item.path).endsWith('.md') ? 'ownership' : 'deletion');
@@ -1209,8 +1298,10 @@ export function replaceSnapshot(snapshot, newContent, options = {}) {
1209
1298
  return locked ? commit() : withPathLocks([snapshot.path], { repoRoot, ...options }, commit);
1210
1299
  }
1211
1300
 
1301
+ // `locked` means the caller already holds filePath's lock, taken in one sorted
1302
+ // withPathLocks set with the other paths its decision depends on.
1212
1303
  export function mutateFile(filePath, options, render) {
1213
- return withPathLocks([filePath], options, () => {
1304
+ const mutate = () => {
1214
1305
  options.testHooks?.beforeMutationSnapshot?.(filePath);
1215
1306
  const snapshot = snapshotFile(filePath);
1216
1307
  const rendered = render(snapshot.content, snapshot);
@@ -1222,7 +1313,8 @@ export function mutateFile(filePath, options, render) {
1222
1313
  if (err.committedSnapshot) restoreUpdate(err.committedSnapshot, snapshot, err);
1223
1314
  throw err;
1224
1315
  }
1225
- });
1316
+ };
1317
+ return options.locked ? mutate() : withPathLocks([filePath], options, mutate);
1226
1318
  }
1227
1319
 
1228
1320
  export function createFileExclusive(filePath, content, options) {
@@ -1244,6 +1336,16 @@ export function createFileExclusive(filePath, content, options) {
1244
1336
  options.testHooks?.afterPublicationBeforePathOpen?.('create-hardlink', filePath);
1245
1337
  } catch (err) {
1246
1338
  if (!['ENOTSUP', 'EOPNOTSUPP', 'EPERM', 'EXDEV', 'EMLINK'].includes(err?.code)) throw err;
1339
+ // A file that is read as code (a config every command imports) must
1340
+ // never be seen holding reservation data, not even after a crash
1341
+ // between the reservation and the rename, so such a caller refuses
1342
+ // instead of falling back.
1343
+ if (options.requireAtomicPublish) {
1344
+ const refusal = new Error(`This filesystem cannot create ${path.resolve(filePath)} atomically (hard links unsupported: ${err.code}).`);
1345
+ refusal.code = 'ERUNLIST_ATOMIC_PUBLISH_UNSUPPORTED';
1346
+ refusal.cause = err;
1347
+ throw refusal;
1348
+ }
1247
1349
  // Portable fallback: reserve with O_EXCL, then replace only our own
1248
1350
  // reservation. This may expose the reservation briefly, but can never
1249
1351
  // overwrite a competing creator's file.
@@ -1282,6 +1384,9 @@ export function createFileExclusive(filePath, content, options) {
1282
1384
 
1283
1385
  export function moveFileAtomic(sourcePath, targetPath, render, options) {
1284
1386
  const { repoRoot, finalize, rollbackFinalize, testHooks, updates = [], creations = [], deletions = [], guards = [] } = options;
1387
+ for (const item of updates.filter(item => item.label === 'handoff')) {
1388
+ validateParticipantPath({ path: path.resolve(item.path), policy: 'generated', label: 'handoff' }, repoRoot, options, 'handoff update');
1389
+ }
1285
1390
  recoverAbandonedTransactions(repoRoot, options);
1286
1391
  return withPathLocks([sourcePath, targetPath, ...updates.map(item => item.path), ...creations.map(item => item.path), ...deletions.map(item => item.path), ...guards.map(item => item.path)], options, () => {
1287
1392
  testHooks?.afterTransactionPhase?.('lock', { sourcePath, targetPath });
package/src/baton.mjs CHANGED
@@ -3,15 +3,17 @@ import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
4
  import { asString, toRepoPath, die, warn, isArchivedPath, resolveRefPath, nowIso, shellQuote } from './util.mjs';
5
5
  import { buildIndex, resolveDocArg } from './index.mjs';
6
- import { preparePromptDocument, runNew, readBodyInput, readPipedBodyInput, slugify } from './new.mjs';
6
+ import { preparePromptDocument, promptDirectory, runNew, readBodyInput, readPipedBodyInput, slugify, unconfiguredPromptHint } from './new.mjs';
7
7
  import { ensurePlanCompletionBeforeRelease, planHasPendingCompletion, renderLifecycleMutation, runSet } from './lifecycle.mjs';
8
8
  import { green, dim, yellow } from './color.mjs';
9
9
  import { handoffNotDoneLine, recordPendingHandoff, runBatonDone } from './handoff-record.mjs';
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';
13
+ import { builtinHandoffTemplate, parentPromptName } from './prompt-handoff.mjs';
14
14
  import { readTemplateStore, templateFile } from './template-store.mjs';
15
+ import { isGitIgnored, gitCommitPaths, literalGitPathspec } from './git.mjs';
16
+ import { assertPublishedPromptFolder, carriedOnto, carryLegacyPrompts, existsAfterCarry, isHandoffsMode, planHandoffsConfig, publishHandoffsConfig, readsAsHandoffsOnly } from './handoffs-mode.mjs';
15
17
 
16
18
  // `runlist baton` is the one-command handoff: save the resume prompt AND release
17
19
  // the plan in a single atomic-ish verb. It exists because the three-step skill
@@ -41,6 +43,9 @@ const BODY_USAGE = `Nothing saved: baton needs the resume you wrote, passed as @
41
43
  runlist baton @/tmp/draft.md # hand off the plan this session owns
42
44
  runlist baton <plan-file> @/tmp/draft.md # hand off a named plan
43
45
  runlist baton <slug> @/tmp/draft.md # no plan: save resume-<slug>, change nothing else`;
46
+ // Where planning is off (or there is no config yet) a name is the only form.
47
+ const HANDOFFS_BODY_USAGE = `Nothing saved: baton needs the resume you wrote, passed as @<file> or - (stdin).
48
+ runlist baton <slug> @/tmp/draft.md # saves resume-<slug>; pick a short slug naming the work`;
44
49
 
45
50
  // A handoff that lands beside a pending one leaves two prompts for the same
46
51
  // work, and the next session picks whichever sorts first. Baton used to step to
@@ -73,34 +78,41 @@ function refusePendingHandoff(pending) {
73
78
  die(`Nothing saved: a handoff for this is already pending:\n${lines}\nInspect it with \`runlist prompts show ${slug}\`. If it is current, keep it and do not hand off again. If it is stale, re-run baton with \`--replace\` to preserve the old handoff under archived/.`);
74
79
  }
75
80
 
76
- function archivedCopyPath(promptPath, config) {
81
+ // `projection` is a carry not yet made (src/handoffs-mode.mjs): its targets
82
+ // count as taken, and the folder may not exist yet to authorize against.
83
+ function archivedCopyPath(promptPath, config, projection = null) {
77
84
  const dir = path.join(path.dirname(promptPath), config.archiveDir);
78
85
  const ext = path.extname(promptPath);
79
86
  const stem = path.basename(promptPath, ext);
80
87
  let candidate = path.join(dir, `${stem}${ext}`);
81
- for (let n = 2; existsSync(candidate); n++) candidate = path.join(dir, `${stem}-${n}${ext}`);
82
- return authorizeManagedDestination(candidate, config, { kind: 'Baton replacement archive' }).path;
88
+ for (let n = 2; existsAfterCarry(projection, candidate); n++) candidate = path.join(dir, `${stem}-${n}${ext}`);
89
+ return projection ? candidate : authorizeManagedDestination(candidate, config, { kind: 'Baton replacement archive' }).path;
83
90
  }
84
91
 
85
92
  function commitCommand(config, message, paths) {
86
- const fallback = ['git', 'commit', '-m', message, '--', ...paths];
87
- let argv = fallback;
88
93
  if (typeof config.hooks.batonCommitCommand === 'function') {
89
94
  try {
90
95
  const custom = config.hooks.batonCommitCommand(message, [...paths]);
91
96
  if (!Array.isArray(custom) || custom.length === 0 || !custom.every(part => typeof part === 'string' && part.length > 0)) {
92
97
  throw new Error('return a nonempty string argv array');
93
98
  }
94
- argv = custom;
99
+ return custom.map(shellQuote).join(' ');
95
100
  } catch (err) { warn(`batonCommitCommand failed (${err.message}); printing the default git command.`); }
96
101
  }
97
- return argv.map(shellQuote).join(' ');
102
+ const files = gitCommitPaths(paths, config.repoRoot);
103
+ if (!files.paths.length) return null;
104
+ // Keep ordinary command output stable, while protecting filenames that Git
105
+ // would otherwise interpret as pathspec magic (shell quoting is not enough).
106
+ const pathspec = file => /[*?[\\]/.test(file) || file.startsWith(':') ? literalGitPathspec(file) : file;
107
+ const command = ['git', 'commit', '-m', message, '--', ...files.paths.map(pathspec)].map(shellQuote).join(' ');
108
+ if (!files.add.length) return command;
109
+ return `${['git', 'add', '--', ...files.add.map(pathspec)].map(shellQuote).join(' ')} && ${command}`;
98
110
  }
99
111
 
100
112
  // With no plan named, --done finds this session's record; a named plan is
101
113
  // echoed back so the command works from any session.
102
114
  function explicitPlanDone(planArg, record) {
103
- return planArg ? `runlist baton --done ${record.plan}` : 'runlist baton --done';
115
+ return planArg ? `runlist baton --done ${shellQuote(record.plan)}` : 'runlist baton --done';
104
116
  }
105
117
 
106
118
  // Is this positional a filesystem reference (must resolve, typos die) or a
@@ -123,6 +135,8 @@ export async function runBaton(argv, config, opts = {}) {
123
135
  let subagent = false;
124
136
  let parentPrompt = null;
125
137
  let template = false;
138
+ let nextStep = null;
139
+ let currentState = null;
126
140
  const positionals = [];
127
141
  for (let i = 0; i < argv.length; i++) {
128
142
  const a = argv[i];
@@ -132,6 +146,11 @@ export async function runBaton(argv, config, opts = {}) {
132
146
  if (a === '--force') { force = true; continue; }
133
147
  if (a === '--replace') { replace = true; continue; }
134
148
  if (a === '--done') { done = true; continue; }
149
+ if (a === '--next-step' || a === '--current-state') {
150
+ if (i + 1 >= argv.length) die(`${a} needs a value: runlist baton --done [<plan-file>] ${a} "<text>"`);
151
+ if (a === '--next-step') nextStep = argv[++i]; else currentState = argv[++i];
152
+ continue;
153
+ }
135
154
  if (a === '--subagent') { subagent = true; continue; }
136
155
  if (a === '--template') { template = true; continue; }
137
156
  if (a === '--parent') {
@@ -145,6 +164,9 @@ export async function runBaton(argv, config, opts = {}) {
145
164
  die(`Unknown flag for \`runlist baton\`: ${a}`);
146
165
  }
147
166
  if (parentPrompt && !subagent) die('--parent requires --subagent.');
167
+ for (const [flag, value] of [['--next-step', nextStep], ['--current-state', currentState]]) {
168
+ if (value !== null && !done) die(`${flag} only goes with --done: save the prompt first, then run \`runlist baton --done [<plan-file>] ${flag} "<text>"\`.`);
169
+ }
148
170
  if (template) {
149
171
  if (done || replace || force || statusFlag || note !== null || positionals.length || bodyFlag !== null) {
150
172
  die('Use `runlist baton --template` or `runlist baton --subagent --template [--parent resume-name]` to print a draft without saving.');
@@ -153,7 +175,7 @@ export async function runBaton(argv, config, opts = {}) {
153
175
  const name = subagent ? 'subagent' : 'resume';
154
176
  const key = `handoff:${name}`;
155
177
  const override = config ? readTemplateStore(config).data.templates[key]?.source : null;
156
- const source = override ?? HANDOFF_TEMPLATES[key].source;
178
+ const source = override ?? builtinHandoffTemplate(key, { planning: !readsAsHandoffsOnly(config) });
157
179
  const draft = source.replaceAll('{{parent_prompt}}', parentPrompt ?? '<resume-parent-name>');
158
180
  process.stdout.write(json ? JSON.stringify({ operation: 'baton-template', name, origin: override ? templateFile : 'Built-in', parentPrompt, template: draft }, null, 2) + '\n' : draft);
159
181
  return;
@@ -170,8 +192,9 @@ export async function runBaton(argv, config, opts = {}) {
170
192
  // The second half of a plan handoff: the prompt is already saved, and this
171
193
  // confirms the plan was brought up to date after it (src/handoff-record.mjs).
172
194
  if (done) {
173
- if (dryRun) die('`runlist baton --done` changes nothing but its own record; run it without --dry-run.');
174
- return runBatonDone(planArg, config, { json });
195
+ if (bodyArg !== null || bodyFlag !== null) die('`runlist baton --done` takes no prompt body; the prompt was saved by the first baton call.');
196
+ if (isHandoffsMode(config)) die('Nothing to confirm: planning is off in this project, so the prompt baton saved is the whole handoff.');
197
+ return runBatonDone(planArg, config, { json, dryRun, nextStep, currentState });
175
198
  }
176
199
 
177
200
  // Body FIRST — it's the common failure (`new prompt` without a body was the
@@ -183,7 +206,7 @@ export async function runBaton(argv, config, opts = {}) {
183
206
  // Auto-consume piped/redirected stdin, same probe as `runlist new`.
184
207
  body = readPipedBodyInput();
185
208
  }
186
- if (!body || !body.trim()) die(BODY_USAGE);
209
+ if (!body || !body.trim()) die(isHandoffsMode(config) || !config.configFound ? HANDOFFS_BODY_USAGE : BODY_USAGE);
187
210
  const draft = extractFrontmatter(body);
188
211
  const draftFm = parseSimpleFrontmatter(draft.frontmatter);
189
212
  if (!subagent && asString(draftFm.parent_prompt)) die('parent_prompt requires --subagent; a resume handoff cannot be its own child.');
@@ -222,6 +245,9 @@ export async function runBaton(argv, config, opts = {}) {
222
245
  planPath = path.resolve(config.repoRoot, owned.plan.path);
223
246
  } else if (owned.owned?.length > 1) {
224
247
  die(`Multiple plans are owned by this session; pass one explicitly:\n${owned.owned.map(d => ' runlist baton ' + d.path + ' @/tmp/draft.md').join('\n')}`);
248
+ } else if (isHandoffsMode(config) || !config.configFound) {
249
+ // No plans here: the only thing missing is a name, which the session picks.
250
+ die('Baton needs a short name for the handoff — a few words naming the work:\n runlist baton <slug> @/tmp/draft.md # saves resume-<slug>');
225
251
  } else {
226
252
  const diagnostics = owned.diagnostics?.length ? `\nIgnored ownership records:\n${owned.diagnostics.map(d => ` ${d}`).join('\n')}` : '';
227
253
  die(`No valid in-session plan is owned by this session, so baton needs a name for the resume prompt:\n runlist baton <slug> @/tmp/draft.md # saves resume-<slug>, touches nothing else\nHanding off a specific plan? runlist baton <plan-file> @/tmp/draft.md${diagnostics}`);
@@ -241,9 +267,14 @@ export async function runBaton(argv, config, opts = {}) {
241
267
  const slugBase = `${handoffPrefix}${unprefixed}`;
242
268
  let replacement = null;
243
269
  let alternatePromptPath = null;
244
- const resolvePending = () => {
245
- const target = preparePromptDocument(slugBase, body, config, { dryRun: true });
246
- const pending = pendingHandoffs(target.filePath, planPath, config);
270
+ // With a carry planned but not made (`projection`), the check runs against
271
+ // docs/prompts/ as the carry will leave it, so a refusal comes before
272
+ // anything moves and dry-run answers as apply will.
273
+ const resolvePending = (projection = null) => {
274
+ const target = projection ? { filePath: path.join(projection.promptDir, `${slugBase}.md`) } : preparePromptDocument(slugBase, body, config, { dryRun: true });
275
+ const carriedHere = projection ? carriedOnto(projection, target.filePath) : null;
276
+ const pending = !projection ? pendingHandoffs(target.filePath, planPath, config)
277
+ : carriedHere ? [carriedHere.from] : existsSync(target.filePath) ? [toRepoPath(target.filePath, config.repoRoot)] : [];
247
278
  if (planPath) {
248
279
  const alternateSlug = `${subagent ? 'resume-' : 'subagent-'}${unprefixed}`;
249
280
  alternatePromptPath = preparePromptDocument(alternateSlug, body, config, { dryRun: true }).filePath;
@@ -260,12 +291,12 @@ export async function runBaton(argv, config, opts = {}) {
260
291
  const detail = pending.length ? `Found ${pending.length}:\n${pending.map(p => ` ${p}`).join('\n')}` : 'No pending handoff matches this work.';
261
292
  die(`Nothing replaced: --replace requires exactly one pending handoff. ${detail}`);
262
293
  }
263
- const oldRepoPath = pending[0];
294
+ const oldRepoPath = carriedHere ? carriedHere.to : pending[0];
264
295
  if (subagent && /^resume-/i.test(path.basename(oldRepoPath))) {
265
296
  die(`Nothing replaced: ${oldRepoPath} is a parent handoff. Inspect it with \`runlist prompts show ${path.basename(oldRepoPath, '.md')}\`; use a separate task slug for the child handoff, or omit --subagent to refresh the parent handoff.`);
266
297
  }
267
- const oldPath = authorizeManagedSource(path.resolve(config.repoRoot, oldRepoPath), config, { kind: 'Baton replacement source' }).path;
268
- const oldRaw = readFileSync(oldPath, 'utf8');
298
+ const oldPath = carriedHere ? carriedHere.target : authorizeManagedSource(path.resolve(config.repoRoot, oldRepoPath), config, { kind: 'Baton replacement source' }).path;
299
+ const oldRaw = carriedHere ? carriedHere.expectedSource : readFileSync(oldPath, 'utf8');
269
300
  const { frontmatter, body: oldBody } = extractFrontmatter(oldRaw);
270
301
  const fm = parseSimpleFrontmatter(frontmatter);
271
302
  if (asString(fm.type) !== 'prompt' || asString(fm.status) !== 'pending') {
@@ -280,7 +311,7 @@ export async function runBaton(argv, config, opts = {}) {
280
311
  parentPrompt ??= priorParent;
281
312
  replacement = {
282
313
  oldPath, oldRepoPath, oldRaw, priorParent,
283
- archivedPath: archivedCopyPath(oldPath, config),
314
+ archivedPath: archivedCopyPath(oldPath, config, projection),
284
315
  alreadyCurrent: oldBody.trim() === draft.body.trim()
285
316
  && Object.entries(draftFm).every(([key, value]) => key === 'parent_prompt' || JSON.stringify(fm[key]) === JSON.stringify(value))
286
317
  && (priorParent ?? null) === parentPrompt
@@ -289,6 +320,57 @@ export async function runBaton(argv, config, opts = {}) {
289
320
  };
290
321
  };
291
322
 
323
+ // Zero setup: a prompt saved in a repository with no config creates the
324
+ // handoffs-only config first (src/handoffs-mode.mjs). Prompts an older
325
+ // version saved in the root prompts/ move to docs/prompts/ first. Every check
326
+ // that can refuse the save runs against docs/prompts/ as that carry leaves
327
+ // it, before anything moves or is written, and dry-run stops at the same
328
+ // point. Plan mode is left alone: a plan saved before setup belongs to
329
+ // `runlist setup`.
330
+ let handoffsCreated = null;
331
+ let handoffsLine = null;
332
+ if (!planPath && !config.configFound) {
333
+ const quiet = { write() {} };
334
+ const setup = await planHandoffsConfig(process.cwd(), config, { dryRun, out: json ? quiet : process.stderr });
335
+ if (setup.previewStopped) {
336
+ const result = { operation: 'baton', parentPrompt, dryRun: true, disposition: 'would-change', wouldChange: true, mode: 'slug', status: null,
337
+ repositoryFiles: [], sessionFiles: [], generatedFiles: [], deferredGeneratedFiles: [], prompt: null, plan: null, planMovement: null,
338
+ replacement: null, claimRelease: null, handoffDone: true, savedPrompt: null, handoffsSetup: null, pendingRecovery: true };
339
+ if (json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
340
+ return result;
341
+ }
342
+ config = setup.config;
343
+ resolvePending(setup);
344
+ await carryLegacyPrompts(setup, { dryRun, out: json ? quiet : process.stderr });
345
+ handoffsCreated = setup.created;
346
+ if (dryRun) {
347
+ handoffsLine = setup.dryRunLine;
348
+ if (handoffsLine && !json) process.stderr.write(`${handoffsLine}\n`);
349
+ const prompt = replacement?.oldRepoPath ?? path.posix.join(setup.promptRoot, `${slugBase}.md`);
350
+ const archivedPrompt = replacement && !replacement.alreadyCurrent ? toRepoPath(replacement.archivedPath, config.repoRoot) : null;
351
+ const result = { operation: 'baton', parentPrompt, dryRun: true, disposition: replacement?.alreadyCurrent ? 'already-current' : 'would-change', wouldChange: true, mode: 'slug', status: null,
352
+ repositoryFiles: [], sessionFiles: [prompt, ...(archivedPrompt ? [archivedPrompt] : [])], generatedFiles: [], deferredGeneratedFiles: [], prompt, plan: null, planMovement: null,
353
+ replacement: replacement ? { previousPrompt: replacement.oldRepoPath, archivedPrompt, pendingPrompt: prompt, alreadyCurrent: replacement.alreadyCurrent } : null,
354
+ claimRelease: null, handoffDone: true, savedPrompt: path.basename(prompt, '.md'), handoffsSetup: handoffsCreated };
355
+ if (json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
356
+ else {
357
+ if (archivedPrompt) process.stderr.write(`${dim('[dry-run]')} Would preserve old handoff: ${replacement.oldRepoPath} → ${archivedPrompt}\n`);
358
+ process.stderr.write(`${dim('[dry-run]')} Would ${replacement ? (replacement.alreadyCurrent ? 'keep' : 'replace') : 'create'}: ${prompt}\n`);
359
+ }
360
+ return result;
361
+ }
362
+ if (!setup.legacy) {
363
+ const published = await publishHandoffsConfig(setup, { testHooks: opts.testHooks });
364
+ config = published.config;
365
+ assertPublishedPromptFolder(setup, published, promptDirectory(config));
366
+ handoffsCreated = published.created;
367
+ handoffsLine = published.line;
368
+ if (handoffsLine && !json) process.stderr.write(`${handoffsLine}\n`);
369
+ }
370
+ // The carry is made: check again against the files themselves below.
371
+ replacement = null;
372
+ }
373
+
292
374
  let repoPath = null;
293
375
  let oldStatus = null;
294
376
  let ownershipPath = null;
@@ -351,6 +433,14 @@ export async function runBaton(argv, config, opts = {}) {
351
433
  else if (planHasPendingCompletion(repoPath, config)) process.stderr.write(`${dim('[dry-run]')} Pending claim completion would block this release.\n`);
352
434
  } else {
353
435
  resolvePending();
436
+ // The skill's rule is "own an in-session plan → no slug"; a session that
437
+ // picked a slug anyway is told its plan stays claimed. Child handoffs under
438
+ // a parent's plan use a slug on purpose.
439
+ if (!subagent && config.configFound && !isHandoffsMode(config)) {
440
+ let owned = [];
441
+ try { owned = listOwnedPlans(config, authoritativeSessionId()); } catch { /* unreadable claims: say nothing */ }
442
+ if (owned.length) warn(`This session owns ${owned.map(record => record.plan).join(', ')}, which stays in-session: this saves ${slugBase} without handing ${owned.length === 1 ? 'it' : 'them'} off. To hand ${owned.length === 1 ? 'the plan' : 'a plan'} off with the prompt, run \`runlist baton ${owned.length === 1 ? '' : '<plan-file> '}@<draft-file>\` with no slug.`);
443
+ }
354
444
  if (statusFlag) warn(`--status ignored — no plan involved in this handoff (saving the prompt only).`);
355
445
  if (note) warn(`--note ignored — no plan involved in this handoff (notes land in a plan's Version History).`);
356
446
  }
@@ -397,7 +487,7 @@ export async function runBaton(argv, config, opts = {}) {
397
487
  newResult = { sessionFiles: [replacement.oldRepoPath, ...(replacement.alreadyCurrent ? [] : [toRepoPath(replacement.archivedPath, config.repoRoot)])] };
398
488
  } else {
399
489
  try {
400
- newResult = await runNew(['prompt', slugBase, '--body', body], config, { parentPrompt, dryRun, deferIndex: true });
490
+ newResult = await runNew(['prompt', slugBase, '--body', body], config, { parentPrompt, dryRun, deferIndex: true, handoffsPlanned: true });
401
491
  } catch (err) {
402
492
  // Lost a race with another baton between the pending check and the write.
403
493
  if (/File already exists/.test(String(err?.message))) refusePendingHandoff([prepared.repoPath]);
@@ -493,6 +583,7 @@ export async function runBaton(argv, config, opts = {}) {
493
583
  claimRelease: planPath ? { wouldRelease: wouldReleaseClaim, released: !dryRun && wouldReleaseClaim } : null,
494
584
  handoffDone: !pendingHandoff,
495
585
  savedPrompt: createdSlug,
586
+ handoffsSetup: handoffsCreated,
496
587
  };
497
588
 
498
589
  const prefix = dryRun ? dim('[dry-run] ') : '';
@@ -501,26 +592,29 @@ export async function runBaton(argv, config, opts = {}) {
501
592
  return operationResult;
502
593
  }
503
594
  const verdict = replacement?.alreadyCurrent ? '✓ Handoff kept' : pendingHandoff ? '✓ Prompt saved' : replacement ? '✓ Baton replaced' : '✓ Baton passed';
595
+ if (!handoffsLine && !dryRun && (planPath || replacement)) process.stderr.write(unconfiguredPromptHint(config));
504
596
  process.stderr.write(`\n${prefix}${green(verdict)}: ${createdSlug} (the next session's hud surfaces it — nothing to paste into chat)\n`);
505
597
  if (archivedPromptRepoPath) process.stderr.write(dim(`${prefix}Previous handoff preserved: ${archivedPromptRepoPath}\n`));
506
598
  if (planGuard) {
507
599
  process.stderr.write(dim(`${repoPath} left at \`${oldStatus}\` — it was not in-session, so there was no claim to release.\n`));
508
600
  process.stderr.write(dim(`Meant to change it? runlist set <status> ${repoPath}\n`));
509
601
  }
510
- if (statusChanged) {
511
- let gitignored = false;
512
- try {
513
- const { isGitIgnored } = await import('./git.mjs');
514
- gitignored = isGitIgnored(planPath, config.repoRoot);
515
- } catch { /* not a git repo — fall through to the hint */ }
516
- if (gitignored) {
517
- process.stderr.write(dim(`${repoPath} is gitignored — no commit needed.\n`));
518
- } else {
602
+ if (statusChanged && isGitIgnored(planPath, config.repoRoot)) {
603
+ // Check the source even after a move: an ignored plan must not produce a
604
+ // commit hint for its destination or rewritten references, including hooks.
605
+ process.stderr.write(dim(`${repoPath} is gitignored — no commit needed.\n`));
606
+ } else if (statusChanged) {
607
+ const command = commitCommand(config, `baton: ${path.basename(planPath, '.md')} ${oldStatus} → ${status}`, operationResult.repositoryFiles);
608
+ if (command) {
519
609
  process.stderr.write(`${prefix}Commit the repository files (session files stay OUT of the pathspec):\n`);
520
- process.stderr.write(`${prefix} ${commitCommand(config, `baton: ${path.basename(planPath, '.md')} ${oldStatus} → ${status}`, operationResult.repositoryFiles)}\n`);
610
+ process.stderr.write(`${prefix} ${command}\n`);
611
+ } else {
612
+ process.stderr.write(dim('No repository files to commit — no commit needed.\n'));
521
613
  }
522
614
  }
523
- if (operationResult.deferredGeneratedFiles.length) process.stderr.write(dim(`Generated index deferred: ${operationResult.deferredGeneratedFiles.join(', ')}\n`));
615
+ // Baton leaves the shared index alone so a handoff never conflicts with other work; nothing
616
+ // here needs the user, so it stays out of the normal output (--json always carries it).
617
+ if (opts.verbose && operationResult.deferredGeneratedFiles.length) process.stderr.write(dim(`Index not refreshed by baton: ${operationResult.deferredGeneratedFiles.join(', ')} (the next runlist command that rebuilds it picks up the change)\n`));
524
618
  if (pendingHandoff) process.stderr.write(yellow(handoffNotDoneLine(pendingHandoff, explicitPlanDone(planArg, pendingHandoff))) + '\n');
525
619
  // The name again, last, so a piped `| tail` still shows what was saved.
526
620
  process.stderr.write(`${prefix}Saved prompt: ${createdSlug}\n`);