dotmd-cli 0.64.1 → 0.64.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/README.md CHANGED
@@ -9,6 +9,7 @@ Index, query, validate, and lifecycle-manage any collection of `.md` files — p
9
9
  ```bash
10
10
  npm install -g dotmd-cli # global — use `dotmd` anywhere
11
11
  npm install -D dotmd-cli # project devDep — use via npm scripts
12
+ npx dotmd-cli init # try it without installing — scaffold a repo first
12
13
  # requires Node.js >= 20
13
14
  ```
14
15
 
@@ -27,6 +28,19 @@ The plugin bundles the hooks (`SessionStart`/`SubagentStart` priming, a `PreTool
27
28
 
28
29
  > **Upgrading to 0.57.0+:** per-repo `.claude/commands/{plans,docs,baton}.md` scaffolding is retired — that guidance now ships via the plugin's workflow skill and `/plans`, `/docs`, `/prompts`, `/baton` commands. On the next `dotmd hud` (SessionStart), dotmd removes those generated files (only banner-stamped `<!-- dotmd-generated -->` ones — your hand-authored command files are never touched). If you'd committed them, you'll see deletions to commit — that's expected. Run `claude plugin update dotmd@dotmd` to pick up `/baton`.
29
30
 
31
+ ### Updating
32
+
33
+ The CLI and the Claude Code plugin are versioned in lockstep but ship as separate artifacts, so upgrading one can leave the other behind. `dotmd update` keeps them aligned:
34
+
35
+ ```bash
36
+ dotmd update # update both: npm CLI + the plugin
37
+ dotmd update --check # report CLI vs plugin versions, change nothing (no network)
38
+ dotmd update --cli-only # just the npm CLI
39
+ dotmd update --plugin-only # just the plugin (what to run after a plain npm upgrade)
40
+ ```
41
+
42
+ `--plugin-only` is the usual fixup: after `npm i -g dotmd-cli@latest` the CLI is fresh but the plugin is stale, so run `dotmd update --plugin-only`, then restart the session (or `/reload-plugins`).
43
+
30
44
  ## Quick Start
31
45
 
32
46
  ```bash
package/bin/dotmd.mjs CHANGED
@@ -255,6 +255,7 @@ Create & Export:
255
255
 
256
256
  Setup:
257
257
  init Create starter config + docs directory
258
+ update [--check|--cli-only|--plugin-only] Update the CLI + Claude Code plugin (--check reports skew, no network)
258
259
  statuses [list|add|set|remove|migrate] Manage per-project status taxonomy
259
260
  help statuses Full status vocabulary + unstuck-actions + transitions
260
261
  watch [command] Re-run a command on file changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.64.1",
3
+ "version": "0.64.3",
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",
package/src/doctor.mjs CHANGED
@@ -11,6 +11,7 @@ import { checkClaudeCommands, removeGeneratedSlashCommands } from './claude-comm
11
11
  import { runMigrateTemplate } from './migrate-template.mjs';
12
12
  import { runMigratePrompts } from './migrate-prompts.mjs';
13
13
  import { runFrontmatterFix } from './frontmatter-fix.mjs';
14
+ import { normalizeEol } from './frontmatter.mjs';
14
15
  import { toRepoPath } from './util.mjs';
15
16
 
16
17
  // Tunable thresholds for `dotmd doctor --statuses` conflation detection.
@@ -165,7 +166,7 @@ function findWorkflowDrift(config) {
165
166
  for (const filePath of collectDocFiles(config)) {
166
167
  let raw = '';
167
168
  try { raw = readFileSync(filePath, 'utf8'); } catch { continue; }
168
- if (!raw.startsWith('---\n')) docsWithoutFrontmatter.push(toRepoPath(filePath, config.repoRoot));
169
+ if (!normalizeEol(raw).startsWith('---\n')) docsWithoutFrontmatter.push(toRepoPath(filePath, config.repoRoot));
169
170
  }
170
171
 
171
172
  const planStatusGaps = [];
@@ -1,24 +1,40 @@
1
+ // Windows-authored (CRLF) docs otherwise slip past the LF-only fence detection
2
+ // below and read as having NO frontmatter — silently dropping them from the
3
+ // managed set (no type, no status). Normalizing CRLF→LF at every parse/rewrite
4
+ // boundary is the fix; the per-line value parser already strips a trailing \r,
5
+ // so only the fence scan was blind. A managed doc settles to LF the first time a
6
+ // dotmd verb rewrites it — content-preserving line-ending normalization, not
7
+ // corruption. For LF docs this is a no-op (the `\r` guard skips the replace), so
8
+ // existing behavior is byte-identical.
9
+ export function normalizeEol(text) {
10
+ return typeof text === 'string' && text.includes('\r') ? text.replace(/\r\n/g, '\n') : text;
11
+ }
12
+
1
13
  export function extractFrontmatter(raw) {
2
- if (!raw.startsWith('---\n')) {
3
- return { frontmatter: '', body: raw };
14
+ const text = normalizeEol(raw);
15
+ if (!text.startsWith('---\n')) {
16
+ return { frontmatter: '', body: text };
4
17
  }
5
18
 
6
- const endMarker = raw.indexOf('\n---\n', 4);
19
+ const endMarker = text.indexOf('\n---\n', 4);
7
20
  if (endMarker === -1) {
8
- return { frontmatter: '', body: raw };
21
+ return { frontmatter: '', body: text };
9
22
  }
10
23
 
11
24
  return {
12
- frontmatter: raw.slice(4, endMarker),
13
- body: raw.slice(endMarker + 5),
25
+ frontmatter: text.slice(4, endMarker),
26
+ body: text.slice(endMarker + 5),
14
27
  };
15
28
  }
16
29
 
17
30
  export function replaceFrontmatter(raw, newFrontmatter) {
18
- if (!raw.startsWith('---\n')) return raw;
19
- const endMarker = raw.indexOf('\n---\n', 4);
31
+ const text = normalizeEol(raw);
32
+ // No frontmatter to replace: return the ORIGINAL bytes untouched (a no-op
33
+ // rewrite must not normalize a file the caller didn't intend to change).
34
+ if (!text.startsWith('---\n')) return raw;
35
+ const endMarker = text.indexOf('\n---\n', 4);
20
36
  if (endMarker === -1) return raw;
21
- const body = raw.slice(endMarker + 5);
37
+ const body = text.slice(endMarker + 5);
22
38
  return `---\n${newFrontmatter}\n---\n${body}`;
23
39
  }
24
40
 
@@ -34,6 +50,16 @@ export function replaceFrontmatter(raw, newFrontmatter) {
34
50
  // folded block scalar `key: >\n one line\n continues` → "one line continues"
35
51
  // literal block scalar `key: |\n one\n two` → "one\ntwo"
36
52
  // chomping indicators `>-`, `|-` (strip), `>+`, `|+` (keep), default (clip to one trailing \n)
53
+ //
54
+ // Supported-subset boundary (deliberately NOT a full YAML parser):
55
+ // - Scalars stay strings except literal `true`/`false`. Numbers, null, and
56
+ // dates are kept verbatim as strings; callers coerce where they need a type
57
+ // (so `1.0`, `2025-01-01`, version strings, and numeric-looking ids survive
58
+ // intact rather than silently changing type).
59
+ // - Duplicate keys keep the FIRST occurrence; later ones are ignored (and
60
+ // reported via the optional `warnings` array).
61
+ // - Nested maps / multi-level indentation are not parsed — only top-level
62
+ // keys, with one level of `- ` items under an array key.
37
63
  export function parseSimpleFrontmatter(text, warnings) {
38
64
  const data = {};
39
65
  const seenDupKeys = new Set();
package/src/guard.mjs CHANGED
@@ -27,6 +27,14 @@ const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'),
27
27
 
28
28
  const SHELL_READERS = new Set(['cat', 'less', 'more', 'head', 'tail', 'bat', 'view', 'open']);
29
29
 
30
+ // Normalize path separators so the guard's `/`-based matching also fires on
31
+ // Windows backslash paths (`docs\prompts\foo.md`). Without this the PreToolUse
32
+ // status-edit/commit-prompt guards silently no-op on Windows — protection
33
+ // absent exactly where it's needed. On POSIX paths this is a no-op.
34
+ function toSlash(p) {
35
+ return typeof p === 'string' ? p.replace(/\\/g, '/') : p;
36
+ }
37
+
30
38
  // A path that ends in .md and sits under a `prompts/` directory is a
31
39
  // session-local saved prompt regardless of which doc root it belongs to —
32
40
  // robust across repos without needing the resolved config. Archived prompts
@@ -34,20 +42,23 @@ const SHELL_READERS = new Set(['cat', 'less', 'more', 'head', 'tail', 'bat', 'vi
34
42
  // committable history, NOT session-local, so they're explicitly excluded — the
35
43
  // guard must not block committing or reading them.
36
44
  function isPromptPath(p) {
37
- if (typeof p !== 'string' || !p.endsWith('.md')) return false;
38
- if (!/(^|\/)prompts\//.test(p)) return false;
39
- if (/(^|\/)archived\//.test(p)) return false;
45
+ const s = toSlash(p);
46
+ if (typeof s !== 'string' || !s.endsWith('.md')) return false;
47
+ if (!/(^|\/)prompts\//.test(s)) return false;
48
+ if (/(^|\/)archived\//.test(s)) return false;
40
49
  return true;
41
50
  }
42
51
 
43
52
  // Loose "is this a dotmd-managed doc" test: a .md file under one of the
44
53
  // configured doc roots (default `docs/`). Used for the status-edit guard.
45
54
  function isManagedDoc(p, config) {
46
- if (typeof p !== 'string' || !p.endsWith('.md')) return false;
55
+ const s = toSlash(p);
56
+ if (typeof s !== 'string' || !s.endsWith('.md')) return false;
47
57
  const roots = config?.docsRoots || (config?.docsRoot ? [config.docsRoot] : ['docs']);
48
58
  return roots.some(r => {
49
- const base = path.basename(r);
50
- return p.includes(`/${base}/`) || p.startsWith(`${base}/`) || p.includes(r);
59
+ const rNorm = toSlash(r).replace(/\/+$/, '');
60
+ const base = rNorm.split('/').pop();
61
+ return s.includes(`/${base}/`) || s.startsWith(`${base}/`) || s.includes(rNorm);
51
62
  });
52
63
  }
53
64
 
package/src/init.mjs CHANGED
@@ -190,9 +190,15 @@ function generateDetectedConfig(scan, rootPath) {
190
190
  lines.push('');
191
191
  }
192
192
 
193
- if (scan.surfaces.size > 0) {
193
+ if (scan.surfaces.size > 0 || scan.modules.size > 0) {
194
194
  lines.push('export const taxonomy = {');
195
- lines.push(` surfaces: [${[...scan.surfaces].sort().map(s => `'${s}'`).join(', ')}],`);
195
+ // Emit the full detected set so every existing doc passes — taxonomy
196
+ // enforcement only flags values outside the list, and the scan collected
197
+ // all of them. New surfaces/modules added later warn until appended here.
198
+ if (scan.surfaces.size > 0)
199
+ lines.push(` surfaces: [${[...scan.surfaces].sort().map(s => `'${s}'`).join(', ')}],`);
200
+ if (scan.modules.size > 0)
201
+ lines.push(` modules: [${[...scan.modules].sort().map(m => `'${m}'`).join(', ')}],`);
196
202
  lines.push('};');
197
203
  lines.push('');
198
204
  }
package/src/lifecycle.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
3
+ import { extractFrontmatter, parseSimpleFrontmatter, normalizeEol } from './frontmatter.mjs';
4
4
  import { asString, toRepoPath, die, warn, resolveDocPath, resolveRefPath, escapeRegex, nowIso, suggestCandidates, emitFilesFooter, isArchivedPath } from './util.mjs';
5
5
  import { gitMv, getGitLastModifiedBatch } from './git.mjs';
6
6
  import { buildIndex, collectDocFiles, resolveDocArg } from './index.mjs';
@@ -935,7 +935,7 @@ function countRefsToUpdate(oldPath, newPath, config) {
935
935
  // — never auto-creates the section (don't surprise users on old plans/docs).
936
936
  export function appendVersionHistory(filePath, entry, { createSection = false } = {}) {
937
937
  let raw;
938
- try { raw = readFileSync(filePath, 'utf8'); } catch { return false; }
938
+ try { raw = normalizeEol(readFileSync(filePath, 'utf8')); } catch { return false; }
939
939
  if (!raw.startsWith('---\n')) return false;
940
940
 
941
941
  const endMarker = raw.indexOf('\n---\n', 4);
@@ -979,7 +979,7 @@ export function appendVersionHistory(filePath, entry, { createSection = false }
979
979
  }
980
980
 
981
981
  export function updateFrontmatter(filePath, updates) {
982
- const raw = readFileSync(filePath, 'utf8');
982
+ const raw = normalizeEol(readFileSync(filePath, 'utf8'));
983
983
  // Name the remedy in the error: this is where every status verb lands when a
984
984
  // doc was created outside dotmd, and "no frontmatter block" alone left
985
985
  // sessions retrying other verbs instead of fixing the doc.
@@ -1009,7 +1009,7 @@ export function updateFrontmatter(filePath, updates) {
1009
1009
  // to updateFrontmatter when a block already exists so callers can hand it any
1010
1010
  // file without pre-checking — the result is the same shape either way.
1011
1011
  export function writeFrontmatter(filePath, fields) {
1012
- const raw = readFileSync(filePath, 'utf8');
1012
+ const raw = normalizeEol(readFileSync(filePath, 'utf8'));
1013
1013
  if (raw.startsWith('---\n')) {
1014
1014
  updateFrontmatter(filePath, fields);
1015
1015
  return;
package/src/new.mjs CHANGED
@@ -5,7 +5,7 @@ import { toRepoPath, die, warn, nowIso, emitFilesFooter } from './util.mjs';
5
5
  import { green, dim, bold } from './color.mjs';
6
6
  import { isInteractive, promptText } from './prompt.mjs';
7
7
  import { regenIndex } from './lifecycle.mjs';
8
- import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
8
+ import { extractFrontmatter, parseSimpleFrontmatter, normalizeEol } from './frontmatter.mjs';
9
9
 
10
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
11
  const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
@@ -203,7 +203,7 @@ Status markers (put in heading text):
203
203
  // trap 4. Returns `{ frontmatter: object|null, body: string }`.
204
204
  function splitBodyFrontmatter(rawBody) {
205
205
  if (!rawBody || typeof rawBody !== 'string') return { frontmatter: null, body: rawBody };
206
- if (!rawBody.startsWith('---\n')) return { frontmatter: null, body: rawBody };
206
+ if (!normalizeEol(rawBody).startsWith('---\n')) return { frontmatter: null, body: rawBody };
207
207
  const { frontmatter: fmText, body } = extractFrontmatter(rawBody);
208
208
  if (!fmText) return { frontmatter: null, body: rawBody };
209
209
  const parsed = parseSimpleFrontmatter(fmText);