runlist 0.0.0 → 0.94.1

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.
Files changed (166) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1114 -6
  3. package/assets/app/app.css +283 -0
  4. package/assets/app/app.mjs +381 -0
  5. package/assets/app/block-editor.mjs +182 -0
  6. package/assets/app/block-model.mjs +52 -0
  7. package/assets/app/document-create.mjs +86 -0
  8. package/assets/app/document-lifecycle.mjs +36 -0
  9. package/assets/app/document-stages.mjs +22 -0
  10. package/assets/app/document-views.mjs +33 -0
  11. package/assets/app/document-yardstick.mjs +258 -0
  12. package/assets/app/editor-navigation.mjs +51 -0
  13. package/assets/app/filing-navigation.mjs +138 -0
  14. package/assets/app/git-changes.mjs +80 -0
  15. package/assets/app/git-commit.mjs +46 -0
  16. package/assets/app/index.html +70 -0
  17. package/assets/app/library-navigation.mjs +93 -0
  18. package/assets/app/outline.mjs +18 -0
  19. package/assets/app/quick-navigation.mjs +52 -0
  20. package/assets/app/record-navigation.mjs +78 -0
  21. package/assets/app/recovery-center.mjs +53 -0
  22. package/assets/app/semantic-search.mjs +14 -0
  23. package/assets/app/settings.mjs +15 -0
  24. package/assets/app/shared.mjs +104 -0
  25. package/assets/app/stage-source.mjs +75 -0
  26. package/assets/app/template-editor.mjs +47 -0
  27. package/assets/app/theme.mjs +25 -0
  28. package/assets/app/transport.mjs +43 -0
  29. package/assets/contracts/source-editing-v1.md +232 -0
  30. package/assets/opencode/plugin.js +162 -0
  31. package/assets/schemas/runlist-record-v1.schema.json +912 -0
  32. package/bin/dotmd.mjs +5 -0
  33. package/bin/runlist.mjs +2714 -0
  34. package/package.json +53 -6
  35. package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
  36. package/plugins/runlist-codex/bin/runlist-hook +86 -0
  37. package/plugins/runlist-codex/hooks/hooks.json +16 -0
  38. package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
  39. package/runlist.config.example.mjs +370 -0
  40. package/scripts/postinstall.mjs +56 -0
  41. package/src/agent-context.mjs +201 -0
  42. package/src/ai.mjs +30 -0
  43. package/src/app-create.mjs +169 -0
  44. package/src/app-filing.mjs +139 -0
  45. package/src/app-git-commits.mjs +48 -0
  46. package/src/app-git.mjs +180 -0
  47. package/src/app-library.mjs +152 -0
  48. package/src/app-lifecycle-worker.mjs +12 -0
  49. package/src/app-lifecycle.mjs +79 -0
  50. package/src/app-records.mjs +134 -0
  51. package/src/app-search.mjs +80 -0
  52. package/src/app-semantic-search.mjs +105 -0
  53. package/src/app-service.mjs +208 -0
  54. package/src/app-yardstick.mjs +269 -0
  55. package/src/app.mjs +102 -0
  56. package/src/atomic-mutation.mjs +1736 -0
  57. package/src/baton.mjs +435 -0
  58. package/src/body-link.mjs +48 -0
  59. package/src/bulk-tag.mjs +174 -0
  60. package/src/check-collapse.mjs +75 -0
  61. package/src/claude-commands.mjs +75 -0
  62. package/src/cli-editor.mjs +8 -0
  63. package/src/code-refs.mjs +545 -0
  64. package/src/codex-integration.mjs +81 -0
  65. package/src/color.mjs +14 -0
  66. package/src/commands.mjs +374 -0
  67. package/src/completions.mjs +93 -0
  68. package/src/config-edit.mjs +606 -0
  69. package/src/config.mjs +705 -0
  70. package/src/decision-action.mjs +58 -0
  71. package/src/decision.mjs +220 -0
  72. package/src/decisions.mjs +787 -0
  73. package/src/deps.mjs +317 -0
  74. package/src/desktop-install.mjs +158 -0
  75. package/src/desktop-runtime.mjs +4 -0
  76. package/src/desktop.mjs +25 -0
  77. package/src/diff.mjs +101 -0
  78. package/src/doctor.mjs +591 -0
  79. package/src/durable-rename.mjs +55 -0
  80. package/src/errors-read.mjs +44 -0
  81. package/src/export.mjs +496 -0
  82. package/src/extractors.mjs +101 -0
  83. package/src/filing.mjs +255 -0
  84. package/src/fix-membership.mjs +134 -0
  85. package/src/fix-refs.mjs +174 -0
  86. package/src/flags.mjs +318 -0
  87. package/src/frontmatter-fix.mjs +195 -0
  88. package/src/frontmatter.mjs +252 -0
  89. package/src/git-commit-filters.mjs +32 -0
  90. package/src/git-commit-protocol.mjs +198 -0
  91. package/src/git-commit-signing.mjs +75 -0
  92. package/src/git-commit-store.mjs +26 -0
  93. package/src/git-commit-support.mjs +16 -0
  94. package/src/git-commit-worker.mjs +32 -0
  95. package/src/git.mjs +909 -0
  96. package/src/glossary-check.mjs +35 -0
  97. package/src/glossary.mjs +243 -0
  98. package/src/graph.mjs +298 -0
  99. package/src/guard.mjs +488 -0
  100. package/src/health.mjs +212 -0
  101. package/src/hints.mjs +129 -0
  102. package/src/host-integration.mjs +339 -0
  103. package/src/hub-membership.mjs +284 -0
  104. package/src/hub.mjs +292 -0
  105. package/src/hud.mjs +436 -0
  106. package/src/index-file.mjs +163 -0
  107. package/src/index.mjs +478 -0
  108. package/src/init.mjs +512 -0
  109. package/src/install.mjs +222 -0
  110. package/src/journal-read.mjs +116 -0
  111. package/src/journal.mjs +472 -0
  112. package/src/lifecycle.mjs +1444 -0
  113. package/src/lint.mjs +386 -0
  114. package/src/managed-path.mjs +192 -0
  115. package/src/markdown-code-spans.mjs +58 -0
  116. package/src/migrate-prompts.mjs +171 -0
  117. package/src/migrate-template.mjs +170 -0
  118. package/src/migrate.mjs +90 -0
  119. package/src/misuse-read.mjs +61 -0
  120. package/src/model-request.mjs +33 -0
  121. package/src/model.mjs +523 -0
  122. package/src/modules.mjs +355 -0
  123. package/src/naming.mjs +55 -0
  124. package/src/native-action.mjs +59 -0
  125. package/src/native-record.mjs +244 -0
  126. package/src/new.mjs +1321 -0
  127. package/src/output-identity.mjs +111 -0
  128. package/src/parent-plan.mjs +58 -0
  129. package/src/parse-cache.mjs +99 -0
  130. package/src/path-containment.mjs +15 -0
  131. package/src/pickup-card.mjs +293 -0
  132. package/src/pickup.mjs +671 -0
  133. package/src/prompt.mjs +25 -0
  134. package/src/prompts.mjs +585 -0
  135. package/src/query.mjs +977 -0
  136. package/src/record-action.mjs +11 -0
  137. package/src/record-create.mjs +82 -0
  138. package/src/reference-planner.mjs +388 -0
  139. package/src/reference-values.mjs +11 -0
  140. package/src/rename.mjs +107 -0
  141. package/src/render.mjs +678 -0
  142. package/src/roadmap.mjs +184 -0
  143. package/src/runlist.mjs +944 -0
  144. package/src/section.mjs +225 -0
  145. package/src/semantic-search-client.mjs +58 -0
  146. package/src/ship.mjs +166 -0
  147. package/src/show.mjs +101 -0
  148. package/src/skill-drift.mjs +98 -0
  149. package/src/source-editor.mjs +491 -0
  150. package/src/stages.mjs +73 -0
  151. package/src/state-migration.mjs +123 -0
  152. package/src/stats.mjs +200 -0
  153. package/src/status-metadata.mjs +87 -0
  154. package/src/statuses.mjs +721 -0
  155. package/src/summary.mjs +74 -0
  156. package/src/surfaces.mjs +28 -0
  157. package/src/sync-status.mjs +316 -0
  158. package/src/template-store.mjs +62 -0
  159. package/src/update.mjs +338 -0
  160. package/src/use.mjs +58 -0
  161. package/src/util.mjs +337 -0
  162. package/src/validate.mjs +794 -0
  163. package/src/watch.mjs +56 -0
  164. package/src/xref.mjs +406 -0
  165. package/src/yardstick-command.mjs +178 -0
  166. package/src/yardstick.mjs +112 -0
