dotmd-cli 0.39.6 → 0.39.7

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)
@@ -897,6 +898,40 @@ directory, updates references, and regenerates the index.
897
898
 
898
899
  Use --dry-run (-n) to preview changes without writing anything.`,
899
900
 
901
+ runlist: `dotmd runlist <hub> [next] — work with an ordered group of plans
902
+
903
+ A "runlist" is just a plan with a \`runlist:\` array of child plan paths in its
904
+ frontmatter — there is no separate doc type. The hub plan can have any status;
905
+ the order of the children comes from the array.
906
+
907
+ Usage:
908
+ dotmd runlist <hub> Show children + their statuses, in order.
909
+ The first non-archived child is marked \`→\`.
910
+ dotmd runlist next <hub> Pick up the first non-archived child.
911
+ Stops if it's not in a pickup-able status
912
+ (active / planned / in-session) so you resolve
913
+ the blocker before continuing the runlist.
914
+
915
+ Flags (only meaningful with \`next\`, forwarded to pickup):
916
+ --takeover Override a held lease.
917
+ --full Print full plan body instead of the pickup card.
918
+ --no-index Skip index regeneration.
919
+ --show-files Emit \`files: …\` footer.
920
+
921
+ Common shape:
922
+ ---
923
+ type: plan
924
+ status: active
925
+ title: Auth Revamp
926
+ runlist:
927
+ - auth-revamp-01-extract.md
928
+ - auth-revamp-02-rewrite.md
929
+ - auth-revamp-03-cleanup.md
930
+ ---
931
+
932
+ Child plans should set \`parent_plan:\` back at the hub — \`dotmd check\` warns
933
+ when they don't.`,
934
+
900
935
  'bulk-tag': `dotmd bulk-tag [files...] — fill in type/status frontmatter on pre-existing markdown
901
936
 
902
937
  Scans the docs tree for files that are missing either \`type:\` or \`status:\`
@@ -1061,6 +1096,7 @@ async function main() {
1061
1096
  if (command === 'journal') { const { runJournal } = await import('../src/journal-read.mjs'); runJournal(restArgs, config); return; }
1062
1097
  if (command === 'pickup') { const { runPickup } = await import('../src/lifecycle.mjs'); await runPickup(restArgs, config, { dryRun }); return; }
1063
1098
  if (command === 'unpickup' || command === 'release') { const { runUnpickup } = await import('../src/lifecycle.mjs'); await runUnpickup(restArgs, config, { dryRun }); return; }
1099
+ if (command === 'runlist') { const { runRunlist } = await import('../src/runlist.mjs'); await runRunlist(restArgs, config, { dryRun }); return; }
1064
1100
  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
1101
  if (command === 'finish') { const { runFinish } = await import('../src/lifecycle.mjs'); await runFinish(restArgs, config, { dryRun }); return; }
1066
1102
  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.7",
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);