dotmd-cli 0.74.1 → 0.74.2

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/bin/dotmd.mjs CHANGED
@@ -202,6 +202,7 @@ Analyze:
202
202
  Validate & Fix:
203
203
  doctor [--apply] Auto-fix everything: refs, lint, long fields, dates, index (preview by default)
204
204
  doctor --transactions Report/clear wedged mutation transactions (run this if mutations refuse repo-wide)
205
+ doctor --claims Report/release plan claims held by sessions that are gone ("busy in another session")
205
206
  self-check Project/version skew diagnostic (alias: doctor --project)
206
207
  lint [--fix] Check and auto-fix frontmatter issues
207
208
  fix-refs [--dry-run] Auto-fix broken reference paths + body links
@@ -727,6 +728,21 @@ Modes:
727
728
  clear the transactions whose files already agree on
728
729
  one generation (no document content is touched);
729
730
  the rest are reported for manual review.
731
+ --claims Report which plans are claimed by which session, how
732
+ old each claim is, and whether the owning session's
733
+ process is still alive. A claim is what makes \`set\`,
734
+ \`archive\`, \`baton\`, and \`rename\` refuse a single
735
+ plan ("Plan is busy in another session"); nothing
736
+ expires one, so a session that died holds its plan
737
+ until someone takes it back. Add --apply to release
738
+ the claims whose owning process is provably gone
739
+ (their plans return to \`active\`).
740
+ --claims --apply --older-than <24h|3d>
741
+ Also release claims dotmd cannot judge — ones written
742
+ before it recorded the owning process, or held on
743
+ another machine — that are older than the duration.
744
+ That threshold is your judgement, not dotmd's: it
745
+ cannot tell a dead session from a slow one.
730
746
  --statuses Read-only diagnostic: detect overloaded status
731
747
  buckets where one status holds plans pursuing
732
748
  multiple distinct unstuck-actions. Suggests how
@@ -1741,7 +1757,9 @@ async function main() {
1741
1757
  const doctorDryRun = doctorSubMode ? dryRun : (dryRun || !doctorExplicitApply);
1742
1758
  const filtered = restArgs.filter(a => a !== '--apply' && a !== '--yes');
1743
1759
  const { runDoctor } = await import('../src/doctor.mjs');
1744
- runDoctor(filtered, config, { dryRun: doctorDryRun });
1760
+ // Awaited because --claims releases through `runSet`, which is async; the
1761
+ // other modes return undefined and are unaffected.
1762
+ await runDoctor(filtered, config, { dryRun: doctorDryRun });
1745
1763
  return;
1746
1764
  }
1747
1765
  if (command === 'statuses') { const { runStatuses } = await import('../src/statuses.mjs'); await runStatuses(restArgs, config, { dryRun, type: typeArg }); return; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.74.1",
3
+ "version": "0.74.2",
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",
package/src/commands.mjs CHANGED
@@ -134,7 +134,7 @@ const definitions = [
134
134
  command('migrate', mutates('managed source sweep'), 'mutate', [form('<field> <old> <new> [files...]', { args: positionals(3, Infinity), options: [flag('--show-files')] })]),
135
135
  command('fix-refs', mutates('managed source sweep'), 'mutate', [form('', { options: [flag('--show-files')] })]),
136
136
  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('--json'), flag('--include-archived')] })]),
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')] })]),
138
138
  command('statuses', mutates('project config path; document scan is read-only'), 'mutate', [
139
139
  form('list', { subcommands: ['list'], options: [value('--type'), flag('--json')] }),
140
140
  form('add <name>', { subcommands: ['add'], args: positionals(1, 1), options: STATUS_PROPERTY_OPTIONS }),
package/src/doctor.mjs CHANGED
@@ -1,9 +1,9 @@
1
- import { readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fixBrokenRefs } from './fix-refs.mjs';
4
4
  import { runLint } from './lint.mjs';
5
5
  import { syncHubStatuses } from './sync-status.mjs';
6
- import { runTouch } from './lifecycle.mjs';
6
+ 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';
@@ -14,8 +14,9 @@ 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 { toRepoPath } from './util.mjs';
17
+ import { die, relTime, toRepoPath } from './util.mjs';
18
18
  import { inspectTransactions, resolveTransactions } from './atomic-mutation.mjs';
19
+ import { availableSessionId, releaseVanishedPlanClaim, surveyOwnershipClaims } from './pickup.mjs';
19
20
 
20
21
  // Tunable thresholds for `dotmd doctor --statuses` conflation detection.
21
22
  // MIN_BUCKET_SIZE: only flag buckets with at least this many docs (small buckets aren't worth nagging).
@@ -105,6 +106,109 @@ function runDoctorTransactions(argv, config, opts = {}) {
105
106
  }
106
107
  }
107
108
 
109
+ function parseOlderThan(argv) {
110
+ const idx = argv.indexOf('--older-than');
111
+ if (idx === -1) return null;
112
+ const raw = argv[idx + 1];
113
+ const match = /^(\d+)([hd])$/.exec(raw ?? '');
114
+ if (!match) die('--older-than takes a duration like 24h or 3d');
115
+ return Number(match[1]) * (match[2] === 'h' ? 3600_000 : 86_400_000) ;
116
+ }
117
+
118
+ // The counterpart to --transactions: a claim nobody will ever release wedges
119
+ // `set`, `archive`, `baton` and `rename` on that plan forever, and until now
120
+ // nothing in the tool could even show you the claims, let alone end one.
121
+ //
122
+ // Two tiers, because two very different things are being asked. A claim whose
123
+ // session process is provably gone is released by --apply on its own: that is
124
+ // dotmd observing a fact, the same bar a forced hook-delivery takeover uses.
125
+ // A claim dotmd *cannot* judge — written before it recorded the owning process,
126
+ // taken from a plain terminal, or held on another machine — is never released
127
+ // by a plain --apply, because "I can't see the owner" is not evidence the owner
128
+ // left. Releasing those needs --older-than, which is the user supplying the
129
+ // judgement dotmd doesn't have, as a policy rather than a guess.
130
+ async function runDoctorClaims(argv, config, opts = {}) {
131
+ const json = argv.includes('--json');
132
+ const apply = !opts.dryRun;
133
+ const olderThanMs = parseOlderThan(argv);
134
+ const claims = surveyOwnershipClaims(config);
135
+
136
+ const dead = claims.filter(claim => !claim.corrupt && claim.liveness === 'dead');
137
+ const aged = olderThanMs === null ? [] : claims.filter(claim =>
138
+ !claim.corrupt && claim.liveness !== 'dead' && claim.ageMs !== null && claim.ageMs >= olderThanMs);
139
+ const targets = [...dead, ...aged];
140
+
141
+ const released = [];
142
+ // The repair runs under whatever identity the shell has, and a shell with none
143
+ // is a normal place to run it from — this is the command you reach for when the
144
+ // sessions are gone. Mirrors the synthetic id `set --dry-run` already uses.
145
+ const operator = availableSessionId() ?? 'doctor:claims-repair';
146
+ if (apply) {
147
+ for (const claim of targets) {
148
+ try {
149
+ // Two shapes of release, because a vanished plan has no file to write a
150
+ // status into. Deciding by existence here rather than by catching
151
+ // runSet's "File not found" keeps the bypass narrow and explicit.
152
+ if (existsSync(path.resolve(config.repoRoot, claim.plan))) {
153
+ await runSet(['active', claim.plan], config, { force: true, sessionId: operator, note: 'Claim released by `dotmd doctor --claims` — the owning session was gone.' });
154
+ } else {
155
+ releaseVanishedPlanClaim(claim, config);
156
+ claim.vanished = true;
157
+ }
158
+ released.push(claim.plan);
159
+ } catch (err) {
160
+ claim.error = err.message.split('\n')[0];
161
+ }
162
+ }
163
+ }
164
+
165
+ if (json) {
166
+ process.stdout.write(JSON.stringify({ claims, released }, null, 2) + '\n');
167
+ return;
168
+ }
169
+ if (claims.length === 0) {
170
+ process.stdout.write(green('✓') + ' No plans are claimed — nothing can be wedged by ownership.\n');
171
+ return;
172
+ }
173
+
174
+ const releasedSet = new Set(released);
175
+ process.stdout.write(bold(`Plan claims (${claims.length})\n`));
176
+ for (const claim of claims) {
177
+ if (claim.corrupt) {
178
+ process.stdout.write(` ${yellow('?')} ${path.basename(claim.recordPath)} — ${claim.reason}\n`);
179
+ continue;
180
+ }
181
+ const mark = releasedSet.has(claim.plan) ? green('✓') : claim.liveness === 'dead' ? yellow('!') : dim('·');
182
+ const age = claim.since ? relTime(claim.since) : 'age unknown';
183
+ const note = releasedSet.has(claim.plan)
184
+ ? (claim.vanished ? 'released (plan no longer exists)' : 'released')
185
+ : claim.error ?? `owner ${claim.liveness}`;
186
+ process.stdout.write(` ${mark} ${claim.plan} — ${age}, session ${claim.sessionId}, ${note}\n`);
187
+ }
188
+
189
+ if (released.length) {
190
+ // Only the claims with a plan behind them came back as `active`; saying so
191
+ // of a vanished one would promise a file the next command cannot open.
192
+ const vanished = targets.filter(claim => claim.vanished && releasedSet.has(claim.plan)).length;
193
+ const revived = released.length - vanished;
194
+ const parts = [];
195
+ if (revived) parts.push(`${revived} plan${revived === 1 ? ' is' : 's are'} active again`);
196
+ if (vanished) parts.push(`${vanished} pinned a plan that no longer exists`);
197
+ process.stdout.write(green(`\n✓ Released ${released.length} claim${released.length === 1 ? '' : 's'}; ${parts.join(', ')}.\n`));
198
+ }
199
+ const pendingDead = dead.filter(claim => !releasedSet.has(claim.plan));
200
+ if (pendingDead.length) {
201
+ process.stdout.write(yellow(`\n${pendingDead.length} held by a session whose process is gone.\n`));
202
+ process.stdout.write(dim('Run `dotmd doctor --claims --apply` to release them.\n'));
203
+ }
204
+ const unjudgeable = claims.filter(claim =>
205
+ !claim.corrupt && claim.liveness !== 'dead' && !releasedSet.has(claim.plan));
206
+ if (unjudgeable.length && olderThanMs === null) {
207
+ process.stdout.write(dim(`\n${unjudgeable.length} cannot be judged from here — no owning process was recorded, or it is on another machine.\n`));
208
+ process.stdout.write(dim('If you know those sessions are over: `dotmd doctor --claims --apply --older-than 24h`.\n'));
209
+ }
210
+ }
211
+
108
212
  export function runDoctor(argv, config, opts = {}) {
109
213
  if (argv.includes('--project')) {
110
214
  runDoctorProject(config, { json: argv.includes('--json') });
@@ -130,6 +234,9 @@ export function runDoctor(argv, config, opts = {}) {
130
234
  runDoctorTransactions(argv, config, opts);
131
235
  return;
132
236
  }
237
+ if (argv.includes('--claims')) {
238
+ return runDoctorClaims(argv, config, opts);
239
+ }
133
240
 
134
241
  const { dryRun, testHooks } = opts;
135
242
  // 0.37.0 (F4): the mode banner makes it impossible to mistake a preview run
package/src/hud.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { currentSessionId, isArchivedPath } from './util.mjs';
4
+ import { currentSessionId, isArchivedPath, relTime } from './util.mjs';
5
5
  import { dim, yellow } from './color.mjs';
6
6
  import { buildIndex } from './index.mjs';
7
7
  import { readJournalEntries, journalFilePath, readMisuseEntries } from './journal.mjs';
@@ -67,19 +67,6 @@ const REJECTIONS_CAP = 3;
67
67
  const FLEET_WINDOW_MS = 24 * 60 * 60 * 1000;
68
68
  const REJECTIONS_WINDOW_MS = 60 * 60 * 1000;
69
69
 
70
- function relTime(ts, now = Date.now()) {
71
- const t = new Date(ts).getTime();
72
- if (!Number.isFinite(t)) return '?';
73
- const delta = Math.max(0, now - t);
74
- const sec = Math.floor(delta / 1000);
75
- if (sec < 60) return `${sec}s ago`;
76
- const min = Math.floor(sec / 60);
77
- if (min < 60) return `${min}m ago`;
78
- const hr = Math.floor(min / 60);
79
- if (hr < 24) return `${hr}h ago`;
80
- return `${Math.floor(hr / 24)}d ago`;
81
- }
82
-
83
70
  // Coarse error-class for rejection grouping. Most dotmd die() messages follow
84
71
  // `<class>: <variable detail>` (e.g. "File not found: docs/foo.md", "Already
85
72
  // archived: docs/plans/x.md", "Too many arguments to status"). Take the chunk
package/src/lifecycle.mjs CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  commitPlanClaim,
25
25
  finishClaimHookDelivery,
26
26
  listOwnedPlans,
27
+ ownershipLiveness,
27
28
  pickupFactsForDoc,
28
29
  prepareOwnershipRelease,
29
30
  readPlanOwnership,
@@ -607,6 +608,7 @@ export async function startPlan(argv, config, opts = {}) {
607
608
  let disposition = classifyPlanPickup({
608
609
  type: docType,
609
610
  status: oldStatus,
611
+ ownerLiveness: ownershipLiveness(ownership),
610
612
  validStatuses: config.typeStatuses?.get('plan') ?? config.validStatuses,
611
613
  startableStatuses: config.lifecycle.startableStatuses,
612
614
  terminalStatuses: config.lifecycle.terminalStatuses,
@@ -959,7 +961,13 @@ export async function runSet(argv, config, opts = {}) {
959
961
  const releasing = asString(oldFm?.type) === 'plan'
960
962
  && (asString(oldFm?.status) === 'in-session' || oldOwnership?.state === 'owned' || oldOwnership?.corrupt);
961
963
  if (releasing) {
962
- sessionId ??= authoritativeSessionId();
964
+ // opts.sessionId lets a repair caller supply the operator identity. Only
965
+ // `doctor --claims` passes it: releasing a claim stamps the id into the
966
+ // tombstone as provenance and takes nothing, so demanding an *authoritative*
967
+ // one of the repairer is backwards for the command that exists to unwedge
968
+ // sessions — it made the recovery path unusable from any shell without a
969
+ // session of its own (a bare login shell, cron, CI).
970
+ sessionId ??= opts.sessionId ?? authoritativeSessionId();
963
971
  assertPlanMutationAuthorized(repoPath, config, { sessionId, force });
964
972
  if (!dryRun) ensurePlanCompletionBeforeRelease(repoPath, config, { testHooks: opts.testHooks });
965
973
  else if (planHasPendingCompletion(repoPath, config)) process.stderr.write(`${dim('[dry-run]')} Pending claim completion would block this release.\n`);
package/src/new.mjs CHANGED
@@ -287,8 +287,15 @@ function isBodyPlaceholder(file) {
287
287
  return BODY_PLACEHOLDER_NAMES.has(file);
288
288
  }
289
289
 
290
+ // `@-` is the natural composition of the two spellings dotmd documents (`@path`
291
+ // and `-`), and agents write it — three times in the platform transcripts, each
292
+ // with a valid heredoc already on stdin that was then thrown away for a file
293
+ // literally named `-`. Nothing else can sensibly be meant by it, so it means
294
+ // stdin, unconditionally. That follows the universal CLI convention rather than
295
+ // the placeholder rule below (where a real file wins): under this convention a
296
+ // file actually named `-` is spelled `./-`, which still takes the file branch.
290
297
  export function readBodyInput(source) {
291
- if (source === '-') {
298
+ if (source === '-' || source === '@-') {
292
299
  try { return readFileSync(0, 'utf8'); } catch (err) { die(`Could not read body from stdin: ${err.message}`); }
293
300
  }
294
301
  if (typeof source === 'string' && source.startsWith('@')) {
@@ -711,7 +718,9 @@ export async function runNew(argv, config, opts = {}) {
711
718
  if (bodyFlag !== null) { bodyInput = readBodyInput(bodyFlag); bodyInputSource = bodyFlagName; }
712
719
  else if (bodyArg !== null) {
713
720
  bodyInput = readBodyInput(bodyArg);
714
- bodyInputSource = bodyArg === '-' ? 'stdin (`-`)' : (bodyArg.startsWith('@') ? `file (\`${bodyArg}\`)` : 'inline body argument');
721
+ bodyInputSource = (bodyArg === '-' || bodyArg === '@-')
722
+ ? `stdin (\`${bodyArg}\`)`
723
+ : (bodyArg.startsWith('@') ? `file (\`${bodyArg}\`)` : 'inline body argument');
715
724
  } else {
716
725
  // Auto-consume piped or redirected stdin so agents don't need the `-`
717
726
  // placeholder for the most common pattern (`cat draft.md | dotmd new …`,
package/src/pickup.mjs CHANGED
@@ -2,9 +2,10 @@ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { authorizeManagedSource, authorizeRepoGeneratedPath } from './managed-path.mjs';
5
- import { currentProcessOwner, mutateFileSet, processOwnerLiveness, replaceSnapshot, snapshotFile, withPathLocks } from './atomic-mutation.mjs';
5
+ import os from 'node:os';
6
+ import { currentProcessOwner, mutateFileSet, processOwnerLiveness, processStartIdentity, replaceSnapshot, snapshotFile, withPathLocks } from './atomic-mutation.mjs';
6
7
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
7
- import { asString } from './util.mjs';
8
+ import { asString, relTime } from './util.mjs';
8
9
 
9
10
  export const OWNERSHIP_SCHEMA = 2;
10
11
  export const HOOK_DELIVERY_LEASE_MS = 30_000;
@@ -29,6 +30,34 @@ export function availableSessionId(env = process.env) {
29
30
  try { return authoritativeSessionId(env); } catch { return null; }
30
31
  }
31
32
 
33
+ // The process that OWNS the session, not the one taking the claim. `dotmd` exits
34
+ // within the second, so its own pid is always dead a moment later and can say
35
+ // nothing about whether the session still exists; the agent harness that spawned
36
+ // it is what outlives the command. Recording that process is what lets a claim
37
+ // answer "is its owner still there?" with the liveness check dotmd already has,
38
+ // instead of an age threshold that cannot tell a three-day-dead session from a
39
+ // long-running one. Absent (a plain terminal, an unknown harness) is not an
40
+ // error — it yields null, which reads as 'unverifiable' and never auto-reclaims.
41
+ export function sessionProcessOwner(env = process.env) {
42
+ const raw = (env.DOTMD_SESSION_PID ?? env.CLAUDE_PID)?.trim();
43
+ const pid = Number(raw);
44
+ if (!raw || !Number.isInteger(pid) || pid <= 0) return null;
45
+ return {
46
+ pid,
47
+ hostname: os.hostname(),
48
+ processStartIdentity: processStartIdentity(pid),
49
+ };
50
+ }
51
+
52
+ // Only a claim that names a process we can probe, on this host, can be judged.
53
+ // Everything else — a record written before sessionOwner existed, a claim from a
54
+ // plain terminal, another machine's claim on a shared filesystem — is
55
+ // 'unverifiable' and is treated as live, so a takeover stays an explicit --force.
56
+ export function ownershipLiveness(ownership) {
57
+ if (!ownership || ownership.corrupt || !ownership.sessionOwner) return 'unverifiable';
58
+ return processOwnerLiveness(ownership.sessionOwner);
59
+ }
60
+
32
61
  function sameIdentity(left, right, fs = { statSync }) {
33
62
  try {
34
63
  const a = fs.statSync(left, { bigint: true });
@@ -61,6 +90,10 @@ export function classifyPlanPickup(facts) {
61
90
  const {
62
91
  type, status, validStatuses, startableStatuses, terminalStatuses,
63
92
  archiveStatuses, physicallyArchived, ownership, sessionId, malformed,
93
+ // Defaulted rather than required: a caller with no view of the owning
94
+ // process (and every existing test) gets the conservative answer, which is
95
+ // that the owner might still be there.
96
+ ownerLiveness = 'unverifiable',
64
97
  } = facts;
65
98
  if (malformed) return { kind: 'malformed', pickupable: false };
66
99
  if (type !== 'plan') return { kind: 'wrong-type', pickupable: false };
@@ -68,11 +101,15 @@ export function classifyPlanPickup(facts) {
68
101
  if (physicallyArchived) return { kind: 'physical-archive', pickupable: false };
69
102
  if (archiveStatuses?.has(status) || terminalStatuses?.has(status)) return { kind: 'terminal', pickupable: false };
70
103
  if (ownership?.corrupt) return { kind: 'ownership-corrupt', pickupable: false };
71
- if (ownership?.state === 'owned' && ownership.sessionId !== sessionId) {
104
+ if (ownership?.state === 'owned' && ownership.sessionId !== sessionId && ownerLiveness !== 'dead') {
72
105
  return { kind: 'busy', pickupable: false, owner: ownership.sessionId };
73
106
  }
74
107
  if (status === 'in-session') {
75
- if (ownership?.state === 'owned') return { kind: 'resume', pickupable: true };
108
+ // `resume` means picking my own claim back up. A record left by a session
109
+ // whose process is gone reached here because it is no longer busy, and
110
+ // taking that over is an adopt — the same disposition as a plan sitting
111
+ // `in-session` with no record at all.
112
+ if (ownership?.state === 'owned' && ownership.sessionId === sessionId) return { kind: 'resume', pickupable: true };
76
113
  return { kind: 'adopt', pickupable: true };
77
114
  }
78
115
  if (startableStatuses?.has(status)) return { kind: 'start', pickupable: true };
@@ -150,6 +187,40 @@ function parseOwnership(raw, recordPath) {
150
187
  }
151
188
  }
152
189
 
190
+ // The bar for reclaiming another session's plan without being asked to, and it is
191
+ // deliberately the same bar `assertHookDeliveryReclaimable` already sets for a
192
+ // forced hook-delivery takeover: *demonstrably* dead. `processOwnerLiveness`
193
+ // answers 'dead' only for a probe that came back ESRCH on this host, or a pid
194
+ // whose process start-identity no longer matches the one recorded — so a reused
195
+ // pid reads live, another machine's claim reads unverifiable, and a record
196
+ // written before `sessionOwner` existed reads unverifiable. Every one of those
197
+ // keeps the refusal and leaves the takeover to an explicit --force. Only a
198
+ // process we watched go away is treated as gone.
199
+ function abandonedByDeadSession(ownership) {
200
+ return ownershipLiveness(ownership) === 'dead';
201
+ }
202
+
203
+ const BUSY_TAKEOVER_RECOVERY = 're-run this command with --force to take the plan over.';
204
+
205
+ // A busy refusal almost always fires on the command that would have *cleared*
206
+ // the wedge — `baton`, `set`, `archive` — so the message has to carry the two
207
+ // things that decide what to do next: how long the owner has held the claim, and
208
+ // the verb that takes it back. Nothing expires a session claim, so an owner
209
+ // three days gone looks exactly like one mid-edit; a survey of real sessions
210
+ // found 16 of these against 7 plans, every owner long dead, and not one of them
211
+ // recovered. What agents did instead was re-run the same command with the plan
212
+ // addressed differently (path, then slug) — reading a bare "busy in another
213
+ // session (<uuid>)" as "you named the plan wrong". The age is the judgement
214
+ // input and the recovery line is the way out; neither was there to read.
215
+ function planBusyError(repoPath, ownership, recovery = BUSY_TAKEOVER_RECOVERY) {
216
+ const since = ownership.updatedAt ?? ownership.claimedAt ?? null;
217
+ const age = since ? relTime(since) : null;
218
+ const held = age && age !== '?' ? ` since ${since} (${age})` : '';
219
+ return new Error(`Plan is busy in another session: ${repoPath}\n`
220
+ + ` held by session ${ownership.sessionId}${held}\n`
221
+ + ` If that session is gone, ${recovery}`);
222
+ }
223
+
153
224
  function validateBinding(record, identity, config) {
154
225
  if (record.corrupt) return record;
155
226
  const expectedPath = recordPathForIdentity(identity, config);
@@ -176,14 +247,20 @@ export function prepareOwnershipMigration(oldRepoPath, newPath, config, { sessio
176
247
  const ownership = readPlanOwnership(oldRepoPath, config);
177
248
  if (!ownership) return null;
178
249
  if (ownership.corrupt) throw new Error(`Ownership record is corrupt for ${oldRepoPath}: ${ownership.reason}; repair or release it before rename.`);
179
- if (ownership.state === 'owned' && ownership.sessionId !== sessionId) {
180
- throw new Error(`Plan is busy in another session (${ownership.sessionId}): ${oldRepoPath}`);
250
+ if (ownership.state === 'owned' && ownership.sessionId !== sessionId && !abandonedByDeadSession(ownership)) {
251
+ // `rename` has no --force of its own: a rename carries the claim across to
252
+ // the new path rather than ending it, so the takeover has to happen first.
253
+ throw planBusyError(oldRepoPath, ownership,
254
+ `release it first with \`dotmd set <status> ${oldRepoPath} --force\`, then rename.`);
181
255
  }
182
256
  const identity = plannedPlanIdentity(newPath, config);
183
257
  const recordPath = recordPathForIdentity(identity, config);
184
258
  const content = recordContent({
185
259
  identity,
186
260
  sessionId: ownership.sessionId,
261
+ // A rename moves the claim, it does not re-take it: the same session still
262
+ // owns the plan, so its owning process carries across untouched.
263
+ sessionOwner: ownership.sessionOwner,
187
264
  state: ownership.state,
188
265
  now,
189
266
  claimedAt: ownership.claimedAt,
@@ -228,7 +305,12 @@ export function listOwnedPlans(config, sessionId = authoritativeSessionId()) {
228
305
  return Object.assign(found, { diagnostics });
229
306
  }
230
307
 
231
- function recordContent({ identity, sessionId, state, now, claimedAt, operation }) {
308
+ // `sessionOwner` is deliberately additive rather than a schema bump: parseOwnership
309
+ // accepts exactly OWNERSHIP_SCHEMA, so raising it would turn every record already
310
+ // on disk corrupt — which is a worse wedge than the one this fixes. An old record
311
+ // simply has no sessionOwner and stays 'unverifiable' for its whole life; an older
312
+ // dotmd reading a new record ignores a field it does not validate.
313
+ function recordContent({ identity, sessionId, state, now, claimedAt, operation, sessionOwner }) {
232
314
  return JSON.stringify({
233
315
  schema: OWNERSHIP_SCHEMA,
234
316
  state,
@@ -236,6 +318,7 @@ function recordContent({ identity, sessionId, state, now, claimedAt, operation }
236
318
  canonicalPath: identity.canonicalPath,
237
319
  identityKey: identity.key,
238
320
  sessionId,
321
+ sessionOwner: sessionOwner ?? null,
239
322
  claimedAt: claimedAt ?? now,
240
323
  updatedAt: now,
241
324
  operation: operation ?? null,
@@ -256,7 +339,8 @@ export function preparePlanClaim({ filePath, sourceContent, renderedContent, own
256
339
  hook: 'pending',
257
340
  };
258
341
  const content = recordContent({ identity, sessionId, state: 'owned', now,
259
- claimedAt: ownership && !ownership.corrupt ? ownership.claimedAt : null, operation });
342
+ sessionOwner: sessionProcessOwner(), operation,
343
+ claimedAt: ownership && !ownership.corrupt ? ownership.claimedAt : null });
260
344
  const updates = [];
261
345
  const guards = [];
262
346
  if (renderedContent !== null && renderedContent !== sourceContent) {
@@ -291,8 +375,9 @@ export function prepareOwnershipRelease(repoPath, config, { sessionId = authorit
291
375
  const ownership = readPlanOwnership(repoPath, config);
292
376
  if (!ownership) return null;
293
377
  if (ownership.corrupt && !force) throw new Error(`Ownership record is corrupt for ${repoPath}: ${ownership.reason}; use an explicit path with --force to recover.`);
294
- if (!ownership.corrupt && ownership.state === 'owned' && ownership.sessionId !== sessionId && !force) {
295
- throw new Error(`Plan is busy in another session (${ownership.sessionId}): ${repoPath}`);
378
+ if (!ownership.corrupt && ownership.state === 'owned' && ownership.sessionId !== sessionId
379
+ && !force && !abandonedByDeadSession(ownership)) {
380
+ throw planBusyError(repoPath, ownership);
296
381
  }
297
382
  if (!ownership.corrupt && ownership.state === 'released') return null;
298
383
  return {
@@ -308,12 +393,90 @@ export function assertPlanMutationAuthorized(repoPath, config, { sessionId = aut
308
393
  if (ownership?.corrupt && !force) {
309
394
  throw new Error(`Ownership record is corrupt for ${repoPath}: ${ownership.reason}; use an explicit path with --force to recover.`);
310
395
  }
311
- if (ownership?.state === 'owned' && ownership.sessionId !== sessionId && !force) {
312
- throw new Error(`Plan is busy in another session (${ownership.sessionId}): ${repoPath}`);
396
+ if (ownership?.state === 'owned' && ownership.sessionId !== sessionId
397
+ && !force && !abandonedByDeadSession(ownership)) {
398
+ throw planBusyError(repoPath, ownership);
313
399
  }
314
400
  return ownership;
315
401
  }
316
402
 
403
+ // Every owned claim in the repo, whoever holds it — the survey `listOwnedPlans`
404
+ // deliberately cannot do, since that one answers "what does THIS session own?".
405
+ // Unlike that function this keeps records whose plan has drifted or vanished:
406
+ // a claim pinning a plan nobody can read is exactly the kind that wedges a repo
407
+ // and so is exactly the kind worth showing.
408
+ export function surveyOwnershipClaims(config, now = Date.now()) {
409
+ const root = ownershipRoot(config);
410
+ if (!existsSync(root)) return [];
411
+ const claims = [];
412
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
413
+ if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
414
+ const recordPath = path.join(root, entry.name);
415
+ let record;
416
+ try { record = parseOwnership(readFileSync(recordPath, 'utf8'), recordPath); }
417
+ catch { record = { corrupt: true, recordPath, reason: 'unreadable ownership record' }; }
418
+ if (record.corrupt) {
419
+ claims.push({ recordPath, plan: null, corrupt: true, reason: record.reason, liveness: 'unverifiable', ageMs: null });
420
+ continue;
421
+ }
422
+ if (record.state !== 'owned') continue;
423
+ const since = record.updatedAt ?? record.claimedAt ?? null;
424
+ const parsed = since ? Date.parse(since) : NaN;
425
+ claims.push({
426
+ recordPath,
427
+ plan: record.plan,
428
+ sessionId: record.sessionId,
429
+ corrupt: false,
430
+ since,
431
+ ageMs: Number.isFinite(parsed) ? Math.max(0, now - parsed) : null,
432
+ liveness: ownershipLiveness(record),
433
+ });
434
+ }
435
+ claims.sort((a, b) => (b.ageMs ?? 0) - (a.ageMs ?? 0));
436
+ return claims;
437
+ }
438
+
439
+ // A claim whose plan file no longer exists — deleted, or renamed by something
440
+ // other than `dotmd rename`, which would have carried the record across. The
441
+ // survey deliberately keeps these (a claim pinning a plan nobody can read is
442
+ // exactly the kind worth showing), but the release path could not act on one:
443
+ // it routes through `runSet`, which needs a file to write a status into, so the
444
+ // record survived every `--apply` and sat in the report forever. Worse, the key
445
+ // is a hash of the path, so a plan later created at that same path would read
446
+ // as owned by a session years gone — born wedged.
447
+ //
448
+ // So this releases from the record itself. No `canonicalPlanIdentity` (it
449
+ // realpaths, which is the thing that cannot work here); the record already
450
+ // carries the identity it was written with. Existence is re-checked under the
451
+ // lock, because "the plan is gone" is the entire justification for bypassing
452
+ // the status write, and a `dotmd new` racing us would invalidate it.
453
+ export function releaseVanishedPlanClaim(claim, config, { now = new Date().toISOString() } = {}) {
454
+ const recordPath = claim.recordPath;
455
+ return withPathLocks([recordPath], { repoRoot: config.repoRoot }, () => {
456
+ const snapshot = snapshotFile(recordPath);
457
+ const ownership = parseOwnership(snapshot.content, recordPath);
458
+ if (ownership.corrupt || ownership.state !== 'owned') {
459
+ throw new Error(`Claim changed under us for ${claim.plan}; re-run to see the current state.`);
460
+ }
461
+ if (existsSync(path.resolve(config.repoRoot, ownership.plan))) {
462
+ throw new Error(`${ownership.plan} exists after all — release it with \`dotmd set active\` instead.`);
463
+ }
464
+ replaceSnapshot(snapshot, JSON.stringify({
465
+ schema: OWNERSHIP_SCHEMA,
466
+ state: 'released',
467
+ plan: ownership.plan,
468
+ canonicalPath: ownership.canonicalPath,
469
+ identityKey: ownership.identityKey,
470
+ sessionId: ownership.sessionId,
471
+ sessionOwner: ownership.sessionOwner ?? null,
472
+ claimedAt: ownership.claimedAt ?? now,
473
+ updatedAt: now,
474
+ operation: null,
475
+ }, null, 2) + '\n', { repoRoot: config.repoRoot, locked: true });
476
+ return ownership.plan;
477
+ });
478
+ }
479
+
317
480
  export function updateOwnershipOperation(repoPath, config, expected, mutate) {
318
481
  const ownership = readPlanOwnership(repoPath, config);
319
482
  if (!ownership || ownership.corrupt || ownership.state !== 'owned' || !ownership.operation) {
@@ -455,6 +618,7 @@ export function pickupFactsForDoc(doc, config, { sessionId = availableSessionId(
455
618
  type: doc?.type ?? null,
456
619
  status: doc?.status ?? null,
457
620
  validStatuses: config.typeStatuses?.get('plan') ?? config.validStatuses,
621
+ ownerLiveness: ownershipLiveness(ownership),
458
622
  startableStatuses: config.lifecycle.startableStatuses,
459
623
  terminalStatuses: config.lifecycle.terminalStatuses,
460
624
  archiveStatuses: config.lifecycle.archiveStatuses,
package/src/prompts.mjs CHANGED
@@ -11,6 +11,7 @@ import { authorizeManagedSource } from './managed-path.mjs';
11
11
  import {
12
12
  authoritativeSessionId,
13
13
  classifyPlanPickup,
14
+ ownershipLiveness,
14
15
  preparePlanClaim,
15
16
  readPlanOwnership,
16
17
  } from './pickup.mjs';
@@ -380,6 +381,7 @@ function prepareLinkedPromptClaim(planRef, config, promptDir) {
380
381
  const disposition = classifyPlanPickup({
381
382
  type: asString(parsed.type),
382
383
  status: oldStatus,
384
+ ownerLiveness: ownershipLiveness(ownership),
383
385
  validStatuses: config.typeStatuses?.get('plan') ?? config.validStatuses,
384
386
  startableStatuses: config.lifecycle.startableStatuses,
385
387
  terminalStatuses: config.lifecycle.terminalStatuses,
package/src/util.mjs CHANGED
@@ -92,6 +92,23 @@ export function nowIso() {
92
92
  return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
93
93
  }
94
94
 
95
+ // Coarse "how long ago", for messages where the exact interval matters less than
96
+ // which order of magnitude it is — a claim held 3d is a dead session, one held
97
+ // 4m is a colleague mid-edit. Lives here rather than in either caller because
98
+ // `hud` and `pickup` both need it and util is a leaf both already import.
99
+ export function relTime(ts, now = Date.now()) {
100
+ const t = new Date(ts).getTime();
101
+ if (!Number.isFinite(t)) return '?';
102
+ const delta = Math.max(0, now - t);
103
+ const sec = Math.floor(delta / 1000);
104
+ if (sec < 60) return `${sec}s ago`;
105
+ const min = Math.floor(sec / 60);
106
+ if (min < 60) return `${min}m ago`;
107
+ const hr = Math.floor(min / 60);
108
+ if (hr < 24) return `${hr}h ago`;
109
+ return `${Math.floor(hr / 24)}d ago`;
110
+ }
111
+
95
112
  export function warn(message) {
96
113
  process.stderr.write(`${dim(message)}\n`);
97
114
  }