dotmd-cli 0.58.0 → 0.60.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
@@ -13,7 +13,7 @@ const __dirname = path.dirname(__filename);
13
13
  const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
14
14
 
15
15
  const QUERY_FLAGS = new Set([
16
- '--type', '--status', '--keyword', '--owner', '--surface', '--module',
16
+ '--type', '--status', '--keyword', '--body', '--owner', '--surface', '--module',
17
17
  '--domain', '--audience', '--execution-mode', '--updated-since', '--limit',
18
18
  '--sort', '--group', '--all', '--include-archived', '--exclude-archived',
19
19
  '--stale', '--has-next-step', '--has-blockers', '--checklist-open', '--json',
@@ -28,6 +28,7 @@ const QUERY_VALUE_FLAGS = new Set([
28
28
  const FLAG_SPECS = {
29
29
  plans: { flags: QUERY_FLAGS, values: QUERY_VALUE_FLAGS, subcommands: new Set(['status']) },
30
30
  query: { flags: QUERY_FLAGS, values: QUERY_VALUE_FLAGS },
31
+ grep: { flags: QUERY_FLAGS, values: QUERY_VALUE_FLAGS },
31
32
  stale: { flags: QUERY_FLAGS, values: QUERY_VALUE_FLAGS },
32
33
  actionable: { flags: QUERY_FLAGS, values: QUERY_VALUE_FLAGS },
33
34
  list: { flags: new Set(['--json', '--verbose']), values: new Set() },
@@ -131,7 +132,7 @@ Common commands:
131
132
  agent-context Compact bounded JSON context for agents
132
133
  set <status> [file] Transition status (start work, finish, archive — all via target status)
133
134
  new <type> <name> Create plan/doc/prompt (pipe stdin or @path for body)
134
- use [<file-or-prompt-slug>] Open a doc by type: prompt → consume, plan → start, doc → read
135
+ use [<file-or-slug>] Open a doc by type: prompt → consume, plan → start, doc → read
135
136
  (no file: consume oldest pending prompt)
136
137
  archive <file> Close out a plan (status → archived, move, update refs)
137
138
 
@@ -153,10 +154,16 @@ Rules:
153
154
  commit-prompt deny git add/commit of a (often gitignored) saved prompt
154
155
  cat-prompt warn cat/less/head of a docs/prompts/*.md (use \`dotmd use\`)
155
156
  read-prompt warn Read tool on a saved prompt (use \`dotmd use\`)
156
- edit-status warn hand-edit of a \`status:\` field (use \`dotmd set\`)
157
+ edit-status deny CHANGING a \`status:\` line — via Edit/Write or in-place
158
+ stream editors (sed -i, perl -pi, awk -i inplace).
159
+ Use \`dotmd set <status> <file>\`. Edits that merely
160
+ carry an unchanged status: line as context don't fire.
157
161
 
158
- Every catch is appended to the cross-repo misuse log. Disable with DOTMD_GUARD=0.
159
- Read the log with \`dotmd misuse\`.`,
162
+ \`guard: { deny: false }\` in dotmd.config.mjs drops edit-status back to
163
+ warn-only. Every catch is appended to the cross-repo misuse log. Disable the
164
+ guard entirely with DOTMD_GUARD=0. Read the log with \`dotmd misuse\`; when one
165
+ rule trips ≥3× in 7 days in a repo, \`dotmd hud\` opens the next session there
166
+ with a one-line recap naming the habit to break.`,
160
167
 
161
168
  update: `dotmd update — update the dotmd CLI and the Claude Code plugin together
162
169
 
@@ -192,9 +199,10 @@ View & Query:
192
199
  context [--summarize] [--json] Full briefing (LLM-oriented; use --json --compact for bounded JSON)
193
200
  agent-context [--json] Compact bounded JSON context for agents
194
201
  focus [status] [--json] Detailed view for one status group
195
- query [filters] [--json] Filtered search (--status, --keyword, --stale, etc.)
202
+ query [filters] [--json] Filtered search (--status, --keyword, --body, --stale, etc.)
203
+ grep <term> Keyword search incl. document bodies (query --keyword --body --all)
196
204
  plans Live plans (excludes archived; --include-archived for all)
197
- use [<file-or-prompt-slug>] Open a doc by type: prompt → consume, plan → start, doc → read
205
+ use [<file-or-slug>] Open a doc by type: prompt → consume, plan → start, doc → read
198
206
  prompts [list|archive|new|hold] Prompt admin (list / archive / save / hold). Use \`dotmd use\` to consume.
199
207
  stale Stale docs (preset)
200
208
  actionable Docs with next steps (preset)
@@ -401,6 +409,7 @@ Filters:
401
409
  --type <t1,t2> Filter by type (plan, doc, research)
402
410
  --status <s1,s2> Filter by status (comma-separated)
403
411
  --keyword <term> Search title, summary, state, path
412
+ --body Extend --keyword into document bodies (lazy scan, shows matching-line excerpts)
404
413
  --module <name> Filter by module
405
414
  --surface <name> Filter by surface
406
415
  --domain <name> Filter by domain
@@ -420,6 +429,22 @@ Filters:
420
429
  --summarize-limit <n> Max docs to summarize (default: 5)
421
430
  --model <name> Model for AI summaries`,
422
431
 
432
+ grep: `dotmd grep <term> — keyword search across frontmatter AND document bodies
433
+
434
+ Alias for \`dotmd query --keyword <term> --body --all\`. Answers "which doc
435
+ discussed X?" with full doc cards (type, status, updated, path) plus 1-2
436
+ matching-line excerpts per body hit — instead of raw-grep's bare paths.
437
+
438
+ Bodies are read lazily: frontmatter filters run first, only surviving
439
+ candidates are opened. Archived docs are included but clearly labeled.
440
+
441
+ Composes with the usual query flags:
442
+ dotmd grep skipStale everything mentioning skipStale
443
+ dotmd grep retries --type plan only plans
444
+ dotmd grep retries --status active only active docs
445
+ dotmd grep retries --limit 5 cap results (default: unlimited)
446
+ dotmd grep retries --json machine-readable (bodyMatches per doc)`,
447
+
423
448
  ship: `dotmd ship [patch|minor|major] — regen + commit + bump in one step
424
449
 
425
450
  Bundles the release steps into a single command:
@@ -444,21 +469,28 @@ Network failures mid-bump (e.g. \`git push\` fails) leave the local
444
469
  commit + tag intact. Inspect with \`git log -1\` and rerun
445
470
  \`git push origin main --tags\` to recover.`,
446
471
 
447
- set: `dotmd set <status> <file> — change a document's status
472
+ set: `dotmd set <status> <file-or-slug> — change a document's status
448
473
 
449
474
  Writes the new status into the file's frontmatter. Nothing else — no plan
450
475
  checkout, no session locks.
451
476
  - target is an archive status → archive the file (move + ref update)
452
477
  - everything else → plain frontmatter status bump
453
478
 
479
+ <file-or-slug> resolves like \`dotmd use\`/\`archive\`: exact path first, then
480
+ a unique bare slug / basename across the doc roots (\`set paused auth-revamp\`).
481
+ Ambiguous slugs error with the candidate list instead of guessing.
482
+
454
483
  Options:
484
+ --note "<text>" Append the reason to \`## Version History\` in the
485
+ same call (creates the section if missing). Saves
486
+ the status-change + worklog-edit round-trip.
455
487
  --no-index Skip index regen (see \`dotmd archive --help\`).
456
488
  --show-files Append \`files: …\` footer.
457
489
  --dry-run, -n Preview without writing.
458
490
 
459
491
  Examples:
460
492
  dotmd set in-session docs/plans/x # mark a plan in-session
461
- dotmd set partial docs/plans/x # mark partial
493
+ dotmd set partial docs/plans/x --note "tail tracked in y.md"
462
494
  dotmd set archived docs/plans/x # archive a specific plan
463
495
 
464
496
  To open a plan (mark in-session AND print its body), use \`dotmd use <file>\`.`,
@@ -507,12 +539,19 @@ Options:
507
539
  --json Output errors and warnings as JSON (always full detail)
508
540
  --dry-run, -n Preview fixes without writing (with --fix)`,
509
541
 
510
- archive: `dotmd archive <file> — archive a document
542
+ archive: `dotmd archive <file-or-slug> — archive a document
511
543
 
512
544
  Sets status to 'archived', moves to the archive directory, auto-updates
513
545
  references in other docs, and regenerates the index.
514
546
 
547
+ <file-or-slug> resolves like \`dotmd use\`: an exact path wins, but a bare
548
+ slug / basename (e.g. \`archive resume-foo\`) falls back to a recursive
549
+ basename match under the doc roots. An ambiguous basename (the same name in
550
+ two places) errors with the candidate list instead of guessing.
551
+
515
552
  Options:
553
+ --note "<text>" Append \`Archived — <text>\` to \`## Version History\`
554
+ in the same call (creates the section if missing).
516
555
  --no-index Skip index regen. Use when multiple sessions are
517
556
  working concurrently and you want a path-limited
518
557
  commit that doesn't pull other agents' uncommitted
@@ -1535,6 +1574,23 @@ async function main() {
1535
1574
 
1536
1575
  if (command === 'focus') { runFocus(index, restArgs, config); return; }
1537
1576
  if (command === 'query') { runQuery(index, restArgs, config); return; }
1577
+ // `dotmd grep <term>` — ergonomic alias for `query --keyword <term> --body`.
1578
+ // Unlimited by default (grep semantics) unless the caller bounds it themselves.
1579
+ if (command === 'grep') {
1580
+ let term = null;
1581
+ const passthrough = [];
1582
+ for (let i = 0; i < restArgs.length; i++) {
1583
+ const arg = restArgs[i];
1584
+ if (QUERY_VALUE_FLAGS.has(arg)) { passthrough.push(arg, restArgs[i + 1]); i += 1; continue; }
1585
+ if (arg.startsWith('-') || term !== null) { passthrough.push(arg); continue; }
1586
+ term = arg;
1587
+ }
1588
+ if (!term) die('Usage: dotmd grep <term> [query flags]\n\nSearches frontmatter fields AND document bodies; alias for `dotmd query --keyword <term> --body --all`.');
1589
+ const defaults = ['--keyword', term, '--body'];
1590
+ if (!passthrough.includes('--limit') && !passthrough.includes('--all')) defaults.push('--all');
1591
+ runQuery(index, [...defaults, ...passthrough], config);
1592
+ return;
1593
+ }
1538
1594
  if (command === 'modules' || command === 'module') {
1539
1595
  // D3: default `--type plan` when the user didn't pass --type explicitly.
1540
1596
  // applyIndexFilters already narrowed by typeArg if it was set; if not, the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.58.0",
3
+ "version": "0.60.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",
package/src/commands.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  // templates points at a real command.
5
5
  export const KNOWN_COMMANDS = [
6
6
  'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context', 'agent-context', 'hud',
7
- 'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'status', 'set', 'use', 'next', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor', 'runlist',
7
+ 'focus', 'query', 'grep', 'plans', 'prompts', 'stale', 'actionable', 'index', 'status', 'set', 'use', 'next', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor', 'runlist',
8
8
  'unblocks', 'health', 'glossary', 'modules', 'module',
9
9
  'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
10
10
  'watch', 'diff', 'new', 'init', 'completions', 'statuses', 'journal',
@@ -1,7 +1,7 @@
1
1
  import { die } from './util.mjs';
2
2
 
3
3
  const COMMANDS = [
4
- 'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'unblocks', 'health', 'glossary', 'briefing', 'context', 'focus', 'query',
4
+ 'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'unblocks', 'health', 'glossary', 'briefing', 'context', 'focus', 'query', 'grep',
5
5
  'plans', 'stale', 'actionable', 'index', 'status', 'set', 'archive', 'bulk', 'touch', 'doctor', 'lint', 'rename', 'migrate',
6
6
  'fix-refs', 'notion', 'export', 'summary', 'watch', 'diff', 'init', 'new', 'completions', 'journal',
7
7
  ];
@@ -9,10 +9,12 @@ const COMMANDS = [
9
9
  const GLOBAL_FLAGS = ['--config', '--dry-run', '--verbose', '--root', '--type', '--help', '--version'];
10
10
 
11
11
  const COMMAND_FLAGS = {
12
- query: ['--type', '--status', '--keyword', '--module', '--surface', '--domain', '--owner',
12
+ query: ['--type', '--status', '--keyword', '--body', '--module', '--surface', '--domain', '--owner',
13
13
  '--updated-since', '--stale', '--has-next-step', '--has-blockers',
14
14
  '--checklist-open', '--sort', '--limit', '--all', '--git', '--json',
15
15
  '--summarize', '--summarize-limit', '--model'],
16
+ grep: ['--type', '--status', '--limit', '--all', '--json', '--sort',
17
+ '--module', '--surface', '--domain', '--owner'],
16
18
  index: ['--write'],
17
19
  list: ['--verbose', '--json'],
18
20
  coverage: ['--json'],
package/src/config.mjs CHANGED
@@ -118,6 +118,10 @@ const DEFAULTS = {
118
118
  // and users who want usage observability flip this on (or set DOTMD_JOURNAL=1).
119
119
  journal: false,
120
120
 
121
+ // PreToolUse guard behavior. `deny: false` drops the status-edit rules from
122
+ // deny (block the tool call) back to warn-only teaching context.
123
+ guard: { deny: true },
124
+
121
125
  presets: {
122
126
  stale: ['--status', 'active,ready,planned,blocked,scoping', '--stale', '--sort', 'updated', '--all'],
123
127
  actionable: ['--status', 'active,ready', '--has-next-step', '--sort', 'updated', '--all'],
@@ -533,6 +537,7 @@ export async function resolveConfig(cwd, explicitConfigPath) {
533
537
  referenceFields: config.referenceFields,
534
538
  presets: config.presets,
535
539
  journal: config.journal === true,
540
+ guard: { deny: config.guard?.deny !== false },
536
541
  hooks,
537
542
  configWarnings,
538
543
  };
package/src/deps.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { buildGraph } from './graph.mjs';
3
- import { buildIndex } from './index.mjs';
4
- import { resolveDocPath, toSlug, toRepoPath, die, warn } from './util.mjs';
3
+ import { buildIndex, resolveDocArg } from './index.mjs';
4
+ import { toRepoPath, die } from './util.mjs';
5
5
  import { bold, dim, green } from './color.mjs';
6
6
 
7
7
  export function runDeps(argv, config) {
@@ -38,8 +38,7 @@ export function runDeps(argv, config) {
38
38
 
39
39
  if (input) {
40
40
  // Tree view for a specific doc
41
- const filePath = resolveDocPath(input, config);
42
- if (!filePath) die(`File not found: ${input}`);
41
+ const filePath = resolveDocArg(input, config);
43
42
  const repoPath = toRepoPath(filePath, config.repoRoot);
44
43
  const doc = docByPath.get(repoPath);
45
44
  if (!doc) die(`Doc not in index: ${repoPath}`);
@@ -255,8 +254,7 @@ export function runUnblocks(argv, config) {
255
254
  const json = argv.includes('--json');
256
255
  if (!input) die('Usage: dotmd unblocks <file>');
257
256
 
258
- const filePath = resolveDocPath(input, config);
259
- if (!filePath) die(`File not found: ${input}`);
257
+ const filePath = resolveDocArg(input, config);
260
258
  const repoPath = toRepoPath(filePath, config.repoRoot);
261
259
 
262
260
  const index = buildIndex(config);
package/src/diff.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
3
- import { asString, toRepoPath, resolveDocPath, die, warn } from './util.mjs';
3
+ import { asString, toRepoPath, die, warn } from './util.mjs';
4
4
  import { gitDiffSince } from './git.mjs';
5
- import { buildIndex } from './index.mjs';
5
+ import { buildIndex, resolveDocArg } from './index.mjs';
6
6
  import { summarizeDiffText, DEFAULT_MODEL } from './ai.mjs';
7
- import { bold, dim, green } from './color.mjs';
7
+ import { bold, dim } from './color.mjs';
8
8
 
9
9
  export function runDiff(argv, config) {
10
10
  // Parse flags
@@ -24,10 +24,7 @@ export function runDiff(argv, config) {
24
24
 
25
25
  if (file) {
26
26
  // Single file mode
27
- const filePath = resolveDocPath(file, config);
28
- if (!filePath) {
29
- die(`File not found: ${file}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`);
30
- }
27
+ const filePath = resolveDocArg(file, config);
31
28
 
32
29
  const raw = readFileSync(filePath, 'utf8');
33
30
  const { frontmatter } = extractFrontmatter(raw);
package/src/guard.mjs CHANGED
@@ -16,12 +16,14 @@ const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'),
16
16
  //
17
17
  // Two decision levels:
18
18
  // 'deny' — block the call and feed the reason back to the model. Reserved for
19
- // moves that are guaranteed-wrong (committing a gitignored prompt — it
20
- // would fail anyway).
19
+ // moves that are guaranteed-wrong: committing a gitignored prompt (it
20
+ // would fail anyway) and hand-editing a `status:` field (`dotmd set`
21
+ // is a complete substitute; config `guard: { deny: false }` drops the
22
+ // status rules back to warn).
21
23
  // 'warn' — let the call proceed but inject teaching context so the agent learns
22
24
  // the dotmd-native command. Used for soft mistakes (cat/Read of a
23
- // prompt, hand-editing a status field) where a human might legitimately
24
- // do it; we nudge rather than block.
25
+ // prompt) where a human might legitimately do it; we nudge rather
26
+ // than block.
25
27
 
26
28
  const SHELL_READERS = new Set(['cat', 'less', 'more', 'head', 'tail', 'bat', 'view', 'open']);
27
29
 
@@ -56,6 +58,34 @@ function shellTokens(command) {
56
58
  return command.split(/\s+/).map(t => t.replace(/^['"]|['"]$/g, '')).filter(Boolean);
57
59
  }
58
60
 
61
+ // Decision level for the status-edit rules. Hand-editing `status:` has no
62
+ // legitimate variant — `dotmd set` is a complete substitute — so it denies by
63
+ // default. `guard: { deny: false }` in config drops it back to warn-only.
64
+ function editStatusDecision(config) {
65
+ return config?.guard?.deny === false ? 'warn' : 'deny';
66
+ }
67
+
68
+ function editStatusResult(target, config, detail) {
69
+ return {
70
+ decision: editStatusDecision(config),
71
+ rule: 'edit-status',
72
+ detail,
73
+ reason:
74
+ `Looks like a hand-edit of the \`status:\` field in ${target}. Use \`dotmd set <status> ${target}\` instead — ` +
75
+ `it validates the status against this doc's type, runs lifecycle hooks, fixes refs, and keeps the index in sync. Direct edits skip all of that.`,
76
+ };
77
+ }
78
+
79
+ // In-place stream editors (`sed -i`, `perl -pi`, `awk -i inplace`) are the
80
+ // shell-side bypass of the Edit-tool status guard. Only the command text
81
+ // before any heredoc marker is scanned — heredoc bodies are document content
82
+ // (often prose *describing* these rules), not commands.
83
+ const STREAM_EDITOR_INPLACE = [
84
+ /\bsed\b[^|;&<>]*\s-i/,
85
+ /\bperl\b[^|;&<>]*\s-[a-zA-Z]*i/,
86
+ /\bg?awk\b[^|;&<>]*\binplace\b/,
87
+ ];
88
+
59
89
  function evalBash(command, config, isIgnored) {
60
90
  const tokens = shellTokens(command);
61
91
  const promptTokens = tokens.filter(isPromptPath);
@@ -94,6 +124,16 @@ function evalBash(command, config, isIgnored) {
94
124
  }
95
125
  }
96
126
 
127
+ // Rule C — in-place stream-editing `status:` in a managed doc. Same wrong-move
128
+ // as the Edit-tool rule, reached via the shell.
129
+ const beforeHeredoc = command.split(/<<-?\s*['"]?\w/)[0];
130
+ if (/status/.test(beforeHeredoc) && STREAM_EDITOR_INPLACE.some(re => re.test(beforeHeredoc))) {
131
+ const managed = shellTokens(beforeHeredoc).filter(t => isManagedDoc(t, config));
132
+ if (managed.length) {
133
+ return editStatusResult(managed[0], config, command);
134
+ }
135
+ }
136
+
97
137
  return null;
98
138
  }
99
139
 
@@ -108,23 +148,39 @@ function evalRead(filePath) {
108
148
  };
109
149
  }
110
150
 
111
- const STATUS_LINE = /^\s*status\s*:/m;
151
+ // Every `status:` line in a snippet, normalized for comparison.
152
+ function statusLines(s) {
153
+ if (typeof s !== 'string') return [];
154
+ return (s.match(/^[ \t]*status[ \t]*:[^\n]*/gm) ?? []).map(l => l.trim());
155
+ }
112
156
 
113
- function evalEdit(input, config) {
157
+ // Only fire when the edit actually CHANGES a `status:` line. An edit whose
158
+ // old/new strings both carry the same `status:` line is using it as anchor
159
+ // context (e.g. adding a `summary:` field above it) — warning on those taught
160
+ // sessions to ignore the rule (the health-repo repeat offenses were exactly
161
+ // this false positive).
162
+ function evalEdit(input, config, deps = {}) {
114
163
  const filePath = input?.file_path;
115
164
  if (!isManagedDoc(filePath, config)) return null;
116
- // Only fire when the edit actually touches a `status:` frontmatter line.
117
- const candidates = [input?.new_string, input?.content, input?.new_str]
118
- .filter(s => typeof s === 'string');
119
- if (!candidates.some(s => STATUS_LINE.test(s))) return null;
120
- return {
121
- decision: 'warn',
122
- rule: 'edit-status',
123
- detail: filePath,
124
- reason:
125
- `Looks like a hand-edit of the \`status:\` field in ${filePath}. Use \`dotmd set <status> ${filePath}\` instead — ` +
126
- `it validates the status against this doc's type, runs lifecycle hooks, fixes refs, and keeps the index in sync. Direct edits skip all of that.`,
127
- };
165
+
166
+ const pairs = [];
167
+ const newStr = input?.new_string ?? input?.new_str;
168
+ if (typeof newStr === 'string') pairs.push([input?.old_string ?? input?.old_str ?? '', newStr]);
169
+ for (const e of Array.isArray(input?.edits) ? input.edits : []) {
170
+ if (typeof e?.new_string === 'string') pairs.push([e.old_string ?? '', e.new_string]);
171
+ }
172
+ if (typeof input?.content === 'string') {
173
+ // Write replaces the whole file — diff against what's on disk. An
174
+ // unreadable/missing target is doc creation, not a status edit.
175
+ const readFile = deps.readFile ?? ((p) => readFileSync(p, 'utf8'));
176
+ let existing;
177
+ try { existing = readFile(filePath); } catch { existing = null; }
178
+ if (typeof existing === 'string') pairs.push([existing, input.content]);
179
+ }
180
+
181
+ const changed = pairs.some(([oldS, newS]) => statusLines(oldS).join('\n') !== statusLines(newS).join('\n'));
182
+ if (!changed) return null;
183
+ return editStatusResult(filePath, config, filePath);
128
184
  }
129
185
 
130
186
  // Pure evaluation — `deps.isIgnored(path) -> bool` is injected so tests don't
@@ -137,7 +193,7 @@ export function evaluateGuard(payload, config, deps = {}) {
137
193
 
138
194
  if (tool === 'Bash') return evalBash(input.command || '', config, isIgnored);
139
195
  if (tool === 'Read') return evalRead(input.file_path || '');
140
- if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit') return evalEdit(input, config);
196
+ if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit') return evalEdit(input, config, deps);
141
197
  return null;
142
198
  }
143
199
 
package/src/hud.mjs CHANGED
@@ -6,7 +6,7 @@ import { asString, toRepoPath, currentSessionId } from './util.mjs';
6
6
  import { dim, yellow } from './color.mjs';
7
7
  import { buildIndex } from './index.mjs';
8
8
  import { refreshStaleSlashCommands } from './claude-commands.mjs';
9
- import { readJournalEntries, journalFilePath } from './journal.mjs';
9
+ import { readJournalEntries, journalFilePath, readMisuseEntries } from './journal.mjs';
10
10
  import { compareVersions } from './update.mjs';
11
11
 
12
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -191,6 +191,39 @@ export function buildJournalSections(config, now = Date.now()) {
191
191
  return { previousSelf, fleet, recentRejections };
192
192
  }
193
193
 
194
+ // Misuse recap: when sessions in THIS repo keep tripping the same guard rule,
195
+ // say so once at SessionStart — the shipped self-correcting-hints pattern
196
+ // pointed at repeat offenses. One line, only for the top rule, only past the
197
+ // threshold; silent otherwise.
198
+ const MISUSE_RECAP_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
199
+ const MISUSE_RECAP_THRESHOLD = 3;
200
+
201
+ const MISUSE_CORRECTIONS = {
202
+ 'edit-status': 'never hand-edit `status:`; use `dotmd set <status> <file>`',
203
+ 'cat-prompt': 'read saved prompts with `dotmd use <file>`',
204
+ 'read-prompt': 'read saved prompts with `dotmd use <file>`',
205
+ 'commit-prompt': 'saved prompts are session-local; never git add/commit them',
206
+ };
207
+
208
+ export function buildMisuseRecap(config, now = Date.now()) {
209
+ let entries;
210
+ try { entries = readMisuseEntries(); } catch { return null; }
211
+ if (!entries.length) return null;
212
+ const cutoff = now - MISUSE_RECAP_WINDOW_MS;
213
+ const counts = new Map();
214
+ for (const e of entries) {
215
+ if (!e?.rule || (e.repo || '') !== config.repoRoot) continue;
216
+ const t = new Date(e.ts).getTime();
217
+ if (!Number.isFinite(t) || t < cutoff) continue;
218
+ counts.set(e.rule, (counts.get(e.rule) ?? 0) + 1);
219
+ }
220
+ const top = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
221
+ if (!top || top[1] < MISUSE_RECAP_THRESHOLD) return null;
222
+ const [rule, count] = top;
223
+ const fix = MISUSE_CORRECTIONS[rule] ?? 'see `dotmd misuse`';
224
+ return `sessions here tripped ${rule} ${count}× this week — ${fix}`;
225
+ }
226
+
194
227
  export function buildHud(config) {
195
228
  const prompts = findActionablePrompts(config);
196
229
 
@@ -212,8 +245,9 @@ export function buildHud(config) {
212
245
  } catch { /* swallow — bad config shouldn't break the SessionStart hook */ }
213
246
 
214
247
  const { previousSelf, fleet, recentRejections } = buildJournalSections(config);
248
+ const misuseRecap = buildMisuseRecap(config);
215
249
 
216
- return { prompts, errors, previousSelf, fleet, recentRejections };
250
+ return { prompts, errors, previousSelf, fleet, recentRejections, misuseRecap };
217
251
  }
218
252
 
219
253
  // Subagent primer: a spawned subagent (Explore, Plan, general-purpose) starts
@@ -286,7 +320,10 @@ export function runHud(argv, config) {
286
320
  // for state that belongs inside its own command. Each of those signals lives
287
321
  // in its proper command (`plans`, `prompts`, `check`) and stays available via
288
322
  // `dotmd hud --json` for programmatic callers. The hook's job is purely to
289
- // teach the verbs, never to report status.
323
+ // teach the verbs, never to report status. The misuse recap below is the one
324
+ // exception because it IS teaching: a repeat-offense rule means the primer
325
+ // alone isn't landing, so name the specific habit to break.
290
326
  process.stdout.write(dim('dotmd: plans|briefing set <status> [<file>] new <type> <slug> use [<file>] archive <file> (use [no-arg] → oldest pending prompt)') + '\n');
327
+ if (hud.misuseRecap) process.stdout.write(yellow(`[dotmd] ${hud.misuseRecap}`) + '\n');
291
328
  if (drift) process.stdout.write(yellow(drift) + '\n');
292
329
  }
package/src/index.mjs CHANGED
@@ -2,7 +2,7 @@ import { readdirSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
4
  import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts, extractBodyLinks } from './extractors.mjs';
5
- import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath, warn } from './util.mjs';
5
+ import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath, warn, die, resolveDocPath, suggestCandidates } from './util.mjs';
6
6
  import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalReferences, checkGitStaleness, checkRunlistBackPointers, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate, enrichRefErrorSuggestions } from './validate.mjs';
