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.
@@ -0,0 +1,151 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { bold, dim, green, yellow } from './color.mjs';
6
+ import {
7
+ CLAUDE_MARKETPLACE, CLAUDE_PLUGIN_ID, installOpencodePlugin, opencodeDetected,
8
+ opencodeStatus, planClaudeInstall, removeOpencodePlugin,
9
+ } from './host-integration.mjs';
10
+ import { readInstalledPlugin } from './update.mjs';
11
+ import { executableName, which } from './util.mjs';
12
+
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+ const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
15
+
16
+ const HOSTS = ['claude', 'opencode'];
17
+ const ALIASES = { 'claude-code': 'claude', claudecode: 'claude' };
18
+
19
+ function flagValue(argv, name) {
20
+ const i = argv.indexOf(name);
21
+ return i >= 0 ? argv[i + 1] : undefined;
22
+ }
23
+
24
+ function hostStates() {
25
+ const plugin = readInstalledPlugin();
26
+ return {
27
+ claude: { installed: Boolean(plugin), version: plugin?.version ?? null, id: plugin?.id ?? CLAUDE_PLUGIN_ID },
28
+ opencode: { ...opencodeStatus({ version: pkg.version }), detected: opencodeDetected() },
29
+ };
30
+ }
31
+
32
+ function reportStatus(json) {
33
+ const states = hostStates();
34
+ if (json) {
35
+ process.stdout.write(JSON.stringify({ cli: pkg.version, hosts: states }, null, 2) + '\n');
36
+ return;
37
+ }
38
+ const { claude, opencode } = states;
39
+ process.stdout.write(`${bold('dotmd host integrations')} ${dim(`CLI ${pkg.version}`)}\n\n`);
40
+
41
+ process.stdout.write(` claude ${claude.installed ? green(claude.version ?? 'installed') : yellow('not installed')}\n`);
42
+ process.stdout.write(` ${dim(claude.installed ? claude.id : 'plugin: SessionStart primer, PreToolUse guard, workflow skill')}\n`);
43
+
44
+ const ocState = opencode.foreign ? yellow('unmanaged file present')
45
+ : !opencode.exists ? yellow('not installed')
46
+ : opencode.stale ? yellow(`${opencode.version} — behind CLI ${pkg.version}`)
47
+ : green(opencode.version);
48
+ process.stdout.write(` opencode ${ocState}\n`);
49
+ process.stdout.write(` ${dim(opencode.path)}\n`);
50
+
51
+ const todo = [];
52
+ if (!claude.installed) todo.push('dotmd install claude');
53
+ if (!opencode.exists || opencode.stale) todo.push('dotmd install opencode');
54
+ if (todo.length) {
55
+ process.stdout.write('\n');
56
+ for (const cmd of todo) process.stdout.write(`Run ${bold(cmd)}\n`);
57
+ }
58
+ if (!opencode.exists) {
59
+ process.stdout.write(dim('\nWithout the OpenCode integration, every session in one OpenCode process\n'));
60
+ process.stdout.write(dim('shares one identity — so a session can release another session\'s plan —\n'));
61
+ process.stdout.write(dim('and no session-start primer runs.\n'));
62
+ }
63
+ }
64
+
65
+ function installClaude(argv, dryRun, json) {
66
+ const remove = argv.includes('--remove');
67
+ const steps = planClaudeInstall({ installed: readInstalledPlugin(), hasClaude: which('claude'), remove });
68
+ if (json) {
69
+ process.stdout.write(JSON.stringify({ host: 'claude', dryRun, steps }, null, 2) + '\n');
70
+ return;
71
+ }
72
+ let ran = false;
73
+ for (const step of steps) {
74
+ if (step.kind === 'skip') { process.stdout.write(`${dim('skip:')} ${step.reason}\n`); continue; }
75
+ if (step.kind === 'manual') {
76
+ process.stdout.write(`${yellow('claude CLI not on PATH')} — run these from a Claude Code session:\n`);
77
+ for (const line of step.lines) process.stdout.write(` ${bold(line)}\n`);
78
+ continue;
79
+ }
80
+ if (dryRun) { process.stdout.write(dim(`[dry-run] Would run: ${step.cmd.join(' ')}\n`)); continue; }
81
+ process.stdout.write(dim(`$ ${step.cmd.join(' ')}\n`));
82
+ const result = spawnSync(executableName(step.cmd[0]), step.cmd.slice(1), {
83
+ stdio: 'inherit', shell: process.platform === 'win32',
84
+ });
85
+ ran = true;
86
+ if (result.status !== 0) {
87
+ process.stdout.write(yellow(`(claude exited ${result.status ?? '?'})\n`));
88
+ process.exitCode = 1;
89
+ return;
90
+ }
91
+ }
92
+ if (ran && !remove) process.stdout.write(green('\n✓ restart Claude Code (or /reload-plugins) to apply.\n'));
93
+ }
94
+
95
+ function installOpencode(argv, dryRun, json) {
96
+ const force = argv.includes('--force');
97
+ const dir = flagValue(argv, '--path');
98
+ const result = argv.includes('--remove')
99
+ ? removeOpencodePlugin({ dryRun, force, dir })
100
+ : installOpencodePlugin({ version: pkg.version, dryRun, force, dir });
101
+
102
+ if (json) {
103
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
104
+ if (result.action === 'refused') process.exitCode = 1;
105
+ return result;
106
+ }
107
+
108
+ const prefix = dryRun ? dim('[dry-run] ') : '';
109
+ switch (result.action) {
110
+ case 'refused':
111
+ process.stderr.write(`${result.path}\n refused: ${result.reason}\n Pass --force to overwrite it.\n`);
112
+ process.exitCode = 1;
113
+ return result;
114
+ case 'current':
115
+ process.stdout.write(`${green('✓')} opencode integration already current (${result.version})\n${dim(result.path)}\n`);
116
+ return result;
117
+ case 'absent':
118
+ process.stdout.write(`${dim('nothing to remove:')} ${result.path}\n`);
119
+ return result;
120
+ case 'removed':
121
+ process.stdout.write(`${prefix}removed ${result.path}\n`);
122
+ process.stdout.write(dim('Restart OpenCode to apply. Sessions fall back to a process-scoped identity.\n'));
123
+ return result;
124
+ default:
125
+ process.stdout.write(`${prefix}${green('✓')} ${result.action} opencode integration (${pkg.version})\n${dim(result.path)}\n`);
126
+ if (dryRun) return result;
127
+ process.stdout.write('\nRestart OpenCode to apply. New sessions then get:\n');
128
+ process.stdout.write(` ${dim('·')} a per-session ownership identity (one session can no longer release another's plan)\n`);
129
+ process.stdout.write(` ${dim('·')} the ${bold('dotmd hud')} primer at session start, like Claude Code's SessionStart hook\n`);
130
+ process.stdout.write(dim('\nPlans already in-session were claimed under the old process-scoped identity;\n'));
131
+ process.stdout.write(dim('close them before restarting, or reclaim with --force afterwards.\n'));
132
+ return result;
133
+ }
134
+ }
135
+
136
+ export function runInstall(argv, _config, opts = {}) {
137
+ const json = argv.includes('--json');
138
+ const requested = argv.find(a => !a.startsWith('-'));
139
+
140
+ if (!requested) { reportStatus(json); return; }
141
+ const host = ALIASES[requested] ?? requested;
142
+ if (!HOSTS.includes(host)) {
143
+ process.stderr.write(`Unknown host "${requested}". Known: ${HOSTS.join(', ')}\n`);
144
+ process.exitCode = 1;
145
+ return;
146
+ }
147
+
148
+ const dryRun = Boolean(opts.dryRun);
149
+ if (host === 'claude') { installClaude(argv, dryRun, json); return; }
150
+ installOpencode(argv, dryRun, json);
151
+ }
package/src/pickup.mjs CHANGED
@@ -5,25 +5,22 @@ import { authorizeManagedSource, authorizeRepoGeneratedPath } from './managed-pa
5
5
  import os from 'node:os';
