dotmd-cli 0.87.2 → 0.89.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/README.md CHANGED
@@ -37,13 +37,28 @@ integration for whichever host you run:
37
37
  ```bash
38
38
  runlist install # what's installed for each host
39
39
  runlist install claude # Claude Code plugin (marketplace + plugin)
40
+ runlist install codex # Codex skill and hooks (personal marketplace)
40
41
  runlist install opencode # OpenCode plugin (one auto-discovered file)
41
42
  runlist doctor --session # what identity runlist sees here, and from where
42
43
  ```
43
44
 
44
- Both are one-time and global; `runlist update` keeps them in step with the CLI.
45
- Codex needs no install for identity: it exports `CODEX_THREAD_ID` to every tool
46
- shell, and runlist reads it as a per-session identity automatically.
45
+ The integrations are one-time and global. Codex exports `CODEX_THREAD_ID` to
46
+ every tool shell, so its identity works without the plugin; the plugin adds a
47
+ workflow skill, session orientation, and the tool guard.
48
+
49
+ ### Codex Plugin
50
+
51
+ `runlist install codex` copies the plugin included in the npm package into the
52
+ personal Codex marketplace and runs `codex plugin add runlist-codex@personal`.
53
+ Codex asks you to review and trust plugin hooks separately. Start a new thread
54
+ after installation so the skill and SessionStart primer load. The hooks use
55
+ the globally installed `runlist` CLI; a missing CLI produces one install hint
56
+ and otherwise leaves tool calls alone. `CODEX_THREAD_ID` stays the ownership
57
+ source. The CLI refuses to overwrite a plugin directory it did not generate.
58
+
59
+ Direct prompt reads warn by default in Codex and Claude. A repository can set
60
+ `export const guard = { promptReads: 'deny' };` to block reads of existing
61
+ pending prompts; `runlist prompts show` remains the read-only inspection verb.
47
62
 
48
63
  ### Claude Code Plugin
49
64
 
@@ -72,6 +87,9 @@ supplies the two things the CLI cannot get on its own:
72
87
  - **A session-start briefing**, the equivalent of Claude Code's SessionStart
73
88
  hook. OpenCode's Claude Code compatibility covers skills and the system
74
89
  prompt, not hooks, so nothing else runs `runlist hud`.
90
+ - **Prompt-read guardrails.** Recognized reads are checked before execution so
91
+ the opt-in strict policy can stop them. Default warnings are attached to the
92
+ result because OpenCode's before hook cannot return model context.
75
93
 
76
94
  Restart OpenCode after installing. The file is version-stamped (`runlist-generated:`, or
77
95
  `dotmd-generated:` from older releases); a `dotmd.js` without either stamp is
