dotmd-cli 0.77.3 → 0.78.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 CHANGED
@@ -125,8 +125,15 @@ operation:
125
125
  dotmd baton @/tmp/resume.md
126
126
  ```
127
127
 
128
+ Baton refuses when a handoff for the same work is already pending, so one piece
129
+ of work never has two resume prompts.
130
+
128
131
  Saved prompts are local session state. Consume them with `dotmd use`; inspect
129
- without consuming via `dotmd prompts show`.
132
+ without consuming via `dotmd prompts show`. Consuming a baton prompt also claims
133
+ its plan; `dotmd use --no-claim` reads and archives it without starting the plan.
134
+
135
+ New plans are created `planned`; `dotmd use` starts one, and
136
+ `dotmd new plan <name> --status <status>` sets a different starting status.
130
137
 
131
138
  ## Document Format
132
139
 
@@ -32,7 +32,9 @@ function cliExecutables() {
32
32
  return process.platform === 'win32' ? ['runlist.cmd', 'dotmd.cmd'] : ['runlist', 'dotmd'];
33
33
  }
34
34
 
35
- function runHud(directory) {
35
+ // Runs the CLI and resolves its trimmed stdout, or '' on any failure. `input`,
36
+ // when given, is written to the child's stdin.
37
+ function runCli(directory, args, input = null) {
36
38
  return new Promise(resolve => {
37
39
  let settled = false;
38
40
  const done = value => { if (!settled) { settled = true; resolve(value); } };
@@ -40,7 +42,7 @@ function runHud(directory) {
40
42
  const attempt = index => {
41
43
  if (index >= candidates.length) { done(''); return; }
42
44
  try {
43
- execFile(candidates[index], ['hud'], {
45
+ const child = execFile(candidates[index], args, {
44
46
  cwd: directory,
45
47
  timeout: PRIMER_TIMEOUT_MS,
46
48
  windowsHide: true,
@@ -49,12 +51,33 @@ function runHud(directory) {
49
51
  if (error?.code === 'ENOENT') attempt(index + 1);
50
52
  else done(error ? '' : (stdout ?? '').trim());
51
53
  });
54
+ child.stdin?.on('error', () => {});
55
+ if (input !== null) child.stdin?.end(input);
56
+ else child.stdin?.end();
52
57
  } catch { attempt(index + 1); }
53
58
  };
54
59
  attempt(0);
55
60
  });
56
61
  }
57
62
 
63
+ // OpenCode runs no Claude Code hooks, so `dotmd guard` never sees its tool
64
+ // calls, and sessions opened pending prompts with the read tool, which prints a
65
+ // prompt without archiving it. The guard's answer for a prompt read is a
66
+ // warning, not a block, so it is applied after the call: the teaching text is
67
+ // appended to what the agent sees. Only calls that name a prompt file reach
68
+ // the CLI; everything else costs a regex.
69
+ const PROMPT_FILE = /(^|[\\/])prompts[\\/]\S*\.md\b/;
70
+
71
+ function guardPayload(tool, args) {
72
+ if (tool === 'read' && typeof args?.filePath === 'string' && PROMPT_FILE.test(args.filePath)) {
73
+ return { tool_name: 'Read', tool_input: { file_path: args.filePath } };
74
+ }
75
+ if (tool === 'bash' && typeof args?.command === 'string' && PROMPT_FILE.test(args.command)) {
76
+ return { tool_name: 'Bash', tool_input: { command: args.command } };
77
+ }
78
+ return null;
79
+ }
80
+
58
81
  export default async function dotmdOpencodePlugin({ directory }) {
59
82
  // Keyed by session so a subagent session primes independently, the way
60
83
  // SubagentStart does under Claude Code.
@@ -68,7 +91,7 @@ export default async function dotmdOpencodePlugin({ directory }) {
68
91
  // only the first request — and a never-refreshed one would keep announcing
69
92
  // a pending prompt this session already consumed.
70
93
  if (cached && Date.now() - cached.at < PRIMER_TTL_MS) return cached.text;
71
- const text = await runHud(directory);
94
+ const text = await runCli(directory, ['hud']);
72
95
  primers.set(key, { text, at: Date.now() });
73
96
  return text;
74
97
  }
@@ -93,6 +116,19 @@ export default async function dotmdOpencodePlugin({ directory }) {
93
116
  } catch { /* never break a shell over this */ }
94
117
  },
95
118
 
119
+ // The guard's warnings, appended after the call ran. A `deny` from the
120
+ // guard is ignored here: the call has already happened, so there is
121
+ // nothing left to refuse.
122
+ 'tool.execute.after': async (input, output) => {
123
+ try {
124
+ const payload = guardPayload(input?.tool, input?.args);
125
+ if (!payload || typeof output?.output !== 'string') return;
126
+ const raw = await runCli(directory, ['guard'], JSON.stringify(payload));
127
+ const note = raw ? JSON.parse(raw)?.hookSpecificOutput?.additionalContext : null;
128
+ if (typeof note === 'string' && note) output.output += `\n\n${note}`;
129
+ } catch { /* a guard failure never touches the tool result */ }
130
+ },
131
+
96
132
  // Session priming — the equivalent of the SessionStart hook that runs
97
133
  // `dotmd hud` under Claude Code. Silent outside a dotmd repo (hud prints
98
134
  // nothing and exits 0), so this is inert in unrelated projects.
package/bin/dotmd.mjs CHANGED
@@ -1150,6 +1150,10 @@ Subcommands:
1150
1150
  slug matching a prompt basename, or a unique substring of a prompt
1151
1151
  path. Ambiguous substrings error with the candidate list.
1152
1152
 
1153
+ A prompt saved by \`dotmd baton\` links its plan; consuming it (\`use\`,
1154
+ \`next\`, or top-level \`dotmd use\`) also claims that plan for this session.
1155
+ Pass \`--no-claim\` to read and archive the prompt without starting the plan.
1156
+
1153
1157
  Default prompt statuses: pending, archived.
1154
1158
 
1155
1159
  Examples:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.77.3",
3
+ "version": "0.78.0",
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",
package/src/baton.mjs CHANGED
@@ -1,7 +1,7 @@
1
- import { readFileSync, existsSync, writeFileSync } from 'node:fs';
1
+ import { readFileSync, existsSync, writeFileSync, realpathSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
- import { asString, toRepoPath, die, warn } from './util.mjs';
4
+ import { asString, toRepoPath, die, warn, isArchivedPath, resolveRefPath } from './util.mjs';
5
5
  import { buildIndex, resolveDocArg } from './index.mjs';
6
6
  import { preparePromptDocument, runNew, readBodyInput, readPipedBodyInput } from './new.mjs';
7
7
  import { ensurePlanCompletionBeforeRelease, planHasPendingCompletion, runSet } from './lifecycle.mjs';
@@ -26,11 +26,42 @@ export function findOwnedPlan(config, index = null) {
26
26
  return { plan: clean && owned.length === 1 ? owned[0] : null, via: clean && owned.length === 1 ? 'ownership' : null, inSession, owned, diagnostics: records.diagnostics ?? [] };
27
27
  }
28
28
 
29
- const BODY_USAGE = `dotmd baton needs the resume draft as its body. Write 10–20 lines first — the next concrete decision plus any gotchas, NOT a recap of the plan — then:
30
- dotmd baton @/tmp/draft.md # body from file (preferred)
31
- cat /tmp/draft.md | dotmd baton # body from stdin
32
- dotmd baton --message "..." # one-liner
33
- No plan in-session? Name the handoff instead: dotmd baton <slug> @/tmp/draft.md`;
29
+ // One line that says what is missing, then one example. Baton never drafts the
30
+ // resume itself: the session that did the work is the only one that knows the
31
+ // next decision, and a prompt assembled from frontmatter reads like a handoff
32
+ // while carrying nothing the plan doesn't already say.
33
+ const BODY_USAGE = `Nothing saved: baton needs the resume you wrote, passed as @<file> or - (stdin).
34
+ dotmd baton [<plan-or-slug>] @/tmp/draft.md`;
35
+
36
+ // A handoff that lands beside a pending one leaves two prompts for the same
37
+ // work, and the next session picks whichever sorts first. Baton used to step to
38
+ // `resume-<x>-2` silently; it now refuses and names what is waiting, so the
39
+ // older prompt is consumed or archived on purpose. Plan mode also catches a
40
+ // pending prompt under a different name that links the same plan.
41
+ function pendingHandoffs(promptPath, planPath, config) {
42
+ const found = new Set();
43
+ if (existsSync(promptPath)) found.add(toRepoPath(promptPath, config.repoRoot));
44
+ if (planPath) {
45
+ const planReal = realpathSync(planPath);
46
+ const index = buildIndex(config, { fast: true, invokeHooks: false });
47
+ for (const doc of index.docs) {
48
+ if (doc.type !== 'prompt' || doc.status === 'archived' || isArchivedPath(doc.path, config)) continue;
49
+ const abs = path.resolve(config.repoRoot, doc.path);
50
+ let planRef;
51
+ try { planRef = asString(parseSimpleFrontmatter(extractFrontmatter(readFileSync(abs, 'utf8')).frontmatter).plan); }
52
+ catch { continue; }
53
+ const linked = planRef ? resolveRefPath(planRef, path.dirname(abs), config.repoRoot) : null;
54
+ if (linked && realpathSync(linked) === planReal) found.add(doc.path);
55
+ }
56
+ }
57
+ return [...found];
58
+ }
59
+
60
+ function refusePendingHandoff(pending) {
61
+ const lines = pending.map(p => ` ${p}`).join('\n');
62
+ const slug = path.basename(pending[0], '.md');
63
+ die(`Nothing saved: a handoff for this is already pending:\n${lines}\nUse it (\`dotmd use ${slug}\`) or archive it (\`dotmd prompts archive ${pending[0]}\`), then run baton again.`);
64
+ }
34
65
 
