dotmd-cli 0.65.0 → 0.66.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/bin/dotmd.mjs CHANGED
@@ -1219,12 +1219,19 @@ frontmatter — there is no separate doc type. The hub plan can have any status;
1219
1219
  the order of the children comes from the array.
1220
1220
 
1221
1221
  Usage:
1222
- dotmd runlist <hub> Show children + their statuses, in order.
1223
- The first non-archived child is marked \`→\`.
1224
- dotmd runlist next <hub> Open the first non-archived child (marks it
1225
- in-session + prints it). Stops if it's not in a
1226
- workable status (active / planned / in-session)
1227
- so you resolve the blocker first.
1222
+ dotmd runlist <hub> Show children + their statuses, in order. The
1223
+ first pickup-able child (active / planned /
1224
+ in-session) is marked \`→\`. Archived (done) and
1225
+ parked children (blocked / partial / paused /
1226
+ awaiting / queued-after) are skipped — \`→\`
1227
+ advances to the first child you can actually
1228
+ start. Parked ≠ done: they don't count toward
1229
+ done/total.
1230
+ dotmd runlist next <hub> Open the first pickup-able child (marks it
1231
+ in-session + prints it), advancing past archived
1232
+ and parked children. If every remaining child is
1233
+ parked, stops and lists them + the unstick verbs
1234
+ so you resolve a blocker first.
1228
1235
  dotmd runlist add <hub> <child...>
1229
1236
  Append children to the hub's \`runlist:\` array
1230
1237
  (no more hand-editing the YAML). Each child can be:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.65.0",
3
+ "version": "0.66.0",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, Notion sync, AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/doctor.mjs CHANGED
@@ -8,6 +8,7 @@ import { renderIndexFile, writeIndex } from './index-file.mjs';
8
8
  import { renderCheck, renderManualFixes } from './render.mjs';
9
9
  import { bold, dim, green, yellow } from './color.mjs';
10
10
  import { checkClaudeCommands, removeGeneratedSlashCommands } from './claude-commands.mjs';
11
+ import { checkSkillDrift } from './skill-drift.mjs';
11
12
  import { runMigrateTemplate } from './migrate-template.mjs';
12
13
  import { runMigratePrompts } from './migrate-prompts.mjs';
13
14
  import { runFrontmatterFix } from './frontmatter-fix.mjs';
@@ -191,6 +192,7 @@ function runDoctorProject(config, { json = false } = {}) {
191
192
  const claudeCommandWarnings = checkClaudeCommands(config.repoRoot);
192
193
  const deprecatedCommandMentions = findDeprecatedCommandMentions(config);
193
194
  const { docsWithoutFrontmatter, planStatusGaps } = findWorkflowDrift(config);
195
+ const skillDriftWarnings = checkSkillDrift(config);
194
196
  const result = {
195
197
  cliVersion: cliPackage?.version ?? null,
196
198
  packageDependency: depVersion,
@@ -198,6 +200,7 @@ function runDoctorProject(config, { json = false } = {}) {
198
200
  deprecatedCommandMentions,
199
201
  docsWithoutFrontmatter,
200
202
  planStatusGaps,
203
+ skillDriftWarnings,
201
204
  };
202
205
 
203
206
  if (json) {
@@ -226,6 +229,11 @@ function runDoctorProject(config, { json = false } = {}) {
226
229
  } else {
227
230
  process.stdout.write('- plan status vocab: ok\n');
228
231
  }
232
+ if (skillDriftWarnings.length) {
233
+ process.stdout.write(yellow(`- canonical workflow block: drifted — CLAUDE.md and the plugin SKILL.md teach different workflows. Reconcile the block between the \`dotmd:canonical-workflow\` markers in both files.`) + '\n');
234
+ } else {
235
+ process.stdout.write('- canonical workflow block: in sync\n');
236
+ }
229
237
  return result;
230
238
  }
231
239
 
package/src/index.mjs CHANGED
@@ -7,6 +7,7 @@ import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalRef
7
7
  import { checkIndex } from './index-file.mjs';
8
8
  import { checkClaudeCommands } from './claude-commands.mjs';
9
9
  import { checkGlossaryConfig } from './glossary-check.mjs';
10
+ import { checkSkillDrift } from './skill-drift.mjs';
10
11
 
11
12
  // `fast: true` skips every pass that produces warnings/errors — the rendered
12
13
  // index file consumes only status/title/snapshot/etc., not the validation
@@ -135,6 +136,9 @@ export function buildIndex(config, opts = {}) {
135
136
 
136
137
  const glossaryWarnings = checkGlossaryConfig(config);
137
138
  warnings.push(...glossaryWarnings);
139
+
140
+ const skillDriftWarnings = checkSkillDrift(config);
141
+ warnings.push(...skillDriftWarnings);
138
142
  }
