dotmd-cli 0.39.2 → 0.39.3

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
@@ -170,6 +170,8 @@ If a plan is already in-session:
170
170
 
171
171
  Options:
172
172
  --takeover Force-claim a plan held by another session
173
+ --no-index Skip index regen (see \`dotmd archive --help\`)
174
+ --show-files Append \`files: …\` line to stderr (see \`dotmd archive --help\`)
173
175
  --json Output as JSON
174
176
  --dry-run, -n Preview without writing
175
177
 
@@ -191,6 +193,8 @@ Options:
191
193
  --all Release every lease in the file (administrative)
192
194
  --stale Release leases whose pid is dead or age >24h
193
195
  --force Override "not yours" refusal on a specific file
196
+ --no-index Skip index regen (see \`dotmd archive --help\`)
197
+ --show-files Append \`files: …\` line to stderr (see \`dotmd archive --help\`)
194
198
  --json Output as JSON ({ released, skipped })
195
199
  --dry-run, -n Preview without writing
196
200
 
@@ -221,6 +225,11 @@ Moves the document to the new status. If transitioning to an archive
221
225
  status, automatically moves the file to the archive directory and
222
226
  regenerates the index (if configured).
223
227
 
228
+ Options:
229
+ --no-index Skip index regen (useful in concurrent-session repos
230
+ doing path-limited commits — see \`dotmd archive --help\`).
231
+ --show-files Append \`files: …\` line to stderr (see \`dotmd archive --help\`).
232
+
224
233
  Default plan statuses (each maps to a distinct unstuck-action):
225
234
  in-session A Claude session is working on it now
226
235
  active Ready to be picked up
@@ -252,7 +261,17 @@ Options:
252
261
  Sets status to 'archived', moves to the archive directory, auto-updates
253
262
  references in other docs, and regenerates the index.
254
263
 
255
- Use --dry-run (-n) to preview changes without writing anything.`,
264
+ Options:
265
+ --no-index Skip index regen. Use when multiple sessions are
266
+ working concurrently and you want a path-limited
267
+ commit that doesn't pull other agents' uncommitted
268
+ index changes into your staging area. Run \`dotmd index\`
269
+ later (or wire it into a commit hook) to refresh.
270
+ --show-files Append a final \`files: a b c …\` line to stderr
271
+ listing every doc/index path the command touched
272
+ (deduped, sorted, repo-relative). Lets agents do
273
+ \`git add\` with the exact set instead of guessing.
274
+ --dry-run, -n Preview changes without writing anything.`,
256
275
 
257
276
  coverage: `dotmd coverage — metadata coverage report
258
277
 
@@ -488,6 +507,8 @@ Other options:
488
507
  --status <s> Set initial status (defaults to first valid status for the type)
489
508
  --title <t> Override the auto-derived title
490
509
  --root <name> Create in a specific docs root
510
+ --show-files Append \`files: …\` line to stderr listing what was touched
511
+ (the new doc + the index file). See \`dotmd archive --help\`.
491
512
  --list-types Show registered types (alias: --list-templates)
492
513
 
493
514
  For plans, the default status vocabulary is: in-session, active, planned,
@@ -680,6 +701,21 @@ Supports all query flags (--status, --json, --sort, etc.)`,
680
701
  Shows documents that reference or depend on the given file.
681
702
  Useful for impact analysis before archiving or changing a plan.
682
703
 
704
+ The dependency edge is read from each plan's \`blockers:\` frontmatter
705
+ (a YAML list of plan slugs or paths). \`blocked_by:\` is accepted as
706
+ an alias since 0.39.3 — both populate the same index field, so use
707
+ whichever name reads better.
708
+
709
+ Frontmatter shape:
710
+
711
+ ---
712
+ type: plan
713
+ status: blocked
714
+ blockers:
715
+ - foo-plan.md
716
+ - docs/plans/bar-plan.md
717
+ ---
718
+
683
719
  Options:
684
720
  --json Output as JSON`,
685
721
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.39.2",
3
+ "version": "0.39.3",
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/index.mjs CHANGED
@@ -175,7 +175,13 @@ export function parseDocFile(filePath, config, opts = {}) {
175
175
  }
176
176
  }
