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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.74.0",
3
+ "version": "0.74.1",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, lifecycle, and AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
- export function mutateFileSet({ updates = [], creations = [] }, options) {
1631
- const paths = [...updates.map(item => item.path), ...creations.map(item => item.path)];
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