runlist 0.0.0 → 0.94.2

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 (167) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1112 -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 +296 -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/public-inspection.mjs +19 -0
  136. package/src/query.mjs +977 -0
  137. package/src/record-action.mjs +11 -0
  138. package/src/record-create.mjs +82 -0
  139. package/src/reference-planner.mjs +388 -0
  140. package/src/reference-values.mjs +11 -0
  141. package/src/rename.mjs +107 -0
  142. package/src/render.mjs +678 -0
  143. package/src/roadmap.mjs +184 -0
  144. package/src/runlist.mjs +944 -0
  145. package/src/section.mjs +225 -0
  146. package/src/semantic-search-client.mjs +58 -0
  147. package/src/ship.mjs +166 -0
  148. package/src/show.mjs +101 -0
  149. package/src/skill-drift.mjs +98 -0
  150. package/src/source-editor.mjs +491 -0
  151. package/src/stages.mjs +73 -0
  152. package/src/state-migration.mjs +123 -0
  153. package/src/stats.mjs +200 -0
  154. package/src/status-metadata.mjs +87 -0
  155. package/src/statuses.mjs +721 -0
  156. package/src/summary.mjs +74 -0
  157. package/src/surfaces.mjs +28 -0
  158. package/src/sync-status.mjs +316 -0
  159. package/src/template-store.mjs +62 -0
  160. package/src/update.mjs +338 -0
  161. package/src/use.mjs +58 -0
  162. package/src/util.mjs +337 -0
  163. package/src/validate.mjs +795 -0
  164. package/src/watch.mjs +56 -0
  165. package/src/xref.mjs +406 -0
  166. package/src/yardstick-command.mjs +178 -0
  167. package/src/yardstick.mjs +112 -0
