runlist 0.0.0 → 0.94.1

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 (166) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1114 -6
  3. package/assets/app/app.css +283 -0
  4. package/assets/app/app.mjs +381 -0
  5. package/assets/app/block-editor.mjs +182 -0
  6. package/assets/app/block-model.mjs +52 -0
  7. package/assets/app/document-create.mjs +86 -0
  8. package/assets/app/document-lifecycle.mjs +36 -0
  9. package/assets/app/document-stages.mjs +22 -0
  10. package/assets/app/document-views.mjs +33 -0
  11. package/assets/app/document-yardstick.mjs +258 -0
  12. package/assets/app/editor-navigation.mjs +51 -0
  13. package/assets/app/filing-navigation.mjs +138 -0
  14. package/assets/app/git-changes.mjs +80 -0
  15. package/assets/app/git-commit.mjs +46 -0
  16. package/assets/app/index.html +70 -0
  17. package/assets/app/library-navigation.mjs +93 -0
  18. package/assets/app/outline.mjs +18 -0
  19. package/assets/app/quick-navigation.mjs +52 -0
  20. package/assets/app/record-navigation.mjs +78 -0
  21. package/assets/app/recovery-center.mjs +53 -0
  22. package/assets/app/semantic-search.mjs +14 -0
  23. package/assets/app/settings.mjs +15 -0
  24. package/assets/app/shared.mjs +104 -0
  25. package/assets/app/stage-source.mjs +75 -0
  26. package/assets/app/template-editor.mjs +47 -0
  27. package/assets/app/theme.mjs +25 -0
  28. package/assets/app/transport.mjs +43 -0
  29. package/assets/contracts/source-editing-v1.md +232 -0
  30. package/assets/opencode/plugin.js +162 -0
  31. package/assets/schemas/runlist-record-v1.schema.json +912 -0
  32. package/bin/dotmd.mjs +5 -0
  33. package/bin/runlist.mjs +2714 -0
  34. package/package.json +53 -6
  35. package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
  36. package/plugins/runlist-codex/bin/runlist-hook +86 -0
  37. package/plugins/runlist-codex/hooks/hooks.json +16 -0
  38. package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
  39. package/runlist.config.example.mjs +370 -0
  40. package/scripts/postinstall.mjs +56 -0
  41. package/src/agent-context.mjs +201 -0
  42. package/src/ai.mjs +30 -0
  43. package/src/app-create.mjs +169 -0
  44. package/src/app-filing.mjs +139 -0
  45. package/src/app-git-commits.mjs +48 -0
  46. package/src/app-git.mjs +180 -0
  47. package/src/app-library.mjs +152 -0
  48. package/src/app-lifecycle-worker.mjs +12 -0
  49. package/src/app-lifecycle.mjs +79 -0
  50. package/src/app-records.mjs +134 -0
  51. package/src/app-search.mjs +80 -0
  52. package/src/app-semantic-search.mjs +105 -0
  53. package/src/app-service.mjs +208 -0
  54. package/src/app-yardstick.mjs +269 -0
  55. package/src/app.mjs +102 -0
  56. package/src/atomic-mutation.mjs +1736 -0
  57. package/src/baton.mjs +435 -0
  58. package/src/body-link.mjs +48 -0
  59. package/src/bulk-tag.mjs +174 -0
  60. package/src/check-collapse.mjs +75 -0
  61. package/src/claude-commands.mjs +75 -0
  62. package/src/cli-editor.mjs +8 -0
  63. package/src/code-refs.mjs +545 -0
  64. package/src/codex-integration.mjs +81 -0
  65. package/src/color.mjs +14 -0
  66. package/src/commands.mjs +374 -0
  67. package/src/completions.mjs +93 -0
  68. package/src/config-edit.mjs +606 -0
  69. package/src/config.mjs +705 -0
  70. package/src/decision-action.mjs +58 -0
  71. package/src/decision.mjs +220 -0
  72. package/src/decisions.mjs +787 -0
  73. package/src/deps.mjs +317 -0
  74. package/src/desktop-install.mjs +158 -0
  75. package/src/desktop-runtime.mjs +4 -0
  76. package/src/desktop.mjs +25 -0
  77. package/src/diff.mjs +101 -0
  78. package/src/doctor.mjs +591 -0
  79. package/src/durable-rename.mjs +55 -0
  80. package/src/errors-read.mjs +44 -0
  81. package/src/export.mjs +496 -0
  82. package/src/extractors.mjs +101 -0
  83. package/src/filing.mjs +255 -0
  84. package/src/fix-membership.mjs +134 -0
  85. package/src/fix-refs.mjs +174 -0
  86. package/src/flags.mjs +318 -0
  87. package/src/frontmatter-fix.mjs +195 -0
  88. package/src/frontmatter.mjs +252 -0
  89. package/src/git-commit-filters.mjs +32 -0
  90. package/src/git-commit-protocol.mjs +198 -0
  91. package/src/git-commit-signing.mjs +75 -0
  92. package/src/git-commit-store.mjs +26 -0
  93. package/src/git-commit-support.mjs +16 -0
  94. package/src/git-commit-worker.mjs +32 -0
  95. package/src/git.mjs +909 -0
  96. package/src/glossary-check.mjs +35 -0
  97. package/src/glossary.mjs +243 -0
  98. package/src/graph.mjs +298 -0
  99. package/src/guard.mjs +488 -0
  100. package/src/health.mjs +212 -0
  101. package/src/hints.mjs +129 -0
  102. package/src/host-integration.mjs +339 -0
  103. package/src/hub-membership.mjs +284 -0
  104. package/src/hub.mjs +292 -0
  105. package/src/hud.mjs +436 -0
  106. package/src/index-file.mjs +163 -0
  107. package/src/index.mjs +478 -0
  108. package/src/init.mjs +512 -0
  109. package/src/install.mjs +222 -0
  110. package/src/journal-read.mjs +116 -0
  111. package/src/journal.mjs +472 -0
  112. package/src/lifecycle.mjs +1444 -0
  113. package/src/lint.mjs +386 -0
  114. package/src/managed-path.mjs +192 -0
  115. package/src/markdown-code-spans.mjs +58 -0
  116. package/src/migrate-prompts.mjs +171 -0
  117. package/src/migrate-template.mjs +170 -0
  118. package/src/migrate.mjs +90 -0
  119. package/src/misuse-read.mjs +61 -0
  120. package/src/model-request.mjs +33 -0
  121. package/src/model.mjs +523 -0
  122. package/src/modules.mjs +355 -0
  123. package/src/naming.mjs +55 -0
  124. package/src/native-action.mjs +59 -0
  125. package/src/native-record.mjs +244 -0
  126. package/src/new.mjs +1321 -0
  127. package/src/output-identity.mjs +111 -0
  128. package/src/parent-plan.mjs +58 -0
  129. package/src/parse-cache.mjs +99 -0
  130. package/src/path-containment.mjs +15 -0
  131. package/src/pickup-card.mjs +293 -0
  132. package/src/pickup.mjs +671 -0
  133. package/src/prompt.mjs +25 -0
  134. package/src/prompts.mjs +585 -0
  135. package/src/query.mjs +977 -0
  136. package/src/record-action.mjs +11 -0
  137. package/src/record-create.mjs +82 -0
  138. package/src/reference-planner.mjs +388 -0
  139. package/src/reference-values.mjs +11 -0
  140. package/src/rename.mjs +107 -0
  141. package/src/render.mjs +678 -0
  142. package/src/roadmap.mjs +184 -0
  143. package/src/runlist.mjs +944 -0
  144. package/src/section.mjs +225 -0
  145. package/src/semantic-search-client.mjs +58 -0
  146. package/src/ship.mjs +166 -0
  147. package/src/show.mjs +101 -0
  148. package/src/skill-drift.mjs +98 -0
  149. package/src/source-editor.mjs +491 -0
  150. package/src/stages.mjs +73 -0
  151. package/src/state-migration.mjs +123 -0
  152. package/src/stats.mjs +200 -0
  153. package/src/status-metadata.mjs +87 -0
  154. package/src/statuses.mjs +721 -0
  155. package/src/summary.mjs +74 -0
  156. package/src/surfaces.mjs +28 -0
  157. package/src/sync-status.mjs +316 -0
  158. package/src/template-store.mjs +62 -0
  159. package/src/update.mjs +338 -0
  160. package/src/use.mjs +58 -0
  161. package/src/util.mjs +337 -0
  162. package/src/validate.mjs +794 -0
  163. package/src/watch.mjs +56 -0
  164. package/src/xref.mjs +406 -0
  165. package/src/yardstick-command.mjs +178 -0
  166. package/src/yardstick.mjs +112 -0