@@ -0,0 +1,794 @@
1
+ import path from 'node:path';
2
+ import { asString, resolveRefPath, suggestCandidates } from './util.mjs';
3
+ import { getGitLastModified, getGitLastModifiedBatch, getGitLastSubstantiveModifiedBatch } from './git.mjs';
4
+ import { toRepoPath } from './util.mjs';
5
+ import { detectMarker, isPhaseHeading, phaseMarkerConflict, walkSections } from './section.mjs';
6
+ import { resolveBodyLinkTarget } from './body-link.mjs';
7
+ import { getStageDefinitions, readPlanStage } from './stages.mjs';
8
+ import { assessmentIssues } from './yardstick.mjs';
9
+
10
+ const NOW = new Date();
11
+
12
+ // Type-conventional dirs are the directories where `runlist new <type>` lands
13
+ // live (non-archive) docs of that type. Built-ins use `dir` ('plans'/'prompts')
14
+ // and `targetRoot`. In flat-array root configs (e.g. root: ['docs/plans',
15
+ // 'docs/prompts']), the root itself is a type-conventional dir; in default
16
+ // single-root configs (root: 'docs'), the type-conventional dirs are
17
+ // '<root>/plans' and '<root>/prompts'. This helper builds the union so the
18
+ // archive-drift check works for both layouts. Custom user templates with
19
+ // their own `dir` would extend this; we hard-code the built-in dir names.
20
+ const BUILTIN_TYPE_DIR_NAMES = ['plans', 'prompts'];
21
+
22
+ function liveTypeDirsForRoots(config) {
23
+ const set = new Set();
24
+ const roots = config.docsRoots || (config.docsRoot ? [config.docsRoot] : []);
25
+ // F15: filed-status bucket dirs are "live" too — a doc whose status is
26
+ // an archive status but whose parent dir is a filed bucket should still
27
+ // trigger the archive-drift warning (the file needs to move into the
28
+ // archive bucket).
29
+ const filedDirs = [...((config.lifecycle?.filedStatuses?.values?.()) ?? [])];
30
+ for (const root of roots) {
31
+ const rootRel = path.relative(config.repoRoot, root).split(path.sep).join('/');
32
+ // The root itself is a live dir (covers flat-array layouts where the
33
+ // root IS the type-container).
34
+ set.add(rootRel);
35
+ // Each builtin type-dir joined to the root (covers single-root layouts
36
+ // where 'docs' contains 'docs/plans' and 'docs/prompts' subdirs).
37
+ for (const dirName of BUILTIN_TYPE_DIR_NAMES) {
38
+ // Skip if root already ends in this name (no double-nesting like
39
+ // 'docs/prompts/prompts').
40
+ if (path.basename(rootRel) === dirName) continue;
41
+ set.add(rootRel ? `${rootRel}/${dirName}` : dirName);
42
+ }
43
+ // User template dirs from config (extend the set with whatever live
44
+ // dirs custom types declare).
45
+ for (const tmpl of Object.values(config.raw?.templates ?? {})) {
46
+ if (!tmpl || typeof tmpl !== 'object') continue;
47
+ if (tmpl.dir && path.basename(rootRel) !== tmpl.dir) {
48
+ set.add(rootRel ? `${rootRel}/${tmpl.dir}` : tmpl.dir);
49
+ }
50
+ }
51
+ // F15: filed bucket dirs joined to each root.
52
+ for (const dirName of filedDirs) {
53
+ if (path.basename(rootRel) === dirName) continue;
54
+ set.add(rootRel ? `${rootRel}/${dirName}` : dirName);
55
+ for (const typeDir of BUILTIN_TYPE_DIR_NAMES) {
56
+ if (path.basename(rootRel) === typeDir) continue;
57
+ const typeRoot = rootRel ? `${rootRel}/${typeDir}` : typeDir;
58
+ set.add(`${typeRoot}/${dirName}`);
59
+ }
60
+ }
61
+ }
62
+ return set;
63
+ }
64
+
65
+ function isValidStatus(status, root, config, type) {
66
+ // When a doc declares a known type, that type's status set is authoritative.
67
+ // Falling through to the global union (across all types) would allow a
68
+ // `type: prompt` doc to carry `status: active` just because `active` is valid
69
+ // for plans — defeating the purpose of type-scoped vocabularies.
70
+ if (type) {
71
+ const typeSet = config.typeStatuses?.get(type);
72
+ if (typeSet) return typeSet.has(status);
73
+ }
74
+ const rootSet = config.rootValidStatuses?.get(root);
75
+ if (rootSet) return rootSet.has(status);
76
+ return config.validStatuses.has(status);
77
+ }
78
+
79
+ export function validateDoc(doc, frontmatter, headingTitle, config) {
80
+ if (doc.type === 'plan') {
81
+ const archived = config.lifecycle.archiveStatuses.has(doc.status) || doc.path.split('/').includes(config.archiveDir);
82
+ for (const message of assessmentIssues(frontmatter, archived)) {
83
+ doc.warnings.push({ path: doc.path, level: 'warning', message });
84
+ }
85
+ const stage = readPlanStage(frontmatter.ships);
86
+ const milestones = (config.raw ?? config).taxonomy?.milestones;
87
+ if (stage.invalid) {
88
+ doc.errors.push({ path: doc.path, level: 'error', message: '`ships` must be a single stage string on one line without NUL characters; leave it blank or omit it for Unset.' });
89
+ } else if (stage.word && Array.isArray(milestones) && !getStageDefinitions(config).some(definition => definition.word === stage.word)) {
90
+ doc.errors.push({ path: doc.path, level: 'error', message: `Unknown stage \`${stage.word}\` in \`ships\`; expected a configured \`taxonomy.milestones\` value.` });
91
+ }
92
+ }
93
+
94
+ // Validate type field
95
+ if (doc.type && config.validTypes && !config.validTypes.has(doc.type)) {
96
+ doc.warnings.push({ path: doc.path, level: 'warning', message: `Unknown type \`${doc.type}\`; expected one of: ${[...config.validTypes].join(', ')}.` });
97
+ }
98
+
99
+ if (!doc.status) {
100
+ doc.errors.push({ path: doc.path, level: 'error', message: 'Missing frontmatter `status`.' });
101
+ } else if (!isValidStatus(doc.status, doc.root, config, doc.type)) {
102
+ const typeSet = doc.type && config.typeStatuses?.get(doc.type);
103
+ const rootSet = config.rootValidStatuses?.get(doc.root);
104
+ // When the doc has a known type, scope the error hint to that type's vocab.
105
+ // Otherwise fall back to root-specific or global validStatuses.
106
+ const hint = typeSet
107
+ ? `valid for type \`${doc.type}\`: ${[...typeSet].join(', ')}`
108
+ : `valid: ${[...(rootSet ?? config.validStatuses)].join(', ')}`;
109
+ doc.errors.push({ path: doc.path, level: 'error', message: `Unknown status \`${doc.status}\`; ${hint}.` });
110
+ }
111
+
112
+ const knownStatus = isValidStatus(doc.status, doc.root, config, doc.type);
113
+
114
+ if (knownStatus && !config.lifecycle.skipsWarnings(doc.status, doc.type) && !doc.updated) {
115
+ doc.errors.push({ path: doc.path, level: 'error', message: 'Missing frontmatter `updated` for non-archived doc.' });
116
+ }
117
+
118
+ if (knownStatus && doc.auditLevel && doc.auditLevel !== 'none' && !doc.audited) {
119
+ doc.errors.push({ path: doc.path, level: 'error', message: '`audit_level` is set without `audited`.' });
120
+ }
121
+
122
+ if (knownStatus && doc.auditLevel === 'none' && doc.audited) {
123
+ doc.errors.push({ path: doc.path, level: 'error', message: '`audit_level: none` cannot be combined with `audited`.' });
124
+ }
125
+
126
+ if (Object.prototype.hasOwnProperty.call(frontmatter, 'blockers') && !Array.isArray(frontmatter.blockers)) {
127
+ doc.errors.push({ path: doc.path, level: 'error', message: '`blockers` must be a YAML list when present.' });
128
+ }
129
+
130
+ if (Object.prototype.hasOwnProperty.call(frontmatter, 'blocked_by') && !Array.isArray(frontmatter.blocked_by)) {
131
+ doc.errors.push({ path: doc.path, level: 'error', message: '`blocked_by` must be a YAML list when present.' });
132
+ }
133
+
134
+ if (Object.prototype.hasOwnProperty.call(frontmatter, 'surfaces') && !Array.isArray(frontmatter.surfaces)) {
135
+ doc.errors.push({ path: doc.path, level: 'error', message: '`surfaces` must be a YAML list when present.' });
136
+ }
137
+
138
+ if (Object.prototype.hasOwnProperty.call(frontmatter, 'modules') && !Array.isArray(frontmatter.modules)) {
139
+ doc.errors.push({ path: doc.path, level: 'error', message: '`modules` must be a YAML list when present.' });
140
+ }
141
+
142
+ // modules-required gate: only fires when the project has actively declared
143
+ // a module taxonomy (`taxonomy.modules` is an array). Repos with no product
144
+ // modules (CLIs, dev tooling, single-domain apps) shouldn't be forced to
145
+ // sprinkle `modules: [none]` on every plan as a sentinel. Mirrors the
146
+ // long-standing `taxonomy.surfaces` opt-in semantics.
147
+ if (config.validModules && config.moduleRequiredStatuses.has(doc.status) && !doc.modules?.length) {
148
+ 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.' });
149
+ }
150
+
151
+ if (config.validSurfaces && !config.lifecycle.skipsWarnings(doc.status, doc.type)) {
152
+ const knownSurfaces = [...config.validSurfaces];
153
+ for (const surface of doc.surfaces) {
154
+ if (!config.validSurfaces.has(surface)) {
155
+ const suggestions = suggestCandidates(surface, knownSurfaces, 3);
156
+ const hint = suggestions.length
157
+ ? ` Did you mean: ${suggestions.map(s => `\`${s}\``).join(' | ')}?`
158
+ : '';
159
+ doc.warnings.push({
160
+ path: doc.path,
161
+ level: 'warning',
162
+ message: `Unknown surface \`${surface}\`; expected a known surface taxonomy value.${hint} Run \`runlist surfaces\` to list all valid values.`,
163
+ });
164
+ }
165
+ }
166
+ }
167
+
168
+ // F18: Singular `module:` / `surface:` are deprecated in favor of plural arrays.
169
+ // The reader still merges singular into plural transparently (back-compat),
170
+ // but new usage should always go through the plural form. The migration
171
+ // target is inlined in the message so `runlist lint --fix` users see exactly
172
+ // what they'll end up with — and so non-fix readers can hand-migrate.
173
+ // Suppress for archived/terminal docs (same noise-control rule as F2).
174
+ if (!config.lifecycle.skipsWarnings(doc.status, doc.type)) {
175
+ for (const { singular, plural } of [{ singular: 'module', plural: 'modules' }, { singular: 'surface', plural: 'surfaces' }]) {
176
+ const singularValue = frontmatter[singular];
177
+ if (!Object.prototype.hasOwnProperty.call(frontmatter, singular)) continue;
178
+ const rawPluralValue = frontmatter[plural];
179
+ const pluralValue = Array.isArray(rawPluralValue)
180
+ ? rawPluralValue
181
+ : rawPluralValue === undefined ? [] : [rawPluralValue];
182
+ const singularValues = Array.isArray(singularValue) ? singularValue : [singularValue];
183
+ const merged = [];
184
+ for (const v of [...singularValues, ...pluralValue]) {
185
+ if (typeof v === 'string' && v && !merged.includes(v)) merged.push(v);
186
+ }
187
+ const target = `${plural}: [${merged.map(v => JSON.stringify(v)).join(', ')}]`;
188
+ const autoFixableString = value => typeof value === 'string' && !/(^|\s)#/.test(value);
189
+ const autoFixable = singularValues.every(autoFixableString)
190
+ && pluralValue.every(autoFixableString);
191
+ const guidance = autoFixable
192
+ ? `use \`${target}\`. Run \`runlist lint --fix\` to migrate.`
193
+ : `use a \`${plural}:\` YAML list. Remove the deprecated \`${singular}:\` block manually and preserve all of its values.`;
194
+ doc.warnings.push({
195
+ path: doc.path,
196
+ level: 'warning',
197
+ message: `\`${singular}:\` (singular) is deprecated — ${guidance}`,
198
+ });
199
+ }
200
+ }
201
+
202
+ // Prompts are intentionally body-only one-shot artifacts: the slug names the
203
+ // prompt, the body IS the payload. Forcing a `title` or blockquote `summary`
204
+ // is friction the prompt format was designed around.
205
+ const skipTitleSummary = doc.type === 'prompt';
206
+
207
+ if (!skipTitleSummary && !headingTitle && !asString(frontmatter.title)) {
208
+ doc.warnings.push({ path: doc.path, level: 'warning', message: 'Missing `title` and no H1 found for fallback.' });
209
+ }
210
+
211
+ if (!skipTitleSummary && !config.lifecycle.skipsWarnings(doc.status, doc.type) && !asString(frontmatter.summary) && !doc.summary) {
212
+ doc.warnings.push({ path: doc.path, level: 'warning', message: 'Missing `summary` and no blockquote fallback found.' });
213
+ }
214
+
215
+ // Determine which statuses should have current_state and next_step (plans only, not docs/research)
216
+ const isPlanWork = knownStatus && doc.status && (!doc.type || doc.type === 'plan')
217
+ && !config.lifecycle.terminalStatuses.has(doc.status) && !config.lifecycle.skipsWarnings(doc.status, doc.type);
218
+
219
+ if (isPlanWork && !asString(frontmatter.current_state)) {
220
+ doc.warnings.push({ path: doc.path, level: 'warning', message: 'Missing `current_state`; index output is using a fallback or placeholder.' });
221
+ }
222
+
223
+ if (isPlanWork && doc.status !== 'blocked' && !asString(frontmatter.next_step)) {
224
+ doc.warnings.push({ path: doc.path, level: 'warning', message: 'Missing `next_step`; command output will omit a clear immediate action.' });
225
+ }
226
+
227
+ // Archived plans must have a ## Closeout section
228
+ if (config.lifecycle.archiveStatuses.has(doc.status) && doc.type === 'plan' && !doc.hasCloseout) {
229
+ doc.warnings.push({ path: doc.path, level: 'warning', message: 'Archived plan missing `## Closeout` section.' });
230
+ }
231
+
232
+ // Archive drift: a doc with an archive-flagged status (`status: archived` by
233
+ // default) whose parent dir is a "live" type-conventional location is
234
+ // misplaced — `runlist archive` would have moved it under `<that>/archiveDir/`.
235
+ // Without this check, default `runlist plans` / `runlist prompts` views silently
236
+ // drop the file (because they exclude archived paths), and the user gets no
237
+ // signal it exists but is invisible. Nested intentional content (e.g.,
238
+ // `docs/plans/audit/<file>.md`) is in a non-conventional subdir and exempt.
239
+ if (config.lifecycle.archiveStatuses.has(doc.status)) {
240
+ const parentDir = path.dirname(doc.path);
241
+ const liveDirs = liveTypeDirsForRoots(config);
242
+ if (liveDirs.has(parentDir)) {
243
+ const expected = `${parentDir}/${config.archiveDir}/${path.basename(doc.path)}`;
244
+ doc.errors.push({
245
+ path: doc.path,
246
+ level: 'error',
247
+ message: `\`status: ${doc.status}\` but file is a direct child of \`${parentDir}/\`, not \`${parentDir}/${config.archiveDir}/\`. Run \`runlist archive ${doc.path}\` to relocate to \`${expected}\`, or change the status.`,
248
+ });
249
+ }
250
+ }
251
+
252
+ // Inverse archive drift (issue #13): a doc whose path is under
253
+ // `<dir>/<archiveDir>/` but whose status is NOT an archive status. Without
254
+ // this check, the file is invisible to default queries (the path is below
255
+ // an archive bucket so it's filtered out by `--exclude-archived`), yet its
256
+ // `status: pending` (or similar) still makes it surface in pending-prompt
257
+ // scans. The heal is to flip the frontmatter via `runlist <type-or-set>
258
+ // archive`, which now restores in place rather than failing.
259
+ if (doc.status && !config.lifecycle.archiveStatuses.has(doc.status)) {
260
+ const parentSegments = path.dirname(doc.path).split('/');
261
+ if (parentSegments.includes(config.archiveDir)) {
262
+ const heal = doc.type === 'prompt'
263
+ ? `runlist prompts archive ${doc.path}`
264
+ : `runlist set archived ${doc.path}`;
265
+ doc.errors.push({
266
+ path: doc.path,
267
+ level: 'error',
268
+ message: `File is under \`${config.archiveDir}/\` but \`status: ${doc.status}\` is not an archive status. Run \`${heal}\` to heal the frontmatter in place, or move the file out of the archive directory.`,
269
+ });
270
+ }
271
+ }
272
+
273
+ // Historical documents still need navigable references. Their prose is a
274
+ // dated record, but links to moved or deleted files cannot be followed.
275
+ const docDir = path.dirname(path.join(config.repoRoot, doc.path));
276
+ const allRefFields = [...(config.referenceFields.bidirectional || []), ...(config.referenceFields.unidirectional || [])];
277
+ for (const field of allRefFields) {
278
+ for (const relPath of (doc.refFields[field] || [])) {
279
+ if (!resolveRefPath(relPath, docDir, config.repoRoot)) {
280
+ doc.errors.push({
281
+ path: doc.path,
282
+ level: 'error',
283
+ message: `${field} entry \`${relPath}\` does not resolve to an existing file.`,
284
+ meta: { kind: 'ref-resolution', field, relPath },
285
+ });
286
+ }
287
+ }
288
+ }
289
+
290
+ for (const link of (doc.bodyLinks || [])) {
291
+ const resolution = resolveBodyLinkTarget(link.href, docDir, config.repoRoot, config.externalBodyLinkRoots);
292
+ if (!resolution.ok) {
293
+ const shownHref = link.rawHref ?? link.href;
294
+ doc.errors.push({
295
+ path: doc.path,
296
+ level: 'error',
297
+ message: resolution.reason === 'outside-repo'
298
+ ? `body link \`${shownHref}\` escapes the repository.`
299
+ : `body link \`${shownHref}\` does not resolve to an existing file or directory.`,
300
+ meta: {
301
+ kind: 'body-link-resolution',
302
+ field: 'body-link',
303
+ relPath: link.href,
304
+ rawHref: shownHref,
305
+ targetKind: link.targetKind ?? 'document',
306
+ angle: link.angle === true,
307
+ reason: resolution.reason,
308
+ },
309
+ });
310
+ }
311
+ }
312
+ }
313
+
314
+ // Guess which doc type a ref field expects so suggestions don't propose
315
+ // nonsense (a `related_plans` ref shouldn't suggest doc filenames). Heuristic:
316
+ // match common substrings in the field name. When ambiguous, return null so
317
+ // the caller suggests across all types.
318
+ function inferRefFieldType(field) {
319
+ if (!field) return null;
320
+ const f = field.toLowerCase();
321
+ if (f.includes('plan')) return 'plan';
322
+ if (f.includes('doc')) return 'doc';
323
+ if (f.includes('prompt')) return 'prompt';
324
+ if (f.includes('research')) return 'research';
325
+ return null;
326
+ }
327
+
328
+ function candidatePathsForType(docs, type) {
329
+ // When the field implies a type, exclude docs that explicitly declare a
330
+ // different type. Untyped docs (no frontmatter `type:`) are kept — we
331
+ // don't know they aren't the target, so suggesting them is still useful.
332
+ const matches = type ? docs.filter(d => !d.type || d.type === type) : docs;
333
+ // Suggest by basename (the friendly handle agents/humans actually type) and
334
+ // by repo-relative path (covers cases where multiple files share a basename
335
+ // and the disambiguator is the directory).
336
+ const out = new Set();
337
+ for (const d of matches) {
338
+ out.add(path.basename(d.path));
339
+ out.add(d.path);
340
+ }
341
+ return [...out];
342
+ }
343
+
344
+ // Post-pass enrichment: walk every doc's errors/warnings for unresolved-ref
345
+ // entries and append `Did you mean: a, b, c?` using the full index. Runs
346
+ // after parsing so it has the global doc list (validateDoc runs per-file and
347
+ // can't see siblings). Filters candidates by ref-field type when the field
348
+ // name implies one (e.g. `related_plans` → plans only).
349
+ export function enrichRefErrorSuggestions(docs, config) {
350
+ // One candidate list per inferred type, not per broken ref. The list is a
351
+ // filter plus a ~2-per-doc Set build over the whole index, and it does not
352
+ // depend on the entry — rebuilding it for each entry made this pass quadratic
353
+ // in (docs × broken refs), adding repeated work to every index build.
354
+ const candidateCache = new Map();
355
+ const candidatesFor = (type) => {
356
+ const key = type ?? '';
357
+ if (!candidateCache.has(key)) candidateCache.set(key, candidatePathsForType(docs, type));
358
+ return candidateCache.get(key);
359
+ };
360
+
361
+ const enrich = (entry) => {
362
+ if (!entry?.meta || !['ref-resolution', 'body-link-resolution'].includes(entry.meta.kind)) return;
363
+ if (entry.meta.kind === 'body-link-resolution'
364
+ && (entry.meta.targetKind !== 'document' || entry.meta.reason !== 'missing')) return;
365
+ if (entry._suggested) return;
366
+ const inferred = inferRefFieldType(entry.meta.field);
367
+ const suggestions = suggestCandidates(path.basename(entry.meta.relPath), candidatesFor(inferred));
368
+ entry._suggested = true;
369
+ if (suggestions.length === 0) return;
370
+ entry.message = `${entry.message} Did you mean: ${suggestions.join(', ')}?`;
371
+ };
372
+
373
+ for (const doc of docs) {
374
+ for (const e of (doc.errors || [])) enrich(e);
375
+ for (const w of (doc.warnings || [])) enrich(w);
376
+ }
377
+ }
378
+
379
+ export function checkBidirectionalReferences(docs, config) {
380
+ const warnings = [];
381
+ const biFields = config.referenceFields.bidirectional || [];
382
+ if (!biFields.length) return { warnings, errors: [] };
383
+
384
+ // refMap stores `Set<targetPath>` keyed by the source doc path — used to
385
+ // answer "does B reference A?" via .has(). oneWayMap stores the subset of A's
386
+ // outbound refs that opted out of expecting a back-ref (via the `>` prefix in
387
+ // frontmatter, parsed in src/index.mjs:parseDocFile). We split these so the
388
+ // membership check stays cheap (Set.has) while the per-ref directionality
389
+ // filter only consults oneWayMap when iterating outbound edges.
390
+ const refMap = new Map();
391
+ const oneWayMap = new Map();
392
+ for (const doc of docs) {
393
+ const docDir = path.dirname(path.join(config.repoRoot, doc.path));
394
+ const refs = new Set();
395
+ const oneWay = new Set();
396
+ for (const field of biFields) {
397
+ const entries = doc.refFields[field] || [];
398
+ const dirs = doc.refFieldDirections?.[field] || [];
399
+ for (let i = 0; i < entries.length; i++) {
400
+ const relPath = entries[i];
401
+ // Use the same doc-relative-then-repo-root fallback as validateDoc so
402
+ // both styles produce identical refMap keys; otherwise an entry like
403
+ // `docs/foo.md` (repo-root style) gets keyed as
404
+ // `<doc-parent>/docs/foo.md` and never matches the target's repo path.
405
+ const resolved = resolveRefPath(relPath, docDir, config.repoRoot)
406
+ ?? path.resolve(docDir, relPath);
407
+ const targetPath = toRepoPath(resolved, config.repoRoot);
408
+ refs.add(targetPath);
409
+ if (dirs[i] === 'one-way') oneWay.add(targetPath);
410
+ }
411
+ }
412
+ refMap.set(doc.path, refs);
413
+ oneWayMap.set(doc.path, oneWay);
414
+ }
415
+
416
+ for (const [docPath, refs] of refMap) {
417
+ const oneWay = oneWayMap.get(docPath);
418
+ for (const targetPath of refs) {
419
+ if (oneWay.has(targetPath)) continue;
420
+ const targetRefs = refMap.get(targetPath);
421
+ if (targetRefs && !targetRefs.has(docPath)) {
422
+ warnings.push({ path: docPath, level: 'warning',
423
+ message: `references \`${targetPath}\` in ${biFields.join('/')}, but that doc does not reference back.` });
424
+ }
425
+ }
426
+ }
427
+
428
+ return { warnings, errors: [] };
429
+ }
430
+
431
+ // Runlist back-pointer check: when a hub plan declares a `runlist:` of children,
432
+ // each child SHOULD have `parent_plan:` pointing back at the hub. The two
433
+ // fields encode complementary information (runlist = ordered execution intent;
434
+ // parent_plan = reverse-link the rest of runlist already uses for related-summary
435
+ // rendering), so divergence almost always means the agent forgot the back-link.
436
+ // Warning fires on the CHILD (that's the file that needs the edit). Skips
437
+ // terminal/archive statuses on either side — runlists referencing closed work
438
+ // are a normal history pattern. A `>`-prefixed (one-way) runlist entry opts
439
+ // that child out of the back-pointer requirement — the same per-ref escape
440
+ // hatch the bidirectional reciprocity check honors (A4) — so a hub can order a
441
+ // child it doesn't own without nagging the child to add `parent_plan:`.
442
+ export function checkRunlistBackPointers(docs, config) {
443
+ const warnings = [];
444
+ // Per-doc, not a precomputed name set: warning suppression is type-scoped, so
445
+ // the same status name can be quiet for one type and loud for another.
446
+ const skipDoc = (d) => config.lifecycle.terminalStatuses.has(d.status)
447
+ || config.lifecycle.skipsWarnings(d.status, d.type);
448
+ const byPath = new Map(docs.map(d => [d.path, d]));
449
+
450
+ for (const hub of docs) {
451
+ if (skipDoc(hub)) continue;
452
+ const runlistRefs = hub.refFields?.runlist ?? [];
453
+ const runlistDirs = hub.refFieldDirections?.runlist ?? [];
454
+ if (runlistRefs.length === 0) continue;
455
+ const hubDir = path.dirname(path.join(config.repoRoot, hub.path));
456
+
457
+ for (let i = 0; i < runlistRefs.length; i++) {
458
+ const ref = runlistRefs[i];
459
+ if (runlistDirs[i] === 'one-way') continue; // `> child.md` → ordered but no back-pointer expected
460
+ const resolved = resolveRefPath(ref, hubDir, config.repoRoot);
461
+ if (!resolved) continue; // unresolved refs already get their own existence error
462
+ const childPath = toRepoPath(resolved, config.repoRoot);
463
+ const child = byPath.get(childPath);
464
+ if (!child) continue;
465
+ if (skipDoc(child)) continue;
466
+ const childParents = (child.refFields?.parent_plan ?? []).map(p => {
467
+ const abs = resolveRefPath(p, path.dirname(path.join(config.repoRoot, child.path)), config.repoRoot);
468
+ return abs ? toRepoPath(abs, config.repoRoot) : p;
469
+ });
470
+ if (childParents.includes(hub.path)) continue;
471
+ warnings.push({
472
+ path: child.path,
473
+ level: 'warning',
474
+ message: `appears in runlist of \`${hub.path}\` but \`parent_plan:\` does not point back at it. Add \`parent_plan: ${hub.path}\` so reverse-link tooling (pickup-card Related:, graph) stays consistent.`,
475
+ meta: {
476
+ kind: childParents.length === 0 ? 'hub-membership-backref' : 'hub-membership-conflict',
477
+ hub: hub.path,
478
+ source: 'frontmatter-runlist',
479
+ },
480
+ });
481
+ }
482
+ }
483
+ return warnings;
484
+ }
485
+
486
+ // Coordination-hub hygiene: a plan whose slug is `*-runlist` / `runlist` reads
487
+ // as a coordination runlist, but `runlist plans` only *reliably* lifts it into the
488
+ // Runlists section (and out of the active count) when `execution_mode:
489
+ // coordination` is set. Slug detection is the fallback; the frontmatter field is
490
+ // the canonical signal. Nudge the few hubs that lean on the slug alone to make
491
+ // it explicit. Skips terminal/quiet statuses like every other warning-only check.
492
+ export function checkCoordinationHubExecutionMode(docs, config) {
493
+ const warnings = [];
494
+ // Per-doc, not a precomputed name set: warning suppression is type-scoped, so
495
+ // the same status name can be quiet for one type and loud for another.
496
+ const skipDoc = (d) => config.lifecycle.terminalStatuses.has(d.status)
497
+ || config.lifecycle.skipsWarnings(d.status, d.type);
498
+ for (const doc of docs) {
499
+ if (doc.type && doc.type !== 'plan') continue;
500
+ if (skipDoc(doc)) continue;
501
+ // ANY explicit `execution_mode` ends it. The field is the canonical signal
502
+ // and the slug is only a fallback for hubs that predate it, so a plan that
503
+ // already answered — `implementation`, `roadmap`, anything — has said what it
504
+ // is. Nudging past that misfires on exactly one population: plans ABOUT
505
+ // runlists, whose slug contains the word while the document is ordinary work.
506
+ // (A real one: a plan to rename the runlist concept, declared
507
+ // `execution_mode: implementation`, nagged to call itself a coordination hub.)
508
+ if (doc.executionMode) continue;
509
+ const base = (doc.path.split('/').pop() || '').replace(/\.md$/, '');
510
+ if (base !== 'runlist' && !base.endsWith('-runlist')) continue;
511
+ warnings.push({
512
+ path: doc.path,
513
+ level: 'warning',
514
+ message: `reads as a coordination runlist (slug \`${base}\`) but is missing \`execution_mode: coordination\`. Add it so \`runlist plans\` / \`runlist runlists\` reliably treat it as a runlist, not an active plan.`,
515
+ });
516
+ }
517
+ return warnings;
518
+ }
519
+
520
+ // Roadmap-hub nudge: a coordination hub whose `related_plans:` children are
521
+ // *themselves* hubs (runlists / coordination hubs) is structurally a tier-3
522
+ // roadmap — runlist renders it flat (a hub among its own children, no recursive
523
+ // rollup) until `execution_mode: roadmap` is set. Nudge, never auto-promote: the
524
+ // explicit field beats structural magic for a primitive (Open Q in the
525
+ // roadmap-layer plan). Fires only when ≥2 children AND a majority are hubs, so a
526
+ // coordination hub that merely references one sibling runlist isn't mislabelled.
527
+ export function checkRoadmapHubExecutionMode(docs, config) {
528
+ const warnings = [];
529
+ // Per-doc, not a precomputed name set: warning suppression is type-scoped, so
530
+ // the same status name can be quiet for one type and loud for another.
531
+ const skipDoc = (d) => config.lifecycle.terminalStatuses.has(d.status)
532
+ || config.lifecycle.skipsWarnings(d.status, d.type);
533
+ const byPath = new Map(docs.map(d => [d.path, d]));
534
+ const byBasename = new Map();
535
+ for (const d of docs) {
536
+ const base = d.path.split('/').pop();
537
+ if (!byBasename.has(base)) byBasename.set(base, d);
538
+ }
539
+ const looksLikeHub = (d) => {
540
+ if (!d) return false;
541
+ if (d.executionMode === 'coordination' || d.executionMode === 'roadmap') return true;
542
+ if (Array.isArray(d.refFields?.runlist) && d.refFields.runlist.length > 0) return true;
543
+ const b = (d.path.split('/').pop() || '').replace(/\.md$/, '');
544
+ return b === 'runlist' || b.endsWith('-runlist');
545
+ };
546
+ for (const doc of docs) {
547
+ if (doc.type && doc.type !== 'plan') continue;
548
+ if (skipDoc(doc)) continue;
549
+ if (doc.executionMode === 'roadmap') continue; // already a roadmap
550
+ if (doc.executionMode !== 'coordination') continue; // only nudge explicit coordination hubs
551
+ const refs = doc.refFields?.related_plans ?? [];
552
+ if (refs.length === 0) continue;
553
+ const dir = path.dirname(path.join(config.repoRoot, doc.path));
554
+ let resolved = 0, hubChildren = 0;
555
+ const seen = new Set();
556
+ for (const ref of refs) {
557
+ const abs = resolveRefPath(ref, dir, config.repoRoot);
558
+ let child = abs ? byPath.get(toRepoPath(abs, config.repoRoot)) ?? null : null;
559
+ if (!child) child = byBasename.get(ref.split('/').pop()) ?? null;
560
+ if (!child || child.path === doc.path || seen.has(child.path)) continue;
561
+ seen.add(child.path);
562
+ resolved++;
563
+ if (looksLikeHub(child)) hubChildren++;
564
+ }
565
+ if (hubChildren >= 2 && hubChildren > resolved / 2) {
566
+ warnings.push({
567
+ path: doc.path,
568
+ level: 'warning',
569
+ message: `is a coordination hub whose ${hubChildren} of ${resolved} \`related_plans:\` children are themselves runlists — that's structurally a tier-3 roadmap. Set \`execution_mode: roadmap\` so \`runlist roadmap\` rolls their progress up (recursive done/total) instead of rendering it flat among them.`,
570
+ });
571
+ }
572
+ }
573
+ return warnings;
574
+ }
575
+
576
+ export function checkGitStaleness(docs, config, options = {}) {
577
+ const warnings = [];
578
+ const eligibleDocs = docs.filter(doc => !config.lifecycle.skipStaleFor.has(doc.status) && doc.updated);
579
+ const pathspecs = (config.docsRoots || [config.docsRoot])
580
+ .map(root => toRepoPath(root, config.repoRoot) || '.');
581
+ const gitMetadata = getGitLastModifiedBatch(
582
+ config.repoRoot,
583
+ eligibleDocs.map(doc => doc.path),
584
+ { pathspecs, ...options },
585
+ );
586
+ const driftCandidates = eligibleDocs.filter(doc => {
587
+ const gitDate = gitMetadata.dates.get(doc.path) ?? null;
588
+ return Boolean(gitDate && gitDate.slice(0, 10) > doc.updated.slice(0, 10));
589
+ });
590
+ const candidatePaths = driftCandidates.map(doc => doc.path);
591
+ const candidateMetadata = {
592
+ dates: new Map(candidatePaths.filter(p => gitMetadata.dates.has(p)).map(p => [p, gitMetadata.dates.get(p)])),
593
+ commits: new Map(candidatePaths.filter(p => gitMetadata.commits?.has(p)).map(p => [p, gitMetadata.commits.get(p)])),
594
+ history: new Map(candidatePaths.filter(p => gitMetadata.history?.has(p)).map(p => [p, gitMetadata.history.get(p)])),
595
+ complete: gitMetadata.complete,
596
+ reason: gitMetadata.reason,
597
+ };
598
+ const substantiveMetadata = candidatePaths.length > 0
599
+ ? getGitLastSubstantiveModifiedBatch(config.repoRoot, candidatePaths, candidateMetadata, options)
600
+ : candidateMetadata;
601
+
602
+ for (const doc of driftCandidates) {
603
+ const gitDate = substantiveMetadata.dates.get(doc.path) ?? null;
604
+ if (!gitDate) continue;
605
+ const gitDay = gitDate.slice(0, 10);
606
+ const fmDay = doc.updated.slice(0, 10);
607
+
608
+ if (gitDay > fmDay) {
609
+ warnings.push({
610
+ path: doc.path,
611
+ level: 'warning',
612
+ message: `frontmatter \`updated: ${doc.updated}\` is behind git history (last committed ${gitDate.slice(0, 10)}).`,
613
+ });
614
+ }
615
+ }
616
+ if (!substantiveMetadata.complete) {
617
+ warnings.push({
618
+ path: toRepoPath(config.docsRoot, config.repoRoot) || '.',
619
+ level: 'warning',
620
+ message: `Git metadata is incomplete (${substantiveMetadata.reason}); staleness checks used known dates only.`,
621
+ });
622
+ }
623
+ return warnings;
624
+ }
625
+
626
+ // Plan-shape lint: soft warnings on convention drift. Plan-only.
627
+ // Body is the unparsed plan body (everything after the closing `---`).
628
+ export function validatePlanShape(doc, body, frontmatter, config) {
629
+ if (doc.type !== 'plan') return;
630
+ // Skip plans in terminal/archive statuses (closed work shouldn't generate noise)
631
+ if (config.lifecycle.terminalStatuses.has(doc.status) || config.lifecycle.archiveStatuses.has(doc.status)) return;
632
+ if (config.lifecycle.skipsWarnings(doc.status, doc.type)) return;
633
+
634
+ // 1. next_step length cap (800 chars). Was 300; raised in parallel with
635
+ // current_state for the same reason: agents need to encode "what to do next"
636
+ // with enough specificity (which file, which decision, which branch) that
637
+ // 300 chars often forced truncation into the body where the briefing
638
+ // doesn't read it.
639
+ const nextStep = typeof frontmatter.next_step === 'string' ? frontmatter.next_step : '';
640
+ if (nextStep.length > 800) {
641
+ doc.warnings.push({
642
+ path: doc.path,
643
+ level: 'warning',
644
+ message: `\`next_step\` is ${nextStep.length} chars (cap: 800). One mechanical fix: \`runlist doctor --frontmatter-fix\` (moves the overflow into the body) — do NOT hand-trim or re-run check in a loop. Going forward, write next_step as a 1-2 sentence pointer; detail goes in the body.`,
645
+ });
646
+ }
647
+
648
+ // 2. current_state length cap (1500 chars). Was 500; raised because agents
649
+ // legitimately need ~150-250 words of resume-context (prior incidents, what
650
+ // shipped, what's verified, where to look) and the prior cap forced a
651
+ // truncate-and-move-to-body retry loop on non-trivial plans.
652
+ const currentState = typeof frontmatter.current_state === 'string' ? frontmatter.current_state : '';
653
+ if (currentState.length > 1500) {
654
+ doc.warnings.push({
655
+ path: doc.path,
656
+ level: 'warning',
657
+ message: `\`current_state\` is ${currentState.length} chars (cap: 1500). One mechanical fix: \`runlist doctor --frontmatter-fix\` (moves the overflow into the body) — do NOT hand-trim or re-run check in a loop. Going forward, write current_state as a 2-4 sentence summary; detail goes in the body.`,
658
+ });
659
+ }
660
+
661
+ if (!body) return;
662
+
663
+ // 4. Heading drift: case + name variants
664
+ const headingDrift = [
665
+ { wrong: /^##\s+(Non-goals|Out of scope|Out of Scope|out of scope)\s*$/m, right: '## Non-Goals' },
666
+ ];
667
+ for (const { wrong, right } of headingDrift) {
668
+ const m = body.match(wrong);
669
+ if (m) {
670
+ doc.warnings.push({
671
+ path: doc.path,
672
+ level: 'warning',
673
+ message: `Heading drift: \`${m[0].trim()}\` → suggest \`${right}\`.`,
674
+ });
675
+ }
676
+ }
677
+
678
+ // 5 & 6 read phases through section.mjs rather than re-parsing them here.
679
+ // This check used to carry its own heading scan and its own copy of the
680
+ // marker vocabulary, which drifted from the real reader in both directions:
681
+ // it counted `### Phase 3 outcome` as a phase needing a marker, and it never
682
+ // learned the fixes section.mjs got. Two parsers for one concept disagreeing
683
+ // is the same defect class as `reference-planner` vs `resolveRefPath`.
684
+ const phases = walkSections(body).filter(isPhaseHeading);
685
+
686
+ // 5. Phase headings that carry no status marker at all.
687
+ if (phases.length > 0) {
688
+ const unmarked = phases.filter(p => detectMarker(p.heading) === null);
689
+ if (unmarked.length > 0) {
690
+ doc.warnings.push({
691
+ path: doc.path,
692
+ level: 'warning',
693
+ message: `${unmarked.length} of ${phases.length} phase heading(s) lack a status marker. Use one of ✅ shipped, ⏭ skipped, 🟡 in-progress, ⬜ todo, 🚧 blocked.`,
694
+ });
695
+ }
696
+ }
697
+
698
+ // 6. A phase whose own checklist contradicts its marker. Reported, never
699
+ // fixed: runlist cannot know whether the marker or the boxes are the stale
700
+ // half, and guessing would either mark work done that is not, or reopen work
701
+ // that is. Both halves are the author's own writing — this only says they
702
+ // disagree.
703
+ for (const phase of phases) {
704
+ const conflict = phaseMarkerConflict(phase);
705
+ if (!conflict) continue;
706
+ doc.warnings.push({
707
+ path: doc.path,
708
+ level: 'warning',
709
+ message: `Phase marker contradicts its own checklist (line ${phase.lineStart}): \`${phase.heading.trim()}\` is marked ${conflict.declared}, but ${conflict.checked}/${conflict.total} boxes are checked — that reads as ${conflict.implied}. Update whichever half is stale.`,
710
+ });
711
+ }
712
+ }
713
+
714
+ // Doc-shape lint: soft warnings on convention drift. Doc-only.
715
+ // Mirrors validatePlanShape's structure.
716
+ export function validateDocShape(doc, body, frontmatter, config) {
717
+ if (doc.type !== 'doc') return;
718
+ if (config.lifecycle.terminalStatuses.has(doc.status) || config.lifecycle.archiveStatuses.has(doc.status)) return;
719
+ if (config.lifecycle.skipsWarnings(doc.status, doc.type)) return;
720
+
721
+ if (!body) return;
722
+
723
+ // Heading drift for docs.
724
+ const headingDrift = [
725
+ { wrong: /^##\s+Related Documents\s*$/m, right: '## Related Documentation' },
726
+ ];
727
+ for (const { wrong, right } of headingDrift) {
728
+ const m = body.match(wrong);
729
+ if (m) {
730
+ doc.warnings.push({
731
+ path: doc.path,
732
+ level: 'warning',
733
+ message: `Heading drift: \`${m[0].trim()}\` → suggest \`${right}\`.`,
734
+ });
735
+ }
736
+ }
737
+ }
738
+
739
+ export function computeDaysSinceUpdate(updated) {
740
+ if (!updated) return null;
741
+ const parsed = new Date(updated);
742
+ if (Number.isNaN(parsed.getTime())) return null;
743
+
744
+ const diffMs = NOW.getTime() - parsed.getTime();
745
+ if (diffMs < 0) return 0;
746
+ return Math.floor(diffMs / (1000 * 60 * 60 * 24));
747
+ }
748
+
749
+ export function computeIsStale(status, updated, config, type = null) {
750
+ const typeStaleDays = type ? config.raw?.types?.[type]?.staleDays : null;
751
+ const hasTypeDays = Object.prototype.hasOwnProperty.call(typeStaleDays ?? {}, status);
752
+ const staleAfterDays = hasTypeDays ? typeStaleDays[status] : (config.staleDaysByStatus[status] ?? null);
753
+ if (staleAfterDays == null) return false;
754
+
755
+ const daysSinceUpdate = computeDaysSinceUpdate(updated);
756
+ if (daysSinceUpdate == null) return false;
757
+ return daysSinceUpdate > staleAfterDays;
758
+ }
759
+
760
+ export function computeChecklistCompletionRate(checklist) {
761
+ if (!checklist.total) return null;
762
+ return Number((checklist.completed / checklist.total).toFixed(4));
763
+ }
764
+
765
+ // A floor under the scan surface.
766
+ //
767
+ // Every other check in this file asks "is this doc wrong?" — none of them can ask
768
+ // "did we look at anything?". If the scan surface breaks (a root that stopped
769
+ // resolving, a config edit that narrowed the tree, a rename that moved docs/ out
770
+ // from under us), `runlist check` reports zero errors, and that output is
771
+ // byte-identical to a clean estate. A guard's whole failure mode is going quiet,
772
+ // and this is the one that would go quiet silently.
773
+ //
774
+ // Off unless `minDocs` is configured — a floor is a claim about YOUR corpus size
775
+ // and runlist cannot guess it. Deliberately an error, not a warning: a warning exits
776
+ // 0, which is the exact outcome this exists to prevent.
777
+ export function applyScanFloor(index, config, { scoped = false } = {}) {
778
+ const floor = config?.minDocs;
779
+ if (!floor) return index;
780
+ // A path-scoped check (`runlist check docs/plans/x.md`) is a deliberate subset, so
781
+ // the floor would fire on every single-file check. Not a corpus claim at all.
782
+ if (scoped) return index;
783
+ if (index.docs.length >= floor) return index;
784
+
785
+ index.errors.push({
786
+ path: null,
787
+ level: 'error',
788
+ message: `only ${index.docs.length} docs in the scan surface (expected at least ${floor}) — `
789
+ + 'the file list broke, so a pass here would mean nothing. Check `root`/`excludeDirs` in '
790
+ + 'your config, or lower `minDocs` if the corpus genuinely shrank.',
791
+ meta: { kind: 'scan-floor', found: index.docs.length, floor },
792
+ });
793
+ return index;
794
+ }