runlist 0.99.2 → 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 +414 -46
  3. package/bin/runlist.mjs +402 -51
  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
package/src/new.mjs CHANGED
@@ -12,6 +12,8 @@ import { regenIndex } from './lifecycle.mjs';
12
12
  import { extractFrontmatter, parseSimpleFrontmatter, normalizeEol } from './frontmatter.mjs';
13
13
  import { authorizeManagedDestination } from './managed-path.mjs';
14
14
  import { createFileExclusive, mutateFileSet, MutationConflictError } from './atomic-mutation.mjs';
15
+ import { assertPublishedPromptFolder, carriedOnto, carryLegacyPrompts, isHandoffsMode, planHandoffsConfig, planningRefusal, publishHandoffsConfig } from './handoffs-mode.mjs';
16
+ import { PROMPT_DIR, rootIsWorkingFolder } from './naming.mjs';
15
17
 
16
18
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
19
  const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
@@ -410,6 +412,12 @@ export function titleize(s) {
410
412
 
411
413
  // Where saved prompts are written: the prompt template's targetRoot when a
412
414
  // root matches it, else the primary root plus the template's `dir`.
415
+ // The prompt template's routing alone, for callers that must not read the
416
+ // Markdown template store (hooks, settings inspection).
417
+ export function promptRouting(config) {
418
+ return { dir: rootIsWorkingFolder(config) ? PROMPT_DIR : 'prompts', targetRoot: 'prompts', ...config.raw?.templates?.prompt };
419
+ }
420
+
413
421
  export function promptDirectory(config, template = resolveTemplate('prompt', config)) {
414
422
  let targetRoot = config.docsRoot;
415
423
  let routed = false;
@@ -420,6 +428,24 @@ export function promptDirectory(config, template = resolveTemplate('prompt', con
420
428
  return template.dir && !routed ? path.join(targetRoot, template.dir) : targetRoot;
421
429
  }
422
430
 
431
+ // The same default/template routing used by `new plan`, including before setup.
432
+ export function planDirectory(config, template = resolveTemplate('plan', config)) {
433
+ const roots = config.docsRoots ?? [config.docsRoot];
434
+ const routed = template.targetRoot && roots.find(root => root.endsWith(template.targetRoot) || path.basename(root) === template.targetRoot);
435
+ return routed || path.join(catchAllRoot(config), template.dir || '');
436
+ }
437
+
438
+ // Only reached where no handoffs config is created for the prompt: a plan
439
+ // handed off before setup, or a save outside Git in a folder an older runlist
440
+ // saved handoffs or plans in.
441
+ export function unconfiguredPromptHint(config) {
442
+ return config.configFound ? '' : 'No runlist config here yet — run `runlist setup`; it carries this prompt over.\n';
443
+ }
444
+
445
+ function userNameDirIn(name) {
446
+ return Boolean(name && (name.includes('/') || name.includes(path.sep)));
447
+ }
448
+
423
449
  function bindPromptParent(content, parentPrompt) {
424
450
  if (!parentPrompt) return content;
425
451
  const { frontmatter, body } = extractFrontmatter(content);
@@ -668,10 +694,122 @@ next_step:
668
694
  `;
669
695
  }
670
696
 
697
+ // Where `runlist new` puts a document: the name's own folder (read relative
698
+ // to the repo, or to --root), else the template's root and folder. Shared by
699
+ // the check a first prompt runs before the carry and by the write itself.
700
+ function resolveNewTarget(name, typeName, template, config, rootName, { explainRoots = true } = {}) {
701
+ // If name contains path separators, split into directory prefix and basename
702
+ let nameDir = null;
703
+ let namePart = name;
704
+ if (name.includes('/') || name.includes(path.sep)) {
705
+ nameDir = path.dirname(name);
706
+ namePart = path.basename(name, '.md');
707
+ } else if (name.endsWith('.md')) {
708
+ namePart = name.slice(0, -3);
709
+ }
710
+ // A prefix the *user* typed and one a template declares resolve differently
711
+ // below, so the distinction has to survive the template's assignment to nameDir.
712
+ const userNameDir = nameDir;
713
+
714
+ // Slugify
715
+ const slug = namePart.toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
716
+ if (!slug) { die('Name resolves to empty slug: ' + name); }
717
+
718
+ // Resolve target root. Precedence: CLI --root > template.targetRoot > config.docsRoot.
719
+ // When the chosen root is a first-class type-container (matched by --root or targetRoot),
720
+ // we skip the `template.dir` join — the root already points at the right directory.
721
+ let targetRoot = catchAllRoot(config);
722
+ let routedToTypeRoot = false;
723
+ if (rootName) {
724
+ const roots = config.docsRoots || [config.docsRoot];
725
+ const match = roots.find(r => r.endsWith(rootName) || path.basename(r) === rootName);
726
+ if (!match) {
727
+ const available = roots.map(r => path.basename(r)).join(', ');
728
+ die(`Unknown root: ${rootName}\nAvailable: ${available}`);
729
+ }
730
+ targetRoot = match;
731
+ routedToTypeRoot = true;
732
+ } else if (typeof template === 'object' && template.targetRoot) {
733
+ const roots = config.docsRoots || [config.docsRoot];
734
+ const match = roots.find(r => r.endsWith(template.targetRoot) || path.basename(r) === template.targetRoot);
735
+ if (match) {
736
+ targetRoot = match;
737
+ routedToTypeRoot = true;
738
+ }
739
+ }
740
+
741
+ // Template-declared subdirectory (e.g., prompt → 'prompts') — only relevant when we
742
+ // didn't already land in a type-specific root via --root or targetRoot.
743
+ if (typeof template === 'object' && template.dir && !nameDir && !routedToTypeRoot) {
744
+ nameDir = path.join(path.relative(config.repoRoot, targetRoot), template.dir);
745
+ }
746
+
747
+ // Path — a directory prefix is read relative to the repo, because `runlist new
748
+ // plan docs/plans/feature` is a full repo path and has to stay one. That was
749
+ // the ONLY reading, which is how `--root` came to be silently ignored the
750
+ // moment a name contained a slash: the block above picked a root and this line
751
+ // threw it away, so the one flag the out-of-root error advertises could not
752
+ // fix the error. With an explicit --root the prefix is now read relative to
753
+ // that root — unless the repo-relative reading already lands inside it, so
754
+ // full paths keep working.
755
+ const allRoots = config.docsRoots ?? [config.docsRoot];
756
+ let baseDir;
757
+ if (!nameDir) baseDir = targetRoot;
758
+ else if (userNameDir && rootName) {
759
+ const repoRelative = path.resolve(config.repoRoot, nameDir);
760
+ baseDir = lexicallyInside(targetRoot, repoRelative) ? repoRelative : path.resolve(targetRoot, nameDir);
761
+ } else baseDir = path.resolve(config.repoRoot, nameDir);
762
+ const filePath = path.join(baseDir, slug + '.md');
763
+ const repoPath = toRepoPath(filePath, config.repoRoot);
764
+
765
+ // Without --root there is nothing to disambiguate with, so a prefix pointing
766
+ // outside every root stays an error — but it names the flag that resolves it,
767
+ // and the flag now works. The generic containment error underneath reports
768
+ // absolute paths and no remedy, which is what agents kept re-guessing at.
769
+ //
770
+ // Gated on the remedy actually working: the suggestion is only offered when
771
+ // `--root` would land the file inside that root. That is what keeps this off
772
+ // a traversal (`../escaped`, an absolute path), where `--root` fixes nothing
773
+ // and the containment check below is the error that should speak — printing
774
+ // an untested remedy is the very defect this finding is about.
775
+ if (explainRoots && userNameDir && !rootName && !allRoots.some(root => lexicallyInside(root, filePath))) {
776
+ const rooted = path.resolve(targetRoot, userNameDir, slug + '.md');
777
+ if (lexicallyInside(targetRoot, rooted)) {
778
+ die(`Destination is outside every configured root:\n`
779
+ + ` ${repoPath}\n\n`
780
+ + `A name with a \`/\` is read relative to the repo.\n`
781
+ + `To place it under a root instead:\n`
782
+ + ` runlist new ${typeName} ${name} --root ${path.basename(targetRoot)}\n`
783
+ + ` → ${toRepoPath(rooted, config.repoRoot)}\n\n`
784
+ + `Roots: ${allRoots.map(root => path.basename(root)).join(', ')}`);
785
+ }
786
+ }
787
+ return { nameDir, namePart, userNameDir, slug, targetRoot, baseDir, filePath, repoPath };
788
+ }
789
+
790
+ // Prompts live in the prompt folder and nowhere else where planning is off or
791
+ // not set up yet (one prompt location, src/naming.mjs): a name with a folder in
792
+ // it lands inside that folder or is refused, so nothing writes a root prompts/.
793
+ function assertPromptFolder(target, name, config) {
794
+ if (config.configFound && !isHandoffsMode(config)) return;
795
+ const folder = promptDirectory(config);
796
+ if (lexicallyInside(folder, target.filePath)) return;
797
+ const where = toRepoPath(folder, config.repoRoot);
798
+ die(`Nothing saved: prompts are saved in ${where}/, and \`${name}\` is outside it. Use a plain name (runlist new prompt ${path.basename(name, '.md')} …) or a path inside ${where}/.`);
799
+ }
800
+
801
+ async function waitingForSetup(config) {
802
+ const { documentsWaitingForSetup } = await import('./setup-waiting.mjs');
803
+ return documentsWaitingForSetup(config);
804
+ }
805
+
671
806
  export async function runNew(argv, config, opts = {}) {
672
807
  const { dryRun } = opts;
673
808
 
674
- if (argv.find(a => !a.startsWith('-')) === 'decision') return runNewDecisionArgs(argv, config, opts);
809
+ if (argv.find(a => !a.startsWith('-')) === 'decision') {
810
+ if (isHandoffsMode(config)) die(planningRefusal('`runlist new decision`', await waitingForSetup(config)));
811
+ return runNewDecisionArgs(argv, config, opts);
812
+ }
675
813
 
676
814
  const knownTypes = new Set(Object.keys(BUILTIN_TEMPLATES));
677
815
  // Also include any custom templates from config
@@ -759,6 +897,9 @@ export async function runNew(argv, config, opts = {}) {
759
897
  typeName = 'plan';
760
898
  if (runlistArg === null && !roadmap) coordination = true;
761
899
  }
900
+ // A handoffs-only project scans its prompt folder alone; anything else
901
+ // created there would land among the prompts and outside every listing.
902
+ if (isHandoffsMode(config) && typeName !== 'prompt') die(planningRefusal(`\`runlist new ${typeName}\``, await waitingForSetup(config)));
762
903
 
763
904
  if (!name) {
764
905
  if (isInteractive()) {
@@ -861,6 +1002,10 @@ export async function runNew(argv, config, opts = {}) {
861
1002
  if (split.frontmatter) {
862
1003
  bodyFrontmatter = split.frontmatter;
863
1004
  bodyInput = split.body;
1005
+ if (Object.hasOwn(bodyFrontmatter, 'status') && !effective.has(bodyFrontmatter.status)) {
1006
+ process.stdout.write(`status \`${bodyFrontmatter.status}\` is not a ${typeName} status; using \`${status}\`\n`);
1007
+ delete bodyFrontmatter.status;
1008
+ }
864
1009
  }
865
1010
  }
866
1011
 
@@ -903,95 +1048,46 @@ export async function runNew(argv, config, opts = {}) {
903
1048
  die(`\`${typeName}\` template does not accept body input, but body was passed via ${bodyInputSource}.${hint}\n${cause}`);
904
1049
  }
905
1050
 
906
- // If name contains path separators, split into directory prefix and basename
907
- let nameDir = null;
908
- let namePart = name;
909
- if (name.includes('/') || name.includes(path.sep)) {
910
- nameDir = path.dirname(name);
911
- namePart = path.basename(name, '.md');
912
- } else if (name.endsWith('.md')) {
913
- namePart = name.slice(0, -3);
1051
+ // Zero setup: the first prompt saved in a repository with no config creates
1052
+ // a handoffs-only config (src/handoffs-mode.mjs), and prompts an older
1053
+ // version saved in the root prompts/ move to docs/prompts/ first. Every check
1054
+ // that can refuse the save runs against docs/prompts/ as that carry leaves
1055
+ // it, before anything moves or is written, and dry-run stops at that point.
1056
+ let handoffsLine = null;
1057
+ if (typeName === 'prompt' && !config.configFound && !opts.handoffsPlanned) {
1058
+ const setup = await planHandoffsConfig(process.cwd(), config, { dryRun, out: process.stdout });
1059
+ if (setup.previewStopped) return;
1060
+ config = setup.config;
1061
+ const target = resolveNewTarget(name, typeName, resolveTemplate('prompt', config), config, rootName, { explainRoots: false });
1062
+ assertPromptFolder(target, name, config);
1063
+ const carried = carriedOnto(setup, target.filePath);
1064
+ if (carried) die(`File already exists: ${carried.from} (it moves to ${carried.to} before this prompt is saved)`);
1065
+ if (existsSync(target.filePath)) die(`File already exists: ${target.repoPath}`);
1066
+ if (dryRun) {
1067
+ if (setup.dryRunLine) process.stdout.write(`${dim(setup.dryRunLine)}\n`);
1068
+ await carryLegacyPrompts(setup, { dryRun, out: process.stdout });
1069
+ process.stdout.write(`${dim('[dry-run]')} Would create: ${target.repoPath}\n${dim('[dry-run]')} Type: prompt\n`);
1070
+ return;
1071
+ }
1072
+ await carryLegacyPrompts(setup, { out: process.stdout });
1073
+ if (!setup.legacy) {
1074
+ const published = await publishHandoffsConfig(setup, { testHooks: opts.testHooks });
1075
+ config = published.config;
1076
+ assertPublishedPromptFolder(setup, published, promptDirectory(config));
1077
+ handoffsLine = published.line;
1078
+ if (handoffsLine) process.stdout.write(`${handoffsLine}\n`);
1079
+ }
914
1080
  }
915
- // A prefix the *user* typed and one a template declares resolve differently
916
- // below, so the distinction has to survive the template's assignment to nameDir.
917
- const userNameDir = nameDir;
918
1081
 
919
- // Slugify
920
- const slug = namePart.toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
921
- if (!slug) { die('Name resolves to empty slug: ' + name); }
1082
+ const promptFolderOnly = typeName === 'prompt' && (!config.configFound || isHandoffsMode(config));
1083
+ const { namePart, slug, baseDir, filePath, repoPath } = resolveNewTarget(name, typeName, typeName === 'prompt' ? resolveTemplate('prompt', config) : template, config, rootName, { explainRoots: !promptFolderOnly });
1084
+ if (promptFolderOnly) assertPromptFolder({ filePath }, name, config);
922
1085
 
923
1086
  // Title
924
1087
  const docTitle = title ?? namePart.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
925
1088
 
926
- // Resolve target root. Precedence: CLI --root > template.targetRoot > config.docsRoot.
927
- // When the chosen root is a first-class type-container (matched by --root or targetRoot),
928
- // we skip the `template.dir` join — the root already points at the right directory.
929
- let targetRoot = catchAllRoot(config);
930
- let routedToTypeRoot = false;
931
- if (rootName) {
932
- const roots = config.docsRoots || [config.docsRoot];
933
- const match = roots.find(r => r.endsWith(rootName) || path.basename(r) === rootName);
934
- if (!match) {
935
- const available = roots.map(r => path.basename(r)).join(', ');
936
- die(`Unknown root: ${rootName}\nAvailable: ${available}`);
937
- }
938
- targetRoot = match;
939
- routedToTypeRoot = true;
940
- } else if (typeof template === 'object' && template.targetRoot) {
941
- const roots = config.docsRoots || [config.docsRoot];
942
- const match = roots.find(r => r.endsWith(template.targetRoot) || path.basename(r) === template.targetRoot);
943
- if (match) {
944
- targetRoot = match;
945
- routedToTypeRoot = true;
946
- }
947
- }
948
-
949
- // Template-declared subdirectory (e.g., prompt → 'prompts') — only relevant when we
950
- // didn't already land in a type-specific root via --root or targetRoot.
951
- if (typeof template === 'object' && template.dir && !nameDir && !routedToTypeRoot) {
952
- nameDir = path.join(path.relative(config.repoRoot, targetRoot), template.dir);
953
- }
954
-
955
- // Path — a directory prefix is read relative to the repo, because `runlist new
956
- // plan docs/plans/feature` is a full repo path and has to stay one. That was
957
- // the ONLY reading, which is how `--root` came to be silently ignored the
958
- // moment a name contained a slash: the block above picked a root and this line
959
- // threw it away, so the one flag the out-of-root error advertises could not
960
- // fix the error. With an explicit --root the prefix is now read relative to
961
- // that root — unless the repo-relative reading already lands inside it, so
962
- // full paths keep working.
963
1089
  const allRoots = config.docsRoots ?? [config.docsRoot];
964
- let baseDir;
965
- if (!nameDir) baseDir = targetRoot;
966
- else if (userNameDir && rootName) {
967
- const repoRelative = path.resolve(config.repoRoot, nameDir);
968
- baseDir = lexicallyInside(targetRoot, repoRelative) ? repoRelative : path.resolve(targetRoot, nameDir);
969
- } else baseDir = path.resolve(config.repoRoot, nameDir);
970
- const filePath = path.join(baseDir, slug + '.md');
971
- const repoPath = toRepoPath(filePath, config.repoRoot);
972
1090
 
973
- // Without --root there is nothing to disambiguate with, so a prefix pointing
974
- // outside every root stays an error — but it names the flag that resolves it,
975
- // and the flag now works. The generic containment error underneath reports
976
- // absolute paths and no remedy, which is what agents kept re-guessing at.
977
- //
978
- // Gated on the remedy actually working: the suggestion is only offered when
979
- // `--root` would land the file inside that root. That is what keeps this off
980
- // a traversal (`../escaped`, an absolute path), where `--root` fixes nothing
981
- // and the containment check below is the error that should speak — printing
982
- // an untested remedy is the very defect this finding is about.
983
- if (userNameDir && !rootName && !allRoots.some(root => lexicallyInside(root, filePath))) {
984
- const rooted = path.resolve(targetRoot, userNameDir, slug + '.md');
985
- if (lexicallyInside(targetRoot, rooted)) {
986
- die(`Destination is outside every configured root:\n`
987
- + ` ${repoPath}\n\n`
988
- + `A name with a \`/\` is read relative to the repo.\n`
989
- + `To place it under a root instead:\n`
990
- + ` runlist new ${typeName} ${name} --root ${path.basename(targetRoot)}\n`
991
- + ` → ${toRepoPath(rooted, config.repoRoot)}\n\n`
992
- + `Roots: ${allRoots.map(root => path.basename(root)).join(', ')}`);
993
- }
994
- }
995
1091
  const destinationAuthorization = authorizeManagedDestination(filePath, config, { kind: 'New document destination' });
996
1092
 
997
1093
  if (existsSync(filePath)) {
@@ -1123,14 +1219,15 @@ export async function runNew(argv, config, opts = {}) {
1123
1219
  process.stdout.write(`${green('Created')}: ${toRepoPath(childPath, config.repoRoot)} ${dim(`(plan · runlist child, ${childStatus})`)}\n`);
1124
1220
  }
1125
1221
 
1126
- // Post-create guidance. Prompts are the classic confusion point: agents
1127
- // reflexively `git add && commit` a freshly-created file, but saved prompts
1128
- // are session-local handoff artifacts — the next session consumes them via
1129
- // `runlist use`, and the prompts dir is often gitignored (the commit then fails
1130
- // confusingly). Tell the agent the next step explicitly, and flag a gitignored
1131
- // target for any type so "why won't this commit" never happens silently.
1222
+ // Prompt consumption and Git tracking are separate choices. Describe the
1223
+ // resolved policy, then flag an actually ignored target for any type.
1132
1224
  if (typeName === 'prompt') {
1133
- process.stdout.write(dim('Session-local — no need to commit. The next session runs `runlist use` (or `runlist use ' + repoPath + '`) to consume it.\n'));
1225
+ if (!handoffsLine) process.stdout.write(unconfiguredPromptHint(config));
1226
+ const tracking = config.promptTracking ?? 'local';
1227
+ const policy = tracking === 'tracked' ? 'Prompt commits are allowed.'
1228
+ : tracking === 'untracked' ? 'Leave untracked to reduce commit churn; git add -A can still stage it.'
1229
+ : 'Local policy: keep pending prompts out of Git operations.';
1230
+ process.stdout.write(dim(`${policy} Inspect with \`runlist config get promptTracking\`. The next session runs \`runlist use ${repoPath}\` to consume it.\n`));
1134
1231
  }
1135
1232
  // Teach the field-length contract at the moment the fields get written —
1136
1233
  // learning it from a cap warning later sends sessions into hand-trim /
@@ -1170,7 +1267,9 @@ export async function runNew(argv, config, opts = {}) {
1170
1267
  // Sessions otherwise read the config file to find out.
1171
1268
  export function newHelpForRepo(config) {
1172
1269
  // Only types with a template: a status-only type can't be created by `new`.
1173
- const types = new Set([...Object.keys(BUILTIN_TEMPLATES), ...Object.keys(config.raw?.templates ?? {})]);
1270
+ // Where planning is off, a prompt is the one thing it creates.
1271
+ const promptsOnly = isHandoffsMode(config);
1272
+ const types = promptsOnly ? new Set(['prompt']) : new Set([...Object.keys(BUILTIN_TEMPLATES), ...Object.keys(config.raw?.templates ?? {})]);
1174
1273
  const rows = [];
1175
1274
  const width = Math.max(...[...types].map(t => t.length));
1176
1275
  for (const typeName of types) {
@@ -1194,6 +1293,7 @@ export function newHelpForRepo(config) {
1194
1293
  if (statuses.length) rows.push(` ${''.padEnd(width)} statuses: ${statuses.join(', ')}`);
1195
1294
  }
1196
1295
  const roots = (config.docsRoots ?? [config.docsRoot]).map(r => path.basename(r));
1296
+ if (promptsOnly) return `This repo:\n${rows.join('\n')}\n Plans and docs come with planning; \`runlist setup\` turns it on.`;
1197
1297
  rows.push(` ${'hub'.padEnd(width)} → a plan with a hub body (coordination unless --runlist or --roadmap)`);
1198
1298
  rows.push(` ${'decision'.padEnd(width)} → an entry in an existing plan's decisions section`);
1199
1299
  return `This repo:
@@ -1238,7 +1338,10 @@ function resolveBaseTemplate(name, config) {
1238
1338
  return merged;
1239
1339
  }
1240
1340
 
1241
- if (builtin) return builtin;
1341
+ // Before any config the docs root is the working folder, so the built-in
1342
+ // prompt folder is spelled out in full: prompts go to docs/prompts/ there
1343
+ // too, never to a root prompts/ (src/naming.mjs).
1344
+ if (builtin) return name === 'prompt' && rootIsWorkingFolder(config) ? { ...builtin, dir: PROMPT_DIR } : builtin;
1242
1345
 
1243
1346
  const available = [...new Set([...Object.keys(BUILTIN_TEMPLATES), ...Object.keys(configTemplates)])];
1244
1347
  die(`Unknown type: ${name}\nAvailable: ${available.join(', ')}`);
@@ -1251,8 +1354,12 @@ function listTemplates(config) {
1251
1354
  all[k] = v;
1252
1355
  }
1253
1356
 
1357
+ // Where planning is off, a prompt is the one thing `new` creates, so it is
1358
+ // the one type listed.
1359
+ const promptsOnly = isHandoffsMode(config);
1254
1360
  process.stdout.write(bold('Available types') + '\n\n');
1255
1361
  for (const [name, tmpl] of Object.entries(all)) {
1362
+ if (promptsOnly && name !== 'prompt') continue;
1256
1363
  const desc = typeof tmpl === 'function'
1257
1364
  ? '(custom function)'
1258
1365
  : (tmpl.description ?? '');
@@ -1261,6 +1368,10 @@ function listTemplates(config) {
1261
1368
  if (desc) process.stdout.write(` ${dim(desc)}\n`);
1262
1369
  process.stdout.write('\n');
1263
1370
  }
1371
+ if (promptsOnly) {
1372
+ process.stdout.write(`${dim('Plans and docs come with planning; `runlist setup` turns it on.')}\n`);
1373
+ return;
1374
+ }
1264
1375
  process.stdout.write(` hub\n ${dim('A plan with a hub body: coordination by default, --runlist or --roadmap for the others.')}\n\n`);
1265
1376
  process.stdout.write(` decision\n ${dim('An entry in an existing plan\'s decisions section: runlist new decision <plan> --question "…" @record.md')}\n\n`);
1266
1377
  }
@@ -282,7 +282,7 @@ export function renderCard(card) {
282
282
  }
283
283
 
284
284
  lines.push('');
285
- lines.push(dim(`Body: ${formatBytes(card.bodyBytes)}. \`runlist set in-session ${card.path} --full\` for everything, or Read the file with offset/limit to target a section by line number.`));
285
+ lines.push(dim(`Body: ${formatBytes(card.bodyBytes)}. \`runlist show ${card.path} --section "<heading>"\` reads one section; \`runlist set in-session ${card.path} --full\` prints everything.`));
286
286
  return lines.join('\n') + '\n';
287
287
  }
288
288
 
@@ -11,6 +11,55 @@ import { isGitIgnored } from './git.mjs';
11
11
 
12
12
  const SHOWN_COMMITS = 10;
13
13
 
14
+ // List-time hints share one bounded Git window. Consume-time detail below
15
+ // keeps its existing next_step comparison and commit messages.
16
+ export function summarizePlanChanges(requests, config, { runGit = spawnSync, stat = statSync } = {}) {
17
+ const maxTargets = 64, maxCommits = 256;
18
+ const targets = new Map();
19
+ for (const { plan } of requests) {
20
+ if (!plan || targets.has(plan) || targets.size >= maxTargets || path.isAbsolute(plan)
21
+ || plan.split(/[\\/]/).includes('..') || /[\0\r\n]/.test(plan)) continue;
22
+ try { targets.set(plan, stat(path.resolve(config.repoRoot, plan))); }
23
+ catch { targets.set(plan, null); }
24
+ }
25
+ const paths = [...targets].filter(([, stamp]) => stamp?.isFile()).map(([file]) => file);
26
+ const query = args => {
27
+ if (!paths.length) return null;
28
+ try {
29
+ const result = runGit('git', args, { cwd: config.repoRoot, encoding: 'utf8', timeout: 750, maxBuffer: 256 * 1024 });
30
+ return !result.error && result.status === 0 ? result.stdout : null;
31
+ } catch { return null; }
32
+ };
33
+ const pathspecs = paths.map(file => `:(top,literal)${file}`);
34
+ const trackedOutput = query(['ls-files', '-z', '--', ...pathspecs]);
35
+ const tracked = new Set((trackedOutput ?? '').split('\0'));
36
+ const trackedPaths = paths.filter(file => tracked.has(file));
37
+ const validTimes = requests.map(({ since }) => since ? new Date(since).getTime() : NaN).filter(Number.isFinite);
38
+ const earliest = validTimes.length ? new Date(Math.min(...validTimes)).toISOString() : null;
39
+ const log = trackedPaths.length && earliest
40
+ ? query(['log', `--max-count=${maxCommits + 1}`, `--since=${earliest}`, '--format=%x00runlist:change:%ct%x00', '--name-only', '-z', '--', ...trackedPaths.map(file => `:(top,literal)${file}`)]) : null;
41
+ const dirtyOutput = trackedPaths.length ? query(['diff', '--name-only', '-z', 'HEAD', '--', ...trackedPaths.map(file => `:(top,literal)${file}`)]) : null;
42
+ const dirty = new Set((dirtyOutput ?? '').split('\0'));
43
+ const latest = new Map();
44
+ let date = null, commits = 0;
45
+ for (const field of (log ?? '').split('\0')) {
46
+ const marker = /^runlist:change:(\d+)$/.exec(field);
47
+ if (marker) { date = Number(marker[1]) * 1000; commits++; continue; }
48
+ const file = field.replace(/^\n/, '');
49
+ if (date !== null && tracked.has(file)) latest.set(file, Math.max(latest.get(file) ?? 0, date));
50
+ }
51
+ return requests.map(({ plan, since }) => {
52
+ const when = since ? new Date(since).getTime() : NaN;
53
+ const stamp = targets.get(plan);
54
+ if (!Number.isFinite(when) || !stamp?.isFile() || trackedOutput === null) return 'unknown';
55
+ if (!tracked.has(plan)) return stamp.mtimeMs > when ? 'edited since save (file clock)' : 'no edits since save (file clock)';
56
+ if ((latest.get(plan) ?? 0) > when) return 'changed since save';
57
+ if (dirty.has(plan)) return 'working changes (time unknown)';
58
+ if (log === null || dirtyOutput === null || commits > maxCommits) return 'unknown';
59
+ return 'no commits since save';
60
+ });
61
+ }
62
+
14
63
  function git(args, repoRoot) {
15
64
  const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' });
16
65
  if (result.error || result.status !== 0) return null;
@@ -59,6 +59,22 @@ parent_prompt: {{parent_prompt}}
59
59
  },
60
60
  };
61
61
 
62
+ // The built-in outlines without plan wording, for a project where planning is
63
+ // off (src/handoffs-mode.mjs). A saved override is the user's text and is used
64
+ // as written.
65
+ const HANDOFFS_ONLY_LINES = [
66
+ ['<!-- Work to resume and owning plan path + section, or a durable brief if there is no plan. -->', '<!-- Work to resume, with the paths it concerns or a durable brief. -->'],
67
+ ['<!-- Review linked child handoffs and record completed results in the owning plan. -->', '<!-- Review linked child handoffs and carry their completed results forward. -->'],
68
+ ['<!-- One sentence: the bounded job and its owning plan or brief. -->', '<!-- One sentence: the bounded job and its brief. -->'],
69
+ ["<!-- Owned files, shared claims, current authorization, and any required new go. -->", '<!-- Owned files, current authorization, and any required new go. -->'],
70
+ ["<!-- Do not release or claim the parent's plan. This handoff grants no new authority. -->", '<!-- This handoff grants no new authority. -->'],
71
+ ];
72
+
73
+ export function builtinHandoffTemplate(key, { planning = true } = {}) {
74
+ const { source } = HANDOFF_TEMPLATES[key];
75
+ return planning ? source : HANDOFFS_ONLY_LINES.reduce((text, [from, to]) => text.replace(from, () => to), source);
76
+ }
77
+
62
78
  // Explicit links win, including missing/ambiguous parents. Legacy child names
63
79
  // may share a parent stem; the longest whole-stem match is the most specific.
64
80