dotmd-cli 0.68.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.68.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;
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