dotmd-cli 0.82.0 → 0.83.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
@@ -1752,12 +1752,18 @@ async function main() {
1752
1752
  process.stderr.write(`Repo root: ${config.repoRoot}\n`);
1753
1753
  }
1754
1754
 
1755
+ // A printed list shows status, title, age and next step, none of which the
1756
+ // validating passes produce, so it reads the index the way `prompts` and
1757
+ // `hud` do. `--json` emits each document's warnings and errors, so it still
1758
+ // pays for the full pass.
1759
+ const listIndexOptions = listArgs => (listArgs.includes('--json') ? {} : { fast: true });
1760
+
1755
1761
  // Preset aliases (user config can override built-in commands below)
1756
1762
  if ((command === 'stale' || command === 'actionable') && !config.configuredPresetNames.has(command)) {
1757
1763
  const { buildIndex } = await import('../src/index.mjs');
1758
1764
  const { runQuery } = await import('../src/query.mjs');
1759
1765
  const { statusMetadataFor } = await import('../src/status-metadata.mjs');
1760
- const index = buildIndex(config);
1766
+ const index = buildIndex(config, listIndexOptions(restArgs));
1761
1767
  applyIndexFilters(index);
1762
1768
  const docs = index.docs.filter(doc => {
1763
1769
  const metadata = statusMetadataFor(config, doc.type, doc.status);
@@ -1774,7 +1780,7 @@ async function main() {
1774
1780
  if (config.presets[command]) {
1775
1781
  const { buildIndex } = await import('../src/index.mjs');
1776
1782
  const { runQuery } = await import('../src/query.mjs');
1777
- const index = buildIndex(config);
1783
+ const index = buildIndex(config, listIndexOptions([...config.presets[command], ...restArgs]));
1778
1784
  applyIndexFilters(index);
1779
1785
  runQuery(index, [...config.presets[command], ...restArgs], config, { preset: command, type: typeArg, root: rootArg });
1780
1786
  return;
@@ -1787,7 +1793,7 @@ async function main() {
1787
1793
  if (command === 'plans') {
1788
1794
  const { buildIndex } = await import('../src/index.mjs');
1789
1795
  const { runQuery } = await import('../src/query.mjs');
1790
- const index = buildIndex(config);
1796
+ const index = buildIndex(config, listIndexOptions(restArgs));
1791
1797
  applyIndexFilters(index);
1792
1798
  const sub = restArgs[0];
1793
1799
  let defaults;
@@ -1807,7 +1813,7 @@ async function main() {
1807
1813
  if (command === 'runlists') {
1808
1814
  const { buildIndex } = await import('../src/index.mjs');
1809
1815
  const { runRunlists } = await import('../src/query.mjs');
1810
- const index = buildIndex(config);
1816
+ const index = buildIndex(config, listIndexOptions(restArgs));
1811
1817
  applyIndexFilters(index);
1812
1818
  runRunlists(index, restArgs, config);
1813
1819
  return;
@@ -1818,7 +1824,7 @@ async function main() {
1818
1824
  if (command === 'roadmaps') {
1819
1825
  const { buildIndex } = await import('../src/index.mjs');
1820
1826
  const { runRoadmaps } = await import('../src/roadmap.mjs');
1821
- const index = buildIndex(config);
1827
+ const index = buildIndex(config, listIndexOptions(restArgs));
1822
1828
  applyIndexFilters(index);
1823
1829
  runRoadmaps(index, restArgs, config);
1824
1830
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.82.0",
3
+ "version": "0.83.0",
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",
@@ -42,7 +42,8 @@ export function extractNextStep(body) {
42
42
  }
43
43
 
44
44
  export function extractBodyLinks(body) {
45
- if (!body) return [];
45
+ // Every inline link contains `](`, and masking never creates one.
46
+ if (!body || !body.includes('](')) return [];
46
47
  // Strip fenced code blocks, then MASK inline code rather than delete it.
47
48
  // Deleting it ate the commonest link idiom in a plan hub: [`plan.md`](plan.md)
48
49
  // has its link TEXT as a code span, so removing the span left `[](plan.md)`,
package/src/index.mjs CHANGED
@@ -4,6 +4,7 @@ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
4
  import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts, extractBodyLinks } from './extractors.mjs';
5
5
  import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath, warn, die, resolveDocPath, suggestCandidates } from './util.mjs';
6
6
  import { findLexicalDocsRoot } from './managed-path.mjs';
7
+ import { openParseCache, fileStamp, stampSize } from './parse-cache.mjs';
7
8
  import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalReferences, checkGitStaleness, checkRunlistBackPointers, checkCoordinationHubExecutionMode, checkRoadmapHubExecutionMode, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate, enrichRefErrorSuggestions } from './validate.mjs';
8
9
  import { checkIndex } from './index-file.mjs';
9
10
  import { checkClaudeCommands } from './claude-commands.mjs';
@@ -30,7 +31,9 @@ export function buildIndex(config, opts = {}) {
30
31
  const invokeHooks = opts.invokeHooks ?? !config._execution?.suppressSideEffects;
31
32
  const gitStaleness = opts.gitStaleness ?? config._execution?.gitStaleness ?? true;
32
33
  const skipWarningOnlyChecks = fast || errorsOnly;
33
- const docs = collectDocFiles(config).map(f => parseDocFile(f, config, { fast }));
34
+ const cache = openParseCache(config);
35
+ const docs = collectDocFiles(config).map(f => parseDocFile(f, config, { fast, cache }));
36
+ if (cache && !config._execution?.suppressSideEffects) cache.save();
34
37
  if (!fast) {
35
38
  // Per-file validation (validateDoc) ran during parse without sibling
36
39
  // visibility. Now that the full index is materialized, enrich
@@ -271,16 +274,45 @@ function walkMarkdownFiles(directory, files, excludedDirs, skipPaths, seen = new
271
274
  }
272
275
  }
273
276
 
274
- export function parseDocFile(filePath, config, opts = {}) {
275
- const { fast = false } = opts;
276
- const relativePath = toRepoPath(filePath, config.repoRoot);
277
- const raw = readFileSync(filePath, 'utf8');
278
- const { frontmatter, body } = extractFrontmatter(raw);
277
+ // Everything parseDocFile takes from the file's text alone, with no config and
278
+ // no clock, which is what the parse cache may keep.
279
+ function extractDocText(frontmatter, body) {
279
280
  const fmWarnings = [];
280
281
  const parsedFrontmatter = parseSimpleFrontmatter(frontmatter, fmWarnings);
281
- const headingTitle = extractFirstHeading(body);
282
+ return {
283
+ parsedFrontmatter,
284
+ fmWarnings: fmWarnings.map(w => ({ message: w.message })),
285
+ headingTitle: extractFirstHeading(body),
286
+ bodySummary: extractSummary(body),
287
+ bodyStatusSnapshot: extractStatusSnapshot(body),
288
+ bodyNextStep: extractNextStep(body),
289
+ checklist: extractChecklistCounts(body),
290
+ bodyLinks: extractBodyLinks(body),
291
+ hasCloseout: /^##\s+Closeout/m.test(body),
292
+ };
293
+ }
294
+
295
+ export function parseDocFile(filePath, config, opts = {}) {
296
+ const { fast = false, cache = null } = opts;
297
+ const relativePath = toRepoPath(filePath, config.repoRoot);
298
+ const stamp = cache ? fileStamp(filePath) : null;
299
+ let text = stamp ? cache.get(relativePath, stamp) : null;
300
+ // Validation reads the body itself, so only a fast build can skip the read.
301
+ let body = null;
302
+ if (!text || !fast) {
303
+ const raw = readFileSync(filePath, 'utf8');
304
+ const extracted = extractFrontmatter(raw);
305
+ body = extracted.body;
306
+ // A file rewritten between the stat and the read no longer matches its stamp.
307
+ if (text && Buffer.byteLength(raw) !== stampSize(stamp)) text = null;
308
+ if (!text) {
309
+ text = extractDocText(extracted.frontmatter, body);
310
+ if (stamp) cache.set(relativePath, stamp, text);
311
+ }
312
+ }
313
+ const { parsedFrontmatter, fmWarnings, headingTitle, checklist, bodyLinks, hasCloseout } = text;
282
314
  const title = asString(parsedFrontmatter.title) ?? headingTitle ?? path.basename(filePath, '.md');
283
- const summary = asString(parsedFrontmatter.summary) ?? extractSummary(body) ?? null;
315
+ const summary = asString(parsedFrontmatter.summary) ?? text.bodySummary ?? null;
284
316
  // For terminal-status docs (archived / reference / deprecated by default),
285
317
  // skip the body-scrape and the "No current_state set" fallback when the user
286
318
  // didn't set `current_state:` in frontmatter explicitly. Body text on a
@@ -305,7 +337,7 @@ export function parseDocFile(filePath, config, opts = {}) {
305
337
  } else if (isTerminalDoc) {
306
338
  currentState = null;
307
339
  } else {
308
- const scraped = extractStatusSnapshot(body);
340
+ const scraped = text.bodyStatusSnapshot;
309
341
  if (scraped) {
310
342
  currentState = scraped;
311
343
  currentStateOrigin = 'body';
@@ -313,7 +345,7 @@ export function parseDocFile(filePath, config, opts = {}) {
313
345
  currentState = 'No current_state set';
314
346
  }
315
347
  }
316
- const nextStep = asString(parsedFrontmatter.next_step) ?? extractNextStep(body) ?? null;
348
+ const nextStep = asString(parsedFrontmatter.next_step) ?? text.bodyNextStep ?? null;
317
349
  // `blocked_by` is accepted as an alias for `blockers` since 0.39.3 — agents
318
350
  // filing tickets naturally reach for the JIRA/Linear name. If both are set,
319
351
  // they're merged (de-duped via normalizeBlockers → mergeUniqueStrings).
@@ -328,9 +360,6 @@ export function parseDocFile(filePath, config, opts = {}) {
328
360
  const domain = asString(parsedFrontmatter.domain) ?? null;
329
361
  const audience = asString(parsedFrontmatter.audience) ?? null;
330
362
  const executionMode = asString(parsedFrontmatter.execution_mode) ?? null;
331
- const checklist = extractChecklistCounts(body);
332
- const bodyLinks = extractBodyLinks(body);
333
- const hasCloseout = /^##\s+Closeout/m.test(body);
334
363
 
335
364
  // Dynamic reference field extraction. A leading `>` on a value (e.g.
336
365
  // `"> docs/audit-beyond-platform.md"`) marks that single ref as one-way —
@@ -1,7 +1,5 @@
1
- function maskRange(value, start, end) {
2
- return value.slice(0, start)
3
- + value.slice(start, end).replace(/[^\n]/g, 'x')
4
- + value.slice(end);
1
+ function mask(text) {
2
+ return text.includes('\n') ? text.replace(/[^\n]/g, 'x') : 'x'.repeat(text.length);
5
3
  }
6
4
 
7
5
  // Markdown code spans close only on a backtick run of the same length as their
@@ -11,13 +9,19 @@ function maskRange(value, start, end) {
11
9
  // rewriter survive: after an unmatched opener, nothing is treated as prose
12
10
  // until a compatible closer appears.
13
11
  export function maskInlineCodeLine(line, state = { run: null }) {
14
- const ranges = [];
12
+ if (!line.includes('`')) return state.run === null ? line : mask(line);
13
+
15
14
  const runs = [...line.matchAll(/`+/g)];
15
+ const findCloser = (from, length) => {
16
+ for (let i = from; i < runs.length; i++) if (runs[i][0].length === length) return i;
17
+ return -1;
18
+ };
19
+ const ranges = [];
16
20
  let index = 0;
17
21
 
18
22
  if (state.run !== null) {
19
- const closingIndex = runs.findIndex(candidate => candidate[0].length === state.run);
20
- if (closingIndex === -1) return maskRange(line, 0, line.length);
23
+ const closingIndex = findCloser(0, state.run);
24
+ if (closingIndex === -1) return mask(line);
21
25
  const closing = runs[closingIndex];
22
26
  ranges.push([0, closing.index + closing[0].length]);
23
27
  index = closingIndex + 1;
@@ -26,8 +30,7 @@ export function maskInlineCodeLine(line, state = { run: null }) {
26
30
 
27
31
  for (; index < runs.length; index++) {
28
32
  const opening = runs[index];
29
- const closingIndex = runs.findIndex((candidate, candidateIndex) =>
30
- candidateIndex > index && candidate[0].length === opening[0].length);
33
+ const closingIndex = findCloser(index + 1, opening[0].length);
31
34
  if (closingIndex === -1) {
32
35
  ranges.push([opening.index, line.length]);
33
36
  state.run = opening[0].length;
@@ -38,7 +41,15 @@ export function maskInlineCodeLine(line, state = { run: null }) {
38
41
  index = closingIndex;
39
42
  }
40
43
 
41
- return ranges.reduceRight((masked, [start, end]) => maskRange(masked, start, end), line);
44
+ // Ranges are ascending and disjoint, so one left-to-right pass builds the
45
+ // result instead of re-copying the whole line once per span.
46
+ let out = '';
47
+ let cursor = 0;
48
+ for (const [start, end] of ranges) {
49
+ out += line.slice(cursor, start) + mask(line.slice(start, end));
50
+ cursor = end;
51
+ }
52
+ return out + line.slice(cursor);
42
53
  }
43
54
 
44
55
  export function maskInlineCodeSpans(value) {
@@ -0,0 +1,99 @@
1
+ import { existsSync, readFileSync, statSync, writeFileSync, unlinkSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { commitRename } from './durable-rename.mjs';
5
+ import { readEnv, stateDir } from './naming.mjs';
6
+
7
+ // What every build of the index used to redo from scratch: read each document
8
+ // and pull its frontmatter, links, checklist and summary out of the body. None
9
+ // of that depends on the config or the clock, so it is kept per file under the
10
+ // state directory and reused while the file's size, times and inode are
11
+ // unchanged. Everything that does depend on them (terminal statuses, staleness,
12
+ // reference fields, validation) is still computed on every run.
13
+ //
14
+ // One line per document, `path \t stamp \t json`, so a hit parses only its own
15
+ // line and hands back fresh objects: nothing a caller does to a document can
16
+ // leak into what is saved. The cache is an optimisation and never an input:
17
+ // a missing, unreadable or foreign-version file is treated as empty, a failed
18
+ // write is dropped, and `RUNLIST_NO_PARSE_CACHE=1` turns it off.
19
+
20
+ const SCHEMA = 1;
21
+ const FILE_NAME = 'parse-cache';
22
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
+ const VERSION = (() => {
24
+ try { return JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version; }
25
+ catch { return 'unknown'; }
26
+ })();
27
+ const HEADER = JSON.stringify({ schema: SCHEMA, version: VERSION });
28
+
29
+ export function fileStamp(filePath) {
30
+ try {
31
+ const s = statSync(filePath, { bigint: true });
32
+ return `${s.size}:${s.mtimeNs}:${s.ctimeNs}:${s.ino}`;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ export function stampSize(stamp) {
39
+ return Number(stamp.slice(0, stamp.indexOf(':')));
40
+ }
41
+
42
+ export function openParseCache(config) {
43
+ if (readEnv('NO_PARSE_CACHE') === '1') return null;
44
+ const dir = stateDir(config.repoRoot);
45
+ // The state directory is created, and ignored by git, by `init` and the
46
+ // commands that own it; a repo without one gets no cache rather than a new
47
+ // untracked folder from a read-only command.
48
+ if (!existsSync(dir)) return null;
49
+ const cachePath = path.join(dir, FILE_NAME);
50
+ const lines = new Map();
51
+ try {
52
+ const text = readFileSync(cachePath, 'utf8');
53
+ const rows = text.split('\n');
54
+ if (rows[0] === HEADER) {
55
+ for (let i = 1; i < rows.length; i++) {
56
+ const row = rows[i];
57
+ const a = row.indexOf('\t');
58
+ const b = row.indexOf('\t', a + 1);
59
+ if (a < 0 || b < 0) continue;
60
+ lines.set(row.slice(0, a), { stamp: row.slice(a + 1, b), row });
61
+ }
62
+ }
63
+ } catch { /* no cache yet */ }
64
+
65
+ const seen = new Set();
66
+ let dirty = false;
67
+
68
+ return {
69
+ get(key, stamp) {
70
+ seen.add(key);
71
+ const entry = lines.get(key);
72
+ if (!entry || entry.stamp !== stamp) return null;
73
+ try { return JSON.parse(entry.row.slice(entry.row.indexOf('\t', entry.row.indexOf('\t') + 1) + 1)); }
74
+ catch { return null; }
75
+ },
76
+ set(key, stamp, value) {
77
+ seen.add(key);
78
+ if (/[\t\n]/.test(key)) return;
79
+ lines.set(key, { stamp, row: `${key}\t${stamp}\t${JSON.stringify(value)}` });
80
+ dirty = true;
81
+ },
82
+ save({ complete = true } = {}) {
83
+ if (complete) {
84
+ for (const key of lines.keys()) {
85
+ if (!seen.has(key)) { lines.delete(key); dirty = true; }
86
+ }
87
+ }
88
+ if (!dirty) return;
89
+ const temp = `${cachePath}.${process.pid}.${Date.now()}.tmp`;
90
+ try {
91
+ writeFileSync(temp, [HEADER, ...[...lines.values()].map(entry => entry.row)].join('\n'), 'utf8');
92
+ commitRename(temp, cachePath);
93
+ dirty = false;
94
+ } catch {
95
+ try { unlinkSync(temp); } catch { /* already gone */ }
96
+ }
97
+ },
98
+ };
99
+ }
package/src/pickup.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
2
+ import { existsSync, lstatSync, 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
5
  import os from 'node:os';
@@ -233,7 +233,48 @@ function validateBinding(record, identity, config) {
233
233
  return record;
234
234
  }
235
235
 
236
+ // Working out a plan's canonical identity lists every directory on its path,
237
+ // three times over, and listing plans asks it of every plan in the repo. A
238
+ // record can only belong to a plan whose file name it carries, so the names in
239
+ // the ownership folder rule most plans out first. The summary is rebuilt
240
+ // whenever the folder changes, and any record it cannot read turns the check
241
+ // off, so the full identity check still decides every case it could get wrong.
242
+ let ownershipNamesCache = null;
243
+
244
+ function ownershipNames(config) {
245
+ const dir = ownershipRoot(config);
246
+ let stamp;
247
+ try { stamp = `${dir}\0${statSync(dir, { bigint: true }).mtimeNs}`; }
248
+ catch { return new Set(); }
249
+ if (ownershipNamesCache?.stamp === stamp) return ownershipNamesCache.names;
250
+ let names = new Set();
251
+ try {
252
+ for (const entry of readdirSync(dir)) {
253
+ if (!entry.endsWith('.json')) continue;
254
+ const record = parseOwnership(readFileSync(path.join(dir, entry), 'utf8'), entry);
255
+ if (record.corrupt) { names = null; break; }
256
+ names.add(path.basename(record.canonicalPath).toLowerCase());
257
+ names.add(path.basename(record.plan).toLowerCase());
258
+ }
259
+ } catch { names = null; }
260
+ ownershipNamesCache = { stamp, names };
261
+ return names;
262
+ }
263
+
264
+ function mayHaveOwnershipRecord(absolutePath, config) {
265
+ const names = ownershipNames(config);
266
+ if (names === null || names.has(path.basename(absolutePath).toLowerCase())) return true;
267
+ try {
268
+ if (lstatSync(absolutePath).isSymbolicLink()) return true;
269
+ // A record whose fields were rewritten still sits at its identity's file
270
+ // name, and has to be found to be reported corrupt.
271
+ const guess = createHash('sha256').update(realpathSync(absolutePath)).digest('hex');
272
+ return existsSync(path.join(ownershipRoot(config), `${guess}.json`));
273
+ } catch { return true; }
274
+ }
275
+
236
276
  export function readPlanOwnership(repoPath, config) {
277
+ if (!mayHaveOwnershipRecord(path.resolve(config.repoRoot, repoPath), config)) return null;
237
278
  let identity;
238
279
  try { identity = canonicalPlanIdentity(path.resolve(config.repoRoot, repoPath), config); }
239
280
  catch { return null; }