dotmd-cli 0.65.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.65.1",
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 {
@@ -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
+ }