6
6
  import { currentProcessOwner, mutateFileSet, processOwnerLiveness, processStartIdentity, replaceSnapshot, snapshotFile, withPathLocks } from './atomic-mutation.mjs';
7
7
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
8
- import { asString, relTime } from './util.mjs';
8
+ import { asString, hostSessionId, relTime } from './util.mjs';
9
9
 
10
10
  export const OWNERSHIP_SCHEMA = 2;
11
11
  export const HOOK_DELIVERY_LEASE_MS = 30_000;
12
12
 
13
13
  export function authoritativeSessionId(env = process.env) {
14
- const candidates = [
15
- ['DOTMD_SESSION_ID', 'dotmd'],
16
- ['CLAUDE_CODE_SESSION_ID', 'claude'],
17
- ['CLAUDE_SESSION_ID', 'claude'],
18
- ['OPENCODE_SESSION_ID', 'opencode'],
19
- ['OPENCODE_SESSION', 'opencode'],
20
- ['TERM_SESSION_ID', 'term'],
21
- ];
22
- for (const [name, host] of candidates) {
23
- const value = env[name]?.trim();
24
- if (value) return host === 'term' ? `term:${value}` : value;
25
- }
26
- throw new Error('No authoritative session identity. Set DOTMD_SESSION_ID for this shell or host session.');
14
+ const id = hostSessionId(env);
15
+ if (id) return id;
16
+ // Name the host when we can recognize it. The generic "set DOTMD_SESSION_ID"
17
+ // is the fallback of last resort, and a poor one to reach for first: exported
18
+ // from a shell profile it gives every session in that shell ONE id, which is
19
+ // the collision the ownership record exists to prevent.
20
+ const host = env.OPENCODE || env.OPENCODE_PID ? 'opencode' : null;
21
+ throw new Error(host
22
+ ? `No authoritative session identity. Run \`dotmd install ${host}\` to give each ${host} session its own, or set DOTMD_SESSION_ID for this shell.`
23
+ : 'No authoritative session identity. Set DOTMD_SESSION_ID for this shell or host session, or see `dotmd install` for supported hosts.');
27
24
  }
