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/guard.mjs CHANGED
@@ -5,6 +5,8 @@ import { inspectGitCommandPaths } from './git.mjs';
5
5
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
6
6
  import { recordGuardEvent } from './journal.mjs';
7
7
  import { readEnv } from './naming.mjs';
8
+ import { promptDirectory, promptRouting } from './new.mjs';
9
+ import { isHandoffsMode, managesFile } from './handoffs-scope.mjs';
8
10
 
9
11
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
12
  const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
@@ -18,8 +20,8 @@ const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'),
18
20
  //
19
21
  // Two decision levels:
20
22
  // 'deny' — block the call and feed the reason back to the model. Reserved for
21
- // moves that are guaranteed-wrong: committing a gitignored prompt (it
22
- // would fail anyway) and hand-editing a `status:` field (`runlist set`
23
+ // pending-prompt Git operations under the local policy, and
24
+ // hand-editing a `status:` field (`runlist set`
23
25
  // is a complete substitute; config `guard: { deny: false }` drops the
24
26
  // status rules back to warn).
25
27
  // 'warn' — let the call proceed but inject teaching context so the agent learns
@@ -38,18 +40,37 @@ function toSlash(p) {
38
40
  }
39
41
 
40
42
  // A path that ends in .md and sits under a `prompts/` directory is a
41
- // session-local saved prompt regardless of which doc root it belongs to —
43
+ // saved prompt regardless of which doc root it belongs to —
42
44
  // robust across repos without needing the resolved config. Archived prompts
43
45
  // (`…/prompts/archived/…`, the default nested archive for the prompt type) are
44
- // committable history, NOT session-local, so they're explicitly excluded — the
46
+ // committable history, so they are explicitly excluded — the
45
47
  // guard must not block committing or reading them.
