dotmd-cli 0.74.0 → 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.0",
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",
@@ -1279,9 +1279,9 @@ export function createFileExclusive(filePath, content, options) {
1279
1279
  }
1280
1280
 
1281
1281
  export function moveFileAtomic(sourcePath, targetPath, render, options) {
1282
- const { repoRoot, finalize, rollbackFinalize, testHooks, updates = [], creations = [], deletions = [] } = options;
1282
+ const { repoRoot, finalize, rollbackFinalize, testHooks, updates = [], creations = [], deletions = [], guards = [] } = options;
1283
1283
  recoverAbandonedTransactions(repoRoot, options);
1284
- return withPathLocks([sourcePath, targetPath, ...updates.map(item => item.path), ...creations.map(item => item.path), ...deletions.map(item => item.path)], options, () => {
1284
+ return withPathLocks([sourcePath, targetPath, ...updates.map(item => item.path), ...creations.map(item => item.path), ...deletions.map(item => item.path), ...guards.map(item => item.path)], options, () => {
1285
1285
  testHooks?.afterTransactionPhase?.('lock', { sourcePath, targetPath });
1286
1286
  testHooks?.beforeMoveSnapshot?.({ sourcePath, targetPath });
1287
1287
  const source = snapshotFile(sourcePath);
@@ -1304,6 +1304,15 @@ export function moveFileAtomic(sourcePath, targetPath, render, options) {
1304
1304
  }
1305
1305
  return { ...item, snapshot };
1306
1306
  });
1307
+ // Read-only participants (see `mutateFileSet`). Checked before the
1308
+ // transaction manifest exists, so a guard conflict can never leave a
1309
+ // transaction to recover from.
1310
+ for (const guard of guards) {
1311
+ const snapshot = snapshotFile(guard.path);
1312
+ if (guard.expectedContent !== undefined && snapshot.content !== guard.expectedContent) {
1313
+ throw new MutationConflictError(`File changed while the move mutation set was being prepared: ${snapshot.path}`);
1314
+ }
1315
+ }
1307
1316
  for (const item of creations) {
1308
1317
  if (existsSync(item.path)) throw new MutationConflictError(`Destination already exists: ${path.resolve(item.path)}`);
1309
1318
  }
@@ -1627,9 +1636,30 @@ export function moveFileAtomic(sourcePath, targetPath, render, options) {
1627
1636
  });
1628
1637
  }
1629
1638
 
1630
- export function mutateFileSet({ updates = [], creations = [] }, options) {
1631
- const paths = [...updates.map(item => item.path), ...creations.map(item => item.path)];
1639
+ // `guards` are read-only participants: files the mutation's VALIDITY depends on
1640
+ // but that it never writes. They take part in locking and in the same
1641
+ // compare-and-swap as `updates`, so a mutation decided from a file it doesn't
1642
+ // modify cannot land after that file changed underneath it.
1643
+ //
1644
+ // Without this, a decision read and the write it justifies are two separate
1645
+ // steps with nothing holding the gap. A claim that adopts an already-in-session
1646
+ // plan writes only the ownership record — so a concurrent `set` releasing that
1647
+ // plan could win the status write while the claim still took ownership, leaving
1648
+ // a record that owns a plan the file says is `active`. A no-op update on the
1649
+ // plan file would close the gap too, but it would rewrite bytes and report the
1650
+ // plan as changed to anything counting touched files. A guard says what is meant.
1651
+ export function mutateFileSet({ updates = [], creations = [], guards = [] }, options) {
1652
+ const paths = [...updates.map(item => item.path), ...creations.map(item => item.path), ...guards.map(item => item.path)];
1632
1653
  return withPathLocks(paths, options, () => {
1654
+ // Guards are pure preconditions, so they are checked before anything at all
1655
+ // is created — a guard conflict must leave the tree byte-identical, not even
1656
+ // an empty directory behind.
1657
+ for (const guard of guards) {
1658
+ const snapshot = snapshotFile(guard.path);
1659
+ if (guard.expectedContent !== undefined && snapshot.content !== guard.expectedContent) {
1660
+ throw new MutationConflictError(`File changed while the mutation set was being prepared: ${snapshot.path}`);
1661
+ }
1662
+ }
1633
1663
  const createdDirectories = [];
1634
1664
  for (const item of creations) {
1635
1665
  const directory = path.dirname(item.path);
@@ -1654,7 +1684,7 @@ export function mutateFileSet({ updates = [], creations = [] }, options) {
1654
1684
  for (const item of creations) {
1655
1685
  if (existsSync(item.path)) throw new MutationConflictError(`Destination already exists: ${path.resolve(item.path)}`);
1656
1686
  }
1657
- options.testHooks?.afterSetPreflight?.({ updates: preparedUpdates, creations });
1687
+ options.testHooks?.afterSetPreflight?.({ updates: preparedUpdates, creations, guards });
1658
1688
 
1659
1689
  const committedUpdates = [];
1660
1690
  const committedCreations = [];
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,
@@ -107,6 +108,7 @@ function commitLifecycleMutation(filePath, targetPath, config, updates, historyF
107
108
  }),
108
109
  })), ...additionalUpdates],
109
110
  creations: options.creations ?? [],
111
+ guards: options.guards ?? [],
110
112
  gitMove: tracked,
111
113
  gitIndex,
112
114
  operation: 'lifecycle-move',
@@ -115,12 +117,13 @@ function commitLifecycleMutation(filePath, targetPath, config, updates, historyF
115
117
  });
116
118
  return { ...result, sourceContent: moveResult.source.content, updatedPaths: moveResult.updatedPaths };
117
119
  }
