runlist 0.0.0 → 0.94.2
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.
- package/LICENSE +21 -0
- package/README.md +1112 -6
- package/assets/app/app.css +283 -0
- package/assets/app/app.mjs +381 -0
- package/assets/app/block-editor.mjs +182 -0
- package/assets/app/block-model.mjs +52 -0
- package/assets/app/document-create.mjs +86 -0
- package/assets/app/document-lifecycle.mjs +36 -0
- package/assets/app/document-stages.mjs +22 -0
- package/assets/app/document-views.mjs +33 -0
- package/assets/app/document-yardstick.mjs +258 -0
- package/assets/app/editor-navigation.mjs +51 -0
- package/assets/app/filing-navigation.mjs +138 -0
- package/assets/app/git-changes.mjs +80 -0
- package/assets/app/git-commit.mjs +46 -0
- package/assets/app/index.html +70 -0
- package/assets/app/library-navigation.mjs +93 -0
- package/assets/app/outline.mjs +18 -0
- package/assets/app/quick-navigation.mjs +52 -0
- package/assets/app/record-navigation.mjs +78 -0
- package/assets/app/recovery-center.mjs +53 -0
- package/assets/app/semantic-search.mjs +14 -0
- package/assets/app/settings.mjs +15 -0
- package/assets/app/shared.mjs +104 -0
- package/assets/app/stage-source.mjs +75 -0
- package/assets/app/template-editor.mjs +47 -0
- package/assets/app/theme.mjs +25 -0
- package/assets/app/transport.mjs +43 -0
- package/assets/contracts/source-editing-v1.md +232 -0
- package/assets/opencode/plugin.js +162 -0
- package/assets/schemas/runlist-record-v1.schema.json +912 -0
- package/bin/dotmd.mjs +5 -0
- package/bin/runlist.mjs +2714 -0
- package/package.json +53 -6
- package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
- package/plugins/runlist-codex/bin/runlist-hook +86 -0
- package/plugins/runlist-codex/hooks/hooks.json +16 -0
- package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
- package/runlist.config.example.mjs +370 -0
- package/scripts/postinstall.mjs +56 -0
- package/src/agent-context.mjs +201 -0
- package/src/ai.mjs +30 -0
- package/src/app-create.mjs +169 -0
- package/src/app-filing.mjs +139 -0
- package/src/app-git-commits.mjs +48 -0
- package/src/app-git.mjs +180 -0
- package/src/app-library.mjs +152 -0
- package/src/app-lifecycle-worker.mjs +12 -0
- package/src/app-lifecycle.mjs +79 -0
- package/src/app-records.mjs +134 -0
- package/src/app-search.mjs +80 -0
- package/src/app-semantic-search.mjs +105 -0
- package/src/app-service.mjs +208 -0
- package/src/app-yardstick.mjs +269 -0
- package/src/app.mjs +102 -0
- package/src/atomic-mutation.mjs +1736 -0
- package/src/baton.mjs +435 -0
- package/src/body-link.mjs +48 -0
- package/src/bulk-tag.mjs +174 -0
- package/src/check-collapse.mjs +75 -0
- package/src/claude-commands.mjs +75 -0
- package/src/cli-editor.mjs +8 -0
- package/src/code-refs.mjs +545 -0
- package/src/codex-integration.mjs +81 -0
- package/src/color.mjs +14 -0
- package/src/commands.mjs +374 -0
- package/src/completions.mjs +93 -0
- package/src/config-edit.mjs +606 -0
- package/src/config.mjs +705 -0
- package/src/decision-action.mjs +58 -0
- package/src/decision.mjs +220 -0
- package/src/decisions.mjs +787 -0
- package/src/deps.mjs +317 -0
- package/src/desktop-install.mjs +158 -0
- package/src/desktop-runtime.mjs +4 -0
- package/src/desktop.mjs +25 -0
- package/src/diff.mjs +101 -0
- package/src/doctor.mjs +591 -0
- package/src/durable-rename.mjs +55 -0
- package/src/errors-read.mjs +44 -0
- package/src/export.mjs +496 -0
- package/src/extractors.mjs +101 -0
- package/src/filing.mjs +255 -0
- package/src/fix-membership.mjs +134 -0
- package/src/fix-refs.mjs +174 -0
- package/src/flags.mjs +318 -0
- package/src/frontmatter-fix.mjs +195 -0
- package/src/frontmatter.mjs +252 -0
- package/src/git-commit-filters.mjs +32 -0
- package/src/git-commit-protocol.mjs +198 -0
- package/src/git-commit-signing.mjs +75 -0
- package/src/git-commit-store.mjs +26 -0
- package/src/git-commit-support.mjs +16 -0
- package/src/git-commit-worker.mjs +32 -0
- package/src/git.mjs +909 -0
- package/src/glossary-check.mjs +35 -0
- package/src/glossary.mjs +243 -0
- package/src/graph.mjs +298 -0
- package/src/guard.mjs +488 -0
- package/src/health.mjs +212 -0
- package/src/hints.mjs +129 -0
- package/src/host-integration.mjs +339 -0
- package/src/hub-membership.mjs +284 -0
- package/src/hub.mjs +296 -0
- package/src/hud.mjs +436 -0
- package/src/index-file.mjs +163 -0
- package/src/index.mjs +478 -0
- package/src/init.mjs +512 -0
- package/src/install.mjs +222 -0
- package/src/journal-read.mjs +116 -0
- package/src/journal.mjs +472 -0
- package/src/lifecycle.mjs +1444 -0
- package/src/lint.mjs +386 -0
- package/src/managed-path.mjs +192 -0
- package/src/markdown-code-spans.mjs +58 -0
- package/src/migrate-prompts.mjs +171 -0
- package/src/migrate-template.mjs +170 -0
- package/src/migrate.mjs +90 -0
- package/src/misuse-read.mjs +61 -0
- package/src/model-request.mjs +33 -0
- package/src/model.mjs +523 -0
- package/src/modules.mjs +355 -0
- package/src/naming.mjs +55 -0
- package/src/native-action.mjs +59 -0
- package/src/native-record.mjs +244 -0
- package/src/new.mjs +1321 -0
- package/src/output-identity.mjs +111 -0
- package/src/parent-plan.mjs +58 -0
- package/src/parse-cache.mjs +99 -0
- package/src/path-containment.mjs +15 -0
- package/src/pickup-card.mjs +293 -0
- package/src/pickup.mjs +671 -0
- package/src/prompt.mjs +25 -0
- package/src/prompts.mjs +585 -0
- package/src/public-inspection.mjs +19 -0
- package/src/query.mjs +977 -0
- package/src/record-action.mjs +11 -0
- package/src/record-create.mjs +82 -0
- package/src/reference-planner.mjs +388 -0
- package/src/reference-values.mjs +11 -0
- package/src/rename.mjs +107 -0
- package/src/render.mjs +678 -0
- package/src/roadmap.mjs +184 -0
- package/src/runlist.mjs +944 -0
- package/src/section.mjs +225 -0
- package/src/semantic-search-client.mjs +58 -0
- package/src/ship.mjs +166 -0
- package/src/show.mjs +101 -0
- package/src/skill-drift.mjs +98 -0
- package/src/source-editor.mjs +491 -0
- package/src/stages.mjs +73 -0
- package/src/state-migration.mjs +123 -0
- package/src/stats.mjs +200 -0
- package/src/status-metadata.mjs +87 -0
- package/src/statuses.mjs +721 -0
- package/src/summary.mjs +74 -0
- package/src/surfaces.mjs +28 -0
- package/src/sync-status.mjs +316 -0
- package/src/template-store.mjs +62 -0
- package/src/update.mjs +338 -0
- package/src/use.mjs +58 -0
- package/src/util.mjs +337 -0
- package/src/validate.mjs +795 -0
- package/src/watch.mjs +56 -0
- package/src/xref.mjs +406 -0
- package/src/yardstick-command.mjs +178 -0
- 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
|
+
}
|