dotmd-cli 0.74.1 → 0.74.3

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
@@ -903,7 +919,10 @@ Plan body variants (plans only — pick one body shape):
903
919
  Other options:
904
920
  --status <s> Set initial status (defaults to first valid status for the type)
905
921
  --title <t> Override the auto-derived title
906
- --root <name> Create in a specific docs root
922
+ --root <name> Create in a specific docs root. Applies to a nested name
923
+ too: \`new doc prospects/kim --root docs\` writes
924
+ docs/prospects/kim.md. Without it, a name containing a
925
+ \`/\` is read relative to the repo.
907
926
  --show-files Append \`files: …\` line to stderr listing what was touched
908
927
  (the new doc + the index file). See \`dotmd archive --help\`.
909
928
  --list-types Show registered types (alias: --list-templates)
@@ -1741,7 +1760,9 @@ async function main() {
1741
1760
  const doctorDryRun = doctorSubMode ? dryRun : (dryRun || !doctorExplicitApply);
1742
1761
  const filtered = restArgs.filter(a => a !== '--apply' && a !== '--yes');
1743
1762
  const { runDoctor } = await import('../src/doctor.mjs');
1744
- runDoctor(filtered, config, { dryRun: doctorDryRun });
1763
+ // Awaited because --claims releases through `runSet`, which is async; the
1764
+ // other modes return undefined and are unaffected.
1765
+ await runDoctor(filtered, config, { dryRun: doctorDryRun });
1745
1766
  return;
1746
1767
  }
1747
1768
  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.3",
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
@@ -60,6 +60,27 @@ function fullBodyShortcut(title, bodyInput) {
60
60
  return hasOwnTitle ? `\n${b}\n` : `\n# ${title}\n\n${b}\n`;
61
61
  }
62
62
 
