dotmd-cli 0.67.0 → 0.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/dotmd.mjs CHANGED
@@ -1078,9 +1078,10 @@ Examples:
1078
1078
  The "save a resume prompt" verb. Works mid-anything:
1079
1079
 
1080
1080
  Plan mode (a plan is in-session, or you pass one):
1081
- 1. Saves a resume prompt named resume-<plan-slug> (collision-safe: -2, -3, …).
1082
- The prompt is session-local — the next session's hud surfaces it; never
1083
- paste resume text into chat.
1081
+ 1. Saves a resume prompt named resume-<plan-slug> (collision-safe: -2, -3, …),
1082
+ stamped with a plan: link so consuming it re-claims the plan (see \`dotmd
1083
+ use\`). The prompt is session-local — the next session's hud surfaces it;
1084
+ never paste resume text into chat.
1084
1085
  2. Releases the plan: one status flip, in-session → active by default
1085
1086
  (--status to override, --note to record why in ## Version History).
1086
1087
  3. Prints the exact \`git commit\` for the plan's frontmatter change — the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.67.0",
3
+ "version": "0.69.0",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, Notion sync, AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -48,7 +48,7 @@
48
48
  "engines": {
49
49
  "node": ">=20"
50
50
  },
51
- "dependencies": {
51
+ "optionalDependencies": {
52
52
  "@notionhq/client": "^5.13.0",
53
53
  "notion-to-md": "^3.1.9"
54
54
  }
package/src/baton.mjs CHANGED
@@ -5,7 +5,8 @@ import { asString, toRepoPath, die, warn, currentSessionId } from './util.mjs';
5
5
  import { buildIndex, resolveDocArg } from './index.mjs';
6
6
  import { readJournalEntries } from './journal.mjs';
7
7
  import { runNew, readBodyInput } from './new.mjs';
8
- import { runSet } from './lifecycle.mjs';
8
+ import { runSet, updateFrontmatter } from './lifecycle.mjs';
9
+ import { resolvePromptInput } from './prompts.mjs';
9
10
  import { green, dim } from './color.mjs';
10
11
 
11
12
  // `dotmd baton` is the one-command handoff: save the resume prompt AND release
@@ -31,9 +32,9 @@ function matchesDocRef(doc, ref) {
31
32
  // or lock — in-session is just frontmatter — so ownership is reconstructed
32
33
  // from the per-repo journal: the last `use <plan>` / `set in-session <plan>`
33
34
  // this sid ran whose target is still in-session. Falls back to "the only
34
- // in-session plan" when the journal can't answer (disabled, or another tool
35
- // flipped the status). Returns { plan, via, inSession }; plan is null when
36
- // there's no defensible answer (caller decides how to ask).
35
+ // in-session plan" when the journal is *silent* for this session (disabled, or
36
+ // another tool flipped the status). Returns { plan, via, inSession }; plan is
37
+ // null when there's no defensible answer (caller decides how to ask).
37
38
  export function findOwnedPlan(config, index = null) {
38
39
  const idx = index ?? buildIndex(config);
39
40
  const inSession = idx.docs.filter(d => d.type === 'plan' && d.status === 'in-session');
@@ -42,6 +43,13 @@ export function findOwnedPlan(config, index = null) {
42
43
  const sid = currentSessionId();
43
44
  let entries = [];
44
45
  try { entries = readJournalEntries(config); } catch { entries = []; }
46
+ // Did THIS session issue any ownership command at all (`use` / `set in-session`
47
+ // / `status … in-session`)? If it did but none matched an in-session plan, this
48
+ // session's work lives elsewhere (e.g. it consumed a `use <prompt>`, or worked
49
+ // a plan that's since been released) — so the lone in-session plan is
50
+ // presumptively *another* session's, and auto-selecting it would flip a
51
+ // stranger's status. Track that to gate the single-in-session fast path below.
52
+ let sawOwnershipRef = false;
45
53
  for (let i = entries.length - 1; i >= 0; i--) {
46
54
  const e = entries[i];
47
55
  if (e?.sid !== sid || !Array.isArray(e.argv) || (e.exit ?? 0) !== 0) continue;
@@ -51,11 +59,19 @@ export function findOwnedPlan(config, index = null) {
51
59
  else if (a[0] === 'set' && a[1] === 'in-session') ref = a.slice(2).find(x => typeof x === 'string' && !x.startsWith('-'));
52
60
  else if (a[0] === 'status' && a.includes('in-session')) ref = a.slice(1).find(x => typeof x === 'string' && !x.startsWith('-') && x !== 'in-session');
53
61
  if (!ref) continue;
62
+ sawOwnershipRef = true;
54
63
  const doc = inSession.find(d => matchesDocRef(d, ref));
55
64
  if (doc) return { plan: doc, via: 'journal', inSession };
56
65
  }
57
66
 
58
- if (inSession.length === 1) return { plan: inSession[0], via: 'single-in-session', inSession };
67
+ // Single-in-session fast path — ONLY when the journal is silent about this
68
+ // session's ownership intent. If this sid *did* run an ownership command that
69
+ // pointed somewhere other than the lone in-session plan, refuse and let the
70
+ // caller demand an explicit slug/plan rather than hand off a plan this session
71
+ // never touched (the cross-session baton misfire).
72
+ if (inSession.length === 1 && !sawOwnershipRef) {
73
+ return { plan: inSession[0], via: 'single-in-session', inSession };
74
+ }
59
75
  return { plan: null, via: null, inSession };
60
76
  }
61
77
 
@@ -193,6 +209,17 @@ export async function runBaton(argv, config, opts = {}) {
193
209
  }
194
210
  if (!createdSlug) die(`Could not find a free prompt slug for ${slugBase} (tried ${slugBase}-2 … ${slugBase}-9).`);
195
211
 
212
+ // Link the prompt back to its plan so the next session's `dotmd use` re-claims
213
+ // it (consume = claim). The resume-<slug> filename is a lossy link under -N
214
+ // suffixing / plan renames; the explicit `plan:` field is the durable one.
215
+ // Best-effort: a resolve/write hiccup must never fail an otherwise-good handoff.
216
+ if (planPath && !dryRun) {
217
+ try {
218
+ const promptPath = resolvePromptInput(createdSlug, config, { dieOnMiss: false });
219
+ if (promptPath) updateFrontmatter(promptPath, { plan: repoPath });
220
+ } catch { /* stamping is best-effort */ }
221
+ }
222
+
196
223
  // 2. Release the plan — exactly one status flip. Skipped entirely in slug
197
224
  // mode: with no plan involved there is nothing to release.
198
225
  let archiveResult = null;
@@ -203,7 +230,9 @@ export async function runBaton(argv, config, opts = {}) {
203
230
  } else {
204
231
  const setArgs = [status, planPath];
205
232
  if (note) setArgs.push('--note', note);
206
- archiveResult = await runSet(setArgs, config, { dryRun });
233
+ // viaBaton: this release IS the handoff — don't let runSet nudge "leave a
234
+ // baton" on top of the baton we're in the middle of.
235
+ archiveResult = await runSet(setArgs, config, { dryRun, viaBaton: true });
207
236
  statusChanged = true;
208
237
  }
209
238
  }
package/src/lifecycle.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter, normalizeEol } from './frontmatter.mjs';
4
- import { asString, toRepoPath, die, warn, resolveDocPath, resolveRefPath, escapeRegex, nowIso, suggestCandidates, emitFilesFooter, isArchivedPath } from './util.mjs';
4
+ import { asString, toRepoPath, die, warn, resolveDocPath, resolveRefPath, escapeRegex, nowIso, suggestCandidates, emitFilesFooter, isArchivedPath, currentSessionId } from './util.mjs';
5
+ import { readJournalEntries } from './journal.mjs';
5
6
  import { gitMv, getGitLastModifiedBatch } from './git.mjs';
6
7
  import { buildIndex, collectDocFiles, resolveDocArg } from './index.mjs';
7
8
  import { renderIndexFile, writeIndex } from './index-file.mjs';
@@ -598,6 +599,24 @@ export function runArchive(argv, config, opts = {}) {
598
599
  // `dotmd set in-session <path>` is refused — acquiring a lease is asymmetric
599
600
  // enough to deserve its own verb (`dotmd pickup`), and silently routing here
600
601
  // would skip the lease-acquisition path entirely.
602
+
603
+ // Did THIS session already hand off via `dotmd baton`? The journal records the
604
+ // top-level argv of every invocation; a successful `baton …` means a resume
605
+ // prompt was saved this session, so the closure nudge would be redundant. Best
606
+ // effort — a disabled journal degrades to "might nudge," which is harmless for
607
+ // an advisory line. (baton's own internal `runSet` is suppressed by `viaBaton`,
608
+ // since its journal entry isn't written until the process exits.)
609
+ function batonSavedThisSession(config) {
610
+ const sid = currentSessionId();
611
+ let entries = [];
612
+ try { entries = readJournalEntries(config); } catch { return false; }
613
+ for (let i = entries.length - 1; i >= 0; i--) {
614
+ const e = entries[i];
615
+ if (e?.sid === sid && (e.exit ?? 0) === 0 && Array.isArray(e.argv) && e.argv[0] === 'baton') return true;
616
+ }
617
+ return false;
618
+ }
619
+
601
620
  export async function runSet(argv, config, opts = {}) {
602
621
  const { dryRun } = opts;
603
622
  const noIndex = argv.includes('--no-index');
@@ -631,26 +650,56 @@ export async function runSet(argv, config, opts = {}) {
631
650
  return runArchive(archiveArgs, config, { dryRun, note, archiveStatus: newStatus });
632
651
  }
633
652
 
634
- // `partial` promises a successor tracking the deferred tail. When neither a
635
- // --note nor any doc reference exists to point at it, remind — advisory
636
- // only, computed before the transition (filing may move the file).
653
+ // Two advisory reminders, both computed from one pre-transition frontmatter
654
+ // read (a non-archive `set` never moves the file, so the path stays valid).
655
+ // Advisory only — never block the transition.
637
656
  let partialReminder = false;
638
- if (newStatus === 'partial' && !note) {
639
- try {
640
- const { frontmatter: fmRaw, body } = extractFrontmatter(readFileSync(filePath, 'utf8'));
641
- const related = parseSimpleFrontmatter(fmRaw).related_plans;
657
+ let batonNudge = false;
658
+ try {
659
+ const { frontmatter: fmRaw, body } = extractFrontmatter(readFileSync(filePath, 'utf8'));
660
+ const fm = parseSimpleFrontmatter(fmRaw);
661
+
662
+ // `partial` promises a successor tracking the deferred tail. When neither a
663
+ // --note nor any doc reference exists to point at it, remind.
664
+ if (newStatus === 'partial' && !note) {
665
+ const related = fm.related_plans;
642
666
  const hasRelated = Array.isArray(related) ? related.length > 0 : Boolean(asString(related)?.trim());
643
667
  partialReminder = !hasRelated && !/[\w./-]+\.md\b/.test(body);
644
- } catch { /* advisory only */ }
645
- }
668
+ }
669
+
670
+ // Baton-on-exit backstop (the one core-loop step with no other mechanical
671
+ // catch). Fire ONLY on the baton-less in-session release: a plan you were
672
+ // actively working (old status `in-session`) is being parked at a
673
+ // non-terminal stop status while a known next pickup (`next_step`) remains,
674
+ // and no baton was saved this session. `archived` never reaches here (routed
675
+ // to runArchive above), so this can't fire on a fully-done close. Gating on
676
+ // `in-session` keeps it off pure triage of never-started plans — where no
677
+ // session work is owed a handoff — which is how the nag-fatigue risk is
678
+ // managed. Suppressed for baton's own internal release via `opts.viaBaton`.
679
+ if (!opts.viaBaton) {
680
+ const docType = asString(fm.type) ?? 'plan';
681
+ const oldStatus = asString(fm.status);
682
+ const nextStep = asString(fm.next_step)?.trim();
683
+ batonNudge = docType === 'plan'
684
+ && oldStatus === 'in-session'
685
+ && newStatus !== 'in-session'
686
+ && Boolean(nextStep)
687
+ && !batonSavedThisSession(config);
688
+ }
689
+ } catch { /* advisory only */ }
646
690
 
647
691
  const statusArgs = [filePath, newStatus];
648
692
  if (noIndex) statusArgs.push('--no-index');
649
693
  if (showFiles) statusArgs.push('--show-files');
650
694
  await runStatus(statusArgs, config, { dryRun, suppressDeprecation: true, note });
651
695
 
652
- if (partialReminder && !dryRun) {
653
- warn('partial usually references the successor plan tracking the tail — add a link to the body, or rerun with --note "tail tracked in <plan>".');
696
+ if (!dryRun) {
697
+ if (partialReminder) {
698
+ warn('partial usually references the successor plan tracking the tail — add a link to the body, or rerun with --note "tail tracked in <plan>".');
699
+ }
700
+ if (batonNudge) {
701
+ warn(`wrapping up? leave a baton so the next session picks up cleanly — \`dotmd baton ${path.basename(filePath, '.md')} @draft\` saves a resume prompt (no copy-paste into chat).`);
702
+ }
654
703
  }
