dotmd-cli 0.59.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 +60 -9
- package/package.json +1 -1
- package/src/commands.mjs +1 -1
- package/src/completions.mjs +4 -2
- package/src/config.mjs +5 -0
- package/src/deps.mjs +4 -6
- package/src/diff.mjs +4 -7
- package/src/guard.mjs +75 -19
- package/src/hud.mjs +40 -3
- package/src/index.mjs +49 -1
- package/src/lifecycle.mjs +73 -46
- package/src/query.mjs +68 -6
- package/src/rename.mjs +3 -6
- package/src/render.mjs +13 -3
- package/src/runlist.mjs +5 -28
- package/src/summary.mjs +3 -3
- package/src/use.mjs +7 -2
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-
|
|
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
|
|
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
|
-
|
|
159
|
-
|
|
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-
|
|
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
|
|
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>\`.`,
|
|
@@ -518,6 +550,8 @@ basename match under the doc roots. An ambiguous basename (the same name in
|
|
|
518
550
|
two places) errors with the candidate list instead of guessing.
|
|
519
551
|
|
|
520
552
|
Options:
|
|
553
|
+
--note "<text>" Append \`Archived — <text>\` to \`## Version History\`
|
|
554
|
+
in the same call (creates the section if missing).
|
|
521
555
|
--no-index Skip index regen. Use when multiple sessions are
|
|
522
556
|
working concurrently and you want a path-limited
|
|
523
557
|
commit that doesn't pull other agents' uncommitted
|
|
@@ -1540,6 +1574,23 @@ async function main() {
|
|
|
1540
1574
|
|
|
1541
1575
|
if (command === 'focus') { runFocus(index, restArgs, config); return; }
|
|
1542
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
|
+
}
|
|
1543
1594
|
if (command === 'modules' || command === 'module') {
|
|
1544
1595
|
// D3: default `--type plan` when the user didn't pass --type explicitly.
|
|
1545
1596
|
// applyIndexFilters already narrowed by typeArg if it was set; if not, the
|
package/package.json
CHANGED
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',
|
package/src/completions.mjs
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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 =
|
|
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,
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
24
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
if (
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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,
|
|
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';
|
|
@@ -15,29 +15,6 @@ function findFileRoot(filePath, config) {
|
|
|
15
15
|
return roots.find(r => filePath.startsWith(r + '/')) ?? config.docsRoot;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
// Resolve an archive target like `dotmd use` resolves a pickup target: an exact
|
|
19
|
-
// path wins (the resolveDocPath fast path), but a bare slug / basename falls
|
|
20
|
-
// back to a recursive basename match under the doc roots. Mirrors
|
|
21
|
-
// resolvePromptInput's basename pass so the closure verb is as forgiving about
|
|
22
|
-
// naming as `use`/`prompts archive`. A basename shared by two files errors with
|
|
23
|
-
// the candidate list rather than guessing which one to move.
|
|
24
|
-
function resolveArchiveTarget(input, config) {
|
|
25
|
-
const direct = resolveDocPath(input, config);
|
|
26
|
-
if (direct) return direct;
|
|
27
|
-
if (!input.endsWith('.md')) {
|
|
28
|
-
const withExt = resolveDocPath(input + '.md', config);
|
|
29
|
-
if (withExt) return withExt;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const slug = input.replace(/\.md$/, '');
|
|
33
|
-
const byBasename = collectDocFiles(config).filter(f => path.basename(f, '.md') === slug);
|
|
34
|
-
if (byBasename.length === 1) return byBasename[0];
|
|
35
|
-
if (byBasename.length > 1) {
|
|
36
|
-
die(`Multiple docs match "${input}" by basename:\n${byBasename.map(f => ' ' + toRepoPath(f, config.repoRoot)).join('\n')}`);
|
|
37
|
-
}
|
|
38
|
-
return null;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
18
|
function defaultTypeDir(docType, config) {
|
|
42
19
|
if (docType === 'plan') return 'plans';
|
|
43
20
|
if (docType === 'prompt') return 'prompts';
|
|
@@ -151,6 +128,13 @@ export async function runStatus(argv, config, opts = {}) {
|
|
|
151
128
|
const noIndex = argv.includes('--no-index') || opts.noIndex;
|
|
152
129
|
const showFiles = argv.includes('--show-files') || opts.showFiles;
|
|
153
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
|
+
}
|
|
154
138
|
const input = argv[0];
|
|
155
139
|
let newStatus = argv[1];
|
|
156
140
|
|
|
@@ -160,8 +144,7 @@ export async function runStatus(argv, config, opts = {}) {
|
|
|
160
144
|
|
|
161
145
|
if (!input) { die('Usage: dotmd status <file> <new-status>'); }
|
|
162
146
|
|
|
163
|
-
const filePath =
|
|
164
|
-
if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); }
|
|
147
|
+
const filePath = resolveDocArg(input, config);
|
|
165
148
|
|
|
166
149
|
// Determine type-specific or root-specific valid statuses
|
|
167
150
|
const raw = readFileSync(filePath, 'utf8');
|
|
@@ -256,12 +239,18 @@ export async function runStatus(argv, config, opts = {}) {
|
|
|
256
239
|
if ((isArchiving || isUnarchiving || isFiling || isUnfiling) && config.indexPath) {
|
|
257
240
|
process.stdout.write(`${prefix} Would regenerate index\n`);
|
|
258
241
|
}
|
|
242
|
+
if (note) {
|
|
243
|
+
process.stdout.write(`${prefix} Would append Version History: - **${today}** Status: ${oldStatus ?? 'unknown'} → ${newStatus} — ${note}\n`);
|
|
244
|
+
}
|
|
259
245
|
process.stdout.write(`${prefix} ${toRepoPath(finalPath, config.repoRoot)}: ${oldStatus ?? 'unknown'} → ${newStatus}\n`);
|
|
260
246
|
return;
|
|
261
247
|
}
|
|
262
248
|
|
|
263
249
|
updateFrontmatter(filePath, { status: newStatus, updated: today });
|
|
264
|
-
|
|
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) });
|
|
265
254
|
|
|
266
255
|
if (isArchiving) {
|
|
267
256
|
mkdirSync(archiveDir, { recursive: true });
|
|
@@ -350,8 +339,7 @@ export async function startPlan(argv, config, opts = {}) {
|
|
|
350
339
|
input = candidates[idx].path;
|
|
351
340
|
}
|
|
352
341
|
|
|
353
|
-
const filePath =
|
|
354
|
-
if (!filePath) die(`File not found: ${input}`);
|
|
342
|
+
const filePath = resolveDocArg(input, config);
|
|
355
343
|
|
|
356
344
|
const raw = readFileSync(filePath, 'utf8');
|
|
357
345
|
const { frontmatter: fmRaw, body } = extractFrontmatter(raw);
|
|
@@ -420,12 +408,18 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
420
408
|
const showFiles = argv.includes('--show-files') || opts.showFiles;
|
|
421
409
|
const closeoutTemplate = argv.includes('--closeout-template');
|
|
422
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
|
+
}
|
|
423
418
|
const input = argv[0];
|
|
424
419
|
|
|
425
420
|
if (!input) { die('Usage: dotmd archive <file>'); }
|
|
426
421
|
|
|
427
|
-
const filePath =
|
|
428
|
-
if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); }
|
|
422
|
+
const filePath = resolveDocArg(input, config);
|
|
429
423
|
|
|
430
424
|
const archiveFileRoot = findFileRoot(filePath, config);
|
|
431
425
|
const relFromRoot = path.relative(archiveFileRoot, filePath);
|
|
@@ -456,7 +450,8 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
456
450
|
return;
|
|
457
451
|
}
|
|
458
452
|
updateFrontmatter(filePath, { status: 'archived', updated: today });
|
|
459
|
-
|
|
453
|
+
const healEntry = `Archived (frontmatter healed in place from \`${oldStatus}\`)${note ? ` — ${note}` : '.'}`;
|
|
454
|
+
appendVersionHistory(filePath, healEntry, { createSection: Boolean(note) });
|
|
460
455
|
if (!noIndex) regenIndex(config);
|
|
461
456
|
out.write(`${green('✓ Healed')}: ${repoPathHeal} (${oldStatus} → archived; file already under \`${config.archiveDir}/\`)\n`);
|
|
462
457
|
const touched = [repoPathHeal];
|
|
@@ -488,6 +483,9 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
488
483
|
out.write(`${prefix} \`## Closeout\` section already present — no injection\n`);
|
|
489
484
|
}
|
|
490
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
|
+
}
|
|
491
489
|
out.write(`${prefix} Would move: ${oldRepoPath} → ${newRepoPath}\n`);
|
|
492
490
|
if (config.indexPath && !noIndex) out.write(`${prefix} Would regenerate index\n`);
|
|
493
491
|
if (config.indexPath && noIndex) out.write(`${prefix} Would skip index regen (--no-index)\n`);
|
|
@@ -510,7 +508,7 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
510
508
|
}
|
|
511
509
|
|
|
512
510
|
updateFrontmatter(filePath, { status: 'archived', updated: today });
|
|
513
|
-
appendVersionHistory(filePath, 'Archived.');
|
|
511
|
+
appendVersionHistory(filePath, note ? `Archived — ${note}` : 'Archived.', { createSection: Boolean(note) });
|
|
514
512
|
|
|
515
513
|
mkdirSync(targetDir, { recursive: true });
|
|
516
514
|
|
|
@@ -571,6 +569,13 @@ export async function runSet(argv, config, opts = {}) {
|
|
|
571
569
|
const noIndex = argv.includes('--no-index');
|
|
572
570
|
const showFiles = argv.includes('--show-files');
|
|
573
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
|
+
}
|
|
574
579
|
|
|
575
580
|
const newStatus = argv[0];
|
|
576
581
|
const input = argv[1];
|
|
@@ -578,8 +583,7 @@ export async function runSet(argv, config, opts = {}) {
|
|
|
578
583
|
if (!newStatus) die('Usage: dotmd set <status> <path>');
|
|
579
584
|
if (!input) die('Usage: dotmd set <status> <path>');
|
|
580
585
|
|
|
581
|
-
const filePath =
|
|
582
|
-
if (!filePath) die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`);
|
|
586
|
+
const filePath = resolveDocArg(input, config);
|
|
583
587
|
|
|
584
588
|
const inArchive = isArchivedPath(toRepoPath(filePath, config.repoRoot), config);
|
|
585
589
|
|
|
@@ -587,13 +591,30 @@ export async function runSet(argv, config, opts = {}) {
|
|
|
587
591
|
const archiveArgs = [filePath];
|
|
588
592
|
if (noIndex) archiveArgs.push('--no-index');
|
|
589
593
|
if (showFiles) archiveArgs.push('--show-files');
|
|
590
|
-
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 */ }
|
|
591
608
|
}
|
|
592
609
|
|
|
593
610
|
const statusArgs = [filePath, newStatus];
|
|
594
611
|
if (noIndex) statusArgs.push('--no-index');
|
|
595
612
|
if (showFiles) statusArgs.push('--show-files');
|
|
596
|
-
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
|
+
}
|
|
597
618
|
}
|
|
598
619
|
|
|
599
620
|
export function runBulkArchive(argv, config, opts = {}) {
|
|
@@ -670,8 +691,7 @@ export function runTouch(argv, config, opts = {}) {
|
|
|
670
691
|
|
|
671
692
|
// --git mode: bulk-sync frontmatter dates from git history
|
|
672
693
|
if (useGit) {
|
|
673
|
-
const allFiles = input ? [
|
|
674
|
-
if (input && allFiles.length === 0) { die(`File not found: ${input}`); }
|
|
694
|
+
const allFiles = input ? [resolveDocArg(input, config)] : collectDocFiles(config);
|
|
675
695
|
|
|
676
696
|
const prefix = dryRun ? dim('[dry-run] ') : '';
|
|
677
697
|
let synced = 0;
|
|
@@ -714,8 +734,7 @@ export function runTouch(argv, config, opts = {}) {
|
|
|
714
734
|
|
|
715
735
|
if (!input) { die('Usage: dotmd touch <file>\n dotmd touch --git Bulk-sync dates from git history'); }
|
|
716
736
|
|
|
717
|
-
const filePath =
|
|
718
|
-
if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); }
|
|
737
|
+
const filePath = resolveDocArg(input, config);
|
|
719
738
|
|
|
720
739
|
const today = nowIso();
|
|
721
740
|
|
|
@@ -862,7 +881,7 @@ function countRefsToUpdate(oldPath, newPath, config) {
|
|
|
862
881
|
// Newest-first ordering: inserted at the top of the section, right after the
|
|
863
882
|
// heading + blank-line gap. If the section is missing, this is a silent no-op
|
|
864
883
|
// — never auto-creates the section (don't surprise users on old plans/docs).
|
|
865
|
-
export function appendVersionHistory(filePath, entry) {
|
|
884
|
+
export function appendVersionHistory(filePath, entry, { createSection = false } = {}) {
|
|
866
885
|
let raw;
|
|
867
886
|
try { raw = readFileSync(filePath, 'utf8'); } catch { return false; }
|
|
868
887
|
if (!raw.startsWith('---\n')) return false;
|
|
@@ -872,10 +891,18 @@ export function appendVersionHistory(filePath, entry) {
|
|
|
872
891
|
const frontmatter = raw.slice(4, endMarker);
|
|
873
892
|
const body = raw.slice(endMarker + 5);
|
|
874
893
|
|
|
875
|
-
const vh = findSection(walkSections(body), 'Version History');
|
|
876
|
-
if (!vh) return false;
|
|
877
|
-
|
|
878
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
|
+
}
|
|
879
906
|
const lines = body.split('\n');
|
|
880
907
|
|
|
881
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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,
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
//
|
|
18
|
-
//
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
26
|
-
|
|
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);
|