@@ -131,7 +149,15 @@ runlist baton @/tmp/resume.md
131
149
  ```
132
150
 
133
151
  Baton refuses when a handoff for the same work is already pending, so one piece
134
- of work never has two resume prompts.
152
+ of work never has two resume prompts. Inspect the pending handoff with
153
+ `runlist prompts show <slug>`. Keep it if current; if stale, pass `--replace`
154
+ with a new draft. Baton archives the previous text and refreshes the pending
155
+ prompt in the same operation as any plan release.
156
+
157
+ Repositories with a commit wrapper can export `batonCommitCommand(message, paths)`
158
+ from `dotmd.config.mjs` and return an argv array such as
159
+ `['just', 'commit', message, ...paths]`. Baton quotes the printed command and
160
+ excludes session prompts from `paths`.
135
161
 
136
162
  Saved prompts are local session state. Consume them with `runlist use`; inspect
137
163
  without consuming via `runlist prompts show`. Consuming a baton prompt also claims
@@ -11,10 +11,8 @@
11
11
  // that isn't one — so an exported helper wouldn't just be untidy, it would
12
12
  // be *called* as a second plugin. Helpers stay module-local.
13
13
  //
14
- // 2. NO HOOK MAY THROW. OpenCode awaits hook callbacks inside the request it
15
- // is serving; a rejected promise fails the user's chat turn. Every hook
16
- // body is wrapped, and a failure degrades to "runlist does nothing here"
17
- // rather than to a broken session.
14
+ // 2. Only a deliberate guard denial may throw. OpenCode awaits hooks inside
15
+ // the chat request, so every other failure degrades to a no-op.
18
16
  //
19
17
  // Runs under Bun inside the OpenCode process. Node builtins only, no deps.
20
18
 
@@ -64,12 +62,9 @@ function runCli(directory, args, input = null) {
64
62
  });
65
63
  }
66
64
 
67
- // OpenCode runs no Claude Code hooks, so `runlist guard` never sees its tool
68
- // calls, and sessions opened pending prompts with the read tool, which prints a
69
- // prompt without archiving it. The guard's answer for a prompt read is a
70
- // warning, not a block, so it is applied after the call: the teaching text is
71
- // appended to what the agent sees. Only calls that name a prompt file reach
72
- // the CLI; everything else costs a regex.
65
+ // OpenCode runs no Claude Code hooks. Evaluate recognized prompt reads before
66
+ // the tool so opt-in strict mode can block them. Its V1 before hook cannot
67
+ // return model context, so ordinary warnings are appended to the result.
73
68
  const PROMPT_FILE = /(^|[\\/])prompts[\\/]\S*\.md\b/;
74
69
 
75
70
  function guardPayload(tool, args) {
@@ -86,6 +81,14 @@ export default async function dotmdOpencodePlugin({ directory }) {
86
81
  // Keyed by session so a subagent session primes independently, the way
87
82
  // SubagentStart does under Claude Code.
88
83
  const primers = new Map();
84
+ const guardDecisions = new Map();
85
+
86
+ async function guardFor(tool, args) {
87
+ const payload = guardPayload(tool, args);
88
+ if (!payload) return null;
89
+ const raw = await runCli(directory, ['guard'], JSON.stringify(payload));
90
+ return raw ? JSON.parse(raw)?.hookSpecificOutput ?? null : null;
91
+ }
89
92
 
90
93
  async function primerFor(sessionId) {
91
94
  const key = sessionId ?? '';
@@ -101,6 +104,18 @@ export default async function dotmdOpencodePlugin({ directory }) {
101
104
  }
102
105
 
103
106
  return {
107
+ 'tool.execute.before': async (input, output) => {
108
+ let refusal = null;
109
+ try {
110
+ const decision = await guardFor(input?.tool, output?.args);
111
+ if (input?.callID && decision) guardDecisions.set(input.callID, decision);
112
+ if (decision?.permissionDecision === 'deny') {
113
+ if (input?.callID) guardDecisions.delete(input.callID);
114
+ refusal = decision.permissionDecisionReason;
115
+ }
116
+ } catch { /* a missing/broken guard must not fail the chat turn */ }
117
+ if (refusal) throw new Error(`[runlist] ${refusal}`);
118
+ },
104
119
  // Ownership identity. OpenCode sets no session-id variable of its own, and
105
120
  // `OPENCODE_PID` — what runlist falls back to without this plugin — names the
106
121
  // OpenCode *process*, so every session in one TUI shares it and can release
@@ -126,10 +141,10 @@ export default async function dotmdOpencodePlugin({ directory }) {
126
141
  // nothing left to refuse.
127
142
  'tool.execute.after': async (input, output) => {
128
143
  try {
129
- const payload = guardPayload(input?.tool, input?.args);
130
- if (!payload || typeof output?.output !== 'string') return;
131
- const raw = await runCli(directory, ['guard'], JSON.stringify(payload));
132
- const note = raw ? JSON.parse(raw)?.hookSpecificOutput?.additionalContext : null;
144
+ if (typeof output?.output !== 'string') return;
145
+ const decision = input?.callID ? guardDecisions.get(input.callID) : null;
146
+ if (input?.callID) guardDecisions.delete(input.callID);
147
+ const note = (decision ?? await guardFor(input?.tool, input?.args))?.additionalContext;
133
148
  if (typeof note === 'string' && note) output.output += `\n\n${note}`;
134
149
  } catch { /* a guard failure never touches the tool result */ }
135
150
  },
package/bin/dotmd.mjs CHANGED
@@ -147,8 +147,9 @@ with a one-line recap naming the habit to break.`,
147
147
 
148
148
  runlist install report what is installed for each known host
149
149
  runlist install claude install the Claude Code plugin (marketplace + plugin)
150
+ runlist install codex install/refresh the Codex plugin (personal marketplace)
150
151
  runlist install opencode install/refresh the OpenCode plugin (global config dir)
151
- runlist install <host> --remove
152
+ runlist install opencode --remove
152
153
  runlist install opencode --path <dir> write to a specific plugin directory
