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 +13 -6
- package/package.json +1 -1
- package/src/doctor.mjs +8 -0
- package/src/index.mjs +4 -0
- package/src/query.mjs +5 -1
- package/src/runlist.mjs +56 -18
- package/src/skill-drift.mjs +85 -0
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
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
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
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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' —
|
|
821
|
-
|
|
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
|
-
|
|
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
|
+
}
|