dotmd-cli 0.63.0 → 0.64.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/README.md +26 -5
- package/bin/dotmd.mjs +102 -73
- package/dotmd.config.example.mjs +2 -0
- package/package.json +2 -2
- package/scripts/postinstall.mjs +6 -1
- package/src/commands.mjs +1 -1
- package/src/completions.mjs +53 -28
- package/src/config.mjs +8 -2
- package/src/init.mjs +36 -4
- package/src/lifecycle.mjs +39 -19
- package/src/new.mjs +166 -4
- package/src/query.mjs +5 -0
- package/src/ship.mjs +6 -0
package/README.md
CHANGED
|
@@ -21,7 +21,9 @@ If you drive dotmd from Claude Code, install the **dotmd plugin**. It teaches ev
|
|
|
21
21
|
/plugin install dotmd@dotmd
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
The plugin bundles the hooks (`SessionStart`/`SubagentStart` priming, a `PreToolUse` guard) and a canonical workflow skill, so guidance travels to **every** repo automatically — no per-repo setup.
|
|
24
|
+
The plugin bundles the hooks (`SessionStart`/`SubagentStart` priming, a `PreToolUse` guard) and a canonical workflow skill, so guidance travels to **every** repo automatically — no per-repo setup. (Source: `plugins/dotmd/` in this repo.)
|
|
25
|
+
|
|
26
|
+
> **The plugin requires a _global_ CLI install.** Its hooks resolve `dotmd` from your `PATH`, so run `npm install -g dotmd-cli`. A project devDependency (`npm install -D dotmd-cli`) lives at `./node_modules/.bin/dotmd` — off `PATH` — so the hooks silently no-op (no errors, just no priming/guarding). Use the devDep for `npm run` scripts; use the global install for the plugin.
|
|
25
27
|
|
|
26
28
|
> **Upgrading to 0.57.0+:** per-repo `.claude/commands/{plans,docs,baton}.md` scaffolding is retired — that guidance now ships via the plugin's workflow skill and `/plans`, `/docs`, `/prompts`, `/baton` commands. On the next `dotmd hud` (SessionStart), dotmd removes those generated files (only banner-stamped `<!-- dotmd-generated -->` ones — your hand-authored command files are never touched). If you'd committed them, you'll see deletions to commit — that's expected. Run `claude plugin update dotmd@dotmd` to pick up `/baton`.
|
|
27
29
|
|
|
@@ -195,15 +197,34 @@ runlist:
|
|
|
195
197
|
---
|
|
196
198
|
```
|
|
197
199
|
|
|
200
|
+
Scaffold the whole sprint in one command instead of hand-writing the hub + children:
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
$ dotmd new plan auth-revamp --runlist extract,rewrite,cleanup
|
|
204
|
+
Created: plans/auth-revamp.md (plan (runlist hub))
|
|
205
|
+
Created: plans/auth-revamp-01-extract.md (plan · runlist child, planned)
|
|
206
|
+
Created: plans/auth-revamp-02-rewrite.md (plan · runlist child, planned)
|
|
207
|
+
Created: plans/auth-revamp-03-cleanup.md (plan · runlist child, planned)
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
That writes the hub above (the `runlist:` array + an `## Order of operations` link list) plus one `planned` child stub per slug, each carrying a `parent_plan: auth-revamp.md` back-ref. Then walk the sequence:
|
|
211
|
+
|
|
198
212
|
```bash
|
|
199
|
-
dotmd runlist
|
|
200
|
-
|
|
201
|
-
|
|
213
|
+
$ dotmd runlist auth-revamp # the sequence + statuses; → marks the next pickup
|
|
214
|
+
runlist: plans/auth-revamp.md
|
|
215
|
+
→ 1. [planned] plans/auth-revamp-01-extract.md
|
|
216
|
+
2. [planned] plans/auth-revamp-02-rewrite.md
|
|
217
|
+
3. [planned] plans/auth-revamp-03-cleanup.md
|
|
218
|
+
|
|
219
|
+
$ dotmd runlist next auth-revamp # pick up the → child (planned → in-session) + print its card
|
|
220
|
+
▶ Started: plans/auth-revamp-01-extract.md (planned → in-session)
|
|
221
|
+
|
|
222
|
+
$ dotmd runlists # dashboard of coordination-hub runlists (--json, --limit N)
|
|
202
223
|
```
|
|
203
224
|
|
|
204
225
|
`runlist next` stops with a runlist-aware error if the next child isn't in a workable status (`active` / `planned` / `in-session`), so you resolve the blocker before continuing. Each child should set `parent_plan:` pointing back at the hub — `dotmd check` warns when it doesn't. There's no separate doc type: a runlist hub is just a plan with the array.
|
|
205
226
|
|
|
206
|
-
In `dotmd plans`,
|
|
227
|
+
In `dotmd plans`, the hub folds its children under one tagged row — `auth-revamp runlist · 0/3 · next → 01-extract [RUNLIST]` — so a multi-plan sprint reads as one runlist instead of cluttering the triage list. A child whose hub is filtered out of the view (e.g. `--status active` when the hub is `planned`) still renders on its own.
|
|
207
228
|
|
|
208
229
|
**Coordination runlists.** A `runlist:` array fits a small ordered *sprint*. For a large, prose-first *coordination map* (a domain hub pointing at many plans, with gating/sequence rationale, sometimes unordered), set `execution_mode: coordination` instead — or just name it `*-runlist`. These aren't folded: `dotmd plans` lifts them into a pinned `Runlists` section and out of the active count, and `dotmd runlists` shows that dashboard standalone. `dotmd briefing` and `dotmd health` do the same — coordination hubs are pulled out of the live/active counts into a `runlists` bucket (briefing) and a held-out `Runlists:` tally (health), so they don't inflate the actionable-plan or aging numbers. The per-hub "N related" count comes from `related_plans:`. `dotmd check` nudges a `*-runlist` hub missing `execution_mode: coordination`.
|
|
209
230
|
|
package/bin/dotmd.mjs
CHANGED
|
@@ -451,21 +451,21 @@ Composes with the usual query flags:
|
|
|
451
451
|
dotmd grep retries --limit 5 cap results (default: unlimited)
|
|
452
452
|
dotmd grep retries --json machine-readable (bodyMatches per doc)`,
|
|
453
453
|
|
|
454
|
-
ship: `dotmd ship [patch|minor|major] —
|
|
454
|
+
ship: `dotmd ship [patch|minor|major] — commit + bump in one step
|
|
455
455
|
|
|
456
456
|
Bundles the release steps into a single command:
|
|
457
|
-
1.
|
|
458
|
-
(
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
bundled in.
|
|
465
|
-
3. Commit with an auto-generated \`chore: release <version>\` message.
|
|
466
|
-
4. Run \`npm version <bump>\` to bump package.json, tag, push, run
|
|
457
|
+
1. Auto-stage every dirty file matching the release allowlist
|
|
458
|
+
(src/, test/, bin/, docs/, plugins/, .claude-plugin/,
|
|
459
|
+
.claude/commands/, package*.json, dotmd.config*.mjs, README.md,
|
|
460
|
+
CLAUDE.md, .gitignore). Anything outside the allowlist is left
|
|
461
|
+
dirty — secrets, WIP, etc. never get bundled in.
|
|
462
|
+
2. Commit with an auto-generated \`chore: release <version>\` message.
|
|
463
|
+
3. Run \`npm version <bump>\` to bump package.json, tag, push, run
|
|
467
464
|
the publish workflow, and reinstall locally.
|
|
468
465
|
|
|
466
|
+
(Per-repo \`.claude/commands\` scaffolding is retired — the dotmd plugin's
|
|
467
|
+
SKILL.md is canonical now — so ship no longer regenerates anything.)
|
|
468
|
+
|
|
469
469
|
Options:
|
|
470
470
|
--dry-run, -n Show what would happen without staging or bumping.
|
|
471
471
|
|
|
@@ -758,10 +758,10 @@ Modes:
|
|
|
758
758
|
in their Version History would be misleading).
|
|
759
759
|
--migrate-template --json Machine-readable result.
|
|
760
760
|
--frontmatter-fix Auto-fix the long-frontmatter warnings that
|
|
761
|
-
\`dotmd check\` flags: \`current_state\` >
|
|
762
|
-
or \`next_step\` >
|
|
761
|
+
\`dotmd check\` flags: \`current_state\` >1500 chars
|
|
762
|
+
or \`next_step\` >800 chars. Truncates the
|
|
763
763
|
frontmatter field at the nearest sentence
|
|
764
|
-
boundary under the target (
|
|
764
|
+
boundary under the target (1200 / 600) and
|
|
765
765
|
appends the remainder to a \`## Current State\`
|
|
766
766
|
/ \`## Next Step\` body section (created above
|
|
767
767
|
the first H2 if absent, appended otherwise).
|
|
@@ -843,6 +843,20 @@ Examples:
|
|
|
843
843
|
EOF
|
|
844
844
|
dotmd new plan auth-revamp "Investigation findings before scoping…"
|
|
845
845
|
|
|
846
|
+
Scaffolding runlists (plans only):
|
|
847
|
+
--runlist <a,b,c> Create a sprint runlist hub plus one child plan per slug.
|
|
848
|
+
The hub carries \`runlist: [<hub>-01-a.md, <hub>-02-b.md, …]\`
|
|
849
|
+
and an \`## Order of operations\` list; each child is a
|
|
850
|
+
\`planned\` stub with a \`parent_plan:\` back-ref. Children
|
|
851
|
+
are named by the documented \`<hub>-NN-<slug>\` convention.
|
|
852
|
+
--coordination Create a prose-first coordination hub: \`execution_mode:
|
|
853
|
+
coordination\` + a \`## Ranked queue\` skeleton (no children).
|
|
854
|
+
Surfaces in \`dotmd runlists\`, held out of the active count.
|
|
855
|
+
(\`--runlist\` and \`--coordination\` are mutually exclusive.)
|
|
856
|
+
|
|
857
|
+
dotmd new plan auth-revamp --runlist extract,rewrite,cleanup
|
|
858
|
+
dotmd new plan platform --coordination
|
|
859
|
+
|
|
846
860
|
Other options:
|
|
847
861
|
--status <s> Set initial status (defaults to first valid status for the type)
|
|
848
862
|
--title <t> Override the auto-derived title
|
|
@@ -853,7 +867,7 @@ Other options:
|
|
|
853
867
|
|
|
854
868
|
For plans, the default status vocabulary is: in-session, active, planned,
|
|
855
869
|
blocked, partial, paused, awaiting, queued-after, archived.
|
|
856
|
-
For prompts: pending (default), claimed, archived.
|
|
870
|
+
For prompts: pending (default), held, shelved, claimed, archived.
|
|
857
871
|
|
|
858
872
|
Use --dry-run (-n) to preview without creating the file.`,
|
|
859
873
|
|
|
@@ -1273,7 +1287,6 @@ the whole docs tree is scanned.`,
|
|
|
1273
1287
|
|
|
1274
1288
|
async function main() {
|
|
1275
1289
|
const args = process.argv.slice(2);
|
|
1276
|
-
let command = args[0] ?? 'list';
|
|
1277
1290
|
|
|
1278
1291
|
// Pre-config flags
|
|
1279
1292
|
if (args.includes('--version') || args.includes('-v')) {
|
|
@@ -1281,8 +1294,70 @@ async function main() {
|
|
|
1281
1294
|
return;
|
|
1282
1295
|
}
|
|
1283
1296
|
|
|
1297
|
+
// Normalize global flags from ANYWHERE in argv (before OR after the command)
|
|
1298
|
+
// so `dotmd --config x list` resolves `list` as the command, not `--config`.
|
|
1299
|
+
// Value flags (--config/--root/--type) consume the next token; the booleans
|
|
1300
|
+
// (--dry-run/-n/--verbose) are read positionally below. --help/-h stay in the
|
|
1301
|
+
// leftover stream and are handled by the blocks just below.
|
|
1302
|
+
let explicitConfig = null;
|
|
1303
|
+
let rootArg = null;
|
|
1304
|
+
let typeArg = null;
|
|
1305
|
+
const normalized = [];
|
|
1306
|
+
for (let i = 0; i < args.length; i++) {
|
|
1307
|
+
const a = args[i];
|
|
1308
|
+
if (a === '--config' && args[i + 1]) { explicitConfig = args[++i]; continue; }
|
|
1309
|
+
if (a === '--type' && args[i + 1]) { typeArg = args[++i]; continue; }
|
|
1310
|
+
if (a === '--root' && args[i + 1]) { rootArg = args[++i]; continue; }
|
|
1311
|
+
if (a === '--dry-run' || a === '-n' || a === '--verbose') continue;
|
|
1312
|
+
normalized.push(a);
|
|
1313
|
+
}
|
|
1314
|
+
const dryRun = args.includes('--dry-run') || args.includes('-n');
|
|
1315
|
+
const verbose = args.includes('--verbose');
|
|
1316
|
+
let command = normalized[0] ?? 'list';
|
|
1317
|
+
const restArgs = normalized.slice(1);
|
|
1318
|
+
|
|
1319
|
+
// Reconstruct the active global flags for proxy commands (e.g. `watch`) that
|
|
1320
|
+
// re-invoke the CLI in a child process and must propagate them through.
|
|
1321
|
+
const globalFlagArgs = () => {
|
|
1322
|
+
const out = [];
|
|
1323
|
+
if (explicitConfig) out.push('--config', explicitConfig);
|
|
1324
|
+
if (rootArg) out.push('--root', rootArg);
|
|
1325
|
+
if (typeArg) out.push('--type', typeArg);
|
|
1326
|
+
if (dryRun) out.push('--dry-run');
|
|
1327
|
+
if (verbose) out.push('--verbose');
|
|
1328
|
+
return out;
|
|
1329
|
+
};
|
|
1330
|
+
|
|
1331
|
+
// Apply global --root / --type filters to an index in place. Shared by the
|
|
1332
|
+
// common index path below AND the early-dispatched commands (plans, runlists,
|
|
1333
|
+
// presets) that build their own index, so filtering is consistent everywhere.
|
|
1334
|
+
// Recomputes BOTH countsByStatus and countsByType so filtered JSON never
|
|
1335
|
+
// reports corpus-wide tallies.
|
|
1336
|
+
function applyIndexFilters(idx) {
|
|
1337
|
+
if (rootArg) {
|
|
1338
|
+
idx.docs = idx.docs.filter(d => d.root === rootArg || d.root.endsWith('/' + rootArg) || d.root.split('/').pop() === rootArg);
|
|
1339
|
+
}
|
|
1340
|
+
if (typeArg) {
|
|
1341
|
+
const types = typeArg.split(',').map(t => t.trim()).filter(Boolean);
|
|
1342
|
+
idx.docs = idx.docs.filter(d => types.includes(d.type));
|
|
1343
|
+
}
|
|
1344
|
+
if (rootArg || typeArg) {
|
|
1345
|
+
idx.errors = idx.errors.filter(e => idx.docs.some(d => d.path === e.path));
|
|
1346
|
+
idx.warnings = idx.warnings.filter(w => idx.docs.some(d => d.path === w.path));
|
|
1347
|
+
idx.countsByStatus = {};
|
|
1348
|
+
idx.countsByType = {};
|
|
1349
|
+
for (const doc of idx.docs) {
|
|
1350
|
+
const status = doc.status ?? 'unknown';
|
|
1351
|
+
idx.countsByStatus[status] = (idx.countsByStatus[status] ?? 0) + 1;
|
|
1352
|
+
const type = doc.type || 'unknown';
|
|
1353
|
+
if (!idx.countsByType[type]) idx.countsByType[type] = {};
|
|
1354
|
+
idx.countsByType[type][status] = (idx.countsByType[type][status] ?? 0) + 1;
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1284
1359
|
if (command === 'help' || command === '--help' || command === '-h') {
|
|
1285
|
-
const topic =
|
|
1360
|
+
const topic = restArgs[0];
|
|
1286
1361
|
if (topic) {
|
|
1287
1362
|
const key = `help:${topic}`;
|
|
1288
1363
|
if (HELP[key]) { process.stdout.write(`${HELP[key]}\n`); return; }
|
|
@@ -1310,22 +1385,10 @@ async function main() {
|
|
|
1310
1385
|
|
|
1311
1386
|
if (command === 'completions') {
|
|
1312
1387
|
const { runCompletions } = await import('../src/completions.mjs');
|
|
1313
|
-
runCompletions(
|
|
1388
|
+
runCompletions(restArgs);
|
|
1314
1389
|
return;
|
|
1315
1390
|
}
|
|
1316
1391
|
|
|
1317
|
-
// Extract --config flag
|
|
1318
|
-
let explicitConfig = null;
|
|
1319
|
-
for (let i = 0; i < args.length; i++) {
|
|
1320
|
-
if (args[i] === '--config' && args[i + 1]) {
|
|
1321
|
-
explicitConfig = args[i + 1];
|
|
1322
|
-
break;
|
|
1323
|
-
}
|
|
1324
|
-
}
|
|
1325
|
-
|
|
1326
|
-
const dryRun = args.includes('--dry-run') || args.includes('-n');
|
|
1327
|
-
const verbose = args.includes('--verbose');
|
|
1328
|
-
|
|
1329
1392
|
const config = await resolveConfig(process.cwd(), explicitConfig);
|
|
1330
1393
|
_resolvedConfig = config;
|
|
1331
1394
|
|
|
@@ -1337,20 +1400,9 @@ async function main() {
|
|
|
1337
1400
|
return;
|
|
1338
1401
|
}
|
|
1339
1402
|
|
|
1340
|
-
// Watch is a
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
// Strip global flags from restArgs so commands don't have to filter them
|
|
1344
|
-
const restArgs = [];
|
|
1345
|
-
let rootArg = null;
|
|
1346
|
-
let typeArg = null;
|
|
1347
|
-
for (let i = 1; i < args.length; i++) {
|
|
1348
|
-
if (args[i] === '--config') { i++; continue; }
|
|
1349
|
-
if (args[i] === '--type' && args[i + 1]) { typeArg = args[++i]; continue; }
|
|
1350
|
-
if (args[i] === '--root' && args[i + 1]) { rootArg = args[++i]; continue; }
|
|
1351
|
-
if (args[i] === '--dry-run' || args[i] === '-n' || args[i] === '--verbose') continue;
|
|
1352
|
-
restArgs.push(args[i]);
|
|
1353
|
-
}
|
|
1403
|
+
// Watch is a proxy — re-inject the active globals so the child re-resolves
|
|
1404
|
+
// the same config/filters.
|
|
1405
|
+
if (command === 'watch') { const { runWatch } = await import('../src/watch.mjs'); runWatch([...globalFlagArgs(), ...restArgs], config); return; }
|
|
1354
1406
|
|
|
1355
1407
|
// Hook commands (`hud`, `guard`) fire in EVERY repo via the globally-enabled
|
|
1356
1408
|
// plugin — `guard` runs on every Bash/Read/Edit. They must stay silent where
|
|
@@ -1381,7 +1433,8 @@ async function main() {
|
|
|
1381
1433
|
const { buildIndex } = await import('../src/index.mjs');
|
|
1382
1434
|
const { runQuery } = await import('../src/query.mjs');
|
|
1383
1435
|
const index = buildIndex(config);
|
|
1384
|
-
|
|
1436
|
+
applyIndexFilters(index);
|
|
1437
|
+
runQuery(index, [...config.presets[command], ...restArgs], config, { preset: command, type: typeArg, root: rootArg });
|
|
1385
1438
|
return;
|
|
1386
1439
|
}
|
|
1387
1440
|
|
|
@@ -1393,6 +1446,7 @@ async function main() {
|
|
|
1393
1446
|
const { buildIndex } = await import('../src/index.mjs');
|
|
1394
1447
|
const { runQuery } = await import('../src/query.mjs');
|
|
1395
1448
|
const index = buildIndex(config);
|
|
1449
|
+
applyIndexFilters(index);
|
|
1396
1450
|
const sub = restArgs[0];
|
|
1397
1451
|
let defaults;
|
|
1398
1452
|
let extras = restArgs;
|
|
@@ -1402,7 +1456,7 @@ async function main() {
|
|
|
1402
1456
|
} else {
|
|
1403
1457
|
defaults = ['--type', 'plan', '--exclude-archived', '--sort', 'updated', '--limit', '10'];
|
|
1404
1458
|
}
|
|
1405
|
-
runQuery(index, [...defaults, ...extras], config, { preset: 'plans' });
|
|
1459
|
+
runQuery(index, [...defaults, ...extras], config, { preset: 'plans', type: typeArg, root: rootArg });
|
|
1406
1460
|
return;
|
|
1407
1461
|
}
|
|
1408
1462
|
// `dotmd runlists` (plural) — the coordination-hub dashboard (the `Runlists`
|
|
@@ -1412,6 +1466,7 @@ async function main() {
|
|
|
1412
1466
|
const { buildIndex } = await import('../src/index.mjs');
|
|
1413
1467
|
const { runRunlists } = await import('../src/query.mjs');
|
|
1414
1468
|
const index = buildIndex(config);
|
|
1469
|
+
applyIndexFilters(index);
|
|
1415
1470
|
runRunlists(index, restArgs, config);
|
|
1416
1471
|
return;
|
|
1417
1472
|
}
|
|
@@ -1517,34 +1572,8 @@ async function main() {
|
|
|
1517
1572
|
const AUTO_HEAL_INDEX_COMMANDS = new Set(['check']);
|
|
1518
1573
|
const index = buildIndex(config, { autoHealIndex: AUTO_HEAL_INDEX_COMMANDS.has(command) && !checkHasPathScope });
|
|
1519
1574
|
|
|
1520
|
-
// Apply --root and --type filters
|
|
1521
|
-
const rootFilter = rootArg;
|
|
1522
|
-
const typeFilter = typeArg;
|
|
1523
|
-
|
|
1524
|
-
function applyIndexFilters(idx) {
|
|
1525
|
-
if (rootFilter) {
|
|
1526
|
-
idx.docs = idx.docs.filter(d => d.root === rootFilter || d.root.endsWith('/' + rootFilter) || d.root.split('/').pop() === rootFilter);
|
|
1527
|
-
}
|
|
1528
|
-
if (typeFilter) {
|
|
1529
|
-
const types = typeFilter.split(',').map(t => t.trim()).filter(Boolean);
|
|
1530
|
-
idx.docs = idx.docs.filter(d => types.includes(d.type));
|
|
1531
|
-
}
|
|
1532
|
-
if (rootFilter || typeFilter) {
|
|
1533
|
-
idx.errors = idx.errors.filter(e => idx.docs.some(d => d.path === e.path));
|
|
1534
|
-
idx.warnings = idx.warnings.filter(w => idx.docs.some(d => d.path === w.path));
|
|
1535
|
-
idx.countsByStatus = {};
|
|
1536
|
-
for (const doc of idx.docs) {
|
|
1537
|
-
const s = doc.status ?? 'unknown';
|
|
1538
|
-
idx.countsByStatus[s] = (idx.countsByStatus[s] ?? 0) + 1;
|
|
1539
|
-
}
|
|
1540
|
-
}
|
|
1541
|
-
}
|
|
1542
|
-
|
|
1543
1575
|
applyIndexFilters(index);
|
|
1544
1576
|
|
|
1545
|
-
if (rootFilter || typeFilter) {
|
|
1546
|
-
}
|
|
1547
|
-
|
|
1548
1577
|
if (verbose) {
|
|
1549
1578
|
process.stderr.write(`Docs found: ${index.docs.length}\n`);
|
|
1550
1579
|
}
|
|
@@ -1671,7 +1700,7 @@ async function main() {
|
|
|
1671
1700
|
}
|
|
1672
1701
|
|
|
1673
1702
|
if (command === 'focus') { runFocus(index, restArgs, config); return; }
|
|
1674
|
-
if (command === 'query') { runQuery(index, restArgs, config); return; }
|
|
1703
|
+
if (command === 'query') { runQuery(index, restArgs, config, { type: typeArg, root: rootArg }); return; }
|
|
1675
1704
|
// `dotmd grep <term>` — ergonomic alias for `query --keyword <term> --body`.
|
|
1676
1705
|
// Unlimited by default (grep semantics) unless the caller bounds it themselves.
|
|
1677
1706
|
if (command === 'grep') {
|
package/dotmd.config.example.mjs
CHANGED
|
@@ -76,6 +76,8 @@ export const excludeDirs = ['evidence'];
|
|
|
76
76
|
// // pending prompts on session start; `dotmd prompts next` claims the oldest.
|
|
77
77
|
// statuses: {
|
|
78
78
|
// 'pending': { context: 'expanded', staleDays: 30 },
|
|
79
|
+
// 'held': { context: 'counted', quiet: true }, // saved but not next: hidden from hud/briefing, skipped by no-arg `use`
|
|
80
|
+
// 'shelved': { context: 'counted', quiet: true }, // legacy alias for held
|
|
79
81
|
// 'claimed': { context: 'counted', quiet: true },
|
|
80
82
|
// 'archived': { context: 'counted', archive: true, terminal: true, quiet: true },
|
|
81
83
|
// },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dotmd-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.64.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",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"test": "node --test test/*.test.mjs",
|
|
43
43
|
"postinstall": "node scripts/postinstall.mjs",
|
|
44
44
|
"preversion": "npm test",
|
|
45
|
-
"version": "node bin/dotmd.mjs hud >/dev/null 2>&1; node scripts/sync-plugin-version.mjs; git add .claude/commands docs/docs.md plugins
|
|
45
|
+
"version": "node bin/dotmd.mjs hud >/dev/null 2>&1; node scripts/sync-plugin-version.mjs; git add .claude/commands docs/docs.md plugins .claude-plugin 2>/dev/null; true",
|
|
46
46
|
"postversion": "bash scripts/postversion.sh"
|
|
47
47
|
},
|
|
48
48
|
"engines": {
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -28,7 +28,12 @@ try {
|
|
|
28
28
|
spawnSync('claude', ['plugin', 'update', 'dotmd@dotmd'], { stdio: 'ignore', timeout: 60000 });
|
|
29
29
|
process.stdout.write('dotmd: refreshed the Claude Code plugin — restart your session (or /reload-plugins) to apply.\n');
|
|
30
30
|
} else {
|
|
31
|
-
|
|
31
|
+
// The CLI just installed fresh, so only the plugin can be stale — point at
|
|
32
|
+
// the targeted refresh rather than the full `dotmd update` (CLI + plugin).
|
|
33
|
+
const nudge = hasClaude
|
|
34
|
+
? 'dotmd CLI installed. Using the Claude Code plugin? Run `dotmd update --plugin-only` to refresh it, then restart.'
|
|
35
|
+
: 'dotmd CLI installed.';
|
|
36
|
+
process.stdout.write(`${nudge}\n`);
|
|
32
37
|
}
|
|
33
38
|
} catch {
|
|
34
39
|
// Best effort only — never break the install.
|
package/src/commands.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
export const KNOWN_COMMANDS = [
|
|
6
6
|
'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context', 'agent-context', 'hud',
|
|
7
7
|
'focus', 'query', 'grep', 'plans', 'prompts', 'stale', 'actionable', 'index', 'status', 'set', 'use', 'next', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor', 'runlist', 'runlists',
|
|
8
|
-
'unblocks', 'health', 'glossary', 'modules', 'module',
|
|
8
|
+
'unblocks', 'health', 'glossary', 'modules', 'module', 'surfaces',
|
|
9
9
|
'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
|
|
10
10
|
'watch', 'diff', 'new', 'init', 'completions', 'statuses', 'journal',
|
|
11
11
|
'guard', 'misuse', 'update',
|
package/src/completions.mjs
CHANGED
|
@@ -1,52 +1,77 @@
|
|
|
1
1
|
import { die } from './util.mjs';
|
|
2
|
+
import { KNOWN_COMMANDS } from './commands.mjs';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
// Derive the completable command list from the dispatcher's canonical verb list
|
|
5
|
+
// so completions never drift behind new commands. A tiny denylist drops verbs
|
|
6
|
+
// that exist but shouldn't be tab-completed (internal self-test).
|
|
7
|
+
const COMPLETION_DENYLIST = new Set(['self-check']);
|
|
8
|
+
const COMMANDS = KNOWN_COMMANDS.filter(c => !COMPLETION_DENYLIST.has(c));
|
|
8
9
|
|
|
9
10
|
const GLOBAL_FLAGS = ['--config', '--dry-run', '--verbose', '--root', '--type', '--help', '--version'];
|
|
10
11
|
|
|
12
|
+
// Shared filter flags for the query-style commands (mirrors QUERY_FLAGS in
|
|
13
|
+
// bin/dotmd.mjs). Kept as one array so query/grep/stale/actionable/plans stay
|
|
14
|
+
// in lockstep.
|
|
15
|
+
const QUERY_FLAGS = [
|
|
16
|
+
'--type', '--status', '--keyword', '--body', '--owner', '--surface', '--module',
|
|
17
|
+
'--domain', '--audience', '--execution-mode', '--updated-since', '--limit', '--sort',
|
|
18
|
+
'--group', '--all', '--include-archived', '--exclude-archived', '--stale',
|
|
19
|
+
'--has-next-step', '--has-blockers', '--checklist-open', '--json', '--git',
|
|
20
|
+
'--summarize', '--summarize-limit', '--model',
|
|
21
|
+
];
|
|
22
|
+
|
|
11
23
|
const COMMAND_FLAGS = {
|
|
12
|
-
query:
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
'--module', '--surface', '--domain', '--owner'],
|
|
18
|
-
index: ['--write'],
|
|
24
|
+
query: QUERY_FLAGS,
|
|
25
|
+
grep: QUERY_FLAGS,
|
|
26
|
+
stale: QUERY_FLAGS,
|
|
27
|
+
actionable: QUERY_FLAGS,
|
|
28
|
+
plans: ['status', ...QUERY_FLAGS],
|
|
19
29
|
list: ['--verbose', '--json'],
|
|
30
|
+
briefing: ['--json'],
|
|
31
|
+
context: ['--json', '--compact', '--summarize', '--model'],
|
|
32
|
+
'agent-context': ['--json'],
|
|
33
|
+
hud: ['--json', '--subagent'],
|
|
34
|
+
index: ['--write'],
|
|
20
35
|
coverage: ['--json'],
|
|
21
|
-
new: ['--status', '--title', '--template', '--list-templates', '--root'],
|
|
22
|
-
diff: ['--stat', '--since', '--summarize', '--model'],
|
|
23
|
-
check: ['--errors-only', '--fix', '--json'],
|
|
24
36
|
stats: ['--json'],
|
|
25
37
|
graph: ['--dot', '--json', '--status', '--module', '--surface'],
|
|
26
38
|
deps: ['--json', '--depth'],
|
|
27
39
|
unblocks: ['--json'],
|
|
28
40
|
health: ['--json'],
|
|
29
41
|
glossary: ['--list', '--json'],
|
|
30
|
-
|
|
42
|
+
modules: ['--sort', '--json'],
|
|
43
|
+
module: ['--json'],
|
|
44
|
+
surfaces: ['--json'],
|
|
45
|
+
runlists: ['--json', '--limit', '--sort'],
|
|
46
|
+
runlist: ['next', '--json', '--full', '--no-index', '--show-files'],
|
|
47
|
+
prompts: ['list', 'next', 'use', 'show', 'archive', 'new', 'hold', 'unhold',
|
|
48
|
+
'--json', '--status', '--include-archived', '--sort', '--limit', '--all'],
|
|
49
|
+
use: [],
|
|
50
|
+
next: [],
|
|
51
|
+
baton: ['--status', '--note', '--body', '--message', '--dry-run'],
|
|
52
|
+
set: [],
|
|
53
|
+
status: [],
|
|
54
|
+
archive: ['--note', '--no-index', '--show-files', '--closeout-template'],
|
|
55
|
+
bulk: ['archive', 'tag'],
|
|
56
|
+
statuses: ['list', 'add', '--type', '--like', '--json'],
|
|
57
|
+
update: ['--check', '--cli-only', '--plugin-only'],
|
|
58
|
+
misuse: ['--json', '--tail', '--by-rule', '--repo'],
|
|
59
|
+
journal: ['--tail', '--errors', '--session', '--since', '--by-command', '--json'],
|
|
60
|
+
new: ['--status', '--title', '--template', '--list-templates', '--root', '--message', '--body'],
|
|
31
61
|
notion: ['import', 'export', 'sync', '--force', '--dry-run'],
|
|
32
62
|
export: ['--format', '--output', '--status', '--module', '--root', '--type'],
|
|
33
63
|
focus: ['--json'],
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
doctor: [],
|
|
41
|
-
watch: [],
|
|
64
|
+
summary: ['--model', '--max-tokens', '--json'],
|
|
65
|
+
diff: ['--stat', '--since', '--summarize', '--model'],
|
|
66
|
+
touch: ['--git'],
|
|
67
|
+
check: ['--fix', '--errors-only', '--no-collapse', '--json', '--verbose'],
|
|
68
|
+
doctor: ['--apply', '--yes', '--dry-run', '--statuses', '--migrate-template',
|
|
69
|
+
'--migrate-prompts', '--frontmatter-fix', '--project', '--json', '--include-archived'],
|
|
42
70
|
lint: ['--fix'],
|
|
71
|
+
ship: [],
|
|
43
72
|
rename: [],
|
|
44
73
|
migrate: [],
|
|
45
74
|
'fix-refs': [],
|
|
46
|
-
summary: ['--model', '--max-tokens', '--json'],
|
|
47
|
-
context: ['--summarize', '--model', '--json'],
|
|
48
|
-
touch: ['--git'],
|
|
49
|
-
journal: ['--tail', '--errors', '--session', '--since', '--by-command', '--json'],
|
|
50
75
|
};
|
|
51
76
|
|
|
52
77
|
function bashCompletion() {
|
package/src/config.mjs
CHANGED
|
@@ -307,8 +307,14 @@ function validateConfig(userConfig, config, validStatuses, indexPath) {
|
|
|
307
307
|
}
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
-
// staleDays keys must exist in validStatuses
|
|
311
|
-
|
|
310
|
+
// staleDays keys must exist in validStatuses — but only validate the map when
|
|
311
|
+
// the user actually wrote `statuses.staleDays`. When it's inherited from
|
|
312
|
+
// defaults (user overrode `statuses.order` but left staleDays unset), the
|
|
313
|
+
// default map is keyed by default statuses (`ready`, `scoping`, …) that the
|
|
314
|
+
// user's order may not include — warning there blames the user for keys they
|
|
315
|
+
// never authored and makes brownfield repos noisy on every command, hook
|
|
316
|
+
// included. Real typos in a user-provided map are still caught.
|
|
317
|
+
if (userConfig.statuses?.staleDays && config.statuses?.staleDays) {
|
|
312
318
|
for (const key of Object.keys(config.statuses.staleDays)) {
|
|
313
319
|
if (!validStatuses.has(key)) {
|
|
314
320
|
warnings.push(`Config: statuses.staleDays contains unknown status '${key}'.`);
|
package/src/init.mjs
CHANGED
|
@@ -152,6 +152,16 @@ function countMarkdownFiles(dir) {
|
|
|
152
152
|
return { withFrontmatter, withoutFrontmatter };
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
// Sensible default stale thresholds (days) for statuses dotmd recognizes, used
|
|
156
|
+
// only to scope the generated config's staleDays to detected statuses. Mirrors
|
|
157
|
+
// the global + per-type defaults in config.mjs DEFAULTS; the repo's own custom
|
|
158
|
+
// statuses are intentionally absent so we don't invent a threshold for vocab we
|
|
159
|
+
// don't understand.
|
|
160
|
+
const KNOWN_STALE_DAYS = {
|
|
161
|
+
'in-session': 1, active: 14, ready: 14, planned: 30, blocked: 30,
|
|
162
|
+
scoping: 30, paused: 3, awaiting: 14, draft: 30, review: 14, pending: 30,
|
|
163
|
+
};
|
|
164
|
+
|
|
155
165
|
function generateDetectedConfig(scan, rootPath) {
|
|
156
166
|
const lines = [`// dotmd.config.mjs — auto-detected from ${scan.docCount} existing docs`, ''];
|
|
157
167
|
lines.push(`export const root = '${rootPath}';`);
|
|
@@ -164,6 +174,18 @@ function generateDetectedConfig(scan, rootPath) {
|
|
|
164
174
|
if (allStatuses.length > 0) {
|
|
165
175
|
lines.push('export const statuses = {');
|
|
166
176
|
lines.push(` order: [${allStatuses.map(s => `'${s}'`).join(', ')}],`);
|
|
177
|
+
// Scope staleDays to the detected statuses. `statuses.staleDays` is a
|
|
178
|
+
// replace-key, so emitting it here stops the resolver from inheriting the
|
|
179
|
+
// default map (keyed by `ready`/`scoping`/… that this repo may not use) —
|
|
180
|
+
// which otherwise makes every command warn about statuses the user never
|
|
181
|
+
// wrote. Only statuses with a sensible known threshold get an entry;
|
|
182
|
+
// unrecognized ones (the repo's own vocab) are left for the user to tune.
|
|
183
|
+
const staleEntries = allStatuses.filter(s => s in KNOWN_STALE_DAYS);
|
|
184
|
+
if (staleEntries.length > 0) {
|
|
185
|
+
lines.push(' staleDays: {');
|
|
186
|
+
for (const s of staleEntries) lines.push(` '${s}': ${KNOWN_STALE_DAYS[s]},`);
|
|
187
|
+
lines.push(' },');
|
|
188
|
+
}
|
|
167
189
|
lines.push('};');
|
|
168
190
|
lines.push('');
|
|
169
191
|
}
|
|
@@ -333,16 +355,24 @@ export async function runInit(cwd, config, opts = {}) {
|
|
|
333
355
|
// Claude Code integration. dotmd no longer scaffolds per-repo
|
|
334
356
|
// `.claude/commands/*.md` slash commands — the dotmd plugin's SKILL.md is the
|
|
335
357
|
// canonical agent-facing workflow now, and `dotmd hud` injects this repo's
|
|
336
|
-
// status vocab at runtime.
|
|
337
|
-
|
|
338
|
-
//
|
|
339
|
-
|
|
358
|
+
// status vocab at runtime.
|
|
359
|
+
const hasProjectClaude = existsSync(path.join(cwd, '.claude'));
|
|
360
|
+
// A project `.claude/` proves it; a user-global `~/.claude/` means they run
|
|
361
|
+
// Claude Code elsewhere, so the plugin nudge is still relevant before this
|
|
362
|
+
// repo has any `.claude/` of its own (the common greenfield case).
|
|
363
|
+
const likelyClaudeUser = hasProjectClaude || existsSync(path.join(os.homedir(), '.claude'));
|
|
364
|
+
|
|
365
|
+
// If a `.claude/` exists, sweep any retired generated command files
|
|
366
|
+
// (banner-gated, so hand-authored ones survive).
|
|
367
|
+
if (hasProjectClaude) {
|
|
340
368
|
const removed = removeGeneratedSlashCommands(cwd, { dryRun });
|
|
341
369
|
for (const r of removed) {
|
|
342
370
|
const verb = dryRun ? 'would remove' : 'removed';
|
|
343
371
|
process.stdout.write(` ${dryTag}${yellow('clean')} .claude/commands/${r.name} (retired — ${verb}; guidance ships via the dotmd plugin)\n`);
|
|
344
372
|
}
|
|
373
|
+
}
|
|
345
374
|
|
|
375
|
+
if (likelyClaudeUser) {
|
|
346
376
|
const sessionStart = detectSessionStartHook(cwd);
|
|
347
377
|
if (sessionStart.wired) {
|
|
348
378
|
process.stdout.write(` ${dim('exists')} ${sessionStart.file} (SessionStart hook for \`dotmd hud\` already wired)\n`);
|
|
@@ -351,6 +381,8 @@ export async function runInit(cwd, config, opts = {}) {
|
|
|
351
381
|
process.stdout.write(` travel to every session and subagent automatically:\n\n`);
|
|
352
382
|
process.stdout.write(` /plugin marketplace add reowens/dotmd\n`);
|
|
353
383
|
process.stdout.write(` /plugin install dotmd@dotmd\n\n`);
|
|
384
|
+
process.stdout.write(` The plugin's hooks call \`dotmd\` on your PATH, so install the CLI\n`);
|
|
385
|
+
process.stdout.write(` globally too — ${green('npm i -g dotmd-cli')} (a project devDependency won't power them).\n\n`);
|
|
354
386
|
process.stdout.write(` Or, without the plugin, wire \`dotmd hud\` at SessionStart by hand —\n`);
|
|
355
387
|
process.stdout.write(` add to .claude/settings.json (merge into any existing hooks):\n\n`);
|
|
356
388
|
process.stdout.write(` "hooks": { "SessionStart": [\n`);
|
package/src/lifecycle.mjs
CHANGED
|
@@ -457,26 +457,37 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
457
457
|
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
458
458
|
const oldStatus = asString(parsed.status) ?? 'unknown';
|
|
459
459
|
|
|
460
|
+
// Preserve a configured custom archive status (e.g. `done` with archive:true)
|
|
461
|
+
// when one is threaded through from `dotmd set <archive-status>`. Fall back to
|
|
462
|
+
// the canonical `archived`, or — if the config has no `archived` at all — its
|
|
463
|
+
// first declared archive status, so we never write a status the config can't
|
|
464
|
+
// validate.
|
|
465
|
+
const archiveStatuses = config.lifecycle.archiveStatuses;
|
|
466
|
+
const defaultArchiveStatus = archiveStatuses.has('archived')
|
|
467
|
+
? 'archived'
|
|
468
|
+
: (archiveStatuses.values().next().value ?? 'archived');
|
|
469
|
+
const targetStatus = opts.archiveStatus ?? defaultArchiveStatus;
|
|
470
|
+
|
|
460
471
|
// Heal stuck frontmatter (issue #13): file is under archiveDir/ but its
|
|
461
472
|
// status hasn't been flipped. Flip in place; don't try to move (it's already
|
|
462
473
|
// archived on disk) and don't refuse — refusal leaves the drift permanent.
|
|
463
474
|
if (inArchiveDir) {
|
|
464
|
-
if (oldStatus ===
|
|
475
|
+
if (oldStatus === targetStatus) {
|
|
465
476
|
die(`Already archived: ${toRepoPath(filePath, config.repoRoot)}`);
|
|
466
477
|
}
|
|
467
478
|
const today = nowIso();
|
|
468
479
|
const repoPathHeal = toRepoPath(filePath, config.repoRoot);
|
|
469
480
|
if (dryRun) {
|
|
470
481
|
const prefix = dim('[dry-run]');
|
|
471
|
-
out.write(`${prefix} Would heal frontmatter in place: status: ${oldStatus} →
|
|
482
|
+
out.write(`${prefix} Would heal frontmatter in place: status: ${oldStatus} → ${targetStatus}, updated: ${today}\n`);
|
|
472
483
|
out.write(`${prefix} Would skip git mv (file already under \`${config.archiveDir}/\`)\n`);
|
|
473
484
|
return;
|
|
474
485
|
}
|
|
475
|
-
updateFrontmatter(filePath, { status:
|
|
486
|
+
updateFrontmatter(filePath, { status: targetStatus, updated: today });
|
|
476
487
|
const healEntry = `Archived (frontmatter healed in place from \`${oldStatus}\`)${note ? ` — ${note}` : '.'}`;
|
|
477
488
|
appendVersionHistory(filePath, healEntry, { createSection: Boolean(note) });
|
|
478
489
|
if (!noIndex) regenIndex(config);
|
|
479
|
-
out.write(`${green('✓ Healed')}: ${repoPathHeal} (${oldStatus} →
|
|
490
|
+
out.write(`${green('✓ Healed')}: ${repoPathHeal} (${oldStatus} → ${targetStatus}; file already under \`${config.archiveDir}/\`)\n`);
|
|
480
491
|
const touched = [repoPathHeal];
|
|
481
492
|
if (config.indexPath && !noIndex) touched.push(config.indexPath);
|
|
482
493
|
if (showFiles) emitFilesFooter(touched, config);
|
|
@@ -505,7 +516,7 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
505
516
|
} else if (closeoutAction?.action === 'skip') {
|
|
506
517
|
out.write(`${prefix} \`## Closeout\` section already present — no injection\n`);
|
|
507
518
|
}
|
|
508
|
-
out.write(`${prefix} Would update frontmatter: status: ${oldStatus} →
|
|
519
|
+
out.write(`${prefix} Would update frontmatter: status: ${oldStatus} → ${targetStatus}, updated: ${today}\n`);
|
|
509
520
|
if (note) {
|
|
510
521
|
out.write(`${prefix} Would append Version History: - **${today}** Archived — ${note}\n`);
|
|
511
522
|
}
|
|
@@ -530,7 +541,7 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
530
541
|
writeFileSync(filePath, `---\n${frontmatter}\n---\n${closeoutAction.newBody}`, 'utf8');
|
|
531
542
|
}
|
|
532
543
|
|
|
533
|
-
updateFrontmatter(filePath, { status:
|
|
544
|
+
updateFrontmatter(filePath, { status: targetStatus, updated: today });
|
|
534
545
|
appendVersionHistory(filePath, note ? `Archived — ${note}` : 'Archived.', { createSection: Boolean(note) });
|
|
535
546
|
|
|
536
547
|
mkdirSync(targetDir, { recursive: true });
|
|
@@ -614,7 +625,10 @@ export async function runSet(argv, config, opts = {}) {
|
|
|
614
625
|
const archiveArgs = [filePath];
|
|
615
626
|
if (noIndex) archiveArgs.push('--no-index');
|
|
616
627
|
if (showFiles) archiveArgs.push('--show-files');
|
|
617
|
-
|
|
628
|
+
// Preserve the exact target status — a config may name its archive status
|
|
629
|
+
// `done` (with archive:true) rather than `archived`. Without this, runArchive
|
|
630
|
+
// would silently rewrite it to `archived`.
|
|
631
|
+
return runArchive(archiveArgs, config, { dryRun, note, archiveStatus: newStatus });
|
|
618
632
|
}
|
|
619
633
|
|
|
620
634
|
// `partial` promises a successor tracking the deferred tail. When neither a
|
|
@@ -784,7 +798,9 @@ export function runTouch(argv, config, opts = {}) {
|
|
|
784
798
|
// when archiving `child.md` (suffix match). oldPath no longer exists on disk
|
|
785
799
|
// post-`git mv`, so existsSync-based resolveRefPath can't be used here.
|
|
786
800
|
function rewriteFrontmatterRefs(fm, docDir, oldPath, newPath, repoRoot) {
|
|
787
|
-
|
|
801
|
+
// Exclude [ ] , from the token so flow-array elements (`refs: [a.md, b.md]`)
|
|
802
|
+
// match individually rather than swallowing the bracket and failing to resolve.
|
|
803
|
+
return fm.replace(/[^\s"'<>:[\],]+\.md\b/g, (token) => {
|
|
788
804
|
const docRelAbs = path.resolve(docDir, token);
|
|
789
805
|
const repoRelAbs = path.resolve(repoRoot, token);
|
|
790
806
|
if (docRelAbs !== oldPath && repoRelAbs !== oldPath) return token;
|
|
@@ -848,24 +864,28 @@ function updateRefsFromMovedFile(oldPath, newPath, config) {
|
|
|
848
864
|
// when the source moves. Without the repo-root fallback, repo-relative refs
|
|
849
865
|
// silently skipped rewriting (existsSync on the doubled doc-relative path
|
|
850
866
|
// returned false).
|
|
867
|
+
// Token-based: rewrite every `*.md` path that resolved to a real file from
|
|
868
|
+
// the old location, regardless of YAML shape — block-sequence list items
|
|
869
|
+
// (` - ./path.md`), inline scalars (`parent_plan: hub.md`), and flow arrays
|
|
870
|
+
// (`related_plans: [a.md, b.md]`). Quotes sit outside the matched token, so
|
|
871
|
+
// `"./path.md"` rewrites in place. Mirrors rewriteFrontmatterRefs (inbound).
|
|
851
872
|
let newFm = frontmatter;
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
const newRelPath = path.relative(newDir, absTarget).split(path.sep).join('/');
|
|
857
|
-
return `${prefix}${newRelPath}`;
|
|
873
|
+
newFm = newFm.replace(/[^\s"'<>:[\],]+\.md\b/g, (token) => {
|
|
874
|
+
const absTarget = resolveRefPath(token, oldDir, config.repoRoot);
|
|
875
|
+
if (!absTarget) return token;
|
|
876
|
+
return path.relative(newDir, absTarget).split(path.sep).join('/');
|
|
858
877
|
});
|
|
859
878
|
|
|
860
|
-
// Fix body markdown links [text](path.md)
|
|
879
|
+
// Fix body markdown links [text](path.md) and [text](path.md#anchor) — the
|
|
880
|
+
// trailing fragment is preserved across the rewrite.
|
|
861
881
|
let newBody = body;
|
|
862
|
-
const linkRegex = /(\[[^\]]*\]\()([^)]+\.md)(\))/g;
|
|
863
|
-
newBody = newBody.replace(linkRegex, (match, pre, href, post) => {
|
|
864
|
-
if (
|
|
882
|
+
const linkRegex = /(\[[^\]]*\]\()([^)#]+\.md)(#[^)]*)?(\))/g;
|
|
883
|
+
newBody = newBody.replace(linkRegex, (match, pre, href, frag, post) => {
|
|
884
|
+
if (/^https?:/i.test(href)) return match;
|
|
865
885
|
const absTarget = resolveRefPath(href, oldDir, config.repoRoot);
|
|
866
886
|
if (!absTarget) return match;
|
|
867
887
|
const newHref = path.relative(newDir, absTarget).split(path.sep).join('/');
|
|
868
|
-
return `${pre}${newHref}${post}`;
|
|
888
|
+
return `${pre}${newHref}${frag ?? ''}${post}`;
|
|
869
889
|
});
|
|
870
890
|
|
|
871
891
|
if (newFm !== frontmatter || newBody !== body) {
|
package/src/new.mjs
CHANGED
|
@@ -268,6 +268,117 @@ export function readBodyInput(source) {
|
|
|
268
268
|
return source;
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
// Slug/title helpers shared by name resolution and runlist child generation.
|
|
272
|
+
function slugify(s) {
|
|
273
|
+
return s.toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
|
274
|
+
}
|
|
275
|
+
function titleize(s) {
|
|
276
|
+
return s.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Resolve one `--runlist` token to a scaffolded child plan: a bare slug becomes
|
|
280
|
+
// `<hub>-NN-<slug>.md` (the documented runlist naming convention). `pos` is the
|
|
281
|
+
// 1-based position used for the zero-padded NN prefix. Tokens must be bare slugs
|
|
282
|
+
// — a path is rejected (wiring an ordered hub to a plan that already lives
|
|
283
|
+
// elsewhere needs a hub-relative ref, so it's a hand-edit, not a scaffold).
|
|
284
|
+
function planChildFromToken(hubSlug, token, pos) {
|
|
285
|
+
const t = token.trim();
|
|
286
|
+
if (t.includes('/') || t.includes(path.sep)) {
|
|
287
|
+
die(`Runlist child "${token}" must be a bare slug, not a path. To put an existing plan in the runlist, add it to the hub's runlist: by hand.`);
|
|
288
|
+
}
|
|
289
|
+
const childSlug = slugify(t.replace(/\.md$/, ''));
|
|
290
|
+
if (!childSlug) die(`Runlist child token resolves to an empty slug: "${token}"`);
|
|
291
|
+
const nn = String(pos).padStart(2, '0');
|
|
292
|
+
return { file: `${hubSlug}-${nn}-${childSlug}.md`, title: titleize(t.replace(/\.md$/, '')) };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Body for a sprint runlist hub: the children ARE the phases, so the heavy
|
|
296
|
+
// generic plan scaffold (Goals/Phases/Deferred/…) is replaced by an ordered
|
|
297
|
+
// `## Order of operations` list that mirrors the `runlist:` frontmatter.
|
|
298
|
+
function runlistHubBody(title, hubSlug, children, bodyInput, today) {
|
|
299
|
+
const steps = children
|
|
300
|
+
.map((c, i) => `${i + 1}. [${c.title}](${c.file}) ⬜`)
|
|
301
|
+
.join('\n');
|
|
302
|
+
const n = children.length;
|
|
303
|
+
return `
|
|
304
|
+
# ${title}
|
|
305
|
+
|
|
306
|
+
> One-paragraph problem statement: what this runlist sprints toward, why now.
|
|
307
|
+
|
|
308
|
+
## Problem
|
|
309
|
+
|
|
310
|
+
${bodyInput?.trim() ?? ''}
|
|
311
|
+
|
|
312
|
+
## Order of operations
|
|
313
|
+
|
|
314
|
+
${steps}
|
|
315
|
+
|
|
316
|
+
Pick up the next child with \`dotmd runlist next ${hubSlug}\` — it targets the
|
|
317
|
+
first non-archived child. \`dotmd runlist ${hubSlug}\` shows the sequence + status.
|
|
318
|
+
|
|
319
|
+
## Version History
|
|
320
|
+
|
|
321
|
+
- **${today}** Created (runlist hub, ${n} ${n === 1 ? 'child' : 'children'}).
|
|
322
|
+
`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Body for a coordination hub: prose-first domain map with a ranked-queue table.
|
|
326
|
+
// Mirrors the `execution_mode: coordination` shape `dotmd runlists` reads.
|
|
327
|
+
function coordinationHubBody(title, bodyInput, today) {
|
|
328
|
+
return `
|
|
329
|
+
# ${title}
|
|
330
|
+
|
|
331
|
+
> One-paragraph: the domain this hub coordinates and how to read the queue below.
|
|
332
|
+
|
|
333
|
+
## Scope
|
|
334
|
+
|
|
335
|
+
${bodyInput?.trim() ?? ''}
|
|
336
|
+
|
|
337
|
+
## Ranked queue
|
|
338
|
+
|
|
339
|
+
<!-- One row per coordinated plan, in pickup order; the gating column explains
|
|
340
|
+
dependencies. Wire each plan into related_plans: so the "N related" count and
|
|
341
|
+
graph pick it up. -->
|
|
342
|
+
|
|
343
|
+
| # | Plan | Why / gating | Status |
|
|
344
|
+
|---|------|--------------|--------|
|
|
345
|
+
| 1 | \`<plan>.md\` | | |
|
|
346
|
+
|
|
347
|
+
## Version History
|
|
348
|
+
|
|
349
|
+
- **${today}** Created (coordination hub).
|
|
350
|
+
`;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Minimal child plan stub for a scaffolded runlist child. parent_plan points
|
|
354
|
+
// back at the hub (same dir) so \`dotmd doctor\` is satisfied and the reverse
|
|
355
|
+
// link/graph work; status starts `planned` (queued behind the hub).
|
|
356
|
+
function runlistChildContent(childTitle, hubSlug, hubTitle, childStatus, today) {
|
|
357
|
+
return `---
|
|
358
|
+
type: plan
|
|
359
|
+
status: ${childStatus}
|
|
360
|
+
created: ${today}
|
|
361
|
+
updated: ${today}
|
|
362
|
+
parent_plan: ${hubSlug}.md
|
|
363
|
+
related_plans:
|
|
364
|
+
current_state:
|
|
365
|
+
next_step:
|
|
366
|
+
---
|
|
367
|
+
|
|
368
|
+
# ${childTitle}
|
|
369
|
+
|
|
370
|
+
> Runlist child of [${hubTitle}](${hubSlug}.md).
|
|
371
|
+
|
|
372
|
+
## Problem
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
## Version History
|
|
377
|
+
|
|
378
|
+
- **${today}** Created (runlist child of ${hubSlug}).
|
|
379
|
+
`;
|
|
380
|
+
}
|
|
381
|
+
|
|
271
382
|
export async function runNew(argv, config, opts = {}) {
|
|
272
383
|
const { dryRun } = opts;
|
|
273
384
|
|
|
@@ -289,9 +400,13 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
289
400
|
let bodyFlag = null;
|
|
290
401
|
let bodyFlagName = null; // tracks which spelling the caller used, for error attribution
|
|
291
402
|
let showFiles = opts.showFiles ?? false;
|
|
403
|
+
let runlistArg = null; // --runlist a,b,c → sprint hub + child stubs
|
|
404
|
+
let coordination = false; // --coordination → coordination hub skeleton
|
|
292
405
|
for (let i = 0; i < argv.length; i++) {
|
|
293
406
|
if (argv[i] === '--status' && argv[i + 1]) { status = argv[++i]; continue; }
|
|
294
407
|
if (argv[i] === '--title' && argv[i + 1]) { title = argv[++i]; continue; }
|
|
408
|
+
if (argv[i] === '--runlist' && argv[i + 1]) { runlistArg = argv[++i]; continue; }
|
|
409
|
+
if (argv[i] === '--coordination') { coordination = true; continue; }
|
|
295
410
|
// --body is the canonical flag; --message is a back-compat alias.
|
|
296
411
|
if ((argv[i] === '--body' || argv[i] === '--message') && argv[i + 1]) {
|
|
297
412
|
bodyFlagName = argv[i];
|
|
@@ -359,6 +474,24 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
359
474
|
die(`Invalid status \`${status}\` for type \`${typeName}\`\nValid: ${[...effective].join(', ')}`);
|
|
360
475
|
}
|
|
361
476
|
|
|
477
|
+
// Runlist/coordination hubs are a plan shape, not a separate type. Guard the
|
|
478
|
+
// flags to type plan and reject the contradictory combination (a sprint
|
|
479
|
+
// `runlist:` array vs a prose-first coordination map are different shapes).
|
|
480
|
+
const isRunlistHub = runlistArg !== null;
|
|
481
|
+
const isCoordinationHub = coordination;
|
|
482
|
+
if ((isRunlistHub || isCoordinationHub) && typeName !== 'plan') {
|
|
483
|
+
die(`--${isRunlistHub ? 'runlist' : 'coordination'} only applies to plans. Use: dotmd new plan <name> --${isRunlistHub ? 'runlist a,b,c' : 'coordination'}`);
|
|
484
|
+
}
|
|
485
|
+
if (isRunlistHub && isCoordinationHub) {
|
|
486
|
+
die('--runlist and --coordination are mutually exclusive: a sprint runlist hub carries an ordered `runlist:` array; a coordination hub is a prose-first map (`execution_mode: coordination`). Pick one.');
|
|
487
|
+
}
|
|
488
|
+
const runlistTokens = isRunlistHub
|
|
489
|
+
? runlistArg.split(',').map(s => s.trim()).filter(Boolean)
|
|
490
|
+
: [];
|
|
491
|
+
if (isRunlistHub && runlistTokens.length === 0) {
|
|
492
|
+
die('--runlist needs at least one child, e.g. --runlist extract,rewrite,cleanup');
|
|
493
|
+
}
|
|
494
|
+
|
|
362
495
|
// Body input resolution: --body flag > positional bodyArg > auto-piped-stdin > nothing
|
|
363
496
|
let bodyInput = null;
|
|
364
497
|
let bodyInputSource = null;
|
|
@@ -498,6 +631,10 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
498
631
|
|
|
499
632
|
const today = nowIso();
|
|
500
633
|
|
|
634
|
+
// Resolve runlist children from the hub slug (e.g. `extract` → hub-01-extract.md).
|
|
635
|
+
const runlistChildren = runlistTokens.map((tok, i) => planChildFromToken(slug, tok, i + 1));
|
|
636
|
+
const childStatus = effective.has('planned') ? 'planned' : status;
|
|
637
|
+
|
|
501
638
|
// Generate content
|
|
502
639
|
let content;
|
|
503
640
|
const validSurfaces = config.raw?.taxonomy?.surfaces ?? (config.validSurfaces ? [...config.validSurfaces] : null);
|
|
@@ -508,7 +645,14 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
508
645
|
} else {
|
|
509
646
|
let fm = template.frontmatter(status, today, tmplCtx);
|
|
510
647
|
if (bodyFrontmatter) fm = mergeBodyFrontmatter(fm, bodyFrontmatter, typeName);
|
|
511
|
-
|
|
648
|
+
// Inject the hub-shape frontmatter (runlist array / coordination marker)
|
|
649
|
+
// on top of the standard plan scaffold, then swap in a purpose-built body.
|
|
650
|
+
if (isRunlistHub) fm = mergeBodyFrontmatter(fm, { runlist: runlistChildren.map(c => c.file) }, typeName);
|
|
651
|
+
if (isCoordinationHub) fm = mergeBodyFrontmatter(fm, { execution_mode: 'coordination' }, typeName);
|
|
652
|
+
let body;
|
|
653
|
+
if (isRunlistHub) body = runlistHubBody(docTitle, slug, runlistChildren, bodyInput, today);
|
|
654
|
+
else if (isCoordinationHub) body = coordinationHubBody(docTitle, bodyInput, today);
|
|
655
|
+
else body = template.body(docTitle, tmplCtx);
|
|
512
656
|
content = `---\n${fm}\n---\n${body}`;
|
|
513
657
|
}
|
|
514
658
|
|
|
@@ -525,9 +669,14 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
525
669
|
rootHint = `Root: ${chosenLabel} (others: ${others.join(', ')} — pass --root <name> to change)\n`;
|
|
526
670
|
}
|
|
527
671
|
|
|
672
|
+
const hubKind = isRunlistHub ? ' (runlist hub)' : isCoordinationHub ? ' (coordination hub)' : '';
|
|
673
|
+
|
|
528
674
|
if (dryRun) {
|
|
529
675
|
process.stdout.write(`${dim('[dry-run]')} Would create: ${repoPath}\n`);
|
|
530
|
-
process.stdout.write(`${dim('[dry-run]')} Type: ${typeName}\n`);
|
|
676
|
+
process.stdout.write(`${dim('[dry-run]')} Type: ${typeName}${hubKind}\n`);
|
|
677
|
+
for (const c of runlistChildren) {
|
|
678
|
+
process.stdout.write(`${dim('[dry-run]')} Would create child: ${toRepoPath(path.join(baseDir, c.file), config.repoRoot)}\n`);
|
|
679
|
+
}
|
|
531
680
|
if (rootHint) process.stdout.write(`${dim('[dry-run]')} ${rootHint}`);
|
|
532
681
|
return;
|
|
533
682
|
}
|
|
@@ -536,9 +685,22 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
536
685
|
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
537
686
|
|
|
538
687
|
writeFileSync(filePath, content, 'utf8');
|
|
539
|
-
process.stdout.write(`${green('Created')}: ${repoPath} ${dim(`(${typeName})`)}\n`);
|
|
688
|
+
process.stdout.write(`${green('Created')}: ${repoPath} ${dim(`(${typeName}${hubKind})`)}\n`);
|
|
540
689
|
if (rootHint) process.stdout.write(dim(rootHint));
|
|
541
690
|
|
|
691
|
+
// Scaffold runlist child stubs. An existing child file is never clobbered.
|
|
692
|
+
const childPaths = [];
|
|
693
|
+
for (const c of runlistChildren) {
|
|
694
|
+
const childPath = path.join(baseDir, c.file);
|
|
695
|
+
if (existsSync(childPath)) {
|
|
696
|
+
warn(`Runlist child already exists, left as-is: ${toRepoPath(childPath, config.repoRoot)}`);
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
writeFileSync(childPath, runlistChildContent(c.title, slug, docTitle, childStatus, today), 'utf8');
|
|
700
|
+
childPaths.push(childPath);
|
|
701
|
+
process.stdout.write(`${green('Created')}: ${toRepoPath(childPath, config.repoRoot)} ${dim(`(plan · runlist child, ${childStatus})`)}\n`);
|
|
702
|
+
}
|
|
703
|
+
|
|
542
704
|
// Post-create guidance. Prompts are the classic confusion point: agents
|
|
543
705
|
// reflexively `git add && commit` a freshly-created file, but saved prompts
|
|
544
706
|
// are session-local handoff artifacts — the next session consumes them via
|
|
@@ -564,7 +726,7 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
564
726
|
regenIndex(config);
|
|
565
727
|
|
|
566
728
|
if (showFiles) {
|
|
567
|
-
const touched = [filePath];
|
|
729
|
+
const touched = [filePath, ...childPaths];
|
|
568
730
|
if (config.indexPath) touched.push(config.indexPath);
|
|
569
731
|
emitFilesFooter(touched, config);
|
|
570
732
|
}
|
package/src/query.mjs
CHANGED
|
@@ -77,6 +77,11 @@ export function runFocus(index, argv, config) {
|
|
|
77
77
|
|
|
78
78
|
export function runQuery(index, argv, config, opts = {}) {
|
|
79
79
|
const filters = parseQueryArgs(argv);
|
|
80
|
+
// Global --type/--root are stripped by the dispatcher and applied to the
|
|
81
|
+
// index before it reaches here; reflect them in the filter echo so JSON
|
|
82
|
+
// metadata isn't reported as unfiltered when the result set is narrowed.
|
|
83
|
+
if (opts.type && !filters.types) filters.types = opts.type.split(',').map(v => v.trim()).filter(Boolean);
|
|
84
|
+
if (opts.root && !filters.root) filters.root = opts.root;
|
|
80
85
|
if (filters.body && !filters.keyword) {
|
|
81
86
|
die('`--body` extends a keyword search into document bodies — pass `--keyword <term>` (or use `dotmd grep <term>`).');
|
|
82
87
|
}
|
package/src/ship.mjs
CHANGED
|
@@ -12,6 +12,12 @@ const ALLOWLIST_PATTERNS = [
|
|
|
12
12
|
/^test\//,
|
|
13
13
|
/^bin\//,
|
|
14
14
|
/^docs\//,
|
|
15
|
+
// Plugin artifacts ship in lockstep with the CLI (the plugin-based workflow
|
|
16
|
+
// is canonical), so a dirty SKILL.md / command / hook / manifest is a release
|
|
17
|
+
// change. `.claude/commands/` stays for repos that still hand-author slash
|
|
18
|
+
// commands — harmless, and dropping it would un-stage their edits.
|
|
19
|
+
/^plugins\//,
|
|
20
|
+
/^\.claude-plugin\//,
|
|
15
21
|
/^\.claude\/commands\//,
|
|
16
22
|
/^dotmd\.config\.example\.mjs$/,
|
|
17
23
|
/^dotmd\.config\.mjs$/,
|