177
177
  const nextStep = asString(parsedFrontmatter.next_step) ?? extractNextStep(body) ?? null;
178
- const blockers = normalizeBlockers(parsedFrontmatter.blockers);
178
+ // `blocked_by` is accepted as an alias for `blockers` since 0.39.3 — agents
179
+ // filing tickets naturally reach for the JIRA/Linear name. If both are set,
180
+ // they're merged (de-duped via normalizeBlockers → mergeUniqueStrings).
181
+ const blockers = mergeUniqueStrings(
182
+ normalizeBlockers(parsedFrontmatter.blockers),
183
+ normalizeBlockers(parsedFrontmatter.blocked_by),
184
+ );
179
185
  const surface = asString(parsedFrontmatter.surface) ?? null;
180
186
  const surfaces = normalizeStringList(parsedFrontmatter.surfaces);
181
187
  const moduleName = asString(parsedFrontmatter.module) ?? null;
package/src/lifecycle.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
4
- import { asString, toRepoPath, die, warn, resolveDocPath, resolveRefPath, escapeRegex, nowIso, suggestCandidates } from './util.mjs';
4
+ import { asString, toRepoPath, die, warn, resolveDocPath, resolveRefPath, escapeRegex, nowIso, suggestCandidates, emitFilesFooter } from './util.mjs';
5
5
  import { gitMv, getGitLastModified, getGitLastModifiedBatch } from './git.mjs';
6
6
  import { buildIndex, collectDocFiles } from './index.mjs';
7
7
  import { renderIndexFile, writeIndex } from './index-file.mjs';