153
154
  runlist install opencode --force overwrite a runlist.js runlist did not write
154
155
 
@@ -168,6 +169,12 @@ host gets that a different way:
168
169
  under extraKnownMarketplaces with a source that no longer matches;
169
170
  runlist names the field but never edits that file.
170
171
 
172
+ codex Copies the npm-packaged runlist-codex plugin into the personal
173
+ marketplace and invokes \`codex plugin add\`. It supplies a compact
174
+ skill, SessionStart orientation, and a PreToolUse guard. Codex
175
+ requires a separate hook trust review; start a new thread after
176
+ installing. CODEX_THREAD_ID remains the ownership identity.
177
+
171
178
  opencode Writes one auto-discovered plugin file. OpenCode has no plugin
172
179
  registry but globs \`{plugin,plugins}/*.{ts,js}\` under its global
173
180
  config dir, so no opencode.json edit is needed. It supplies two
@@ -302,7 +309,7 @@ Create & Export:
302
309
 
303
310
  Setup:
304
311
  init Create starter config + docs directory
305
- install [opencode] Install the agent-host integration (no arg = status)
312
+ install [claude|codex|opencode] Install the agent-host integration (no arg = status)
306
313
  update [--check|--cli-only|--plugin-only] Update the CLI + Claude Code plugin (--check reports skew, no network)
307
314
  statuses [list|add|set|remove|migrate] Manage per-project status taxonomy
308
315
  help statuses Full status vocabulary + unstuck-actions + transitions
@@ -849,7 +856,7 @@ Modes:
849
856
  --migrate-prompts Retrofit pre-existing markdown files under any docs
850
857
  root's prompts/ subdirectory with proper prompt
851
858
  frontmatter (type, status, created from git
852
- history, dotmd_version, context, related_plans).
859
+ history, runlist_version, context, related_plans).
853
860
  Skips files that already have frontmatter.
854
861
  --migrate-template <file> Migrate just one plan.
855
862
  --migrate-template --include-archived
@@ -1271,13 +1278,21 @@ Options:
1271
1278
  --note "why" Append the reason to ## Version History (plan mode only)
1272
1279
  --message / --body Inline body (one-liners; prefer @path or stdin)
1273
1280
  --force Recover another session's plan (explicit path required)
1281
+ --replace Replace exactly one pending handoff; archive its prior text
1274
1282
  --json Structured repository/session/generated file result
1275
1283
  --dry-run, -n Preview without writing
1276
1284
 
1285
+ Repo-specific commit hint (dotmd.config.mjs):
1286
+ export function batonCommitCommand(message, paths) {
1287
+ return ['just', 'commit', message, ...paths];
1288
+ }
1289
+ The function returns argv; baton shell-quotes each argument before printing.
1290
+
1277
1291
  Examples:
1278
1292
  runlist baton @/tmp/draft.md
1279
1293
  runlist baton checkout-fixes @/tmp/draft.md
1280
1294
  runlist baton docs/plans/auth.md @/tmp/draft.md
1295
+ runlist baton docs/plans/auth.md @/tmp/new-draft.md --replace
1281
1296
  runlist baton --status paused --note "blocked on review" @/tmp/d.md
1282
1297
  cat /tmp/draft.md | runlist baton
1283
1298
 
@@ -1296,11 +1311,13 @@ cooperating transaction:
1296
1311
  grant ownership. A live pickup-hook delivery lease blocks release and force
1297
1312
  takeover; hooks are at-least-once and deduplicate the stable operationId.
1298
1313
 
1299
- Slug mode (no plan involved) saves resume-<slug> and touches nothing else: no
1300
- status change, no commit. A bare word that names a plan is treated as that plan.
1314
+ Slug mode (no plan involved) saves resume-<slug> without a status change or
1315
+ commit. A bare word that names a plan is treated as that plan.
1301
1316
 
1302
1317
  Baton saves nothing while a handoff for the same work is pending (resume-<name>,
1303
- or a pending prompt linked to the plan): consume or archive it, then re-run.
1318
+ or a pending prompt linked to the plan): inspect it with \`runlist prompts show\`.
1319
+ Keep it if current; use \`--replace\` with a new draft if stale. Replacement
1320
+ preserves the prior text under archived/ and refuses multiple matches.
1304
1321
  With no @file, \`-\` or --message, baton reads stdin only when something is piped
1305
1322
  in; an open pipe that sends nothing is given up on after a moment.`,
