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 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
- The recommended Claude Code setup is the dotmd plugin:
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 and OpenCode session IDs are recognized automatically. Other hosts
468
- must set DOTMD_SESSION_ID; anonymous ownership mutations fail closed.
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.74.5",
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
  ],
@@ -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. situ.md)
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
+ }