package/src/index.mjs ADDED
@@ -0,0 +1,478 @@
1
+ import { referenceValues } from './reference-values.mjs';
2
+ import { readdirSync, readFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
5
+ import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts, extractBodyLinks } from './extractors.mjs';
6
+ import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath, warn, die, resolveDocPath, suggestCandidates } from './util.mjs';
7
+ import { findLexicalDocsRoot } from './managed-path.mjs';
8
+ import { openParseCache, fileStamp, stampSize } from './parse-cache.mjs';
9
+ import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalReferences, checkGitStaleness, checkRunlistBackPointers, checkCoordinationHubExecutionMode, checkRoadmapHubExecutionMode, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate, enrichRefErrorSuggestions } from './validate.mjs';
10
+ import { checkIndex } from './index-file.mjs';
11
+ import { checkClaudeCommands } from './claude-commands.mjs';
12
+ import { checkGlossaryConfig } from './glossary-check.mjs';
13
+ import { checkSkillDrift } from './skill-drift.mjs';
14
+ import { checkHubStatusDrift } from './sync-status.mjs';
15
+ import { checkHubMembershipDrift } from './hub-membership.mjs';
16
+ import {parseNativeRecord} from './native-record.mjs';
17
+ import { readShipsFrontmatter } from './stages.mjs';
18
+ import { buildFilingCoverage, filingFindings } from './filing.mjs';
19
+
20
+ // `fast: true` skips every pass that produces warnings/errors — the rendered
21
+ // index file consumes only status/title/snapshot/etc., not the validation
22
+ // output. Use it from `regenIndex` (post-mutation index refresh) where
23
+ // validation has already run elsewhere (or will, next time the user runs
24
+ // `runlist check`). Saves the full-repo `git log` scan in `checkGitStaleness`
25
+ // plus the bidirectional ref walk + claude-commands check.
26
+ //
27
+ // `errorsOnly: true` runs every built-in error-producing pass (per-file
28
+ // `validateDoc`, `checkIndex`) but skips the warning-only cross-doc
29
+ // passes (bidirectional refs, runlist back-pointers, git staleness, claude
30
+ // commands). Use it from `runlist hud` — the SessionStart hook only renders the
31
+ // error COUNT, so the warning-only passes are pure overhead there. Preserves
32
+ // the built-in invariant that HUD's error count matches `runlist check`.
33
+ export function buildIndex(config, opts = {}) {
34
+ const { fast = false, errorsOnly = false, autoHealIndex = false } = opts;
35
+ const invokeHooks = opts.invokeHooks ?? !config._execution?.suppressSideEffects;
36
+ const gitStaleness = opts.gitStaleness ?? config._execution?.gitStaleness ?? true;
37
+ const skipWarningOnlyChecks = fast || errorsOnly;
38
+ const cache = openParseCache(config);
39
+ const scanFailures = [];
40
+ const docs = collectDocFiles(config, { onError: (directory, err) => {
41
+ scanFailures.push({ path: null, level: 'error', message: `Could not read scan directory ${toRepoPath(directory, config.repoRoot)}: ${err.message}`, meta: { kind: 'scan-failure' } });
42
+ } }).flatMap(f => {
43
+ try { return [parseDocFile(f, config, { fast, cache })]; }
44
+ catch (err) {
45
+ if (err.code !== 'ENOENT') throw err;
46
+ scanFailures.push({ path: toRepoPath(f, config.repoRoot), level: 'warning', message: 'Document disappeared after discovery; it was not checked.', meta: { kind: 'scan-disappeared' } });
47
+ return [];
48
+ }
49
+ });
50
+ if (cache && !config._execution?.suppressSideEffects) cache.save();
51
+ if (!fast) {
52
+ // Per-file validation (validateDoc) ran during parse without sibling
53
+ // visibility. Now that the full index is materialized, enrich
54
+ // unresolved-ref entries with "Did you mean..." candidates drawn from the
55
+ // index — mutates doc.errors/doc.warnings in place so the aggregations
56
+ // below pick up the enriched messages.
57
+ enrichRefErrorSuggestions(docs, config);
58
+ }
59
+ const warnings = scanFailures.filter(f => f.level === 'warning');
60
+ const errors = scanFailures.filter(f => f.level === 'error');
61
+
62
+ for (const doc of docs) {
63
+ warnings.push(...doc.warnings);
64
+ errors.push(...doc.errors);
65
+ }
66
+
67
+ if (!fast && invokeHooks && config.hooks.validate) {
68
+ const ctx = { config, allDocs: docs, repoRoot: config.repoRoot };
69
+ for (const doc of docs) {
70
+ try {
71
+ const result = config.hooks.validate(doc, ctx);
72
+ if (result?.errors) {
73
+ doc.errors.push(...result.errors);
74
+ errors.push(...result.errors);
75
+ }
76
+ if (result?.warnings) {
77
+ doc.warnings.push(...result.warnings);
78
+ warnings.push(...result.warnings);
79
+ }
80
+ } catch (err) {
81
+ const hookError = { path: doc.path, level: 'error', message: `Hook 'validate' threw: ${err.message}`, meta: { kind: 'hook-failure' } };
82
+ scanFailures.push(hookError);
83
+ doc.errors.push(hookError);
84
+ errors.push(hookError);
85
+ }
86
+ }
87
+ }
88
+
89
+ const transformedDocs = invokeHooks && config.hooks.transformDoc
90
+ ? docs.map(d => {
91
+ try { return config.hooks.transformDoc(d) ?? d; }
92
+ catch (err) {
93
+ const failure = { path: d.path, level: 'warning', message: `Hook 'transformDoc' threw: ${err.message}`, meta: { kind: 'hook-failure' } };
94
+ warnings.push(failure);
95
+ scanFailures.push(failure);
96
+ return d;
97
+ }
98
+ })
99
+ : docs;
100
+
101
+ const countsByStatus = Object.fromEntries(config.statusOrder.map(status => [
102
+ status,
103
+ transformedDocs.filter(doc => doc.status === status).length,
104
+ ]));
105
+ const knownStatuses = new Set(config.statusOrder);
106
+ for (const doc of transformedDocs) {
107
+ if (doc.status && !knownStatuses.has(doc.status)) {
108
+ countsByStatus[doc.status] = (countsByStatus[doc.status] ?? 0) + 1;
109
+ }
110
+ }
111
+
112
+ // Per-type counts (F6): same input docs, keyed by `type` first so callers
113
+ // can distinguish `plan/partial` (work shipped + tail deferred) from
114
+ // `doc/partial` (incomplete reference material). Untyped docs (pre-0.30
115
+ // corpora) land under `unknown` rather than getting dropped silently.
116
+ const countsByType = {};
117
+ for (const doc of transformedDocs) {
118
+ if (!doc.status) continue;
119
+ const type = doc.type || 'unknown';
120
+ if (!countsByType[type]) countsByType[type] = {};
121
+ countsByType[type][doc.status] = (countsByType[type][doc.status] ?? 0) + 1;
122
+ }
123
+
124
+ if (!fast && config.indexPath) {
125
+ // `autoHealIndex` is opt-in from the caller (currently `runlist check`).
126
+ // When true, drift triggers an in-place rewrite and a
127
+ // warning instead of the old "Run `runlist index`" error — closing the
128
+ // class of nags produced by mutation paths that skip `regenIndex`
129
+ // (`lint --fix`, direct file edits, etc). `transformedDocs` here is
130
+ // always the canonical full set; CLI-level `--root`/`--type` filtering
131
+ // runs after `buildIndex` returns, so a rewrite is safe. Off by default
132
+ // so dry-run / print modes never mutate disk as a side effect.
133
+ const indexCheck = checkIndex(transformedDocs, config, {
134
+ autoHeal: autoHealIndex && scanFailures.length === 0,
135
+ rebuildDocs: autoHealIndex ? () => buildIndex(config, { fast: true }).docs : null,
136
+ testHooks: opts.testHooks,
137
+ });
138
+ warnings.push(...indexCheck.warnings);
139
+ errors.push(...indexCheck.errors);
140
+ }
141
+
142
+ // Hub status drift produces ERRORS (a drifted marked span), so it runs in
143
+ // errorsOnly mode too — that's what keeps `runlist hud`'s error count equal to
144
+ // `runlist check`'s. Its warnings still obey the warning-only gate.
145
+ if (!fast) {
146
+ const hubStatus = checkHubStatusDrift(transformedDocs, config);
147
+ errors.push(...hubStatus.errors);
148
+ if (!skipWarningOnlyChecks) warnings.push(...hubStatus.warnings);
149
+ for (const entry of [...hubStatus.errors, ...(skipWarningOnlyChecks ? [] : hubStatus.warnings)]) {
150
+ const hub = transformedDocs.find(d => d.path === entry.path);
151
+ if (!hub) continue;
152
+ (entry.level === 'error' ? hub.errors : hub.warnings).push(entry);
153
+ }
154
+ }
155
+
156
+ if (!skipWarningOnlyChecks) {
157
+ const refCheck = checkBidirectionalReferences(transformedDocs, config);
158
+ warnings.push(...refCheck.warnings);
159
+
160
+ const runlistWarnings = checkRunlistBackPointers(transformedDocs, config);
161
+ warnings.push(...runlistWarnings);
162
+ for (const w of runlistWarnings) {
163
+ const child = transformedDocs.find(d => d.path === w.path);
164
+ if (child) child.warnings.push(w);
165
+ }
166
+
167
+ const membershipWarnings = checkHubMembershipDrift(transformedDocs, config);
168
+ warnings.push(...membershipWarnings);
169
+ for (const w of membershipWarnings) {
170
+ const owner = transformedDocs.find(d => d.path === w.path);
171
+ if (owner) owner.warnings.push(w);
172
+ }
173
+
174
+ const coordHubWarnings = checkCoordinationHubExecutionMode(transformedDocs, config);
175
+ warnings.push(...coordHubWarnings);
176
+ for (const w of coordHubWarnings) {
177
+ const hub = transformedDocs.find(d => d.path === w.path);
178
+ if (hub) hub.warnings.push(w);
179
+ }
180
+
181
+ const roadmapHubWarnings = checkRoadmapHubExecutionMode(transformedDocs, config);
182
+ warnings.push(...roadmapHubWarnings);
183
+ for (const w of roadmapHubWarnings) {
184
+ const hub = transformedDocs.find(d => d.path === w.path);
185
+ if (hub) hub.warnings.push(w);
186
+ }
187
+
188
+ if (gitStaleness) {
189
+ const gitWarnings = checkGitStaleness(transformedDocs, config, opts.gitMetadataOptions);
190
+ warnings.push(...gitWarnings);
191
+ }
192
+
193
+ const claudeWarnings = checkClaudeCommands(config.repoRoot);
194
+ warnings.push(...claudeWarnings);
195
+
196
+ const glossaryWarnings = checkGlossaryConfig(config);
197
+ warnings.push(...glossaryWarnings);
198
+
199
+ const skillDriftWarnings = checkSkillDrift(config);
200
+ warnings.push(...skillDriftWarnings);
201
+ }
202
+
203
+ const filingCoverage = !fast && !errorsOnly ? buildFilingCoverage(transformedDocs, config) : null;
204
+ if (filingCoverage) {
205
+ for (const failure of filingCoverage.failures) scanFailures.push({ path: failure.path, level: 'warning',
206
+ message: `Could not read hub for filing coverage: ${failure.message}`, meta: { kind: 'filing-read-failure' } });
207
+ if (scanFailures.length) filingCoverage.complete = false;
208
+ const byPath = new Map(transformedDocs.map(doc => [doc.path, doc]));
209
+ for (const finding of filingFindings(filingCoverage)) {
210
+ warnings.push(finding);
211
+ byPath.get(finding.path)?.warnings.push(finding);
212
+ }
213
+ }
214
+
215
+ return {
216
+ generatedAt: new Date().toISOString(),
217
+ filingCoverage,
218
+ docs: transformedDocs,
219
+ countsByStatus,
220
+ countsByType,
221
+ warnings,
222
+ errors,
223
+ scanCoverage: { complete: scanFailures.length === 0, failures: scanFailures },
224
+ };
225
+ }
226
+
227
+ export function collectDocFiles(config, { onError = null } = {}) {
228
+ const files = [];
229
+ const skipPaths = new Set();
230
+ if (config.indexPath) skipPaths.add(config.indexPath);
231
+ const roots = config.docsRoots || [config.docsRoot];
232
+ const seen = new Set();
233
+ for (const root of roots) {
234
+ walkMarkdownFiles(root, files, config.excludeDirs, skipPaths, seen, onError);
235
+ }
236
+ return files.sort((a, b) => a.localeCompare(b));
237
+ }
238
+
239
+ // Shared resolver for CLI file arguments — the single path every file-taking
240
+ // verb (`use`, `set`, `archive`, `touch`, `rename`, …) funnels through so
241
+ // bare slugs behave identically everywhere. Tries, in order: exact path
242
+ // (the resolveDocPath fast path), `<input>.md`, then a unique basename match
243
+ // across all doc roots. An ambiguous basename dies listing the candidates
244
+ // rather than guessing — a wrong auto-resolved mutation is worse than a
245
+ // retry. A full miss dies with did-you-mean suggestions drawn from the doc
246
+ // corpus; pass { dieOnMiss: false } to get null instead and keep a custom
247
+ // fallback at the call site.
248
+ export function resolveDocArg(input, config, { dieOnMiss = true } = {}) {
249
+ if (!input) return null;
250
+ const direct = resolveDocPath(input, config);
251
+ if (direct) return direct;
252
+ if (!input.endsWith('.md')) {
253
+ const withExt = resolveDocPath(input + '.md', config);
254
+ if (withExt) return withExt;
255
+ }
256
+
257
+ const slug = input.replace(/\.md$/, '');
258
+ const files = collectDocFiles(config);
259
+ const byBasename = files.filter(f => path.basename(f, '.md') === slug);
260
+ if (byBasename.length === 1) return byBasename[0];
261
+ if (byBasename.length > 1) {
262
+ die(`Multiple docs match "${input}" by basename:\n${byBasename.map(f => ' ' + toRepoPath(f, config.repoRoot)).join('\n')}`);
263
+ }
264
+
265
+ if (!dieOnMiss) return null;
266
+ die(docArgMissMessage(input, config, files));
267
+ }
268
+
269
+ // `File not found` + the searched roots + up-to-3 did-you-mean candidates
270
+ // matched on basename and printed as repo-relative paths. Exported so verbs
271
+ // with their own resolution (e.g. interactive pickers) can reuse the message.
272
+ export function docArgMissMessage(input, config, files = collectDocFiles(config)) {
273
+ const roots = config.docsRoots || [config.docsRoot];
274
+ const searched = [toRepoPath(config.repoRoot, config.repoRoot) || '.', ...roots.map(r => toRepoPath(r, config.repoRoot))].join(', ');
275
+ const slug = String(input).split('/').pop().replace(/\.md$/, '');
276
+ const pathsByBase = new Map();
277
+ for (const f of files) {
278
+ const base = path.basename(f, '.md');
279
+ if (!pathsByBase.has(base)) pathsByBase.set(base, toRepoPath(f, config.repoRoot));
280
+ }
281
+ const hits = suggestCandidates(slug, [...pathsByBase.keys()]);
282
+ let msg = `File not found: ${input}\nSearched: ${searched}`;
283
+ if (hits.length) msg += `\nDid you mean: ${hits.map(b => pathsByBase.get(b)).join(', ')}?`;
284
+ return msg;
285
+ }
286
+
287
+ function walkMarkdownFiles(directory, files, excludedDirs, skipPaths, seen = new Set(), onError = null) {
288
+ let entries;
289
+ try {
290
+ entries = readdirSync(directory, { withFileTypes: true });
291
+ } catch (err) {
292
+ onError?.(directory, err);
293
+ warn(`Could not read directory ${directory}: ${err.message}`);
294
+ return;
295
+ }
296
+ for (const entry of entries) {
297
+ if (entry.isDirectory()) {
298
+ if (excludedDirs && excludedDirs.has(entry.name)) continue;
299
+ walkMarkdownFiles(path.join(directory, entry.name), files, excludedDirs, skipPaths, seen, onError);
300
+ continue;
301
+ }
302
+ const fullPath = path.join(directory, entry.name);
303
+ if (!entry.isFile() || !entry.name.endsWith('.md') || skipPaths.has(fullPath) || seen.has(fullPath)) continue;
304
+ seen.add(fullPath);
305
+ files.push(fullPath);
306
+ }
307
+ }
308
+
309
+ // Everything parseDocFile takes from the file's text alone, with no config and
310
+ // no clock, which is what the parse cache may keep.
311
+ function extractDocText(frontmatter, body) {
312
+ const fmWarnings = [];
313
+ const parsedFrontmatter = parseSimpleFrontmatter(frontmatter, fmWarnings);
314
+ if (Object.hasOwn(parsedFrontmatter, 'ships')) parsedFrontmatter.ships = readShipsFrontmatter(frontmatter);
315
+ return {
316
+ shipsMetadataVersion: 1,
317
+ parsedFrontmatter,
318
+ fmWarnings: fmWarnings.map(w => ({ message: w.message })),
319
+ headingTitle: extractFirstHeading(body),
320
+ bodySummary: extractSummary(body),
321
+ bodyStatusSnapshot: extractStatusSnapshot(body),
322
+ bodyNextStep: extractNextStep(body),
323
+ checklist: extractChecklistCounts(body),
324
+ bodyLinks: extractBodyLinks(body),
325
+ hasCloseout: /^##\s+Closeout/m.test(body),
326
+ };
327
+ }
328
+
329
+ export function parseDocFile(filePath, config, opts = {}) {
330
+ const { fast = false, cache = null } = opts;
331
+ const relativePath = toRepoPath(filePath, config.repoRoot);
332
+ const stamp = cache && opts.source === undefined ? fileStamp(filePath) : null;
333
+ let text = stamp ? cache.get(relativePath, stamp) : null;
334
+ // Older cache entries cannot distinguish blank ships from an empty YAML list.
335
+ if (text && text.shipsMetadataVersion !== 1) text = null;
336
+ // Validation reads the body itself, so only a fast build can skip the read.
337
+ let body = null;
338
+ let rawSource = null;
339
+ if (!text || !fast) {
340
+ const raw = opts.source ?? readFileSync(filePath, 'utf8');
341
+ rawSource = raw;
342
+ const extracted = extractFrontmatter(raw);
343
+ body = extracted.body;
344
+ // A file rewritten between the stat and the read no longer matches its stamp.
345
+ if (text && Buffer.byteLength(raw) !== stampSize(stamp)) text = null;
346
+ if (!text) {
347
+ text = extractDocText(extracted.frontmatter, body);
348
+ if (stamp) cache.set(relativePath, stamp, text);
349
+ }
350
+ }
351
+ const { parsedFrontmatter, fmWarnings, headingTitle, checklist, bodyLinks, hasCloseout } = text;
352
+ const nativeTitle = parsedFrontmatter.record_schema==='runlist.record/v1' ? asString(parsedFrontmatter.finding)??asString(parsedFrontmatter.question) : null;
353
+ const title = nativeTitle ?? asString(parsedFrontmatter.title) ?? headingTitle ?? path.basename(filePath, '.md');
354
+ const summary = asString(parsedFrontmatter.summary) ?? text.bodySummary ?? null;
355
+ // For terminal-status docs (archived / reference / deprecated by default),
356
+ // skip the body-scrape and the "No current_state set" fallback when the user
357
+ // didn't set `current_state:` in frontmatter explicitly. Body text on a
358
+ // settled doc often contains stale "in progress" / "FIXED (uncommitted)"
359
+ // snapshots from when the doc was live; surfacing those in the index lies
360
+ // about current state. Frontmatter still wins if explicit — the audit's
361
+ // criterion: "should defer to frontmatter when status is terminal."
362
+ const fmCurrentState = asString(parsedFrontmatter.current_state);
363
+ const docStatus = asString(parsedFrontmatter.status);
364
+ const isTerminalDoc = docStatus && (config.lifecycle?.isTerminal?.(docStatus, asString(parsedFrontmatter.type) ?? null)
365
+ ?? config.lifecycle?.terminalStatuses?.has?.(docStatus));
366
+ // Track where currentState came from so renderers can prefix `(auto)` on
367
+ // body-scraped values. Frontmatter wins silently; body-scraped values flag
368
+ // their origin so the user knows the string was inferred (and that adding
369
+ // `current_state:` to frontmatter would override). The placeholder
370
+ // `'No current_state set'` is neither — origin stays null.
371
+ let currentState;
372
+ let currentStateOrigin = null;
373
+ if (fmCurrentState) {
374
+ currentState = fmCurrentState;
375
+ currentStateOrigin = 'frontmatter';
376
+ } else if (isTerminalDoc) {
377
+ currentState = null;
378
+ } else {
379
+ const scraped = text.bodyStatusSnapshot;
380
+ if (scraped) {
381
+ currentState = scraped;
382
+ currentStateOrigin = 'body';
383
+ } else {
384
+ currentState = 'No current_state set';
385
+ }
386
+ }
387
+ const nextStep = asString(parsedFrontmatter.next_step) ?? text.bodyNextStep ?? null;
388
+ // `blocked_by` is accepted as an alias for `blockers` since 0.39.3 — agents
389
+ // filing tickets naturally reach for the JIRA/Linear name. If both are set,
390
+ // they're merged (de-duped via normalizeBlockers → mergeUniqueStrings).
391
+ const blockers = mergeUniqueStrings(
392
+ normalizeBlockers(parsedFrontmatter.blockers),
393
+ normalizeBlockers(parsedFrontmatter.blocked_by),
394
+ );
395
+ const surface = asString(parsedFrontmatter.surface) ?? null;
396
+ const surfaces = normalizeStringList(parsedFrontmatter.surfaces);
397
+ const moduleName = asString(parsedFrontmatter.module) ?? null;
398
+ const modules = normalizeStringList(parsedFrontmatter.modules);
399
+ const domain = asString(parsedFrontmatter.domain) ?? null;
400
+ const audience = asString(parsedFrontmatter.audience) ?? null;
401
+ const executionMode = asString(parsedFrontmatter.execution_mode) ?? null;
402
+
403
+ // Dynamic reference field extraction. A leading `>` on a value (e.g.
404
+ // `"> docs/audit-example-project.md"`) marks that single ref as one-way —
405
+ // the prefix is stripped so path resolution still works, and the direction
406
+ // is recorded on a parallel `refFieldDirections[field]` array indexed the
407
+ // same as `refFields[field]`. Bidirectional reciprocity checks consume the
408
+ // directions to skip outbound entries that opted out of expecting a back-ref.
409
+ const refFields = {};
410
+ const refFieldDirections = {};
411
+ for (const field of [...(config.referenceFields.bidirectional || []), ...(config.referenceFields.unidirectional || [])]) {
412
+ const { paths, directions } = referenceValues(parsedFrontmatter[field]);
413
+ refFields[field] = paths;
414
+ refFieldDirections[field] = directions;
415
+ }
416
+
417
+ // Tag doc with its root
418
+ const docRoot = findLexicalDocsRoot(filePath, config) ?? config.docsRoot;
419
+ const rootLabel = path.relative(config.repoRoot, docRoot).split(path.sep).join('/');
420
+
421
+ const docType = asString(parsedFrontmatter.type) ?? null;
422
+
423
+ const doc = {
424
+ path: relativePath,
425
+ root: rootLabel,
426
+ type: docType,
427
+ status: asString(parsedFrontmatter.status) ?? null,
428
+ // Preserve ships without coercing lists/objects into apparently valid stages.
429
+ ships: parsedFrontmatter.ships ?? null,
430
+ owner: asString(parsedFrontmatter.owner) ?? null,
431
+ surface,
432
+ surfaces: mergeUniqueStrings(surface ? [surface] : [], surfaces),
433
+ module: moduleName,
434
+ modules: mergeUniqueStrings(moduleName ? [moduleName] : [], modules),
435
+ domain,
436
+ audience,
437
+ executionMode,
438
+ title,
439
+ summary,
440
+ currentState,
441
+ currentStateOrigin,
442
+ nextStep,
443
+ blockers,
444
+ updated: asString(parsedFrontmatter.updated) ?? null,
445
+ created: asString(parsedFrontmatter.created) ?? null,
446
+ audited: asString(parsedFrontmatter.audited) ?? null,
447
+ auditLevel: asString(parsedFrontmatter.audit_level) ?? null,
448
+ sourceOfTruth: asString(parsedFrontmatter.source_of_truth) ?? null,
449
+ checklist,
450
+ bodyLinks,
451
+ refFields,
452
+ refFieldDirections,
453
+ checklistCompletionRate: computeChecklistCompletionRate(checklist),
454
+ hasCloseout,
455
+ hasNextStep: Boolean(nextStep),
456
+ hasBlockers: blockers.length > 0,
457
+ daysSinceUpdate: computeDaysSinceUpdate(asString(parsedFrontmatter.updated) ?? null),
458
+ isStale: computeIsStale(asString(parsedFrontmatter.status), asString(parsedFrontmatter.updated) ?? null, config, docType),
459
+ warnings: [],
460
+ errors: [],
461
+ };
462
+
463
+ for (const w of fmWarnings) {
464
+ doc.warnings.push({ path: relativePath, level: 'warning', message: w.message });
465
+ }
466
+
467
+ if (!fast) {
468
+ if(parsedFrontmatter.record_schema!==undefined&&['flag','decision'].includes(doc.type)){
469
+ const native=parseNativeRecord(rawSource);
470
+ doc.errors.push(...native.diagnostics.map(d=>({path:relativePath,level:'error',message:`${d.path}: ${d.message}`})));
471
+ return doc;
472
+ }
473
+ validateDoc(doc, parsedFrontmatter, headingTitle, config);
474
+ validatePlanShape(doc, body, parsedFrontmatter, config);
475
+ validateDocShape(doc, body, parsedFrontmatter, config);
476
+ }
477
+ return doc;
478
+ }