35
66
  // Is this positional a filesystem reference (must resolve, typos die) or a
36
67
  // bare word (may be a plan slug, may be a brand-new handoff name)?
@@ -114,6 +145,14 @@ export async function runBaton(argv, config, opts = {}) {
114
145
  }
115
146
  }
116
147
 
148
+ const nameBase = planPath ? path.basename(planPath, '.md') : promptSlug;
149
+ const slugBase = nameBase.startsWith('resume-') ? nameBase : `resume-${nameBase}`;
150
+ const refuseIfPending = () => {
151
+ const target = preparePromptDocument(slugBase, body, config, { dryRun: true });
152
+ const pending = pendingHandoffs(target.filePath, planPath, config);
153
+ if (pending.length) refusePendingHandoff(pending);
154
+ };
155
+
117
156
  let repoPath = null;
118
157
  let oldStatus = null;
119
158
  let ownershipPath = null;
@@ -140,18 +179,19 @@ export async function runBaton(argv, config, opts = {}) {
140
179
  die('`dotmd baton --status in-session` contradicts baton release semantics. Choose active/paused/awaiting/partial/blocked.');
141
180
  }
142
181
  assertPlanMutationAuthorized(repoPath, config, { sessionId: authoritativeSessionId(), force });
182
+ // Before the plan-completion step: a refusal must leave nothing changed.
183
+ refuseIfPending();
143
184
  ownershipPath = readPlanOwnership(repoPath, config)?.recordPath ?? null;