@@ -68,6 +68,9 @@ function uniqueArchiveTarget(targetDir, basename) {
68
68
 
69
69
  export async function runStatus(argv, config, opts = {}) {
70
70
  const { dryRun } = opts;
71
+ const noIndex = argv.includes('--no-index') || opts.noIndex;
72
+ const showFiles = argv.includes('--show-files') || opts.showFiles;
73
+ argv = argv.filter(a => a !== '--no-index' && a !== '--show-files');
71
74
  const input = argv[0];
72
75
  let newStatus = argv[1];
73
76
 
@@ -167,11 +170,24 @@ export async function runStatus(argv, config, opts = {}) {
167
170
 
168
171
  // Regen the index on every status change — `active → planned` etc. drift
169
172
  // the per-status sections just as much as archive crossings. Archive paths
170
- // also benefit (replaces the previously-gated regen).
171
- regenIndex(config);
173
+ // also benefit (replaces the previously-gated regen). `--no-index` skips
174
+ // this so concurrent agents can do path-limited commits without pulling
175
+ // each other's uncommitted index changes into the staging area.
176
+ if (noIndex) {
177
+ process.stderr.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
178
+ } else {
179
+ regenIndex(config);
180
+ }
172
181
 
173
182
  process.stdout.write(`${green(toRepoPath(finalPath, config.repoRoot))}: ${oldStatus ?? 'unknown'} → ${newStatus}\n`);
174
183
 
184
+ if (showFiles) {
185
+ const touched = [filePath];
186
+ if (finalPath !== filePath) touched.push(finalPath);
187
+ if (config.indexPath && !noIndex) touched.push(config.indexPath);
188
+ emitFilesFooter(touched, config);
189
+ }
190
+
175
191
  try { config.hooks.onStatusChange?.({ path: toRepoPath(finalPath, config.repoRoot), oldStatus, newStatus }, {
176
192
  oldPath: toRepoPath(filePath, config.repoRoot),
177
193
  newPath: toRepoPath(finalPath, config.repoRoot),
@@ -183,6 +199,8 @@ export async function runPickup(argv, config, opts = {}) {
183
199
  const json = argv.includes('--json');
184
200
  const takeover = argv.includes('--takeover');
185
201
  const fullBody = argv.includes('--full');
202
+ const noIndex = argv.includes('--no-index') || opts.noIndex;
203
+ const showFiles = argv.includes('--show-files') || opts.showFiles;
186
204
  let input = argv.find(a => !a.startsWith('-'));
187
205
 
188
206
  // Interactive: pick from active/planned plans
@@ -253,7 +271,11 @@ export async function runPickup(argv, config, opts = {}) {
253
271
  }
254
272
  if (oldStatus !== 'in-session') {
255
273
  updateFrontmatter(filePath, { status: 'in-session', updated: today });
256
- regenIndex(config);
274
+ if (noIndex) {
275
+ process.stderr.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
276
+ } else {
277
+ regenIndex(config);
278
+ }
257
279
  }
258
280
  // VH append per lease outcome:
259
281
  // acquired → `Picked up (<old> → in-session).`
@@ -295,6 +317,12 @@ export async function runPickup(argv, config, opts = {}) {
295
317
  }
296
318
  }
297
319
 
320
+ if (showFiles && oldStatus !== 'in-session') {
321
+ const touched = [filePath];
322
+ if (config.indexPath && !noIndex) touched.push(config.indexPath);
323
+ emitFilesFooter(touched, config);
324
+ }
325
+
298
326
  try { config.hooks.onPickup?.({ path: repoPath, oldStatus, newStatus: 'in-session' }); } catch (err) { warn(`Hook 'onPickup' threw: ${err.message}`); }
299
327
  }
300
328
 
@@ -304,10 +332,13 @@ export async function runUnpickup(argv, config, opts = {}) {
304
332
  const all = argv.includes('--all');
305
333
  const stale = argv.includes('--stale');
306
334
  const force = argv.includes('--force');
335
+ const noIndex = argv.includes('--no-index') || opts.noIndex;
336
+ const showFiles = argv.includes('--show-files') || opts.showFiles;
307
337
  const toIdx = argv.indexOf('--to');
308
338
  const toStatus = toIdx >= 0 ? argv[toIdx + 1] : null;
309
339
  const positional = argv.filter((a, i) => !a.startsWith('-') && argv[i - 1] !== '--to');
310
340
  const fileArg = positional[0];
341
+ const touched = [];
311
342
 
312
343
  const session = currentSessionId();
313
344
  const released = [];
@@ -360,7 +391,12 @@ export async function runUnpickup(argv, config, opts = {}) {
360
391
  const today = nowIso();
361
392
  updateFrontmatter(filePath, { status: newStatus, updated: today });
362
393
  appendVersionHistory(filePath, `Released (in-session → ${newStatus}).`);
363
- regenIndex(config);
394
+ touched.push(filePath);
395
+ if (noIndex) {
396
+ process.stderr.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
397
+ } else {
398
+ regenIndex(config);
399
+ }
364
400
  }
365
401
  // If frontmatter is no longer in-session (manual flip), leave it alone.
366
402
  } catch (err) {
@@ -429,6 +465,12 @@ export async function runUnpickup(argv, config, opts = {}) {
429
465
  process.stderr.write(`${yellow('⚠ Skipped')}: ${s.path} (held by ${s.session}; use --force to override)\n`);
430
466
  }
431
467
  }
468
+
469
+ if (showFiles && touched.length > 0) {
470
+ const all = [...touched];
471
+ if (config.indexPath && !noIndex) all.push(config.indexPath);
472
+ emitFilesFooter(all, config);
473
+ }
432
474
  }
433
475
 
434
476
  export async function runFinish(argv, config, opts = {}) {
@@ -493,6 +535,9 @@ export async function runFinish(argv, config, opts = {}) {
493
535
 
494
536
  export function runArchive(argv, config, opts = {}) {
495
537
  const { dryRun, out = process.stdout } = opts;
538
+ const noIndex = argv.includes('--no-index') || opts.noIndex;
539
+ const showFiles = argv.includes('--show-files') || opts.showFiles;
540
+ argv = argv.filter(a => a !== '--no-index' && a !== '--show-files');
496
541
  const input = argv[0];
497
542
 
498
543
  if (!input) { die('Usage: dotmd archive <file>'); }
@@ -519,7 +564,8 @@ export function runArchive(argv, config, opts = {}) {
519
564
  const prefix = dim('[dry-run]');
520
565
  out.write(`${prefix} Would update frontmatter: status: ${oldStatus} → archived, updated: ${today}\n`);
521
566
  out.write(`${prefix} Would move: ${oldRepoPath} → ${newRepoPath}\n`);
522
- if (config.indexPath) out.write(`${prefix} Would regenerate index\n`);
567
+ if (config.indexPath && !noIndex) out.write(`${prefix} Would regenerate index\n`);
568
+ if (config.indexPath && noIndex) out.write(`${prefix} Would skip index regen (--no-index)\n`);
523
569
 
524
570
  // Preview reference updates
525
571
  const refCount = countRefsToUpdate(filePath, targetPath, config);
@@ -541,22 +587,31 @@ export function runArchive(argv, config, opts = {}) {
541
587
  const selfRefsFixed = updateRefsFromMovedFile(filePath, targetPath, config);
542
588
 
543
589
  // Auto-update references in other docs
544
- const updatedRefCount = updateRefsAfterMove(filePath, targetPath, config);
590
+ const { count: updatedRefCount, paths: refTouchedPaths } = updateRefsAfterMove(filePath, targetPath, config);
545
591
 
546
- regenIndex(config);
592
+ if (!noIndex) regenIndex(config);
547
593
 
548
594
  out.write(`${green('Archived')}: ${oldRepoPath} → ${newRepoPath}\n`);
549
595
  if (selfRefsFixed) out.write('Updated references in archived file.\n');
550
596
  if (updatedRefCount > 0) out.write(`Updated references in ${updatedRefCount} file(s).\n`);
551
- if (config.indexPath) out.write('Index regenerated.\n');
597
+ if (config.indexPath && !noIndex) out.write('Index regenerated.\n');
598
+ if (config.indexPath && noIndex) out.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
552
599
 
553
600
  try { releaseLease(config, oldRepoPath, { force: true }); } catch (err) { warn(`Could not release lease for ${oldRepoPath}: ${err.message}`); }
554
601
 
602
+ const touched = [oldRepoPath, newRepoPath, ...refTouchedPaths];
603
+ if (config.indexPath && !noIndex) touched.push(config.indexPath);
604
+ if (showFiles) emitFilesFooter(touched, config);
605
+
555
606
  try { config.hooks.onArchive?.({ path: newRepoPath, oldStatus }, { oldPath: oldRepoPath, newPath: newRepoPath }); } catch (err) { warn(`Hook 'onArchive' threw: ${err.message}`); }
607
+
608
+ return { touched };
556
609
  }
557
610
 
558
611
  export function runBulkArchive(argv, config, opts = {}) {
559
612
  const { dryRun } = opts;
613
+ const noIndex = argv.includes('--no-index') || opts.noIndex;
614
+ const showFiles = argv.includes('--show-files') || opts.showFiles;
560
615
  const inputs = argv.filter(a => !a.startsWith('-'));
561
616
  if (inputs.length === 0) die('Usage: dotmd bulk archive <file1> <file2> ... or <glob>');
562
617
 
@@ -592,14 +647,30 @@ export function runBulkArchive(argv, config, opts = {}) {
592
647
  }
593
648
 
594
649
  process.stdout.write('\n');
650
+ // Bulk archives always defer index regen to the end — N individual regens
651
+ // is wasteful and the final state is the same. `--no-index` skips even
652
+ // the final one.
653
+ const bulkTouched = [];
595
654
  for (const f of unique) {
596
655
  const relPath = toRepoPath(f, config.repoRoot);
597
656
  try {
598
- runArchive([relPath], config, opts);
657
+ const result = runArchive([relPath], config, { ...opts, noIndex: true, showFiles: false });
658
+ if (result?.touched) bulkTouched.push(...result.touched);
599
659
  } catch (err) {
600
660
  warn(`Failed to archive ${relPath}: ${err.message}`);
601
661
  }
602
662
  }
663
+ if (!noIndex) {
664
+ regenIndex(config);
665
+ if (config.indexPath) process.stdout.write('Index regenerated.\n');
666
+ } else if (config.indexPath) {
667
+ process.stdout.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
668
+ }
669
+ if (showFiles) {
670
+ const all = [...bulkTouched];
671
+ if (config.indexPath && !noIndex) all.push(config.indexPath);
672
+ emitFilesFooter(all, config);
673
+ }
603
674
  }
604
675
 
605
676
  export function runTouch(argv, config, opts = {}) {
@@ -682,7 +753,7 @@ export function runTouch(argv, config, opts = {}) {
682
753
  function updateRefsAfterMove(oldPath, newPath, config) {
683
754
  const basename = path.basename(oldPath);
684
755
  const allFiles = collectDocFiles(config);
685
- let updatedCount = 0;
756
+ const touched = [];
686
757
 
687
758
  for (const docFile of allFiles) {
688
759
  if (docFile === newPath) continue;
@@ -710,11 +781,11 @@ function updateRefsAfterMove(oldPath, newPath, config) {
710
781
  if (newFm !== fm) {
711
782
  raw = replaceFrontmatter(raw, newFm);
712
783
  writeFileSync(docFile, raw, 'utf8');
713
- updatedCount++;
784
+ touched.push(docFile);
714
785
  }
715
786
  }
716
787
 
717
- return updatedCount;
788
+ return { count: touched.length, paths: touched };
718
789
  }
719
790
 
720
791
  function updateRefsFromMovedFile(oldPath, newPath, config) {
package/src/new.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { toRepoPath, die, warn, nowIso } from './util.mjs';
4
+ import { toRepoPath, die, warn, nowIso, emitFilesFooter } from './util.mjs';
5
5
  import { green, dim, bold } from './color.mjs';
6
6
  import { isInteractive, promptText } from './prompt.mjs';
7
7
  import { regenIndex } from './lifecycle.mjs';
@@ -185,12 +185,14 @@ export async function runNew(argv, config, opts = {}) {
185
185
  let title = null;
186
186
  let rootName = opts.root ?? null;
187
187
  let messageFlag = null;
188
+ let showFiles = opts.showFiles ?? false;
188
189
  for (let i = 0; i < argv.length; i++) {
189
190
  if (argv[i] === '--status' && argv[i + 1]) { status = argv[++i]; continue; }
190
191
  if (argv[i] === '--title' && argv[i + 1]) { title = argv[++i]; continue; }
191
192
  if (argv[i] === '--message' && argv[i + 1]) { messageFlag = argv[++i]; continue; }
192
193
  if (argv[i] === '--root' && argv[i + 1]) { rootName = argv[++i]; continue; }
193
194
  if (argv[i] === '--config') { i++; continue; }
195
+ if (argv[i] === '--show-files') { showFiles = true; continue; }
194
196
  if (argv[i] === '--list-templates' || argv[i] === '--list-types') {
195
197
  listTemplates(config);
196
198
  return;
@@ -395,6 +397,12 @@ export async function runNew(argv, config, opts = {}) {
395
397
 
396
398
  regenIndex(config);
397
399
 
400
+ if (showFiles) {
401
+ const touched = [filePath];
402
+ if (config.indexPath) touched.push(config.indexPath);
403
+ emitFilesFooter(touched, config);
404
+ }
405
+
398
406
  try { config.hooks.onNew?.({ path: repoPath, status, title: docTitle, type: typeName }); } catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
399
407
  }
400
408
 
package/src/prompts.mjs CHANGED
@@ -116,12 +116,14 @@ function resolvePromptInput(input, config) {
116
116
  function runPromptsUse(argv, config, opts = {}) {
117
117
  const input = argv.find(a => !a.startsWith('-'));
118
118
  if (!input) die('Usage: dotmd prompts use <file-or-slug>');
119
+ const noIndex = argv.includes('--no-index') || opts.noIndex;
120
+ const showFiles = argv.includes('--show-files') || opts.showFiles;
119
121
  const filePath = resolvePromptInput(input, config);
120
- consumePrompt(filePath, config, opts);
122
+ consumePrompt(filePath, config, { ...opts, noIndex, showFiles });
121
123
  }
122
124
 
123
125
  function consumePrompt(filePath, config, opts) {
124
- const { dryRun } = opts;
126
+ const { dryRun, noIndex, showFiles } = opts;
125
127
  const raw = readFileSync(filePath, 'utf8');
126
128
  const { frontmatter, body } = extractFrontmatter(raw);
127
129
  const parsed = parseSimpleFrontmatter(frontmatter);
@@ -138,20 +140,22 @@ function consumePrompt(filePath, config, opts) {
138
140
 
139
141
  if (dryRun) {
140
142
  process.stderr.write(`${dim('[dry-run]')} Would emit body and archive: ${repoPath} (${status ?? 'unknown'} → archived)\n`);
141
- runArchive([filePath], config, { dryRun: true, out: process.stderr });
143
+ runArchive([filePath], config, { dryRun: true, noIndex, out: process.stderr });
142
144
  return;
143
145
  }
144
146
 
145
147
  process.stdout.write(body);
146
148
  if (!body.endsWith('\n')) process.stdout.write('\n');
147
149
 
148
- runArchive([filePath], config, { out: process.stderr });
150
+ runArchive([filePath], config, { noIndex, showFiles, out: process.stderr });
149
151
  process.stderr.write(`${green('✓ Consumed')}: ${repoPath}\n`);
150
152
  }
151
153
 
152
154
  function runPromptsArchive(argv, config, opts = {}) {
153
155
  const input = argv.find(a => !a.startsWith('-'));
154
156
  if (!input) die('Usage: dotmd prompts archive <file-or-slug>');
157
+ const noIndex = argv.includes('--no-index') || opts.noIndex;
158
+ const showFiles = argv.includes('--show-files') || opts.showFiles;
155
159
  const filePath = resolvePromptInput(input, config);
156
160
 
157
161
  const raw = readFileSync(filePath, 'utf8');
@@ -161,7 +165,7 @@ function runPromptsArchive(argv, config, opts = {}) {
161
165
  die(`Not a prompt: ${toRepoPath(filePath, config.repoRoot)}`);
162
166
  }
163
167
 
164
- runArchive([filePath], config, opts);
168
+ runArchive([filePath], config, { ...opts, noIndex, showFiles });
165
169
  }
166
170
 
167
171
  async function runPromptsNew(argv, config, opts = {}) {
package/src/util.mjs CHANGED
@@ -55,6 +55,18 @@ export function toRepoPath(absolutePath, repoRoot) {
55
55
  return path.relative(repoRoot, absolutePath).split(path.sep).join('/');
56
56
  }
57
57
 
58
+ // Emit a `files: a b c` line to stderr listing every doc / index path
59
+ // the command touched (deduped, sorted, repo-relative). Lets agents do
60
+ // `git add` with the exact set instead of guessing. Opt-in via
61
+ // `--show-files` on lifecycle commands; default off to keep output stable.
62
+ export function emitFilesFooter(paths, config) {
63
+ const rel = [...new Set(paths.filter(Boolean))]
64
+ .map(p => path.isAbsolute(p) ? toRepoPath(p, config.repoRoot) : p)
65
+ .sort();
66
+ if (rel.length === 0) return;
67
+ process.stderr.write(`files: ${rel.join(' ')}\n`);
68
+ }
69
+
58
70
  export function nowIso() {
59
71
  return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
60
72
  }
package/src/validate.mjs CHANGED
@@ -95,6 +95,10 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
95
95
  doc.errors.push({ path: doc.path, level: 'error', message: '`blockers` must be a YAML list when present.' });
96
96
  }
97
97
 
98
+ if (Object.prototype.hasOwnProperty.call(frontmatter, 'blocked_by') && !Array.isArray(frontmatter.blocked_by)) {
99
+ doc.errors.push({ path: doc.path, level: 'error', message: '`blocked_by` must be a YAML list when present.' });
100
+ }
101
+
98
102
  if (Object.prototype.hasOwnProperty.call(frontmatter, 'surfaces') && !Array.isArray(frontmatter.surfaces)) {
99
103
  doc.errors.push({ path: doc.path, level: 'error', message: '`surfaces` must be a YAML list when present.' });
100
104
  }