7
7
  import { checkIndex } from './index-file.mjs';
8
8
  import { checkClaudeCommands } from './claude-commands.mjs';
@@ -152,6 +152,54 @@ export function collectDocFiles(config) {
152
152
  return files.sort((a, b) => a.localeCompare(b));
153
153
  }
154
154
 
155
+ // Shared resolver for CLI file arguments — the single path every file-taking
156
+ // verb (`use`, `set`, `archive`, `touch`, `rename`, …) funnels through so
157
+ // bare slugs behave identically everywhere. Tries, in order: exact path
158
+ // (the resolveDocPath fast path), `<input>.md`, then a unique basename match
159
+ // across all doc roots. An ambiguous basename dies listing the candidates
160
+ // rather than guessing — a wrong auto-resolved mutation is worse than a
161
+ // retry. A full miss dies with did-you-mean suggestions drawn from the doc
162
+ // corpus; pass { dieOnMiss: false } to get null instead and keep a custom
163
+ // fallback at the call site.
164
+ export function resolveDocArg(input, config, { dieOnMiss = true } = {}) {
165
+ if (!input) return null;
166
+ const direct = resolveDocPath(input, config);
167
+ if (direct) return direct;
168
+ if (!input.endsWith('.md')) {
169
+ const withExt = resolveDocPath(input + '.md', config);
170
+ if (withExt) return withExt;
171
+ }
172
+
173
+ const slug = input.replace(/\.md$/, '');
174
+ const files = collectDocFiles(config);
175
+ const byBasename = files.filter(f => path.basename(f, '.md') === slug);
176
+ if (byBasename.length === 1) return byBasename[0];
177
+ if (byBasename.length > 1) {
178
+ die(`Multiple docs match "${input}" by basename:\n${byBasename.map(f => ' ' + toRepoPath(f, config.repoRoot)).join('\n')}`);
179
+ }
180
+
181
+ if (!dieOnMiss) return null;
182
+ die(docArgMissMessage(input, config, files));
183
+ }
184
+
185
+ // `File not found` + the searched roots + up-to-3 did-you-mean candidates
186
+ // matched on basename and printed as repo-relative paths. Exported so verbs
187
+ // with their own resolution (e.g. interactive pickers) can reuse the message.
188
+ export function docArgMissMessage(input, config, files = collectDocFiles(config)) {
189
+ const roots = config.docsRoots || [config.docsRoot];
190
+ const searched = [toRepoPath(config.repoRoot, config.repoRoot) || '.', ...roots.map(r => toRepoPath(r, config.repoRoot))].join(', ');
191
+ const slug = String(input).split('/').pop().replace(/\.md$/, '');
192
+ const pathsByBase = new Map();
193
+ for (const f of files) {
194
+ const base = path.basename(f, '.md');
195
+ if (!pathsByBase.has(base)) pathsByBase.set(base, toRepoPath(f, config.repoRoot));
196
+ }
197
+ const hits = suggestCandidates(slug, [...pathsByBase.keys()]);
198
+ let msg = `File not found: ${input}\nSearched: ${searched}`;
199
+ if (hits.length) msg += `\nDid you mean: ${hits.map(b => pathsByBase.get(b)).join(', ')}?`;
200
+ return msg;
201
+ }
202
+
155
203
  function walkMarkdownFiles(directory, files, excludedDirs, skipPaths, seen = new Set()) {
156
204
  let entries;
157
205
  try {
package/src/lifecycle.mjs CHANGED
@@ -2,8 +2,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
4
  import { asString, toRepoPath, die, warn, resolveDocPath, resolveRefPath, escapeRegex, nowIso, suggestCandidates, emitFilesFooter, isArchivedPath } from './util.mjs';
5
- import { gitMv, getGitLastModified, getGitLastModifiedBatch } from './git.mjs';
6
- import { buildIndex, collectDocFiles } from './index.mjs';
5
+ import { gitMv, getGitLastModifiedBatch } from './git.mjs';
6
+ import { buildIndex, collectDocFiles, resolveDocArg } from './index.mjs';
7
7
  import { renderIndexFile, writeIndex } from './index-file.mjs';
8
8
  import { green, dim } from './color.mjs';
9
9
  import { isInteractive, promptChoice } from './prompt.mjs';
@@ -128,6 +128,13 @@ export async function runStatus(argv, config, opts = {}) {
128
128
  const noIndex = argv.includes('--no-index') || opts.noIndex;
129
129
  const showFiles = argv.includes('--show-files') || opts.showFiles;
130
130
  argv = argv.filter(a => a !== '--no-index' && a !== '--show-files');
131
+ let note = opts.note ?? null;
132
+ const noteIdx = argv.indexOf('--note');
133
+ if (noteIdx !== -1) {
134
+ note = note ?? argv[noteIdx + 1] ?? null;
135
+ if (!note || note.startsWith('--')) die('--note requires a value: --note "what changed and why"');
136
+ argv = argv.filter((_, i) => i !== noteIdx && i !== noteIdx + 1);
137
+ }
131
138
  const input = argv[0];
132
139
  let newStatus = argv[1];
133
140
 
@@ -137,8 +144,7 @@ export async function runStatus(argv, config, opts = {}) {
137
144
 
138
145
  if (!input) { die('Usage: dotmd status <file> <new-status>'); }
139
146
 
140
- const filePath = resolveDocPath(input, config);
141
- if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); }
147
+ const filePath = resolveDocArg(input, config);
142
148
 
143
149
  // Determine type-specific or root-specific valid statuses
144
150
  const raw = readFileSync(filePath, 'utf8');
@@ -233,12 +239,18 @@ export async function runStatus(argv, config, opts = {}) {
233
239
  if ((isArchiving || isUnarchiving || isFiling || isUnfiling) && config.indexPath) {
234
240
  process.stdout.write(`${prefix} Would regenerate index\n`);
235
241
  }
242
+ if (note) {
243
+ process.stdout.write(`${prefix} Would append Version History: - **${today}** Status: ${oldStatus ?? 'unknown'} → ${newStatus} — ${note}\n`);
244
+ }
236
245
  process.stdout.write(`${prefix} ${toRepoPath(finalPath, config.repoRoot)}: ${oldStatus ?? 'unknown'} → ${newStatus}\n`);
237
246
  return;
238
247
  }
239
248
 
240
249
  updateFrontmatter(filePath, { status: newStatus, updated: today });
241
- appendVersionHistory(filePath, `Status: ${oldStatus ?? 'unknown'} → ${newStatus}.`);
250
+ const transition = `Status: ${oldStatus ?? 'unknown'} → ${newStatus}`;
251
+ // A --note must land even when the doc has no Version History section yet;
252
+ // the plain transition entry stays best-effort (bare docs skip it).
253
+ appendVersionHistory(filePath, note ? `${transition} — ${note}` : `${transition}.`, { createSection: Boolean(note) });
242
254
 
243
255
  if (isArchiving) {
244
256
  mkdirSync(archiveDir, { recursive: true });
@@ -327,8 +339,7 @@ export async function startPlan(argv, config, opts = {}) {
327
339
  input = candidates[idx].path;
328
340
  }
329
341
 
330
- const filePath = resolveDocPath(input, config);
331
- if (!filePath) die(`File not found: ${input}`);
342
+ const filePath = resolveDocArg(input, config);
332
343
 
333
344
  const raw = readFileSync(filePath, 'utf8');
334
345
  const { frontmatter: fmRaw, body } = extractFrontmatter(raw);
@@ -397,12 +408,18 @@ export function runArchive(argv, config, opts = {}) {
397
408
  const showFiles = argv.includes('--show-files') || opts.showFiles;
398
409
  const closeoutTemplate = argv.includes('--closeout-template');
399
410
  argv = argv.filter(a => a !== '--no-index' && a !== '--show-files' && a !== '--closeout-template');
411
+ let note = opts.note ?? null;
412
+ const noteIdx = argv.indexOf('--note');
413
+ if (noteIdx !== -1) {
414
+ note = note ?? argv[noteIdx + 1] ?? null;
415
+ if (!note || note.startsWith('--')) die('--note requires a value: --note "what shipped / why closed"');
416
+ argv = argv.filter((_, i) => i !== noteIdx && i !== noteIdx + 1);
417
+ }
400
418
  const input = argv[0];
401
419
 
402
420
  if (!input) { die('Usage: dotmd archive <file>'); }
403
421
 
404
- const filePath = resolveDocPath(input, config);
405
- if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); }
422
+ const filePath = resolveDocArg(input, config);
406
423
 
407
424
  const archiveFileRoot = findFileRoot(filePath, config);
408
425
  const relFromRoot = path.relative(archiveFileRoot, filePath);
@@ -433,7 +450,8 @@ export function runArchive(argv, config, opts = {}) {
433
450
  return;
434
451
  }
435
452
  updateFrontmatter(filePath, { status: 'archived', updated: today });
436
- appendVersionHistory(filePath, `Archived (frontmatter healed in place from \`${oldStatus}\`).`);
453
+ const healEntry = `Archived (frontmatter healed in place from \`${oldStatus}\`)${note ? ` — ${note}` : '.'}`;
454
+ appendVersionHistory(filePath, healEntry, { createSection: Boolean(note) });
437
455
  if (!noIndex) regenIndex(config);
438
456
  out.write(`${green('✓ Healed')}: ${repoPathHeal} (${oldStatus} → archived; file already under \`${config.archiveDir}/\`)\n`);
439
457
  const touched = [repoPathHeal];
@@ -465,6 +483,9 @@ export function runArchive(argv, config, opts = {}) {
465
483
  out.write(`${prefix} \`## Closeout\` section already present — no injection\n`);
466
484
  }
467
485
  out.write(`${prefix} Would update frontmatter: status: ${oldStatus} → archived, updated: ${today}\n`);
486
+ if (note) {
487
+ out.write(`${prefix} Would append Version History: - **${today}** Archived — ${note}\n`);
488
+ }
468
489
  out.write(`${prefix} Would move: ${oldRepoPath} → ${newRepoPath}\n`);
469
490
  if (config.indexPath && !noIndex) out.write(`${prefix} Would regenerate index\n`);
470
491
  if (config.indexPath && noIndex) out.write(`${prefix} Would skip index regen (--no-index)\n`);
@@ -487,7 +508,7 @@ export function runArchive(argv, config, opts = {}) {
487
508
  }
488
509
 
489
510
  updateFrontmatter(filePath, { status: 'archived', updated: today });
490
- appendVersionHistory(filePath, 'Archived.');
511
+ appendVersionHistory(filePath, note ? `Archived — ${note}` : 'Archived.', { createSection: Boolean(note) });
491
512
 
492
513
  mkdirSync(targetDir, { recursive: true });
493
514
 
@@ -548,6 +569,13 @@ export async function runSet(argv, config, opts = {}) {
548
569
  const noIndex = argv.includes('--no-index');
549
570
  const showFiles = argv.includes('--show-files');
550
571
  argv = argv.filter(a => a !== '--no-index' && a !== '--show-files');
572
+ let note = opts.note ?? null;
573
+ const noteIdx = argv.indexOf('--note');
574
+ if (noteIdx !== -1) {
575
+ note = note ?? argv[noteIdx + 1] ?? null;
576
+ if (!note || note.startsWith('--')) die('--note requires a value: --note "what changed and why"');
577
+ argv = argv.filter((_, i) => i !== noteIdx && i !== noteIdx + 1);
578
+ }
551
579
 
552
580
  const newStatus = argv[0];
553
581
  const input = argv[1];
@@ -555,8 +583,7 @@ export async function runSet(argv, config, opts = {}) {
555
583
  if (!newStatus) die('Usage: dotmd set <status> <path>');
556
584
  if (!input) die('Usage: dotmd set <status> <path>');
557
585
 
558
- const filePath = resolveDocPath(input, config);
559
- if (!filePath) die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`);
586
+ const filePath = resolveDocArg(input, config);
560
587
 
561
588
  const inArchive = isArchivedPath(toRepoPath(filePath, config.repoRoot), config);
562
589
 
@@ -564,13 +591,30 @@ export async function runSet(argv, config, opts = {}) {
564
591
  const archiveArgs = [filePath];
565
592
  if (noIndex) archiveArgs.push('--no-index');
566
593
  if (showFiles) archiveArgs.push('--show-files');
567
- return runArchive(archiveArgs, config, { dryRun });
594
+ return runArchive(archiveArgs, config, { dryRun, note });
595
+ }
596
+
597
+ // `partial` promises a successor tracking the deferred tail. When neither a
598
+ // --note nor any doc reference exists to point at it, remind — advisory
599
+ // only, computed before the transition (filing may move the file).
600
+ let partialReminder = false;
601
+ if (newStatus === 'partial' && !note) {
602
+ try {
603
+ const { frontmatter: fmRaw, body } = extractFrontmatter(readFileSync(filePath, 'utf8'));
604
+ const related = parseSimpleFrontmatter(fmRaw).related_plans;
605
+ const hasRelated = Array.isArray(related) ? related.length > 0 : Boolean(asString(related)?.trim());
606
+ partialReminder = !hasRelated && !/[\w./-]+\.md\b/.test(body);
607
+ } catch { /* advisory only */ }
568
608
  }
569
609
 
570
610
  const statusArgs = [filePath, newStatus];
571
611
  if (noIndex) statusArgs.push('--no-index');
572
612
  if (showFiles) statusArgs.push('--show-files');
573
- await runStatus(statusArgs, config, { dryRun, suppressDeprecation: true });
613
+ await runStatus(statusArgs, config, { dryRun, suppressDeprecation: true, note });
614
+
615
+ if (partialReminder && !dryRun) {
616
+ warn('partial usually references the successor plan tracking the tail — add a link to the body, or rerun with --note "tail tracked in <plan>".');
617
+ }
574
618
  }
575
619
 
576
620
  export function runBulkArchive(argv, config, opts = {}) {
@@ -647,8 +691,7 @@ export function runTouch(argv, config, opts = {}) {
647
691
 
648
692
  // --git mode: bulk-sync frontmatter dates from git history
649
693
  if (useGit) {
650
- const allFiles = input ? [resolveDocPath(input, config)].filter(Boolean) : collectDocFiles(config);
651
- if (input && allFiles.length === 0) { die(`File not found: ${input}`); }
694
+ const allFiles = input ? [resolveDocArg(input, config)] : collectDocFiles(config);
652
695
 
653
696
  const prefix = dryRun ? dim('[dry-run] ') : '';
654
697
  let synced = 0;
@@ -691,8 +734,7 @@ export function runTouch(argv, config, opts = {}) {
691
734
 
692
735
  if (!input) { die('Usage: dotmd touch <file>\n dotmd touch --git Bulk-sync dates from git history'); }
693
736
 
694
- const filePath = resolveDocPath(input, config);
695
- if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); }
737
+ const filePath = resolveDocArg(input, config);
696
738
 
697
739
  const today = nowIso();
698
740
 
@@ -839,7 +881,7 @@ function countRefsToUpdate(oldPath, newPath, config) {
839
881
  // Newest-first ordering: inserted at the top of the section, right after the
840
882
  // heading + blank-line gap. If the section is missing, this is a silent no-op
841
883
  // — never auto-creates the section (don't surprise users on old plans/docs).
842
- export function appendVersionHistory(filePath, entry) {
884
+ export function appendVersionHistory(filePath, entry, { createSection = false } = {}) {
843
885
  let raw;
844
886
  try { raw = readFileSync(filePath, 'utf8'); } catch { return false; }
845
887
  if (!raw.startsWith('---\n')) return false;
@@ -849,10 +891,18 @@ export function appendVersionHistory(filePath, entry) {
849
891
  const frontmatter = raw.slice(4, endMarker);
850
892
  const body = raw.slice(endMarker + 5);
851
893
 
852
- const vh = findSection(walkSections(body), 'Version History');
853
- if (!vh) return false;
854
-
855
894
  const bullet = `- **${nowIso()}** ${entry}`;
895
+
896
+ const vh = findSection(walkSections(body), 'Version History');
897
+ if (!vh) {
898
+ // Bare docs (no scaffold) have no Version History; transitions silently
899
+ // skip the worklog. But an explicit `--note` must not be dropped — the
900
+ // caller opts into creating the section at the end of the body.
901
+ if (!createSection) return false;
902
+ const trimmed = body.replace(/\n+$/, '');
903
+ writeFileSync(filePath, `---\n${frontmatter}\n---\n${trimmed}\n\n## Version History\n\n${bullet}\n`, 'utf8');
904
+ return true;
905
+ }
856
906
  const lines = body.split('\n');
857
907
 
858
908
  // vh.lineStart is 1-indexed for the heading line. The line immediately
package/src/query.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { capitalize, toSlug, truncate, warn, suggestCandidates, isArchivedPath } from './util.mjs';
3
+ import { capitalize, toSlug, truncate, warn, die, suggestCandidates, isArchivedPath } from './util.mjs';
4
4
  import { renderProgressBar, formatCurrentState } from './render.mjs';
5
5
  import { computeDaysSinceUpdate, computeIsStale } from './validate.mjs';
6
6
  import { getGitLastModifiedBatch } from './git.mjs';
@@ -76,6 +76,9 @@ export function runFocus(index, argv, config) {
76
76
 
77
77
  export function runQuery(index, argv, config, opts = {}) {
78
78
  const filters = parseQueryArgs(argv);
79
+ if (filters.body && !filters.keyword) {
80
+ die('`--body` extends a keyword search into document bodies — pass `--keyword <term>` (or use `dotmd grep <term>`).');
81
+ }
79
82
  const docs = filterDocs(index.docs, filters, config);
80
83
 
81
84
  if (filters.json) {
@@ -119,7 +122,7 @@ function writeUnknownFilterValueHint(filters, index) {
119
122
 
120
123
  export function parseQueryArgs(argv) {
121
124
  const filters = {
122
- types: null, statuses: null, keyword: null, owner: null, surface: null,
125
+ types: null, statuses: null, keyword: null, body: false, owner: null, surface: null,
123
126
  module: null, domain: null, audience: null, executionMode: null,
124
127
  updatedSince: null, limit: 20, all: false, sort: 'updated',
125
128
  group: null,
@@ -136,6 +139,7 @@ export function parseQueryArgs(argv) {
136
139
  if (arg === '--type' && next) { filters.types = next.split(',').map(v => v.trim()).filter(Boolean); i += 1; continue; }
137
140
  if (arg === '--status' && next) { filters.statuses = next.split(',').map(v => v.trim()).filter(Boolean); i += 1; continue; }
138
141
  if (arg === '--keyword' && next) { filters.keyword = next; i += 1; continue; }
142
+ if (arg === '--body') { filters.body = true; continue; }
139
143
  if (arg === '--owner' && next) { filters.owner = next; i += 1; continue; }
140
144
  if (arg === '--surface' && next) { filters.surface = next; i += 1; continue; }
141
145
  if (arg === '--module' && next) { filters.module = next; i += 1; continue; }
@@ -187,9 +191,12 @@ export function filterDocs(docs, filters, config) {
187
191
  result = result.filter(d => !archived.has(d.status) && !isArchivedPath(d.path, config));
188
192
  }
189
193
 
190
- if (filters.keyword) {
191
- const needle = filters.keyword.toLowerCase();
192
- result = result.filter(d => [d.title, d.summary, d.currentState, d.nextStep, d.path, ...(d.blockers ?? [])].filter(Boolean).join(' ').toLowerCase().includes(needle));
194
+ const keywordNeedle = filters.keyword ? filters.keyword.toLowerCase() : null;
195
+ const matchesFrontmatter = d => [d.title, d.summary, d.currentState, d.nextStep, d.path, ...(d.blockers ?? [])].filter(Boolean).join(' ').toLowerCase().includes(keywordNeedle);
196
+ // With --body the keyword filter is deferred to after the cheap frontmatter
197
+ // filters (below) so body files are only read for surviving candidates.
198
+ if (keywordNeedle && !filters.body) {
199
+ result = result.filter(matchesFrontmatter);
193
200
  }
194
201
 
195
202
  // Positional substring filter: AND match against slug + title.
@@ -224,6 +231,19 @@ export function filterDocs(docs, filters, config) {
224
231
  if (filters.hasBlockers) result = result.filter(d => d.hasBlockers);
225
232
  if (filters.checklistOpen) result = result.filter(d => (d.checklist?.open ?? 0) > 0);
226
233
 
234
+ // Lazy body scan: docs already matching on frontmatter fields keep their spot
235
+ // without a file read; only the rest get their bodies scanned. Hits carry
236
+ // 1-2 matching-line excerpts so the caller can rank without opening files.
237
+ if (keywordNeedle && filters.body) {
238
+ result = result.filter(d => {
239
+ if (matchesFrontmatter(d)) return true;
240
+ const matches = scanBodyForKeyword(d, keywordNeedle, config);
241
+ if (!matches.length) return false;
242
+ d.bodyMatches = matches;
243
+ return true;
244
+ });
245
+ }
246
+
227
247
  result.sort(buildSorter(filters.sort, config));
228
248
  // Stash pre-limit count and per-status breakdown on filters so renderers
229
249
  // can show "N more" footers and accurate pipeline summaries even when the
@@ -237,6 +257,45 @@ export function filterDocs(docs, filters, config) {
237
257
  return filters.all ? result : result.slice(0, filters.limit);
238
258
  }
239
259
 
260
+ // Read one doc's body and return up to MAX_BODY_MATCHES matching-line
261
+ // excerpts: { line: <1-based file line>, text: <trimmed, windowed around the
262
+ // match> }. Line numbers are file-absolute (frontmatter included) so they can
263
+ // feed straight into a Read offset.
264
+ const MAX_BODY_MATCHES = 2;
265
+ const EXCERPT_WIDTH = 120;
266
+
267
+ function scanBodyForKeyword(doc, needle, config) {
268
+ let raw;
269
+ try {
270
+ raw = readFileSync(path.resolve(config.repoRoot, doc.path), 'utf8');
271
+ } catch (err) {
272
+ warn(`Could not read ${doc.path}: ${err.message}`);
273
+ return [];
274
+ }
275
+ const { body } = extractFrontmatter(raw);
276
+ if (!body || !body.toLowerCase().includes(needle)) return [];
277
+
278
+ // body is a suffix of raw — the slice before it is the frontmatter block.
279
+ const bodyStartLine = raw.slice(0, raw.length - body.length).split('\n').length;
280
+ const lines = body.split('\n');
281
+ const matches = [];
282
+ for (let i = 0; i < lines.length && matches.length < MAX_BODY_MATCHES; i++) {
283
+ const text = lines[i].trim();
284
+ const at = text.toLowerCase().indexOf(needle);
285
+ if (at === -1) continue;
286
+ matches.push({ line: bodyStartLine + i, text: excerptAround(text, at, needle.length) });
287
+ }
288
+ return matches;
289
+ }
290
+
291
+ // Window a long line around the match so the needle is always visible.
292
+ function excerptAround(text, at, needleLen) {
293
+ if (text.length <= EXCERPT_WIDTH) return text;
294
+ const start = Math.max(0, Math.min(at - Math.floor((EXCERPT_WIDTH - needleLen) / 2), text.length - EXCERPT_WIDTH));
295
+ const slice = text.slice(start, start + EXCERPT_WIDTH);
296
+ return `${start > 0 ? '…' : ''}${slice}${start + EXCERPT_WIDTH < text.length ? '…' : ''}`;
297
+ }
298
+
240
299
  function getDocSummary(doc, config) {
241
300
  try {
242
301
  const absPath = path.resolve(config.repoRoot, doc.path);
@@ -261,7 +320,7 @@ function renderQueryResults(docs, filters, config) {
261
320
  }
262
321
  if (filters.types?.length) process.stdout.write(`- type: ${filters.types.join(', ')}\n`);
263
322
  if (filters.statuses?.length) process.stdout.write(`- status: ${filters.statuses.join(', ')}\n`);
264
- if (filters.keyword) process.stdout.write(`- keyword: ${filters.keyword}\n`);
323
+ if (filters.keyword) process.stdout.write(`- keyword: ${filters.keyword}${filters.body ? ' (bodies scanned)' : ''}\n`);
265
324
  if (filters.owner) process.stdout.write(`- owner: ${filters.owner}\n`);
266
325
  if (filters.surface) process.stdout.write(`- surface: ${filters.surface}\n`);
267
326
  if (filters.module) process.stdout.write(`- module: ${filters.module}\n`);
@@ -299,6 +358,9 @@ function renderQueryResults(docs, filters, config) {
299
358
  if (doc.executionMode) process.stdout.write(` execution-mode: ${doc.executionMode}\n`);
300
359
  if (doc.blockers?.length) process.stdout.write(` blockers: ${doc.blockers.join('; ')}\n`);
301
360
  if (doc.checklist?.total) process.stdout.write(` checklist: ${doc.checklist.completed}/${doc.checklist.total} complete\n`);
361
+ for (const m of doc.bodyMatches ?? []) {
362
+ process.stdout.write(` match: ${dim(`L${m.line}:`)} ${m.text}\n`);
363
+ }
302
364
  if (filters.summarize && idx < filters.summarizeLimit) {
303
365
  const summary = getDocSummary(doc, config);
304
366
  if (summary) process.stdout.write(` ${dim('ai-summary:')} ${summary}\n`);
package/src/rename.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { toRepoPath, resolveDocPath, die, warn } from './util.mjs';
4
- import { collectDocFiles } from './index.mjs';
3
+ import { toRepoPath, die, warn } from './util.mjs';
4
+ import { collectDocFiles, resolveDocArg } from './index.mjs';
5
5
  import { regenIndex } from './lifecycle.mjs';
6
6
  import { gitMv } from './git.mjs';
7
7
  import { green, dim } from './color.mjs';
@@ -31,10 +31,7 @@ export async function runRename(argv, config, opts = {}) {
31
31
  }
32
32
 
33
33
  // Resolve old path
34
- const oldPath = resolveDocPath(oldInput, config);
35
- if (!oldPath) {
36
- die(`File not found: ${oldInput}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`);
37
- }
34
+ const oldPath = resolveDocArg(oldInput, config);
38
35
 
39
36
  // Compute new path — cross-directory if input has slashes, same directory otherwise
40
37
  let newPath;
package/src/render.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { capitalize, toSlug, truncate, warn } from './util.mjs';
3
+ import { capitalize, toSlug, truncate, warn, isArchivedPath } from './util.mjs';
4
4
  import { extractFrontmatter } from './frontmatter.mjs';
5
5
  import { summarizeDocBody } from './ai.mjs';
6
6
  import { bold, red, yellow, green, dim } from './color.mjs';
@@ -323,10 +323,20 @@ export function renderBriefing(index, config) {
323
323
  const untyped = index.docs.filter(d => !d.type);
324
324
 
325
325
  if (plans.length) {
326
+ // Headline counts LIVE plans first — "30 plans: 25 archived, …" skims as
327
+ // 30 open work items when zero are. "Live" mirrors the `dotmd plans`
328
+ // filter: not in an archive/terminal status and not filed under archived/.
329
+ const closed = new Set([
330
+ ...(config.lifecycle?.archiveStatuses ?? []),
331
+ ...(config.lifecycle?.terminalStatuses ?? []),
332
+ ]);
333
+ const live = plans.filter(p => !closed.has(p.status) && !isArchivedPath(p.path, config));
326
334
  const bySt = {};
327
- for (const p of plans) { bySt[p.status] = (bySt[p.status] ?? 0) + 1; }
335
+ for (const p of live) { bySt[p.status] = (bySt[p.status] ?? 0) + 1; }
328
336
  const counts = Object.entries(bySt).map(([s, n]) => `${n} ${s}`).join(', ');
329
- lines.push(`${plans.length} plans: ${counts}`);
337
+ const closedCount = plans.length - live.length;
338
+ const closedPart = closedCount ? ` (${closedCount} archived)` : '';
339
+ lines.push(live.length ? `${live.length} live plans${closedPart}: ${counts}` : `0 live plans${closedPart}`);
330
340
  const show = plans.filter(p => p.status === 'in-session' || p.status === 'active');
331
341
  for (const p of show) {
332
342
  const next = p.nextStep ? `next: ${p.nextStep}` : '(no next step)';
package/src/runlist.mjs CHANGED
@@ -1,45 +1,22 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
4
  import {
5
5
  asString,
6
6
  die,
7
7
  normalizeStringList,
8
- resolveDocPath,
9
8
  resolveRefPath,
10
9
  toRepoPath,
11
- toSlug,
12
10
  } from './util.mjs';
11
+ import { resolveDocArg } from './index.mjs';
13
12
  import { bold, cyan, dim, green, red, yellow } from './color.mjs';
14
13
 
15
14
  const PICKUPABLE_STATUSES = new Set(['active', 'planned', 'in-session']);
16
15
 
17
- // resolveDocPath only tries cwd / repo-root / docs-root joins, so bare plan
18
- // slugs like `clear-the-deck` don't resolve when plans live under
19
- // `<docsRoot>/plans/`. The other commands (`dotmd set <slug>`, `dotmd plans`)
20
- // take slugs — runlist should too. Try the direct resolve first, then
21
- // fall back to `<root>/plans/<slug>.md` under each configured doc root.
16
+ // Bare hub slugs resolve through the shared resolver; the caller keeps its
17
+ // runlist-specific miss message, so no die-on-miss here.
22
18
  function resolveHubInput(input, config) {
23
- const direct = resolveDocPath(input, config);
24
- if (direct) return direct;
25
-
26
- if (!input.endsWith('.md')) {
27
- const withExt = resolveDocPath(input + '.md', config);
28
- if (withExt) return withExt;
29
- }
30
-
31
- const slugFile = input.endsWith('.md') ? input : `${input}.md`;
32
- const roots = config.docsRoots || (config.docsRoot ? [config.docsRoot] : []);
33
- for (const root of roots) {
34
- const candidate = path.join(root, 'plans', slugFile);
35
- if (existsSync(candidate)) return candidate;
36
- // Multi-root layouts may already have a `plans` root, so also try the
37
- // root itself.
38
- const rootCandidate = path.join(root, slugFile);
39
- if (existsSync(rootCandidate)) return rootCandidate;
40
- }
41
-
42
- return null;
19
+ return resolveDocArg(input, config, { dieOnMiss: false });
43
20
  }
44
21
 
45
22
  // Read a hub plan's `runlist:` and resolve each entry to a repo-relative path
package/src/summary.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
- import { asString, toRepoPath, resolveDocPath, die, warn } from './util.mjs';
4
+ import { asString, toRepoPath, die, warn } from './util.mjs';
5
+ import { resolveDocArg } from './index.mjs';
5
6
  import { summarizeDocBody } from './ai.mjs';
6
7
  import { bold, dim } from './color.mjs';
7
8
 
@@ -23,8 +24,7 @@ export function runSummary(argv, config) {
23
24
  const input = positional[0];
24
25
  if (!input) { die('Usage: dotmd summary <file> [--model <name>] [--json]'); }
25
26
 
26
- const filePath = resolveDocPath(input, config);
27
- if (!filePath) { die(`File not found: ${input}`); }
27
+ const filePath = resolveDocArg(input, config);
28
28
 
29
29
  const raw = readFileSync(filePath, 'utf8');
30
30
  const { frontmatter, body } = extractFrontmatter(raw);
package/src/use.mjs CHANGED
@@ -3,6 +3,7 @@ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
3
3
  import { asString, die, resolveDocPath, toRepoPath } from './util.mjs';
4
4
  import { consumePrompt, pendingPromptsOldestFirst, resolvePromptInput } from './prompts.mjs';
5
5
  import { startPlan } from './lifecycle.mjs';
6
+ import { resolveDocArg } from './index.mjs';
6
7
 
7
8
  // Top-level `dotmd use [file]` — the single "start engaging with this doc"
8
9
  // verb. Dispatches by the target doc's `type:` so agents don't have to know
@@ -22,8 +23,12 @@ export async function runUse(argv, config, opts = {}) {
22
23
  return consumePrompt(head.abs, config, opts);
23
24
  }
24
25
 
25
- const filePath = resolveDocPath(positional, config) ?? resolvePromptInput(positional, config, { dieOnMiss: false });
26
- if (!filePath) die(`File not found: ${positional}`);
26
+ // Exact path first, then prompt slugs (they keep precedence on a slug
27
+ // collision — consuming a prompt is the more common intent), then the
28
+ // shared resolver for plan/doc slugs, which dies with did-you-mean on miss.
29
+ const filePath = resolveDocPath(positional, config)
30
+ ?? resolvePromptInput(positional, config, { dieOnMiss: false })
31
+ ?? resolveDocArg(positional, config);
27
32
 
28
33
  const raw = readFileSync(filePath, 'utf8');
29
34
  const { frontmatter } = extractFrontmatter(raw);