63
+ function lexicallyInside(parent, child) {
64
+ return child === parent || child.startsWith(parent + path.sep);
65
+ }
66
+
67
+ // The default root for a type that doesn't name one of its own. This used to be
68
+ // "whichever root is listed first", which drops a `doc` into `docs/plans` for
69
+ // any project that lists its plans root first — the plan type's own root, for a
70
+ // type that isn't a plan. When one configured root contains another it is
71
+ // structurally the catch-all (`docs` holding `docs/plans` and `docs/adr`), so
72
+ // prefer that; the deepest one, so a nested chain picks the most specific
73
+ // container rather than the outermost. Single-root projects and flat sibling
74
+ // roots have no catch-all to find and keep first-listed order.
75
+ export function catchAllRoot(config) {
76
+ const roots = config.docsRoots ?? [config.docsRoot];
77
+ if (roots.length < 2) return config.docsRoot;
78
+ const containers = roots.filter(root => roots.some(other => other !== root && lexicallyInside(root, other)));
79
+ if (!containers.length) return config.docsRoot;
80
+ const depth = root => root.split(path.sep).length;
81
+ return containers.reduce((deepest, root) => depth(root) > depth(deepest) ? root : deepest);
82
+ }
83
+
63
84
  const BUILTIN_TEMPLATES = {
64
85
  doc: {
65
86
  description: 'Reference doc, design note, module overview — build-up shape lite',
@@ -287,8 +308,15 @@ function isBodyPlaceholder(file) {
287
308
  return BODY_PLACEHOLDER_NAMES.has(file);
288
309
  }
289
310
 
311
+ // `@-` is the natural composition of the two spellings dotmd documents (`@path`
312
+ // and `-`), and agents write it — three times in the platform transcripts, each
313
+ // with a valid heredoc already on stdin that was then thrown away for a file
314
+ // literally named `-`. Nothing else can sensibly be meant by it, so it means
315
+ // stdin, unconditionally. That follows the universal CLI convention rather than
316
+ // the placeholder rule below (where a real file wins): under this convention a
317
+ // file actually named `-` is spelled `./-`, which still takes the file branch.
290
318
  export function readBodyInput(source) {
291
- if (source === '-') {
319
+ if (source === '-' || source === '@-') {
292
320
  try { return readFileSync(0, 'utf8'); } catch (err) { die(`Could not read body from stdin: ${err.message}`); }
293
321
  }
294
322
  if (typeof source === 'string' && source.startsWith('@')) {
@@ -711,7 +739,9 @@ export async function runNew(argv, config, opts = {}) {
711
739
  if (bodyFlag !== null) { bodyInput = readBodyInput(bodyFlag); bodyInputSource = bodyFlagName; }
712
740
  else if (bodyArg !== null) {
713
741
  bodyInput = readBodyInput(bodyArg);
714
- bodyInputSource = bodyArg === '-' ? 'stdin (`-`)' : (bodyArg.startsWith('@') ? `file (\`${bodyArg}\`)` : 'inline body argument');
742
+ bodyInputSource = (bodyArg === '-' || bodyArg === '@-')
743
+ ? `stdin (\`${bodyArg}\`)`
744
+ : (bodyArg.startsWith('@') ? `file (\`${bodyArg}\`)` : 'inline body argument');
715
745
  } else {
716
746
  // Auto-consume piped or redirected stdin so agents don't need the `-`
717
747
  // placeholder for the most common pattern (`cat draft.md | dotmd new …`,
@@ -791,6 +821,9 @@ export async function runNew(argv, config, opts = {}) {
791
821
  } else if (name.endsWith('.md')) {
792
822
  namePart = name.slice(0, -3);
793
823
  }
824
+ // A prefix the *user* typed and one a template declares resolve differently
825
+ // below, so the distinction has to survive the template's assignment to nameDir.
826
+ const userNameDir = nameDir;
794
827
 
795
828
  // Slugify
796
829
  const slug = namePart.toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
@@ -802,7 +835,7 @@ export async function runNew(argv, config, opts = {}) {
802
835
  // Resolve target root. Precedence: CLI --root > template.targetRoot > config.docsRoot.
803
836
  // When the chosen root is a first-class type-container (matched by --root or targetRoot),
804
837
  // we skip the `template.dir` join — the root already points at the right directory.
805
- let targetRoot = config.docsRoot;
838
+ let targetRoot = catchAllRoot(config);
806
839
  let routedToTypeRoot = false;
807
840
  if (rootName) {
808
841
  const roots = config.docsRoots || [config.docsRoot];
@@ -828,10 +861,46 @@ export async function runNew(argv, config, opts = {}) {
828
861
  nameDir = path.join(path.relative(config.repoRoot, targetRoot), template.dir);
829
862
  }
830
863
 
831
- // Path — if user provided a directory prefix OR template declared one, resolve relative to repoRoot
832
- const baseDir = nameDir ? path.resolve(config.repoRoot, nameDir) : targetRoot;
864
+ // Path — a directory prefix is read relative to the repo, because `dotmd new
865
+ // plan docs/plans/feature` is a full repo path and has to stay one. That was
866
+ // the ONLY reading, which is how `--root` came to be silently ignored the
867
+ // moment a name contained a slash: the block above picked a root and this line
868
+ // threw it away, so the one flag the out-of-root error advertises could not
869
+ // fix the error. With an explicit --root the prefix is now read relative to
870
+ // that root — unless the repo-relative reading already lands inside it, so
871
+ // full paths keep working.
872
+ const allRoots = config.docsRoots ?? [config.docsRoot];
873
+ let baseDir;
874
+ if (!nameDir) baseDir = targetRoot;
875
+ else if (userNameDir && rootName) {
876
+ const repoRelative = path.resolve(config.repoRoot, nameDir);
877
+ baseDir = lexicallyInside(targetRoot, repoRelative) ? repoRelative : path.resolve(targetRoot, nameDir);
878
+ } else baseDir = path.resolve(config.repoRoot, nameDir);
833
879
  const filePath = path.join(baseDir, slug + '.md');
834
880
  const repoPath = toRepoPath(filePath, config.repoRoot);
881
+
882
+ // Without --root there is nothing to disambiguate with, so a prefix pointing
883
+ // outside every root stays an error — but it names the flag that resolves it,
884
+ // and the flag now works. The generic containment error underneath reports
885
+ // absolute paths and no remedy, which is what agents kept re-guessing at.
886
+ //
887
+ // Gated on the remedy actually working: the suggestion is only offered when
888
+ // `--root` would land the file inside that root. That is what keeps this off
889
+ // a traversal (`../escaped`, an absolute path), where `--root` fixes nothing
890
+ // and the containment check below is the error that should speak — printing
891
+ // an untested remedy is the very defect this finding is about.
892
+ if (userNameDir && !rootName && !allRoots.some(root => lexicallyInside(root, filePath))) {
893
+ const rooted = path.resolve(targetRoot, userNameDir, slug + '.md');
894
+ if (lexicallyInside(targetRoot, rooted)) {
895
+ die(`Destination is outside every configured root:\n`
896
+ + ` ${repoPath}\n\n`
897
+ + `A name with a \`/\` is read relative to the repo.\n`
898
+ + `To place it under a root instead:\n`
899
+ + ` dotmd new ${typeName} ${name} --root ${path.basename(targetRoot)}\n`
900
+ + ` → ${toRepoPath(rooted, config.repoRoot)}\n\n`
901
+ + `Roots: ${allRoots.map(root => path.basename(root)).join(', ')}`);
902
+ }
903
+ }
835
904
  const destinationAuthorization = authorizeManagedDestination(filePath, config, { kind: 'New document destination' });
836
905
 
837
906
  if (existsSync(filePath)) {
@@ -853,12 +922,15 @@ export async function runNew(argv, config, opts = {}) {
853
922
  // When the project has >1 root and `--root` was omitted, surface the choice
854
923
  // so agents can see that an alternative root was available. Cheap visibility
855
924
  // for the "ended up in docs/plans/ for a doc" foot-gun.
856
- const allRoots = config.docsRoots ?? [config.docsRoot];
925
+ // Report the root the file actually landed in, not the one resolution started
926
+ // from: a directory prefix can move the destination into a different root
927
+ // entirely, and the line used to say `Root: plans` while writing docs/prospects/.
857
928
  let rootHint = '';
858
929
  if (!rootName && allRoots.length > 1) {
859
- const chosenLabel = path.basename(targetRoot);
930
+ const owningRoot = destinationAuthorization.root.lexicalPath;
931
+ const chosenLabel = path.basename(owningRoot);
860
932
  const others = allRoots
861
- .filter(r => r !== targetRoot)
933
+ .filter(r => r !== owningRoot)
862
934
  .map(r => path.basename(r));
863
935
  rootHint = `Root: ${chosenLabel} (others: ${others.join(', ')} — pass --root <name> to change)\n`;
864
936
  }
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
  }