144
185
  if (!dryRun) ensurePlanCompletionBeforeRelease(repoPath, config, { testHooks: opts.testHooks });
145
186
  else if (planHasPendingCompletion(repoPath, config)) process.stderr.write(`${dim('[dry-run]')} Pending claim completion would block this release.\n`);
146
187
  } else {
188
+ refuseIfPending();
147
189
  if (statusFlag) warn(`--status ignored — no plan involved in this handoff (saving the prompt only).`);
148
190
  if (note) warn(`--note ignored — no plan involved in this handoff (notes land in a plan's Version History).`);
149
191
  }
150
192
 
151
193
  // Plan mode publishes the already-stamped prompt, status/history update, and
152
194
  // ownership release in one transaction. Slug mode has no plan transaction.
153
- const nameBase = planPath ? path.basename(planPath, '.md') : promptSlug;
154
- const slugBase = nameBase.startsWith('resume-') ? nameBase : `resume-${nameBase}`;
155
195
  let createdSlug = null;
156
196
  let archiveResult = null;
157
197
  let statusChanged = false;
@@ -162,49 +202,45 @@ export async function runBaton(argv, config, opts = {}) {
162
202
  if (json) process.stdout.write = chunk => { muted.push(String(chunk)); return true; };
163
203
  try {
164
204
  if (!planPath) {
165
- for (let n = 1; n <= 9 && !createdSlug; n++) {
166
- const slug = n === 1 ? slugBase : `${slugBase}-${n}`;
167
- try {
168
- const prepared = preparePromptDocument(slug, body, config, { dryRun });
169
- newResult = await runNew(['prompt', slug, '--body', body], config, { dryRun, deferIndex: true });
170
- createdSlug = slug;
171
- promptRepoPath = prepared.repoPath;
172
- }
173
- catch (err) { if (!/File already exists/.test(String(err?.message))) throw err; }
205
+ const prepared = preparePromptDocument(slugBase, body, config, { dryRun });
206
+ try {
207
+ newResult = await runNew(['prompt', slugBase, '--body', body], config, { dryRun, deferIndex: true });
208
+ } catch (err) {
209
+ // Lost a race with another baton between the pending check and the write.
210
+ if (/File already exists/.test(String(err?.message))) refusePendingHandoff([prepared.repoPath]);
211
+ throw err;
174
212
  }
213
+ createdSlug = slugBase;
214
+ promptRepoPath = prepared.repoPath;
175
215
  } else {
176
- for (let n = 1; n <= 9 && !createdSlug; n++) {
177
- const candidate = n === 1 ? slugBase : `${slugBase}-${n}`;
178
- const prepared = preparePromptDocument(candidate, body, config, { plan: repoPath, dryRun });
179
- if (existsSync(prepared.filePath)) continue;
180
- const setArgs = [status, planPath];
181
- if (force) setArgs.push('--force');
182
- if (note) setArgs.push('--note', note);
183
- try {
184
- if (dryRun) process.stdout.write(`${dim('[dry-run]')} Would create: ${prepared.repoPath}\n`);
185
- archiveResult = await runSet(setArgs, config, {
186
- dryRun,
187
- viaBaton: true,
188
- testHooks: opts.testHooks,
189
- creations: dryRun ? [] : [{ path: prepared.filePath, content: prepared.content }],
190
- deferIndex: true,
191
- });
192
- createdSlug = prepared.slug;
193
- promptRepoPath = prepared.repoPath;
194
- statusChanged = oldStatus !== status;
195
- if (!dryRun) {
196
- try { config.hooks.onNew?.({ path: prepared.repoPath, status: 'pending', title: prepared.slug, type: 'prompt' }); }
197
- catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
198
- }
199
- } catch (err) {
200
- if (!/Destination already exists|File already exists/.test(String(err?.message))) throw err;
201
- }
216
+ const prepared = preparePromptDocument(slugBase, body, config, { plan: repoPath, dryRun });
217
+ const setArgs = [status, planPath];
218
+ if (force) setArgs.push('--force');
219
+ if (note) setArgs.push('--note', note);
220
+ try {
221
+ if (dryRun) process.stdout.write(`${dim('[dry-run]')} Would create: ${prepared.repoPath}\n`);
222
+ archiveResult = await runSet(setArgs, config, {
223
+ dryRun,
224
+ viaBaton: true,
225
+ testHooks: opts.testHooks,
226
+ creations: dryRun ? [] : [{ path: prepared.filePath, content: prepared.content }],
227
+ deferIndex: true,
228
+ });
229
+ } catch (err) {
230
+ if (/Destination already exists|File already exists/.test(String(err?.message))) refusePendingHandoff([prepared.repoPath]);
231
+ throw err;
232
+ }
233
+ createdSlug = prepared.slug;
234
+ promptRepoPath = prepared.repoPath;
235
+ statusChanged = oldStatus !== status;
236
+ if (!dryRun) {
237
+ try { config.hooks.onNew?.({ path: prepared.repoPath, status: 'pending', title: prepared.slug, type: 'prompt' }); }
238
+ catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
202
239
  }
203
240
  }
204
241
  } finally {
205
242
  if (json) process.stdout.write = originalStdoutWrite;
206
243
  }
207
- if (!createdSlug) die(`Could not find a free prompt slug for ${slugBase} (tried ${slugBase}-2 … ${slugBase}-9).`);
208
244
 
209
245
  // A release status can FILE the plan into a bucket (`lifecycle.filedStatuses`,
210
246
  // e.g. paused → docs/plans/held/). The prompt is created inside the same
package/src/commands.mjs CHANGED
@@ -92,16 +92,16 @@ const definitions = [
92
92
  command('prompts', mutates('managed sources/destinations by subcommand'), 'workflow', [
93
93
  form('', { options: [flag('--json'), value('--status'), flag('--include-archived'), value('--sort'), value('--limit'), flag('--all')] }),
94
94
  form('list', { subcommands: ['list', 'status'], options: [flag('--json'), value('--status'), flag('--include-archived'), value('--sort'), value('--limit'), flag('--all')] }),
95
- form('next', { subcommands: ['next'] }),
96
- form('use [file]', { subcommands: ['use', 'resume'], args: positionals(0, 1), options: [flag('--no-index'), flag('--show-files'), flag('--force')] }),
95
+ form('next', { subcommands: ['next'], options: [flag('--no-claim')] }),
96
+ form('use [file]', { subcommands: ['use', 'resume'], args: positionals(0, 1), options: [flag('--no-index'), flag('--show-files'), flag('--force'), flag('--no-claim')] }),
97
97
  form('show [file...]', { subcommands: ['show', 'peek'], args: positionals(0, Infinity), options: [flag('--json'), flag('--all'), value('--limit')] }),
98
98
  form('archive <file>', { subcommands: ['archive'], args: positionals(1, 1), options: [flag('--no-index'), flag('--show-files')] }),
99
99
  form('new <slug> [body...]', { subcommands: ['new'], args: positionals(1, Infinity), options: [value('--body', '--message'), value('--title'), value('--status')] }),
100
100
  form('hold <file>', { subcommands: ['hold', 'shelve'], args: positionals(1, 1) }),
101
101
  form('unhold <file>', { subcommands: ['unhold', 'unshelve'], args: positionals(1, 1) }),
102
102
  ], { aliases: ['prompt'] }),
103
- command('use', mutates('managed source when starting/consuming; docs remain read-only'), 'workflow', [form('[file]', { args: positionals(0, 1), options: [flag('--json'), flag('--full'), flag('--no-index'), flag('--show-files'), flag('--force')] })]),
104
- command('next', mutates('managed prompt source and same-root archive destination'), 'workflow', [form('', { options: [flag('--json'), flag('--no-index'), flag('--show-files'), flag('--force')] })]),
103
+ command('use', mutates('managed source when starting/consuming; docs remain read-only'), 'workflow', [form('[file]', { args: positionals(0, 1), options: [flag('--json'), flag('--full'), flag('--no-index'), flag('--show-files'), flag('--force'), flag('--no-claim')] })]),
104
+ command('next', mutates('managed prompt source and same-root archive destination'), 'workflow', [form('', { options: [flag('--json'), flag('--no-index'), flag('--show-files'), flag('--force'), flag('--no-claim')] })]),
105
105
  command('baton', mutates('managed plan/prompt sources and managed prompt destination'), 'workflow', [form('[plan|slug] <@<file>|->', { args: positionals(0, 2), options: [value('--status'), value('--note'), value('--body', '--message'), flag('--force'), flag('--json')] })]),
106
106
  command('runlist', mutates('managed hubs/children and managed scaffold destinations'), 'workflow', [
107
107
  form('<hub>', { args: positionals(1, 1), options: [flag('--json')] }),
package/src/guard.mjs CHANGED
@@ -223,8 +223,11 @@ function editStatusResult(target, config, detail) {
223
223
  rule: 'edit-status',
224
224
  detail,
225
225
  reason:
226
- `Looks like a hand-edit of the \`status:\` field in ${target}. Use \`dotmd set <status> ${target}\` instead — ` +
227
- `it validates the status against this doc's type, runs lifecycle hooks, fixes refs, and keeps the index in sync. Direct edits skip all of that.`,
226
+ // Names the canonical executable: a session that follows this message
227
+ // should land on the current name, not the legacy alias.
228
+ `Looks like a hand-edit of the \`status:\` field in ${target}. Use \`runlist set <status> ${target}\` instead — ` +
229
+ `it validates the status against this doc's type, runs lifecycle hooks, fixes refs, and keeps the index in sync. Direct edits skip all of that. ` +
230
+ `Creating a doc? \`runlist new <type> <name> --status <status>\` writes the status in the same step.`,
228
231
  };
229
232
  }
230
233
 
package/src/new.mjs CHANGED
@@ -132,7 +132,10 @@ ${ctx?.bodyInput?.trim() ?? ''}
132
132
  description: 'Execution plan — build-up shape (Problem → Phases → Closeout) with phase status markers and Version History',
133
133
  dir: 'plans',
134
134
  targetRoot: 'plans',
135
- defaultStatus: 'active',
135
+ // A scaffolded plan is scoped, not being worked; `dotmd use` starts it.
136
+ // Defaulting to `active` forced a second, guarded status write for every
137
+ // plan that was only being written down.
138
+ defaultStatus: 'planned',
136
139
  // Body input lands in the Problem section. Plans don't have an Overview;
137
140
  // Problem is the established opening section in the build-up shape.
138
141
  acceptsBody: true,
package/src/prompts.mjs CHANGED
@@ -196,7 +196,7 @@ async function runPromptsNext(argv, config, opts = {}) {
196
196
  }
197
197
  const head = queue[0];
198
198
  if (!head.abs) die(`Could not resolve path: ${head.doc.path}`);
199
- return consumePrompt(head.abs, config, opts);
199
+ return consumePrompt(head.abs, config, { ...opts, noClaim: argv.includes('--no-claim') });
200
200
  }
201
201
 
202
202
  // Resolve user input to a prompt path. Tries (in order): exact path,
@@ -245,8 +245,9 @@ async function runPromptsUse(argv, config, opts = {}) {
245
245
  if (!input) die('Usage: dotmd prompts use <file-or-slug>');
246
246
  const noIndex = argv.includes('--no-index') || opts.noIndex;
247
247
  const showFiles = argv.includes('--show-files') || opts.showFiles;
248
+ const noClaim = argv.includes('--no-claim');
248
249
  const filePath = resolvePromptInput(input, config);
249
- return consumePrompt(filePath, config, { ...opts, noIndex, showFiles });
250
+ return consumePrompt(filePath, config, { ...opts, noIndex, showFiles, noClaim });
250
251
  }
251
252
 
252
253
  export async function consumePrompt(filePath, config, opts) {
@@ -269,7 +270,9 @@ export async function consumePrompt(filePath, config, opts) {
269
270
  const planRef = asString(parsed.plan);
270
271
  let linkedClaim = null;
271
272
  let claimSkipReason = null;
272
- if (planRef) {
273
+ if (planRef && opts.noClaim) {
274
+ claimSkipReason = `--no-claim, ${planRef} left as it is`;
275
+ } else if (planRef) {
273
276
  const outcome = prepareLinkedPromptClaim(planRef, config, path.dirname(filePath));
274
277
  if (outcome?.skipped) claimSkipReason = outcome.reason;
275
278
  else linkedClaim = outcome;
package/src/use.mjs CHANGED
@@ -15,13 +15,16 @@ import { resolveDocArg } from './index.mjs';
15
15
  // With no argument, consumes the oldest pending prompt.
16
16
  export async function runUse(argv, config, opts = {}) {
17
17
  const positional = argv.find(a => !a.startsWith('-'));
18
+ // `--no-claim` reads and archives a prompt without starting the plan it links.
19
+ const noClaim = argv.includes('--no-claim');
20
+ const promptOpts = { ...opts, noClaim };
18
21
 
19
22
  if (!positional) {
20
23
  const queue = pendingPromptsOldestFirst(config);
21
24
  if (queue.length === 0) die('No pending prompts. Pass a file to use a plan or doc.');
22
25
  const head = queue[0];
23
26
  if (!head.abs) die(`Could not resolve path: ${head.doc.path}`);
24
- return consumePrompt(head.abs, config, opts);
27
+ return consumePrompt(head.abs, config, promptOpts);
25
28
  }
26
29
 
27
30
  // Exact path first, then prompt slugs (they keep precedence on a slug
@@ -39,8 +42,9 @@ export async function runUse(argv, config, opts = {}) {
39
42
  const type = asString(parsed.type);
40
43
 
41
44
  if (type === 'prompt') {
42
- return consumePrompt(filePath, config, opts);
45
+ return consumePrompt(filePath, config, promptOpts);
43
46
  }
47
+ if (noClaim) die('--no-claim applies to prompts only; a plan opened with `use` is always claimed.');
44
48
  if (type === 'plan') {
45
49
  // Mark in-session (pure frontmatter) and print the plan card.
46
50
  return startPlan([filePath, ...argv.filter(a => a !== positional)], config, opts);