139
143
 
140
144
  return {
package/src/query.mjs CHANGED
@@ -768,9 +768,13 @@ function renderHubBlock(hub, info, children, maxWidth, topMaxSlug) {
768
768
  const hubSlug = toSlug(hub);
769
769
  const nextDoc = info.nextChildPath ? info.children.find(c => c.path === info.nextChildPath)?.doc : null;
770
770
  const nextLabel = nextDoc ? stripHubPrefix(toSlug(nextDoc), hubSlug) : null;
771
+ // No pickup-able child: "N parked" when a live-but-unstartable child remains
772
+ // (the runlist is stuck, not finished), else "all archived" (truly done).
771
773
  const descr = nextLabel
772
774
  ? `runlist · ${info.doneCount}/${info.total} · next → ${nextLabel}`
773
- : `runlist · ${info.doneCount}/${info.total} · all archived`;
775
+ : info.parkedCount > 0
776
+ ? `runlist · ${info.doneCount}/${info.total} · ${info.parkedCount} parked`
777
+ : `runlist · ${info.doneCount}/${info.total} · all archived`;
774
778
 
775
779
  // Header row: hub slug + descriptor, with `[RUNLIST]` right-aligned like a tag.
776
780
  const slugCell = hubSlug.padEnd(topMaxSlug);
package/src/runlist.mjs CHANGED
@@ -20,6 +20,20 @@ import { bold, cyan, dim, green, red, yellow } from './color.mjs';
20
20
 
21
21
  const PICKUPABLE_STATUSES = new Set(['active', 'planned', 'in-session']);
22
22
 
23
+ // A child is the runlist's NEXT PICKUP only when a session could start it right
24
+ // now — i.e. its status is one `dotmd use` accepts. The "parked" statuses
25
+ // (blocked/partial/paused/awaiting/queued-after) are deliberately NOT
26
+ // pickup-able: each needs its own unstuck action (monitor / spawn successor /
27
+ // re-evaluate / ask / check predecessor) before work resumes. So next-pickup
28
+ // resolution skips them and advances to the first child that's actually
29
+ // startable. Skipping ≠ done, though: a parked child is not archived, so it
30
+ // never counts toward `done/total` — that tally tracks closed (archived) only.
31
+ // This keeps the `→` marker in agreement with `runlist next`, which already
32
+ // gates on PICKUPABLE_STATUSES.
33
+ function isPickupable(status) {
34
+ return PICKUPABLE_STATUSES.has(status);
35
+ }
36
+
23
37
  // Build a hub/child map straight from the in-memory index — no disk IO. A doc
24
38
  // is a runlist *hub* when its `runlist:` frontmatter (`refFields.runlist`) is
25
39
  // non-empty. Each ref resolves to a doc in the index by path, falling back to
@@ -61,11 +75,17 @@ export function buildRunlistIndex(index, config) {
61
75
  }
62
76
  }
63
77
 
64
- const next = children.find(c => !c.missing && !c.archived) ?? null;
78
+ // Next pickup = first child a session can actually start. Skip archived
79
+ // (done) AND parked children alike; advance to the first pickup-able one.
80
+ const next = children.find(c => !c.missing && !c.archived && isPickupable(c.status)) ?? null;
65
81
  hubs.set(hub.path, {
66
82
  hub,
67
83
  total: children.length,
68
84
  doneCount: children.filter(c => c.archived).length,
85
+ // Live-but-not-startable children (parked: blocked/partial/paused/…). Lets
86
+ // the `dotmd plans` fold say "N parked" instead of mislabelling a hub with
87
+ // a parked-but-unfinished child as "all archived".
88
+ parkedCount: children.filter(c => !c.missing && !c.archived && !isPickupable(c.status)).length,
69
89
  children,
70
90
  nextChildPath: next?.path ?? null,
71
91
  });
@@ -271,6 +291,11 @@ function resolveHubNextPickup(hubDoc, hubDir, resolveRef, archiveStatuses, confi
271
291
  if (child.type && child.type !== 'plan') continue;
272
292
  const archived = archiveStatuses.has(child.status) || isArchivedPath(child.path, config);
273
293
  if (archived) continue;
294
+ // Skip parked ranked children too (blocked/partial/paused/awaiting/
295
+ // queued-after) — the hub's next-pickup is the first startable plan, the
296
+ // same gate sprint runlists use, so a hub never points `→` at a child a
297
+ // session can't actually pick up.
298
+ if (!isPickupable(child.status)) continue;
274
299
  return { path: child.path, status: child.status ?? null, label: coordinationChildLabel(child, hubDoc) };
275
300
  }
276
301
  return null;
@@ -330,7 +355,9 @@ function renderRunlist(hubRepoPath, children, opts = {}) {
330
355
  lines.push(` ${idx}. ${red('missing')} ${c.ref}`);
331
356
  continue;
332
357
  }
333
- const isNext = !nextPicked && !archiveStatuses.has(c.status);
358
+ // → marks the first child a session can actually start: skip archived and
359
+ // parked (blocked/partial/paused/awaiting/queued-after) alike.
360
+ const isNext = !nextPicked && isPickupable(c.status);
334
361
  if (isNext) nextPicked = true;
335
362
  const marker = isNext ? green('→') : ' ';
336
363
  const statusTag = `[${colorStatus(c.status)}]`;
@@ -338,7 +365,12 @@ function renderRunlist(hubRepoPath, children, opts = {}) {
338
365
  }
339
366
  if (!nextPicked) {
340
367
  lines.push('');
341
- lines.push(dim(' All children archived. Hub is ready for archive.'));
368
+ // No pickup-able child. Distinguish "done — ready to archive" from "stuck —
369
+ // a parked child needs unsticking" so the agent knows which it is.
370
+ const parked = children.filter(c => !c.missing && !archiveStatuses.has(c.status));
371
+ lines.push(dim(parked.length === 0
372
+ ? ' All children archived. Hub is ready for archive.'
373
+ : ` No pickup-able child — ${parked.length} parked. Unstick one (e.g. \`dotmd set active <child>\`) to continue.`));
342
374
  }
343
375
  return lines.join('\n') + '\n';
344
376
  }