118
- if ((options.additionalUpdates?.length ?? 0) > 0 || (options.creations?.length ?? 0) > 0) {
120
+ if ((options.additionalUpdates?.length ?? 0) > 0 || (options.creations?.length ?? 0) > 0 || (options.guards?.length ?? 0) > 0) {
119
121
  const sourceContent = readFileSync(filePath, 'utf8');
120
122
  const result = render(sourceContent);
121
123
  mutateFileSet({
122
124
  updates: [{ path: filePath, expectedContent: sourceContent, content: result.content }, ...(options.additionalUpdates ?? [])],
123
125
  creations: options.creations ?? [],
126
+ guards: options.guards ?? [],
124
127
  }, { repoRoot: config.repoRoot, testHooks: options.testHooks });
125
128
  return { ...result, sourceContent, updatedPaths: [] };
126
129
  }
@@ -605,6 +608,7 @@ export async function startPlan(argv, config, opts = {}) {
605
608
  let disposition = classifyPlanPickup({
606
609
  type: docType,
607
610
  status: oldStatus,
611
+ ownerLiveness: ownershipLiveness(ownership),
608
612
  validStatuses: config.typeStatuses?.get('plan') ?? config.validStatuses,
609
613
  startableStatuses: config.lifecycle.startableStatuses,
610
614
  terminalStatuses: config.lifecycle.terminalStatuses,
@@ -767,6 +771,7 @@ export function runArchive(argv, config, opts = {}) {
767
771
  createSection: Boolean(note),
768
772
  additionalUpdates: [...(opts.additionalUpdates ?? []), ...(releaseUpdate ? [releaseUpdate] : [])],
769
773
  creations: opts.creations,
774
+ guards: opts.guards,
770
775
  testHooks: opts.testHooks,
771
776
  });
772
777
  if (!noIndex && !opts.deferIndex) regenIndex(config);
@@ -833,6 +838,7 @@ export function runArchive(argv, config, opts = {}) {
833
838
  testHooks: opts.testHooks,
834
839
  additionalUpdates: [...(opts.additionalUpdates ?? []), ...(releaseUpdate ? [releaseUpdate] : [])],
835
840
  creations: opts.creations,
841
+ guards: opts.guards,
836
842
  skipInboundRefs: opts.skipInboundRefs,
837
843
  bodyTransform: closeoutTemplate ? currentBody => {
838
844
  committedCloseoutAction = planCloseoutInjection(currentBody);
@@ -955,7 +961,13 @@ export async function runSet(argv, config, opts = {}) {
955
961
  const releasing = asString(oldFm?.type) === 'plan'
956
962
  && (asString(oldFm?.status) === 'in-session' || oldOwnership?.state === 'owned' || oldOwnership?.corrupt);
957
963
  if (releasing) {
958
- 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();
959
971
  assertPlanMutationAuthorized(repoPath, config, { sessionId, force });
960
972
  if (!dryRun) ensurePlanCompletionBeforeRelease(repoPath, config, { testHooks: opts.testHooks });
961
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,21 +339,31 @@ 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 = [];
345
+ const guards = [];
261
346
  if (renderedContent !== null && renderedContent !== sourceContent) {
262
347
  updates.push({ path: filePath, expectedContent: sourceContent, content: renderedContent });
348
+ } else {
349
+ // An `adopt` claim writes only the ownership record — the plan is already
350
+ // `in-session`, so there is nothing to render. The DECISION still rests on
351
+ // the plan's status, which was read before this mutation was prepared, so
352
+ // the plan file joins as a read-only guard. Without it, a concurrent
353
+ // `set active` could win the status write while this claim takes ownership,
354
+ // leaving a record that owns a plan the file calls `active`.
355
+ guards.push({ path: filePath, expectedContent: sourceContent });
263
356
  }
264
357
  const creations = [];
265
358
  if (ownership) updates.push({ path: recordPath, expectedContent: ownership.raw, content });
266
359
  else creations.push({ path: recordPath, content });
267
- return { identity, recordPath, updates, creations, operationId };
360
+ return { identity, recordPath, updates, creations, guards, operationId };
268
361
  }
269
362
 
270
363
  export function commitPlanClaim(args) {
271
364
  const prepared = preparePlanClaim(args);
272
365
  mkdirSync(path.dirname(prepared.recordPath), { recursive: true });
273
- mutateFileSet({ updates: prepared.updates, creations: prepared.creations }, {
366
+ mutateFileSet({ updates: prepared.updates, creations: prepared.creations, guards: prepared.guards }, {
274
367
  repoRoot: args.config.repoRoot,
275
368
  testHooks: args.testHooks,
276
369
  });
@@ -282,8 +375,9 @@ export function prepareOwnershipRelease(repoPath, config, { sessionId = authorit
282
375
  const ownership = readPlanOwnership(repoPath, config);
283
376
  if (!ownership) return null;
284
377
  if (ownership.corrupt && !force) throw new Error(`Ownership record is corrupt for ${repoPath}: ${ownership.reason}; use an explicit path with --force to recover.`);
285
- if (!ownership.corrupt && ownership.state === 'owned' && ownership.sessionId !== sessionId && !force) {
286
- 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);
287
381
  }
288
382
  if (!ownership.corrupt && ownership.state === 'released') return null;
289
383
  return {
@@ -299,12 +393,90 @@ export function assertPlanMutationAuthorized(repoPath, config, { sessionId = aut
299
393
  if (ownership?.corrupt && !force) {
300
394
  throw new Error(`Ownership record is corrupt for ${repoPath}: ${ownership.reason}; use an explicit path with --force to recover.`);
301
395
  }
302
- if (ownership?.state === 'owned' && ownership.sessionId !== sessionId && !force) {
303
- 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);
304
399
  }
305
400
  return ownership;
306
401
  }
307
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
+
308
480
  export function updateOwnershipOperation(repoPath, config, expected, mutate) {
309
481
  const ownership = readPlanOwnership(repoPath, config);
310
482
  if (!ownership || ownership.corrupt || ownership.state !== 'owned' || !ownership.operation) {
@@ -446,6 +618,7 @@ export function pickupFactsForDoc(doc, config, { sessionId = availableSessionId(
446
618
  type: doc?.type ?? null,
447
619
  status: doc?.status ?? null,
448
620
  validStatuses: config.typeStatuses?.get('plan') ?? config.validStatuses,
621
+ ownerLiveness: ownershipLiveness(ownership),
449
622
  startableStatuses: config.lifecycle.startableStatuses,
450
623
  terminalStatuses: config.lifecycle.terminalStatuses,
451
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';
@@ -289,6 +290,10 @@ export async function consumePrompt(filePath, config, opts) {
289
290
  skipInboundRefs: true,
290
291
  additionalUpdates: linkedClaim?.prepared?.updates,
291
292
  creations: linkedClaim?.prepared?.creations,
293
+ // An adopt-shaped claim writes no plan content, so the plan file rides
294
+ // along as a read-only guard — the claim is only valid while the plan still
295
+ // says what the disposition was read from.
296
+ guards: linkedClaim?.prepared?.guards,
292
297
  });
293
298
  const consumedBody = archiveResult?.consumedBody ?? body;
294
299
 
@@ -376,6 +381,7 @@ function prepareLinkedPromptClaim(planRef, config, promptDir) {
376
381
  const disposition = classifyPlanPickup({
377
382
  type: asString(parsed.type),
378
383
  status: oldStatus,
384
+ ownerLiveness: ownershipLiveness(ownership),
379
385
  validStatuses: config.typeStatuses?.get('plan') ?? config.validStatuses,
380
386
  startableStatuses: config.lifecycle.startableStatuses,
381
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
  }