28
25
 
29
26
  export function availableSessionId(env = process.env) {
@@ -38,8 +35,10 @@ export function availableSessionId(env = process.env) {
38
35
  // instead of an age threshold that cannot tell a three-day-dead session from a
39
36
  // long-running one. Absent (a plain terminal, an unknown harness) is not an
40
37
  // error — it yields null, which reads as 'unverifiable' and never auto-reclaims.
38
+ // `OPENCODE_PID` is the OpenCode server process, which is exactly that harness:
39
+ // it hosts the session and outlives every tool shell it spawns.
41
40
  export function sessionProcessOwner(env = process.env) {
42
- const raw = (env.DOTMD_SESSION_PID ?? env.CLAUDE_PID)?.trim();
41
+ const raw = (env.DOTMD_SESSION_PID ?? env.CLAUDE_PID ?? env.OPENCODE_PID)?.trim();
43
42
  const pid = Number(raw);
44
43
  if (!raw || !Number.isInteger(pid) || pid <= 0) return null;
45
44
  return {
package/src/prompts.mjs CHANGED
@@ -6,7 +6,7 @@ import { buildIndex, resolveDocArg } from './index.mjs';
6
6
  import { runQuery } from './query.mjs';
7
7
  import { completePlanClaim, regenIndex, renderLifecycleMutation, runArchive, runStatus } from './lifecycle.mjs';
8
8
  import { runNew } from './new.mjs';
9
- import { green, dim } from './color.mjs';
9
+ import { green, dim, yellow } from './color.mjs';
10
10
  import { authorizeManagedSource } from './managed-path.mjs';
11
11
  import {
12
12
  authoritativeSessionId,
@@ -259,11 +259,17 @@ export async function consumePrompt(filePath, config, opts) {
259
259
 
260
260
  const planRef = asString(parsed.plan);
261
261
  let linkedClaim = null;
262
- if (planRef) linkedClaim = prepareLinkedPromptClaim(planRef, config, path.dirname(filePath));
262
+ let claimSkipReason = null;
263
+ if (planRef) {
264
+ const outcome = prepareLinkedPromptClaim(planRef, config, path.dirname(filePath));
265
+ if (outcome?.skipped) claimSkipReason = outcome.reason;
266
+ else linkedClaim = outcome;
267
+ }
263
268
 
264
269
  if (dryRun) {
265
270
  const prefix = dim('[dry-run]');
266
271
  process.stderr.write(`${prefix} Would emit body and archive: ${repoPath} (${status ?? 'unknown'} → archived)\n`);
272
+ if (claimSkipReason) process.stderr.write(`${prefix} Would NOT claim the linked plan: ${claimSkipReason}\n`);
267
273
  const bytes = Buffer.byteLength(body, 'utf8');
268
274
  const lines = body.split('\n').length;
269
275
  process.stderr.write(`${prefix} body preview (${bytes}B, ${lines} lines):\n`);
@@ -315,6 +321,7 @@ export async function consumePrompt(filePath, config, opts) {
315
321
  }
316
322
  process.stderr.write(`${green('→ Claimed')}: ${linkedClaim.repoPath} (in-session)\n`);
317
323
  }
324
+ if (claimSkipReason) process.stderr.write(`${yellow('→ Not claimed')}: ${claimSkipReason}\n`);
318
325
  process.stderr.write(`${green('✓ Consumed')}: ${consumedPath}\n`);
319
326
  const ownershipRecordPath = linkedClaim?.prepared?.recordPath ?? (linkedClaim ? readPlanOwnership(linkedClaim.repoPath, config)?.recordPath : null);
320
327
  const ownershipPath = ownershipRecordPath ? toRepoPath(ownershipRecordPath, config.repoRoot) : null;
@@ -364,16 +371,54 @@ export async function writeConsumedBody(body, archivedPath, write = null, linked
364
371
  // with `resolveDocPath` alone read only the repo-root form, so a doc-relative
365
372
  // link — the form nothing validates, since `plan` is not a `referenceFields`
366
373
  // entry — died as "missing" while pointing at a file that was plainly there.
374
+ // Why a linked plan could not be claimed, phrased so the reader knows what to
375
+ // do next. Each of these used to abort consumption entirely (see the skip
376
+ // contract on prepareLinkedPromptClaim).
377
+ // `startable` comes from the repo's own lifecycle config rather than the
378
+ // built-in default: a repo that configures its own startable statuses would
379
+ // otherwise be told to run `dotmd set active`, which its own validation
380
+ // rejects.
381
+ function explainUnclaimablePlan(disposition, repoPath, status, startable) {
382
+ switch (disposition.kind) {
383
+ case 'parked':
384
+ return `${repoPath} is ${status} — \`dotmd set ${startable} ${repoPath}\` to unpark it, then \`dotmd use ${repoPath}\``;
385
+ case 'busy':
386
+ return `${repoPath} is claimed by another session (${disposition.owner})`;
387
+ case 'terminal':
388
+ case 'physical-archive':
389
+ return `${repoPath} is already closed (${status})`;
390
+ case 'wrong-type':
391
+ return `${repoPath} is not a plan`;
392
+ case 'unconfigured-status':
393
+ return `${repoPath} has a status this repo does not configure (${status ?? 'none'})`;
394
+ case 'ownership-corrupt':
395
+ return `${repoPath} has an unreadable ownership record — \`dotmd doctor --claims\``;
396
+ default:
397
+ return `${repoPath} cannot be claimed (${disposition.kind})`;
398
+ }
399
+ }
400
+
401
+ // Returns a claim, or `{ skipped, reason }` when the linked plan cannot be
402
+ // claimed. It never refuses the consumption itself.
403
+ //
404
+ // It used to `die` on all three of these paths, which deadlocked the handoff
405
+ // loop the feature exists to close: `dotmd baton` stamps this link and parks
406
+ // the plan in the same breath, and five of the seven statuses it parks with are
407
+ // not startable — so baton routinely produced a prompt that `dotmd use` would
408
+ // refuse forever, while the SessionStart hud kept telling every new session to
409
+ // run exactly that command. The body is the whole point of a saved prompt, and
410
+ // no reason to skip the claim is a reason to withhold it.
367
411
  function prepareLinkedPromptClaim(planRef, config, promptDir) {
412
+ const skip = reason => ({ skipped: true, reason });
368
413
  let planPath = resolveRefPath(planRef, promptDir, config.repoRoot)
369
414
  ?? resolveDocPath(planRef, config)
370
415
  ?? resolveDocArg(planRef, config, { dieOnMiss: false });
371
- if (!planPath || !existsSync(planPath)) die(`Linked plan is missing; prompt was not consumed: ${planRef}`);
416
+ if (!planPath || !existsSync(planPath)) return skip(`linked plan is missing (moved or renamed): ${planRef}`);
372
417
  planPath = authorizeManagedSource(planPath, config, { kind: 'Prompt linked plan source' }).path;
373
418
  const raw = readFileSync(planPath, 'utf8');
374
419
  let parsed;
375
420
  try { parsed = parseSimpleFrontmatter(extractFrontmatter(raw).frontmatter); }
376
- catch { die(`Linked plan is malformed; prompt was not consumed: ${planRef}`); }
421
+ catch { return skip(`linked plan has malformed frontmatter: ${toRepoPath(planPath, config.repoRoot)}`); }
377
422
  const repoPath = toRepoPath(planPath, config.repoRoot);
378
423
  const sessionId = authoritativeSessionId();
379
424
  const ownership = readPlanOwnership(repoPath, config);
@@ -391,7 +436,10 @@ function prepareLinkedPromptClaim(planRef, config, promptDir) {
391
436
  sessionId,
392
437
  malformed: false,
393
438
  });
394
- if (!disposition.pickupable) die(`Linked plan cannot be claimed (${disposition.kind}); prompt was not consumed: ${repoPath}`);
439
+ if (!disposition.pickupable) {
440
+ const startable = [...(config.lifecycle.startableStatuses ?? [])][0] ?? 'active';
441
+ return skip(explainUnclaimablePlan(disposition, repoPath, oldStatus, startable));
442
+ }
395
443
  if (disposition.kind === 'resume') return { planPath, repoPath, prepared: null, planChanged: false, disposition: disposition.kind };
396
444
  const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
397
445
  const rendered = disposition.kind === 'start'
package/src/section.mjs CHANGED
@@ -53,23 +53,121 @@ export function findSection(sections, name) {
53
53
 
54
54
  // Status marker detection for phase headings. Returns one of:
55
55
  // 'shipped' | 'skipped' | 'in-progress' | 'blocked' | 'todo' | null
56
- const MARKER_PATTERNS = [
57
- { kind: 'shipped', re: /(✅|☑|✔|\bshipped\b|\bdone\b|\bcomplete\b)/i },
58
- { kind: 'skipped', re: /(⏭|\bskip(?:ped)?\b)/i },
59
- { kind: 'in-progress', re: /(🟡|🔄|\bin[-_ ]?(?:progress|flight)\b|\bwip\b)/i },
60
- { kind: 'blocked', re: /(🚧|🔴|\bblocked\b)/i },
61
- { kind: 'todo', re: /(⬜|⬛|◻|☐|\btodo\b|\bnot[-_ ]?started\b)/i },
56
+ // Glyphs are checked BEFORE prose, not interleaved with it. A glyph is a
57
+ // deliberate mark; a prose word is often about something else in the sentence:
58
+ //
59
+ // ⬜ Phase 4: Retry budget rework (scoping COMPLETE) ← the SCOPING completed
60
+ // Phase 2 — schema migrated ⬜ todo (column rename half DONE) ← half a RENAME is done
61
+ // Phase 3 (original scope) — ⏭ superseded by the shipped Phase 3 ← a DIFFERENT phase shipped
62
+ //
63
+ // Interleaved, first-match-wins order called all three shipped, so
64
+ // findActivePhase skipped a phase the author had explicitly marked unstarted.
65
+ // Within each tier the order is still priority order, which decides a heading
66
+ // carrying more than one mark.
67
+ const GLYPH_PATTERNS = [
68
+ { kind: 'shipped', re: /(✅|☑|✔)/ },
69
+ { kind: 'skipped', re: /(⏭)/ },
70
+ { kind: 'in-progress', re: /(🟡|🔄)/ },
71
+ { kind: 'blocked', re: /(🚧|🔴)/ },
72
+ { kind: 'todo', re: /(⬜|⬛|◻|☐)/ },
73
+ ];
74
+
75
+ const PROSE_PATTERNS = [
76
+ // A qualifier inverts the word it modifies, and these run BEFORE the plain
77
+ // `shipped` pattern so the qualifier wins. Otherwise `mostly done` and
78
+ // `(partially complete)` both read as shipped, and findActivePhase skips a
79
+ // phase whose own checklist still has open boxes.
80
+ { kind: 'todo', re: /\b(?:not|never)[\s-]+(?:complete(?:d)?|done|shipped|started)\b/i },
81
+ { kind: 'in-progress', re: /\b(?:partially|partly|mostly|nearly|almost|half)[\s-]*(?:complete(?:d)?|done|shipped)\b/i },
82
+ { kind: 'shipped', re: /(\bshipped\b|\bdone\b|\bcomplete\b)/i },
83
+ { kind: 'skipped', re: /(\bskip(?:ped)?\b)/i },
84
+ { kind: 'in-progress', re: /(\bin[-_ ]?(?:progress|flight)\b|\bwip\b)/i },
85
+ { kind: 'blocked', re: /(\bblocked\b)/i },
86
+ { kind: 'todo', re: /(\btodo\b|\bnot[-_ ]?started\b)/i },
62
87
  ];
63
88
 
64
89
  export function detectMarker(heading) {
65
- for (const { kind, re } of MARKER_PATTERNS) {
90
+ for (const { kind, re } of GLYPH_PATTERNS) {
91
+ if (re.test(heading)) return kind;
92
+ }
93
+ for (const { kind, re } of PROSE_PATTERNS) {
66
94
  if (re.test(heading)) return kind;
67
95
  }
68
96
  return null;
69
97
  }
70
98
 
99
+ // Leading decoration a phase heading may carry before the word "Phase":
100
+ // emphasis punctuation and any run of status markers. `detectMarker` already
101
+ // reads a marker wherever it sits, but this test was anchored at `^phase`, so
102
+ // a plan that writes `### ⬜ Phase 2 — …` had NO phases at all — not a
103
+ // miscount, an empty phase set, which drops the pickup card to its
104
+ // "no ## Phases section" fallback. 93 headings in one 482-plan corpus.
105
+ // Several of these glyphs are commonly typed in emoji-presentation form
106
+ // (the base codepoint plus a variation selector): the skip mark, the ballot
107
+ // box, the check mark. `detectMarker` is a substring test so it never
108
+ // noticed. This is a character CLASS, and the selector sits between the glyph
109
+ // and the space, is not \s, and ends the run — so a heading led by the
110
+ // emoji-presentation form was not a phase heading at all, while the bare
111
+ // codepoint was. Reported by the owner, 2026-08-16.
112
+ const PHASE_DECORATION = String.raw`[\s>*_~\`#-]*(?:[✅🚧⬜🟡⏭☑✔◻☐⬛🔴🔄][︎️]?\s*)*`;
113
+ const PHASE_LEAD = new RegExp(`^${PHASE_DECORATION}phase\\b`, 'i');
114
+
115
+ // "Phase 3 outcome" is commentary ABOUT a phase, not a phase. Counting it
116
+ // inflates the phase set, and because `findActivePhase` ranks blocked above
117
+ // todo, a heading like "Phase 3 smoke findings — BLOCKER" gets picked as the
118
+ // plan's active phase over a real unstarted one. 43 in the same corpus.
119
+ //
120
+ // The noun list is deliberately tight. Wrongly excluding a real phase hides
121
+ // work; wrongly including commentary only miscounts — so this errs toward
122
+ // counting, and a word is added here only once the corpus shows it standing
123
+ // for a retrospective rather than a phase.
124
+ const PHASE_COMMENTARY = new RegExp(
125
+ `^${PHASE_DECORATION}phase\\s+\\S+\\s+(outcome|progress|notes?|findings?|smoke|retro|review|recap|summary)\\b`,
126
+ 'i',
127
+ );
128
+
71
129
  export function isPhaseHeading(section) {
72
- return section.level === 3 && /^phase\b/i.test(section.heading);
130
+ if (section.level !== 3) return false;
131
+ return PHASE_LEAD.test(section.heading) && !PHASE_COMMENTARY.test(section.heading);
132
+ }
133
+
134
+ // A phase's OWN checklist — the boxes directly under its heading, stopping at
135
+ // the next heading of any level so a sub-section's checklist is never counted
136
+ // as the parent phase's evidence.
137
+ export function phaseTally(section) {
138
+ let checked = 0, unchecked = 0;
139
+ for (const line of String(section?.body ?? '').split('\n')) {
140
+ if (/^#{1,6}\s/.test(line)) break;
141
+ if (/^\s*[-*]\s*\[x\]/i.test(line)) checked++;
142
+ else if (/^\s*[-*]\s*\[ \]/.test(line)) unchecked++;
143
+ }
144
+ return { checked, unchecked, total: checked + unchecked };
145
+ }
146
+
147
+ /**
148
+ * A phase whose declared marker its own checklist contradicts.
149
+ *
150
+ * Only two disagreements are reported, because only two are unambiguous:
151
+ *
152
+ * shipped + an open box — the plan's own checklist says otherwise
153
+ * todo + every box checked — the work is done, the marker says unstarted
154
+ *
155
+ * `blocked` and `skipped` are judgements a tally cannot refute (blocked at 0/7
156
+ * is perfectly coherent), and `in-progress` with everything checked usually
157
+ * means work the checklist does not enumerate. Including those three took the
158
+ * count from 13 to 19 on a 482-plan corpus, every extra one arguable — so they
159
+ * are excluded rather than reported and explained away.
160
+ *
161
+ * Returns null when there is no conflict, no marker, or no checklist.
162
+ */
163
+ export function phaseMarkerConflict(section) {
164
+ const declared = detectMarker(section?.heading ?? '');
165
+ if (declared !== 'shipped' && declared !== 'todo') return null;
166
+ const tally = phaseTally(section);
167
+ if (tally.total === 0) return null;
168
+ if (declared === 'shipped' && tally.unchecked > 0) return { declared, implied: 'in progress', ...tally };
169
+ if (declared === 'todo' && tally.unchecked === 0) return { declared, implied: 'shipped', ...tally };
170
+ return null;
73
171
  }
74
172
 
75
173
  // Summarize a phase set: { 'shipped': 2, 'in-progress': 1, 'todo': 2 }
package/src/update.mjs CHANGED
@@ -4,6 +4,8 @@ import path from 'node:path';
4
4
  import os from 'node:os';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { green, dim, yellow } from './color.mjs';
7
+ import { executableName, which } from './util.mjs';
8
+ import { installOpencodePlugin, opencodeStatus } from './host-integration.mjs';
7
9
 
8
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
11
  const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
@@ -72,23 +74,19 @@ export function planUpdate(opts, ctx) {
72
74
  } else {
73
75
  steps.push({ kind: 'plugin', cmd: ['claude', 'plugin', 'update', ctx.plugin.id] });
74
76
  }
77
+ // The OpenCode integration is a file dotmd wrote, so it goes stale silently
78
+ // the moment the CLI moves on. Refresh it here — but only if it is already
79
+ // installed. `update` keeps hosts in lockstep; it never adopts a new one,
80
+ // which stays the job of the explicit `dotmd install`.
81
+ if (ctx.opencode?.exists && ctx.opencode.stale) {
82
+ steps.push({ kind: 'opencode', path: ctx.opencode.path });
83
+ } else if (ctx.opencode?.foreign) {
84
+ steps.push({ kind: 'skip', reason: `${ctx.opencode.path} was not written by dotmd — leaving it alone` });
85
+ }
75
86
  }
76
87
  return steps;
77
88
  }
78
89
 
79
- function which(bin) {
80
- try {
81
- const cmd = process.platform === 'win32' ? 'where' : 'which';
82
- return spawnSync(cmd, [bin], { encoding: 'utf8' }).status === 0;
83
- } catch {
84
- return false;
85
- }
86
- }
87
-
88
- function executableName(bin) {
89
- return process.platform === 'win32' && !bin.endsWith('.cmd') ? `${bin}.cmd` : bin;
90
- }
91
-
92
90
  export function runUpdate(argv, _config, opts = {}) {
93
91
  const check = argv.includes('--check');
94
92
  const cliOnly = argv.includes('--cli-only');
@@ -105,15 +103,21 @@ export function runUpdate(argv, _config, opts = {}) {
105
103
  : yellow('ahead — CLI is behind');
106
104
  process.stdout.write(`dotmd plugin: ${plugin.version ?? '?'} (${plugin.id}) ${tag}\n`);
107
105
  } else {
108
- process.stdout.write(dim('dotmd plugin: not installed\n'));
106
+ process.stdout.write(dim('dotmd plugin: not installed — `dotmd install claude`\n'));
109
107
  }
108
+ const oc = opencodeStatus({ version: pkg.version });
109
+ if (!oc.exists) process.stdout.write(dim('dotmd opencode: not installed\n'));
110
+ else if (oc.foreign) process.stdout.write(`dotmd opencode: ${yellow('unmanaged file — not written by dotmd')}\n`);
111
+ else process.stdout.write(`dotmd opencode: ${oc.version} ${oc.stale ? yellow('behind — run `dotmd update`') : green('in sync')}\n`);
110
112
  return;
111
113
  }
112
114
 
113
- const steps = planUpdate({ cliOnly, pluginOnly }, { plugin, hasClaude: which('claude'), hasNpm: which('npm') });
115
+ const opencode = opencodeStatus({ version: pkg.version });
116
+ const steps = planUpdate({ cliOnly, pluginOnly }, { plugin, opencode, hasClaude: which('claude'), hasNpm: which('npm') });
114
117
  if (opts.dryRun) {
115
118
  for (const step of steps) {
116
119
  if (step.kind === 'skip') process.stdout.write(dim(`[dry-run] skip: ${step.reason}\n`));
120
+ else if (step.kind === 'opencode') process.stdout.write(dim(`[dry-run] Would refresh: ${step.path}\n`));
117
121
  else process.stdout.write(dim(`[dry-run] Would run: ${step.cmd.join(' ')}\n`));
118
122
  }
119
123
  return;
@@ -125,6 +129,12 @@ export function runUpdate(argv, _config, opts = {}) {
125
129
  process.stdout.write(dim(`skip: ${s.reason}\n`));
126
130
  continue;
127
131
  }
132
+ if (s.kind === 'opencode') {
133
+ const result = installOpencodePlugin({ version: pkg.version });
134
+ process.stdout.write(dim(`refreshed opencode integration → ${pkg.version} ${result.path}\n`));
135
+ ran = true;
136
+ continue;
137
+ }
128
138
  process.stdout.write(dim(`$ ${s.cmd.join(' ')}\n`));
129
139
  const r = spawnSync(executableName(s.cmd[0]), s.cmd.slice(1), {
130
140
  stdio: 'inherit',
package/src/util.mjs CHANGED
@@ -1,16 +1,88 @@
1
- import { existsSync } from 'node:fs';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
2
3
  import path from 'node:path';
3
4
  import os from 'node:os';
5
+ import { fileURLToPath } from 'node:url';
4
6
  import { dim } from './color.mjs';
5
7
 
8
+ // The one list of environment variables that can name the session dotmd is
9
+ // running inside. It lives here, in a leaf module, because both consumers must
10
+ // read the same list: `currentSessionId` below (journal attribution, which falls
11
+ // back to the shell) and `authoritativeSessionId` in pickup.mjs (plan ownership,
12
+ // which fails closed). Two hand-maintained copies is how the OpenCode entries
13
+ // drifted into naming variables OpenCode does not set.
14
+ //
15
+ // Order is most-specific-first. `OPENCODE_PID` is a real fallback, not a guess:
16
+ // OpenCode's CLI middleware sets it on the process every tool shell inherits,
17
+ // and it is per-OpenCode-process rather than per-session, so it sits below the
18
+ // session-scoped names above it and above `TERM_SESSION_ID` — a terminal id is
19
+ // shared by every agent run in that window and outlives all of them.
20
+ // `scope: 'session'` means the variable names one agent session; `'process'`
21
+ // and `'terminal'` are coarser — several sessions can share one, so they cannot
22
+ // tell two of them apart. `dotmd doctor --session` reports that distinction, and
23
+ // it is the whole reason `dotmd install opencode` exists.
24
+ const SESSION_ID_SOURCES = [
25
+ { variable: 'DOTMD_SESSION_ID', prefix: null, scope: 'session', host: 'explicit override' },
26
+ { variable: 'CLAUDE_CODE_SESSION_ID', prefix: null, scope: 'session', host: 'Claude Code' },
27
+ { variable: 'CLAUDE_SESSION_ID', prefix: null, scope: 'session', host: 'Claude Code' },
28
+ { variable: 'OPENCODE_SESSION_ID', prefix: null, scope: 'session', host: 'OpenCode' },
29
+ { variable: 'OPENCODE_SESSION', prefix: null, scope: 'session', host: 'OpenCode' },
30
+ { variable: 'OPENCODE_PID', prefix: 'opencode', scope: 'process', host: 'OpenCode' },
31
+ { variable: 'TERM_SESSION_ID', prefix: 'term', scope: 'terminal', host: 'terminal' },
32
+ ];
33
+
34
+ // Which source named the session, with the id it produced — or null when the
35
+ // environment names none.
36
+ export function hostSessionSource(env = process.env) {
37
+ for (const source of SESSION_ID_SOURCES) {
38
+ const value = env[source.variable]?.trim();
39
+ if (!value) continue;
40
+ return { ...source, id: source.prefix ? `${source.prefix}:${value}` : value };
41
+ }
42
+ return null;
43
+ }
44
+
45
+ // The session id the environment names, or null when it names none.
46
+ export function hostSessionId(env = process.env) {
47
+ return hostSessionSource(env)?.id ?? null;
48
+ }
49
+
6
50
  // Stable identifier for the current shell/agent session. Used for journal
7
51
  // attribution and hint de-duplication — not for any plan locking.
8
52
  export function currentSessionId() {
9
- if (process.env.DOTMD_SESSION_ID) return process.env.DOTMD_SESSION_ID;
10
- if (process.env.CLAUDE_CODE_SESSION_ID) return process.env.CLAUDE_CODE_SESSION_ID;
11
- if (process.env.CLAUDE_SESSION_ID) return process.env.CLAUDE_SESSION_ID;
12
- if (process.env.TERM_SESSION_ID) return `term:${process.env.TERM_SESSION_ID}`;
13
- return `shell:${os.userInfo().username}@${os.hostname()}`;
53
+ return hostSessionId() ?? `shell:${os.userInfo().username}@${os.hostname()}`;
54
+ }
55
+
56
+ // The running CLI's version, read once and memoized — several surfaces compare
57
+ // it against what a host integration was generated from.
58
+ let cachedVersion;
59
+ export function dotmdVersion() {
60
+ if (cachedVersion === undefined) {
61
+ try {
62
+ const pkgPath = path.resolve(fileURLToPath(import.meta.url), '..', '..', 'package.json');
63
+ cachedVersion = JSON.parse(readFileSync(pkgPath, 'utf8')).version ?? null;
64
+ } catch {
65
+ cachedVersion = null;
66
+ }
67
+ }
68
+ return cachedVersion;
69
+ }
70
+
71
+ // Is `bin` runnable from PATH? Used to decide whether dotmd can drive a host's
72
+ // own CLI or must print the in-session commands for the user to run instead.
73
+ export function which(bin) {
74
+ try {
75
+ const cmd = process.platform === 'win32' ? 'where' : 'which';
76
+ return spawnSync(cmd, [bin], { encoding: 'utf8' }).status === 0;
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
82
+ // Windows resolves `foo` to `foo.cmd` only through a shell; spawn needs the
83
+ // real name.
84
+ export function executableName(bin) {
85
+ return process.platform === 'win32' && !bin.endsWith('.cmd') ? `${bin}.cmd` : bin;
14
86
  }
15
87
 
16
88
  export function escapeTable(value) {