@@ -817,11 +849,29 @@ export async function runRunlist(argv, config, opts = {}) {
817
849
  return;
818
850
  }
819
851
 
820
- // sub === 'next' — find first non-archived non-missing child and pick it up.
821
- const target = children.find(c => !c.missing && !archiveStatuses.has(c.status));
852
+ // sub === 'next' — pick up the first child a session can actually start.
853
+ // Skip both archived (done) and parked (blocked/partial/paused/awaiting/
854
+ // queued-after) children: the runlist advances to the first pickup-able one,
855
+ // so the picked target is guaranteed in a `dotmd use`-able status.
856
+ const target = children.find(c => !c.missing && !archiveStatuses.has(c.status) && isPickupable(c.status));
822
857
  if (!target) {
823
858
  if (children.length === 0) die(`Hub ${hubRepoPath} has empty \`runlist:\` — nothing to pick up.`);
824
- const allArchived = children.every(c => !c.missing && archiveStatuses.has(c.status));
859
+ // Live (non-archived, non-missing) children that exist but aren't startable.
860
+ const parked = children.filter(c => !c.missing && !archiveStatuses.has(c.status));
861
+ if (parked.length > 0) {
862
+ // Every remaining child is parked — surface them with statuses + the
863
+ // unstick verbs so the agent can resume one instead of being told a
864
+ // generic "no pickup" with no path forward.
865
+ const listed = parked.map(c => ` ${c.path} (status: ${c.status})`).join('\n');
866
+ die(
867
+ `No pickup-able child in runlist ${hubRepoPath} — every remaining child is parked:\n` +
868
+ `${listed}\n` +
869
+ `Unstick one before continuing:\n` +
870
+ ` dotmd set active <child> # if ready to resume\n` +
871
+ ` dotmd use <child> # to inspect`,
872
+ );
873
+ }
874
+ const allArchived = children.some(c => !c.missing) && children.every(c => c.missing || archiveStatuses.has(c.status));
825
875
  if (allArchived) {
826
876
  die(`All children in runlist ${hubRepoPath} are archived. Hub is ready for \`dotmd archive ${hubRepoPath}\`.`);
827
877
  }
@@ -829,18 +879,6 @@ export async function runRunlist(argv, config, opts = {}) {
829
879
  die(`No pickup-able child in runlist ${hubRepoPath}. Unresolved refs: ${missing.join(', ')}`);
830
880
  }
831
881
 
832
- // Pre-check status: pickup will die on non-pickup-able statuses, but with
833
- // a generic message. Surface the runlist context first so the agent knows
834
- // which list is blocked and on which item.
835
- if (!PICKUPABLE_STATUSES.has(target.status)) {
836
- die(
837
- `Next child in runlist ${hubRepoPath} is ${target.path} (status: ${target.status}).\n` +
838
- `Resolve the blocker before continuing the runlist.\n` +
839
- ` dotmd set active ${target.path} # if ready to resume\n` +
840
- ` dotmd use ${target.path} # to inspect`,
841
- );
842
- }
843
-
844
882
  // Open the next child: set it in-session (frontmatter) and render its card.
845
883
  // Dynamic import to avoid circular module-load cost when the runlist command
846
884
  // isn't used.
@@ -0,0 +1,85 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ // dotmd is a drift- and staleness-catcher that didn't, until now, catch drift
5
+ // in its OWN plugin surface. Two canonical workflow-doc surfaces are kept in
6
+ // lockstep by hand: the repo's CLAUDE.md (its own instructions) and the plugin
7
+ // SKILL.md (what every OTHER repo's session learns the workflow from). CLAUDE.md
8
+ // literally says "keep it in sync with the guidance below" — this guard makes
9
+ // that lockstep mechanical instead of manual.
10
+ //
11
+ // The comparison unit is a marked block (not a fuzzy phrase heuristic):
12
+ // deterministic, robust, and zero false positives. Each surface wraps the
13
+ // irreducible workflow contract between the markers below; the guard extracts
14
+ // both and compares them whitespace-tolerantly. Editing the contract in one
15
+ // surface and forgetting the other is exactly what this catches.
16
+
17
+ export const CANONICAL_MARKERS = {
18
+ start: '<!-- dotmd:canonical-workflow:start -->',
19
+ end: '<!-- dotmd:canonical-workflow:end -->',
20
+ };
21
+
22
+ const CLAUDE_MD = 'CLAUDE.md';
23
+ const SKILL_MD = path.join('plugins', 'dotmd', 'skills', 'dotmd', 'SKILL.md');
24
+
25
+ // Pull the inner text between the canonical markers. Returns null when the
26
+ // markers are absent or malformed — callers treat null as "this surface doesn't
27
+ // participate in the lockstep block", which is what keeps a user repo that has
28
+ // its own CLAUDE.md (but never adopted the block) from ever tripping the guard.
29
+ export function extractCanonicalBlock(text) {
30
+ if (typeof text !== 'string') return null;
31
+ const startIdx = text.indexOf(CANONICAL_MARKERS.start);
32
+ if (startIdx === -1) return null;
33
+ const afterStart = startIdx + CANONICAL_MARKERS.start.length;
34
+ const endIdx = text.indexOf(CANONICAL_MARKERS.end, afterStart);
35
+ if (endIdx === -1 || endIdx < afterStart) return null;
36
+ return text.slice(afterStart, endIdx);
37
+ }
38
+
39
+ // Whitespace-tolerant so a CRLF vs LF or a stray trailing space between the two
40
+ // files isn't reported as drift — only meaningful content divergence is.
41
+ function normalizeBlock(block) {
42
+ return block
43
+ .replace(/\r\n?/g, '\n')
44
+ .split('\n')
45
+ .map(line => line.replace(/\s+$/, ''))
46
+ .join('\n')
47
+ .trim();
48
+ }
49
+
50
+ function readIfPresent(filePath) {
51
+ try {
52
+ if (!existsSync(filePath)) return null;
53
+ return readFileSync(filePath, 'utf8');
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ // Guard: the canonical workflow block must stay identical across CLAUDE.md and
60
+ // the plugin SKILL.md. Returns [] unless BOTH files exist AND BOTH carry the
61
+ // block — so it only fires in a repo that has adopted the lockstep convention
62
+ // (i.e. the dotmd repo itself), never in a user repo that merely has its own
63
+ // CLAUDE.md. That strict gate is the price of zero false positives; the
64
+ // trade-off is that deleting the markers from one surface silently disables the
65
+ // guard rather than warning (a deliberate, visible act, unlike a content edit).
66
+ export function checkSkillDrift(config) {
67
+ const repoRoot = config?.repoRoot;
68
+ if (!repoRoot) return [];
69
+
70
+ const claudeText = readIfPresent(path.join(repoRoot, CLAUDE_MD));
71
+ const skillText = readIfPresent(path.join(repoRoot, SKILL_MD));
72
+ if (claudeText === null || skillText === null) return [];
73
+
74
+ const claudeBlock = extractCanonicalBlock(claudeText);
75
+ const skillBlock = extractCanonicalBlock(skillText);
76
+ if (claudeBlock === null || skillBlock === null) return [];
77
+
78
+ if (normalizeBlock(claudeBlock) === normalizeBlock(skillBlock)) return [];
79
+
80
+ return [{
81
+ path: SKILL_MD,
82
+ level: 'warning',
83
+ message: `canonical workflow block drifted from \`${CLAUDE_MD}\`. The block between \`${CANONICAL_MARKERS.start}\` and \`${CANONICAL_MARKERS.end}\` must stay identical in both files — reconcile them so the plugin skill and the repo instructions teach the same workflow.`,
84
+ }];
85
+ }