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/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. If a `.claude/` exists, sweep any retired
337
- // generated command files (banner-gated, so hand-authored ones survive) and
338
- // point the user at the plugin instead.
339
- if (existsSync(path.join(cwd, '.claude'))) {
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 === 'archived') {
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} → archived, updated: ${today}\n`);
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: 'archived', updated: today });
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} → archived; file already under \`${config.archiveDir}/\`)\n`);
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} → archived, updated: ${today}\n`);
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: 'archived', updated: today });
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
- return runArchive(archiveArgs, config, { dryRun, note });
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 oldRelPath = path.relative(docDir, oldPath).split(path.sep).join('/');
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
- const refRegex = /^(\s+-\s+)(\S+\.md)$/gm;
820
- newFm = newFm.replace(refRegex, (match, prefix, refPath) => {
821
- const absTarget = resolveRefPath(refPath, oldDir, config.repoRoot);
822
- if (!absTarget) return match;
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 (href.startsWith('http')) return match;
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 oldRelPath = path.relative(docDir, oldPath).split(path.sep).join('/');
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
- const body = template.body(docTitle, tmplCtx);
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
  }