46
48
  function isPromptPath(p, config) {
47
49
  const s = toSlash(p);
48
50
  if (typeof s !== 'string' || !s.endsWith('.md')) return false;
49
- if (!/(^|\/)prompts\//.test(s)) return false;
50
51
  const archiveDir = toSlash(config?.archiveDir || 'archived').replace(/^\/+|\/+$/g, '');
51
52
  if (archiveDir && s.split('/').includes(archiveDir)) return false;
52
- return true;
53
+ // Where planning is off, a saved prompt is a `type: prompt` file in the
54
+ // prompt folder and nothing else (src/handoffs-scope.mjs): the user's own
55
+ // Markdown there is not one.
56
+ if (isHandoffsMode(config)) return managesFile(path.resolve(config.repoRoot, s), config);
57
+ if (/(^|\/)prompts\//.test(s)) return true;
58
+ // Respect a configured prompt location too. When prompts share a whole doc
59
+ // root, inspect the file's type rather than treating every Markdown doc as a
60
+ // prompt. Directory routing uses the same resolver as prompt creation.
61
+ if (!config?.repoRoot || !config.docsRoot) return false;
62
+ try {
63
+ // Markdown template-store overrides change content only; routing is owned
64
+ // by the JS template config. Avoid reading that store on each hook token.
65
+ const directory = path.resolve(promptDirectory(config, promptRouting(config)));
66
+ const file = path.resolve(config.repoRoot, s);
67
+ const relative = path.relative(directory, file);
68
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return false;
69
+ const roots = config.docsRoots ?? [config.docsRoot];
70
+ if (!roots.some(root => path.resolve(root) === directory)) return true;
71
+ const { frontmatter } = extractFrontmatter(readFileSync(file, 'utf8'));
72
+ return Boolean(frontmatter && parseSimpleFrontmatter(frontmatter).type === 'prompt');
73
+ } catch { return false; }
53
74
  }
54
75
 
55
76
  // Loose "is this a runlist-managed doc" test: a .md file under one of the
@@ -57,6 +78,9 @@ function isPromptPath(p, config) {
57
78
  function isManagedDoc(p, config) {
58
79
  const s = toSlash(p);
59
80
  if (typeof s !== 'string' || !s.endsWith('.md')) return false;
81
+ // Where planning is off, runlist manages the prompts in the prompt folder
82
+ // alone, so only their status edits go through `runlist set`.
83
+ if (isHandoffsMode(config)) return managesFile(path.resolve(config.repoRoot, s), config);
60
84
  const roots = config?.docsRoots || (config?.docsRoot ? [config.docsRoot] : ['docs']);
61
85
  return roots.some(r => {
62
86
  const rNorm = toSlash(r).replace(/\/+$/, '');
@@ -276,22 +300,26 @@ function evalBash(command, config, inspectGitPaths, baseCwd, deps = {}) {
276
300
  continue;
277
301
  }
278
302
 
279
- // Rule A — deny only when Git's current state says the command would
303
+ // Rule A — apply the project's prompt tracking policy only when Git says it would
280
304
  // actually include a live prompt. This covers broad forms (`add .`, `-A`,
281
305
  // pathless commit, commit -a) without blocking ignored or clean prompts.
282
306
  const git = parseGitInvocation(segTokens, cwd);
283
307
  if (git) {
284
308
  const includedPaths = inspectGitPaths(git.subcommand, git.args, git.cwd);
285
309
  const targets = [...new Set(includedPaths.filter(candidate => isPromptPath(candidate, config)))];
286
- if (targets.length > 0) {
310
+ const tracking = config.promptTracking ?? 'local';
311
+ if (targets.length > 0 && tracking !== 'tracked') {
287
312
  return {
288
- decision: 'deny',
313
+ decision: tracking === 'untracked' ? 'warn' : 'deny',
289
314
  rule: 'commit-prompt',
290
315
  detail: `git ${git.subcommand} ${targets.join(' ')}`,
291
- reason:
292
- `Saved prompts (${targets.join(', ')}) are session-local runlist artifacts, not source to commit. ` +
293
- `Don't git add/commit them — commit your other changes without the prompt in the pathspec. ` +
294
- `The next session consumes a prompt with \`runlist use <file>\` (or \`runlist use\` for the oldest pending), which prints the body and archives it atomically.`,
316
+ reason: tracking === 'untracked'
317
+ ? `This project's promptTracking is untracked: leaving pending prompts (${targets.join(', ')}) out of commits is recommended to reduce commit churn. ` +
318
+ `Untracked does not mean ignored; broad commands such as \`git add -A\` can include them. ` +
319
+ `Choose files explicitly, or use \`runlist config set promptTracking tracked\` if the project wants to commit prompts.`
320
+ : `This project's promptTracking is local (the legacy default for existing configs). ` +
321
+ `Exclude pending prompts (${targets.join(', ')}) from this Git operation. ` +
322
+ `Inspect the policy with \`runlist config get promptTracking\`; explicitly choose tracked to allow prompt commits, or untracked for advisory warnings.`,
295
323
  };
296
324
  }
297
325
  }
@@ -1,11 +1,17 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
1
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
2
3
  import path from 'node:path';
3
- import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
- import { die, nowIso, toRepoPath } from './util.mjs';
4
+ import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
5
+ import { replaceFrontmatterField } from './frontmatter-fix.mjs';
6
+ import { renderLifecycleMutation } from './lifecycle.mjs';
7
+ import { mutateFile, withPathLocks } from './atomic-mutation.mjs';
8
+ import { commitRename } from './durable-rename.mjs';
9
+ import { authorizeManagedSource } from './managed-path.mjs';
10
+ import { DotmdError, die, isArchivedPath, nowIso, shellQuote, toRepoPath } from './util.mjs';
5
11
  import { stateDir } from './naming.mjs';
6
- import { resolveDocArg } from './index.mjs';
12
+ import { docArgMissMessage, resolveDocArg } from './index.mjs';
7
13
  import { authoritativeSessionId } from './pickup.mjs';
8
- import { green } from './color.mjs';
14
+ import { dim, green } from './color.mjs';
9
15
 
10
16
  // A baton that releases an in-session plan saves the prompt first, so a usage
11
17
  // stop partway through the handoff loses nothing. The plan update that follows
@@ -13,6 +19,7 @@ import { green } from './color.mjs';
13
19
  // done, and left next_step saying what it said before the work. So baton leaves
14
20
  // a record of the plan's next_step as it stood when the prompt was saved, and
15
21
  // `runlist baton --done` reports the handoff done only once next_step differs.
22
+ // `--next-step` writes the new value in the same call.
16
23
  // The record is local state under the runlist state directory, one per plan,
17
24
  // so a later session (or this one after a compaction) can still confirm it.
18
25
 
@@ -20,15 +27,25 @@ function recordsDir(config) {
20
27
  return path.join(stateDir(config.repoRoot), 'handoffs');
21
28
  }
22
29
 
30
+ // Keyed by the plan's whole repo path: two plans sharing a file name in
31
+ // different directories each keep their own record. The hash keeps the name
32
+ // legal on every filesystem; the file name in front is only for a human
33
+ // listing the directory. Records written before this were keyed by the file
34
+ // name alone (`widget.json`); they are still read, and matched by the `plan`
35
+ // they carry, never by their name.
23
36
  function recordPath(config, planRepoPath) {
24
- return path.join(recordsDir(config), `${path.basename(planRepoPath, '.md')}.json`);
37
+ const key = createHash('sha256').update(planRepoPath).digest('hex').slice(0, 16);
38
+ return path.join(recordsDir(config), `${path.basename(planRepoPath, '.md')}-${key}.json`);
25
39
  }
26
40
 
27
- function planNextStep(absPath) {
28
- const { frontmatter } = extractFrontmatter(readFileSync(absPath, 'utf8'));
29
- if (!frontmatter) return { present: false, value: null };
30
- const fm = parseSimpleFrontmatter(frontmatter);
31
- if (!Object.prototype.hasOwnProperty.call(fm, 'next_step')) return { present: false, value: null };
41
+ function frontmatterOf(content) {
42
+ const { frontmatter } = extractFrontmatter(content);
43
+ return frontmatter ? parseSimpleFrontmatter(frontmatter) : null;
44
+ }
45
+
46
+ function nextStepOf(content) {
47
+ const fm = frontmatterOf(content);
48
+ if (!fm || !Object.prototype.hasOwnProperty.call(fm, 'next_step')) return { present: false, value: null };
32
49
  return { present: true, value: JSON.stringify(fm.next_step ?? null) };
33
50
  }
34
51
 
@@ -36,10 +53,13 @@ function planNextStep(absPath) {
36
53
  // the record when the plan carries a next_step to wait on, else null (a repo
37
54
  // whose plans have no next_step field has nothing for --done to check).
38
55
  export function recordPendingHandoff(config, { planRepoPath, promptRepoPath, promptName }) {
39
- const abs = path.resolve(config.repoRoot, planRepoPath);
40
- const { present, value } = planNextStep(abs);
56
+ const content = readFileSync(path.resolve(config.repoRoot, planRepoPath), 'utf8');
57
+ const { present, value } = nextStepOf(content);
41
58
  if (!present) return null;
59
+ // `id` names this generation of the record: savedAt has one-second
60
+ // resolution, so two batons for one plan within a second share it.
42
61
  const record = {
62
+ id: randomUUID(),
43
63
  plan: planRepoPath,
44
64
  prompt: promptRepoPath,
45
65
  name: promptName,
@@ -47,8 +67,20 @@ export function recordPendingHandoff(config, { planRepoPath, promptRepoPath, pro
47
67
  nextStep: value,
48
68
  sessionId: authoritativeSessionId() ?? null,
49
69
  };
70
+ const file = recordPath(config, planRepoPath);
71
+ // An older record for this same plan (the name-keyed form) is replaced, not
72
+ // left beside the new one to be finished separately.
73
+ const superseded = listPendingHandoffs(config).filter(r => r.plan === planRepoPath && r.recordFile !== file).map(r => r.recordFile);
50
74
  mkdirSync(recordsDir(config), { recursive: true });
51
- writeFileSync(recordPath(config, planRepoPath), JSON.stringify(record, null, 2) + '\n');
75
+ // Under the record's lock, like `--done`, so a confirmation that checked
76
+ // the previous record can never remove this one.
77
+ withPathLocks([file, ...superseded], { repoRoot: config.repoRoot }, () => {
78
+ const temp = `${file}.${process.pid}.tmp`;
79
+ writeFileSync(temp, JSON.stringify(record, null, 2) + '\n');
80
+ try { commitRename(temp, file); }
81
+ catch (err) { rmSync(temp, { force: true }); throw err; }
82
+ for (const stale of superseded) rmSync(stale, { force: true });
83
+ });
52
84
  return record;
53
85
  }
54
86
 
@@ -69,39 +101,267 @@ export function pendingHandoffsForSession(config, sessionId = authoritativeSessi
69
101
  return listPendingHandoffs(config).filter(record => record.sessionId === sessionId);
70
102
  }
71
103
 
104
+ // The frontmatter half of the update is a flag on --done, so the guidance never
105
+ // asks for a hand edit of next_step: the body is prose the session writes, the
106
+ // two fields are what this verb writes.
72
107
  export function handoffNotDoneLine(record, doneCommand = 'runlist baton --done') {
73
- return `Handoff not done yet: the prompt is saved, and ${record.plan} still needs bringing up to date (the body, then next_step). Then run \`${doneCommand}\`.`;
108
+ return `Handoff not done yet: the prompt is saved, and ${record.plan} still needs bringing up to date. Update the plan body first, then run \`${doneCommand} --next-step "<next move>"\` (add \`--current-state "<where it stands>"\` if that changed): it writes the frontmatter and confirms the handoff.`;
109
+ }
110
+
111
+ // Whitespace runs collapse to one space: that is what the folded scalar the
112
+ // writer emits reads back as, so a value differing only in spacing is the same
113
+ // next_step and must not pass the gate.
114
+ function normalizeFieldValue(value) {
115
+ return value.replace(/\s+/g, ' ').trim();
116
+ }
117
+
118
+ // Every completion path compares the same way: a string next_step after
119
+ // whitespace normalisation, anything else (a list, a boolean) as parsed.
120
+ function comparableNextStep(json) {
121
+ let value;
122
+ try { value = JSON.parse(json); } catch { return undefined; }
123
+ return JSON.stringify(typeof value === 'string' ? normalizeFieldValue(value) : value);
124
+ }
125
+
126
+ function stillAsRecorded(record, content) {
127
+ const { present, value } = nextStepOf(content);
128
+ return present && comparableNextStep(value) === comparableNextStep(record.nextStep);
129
+ }
130
+
131
+ // One line of prose per field. A newline is refused rather than folded away:
132
+ // the writer emits a folded scalar, which would silently join the lines, and a
133
+ // next_step that needs several lines belongs in the plan body.
134
+ function checkedFieldValue(flag, value) {
135
+ if (typeof value !== 'string' || !value.trim()) die(`${flag} needs a nonempty value, e.g. ${flag} "<text>".`);
136
+ if (/[\r\n]/.test(value)) die(`${flag} takes one line; put anything longer in the plan body.`);
137
+ return normalizeFieldValue(value);
138
+ }
139
+
140
+ function renderFieldUpdates(raw, fields) {
141
+ const { frontmatter } = extractFrontmatter(raw);
142
+ if (!frontmatter) throw new Error('the plan has no frontmatter block');
143
+ let next = frontmatter;
144
+ for (const [key, value] of Object.entries(fields)) next = replaceFrontmatterField(next, key, value);
145
+ return renderLifecycleMutation(replaceFrontmatter(raw, next), { updated: nowIso() }, null);
74
146
  }
75
147
 
76
- // `runlist baton --done [<plan>]`
77
- export function runBatonDone(planArg, config, { json = false } = {}) {
148
+
149
+ function readRecord(file) {
150
+ try { return JSON.parse(readFileSync(file, 'utf8')); }
151
+ catch { return null; }
152
+ }
153
+
154
+ // Whether the record on disk is still the generation this call read. A record
155
+ // written before ids existed is compared whole.
156
+ function sameGeneration(record, current) {
157
+ if (!current) return false;
158
+ if (record.id !== undefined) return current.id === record.id;
159
+ const { recordFile: _file, ...saved } = record;
160
+ return JSON.stringify(current) === JSON.stringify(saved);
161
+ }
162
+
163
+ const isGone = (config, record) => !existsSync(path.resolve(config.repoRoot, record.plan));
164
+
165
+ function recordLine(config, record) {
166
+ return ` ${record.plan} (${isGone(config, record) ? 'gone' : 'still in place'}; record ${toRepoPath(record.recordFile, config.repoRoot)}, prompt ${record.name}, saved ${record.savedAt})`;
167
+ }
168
+
169
+ // Every command printed for a path is quoted, so it runs as shown.
170
+ const doneCommand = repoPath => `runlist baton --done ${shellQuote(repoPath)}`;
171
+ const clearCommand = record => doneCommand(record.plan);
172
+ const ARCHIVE_HINT = 'To archive a plan as part of a handoff, use `runlist baton --status archived`.';
173
+
174
+ // Whether a gone plan has a copy where `runlist archive` would have put it.
175
+ function archivedCopyExists(config, repoPath) {
176
+ const abs = path.resolve(config.repoRoot, repoPath);
177
+ return (config.docsRoots || [config.docsRoot]).some(root => {
178
+ const rel = path.relative(root, abs);
179
+ return rel && !rel.startsWith('..') && !path.isAbsolute(rel) && existsSync(path.join(root, config.archiveDir, path.basename(abs)));
180
+ });
181
+ }
182
+
183
+ // A plan whose handoff names a path it is no longer at. Writes never follow
184
+ // it: the fields go only to the plan at the path the handoff was saved for.
185
+ function goneMessage(config, record, writing) {
186
+ const lead = writing
187
+ ? `${record.plan} is gone (moved or archived?), so nothing was written: --next-step and --current-state write only the plan at the path its handoff was saved for.`
188
+ : `${record.plan} is gone (moved or archived?).`;
189
+ const hint = writing && archivedCopyExists(config, record.plan) ? `\n${ARCHIVE_HINT}` : '';
190
+ return `${lead} Bring the plan up to date where it is now, then run \`runlist baton --done <its new path>\`; or clear this handoff by its old path: \`${clearCommand(record)}\`.${hint}\nSaved prompt: ${record.name}`;
191
+ }
192
+
193
+ // A named plan finishes the record saved for that exact path. The one
194
+ // exception is plain --done (no fields to write) for a plan that moved after
195
+ // the prompt was saved: when exactly one record shares the file name and its
196
+ // plan is gone, that record is cleared once the plan at the new path has a
197
+ // different next_step. It writes no plan, so the worst a wrong guess does is
198
+ // clear a reminder; anything less certain is refused with the records listed.
199
+ function namedHandoff(config, repoPath, writing) {
200
+ const records = listPendingHandoffs(config);
201
+ const exact = records.find(r => r.plan === repoPath);
202
+ if (exact) return { record: exact, relocated: false };
203
+ const sameName = records.filter(r => typeof r.plan === 'string' && path.basename(r.plan) === path.basename(repoPath));
204
+ const moved = sameName.filter(r => isGone(config, r));
205
+ if (!sameName.length) die(`No handoff waits on ${repoPath}: baton has not saved a prompt for it that needs the plan brought up to date.`);
206
+ if (!moved.length) die(`No handoff waits on ${repoPath}. The handoff for a plan of the same file name is ${sameName.map(r => r.plan).join(', ')}, which is still in place; finish it there: ${clearCommand(sameName[0])}`);
207
+ // With several candidates, at most one was saved for this plan, and
208
+ // clearing it says nothing about whose the others are.
209
+ const clearOne = `At most one of these was saved for this plan; clear that one by its old path (only its record is removed, the others stay pending):\n${moved.map(r => ` ${clearCommand(r)}`).join('\n')}`;
210
+ if (writing) {
211
+ const recover = sameName.length === 1
212
+ ? `If ${repoPath} is that plan, moved: bring it up to date by any means and run \`${doneCommand(repoPath)}\`, or clear the handoff by its old path: \`${clearCommand(moved[0])}\`.`
213
+ : clearOne;
214
+ const hint = isArchivedPath(repoPath, config) ? `\n${ARCHIVE_HINT}` : '';
215
+ die(`No handoff was saved for ${repoPath}, so nothing was written: --next-step and --current-state write only the plan at the path a handoff was saved for.\n${sameName.map(r => recordLine(config, r)).join('\n')}\n${recover}${hint}`);
216
+ }
217
+ if (sameName.length > 1) {
218
+ die(`No handoff was saved for ${repoPath}, and ${sameName.length} handoffs for plans of that file name could be meant:\n${sameName.map(r => recordLine(config, r)).join('\n')}\n${clearOne}`);
219
+ }
220
+ return { record: moved[0], relocated: true };
221
+ }
222
+
223
+ // `runlist baton --done [<plan>] [--next-step "…"] [--current-state "…"]`
224
+ export function runBatonDone(planArg, config, { json = false, dryRun = false, nextStep = null, currentState = null } = {}) {
225
+ const fields = {};
226
+ if (nextStep !== null) fields.next_step = checkedFieldValue('--next-step', nextStep);
227
+ if (currentState !== null) fields.current_state = checkedFieldValue('--current-state', currentState);
228
+ const writing = Object.keys(fields).length > 0;
229
+ if (dryRun && !writing) die('`runlist baton --done` changes nothing but its own record; run it without --dry-run, or preview a write with --next-step.');
230
+
78
231
  let record;
232
+ let relocated = false;
233
+ let abs = null;
79
234
  if (planArg) {
80
- const abs = resolveDocArg(planArg, config);
81
- const repoPath = toRepoPath(abs, config.repoRoot);
82
- record = listPendingHandoffs(config).find(r => r.plan === repoPath)
83
- ?? listPendingHandoffs(config).find(r => path.basename(r.plan) === path.basename(repoPath));
84
- if (!record) die(`No handoff waits on ${repoPath}: baton has not saved a prompt for it that needs the plan brought up to date.`);
235
+ // The argument is first taken as a path, exactly as typed: a record saved
236
+ // for that path is the handoff meant, whether or not the plan is still
237
+ // there. Resolving it as a doc first could land on another plan (another
238
+ // root, a slug, a file name) while the named path is gone.
239
+ // As for any doc argument, a path typed without `.md` names the file with it.
240
+ const argRepoPath = toRepoPath(path.resolve(config.repoRoot, planArg), config.repoRoot);
241
+ const candidates = argRepoPath.endsWith('.md') ? [argRepoPath] : [argRepoPath, `${argRepoPath}.md`];
242
+ const pending = listPendingHandoffs(config);
243
+ record = candidates.map(candidate => pending.find(r => r.plan === candidate)).find(Boolean);
244
+ if (!record) {
245
+ abs = resolveDocArg(planArg, config, { dieOnMiss: false });
246
+ if (!abs) die(docArgMissMessage(planArg, config));
247
+ ({ record, relocated } = namedHandoff(config, toRepoPath(abs, config.repoRoot), writing));
248
+ }
85
249
  } else {
86
250
  const mine = pendingHandoffsForSession(config);
87
251
  if (mine.length === 0) die('No handoff of this session waits on a plan update. Name the plan: runlist baton --done <plan-file>');
88
- if (mine.length > 1) die(`This session has ${mine.length} handoffs waiting; name the plan:\n${mine.map(r => ` runlist baton --done ${r.plan}`).join('\n')}`);
252
+ if (mine.length > 1) die(`This session has ${mine.length} handoffs waiting; name the plan:\n${mine.map(r => ` ${doneCommand(r.plan)}`).join('\n')}`);
89
253
  record = mine[0];
90
254
  }
91
255
 
92
- const abs = path.resolve(config.repoRoot, record.plan);
93
- if (!existsSync(abs)) die(`${record.plan} is gone (moved or archived?). Run \`runlist baton --done <its new path>\` once it is up to date.\nSaved prompt: ${record.name}`);
94
- const { present, value } = planNextStep(abs);
95
- if (present && value === record.nextStep) {
96
- die(`Not done: ${record.plan} next_step is the same as when the prompt was saved (${record.savedAt}). Bring the plan up to date (the body, then next_step), then run \`runlist baton --done${planArg ? ` ${record.plan}` : ''}\` again.\nSaved prompt: ${record.name}`);
256
+ abs ??= path.resolve(config.repoRoot, record.plan);
257
+ const planGone = !existsSync(abs);
258
+ // Naming the gone path itself is how a handoff whose plan moved away is
259
+ // cleared; with fields to write, or with no path named, it is refused.
260
+ if (planGone && (writing || !planArg)) die(goneMessage(config, record, writing));
261
+ const planRepoPath = toRepoPath(abs, config.repoRoot);
262
+ const again = planArg ? doneCommand(planRepoPath) : 'runlist baton --done';
263
+
264
+ if (fields.next_step !== undefined && JSON.stringify(fields.next_step) === comparableNextStep(record.nextStep)) {
265
+ die(`Not done: --next-step repeats the next_step ${planRepoPath} had when the prompt was saved (${record.savedAt}). Write where the work goes from here.\nSaved prompt: ${record.name}`);
266
+ }
267
+
268
+ let source = abs;
269
+ if (writing) {
270
+ try { source = authorizeManagedSource(abs, config, { kind: 'Baton --done plan' }).path; }
271
+ catch (err) { die(err.message); }
97
272
  }
98
- rmSync(record.recordFile, { force: true });
99
- const result = { operation: 'baton-done', plan: record.plan, prompt: record.prompt, name: record.name, nextStepChanged: true };
273
+
274
+ const finish = raw => {
275
+ const next = writing ? renderFieldUpdates(raw, fields) : raw;
276
+ if (stillAsRecorded(record, next)) {
277
+ die(`Not done: ${planRepoPath} next_step is the same as when the prompt was saved (${record.savedAt}). Update the plan body first, then run \`${again} --next-step "<next move>"\`.\nSaved prompt: ${record.name}`);
278
+ }
279
+ return next;
280
+ };
281
+ const failed = err => {
282
+ if (err instanceof DotmdError) throw err;
283
+ const verb = planGone ? 'clear the handoff for' : writing ? 'write' : 'check';
284
+ die(`Could not ${verb} ${planRepoPath} (${err.message}); the handoff is still pending.\nSaved prompt: ${record.name}`);
285
+ };
286
+ // Checked under the record's lock, before any plan write is published and
287
+ // before the record is removed: a baton that saved a newer record for this
288
+ // plan meanwhile is never finished by a call that read the older one.
289
+ const current = () => {
290
+ if (!sameGeneration(record, readRecord(record.recordFile))) {
291
+ die(`The handoff record for ${record.plan} changed while this ran (a new baton, or another --done); run \`${again}\` again.\nSaved prompt: ${record.name}`);
292
+ }
293
+ };
294
+
295
+ let preview = null;
296
+ if (dryRun) {
297
+ try { preview = finish(readFileSync(source, 'utf8')); } catch (err) { failed(err); }
298
+ } else {
299
+ try {
300
+ if (planGone || relocated) {
301
+ // No plan is written, so no plan is locked: a relocated plan is read
302
+ // the way any reader would, and the old path is not touched at all.
303
+ if (relocated) finish(readFileSync(source, 'utf8'));
304
+ withPathLocks([record.recordFile], { repoRoot: config.repoRoot }, () => {
305
+ current();
306
+ // The saved path was gone when this started; a restore since then
307
+ // (`set active` on the archived copy) brings the plan back as it
308
+ // was, and that handoff is finished there, not cleared.
309
+ if (existsSync(path.resolve(config.repoRoot, record.plan))) {
310
+ die(`${record.plan} is back at the path its handoff was saved for, so the handoff was not cleared. Finish it there: \`${clearCommand(record)}\` once the plan is up to date.\nSaved prompt: ${record.name}`);
311
+ }
312
+ rmSync(record.recordFile, { force: true });
313
+ });
314
+ } else {
315
+ // One sorted lock set, record and plan, so the verdict is reached on
316
+ // the plan as it stands while the record cannot change: a refused call
317
+ // publishes nothing, and the record goes only after the plan passed.
318
+ // No other caller holds a plan lock while it takes a record lock
319
+ // (baton records after its plan transaction releases). Like baton
320
+ // itself, this leaves the shared generated index deferred.
321
+ withPathLocks([record.recordFile, source], { repoRoot: config.repoRoot }, () => {
322
+ current();
323
+ mutateFile(source, { repoRoot: config.repoRoot, locked: true }, finish);
324
+ rmSync(record.recordFile, { force: true });
325
+ });
326
+ }
327
+ } catch (err) { failed(err); }
328
+ }
329
+
330
+ const result = {
331
+ operation: 'baton-done',
332
+ dryRun: Boolean(dryRun),
333
+ plan: planRepoPath,
334
+ prompt: record.prompt,
335
+ name: record.name,
336
+ nextStepChanged: !planGone,
337
+ planGone,
338
+ written: fields,
339
+ };
100
340
  if (json) {
101
341
  process.stdout.write(JSON.stringify(result, null, 2) + '\n');
102
342
  return result;
103
343
  }
104
- process.stderr.write(`${green('✓ Handoff done')}: ${record.plan} next_step was updated after the prompt was saved.\n`);
344
+ if (dryRun) {
345
+ const prefix = dim('[dry-run] ');
346
+ process.stderr.write(`${prefix}Would write ${planRepoPath}:\n`);
347
+ const { frontmatter } = extractFrontmatter(preview);
348
+ const lines = frontmatter.split('\n');
349
+ for (const key of Object.keys(fields)) {
350
+ const at = lines.findIndex(line => line.startsWith(`${key}:`));
351
+ for (let i = at; i >= 0 && i < lines.length && (i === at || /^[ \t]/.test(lines[i])); i++) process.stderr.write(`${prefix} ${lines[i]}\n`);
352
+ }
353
+ process.stderr.write(`${prefix}Would report the handoff done and remove its record.\n`);
354
+ process.stderr.write(`Saved prompt: ${record.name}\n`);
355
+ return result;
356
+ }
357
+ if (planGone) {
358
+ process.stderr.write(`${green('✓ Handoff cleared')}: ${planRepoPath} is gone, so there is no plan to update there; its record is removed.\n`);
359
+ const others = listPendingHandoffs(config).filter(r => typeof r.plan === 'string' && path.basename(r.plan) === path.basename(planRepoPath));
360
+ if (others.length) process.stderr.write(`Still pending, each for its own saved path: ${others.map(r => r.plan).join(', ')}.\n`);
361
+ } else {
362
+ const how = writing ? `${Object.keys(fields).join(' and ')} written` : 'next_step was updated after the prompt was saved';
363
+ process.stderr.write(`${green('✓ Handoff done')}: ${planRepoPath} ${how}.\n`);
364
+ }
105
365
  process.stderr.write(`Saved prompt: ${record.name}\n`);
106
366
  return result;
107
367
  }
@@ -0,0 +1,143 @@
1
+ // Per-command help where planning is off (a handoffs-only project,
2
+ // src/handoffs-mode.mjs), for the commands that still work there but whose
3
+ // usual help describes plans. `runlist help <cmd>` and `runlist <cmd> --help`
4
+ // both read this through the dispatcher's one help renderer. A planning
5
+ // command's help there is the planning-off line itself.
6
+
7
+ const OFF = '(planning is off in this project)';
8
+ const SETUP = '`runlist setup` turns it on (your prompts stay where they are).';
9
+
10
+ const HELP = {
11
+ baton: `runlist baton — save a resume prompt for the next session ${OFF}
12
+
13
+ Usage (write the resume to a file first):
14
+ runlist baton <slug> @/tmp/draft.md # saves resume-<slug> in docs/prompts/
15
+ The resume can also come from stdin (\`-\`, or a pipe) or --message "..." for one-liners.
16
+ Baton prints the prompt name it saved as its last line.
17
+
18
+ The resume (10–20 lines): the next concrete decision plus any gotchas, and the
19
+ paths it concerns — not a recap. The next session's hud lists the prompt and
20
+ \`runlist use <name>\` resumes it. When a handoff for the same work is already
21
+ pending, baton saves nothing and names it: inspect it with
22
+ \`runlist prompts show <name>\`, then pass --replace to refresh its text.
23
+
24
+ Options:
25
+ --message / --body Inline body (one-liners; prefer @path or stdin)
26
+ --subagent Save subagent-<name> instead of resume-<name>
27
+ --parent <resume-name> Group this --subagent handoff under its parent in prompts listings
28
+ --template Print a resume draft (or --subagent draft)
29
+ --replace Replace exactly one pending handoff; archive its prior text
30
+ --json Structured result
31
+ --dry-run, -n Preview without writing
32
+
33
+ Examples:
34
+ runlist baton --template > /tmp/resume.md
35
+ runlist baton checkout-fixes @/tmp/draft.md
36
+ runlist baton checkout-fixes @/tmp/new-draft.md --replace
37
+ runlist baton --subagent totals-review --parent resume-checkout-fixes @/tmp/child.md
38
+
39
+ Handing off a plan comes with planning; ${SETUP}`,
40
+
41
+ new: `runlist new prompt <name> [body] — save a prompt under a name of its own ${OFF}
42
+
43
+ The prompt lands in docs/prompts/<name>.md as pending; the next session's hud
44
+ lists it and \`runlist use <name>\` resumes it. A name with a folder in it must
45
+ stay inside docs/prompts/.
46
+
47
+ Body: @<file> (preferred for more than one line), - (stdin), --message "...",
48
+ or inline for one-liners.
49
+
50
+ Options:
51
+ --title "..." Title (default: from the name)
52
+ --message / --body Inline body
53
+ --dry-run, -n Preview without writing
54
+
55
+ Examples:
56
+ runlist new prompt look-at-retries @/tmp/draft.md
57
+ runlist new prompt cleanup --message "look at the remaining lint warnings"
58
+
59
+ Plans and docs come with planning; ${SETUP}`,
60
+
61
+ set: `runlist set <status> <prompt> — change a saved prompt's status ${OFF}
62
+
63
+ A prompt is pending (waiting for a session) or archived (consumed or set
64
+ aside). \`runlist set archived <prompt>\` sets one aside, as
65
+ \`runlist prompts archive <prompt>\` does.
66
+
67
+ Options:
68
+ --dry-run, -n Preview without writing
69
+
70
+ Plan statuses come with planning; ${SETUP}`,
71
+
72
+ status: `runlist status <prompt> <status> — deprecated; use \`runlist set <status> <prompt>\` ${OFF}
73
+
74
+ A prompt is pending or archived. Plan statuses come with planning; ${SETUP}`,
75
+
76
+ statuses: `runlist statuses — the prompt statuses ${OFF}
77
+
78
+ runlist statuses Table of the prompt statuses and their flags
79
+ runlist statuses list --json The same as JSON
80
+ runlist statuses add|set|remove <name> --type prompt
81
+ Edit the prompt statuses in runlist.config.mjs
82
+
83
+ Plan and doc statuses come with planning; ${SETUP}`,
84
+
85
+ archive: `runlist archive <prompt> — set a saved prompt aside without reading it ${OFF}
86
+
87
+ Sets the prompt to archived and moves it to docs/prompts/archived/, as
88
+ \`runlist prompts archive <prompt>\` does. <prompt> is a path or a name.
89
+
90
+ Options:
91
+ --dry-run, -n Preview without writing`,
92
+
93
+ prompts: `runlist prompts — saved prompts ${OFF}
94
+
95
+ Prompts are the handoffs runlist keeps in docs/prompts/. Consuming one prints
96
+ its body and archives it (one-shot). Only an agent session consumes; in a plain
97
+ terminal \`use\` and \`next\` print the body and the command the session runs.
98
+
99
+ Subcommands:
100
+ list List pending prompts (default)
101
+ next Consume the oldest pending prompt
102
+ use <name> Consume a named prompt (\`resume\` is an alias)
103
+ show <name...> Read prompts without consuming them (\`peek\` is an alias)
104
+ archive <name> Set a prompt aside without reading it
105
+ link <child...> --parent <resume-name>
106
+ Attach existing child handoffs to their parent
107
+ new <slug> [body] Save a prompt (\`runlist new prompt <slug>\`)
108
+
109
+ A prompt is pending or archived.
110
+
111
+ Examples:
112
+ runlist prompts # pending prompts
113
+ runlist prompts show resume-foo # peek without consuming
114
+ runlist prompts show a b c # peek several by name
115
+ runlist prompts use resume-foo # in a session: consume it
116
+ runlist prompts archive old-thing # set one aside`,
117
+
118
+ briefing: `runlist briefing — the check counts ${OFF}
119
+
120
+ Shows the stale, error and warning counts, then says planning is off;
121
+ \`runlist prompts\` lists the saved prompts. Plan pipelines come with
122
+ planning; ${SETUP}
123
+
124
+ Options:
125
+ --json Output as JSON`,
126
+
127
+ hud: `runlist hud — what a new session is told at startup ${OFF}
128
+
129
+ Prints the pending prompts, saying to resume one only when asked
130
+ (\`runlist use <name>\`), and actionable notices; silent when nothing needs
131
+ attention. --prompt-submit reads a hook JSON payload from stdin: save requests
132
+ get baton instructions; pickup requests get a hint only when prompts are
133
+ pending. Text hooks read prompt frontmatter without building the index.
134
+
135
+ Options:
136
+ --prompt-submit Request-specific save/pickup hint (hook JSON on stdin)
137
+ --subagent Compact workflow primer for child agents
138
+ --json Output as JSON`,
139
+ };
140
+
141
+ export function handoffsCommandHelp(command) {
142
+ return HELP[command] ?? null;
143
+ }