1306
1323
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.87.2",
3
+ "version": "0.89.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",
@@ -16,6 +16,7 @@
16
16
  "bin/",
17
17
  "src/",
18
18
  "assets/",
19
+ "plugins/runlist-codex/",
19
20
  "scripts/postinstall.mjs",
20
21
  "runlist.config.example.mjs"
21
22
  ],
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "runlist-codex",
3
+ "version": "0.89.1",
4
+ "description": "Runlist workflow skill, session orientation, and tool guard for Codex",
5
+ "author": {
6
+ "name": "Reo Owens"
7
+ },
8
+ "skills": "./skills/",
9
+ "interface": {
10
+ "displayName": "Runlist Codex",
11
+ "shortDescription": "Plan, handoff, and document workflow for Codex.",
12
+ "longDescription": "Adds a concise runlist skill, session primer, and prompt/status guard hooks for managed Markdown repositories.",
13
+ "developerName": "Reo Owens",
14
+ "category": "Productivity",
15
+ "capabilities": [],
16
+ "defaultPrompt": "Show my runlist plans and pending handoffs."
17
+ }
18
+ }
@@ -0,0 +1,86 @@
1
+ #!/bin/sh
2
+ # runlist Codex plugin hook wrapper.
3
+ #
4
+ # The plugin's hooks drive the whole workflow through the PATH `runlist` binary
5
+ # (`runlist hud`, `runlist guard`), falling back to the legacy `dotmd` alias. If a user enables the plugin but hasn't run
6
+ # `npm i -g dotmd-cli`, the binary is missing, the hook errors `command not
7
+ # found`, and the session is primed with *silent nothing* — no signal that the
8
+ # whole workflow is one install away. runlist can't warn about its own absence
9
+ # (it's the thing that's missing), so the check lives here, outside the binary.
10
+ #
11
+ # Usage: runlist-hook [--hint] <runlist-subcommand> [args...]
12
+ # --hint emit a one-line install hint to stdout when no CLI is on PATH.
13
+ # Only SessionStart passes this, so the hint surfaces once per hook
14
+ # invocation (stdout becomes session context), never on a tool call.
15
+ #
16
+ # When the CLI is missing we always exit 0 with no further output: hooks must
17
+ # stay non-blocking, and the PreToolUse guard in particular must never deny a
18
+ # tool just because the optional CLI isn't installed.
19
+ hint=0
20
+ if [ "$1" = "--hint" ]; then
21
+ hint=1
22
+ shift
23
+ fi
24
+
25
+ if command -v runlist >/dev/null 2>&1; then
26
+ CLI=runlist
27
+ elif command -v dotmd >/dev/null 2>&1; then
28
+ CLI=dotmd
29
+ else
30
+ if [ "$hint" = "1" ]; then
31
+ echo "runlist plugin: neither \`runlist\` nor \`dotmd\` is on PATH, so its hooks are no-ops. Enable the workflow with: npm i -g dotmd-cli"
32
+ fi
33
+ exit 0
34
+ fi
35
+
36
+ # PreToolUse fast path: the guard fires on matched Bash, apply_patch, and MCP
37
+ # read calls, and
38
+ # a full Node boot (~100ms: CLI startup + config discovery) is pure overhead for
39
+ # the vast majority of payloads no guard rule can possibly match. Every rule
40
+ # needs a `.md` path AND either a prompts/ path (commit/cat/read-prompt) or the
41
+ # word `status` (edit-status) somewhere in the payload — a cheap shell glob
42
+ # decides that without leaving sh. Anything else gets the no-opinion reply
43
+ # (`{}`) directly. False positives here just pay the old cost (Node runs and
44
+ # returns {}); false negatives are impossible because the globs are strictly
45
+ # weaker than the rules they gate.
46
+ if [ "$1" = "guard" ]; then
47
+ payload=$(cat)
48
+ # Broad Git forms do not name a .md file in the payload; Node inspects the
49
+ # actual eligible/staged path set before deciding whether a live prompt is
50
+ # included. Always route those forms through the guard.
51
+ case "$payload" in
52
+ *git*add*|*git*stage*|*git*commit*)
53
+ printf '%s' "$payload" | "$CLI" "$@"
54
+ exit $?
55
+ ;;
56
+ esac
57
+ case "$payload" in
58
+ *.md*)
59
+ case "$payload" in
60
+ *prompts/*|*prompts\\*|*status*)
61
+ printf '%s' "$payload" | "$CLI" "$@"
62
+ exit $?
63
+ ;;
64
+ esac
65
+ ;;
66
+ esac
67
+ printf '{}\n'
68
+ exit 0
69
+ fi
70
+
71
+ # UserPromptSubmit fast path is retained for compatibility with the Claude
72
+ # wrapper; Codex does not register that hook here. It would fire on every
73
+ # message that asks for a handoff gets an answer. The glob is strictly weaker
74
+ # than the Node-side match, so a skipped payload could never have matched.
75
+ if [ "$1" = "hud" ] && [ "$2" = "--prompt-submit" ]; then
76
+ payload=$(cat)
77
+ case "$payload" in
78
+ *[Bb][Aa][Tt][Oo][Nn]*|*[Hh][Aa][Nn][Dd]*[Oo][Ff][Ff]*|*[Rr][Ee][Ss][Uu][Mm][Ee]*|*[Pp][Ii][Cc][Kk]*[Uu][Pp]*)
79
+ printf '%s' "$payload" | "$CLI" "$@"
80
+ exit 0
81
+ ;;
82
+ esac
83
+ exit 0
84
+ fi
85
+
86
+ exec "$CLI" "$@"
@@ -0,0 +1,16 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "matcher": "startup|resume|clear|compact",
6
+ "hooks": [{ "type": "command", "command": "sh \"${PLUGIN_ROOT}/bin/runlist-hook\" --hint hud", "timeout": 5 }]
7
+ }
8
+ ],
9
+ "PreToolUse": [
10
+ {
11
+ "matcher": "Bash|apply_patch|mcp__.*__(read_file|read_text_file|read_media_file|read_multiple_files)",
12
+ "hooks": [{ "type": "command", "command": "sh \"${PLUGIN_ROOT}/bin/runlist-hook\" guard", "timeout": 5 }]
13
+ }
14
+ ]
15
+ }
16
+ }
@@ -0,0 +1,21 @@
1
+ ---
2
+ name: runlist
3
+ description: Manage a repository's plans, docs, decisions, and saved prompts with the runlist CLI. Use when starting or closing plan work, checking the plan queue, or creating or consuming a handoff.
4
+ ---
5
+
6
+ # Runlist workflow
7
+
8
+ Use this skill in repositories with `runlist.config.mjs` or `dotmd.config.mjs`. The CLI is `runlist` (from `dotmd-cli`; `dotmd` remains an alias). The session hook prints a short live orientation. `CODEX_THREAD_ID` is the session identity used for plan claims; do not replace it with a process ID.
9
+
10
+ - Orient with `runlist plans`. Use `runlist agent-context` for structured state; `runlist briefing` is the comprehensive view and can be large.
11
+ - Start plan work with `runlist use <plan-file>`. It claims the plan, marks it `in-session`, and prints the card.
12
+ - Change status with `runlist set <status> <file> [--note "why"]`; this runs validation, lifecycle hooks, ref repairs, and index updates. Never hand-edit a `status:` field.
13
+ - Close a shipped plan with `runlist archive <file>`; use `partial`, `active`, `awaiting`, or `blocked` when those match the actual state. Valid statuses are repository and type specific: `runlist statuses list --type plan`.
14
+ - For a handoff, write a concrete resume draft, then run `runlist baton [<plan-or-slug>] @<draft-file>`. An owned plan is released in the same operation. If a handoff for that work is pending, inspect it with `runlist prompts show <file>`; keep it if current or pass `--replace` with the new draft. Replacement archives the old text and refreshes exactly one pending prompt.
15
+ - Consume a saved prompt with `runlist use <prompt-file>` (or bare `runlist use` for the oldest). This archives and claims before printing the body. For read-only triage, use `runlist prompts show <file>` or `runlist prompts show --all`; never open pending prompts with a file reader or `cat`.
16
+ - Pending prompts are session-local. Commit the tracked plan or source files named by baton, not `docs/prompts/*.md`.
17
+ - Create a document with `runlist new <type> <name> @<draft-file>`. A draft starting with frontmatter can supply scaffold fields; `type` is fixed by the command.
18
+ - Record a decision with `runlist new decision <plan> --question "…" @<record-file>`; configured registers may also require `--answers`.
19
+ - If a lifecycle mutation reports an abandoned transaction, inspect `runlist doctor --transactions` before retrying. Use `--apply` only for cases the doctor identifies as recoverable.
20
+
21
+ The `PreToolUse` hook warns on direct pending-prompt reads and denies hand-edited status transitions or Git operations that would include a live prompt. A repo can opt into blocking direct reads of existing pending prompts with `guard: { promptReads: 'deny' }`. Archived prompts and `runlist prompts show` remain readable. Codex requires explicit trust of a plugin's hooks before they run.
@@ -13,6 +13,11 @@ export const archiveDir = 'archived';
13
13
  // Directories to skip when scanning
14
14
  export const excludeDirs = ['evidence'];
15
15
 
16
+ // Agent-host tool guard: direct reads of existing pending prompts warn by
17
+ // default. Set promptReads to 'deny' to block those reads before execution;
18
+ // `runlist prompts show` and archived prompt reads remain available.
19
+ // export const guard = { promptReads: 'deny' };
20
+
16
21
  // Floor under the scan surface. `runlist check` fails when it scans fewer docs than
17
22
  // this, so a broken root or an over-eager exclude can't read as a clean estate —
18
23
  // zero errors and zero docs look identical otherwise. Off when unset. Set it well
@@ -1159,7 +1159,7 @@ function reserveExclusive(filePath, mode, content, testHooks = {}) {
1159
1159
  fd = openSync(filePath, 'wx', mode);
1160
1160
  const opened = fstatSync(fd, { bigint: true });
1161
1161
  openedIdentity = { dev: opened.dev, ino: opened.ino };
1162
- const reservationContent = content ?? JSON.stringify({ dotmdReservation: true, pid: process.pid, createdAt: new Date().toISOString() }) + '\n';
1162
+ const reservationContent = content ?? JSON.stringify({ runlistReservation: true, pid: process.pid, createdAt: new Date().toISOString() }) + '\n';
1163
1163
  writeFileSync(fd, reservationContent, 'utf8');
1164
1164
  testHooks.afterReserveWrite?.(filePath);
1165
1165
  testHooks.beforeReserveFsync?.(filePath);
@@ -1310,6 +1310,10 @@ export function moveFileAtomic(sourcePath, targetPath, render, options) {
1310
1310
  // transaction manifest exists, so a guard conflict can never leave a
1311
1311
  // transaction to recover from.
1312
1312
  for (const guard of guards) {
1313
+ if (guard.absent) {
1314
+ if (existsSync(guard.path)) throw new MutationConflictError(`File appeared while the move mutation set was being prepared: ${path.resolve(guard.path)}`);
1315
+ continue;
1316
+ }
1313
1317
  const snapshot = snapshotFile(guard.path);
1314
1318
  if (guard.expectedContent !== undefined && snapshot.content !== guard.expectedContent) {
1315
1319
  throw new MutationConflictError(`File changed while the move mutation set was being prepared: ${snapshot.path}`);
@@ -1353,7 +1357,7 @@ export function moveFileAtomic(sourcePath, targetPath, render, options) {
1353
1357
  for (const item of creations) ensureTransactionDirectory(path.dirname(item.path), transaction, options, item.path);
1354
1358
  try {
1355
1359
  reservation = reserveExclusive(targetPath, source.identity.mode, JSON.stringify({
1356
- dotmdReservation: true,
1360
+ runlistReservation: true,
1357
1361
  transactionId,
1358
1362
  sourcePath,
1359
1363
  backup,
@@ -1657,6 +1661,10 @@ export function mutateFileSet({ updates = [], creations = [], guards = [] }, opt
1657
1661
  // is created — a guard conflict must leave the tree byte-identical, not even
1658
1662
  // an empty directory behind.
1659
1663
  for (const guard of guards) {
1664
+ if (guard.absent) {
1665
+ if (existsSync(guard.path)) throw new MutationConflictError(`File appeared while the mutation set was being prepared: ${path.resolve(guard.path)}`);
1666
+ continue;
1667
+ }
1660
1668
  const snapshot = snapshotFile(guard.path);
1661
1669
  if (guard.expectedContent !== undefined && snapshot.content !== guard.expectedContent) {
1662
1670
  throw new MutationConflictError(`File changed while the mutation set was being prepared: ${snapshot.path}`);