655
704
  }
656
705
 
package/src/lint.mjs CHANGED
@@ -73,12 +73,26 @@ export function runLint(argv, config, opts = {}) {
73
73
  // existing plural array happens at apply-time so the message reflects
74
74
  // just what's being introduced from the singular form.
75
75
  for (const { singular, plural } of [{ singular: 'module', plural: 'modules' }, { singular: 'surface', plural: 'surfaces' }]) {
76
- const val = asString(parsed[singular]);
77
- if (!val) continue;
78
- const values = val.includes(',')
79
- ? val.split(',').map(s => s.trim()).filter(Boolean)
80
- : [val];
81
- fixes.push({ field: singular, oldValue: val, newValue: values, pluralKey: plural, type: 'singular-to-plural' });
76
+ const rawVal = parsed[singular];
77
+ const val = asString(rawVal);
78
+ if (val) {
79
+ // Inline value (`surface: foo`) → migrate into the plural array.
80
+ const values = val.includes(',')
81
+ ? val.split(',').map(s => s.trim()).filter(Boolean)
82
+ : [val];
83
+ fixes.push({ field: singular, oldValue: val, newValue: values, pluralKey: plural, type: 'singular-to-plural' });
84
+ } else if (singular in parsed && (!Array.isArray(rawVal) || rawVal.length === 0)) {
85
+ // Empty deprecated key (`surface:` with nothing after it, which the
86
+ // parser yields as `[]`) — usually sitting right above a populated
87
+ // `surfaces:`. There's no value to migrate; the deprecation warning
88
+ // just wants the dead line gone. `validate` treats the empty `[]` as
89
+ // present (so it warns), while the old `asString` gate here saw it as
90
+ // absent and skipped — the exact no-op reported in issue #17. Emit a
91
+ // drop fix so `lint --fix` does what the warning advertises. A
92
+ // populated block-form singular (rawVal.length > 0) is left untouched:
93
+ // too rare to risk mis-editing.
94
+ fixes.push({ field: singular, pluralKey: plural, type: 'drop-singular' });
95
+ }
82
96
  }
83
97
 
84
98
  // Trailing whitespace in values
@@ -102,8 +116,22 @@ export function runLint(argv, config, opts = {}) {
102
116
  // Also get non-fixable issues from index, excluding issues we can already fix
103
117
  const index = buildIndex(config);
104
118
  const fixablePaths = new Set(fixable.map(f => f.repoPath));
119
+ // Singular-key deprecation warnings whose fix `lint --fix` will actually make.
120
+ // Without this, those warnings landed in BOTH the "fixable" preview and the
121
+ // "non-fixable" list, so the header claimed "N non-fixable" while every line
122
+ // told you to run `lint --fix` (issue #17, item 8).
123
+ const fixableSingularKeys = new Set();
124
+ for (const { repoPath, fixes } of fixable) {
125
+ for (const f of fixes) {
126
+ if (f.type === 'singular-to-plural' || f.type === 'drop-singular') {
127
+ fixableSingularKeys.add(`${repoPath}::${f.field}`);
128
+ }
129
+ }
130
+ }
105
131
  const nonFixable = [...index.errors, ...index.warnings].filter(issue => {
106
132
  if (issue.message.includes('Missing frontmatter `status`') && fixablePaths.has(issue.path)) return false;
133
+ const dep = issue.message.match(/^`(module|surface):` \(singular\) is deprecated/);
134
+ if (dep && fixableSingularKeys.has(`${issue.path}::${dep[1]}`)) return false;
107
135
  return true;
108
136
  });
109
137
 
@@ -120,6 +148,8 @@ export function runLint(argv, config, opts = {}) {
120
148
  process.stdout.write(dim(` missing status (fixable via AI)\n`));
121
149
  } else if (f.type === 'singular-to-plural') {
122
150
  process.stdout.write(dim(` ${f.field}: "${f.oldValue}" → ${f.pluralKey}: [${f.newValue.join(', ')}]\n`));
151
+ } else if (f.type === 'drop-singular') {
152
+ process.stdout.write(dim(` remove deprecated \`${f.field}:\` (empty; \`${f.pluralKey}:\` is the live key)\n`));
123
153
  } else if (f.type === 'eof') {
124
154
  process.stdout.write(dim(` missing newline at end of file\n`));
125
155
  } else if (f.type === 'add') {
@@ -155,6 +185,7 @@ export function runLint(argv, config, opts = {}) {
155
185
  let needsEofFix = false;
156
186
  const trimFixes = [];
157
187
  const singularToPlural = [];
188
+ const dropSingular = [];
158
189
 
159
190
  for (const f of fixes) {
160
191
  if (f.type === 'rename-key') {
@@ -165,6 +196,8 @@ export function runLint(argv, config, opts = {}) {
165
196
  trimFixes.push(f);
166
197
  } else if (f.type === 'singular-to-plural') {
167
198
  singularToPlural.push(f);
199
+ } else if (f.type === 'drop-singular') {
200
+ dropSingular.push(f);
168
201
  } else {
169
202
  updates[f.field] = f.newValue;
170
203
  }
@@ -212,6 +245,19 @@ export function runLint(argv, config, opts = {}) {
212
245
  writeFileSync(filePath, raw, 'utf8');
213
246
  }
214
247
 
248
+ // Drop empty deprecated singular keys. The value is known-empty (the fix
249
+ // is only emitted for `[]`/`''`), so this matches the bare `key:` line
250
+ // exactly — never a line carrying content — and removes it.
251
+ for (const ds of dropSingular) {
252
+ let raw = readFileSync(filePath, 'utf8');
253
+ const { frontmatter: fm } = extractFrontmatter(raw);
254
+ const newFm = fm
255
+ .replace(new RegExp(`^${escapeRegex(ds.field)}:[ \\t]*$`, 'm'), '')
256
+ .replace(/\n{2,}/g, '\n');
257
+ raw = replaceFrontmatter(raw, newFm.trim());
258
+ writeFileSync(filePath, raw, 'utf8');
259
+ }
260
+
215
261
  // Apply key renames and trim fixes via raw string manipulation
216
262
  if (keyRenames.length > 0 || trimFixes.length > 0) {
217
263
  let raw = readFileSync(filePath, 'utf8');
@@ -259,6 +305,8 @@ export function runLint(argv, config, opts = {}) {
259
305
  }
260
306
  } else if (f.type === 'singular-to-plural') {
261
307
  process.stdout.write(`${prefix} ${dim(`${f.field}: "${f.oldValue}" → ${f.pluralKey}: [${f.newValue.join(', ')}]`)}\n`);
308
+ } else if (f.type === 'drop-singular') {
309
+ process.stdout.write(`${prefix} ${dim(`removed deprecated \`${f.field}:\` (empty)`)}\n`);
262
310
  } else if (f.type === 'add') {
263
311
  process.stdout.write(`${prefix} ${dim(`add ${f.field}: ${f.newValue}`)}\n`);
264
312
  } else {
package/src/notion.mjs CHANGED
@@ -16,7 +16,7 @@ async function loadDeps() {
16
16
  notionClient = Client;
17
17
  notionToMd = NotionToMarkdown;
18
18
  } catch {
19
- die('Notion dependencies not installed. Run: npm install @notionhq/client notion-to-md');
19
+ die('Notion integration needs its optional dependencies. Run: npm install @notionhq/client notion-to-md\n(These ship as optionalDependencies — a plain `npm i -g dotmd-cli` installs them unless you passed `--omit=optional`.)');
20
20
  }
21
21
  }
22
22
 
package/src/prompts.mjs CHANGED
@@ -1,13 +1,18 @@
1
- import { readFileSync, statSync } from 'node:fs';
1
+ import { readFileSync, statSync, existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
3
4
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
- import { asString, toRepoPath, die, resolveDocPath, isArchivedPath } from './util.mjs';
5
- import { buildIndex } from './index.mjs';
5
+ import { asString, toRepoPath, die, resolveDocPath, isArchivedPath, currentSessionId } from './util.mjs';
6
+ import { buildIndex, resolveDocArg } from './index.mjs';
6
7
  import { runQuery } from './query.mjs';
7
- import { runArchive, runStatus } from './lifecycle.mjs';
8
+ import { runArchive, runStatus, updateFrontmatter } from './lifecycle.mjs';
9
+ import { appendJournalEntry } from './journal.mjs';
8
10
  import { runNew } from './new.mjs';
9
11
  import { green, dim } from './color.mjs';
10
12
 
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+ const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
15
+
11
16
  // `resume` is an alias for `use` — agents reach for "resume" when continuing a
12
17
  // session; `use` reads as internal mechanics. Both names stay valid; the
13
18
  // canonical output ("Consumed: …") is unchanged.
@@ -283,6 +288,48 @@ export function consumePrompt(filePath, config, opts) {
283
288
 
284
289
  const consumedPath = archiveResult?.newRepoPath ?? repoPath;
285
290
  process.stderr.write(`${green('✓ Consumed')}: ${consumedPath}\n`);
291
+
292
+ // Consume = claim: a baton-created resume prompt carries `plan: <path>`.
293
+ // Adopt that plan for THIS session so the next `dotmd baton` (with no arg)
294
+ // hands it off — closing the cross-session ownership loop that otherwise dies
295
+ // at the prompt boundary.
296
+ claimPromptPlan(asString(parsed.plan), config);
297
+ }
298
+
299
+ // Flip the resume prompt's linked plan to in-session for this session and
300
+ // record the ownership in the journal. The journal entry matters: baton's
301
+ // `findOwnedPlan` reconstructs ownership from journaled `set in-session <plan>`
302
+ // commands, and the outer `use <prompt>` argv can't tie a prompt ref to a plan
303
+ // — so without this synthetic entry the claim would be invisible to baton (and,
304
+ // with the misfire gate, would even make the next baton refuse).
305
+ function claimPromptPlan(planRef, config) {
306
+ if (!planRef) return;
307
+ let planPath = null;
308
+ try { planPath = resolveDocPath(planRef, config) ?? resolveDocArg(planRef, config, { dieOnMiss: false }); }
309
+ catch { planPath = null; }
310
+ if (!planPath || !existsSync(planPath)) return; // link went stale (plan renamed/removed) — the resume body already printed, so stay quiet
311
+
312
+ let planFm;
313
+ try { planFm = parseSimpleFrontmatter(extractFrontmatter(readFileSync(planPath, 'utf8')).frontmatter); }
314
+ catch { return; }
315
+ const cur = asString(planFm.status);
316
+ // Only claim a startable plan. Already in-session → someone's on it (don't
317
+ // steal); archived/terminal → the link is stale. Either way, leave it be.
318
+ if (!cur || cur === 'in-session' || cur === 'archived') return;
319
+
320
+ const repoPath = toRepoPath(planPath, config.repoRoot);
321
+ try { updateFrontmatter(planPath, { status: 'in-session' }); }
322
+ catch { return; }
323
+ try {
324
+ // `v` MUST be the real CLI version: the journal rotates when a new entry's
325
+ // version differs from the file's first entry, so a sentinel here would shove
326
+ // our just-written claim into the backup file where findOwnedPlan can't see it.
327
+ appendJournalEntry(config, {
328
+ ts: new Date().toISOString(), sid: currentSessionId(), pid: process.pid,
329
+ argv: ['set', 'in-session', repoPath], exit: 0, ms: 0, v: pkg.version,
330
+ });
331
+ } catch { /* journal is best-effort — the status flip already landed */ }
332
+ process.stderr.write(`${green('→ Claimed')}: ${repoPath} (in-session)\n`);
286
333
  }
287
334
 
288
335
  // Read-only peek: print the body WITHOUT consuming. The sanctioned triage path