dotmd-cli 0.77.2 → 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 +8 -1
- package/assets/opencode/plugin.js +39 -3
- package/bin/dotmd.mjs +15 -1
- package/package.json +1 -1
- package/src/baton.mjs +81 -45
- package/src/commands.mjs +4 -4
- package/src/guard.mjs +5 -2
- package/src/host-integration.mjs +21 -0
- package/src/install.mjs +22 -5
- package/src/new.mjs +4 -1
- package/src/prompts.mjs +6 -3
- package/src/update.mjs +81 -7
- package/src/use.mjs +6 -2
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
|
-
|
|
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],
|
|
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
|
|
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
|
@@ -160,6 +160,12 @@ host gets that a different way:
|
|
|
160
160
|
with "plugin not installed"), and the README's slash commands only
|
|
161
161
|
work from inside a session. Without the \`claude\` CLI on PATH the
|
|
162
162
|
two in-session commands are printed instead.
|
|
163
|
+
It also repairs a plugin Claude lists as "failed to load: Marketplace
|
|
164
|
+
dotmd not found" — an install record whose marketplace registration
|
|
165
|
+
is gone — by re-adding the marketplace and updating the plugin. If
|
|
166
|
+
Claude refuses the marketplace, ~/.claude/settings.json declares it
|
|
167
|
+
under extraKnownMarketplaces with a source that no longer matches;
|
|
168
|
+
dotmd names the field but never edits that file.
|
|
163
169
|
|
|
164
170
|
opencode Writes one auto-discovered plugin file. OpenCode has no plugin
|
|
165
171
|
registry but globs \`{plugin,plugins}/*.{ts,js}\` under its global
|
|
@@ -190,7 +196,11 @@ reports a missing integration but never installs one.`,
|
|
|
190
196
|
The plugin and CLI ship in lockstep; a release bumps both. Updating the plugin
|
|
191
197
|
requires a session restart (or /reload-plugins) to apply. The plugin step needs
|
|
192
198
|
the \`claude\` CLI on PATH — otherwise it prints the \`/plugin update\` command to
|
|
193
|
-
run from a session instead
|
|
199
|
+
run from a session instead. The OpenCode file is refreshed in the same run when
|
|
200
|
+
it is present and behind. The hosts are independent, so a failing step does not
|
|
201
|
+
stop the others: every step runs, the failures are listed together, and the
|
|
202
|
+
exit code is 1. A plugin whose marketplace registration is gone gets the
|
|
203
|
+
marketplace re-added before the update (see \`dotmd install claude\`).`,
|
|
194
204
|
|
|
195
205
|
misuse: `dotmd misuse — read the cross-repo guard log (~/.claude/logs/dotmd-misuse.log)
|
|
196
206
|
|
|
@@ -1140,6 +1150,10 @@ Subcommands:
|
|
|
1140
1150
|
slug matching a prompt basename, or a unique substring of a prompt
|
|
1141
1151
|
path. Ambiguous substrings error with the candidate list.
|
|
1142
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
|
+
|
|
1143
1157
|
Default prompt statuses: pending, archived.
|
|
1144
1158
|
|
|
1145
1159
|
Examples:
|
package/package.json
CHANGED
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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/host-integration.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import path from 'node:path';
|
|
|
23
23
|
import { fileURLToPath } from 'node:url';
|
|
24
24
|
import { hostSessionSource } from './util.mjs';
|
|
25
25
|
import { readEnv, stateDir } from './naming.mjs';
|
|
26
|
+
import { planMarketplaceRepair } from './update.mjs';
|
|
26
27
|
|
|
27
28
|
export const GENERATED_MARKER = 'dotmd-generated:';
|
|
28
29
|
const PLUGIN_FILENAME = 'dotmd.js';
|
|
@@ -233,6 +234,19 @@ export function degradedIdentityNotice(repoRoot, opts = {}) {
|
|
|
233
234
|
export const CLAUDE_MARKETPLACE = 'reowens/dotmd';
|
|
234
235
|
export const CLAUDE_PLUGIN_ID = 'dotmd@dotmd';
|
|
235
236
|
|
|
237
|
+
// Why `claude plugin marketplace add` can refuse a marketplace that is not
|
|
238
|
+
// even registered: settings.json may still DECLARE it (extraKnownMarketplaces),
|
|
239
|
+
// and Claude rejects an add whose source differs from that declaration in any
|
|
240
|
+
// fetch-shaping field. dotmd never writes that declaration and will not edit
|
|
241
|
+
// it, so the most it can do is name the field.
|
|
242
|
+
export function claudeMarketplaceRefusalHint(marketplace = 'dotmd') {
|
|
243
|
+
return [
|
|
244
|
+
`Claude refused to register marketplace "${marketplace}". If ~/.claude/settings.json declares it under`,
|
|
245
|
+
`extraKnownMarketplaces, its source must be exactly {"source":"github","repo":"${CLAUDE_MARKETPLACE}"} —`,
|
|
246
|
+
'a stray "path", "ref" or header there makes the add fail and the installed plugin fail to load.',
|
|
247
|
+
];
|
|
248
|
+
}
|
|
249
|
+
|
|
236
250
|
// Pure planner, mirroring planUpdate: the orchestration is unit-testable and
|
|
237
251
|
// the side effects stay in the caller.
|
|
238
252
|
export function planClaudeInstall({ installed, hasClaude, remove = false } = {}) {
|
|
@@ -242,6 +256,13 @@ export function planClaudeInstall({ installed, hasClaude, remove = false } = {})
|
|
|
242
256
|
? [{ kind: 'run', cmd: ['claude', 'plugin', 'uninstall', installed.id] }]
|
|
243
257
|
: [{ kind: 'manual', lines: [`/plugin uninstall ${installed.id}`] }];
|
|
244
258
|
}
|
|
259
|
+
// An install record whose marketplace registration is gone is not an
|
|
260
|
+
// installed plugin — Claude lists it as "failed to load". Skipping here with
|
|
261
|
+
// "already installed" left the user with no dotmd verb that could repair it.
|
|
262
|
+
if (installed?.marketplaceRegistered === false) {
|
|
263
|
+
return planMarketplaceRepair(installed, { hasClaude, verb: 'update' })
|
|
264
|
+
.map(step => (step.kind === 'marketplace' || step.kind === 'plugin' ? { ...step, kind: 'run', step: step.kind } : step));
|
|
265
|
+
}
|
|
245
266
|
if (installed) return [{ kind: 'skip', reason: `dotmd plugin already installed (${installed.version ?? 'unknown version'})` }];
|
|
246
267
|
// The marketplace has to be registered before the plugin resolves; adding one
|
|
247
268
|
// already present is a no-op, so this stays safe to re-run.
|
package/src/install.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { bold, dim, green, yellow } from './color.mjs';
|
|
6
6
|
import {
|
|
7
|
-
CLAUDE_MARKETPLACE, CLAUDE_PLUGIN_ID, installOpencodePlugin, opencodeDetected,
|
|
7
|
+
CLAUDE_MARKETPLACE, CLAUDE_PLUGIN_ID, claudeMarketplaceRefusalHint, installOpencodePlugin, opencodeDetected,
|
|
8
8
|
opencodeStatus, planClaudeInstall, removeOpencodePlugin,
|
|
9
9
|
} from './host-integration.mjs';
|
|
10
10
|
import { readInstalledPlugin } from './update.mjs';
|
|
@@ -24,7 +24,14 @@ function flagValue(argv, name) {
|
|
|
24
24
|
function hostStates() {
|
|
25
25
|
const plugin = readInstalledPlugin();
|
|
26
26
|
return {
|
|
27
|
-
claude: {
|
|
27
|
+
claude: {
|
|
28
|
+
installed: Boolean(plugin),
|
|
29
|
+
version: plugin?.version ?? null,
|
|
30
|
+
id: plugin?.id ?? CLAUDE_PLUGIN_ID,
|
|
31
|
+
marketplace: plugin?.marketplace ?? null,
|
|
32
|
+
// false = record present, marketplace gone: Claude shows "failed to load".
|
|
33
|
+
marketplaceRegistered: plugin ? plugin.marketplaceRegistered : null,
|
|
34
|
+
},
|
|
28
35
|
opencode: { ...opencodeStatus({ version: pkg.version }), detected: opencodeDetected() },
|
|
29
36
|
};
|
|
30
37
|
}
|
|
@@ -38,7 +45,11 @@ function reportStatus(json) {
|
|
|
38
45
|
const { claude, opencode } = states;
|
|
39
46
|
process.stdout.write(`${bold('dotmd host integrations')} ${dim(`CLI ${pkg.version}`)}\n\n`);
|
|
40
47
|
|
|
41
|
-
|
|
48
|
+
const claudeBroken = claude.installed && claude.marketplaceRegistered === false;
|
|
49
|
+
const claudeState = !claude.installed ? yellow('not installed')
|
|
50
|
+
: claudeBroken ? yellow(`${claude.version ?? 'installed'} — marketplace "${claude.marketplace}" not registered, plugin fails to load`)
|
|
51
|
+
: green(claude.version ?? 'installed');
|
|
52
|
+
process.stdout.write(` claude ${claudeState}\n`);
|
|
42
53
|
process.stdout.write(` ${dim(claude.installed ? claude.id : 'plugin: SessionStart primer, PreToolUse guard, workflow skill')}\n`);
|
|
43
54
|
|
|
44
55
|
const ocState = opencode.foreign ? yellow('unmanaged file present')
|
|
@@ -49,7 +60,7 @@ function reportStatus(json) {
|
|
|
49
60
|
process.stdout.write(` ${dim(opencode.path)}\n`);
|
|
50
61
|
|
|
51
62
|
const todo = [];
|
|
52
|
-
if (!claude.installed) todo.push('dotmd install claude');
|
|
63
|
+
if (!claude.installed || claudeBroken) todo.push('dotmd install claude');
|
|
53
64
|
if (!opencode.exists || opencode.stale) todo.push('dotmd install opencode');
|
|
54
65
|
if (todo.length) {
|
|
55
66
|
process.stdout.write('\n');
|
|
@@ -73,10 +84,11 @@ function installClaude(argv, dryRun, json) {
|
|
|
73
84
|
for (const step of steps) {
|
|
74
85
|
if (step.kind === 'skip') { process.stdout.write(`${dim('skip:')} ${step.reason}\n`); continue; }
|
|
75
86
|
if (step.kind === 'manual') {
|
|
76
|
-
process.stdout.write(`${yellow('claude CLI not on PATH')} — run these from a Claude Code session:\n`);
|
|
87
|
+
process.stdout.write(`${yellow(step.reason ?? 'claude CLI not on PATH')} — run these from a Claude Code session:\n`);
|
|
77
88
|
for (const line of step.lines) process.stdout.write(` ${bold(line)}\n`);
|
|
78
89
|
continue;
|
|
79
90
|
}
|
|
91
|
+
if (step.reason) process.stdout.write(`${step.reason}\n`);
|
|
80
92
|
if (dryRun) { process.stdout.write(dim(`[dry-run] Would run: ${step.cmd.join(' ')}\n`)); continue; }
|
|
81
93
|
process.stdout.write(dim(`$ ${step.cmd.join(' ')}\n`));
|
|
82
94
|
const result = spawnSync(executableName(step.cmd[0]), step.cmd.slice(1), {
|
|
@@ -85,6 +97,11 @@ function installClaude(argv, dryRun, json) {
|
|
|
85
97
|
ran = true;
|
|
86
98
|
if (result.status !== 0) {
|
|
87
99
|
process.stdout.write(yellow(`(claude exited ${result.status ?? '?'})\n`));
|
|
100
|
+
// Each later step here depends on this one (the plugin resolves through
|
|
101
|
+
// the marketplace), so unlike `update` there is nothing independent left.
|
|
102
|
+
if (step.step === 'marketplace' || step.cmd.includes('marketplace')) {
|
|
103
|
+
for (const line of claudeMarketplaceRefusalHint(readInstalledPlugin()?.marketplace)) process.stdout.write(yellow(`${line}\n`));
|
|
104
|
+
}
|
|
88
105
|
process.exitCode = 1;
|
|
89
106
|
return;
|
|
90
107
|
}
|
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
|
-
|
|
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/update.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import os from 'node:os';
|
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { green, dim, yellow } from './color.mjs';
|
|
7
7
|
import { executableName, which } from './util.mjs';
|
|
8
|
-
import { installOpencodePlugin, opencodeStatus } from './host-integration.mjs';
|
|
8
|
+
import { CLAUDE_MARKETPLACE, claudeMarketplaceRefusalHint, installOpencodePlugin, opencodeStatus } from './host-integration.mjs';
|
|
9
9
|
|
|
10
10
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
11
|
const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
@@ -50,10 +50,54 @@ export function readInstalledPluginRecords(opts = {}) {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// Claude Code's marketplace registry, keyed by marketplace name. A plugin id
|
|
54
|
+
// is `<plugin>@<marketplace>`, and the plugin only loads while its marketplace
|
|
55
|
+
// is registered here — an install record alone is not an installed plugin.
|
|
56
|
+
export function readKnownMarketplaces(opts = {}) {
|
|
57
|
+
const home = opts.home || os.homedir();
|
|
58
|
+
const file = path.join(home, '.claude', 'plugins', 'known_marketplaces.json');
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
61
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
62
|
+
} catch {
|
|
63
|
+
return {};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// `marketplaceRegistered: false` is the state `claude plugin list` shows as
|
|
68
|
+
// "failed to load: Marketplace dotmd not found": the install record survived,
|
|
69
|
+
// the marketplace registration did not (a settings.json declaration that no
|
|
70
|
+
// longer matches, a wiped registry). Reading only the install record called
|
|
71
|
+
// that "installed", so `dotmd install claude` skipped the one repair it owns
|
|
72
|
+
// and `dotmd update` ran a `plugin update` that could only fail.
|
|
53
73
|
export function readInstalledPlugin(opts = {}) {
|
|
54
74
|
const records = readInstalledPluginRecords(opts);
|
|
55
75
|
if (!records) return null;
|
|
56
|
-
|
|
76
|
+
const marketplace = records.id.split('@')[1] || null;
|
|
77
|
+
const known = readKnownMarketplaces(opts);
|
|
78
|
+
return {
|
|
79
|
+
id: records.id,
|
|
80
|
+
version: records.entries[0]?.version ?? null,
|
|
81
|
+
marketplace,
|
|
82
|
+
marketplaceRegistered: Boolean(marketplace && known[marketplace]),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The steps that put a plugin whose marketplace registration is gone back on
|
|
87
|
+
// its feet. Only the marketplace dotmd publishes has a source dotmd knows; a
|
|
88
|
+
// plugin installed from some other marketplace names a source we cannot guess.
|
|
89
|
+
export function planMarketplaceRepair(plugin, { hasClaude, verb }) {
|
|
90
|
+
const reason = `marketplace "${plugin.marketplace}" is not registered, so the installed plugin cannot load`;
|
|
91
|
+
if (plugin.marketplace !== 'dotmd') {
|
|
92
|
+
return [{ kind: 'skip', reason: `${reason} — re-add that marketplace (dotmd does not know its source), then rerun` }];
|
|
93
|
+
}
|
|
94
|
+
if (!hasClaude) {
|
|
95
|
+
return [{ kind: 'manual', reason, lines: [`/plugin marketplace add ${CLAUDE_MARKETPLACE}`, `/plugin ${verb} ${plugin.id}`] }];
|
|
96
|
+
}
|
|
97
|
+
return [
|
|
98
|
+
{ kind: 'marketplace', reason: `${reason} — re-adding it first`, cmd: ['claude', 'plugin', 'marketplace', 'add', CLAUDE_MARKETPLACE] },
|
|
99
|
+
{ kind: 'plugin', needs: 'marketplace', cmd: ['claude', 'plugin', verb, plugin.id] },
|
|
100
|
+
];
|
|
57
101
|
}
|
|
58
102
|
|
|
59
103
|
// Decide which steps `dotmd update` should run. Pure — no side effects — so the
|
|
@@ -69,6 +113,8 @@ export function planUpdate(opts, ctx) {
|
|
|
69
113
|
if (!opts.cliOnly) {
|
|
70
114
|
if (!ctx.plugin) {
|
|
71
115
|
steps.push({ kind: 'skip', reason: 'dotmd plugin not installed — skipping plugin update' });
|
|
116
|
+
} else if (ctx.plugin.marketplaceRegistered === false) {
|
|
117
|
+
steps.push(...planMarketplaceRepair(ctx.plugin, { hasClaude: ctx.hasClaude, verb: 'update' }));
|
|
72
118
|
} else if (!ctx.hasClaude) {
|
|
73
119
|
steps.push({ kind: 'skip', reason: `claude CLI not found — run \`/plugin update ${ctx.plugin.id}\` from a session instead` });
|
|
74
120
|
} else {
|
|
@@ -102,6 +148,9 @@ export function runUpdate(argv, _config, opts = {}) {
|
|
|
102
148
|
: cmp < 0 ? yellow('behind — run `dotmd update`')
|
|
103
149
|
: yellow('ahead — CLI is behind');
|
|
104
150
|
process.stdout.write(`dotmd plugin: ${plugin.version ?? '?'} (${plugin.id}) ${tag}\n`);
|
|
151
|
+
if (plugin.marketplaceRegistered === false) {
|
|
152
|
+
process.stdout.write(yellow(` marketplace "${plugin.marketplace}" is not registered — the plugin fails to load; run \`dotmd install claude\`\n`));
|
|
153
|
+
}
|
|
105
154
|
} else {
|
|
106
155
|
process.stdout.write(dim('dotmd plugin: not installed — `dotmd install claude`\n'));
|
|
107
156
|
}
|
|
@@ -117,24 +166,41 @@ export function runUpdate(argv, _config, opts = {}) {
|
|
|
117
166
|
if (opts.dryRun) {
|
|
118
167
|
for (const step of steps) {
|
|
119
168
|
if (step.kind === 'skip') process.stdout.write(dim(`[dry-run] skip: ${step.reason}\n`));
|
|
169
|
+
else if (step.kind === 'manual') for (const line of step.lines) process.stdout.write(dim(`[dry-run] Run from a session: ${line}\n`));
|
|
120
170
|
else if (step.kind === 'opencode') process.stdout.write(dim(`[dry-run] Would refresh: ${step.path}\n`));
|
|
121
171
|
else process.stdout.write(dim(`[dry-run] Would run: ${step.cmd.join(' ')}\n`));
|
|
122
172
|
}
|
|
123
173
|
return;
|
|
124
174
|
}
|
|
175
|
+
// The hosts are independent: a failed `claude plugin update` says nothing
|
|
176
|
+
// about the OpenCode file, so every step runs and the failures are reported
|
|
177
|
+
// together at the end. Stopping at the first one left OpenCode stale behind a
|
|
178
|
+
// Claude registry problem — silently, since the abort said nothing about the
|
|
179
|
+
// steps it never reached.
|
|
125
180
|
let ran = false;
|
|
126
|
-
|
|
181
|
+
const failures = [];
|
|
182
|
+
const failedKinds = new Set();
|
|
127
183
|
for (const s of steps) {
|
|
128
184
|
if (s.kind === 'skip') {
|
|
129
185
|
process.stdout.write(dim(`skip: ${s.reason}\n`));
|
|
130
186
|
continue;
|
|
131
187
|
}
|
|
188
|
+
if (s.kind === 'manual') {
|
|
189
|
+
process.stdout.write(`${yellow(s.reason)} — run these from a Claude Code session:\n`);
|
|
190
|
+
for (const line of s.lines) process.stdout.write(` ${line}\n`);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (s.needs && failedKinds.has(s.needs)) {
|
|
194
|
+
process.stdout.write(dim(`skip: ${s.cmd.join(' ')} — the ${s.needs} step it depends on failed\n`));
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
132
197
|
if (s.kind === 'opencode') {
|
|
133
198
|
const result = installOpencodePlugin({ version: pkg.version });
|
|
134
199
|
process.stdout.write(dim(`refreshed opencode integration → ${pkg.version} ${result.path}\n`));
|
|
135
200
|
ran = true;
|
|
136
201
|
continue;
|
|
137
202
|
}
|
|
203
|
+
if (s.reason) process.stdout.write(dim(`${s.reason}\n`));
|
|
138
204
|
process.stdout.write(dim(`$ ${s.cmd.join(' ')}\n`));
|
|
139
205
|
const r = spawnSync(executableName(s.cmd[0]), s.cmd.slice(1), {
|
|
140
206
|
stdio: 'inherit',
|
|
@@ -142,13 +208,21 @@ export function runUpdate(argv, _config, opts = {}) {
|
|
|
142
208
|
});
|
|
143
209
|
ran = true;
|
|
144
210
|
if (r.status !== 0) {
|
|
145
|
-
|
|
211
|
+
failedKinds.add(s.kind);
|
|
212
|
+
failures.push(s);
|
|
146
213
|
process.stdout.write(yellow(`(${s.cmd[0]} exited ${r.status ?? '?'})\n`));
|
|
147
|
-
|
|
214
|
+
if (s.kind === 'marketplace') for (const line of claudeMarketplaceRefusalHint(plugin?.marketplace)) process.stdout.write(yellow(`${line}\n`));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (failures.length) {
|
|
218
|
+
process.stdout.write(yellow(`\n${failures.length === 1 ? '1 step' : `${failures.length} steps`} failed: ${failures.map(s => s.cmd.join(' ')).join('; ')}\n`));
|
|
219
|
+
if (ran && failures.length < steps.filter(s => s.kind !== 'skip' && s.kind !== 'manual').length) {
|
|
220
|
+
process.stdout.write(dim('the other steps completed; restart your Claude Code session (or /reload-plugins) to apply them.\n'));
|
|
148
221
|
}
|
|
222
|
+
process.exitCode = 1;
|
|
223
|
+
return;
|
|
149
224
|
}
|
|
150
|
-
if (ran
|
|
225
|
+
if (ran) {
|
|
151
226
|
process.stdout.write(green('\n✓ restart your Claude Code session (or /reload-plugins) to apply.\n'));
|
|
152
227
|
}
|
|
153
|
-
if (failed) process.exitCode = 1;
|
|
154
228
|
}
|
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,
|
|
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,
|
|
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);
|