dotmd-cli 0.74.2 → 0.74.4

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
@@ -919,7 +919,10 @@ Plan body variants (plans only — pick one body shape):
919
919
  Other options:
920
920
  --status <s> Set initial status (defaults to first valid status for the type)
921
921
  --title <t> Override the auto-derived title
922
- --root <name> Create in a specific docs root
922
+ --root <name> Create in a specific docs root. Applies to a nested name
923
+ too: \`new doc prospects/kim --root docs\` writes
924
+ docs/prospects/kim.md. Without it, a name containing a
925
+ \`/\` is read relative to the repo.
923
926
  --show-files Append \`files: …\` line to stderr listing what was touched
924
927
  (the new doc + the index file). See \`dotmd archive --help\`.
925
928
  --list-types Show registered types (alias: --list-templates)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.74.2",
3
+ "version": "0.74.4",
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,
@@ -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/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/new.mjs CHANGED
@@ -60,6 +60,27 @@ function fullBodyShortcut(title, bodyInput) {
60
60
  return hasOwnTitle ? `\n${b}\n` : `\n# ${title}\n\n${b}\n`;
61
61
  }
62
62
 
63
+ function lexicallyInside(parent, child) {
64
+ return child === parent || child.startsWith(parent + path.sep);
65
+ }
66
+
67
+ // The default root for a type that doesn't name one of its own. This used to be
68
+ // "whichever root is listed first", which drops a `doc` into `docs/plans` for
69
+ // any project that lists its plans root first — the plan type's own root, for a
70
+ // type that isn't a plan. When one configured root contains another it is
71
+ // structurally the catch-all (`docs` holding `docs/plans` and `docs/adr`), so
72
+ // prefer that; the deepest one, so a nested chain picks the most specific
73
+ // container rather than the outermost. Single-root projects and flat sibling
74
+ // roots have no catch-all to find and keep first-listed order.
75
+ export function catchAllRoot(config) {
76
+ const roots = config.docsRoots ?? [config.docsRoot];
77
+ if (roots.length < 2) return config.docsRoot;
78
+ const containers = roots.filter(root => roots.some(other => other !== root && lexicallyInside(root, other)));
79
+ if (!containers.length) return config.docsRoot;
80
+ const depth = root => root.split(path.sep).length;
81
+ return containers.reduce((deepest, root) => depth(root) > depth(deepest) ? root : deepest);
82
+ }
83
+
63
84
  const BUILTIN_TEMPLATES = {
64
85
  doc: {
65
86
  description: 'Reference doc, design note, module overview — build-up shape lite',
@@ -800,6 +821,9 @@ export async function runNew(argv, config, opts = {}) {
800
821
  } else if (name.endsWith('.md')) {
801
822
  namePart = name.slice(0, -3);
802
823
  }
824
+ // A prefix the *user* typed and one a template declares resolve differently
825
+ // below, so the distinction has to survive the template's assignment to nameDir.
826
+ const userNameDir = nameDir;
803
827
 
804
828
  // Slugify
805
829
  const slug = namePart.toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
@@ -811,7 +835,7 @@ export async function runNew(argv, config, opts = {}) {
811
835
  // Resolve target root. Precedence: CLI --root > template.targetRoot > config.docsRoot.
812
836
  // When the chosen root is a first-class type-container (matched by --root or targetRoot),
813
837
  // we skip the `template.dir` join — the root already points at the right directory.
814
- let targetRoot = config.docsRoot;
838
+ let targetRoot = catchAllRoot(config);
815
839
  let routedToTypeRoot = false;
816
840
  if (rootName) {
817
841
  const roots = config.docsRoots || [config.docsRoot];
@@ -837,10 +861,46 @@ export async function runNew(argv, config, opts = {}) {
837
861
  nameDir = path.join(path.relative(config.repoRoot, targetRoot), template.dir);
838
862
  }
839
863
 
840
- // Path — if user provided a directory prefix OR template declared one, resolve relative to repoRoot
841
- const baseDir = nameDir ? path.resolve(config.repoRoot, nameDir) : targetRoot;
864
+ // Path — a directory prefix is read relative to the repo, because `dotmd new
865
+ // plan docs/plans/feature` is a full repo path and has to stay one. That was
866
+ // the ONLY reading, which is how `--root` came to be silently ignored the
867
+ // moment a name contained a slash: the block above picked a root and this line
868
+ // threw it away, so the one flag the out-of-root error advertises could not
869
+ // fix the error. With an explicit --root the prefix is now read relative to
870
+ // that root — unless the repo-relative reading already lands inside it, so
871
+ // full paths keep working.
872
+ const allRoots = config.docsRoots ?? [config.docsRoot];
873
+ let baseDir;
874
+ if (!nameDir) baseDir = targetRoot;
875
+ else if (userNameDir && rootName) {
876
+ const repoRelative = path.resolve(config.repoRoot, nameDir);
877
+ baseDir = lexicallyInside(targetRoot, repoRelative) ? repoRelative : path.resolve(targetRoot, nameDir);
878
+ } else baseDir = path.resolve(config.repoRoot, nameDir);
842
879
  const filePath = path.join(baseDir, slug + '.md');
843
880
  const repoPath = toRepoPath(filePath, config.repoRoot);
881
+
882
+ // Without --root there is nothing to disambiguate with, so a prefix pointing
883
+ // outside every root stays an error — but it names the flag that resolves it,
884
+ // and the flag now works. The generic containment error underneath reports
885
+ // absolute paths and no remedy, which is what agents kept re-guessing at.
886
+ //
887
+ // Gated on the remedy actually working: the suggestion is only offered when
888
+ // `--root` would land the file inside that root. That is what keeps this off
889
+ // a traversal (`../escaped`, an absolute path), where `--root` fixes nothing
890
+ // and the containment check below is the error that should speak — printing
891
+ // an untested remedy is the very defect this finding is about.
892
+ if (userNameDir && !rootName && !allRoots.some(root => lexicallyInside(root, filePath))) {
893
+ const rooted = path.resolve(targetRoot, userNameDir, slug + '.md');
894
+ if (lexicallyInside(targetRoot, rooted)) {
895
+ die(`Destination is outside every configured root:\n`
896
+ + ` ${repoPath}\n\n`
897
+ + `A name with a \`/\` is read relative to the repo.\n`
898
+ + `To place it under a root instead:\n`
899
+ + ` dotmd new ${typeName} ${name} --root ${path.basename(targetRoot)}\n`
900
+ + ` → ${toRepoPath(rooted, config.repoRoot)}\n\n`
901
+ + `Roots: ${allRoots.map(root => path.basename(root)).join(', ')}`);
902
+ }
903
+ }
844
904
  const destinationAuthorization = authorizeManagedDestination(filePath, config, { kind: 'New document destination' });
845
905
 
846
906
  if (existsSync(filePath)) {
@@ -862,12 +922,15 @@ export async function runNew(argv, config, opts = {}) {
862
922
  // When the project has >1 root and `--root` was omitted, surface the choice
863
923
  // so agents can see that an alternative root was available. Cheap visibility
864
924
  // for the "ended up in docs/plans/ for a doc" foot-gun.
865
- const allRoots = config.docsRoots ?? [config.docsRoot];
925
+ // Report the root the file actually landed in, not the one resolution started
926
+ // from: a directory prefix can move the destination into a different root
927
+ // entirely, and the line used to say `Root: plans` while writing docs/prospects/.
866
928
  let rootHint = '';
867
929
  if (!rootName && allRoots.length > 1) {
868
- const chosenLabel = path.basename(targetRoot);
930
+ const owningRoot = destinationAuthorization.root.lexicalPath;
931
+ const chosenLabel = path.basename(owningRoot);
869
932
  const others = allRoots
870
- .filter(r => r !== targetRoot)
933
+ .filter(r => r !== owningRoot)
871
934
  .map(r => path.basename(r));
872
935
  rootHint = `Root: ${chosenLabel} (others: ${others.join(', ')} — pass --root <name> to change)\n`;
873
936
  }
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,13 +451,13 @@ 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;
460
+ if (skipDoc(doc)) continue;
461
461
  // A roadmap (`execution_mode: roadmap`) is already an explicit held-out hub —
462
462
  // just a tier up. Don't nudge it toward `coordination` even when its slug is
463
463
  // `*-runlist` (e.g. a `master-runlist` promoted to a roadmap).
@@ -482,10 +482,10 @@ export function checkCoordinationHubExecutionMode(docs, config) {
482
482
  // coordination hub that merely references one sibling runlist isn't mislabelled.
483
483
  export function checkRoadmapHubExecutionMode(docs, config) {
484
484
  const warnings = [];
485
- const skipStatuses = new Set([
486
- ...(config.lifecycle.terminalStatuses ?? []),
487
- ...(config.lifecycle.skipWarningsFor ?? []),
488
- ]);
485
+ // Per-doc, not a precomputed name set: warning suppression is type-scoped, so
486
+ // the same status name can be quiet for one type and loud for another.
487
+ const skipDoc = (d) => config.lifecycle.terminalStatuses.has(d.status)
488
+ || config.lifecycle.skipsWarnings(d.status, d.type);
489
489
  const byPath = new Map(docs.map(d => [d.path, d]));
490
490
  const byBasename = new Map();
491
491
  for (const d of docs) {
@@ -501,7 +501,7 @@ export function checkRoadmapHubExecutionMode(docs, config) {
501
501
  };
502
502
  for (const doc of docs) {
503
503
  if (doc.type && doc.type !== 'plan') continue;
504
- if (skipStatuses.has(doc.status)) continue;
504
+ if (skipDoc(doc)) continue;
505
505
  if (doc.executionMode === 'roadmap') continue; // already a roadmap
506
506
  if (doc.executionMode !== 'coordination') continue; // only nudge explicit coordination hubs
507
507
  const refs = doc.refFields?.related_plans ?? [];
@@ -585,7 +585,7 @@ export function validatePlanShape(doc, body, frontmatter, config) {
585
585
  if (doc.type !== 'plan') return;
586
586
  // Skip plans in terminal/archive statuses (closed work shouldn't generate noise)
587
587
  if (config.lifecycle.terminalStatuses.has(doc.status) || config.lifecycle.archiveStatuses.has(doc.status)) return;
588
- if (config.lifecycle.skipWarningsFor.has(doc.status)) return;
588
+ if (config.lifecycle.skipsWarnings(doc.status, doc.type)) return;
589
589
 
590
590
  // 1. next_step length cap (800 chars). Was 300; raised in parallel with
591
591
  // current_state for the same reason: agents need to encode "what to do next"
@@ -658,7 +658,7 @@ export function validatePlanShape(doc, body, frontmatter, config) {
658
658
  export function validateDocShape(doc, body, frontmatter, config) {
659
659
  if (doc.type !== 'doc') return;
660
660
  if (config.lifecycle.terminalStatuses.has(doc.status) || config.lifecycle.archiveStatuses.has(doc.status)) return;
661
- if (config.lifecycle.skipWarningsFor.has(doc.status)) return;
661
+ if (config.lifecycle.skipsWarnings(doc.status, doc.type)) return;
662
662
 
663
663
  if (!body) return;
664
664