dotmd-cli 0.39.6 → 0.39.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/dotmd.mjs CHANGED
@@ -49,6 +49,7 @@ Validate & Fix:
49
49
  Lifecycle:
50
50
  pickup <file> [--takeover] Pick up a plan (set in-session + print body)
51
51
  release [<file>] [--to <s>] Release in-session lease (alias: unpickup)
52
+ runlist <hub> [next] Show or walk an ordered group of plans (see \`dotmd help runlist\`)
52
53
  finish <file> [done|active] Finish a plan (set done or active)
53
54
  status <file> <status> Transition document status
54
55
  archive <file> Archive (status + move + update refs)
@@ -580,10 +581,16 @@ Types and their default destinations:
580
581
  \`<name>\` is slugified for the filename.
581
582
 
582
583
  Body input (all built-in types — required for prompt, optional for plan/doc):
583
- <text> Inline body as 3rd positional
584
- --message "<text>" Explicit inline body
584
+ @path Read body from a file (preferred for multi-line bodies)
585
585
  - Read body from stdin (heredoc-friendly for agents)
586
- @path Read body from a file
586
+ --message "<text>" Explicit inline body
587
+ <text> Inline body as 3rd positional
588
+
589
+ Tip for agents: prefer \`@path\` or \`-\` for multi-line bodies. Inline bodies
590
+ put the entire content on the bash command line, which (a) breaks under shell
591
+ quoting for backticks/dollar-signs and (b) trips PreToolUse hooks that scan
592
+ command strings for forbidden literals (destructive-git patterns, etc.).
593
+ \`@/tmp/foo.md\` sidesteps both.
587
594
 
588
595
  For plan/doc, a single-section body lands under the type's first scaffolded
589
596
  section (e.g. \`## Problem\` for plans). If the body already authors
@@ -593,19 +600,19 @@ the title + your body is emitted — no duplicated empty outline below
593
600
 
594
601
  Examples:
595
602
  dotmd new plan auth-revamp
596
- dotmd new plan auth-revamp "Investigation findings before scoping…"
603
+ dotmd new prompt resume-foo @/tmp/draft.md
604
+ dotmd new prompt resume-foo - <<'EOF'
605
+ multi-line
606
+ prompt body
607
+ EOF
608
+ dotmd new prompt cleanup-tomorrow "look at remaining lint warnings"
597
609
  dotmd new plan full-spec - <<'EOF'
598
610
  ## Problem
599
611
 
600
612
  ## Phases
601
613
 
602
614
  EOF
603
- dotmd new prompt cleanup-tomorrow "look at remaining lint warnings"
604
- dotmd new prompt resume-foo - <<'EOF'
605
- multi-line
606
- prompt body
607
- EOF
608
- dotmd new prompt from-file @/tmp/draft.md
615
+ dotmd new plan auth-revamp "Investigation findings before scoping…"
609
616
 
610
617
  Other options:
611
618
  --status <s> Set initial status (defaults to first valid status for the type)
@@ -897,6 +904,40 @@ directory, updates references, and regenerates the index.
897
904
 
898
905
  Use --dry-run (-n) to preview changes without writing anything.`,
899
906
 
907
+ runlist: `dotmd runlist <hub> [next] — work with an ordered group of plans
908
+
909
+ A "runlist" is just a plan with a \`runlist:\` array of child plan paths in its
910
+ frontmatter — there is no separate doc type. The hub plan can have any status;
911
+ the order of the children comes from the array.
912
+
913
+ Usage:
914
+ dotmd runlist <hub> Show children + their statuses, in order.
915
+ The first non-archived child is marked \`→\`.
916
+ dotmd runlist next <hub> Pick up the first non-archived child.
917
+ Stops if it's not in a pickup-able status
918
+ (active / planned / in-session) so you resolve
919
+ the blocker before continuing the runlist.
920
+
921
+ Flags (only meaningful with \`next\`, forwarded to pickup):
922
+ --takeover Override a held lease.
923
+ --full Print full plan body instead of the pickup card.
924
+ --no-index Skip index regeneration.
925
+ --show-files Emit \`files: …\` footer.
926
+
927
+ Common shape:
928
+ ---
929
+ type: plan
930
+ status: active
931
+ title: Auth Revamp
932
+ runlist:
933
+ - auth-revamp-01-extract.md
934
+ - auth-revamp-02-rewrite.md
935
+ - auth-revamp-03-cleanup.md
936
+ ---
937
+
938
+ Child plans should set \`parent_plan:\` back at the hub — \`dotmd check\` warns
939
+ when they don't.`,
940
+
900
941
  'bulk-tag': `dotmd bulk-tag [files...] — fill in type/status frontmatter on pre-existing markdown
901
942
 
902
943
  Scans the docs tree for files that are missing either \`type:\` or \`status:\`
@@ -1061,6 +1102,7 @@ async function main() {
1061
1102
  if (command === 'journal') { const { runJournal } = await import('../src/journal-read.mjs'); runJournal(restArgs, config); return; }
1062
1103
  if (command === 'pickup') { const { runPickup } = await import('../src/lifecycle.mjs'); await runPickup(restArgs, config, { dryRun }); return; }
1063
1104
  if (command === 'unpickup' || command === 'release') { const { runUnpickup } = await import('../src/lifecycle.mjs'); await runUnpickup(restArgs, config, { dryRun }); return; }
1105
+ if (command === 'runlist') { const { runRunlist } = await import('../src/runlist.mjs'); await runRunlist(restArgs, config, { dryRun }); return; }
1064
1106
  if (command === 'handoff') { die('`dotmd handoff` was removed in 0.31.0. Use `dotmd prompts new <name>` to create a saved prompt instead. The .dotmd/handoffs/ sidecar mechanism no longer exists; see CHANGELOG.'); }
1065
1107
  if (command === 'finish') { const { runFinish } = await import('../src/lifecycle.mjs'); await runFinish(restArgs, config, { dryRun }); return; }
1066
1108
  if (command === 'status') { const { runStatus } = await import('../src/lifecycle.mjs'); await runStatus(restArgs, config, { dryRun }); return; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.39.6",
3
+ "version": "0.39.8",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, Notion sync, AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.mjs CHANGED
@@ -89,7 +89,7 @@ const DEFAULTS = {
89
89
  // resolver work without config. Override by setting `referenceFields`
90
90
  // in your config — your value fully replaces (no per-field merge).
91
91
  bidirectional: ['related_plans', 'related_docs'],
92
- unidirectional: ['parent_plan'],
92
+ unidirectional: ['parent_plan', 'runlist'],
93
93
  },
94
94
 
95
95
  templates: {},
package/src/index.mjs CHANGED
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
4
  import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts, extractBodyLinks } from './extractors.mjs';
5
5
  import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath, warn } from './util.mjs';
6
- import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalReferences, checkGitStaleness, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate, enrichRefErrorSuggestions } from './validate.mjs';
6
+ import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalReferences, checkGitStaleness, checkRunlistBackPointers, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate, enrichRefErrorSuggestions } from './validate.mjs';
7
7
  import { checkIndex } from './index-file.mjs';
8
8
  import { checkClaudeCommands } from './claude-commands.mjs';
9
9
 
@@ -96,6 +96,13 @@ export function buildIndex(config, opts = {}) {
96
96
  const refCheck = checkBidirectionalReferences(transformedDocs, config);
97
97
  warnings.push(...refCheck.warnings);
98
98
 
99
+ const runlistWarnings = checkRunlistBackPointers(transformedDocs, config);
100
+ warnings.push(...runlistWarnings);
101
+ for (const w of runlistWarnings) {
102
+ const child = transformedDocs.find(d => d.path === w.path);
103
+ if (child) child.warnings.push(w);
104
+ }
105
+
99
106
  const gitWarnings = checkGitStaleness(transformedDocs, config);
100
107
  warnings.push(...gitWarnings);
101
108
 
package/src/init.mjs CHANGED
@@ -64,7 +64,7 @@ export const index = {
64
64
  // Add field names here (and to your templates) to track more relationships.
65
65
  export const referenceFields = {
66
66
  bidirectional: ['related_plans', 'related_docs'],
67
- unidirectional: ['parent_plan'],
67
+ unidirectional: ['parent_plan', 'runlist'],
68
68
  };
69
69
  `;
70
70
 
@@ -0,0 +1,169 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
+ import {
5
+ asString,
6
+ die,
7
+ normalizeStringList,
8
+ resolveDocPath,
9
+ resolveRefPath,
10
+ toRepoPath,
11
+ toSlug,
12
+ } from './util.mjs';
13
+ import { bold, cyan, dim, green, red, yellow } from './color.mjs';
14
+
15
+ const PICKUPABLE_STATUSES = new Set(['active', 'planned', 'in-session']);
16
+
17
+ // Read a hub plan's `runlist:` and resolve each entry to a repo-relative path
18
+ // plus its current status. Missing files are reported with `missing: true`;
19
+ // callers decide how to render them. Pure: no IO beyond file reads.
20
+ function readRunlistChildren(hubAbsPath, config) {
21
+ const raw = readFileSync(hubAbsPath, 'utf8');
22
+ const { frontmatter: fmRaw } = extractFrontmatter(raw);
23
+ const fm = parseSimpleFrontmatter(fmRaw);
24
+ const refs = normalizeStringList(fm.runlist);
25
+
26
+ const hubDir = path.dirname(hubAbsPath);
27
+ const out = [];
28
+ for (const ref of refs) {
29
+ const abs = resolveRefPath(ref, hubDir, config.repoRoot);
30
+ if (!abs) {
31
+ out.push({ ref, path: null, status: null, title: null, missing: true });
32
+ continue;
33
+ }
34
+ const repoPath = toRepoPath(abs, config.repoRoot);
35
+ try {
36
+ const childRaw = readFileSync(abs, 'utf8');
37
+ const { frontmatter: childFmRaw } = extractFrontmatter(childRaw);
38
+ const childFm = parseSimpleFrontmatter(childFmRaw);
39
+ out.push({
40
+ ref,
41
+ path: repoPath,
42
+ status: asString(childFm.status) ?? null,
43
+ title: asString(childFm.title) ?? path.basename(abs, '.md'),
44
+ parentPlan: childFm.parent_plan ?? null,
45
+ missing: false,
46
+ });
47
+ } catch {
48
+ out.push({ ref, path: repoPath, status: null, title: null, missing: true });
49
+ }
50
+ }
51
+ return out;
52
+ }
53
+
54
+ const STATUS_TAG_COLORS = {
55
+ 'in-session': (s) => bold(red(s)),
56
+ 'active': green,
57
+ 'planned': (s) => s,
58
+ 'blocked': yellow,
59
+ 'partial': (s) => dim(green(s)),
60
+ 'paused': (s) => yellow(s),
61
+ 'awaiting': yellow,
62
+ 'queued-after': (s) => dim(cyan(s)),
63
+ 'archived': dim,
64
+ };
65
+
66
+ function colorStatus(status) {
67
+ const fn = STATUS_TAG_COLORS[status] ?? ((s) => s);
68
+ return fn(status ?? 'unknown');
69
+ }
70
+
71
+ function renderRunlist(hubRepoPath, children, opts = {}) {
72
+ const lines = [];
73
+ lines.push(bold(`runlist: ${hubRepoPath}`));
74
+ if (children.length === 0) {
75
+ lines.push(dim(' (empty — add child plan paths to the hub plan\'s `runlist:` field)'));
76
+ return lines.join('\n') + '\n';
77
+ }
78
+
79
+ const archiveStatuses = opts.archiveStatuses ?? new Set(['archived']);
80
+ let nextPicked = false;
81
+ for (let i = 0; i < children.length; i++) {
82
+ const c = children[i];
83
+ const idx = String(i + 1).padStart(2);
84
+ if (c.missing) {
85
+ lines.push(` ${idx}. ${red('missing')} ${c.ref}`);
86
+ continue;
87
+ }
88
+ const isNext = !nextPicked && !archiveStatuses.has(c.status);
89
+ if (isNext) nextPicked = true;
90
+ const marker = isNext ? green('→') : ' ';
91
+ const statusTag = `[${colorStatus(c.status)}]`;
92
+ lines.push(` ${marker} ${idx}. ${statusTag} ${c.path}`);
93
+ }
94
+ if (!nextPicked) {
95
+ lines.push('');
96
+ lines.push(dim(' All children archived. Hub is ready for archive.'));
97
+ }
98
+ return lines.join('\n') + '\n';
99
+ }
100
+
101
+ export async function runRunlist(argv, config, opts = {}) {
102
+ const json = argv.includes('--json');
103
+ const positional = argv.filter(a => !a.startsWith('-'));
104
+
105
+ // Subcommand dispatch: `runlist <hub>` (show) vs `runlist next <hub>` (pickup)
106
+ const sub = positional[0] === 'next' ? 'next' : 'show';
107
+ const hubInput = sub === 'next' ? positional[1] : positional[0];
108
+
109
+ if (!hubInput) {
110
+ die(sub === 'next'
111
+ ? 'Usage: dotmd runlist next <hub-plan>'
112
+ : 'Usage: dotmd runlist <hub-plan>');
113
+ }
114
+
115
+ const hubAbs = resolveDocPath(hubInput, config);
116
+ if (!hubAbs) die(`Hub plan not found: ${hubInput}`);
117
+ const hubRepoPath = toRepoPath(hubAbs, config.repoRoot);
118
+
119
+ const children = readRunlistChildren(hubAbs, config);
120
+ const archiveStatuses = config.lifecycle?.archiveStatuses ?? new Set(['archived']);
121
+
122
+ if (sub === 'show') {
123
+ if (json) {
124
+ process.stdout.write(JSON.stringify({
125
+ hub: hubRepoPath,
126
+ children,
127
+ }, null, 2) + '\n');
128
+ return;
129
+ }
130
+ process.stdout.write(renderRunlist(hubRepoPath, children, { archiveStatuses }));
131
+ return;
132
+ }
133
+
134
+ // sub === 'next' — find first non-archived non-missing child and pick it up.
135
+ const target = children.find(c => !c.missing && !archiveStatuses.has(c.status));
136
+ if (!target) {
137
+ if (children.length === 0) die(`Hub ${hubRepoPath} has empty \`runlist:\` — nothing to pick up.`);
138
+ const allArchived = children.every(c => !c.missing && archiveStatuses.has(c.status));
139
+ if (allArchived) {
140
+ die(`All children in runlist ${hubRepoPath} are archived. Hub is ready for \`dotmd archive ${hubRepoPath}\`.`);
141
+ }
142
+ const missing = children.filter(c => c.missing).map(c => c.ref);
143
+ die(`No pickup-able child in runlist ${hubRepoPath}. Unresolved refs: ${missing.join(', ')}`);
144
+ }
145
+
146
+ // Pre-check status: pickup will die on non-pickup-able statuses, but with
147
+ // a generic message. Surface the runlist context first so the agent knows
148
+ // which list is blocked and on which item.
149
+ if (!PICKUPABLE_STATUSES.has(target.status)) {
150
+ die(
151
+ `Next child in runlist ${hubRepoPath} is ${target.path} (status: ${target.status}).\n` +
152
+ `Resolve the blocker before continuing the runlist.\n` +
153
+ ` dotmd status ${target.path} active # if ready to resume\n` +
154
+ ` dotmd pickup ${target.path} # to inspect`,
155
+ );
156
+ }
157
+
158
+ // Delegate to runPickup — same lease semantics, same VH append, same card
159
+ // render. Dynamic import to avoid circular module-load cost when the
160
+ // runlist command isn't used.
161
+ const { runPickup } = await import('./lifecycle.mjs');
162
+ const pickupArgs = [target.path];
163
+ if (argv.includes('--takeover')) pickupArgs.push('--takeover');
164
+ if (argv.includes('--full')) pickupArgs.push('--full');
165
+ if (argv.includes('--no-index')) pickupArgs.push('--no-index');
166
+ if (argv.includes('--show-files')) pickupArgs.push('--show-files');
167
+ if (json) pickupArgs.push('--json');
168
+ await runPickup(pickupArgs, config, opts);
169
+ }
package/src/validate.mjs CHANGED
@@ -358,6 +358,50 @@ export function checkBidirectionalReferences(docs, config) {
358
358
  return { warnings, errors: [] };
359
359
  }
360
360
 
361
+ // Runlist back-pointer check: when a hub plan declares a `runlist:` of children,
362
+ // each child SHOULD have `parent_plan:` pointing back at the hub. The two
363
+ // fields encode complementary information (runlist = ordered execution intent;
364
+ // parent_plan = reverse-link the rest of dotmd already uses for related-summary
365
+ // rendering), so divergence almost always means the agent forgot the back-link.
366
+ // Warning fires on the CHILD (that's the file that needs the edit). Skips
367
+ // terminal/archive statuses on either side — runlists referencing closed work
368
+ // are a normal history pattern.
369
+ export function checkRunlistBackPointers(docs, config) {
370
+ const warnings = [];
371
+ const skipStatuses = new Set([
372
+ ...(config.lifecycle.terminalStatuses ?? []),
373
+ ...(config.lifecycle.skipWarningsFor ?? []),
374
+ ]);
375
+ const byPath = new Map(docs.map(d => [d.path, d]));
376
+
377
+ for (const hub of docs) {
378
+ if (skipStatuses.has(hub.status)) continue;
379
+ const runlistRefs = hub.refFields?.runlist ?? [];
380
+ if (runlistRefs.length === 0) continue;
381
+ const hubDir = path.dirname(path.join(config.repoRoot, hub.path));
382
+
383
+ for (const ref of runlistRefs) {
384
+ const resolved = resolveRefPath(ref, hubDir, config.repoRoot);
385
+ if (!resolved) continue; // unresolved refs already get their own existence error
386
+ const childPath = toRepoPath(resolved, config.repoRoot);
387
+ const child = byPath.get(childPath);
388
+ if (!child) continue;
389
+ if (skipStatuses.has(child.status)) continue;
390
+ const childParents = (child.refFields?.parent_plan ?? []).map(p => {
391
+ const abs = resolveRefPath(p, path.dirname(path.join(config.repoRoot, child.path)), config.repoRoot);
392
+ return abs ? toRepoPath(abs, config.repoRoot) : p;
393
+ });
394
+ if (childParents.includes(hub.path)) continue;
395
+ warnings.push({
396
+ path: child.path,
397
+ level: 'warning',
398
+ message: `appears in runlist of \`${hub.path}\` but \`parent_plan:\` does not point back at it. Add \`parent_plan: ${hub.path}\` so reverse-link tooling (pickup-card Related:, graph) stays consistent.`,
399
+ });
400
+ }
401
+ }
402
+ return warnings;
403
+ }
404
+
361
405
  export function checkGitStaleness(docs, config) {
362
406
  const warnings = [];
363
407
  const gitDates = getGitLastModifiedBatch(config.repoRoot);