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/src/install.mjs
ADDED
|
@@ -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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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))
|
|
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 {
|
|
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)
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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) {
|