package/src/baton.mjs ADDED
@@ -0,0 +1,435 @@
1
+ import { readFileSync, existsSync, realpathSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
+ import { asString, toRepoPath, die, warn, isArchivedPath, resolveRefPath, nowIso } from './util.mjs';
5
+ import { buildIndex, resolveDocArg } from './index.mjs';
6
+ import { preparePromptDocument, runNew, readBodyInput, readPipedBodyInput } from './new.mjs';
7
+ import { ensurePlanCompletionBeforeRelease, planHasPendingCompletion, renderLifecycleMutation, runSet } from './lifecycle.mjs';
8
+ import { green, dim } from './color.mjs';
9
+ import { authorizeManagedDestination, authorizeManagedSource } from './managed-path.mjs';
10
+ import { mutateFileSet } from './atomic-mutation.mjs';
11
+ import { assertPlanMutationAuthorized, authoritativeSessionId, listOwnedPlans, readPlanOwnership } from './pickup.mjs';
12
+
13
+ // `runlist baton` is the one-command handoff: save the resume prompt AND release
14
+ // the plan in a single atomic-ish verb. It exists because the three-step skill
15
+ // version ("save prompt, pick a status, commit") kept expanding in practice —
16
+ // sessions turned closeout into repo triage, forgot the prompt body, or got
17
+ // tangled in what to commit. Baton does exactly one plan, one prompt, one
18
+ // status flip, and then *tells* the agent the exact commit command.
19
+
20
+ export function findOwnedPlan(config, index = null) {
21
+ const idx = index ?? buildIndex(config);
22
+ const inSession = idx.docs.filter(d => d.type === 'plan' && d.status === 'in-session');
23
+ let records = [];
24
+ try { records = listOwnedPlans(config, authoritativeSessionId()); } catch { return { plan: null, via: null, inSession }; }
25
+ const owned = records.map(record => inSession.find(doc => doc.path === record.plan)).filter(Boolean);
26
+ const clean = (records.diagnostics?.length ?? 0) === 0;
27
+ return { plan: clean && owned.length === 1 ? owned[0] : null, via: clean && owned.length === 1 ? 'ownership' : null, inSession, owned, diagnostics: records.diagnostics ?? [] };
28
+ }
29
+
30
+ // One line that says what is missing, then one example. Baton never drafts the
31
+ // resume itself: the session that did the work is the only one that knows the
32
+ // next decision, and a prompt assembled from frontmatter reads like a handoff
33
+ // while carrying nothing the plan doesn't already say.
34
+ // The three forms are listed because this is the message a bare `runlist baton`
35
+ // prints, and an agent unsure of the shape should be able to act on it without
36
+ // a trip through --help.
37
+ const BODY_USAGE = `Nothing saved: baton needs the resume you wrote, passed as @<file> or - (stdin).
38
+ runlist baton @/tmp/draft.md # hand off the plan this session owns
39
+ runlist baton <plan-file> @/tmp/draft.md # hand off a named plan
40
+ runlist baton <slug> @/tmp/draft.md # no plan: save resume-<slug>, change nothing else`;
41
+
42
+ // A handoff that lands beside a pending one leaves two prompts for the same
43
+ // work, and the next session picks whichever sorts first. Baton used to step to
44
+ // `resume-<x>-2` silently; it now refuses and names what is waiting, so the
45
+ // older prompt is consumed or archived on purpose. Plan mode also catches a
46
+ // pending prompt under a different name that links the same plan.
47
+ function pendingHandoffs(promptPath, planPath, config) {
48
+ const found = new Set();
49
+ if (existsSync(promptPath)) found.add(toRepoPath(promptPath, config.repoRoot));
50
+ if (planPath) {
51
+ const planReal = realpathSync(planPath);
52
+ const index = buildIndex(config, { fast: true, invokeHooks: false });
53
+ for (const doc of index.docs) {
54
+ if (doc.type !== 'prompt' || doc.status === 'archived' || isArchivedPath(doc.path, config)) continue;
55
+ const abs = path.resolve(config.repoRoot, doc.path);
56
+ let planRef;
57
+ try { planRef = asString(parseSimpleFrontmatter(extractFrontmatter(readFileSync(abs, 'utf8')).frontmatter).plan); }
58
+ catch { continue; }
59
+ const linked = planRef ? resolveRefPath(planRef, path.dirname(abs), config.repoRoot) : null;
60
+ try { if (linked && realpathSync(linked) === planReal) found.add(doc.path); }
61
+ catch { /* stale plan link is not evidence this prompt belongs to the plan */ }
62
+ }
63
+ }
64
+ return [...found];
65
+ }
66
+
67
+ function refusePendingHandoff(pending) {
68
+ const lines = pending.map(p => ` ${p}`).join('\n');
69
+ const slug = path.basename(pending[0], '.md');
70
+ 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/.`);
71
+ }
72
+
73
+ function archivedCopyPath(promptPath, config) {
74
+ const dir = path.join(path.dirname(promptPath), config.archiveDir);
75
+ const ext = path.extname(promptPath);
76
+ const stem = path.basename(promptPath, ext);
77
+ let candidate = path.join(dir, `${stem}${ext}`);
78
+ for (let n = 2; existsSync(candidate); n++) candidate = path.join(dir, `${stem}-${n}${ext}`);
79
+ return authorizeManagedDestination(candidate, config, { kind: 'Baton replacement archive' }).path;
80
+ }
81
+
82
+ function shellQuote(value) {
83
+ const raw = String(value);
84
+ return /^[A-Za-z0-9_./:@+-]+$/.test(raw) ? raw : `"${raw.replace(/["\\$`]/g, '\\$&')}"`;
85
+ }
86
+
87
+ function commitCommand(config, message, paths) {
88
+ const fallback = ['git', 'commit', '-m', message, '--', ...paths];
89
+ let argv = fallback;
90
+ if (typeof config.hooks.batonCommitCommand === 'function') {
91
+ try {
92
+ const custom = config.hooks.batonCommitCommand(message, [...paths]);
93
+ if (!Array.isArray(custom) || custom.length === 0 || !custom.every(part => typeof part === 'string' && part.length > 0)) {
94
+ throw new Error('return a nonempty string argv array');
95
+ }
96
+ argv = custom;
97
+ } catch (err) { warn(`batonCommitCommand failed (${err.message}); printing the default git command.`); }
98
+ }
99
+ return argv.map(shellQuote).join(' ');
100
+ }
101
+
102
+ // Is this positional a filesystem reference (must resolve, typos die) or a
103
+ // bare word (may be a plan slug, may be a brand-new handoff name)?
104
+ function looksLikePath(arg) {
105
+ return arg.includes('/') || arg.endsWith('.md');
106
+ }
107
+
108
+ export async function runBaton(argv, config, opts = {}) {
109
+ const { dryRun } = opts;
110
+ const json = argv.includes('--json');
111
+
112
+ let status = 'active';
113
+ let statusFlag = false;
114
+ let note = null;
115
+ let bodyFlag = null;
116
+ let force = false;
117
+ let replace = false;
118
+ const positionals = [];
119
+ for (let i = 0; i < argv.length; i++) {
120
+ const a = argv[i];
121
+ if (a === '--status' && argv[i + 1]) { status = argv[++i]; statusFlag = true; continue; }
122
+ if (a === '--note' && argv[i + 1]) { note = argv[++i]; continue; }
123
+ if ((a === '--body' || a === '--message') && argv[i + 1]) { bodyFlag = argv[++i]; continue; }
124
+ if (a === '--force') { force = true; continue; }
125
+ if (a === '--replace') { replace = true; continue; }
126
+ if (a === '--json') continue;
127
+ if (!a.startsWith('-') || a === '-' || a.startsWith('@')) { positionals.push(a); continue; }
128
+ die(`Unknown flag for \`runlist baton\`: ${a}`);
129
+ }
130
+
131
+ let planArg = null;
132
+ let bodyArg = null;
133
+ for (const p of positionals) {
134
+ if (p === '-' || p.startsWith('@')) { bodyArg = p; continue; }
135
+ if (!planArg) { planArg = p; continue; }
136
+ if (bodyArg === null) bodyArg = p; // trailing inline body
137
+ }
138
+
139
+ // Body FIRST — it's the common failure (`new prompt` without a body was the
140
+ // top real-world baton error), and nothing must mutate before it's secured.
141
+ let body = null;
142
+ if (bodyFlag !== null) body = bodyFlag;
143
+ else if (bodyArg !== null) body = readBodyInput(bodyArg);
144
+ else {
145
+ // Auto-consume piped/redirected stdin, same probe as `runlist new`.
146
+ body = readPipedBodyInput();
147
+ }
148
+ if (!body || !body.trim()) die(BODY_USAGE);
149
+
150
+ // Resolve what's being handed off. Two modes:
151
+ // plan mode — a plan is released alongside the prompt (one status flip).
152
+ // slug mode — no plan involved: "save a resume prompt for what I'm doing
153
+ // right now". The hallmark use ("update the docs and save a resume prompt
154
+ // for this") must work mid-anything, claimed plan or not — baton does
155
+ // nothing but save the prompt in this mode.
156
+ let planPath = null;
157
+ let promptSlug = null;
158
+ if (planArg) {
159
+ if (looksLikePath(planArg)) {
160
+ planPath = resolveDocArg(planArg, config); // typos die loudly — a mistyped path must not silently become a prompt name
161
+ } else {
162
+ // Bare word: a plan slug if it resolves to a plan, else a handoff name.
163
+ const resolved = resolveDocArg(planArg, config, { dieOnMiss: false });
164
+ let resolvedType = null;
165
+ if (resolved) {
166
+ try {
167
+ const { frontmatter: fmProbe } = extractFrontmatter(readFileSync(resolved, 'utf8'));
168
+ resolvedType = fmProbe ? asString(parseSimpleFrontmatter(fmProbe).type) : null;
169
+ } catch { resolvedType = null; }
170
+ }
171
+ if (resolved && resolvedType === 'plan') planPath = resolved;
172
+ else promptSlug = planArg;
173
+ }
174
+ } else {
175
+ const owned = findOwnedPlan(config);
176
+ if (owned.plan) {
177
+ planPath = path.resolve(config.repoRoot, owned.plan.path);
178
+ } else if (owned.owned?.length > 1) {
179
+ 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')}`);
180
+ } else {
181
+ const diagnostics = owned.diagnostics?.length ? `\nIgnored ownership records:\n${owned.diagnostics.map(d => ` ${d}`).join('\n')}` : '';
182
+ 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}`);
183
+ }
184
+ }
185
+
186
+ const nameBase = planPath ? path.basename(planPath, '.md') : promptSlug;
187
+ const slugBase = nameBase.startsWith('resume-') ? nameBase : `resume-${nameBase}`;
188
+ let replacement = null;
189
+ const resolvePending = () => {
190
+ const target = preparePromptDocument(slugBase, body, config, { dryRun: true });
191
+ const pending = pendingHandoffs(target.filePath, planPath, config);
192
+ if (!replace) {
193
+ if (pending.length) refusePendingHandoff(pending);
194
+ return;
195
+ }
196
+ if (pending.length !== 1) {
197
+ const detail = pending.length ? `Found ${pending.length}:\n${pending.map(p => ` ${p}`).join('\n')}` : 'No pending handoff matches this work.';
198
+ die(`Nothing replaced: --replace requires exactly one pending handoff. ${detail}`);
199
+ }
200
+ const oldRepoPath = pending[0];
201
+ const oldPath = authorizeManagedSource(path.resolve(config.repoRoot, oldRepoPath), config, { kind: 'Baton replacement source' }).path;
202
+ const oldRaw = readFileSync(oldPath, 'utf8');
203
+ const { frontmatter, body: oldBody } = extractFrontmatter(oldRaw);
204
+ const fm = parseSimpleFrontmatter(frontmatter);
205
+ if (asString(fm.type) !== 'prompt' || asString(fm.status) !== 'pending') {
206
+ die(`Nothing replaced: ${oldRepoPath} is not a pending prompt.`);
207
+ }
208
+ const priorPlanRef = asString(fm.plan);
209
+ const priorPlanPath = priorPlanRef ? resolveRefPath(priorPlanRef, path.dirname(oldPath), config.repoRoot) : null;
210
+ if (!planPath && priorPlanRef) {
211
+ die(`Nothing replaced: ${oldRepoPath} links a plan (${priorPlanRef}). Use \`runlist baton <plan-file> @<draft-file> --replace\` so the plan link and release stay together.`);
212
+ }
213
+ replacement = {
214
+ oldPath, oldRepoPath, oldRaw,
215
+ archivedPath: archivedCopyPath(oldPath, config),
216
+ alreadyCurrent: oldBody.trim() === body.trim()
217
+ && (!planPath || (priorPlanPath && path.resolve(priorPlanPath) === path.resolve(planPath))),
218
+ canonicalPath: target.filePath,
219
+ };
220
+ };
221
+
222
+ let repoPath = null;
223
+ let oldStatus = null;
224
+ let ownershipPath = null;
225
+ let planGuard = null;
226
+ let wouldReleaseClaim = false;
227
+ if (planPath) {
228
+ planPath = authorizeManagedSource(planPath, config, { kind: 'Baton plan source' }).path;
229
+ repoPath = toRepoPath(planPath, config.repoRoot);
230
+ const raw = readFileSync(planPath, 'utf8');
231
+ const { frontmatter: fmRaw } = extractFrontmatter(raw);
232
+ if (!fmRaw) {
233
+ die(`${repoPath} has no frontmatter block — baton can't flip its status.\nFix the doc first (\`runlist bulk-tag ${repoPath} --type plan --status in-session\`), or save the prompt without a status flip: runlist baton ${path.basename(planPath, '.md')} @/tmp/draft.md`);
234
+ }
235
+ const fm = parseSimpleFrontmatter(fmRaw);
236
+ const docType = asString(fm.type);
237
+ oldStatus = asString(fm.status) ?? 'unset';
238
+ if (docType && docType !== 'plan') warn(`${repoPath} has type '${docType}', not 'plan'.`);
239
+
240
+ // Validate the target status BEFORE creating the prompt so a bad --status
241
+ // doesn't leave a half-done handoff.
242
+ const validStatuses = config.typeStatuses?.get(docType ?? 'plan') ?? config.validStatuses;
243
+ if (validStatuses && validStatuses.size > 0 && !validStatuses.has(status)) {
244
+ die(`Invalid status \`${status}\` for type \`${docType ?? 'plan'}\`\nValid: ${[...validStatuses].join(', ')}`);
245
+ }
246
+ if (status === 'in-session') {
247
+ die('`runlist baton --status in-session` contradicts baton release semantics. Choose active/paused/awaiting/partial/blocked.');
248
+ }
249
+ const sessionId = authoritativeSessionId();
250
+ const ownership = assertPlanMutationAuthorized(repoPath, config, { sessionId, force });
251
+ const ownedHere = ownership?.state === 'owned' && ownership.sessionId === sessionId;
252
+ wouldReleaseClaim = ownership?.state === 'owned';
253
+ // Baton's release only means something when there is a claim to release. A
254
+ // plan that is neither in-session nor owned here carries a status someone
255
+ // chose on purpose (`awaiting`, `blocked`, a repo's own `awaiting-testing`),
256
+ // and flipping it to the default `active` as a side effect of saving a
257
+ // prompt overwrites that reason — silently, since baton's headline is the
258
+ // prompt it saved. That is not hypothetical: a prompt-refresh pass named a
259
+ // plan slug, got a status flip it never asked for, and had to notice and
260
+ // undo it. So the prompt still lands with its `plan:` link, and the plan's
261
+ // status stays put unless the caller states the transition with --status.
262
+ const currentStatus = asString(fm.status);
263
+ if (!statusFlag && oldStatus !== 'in-session' && !ownedHere) {
264
+ if (!currentStatus || (validStatuses?.size > 0 && !validStatuses.has(currentStatus))) {
265
+ die(`${repoPath} is not in-session and its status (\`${oldStatus}\`) is not one baton can leave in place.\n`
266
+ + `Say what it should become: runlist baton ${repoPath} @<draft-file> --status <status>\n`
267
+ + `Or save the prompt without touching the plan: runlist baton ${path.basename(planPath, '.md')} @<draft-file>`);
268
+ }
269
+ status = currentStatus;
270
+ planGuard = { path: planPath, expectedContent: raw };
271
+ if (note) warn('--note ignored — the plan\'s status is unchanged, so there is no transition to record.');
272
+ }
273
+ // Before the plan-completion step: a refusal must leave nothing changed.
274
+ resolvePending();
275
+ // A status transition may file the plan at a new path. Refreshing the
276
+ // prompt inside that transaction keeps its plan link valid even when its
277
+ // body text happened to be identical to the previous handoff.
278
+ if (replacement && status !== oldStatus) replacement.alreadyCurrent = false;
279
+ ownershipPath = readPlanOwnership(repoPath, config)?.recordPath ?? null;
280
+ if (!dryRun) ensurePlanCompletionBeforeRelease(repoPath, config, { testHooks: opts.testHooks });
281
+ else if (planHasPendingCompletion(repoPath, config)) process.stderr.write(`${dim('[dry-run]')} Pending claim completion would block this release.\n`);
282
+ } else {
283
+ resolvePending();
284
+ if (statusFlag) warn(`--status ignored — no plan involved in this handoff (saving the prompt only).`);
285
+ if (note) warn(`--note ignored — no plan involved in this handoff (notes land in a plan's Version History).`);
286
+ }
287
+
288
+ const preparedReplacement = prepared => {
289
+ if (!replacement || replacement.alreadyCurrent) return { updates: [], creations: [], guards: [] };
290
+ let archivedContent = renderLifecycleMutation(replacement.oldRaw,
291
+ { status: 'archived', updated: nowIso() }, () => 'Archived.');
292
+ if (repoPath) archivedContent = archivedContent.replace(/^plan:[ \t]*\S.*$/m, `plan: ${repoPath}`);
293
+ return {
294
+ updates: [{ path: replacement.oldPath, expectedContent: replacement.oldRaw, content: prepared.content }],
295
+ creations: [{ path: replacement.archivedPath, content: archivedContent }],
296
+ guards: path.resolve(replacement.canonicalPath) === path.resolve(replacement.oldPath)
297
+ ? [] : [{ path: replacement.canonicalPath, absent: true }],
298
+ };
299
+ };
300
+ if (dryRun && !json && replacement && !replacement.alreadyCurrent) {
301
+ process.stdout.write(`${dim('[dry-run]')} Would preserve old handoff: ${replacement.oldRepoPath} → ${toRepoPath(replacement.archivedPath, config.repoRoot)}\n`);
302
+ }
303
+
304
+ // Plan mode publishes the already-stamped prompt, status/history update, and
305
+ // ownership release in one transaction. Slug mode has no plan transaction.
306
+ let createdSlug = null;
307
+ let archiveResult = null;
308
+ let statusChanged = false;
309
+ let promptRepoPath = null;
310
+ let newResult = null;
311
+ const muted = [];
312
+ const originalStdoutWrite = process.stdout.write;
313
+ if (json) process.stdout.write = chunk => { muted.push(String(chunk)); return true; };
314
+ try {
315
+ if (!planPath) {
316
+ const promptName = replacement ? path.basename(replacement.oldPath, '.md') : slugBase;
317
+ const prepared = preparePromptDocument(promptName, body, config, { dryRun });
318
+ if (replacement) {
319
+ const mutation = preparedReplacement(prepared);
320
+ if (!dryRun && !replacement.alreadyCurrent) mutateFileSet(mutation, { repoRoot: config.repoRoot, testHooks: opts.testHooks });
321
+ newResult = { sessionFiles: [replacement.oldRepoPath, ...(replacement.alreadyCurrent ? [] : [toRepoPath(replacement.archivedPath, config.repoRoot)])] };
322
+ } else {
323
+ try {
324
+ newResult = await runNew(['prompt', slugBase, '--body', body], config, { dryRun, deferIndex: true });
325
+ } catch (err) {
326
+ // Lost a race with another baton between the pending check and the write.
327
+ if (/File already exists/.test(String(err?.message))) refusePendingHandoff([prepared.repoPath]);
328
+ throw err;
329
+ }
330
+ }
331
+ createdSlug = promptName;
332
+ promptRepoPath = replacement?.oldRepoPath ?? prepared.repoPath;
333
+ } else {
334
+ const promptName = replacement ? path.basename(replacement.oldPath, '.md') : slugBase;
335
+ const prepared = preparePromptDocument(promptName, body, config, { plan: repoPath, dryRun });
336
+ const mutation = preparedReplacement(prepared);
337
+ const setArgs = [status, planPath];
338
+ if (force) setArgs.push('--force');
339
+ if (note && !planGuard) setArgs.push('--note', note);
340
+ try {
341
+ if (dryRun) process.stdout.write(`${dim('[dry-run]')} Would ${replacement ? (replacement.alreadyCurrent ? 'keep' : 'replace') : 'create'}: ${replacement?.oldRepoPath ?? prepared.repoPath}\n`);
342
+ archiveResult = await runSet(setArgs, config, {
343
+ dryRun,
344
+ viaBaton: true,
345
+ testHooks: opts.testHooks,
346
+ additionalUpdates: dryRun ? [] : mutation.updates,
347
+ creations: dryRun ? [] : replacement ? mutation.creations : [{ path: prepared.filePath, content: prepared.content }],
348
+ guards: dryRun ? [] : [...mutation.guards, ...(planGuard ? [planGuard] : [])],
349
+ deferIndex: true,
350
+ });
351
+ } catch (err) {
352
+ if (/Destination already exists|File already exists/.test(String(err?.message))) refusePendingHandoff([prepared.repoPath]);
353
+ throw err;
354
+ }
355
+ createdSlug = prepared.slug;
356
+ promptRepoPath = replacement?.oldRepoPath ?? prepared.repoPath;
357
+ statusChanged = oldStatus !== status;
358
+ if (!dryRun && !replacement) {
359
+ try { config.hooks.onNew?.({ path: prepared.repoPath, status: 'pending', title: prepared.slug, type: 'prompt' }); }
360
+ catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
361
+ }
362
+ }
363
+ } finally {
364
+ if (json) process.stdout.write = originalStdoutWrite;
365
+ }
366
+
367
+ if (!dryRun && replacement && !replacement.alreadyCurrent) {
368
+ const archivedRepoPath = toRepoPath(replacement.archivedPath, config.repoRoot);
369
+ try { config.hooks.onArchive?.({ path: archivedRepoPath, oldStatus: 'pending' }, { oldPath: replacement.oldRepoPath, newPath: archivedRepoPath }); }
370
+ catch (err) { warn(`Hook 'onArchive' threw: ${err.message}`); }
371
+ try { config.hooks.onNew?.({ path: replacement.oldRepoPath, status: 'pending', title: createdSlug, type: 'prompt' }); }
372
+ catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
373
+ }
374
+
375
+ const normalizeRepoPath = candidate => {
376
+ if (!candidate) return null;
377
+ return path.isAbsolute(candidate) ? toRepoPath(candidate, config.repoRoot) : candidate;
378
+ };
379
+ const touched = (archiveResult?.touched ?? (planPath && statusChanged ? [repoPath] : [])).map(normalizeRepoPath);
380
+ const ownershipRepoPath = normalizeRepoPath(ownershipPath);
381
+ const archivedPromptRepoPath = replacement && !replacement.alreadyCurrent ? toRepoPath(replacement.archivedPath, config.repoRoot) : null;
382
+ const repositoryFiles = [...new Set(touched.filter(candidate => candidate && candidate !== ownershipRepoPath && candidate !== promptRepoPath && candidate !== archivedPromptRepoPath && candidate !== normalizeRepoPath(config.indexPath)))];
383
+ const sessionFiles = [...new Set([...(newResult?.sessionFiles ?? []), promptRepoPath, archivedPromptRepoPath, ownershipRepoPath].filter(Boolean))];
384
+ const deferredGeneratedFiles = [...new Set(newResult?.deferredGeneratedFiles ?? (config.indexPath ? [normalizeRepoPath(config.indexPath)] : []))];
385
+ const operationResult = {
386
+ operation: 'baton',
387
+ dryRun: Boolean(dryRun),
388
+ disposition: replacement?.alreadyCurrent && !statusChanged && !wouldReleaseClaim ? 'already-current' : dryRun ? 'would-change' : 'applied',
389
+ wouldChange: Boolean(dryRun),
390
+ mode: planPath ? 'plan' : 'slug',
391
+ status: planPath ? { from: oldStatus, to: status, changed: statusChanged } : null,
392
+ repositoryFiles,
393
+ sessionFiles,
394
+ generatedFiles: [],
395
+ deferredGeneratedFiles,
396
+ prompt: promptRepoPath,
397
+ plan: archiveResult?.newRepoPath ?? repoPath,
398
+ planMovement: planPath ? { from: repoPath, to: archiveResult?.newRepoPath ?? repoPath } : null,
399
+ replacement: replacement ? {
400
+ previousPrompt: replacement.oldRepoPath,
401
+ archivedPrompt: archivedPromptRepoPath,
402
+ pendingPrompt: promptRepoPath,
403
+ alreadyCurrent: replacement.alreadyCurrent,
404
+ } : null,
405
+ claimRelease: planPath ? { wouldRelease: wouldReleaseClaim, released: !dryRun && wouldReleaseClaim } : null,
406
+ };
407
+
408
+ const prefix = dryRun ? dim('[dry-run] ') : '';
409
+ if (json) {
410
+ process.stdout.write(JSON.stringify(operationResult, null, 2) + '\n');
411
+ return operationResult;
412
+ }
413
+ const verdict = replacement?.alreadyCurrent ? '✓ Handoff kept' : replacement ? '✓ Baton replaced' : '✓ Baton passed';
414
+ process.stderr.write(`\n${prefix}${green(verdict)}: ${createdSlug} (the next session's hud surfaces it — nothing to paste into chat)\n`);
415
+ if (archivedPromptRepoPath) process.stderr.write(dim(`${prefix}Previous handoff preserved: ${archivedPromptRepoPath}\n`));
416
+ if (planGuard) {
417
+ process.stderr.write(dim(`${repoPath} left at \`${oldStatus}\` — it was not in-session, so there was no claim to release.\n`));
418
+ process.stderr.write(dim(`Meant to change it? runlist set <status> ${repoPath}\n`));
419
+ }
420
+ if (statusChanged) {
421
+ let gitignored = false;
422
+ try {
423
+ const { isGitIgnored } = await import('./git.mjs');
424
+ gitignored = isGitIgnored(planPath, config.repoRoot);
425
+ } catch { /* not a git repo — fall through to the hint */ }
426
+ if (gitignored) {
427
+ process.stderr.write(dim(`${repoPath} is gitignored — no commit needed.\n`));
428
+ } else {
429
+ process.stderr.write(`${prefix}Commit the repository files (session files stay OUT of the pathspec):\n`);
430
+ process.stderr.write(`${prefix} ${commitCommand(config, `baton: ${path.basename(planPath, '.md')} ${oldStatus} → ${status}`, operationResult.repositoryFiles)}\n`);
431
+ }
432
+ }
433
+ if (operationResult.deferredGeneratedFiles.length) process.stderr.write(dim(`Generated index deferred: ${operationResult.deferredGeneratedFiles.join(', ')}\n`));
434
+ return operationResult;
435
+ }
@@ -0,0 +1,48 @@
1
+ import { existsSync, realpathSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ function isWithin(root, candidate) {
5
+ const relative = path.relative(root, candidate);
6
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
7
+ }
8
+
9
+ // Markdown body links are filesystem paths relative to the document that
10
+ // contains them. They deliberately do not inherit frontmatter references'
11
+ // repo-root fallback: that fallback can make a broken Markdown link look valid.
12
+ export function resolveBodyLinkTarget(href, docDir, repoRoot, externalRoots = []) {
13
+ if (!href) return { ok: false, reason: 'missing' };
14
+
15
+ const root = path.resolve(repoRoot);
16
+ const candidate = path.resolve(docDir, href);
17
+ const allowedRoots = externalRoots.map(entry => path.resolve(root, entry));
18
+ const allowedLexically = isWithin(root, candidate) || allowedRoots.some(entry => isWithin(entry, candidate));
19
+ if (!allowedLexically) return { ok: false, reason: 'outside-repo' };
20
+ if (!existsSync(candidate)) return { ok: false, reason: 'missing' };
21
+
22
+ let canonicalRoot;
23
+ let canonicalTarget;
24
+ try {
25
+ canonicalRoot = realpathSync(root);
26
+ canonicalTarget = realpathSync(candidate);
27
+ } catch {
28
+ return { ok: false, reason: 'unreadable' };
29
+ }
30
+ const canonicalAllowedRoots = allowedRoots.flatMap(entry => {
31
+ try { return [realpathSync(entry)]; } catch { return []; }
32
+ });
33
+ if (!isWithin(canonicalRoot, canonicalTarget)
34
+ && !canonicalAllowedRoots.some(entry => isWithin(entry, canonicalTarget))) {
35
+ return { ok: false, reason: 'outside-repo' };
36
+ }
37
+
38
+ try {
39
+ const stat = statSync(canonicalTarget);
40
+ if (!stat.isFile() && !stat.isDirectory()) return { ok: false, reason: 'unsupported-target' };
41
+ } catch {
42
+ return { ok: false, reason: 'unreadable' };
43
+ }
44
+ // Return the authored lexical route after using the canonical route only for
45
+ // containment. Callers index documents by their repo-relative spelling (and
46
+ // may intentionally include an in-repo symlink alias).
47
+ return { ok: true, path: candidate, realPath: canonicalTarget };
48
+ }
@@ -0,0 +1,174 @@
1
+ import path from 'node:path';
2
+ import { readFileSync } from 'node:fs';
3
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
+ import { collectDocFiles } from './index.mjs';
5
+ import { toRepoPath, die, warn, resolveDocPath } from './util.mjs';
6
+ import { writeFrontmatter } from './lifecycle.mjs';
7
+ import { green, dim, yellow } from './color.mjs';
8
+ import { authorizeManagedSweep, findLexicalDocsRoot } from './managed-path.mjs';
9
+
10
+ // Per-type default status for bulk-tagging pre-existing untagged markdown.
11
+ // These intentionally lean conservative (draft / planned) rather than active —
12
+ // the user is triaging files they didn't create through `runlist new`, so
13
+ // dropping them into the active list would clutter it without consent. The
14
+ // audit handoff endorsed this conservative default.
15
+ const DEFAULT_STATUS_BY_TYPE = {
16
+ plan: 'planned',
17
+ doc: 'draft',
18
+ prompt: 'pending',
19
+ };
20
+
21
+ // Derive a doc type from the file's first subdir under its docsRoot:
22
+ // docs/plans/foo.md → 'plan'
23
+ // docs/prompts/bar.md → 'prompt'
24
+ // docs/baz.md → 'doc' (root-level under docsRoot)
25
+ // docs/notes/qux.md → 'doc' (unrecognized subdir falls through)
26
+ // Centralizes the inline `rootLabel.includes('plan')` heuristic from lint.mjs
27
+ // and extends it for prompts.
28
+ export function inferTypeFromPath(filePath, docsRoot) {
29
+ const rootKind = path.basename(docsRoot);
30
+ if (rootKind === 'plans') return 'plan';
31
+ if (rootKind === 'prompts') return 'prompt';
32
+ const rel = path.relative(docsRoot, filePath);
33
+ const segments = rel.split(path.sep);
34
+ if (segments.length >= 2) {
35
+ const sub = segments[0];
36
+ if (sub === 'plans') return 'plan';
37
+ if (sub === 'prompts') return 'prompt';
38
+ }
39
+ return 'doc';
40
+ }
41
+
42
+ function parseArgs(argv) {
43
+ const opts = { typeOverride: null, statusOverride: null, json: false, positional: [] };
44
+ for (let i = 0; i < argv.length; i++) {
45
+ const a = argv[i];
46
+ if (a === '--type') { opts.typeOverride = argv[++i]; continue; }
47
+ if (a === '--status') { opts.statusOverride = argv[++i]; continue; }
48
+ if (a === '--json') { opts.json = true; continue; }
49
+ if (a.startsWith('-')) die(`Unknown flag: ${a}`);
50
+ opts.positional.push(a);
51
+ }
52
+ return opts;
53
+ }
54
+
55
+ export function runBulkTag(argv, config, opts = {}) {
56
+ const { dryRun } = opts;
57
+ const args = parseArgs(argv);
58
+
59
+ // Determine candidate set: explicit positional args take precedence; otherwise
60
+ // scan all collected doc files (which already excludes config.indexPath).
61
+ const allFiles = collectDocFiles(config);
62
+ let pool;
63
+ if (args.positional.length > 0) {
64
+ pool = args.positional
65
+ .map(p => resolveDocPath(p, config))
66
+ .filter(Boolean);
67
+ if (pool.length === 0) die('No matching files found for the given paths.');
68
+ } else {
69
+ pool = allFiles;
70
+ }
71
+ authorizeManagedSweep(pool, config, { kind: 'Bulk tag source' });
72
+
73
+ // Skip already-archived files (mirrors bulk archive's policy at
74
+ // lifecycle.mjs:569–573) — settled docs shouldn't be retroactively tagged.
75
+ const archiveDir = config.archiveDir;
76
+ const inArchive = (f) => {
77
+ const root = findLexicalDocsRoot(f, config) ?? config.docsRoot;
78
+ const rel = path.relative(root, f);
79
+ return rel.startsWith(archiveDir + '/') || rel.startsWith(archiveDir + path.sep);
80
+ };
81
+
82
+ const candidates = [];
83
+ for (const filePath of pool) {
84
+ if (inArchive(filePath)) continue;
85
+ let raw;
86
+ try { raw = readFileSync(filePath, 'utf8'); } catch (err) { warn(`Could not read ${filePath}: ${err.message}`); continue; }
87
+ const { frontmatter } = extractFrontmatter(raw);
88
+ const parsed = frontmatter ? parseSimpleFrontmatter(frontmatter) : {};
89
+ const hasType = typeof parsed.type === 'string' && parsed.type.length > 0;
90
+ const hasStatus = typeof parsed.status === 'string' && parsed.status.length > 0;
91
+ // Only files missing type OR status are candidates; fully tagged files are
92
+ // skipped silently (bulk-tag's job is to fill gaps, not nag).
93
+ if (hasType && hasStatus) continue;
94
+
95
+ const root = findLexicalDocsRoot(filePath, config) ?? config.docsRoot;
96
+ const inferredType = args.typeOverride ?? (hasType ? parsed.type : inferTypeFromPath(filePath, root));
97
+ const defaultStatus = DEFAULT_STATUS_BY_TYPE[inferredType] ?? 'draft';
98
+ const inferredStatus = args.statusOverride ?? (hasStatus ? parsed.status : defaultStatus);
99
+
100
+ const updates = {};
101
+ if (!hasType) updates.type = inferredType;
102
+ if (!hasStatus) updates.status = inferredStatus;
103
+
104
+ candidates.push({
105
+ filePath,
106
+ relPath: toRepoPath(filePath, config.repoRoot),
107
+ hadFrontmatter: Boolean(frontmatter),
108
+ hadType: hasType,
109
+ hadStatus: hasStatus,
110
+ currentType: hasType ? parsed.type : null,
111
+ currentStatus: hasStatus ? parsed.status : null,
112
+ newType: inferredType,
113
+ newStatus: inferredStatus,
114
+ updates,
115
+ });
116
+ }
117
+
118
+ if (args.json) {
119
+ process.stdout.write(JSON.stringify({
120
+ dryRun: Boolean(dryRun),
121
+ count: candidates.length,
122
+ candidates: candidates.map(c => ({
123
+ path: c.relPath,
124
+ hadFrontmatter: c.hadFrontmatter,
125
+ currentType: c.currentType,
126
+ currentStatus: c.currentStatus,
127
+ newType: c.newType,
128
+ newStatus: c.newStatus,
129
+ updates: c.updates,
130
+ })),
131
+ }, null, 2) + '\n');
132
+ if (!dryRun) {
133
+ for (const c of candidates) {
134
+ try { writeFrontmatter(c.filePath, c.updates); }
135
+ catch (err) { warn(`Failed to tag ${c.relPath}: ${err.message}`); }
136
+ }
137
+ }
138
+ return;
139
+ }
140
+
141
+ if (candidates.length === 0) {
142
+ process.stdout.write(green('No untagged files found.') + '\n');
143
+ return;
144
+ }
145
+
146
+ process.stdout.write(`${candidates.length} untagged file(s) — will tag:\n`);
147
+ const pathWidth = Math.min(60, Math.max(...candidates.map(c => c.relPath.length)));
148
+ for (const c of candidates) {
149
+ const fields = [];
150
+ if (c.updates.type) fields.push(`type: ${c.updates.type}`);
151
+ if (c.updates.status) fields.push(`status: ${c.updates.status}`);
152
+ const note = c.hadFrontmatter
153
+ ? `(added ${Object.keys(c.updates).join(', ')})`
154
+ : '(no frontmatter)';
155
+ process.stdout.write(` ${c.relPath.padEnd(pathWidth)} ${fields.join(' ')} ${dim(note)}\n`);
156
+ }
157
+
158
+ if (dryRun) {
159
+ process.stdout.write(dim('\n[dry-run] No changes made.\n'));
160
+ return;
161
+ }
162
+
163
+ process.stdout.write('\n');
164
+ let tagged = 0;
165
+ for (const c of candidates) {
166
+ try {
167
+ writeFrontmatter(c.filePath, c.updates);
168
+ tagged++;
169
+ } catch (err) {
170
+ warn(`Failed to tag ${c.relPath}: ${err.message}`);
171
+ }
172
+ }
173
+ process.stdout.write(green(`Tagged ${tagged} file(s).`) + '\n');
174
+ }