dotmd-cli 0.74.0 → 0.74.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/atomic-mutation.mjs +35 -5
- package/src/lifecycle.mjs +5 -1
- package/src/pickup.mjs +11 -2
- package/src/prompts.mjs +4 -0
package/package.json
CHANGED
package/src/atomic-mutation.mjs
CHANGED
|
@@ -1279,9 +1279,9 @@ export function createFileExclusive(filePath, content, options) {
|
|
|
1279
1279
|
}
|
|
1280
1280
|
|
|
1281
1281
|
export function moveFileAtomic(sourcePath, targetPath, render, options) {
|
|
1282
|
-
const { repoRoot, finalize, rollbackFinalize, testHooks, updates = [], creations = [], deletions = [] } = options;
|
|
1282
|
+
const { repoRoot, finalize, rollbackFinalize, testHooks, updates = [], creations = [], deletions = [], guards = [] } = options;
|
|
1283
1283
|
recoverAbandonedTransactions(repoRoot, options);
|
|
1284
|
-
return withPathLocks([sourcePath, targetPath, ...updates.map(item => item.path), ...creations.map(item => item.path), ...deletions.map(item => item.path)], options, () => {
|
|
1284
|
+
return withPathLocks([sourcePath, targetPath, ...updates.map(item => item.path), ...creations.map(item => item.path), ...deletions.map(item => item.path), ...guards.map(item => item.path)], options, () => {
|
|
1285
1285
|
testHooks?.afterTransactionPhase?.('lock', { sourcePath, targetPath });
|
|
1286
1286
|
testHooks?.beforeMoveSnapshot?.({ sourcePath, targetPath });
|
|
1287
1287
|
const source = snapshotFile(sourcePath);
|
|
@@ -1304,6 +1304,15 @@ export function moveFileAtomic(sourcePath, targetPath, render, options) {
|
|
|
1304
1304
|
}
|
|
1305
1305
|
return { ...item, snapshot };
|
|
1306
1306
|
});
|
|
1307
|
+
// Read-only participants (see `mutateFileSet`). Checked before the
|
|
1308
|
+
// transaction manifest exists, so a guard conflict can never leave a
|
|
1309
|
+
// transaction to recover from.
|
|
1310
|
+
for (const guard of guards) {
|
|
1311
|
+
const snapshot = snapshotFile(guard.path);
|
|
1312
|
+
if (guard.expectedContent !== undefined && snapshot.content !== guard.expectedContent) {
|
|
1313
|
+
throw new MutationConflictError(`File changed while the move mutation set was being prepared: ${snapshot.path}`);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1307
1316
|
for (const item of creations) {
|
|
1308
1317
|
if (existsSync(item.path)) throw new MutationConflictError(`Destination already exists: ${path.resolve(item.path)}`);
|
|
1309
1318
|
}
|
|
@@ -1627,9 +1636,30 @@ export function moveFileAtomic(sourcePath, targetPath, render, options) {
|
|
|
1627
1636
|
});
|
|
1628
1637
|
}
|
|
1629
1638
|
|
|
1630
|
-
|
|
1631
|
-
|
|
1639
|
+
// `guards` are read-only participants: files the mutation's VALIDITY depends on
|
|
1640
|
+
// but that it never writes. They take part in locking and in the same
|
|
1641
|
+
// compare-and-swap as `updates`, so a mutation decided from a file it doesn't
|
|
1642
|
+
// modify cannot land after that file changed underneath it.
|
|
1643
|
+
//
|
|
1644
|
+
// Without this, a decision read and the write it justifies are two separate
|
|
1645
|
+
// steps with nothing holding the gap. A claim that adopts an already-in-session
|
|
1646
|
+
// plan writes only the ownership record — so a concurrent `set` releasing that
|
|
1647
|
+
// plan could win the status write while the claim still took ownership, leaving
|
|
1648
|
+
// a record that owns a plan the file says is `active`. A no-op update on the
|
|
1649
|
+
// plan file would close the gap too, but it would rewrite bytes and report the
|
|
1650
|
+
// plan as changed to anything counting touched files. A guard says what is meant.
|
|
1651
|
+
export function mutateFileSet({ updates = [], creations = [], guards = [] }, options) {
|
|
1652
|
+
const paths = [...updates.map(item => item.path), ...creations.map(item => item.path), ...guards.map(item => item.path)];
|
|
1632
1653
|
return withPathLocks(paths, options, () => {
|
|
1654
|
+
// Guards are pure preconditions, so they are checked before anything at all
|
|
1655
|
+
// is created — a guard conflict must leave the tree byte-identical, not even
|
|
1656
|
+
// an empty directory behind.
|
|
1657
|
+
for (const guard of guards) {
|
|
1658
|
+
const snapshot = snapshotFile(guard.path);
|
|
1659
|
+
if (guard.expectedContent !== undefined && snapshot.content !== guard.expectedContent) {
|
|
1660
|
+
throw new MutationConflictError(`File changed while the mutation set was being prepared: ${snapshot.path}`);
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1633
1663
|
const createdDirectories = [];
|
|
1634
1664
|
for (const item of creations) {
|
|
1635
1665
|
const directory = path.dirname(item.path);
|
|
@@ -1654,7 +1684,7 @@ export function mutateFileSet({ updates = [], creations = [] }, options) {
|
|
|
1654
1684
|
for (const item of creations) {
|
|
1655
1685
|
if (existsSync(item.path)) throw new MutationConflictError(`Destination already exists: ${path.resolve(item.path)}`);
|
|
1656
1686
|
}
|
|
1657
|
-
options.testHooks?.afterSetPreflight?.({ updates: preparedUpdates, creations });
|
|
1687
|
+
options.testHooks?.afterSetPreflight?.({ updates: preparedUpdates, creations, guards });
|
|
1658
1688
|
|
|
1659
1689
|
const committedUpdates = [];
|
|
1660
1690
|
const committedCreations = [];
|
package/src/lifecycle.mjs
CHANGED
|
@@ -107,6 +107,7 @@ function commitLifecycleMutation(filePath, targetPath, config, updates, historyF
|
|
|
107
107
|
}),
|
|
108
108
|
})), ...additionalUpdates],
|
|
109
109
|
creations: options.creations ?? [],
|
|
110
|
+
guards: options.guards ?? [],
|
|
110
111
|
gitMove: tracked,
|
|
111
112
|
gitIndex,
|
|
112
113
|
operation: 'lifecycle-move',
|
|
@@ -115,12 +116,13 @@ function commitLifecycleMutation(filePath, targetPath, config, updates, historyF
|
|
|
115
116
|
});
|
|
116
117
|
return { ...result, sourceContent: moveResult.source.content, updatedPaths: moveResult.updatedPaths };
|
|
117
118
|
}
|
|
118
|
-
if ((options.additionalUpdates?.length ?? 0) > 0 || (options.creations?.length ?? 0) > 0) {
|
|
119
|
+
if ((options.additionalUpdates?.length ?? 0) > 0 || (options.creations?.length ?? 0) > 0 || (options.guards?.length ?? 0) > 0) {
|
|
119
120
|
const sourceContent = readFileSync(filePath, 'utf8');
|
|
120
121
|
const result = render(sourceContent);
|
|
121
122
|
mutateFileSet({
|
|
122
123
|
updates: [{ path: filePath, expectedContent: sourceContent, content: result.content }, ...(options.additionalUpdates ?? [])],
|
|
123
124
|
creations: options.creations ?? [],
|
|
125
|
+
guards: options.guards ?? [],
|
|
124
126
|
}, { repoRoot: config.repoRoot, testHooks: options.testHooks });
|
|
125
127
|
return { ...result, sourceContent, updatedPaths: [] };
|
|
126
128
|
}
|
|
@@ -767,6 +769,7 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
767
769
|
createSection: Boolean(note),
|
|
768
770
|
additionalUpdates: [...(opts.additionalUpdates ?? []), ...(releaseUpdate ? [releaseUpdate] : [])],
|
|
769
771
|
creations: opts.creations,
|
|
772
|
+
guards: opts.guards,
|
|
770
773
|
testHooks: opts.testHooks,
|
|
771
774
|
});
|
|
772
775
|
if (!noIndex && !opts.deferIndex) regenIndex(config);
|
|
@@ -833,6 +836,7 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
833
836
|
testHooks: opts.testHooks,
|
|
834
837
|
additionalUpdates: [...(opts.additionalUpdates ?? []), ...(releaseUpdate ? [releaseUpdate] : [])],
|
|
835
838
|
creations: opts.creations,
|
|
839
|
+
guards: opts.guards,
|
|
836
840
|
skipInboundRefs: opts.skipInboundRefs,
|
|
837
841
|
bodyTransform: closeoutTemplate ? currentBody => {
|
|
838
842
|
committedCloseoutAction = planCloseoutInjection(currentBody);
|
package/src/pickup.mjs
CHANGED
|
@@ -258,19 +258,28 @@ export function preparePlanClaim({ filePath, sourceContent, renderedContent, own
|
|
|
258
258
|
const content = recordContent({ identity, sessionId, state: 'owned', now,
|
|
259
259
|
claimedAt: ownership && !ownership.corrupt ? ownership.claimedAt : null, operation });
|
|
260
260
|
const updates = [];
|
|
261
|
+
const guards = [];
|
|
261
262
|
if (renderedContent !== null && renderedContent !== sourceContent) {
|
|
262
263
|
updates.push({ path: filePath, expectedContent: sourceContent, content: renderedContent });
|
|
264
|
+
} else {
|
|
265
|
+
// An `adopt` claim writes only the ownership record — the plan is already
|
|
266
|
+
// `in-session`, so there is nothing to render. The DECISION still rests on
|
|
267
|
+
// the plan's status, which was read before this mutation was prepared, so
|
|
268
|
+
// the plan file joins as a read-only guard. Without it, a concurrent
|
|
269
|
+
// `set active` could win the status write while this claim takes ownership,
|
|
270
|
+
// leaving a record that owns a plan the file calls `active`.
|
|
271
|
+
guards.push({ path: filePath, expectedContent: sourceContent });
|
|
263
272
|
}
|
|
264
273
|
const creations = [];
|
|
265
274
|
if (ownership) updates.push({ path: recordPath, expectedContent: ownership.raw, content });
|
|
266
275
|
else creations.push({ path: recordPath, content });
|
|
267
|
-
return { identity, recordPath, updates, creations, operationId };
|
|
276
|
+
return { identity, recordPath, updates, creations, guards, operationId };
|
|
268
277
|
}
|
|
269
278
|
|
|
270
279
|
export function commitPlanClaim(args) {
|
|
271
280
|
const prepared = preparePlanClaim(args);
|
|
272
281
|
mkdirSync(path.dirname(prepared.recordPath), { recursive: true });
|
|
273
|
-
mutateFileSet({ updates: prepared.updates, creations: prepared.creations }, {
|
|
282
|
+
mutateFileSet({ updates: prepared.updates, creations: prepared.creations, guards: prepared.guards }, {
|
|
274
283
|
repoRoot: args.config.repoRoot,
|
|
275
284
|
testHooks: args.testHooks,
|
|
276
285
|
});
|
package/src/prompts.mjs
CHANGED
|
@@ -289,6 +289,10 @@ export async function consumePrompt(filePath, config, opts) {
|
|
|
289
289
|
skipInboundRefs: true,
|
|
290
290
|
additionalUpdates: linkedClaim?.prepared?.updates,
|
|
291
291
|
creations: linkedClaim?.prepared?.creations,
|
|
292
|
+
// An adopt-shaped claim writes no plan content, so the plan file rides
|
|
293
|
+
// along as a read-only guard — the claim is only valid while the plan still
|
|
294
|
+
// says what the disposition was read from.
|
|
295
|
+
guards: linkedClaim?.prepared?.guards,
|
|
292
296
|
});
|
|
293
297
|
const consumedBody = archiveResult?.consumedBody ?? body;
|
|
294
298
|
|