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
@@ -0,0 +1,1444 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { extractFrontmatter, parseSimpleFrontmatter, normalizeEol } from './frontmatter.mjs';
4
+ import { asString, toRepoPath, die, warn, resolveDocPath, resolveRefPath, escapeRegex, nowIso, suggestCandidates, emitFilesFooter, isArchivedPath, currentSessionId } from './util.mjs';
5
+ import { readJournalEntries } from './journal.mjs';
6
+ import { captureGitIndexGeneration, getGitLastModifiedBatch, getGitLastSubstantiveModifiedBatch, isTracked } from './git.mjs';
7
+ import { buildIndex, collectDocFiles, resolveDocArg } from './index.mjs';
8
+ import { writeRenderedIndex } from './index-file.mjs';
9
+ import { green, dim } from './color.mjs';
10
+ import { isInteractive, promptChoice } from './prompt.mjs';
11
+ import { buildCard, renderCard } from './pickup-card.mjs';
12
+ import { walkSections, findSection } from './section.mjs';
13
+ import { authorizeManagedDestination, authorizeManagedSource, authorizeManagedSweep } from './managed-path.mjs';
14
+ import { withPathLocks, snapshotFile, replaceSnapshot, moveFileAtomic, mutateFile, mutateFileSet } from './atomic-mutation.mjs';
15
+ import { configuredReferenceFields, createReferenceIdentitySet, rewriteDocumentReferences } from './reference-planner.mjs';
16
+ import { reportMovedCodeRefs } from './code-refs.mjs';
17
+ import {
18
+ assertPlanMutationAuthorized,
19
+ assertHookDeliveryTakeoverSafe,
20
+ abandonClaimHookDelivery,
21
+ authoritativeSessionId,
22
+ availableSessionId,
23
+ beginClaimHookDelivery,
24
+ classifyPlanPickup,
25
+ commitPlanClaim,
26
+ finishClaimHookDelivery,
27
+ listOwnedPlans,
28
+ ownershipLiveness,
29
+ pickupFactsForDoc,
30
+ prepareOwnershipRelease,
31
+ readPlanOwnership,
32
+ skipClaimHookDelivery,
33
+ updateOwnershipOperation,
34
+ validatedClaimOperation,
35
+ } from './pickup.mjs';
36
+
37
+ export function renderLifecycleMutation(raw, updates, historyEntry, { createSection = false, bodyTransform = null } = {}) {
38
+ raw = normalizeEol(raw);
39
+ if (!raw.startsWith('---\n')) throw new Error('Document has no frontmatter block. Retrofit it with `runlist bulk-tag` first.');
40
+ const endMarker = raw.indexOf('\n---\n', 4);
41
+ if (endMarker === -1) throw new Error('Document has an unclosed frontmatter block.');
42
+ let frontmatter = raw.slice(4, endMarker);
43
+ let body = raw.slice(endMarker + 5);
44
+ if (bodyTransform) body = bodyTransform(body);
45
+ for (const [key, value] of Object.entries(updates)) {
46
+ const regex = new RegExp(`^${escapeRegex(key)}:.*$`, 'm');
47
+ frontmatter = regex.test(frontmatter)
48
+ ? frontmatter.replace(regex, `${key}: ${value}`)
49
+ : `${frontmatter}\n${key}: ${value}`;
50
+ }
51
+ if (historyEntry) {
52
+ const bullet = `- **${updates.updated ?? nowIso()}** ${historyEntry}`;
53
+ const vh = findSection(walkSections(body), 'Version History');
54
+ if (vh) {
55
+ const lines = body.split('\n');
56
+ let insertAt = vh.lineStart;
57
+ while (insertAt < lines.length && lines[insertAt].trim() === '') insertAt++;
58
+ lines.splice(insertAt, 0, bullet, ...(insertAt >= lines.length || lines[insertAt]?.startsWith('#') ? [''] : []));
59
+ body = lines.join('\n');
60
+ } else if (createSection) {
61
+ body = `${body.replace(/\n+$/, '')}\n\n## Version History\n\n${bullet}\n`;
62
+ }
63
+ }
64
+ return `---\n${frontmatter}\n---\n${body}`;
65
+ }
66
+
67
+ function commitLifecycleMutation(filePath, targetPath, config, updates, historyForOldStatus, options = {}) {
68
+ const render = sourceContent => {
69
+ const currentFm = parseSimpleFrontmatter(extractFrontmatter(sourceContent).frontmatter);
70
+ const currentOldStatus = asString(currentFm.status) ?? 'unknown';
71
+ let content = renderLifecycleMutation(options.sourceTransform ? options.sourceTransform(sourceContent) : sourceContent,
72
+ updates, historyForOldStatus(currentOldStatus), options);
73
+ const beforeSelfRefs = content;
74
+ if (targetPath) content = renderMovedFileRefs(content, filePath, targetPath, config);
75
+ return {
76
+ oldStatus: currentOldStatus,
77
+ content,
78
+ selfRefsFixed: content !== beforeSelfRefs,
79
+ };
80
+ };
81
+ if (targetPath) {
82
+ // A companion prompt created or refreshed by baton links this plan before
83
+ // its status transition decides whether to file it elsewhere. Stamp the
84
+ // destination into that companion before publishing any transaction path.
85
+ const newRepoPath = toRepoPath(targetPath, config.repoRoot);
86
+ const retargetPrompt = item => {
87
+ if (typeof item.content !== 'string') return item;
88
+ const { frontmatter, body } = extractFrontmatter(item.content);
89
+ if (!frontmatter) return item;
90
+ const fm = parseSimpleFrontmatter(frontmatter);
91
+ const linked = asString(fm.plan);
92
+ const resolved = linked ? resolveRefPath(linked, path.dirname(item.path), config.repoRoot) : null;
93
+ if (asString(fm.type) !== 'prompt' || !resolved || path.resolve(resolved) !== path.resolve(filePath)) return item;
94
+ const rewritten = frontmatter.replace(/^plan:[ \t]*\S.*$/m, `plan: ${newRepoPath}`);
95
+ return { ...item, content: `---\n${rewritten}\n---\n${body}` };
96
+ };
97
+ const creations = (options.creations ?? []).map(retargetPrompt);
98
+ let result;
99
+ const tracked = isTracked(filePath, config.repoRoot);
100
+ const gitIndex = tracked ? captureGitIndexGeneration(config.repoRoot) : null;
101
+ const companionPaths = new Set((options.additionalUpdates ?? []).map(item => path.resolve(item.path)));
102
+ const allFiles = options.skipInboundRefs
103
+ ? []
104
+ : collectDocFiles(config).filter(candidate => candidate !== filePath && candidate !== targetPath && !companionPaths.has(path.resolve(candidate)));
105
+ authorizeManagedSweep(allFiles, config, { kind: 'Reference rewrite source' });
106
+ const identities = createReferenceIdentitySet([filePath, ...allFiles]);
107
+ const referenceFields = configuredReferenceFields(config);
108
+ const additionalUpdates = options.skipInboundRefs
109
+ ? (options.additionalUpdates ?? []).map(retargetPrompt)
110
+ : (options.additionalUpdates ?? []).map(item => retargetPrompt({
111
+ ...item,
112
+ content: item.content === undefined ? undefined : rewriteDocumentReferences(item.content, {
113
+ sourcePath: item.path, repoRoot: config.repoRoot, identities, oldPath: filePath, newPath: targetPath, referenceFields,
114
+ }),
115
+ }));
116
+ const moveResult = moveFileAtomic(filePath, targetPath, sourceContent => {
117
+ result = render(sourceContent);
118
+ return result.content;
119
+ }, {
120
+ repoRoot: config.repoRoot,
121
+ config,
122
+ updates: [...allFiles.map(docFile => ({
123
+ path: docFile,
124
+ render: raw => rewriteDocumentReferences(raw, {
125
+ sourcePath: docFile, repoRoot: config.repoRoot, identities, oldPath: filePath, newPath: targetPath, referenceFields,
126
+ }),
127
+ })), ...additionalUpdates],
128
+ creations,
129
+ guards: options.guards ?? [],
130
+ gitMove: tracked,
131
+ gitIndex,
132
+ operation: 'lifecycle-move',
133
+ sessionId: availableSessionId(),
134
+ testHooks: options.testHooks,
135
+ });
136
+ return { ...result, sourceContent: moveResult.source.content, updatedPaths: moveResult.updatedPaths };
137
+ }
138
+ if ((options.additionalUpdates?.length ?? 0) > 0 || (options.creations?.length ?? 0) > 0 || (options.guards?.length ?? 0) > 0) {
139
+ const sourceContent = readFileSync(filePath, 'utf8');
140
+ const result = render(sourceContent);
141
+ mutateFileSet({
142
+ updates: [{ path: filePath, expectedContent: sourceContent, content: result.content }, ...(options.additionalUpdates ?? [])],
143
+ creations: options.creations ?? [],
144
+ guards: options.guards ?? [],
145
+ }, { repoRoot: config.repoRoot, testHooks: options.testHooks });
146
+ return { ...result, sourceContent, updatedPaths: [] };
147
+ }
148
+ return withPathLocks([filePath], { repoRoot: config.repoRoot }, () => {
149
+ const sourceSnapshot = snapshotFile(filePath);
150
+ const result = render(sourceSnapshot.content);
151
+ replaceSnapshot(sourceSnapshot, result.content, { repoRoot: config.repoRoot, locked: true });
152
+ return { ...result, sourceContent: sourceSnapshot.content, updatedPaths: [] };
153
+ });
154
+ }
155
+
156
+ export function updateFrontmatterAtomic(filePath, updates, config, options = {}) {
157
+ return mutateFile(filePath, { repoRoot: config.repoRoot }, raw => {
158
+ if (options.expected) {
159
+ const current = parseSimpleFrontmatter(extractFrontmatter(raw).frontmatter);
160
+ for (const [key, value] of Object.entries(options.expected)) {
161
+ if (asString(current[key]) !== value) throw new Error(`${key} changed while the mutation was being prepared.`);
162
+ }
163
+ }
164
+ return renderLifecycleMutation(raw, updates, null);
165
+ });
166
+ }
167
+
168
+ function defaultTypeDir(docType, config) {
169
+ if (docType === 'plan') return 'plans';
170
+ if (docType === 'prompt') return 'prompts';
171
+ const templateDir = config.raw?.templates?.[docType]?.dir;
172
+ return typeof templateDir === 'string' && templateDir ? templateDir : null;
173
+ }
174
+
175
+ function findFilingRoot(filePath, fileRoot, docType, config) {
176
+ const dirName = defaultTypeDir(docType, config);
177
+ if (!dirName) return fileRoot;
178
+ if (path.basename(fileRoot) === dirName) return fileRoot;
179
+
180
+ const relSegments = path.relative(fileRoot, filePath).split(path.sep);
181
+ if (relSegments[0] === dirName) return path.join(fileRoot, dirName);
182
+ return fileRoot;
183
+ }
184
+
185
+ // Base directory a doc archives under. Types in lifecycle.archiveNestedTypes
186
+ // (default: prompt) archive into their own <typeDir>/ — yielding
187
+ // <typeDir>/<archiveDir> (e.g. docs/prompts/archived/) — so session-local
188
+ // prompt churn doesn't bury plans/docs in the shared <root>/<archiveDir>.
189
+ // Everything else archives under fileRoot. Used by both runStatus (set
190
+ // archived) and runArchive so the two paths stay in lockstep.
191
+ function archiveBaseFor(filePath, fileRoot, docType, config) {
192
+ const nest = config.lifecycle?.archiveNestedTypes?.has(docType) ?? false;
193
+ return nest ? findFilingRoot(filePath, fileRoot, docType, config) : fileRoot;
194
+ }
195
+
196
+ // Best-effort index regen for any doc-set or doc-status mutation. The
197
+ // generated block groups by status and embeds per-doc snapshots, so any
198
+ // change that affects what would render leaves the index stale. Wrapped
199
+ // in try/catch — a regen failure shouldn't undo the successful mutation,
200
+ // only warn with the recovery command.
201
+ export function regenIndex(config, options = {}) {
202
+ if (!config.indexPath) return false;
203
+ try {
204
+ // Fast path: skip validation/git-staleness/ref-checking — the rendered
205
+ // index file only consumes status/title/snapshot/etc. Validation runs on
206
+ // explicit `runlist check` / `runlist index`. This keeps lifecycle commands
207
+ // snappy on repos with huge git history or heavy `validate` hooks.
208
+ options.testHooks?.beforeClaimIndex?.();
209
+ writeRenderedIndex(() => buildIndex(config, { fast: true }), config, { testHooks: options.testHooks });
210
+ return true;
211
+ } catch (err) {
212
+ if (options.throwOnError) throw err;
213
+ warn(`Could not regenerate index (run \`runlist index\`): ${err.message}`);
214
+ return false;
215
+ }
216
+ }
217
+
218
+ function reconcileClaimOperation(repoPath, config, opts = {}) {
219
+ let current = validatedClaimOperation(repoPath, config);
220
+ if (!current) return { indexRegenerated: false, ownershipChanged: false, hook: 'none', pending: false };
221
+ let indexRegenerated = false;
222
+ let ownershipChanged = false;
223
+ const binding = current.binding;
224
+ if (current.operation.index === 'pending') {
225
+ regenIndex(config, { throwOnError: true, testHooks: opts.testHooks });
226
+ indexRegenerated = true;
227
+ updateOwnershipOperation(repoPath, config, binding, op => { op.index = 'done'; });
228
+ ownershipChanged = true;
229
+ current = validatedClaimOperation(repoPath, config, binding);
230
+ if (!current) throw new Error(`Claim completion was superseded for ${repoPath}.`);
231
+ }
232
+ if (['pending', 'delivering'].includes(current.operation.hook)) {
233
+ if (current.operation.hook === 'pending' && !config.hooks.onPickup) {
234
+ updateOwnershipOperation(repoPath, config, binding, op => {
235
+ op.hook = 'skipped';
236
+ delete op.hookDeliveryToken;
237
+ delete op.hookDeliveryStartedAt;
238
+ delete op.hookDeliveryOwner;
239
+ });
240
+ return { indexRegenerated, ownershipChanged: true, hook: 'skipped', pending: false };
241
+ }
242
+ // Durable outbox contract: retries reuse operationId until the hook returns.
243
+ // Hooks that perform external side effects must deduplicate by operationId.
244
+ // No local protocol can distinguish a crash after the external side effect
245
+ // from a crash before the completion marker is persisted.
246
+ opts.testHooks?.beforeClaimHookInvoke?.({ repoPath, binding });
247
+ const lease = beginClaimHookDelivery(repoPath, config, binding, {
248
+ now: opts.hookLeaseNow,
249
+ leaseMs: opts.hookLeaseMs,
250
+ ownerLiveness: opts.hookOwnerLiveness,
251
+ });
252
+ if (!lease) return { indexRegenerated, ownershipChanged, hook: current.operation.hook, pending: true };
253
+ if (lease.busy) throw new Error(`Claim hook delivery is already in progress for ${repoPath}.`);
254
+ if (!config.hooks.onPickup) {
255
+ skipClaimHookDelivery(repoPath, config, binding, lease.token);
256
+ return { indexRegenerated, ownershipChanged: true, hook: 'skipped', pending: false };
257
+ }
258
+ try {
259
+ const operation = lease.operation;
260
+ const event = { path: repoPath, oldStatus: operation.oldStatus, newStatus: 'in-session', operationId: operation.id };
261
+ config.hooks.onPickup(event);
262
+ } catch (err) {
263
+ abandonClaimHookDelivery(repoPath, config, binding, lease.token);
264
+ throw err;
265
+ }
266
+ finishClaimHookDelivery(repoPath, config, binding, lease.token);
267
+ ownershipChanged = true;
268
+ }
269
+ const after = validatedClaimOperation(repoPath, config, binding);
270
+ return { indexRegenerated, ownershipChanged, hook: after?.operation?.hook ?? 'done', pending: Boolean(after && (after.operation.index === 'pending' || ['pending', 'delivering'].includes(after.operation.hook))) };
271
+ }
272
+
273
+ export function completePlanClaim(repoPath, config, opts = {}) {
274
+ const current = validatedClaimOperation(repoPath, config);
275
+ let skipped = false;
276
+ if (opts.noIndex && current) {
277
+ updateOwnershipOperation(repoPath, config, current.binding, op => { op.index = 'skipped'; });
278
+ skipped = true;
279
+ }
280
+ const result = reconcileClaimOperation(repoPath, config, opts);
281
+ if (skipped) result.ownershipChanged = true;
282
+ return result;
283
+ }
284
+
285
+ export function ensurePlanCompletionBeforeRelease(repoPath, config, opts = {}) {
286
+ const before = validatedClaimOperation(repoPath, config);
287
+ if (!before) return;
288
+ reconcileClaimOperation(repoPath, config, opts);
289
+ const after = validatedClaimOperation(repoPath, config, before.binding);
290
+ if (after && (after.operation.index === 'pending' || ['pending', 'delivering'].includes(after.operation.hook))) {
291
+ throw new Error(`Cannot release ${repoPath}; claim completion is still pending.`);
292
+ }
293
+ }
294
+
295
+ export function planHasPendingCompletion(repoPath, config) {
296
+ const current = validatedClaimOperation(repoPath, config);
297
+ return Boolean(current && (current.operation.index === 'pending' || ['pending', 'delivering'].includes(current.operation.hook)));
298
+ }
299
+
300
+ export function pickupCandidates(index, config, sessionId) {
301
+ return index.docs.filter(doc => pickupFactsForDoc(doc, config, { sessionId }).pickupable);
302
+ }
303
+
304
+ // Pick an archive destination that won't clobber an existing record. If
305
+ // `<dir>/<basename>` is free, returns it unchanged; otherwise appends a
306
+ // numeric suffix (`-2`, `-3`, …) so the slug → path mapping stays readable
307
+ // across re-archives (issue #10 finding #6). The pre-0.39.5 behavior used a
308
+ // UTC timestamp on collision, which made the second archive's path
309
+ // non-deterministic and harder to cross-reference against the original.
310
+ // Closeout skeleton injected by `runlist archive --closeout-template`. Loose
311
+ // bullet shape (not sub-headings) matches the freeform prose-and-bullets style
312
+ // of existing in-repo closeouts — agents replace bullets with prose when that
313
+ // flows better. The HTML comment is the agent-facing prompt.
314
+ const CLOSEOUT_SKELETON = `## Closeout
315
+
316
+ <!-- Fill in below. Replace bullets with prose if that flows better. -->
317
+ - **Outcomes:**
318
+ - **Key commits:**
319
+ - **Deferrals:**
320
+ `;
321
+
322
+ // Plans where to inject the closeout skeleton without writing anything. Returns:
323
+ // { action: 'skip' } — section already present
324
+ // { action: 'inject', placement, newBody } — built body with skeleton inserted
325
+ // Placement: just before `## Version History` (so the closeout reads as work
326
+ // content, not appendix); falls back to end-of-body if VH is absent.
327
+ export function planCloseoutInjection(body) {
328
+ if (/^##\s+Closeout\s*$/mi.test(body)) {
329
+ return { action: 'skip' };
330
+ }
331
+ const vhMatch = body.match(/^##\s+Version History\s*$/mi);
332
+ if (vhMatch && vhMatch.index !== undefined) {
333
+ const before = body.slice(0, vhMatch.index).replace(/\s+$/, '');
334
+ const rest = body.slice(vhMatch.index);
335
+ return {
336
+ action: 'inject',
337
+ placement: 'before `## Version History`',
338
+ newBody: `${before}\n\n${CLOSEOUT_SKELETON}\n${rest}`,
339
+ };
340
+ }
341
+ const trimmed = body.replace(/\s+$/, '');
342
+ return {
343
+ action: 'inject',
344
+ placement: 'end of body',
345
+ newBody: `${trimmed}\n\n${CLOSEOUT_SKELETON}`,
346
+ };
347
+ }
348
+
349
+ function uniqueArchiveTarget(targetDir, basename) {
350
+ const base = path.join(targetDir, basename);
351
+ if (!existsSync(base)) return base;
352
+
353
+ const ext = path.extname(basename);
354
+ const stem = basename.slice(0, -ext.length);
355
+
356
+ let n = 2;
357
+ let target = path.join(targetDir, `${stem}-${n}${ext}`);
358
+ while (existsSync(target)) {
359
+ n++;
360
+ target = path.join(targetDir, `${stem}-${n}${ext}`);
361
+ }
362
+ return target;
363
+ }
364
+
365
+ export async function runStatus(argv, config, opts = {}) {
366
+ const { dryRun } = opts;
367
+ const noIndex = argv.includes('--no-index') || opts.noIndex;
368
+ const showFiles = argv.includes('--show-files') || opts.showFiles;
369
+ argv = argv.filter(a => a !== '--no-index' && a !== '--show-files');
370
+ let note = opts.note ?? null;
371
+ const noteIdx = argv.indexOf('--note');
372
+ if (noteIdx !== -1) {
373
+ note = note ?? argv[noteIdx + 1] ?? null;
374
+ if (!note || note.startsWith('--')) die('--note requires a value: --note "what changed and why"');
375
+ argv = argv.filter((_, i) => i !== noteIdx && i !== noteIdx + 1);
376
+ }
377
+ const input = argv[0];
378
+ let newStatus = argv[1];
379
+
380
+ if (!opts.suppressDeprecation) {
381
+ process.stderr.write(dim('`runlist status <file> <status>` is deprecated; prefer `runlist set <status> [<file>]` (note: <status> first; <file> optional when a plan is in-session). Removed in a future major.\n'));
382
+ }
383
+
384
+ if (!input) { die('Usage: runlist status <file> <new-status>'); }
385
+
386
+ let filePath = resolveDocArg(input, config);
387
+ const sourceAuthorization = authorizeManagedSource(filePath, config, { kind: 'Status source' });
388
+ filePath = sourceAuthorization.path;
389
+
390
+ // Determine type-specific or root-specific valid statuses
391
+ const raw = readFileSync(filePath, 'utf8');
392
+ const { frontmatter: fmRaw } = extractFrontmatter(raw);
393
+ const parsedFm = parseSimpleFrontmatter(fmRaw);
394
+ const docType = asString(parsedFm.type) ?? null;
395
+ const fileRoot = sourceAuthorization.root.lexicalPath;
396
+ const rootLabel = path.relative(config.repoRoot, fileRoot).split(path.sep).join('/');
397
+
398
+ // Build effective valid status set: type > root > global
399
+ let effectiveValid;
400
+ let effectiveOrder;
401
+ if (docType && config.typeStatuses?.has(docType)) {
402
+ effectiveValid = config.typeStatuses.get(docType);
403
+ effectiveOrder = [...effectiveValid];
404
+ } else {
405
+ const rootSet = config.rootValidStatuses?.get(rootLabel);
406
+ effectiveValid = rootSet ?? config.validStatuses;
407
+ effectiveOrder = config.statusOrder;
408
+ }
409
+
410
+ if (!newStatus) {
411
+ if (isInteractive()) {
412
+ newStatus = await promptChoice('Which status?', effectiveOrder);
413
+ if (!newStatus) die('No status selected.');
414
+ } else {
415
+ die('Usage: runlist status <file> <new-status>');
416
+ }
417
+ }
418
+
419
+ if (!effectiveValid.has(newStatus)) {
420
+ const suggestions = suggestCandidates(newStatus, [...effectiveValid]);
421
+ const hint = suggestions.length ? `\nDid you mean: ${suggestions.join(', ')}?` : '';
422
+ die(`Invalid status: ${newStatus}\nValid: ${[...effectiveValid].join(', ')}${hint}`);
423
+ }
424
+
425
+ if (!opts.suppressDeprecation) {
426
+ const delegated = [newStatus, input];
427
+ if (note) delegated.push('--note', note);
428
+ if (noIndex) delegated.push('--no-index');
429
+ if (showFiles) delegated.push('--show-files');
430
+ if (argv.includes('--force')) delegated.push('--force');
431
+ return runSet(delegated, config, { ...opts, suppressDeprecation: true });
432
+ }
433
+
434
+ const oldStatus = asString(parsedFm.status);
435
+
436
+ if (oldStatus === newStatus) {
437
+ if (!dryRun && (opts.additionalUpdates?.length || opts.creations?.length || opts.guards?.length)) {
438
+ // Guards belong here as much as on the mutating paths below: a caller that
439
+ // asked for the status it already has (baton's prompt-only handoff) decided
440
+ // that from this file's status and then writes only its creations, so the
441
+ // read and the write need something holding the gap.
442
+ mutateFileSet({
443
+ updates: opts.additionalUpdates ?? [],
444
+ creations: opts.creations ?? [],
445
+ guards: opts.guards ?? [],
446
+ }, {
447
+ repoRoot: config.repoRoot,
448
+ testHooks: opts.testHooks,
449
+ });
450
+ }
451
+ process.stdout.write(`${toRepoPath(filePath, config.repoRoot)}: already ${newStatus}, no changes made.\n`);
452
+ return;
453
+ }
454
+
455
+ const today = nowIso();
456
+ const filingRoot = findFilingRoot(filePath, fileRoot, docType, config);
457
+ // Type-aware archive base (prompts nest under their type dir by default);
458
+ // unarchive reuses archiveBase so a prompt restores to its type dir.
459
+ const archiveBase = archiveBaseFor(filePath, fileRoot, docType, config);
460
+ const archiveDir = path.join(archiveBase, config.archiveDir);
461
+ const relFromFilingRoot = path.relative(filingRoot, filePath);
462
+ const relSegments = relFromFilingRoot.split(path.sep);
463
+ const inArchive = isArchivedPath(toRepoPath(filePath, config.repoRoot), config);
464
+ const isArchiving = config.lifecycle.archiveStatuses.has(newStatus) && !inArchive;
465
+ const isUnarchiving = !config.lifecycle.archiveStatuses.has(newStatus) && inArchive;
466
+
467
+ // F15 filing: a status with `filed: true` lives in `<root>/<dirName>/`. The
468
+ // current parent dir under root tells us whether the file is in some
469
+ // "bucket" right now. Archiving keeps its own path; filing is a separate
470
+ // primitive that fires only when the new status is filed (and isn't an
471
+ // archive transition — archive wins by being earlier in the conditional).
472
+ const filedStatuses = config.lifecycle.filedStatuses ?? new Map();
473
+ const newFiledDir = filedStatuses.get(newStatus) ?? null;
474
+ const oldFiledDir = oldStatus ? (filedStatuses.get(oldStatus) ?? null) : null;
475
+ const currentBucket = relSegments.length > 1 ? relSegments[0] : null;
476
+ const isFiling = !isArchiving && !isUnarchiving && newFiledDir && currentBucket !== newFiledDir;
477
+ const isUnfiling = !isArchiving && !isUnarchiving && !newFiledDir && oldFiledDir && currentBucket === oldFiledDir;
478
+ const authorizeTarget = targetPath => authorizeManagedDestination(targetPath, config, {
479
+ root: sourceAuthorization.root,
480
+ kind: 'Status move destination',
481
+ }).path;
482
+ let targetDir = null;
483
+ let targetPath = null;
484
+ if (isArchiving) {
485
+ targetDir = archiveDir;
486
+ targetPath = authorizeTarget(uniqueArchiveTarget(targetDir, path.basename(filePath)));
487
+ } else if (isUnarchiving) {
488
+ targetPath = authorizeTarget(path.join(archiveBase, path.basename(filePath)));
489
+ } else if (isFiling) {
490
+ targetDir = path.join(filingRoot, newFiledDir);
491
+ targetPath = authorizeTarget(path.join(targetDir, path.basename(filePath)));
492
+ } else if (isUnfiling) {
493
+ targetPath = authorizeTarget(path.join(filingRoot, path.basename(filePath)));
494
+ }
495
+ if (targetPath && !isArchiving && existsSync(targetPath)) {
496
+ die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`);
497
+ }
498
+ let finalPath = targetPath ?? filePath;
499
+ if(opts.expectedDestination && path.resolve(opts.expectedDestination)!==path.resolve(finalPath))die('Lifecycle destination changed since review; reload before applying.');
500
+
501
+ if (dryRun) {
502
+ const prefix = dim('[dry-run]');
503
+ process.stdout.write(`${prefix} Would update frontmatter: status: ${oldStatus ?? 'unknown'} → ${newStatus}, updated: ${today}\n`);
504
+ if (isArchiving) {
505
+ process.stdout.write(`${prefix} Would move: ${toRepoPath(filePath, config.repoRoot)} → ${toRepoPath(targetPath, config.repoRoot)}\n`);
506
+ }
507
+ if (isUnarchiving) {
508
+ process.stdout.write(`${prefix} Would move: ${toRepoPath(filePath, config.repoRoot)} → ${toRepoPath(targetPath, config.repoRoot)}\n`);
509
+ }
510
+ if (isFiling) {
511
+ process.stdout.write(`${prefix} Would file: ${toRepoPath(filePath, config.repoRoot)} → ${toRepoPath(targetPath, config.repoRoot)}\n`);
512
+ }
513
+ if (isUnfiling) {
514
+ process.stdout.write(`${prefix} Would unfile: ${toRepoPath(filePath, config.repoRoot)} → ${toRepoPath(targetPath, config.repoRoot)}\n`);
515
+ }
516
+ if (finalPath !== filePath) {
517
+ const refCount = countRefsToUpdate(filePath, finalPath, config);
518
+ if (refCount > 0) process.stdout.write(`${prefix} Would update references in ${refCount} file(s)\n`);
519
+ }
520
+ if ((isArchiving || isUnarchiving || isFiling || isUnfiling) && config.indexPath) {
521
+ process.stdout.write(`${prefix} Would regenerate index\n`);
522
+ }
523
+ if (note) {
524
+ process.stdout.write(`${prefix} Would append Version History: - **${today}** Status: ${oldStatus ?? 'unknown'} → ${newStatus} — ${note}\n`);
525
+ }
526
+ process.stdout.write(`${prefix} ${toRepoPath(finalPath, config.repoRoot)}: ${oldStatus ?? 'unknown'} → ${newStatus}\n`);
527
+ return { dryRun: true, oldRepoPath: toRepoPath(filePath, config.repoRoot), newRepoPath: toRepoPath(finalPath, config.repoRoot), touched: [] };
528
+ }
529
+
530
+ const mutationResult = commitLifecycleMutation(filePath, targetPath, config, { status: newStatus, updated: today }, currentOld => {
531
+ const currentTransition = `Status: ${currentOld} → ${newStatus}`;
532
+ return note ? `${currentTransition} — ${note}` : `${currentTransition}.`;
533
+ }, {
534
+ createSection: Boolean(note),
535
+ additionalUpdates: opts.additionalUpdates,
536
+ creations: opts.creations,
537
+ guards: opts.guards,
538
+ testHooks: opts.testHooks,
539
+ });
540
+
541
+ // Any of the four moves above shifts the file's directory, which breaks
542
+ // relative refs in both directions — links FROM the moved file and inbound
543
+ // refs TO it from other docs. runArchive repairs both; mirror that here so
544
+ // the deprecated `runlist status <file> archived` path and the `runlist set`
545
+ // unarchive/file/unfile transitions (which route through runStatus, not
546
+ // runArchive) don't silently leave dangling links.
547
+ const selfRefsFixed = Boolean(mutationResult.selfRefsFixed);
548
+ const inboundRefPaths = mutationResult.updatedPaths ?? [];
549
+ const inboundRefCount = inboundRefPaths.length;
550
+
551
+ // Regen the index on every status change — `active → planned` etc. drift
552
+ // the per-status sections just as much as archive crossings. Archive paths
553
+ // also benefit (replaces the previously-gated regen). `--no-index` skips
554
+ // this so concurrent agents can do path-limited commits without pulling
555
+ // each other's uncommitted index changes into the staging area.
556
+ if (noIndex) {
557
+ process.stderr.write(dim('(index not regenerated — run `runlist index` to refresh)\n'));
558
+ } else if (!opts.deferIndex) {
559
+ regenIndex(config);
560
+ }
561
+
562
+ process.stdout.write(`${green(toRepoPath(finalPath, config.repoRoot))}: ${oldStatus ?? 'unknown'} → ${newStatus}\n`);
563
+ if (selfRefsFixed) process.stdout.write('Updated references in moved file.\n');
564
+ if (inboundRefCount > 0) process.stdout.write(`Updated references in ${inboundRefCount} file(s).\n`);
565
+
566
+ if (showFiles) {
567
+ const touched = [filePath];
568
+ if (finalPath !== filePath) touched.push(finalPath);
569
+ touched.push(...inboundRefPaths);
570
+ if (config.indexPath && !noIndex && !opts.deferIndex) touched.push(config.indexPath);
571
+ emitFilesFooter(touched, config);
572
+ }
573
+
574
+ try { config.hooks.onStatusChange?.({ path: toRepoPath(finalPath, config.repoRoot), oldStatus, newStatus }, {
575
+ oldPath: toRepoPath(filePath, config.repoRoot),
576
+ newPath: toRepoPath(finalPath, config.repoRoot),
577
+ }); } catch (err) { warn(`Hook 'onStatusChange' threw: ${err.message}`); }
578
+ return {
579
+ action: 'status-changed',
580
+ oldRepoPath: toRepoPath(filePath, config.repoRoot),
581
+ newRepoPath: toRepoPath(finalPath, config.repoRoot),
582
+ touched: [toRepoPath(filePath, config.repoRoot), ...(finalPath !== filePath ? [toRepoPath(finalPath, config.repoRoot)] : []), ...inboundRefPaths.map(item => toRepoPath(item, config.repoRoot))],
583
+ };
584
+ }
585
+
586
+ // Atomically claim a plan for this session, then reconcile its generated index
587
+ // and pickup hook before printing the card. Backs direct use and next selectors.
588
+ export async function startPlan(argv, config, opts = {}) {
589
+ const { dryRun } = opts;
590
+ const json = argv.includes('--json');
591
+ const fullBody = argv.includes('--full');
592
+ const noIndex = argv.includes('--no-index') || opts.noIndex;
593
+ const showFiles = argv.includes('--show-files') || opts.showFiles;
594
+ const force = argv.includes('--force') || opts.force;
595
+ let input = argv.find(a => !a.startsWith('-'));
596
+
597
+ // Interactive: pick from active/planned plans
598
+ if (!input) {
599
+ if (!isInteractive()) die('Usage: runlist use <plan>');
600
+ const index = buildIndex(config);
601
+ const sessionId = authoritativeSessionId();
602
+ const candidates = pickupCandidates(index, config, sessionId);
603
+ if (candidates.length === 0) die('No pickup-able plans.');
604
+ const labelFor = (d) => `${d.title} (${d.status}) — ${d.path}`;
605
+ const choice = await promptChoice('Pick a plan:', candidates.map(labelFor));
606
+ if (!choice) die('No plan selected.');
607
+ const idx = candidates.findIndex(d => choice === labelFor(d));
608
+ if (idx === -1) die('No plan selected.');
609
+ input = candidates[idx].path;
610
+ }
611
+
612
+ let filePath = resolveDocArg(input, config);
613
+ filePath = authorizeManagedSource(filePath, config, { kind: 'Plan start source' }).path;
614
+
615
+ let raw = readFileSync(filePath, 'utf8');
616
+ let fmRaw, body, parsedFm;
617
+ try {
618
+ ({ frontmatter: fmRaw, body } = extractFrontmatter(raw));
619
+ if (!fmRaw) throw new Error('missing frontmatter');
620
+ parsedFm = parseSimpleFrontmatter(fmRaw);
621
+ } catch {
622
+ die(`Malformed plan document: ${toRepoPath(filePath, config.repoRoot)}`);
623
+ }
624
+ const docType = asString(parsedFm.type) ?? null;
625
+ const oldStatus = asString(parsedFm.status);
626
+ const title = asString(parsedFm.title) ?? path.basename(filePath, '.md');
627
+ const repoPath = toRepoPath(filePath, config.repoRoot);
628
+
629
+ const sessionId = dryRun ? (availableSessionId() ?? '__dry-run-no-session__') : authoritativeSessionId();
630
+ const ownership = readPlanOwnership(repoPath, config);
631
+ if (force) assertHookDeliveryTakeoverSafe(ownership, {
632
+ now: opts.hookLeaseNow,
633
+ leaseMs: opts.hookLeaseMs,
634
+ ownerLiveness: opts.hookOwnerLiveness,
635
+ });
636
+ let disposition = classifyPlanPickup({
637
+ type: docType,
638
+ status: oldStatus,
639
+ ownerLiveness: ownershipLiveness(ownership),
640
+ validStatuses: config.typeStatuses?.get('plan') ?? config.validStatuses,
641
+ startableStatuses: config.lifecycle.startableStatuses,
642
+ terminalStatuses: config.lifecycle.terminalStatuses,
643
+ archiveStatuses: config.lifecycle.archiveStatuses,
644
+ physicallyArchived: isArchivedPath(repoPath, config),
645
+ ownership,
646
+ sessionId,
647
+ malformed: false,
648
+ });
649
+ if (force && ['busy', 'ownership-corrupt'].includes(disposition.kind)) {
650
+ disposition = classifyPlanPickup({
651
+ type: docType, status: oldStatus,
652
+ validStatuses: config.typeStatuses?.get('plan') ?? config.validStatuses,
653
+ startableStatuses: config.lifecycle.startableStatuses,
654
+ terminalStatuses: config.lifecycle.terminalStatuses,
655
+ archiveStatuses: config.lifecycle.archiveStatuses,
656
+ physicallyArchived: isArchivedPath(repoPath, config), ownership: null, sessionId, malformed: false,
657
+ });
658
+ }
659
+ if (!disposition.pickupable) {
660
+ const detail = disposition.kind === 'busy' ? ` (owned by ${disposition.owner})` : '';
661
+ die(`Plan cannot be picked up: ${disposition.kind}${detail}\n ${repoPath}`);
662
+ }
663
+ const today = nowIso();
664
+ if (dryRun) {
665
+ if (disposition.kind === 'resume') {
666
+ process.stderr.write(`${dim('[dry-run]')} Already in-session: ${repoPath}\n`);
667
+ } else if (disposition.kind === 'adopt') {
668
+ process.stderr.write(`${dim('[dry-run]')} Would adopt unowned in-session plan: ${repoPath}\n`);
669
+ } else {
670
+ process.stderr.write(`${dim('[dry-run]')} Would update: status: ${oldStatus} → in-session, updated: ${today}\n`);
671
+ }
672
+ } else if (disposition.kind !== 'resume') {
673
+ const history = opts.note
674
+ ? `Started (${oldStatus} → in-session) — ${opts.note}`
675
+ : `Started (${oldStatus} → in-session).`;
676
+ const rendered = disposition.kind === 'start'
677
+ ? renderLifecycleMutation(raw, { status: 'in-session', updated: today }, history, { createSection: true })
678
+ : null;
679
+ commitPlanClaim({ filePath, repoPath, sourceContent: raw, renderedContent: rendered,
680
+ ownership, sessionId, now: today, config, testHooks: opts.testHooks });
681
+ }
682
+
683
+ if (!dryRun) {
684
+ if (noIndex) {
685
+ const current = validatedClaimOperation(repoPath, config);
686
+ if (current) updateOwnershipOperation(repoPath, config, current.binding, op => { op.index = 'skipped'; });
687
+ }
688
+ reconcileClaimOperation(repoPath, config, { ...opts, oldStatus });
689
+ raw = readFileSync(filePath, 'utf8');
690
+ ({ body } = extractFrontmatter(raw));
691
+ }
692
+
693
+ if (opts.quiet) {
694
+ // Prompt consume owns the user-facing output; the transition is identical.
695
+ } else if (json) {
696
+ const card = buildCard(filePath, raw, config);
697
+ process.stdout.write(JSON.stringify({
698
+ path: repoPath, oldStatus, newStatus: 'in-session', title,
699
+ body: body?.trim() ?? '',
700
+ card,
701
+ }, null, 2) + '\n');
702
+ } else {
703
+ process.stderr.write(`${green('▶ Started')}: ${repoPath} (${oldStatus ?? 'unset'} → in-session)\n\n`);
704
+ if (fullBody) {
705
+ const header = `[runlist] in-session: ${repoPath} — close with: runlist set <status> ${repoPath}\n---\n`;
706
+ process.stdout.write(header);
707
+ const content = (body ?? '').trim();
708
+ if (content) process.stdout.write(content + '\n');
709
+ } else {
710
+ const card = buildCard(filePath, raw, config);
711
+ process.stdout.write(renderCard(card));
712
+ }
713
+ }
714
+
715
+ if (showFiles && disposition.kind === 'start') {
716
+ const touched = [filePath];
717
+ if (config.indexPath && !noIndex && !opts.deferIndex) touched.push(config.indexPath);
718
+ emitFilesFooter(touched, config);
719
+ }
720
+
721
+ return { path: repoPath, oldStatus, disposition: disposition.kind };
722
+ }
723
+
724
+ export function runArchive(argv, config, opts = {}) {
725
+ const { dryRun, out = process.stdout } = opts;
726
+ const force = argv.includes('--force') || opts.force;
727
+ const noIndex = argv.includes('--no-index') || opts.noIndex;
728
+ const showFiles = argv.includes('--show-files') || opts.showFiles;
729
+ const closeoutTemplate = argv.includes('--closeout-template');
730
+ const fixCodeRefs = argv.includes('--fix-refs') || opts.fixRefs;
731
+ const codeRefStrings = argv.includes('--strings');
732
+ argv = argv.filter(a => a !== '--no-index' && a !== '--show-files' && a !== '--closeout-template' && a !== '--force'
733
+ && a !== '--fix-refs' && a !== '--strings');
734
+ let note = opts.note ?? null;
735
+ const noteIdx = argv.indexOf('--note');
736
+ if (noteIdx !== -1) {
737
+ note = note ?? argv[noteIdx + 1] ?? null;
738
+ if (!note || note.startsWith('--')) die('--note requires a value: --note "what shipped / why closed"');
739
+ argv = argv.filter((_, i) => i !== noteIdx && i !== noteIdx + 1);
740
+ }
741
+ const input = argv[0];
742
+
743
+ if (!input) { die('Usage: runlist archive <file>'); }
744
+
745
+ let filePath = resolveDocArg(input, config);
746
+ const sourceAuthorization = authorizeManagedSource(filePath, config, { kind: 'Archive source' });
747
+ filePath = sourceAuthorization.path;
748
+
749
+ const archiveFileRoot = sourceAuthorization.root.lexicalPath;
750
+ const relFromRoot = path.relative(archiveFileRoot, filePath);
751
+ // Segment-membership covers both single-root (`<root>/archived/foo.md`) and
752
+ // multi-root (`<type-root>/archived/foo.md`) layouts. The older
753
+ // startsWith-only check missed nested cases where archived/ wasn't the first
754
+ // segment under the resolved root.
755
+ const inArchiveDir = relFromRoot.split(path.sep).includes(config.archiveDir);
756
+
757
+ const raw = readFileSync(filePath, 'utf8');
758
+ const { frontmatter, body } = extractFrontmatter(raw);
759
+ const parsed = parseSimpleFrontmatter(frontmatter);
760
+ const oldStatus = asString(parsed.status) ?? 'unknown';
761
+ const archiveRepoPath = toRepoPath(filePath, config.repoRoot);
762
+ const archiveOwnership = asString(parsed.type) === 'plan' ? readPlanOwnership(archiveRepoPath, config) : null;
763
+ const releasingOwnership = asString(parsed.type) === 'plan'
764
+ && (oldStatus === 'in-session' || archiveOwnership?.state === 'owned' || archiveOwnership?.corrupt);
765
+ const releaseSessionId = releasingOwnership ? authoritativeSessionId() : null;
766
+ if (releasingOwnership) assertPlanMutationAuthorized(archiveRepoPath, config, { sessionId: releaseSessionId, force });
767
+ if (releasingOwnership && !dryRun) ensurePlanCompletionBeforeRelease(archiveRepoPath, config, { testHooks: opts.testHooks });
768
+ if (releasingOwnership && dryRun && planHasPendingCompletion(archiveRepoPath, config)) {
769
+ out.write(`${dim('[dry-run]')} Pending claim completion would block this release.\n`);
770
+ }
771
+ const releaseUpdate = releasingOwnership
772
+ ? prepareOwnershipRelease(archiveRepoPath, config, { sessionId: releaseSessionId, force })
773
+ : null;
774
+
775
+ // Preserve a configured custom archive status (e.g. `done` with archive:true)
776
+ // when one is threaded through from `runlist set <archive-status>`. Fall back to
777
+ // the canonical `archived`, or — if the config has no `archived` at all — its
778
+ // first declared archive status, so we never write a status the config can't
779
+ // validate.
780
+ const archiveStatuses = config.lifecycle.archiveStatuses;
781
+ const defaultArchiveStatus = archiveStatuses.has('archived')
782
+ ? 'archived'
783
+ : (archiveStatuses.values().next().value ?? 'archived');
784
+ const targetStatus = opts.archiveStatus ?? defaultArchiveStatus;
785
+
786
+ // Heal stuck frontmatter (issue #13): file is under archiveDir/ but its
787
+ // status hasn't been flipped. Flip in place; don't try to move (it's already
788
+ // archived on disk) and don't refuse — refusal leaves the drift permanent.
789
+ if (inArchiveDir) {
790
+ if (oldStatus === targetStatus) {
791
+ die(`Already archived: ${toRepoPath(filePath, config.repoRoot)}`);
792
+ }
793
+ const today = nowIso();
794
+ const repoPathHeal = toRepoPath(filePath, config.repoRoot);
795
+ if (dryRun) {
796
+ const prefix = dim('[dry-run]');
797
+ out.write(`${prefix} Would heal frontmatter in place: status: ${oldStatus} → ${targetStatus}, updated: ${today}\n`);
798
+ out.write(`${prefix} Would skip git mv (file already under \`${config.archiveDir}/\`)\n`);
799
+ return;
800
+ }
801
+ commitLifecycleMutation(filePath, null, config, { status: targetStatus, updated: today },
802
+ currentOld => `Archived (frontmatter healed in place from \`${currentOld}\`)${note ? ` — ${note}` : '.'}`,
803
+ {
804
+ createSection: Boolean(note),
805
+ additionalUpdates: [...(opts.additionalUpdates ?? []), ...(releaseUpdate ? [releaseUpdate] : [])],
806
+ creations: opts.creations,
807
+ guards: opts.guards,
808
+ testHooks: opts.testHooks,
809
+ sourceTransform: opts.sourceTransform,
810
+ });
811
+ if (!noIndex && !opts.deferIndex) regenIndex(config);
812
+ out.write(`${green('✓ Healed')}: ${repoPathHeal} (${oldStatus} → ${targetStatus}; file already under \`${config.archiveDir}/\`)\n`);
813
+ const touched = [repoPathHeal];
814
+ if (config.indexPath && !noIndex && !opts.deferIndex) touched.push(config.indexPath);
815
+ if (showFiles) emitFilesFooter(touched, config);
816
+ return {
817
+ action: 'healed',
818
+ oldRepoPath: repoPathHeal,
819
+ newRepoPath: repoPathHeal,
820
+ touched,
821
+ };
822
+ }
823
+
824
+ const closeoutAction = closeoutTemplate ? planCloseoutInjection(body) : null;
825
+ let committedCloseoutAction = closeoutAction;
826
+
827
+ const today = nowIso();
828
+ // Type-aware: prompts archive under docs/prompts/archived/ by default (see
829
+ // archiveBaseFor); plans/docs keep the shared <root>/archived/.
830
+ const targetDir = path.join(archiveBaseFor(filePath, archiveFileRoot, asString(parsed.type), config), config.archiveDir);
831
+ const targetPath = authorizeManagedDestination(uniqueArchiveTarget(targetDir, path.basename(filePath)), config, {
832
+ root: sourceAuthorization.root,
833
+ kind: 'Archive destination',
834
+ }).path;
835
+ const oldRepoPath = toRepoPath(filePath, config.repoRoot);
836
+ const newRepoPath = toRepoPath(targetPath, config.repoRoot);
837
+ if(opts.expectedDestination && path.resolve(opts.expectedDestination)!==path.resolve(targetPath))die('Lifecycle destination changed since review; reload before applying.');
838
+
839
+ if (dryRun) {
840
+ const prefix = dim('[dry-run]');
841
+ if (closeoutAction?.action === 'inject') {
842
+ out.write(`${prefix} Would inject \`## Closeout\` template (${closeoutAction.placement})\n`);
843
+ } else if (closeoutAction?.action === 'skip') {
844
+ out.write(`${prefix} \`## Closeout\` section already present — no injection\n`);
845
+ }
846
+ out.write(`${prefix} Would update frontmatter: status: ${oldStatus} → ${targetStatus}, updated: ${today}\n`);
847
+ if (note) {
848
+ out.write(`${prefix} Would append Version History: - **${today}** Archived — ${note}\n`);
849
+ }
850
+ out.write(`${prefix} Would move: ${oldRepoPath} → ${newRepoPath}\n`);
851
+ if (config.indexPath && !noIndex && !opts.deferIndex) out.write(`${prefix} Would regenerate index\n`);
852
+ if (config.indexPath && noIndex) out.write(`${prefix} Would skip index regen (--no-index)\n`);
853
+
854
+ // Preview reference updates unless the caller owns a non-referenceable,
855
+ // session-local document such as a saved prompt.
856
+ if (!opts.skipInboundRefs) {
857
+ const refCount = countRefsToUpdate(filePath, targetPath, config);
858
+ if (refCount > 0) {
859
+ out.write(`${prefix} Would update references in ${refCount} file(s)\n`);
860
+ }
861
+ }
862
+
863
+ if (!opts.skipInboundRefs) {
864
+ reportMovedCodeRefs(config, oldRepoPath, newRepoPath, { dryRun: true, out, prefix: `${prefix} ` });
865
+ }
866
+
867
+ // Preview onArchive hook fire
868
+ if (config.hooks?.onArchive) {
869
+ out.write(`${prefix} Would fire hook: onArchive\n`);
870
+ }
871
+ return { dryRun: true, oldRepoPath, newRepoPath, touched: [] };
872
+ }
873
+
874
+ const mutationResult = commitLifecycleMutation(filePath, targetPath, config, { status: targetStatus, updated: today },
875
+ () => note ? `Archived — ${note}` : 'Archived.', {
876
+ createSection: Boolean(note),
877
+ testHooks: opts.testHooks,
878
+ additionalUpdates: [...(opts.additionalUpdates ?? []), ...(releaseUpdate ? [releaseUpdate] : [])],
879
+ creations: opts.creations,
880
+ guards: opts.guards,
881
+ skipInboundRefs: opts.skipInboundRefs,
882
+ sourceTransform: opts.sourceTransform,
883
+ bodyTransform: closeoutTemplate ? currentBody => {
884
+ committedCloseoutAction = planCloseoutInjection(currentBody);
885
+ return committedCloseoutAction.action === 'inject' ? committedCloseoutAction.newBody : currentBody;
886
+ } : null,
887
+ });
888
+
889
+ const selfRefsFixed = mutationResult.selfRefsFixed;
890
+ const refTouchedPaths = mutationResult.updatedPaths;
891
+ const updatedRefCount = refTouchedPaths.length;
892
+
893
+ const indexRegenerated = !noIndex && !opts.deferIndex ? regenIndex(config) : false;
894
+
895
+ out.write(`${green('Archived')}: ${oldRepoPath} → ${newRepoPath}\n`);
896
+ if (committedCloseoutAction?.action === 'inject') {
897
+ out.write(`Injected \`## Closeout\` template — fill in: outcomes, key commits, deferrals.\n`);
898
+ } else if (committedCloseoutAction?.action === 'skip') {
899
+ out.write(dim('(closeout template skipped — `## Closeout` section already present)\n'));
900
+ }
901
+ if (selfRefsFixed) out.write('Updated references in archived file.\n');
902
+ if (updatedRefCount > 0) out.write(`Updated references in ${updatedRefCount} file(s).\n`);
903
+ // The doc-root repair above is committed. The code roots are a separate
904
+ // sweep outside the move transaction, so the count is reported and the
905
+ // write waits for --fix-refs.
906
+ if (!opts.skipInboundRefs) {
907
+ reportMovedCodeRefs(config, oldRepoPath, newRepoPath, { fix: fixCodeRefs, strings: codeRefStrings, out });
908
+ }
909
+ if (config.indexPath && indexRegenerated) out.write('Index regenerated.\n');
910
+ if (config.indexPath && noIndex) out.write(dim('(index not regenerated — run `runlist index` to refresh)\n'));
911
+
912
+ const touched = [oldRepoPath, newRepoPath, ...refTouchedPaths];
913
+ if (config.indexPath && indexRegenerated) touched.push(config.indexPath);
914
+ if (showFiles) emitFilesFooter(touched, config);
915
+
916
+ try { config.hooks.onArchive?.({ path: newRepoPath, oldStatus }, { oldPath: oldRepoPath, newPath: newRepoPath }); } catch (err) { warn(`Hook 'onArchive' threw: ${err.message}`); }
917
+
918
+ return {
919
+ action: 'archived',
920
+ oldRepoPath,
921
+ newRepoPath,
922
+ touched,
923
+ referencePaths: refTouchedPaths.map(item => toRepoPath(item, config.repoRoot)),
924
+ indexRegenerated,
925
+ consumedBody: extractFrontmatter(mutationResult.sourceContent).body,
926
+ consumedFrontmatter: parseSimpleFrontmatter(extractFrontmatter(mutationResult.sourceContent).frontmatter),
927
+ };
928
+ }
929
+
930
+ // Unified status-transition verb. Collapses status/archive/release into one
931
+ // signature — `runlist set <status> [<path>]` — and dispatches to the right
932
+ // plumbing based on the *target* status:
933
+ // - target in archiveStatuses (and file not already archived) → runArchive
934
+ // (gets us ref-fixing + atomic ownership release + closeout-template offer)
935
+ // - source = in-session, target != in-session → runStatus +
936
+ // atomic release of the ownership record
937
+ // - everything else (incl. unarchive, plain transitions) → runStatus
938
+ //
939
+ // Path is inferred from the calling session's valid ownership record when omitted. With
940
+ // zero records, ambiguity, or corruption, we refuse and ask for explicit `<path>` instead
941
+ // of guessing.
942
+ //
943
+ // `runlist set in-session <path>` routes through the exact same claim transition
944
+ // as `runlist use`, including history, ownership, index, and hook completion.
945
+
946
+ // Did THIS session already hand off via `runlist baton`? The journal records the
947
+ // top-level argv of every invocation; a successful `baton …` means a resume
948
+ // prompt was saved this session, so the closure nudge would be redundant. Best
949
+ // effort — a disabled journal degrades to "might nudge," which is harmless for
950
+ // an advisory line. (baton's own internal `runSet` is suppressed by `viaBaton`,
951
+ // since its journal entry isn't written until the process exits.)
952
+ function batonSavedThisSession(config) {
953
+ const sid = currentSessionId();
954
+ let entries = [];
955
+ try { entries = readJournalEntries(config); } catch { return false; }
956
+ for (let i = entries.length - 1; i >= 0; i--) {
957
+ const e = entries[i];
958
+ if (e?.sid === sid && (e.exit ?? 0) === 0 && Array.isArray(e.argv) && e.argv[0] === 'baton') return true;
959
+ }
960
+ return false;
961
+ }
962
+
963
+ export async function runSet(argv, config, opts = {}) {
964
+ const { dryRun } = opts;
965
+ const noIndex = argv.includes('--no-index');
966
+ const showFiles = argv.includes('--show-files');
967
+ const force = argv.includes('--force') || opts.force;
968
+ argv = argv.filter(a => a !== '--no-index' && a !== '--show-files' && a !== '--force');
969
+ let note = opts.note ?? null;
970
+ const noteIdx = argv.indexOf('--note');
971
+ if (noteIdx !== -1) {
972
+ note = note ?? argv[noteIdx + 1] ?? null;
973
+ if (!note || note.startsWith('--')) die('--note requires a value: --note "what changed and why"');
974
+ argv = argv.filter((_, i) => i !== noteIdx && i !== noteIdx + 1);
975
+ }
976
+
977
+ const newStatus = argv[0];
978
+ let input = argv[1];
979
+
980
+ if (!newStatus) die('Usage: runlist set <status> [<path>]');
981
+ let sessionId = null;
982
+ if (!input) {
983
+ sessionId = authoritativeSessionId();
984
+ const owned = listOwnedPlans(config, sessionId);
985
+ if (owned.length !== 1 || owned.diagnostics?.length) {
986
+ const diagnostics = owned.diagnostics?.length ? `\nIgnored ownership records:\n${owned.diagnostics.map(d => ` ${d}`).join('\n')}` : '';
987
+ die(`No-target set requires exactly one valid in-session plan owned by this session; found ${owned.length}. Pass an explicit path.${diagnostics}`);
988
+ }
989
+ input = owned[0].plan;
990
+ }
991
+
992
+ let filePath = resolveDocArg(input, config);
993
+ filePath = authorizeManagedSource(filePath, config, { kind: 'Set source' }).path;
994
+ const repoPath = toRepoPath(filePath, config.repoRoot);
995
+
996
+ if (newStatus === 'in-session') {
997
+ const args = [filePath];
998
+ if (noIndex) args.push('--no-index');
999
+ if (showFiles) args.push('--show-files');
1000
+ if (force) args.push('--force');
1001
+ return startPlan(args, config, { ...opts, force, note });
1002
+ }
1003
+
1004
+ let oldFm = null;
1005
+ try { oldFm = parseSimpleFrontmatter(extractFrontmatter(readFileSync(filePath, 'utf8')).frontmatter); } catch { /* runStatus reports malformed docs */ }
1006
+ const oldOwnership = asString(oldFm?.type) === 'plan' ? readPlanOwnership(repoPath, config) : null;
1007
+ const releasing = asString(oldFm?.type) === 'plan'
1008
+ && (asString(oldFm?.status) === 'in-session' || oldOwnership?.state === 'owned' || oldOwnership?.corrupt);
1009
+ if (releasing) {
1010
+ // opts.sessionId lets a repair caller supply the operator identity. Only
1011
+ // `doctor --claims` passes it: releasing a claim stamps the id into the
1012
+ // tombstone as provenance and takes nothing, so demanding an *authoritative*
1013
+ // one of the repairer is backwards for the command that exists to unwedge
1014
+ // sessions — it made the recovery path unusable from any shell without a
1015
+ // session of its own (a bare login shell, cron, CI).
1016
+ sessionId ??= opts.sessionId ?? authoritativeSessionId();
1017
+ assertPlanMutationAuthorized(repoPath, config, { sessionId, force });
1018
+ if (!dryRun) ensurePlanCompletionBeforeRelease(repoPath, config, { testHooks: opts.testHooks });
1019
+ else if (planHasPendingCompletion(repoPath, config)) process.stderr.write(`${dim('[dry-run]')} Pending claim completion would block this release.\n`);
1020
+ }
1021
+
1022
+ const inArchive = isArchivedPath(toRepoPath(filePath, config.repoRoot), config);
1023
+
1024
+ if (config.lifecycle.archiveStatuses.has(newStatus) && !inArchive) {
1025
+ const archiveArgs = [filePath];
1026
+ if (noIndex) archiveArgs.push('--no-index');
1027
+ if (showFiles) archiveArgs.push('--show-files');
1028
+ // Preserve the exact target status — a config may name its archive status
1029
+ // `done` (with archive:true) rather than `archived`. Without this, runArchive
1030
+ // would silently rewrite it to `archived`.
1031
+ return runArchive(archiveArgs, config, {
1032
+ dryRun, note, archiveStatus: newStatus, force, testHooks: opts.testHooks, deferIndex: opts.deferIndex,
1033
+ additionalUpdates: opts.additionalUpdates,
1034
+ creations: opts.creations,
1035
+ guards: opts.guards,
1036
+ expectedDestination: opts.expectedDestination,
1037
+ });
1038
+ }
1039
+
1040
+ // Two advisory reminders, both computed from one pre-transition frontmatter
1041
+ // read (a non-archive `set` never moves the file, so the path stays valid).
1042
+ // Advisory only — never block the transition.
1043
+ let partialReminder = false;
1044
+ let batonNudge = false;
1045
+ try {
1046
+ const { frontmatter: fmRaw, body } = extractFrontmatter(readFileSync(filePath, 'utf8'));
1047
+ const fm = parseSimpleFrontmatter(fmRaw);
1048
+
1049
+ // `partial` promises a successor tracking the deferred tail. When neither a
1050
+ // --note nor any doc reference exists to point at it, remind.
1051
+ if (newStatus === 'partial' && !note) {
1052
+ const related = fm.related_plans;
1053
+ const hasRelated = Array.isArray(related) ? related.length > 0 : Boolean(asString(related)?.trim());
1054
+ partialReminder = !hasRelated && !/[\w./-]+\.md\b/.test(body);
1055
+ }
1056
+
1057
+ // Baton-on-exit backstop (the one core-loop step with no other mechanical
1058
+ // catch). Fire ONLY on the baton-less in-session release: a plan you were
1059
+ // actively working (old status `in-session`) is being parked at a
1060
+ // non-terminal stop status while a known next pickup (`next_step`) remains,
1061
+ // and no baton was saved this session. `archived` never reaches here (routed
1062
+ // to runArchive above), so this can't fire on a fully-done close. Gating on
1063
+ // `in-session` keeps it off pure triage of never-started plans — where no
1064
+ // session work is owed a handoff — which is how the nag-fatigue risk is
1065
+ // managed. Suppressed for baton's own internal release via `opts.viaBaton`.
1066
+ if (!opts.viaBaton) {
1067
+ const docType = asString(fm.type) ?? 'plan';
1068
+ const oldStatus = asString(fm.status);
1069
+ const nextStep = asString(fm.next_step)?.trim();
1070
+ batonNudge = docType === 'plan'
1071
+ && oldStatus === 'in-session'
1072
+ && newStatus !== 'in-session'
1073
+ && Boolean(nextStep)
1074
+ && !batonSavedThisSession(config);
1075
+ }
1076
+ } catch { /* advisory only */ }
1077
+
1078
+ const statusArgs = [filePath, newStatus];
1079
+ if (noIndex) statusArgs.push('--no-index');
1080
+ if (showFiles) statusArgs.push('--show-files');
1081
+ const releaseUpdate = releasing
1082
+ ? prepareOwnershipRelease(repoPath, config, { sessionId, force })
1083
+ : null;
1084
+ const result = await runStatus(statusArgs, config, {
1085
+ dryRun,
1086
+ suppressDeprecation: true,
1087
+ note,
1088
+ additionalUpdates: [...(opts.additionalUpdates ?? []), ...(releaseUpdate ? [releaseUpdate] : [])],
1089
+ creations: opts.creations,
1090
+ guards: opts.guards,
1091
+ testHooks: opts.testHooks,
1092
+ deferIndex: opts.deferIndex,
1093
+ expectedDestination: opts.expectedDestination,
1094
+ });
1095
+
1096
+ if (!dryRun) {
1097
+ if (partialReminder) {
1098
+ warn('partial usually references the successor plan tracking the tail — add a link to the body, or rerun with --note "tail tracked in <plan>".');
1099
+ }
1100
+ if (batonNudge) {
1101
+ // `@<draft-file>` stays an obvious metavariable: the old `@draft` read as
1102
+ // a runnable path and got copied verbatim into failing batons. If it is
1103
+ // copied anyway, readBodyInput recognizes the <…> form and says so.
1104
+ warn(`wrapping up? leave a baton so the next session picks up cleanly — write your resume notes to a file, then \`runlist baton ${path.basename(filePath, '.md')} @<draft-file>\` saves a resume prompt (no copy-paste into chat).`);
1105
+ }
1106
+ }
1107
+ return result;
1108
+ }
1109
+
1110
+ export function runBulkArchive(argv, config, opts = {}) {
1111
+ const { dryRun } = opts;
1112
+ const json = argv.includes('--json');
1113
+ const noIndex = argv.includes('--no-index') || opts.noIndex;
1114
+ const showFiles = argv.includes('--show-files') || opts.showFiles;
1115
+ const inputs = argv.filter(a => !a.startsWith('-'));
1116
+ if (inputs.length === 0) die('Usage: runlist bulk archive <file1> <file2> ... or <glob>');
1117
+
1118
+ const allFiles = collectDocFiles(config);
1119
+ const matched = [];
1120
+
1121
+ for (const input of inputs) {
1122
+ const filePath = resolveDocPath(input, config);
1123
+ if (filePath) {
1124
+ matched.push(filePath);
1125
+ } else {
1126
+ // Try as glob-style substring match
1127
+ const hits = allFiles.filter(f => f.includes(input) || path.basename(f).includes(input));
1128
+ matched.push(...hits);
1129
+ }
1130
+ }
1131
+
1132
+ const unique = [...new Set(matched)].filter(f => !isArchivedPath(toRepoPath(f, config.repoRoot), config));
1133
+ if (unique.length === 0) die('No matching files found (already-archived files are excluded).');
1134
+ authorizeManagedSweep(unique, config, { kind: 'Bulk archive source' });
1135
+
1136
+ if (!json) {
1137
+ process.stdout.write(`${unique.length} file(s) to archive (independent per-item transactions):\n`);
1138
+ for (const f of unique) process.stdout.write(` ${toRepoPath(f, config.repoRoot)}\n`);
1139
+ }
1140
+
1141
+ if (dryRun) {
1142
+ const result = { operation: 'bulk-archive', atomicity: 'per-item', dryRun: true, items: unique.map(file => ({ path: toRepoPath(file, config.repoRoot), result: 'would-archive' })) };
1143
+ if (json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
1144
+ else process.stdout.write(dim('\n[dry-run] No changes made.\n'));
1145
+ return result;
1146
+ }
1147
+
1148
+ if (!json) process.stdout.write('\n');
1149
+ // Bulk archives always defer index regen to the end — N individual regens
1150
+ // is wasteful and the final state is the same. `--no-index` skips even
1151
+ // the final one.
1152
+ const bulkTouched = [];
1153
+ const items = [];
1154
+ let indexRegenerated = false;
1155
+ let indexError = null;
1156
+ let succeeded = 0;
1157
+ const originalStdoutWrite = process.stdout.write;
1158
+ if (json) process.stdout.write = () => true;
1159
+ try {
1160
+ for (const f of unique) {
1161
+ const relPath = toRepoPath(f, config.repoRoot);
1162
+ try {
1163
+ const result = runArchive([relPath], config, { ...opts, noIndex: true, showFiles: false });
1164
+ if (result?.touched) bulkTouched.push(...result.touched);
1165
+ items.push({ path: relPath, result: 'archived', newPath: result?.newRepoPath ?? null, repositoryFiles: result?.touched ?? [] });
1166
+ } catch (err) {
1167
+ items.push({ path: relPath, result: 'failed', error: err.message });
1168
+ if (!json) warn(`Failed to archive ${relPath}: ${err.message}`);
1169
+ }
1170
+ }
1171
+ succeeded = items.filter(item => item.result === 'archived').length;
1172
+ if (!noIndex && succeeded > 0) {
1173
+ try { indexRegenerated = regenIndex(config, { throwOnError: true, testHooks: opts.testHooks }); }
1174
+ catch (err) { indexError = err.message; }
1175
+ if (config.indexPath && indexRegenerated) process.stdout.write('Index regenerated.\n');
1176
+ } else if (config.indexPath) {
1177
+ process.stdout.write(dim('(index not regenerated — run `runlist index` to refresh)\n'));
1178
+ }
1179
+ if (showFiles) {
1180
+ const all = [...bulkTouched];
1181
+ if (config.indexPath && !noIndex) all.push(config.indexPath);
1182
+ emitFilesFooter(all, config);
1183
+ }
1184
+ } finally {
1185
+ if (json) process.stdout.write = originalStdoutWrite;
1186
+ }
1187
+ const normalize = item => path.isAbsolute(item) ? toRepoPath(item, config.repoRoot) : item.split(path.sep).join('/');
1188
+ for (const item of items) if (item.repositoryFiles) item.repositoryFiles = [...new Set(item.repositoryFiles.map(normalize))];
1189
+ const deferredGeneratedFiles = config.indexPath && succeeded > 0 && (noIndex || indexError) ? [toRepoPath(config.indexPath, config.repoRoot)] : [];
1190
+ const result = {
1191
+ operation: 'bulk-archive', atomicity: 'per-item', items,
1192
+ repositoryFiles: [...new Set(bulkTouched.map(normalize))],
1193
+ generatedFiles: config.indexPath && indexRegenerated ? [toRepoPath(config.indexPath, config.repoRoot)] : [],
1194
+ deferredGeneratedFiles,
1195
+ index: !config.indexPath ? { status: 'not-configured' }
1196
+ : indexRegenerated ? { status: 'generated', path: toRepoPath(config.indexPath, config.repoRoot) }
1197
+ : indexError ? { status: 'failed', path: toRepoPath(config.indexPath, config.repoRoot), error: indexError }
1198
+ : { status: noIndex ? 'deferred' : 'skipped', path: toRepoPath(config.indexPath, config.repoRoot) },
1199
+ };
1200
+ if (json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
1201
+ if (items.some(item => item.result === 'failed') || indexError) process.exitCode = 1;
1202
+ return result;
1203
+ }
1204
+
1205
+ export function runTouch(argv, config, opts = {}) {
1206
+ const { dryRun } = opts;
1207
+ const useGit = argv.includes('--git');
1208
+ const positional = [];
1209
+ for (let i = 0; i < argv.length; i++) {
1210
+ if (argv[i] === '--config') { i++; continue; }
1211
+ if (argv[i].startsWith('-')) continue;
1212
+ positional.push(argv[i]);
1213
+ }
1214
+ const inputs = positional;
1215
+ const input = positional[0];
1216
+
1217
+ // --git mode: bulk-sync frontmatter dates from git history
1218
+ if (useGit) {
1219
+ const allFiles = inputs.length > 0
1220
+ ? [...new Set(inputs.map(item => resolveDocArg(item, config)))]
1221
+ : collectDocFiles(config);
1222
+ authorizeManagedSweep(allFiles, config, { kind: 'Touch --git source' });
1223
+
1224
+ const records = [];
1225
+ for (const filePath of allFiles) {
1226
+ const raw = readFileSync(filePath, 'utf8');
1227
+ const { frontmatter } = extractFrontmatter(raw);
1228
+ if (!frontmatter) continue;
1229
+ const parsed = parseSimpleFrontmatter(frontmatter);
1230
+ if (config.lifecycle.skipStaleFor.has(asString(parsed.status))) continue;
1231
+ records.push({
1232
+ filePath,
1233
+ repoPath: toRepoPath(filePath, config.repoRoot),
1234
+ fmUpdated: asString(parsed.updated),
1235
+ });
1236
+ }
1237
+
1238
+ const prefix = dryRun ? dim('[dry-run] ') : '';
1239
+ let synced = 0;
1240
+ const repoPaths = records.map(item => item.repoPath);
1241
+ const rootPathspecs = inputs.length > 0 ? null : (config.docsRoots || [config.docsRoot])
1242
+ .map(root => toRepoPath(root, config.repoRoot) || '.');
1243
+ const gitMetadata = getGitLastModifiedBatch(config.repoRoot, repoPaths, {
1244
+ ...(rootPathspecs ? { pathspecs: rootPathspecs } : {}),
1245
+ ...opts.gitMetadataOptions,
1246
+ });
1247
+ if (!gitMetadata.complete) {
1248
+ die(`Cannot touch from incomplete Git metadata (${gitMetadata.reason}); no files were changed.`);
1249
+ }
1250
+ const candidates = [];
1251
+ for (const { filePath, repoPath, fmUpdated } of records) {
1252
+ const gitDate = gitMetadata.dates.get(repoPath) ?? null;
1253
+ if (!gitDate) continue;
1254
+ const gitDay = gitDate.slice(0, 10);
1255
+ if (fmUpdated === gitDay) continue;
1256
+ // Only sync if git is newer than frontmatter (compare date strings)
1257
+ if (fmUpdated && fmUpdated >= gitDay) continue;
1258
+ candidates.push({ filePath, repoPath, fmUpdated });
1259
+ }
1260
+
1261
+ const candidatePaths = candidates.map(item => item.repoPath);
1262
+ const substantiveMetadata = getGitLastSubstantiveModifiedBatch(config.repoRoot, candidatePaths, {
1263
+ dates: new Map(candidatePaths.filter(p => gitMetadata.dates.has(p)).map(p => [p, gitMetadata.dates.get(p)])),
1264
+ commits: new Map(candidatePaths.filter(p => gitMetadata.commits?.has(p)).map(p => [p, gitMetadata.commits.get(p)])),
1265
+ history: new Map(candidatePaths.filter(p => gitMetadata.history?.has(p)).map(p => [p, gitMetadata.history.get(p)])),
1266
+ complete: gitMetadata.complete,
1267
+ reason: gitMetadata.reason,
1268
+ }, opts.gitMetadataOptions);
1269
+ if (!substantiveMetadata.complete) {
1270
+ die(`Cannot touch from incomplete Git metadata (${substantiveMetadata.reason}); no files were changed.`);
1271
+ }
1272
+
1273
+ for (const { filePath, repoPath, fmUpdated } of candidates) {
1274
+ const gitDate = substantiveMetadata.dates.get(repoPath) ?? null;
1275
+ if (!gitDate) continue;
1276
+ const gitDay = gitDate.slice(0, 10);
1277
+ if (fmUpdated && fmUpdated >= gitDay) continue;
1278
+
1279
+ if (!dryRun) {
1280
+ const result = mutateFile(filePath, { repoRoot: config.repoRoot, testHooks: opts.testHooks }, current => {
1281
+ const currentFm = parseSimpleFrontmatter(extractFrontmatter(current).frontmatter);
1282
+ if (config.lifecycle.skipStaleFor.has(asString(currentFm.status))) return current;
1283
+ const currentUpdated = asString(currentFm.updated);
1284
+ if (currentUpdated && currentUpdated >= gitDay) return current;
1285
+ return renderLifecycleMutation(current, { updated: gitDay }, null);
1286
+ });
1287
+ if (!result.changed) continue;
1288
+ }
1289
+ process.stdout.write(`${prefix}${green('Synced')}: ${repoPath} (updated → ${gitDay})\n`);
1290
+ synced++;
1291
+ }
1292
+
1293
+ if (synced === 0) {
1294
+ process.stdout.write(green('All frontmatter dates are in sync with git.') + '\n');
1295
+ } else {
1296
+ process.stdout.write(`\n${prefix}${synced} file(s) synced.\n`);
1297
+ }
1298
+ return;
1299
+ }
1300
+
1301
+ if (inputs.length > 1) die('Multiple files require `runlist touch --git <file...>`.');
1302
+ if (!input) { die('Usage: runlist touch <file>\n runlist touch --git Bulk-sync dates from git history'); }
1303
+
1304
+ let filePath = resolveDocArg(input, config);
1305
+ filePath = authorizeManagedSource(filePath, config, { kind: 'Touch source' }).path;
1306
+
1307
+ const today = nowIso();
1308
+
1309
+ if (dryRun) {
1310
+ process.stdout.write(`${dim('[dry-run]')} Would touch: ${toRepoPath(filePath, config.repoRoot)} (updated → ${today})\n`);
1311
+ return;
1312
+ }
1313
+
1314
+ mutateFile(filePath, { repoRoot: config.repoRoot, testHooks: opts.testHooks }, current => renderLifecycleMutation(current, { updated: today }, null));
1315
+ process.stdout.write(`${green('Touched')}: ${toRepoPath(filePath, config.repoRoot)} (updated → ${today})\n`);
1316
+
1317
+ try { config.hooks.onTouch?.({ path: toRepoPath(filePath, config.repoRoot) }, { path: toRepoPath(filePath, config.repoRoot), date: today }); } catch (err) { warn(`Hook 'onTouch' threw: ${err.message}`); }
1318
+ }
1319
+
1320
+ // Rewrite every frontmatter ref token (a `*.md` path in a YAML list item or an
1321
+ // inline scalar, quoted or `>`-prefixed) that points at `oldPath` so it points
1322
+ // at `newPath`. Each token is resolved doc-relative *and* repo-relative and
1323
+ // compared to oldPath by absolute path — mirroring how the body-link branch and
1324
+ // `updateRefsFromMovedFile` resolve refs. This replaces an older substring
1325
+ // rewrite (`fm.split(oldRelPath).join(newRelPath)`) that only knew doc-relative
1326
+ // paths, so it: left repo-relative cross-dir refs (`docs/plans/child.md` from
1327
+ // `docs/rfcs/spec.md`) broken; mangled same-dir repo-relative refs into
1328
+ // `docs/plans/../archived/child.md`; and could corrupt a `grandchild.md` ref
1329
+ // when archiving `child.md` (suffix match). oldPath no longer exists on disk
1330
+ // post-`git mv`, so existsSync-based resolveRefPath can't be used here.
1331
+ function renderMovedFileRefs(raw, oldPath, newPath, config) {
1332
+ const files = collectDocFiles(config);
1333
+ return rewriteDocumentReferences(raw, {
1334
+ sourcePath: oldPath,
1335
+ outputPath: newPath,
1336
+ repoRoot: config.repoRoot,
1337
+ identities: createReferenceIdentitySet(files),
1338
+ referenceFields: configuredReferenceFields(config),
1339
+ oldPath,
1340
+ newPath,
1341
+ rebaseAll: true,
1342
+ });
1343
+ }
1344
+
1345
+ function countRefsToUpdate(oldPath, newPath, config) {
1346
+ const allFiles = collectDocFiles(config);
1347
+ const identities = createReferenceIdentitySet(allFiles);
1348
+ return allFiles.filter(docFile => {
1349
+ if (docFile === oldPath || docFile === newPath) return false;
1350
+ const raw = readFileSync(docFile, 'utf8');
1351
+ return rewriteDocumentReferences(raw, {
1352
+ sourcePath: docFile, repoRoot: config.repoRoot, identities, oldPath, newPath, referenceFields: configuredReferenceFields(config),
1353
+ }) !== raw;
1354
+ }).length;
1355
+ }
1356
+
1357
+ // Append a one-line dated bullet to the file's `## Version History` section.
1358
+ // Newest-first ordering: inserted at the top of the section, right after the
1359
+ // heading + blank-line gap. If the section is missing, this is a silent no-op
1360
+ // — never auto-creates the section (don't surprise users on old plans/docs).
1361
+ export function appendVersionHistory(filePath, entry, { createSection = false } = {}) {
1362
+ let raw;
1363
+ try { raw = normalizeEol(readFileSync(filePath, 'utf8')); } catch { return false; }
1364
+ if (!raw.startsWith('---\n')) return false;
1365
+
1366
+ const endMarker = raw.indexOf('\n---\n', 4);
1367
+ if (endMarker === -1) return false;
1368
+ const frontmatter = raw.slice(4, endMarker);
1369
+ const body = raw.slice(endMarker + 5);
1370
+
1371
+ const bullet = `- **${nowIso()}** ${entry}`;
1372
+
1373
+ const vh = findSection(walkSections(body), 'Version History');
1374
+ if (!vh) {
1375
+ // Bare docs (no scaffold) have no Version History; transitions silently
1376
+ // skip the worklog. But an explicit `--note` must not be dropped — the
1377
+ // caller opts into creating the section at the end of the body.
1378
+ if (!createSection) return false;
1379
+ const trimmed = body.replace(/\n+$/, '');
1380
+ writeFileSync(filePath, `---\n${frontmatter}\n---\n${trimmed}\n\n## Version History\n\n${bullet}\n`, 'utf8');
1381
+ return true;
1382
+ }
1383
+ const lines = body.split('\n');
1384
+
1385
+ // vh.lineStart is 1-indexed for the heading line. The line immediately
1386
+ // after the heading is at 0-indexed `vh.lineStart`. Skip leading blanks
1387
+ // to find the first content line (existing bullet or next heading).
1388
+ let insertAt = vh.lineStart;
1389
+ while (insertAt < lines.length && lines[insertAt].trim() === '') {
1390
+ insertAt++;
1391
+ }
1392
+
1393
+ // If we're inserting just before another heading (next H2), pad with a
1394
+ // blank line after our bullet for readability. Otherwise just splice in.
1395
+ const atSectionBoundary = insertAt >= lines.length || lines[insertAt].startsWith('#');
1396
+ if (atSectionBoundary) {
1397
+ lines.splice(insertAt, 0, bullet, '');
1398
+ } else {
1399
+ lines.splice(insertAt, 0, bullet);
1400
+ }
1401
+
1402
+ writeFileSync(filePath, `---\n${frontmatter}\n---\n${lines.join('\n')}`, 'utf8');
1403
+ return true;
1404
+ }
1405
+
1406
+ export function updateFrontmatter(filePath, updates) {
1407
+ const raw = normalizeEol(readFileSync(filePath, 'utf8'));
1408
+ // Name the remedy in the error: this is where every status verb lands when a
1409
+ // doc was created outside runlist, and "no frontmatter block" alone left
1410
+ // sessions retrying other verbs instead of fixing the doc.
1411
+ if (!raw.startsWith('---\n')) throw new Error(`${filePath} has no frontmatter block. Retrofit it first: runlist bulk-tag ${filePath} --type <type> --status <status>`);
1412
+
1413
+ const endMarker = raw.indexOf('\n---\n', 4);
1414
+ if (endMarker === -1) throw new Error(`${filePath} has unclosed frontmatter block.`);
1415
+
1416
+ let frontmatter = raw.slice(4, endMarker);
1417
+ const body = raw.slice(endMarker + 5);
1418
+
1419
+ for (const [key, value] of Object.entries(updates)) {
1420
+ const regex = new RegExp(`^${escapeRegex(key)}:.*$`, 'm');
1421
+ if (regex.test(frontmatter)) {
1422
+ frontmatter = frontmatter.replace(regex, `${key}: ${value}`);
1423
+ } else {
1424
+ frontmatter += `\n${key}: ${value}`;
1425
+ }
1426
+ }
1427
+
1428
+ writeFileSync(filePath, `---\n${frontmatter}\n---\n${body}`, 'utf8');
1429
+ }
1430
+
1431
+ // Prepend a fresh `---\n…\n---\n` block to a file that has no frontmatter yet.
1432
+ // Sibling to updateFrontmatter() for the bulk-tag flow, which needs to tag
1433
+ // pre-existing markdown files that never had a frontmatter block. Delegates
1434
+ // to updateFrontmatter when a block already exists so callers can hand it any
1435
+ // file without pre-checking — the result is the same shape either way.
1436
+ export function writeFrontmatter(filePath, fields) {
1437
+ const raw = normalizeEol(readFileSync(filePath, 'utf8'));
1438
+ if (raw.startsWith('---\n')) {
1439
+ updateFrontmatter(filePath, fields);
1440
+ return;
1441
+ }
1442
+ const lines = Object.entries(fields).map(([k, v]) => `${k}: ${v}`).join('\n');
1443
+ writeFileSync(filePath, `---\n${lines}\n---\n${raw}`, 'utf8');
1444
+ }