dotmd-cli 0.68.0 → 0.70.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +144 -964
- package/bin/dotmd.mjs +241 -197
- package/dotmd.config.example.mjs +5 -8
- package/package.json +6 -10
- package/src/agent-context.mjs +132 -0
- package/src/atomic-mutation.mjs +1505 -0
- package/src/baton.mjs +109 -87
- package/src/bulk-tag.mjs +7 -7
- package/src/commands.mjs +326 -12
- package/src/completions.mjs +38 -98
- package/src/config.mjs +18 -3
- package/src/diff.mjs +7 -3
- package/src/doctor.mjs +12 -5
- package/src/export.mjs +154 -25
- package/src/fix-refs.mjs +2 -0
- package/src/frontmatter-fix.mjs +2 -0
- package/src/frontmatter.mjs +3 -2
- package/src/git.mjs +531 -14
- package/src/graph.mjs +53 -25
- package/src/guard.mjs +163 -60
- package/src/hud.mjs +65 -76
- package/src/index-file.mjs +28 -16
- package/src/index.mjs +17 -12
- package/src/init.mjs +1 -1
- package/src/journal.mjs +145 -12
- package/src/lifecycle.mjs +554 -282
- package/src/lint.mjs +57 -9
- package/src/managed-path.mjs +192 -0
- package/src/migrate-prompts.mjs +2 -0
- package/src/migrate-template.mjs +2 -0
- package/src/migrate.mjs +7 -1
- package/src/new.mjs +135 -54
- package/src/output-identity.mjs +106 -0
- package/src/pickup-card.mjs +24 -10
- package/src/pickup.mjs +457 -0
- package/src/prompts.mjs +138 -32
- package/src/query.mjs +22 -10
- package/src/reference-planner.mjs +292 -0
- package/src/rename.mjs +65 -73
- package/src/render.mjs +17 -8
- package/src/runlist.mjs +109 -71
- package/src/section.mjs +2 -1
- package/src/ship.mjs +39 -20
- package/src/stats.mjs +1 -1
- package/src/status-metadata.mjs +87 -0
- package/src/statuses.mjs +11 -26
- package/src/summary.mjs +14 -3
- package/src/update.mjs +38 -10
- package/src/use.mjs +4 -1
- package/src/util.mjs +1 -0
- package/src/validate.mjs +14 -6
- package/src/watch.mjs +6 -1
- package/src/notion.mjs +0 -528
package/src/lifecycle.mjs
CHANGED
|
@@ -1,19 +1,142 @@
|
|
|
1
|
-
import { existsSync,
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { extractFrontmatter, parseSimpleFrontmatter, normalizeEol } from './frontmatter.mjs';
|
|
4
|
-
import { asString, toRepoPath, die, warn, resolveDocPath,
|
|
4
|
+
import { asString, toRepoPath, die, warn, resolveDocPath, escapeRegex, nowIso, suggestCandidates, emitFilesFooter, isArchivedPath, currentSessionId } from './util.mjs';
|
|
5
5
|
import { readJournalEntries } from './journal.mjs';
|
|
6
|
-
import {
|
|
6
|
+
import { captureGitIndexGeneration, getGitLastModifiedBatch, isTracked } from './git.mjs';
|
|
7
7
|
import { buildIndex, collectDocFiles, resolveDocArg } from './index.mjs';
|
|
8
|
-
import {
|
|
8
|
+
import { writeRenderedIndex } from './index-file.mjs';
|
|
9
9
|
import { green, dim } from './color.mjs';
|
|
10
10
|
import { isInteractive, promptChoice } from './prompt.mjs';
|
|
11
11
|
import { buildCard, renderCard } from './pickup-card.mjs';
|
|
12
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 {
|
|
17
|
+
assertPlanMutationAuthorized,
|
|
18
|
+
assertHookDeliveryTakeoverSafe,
|
|
19
|
+
abandonClaimHookDelivery,
|
|
20
|
+
authoritativeSessionId,
|
|
21
|
+
availableSessionId,
|
|
22
|
+
beginClaimHookDelivery,
|
|
23
|
+
classifyPlanPickup,
|
|
24
|
+
commitPlanClaim,
|
|
25
|
+
finishClaimHookDelivery,
|
|
26
|
+
listOwnedPlans,
|
|
27
|
+
pickupFactsForDoc,
|
|
28
|
+
prepareOwnershipRelease,
|
|
29
|
+
readPlanOwnership,
|
|
30
|
+
skipClaimHookDelivery,
|
|
31
|
+
updateOwnershipOperation,
|
|
32
|
+
validatedClaimOperation,
|
|
33
|
+
} from './pickup.mjs';
|
|
34
|
+
|
|
35
|
+
export function renderLifecycleMutation(raw, updates, historyEntry, { createSection = false, bodyTransform = null } = {}) {
|
|
36
|
+
raw = normalizeEol(raw);
|
|
37
|
+
if (!raw.startsWith('---\n')) throw new Error('Document has no frontmatter block. Retrofit it with `dotmd bulk-tag` first.');
|
|
38
|
+
const endMarker = raw.indexOf('\n---\n', 4);
|
|
39
|
+
if (endMarker === -1) throw new Error('Document has an unclosed frontmatter block.');
|
|
40
|
+
let frontmatter = raw.slice(4, endMarker);
|
|
41
|
+
let body = raw.slice(endMarker + 5);
|
|
42
|
+
if (bodyTransform) body = bodyTransform(body);
|
|
43
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
44
|
+
const regex = new RegExp(`^${escapeRegex(key)}:.*$`, 'm');
|
|
45
|
+
frontmatter = regex.test(frontmatter)
|
|
46
|
+
? frontmatter.replace(regex, `${key}: ${value}`)
|
|
47
|
+
: `${frontmatter}\n${key}: ${value}`;
|
|
48
|
+
}
|
|
49
|
+
if (historyEntry) {
|
|
50
|
+
const bullet = `- **${updates.updated ?? nowIso()}** ${historyEntry}`;
|
|
51
|
+
const vh = findSection(walkSections(body), 'Version History');
|
|
52
|
+
if (vh) {
|
|
53
|
+
const lines = body.split('\n');
|
|
54
|
+
let insertAt = vh.lineStart;
|
|
55
|
+
while (insertAt < lines.length && lines[insertAt].trim() === '') insertAt++;
|
|
56
|
+
lines.splice(insertAt, 0, bullet, ...(insertAt >= lines.length || lines[insertAt]?.startsWith('#') ? [''] : []));
|
|
57
|
+
body = lines.join('\n');
|
|
58
|
+
} else if (createSection) {
|
|
59
|
+
body = `${body.replace(/\n+$/, '')}\n\n## Version History\n\n${bullet}\n`;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return `---\n${frontmatter}\n---\n${body}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function commitLifecycleMutation(filePath, targetPath, config, updates, historyForOldStatus, options = {}) {
|
|
66
|
+
const render = sourceContent => {
|
|
67
|
+
const currentFm = parseSimpleFrontmatter(extractFrontmatter(sourceContent).frontmatter);
|
|
68
|
+
const currentOldStatus = asString(currentFm.status) ?? 'unknown';
|
|
69
|
+
let content = renderLifecycleMutation(sourceContent, updates, historyForOldStatus(currentOldStatus), options);
|
|
70
|
+
const beforeSelfRefs = content;
|
|
71
|
+
if (targetPath) content = renderMovedFileRefs(content, filePath, targetPath, config);
|
|
72
|
+
return {
|
|
73
|
+
oldStatus: currentOldStatus,
|
|
74
|
+
content,
|
|
75
|
+
selfRefsFixed: content !== beforeSelfRefs,
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
if (targetPath) {
|
|
79
|
+
let result;
|
|
80
|
+
const tracked = isTracked(filePath, config.repoRoot);
|
|
81
|
+
const gitIndex = tracked ? captureGitIndexGeneration(config.repoRoot) : null;
|
|
82
|
+
const companionPaths = new Set((options.additionalUpdates ?? []).map(item => path.resolve(item.path)));
|
|
83
|
+
const allFiles = collectDocFiles(config).filter(candidate => candidate !== filePath && candidate !== targetPath && !companionPaths.has(path.resolve(candidate)));
|
|
84
|
+
authorizeManagedSweep(allFiles, config, { kind: 'Reference rewrite source' });
|
|
85
|
+
const identities = createReferenceIdentitySet([filePath, ...allFiles]);
|
|
86
|
+
const referenceFields = configuredReferenceFields(config);
|
|
87
|
+
const moveResult = moveFileAtomic(filePath, targetPath, sourceContent => {
|
|
88
|
+
result = render(sourceContent);
|
|
89
|
+
return result.content;
|
|
90
|
+
}, {
|
|
91
|
+
repoRoot: config.repoRoot,
|
|
92
|
+
config,
|
|
93
|
+
updates: [...allFiles.map(docFile => ({
|
|
94
|
+
path: docFile,
|
|
95
|
+
render: raw => rewriteDocumentReferences(raw, {
|
|
96
|
+
sourcePath: docFile, repoRoot: config.repoRoot, identities, oldPath: filePath, newPath: targetPath, referenceFields,
|
|
97
|
+
}),
|
|
98
|
+
})), ...(options.additionalUpdates ?? []).map(item => ({
|
|
99
|
+
...item,
|
|
100
|
+
content: item.content === undefined ? undefined : rewriteDocumentReferences(item.content, {
|
|
101
|
+
sourcePath: item.path, repoRoot: config.repoRoot, identities, oldPath: filePath, newPath: targetPath, referenceFields,
|
|
102
|
+
}),
|
|
103
|
+
}))],
|
|
104
|
+
creations: options.creations ?? [],
|
|
105
|
+
gitMove: tracked,
|
|
106
|
+
gitIndex,
|
|
107
|
+
operation: 'lifecycle-move',
|
|
108
|
+
sessionId: availableSessionId(),
|
|
109
|
+
testHooks: options.testHooks,
|
|
110
|
+
});
|
|
111
|
+
return { ...result, sourceContent: moveResult.source.content, updatedPaths: moveResult.updatedPaths };
|
|
112
|
+
}
|
|
113
|
+
if ((options.additionalUpdates?.length ?? 0) > 0 || (options.creations?.length ?? 0) > 0) {
|
|
114
|
+
const sourceContent = readFileSync(filePath, 'utf8');
|
|
115
|
+
const result = render(sourceContent);
|
|
116
|
+
mutateFileSet({
|
|
117
|
+
updates: [{ path: filePath, expectedContent: sourceContent, content: result.content }, ...(options.additionalUpdates ?? [])],
|
|
118
|
+
creations: options.creations ?? [],
|
|
119
|
+
}, { repoRoot: config.repoRoot, testHooks: options.testHooks });
|
|
120
|
+
return { ...result, sourceContent, updatedPaths: [] };
|
|
121
|
+
}
|
|
122
|
+
return withPathLocks([filePath], { repoRoot: config.repoRoot }, () => {
|
|
123
|
+
const sourceSnapshot = snapshotFile(filePath);
|
|
124
|
+
const result = render(sourceSnapshot.content);
|
|
125
|
+
replaceSnapshot(sourceSnapshot, result.content, { repoRoot: config.repoRoot, locked: true });
|
|
126
|
+
return { ...result, sourceContent: sourceSnapshot.content, updatedPaths: [] };
|
|
127
|
+
});
|
|
128
|
+
}
|
|
13
129
|
|
|
14
|
-
function
|
|
15
|
-
|
|
16
|
-
|
|
130
|
+
export function updateFrontmatterAtomic(filePath, updates, config, options = {}) {
|
|
131
|
+
return mutateFile(filePath, { repoRoot: config.repoRoot }, raw => {
|
|
132
|
+
if (options.expected) {
|
|
133
|
+
const current = parseSimpleFrontmatter(extractFrontmatter(raw).frontmatter);
|
|
134
|
+
for (const [key, value] of Object.entries(options.expected)) {
|
|
135
|
+
if (asString(current[key]) !== value) throw new Error(`${key} changed while the mutation was being prepared.`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return renderLifecycleMutation(raw, updates, null);
|
|
139
|
+
});
|
|
17
140
|
}
|
|
18
141
|
|
|
19
142
|
function defaultTypeDir(docType, config) {
|
|
@@ -49,20 +172,109 @@ function archiveBaseFor(filePath, fileRoot, docType, config) {
|
|
|
49
172
|
// change that affects what would render leaves the index stale. Wrapped
|
|
50
173
|
// in try/catch — a regen failure shouldn't undo the successful mutation,
|
|
51
174
|
// only warn with the recovery command.
|
|
52
|
-
export function regenIndex(config) {
|
|
53
|
-
if (!config.indexPath) return;
|
|
175
|
+
export function regenIndex(config, options = {}) {
|
|
176
|
+
if (!config.indexPath) return false;
|
|
54
177
|
try {
|
|
55
178
|
// Fast path: skip validation/git-staleness/ref-checking — the rendered
|
|
56
179
|
// index file only consumes status/title/snapshot/etc. Validation runs on
|
|
57
180
|
// explicit `dotmd check` / `dotmd index`. This keeps lifecycle commands
|
|
58
181
|
// snappy on repos with huge git history or heavy `validate` hooks.
|
|
59
|
-
|
|
60
|
-
|
|
182
|
+
options.testHooks?.beforeClaimIndex?.();
|
|
183
|
+
writeRenderedIndex(() => buildIndex(config, { fast: true }), config, { testHooks: options.testHooks });
|
|
184
|
+
return true;
|
|
61
185
|
} catch (err) {
|
|
186
|
+
if (options.throwOnError) throw err;
|
|
62
187
|
warn(`Could not regenerate index (run \`dotmd index\`): ${err.message}`);
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function reconcileClaimOperation(repoPath, config, opts = {}) {
|
|
193
|
+
let current = validatedClaimOperation(repoPath, config);
|
|
194
|
+
if (!current) return { indexRegenerated: false, ownershipChanged: false, hook: 'none', pending: false };
|
|
195
|
+
let indexRegenerated = false;
|
|
196
|
+
let ownershipChanged = false;
|
|
197
|
+
const binding = current.binding;
|
|
198
|
+
if (current.operation.index === 'pending') {
|
|
199
|
+
regenIndex(config, { throwOnError: true, testHooks: opts.testHooks });
|
|
200
|
+
indexRegenerated = true;
|
|
201
|
+
updateOwnershipOperation(repoPath, config, binding, op => { op.index = 'done'; });
|
|
202
|
+
ownershipChanged = true;
|
|
203
|
+
current = validatedClaimOperation(repoPath, config, binding);
|
|
204
|
+
if (!current) throw new Error(`Claim completion was superseded for ${repoPath}.`);
|
|
205
|
+
}
|
|
206
|
+
if (['pending', 'delivering'].includes(current.operation.hook)) {
|
|
207
|
+
if (current.operation.hook === 'pending' && !config.hooks.onPickup) {
|
|
208
|
+
updateOwnershipOperation(repoPath, config, binding, op => {
|
|
209
|
+
op.hook = 'skipped';
|
|
210
|
+
delete op.hookDeliveryToken;
|
|
211
|
+
delete op.hookDeliveryStartedAt;
|
|
212
|
+
delete op.hookDeliveryOwner;
|
|
213
|
+
});
|
|
214
|
+
return { indexRegenerated, ownershipChanged: true, hook: 'skipped', pending: false };
|
|
215
|
+
}
|
|
216
|
+
// Durable outbox contract: retries reuse operationId until the hook returns.
|
|
217
|
+
// Hooks that perform external side effects must deduplicate by operationId.
|
|
218
|
+
// No local protocol can distinguish a crash after the external side effect
|
|
219
|
+
// from a crash before the completion marker is persisted.
|
|
220
|
+
opts.testHooks?.beforeClaimHookInvoke?.({ repoPath, binding });
|
|
221
|
+
const lease = beginClaimHookDelivery(repoPath, config, binding, {
|
|
222
|
+
now: opts.hookLeaseNow,
|
|
223
|
+
leaseMs: opts.hookLeaseMs,
|
|
224
|
+
ownerLiveness: opts.hookOwnerLiveness,
|
|
225
|
+
});
|
|
226
|
+
if (!lease) return { indexRegenerated, ownershipChanged, hook: current.operation.hook, pending: true };
|
|
227
|
+
if (lease.busy) throw new Error(`Claim hook delivery is already in progress for ${repoPath}.`);
|
|
228
|
+
if (!config.hooks.onPickup) {
|
|
229
|
+
skipClaimHookDelivery(repoPath, config, binding, lease.token);
|
|
230
|
+
return { indexRegenerated, ownershipChanged: true, hook: 'skipped', pending: false };
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
const operation = lease.operation;
|
|
234
|
+
const event = { path: repoPath, oldStatus: operation.oldStatus, newStatus: 'in-session', operationId: operation.id };
|
|
235
|
+
config.hooks.onPickup(event);
|
|
236
|
+
} catch (err) {
|
|
237
|
+
abandonClaimHookDelivery(repoPath, config, binding, lease.token);
|
|
238
|
+
throw err;
|
|
239
|
+
}
|
|
240
|
+
finishClaimHookDelivery(repoPath, config, binding, lease.token);
|
|
241
|
+
ownershipChanged = true;
|
|
242
|
+
}
|
|
243
|
+
const after = validatedClaimOperation(repoPath, config, binding);
|
|
244
|
+
return { indexRegenerated, ownershipChanged, hook: after?.operation?.hook ?? 'done', pending: Boolean(after && (after.operation.index === 'pending' || ['pending', 'delivering'].includes(after.operation.hook))) };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function completePlanClaim(repoPath, config, opts = {}) {
|
|
248
|
+
const current = validatedClaimOperation(repoPath, config);
|
|
249
|
+
let skipped = false;
|
|
250
|
+
if (opts.noIndex && current) {
|
|
251
|
+
updateOwnershipOperation(repoPath, config, current.binding, op => { op.index = 'skipped'; });
|
|
252
|
+
skipped = true;
|
|
253
|
+
}
|
|
254
|
+
const result = reconcileClaimOperation(repoPath, config, opts);
|
|
255
|
+
if (skipped) result.ownershipChanged = true;
|
|
256
|
+
return result;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function ensurePlanCompletionBeforeRelease(repoPath, config, opts = {}) {
|
|
260
|
+
const before = validatedClaimOperation(repoPath, config);
|
|
261
|
+
if (!before) return;
|
|
262
|
+
reconcileClaimOperation(repoPath, config, opts);
|
|
263
|
+
const after = validatedClaimOperation(repoPath, config, before.binding);
|
|
264
|
+
if (after && (after.operation.index === 'pending' || ['pending', 'delivering'].includes(after.operation.hook))) {
|
|
265
|
+
throw new Error(`Cannot release ${repoPath}; claim completion is still pending.`);
|
|
63
266
|
}
|
|
64
267
|
}
|
|
65
268
|
|
|
269
|
+
export function planHasPendingCompletion(repoPath, config) {
|
|
270
|
+
const current = validatedClaimOperation(repoPath, config);
|
|
271
|
+
return Boolean(current && (current.operation.index === 'pending' || ['pending', 'delivering'].includes(current.operation.hook)));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function pickupCandidates(index, config, sessionId) {
|
|
275
|
+
return index.docs.filter(doc => pickupFactsForDoc(doc, config, { sessionId }).pickupable);
|
|
276
|
+
}
|
|
277
|
+
|
|
66
278
|
// Pick an archive destination that won't clobber an existing record. If
|
|
67
279
|
// `<dir>/<basename>` is free, returns it unchanged; otherwise appends a
|
|
68
280
|
// numeric suffix (`-2`, `-3`, …) so the slug → path mapping stays readable
|
|
@@ -145,14 +357,16 @@ export async function runStatus(argv, config, opts = {}) {
|
|
|
145
357
|
|
|
146
358
|
if (!input) { die('Usage: dotmd status <file> <new-status>'); }
|
|
147
359
|
|
|
148
|
-
|
|
360
|
+
let filePath = resolveDocArg(input, config);
|
|
361
|
+
const sourceAuthorization = authorizeManagedSource(filePath, config, { kind: 'Status source' });
|
|
362
|
+
filePath = sourceAuthorization.path;
|
|
149
363
|
|
|
150
364
|
// Determine type-specific or root-specific valid statuses
|
|
151
365
|
const raw = readFileSync(filePath, 'utf8');
|
|
152
366
|
const { frontmatter: fmRaw } = extractFrontmatter(raw);
|
|
153
367
|
const parsedFm = parseSimpleFrontmatter(fmRaw);
|
|
154
368
|
const docType = asString(parsedFm.type) ?? null;
|
|
155
|
-
const fileRoot =
|
|
369
|
+
const fileRoot = sourceAuthorization.root.lexicalPath;
|
|
156
370
|
const rootLabel = path.relative(config.repoRoot, fileRoot).split(path.sep).join('/');
|
|
157
371
|
|
|
158
372
|
// Build effective valid status set: type > root > global
|
|
@@ -182,9 +396,24 @@ export async function runStatus(argv, config, opts = {}) {
|
|
|
182
396
|
die(`Invalid status: ${newStatus}\nValid: ${[...effectiveValid].join(', ')}${hint}`);
|
|
183
397
|
}
|
|
184
398
|
|
|
399
|
+
if (!opts.suppressDeprecation) {
|
|
400
|
+
const delegated = [newStatus, input];
|
|
401
|
+
if (note) delegated.push('--note', note);
|
|
402
|
+
if (noIndex) delegated.push('--no-index');
|
|
403
|
+
if (showFiles) delegated.push('--show-files');
|
|
404
|
+
if (argv.includes('--force')) delegated.push('--force');
|
|
405
|
+
return runSet(delegated, config, { ...opts, suppressDeprecation: true });
|
|
406
|
+
}
|
|
407
|
+
|
|
185
408
|
const oldStatus = asString(parsedFm.status);
|
|
186
409
|
|
|
187
410
|
if (oldStatus === newStatus) {
|
|
411
|
+
if (!dryRun && (opts.additionalUpdates?.length || opts.creations?.length)) {
|
|
412
|
+
mutateFileSet({ updates: opts.additionalUpdates ?? [], creations: opts.creations ?? [] }, {
|
|
413
|
+
repoRoot: config.repoRoot,
|
|
414
|
+
testHooks: opts.testHooks,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
188
417
|
process.stdout.write(`${toRepoPath(filePath, config.repoRoot)}: already ${newStatus}, no changes made.\n`);
|
|
189
418
|
return;
|
|
190
419
|
}
|
|
@@ -212,30 +441,42 @@ export async function runStatus(argv, config, opts = {}) {
|
|
|
212
441
|
const currentBucket = relSegments.length > 1 ? relSegments[0] : null;
|
|
213
442
|
const isFiling = !isArchiving && !isUnarchiving && newFiledDir && currentBucket !== newFiledDir;
|
|
214
443
|
const isUnfiling = !isArchiving && !isUnarchiving && !newFiledDir && oldFiledDir && currentBucket === oldFiledDir;
|
|
215
|
-
|
|
444
|
+
const authorizeTarget = targetPath => authorizeManagedDestination(targetPath, config, {
|
|
445
|
+
root: sourceAuthorization.root,
|
|
446
|
+
kind: 'Status move destination',
|
|
447
|
+
}).path;
|
|
448
|
+
let targetDir = null;
|
|
449
|
+
let targetPath = null;
|
|
450
|
+
if (isArchiving) {
|
|
451
|
+
targetDir = archiveDir;
|
|
452
|
+
targetPath = authorizeTarget(uniqueArchiveTarget(targetDir, path.basename(filePath)));
|
|
453
|
+
} else if (isUnarchiving) {
|
|
454
|
+
targetPath = authorizeTarget(path.join(archiveBase, path.basename(filePath)));
|
|
455
|
+
} else if (isFiling) {
|
|
456
|
+
targetDir = path.join(filingRoot, newFiledDir);
|
|
457
|
+
targetPath = authorizeTarget(path.join(targetDir, path.basename(filePath)));
|
|
458
|
+
} else if (isUnfiling) {
|
|
459
|
+
targetPath = authorizeTarget(path.join(filingRoot, path.basename(filePath)));
|
|
460
|
+
}
|
|
461
|
+
if (targetPath && !isArchiving && existsSync(targetPath)) {
|
|
462
|
+
die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`);
|
|
463
|
+
}
|
|
464
|
+
let finalPath = targetPath ?? filePath;
|
|
216
465
|
|
|
217
466
|
if (dryRun) {
|
|
218
467
|
const prefix = dim('[dry-run]');
|
|
219
468
|
process.stdout.write(`${prefix} Would update frontmatter: status: ${oldStatus ?? 'unknown'} → ${newStatus}, updated: ${today}\n`);
|
|
220
469
|
if (isArchiving) {
|
|
221
|
-
const targetPath = uniqueArchiveTarget(archiveDir, path.basename(filePath));
|
|
222
470
|
process.stdout.write(`${prefix} Would move: ${toRepoPath(filePath, config.repoRoot)} → ${toRepoPath(targetPath, config.repoRoot)}\n`);
|
|
223
|
-
finalPath = targetPath;
|
|
224
471
|
}
|
|
225
472
|
if (isUnarchiving) {
|
|
226
|
-
const targetPath = path.join(archiveBase, path.basename(filePath));
|
|
227
473
|
process.stdout.write(`${prefix} Would move: ${toRepoPath(filePath, config.repoRoot)} → ${toRepoPath(targetPath, config.repoRoot)}\n`);
|
|
228
|
-
finalPath = targetPath;
|
|
229
474
|
}
|
|
230
475
|
if (isFiling) {
|
|
231
|
-
const targetPath = path.join(filingRoot, newFiledDir, path.basename(filePath));
|
|
232
476
|
process.stdout.write(`${prefix} Would file: ${toRepoPath(filePath, config.repoRoot)} → ${toRepoPath(targetPath, config.repoRoot)}\n`);
|
|
233
|
-
finalPath = targetPath;
|
|
234
477
|
}
|
|
235
478
|
if (isUnfiling) {
|
|
236
|
-
const targetPath = path.join(filingRoot, path.basename(filePath));
|
|
237
479
|
process.stdout.write(`${prefix} Would unfile: ${toRepoPath(filePath, config.repoRoot)} → ${toRepoPath(targetPath, config.repoRoot)}\n`);
|
|
238
|
-
finalPath = targetPath;
|
|
239
480
|
}
|
|
240
481
|
if (finalPath !== filePath) {
|
|
241
482
|
const refCount = countRefsToUpdate(filePath, finalPath, config);
|
|
@@ -251,45 +492,15 @@ export async function runStatus(argv, config, opts = {}) {
|
|
|
251
492
|
return;
|
|
252
493
|
}
|
|
253
494
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const result = gitMv(filePath, targetPath, config.repoRoot);
|
|
264
|
-
if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
|
|
265
|
-
finalPath = targetPath;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
if (isUnarchiving) {
|
|
269
|
-
const targetPath = path.join(archiveBase, path.basename(filePath));
|
|
270
|
-
if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
|
|
271
|
-
const result = gitMv(filePath, targetPath, config.repoRoot);
|
|
272
|
-
if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
|
|
273
|
-
finalPath = targetPath;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
if (isFiling) {
|
|
277
|
-
const targetDir = path.join(filingRoot, newFiledDir);
|
|
278
|
-
mkdirSync(targetDir, { recursive: true });
|
|
279
|
-
const targetPath = path.join(targetDir, path.basename(filePath));
|
|
280
|
-
if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
|
|
281
|
-
const result = gitMv(filePath, targetPath, config.repoRoot);
|
|
282
|
-
if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
|
|
283
|
-
finalPath = targetPath;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
if (isUnfiling) {
|
|
287
|
-
const targetPath = path.join(filingRoot, path.basename(filePath));
|
|
288
|
-
if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
|
|
289
|
-
const result = gitMv(filePath, targetPath, config.repoRoot);
|
|
290
|
-
if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
|
|
291
|
-
finalPath = targetPath;
|
|
292
|
-
}
|
|
495
|
+
const mutationResult = commitLifecycleMutation(filePath, targetPath, config, { status: newStatus, updated: today }, currentOld => {
|
|
496
|
+
const currentTransition = `Status: ${currentOld} → ${newStatus}`;
|
|
497
|
+
return note ? `${currentTransition} — ${note}` : `${currentTransition}.`;
|
|
498
|
+
}, {
|
|
499
|
+
createSection: Boolean(note),
|
|
500
|
+
additionalUpdates: opts.additionalUpdates,
|
|
501
|
+
creations: opts.creations,
|
|
502
|
+
testHooks: opts.testHooks,
|
|
503
|
+
});
|
|
293
504
|
|
|
294
505
|
// Any of the four moves above shifts the file's directory, which breaks
|
|
295
506
|
// relative refs in both directions — links FROM the moved file and inbound
|
|
@@ -297,15 +508,9 @@ export async function runStatus(argv, config, opts = {}) {
|
|
|
297
508
|
// the deprecated `dotmd status <file> archived` path and the `dotmd set`
|
|
298
509
|
// unarchive/file/unfile transitions (which route through runStatus, not
|
|
299
510
|
// runArchive) don't silently leave dangling links.
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
if (finalPath !== filePath) {
|
|
304
|
-
selfRefsFixed = updateRefsFromMovedFile(filePath, finalPath, config) > 0;
|
|
305
|
-
const inbound = updateRefsAfterMove(filePath, finalPath, config);
|
|
306
|
-
inboundRefCount = inbound.count;
|
|
307
|
-
inboundRefPaths = inbound.paths;
|
|
308
|
-
}
|
|
511
|
+
const selfRefsFixed = Boolean(mutationResult.selfRefsFixed);
|
|
512
|
+
const inboundRefPaths = mutationResult.updatedPaths ?? [];
|
|
513
|
+
const inboundRefCount = inboundRefPaths.length;
|
|
309
514
|
|
|
310
515
|
// Regen the index on every status change — `active → planned` etc. drift
|
|
311
516
|
// the per-status sections just as much as archive crossings. Archive paths
|
|
@@ -314,7 +519,7 @@ export async function runStatus(argv, config, opts = {}) {
|
|
|
314
519
|
// each other's uncommitted index changes into the staging area.
|
|
315
520
|
if (noIndex) {
|
|
316
521
|
process.stderr.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
|
|
317
|
-
} else {
|
|
522
|
+
} else if (!opts.deferIndex) {
|
|
318
523
|
regenIndex(config);
|
|
319
524
|
}
|
|
320
525
|
|
|
@@ -326,7 +531,7 @@ export async function runStatus(argv, config, opts = {}) {
|
|
|
326
531
|
const touched = [filePath];
|
|
327
532
|
if (finalPath !== filePath) touched.push(finalPath);
|
|
328
533
|
touched.push(...inboundRefPaths);
|
|
329
|
-
if (config.indexPath && !noIndex) touched.push(config.indexPath);
|
|
534
|
+
if (config.indexPath && !noIndex && !opts.deferIndex) touched.push(config.indexPath);
|
|
330
535
|
emitFilesFooter(touched, config);
|
|
331
536
|
}
|
|
332
537
|
|
|
@@ -334,27 +539,32 @@ export async function runStatus(argv, config, opts = {}) {
|
|
|
334
539
|
oldPath: toRepoPath(filePath, config.repoRoot),
|
|
335
540
|
newPath: toRepoPath(finalPath, config.repoRoot),
|
|
336
541
|
}); } catch (err) { warn(`Hook 'onStatusChange' threw: ${err.message}`); }
|
|
542
|
+
return {
|
|
543
|
+
action: 'status-changed',
|
|
544
|
+
oldRepoPath: toRepoPath(filePath, config.repoRoot),
|
|
545
|
+
newRepoPath: toRepoPath(finalPath, config.repoRoot),
|
|
546
|
+
touched: [toRepoPath(filePath, config.repoRoot), ...(finalPath !== filePath ? [toRepoPath(finalPath, config.repoRoot)] : []), ...inboundRefPaths.map(item => toRepoPath(item, config.repoRoot))],
|
|
547
|
+
};
|
|
337
548
|
}
|
|
338
549
|
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
// status write. Backs `dotmd use <plan>` and `dotmd runlist next`.
|
|
550
|
+
// Atomically claim a plan for this session, then reconcile its generated index
|
|
551
|
+
// and pickup hook before printing the card. Backs direct use and next selectors.
|
|
342
552
|
export async function startPlan(argv, config, opts = {}) {
|
|
343
553
|
const { dryRun } = opts;
|
|
344
554
|
const json = argv.includes('--json');
|
|
345
555
|
const fullBody = argv.includes('--full');
|
|
346
556
|
const noIndex = argv.includes('--no-index') || opts.noIndex;
|
|
347
557
|
const showFiles = argv.includes('--show-files') || opts.showFiles;
|
|
558
|
+
const force = argv.includes('--force') || opts.force;
|
|
348
559
|
let input = argv.find(a => !a.startsWith('-'));
|
|
349
560
|
|
|
350
561
|
// Interactive: pick from active/planned plans
|
|
351
562
|
if (!input) {
|
|
352
563
|
if (!isInteractive()) die('Usage: dotmd use <plan>');
|
|
353
564
|
const index = buildIndex(config);
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
);
|
|
357
|
-
if (candidates.length === 0) die('No active/planned plans.');
|
|
565
|
+
const sessionId = authoritativeSessionId();
|
|
566
|
+
const candidates = pickupCandidates(index, config, sessionId);
|
|
567
|
+
if (candidates.length === 0) die('No pickup-able plans.');
|
|
358
568
|
const labelFor = (d) => `${d.title} (${d.status}) — ${d.path}`;
|
|
359
569
|
const choice = await promptChoice('Pick a plan:', candidates.map(labelFor));
|
|
360
570
|
if (!choice) die('No plan selected.');
|
|
@@ -363,41 +573,87 @@ export async function startPlan(argv, config, opts = {}) {
|
|
|
363
573
|
input = candidates[idx].path;
|
|
364
574
|
}
|
|
365
575
|
|
|
366
|
-
|
|
576
|
+
let filePath = resolveDocArg(input, config);
|
|
577
|
+
filePath = authorizeManagedSource(filePath, config, { kind: 'Plan start source' }).path;
|
|
367
578
|
|
|
368
579
|
const raw = readFileSync(filePath, 'utf8');
|
|
369
|
-
|
|
370
|
-
|
|
580
|
+
let fmRaw, body, parsedFm;
|
|
581
|
+
try {
|
|
582
|
+
({ frontmatter: fmRaw, body } = extractFrontmatter(raw));
|
|
583
|
+
if (!fmRaw) throw new Error('missing frontmatter');
|
|
584
|
+
parsedFm = parseSimpleFrontmatter(fmRaw);
|
|
585
|
+
} catch {
|
|
586
|
+
die(`Malformed plan document: ${toRepoPath(filePath, config.repoRoot)}`);
|
|
587
|
+
}
|
|
371
588
|
const docType = asString(parsedFm.type) ?? null;
|
|
372
589
|
const oldStatus = asString(parsedFm.status);
|
|
373
590
|
const title = asString(parsedFm.title) ?? path.basename(filePath, '.md');
|
|
374
591
|
const repoPath = toRepoPath(filePath, config.repoRoot);
|
|
375
592
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
if (
|
|
379
|
-
|
|
380
|
-
|
|
593
|
+
const sessionId = dryRun ? (availableSessionId() ?? '__dry-run-no-session__') : authoritativeSessionId();
|
|
594
|
+
const ownership = readPlanOwnership(repoPath, config);
|
|
595
|
+
if (force) assertHookDeliveryTakeoverSafe(ownership, {
|
|
596
|
+
now: opts.hookLeaseNow,
|
|
597
|
+
leaseMs: opts.hookLeaseMs,
|
|
598
|
+
ownerLiveness: opts.hookOwnerLiveness,
|
|
599
|
+
});
|
|
600
|
+
let disposition = classifyPlanPickup({
|
|
601
|
+
type: docType,
|
|
602
|
+
status: oldStatus,
|
|
603
|
+
validStatuses: config.typeStatuses?.get('plan') ?? config.validStatuses,
|
|
604
|
+
startableStatuses: config.lifecycle.startableStatuses,
|
|
605
|
+
terminalStatuses: config.lifecycle.terminalStatuses,
|
|
606
|
+
archiveStatuses: config.lifecycle.archiveStatuses,
|
|
607
|
+
physicallyArchived: isArchivedPath(repoPath, config),
|
|
608
|
+
ownership,
|
|
609
|
+
sessionId,
|
|
610
|
+
malformed: false,
|
|
611
|
+
});
|
|
612
|
+
if (force && ['busy', 'ownership-corrupt'].includes(disposition.kind)) {
|
|
613
|
+
disposition = classifyPlanPickup({
|
|
614
|
+
type: docType, status: oldStatus,
|
|
615
|
+
validStatuses: config.typeStatuses?.get('plan') ?? config.validStatuses,
|
|
616
|
+
startableStatuses: config.lifecycle.startableStatuses,
|
|
617
|
+
terminalStatuses: config.lifecycle.terminalStatuses,
|
|
618
|
+
archiveStatuses: config.lifecycle.archiveStatuses,
|
|
619
|
+
physicallyArchived: isArchivedPath(repoPath, config), ownership: null, sessionId, malformed: false,
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
if (!disposition.pickupable) {
|
|
623
|
+
const detail = disposition.kind === 'busy' ? ` (owned by ${disposition.owner})` : '';
|
|
624
|
+
die(`Plan cannot be picked up: ${disposition.kind}${detail}\n ${repoPath}`);
|
|
381
625
|
}
|
|
382
|
-
|
|
383
626
|
const today = nowIso();
|
|
384
627
|
if (dryRun) {
|
|
385
|
-
if (
|
|
628
|
+
if (disposition.kind === 'resume') {
|
|
386
629
|
process.stderr.write(`${dim('[dry-run]')} Already in-session: ${repoPath}\n`);
|
|
630
|
+
} else if (disposition.kind === 'adopt') {
|
|
631
|
+
process.stderr.write(`${dim('[dry-run]')} Would adopt unowned in-session plan: ${repoPath}\n`);
|
|
387
632
|
} else {
|
|
388
633
|
process.stderr.write(`${dim('[dry-run]')} Would update: status: ${oldStatus} → in-session, updated: ${today}\n`);
|
|
389
634
|
}
|
|
390
|
-
} else if (
|
|
391
|
-
|
|
392
|
-
|
|
635
|
+
} else if (disposition.kind !== 'resume') {
|
|
636
|
+
const history = opts.note
|
|
637
|
+
? `Started (${oldStatus} → in-session) — ${opts.note}`
|
|
638
|
+
: `Started (${oldStatus} → in-session).`;
|
|
639
|
+
const rendered = disposition.kind === 'start'
|
|
640
|
+
? renderLifecycleMutation(raw, { status: 'in-session', updated: today }, history, { createSection: true })
|
|
641
|
+
: null;
|
|
642
|
+
commitPlanClaim({ filePath, repoPath, sourceContent: raw, renderedContent: rendered,
|
|
643
|
+
ownership, sessionId, now: today, config, testHooks: opts.testHooks });
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (!dryRun) {
|
|
393
647
|
if (noIndex) {
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
regenIndex(config);
|
|
648
|
+
const current = validatedClaimOperation(repoPath, config);
|
|
649
|
+
if (current) updateOwnershipOperation(repoPath, config, current.binding, op => { op.index = 'skipped'; });
|
|
397
650
|
}
|
|
651
|
+
reconcileClaimOperation(repoPath, config, { ...opts, oldStatus });
|
|
398
652
|
}
|
|
399
653
|
|
|
400
|
-
if (
|
|
654
|
+
if (opts.quiet) {
|
|
655
|
+
// Prompt consume owns the user-facing output; the transition is identical.
|
|
656
|
+
} else if (json) {
|
|
401
657
|
const card = buildCard(filePath, raw, config);
|
|
402
658
|
process.stdout.write(JSON.stringify({
|
|
403
659
|
path: repoPath, oldStatus, newStatus: 'in-session', title,
|
|
@@ -417,21 +673,22 @@ export async function startPlan(argv, config, opts = {}) {
|
|
|
417
673
|
}
|
|
418
674
|
}
|
|
419
675
|
|
|
420
|
-
if (showFiles &&
|
|
676
|
+
if (showFiles && disposition.kind === 'start') {
|
|
421
677
|
const touched = [filePath];
|
|
422
|
-
if (config.indexPath && !noIndex) touched.push(config.indexPath);
|
|
678
|
+
if (config.indexPath && !noIndex && !opts.deferIndex) touched.push(config.indexPath);
|
|
423
679
|
emitFilesFooter(touched, config);
|
|
424
680
|
}
|
|
425
681
|
|
|
426
|
-
|
|
682
|
+
return { path: repoPath, oldStatus, disposition: disposition.kind };
|
|
427
683
|
}
|
|
428
684
|
|
|
429
685
|
export function runArchive(argv, config, opts = {}) {
|
|
430
686
|
const { dryRun, out = process.stdout } = opts;
|
|
687
|
+
const force = argv.includes('--force') || opts.force;
|
|
431
688
|
const noIndex = argv.includes('--no-index') || opts.noIndex;
|
|
432
689
|
const showFiles = argv.includes('--show-files') || opts.showFiles;
|
|
433
690
|
const closeoutTemplate = argv.includes('--closeout-template');
|
|
434
|
-
argv = argv.filter(a => a !== '--no-index' && a !== '--show-files' && a !== '--closeout-template');
|
|
691
|
+
argv = argv.filter(a => a !== '--no-index' && a !== '--show-files' && a !== '--closeout-template' && a !== '--force');
|
|
435
692
|
let note = opts.note ?? null;
|
|
436
693
|
const noteIdx = argv.indexOf('--note');
|
|
437
694
|
if (noteIdx !== -1) {
|
|
@@ -443,9 +700,11 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
443
700
|
|
|
444
701
|
if (!input) { die('Usage: dotmd archive <file>'); }
|
|
445
702
|
|
|
446
|
-
|
|
703
|
+
let filePath = resolveDocArg(input, config);
|
|
704
|
+
const sourceAuthorization = authorizeManagedSource(filePath, config, { kind: 'Archive source' });
|
|
705
|
+
filePath = sourceAuthorization.path;
|
|
447
706
|
|
|
448
|
-
const archiveFileRoot =
|
|
707
|
+
const archiveFileRoot = sourceAuthorization.root.lexicalPath;
|
|
449
708
|
const relFromRoot = path.relative(archiveFileRoot, filePath);
|
|
450
709
|
// Segment-membership covers both single-root (`<root>/archived/foo.md`) and
|
|
451
710
|
// multi-root (`<type-root>/archived/foo.md`) layouts. The older
|
|
@@ -457,6 +716,19 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
457
716
|
const { frontmatter, body } = extractFrontmatter(raw);
|
|
458
717
|
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
459
718
|
const oldStatus = asString(parsed.status) ?? 'unknown';
|
|
719
|
+
const archiveRepoPath = toRepoPath(filePath, config.repoRoot);
|
|
720
|
+
const archiveOwnership = asString(parsed.type) === 'plan' ? readPlanOwnership(archiveRepoPath, config) : null;
|
|
721
|
+
const releasingOwnership = asString(parsed.type) === 'plan'
|
|
722
|
+
&& (oldStatus === 'in-session' || archiveOwnership?.state === 'owned' || archiveOwnership?.corrupt);
|
|
723
|
+
const releaseSessionId = releasingOwnership ? authoritativeSessionId() : null;
|
|
724
|
+
if (releasingOwnership) assertPlanMutationAuthorized(archiveRepoPath, config, { sessionId: releaseSessionId, force });
|
|
725
|
+
if (releasingOwnership && !dryRun) ensurePlanCompletionBeforeRelease(archiveRepoPath, config, { testHooks: opts.testHooks });
|
|
726
|
+
if (releasingOwnership && dryRun && planHasPendingCompletion(archiveRepoPath, config)) {
|
|
727
|
+
out.write(`${dim('[dry-run]')} Pending claim completion would block this release.\n`);
|
|
728
|
+
}
|
|
729
|
+
const releaseUpdate = releasingOwnership
|
|
730
|
+
? prepareOwnershipRelease(archiveRepoPath, config, { sessionId: releaseSessionId, force })
|
|
731
|
+
: null;
|
|
460
732
|
|
|
461
733
|
// Preserve a configured custom archive status (e.g. `done` with archive:true)
|
|
462
734
|
// when one is threaded through from `dotmd set <archive-status>`. Fall back to
|
|
@@ -484,13 +756,18 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
484
756
|
out.write(`${prefix} Would skip git mv (file already under \`${config.archiveDir}/\`)\n`);
|
|
485
757
|
return;
|
|
486
758
|
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
759
|
+
commitLifecycleMutation(filePath, null, config, { status: targetStatus, updated: today },
|
|
760
|
+
currentOld => `Archived (frontmatter healed in place from \`${currentOld}\`)${note ? ` — ${note}` : '.'}`,
|
|
761
|
+
{
|
|
762
|
+
createSection: Boolean(note),
|
|
763
|
+
additionalUpdates: [...(opts.additionalUpdates ?? []), ...(releaseUpdate ? [releaseUpdate] : [])],
|
|
764
|
+
creations: opts.creations,
|
|
765
|
+
testHooks: opts.testHooks,
|
|
766
|
+
});
|
|
767
|
+
if (!noIndex && !opts.deferIndex) regenIndex(config);
|
|
491
768
|
out.write(`${green('✓ Healed')}: ${repoPathHeal} (${oldStatus} → ${targetStatus}; file already under \`${config.archiveDir}/\`)\n`);
|
|
492
769
|
const touched = [repoPathHeal];
|
|
493
|
-
if (config.indexPath && !noIndex) touched.push(config.indexPath);
|
|
770
|
+
if (config.indexPath && !noIndex && !opts.deferIndex) touched.push(config.indexPath);
|
|
494
771
|
if (showFiles) emitFilesFooter(touched, config);
|
|
495
772
|
return {
|
|
496
773
|
action: 'healed',
|
|
@@ -501,12 +778,16 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
501
778
|
}
|
|
502
779
|
|
|
503
780
|
const closeoutAction = closeoutTemplate ? planCloseoutInjection(body) : null;
|
|
781
|
+
let committedCloseoutAction = closeoutAction;
|
|
504
782
|
|
|
505
783
|
const today = nowIso();
|
|
506
784
|
// Type-aware: prompts archive under docs/prompts/archived/ by default (see
|
|
507
785
|
// archiveBaseFor); plans/docs keep the shared <root>/archived/.
|
|
508
786
|
const targetDir = path.join(archiveBaseFor(filePath, archiveFileRoot, asString(parsed.type), config), config.archiveDir);
|
|
509
|
-
const targetPath = uniqueArchiveTarget(targetDir, path.basename(filePath))
|
|
787
|
+
const targetPath = authorizeManagedDestination(uniqueArchiveTarget(targetDir, path.basename(filePath)), config, {
|
|
788
|
+
root: sourceAuthorization.root,
|
|
789
|
+
kind: 'Archive destination',
|
|
790
|
+
}).path;
|
|
510
791
|
const oldRepoPath = toRepoPath(filePath, config.repoRoot);
|
|
511
792
|
const newRepoPath = toRepoPath(targetPath, config.repoRoot);
|
|
512
793
|
|
|
@@ -522,7 +803,7 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
522
803
|
out.write(`${prefix} Would append Version History: - **${today}** Archived — ${note}\n`);
|
|
523
804
|
}
|
|
524
805
|
out.write(`${prefix} Would move: ${oldRepoPath} → ${newRepoPath}\n`);
|
|
525
|
-
if (config.indexPath && !noIndex) out.write(`${prefix} Would regenerate index\n`);
|
|
806
|
+
if (config.indexPath && !noIndex && !opts.deferIndex) out.write(`${prefix} Would regenerate index\n`);
|
|
526
807
|
if (config.indexPath && noIndex) out.write(`${prefix} Would skip index regen (--no-index)\n`);
|
|
527
808
|
|
|
528
809
|
// Preview reference updates
|
|
@@ -538,39 +819,37 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
538
819
|
return;
|
|
539
820
|
}
|
|
540
821
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
// Fix refs FROM the archived file (relative paths shifted by move)
|
|
554
|
-
const selfRefsFixed = updateRefsFromMovedFile(filePath, targetPath, config);
|
|
822
|
+
const mutationResult = commitLifecycleMutation(filePath, targetPath, config, { status: targetStatus, updated: today },
|
|
823
|
+
() => note ? `Archived — ${note}` : 'Archived.', {
|
|
824
|
+
createSection: Boolean(note),
|
|
825
|
+
testHooks: opts.testHooks,
|
|
826
|
+
additionalUpdates: [...(opts.additionalUpdates ?? []), ...(releaseUpdate ? [releaseUpdate] : [])],
|
|
827
|
+
creations: opts.creations,
|
|
828
|
+
bodyTransform: closeoutTemplate ? currentBody => {
|
|
829
|
+
committedCloseoutAction = planCloseoutInjection(currentBody);
|
|
830
|
+
return committedCloseoutAction.action === 'inject' ? committedCloseoutAction.newBody : currentBody;
|
|
831
|
+
} : null,
|
|
832
|
+
});
|
|
555
833
|
|
|
556
|
-
|
|
557
|
-
const
|
|
834
|
+
const selfRefsFixed = mutationResult.selfRefsFixed;
|
|
835
|
+
const refTouchedPaths = mutationResult.updatedPaths;
|
|
836
|
+
const updatedRefCount = refTouchedPaths.length;
|
|
558
837
|
|
|
559
|
-
|
|
838
|
+
const indexRegenerated = !noIndex && !opts.deferIndex ? regenIndex(config) : false;
|
|
560
839
|
|
|
561
840
|
out.write(`${green('Archived')}: ${oldRepoPath} → ${newRepoPath}\n`);
|
|
562
|
-
if (
|
|
841
|
+
if (committedCloseoutAction?.action === 'inject') {
|
|
563
842
|
out.write(`Injected \`## Closeout\` template — fill in: outcomes, key commits, deferrals.\n`);
|
|
564
|
-
} else if (
|
|
843
|
+
} else if (committedCloseoutAction?.action === 'skip') {
|
|
565
844
|
out.write(dim('(closeout template skipped — `## Closeout` section already present)\n'));
|
|
566
845
|
}
|
|
567
846
|
if (selfRefsFixed) out.write('Updated references in archived file.\n');
|
|
568
847
|
if (updatedRefCount > 0) out.write(`Updated references in ${updatedRefCount} file(s).\n`);
|
|
569
|
-
if (config.indexPath &&
|
|
848
|
+
if (config.indexPath && indexRegenerated) out.write('Index regenerated.\n');
|
|
570
849
|
if (config.indexPath && noIndex) out.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
|
|
571
850
|
|
|
572
851
|
const touched = [oldRepoPath, newRepoPath, ...refTouchedPaths];
|
|
573
|
-
if (config.indexPath &&
|
|
852
|
+
if (config.indexPath && indexRegenerated) touched.push(config.indexPath);
|
|
574
853
|
if (showFiles) emitFilesFooter(touched, config);
|
|
575
854
|
|
|
576
855
|
try { config.hooks.onArchive?.({ path: newRepoPath, oldStatus }, { oldPath: oldRepoPath, newPath: newRepoPath }); } catch (err) { warn(`Hook 'onArchive' threw: ${err.message}`); }
|
|
@@ -580,6 +859,10 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
580
859
|
oldRepoPath,
|
|
581
860
|
newRepoPath,
|
|
582
861
|
touched,
|
|
862
|
+
referencePaths: refTouchedPaths.map(item => toRepoPath(item, config.repoRoot)),
|
|
863
|
+
indexRegenerated,
|
|
864
|
+
consumedBody: extractFrontmatter(mutationResult.sourceContent).body,
|
|
865
|
+
consumedFrontmatter: parseSimpleFrontmatter(extractFrontmatter(mutationResult.sourceContent).frontmatter),
|
|
583
866
|
};
|
|
584
867
|
}
|
|
585
868
|
|
|
@@ -587,18 +870,17 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
587
870
|
// signature — `dotmd set <status> [<path>]` — and dispatches to the right
|
|
588
871
|
// plumbing based on the *target* status:
|
|
589
872
|
// - target in archiveStatuses (and file not already archived) → runArchive
|
|
590
|
-
// (gets us ref-fixing +
|
|
873
|
+
// (gets us ref-fixing + atomic ownership release + closeout-template offer)
|
|
591
874
|
// - source = in-session, target != in-session → runStatus +
|
|
592
|
-
//
|
|
875
|
+
// atomic release of the ownership record
|
|
593
876
|
// - everything else (incl. unarchive, plain transitions) → runStatus
|
|
594
877
|
//
|
|
595
|
-
// Path is inferred from the calling session's
|
|
596
|
-
// zero
|
|
878
|
+
// Path is inferred from the calling session's valid ownership record when omitted. With
|
|
879
|
+
// zero records, ambiguity, or corruption, we refuse and ask for explicit `<path>` instead
|
|
597
880
|
// of guessing.
|
|
598
881
|
//
|
|
599
|
-
// `dotmd set in-session <path>`
|
|
600
|
-
//
|
|
601
|
-
// would skip the lease-acquisition path entirely.
|
|
882
|
+
// `dotmd set in-session <path>` routes through the exact same claim transition
|
|
883
|
+
// as `dotmd use`, including history, ownership, index, and hook completion.
|
|
602
884
|
|
|
603
885
|
// Did THIS session already hand off via `dotmd baton`? The journal records the
|
|
604
886
|
// top-level argv of every invocation; a successful `baton …` means a resume
|
|
@@ -621,7 +903,8 @@ export async function runSet(argv, config, opts = {}) {
|
|
|
621
903
|
const { dryRun } = opts;
|
|
622
904
|
const noIndex = argv.includes('--no-index');
|
|
623
905
|
const showFiles = argv.includes('--show-files');
|
|
624
|
-
|
|
906
|
+
const force = argv.includes('--force') || opts.force;
|
|
907
|
+
argv = argv.filter(a => a !== '--no-index' && a !== '--show-files' && a !== '--force');
|
|
625
908
|
let note = opts.note ?? null;
|
|
626
909
|
const noteIdx = argv.indexOf('--note');
|
|
627
910
|
if (noteIdx !== -1) {
|
|
@@ -631,12 +914,43 @@ export async function runSet(argv, config, opts = {}) {
|
|
|
631
914
|
}
|
|
632
915
|
|
|
633
916
|
const newStatus = argv[0];
|
|
634
|
-
|
|
917
|
+
let input = argv[1];
|
|
918
|
+
|
|
919
|
+
if (!newStatus) die('Usage: dotmd set <status> [<path>]');
|
|
920
|
+
let sessionId = null;
|
|
921
|
+
if (!input) {
|
|
922
|
+
sessionId = authoritativeSessionId();
|
|
923
|
+
const owned = listOwnedPlans(config, sessionId);
|
|
924
|
+
if (owned.length !== 1 || owned.diagnostics?.length) {
|
|
925
|
+
const diagnostics = owned.diagnostics?.length ? `\nIgnored ownership records:\n${owned.diagnostics.map(d => ` ${d}`).join('\n')}` : '';
|
|
926
|
+
die(`No-target set requires exactly one valid in-session plan owned by this session; found ${owned.length}. Pass an explicit path.${diagnostics}`);
|
|
927
|
+
}
|
|
928
|
+
input = owned[0].plan;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
let filePath = resolveDocArg(input, config);
|
|
932
|
+
filePath = authorizeManagedSource(filePath, config, { kind: 'Set source' }).path;
|
|
933
|
+
const repoPath = toRepoPath(filePath, config.repoRoot);
|
|
635
934
|
|
|
636
|
-
if (
|
|
637
|
-
|
|
935
|
+
if (newStatus === 'in-session') {
|
|
936
|
+
const args = [filePath];
|
|
937
|
+
if (noIndex) args.push('--no-index');
|
|
938
|
+
if (showFiles) args.push('--show-files');
|
|
939
|
+
if (force) args.push('--force');
|
|
940
|
+
return startPlan(args, config, { ...opts, force, note });
|
|
941
|
+
}
|
|
638
942
|
|
|
639
|
-
|
|
943
|
+
let oldFm = null;
|
|
944
|
+
try { oldFm = parseSimpleFrontmatter(extractFrontmatter(readFileSync(filePath, 'utf8')).frontmatter); } catch { /* runStatus reports malformed docs */ }
|
|
945
|
+
const oldOwnership = asString(oldFm?.type) === 'plan' ? readPlanOwnership(repoPath, config) : null;
|
|
946
|
+
const releasing = asString(oldFm?.type) === 'plan'
|
|
947
|
+
&& (asString(oldFm?.status) === 'in-session' || oldOwnership?.state === 'owned' || oldOwnership?.corrupt);
|
|
948
|
+
if (releasing) {
|
|
949
|
+
sessionId ??= authoritativeSessionId();
|
|
950
|
+
assertPlanMutationAuthorized(repoPath, config, { sessionId, force });
|
|
951
|
+
if (!dryRun) ensurePlanCompletionBeforeRelease(repoPath, config, { testHooks: opts.testHooks });
|
|
952
|
+
else if (planHasPendingCompletion(repoPath, config)) process.stderr.write(`${dim('[dry-run]')} Pending claim completion would block this release.\n`);
|
|
953
|
+
}
|
|
640
954
|
|
|
641
955
|
const inArchive = isArchivedPath(toRepoPath(filePath, config.repoRoot), config);
|
|
642
956
|
|
|
@@ -647,7 +961,11 @@ export async function runSet(argv, config, opts = {}) {
|
|
|
647
961
|
// Preserve the exact target status — a config may name its archive status
|
|
648
962
|
// `done` (with archive:true) rather than `archived`. Without this, runArchive
|
|
649
963
|
// would silently rewrite it to `archived`.
|
|
650
|
-
return runArchive(archiveArgs, config, {
|
|
964
|
+
return runArchive(archiveArgs, config, {
|
|
965
|
+
dryRun, note, archiveStatus: newStatus, force, testHooks: opts.testHooks, deferIndex: opts.deferIndex,
|
|
966
|
+
additionalUpdates: opts.additionalUpdates,
|
|
967
|
+
creations: opts.creations,
|
|
968
|
+
});
|
|
651
969
|
}
|
|
652
970
|
|
|
653
971
|
// Two advisory reminders, both computed from one pre-transition frontmatter
|
|
@@ -691,7 +1009,18 @@ export async function runSet(argv, config, opts = {}) {
|
|
|
691
1009
|
const statusArgs = [filePath, newStatus];
|
|
692
1010
|
if (noIndex) statusArgs.push('--no-index');
|
|
693
1011
|
if (showFiles) statusArgs.push('--show-files');
|
|
694
|
-
|
|
1012
|
+
const releaseUpdate = releasing
|
|
1013
|
+
? prepareOwnershipRelease(repoPath, config, { sessionId, force })
|
|
1014
|
+
: null;
|
|
1015
|
+
const result = await runStatus(statusArgs, config, {
|
|
1016
|
+
dryRun,
|
|
1017
|
+
suppressDeprecation: true,
|
|
1018
|
+
note,
|
|
1019
|
+
additionalUpdates: releaseUpdate ? [releaseUpdate] : [],
|
|
1020
|
+
creations: opts.creations,
|
|
1021
|
+
testHooks: opts.testHooks,
|
|
1022
|
+
deferIndex: opts.deferIndex,
|
|
1023
|
+
});
|
|
695
1024
|
|
|
696
1025
|
if (!dryRun) {
|
|
697
1026
|
if (partialReminder) {
|
|
@@ -701,10 +1030,12 @@ export async function runSet(argv, config, opts = {}) {
|
|
|
701
1030
|
warn(`wrapping up? leave a baton so the next session picks up cleanly — \`dotmd baton ${path.basename(filePath, '.md')} @draft\` saves a resume prompt (no copy-paste into chat).`);
|
|
702
1031
|
}
|
|
703
1032
|
}
|
|
1033
|
+
return result;
|
|
704
1034
|
}
|
|
705
1035
|
|
|
706
1036
|
export function runBulkArchive(argv, config, opts = {}) {
|
|
707
1037
|
const { dryRun } = opts;
|
|
1038
|
+
const json = argv.includes('--json');
|
|
708
1039
|
const noIndex = argv.includes('--no-index') || opts.noIndex;
|
|
709
1040
|
const showFiles = argv.includes('--show-files') || opts.showFiles;
|
|
710
1041
|
const inputs = argv.filter(a => !a.startsWith('-'));
|
|
@@ -726,34 +1057,48 @@ export function runBulkArchive(argv, config, opts = {}) {
|
|
|
726
1057
|
|
|
727
1058
|
const unique = [...new Set(matched)].filter(f => !isArchivedPath(toRepoPath(f, config.repoRoot), config));
|
|
728
1059
|
if (unique.length === 0) die('No matching files found (already-archived files are excluded).');
|
|
1060
|
+
authorizeManagedSweep(unique, config, { kind: 'Bulk archive source' });
|
|
729
1061
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
process.stdout.write(` ${toRepoPath(f, config.repoRoot)}\n`);
|
|
1062
|
+
if (!json) {
|
|
1063
|
+
process.stdout.write(`${unique.length} file(s) to archive (independent per-item transactions):\n`);
|
|
1064
|
+
for (const f of unique) process.stdout.write(` ${toRepoPath(f, config.repoRoot)}\n`);
|
|
733
1065
|
}
|
|
734
1066
|
|
|
735
1067
|
if (dryRun) {
|
|
736
|
-
|
|
737
|
-
|
|
1068
|
+
const result = { operation: 'bulk-archive', atomicity: 'per-item', dryRun: true, items: unique.map(file => ({ path: toRepoPath(file, config.repoRoot), result: 'would-archive' })) };
|
|
1069
|
+
if (json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
1070
|
+
else process.stdout.write(dim('\n[dry-run] No changes made.\n'));
|
|
1071
|
+
return result;
|
|
738
1072
|
}
|
|
739
1073
|
|
|
740
|
-
process.stdout.write('\n');
|
|
1074
|
+
if (!json) process.stdout.write('\n');
|
|
741
1075
|
// Bulk archives always defer index regen to the end — N individual regens
|
|
742
1076
|
// is wasteful and the final state is the same. `--no-index` skips even
|
|
743
1077
|
// the final one.
|
|
744
1078
|
const bulkTouched = [];
|
|
1079
|
+
const items = [];
|
|
1080
|
+
let indexRegenerated = false;
|
|
1081
|
+
let indexError = null;
|
|
1082
|
+
let succeeded = 0;
|
|
1083
|
+
const originalStdoutWrite = process.stdout.write;
|
|
1084
|
+
if (json) process.stdout.write = () => true;
|
|
1085
|
+
try {
|
|
745
1086
|
for (const f of unique) {
|
|
746
1087
|
const relPath = toRepoPath(f, config.repoRoot);
|
|
747
1088
|
try {
|
|
748
1089
|
const result = runArchive([relPath], config, { ...opts, noIndex: true, showFiles: false });
|
|
749
1090
|
if (result?.touched) bulkTouched.push(...result.touched);
|
|
1091
|
+
items.push({ path: relPath, result: 'archived', newPath: result?.newRepoPath ?? null, repositoryFiles: result?.touched ?? [] });
|
|
750
1092
|
} catch (err) {
|
|
751
|
-
|
|
1093
|
+
items.push({ path: relPath, result: 'failed', error: err.message });
|
|
1094
|
+
if (!json) warn(`Failed to archive ${relPath}: ${err.message}`);
|
|
752
1095
|
}
|
|
753
1096
|
}
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
1097
|
+
succeeded = items.filter(item => item.result === 'archived').length;
|
|
1098
|
+
if (!noIndex && succeeded > 0) {
|
|
1099
|
+
try { indexRegenerated = regenIndex(config, { throwOnError: true, testHooks: opts.testHooks }); }
|
|
1100
|
+
catch (err) { indexError = err.message; }
|
|
1101
|
+
if (config.indexPath && indexRegenerated) process.stdout.write('Index regenerated.\n');
|
|
757
1102
|
} else if (config.indexPath) {
|
|
758
1103
|
process.stdout.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
|
|
759
1104
|
}
|
|
@@ -762,6 +1107,25 @@ export function runBulkArchive(argv, config, opts = {}) {
|
|
|
762
1107
|
if (config.indexPath && !noIndex) all.push(config.indexPath);
|
|
763
1108
|
emitFilesFooter(all, config);
|
|
764
1109
|
}
|
|
1110
|
+
} finally {
|
|
1111
|
+
if (json) process.stdout.write = originalStdoutWrite;
|
|
1112
|
+
}
|
|
1113
|
+
const normalize = item => path.isAbsolute(item) ? toRepoPath(item, config.repoRoot) : item.split(path.sep).join('/');
|
|
1114
|
+
for (const item of items) if (item.repositoryFiles) item.repositoryFiles = [...new Set(item.repositoryFiles.map(normalize))];
|
|
1115
|
+
const deferredGeneratedFiles = config.indexPath && succeeded > 0 && (noIndex || indexError) ? [toRepoPath(config.indexPath, config.repoRoot)] : [];
|
|
1116
|
+
const result = {
|
|
1117
|
+
operation: 'bulk-archive', atomicity: 'per-item', items,
|
|
1118
|
+
repositoryFiles: [...new Set(bulkTouched.map(normalize))],
|
|
1119
|
+
generatedFiles: config.indexPath && indexRegenerated ? [toRepoPath(config.indexPath, config.repoRoot)] : [],
|
|
1120
|
+
deferredGeneratedFiles,
|
|
1121
|
+
index: !config.indexPath ? { status: 'not-configured' }
|
|
1122
|
+
: indexRegenerated ? { status: 'generated', path: toRepoPath(config.indexPath, config.repoRoot) }
|
|
1123
|
+
: indexError ? { status: 'failed', path: toRepoPath(config.indexPath, config.repoRoot), error: indexError }
|
|
1124
|
+
: { status: noIndex ? 'deferred' : 'skipped', path: toRepoPath(config.indexPath, config.repoRoot) },
|
|
1125
|
+
};
|
|
1126
|
+
if (json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
1127
|
+
if (items.some(item => item.result === 'failed') || indexError) process.exitCode = 1;
|
|
1128
|
+
return result;
|
|
765
1129
|
}
|
|
766
1130
|
|
|
767
1131
|
export function runTouch(argv, config, opts = {}) {
|
|
@@ -778,10 +1142,16 @@ export function runTouch(argv, config, opts = {}) {
|
|
|
778
1142
|
// --git mode: bulk-sync frontmatter dates from git history
|
|
779
1143
|
if (useGit) {
|
|
780
1144
|
const allFiles = input ? [resolveDocArg(input, config)] : collectDocFiles(config);
|
|
1145
|
+
authorizeManagedSweep(allFiles, config, { kind: 'Touch --git source' });
|
|
781
1146
|
|
|
782
1147
|
const prefix = dryRun ? dim('[dry-run] ') : '';
|
|
783
1148
|
let synced = 0;
|
|
784
|
-
const
|
|
1149
|
+
const repoPaths = allFiles.map(filePath => toRepoPath(filePath, config.repoRoot));
|
|
1150
|
+
const gitMetadata = getGitLastModifiedBatch(config.repoRoot, repoPaths, opts.gitMetadataOptions);
|
|
1151
|
+
if (!gitMetadata.complete) {
|
|
1152
|
+
die(`Cannot touch from incomplete Git metadata (${gitMetadata.reason}); no files were changed.`);
|
|
1153
|
+
}
|
|
1154
|
+
const gitDates = gitMetadata.dates;
|
|
785
1155
|
|
|
786
1156
|
for (const filePath of allFiles) {
|
|
787
1157
|
const repoPath = toRepoPath(filePath, config.repoRoot);
|
|
@@ -804,7 +1174,14 @@ export function runTouch(argv, config, opts = {}) {
|
|
|
804
1174
|
if (fmUpdated && fmUpdated >= gitDay) continue;
|
|
805
1175
|
|
|
806
1176
|
if (!dryRun) {
|
|
807
|
-
|
|
1177
|
+
const result = mutateFile(filePath, { repoRoot: config.repoRoot, testHooks: opts.testHooks }, current => {
|
|
1178
|
+
const currentFm = parseSimpleFrontmatter(extractFrontmatter(current).frontmatter);
|
|
1179
|
+
if (config.lifecycle.skipStaleFor.has(asString(currentFm.status))) return current;
|
|
1180
|
+
const currentUpdated = asString(currentFm.updated);
|
|
1181
|
+
if (currentUpdated && currentUpdated >= gitDay) return current;
|
|
1182
|
+
return renderLifecycleMutation(current, { updated: gitDay }, null);
|
|
1183
|
+
});
|
|
1184
|
+
if (!result.changed) continue;
|
|
808
1185
|
}
|
|
809
1186
|
process.stdout.write(`${prefix}${green('Synced')}: ${repoPath} (updated → ${gitDay})\n`);
|
|
810
1187
|
synced++;
|
|
@@ -820,7 +1197,8 @@ export function runTouch(argv, config, opts = {}) {
|
|
|
820
1197
|
|
|
821
1198
|
if (!input) { die('Usage: dotmd touch <file>\n dotmd touch --git Bulk-sync dates from git history'); }
|
|
822
1199
|
|
|
823
|
-
|
|
1200
|
+
let filePath = resolveDocArg(input, config);
|
|
1201
|
+
filePath = authorizeManagedSource(filePath, config, { kind: 'Touch source' }).path;
|
|
824
1202
|
|
|
825
1203
|
const today = nowIso();
|
|
826
1204
|
|
|
@@ -829,7 +1207,7 @@ export function runTouch(argv, config, opts = {}) {
|
|
|
829
1207
|
return;
|
|
830
1208
|
}
|
|
831
1209
|
|
|
832
|
-
|
|
1210
|
+
mutateFile(filePath, { repoRoot: config.repoRoot, testHooks: opts.testHooks }, current => renderLifecycleMutation(current, { updated: today }, null));
|
|
833
1211
|
process.stdout.write(`${green('Touched')}: ${toRepoPath(filePath, config.repoRoot)} (updated → ${today})\n`);
|
|
834
1212
|
|
|
835
1213
|
try { config.hooks.onTouch?.({ path: toRepoPath(filePath, config.repoRoot) }, { path: toRepoPath(filePath, config.repoRoot), date: today }); } catch (err) { warn(`Hook 'onTouch' threw: ${err.message}`); }
|
|
@@ -846,136 +1224,30 @@ export function runTouch(argv, config, opts = {}) {
|
|
|
846
1224
|
// `docs/plans/../archived/child.md`; and could corrupt a `grandchild.md` ref
|
|
847
1225
|
// when archiving `child.md` (suffix match). oldPath no longer exists on disk
|
|
848
1226
|
// post-`git mv`, so existsSync-based resolveRefPath can't be used here.
|
|
849
|
-
function
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
/**
|
|
861
|
-
* After a file moves (archive/unarchive), update frontmatter references in all
|
|
862
|
-
* docs that pointed to the old location so they point to the new one.
|
|
863
|
-
*/
|
|
864
|
-
function updateRefsAfterMove(oldPath, newPath, config) {
|
|
865
|
-
const basename = path.basename(oldPath);
|
|
866
|
-
const allFiles = collectDocFiles(config);
|
|
867
|
-
const touched = [];
|
|
868
|
-
|
|
869
|
-
for (const docFile of allFiles) {
|
|
870
|
-
if (docFile === newPath) continue;
|
|
871
|
-
const raw = readFileSync(docFile, 'utf8');
|
|
872
|
-
if (!raw.includes(basename)) continue;
|
|
873
|
-
const { frontmatter: fm, body } = extractFrontmatter(raw);
|
|
874
|
-
if (!fm) continue;
|
|
875
|
-
|
|
876
|
-
const docDir = path.dirname(docFile);
|
|
877
|
-
const newFm = rewriteFrontmatterRefs(fm, docDir, oldPath, newPath, config.repoRoot);
|
|
878
|
-
|
|
879
|
-
// Body markdown links [text](path.md) or [text](path.md#anchor) pointing
|
|
880
|
-
// at oldPath. resolveRefPath can't be used here: oldPath no longer exists
|
|
881
|
-
// on disk (git mv already ran), so its existsSync probe would fail. Match
|
|
882
|
-
// by resolving the href manually and comparing absolute paths instead.
|
|
883
|
-
const linkRegex = /(\[[^\]]*\]\()([^)#]+\.md)(#[^)]*)?(\))/g;
|
|
884
|
-
const newBody = body.replace(linkRegex, (match, pre, href, frag, post) => {
|
|
885
|
-
if (/^https?:/i.test(href)) return match;
|
|
886
|
-
const docRelAbs = path.resolve(docDir, href);
|
|
887
|
-
const repoRelAbs = path.resolve(config.repoRoot, href);
|
|
888
|
-
if (docRelAbs !== oldPath && repoRelAbs !== oldPath) return match;
|
|
889
|
-
const newHref = path.relative(docDir, newPath).split(path.sep).join('/');
|
|
890
|
-
return `${pre}${newHref}${frag ?? ''}${post}`;
|
|
891
|
-
});
|
|
892
|
-
|
|
893
|
-
if (newFm !== fm || newBody !== body) {
|
|
894
|
-
writeFileSync(docFile, `---\n${newFm}\n---\n${newBody}`, 'utf8');
|
|
895
|
-
touched.push(docFile);
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
return { count: touched.length, paths: touched };
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
function updateRefsFromMovedFile(oldPath, newPath, config) {
|
|
903
|
-
const oldDir = path.dirname(oldPath);
|
|
904
|
-
const newDir = path.dirname(newPath);
|
|
905
|
-
if (oldDir === newDir) return 0;
|
|
906
|
-
|
|
907
|
-
let raw = readFileSync(newPath, 'utf8');
|
|
908
|
-
const { frontmatter, body } = extractFrontmatter(raw);
|
|
909
|
-
|
|
910
|
-
// Fix frontmatter ref fields (YAML list items like - ./path.md).
|
|
911
|
-
// Resolve doc-relative first, then repo-root-relative — so a ref like
|
|
912
|
-
// `docs/foo/bar.md` written from any nesting level gets rewritten correctly
|
|
913
|
-
// when the source moves. Without the repo-root fallback, repo-relative refs
|
|
914
|
-
// silently skipped rewriting (existsSync on the doubled doc-relative path
|
|
915
|
-
// returned false).
|
|
916
|
-
// Token-based: rewrite every `*.md` path that resolved to a real file from
|
|
917
|
-
// the old location, regardless of YAML shape — block-sequence list items
|
|
918
|
-
// (` - ./path.md`), inline scalars (`parent_plan: hub.md`), and flow arrays
|
|
919
|
-
// (`related_plans: [a.md, b.md]`). Quotes sit outside the matched token, so
|
|
920
|
-
// `"./path.md"` rewrites in place. Mirrors rewriteFrontmatterRefs (inbound).
|
|
921
|
-
let newFm = frontmatter;
|
|
922
|
-
newFm = newFm.replace(/[^\s"'<>:[\],]+\.md\b/g, (token) => {
|
|
923
|
-
const absTarget = resolveRefPath(token, oldDir, config.repoRoot);
|
|
924
|
-
if (!absTarget) return token;
|
|
925
|
-
return path.relative(newDir, absTarget).split(path.sep).join('/');
|
|
926
|
-
});
|
|
927
|
-
|
|
928
|
-
// Fix body markdown links [text](path.md) and [text](path.md#anchor) — the
|
|
929
|
-
// trailing fragment is preserved across the rewrite.
|
|
930
|
-
let newBody = body;
|
|
931
|
-
const linkRegex = /(\[[^\]]*\]\()([^)#]+\.md)(#[^)]*)?(\))/g;
|
|
932
|
-
newBody = newBody.replace(linkRegex, (match, pre, href, frag, post) => {
|
|
933
|
-
if (/^https?:/i.test(href)) return match;
|
|
934
|
-
const absTarget = resolveRefPath(href, oldDir, config.repoRoot);
|
|
935
|
-
if (!absTarget) return match;
|
|
936
|
-
const newHref = path.relative(newDir, absTarget).split(path.sep).join('/');
|
|
937
|
-
return `${pre}${newHref}${frag ?? ''}${post}`;
|
|
1227
|
+
function renderMovedFileRefs(raw, oldPath, newPath, config) {
|
|
1228
|
+
const files = collectDocFiles(config);
|
|
1229
|
+
return rewriteDocumentReferences(raw, {
|
|
1230
|
+
sourcePath: oldPath,
|
|
1231
|
+
outputPath: newPath,
|
|
1232
|
+
repoRoot: config.repoRoot,
|
|
1233
|
+
identities: createReferenceIdentitySet(files),
|
|
1234
|
+
referenceFields: configuredReferenceFields(config),
|
|
1235
|
+
oldPath,
|
|
1236
|
+
newPath,
|
|
1237
|
+
rebaseAll: true,
|
|
938
1238
|
});
|
|
939
|
-
|
|
940
|
-
if (newFm !== frontmatter || newBody !== body) {
|
|
941
|
-
writeFileSync(newPath, `---\n${newFm}\n---\n${newBody}`, 'utf8');
|
|
942
|
-
return 1;
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
return 0;
|
|
946
1239
|
}
|
|
947
1240
|
|
|
948
1241
|
function countRefsToUpdate(oldPath, newPath, config) {
|
|
949
|
-
const basename = path.basename(oldPath);
|
|
950
1242
|
const allFiles = collectDocFiles(config);
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
if (docFile === newPath) continue;
|
|
1243
|
+
const identities = createReferenceIdentitySet(allFiles);
|
|
1244
|
+
return allFiles.filter(docFile => {
|
|
1245
|
+
if (docFile === oldPath || docFile === newPath) return false;
|
|
955
1246
|
const raw = readFileSync(docFile, 'utf8');
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
const docDir = path.dirname(docFile);
|
|
961
|
-
const fmHit = rewriteFrontmatterRefs(fm, docDir, oldPath, newPath, config.repoRoot) !== fm;
|
|
962
|
-
|
|
963
|
-
let bodyHit = false;
|
|
964
|
-
if (!fmHit) {
|
|
965
|
-
const linkRegex = /\[[^\]]*\]\(([^)#]+\.md)(?:#[^)]*)?\)/g;
|
|
966
|
-
for (const match of body.matchAll(linkRegex)) {
|
|
967
|
-
const href = match[1];
|
|
968
|
-
if (/^https?:/i.test(href)) continue;
|
|
969
|
-
const docRelAbs = path.resolve(docDir, href);
|
|
970
|
-
const repoRelAbs = path.resolve(config.repoRoot, href);
|
|
971
|
-
if (docRelAbs === oldPath || repoRelAbs === oldPath) { bodyHit = true; break; }
|
|
972
|
-
}
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
if (fmHit || bodyHit) count++;
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
return count;
|
|
1247
|
+
return rewriteDocumentReferences(raw, {
|
|
1248
|
+
sourcePath: docFile, repoRoot: config.repoRoot, identities, oldPath, newPath, referenceFields: configuredReferenceFields(config),
|
|
1249
|
+
}) !== raw;
|
|
1250
|
+
}).length;
|
|
979
1251
|
}
|
|
980
1252
|
|
|
981
1253
|
// Append a one-line dated bullet to the file's `## Version History` section.
|