dotmd-cli 0.74.5 → 0.75.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 +32 -1
- package/assets/opencode/plugin.js +97 -0
- package/bin/dotmd.mjs +52 -2
- package/package.json +2 -1
- package/scripts/postinstall.mjs +13 -0
- package/src/baton.mjs +21 -1
- package/src/commands.mjs +4 -1
- package/src/doctor.mjs +42 -2
- package/src/glossary.mjs +1 -1
- package/src/host-integration.mjs +211 -0
- package/src/install.mjs +151 -0
- package/src/pickup.mjs +14 -15
- package/src/prompts.mjs +53 -5
- package/src/section.mjs +106 -8
- package/src/update.mjs +25 -15
- package/src/util.mjs +78 -6
- package/src/validate.mjs +33 -18
package/README.md
CHANGED
|
@@ -23,9 +23,23 @@ npx dotmd-cli init # try it without installing
|
|
|
23
23
|
Maintainer release automation is POSIX-only because it uses Bash and POSIX
|
|
24
24
|
command-line tools. The published Node.js CLI remains cross-platform.
|
|
25
25
|
|
|
26
|
+
### Agent host setup
|
|
27
|
+
|
|
28
|
+
The CLI alone gives an agent no orientation and no session identity. Install the
|
|
29
|
+
integration for whichever host you run:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
dotmd install # what's installed for each host
|
|
33
|
+
dotmd install claude # Claude Code plugin (marketplace + plugin)
|
|
34
|
+
dotmd install opencode # OpenCode plugin (one auto-discovered file)
|
|
35
|
+
dotmd doctor --session # what identity dotmd sees here, and from where
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Both are one-time and global; `dotmd update` keeps them in step with the CLI.
|
|
39
|
+
|
|
26
40
|
### Claude Code Plugin
|
|
27
41
|
|
|
28
|
-
|
|
42
|
+
`dotmd install claude` runs the two steps below for you. From inside a session:
|
|
29
43
|
|
|
30
44
|
```text
|
|
31
45
|
/plugin marketplace add reowens/dotmd
|
|
@@ -36,6 +50,23 @@ The plugin provides SessionStart and SubagentStart orientation, a PreToolUse
|
|
|
36
50
|
guard, the canonical workflow skill, and `/plans`, `/docs`, `/prompts`, and
|
|
37
51
|
`/baton` commands.
|
|
38
52
|
|
|
53
|
+
### OpenCode Plugin
|
|
54
|
+
|
|
55
|
+
`dotmd install opencode` writes one plugin file into OpenCode's global config
|
|
56
|
+
directory, where OpenCode auto-discovers it — no `opencode.json` edit. It
|
|
57
|
+
supplies the two things the CLI cannot get on its own:
|
|
58
|
+
|
|
59
|
+
- **Per-session plan ownership.** OpenCode exports no session id to a tool
|
|
60
|
+
shell. Without the plugin, dotmd falls back to `OPENCODE_PID`, which names the
|
|
61
|
+
OpenCode *process* — so every session in one OpenCode instance shares an
|
|
62
|
+
identity and can release the others' in-session plans.
|
|
63
|
+
- **A session-start briefing**, the equivalent of Claude Code's SessionStart
|
|
64
|
+
hook. OpenCode's Claude Code compatibility covers skills and the system
|
|
65
|
+
prompt, not hooks, so nothing else runs `dotmd hud`.
|
|
66
|
+
|
|
67
|
+
Restart OpenCode after installing. The file is version-stamped; a `dotmd.js`
|
|
68
|
+
without that stamp is treated as hand-authored and is never overwritten.
|
|
69
|
+
|
|
39
70
|
The plugin requires a global CLI install because its hooks resolve `dotmd` from
|
|
40
71
|
`PATH`. A project devDependency is useful for npm scripts but does not put the
|
|
41
72
|
CLI on the hook's `PATH`.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// dotmd's OpenCode integration. Installed by `dotmd install opencode`, which
|
|
2
|
+
// copies this file (with a version banner prepended) to the OpenCode plugin
|
|
3
|
+
// directory, where OpenCode auto-discovers it — it globs
|
|
4
|
+
// `{plugin,plugins}/*.{ts,js}` under `.opencode/` and the global config dir, so
|
|
5
|
+
// no `opencode.json` edit is needed.
|
|
6
|
+
//
|
|
7
|
+
// TWO CONSTRAINTS, both load-bearing:
|
|
8
|
+
//
|
|
9
|
+
// 1. EXACTLY ONE EXPORT. OpenCode treats every export of a plugin module as a
|
|
10
|
+
// plugin factory and throws `Plugin export is not a function` on anything
|
|
11
|
+
// that isn't one — so an exported helper wouldn't just be untidy, it would
|
|
12
|
+
// be *called* as a second plugin. Helpers stay module-local.
|
|
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 "dotmd does nothing here"
|
|
17
|
+
// rather than to a broken session.
|
|
18
|
+
//
|
|
19
|
+
// Runs under Bun inside the OpenCode process. Node builtins only, no deps.
|
|
20
|
+
|
|
21
|
+
import { execFile } from 'node:child_process';
|
|
22
|
+
|
|
23
|
+
// The primer is a nicety; identity is a correctness invariant. They are split
|
|
24
|
+
// across two hooks deliberately: `shell.env` is stable API, while
|
|
25
|
+
// `experimental.chat.system.transform` may be renamed by OpenCode. If it is,
|
|
26
|
+
// priming stops and ownership keeps working — the failure lands on the half
|
|
27
|
+
// that can afford it.
|
|
28
|
+
const PRIMER_TTL_MS = 60_000;
|
|
29
|
+
const PRIMER_TIMEOUT_MS = 5_000;
|
|
30
|
+
|
|
31
|
+
function dotmdExecutable() {
|
|
32
|
+
return process.platform === 'win32' ? 'dotmd.cmd' : 'dotmd';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function runHud(directory) {
|
|
36
|
+
return new Promise(resolve => {
|
|
37
|
+
let settled = false;
|
|
38
|
+
const done = value => { if (!settled) { settled = true; resolve(value); } };
|
|
39
|
+
try {
|
|
40
|
+
execFile(dotmdExecutable(), ['hud'], {
|
|
41
|
+
cwd: directory,
|
|
42
|
+
timeout: PRIMER_TIMEOUT_MS,
|
|
43
|
+
windowsHide: true,
|
|
44
|
+
env: { ...process.env, NO_COLOR: '1' },
|
|
45
|
+
}, (error, stdout) => done(error ? '' : (stdout ?? '').trim()));
|
|
46
|
+
} catch {
|
|
47
|
+
// `dotmd` not on PATH, spawn refused — no primer, no noise.
|
|
48
|
+
done('');
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default async function dotmdOpencodePlugin({ directory }) {
|
|
54
|
+
// Keyed by session so a subagent session primes independently, the way
|
|
55
|
+
// SubagentStart does under Claude Code.
|
|
56
|
+
const primers = new Map();
|
|
57
|
+
|
|
58
|
+
async function primerFor(sessionId) {
|
|
59
|
+
const key = sessionId ?? '';
|
|
60
|
+
const cached = primers.get(key);
|
|
61
|
+
// Refreshed on a TTL rather than cached for the session's life: OpenCode
|
|
62
|
+
// rebuilds the system prompt every turn, so a once-only push would reach
|
|
63
|
+
// only the first request — and a never-refreshed one would keep announcing
|
|
64
|
+
// a pending prompt this session already consumed.
|
|
65
|
+
if (cached && Date.now() - cached.at < PRIMER_TTL_MS) return cached.text;
|
|
66
|
+
const text = await runHud(directory);
|
|
67
|
+
primers.set(key, { text, at: Date.now() });
|
|
68
|
+
return text;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
// Ownership identity. OpenCode sets no session-id variable of its own, and
|
|
73
|
+
// `OPENCODE_PID` — what dotmd falls back to without this plugin — names the
|
|
74
|
+
// OpenCode *process*, so every session in one TUI shares it and can release
|
|
75
|
+
// the others' plans. This is the only place the real session id is
|
|
76
|
+
// available to a tool shell.
|
|
77
|
+
'shell.env': async (input, output) => {
|
|
78
|
+
try {
|
|
79
|
+
if (input?.sessionID) output.env.DOTMD_SESSION_ID = `opencode:${input.sessionID}`;
|
|
80
|
+
// The OpenCode server process hosts the session and outlives every tool
|
|
81
|
+
// shell, so it is the process whose liveness answers "is this claim's
|
|
82
|
+
// owner still there?" — `dotmd doctor --claims` probes exactly this.
|
|
83
|
+
output.env.DOTMD_SESSION_PID = String(process.pid);
|
|
84
|
+
} catch { /* never break a shell over this */ }
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
// Session priming — the equivalent of the SessionStart hook that runs
|
|
88
|
+
// `dotmd hud` under Claude Code. Silent outside a dotmd repo (hud prints
|
|
89
|
+
// nothing and exits 0), so this is inert in unrelated projects.
|
|
90
|
+
'experimental.chat.system.transform': async (input, output) => {
|
|
91
|
+
try {
|
|
92
|
+
const text = await primerFor(input?.sessionID);
|
|
93
|
+
if (text) output.system.push(text);
|
|
94
|
+
} catch { /* priming is best-effort */ }
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
package/bin/dotmd.mjs
CHANGED
|
@@ -142,6 +142,44 @@ guard entirely with DOTMD_GUARD=0. Read the log with \`dotmd misuse\`; when one
|
|
|
142
142
|
rule trips ≥3× in 7 days in a repo, \`dotmd hud\` opens the next session there
|
|
143
143
|
with a one-line recap naming the habit to break.`,
|
|
144
144
|
|
|
145
|
+
install: `dotmd install [<host>] — install dotmd's integration into an agent host
|
|
146
|
+
|
|
147
|
+
dotmd install report what is installed for each known host
|
|
148
|
+
dotmd install claude install the Claude Code plugin (marketplace + plugin)
|
|
149
|
+
dotmd install opencode install/refresh the OpenCode plugin (global config dir)
|
|
150
|
+
dotmd install <host> --remove
|
|
151
|
+
dotmd install opencode --path <dir> write to a specific plugin directory
|
|
152
|
+
dotmd install opencode --force overwrite a dotmd.js dotmd did not write
|
|
153
|
+
|
|
154
|
+
The CLI on its own gives an agent no orientation and no session identity. Each
|
|
155
|
+
host gets that a different way:
|
|
156
|
+
|
|
157
|
+
claude Drives \`claude plugin marketplace add ${'reowens/dotmd'}\` +
|
|
158
|
+
\`claude plugin install dotmd@dotmd\`. This is the FIRST install;
|
|
159
|
+
\`dotmd update\` only refreshes a plugin already present (it skips
|
|
160
|
+
with "plugin not installed"), and the README's slash commands only
|
|
161
|
+
work from inside a session. Without the \`claude\` CLI on PATH the
|
|
162
|
+
two in-session commands are printed instead.
|
|
163
|
+
|
|
164
|
+
opencode Writes one auto-discovered plugin file. OpenCode has no plugin
|
|
165
|
+
registry but globs \`{plugin,plugins}/*.{ts,js}\` under its global
|
|
166
|
+
config dir, so no opencode.json edit is needed. It supplies two
|
|
167
|
+
things the CLI cannot get on its own:
|
|
168
|
+
- Per-session ownership identity. OpenCode exports no session id
|
|
169
|
+
to a tool shell, so without it every session in one OpenCode
|
|
170
|
+
process shares one identity and can release the others'
|
|
171
|
+
in-session plans.
|
|
172
|
+
- The \`dotmd hud\` primer at session start, the equivalent of
|
|
173
|
+
Claude Code's SessionStart hook. OpenCode's Claude Code
|
|
174
|
+
compatibility covers skills and the system prompt — not hooks —
|
|
175
|
+
so nothing else runs it.
|
|
176
|
+
The file is version-stamped and refreshed by \`dotmd update\`. A
|
|
177
|
+
\`dotmd.js\` without that stamp is treated as hand-authored and is
|
|
178
|
+
never overwritten or removed without --force.
|
|
179
|
+
|
|
180
|
+
Writing outside the repo is why this is an explicit verb: \`dotmd doctor\`
|
|
181
|
+
reports a missing integration but never installs one.`,
|
|
182
|
+
|
|
145
183
|
update: `dotmd update — update the dotmd CLI and the Claude Code plugin together
|
|
146
184
|
|
|
147
185
|
dotmd update npm i -g dotmd-cli + claude plugin update dotmd@dotmd
|
|
@@ -232,6 +270,7 @@ Create & Export:
|
|
|
232
270
|
|
|
233
271
|
Setup:
|
|
234
272
|
init Create starter config + docs directory
|
|
273
|
+
install [opencode] Install the agent-host integration (no arg = status)
|
|
235
274
|
update [--check|--cli-only|--plugin-only] Update the CLI + Claude Code plugin (--check reports skew, no network)
|
|
236
275
|
statuses [list|add|set|remove|migrate] Manage per-project status taxonomy
|
|
237
276
|
help statuses Full status vocabulary + unstuck-actions + transitions
|
|
@@ -464,8 +503,9 @@ another session's work.
|
|
|
464
503
|
a unique bare slug / basename across the doc roots (\`set paused auth-revamp\`).
|
|
465
504
|
Ambiguous slugs error with the candidate list instead of guessing.
|
|
466
505
|
When the path is omitted, exactly one plan must be owned by this session.
|
|
467
|
-
Claude Code
|
|
468
|
-
|
|
506
|
+
Claude Code session IDs are recognized automatically, as is OpenCode (via
|
|
507
|
+
OPENCODE_PID — per OpenCode process, not per session). Other hosts must set
|
|
508
|
+
DOTMD_SESSION_ID; anonymous ownership mutations fail closed.
|
|
469
509
|
Pickup hooks use at-least-once delivery with a stable operationId; hook side
|
|
470
510
|
effects must deduplicate that ID.
|
|
471
511
|
|
|
@@ -743,6 +783,15 @@ Modes:
|
|
|
743
783
|
another machine — that are older than the duration.
|
|
744
784
|
That threshold is your judgement, not dotmd's: it
|
|
745
785
|
cannot tell a dead session from a slow one.
|
|
786
|
+
--session Read-only: what session identity dotmd resolved, from
|
|
787
|
+
which environment variable, and whether it names THIS
|
|
788
|
+
session or something coarser that its siblings share
|
|
789
|
+
(a host process, a terminal) — sessions sharing an id
|
|
790
|
+
can release each other's plans. Also reports whether
|
|
791
|
+
the current host's integration is installed. Run this
|
|
792
|
+
when a verb says "No authoritative session identity",
|
|
793
|
+
or on any host dotmd has never been tried on.
|
|
794
|
+
--session --json Machine-readable identity + host-integration state.
|
|
746
795
|
--statuses Read-only diagnostic: detect overloaded status
|
|
747
796
|
buckets where one status holds plans pursuing
|
|
748
797
|
multiple distinct unstuck-actions. Suggests how
|
|
@@ -1724,6 +1773,7 @@ async function main() {
|
|
|
1724
1773
|
if (command === 'hud') { const { runHud } = await import('../src/hud.mjs'); runHud(restArgs, config); return; }
|
|
1725
1774
|
if (command === 'guard') { const { runGuard } = await import('../src/guard.mjs'); await runGuard(restArgs, config, { dryRun }); return; }
|
|
1726
1775
|
if (command === 'update') { const { runUpdate } = await import('../src/update.mjs'); runUpdate(restArgs, config, { dryRun }); return; }
|
|
1776
|
+
if (command === 'install') { const { runInstall } = await import('../src/install.mjs'); runInstall(restArgs, config, { dryRun }); return; }
|
|
1727
1777
|
if (command === 'misuse') { const { runMisuse } = await import('../src/misuse-read.mjs'); runMisuse(restArgs, config); return; }
|
|
1728
1778
|
if (command === 'journal') { const { runJournal } = await import('../src/journal-read.mjs'); runJournal(restArgs, config); return; }
|
|
1729
1779
|
if (command === 'pickup' || command === 'unpickup' || command === 'release' || command === 'finish') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dotmd-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.75.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",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"files": [
|
|
14
14
|
"bin/",
|
|
15
15
|
"src/",
|
|
16
|
+
"assets/",
|
|
16
17
|
"scripts/postinstall.mjs",
|
|
17
18
|
"dotmd.config.example.mjs"
|
|
18
19
|
],
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -24,6 +24,16 @@ try {
|
|
|
24
24
|
} catch { return false; }
|
|
25
25
|
})();
|
|
26
26
|
|
|
27
|
+
// OpenCode's integration is a file dotmd writes, and nothing else would ever
|
|
28
|
+
// mention it — an OpenCode user who installs only the CLI gets no primer and
|
|
29
|
+
// a process-scoped session identity, silently. One line, never an install.
|
|
30
|
+
const hasOpencode = (() => {
|
|
31
|
+
try {
|
|
32
|
+
const cmd = process.platform === 'win32' ? 'where' : 'which';
|
|
33
|
+
return spawnSync(cmd, ['opencode'], { encoding: 'utf8' }).status === 0;
|
|
34
|
+
} catch { return false; }
|
|
35
|
+
})();
|
|
36
|
+
|
|
27
37
|
if (process.env.DOTMD_AUTO_PLUGIN_UPDATE === '1' && hasClaude) {
|
|
28
38
|
spawnSync('claude', ['plugin', 'update', 'dotmd@dotmd'], { stdio: 'ignore', timeout: 60000 });
|
|
29
39
|
process.stdout.write('dotmd: refreshed the Claude Code plugin — restart your session (or /reload-plugins) to apply.\n');
|
|
@@ -35,6 +45,9 @@ try {
|
|
|
35
45
|
: 'dotmd CLI installed.';
|
|
36
46
|
process.stdout.write(`${nudge}\n`);
|
|
37
47
|
}
|
|
48
|
+
if (hasOpencode) {
|
|
49
|
+
process.stdout.write('dotmd: OpenCode detected — run `dotmd install opencode` for per-session plan ownership and a session-start briefing.\n');
|
|
50
|
+
}
|
|
38
51
|
} catch {
|
|
39
52
|
// Best effort only — never break the install.
|
|
40
53
|
}
|
package/src/baton.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
4
|
import { asString, toRepoPath, die, warn } from './util.mjs';
|
|
@@ -206,6 +206,26 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
206
206
|
}
|
|
207
207
|
if (!createdSlug) die(`Could not find a free prompt slug for ${slugBase} (tried ${slugBase}-2 … ${slugBase}-9).`);
|
|
208
208
|
|
|
209
|
+
// A release status can FILE the plan into a bucket (`lifecycle.filedStatuses`,
|
|
210
|
+
// e.g. paused → docs/plans/held/). The prompt is created inside the same
|
|
211
|
+
// transaction as that move, so its `plan:` link necessarily holds the
|
|
212
|
+
// pre-move path and is stale the instant it lands. The move's own reference
|
|
213
|
+
// rewrite does not cover it: `plan` is deliberately not a `referenceFields`
|
|
214
|
+
// entry, so nothing validates or repoints it. Retarget it here.
|
|
215
|
+
if (!dryRun && promptRepoPath && archiveResult?.newRepoPath && archiveResult.newRepoPath !== repoPath) {
|
|
216
|
+
const promptPath = path.join(config.repoRoot, promptRepoPath);
|
|
217
|
+
try {
|
|
218
|
+
const { frontmatter, body } = extractFrontmatter(readFileSync(promptPath, 'utf8'));
|
|
219
|
+
// Rewritten in place rather than through replaceFrontmatterField, which
|
|
220
|
+
// always emits a folded block scalar — right for prose fields, wrong for
|
|
221
|
+
// a path every other prompt carries on one line. Baton wrote this line
|
|
222
|
+
// itself moments ago, so the single-line form is guaranteed.
|
|
223
|
+
const rewritten = frontmatter.replace(/^plan:[ \t]*\S.*$/m, `plan: ${archiveResult.newRepoPath}`);
|
|
224
|
+
if (rewritten !== frontmatter) writeFileSync(promptPath, `---\n${rewritten}\n---\n${body}`, 'utf8');
|
|
225
|
+
}
|
|
226
|
+
catch (err) { warn(`Saved the prompt, but could not repoint its plan link to ${archiveResult.newRepoPath}: ${err.message}`); }
|
|
227
|
+
}
|
|
228
|
+
|
|
209
229
|
const normalizeRepoPath = candidate => {
|
|
210
230
|
if (!candidate) return null;
|
|
211
231
|
return path.isAbsolute(candidate) ? toRepoPath(candidate, config.repoRoot) : candidate;
|
package/src/commands.mjs
CHANGED
|
@@ -114,6 +114,9 @@ const definitions = [
|
|
|
114
114
|
command('export', mutates('external output path intentionally unrestricted'), 'mutate', [form('[file]', { args: positionals(0, 1), options: [value('--format'), value('--output'), value('--status'), value('--module'), value('--root'), value('--type')] })]),
|
|
115
115
|
command('guard', mutates('global misuse log; no managed document writes'), 'mutate', [form('')]),
|
|
116
116
|
command('update', mutates('global CLI and plugin installation state'), 'mutate', [form('', { options: [flag('--check'), flag('--cli-only'), flag('--plugin-only')] })]),
|
|
117
|
+
command('install', mutates('agent-host plugin directory outside the repo'), 'mutate', [
|
|
118
|
+
form('[host]', { args: positionals(0, 1), options: [flag('--remove'), flag('--force'), flag('--json'), value('--path')] }),
|
|
119
|
+
]),
|
|
117
120
|
command('status', mutates('managed source and same-root destination'), 'mutate', [form('<file> [status]', { args: positionals(1, 2), options: LIFECYCLE_OPTIONS })]),
|
|
118
121
|
command('set', mutates('managed source and same-root destination'), 'mutate', [form('<status> [file]', { args: positionals(1, 2), options: LIFECYCLE_OPTIONS })]),
|
|
119
122
|
command('ship', mutates('repository release/index paths and global release tooling'), 'mutate', [form('[patch|minor|major]', { args: positionals(0, 1) })]),
|
|
@@ -134,7 +137,7 @@ const definitions = [
|
|
|
134
137
|
command('migrate', mutates('managed source sweep'), 'mutate', [form('<field> <old> <new> [files...]', { args: positionals(3, Infinity), options: [flag('--show-files')] })]),
|
|
135
138
|
command('fix-refs', mutates('managed source sweep'), 'mutate', [form('', { options: [flag('--show-files')] })]),
|
|
136
139
|
command('sync-status', mutates('managed source sweep'), 'mutate', [form('[hubs...]', { args: positionals(0, Infinity), options: [flag('--adopt'), flag('--json')] })]),
|
|
137
|
-
command('doctor', mutates('managed sweeps, repo index, and maintenance config paths by mode'), 'mutate', [form('[path]', { args: positionals(0, 1), options: [flag('--apply', '--yes'), flag('--statuses'), optionalValue('--migrate-template'), flag('--migrate-prompts'), flag('--frontmatter-fix'), flag('--project'), flag('--transactions'), flag('--claims'), value('--older-than'), flag('--json'), flag('--include-archived')] })]),
|
|
140
|
+
command('doctor', mutates('managed sweeps, repo index, and maintenance config paths by mode'), 'mutate', [form('[path]', { args: positionals(0, 1), options: [flag('--apply', '--yes'), flag('--statuses'), optionalValue('--migrate-template'), flag('--migrate-prompts'), flag('--frontmatter-fix'), flag('--project'), flag('--transactions'), flag('--claims'), flag('--session'), value('--older-than'), flag('--json'), flag('--include-archived')] })]),
|
|
138
141
|
command('statuses', mutates('project config path; document scan is read-only'), 'mutate', [
|
|
139
142
|
form('list', { subcommands: ['list'], options: [value('--type'), flag('--json')] }),
|
|
140
143
|
form('add <name>', { subcommands: ['add'], args: positionals(1, 1), options: STATUS_PROPERTY_OPTIONS }),
|
package/src/doctor.mjs
CHANGED
|
@@ -7,15 +7,16 @@ import { runSet, runTouch } from './lifecycle.mjs';
|
|
|
7
7
|
import { buildIndex, collectDocFiles } from './index.mjs';
|
|
8
8
|
import { writeRenderedIndex } from './index-file.mjs';
|
|
9
9
|
import { renderCheck, renderManualFixes } from './render.mjs';
|
|
10
|
-
import { bold, dim, green, yellow } from './color.mjs';
|
|
10
|
+
import { bold, dim, green, red, yellow } from './color.mjs';
|
|
11
11
|
import { checkClaudeCommands, removeGeneratedSlashCommands } from './claude-commands.mjs';
|
|
12
12
|
import { checkSkillDrift } from './skill-drift.mjs';
|
|
13
13
|
import { runMigrateTemplate } from './migrate-template.mjs';
|
|
14
14
|
import { runMigratePrompts } from './migrate-prompts.mjs';
|
|
15
15
|
import { runFrontmatterFix } from './frontmatter-fix.mjs';
|
|
16
16
|
import { normalizeEol } from './frontmatter.mjs';
|
|
17
|
-
import { die, relTime, toRepoPath } from './util.mjs';
|
|
17
|
+
import { die, dotmdVersion, relTime, toRepoPath } from './util.mjs';
|
|
18
18
|
import { inspectTransactions, resolveTransactions } from './atomic-mutation.mjs';
|
|
19
|
+
import { describeSessionIdentity, opencodeStatus } from './host-integration.mjs';
|
|
19
20
|
import { availableSessionId, releaseVanishedPlanClaim, surveyOwnershipClaims } from './pickup.mjs';
|
|
20
21
|
|
|
21
22
|
// Tunable thresholds for `dotmd doctor --statuses` conflation detection.
|
|
@@ -209,6 +210,29 @@ async function runDoctorClaims(argv, config, opts = {}) {
|
|
|
209
210
|
}
|
|
210
211
|
}
|
|
211
212
|
|
|
213
|
+
// Read-only: what session identity does dotmd see, and is the current host's
|
|
214
|
+
// integration installed? Never writes — installing lives behind `dotmd install`
|
|
215
|
+
// because it touches state outside the repo.
|
|
216
|
+
function runDoctorSession(argv) {
|
|
217
|
+
const identity = describeSessionIdentity({ version: dotmdVersion() });
|
|
218
|
+
const oc = opencodeStatus({ version: dotmdVersion() });
|
|
219
|
+
if (argv.includes('--json')) {
|
|
220
|
+
process.stdout.write(JSON.stringify({ identity, hosts: { opencode: oc } }, null, 2) + '\n');
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
process.stdout.write(bold('Session identity\n'));
|
|
224
|
+
const mark = identity.scope === 'session' ? green('✓') : identity.id ? yellow('!') : red('✗');
|
|
225
|
+
process.stdout.write(` ${mark} ${identity.id ?? dim('none')}\n`);
|
|
226
|
+
process.stdout.write(` ${dim(identity.summary)}\n`);
|
|
227
|
+
for (const line of identity.advice) process.stdout.write(` → ${line}\n`);
|
|
228
|
+
|
|
229
|
+
process.stdout.write('\n' + bold('Host integration\n'));
|
|
230
|
+
if (oc.foreign) process.stdout.write(` ${yellow('!')} opencode: a dotmd.js dotmd did not write — ${oc.path}\n`);
|
|
231
|
+
else if (!oc.exists) process.stdout.write(` ${dim('·')} opencode: not installed — ${dim(oc.path)}\n`);
|
|
232
|
+
else process.stdout.write(` ${oc.stale ? yellow('!') : green('✓')} opencode: ${oc.version}${oc.stale ? ` (CLI is ${dotmdVersion()} — run \`dotmd update\`)` : ''}\n`);
|
|
233
|
+
process.stdout.write(dim(' Claude Code ships as a plugin — `dotmd install` reports both hosts.\n'));
|
|
234
|
+
}
|
|
235
|
+
|
|
212
236
|
export function runDoctor(argv, config, opts = {}) {
|
|
213
237
|
if (argv.includes('--project')) {
|
|
214
238
|
runDoctorProject(config, { json: argv.includes('--json') });
|
|
@@ -237,6 +261,10 @@ export function runDoctor(argv, config, opts = {}) {
|
|
|
237
261
|
if (argv.includes('--claims')) {
|
|
238
262
|
return runDoctorClaims(argv, config, opts);
|
|
239
263
|
}
|
|
264
|
+
if (argv.includes('--session')) {
|
|
265
|
+
runDoctorSession(argv);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
240
268
|
|
|
241
269
|
const { dryRun, testHooks } = opts;
|
|
242
270
|
// 0.37.0 (F4): the mode banner makes it impossible to mistake a preview run
|
|
@@ -325,6 +353,18 @@ export function runDoctor(argv, config, opts = {}) {
|
|
|
325
353
|
process.stdout.write('\n' + bold('Closeout guidance') + '\n');
|
|
326
354
|
process.stdout.write(manual);
|
|
327
355
|
}
|
|
356
|
+
|
|
357
|
+
// Not a numbered step and never auto-fixed: installing touches state outside
|
|
358
|
+
// the repo, which is `dotmd install`'s job. Doctor's part is making sure a
|
|
359
|
+
// degraded identity is something you're told about rather than something you
|
|
360
|
+
// find out when a verb fails. Silent when there is nothing to say.
|
|
361
|
+
const identity = describeSessionIdentity({ version: dotmdVersion() });
|
|
362
|
+
if (identity.advice.length) {
|
|
363
|
+
process.stdout.write('\n' + bold('Session identity') + '\n');
|
|
364
|
+
process.stdout.write(`${yellow('!')} ${identity.summary}\n`);
|
|
365
|
+
for (const line of identity.advice) process.stdout.write(dim(` → ${line}\n`));
|
|
366
|
+
process.stdout.write(dim(' `dotmd doctor --session` for the full picture.\n'));
|
|
367
|
+
}
|
|
328
368
|
}
|
|
329
369
|
|
|
330
370
|
function readJsonIfPresent(filePath) {
|
package/src/glossary.mjs
CHANGED
|
@@ -136,7 +136,7 @@ function renderEntry(entry, index, allEntries) {
|
|
|
136
136
|
if (relatedDocs.length > 0) {
|
|
137
137
|
lines.push('');
|
|
138
138
|
|
|
139
|
-
// Module entry point (the main module doc, e.g.
|
|
139
|
+
// Module entry point (the main module doc, e.g. ledger.md)
|
|
140
140
|
const entryPoint = relatedDocs.find(d =>
|
|
141
141
|
d.root?.includes('modules') && path.basename(d.path, '.md') === entry.term.toLowerCase()
|
|
142
142
|
);
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Installing dotmd's integration into agent hosts other than Claude Code.
|
|
2
|
+
//
|
|
3
|
+
// Claude Code gets its integration through a real plugin (`plugins/dotmd/`,
|
|
4
|
+
// installed by `claude plugin`). OpenCode has no equivalent registry, but it
|
|
5
|
+
// auto-discovers plugin files: it globs `{plugin,plugins}/*.{ts,js}` under the
|
|
6
|
+
// global config dir and under a project's `.opencode/`, with no config entry
|
|
7
|
+
// needed. So the integration ships as one generated file.
|
|
8
|
+
//
|
|
9
|
+
// It goes in the GLOBAL config dir, not per-repo. dotmd retired per-repo
|
|
10
|
+
// generated scaffolding once already (`.claude/commands`, see
|
|
11
|
+
// claude-commands.mjs) because a file copied into every repo drifts from the
|
|
12
|
+
// CLI that wrote it. One global file covers every repo and is refreshed by
|
|
13
|
+
// `dotmd update` in lockstep with the CLI.
|
|
14
|
+
//
|
|
15
|
+
// Writing outside the repo is why this is an explicit verb. `dotmd doctor`
|
|
16
|
+
// reports that the integration is missing and names the command; it never
|
|
17
|
+
// installs. Passive commands touch nothing.
|
|
18
|
+
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import os from 'node:os';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { hostSessionSource } from './util.mjs';
|
|
24
|
+
|
|
25
|
+
export const GENERATED_MARKER = 'dotmd-generated:';
|
|
26
|
+
const PLUGIN_FILENAME = 'dotmd.js';
|
|
27
|
+
const ASSET = path.resolve(fileURLToPath(import.meta.url), '..', '..', 'assets', 'opencode', 'plugin.js');
|
|
28
|
+
|
|
29
|
+
// OpenCode's own resolution order for its global config directory. Verified
|
|
30
|
+
// against the shipped binary: `OPENCODE_CONFIG_DIR` is an explicit override it
|
|
31
|
+
// consults directly, and the default is the XDG location.
|
|
32
|
+
export function opencodeConfigDir(env = process.env, homedir = os.homedir()) {
|
|
33
|
+
const explicit = env.OPENCODE_CONFIG_DIR?.trim();
|
|
34
|
+
if (explicit) return path.resolve(explicit);
|
|
35
|
+
const xdg = env.XDG_CONFIG_HOME?.trim();
|
|
36
|
+
return xdg ? path.join(path.resolve(xdg), 'opencode') : path.join(homedir, '.config', 'opencode');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Both spellings are globbed, so an existing one is adopted rather than
|
|
40
|
+
// creating a second plugin directory beside the user's own plugins.
|
|
41
|
+
export function opencodePluginDir(configDir) {
|
|
42
|
+
for (const name of ['plugin', 'plugins']) {
|
|
43
|
+
const candidate = path.join(configDir, name);
|
|
44
|
+
if (existsSync(candidate)) return candidate;
|
|
45
|
+
}
|
|
46
|
+
return path.join(configDir, 'plugin');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function opencodePluginPath(opts = {}) {
|
|
50
|
+
const { env = process.env, homedir = os.homedir(), dir } = opts;
|
|
51
|
+
if (dir) return path.join(path.resolve(dir), PLUGIN_FILENAME);
|
|
52
|
+
return path.join(opencodePluginDir(opencodeConfigDir(env, homedir)), PLUGIN_FILENAME);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function banner(version) {
|
|
56
|
+
return [
|
|
57
|
+
`// ${GENERATED_MARKER}${version}`,
|
|
58
|
+
'// Generated by `dotmd install opencode`. Refreshed by `dotmd update`.',
|
|
59
|
+
'// Hand edits are overwritten — remove with `dotmd install opencode --remove`.',
|
|
60
|
+
'',
|
|
61
|
+
].join('\n');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function renderOpencodePlugin(version) {
|
|
65
|
+
return banner(version) + readFileSync(ASSET, 'utf8');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The version a generated file was written by, or null when the file is absent
|
|
69
|
+
// or carries no dotmd banner. A file without the banner is the user's own and
|
|
70
|
+
// is never written or removed — same rule the retired slash-command scaffolding
|
|
71
|
+
// followed.
|
|
72
|
+
export function installedVersion(filePath) {
|
|
73
|
+
let contents;
|
|
74
|
+
try { contents = readFileSync(filePath, 'utf8'); } catch { return null; }
|
|
75
|
+
const marker = contents.indexOf(GENERATED_MARKER);
|
|
76
|
+
if (marker < 0) return null;
|
|
77
|
+
const rest = contents.slice(marker + GENERATED_MARKER.length);
|
|
78
|
+
return rest.split('\n', 1)[0].trim() || null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function opencodeStatus(opts = {}) {
|
|
82
|
+
const { version, ...rest } = opts;
|
|
83
|
+
const filePath = opencodePluginPath(rest);
|
|
84
|
+
const exists = existsSync(filePath);
|
|
85
|
+
const installed = exists ? installedVersion(filePath) : null;
|
|
86
|
+
return {
|
|
87
|
+
path: filePath,
|
|
88
|
+
exists,
|
|
89
|
+
version: installed,
|
|
90
|
+
// Present but unmarked: the user put a `dotmd.js` there themselves.
|
|
91
|
+
foreign: exists && installed === null,
|
|
92
|
+
stale: installed !== null && version !== undefined && installed !== version,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Is OpenCode plausibly in use on this machine? Used only to decide whether to
|
|
97
|
+
// mention the integration — never to install anything.
|
|
98
|
+
export function opencodeDetected(opts = {}) {
|
|
99
|
+
const { env = process.env, homedir = os.homedir() } = opts;
|
|
100
|
+
if (env.OPENCODE || env.OPENCODE_PID) return true;
|
|
101
|
+
const configDir = opencodeConfigDir(env, homedir);
|
|
102
|
+
if (!existsSync(configDir)) return false;
|
|
103
|
+
try { return readdirSync(configDir).length > 0; } catch { return false; }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function installOpencodePlugin(opts = {}) {
|
|
107
|
+
const { version, dryRun = false, force = false, ...rest } = opts;
|
|
108
|
+
const status = opencodeStatus({ version, ...rest });
|
|
109
|
+
if (status.foreign && !force) {
|
|
110
|
+
return { ...status, action: 'refused', reason: 'a dotmd.js without a dotmd banner is already there — it was not written by dotmd' };
|
|
111
|
+
}
|
|
112
|
+
if (status.exists && !status.stale && !status.foreign) {
|
|
113
|
+
return { ...status, action: 'current' };
|
|
114
|
+
}
|
|
115
|
+
const action = status.exists ? 'updated' : 'installed';
|
|
116
|
+
if (!dryRun) {
|
|
117
|
+
mkdirSync(path.dirname(status.path), { recursive: true });
|
|
118
|
+
writeFileSync(status.path, renderOpencodePlugin(version), 'utf8');
|
|
119
|
+
}
|
|
120
|
+
return { ...status, action, version };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// What identity does dotmd actually see here, and can it tell this session
|
|
124
|
+
// apart from its siblings? This report exists because the OpenCode breakage was
|
|
125
|
+
// invisible until a verb failed: dotmd looked for variables OpenCode never set,
|
|
126
|
+
// and nothing surfaced that until `use` refused to run. Anyone on any host can
|
|
127
|
+
// now check what dotmd resolved instead of discovering it through a failure.
|
|
128
|
+
export function describeSessionIdentity(opts = {}) {
|
|
129
|
+
const { env = process.env, homedir = os.homedir(), version } = opts;
|
|
130
|
+
const source = hostSessionSource(env);
|
|
131
|
+
const opencode = opencodeStatus({ env, homedir, version });
|
|
132
|
+
const underOpencode = Boolean(env.OPENCODE || env.OPENCODE_PID);
|
|
133
|
+
|
|
134
|
+
if (!source) {
|
|
135
|
+
return {
|
|
136
|
+
id: null, scope: 'none', source: null, host: null,
|
|
137
|
+
summary: 'no session identity — `use`, `set`, `baton` and `archive` fail closed',
|
|
138
|
+
advice: underOpencode
|
|
139
|
+
? ['dotmd install opencode', 'or set DOTMD_SESSION_ID for this shell']
|
|
140
|
+
: ['set DOTMD_SESSION_ID for this shell or host session', 'known hosts: dotmd install'],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const advice = [];
|
|
145
|
+
// The distinction that matters: a process- or terminal-scoped id is shared by
|
|
146
|
+
// every session under it, so those sessions can release each other's plans.
|
|
147
|
+
if (source.scope === 'process' && source.host === 'OpenCode') {
|
|
148
|
+
advice.push(opencode.exists
|
|
149
|
+
// The plugin is there but this shell was spawned before it loaded, so it
|
|
150
|
+
// still carries the old identity — say so rather than leave a warning
|
|
151
|
+
// marker with no explanation.
|
|
152
|
+
? 'restart OpenCode — this session predates the installed integration'
|
|
153
|
+
: 'dotmd install opencode — for a per-session identity');
|
|
154
|
+
} else if (source.scope === 'terminal') {
|
|
155
|
+
advice.push('every agent session in this terminal shares this id — set DOTMD_SESSION_ID per session');
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
id: source.id,
|
|
159
|
+
scope: source.scope,
|
|
160
|
+
source: source.variable,
|
|
161
|
+
host: source.host,
|
|
162
|
+
summary: source.scope === 'session'
|
|
163
|
+
? `per-session identity from ${source.variable}`
|
|
164
|
+
: `${source.scope}-scoped identity from ${source.variable} — sessions sharing it can release each other's plans`,
|
|
165
|
+
advice,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --- Claude Code -----------------------------------------------------------
|
|
170
|
+
//
|
|
171
|
+
// Claude Code has a real plugin registry, so dotmd drives `claude plugin`
|
|
172
|
+
// rather than writing files. What it lacked was any CLI path to the FIRST
|
|
173
|
+
// install: `dotmd update` deliberately skips the plugin step when nothing is
|
|
174
|
+
// installed ("dotmd plugin not installed"), and the README's two slash commands
|
|
175
|
+
// only work from inside a session. So a user who installed the CLI from npm had
|
|
176
|
+
// no way to discover, from the CLI, that the plugin exists.
|
|
177
|
+
|
|
178
|
+
export const CLAUDE_MARKETPLACE = 'reowens/dotmd';
|
|
179
|
+
export const CLAUDE_PLUGIN_ID = 'dotmd@dotmd';
|
|
180
|
+
|
|
181
|
+
// Pure planner, mirroring planUpdate: the orchestration is unit-testable and
|
|
182
|
+
// the side effects stay in the caller.
|
|
183
|
+
export function planClaudeInstall({ installed, hasClaude, remove = false } = {}) {
|
|
184
|
+
if (remove) {
|
|
185
|
+
if (!installed) return [{ kind: 'skip', reason: 'dotmd plugin is not installed' }];
|
|
186
|
+
return hasClaude
|
|
187
|
+
? [{ kind: 'run', cmd: ['claude', 'plugin', 'uninstall', installed.id] }]
|
|
188
|
+
: [{ kind: 'manual', lines: [`/plugin uninstall ${installed.id}`] }];
|
|
189
|
+
}
|
|
190
|
+
if (installed) return [{ kind: 'skip', reason: `dotmd plugin already installed (${installed.version ?? 'unknown version'})` }];
|
|
191
|
+
// The marketplace has to be registered before the plugin resolves; adding one
|
|
192
|
+
// already present is a no-op, so this stays safe to re-run.
|
|
193
|
+
if (!hasClaude) {
|
|
194
|
+
return [{ kind: 'manual', lines: [`/plugin marketplace add ${CLAUDE_MARKETPLACE}`, `/plugin install ${CLAUDE_PLUGIN_ID}`] }];
|
|
195
|
+
}
|
|
196
|
+
return [
|
|
197
|
+
{ kind: 'run', cmd: ['claude', 'plugin', 'marketplace', 'add', CLAUDE_MARKETPLACE] },
|
|
198
|
+
{ kind: 'run', cmd: ['claude', 'plugin', 'install', CLAUDE_PLUGIN_ID] },
|
|
199
|
+
];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function removeOpencodePlugin(opts = {}) {
|
|
203
|
+
const { dryRun = false, force = false, ...rest } = opts;
|
|
204
|
+
const status = opencodeStatus(rest);
|
|
205
|
+
if (!status.exists) return { ...status, action: 'absent' };
|
|
206
|
+
if (status.foreign && !force) {
|
|
207
|
+
return { ...status, action: 'refused', reason: 'not a dotmd-generated file' };
|
|
208
|
+
}
|
|
209
|
+
if (!dryRun) rmSync(status.path, { force: true });
|
|
210
|
+
return { ...status, action: 'removed' };
|
|
211
|
+
}
|