dotmd-cli 0.74.3 → 0.74.5

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.74.3",
3
+ "version": "0.74.5",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, lifecycle, and AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.mjs CHANGED
@@ -146,6 +146,13 @@ function normalizeRichStatuses(config, userConfig) {
146
146
  archiveStatuses: [],
147
147
  skipStaleFor: [],
148
148
  skipWarningsFor: [],
149
+ // Same membership as `skipWarningsFor`, but kept per declaring type. The flat
150
+ // list is a union of status NAMES, so a name any type marks quiet suppresses
151
+ // warnings for every other type that happens to share it — `journey.active`
152
+ // silencing every `active` plan in the repo. Statuses are type-scoped
153
+ // everywhere else (`typeStatuses`, `isValidStatus`), so the suppression that
154
+ // reads them has to be too.
155
+ skipWarningsForByType: {},
149
156
  terminalStatuses: [],
150
157
  moduleRequiredFor: [],
151
158
  // F15: status-name → directory-name (defaults to the status name verbatim).
@@ -166,6 +173,10 @@ function normalizeRichStatuses(config, userConfig) {
166
173
  if (typeof typeDef.statuses !== 'object') continue;
167
174
 
168
175
  hasRich = true;
176
+ // Claim the key even when no status is quiet: an empty list means "this type
177
+ // declared its statuses and suppresses nothing", which must not fall back to
178
+ // the global union. Absent entirely means "type never declared rich statuses".
179
+ derived.skipWarningsForByType[typeName] ??= [];
169
180
  const statusNames = [];
170
181
  const typeContext = { expanded: [], listed: [], counted: [] };
171
182
  const typeStaleDays = {};
@@ -203,7 +214,10 @@ function normalizeRichStatuses(config, userConfig) {
203
214
  if (p.archive && !derived.archiveStatuses.includes(name)) derived.archiveStatuses.push(name);
204
215
  if (p.startable && !derived.startableStatuses.includes(name)) derived.startableStatuses.push(name);
205
216
  if ((p.skipStale || quietImpliesSkipStale) && !derived.skipStaleFor.includes(name)) derived.skipStaleFor.push(name);
206
- if ((p.skipWarnings || quietImpliesSkipWarnings) && !derived.skipWarningsFor.includes(name)) derived.skipWarningsFor.push(name);
217
+ if (p.skipWarnings || quietImpliesSkipWarnings) {
218
+ if (!derived.skipWarningsFor.includes(name)) derived.skipWarningsFor.push(name);
219
+ (derived.skipWarningsForByType[typeName] ??= []).push(name);
220
+ }
207
221
  if (p.terminal && !derived.terminalStatuses.includes(name)) derived.terminalStatuses.push(name);
208
222
  if (p.requiresModule && !derived.moduleRequiredFor.includes(name)) derived.moduleRequiredFor.push(name);
209
223
  if (p.filed && !derived.filedStatuses[name]) {
@@ -253,8 +267,17 @@ function applyDerivedConfig(config, userConfig, derived) {
253
267
  if (!userConfig.lifecycle?.skipStaleFor && derived.skipStaleFor.length) {
254
268
  config.lifecycle.skipStaleFor = derived.skipStaleFor;
255
269
  }
270
+ // Assigned on BOTH branches, never conditionally: `config.lifecycle` can be the
271
+ // module-level DEFAULTS.lifecycle object itself (deepMerge shallow-copies, so a
272
+ // user config with no `lifecycle` key shares the reference), and a conditional
273
+ // write would leave a previous resolveConfig call's map in place.
256
274
  if (!userConfig.lifecycle?.skipWarningsFor && derived.skipWarningsFor.length) {
257
275
  config.lifecycle.skipWarningsFor = derived.skipWarningsFor;
276
+ config.lifecycle.skipWarningsForByType = derived.skipWarningsForByType;
277
+ } else {
278
+ // An explicit `lifecycle.skipWarningsFor` is a deliberate repo-wide statement
279
+ // and stays type-blind.
280
+ config.lifecycle.skipWarningsForByType = {};
258
281
  }
259
282
  if (!userConfig.lifecycle?.terminalStatuses && derived.terminalStatuses.length) {
260
283
  config.lifecycle.terminalStatuses = derived.terminalStatuses;
@@ -504,6 +527,17 @@ export async function resolveConfig(cwd, explicitConfigPath) {
504
527
  const startableStatuses = new Set(lifecycle.startableStatuses ?? ['active', 'planned']);
505
528
  const skipStaleFor = new Set(lifecycle.skipStaleFor);
506
529
  const skipWarningsFor = new Set(lifecycle.skipWarningsFor);
530
+ const skipWarningsForByType = new Map(
531
+ Object.entries(lifecycle.skipWarningsForByType ?? {}).map(([type, names]) => [type, new Set(names)]),
532
+ );
533
+ // Does `status` suppress warning-only checks for a doc of this type? A type that
534
+ // declared rich statuses answers for itself — including answering "no" for a
535
+ // name some other type marked quiet. Everything else (no rich statuses, an
536
+ // explicit global `skipWarningsFor`, a doc with no type) reads the flat set.
537
+ const skipsWarnings = (status, type) => {
538
+ const own = type != null ? skipWarningsForByType.get(type) : undefined;
539
+ return own ? own.has(status) : skipWarningsFor.has(status);
540
+ };
507
541
  const terminalStatuses = new Set(lifecycle.terminalStatuses);
508
542
  // F15: filedStatuses keyed by status name, value = directory name. Empty
509
543
  // object when no status opts in via `filed: true` (or `filed: '<dirname>'`).
@@ -555,7 +589,7 @@ export async function resolveConfig(cwd, explicitConfigPath) {
555
589
  rootValidStatuses,
556
590
  staleDaysByStatus,
557
591
 
558
- lifecycle: { archiveStatuses, startableStatuses, skipStaleFor, skipWarningsFor, terminalStatuses, filedStatuses, archiveNestedTypes },
592
+ lifecycle: { archiveStatuses, startableStatuses, skipStaleFor, skipWarningsFor, skipWarningsForByType, skipsWarnings, terminalStatuses, filedStatuses, archiveNestedTypes },
559
593
 
560
594
  validSurfaces,
561
595
  validModules,
@@ -41,8 +41,17 @@ export function extractNextStep(body) {
41
41
 
42
42
  export function extractBodyLinks(body) {
43
43
  if (!body) return [];
44
- // Strip fenced code blocks and inline code to avoid false positives
45
- const stripped = body.replace(/^```[\s\S]*?^```/gm, '').replace(/`[^`]+`/g, '');
44
+ // Strip fenced code blocks, then MASK inline code rather than delete it.
45
+ // Deleting it ate the commonest link idiom in a plan hub: [`plan.md`](plan.md)
46
+ // has its link TEXT as a code span, so removing the span left `[](plan.md)`,
47
+ // which the regex below rejects for having empty text. A hub with hundreds of
48
+ // such links reported one — and since this list is what validates body links,
49
+ // every one of them was also never checked for breakage. Masking to same-length
50
+ // filler keeps the link matchable while still neutralizing a link that is
51
+ // itself inside code (`[fake](x.md)` stays unmatched), and preserves offsets.
52
+ const stripped = body
53
+ .replace(/^```[\s\S]*?^```/gm, '')
54
+ .replace(/`[^`]+`/g, match => 'x'.repeat(match.length));
46
55
  const links = [];
47
56
  // Match [text](path.md) or [text](path.md#anchor), skip images (preceded by !)
48
57
  const regex = /(?<!!)\[([^\]]+)\]\(([^)]+\.md(?:#[^)]*)?)\)/g;
@@ -33,7 +33,7 @@ export function runFrontmatterFix(config, opts = {}) {
33
33
  const status = asString(parsed.status);
34
34
  if (config.lifecycle.terminalStatuses.has(status)
35
35
  || config.lifecycle.archiveStatuses.has(status)
36
- || config.lifecycle.skipWarningsFor.has(status)) continue;
36
+ || config.lifecycle.skipsWarnings(status, docType)) continue;
37
37
 
38
38
  const ops = [];
39
39
  for (const { name, cap, target, heading } of FIELDS) {
@@ -39,10 +39,10 @@ const BACKREF_KIND = 'hub-membership-backref';
39
39
 
40
40
  export function checkHubMembershipDrift(docs, config) {
41
41
  const warnings = [];
42
- const quiet = new Set([
43
- ...(config.lifecycle?.terminalStatuses ?? []),
44
- ...(config.lifecycle?.skipWarningsFor ?? []),
45
- ]);
42
+ // Per-doc: warning suppression is type-scoped, so a status name quiet for one
43
+ // type stays loud for another that declared the same name.
44
+ const quiet = (d) => config.lifecycle?.terminalStatuses?.has(d.status)
45
+ || config.lifecycle?.skipsWarnings(d.status, d.type);
46
46
  const byPath = new Map(docs.map(doc => [doc.path, doc]));
47
47
  const refFields = [
48
48
  ...(config.referenceFields?.bidirectional ?? []),
@@ -82,7 +82,7 @@ export function checkHubMembershipDrift(docs, config) {
82
82
  // it lives. Warns on the HUB: the hub's list is the half that lost the entry.
83
83
  const knownCache = new Map();
84
84
  for (const child of docs) {
85
- if (quiet.has(child.status)) continue;
85
+ if (quiet(child)) continue;
86
86
  const parents = child.refFields?.parent_plan ?? [];
87
87
  if (parents.length === 0) continue;
88
88
  const dir = dirOf(child);
@@ -92,7 +92,7 @@ export function checkHubMembershipDrift(docs, config) {
92
92
  // parent rows nothing, and demanding a link back there would be a new
93
93
  // opinion rather than a drift check.
94
94
  if (!hub || hub.path === child.path || !isHubDoc(hub)) continue;
95
- if (quiet.has(hub.status)) continue;
95
+ if (quiet(hub)) continue;
96
96
  if (!knownCache.has(hub.path)) knownCache.set(hub.path, knownTo(hub));
97
97
  if (knownCache.get(hub.path).has(child.path)) continue;
98
98
  warnings.push({
@@ -113,7 +113,7 @@ export function checkHubMembershipDrift(docs, config) {
113
113
  // back-ref is never reported twice. Warns on the CHILD, matching that check:
114
114
  // it's the file that needs the edit.
115
115
  for (const hub of docs) {
116
- if (!isHubDoc(hub) || quiet.has(hub.status)) continue;
116
+ if (!isHubDoc(hub) || quiet(hub)) continue;
117
117
  let body;
118
118
  try { ({ body } = extractFrontmatter(readFileSync(path.join(config.repoRoot, hub.path), 'utf8'))); }
119
119
  catch { continue; }
@@ -131,7 +131,7 @@ export function checkHubMembershipDrift(docs, config) {
131
131
  const child = resolve(ref, dir);
132
132
  if (!child || child.path === hub.path || seen.has(child.path)) continue;
133
133
  seen.add(child.path);
134
- if (quiet.has(child.status)) continue; // closed work is normal history
134
+ if (quiet(child)) continue; // closed work is normal history
135
135
  if (inFrontmatterRunlist.has(child.path)) continue; // checkRunlistBackPointers owns it
136
136
  if (isHubDoc(child)) continue; // a hub under a hub is the roadmap tier
137
137
  if (child.type && child.type !== 'plan') continue; // `parent_plan` is a plan relationship
package/src/hub.mjs CHANGED
@@ -242,9 +242,31 @@ export function detectBodyRunlistRefs(body) {
242
242
  let match;
243
243
  while ((match = linkSectionRe.exec(body)) !== null) {
244
244
  const section = sliceSection(match.index + match[0].length);
245
+ // Line by line, because the SHAPE decides what a link means, not the
246
+ // heading's name. A section titled `## Runlist index — by category` matches
247
+ // this heading list but is written as tables, and taking every link in it
248
+ // made each row's descriptive prose a membership claim: a row about plan A
249
+ // whose prose says "spawned children B, C" ranked B and C as the hub's own,
250
+ // then warned B and C for not naming the hub as parent. In a table, only the
251
+ // row's first link is the ranked plan — the same rule the ranked-queue
252
+ // branch below already applies.
245
253
  const allLinks = new RegExp(linkRe.source, 'g');
246
- let link;
247
- while ((link = allLinks.exec(section)) !== null) refs.push(link[1]);
254
+ for (const rawLine of section.split('\n')) {
255
+ const line = rawLine.trim();
256
+ if (line.startsWith('|')) {
257
+ const link = firstRowLink(line);
258
+ if (link) refs.push(link);
259
+ continue;
260
+ }
261
+ // One ranked item per line, first link wins — the same rule as a table
262
+ // row. A ranked item routinely carries commentary that links elsewhere
263
+ // ("Phase 7's open item closes on a route decision in [other-plan.md]"),
264
+ // and counting those made the hub claim plans it was only citing. A hub
265
+ // that means to rank two plans lists them on two lines.
266
+ allLinks.lastIndex = 0;
267
+ const link = allLinks.exec(line);
268
+ if (link) refs.push(link[1]);
269
+ }
248
270
 
249
271
  const checklistRe = /^\s*[-*]\s+\[[ xX]\]\s+([^\s)]+\.md(?:#[^\s)]+)?)/gm;
250
272
  let item;
package/src/lint.mjs CHANGED
@@ -79,7 +79,7 @@ export function runLint(argv, config, opts = {}) {
79
79
  const parsed = parseSimpleFrontmatter(frontmatter);
80
80
  const repoPath = toRepoPath(filePath, config.repoRoot);
81
81
  const fixes = [];
82
- const skipWarnings = config.lifecycle.skipWarningsFor.has(asString(parsed.status));
82
+ const skipWarnings = config.lifecycle.skipsWarnings(asString(parsed.status), asString(parsed.type));
83
83
 
84
84
  // Missing type (fixable — infer from root: plans → 'plan', else 'doc')
85
85
  if (!asString(parsed.type)) {
package/src/render.mjs CHANGED
@@ -291,8 +291,8 @@ function _renderContext(index, config, opts = {}) {
291
291
  lines.push('Stale: none');
292
292
  }
293
293
 
294
- const withErrors = index.docs.filter(d => d.errors.length > 0 && !config.lifecycle.skipWarningsFor.has(d.status));
295
- const withWarnings = index.docs.filter(d => d.warnings.length > 0 && !config.lifecycle.skipWarningsFor.has(d.status));
294
+ const withErrors = index.docs.filter(d => d.errors.length > 0 && !config.lifecycle.skipsWarnings(d.status, d.type));
295
+ const withWarnings = index.docs.filter(d => d.warnings.length > 0 && !config.lifecycle.skipsWarnings(d.status, d.type));
296
296
  if (withErrors.length || withWarnings.length) {
297
297
  const parts = [];
298
298
  if (withErrors.length) parts.push(`${withErrors.length} with errors`);
package/src/stats.mjs CHANGED
@@ -15,7 +15,7 @@ export function buildStats(index, config) {
15
15
  }
16
16
  }
17
17
  const scoped = docs.filter(d => scope.includes(d.status));
18
- const nonArchived = docs.filter(d => !config.lifecycle.skipWarningsFor.has(d.status));
18
+ const nonArchived = docs.filter(d => !config.lifecycle.skipsWarnings(d.status, d.type));
19
19
 
20
20
  // Health
21
21
  const staleCount = nonArchived.filter(d => d.isStale).length;
@@ -22,7 +22,7 @@ export function resolveStatusMetadata(config) {
22
22
 
23
23
  byType[type] = statuses.map((name, rank) => {
24
24
  const skipStale = config.lifecycle.skipStaleFor.has(name);
25
- const skipWarnings = config.lifecycle.skipWarningsFor.has(name);
25
+ const skipWarnings = config.lifecycle.skipsWarnings(name, type);
26
26
  const hasTypeStaleDays = Object.prototype.hasOwnProperty.call(typeDef.staleDays ?? {}, name);
27
27
  return {
28
28
  name,
package/src/statuses.mjs CHANGED
@@ -451,7 +451,7 @@ async function runMigrateType(args, config, opts) {
451
451
  if (lc.terminalStatuses.has(name)) props.terminal = true;
452
452
  // Apply quiet sugar when both skipStale and skipWarnings hold; otherwise emit the individual flag.
453
453
  const skipStale = lc.skipStaleFor.has(name);
454
- const skipWarnings = lc.skipWarningsFor.has(name);
454
+ const skipWarnings = lc.skipsWarnings(name, typeName);
455
455
  if (skipStale && skipWarnings) props.quiet = true;
456
456
  else {
457
457
  if (skipStale) props.skipStale = true;
@@ -89,16 +89,16 @@ export function collectHubStatusRows(docs, config, { hubPaths = null } = {}) {
89
89
  const key = doc.path.toLowerCase();
90
90
  docByFoldedPath.set(key, docByFoldedPath.has(key) ? null : doc);
91
91
  }
92
- const quiet = new Set([
93
- ...(config.lifecycle?.terminalStatuses ?? []),
94
- ...(config.lifecycle?.skipWarningsFor ?? []),
95
- ]);
92
+ // Per-doc: warning suppression is type-scoped, so a status name quiet for one
93
+ // type stays loud for another that declared the same name.
94
+ const quiet = (d) => config.lifecycle?.terminalStatuses?.has(d.status)
95
+ || config.lifecycle?.skipsWarnings(d.status, d.type);
96
96
  const out = [];
97
97
 
98
98
  for (const hub of docs) {
99
99
  if (!isHubDoc(hub)) continue;
100
100
  if (hubPaths) { if (!hubPaths.has(hub.path)) continue; }
101
- else if (quiet.has(hub.status)) continue;
101
+ else if (quiet(hub)) continue;
102
102
 
103
103
  let raw;
104
104
  try { raw = readFileSync(path.join(config.repoRoot, hub.path), 'utf8'); } catch { continue; }
package/src/validate.mjs CHANGED
@@ -93,7 +93,7 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
93
93
 
94
94
  const knownStatus = isValidStatus(doc.status, doc.root, config, doc.type);
95
95
 
96
- if (knownStatus && !config.lifecycle.skipWarningsFor.has(doc.status) && !doc.updated) {
96
+ if (knownStatus && !config.lifecycle.skipsWarnings(doc.status, doc.type) && !doc.updated) {
97
97
  doc.errors.push({ path: doc.path, level: 'error', message: 'Missing frontmatter `updated` for non-archived doc.' });
98
98
  }
99
99
 
@@ -130,7 +130,7 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
130
130
  doc.errors.push({ path: doc.path, level: 'error', message: '`modules` is required for this status; declare a real module from `taxonomy.modules`, or `none` as the explicit no-module sentinel.' });
131
131
  }
132
132
 
133
- if (config.validSurfaces && !config.lifecycle.skipWarningsFor.has(doc.status)) {
133
+ if (config.validSurfaces && !config.lifecycle.skipsWarnings(doc.status, doc.type)) {
134
134
  const knownSurfaces = [...config.validSurfaces];
135
135
  for (const surface of doc.surfaces) {
136
136
  if (!config.validSurfaces.has(surface)) {
@@ -153,7 +153,7 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
153
153
  // target is inlined in the message so `dotmd lint --fix` users see exactly
154
154
  // what they'll end up with — and so non-fix readers can hand-migrate.
155
155
  // Suppress for archived/terminal docs (same noise-control rule as F2).
156
- if (!config.lifecycle.skipWarningsFor.has(doc.status)) {
156
+ if (!config.lifecycle.skipsWarnings(doc.status, doc.type)) {
157
157
  for (const { singular, plural } of [{ singular: 'module', plural: 'modules' }, { singular: 'surface', plural: 'surfaces' }]) {
158
158
  const singularValue = frontmatter[singular];
159
159
  if (!Object.prototype.hasOwnProperty.call(frontmatter, singular)) continue;
@@ -190,13 +190,13 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
190
190
  doc.warnings.push({ path: doc.path, level: 'warning', message: 'Missing `title` and no H1 found for fallback.' });
191
191
  }
192
192
 
193
- if (!skipTitleSummary && !config.lifecycle.skipWarningsFor.has(doc.status) && !asString(frontmatter.summary) && !doc.summary) {
193
+ if (!skipTitleSummary && !config.lifecycle.skipsWarnings(doc.status, doc.type) && !asString(frontmatter.summary) && !doc.summary) {
194
194
  doc.warnings.push({ path: doc.path, level: 'warning', message: 'Missing `summary` and no blockquote fallback found.' });
195
195
  }
196
196
 
197
197
  // Determine which statuses should have current_state and next_step (plans only, not docs/research)
198
198
  const isPlanWork = knownStatus && doc.status && (!doc.type || doc.type === 'plan')
199
- && !config.lifecycle.terminalStatuses.has(doc.status) && !config.lifecycle.skipWarningsFor.has(doc.status);
199
+ && !config.lifecycle.terminalStatuses.has(doc.status) && !config.lifecycle.skipsWarnings(doc.status, doc.type);
200
200
 
201
201
  if (isPlanWork && !asString(frontmatter.current_state)) {
202
202
  doc.warnings.push({ path: doc.path, level: 'warning', message: 'Missing `current_state`; index output is using a fallback or placeholder.' });
@@ -259,7 +259,7 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
259
259
  const docDir = path.dirname(path.join(config.repoRoot, doc.path));
260
260
  const allRefFields = [...(config.referenceFields.bidirectional || []), ...(config.referenceFields.unidirectional || [])];
261
261
  const skipRefValidation = config.lifecycle.terminalStatuses.has(doc.status)
262
- || config.lifecycle.skipWarningsFor.has(doc.status);
262
+ || config.lifecycle.skipsWarnings(doc.status, doc.type);
263
263
  if (!skipRefValidation) {
264
264
  for (const field of allRefFields) {
265
265
  for (const relPath of (doc.refFields[field] || [])) {
@@ -406,14 +406,14 @@ export function checkBidirectionalReferences(docs, config) {
406
406
  // child it doesn't own without nagging the child to add `parent_plan:`.
407
407
  export function checkRunlistBackPointers(docs, config) {
408
408
  const warnings = [];
409
- const skipStatuses = new Set([
410
- ...(config.lifecycle.terminalStatuses ?? []),
411
- ...(config.lifecycle.skipWarningsFor ?? []),
412
- ]);
409
+ // Per-doc, not a precomputed name set: warning suppression is type-scoped, so
410
+ // the same status name can be quiet for one type and loud for another.
411
+ const skipDoc = (d) => config.lifecycle.terminalStatuses.has(d.status)
412
+ || config.lifecycle.skipsWarnings(d.status, d.type);
413
413
  const byPath = new Map(docs.map(d => [d.path, d]));
414
414
 
415
415
  for (const hub of docs) {
416
- if (skipStatuses.has(hub.status)) continue;
416
+ if (skipDoc(hub)) continue;
417
417
  const runlistRefs = hub.refFields?.runlist ?? [];
418
418
  const runlistDirs = hub.refFieldDirections?.runlist ?? [];
419
419
  if (runlistRefs.length === 0) continue;
@@ -427,7 +427,7 @@ export function checkRunlistBackPointers(docs, config) {
427
427
  const childPath = toRepoPath(resolved, config.repoRoot);
428
428
  const child = byPath.get(childPath);
429
429
  if (!child) continue;
430
- if (skipStatuses.has(child.status)) continue;
430
+ if (skipDoc(child)) continue;
431
431
  const childParents = (child.refFields?.parent_plan ?? []).map(p => {
432
432
  const abs = resolveRefPath(p, path.dirname(path.join(config.repoRoot, child.path)), config.repoRoot);
433
433
  return abs ? toRepoPath(abs, config.repoRoot) : p;
@@ -451,17 +451,21 @@ export function checkRunlistBackPointers(docs, config) {
451
451
  // it explicit. Skips terminal/quiet statuses like every other warning-only check.
452
452
  export function checkCoordinationHubExecutionMode(docs, config) {
453
453
  const warnings = [];
454
- const skipStatuses = new Set([
455
- ...(config.lifecycle.terminalStatuses ?? []),
456
- ...(config.lifecycle.skipWarningsFor ?? []),
457
- ]);
454
+ // Per-doc, not a precomputed name set: warning suppression is type-scoped, so
455
+ // the same status name can be quiet for one type and loud for another.
456
+ const skipDoc = (d) => config.lifecycle.terminalStatuses.has(d.status)
457
+ || config.lifecycle.skipsWarnings(d.status, d.type);
458
458
  for (const doc of docs) {
459
459
  if (doc.type && doc.type !== 'plan') continue;
460
- if (skipStatuses.has(doc.status)) continue;
461
- // A roadmap (`execution_mode: roadmap`) is already an explicit held-out hub —
462
- // just a tier up. Don't nudge it toward `coordination` even when its slug is
463
- // `*-runlist` (e.g. a `master-runlist` promoted to a roadmap).
464
- if (doc.executionMode === 'coordination' || doc.executionMode === 'roadmap') continue;
460
+ if (skipDoc(doc)) continue;
461
+ // ANY explicit `execution_mode` ends it. The field is the canonical signal
462
+ // and the slug is only a fallback for hubs that predate it, so a plan that
463
+ // already answered — `implementation`, `roadmap`, anything — has said what it
464
+ // is. Nudging past that misfires on exactly one population: plans ABOUT
465
+ // runlists, whose slug contains the word while the document is ordinary work.
466
+ // (A real one: a plan to rename the runlist concept, declared
467
+ // `execution_mode: implementation`, nagged to call itself a coordination hub.)
468
+ if (doc.executionMode) continue;
465
469
  const base = (doc.path.split('/').pop() || '').replace(/\.md$/, '');
466
470
  if (base !== 'runlist' && !base.endsWith('-runlist')) continue;
467
471
  warnings.push({
@@ -482,10 +486,10 @@ export function checkCoordinationHubExecutionMode(docs, config) {
482
486
  // coordination hub that merely references one sibling runlist isn't mislabelled.
483
487
  export function checkRoadmapHubExecutionMode(docs, config) {
484
488
  const warnings = [];
485
- const skipStatuses = new Set([
486
- ...(config.lifecycle.terminalStatuses ?? []),
487
- ...(config.lifecycle.skipWarningsFor ?? []),
488
- ]);
489
+ // Per-doc, not a precomputed name set: warning suppression is type-scoped, so
490
+ // the same status name can be quiet for one type and loud for another.
491
+ const skipDoc = (d) => config.lifecycle.terminalStatuses.has(d.status)
492
+ || config.lifecycle.skipsWarnings(d.status, d.type);
489
493
  const byPath = new Map(docs.map(d => [d.path, d]));
490
494
  const byBasename = new Map();
491
495
  for (const d of docs) {
@@ -501,7 +505,7 @@ export function checkRoadmapHubExecutionMode(docs, config) {
501
505
  };
502
506
  for (const doc of docs) {
503
507
  if (doc.type && doc.type !== 'plan') continue;
504
- if (skipStatuses.has(doc.status)) continue;
508
+ if (skipDoc(doc)) continue;
505
509
  if (doc.executionMode === 'roadmap') continue; // already a roadmap
506
510
  if (doc.executionMode !== 'coordination') continue; // only nudge explicit coordination hubs
507
511
  const refs = doc.refFields?.related_plans ?? [];
@@ -585,7 +589,7 @@ export function validatePlanShape(doc, body, frontmatter, config) {
585
589
  if (doc.type !== 'plan') return;
586
590
  // Skip plans in terminal/archive statuses (closed work shouldn't generate noise)
587
591
  if (config.lifecycle.terminalStatuses.has(doc.status) || config.lifecycle.archiveStatuses.has(doc.status)) return;
588
- if (config.lifecycle.skipWarningsFor.has(doc.status)) return;
592
+ if (config.lifecycle.skipsWarnings(doc.status, doc.type)) return;
589
593
 
590
594
  // 1. next_step length cap (800 chars). Was 300; raised in parallel with
591
595
  // current_state for the same reason: agents need to encode "what to do next"
@@ -658,7 +662,7 @@ export function validatePlanShape(doc, body, frontmatter, config) {
658
662
  export function validateDocShape(doc, body, frontmatter, config) {
659
663
  if (doc.type !== 'doc') return;
660
664
  if (config.lifecycle.terminalStatuses.has(doc.status) || config.lifecycle.archiveStatuses.has(doc.status)) return;
661
- if (config.lifecycle.skipWarningsFor.has(doc.status)) return;
665
+ if (config.lifecycle.skipsWarnings(doc.status, doc.type)) return;
662
666
 
663
667
  if (!body) return;
664
668