dotmd-cli 0.62.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 +110 -76
- 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/health.mjs +5 -4
- package/src/init.mjs +36 -4
- package/src/lifecycle.mjs +83 -31
- package/src/new.mjs +166 -4
- package/src/query.mjs +17 -2
- package/src/runlist.mjs +88 -11
- package/src/ship.mjs +6 -0
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
|
@@ -236,6 +236,10 @@ export async function runStatus(argv, config, opts = {}) {
|
|
|
236
236
|
process.stdout.write(`${prefix} Would unfile: ${toRepoPath(filePath, config.repoRoot)} → ${toRepoPath(targetPath, config.repoRoot)}\n`);
|
|
237
237
|
finalPath = targetPath;
|
|
238
238
|
}
|
|
239
|
+
if (finalPath !== filePath) {
|
|
240
|
+
const refCount = countRefsToUpdate(filePath, finalPath, config);
|
|
241
|
+
if (refCount > 0) process.stdout.write(`${prefix} Would update references in ${refCount} file(s)\n`);
|
|
242
|
+
}
|
|
239
243
|
if ((isArchiving || isUnarchiving || isFiling || isUnfiling) && config.indexPath) {
|
|
240
244
|
process.stdout.write(`${prefix} Would regenerate index\n`);
|
|
241
245
|
}
|
|
@@ -286,6 +290,22 @@ export async function runStatus(argv, config, opts = {}) {
|
|
|
286
290
|
finalPath = targetPath;
|
|
287
291
|
}
|
|
288
292
|
|
|
293
|
+
// Any of the four moves above shifts the file's directory, which breaks
|
|
294
|
+
// relative refs in both directions — links FROM the moved file and inbound
|
|
295
|
+
// refs TO it from other docs. runArchive repairs both; mirror that here so
|
|
296
|
+
// the deprecated `dotmd status <file> archived` path and the `dotmd set`
|
|
297
|
+
// unarchive/file/unfile transitions (which route through runStatus, not
|
|
298
|
+
// runArchive) don't silently leave dangling links.
|
|
299
|
+
let selfRefsFixed = false;
|
|
300
|
+
let inboundRefCount = 0;
|
|
301
|
+
let inboundRefPaths = [];
|
|
302
|
+
if (finalPath !== filePath) {
|
|
303
|
+
selfRefsFixed = updateRefsFromMovedFile(filePath, finalPath, config) > 0;
|
|
304
|
+
const inbound = updateRefsAfterMove(filePath, finalPath, config);
|
|
305
|
+
inboundRefCount = inbound.count;
|
|
306
|
+
inboundRefPaths = inbound.paths;
|
|
307
|
+
}
|
|
308
|
+
|
|
289
309
|
// Regen the index on every status change — `active → planned` etc. drift
|
|
290
310
|
// the per-status sections just as much as archive crossings. Archive paths
|
|
291
311
|
// also benefit (replaces the previously-gated regen). `--no-index` skips
|
|
@@ -298,10 +318,13 @@ export async function runStatus(argv, config, opts = {}) {
|
|
|
298
318
|
}
|
|
299
319
|
|
|
300
320
|
process.stdout.write(`${green(toRepoPath(finalPath, config.repoRoot))}: ${oldStatus ?? 'unknown'} → ${newStatus}\n`);
|
|
321
|
+
if (selfRefsFixed) process.stdout.write('Updated references in moved file.\n');
|
|
322
|
+
if (inboundRefCount > 0) process.stdout.write(`Updated references in ${inboundRefCount} file(s).\n`);
|
|
301
323
|
|
|
302
324
|
if (showFiles) {
|
|
303
325
|
const touched = [filePath];
|
|
304
326
|
if (finalPath !== filePath) touched.push(finalPath);
|
|
327
|
+
touched.push(...inboundRefPaths);
|
|
305
328
|
if (config.indexPath && !noIndex) touched.push(config.indexPath);
|
|
306
329
|
emitFilesFooter(touched, config);
|
|
307
330
|
}
|
|
@@ -434,26 +457,37 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
434
457
|
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
435
458
|
const oldStatus = asString(parsed.status) ?? 'unknown';
|
|
436
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
|
+
|
|
437
471
|
// Heal stuck frontmatter (issue #13): file is under archiveDir/ but its
|
|
438
472
|
// status hasn't been flipped. Flip in place; don't try to move (it's already
|
|
439
473
|
// archived on disk) and don't refuse — refusal leaves the drift permanent.
|
|
440
474
|
if (inArchiveDir) {
|
|
441
|
-
if (oldStatus ===
|
|
475
|
+
if (oldStatus === targetStatus) {
|
|
442
476
|
die(`Already archived: ${toRepoPath(filePath, config.repoRoot)}`);
|
|
443
477
|
}
|
|
444
478
|
const today = nowIso();
|
|
445
479
|
const repoPathHeal = toRepoPath(filePath, config.repoRoot);
|
|
446
480
|
if (dryRun) {
|
|
447
481
|
const prefix = dim('[dry-run]');
|
|
448
|
-
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`);
|
|
449
483
|
out.write(`${prefix} Would skip git mv (file already under \`${config.archiveDir}/\`)\n`);
|
|
450
484
|
return;
|
|
451
485
|
}
|
|
452
|
-
updateFrontmatter(filePath, { status:
|
|
486
|
+
updateFrontmatter(filePath, { status: targetStatus, updated: today });
|
|
453
487
|
const healEntry = `Archived (frontmatter healed in place from \`${oldStatus}\`)${note ? ` — ${note}` : '.'}`;
|
|
454
488
|
appendVersionHistory(filePath, healEntry, { createSection: Boolean(note) });
|
|
455
489
|
if (!noIndex) regenIndex(config);
|
|
456
|
-
out.write(`${green('✓ Healed')}: ${repoPathHeal} (${oldStatus} →
|
|
490
|
+
out.write(`${green('✓ Healed')}: ${repoPathHeal} (${oldStatus} → ${targetStatus}; file already under \`${config.archiveDir}/\`)\n`);
|
|
457
491
|
const touched = [repoPathHeal];
|
|
458
492
|
if (config.indexPath && !noIndex) touched.push(config.indexPath);
|
|
459
493
|
if (showFiles) emitFilesFooter(touched, config);
|
|
@@ -482,7 +516,7 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
482
516
|
} else if (closeoutAction?.action === 'skip') {
|
|
483
517
|
out.write(`${prefix} \`## Closeout\` section already present — no injection\n`);
|
|
484
518
|
}
|
|
485
|
-
out.write(`${prefix} Would update frontmatter: status: ${oldStatus} →
|
|
519
|
+
out.write(`${prefix} Would update frontmatter: status: ${oldStatus} → ${targetStatus}, updated: ${today}\n`);
|
|
486
520
|
if (note) {
|
|
487
521
|
out.write(`${prefix} Would append Version History: - **${today}** Archived — ${note}\n`);
|
|
488
522
|
}
|
|
@@ -507,7 +541,7 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
507
541
|
writeFileSync(filePath, `---\n${frontmatter}\n---\n${closeoutAction.newBody}`, 'utf8');
|
|
508
542
|
}
|
|
509
543
|
|
|
510
|
-
updateFrontmatter(filePath, { status:
|
|
544
|
+
updateFrontmatter(filePath, { status: targetStatus, updated: today });
|
|
511
545
|
appendVersionHistory(filePath, note ? `Archived — ${note}` : 'Archived.', { createSection: Boolean(note) });
|
|
512
546
|
|
|
513
547
|
mkdirSync(targetDir, { recursive: true });
|
|
@@ -591,7 +625,10 @@ export async function runSet(argv, config, opts = {}) {
|
|
|
591
625
|
const archiveArgs = [filePath];
|
|
592
626
|
if (noIndex) archiveArgs.push('--no-index');
|
|
593
627
|
if (showFiles) archiveArgs.push('--show-files');
|
|
594
|
-
|
|
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 });
|
|
595
632
|
}
|
|
596
633
|
|
|
597
634
|
// `partial` promises a successor tracking the deferred tail. When neither a
|
|
@@ -749,6 +786,28 @@ export function runTouch(argv, config, opts = {}) {
|
|
|
749
786
|
try { config.hooks.onTouch?.({ path: toRepoPath(filePath, config.repoRoot) }, { path: toRepoPath(filePath, config.repoRoot), date: today }); } catch (err) { warn(`Hook 'onTouch' threw: ${err.message}`); }
|
|
750
787
|
}
|
|
751
788
|
|
|
789
|
+
// Rewrite every frontmatter ref token (a `*.md` path in a YAML list item or an
|
|
790
|
+
// inline scalar, quoted or `>`-prefixed) that points at `oldPath` so it points
|
|
791
|
+
// at `newPath`. Each token is resolved doc-relative *and* repo-relative and
|
|
792
|
+
// compared to oldPath by absolute path — mirroring how the body-link branch and
|
|
793
|
+
// `updateRefsFromMovedFile` resolve refs. This replaces an older substring
|
|
794
|
+
// rewrite (`fm.split(oldRelPath).join(newRelPath)`) that only knew doc-relative
|
|
795
|
+
// paths, so it: left repo-relative cross-dir refs (`docs/plans/child.md` from
|
|
796
|
+
// `docs/rfcs/spec.md`) broken; mangled same-dir repo-relative refs into
|
|
797
|
+
// `docs/plans/../archived/child.md`; and could corrupt a `grandchild.md` ref
|
|
798
|
+
// when archiving `child.md` (suffix match). oldPath no longer exists on disk
|
|
799
|
+
// post-`git mv`, so existsSync-based resolveRefPath can't be used here.
|
|
800
|
+
function rewriteFrontmatterRefs(fm, docDir, oldPath, newPath, repoRoot) {
|
|
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) => {
|
|
804
|
+
const docRelAbs = path.resolve(docDir, token);
|
|
805
|
+
const repoRelAbs = path.resolve(repoRoot, token);
|
|
806
|
+
if (docRelAbs !== oldPath && repoRelAbs !== oldPath) return token;
|
|
807
|
+
return path.relative(docDir, newPath).split(path.sep).join('/');
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
|
|
752
811
|
/**
|
|
753
812
|
* After a file moves (archive/unarchive), update frontmatter references in all
|
|
754
813
|
* docs that pointed to the old location so they point to the new one.
|
|
@@ -766,17 +825,7 @@ function updateRefsAfterMove(oldPath, newPath, config) {
|
|
|
766
825
|
if (!fm) continue;
|
|
767
826
|
|
|
768
827
|
const docDir = path.dirname(docFile);
|
|
769
|
-
const
|
|
770
|
-
const newRelPath = path.relative(docDir, newPath).split(path.sep).join('/');
|
|
771
|
-
|
|
772
|
-
let newFm = fm;
|
|
773
|
-
if (newFm.includes(oldRelPath)) {
|
|
774
|
-
newFm = newFm.split(oldRelPath).join(newRelPath);
|
|
775
|
-
}
|
|
776
|
-
const dotSlashOld = './' + oldRelPath;
|
|
777
|
-
if (newFm.includes(dotSlashOld)) {
|
|
778
|
-
newFm = newFm.split(dotSlashOld).join(newRelPath);
|
|
779
|
-
}
|
|
828
|
+
const newFm = rewriteFrontmatterRefs(fm, docDir, oldPath, newPath, config.repoRoot);
|
|
780
829
|
|
|
781
830
|
// Body markdown links [text](path.md) or [text](path.md#anchor) pointing
|
|
782
831
|
// at oldPath. resolveRefPath can't be used here: oldPath no longer exists
|
|
@@ -815,24 +864,28 @@ function updateRefsFromMovedFile(oldPath, newPath, config) {
|
|
|
815
864
|
// when the source moves. Without the repo-root fallback, repo-relative refs
|
|
816
865
|
// silently skipped rewriting (existsSync on the doubled doc-relative path
|
|
817
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).
|
|
818
872
|
let newFm = frontmatter;
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
const newRelPath = path.relative(newDir, absTarget).split(path.sep).join('/');
|
|
824
|
-
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('/');
|
|
825
877
|
});
|
|
826
878
|
|
|
827
|
-
// 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.
|
|
828
881
|
let newBody = body;
|
|
829
|
-
const linkRegex = /(\[[^\]]*\]\()([^)]+\.md)(\))/g;
|
|
830
|
-
newBody = newBody.replace(linkRegex, (match, pre, href, post) => {
|
|
831
|
-
if (
|
|
882
|
+
const linkRegex = /(\[[^\]]*\]\()([^)#]+\.md)(#[^)]*)?(\))/g;
|
|
883
|
+
newBody = newBody.replace(linkRegex, (match, pre, href, frag, post) => {
|
|
884
|
+
if (/^https?:/i.test(href)) return match;
|
|
832
885
|
const absTarget = resolveRefPath(href, oldDir, config.repoRoot);
|
|
833
886
|
if (!absTarget) return match;
|
|
834
887
|
const newHref = path.relative(newDir, absTarget).split(path.sep).join('/');
|
|
835
|
-
return `${pre}${newHref}${post}`;
|
|
888
|
+
return `${pre}${newHref}${frag ?? ''}${post}`;
|
|
836
889
|
});
|
|
837
890
|
|
|
838
891
|
if (newFm !== frontmatter || newBody !== body) {
|
|
@@ -856,8 +909,7 @@ function countRefsToUpdate(oldPath, newPath, config) {
|
|
|
856
909
|
if (!fm) continue;
|
|
857
910
|
|
|
858
911
|
const docDir = path.dirname(docFile);
|
|
859
|
-
const
|
|
860
|
-
const fmHit = fm.includes(oldRelPath) || fm.includes('./' + oldRelPath);
|
|
912
|
+
const fmHit = rewriteFrontmatterRefs(fm, docDir, oldPath, newPath, config.repoRoot) !== fm;
|
|
861
913
|
|
|
862
914
|
let bodyHit = false;
|
|
863
915
|
if (!fmHit) {
|
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
|
}
|
|
@@ -162,6 +167,7 @@ export function runRunlists(index, argv, config) {
|
|
|
162
167
|
status: d.status,
|
|
163
168
|
title: d.title,
|
|
164
169
|
childCount: coordination.get(d.path)?.childCount ?? 0,
|
|
170
|
+
nextPickup: coordination.get(d.path)?.nextPickup ?? null,
|
|
165
171
|
updated: d.updated,
|
|
166
172
|
nextStep: d.nextStep ?? null,
|
|
167
173
|
}));
|
|
@@ -782,9 +788,18 @@ function renderCoordinationSection(coordDocs, coordination, maxWidth, total) {
|
|
|
782
788
|
const statusTag = doc.status && doc.status !== 'active' ? ` ${colorTag(doc.status)}` : '';
|
|
783
789
|
const desc = (doc.nextStep || doc.currentState || doc.title || '').replace(/\s+/g, ' ').trim();
|
|
784
790
|
|
|
791
|
+
// Next-pickup (first non-archived ranked child from the body) leads the
|
|
792
|
+
// description column when one resolves — it's the most actionable cell. Its
|
|
793
|
+
// status is shown only when it's not the expected `active`.
|
|
794
|
+
const next = info?.nextPickup;
|
|
795
|
+
const nextStr = next
|
|
796
|
+
? `${green('→')} ${next.label}${next.status && next.status !== 'active' ? dim(` (${next.status})`) : ''}`
|
|
797
|
+
: '';
|
|
798
|
+
const nextPart = nextStr ? `${nextStr} ` : '';
|
|
799
|
+
|
|
785
800
|
const left = ` ${slug} ${ageStr} ${dim(count)} `;
|
|
786
|
-
const budget = Math.max(10, maxWidth - visibleLen(left) - visibleLen(statusTag) - 2);
|
|
801
|
+
const budget = Math.max(10, maxWidth - visibleLen(left) - visibleLen(nextPart) - visibleLen(statusTag) - 2);
|
|
787
802
|
const descR = desc.length > budget ? desc.slice(0, budget - 3) + '...' : desc;
|
|
788
|
-
process.stdout.write(`${left}${dim(descR)}${statusTag}\n`);
|
|
803
|
+
process.stdout.write(`${left}${nextPart}${dim(descR)}${statusTag}\n`);
|
|
789
804
|
}
|
|
790
805
|
}
|