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/config.mjs ADDED
@@ -0,0 +1,705 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { die, warn } from './util.mjs';
5
+ import { CONFIG_FILENAMES } from './naming.mjs';
6
+ import { validateStageDefinitions } from './stages.mjs';
7
+ import { validateFilingConfig } from './filing.mjs';
8
+ import { validateYardstickConfig } from './yardstick.mjs';
9
+
10
+ // Keys where user config replaces defaults entirely (not deep-merged).
11
+ // These are flat maps or config sections where the user's version is authoritative —
12
+ // default keys that the user omitted should NOT survive the merge.
13
+ const REPLACE_KEYS = new Set([
14
+ 'statuses.staleDays',
15
+ 'statuses.rootStatuses',
16
+ 'lifecycle.filedStatuses',
17
+ 'presets',
18
+ 'context',
19
+ ]);
20
+
21
+ const DEFAULTS = {
22
+ root: '.',
23
+ archiveDir: 'archived',
24
+ excludeDirs: [],
25
+ externalBodyLinkRoots: [],
26
+ records: null,
27
+ filing: null,
28
+ yardstick: null,
29
+ // Floor under the scan surface; null = off. See `applyScanFloor` in validate.mjs.
30
+ minDocs: null,
31
+
32
+ // Reference repair outside the doc roots (`runlist refs`). An empty
33
+ // `codeRoots` is off, which is the behaviour before this key existed: the
34
+ // doc-root repair archive and rename already run is unchanged either way.
35
+ // Paths are repo-relative. `null` on the two lists means "the built-in
36
+ // list", which lives in code-refs.mjs so config.mjs stays a leaf.
37
+ codeRoots: [],
38
+ codeExtensions: null,
39
+ codeExcludes: null,
40
+ // Files whose content is data keyed on document paths — a guard baseline
41
+ // whose keys are plan paths, for instance. Reported, never written.
42
+ codeRefsUntouched: [],
43
+
44
+ types: {
45
+ plan: {
46
+ statuses: ['in-session', 'active', 'planned', 'blocked', 'partial', 'paused', 'awaiting', 'queued-after', 'archived'],
47
+ context: {
48
+ expanded: ['in-session', 'active', 'partial'],
49
+ listed: ['planned', 'blocked', 'paused', 'awaiting'],
50
+ counted: ['queued-after', 'archived'],
51
+ },
52
+ staleDays: { 'in-session': 1, active: 14, planned: 30, blocked: 30, paused: 3, awaiting: 14 },
53
+ },
54
+ doc: {
55
+ statuses: ['draft', 'active', 'review', 'reference', 'deprecated', 'archived'],
56
+ context: { expanded: ['active'], listed: ['draft', 'review'], counted: ['reference', 'deprecated', 'archived'] },
57
+ staleDays: { draft: 30, active: 14, review: 14 },
58
+ },
59
+ // A prompt has two states and no third. It is pending — the next session
60
+ // should consume it — or it is archived, consumed, body kept under the
61
+ // archive directory. `held`, `shelved` and `claimed` were removed 2026-08-30:
62
+ // a prompt directs work, so parking one instead of archiving it leaves work
63
+ // state sitting outside the plan that owns it, where it fights with the
64
+ // plan's own status and neither one is the truth. If a prompt should not be
65
+ // consumed, its content belongs in the plan and the prompt should be
66
+ // archived. See lifecycle.filedStatuses — prompts file nowhere.
67
+ prompt: {
68
+ statuses: ['pending', 'archived'],
69
+ context: { expanded: ['pending'], counted: ['archived'] },
70
+ staleDays: { pending: 30 },
71
+ },
72
+ },
73
+
74
+ statuses: {
75
+ order: ['active', 'ready', 'planned', 'scoping', 'blocked', 'reference', 'archived'],
76
+ staleDays: {
77
+ active: 14,
78
+ ready: 14,
79
+ planned: 30,
80
+ blocked: 30,
81
+ scoping: 30,
82
+ },
83
+ },
84
+
85
+ lifecycle: {
86
+ startableStatuses: ['active', 'planned'],
87
+ archiveStatuses: ['archived'],
88
+ skipStaleFor: ['archived', 'reference', 'partial', 'queued-after'],
89
+ skipWarningsFor: ['archived', 'partial', 'queued-after'],
90
+ terminalStatuses: ['archived', 'deprecated', 'reference'],
91
+ // F15: per-status filing buckets (status → dirName). Built-in held/paused
92
+ // statuses file under the owning type folder; archive remains a separate
93
+ // primitive untouched.
94
+ // Plans only. A paused plan files under <plansDir>/held/ so the live plan
95
+ // list stays readable. Prompts were removed from this map 2026-08-30 along
96
+ // with their held/shelved statuses — `archived` is the only directory a
97
+ // prompt ever moves into.
98
+ filedStatuses: { paused: 'held' },
99
+ // Types whose archive nests under their own type dir (<typeDir>/<archiveDir>,
100
+ // e.g. docs/prompts/archived/) instead of the shared <root>/<archiveDir>.
101
+ // Prompts are session-local churn — keeping their archive out of the shared
102
+ // docs/archived/ stops them from burying plans/docs there. Set to [] to send
103
+ // every type to the shared archive.
104
+ archiveNestedTypes: ['prompt'],
105
+ },
106
+
107
+ taxonomy: {
108
+ surfaces: null,
109
+ // Ordered delivery stages; null leaves the repository vocabulary unrestricted.
110
+ milestones: null,
111
+ // modules: when null (default), the project doesn't enumerate product
112
+ // modules — the modules-required validator silently skips. When set to
113
+ // an array, modules are taxonomy-enforced AND moduleRequiredFor activates.
114
+ // Mirrors the long-standing semantics of `taxonomy.surfaces`.
115
+ modules: null,
116
+ moduleRequiredFor: ['partial', 'paused', 'awaiting', 'queued-after'],
117
+ },
118
+
119
+ index: null,
120
+
121
+ context: {
122
+ expanded: ['active'],
123
+ listed: ['ready', 'planned'],
124
+ counted: ['blocked', 'scoping', 'reference', 'archived'],
125
+ recentDays: 3,
126
+ recentStatuses: ['active', 'ready', 'planned'],
127
+ recentLimit: 10,
128
+ truncateNextStep: 80,
129
+ },
130
+
131
+ display: {
132
+ lineWidth: 0,
133
+ truncateTitle: 30,
134
+ truncateNextStep: 80,
135
+ },
136
+
137
+ referenceFields: {
138
+ // Defaults match the fields the built-in `plan`, `doc`, and `prompt`
139
+ // templates scaffold, so graph / deps / unblocks / pickup's Related:
140
+ // resolver work without config. Override by setting `referenceFields`
141
+ // in your config — your value fully replaces (no per-field merge).
142
+ bidirectional: ['related_plans', 'related_docs'],
143
+ unidirectional: ['parent_plan', 'runlist'],
144
+ },
145
+
146
+ templates: {},
147
+
148
+ glossary: null,
149
+ // `runlist new decision`: { section, prefix, register: { file, statusLine } }.
150
+ decisions: null,
151
+ // `runlist flag` / `runlist flags`: { file } moves the log from .runlist/flags.jsonl.
152
+ flags: null,
153
+
154
+ // Opt-in JSONL command journal at .runlist/journal.jsonl. Default off — agents
155
+ // and users who want usage observability flip this on (or set RUNLIST_JOURNAL=1).
156
+ journal: false,
157
+
158
+ // PreToolUse guard behavior. `deny: false` drops status edits to warnings;
159
+ // `promptReads: 'deny'` blocks direct reads of existing pending prompts.
160
+ guard: { deny: true, promptReads: 'warn' },
161
+
162
+ presets: {
163
+ stale: ['--status', 'active,ready,planned,blocked,scoping', '--stale', '--sort', 'updated', '--all'],
164
+ actionable: ['--status', 'active,ready', '--has-next-step', '--sort', 'updated', '--all'],
165
+ },
166
+ };
167
+
168
+ const VALID_CONTEXT_VALUES = new Set(['expanded', 'listed', 'counted']);
169
+
170
+ /**
171
+ * Normalize rich status definitions (object form) into array form + derived config.
172
+ * When types.<type>.statuses is an object like:
173
+ * { 'active': { context: 'expanded', staleDays: 14, requiresModule: true }, ... }
174
+ * this extracts all behavioral properties, converts statuses to an array for
175
+ * downstream processing, and returns derived values for lifecycle/taxonomy/context.
176
+ *
177
+ * Returns null if no types use object-form statuses.
178
+ */
179
+ function normalizeRichStatuses(config, userConfig) {
180
+ const derived = {
181
+ startableStatuses: [],
182
+ archiveStatuses: [],
183
+ skipStaleFor: [],
184
+ skipWarningsFor: [],
185
+ // Same membership as `skipWarningsFor`, but kept per declaring type. The flat
186
+ // list is a union of status NAMES, so a name any type marks quiet suppresses
187
+ // warnings for every other type that happens to share it — `journey.active`
188
+ // silencing every `active` plan in the repo. Statuses are type-scoped
189
+ // everywhere else (`typeStatuses`, `isValidStatus`), so the suppression that
190
+ // reads them has to be too.
191
+ skipWarningsForByType: {},
192
+ terminalStatuses: [],
193
+ terminalStatusesByType: {},
194
+ moduleRequiredFor: [],
195
+ // F15: status-name → directory-name (defaults to the status name verbatim).
196
+ // Filed statuses move docs into <root>/<dirName>/ on transition INTO the
197
+ // status, and back to flat <root>/ on transition OUT. Separate from
198
+ // archive: true to avoid touching that long-standing primitive's
199
+ // semantics.
200
+ filedStatuses: {},
201
+ staleDays: {},
202
+ statusOrder: [],
203
+ context: { expanded: [], listed: [], counted: [] },
204
+ };
205
+
206
+ let hasRich = false;
207
+
208
+ for (const [typeName, typeDef] of Object.entries(config.types ?? {})) {
209
+ if (!typeDef.statuses || Array.isArray(typeDef.statuses)) continue;
210
+ if (typeof typeDef.statuses !== 'object') continue;
211
+
212
+ hasRich = true;
213
+ // Claim the key even when no status is quiet: an empty list means "this type
214
+ // declared its statuses and suppresses nothing", which must not fall back to
215
+ // the global union. Absent entirely means "type never declared rich statuses".
216
+ derived.skipWarningsForByType[typeName] ??= [];
217
+ derived.terminalStatusesByType[typeName] ??= [];
218
+ const statusNames = [];
219
+ const typeContext = { expanded: [], listed: [], counted: [] };
220
+ const typeStaleDays = {};
221
+
222
+ for (const [name, props] of Object.entries(typeDef.statuses)) {
223
+ const p = props ?? {};
224
+ statusNames.push(name);
225
+
226
+ const ctx = p.context ?? 'counted';
227
+ if (typeContext[ctx]) typeContext[ctx].push(name);
228
+ // Global context: only add if not already in any bucket (first type wins)
229
+ const inGlobal = derived.context.expanded.includes(name) ||
230
+ derived.context.listed.includes(name) || derived.context.counted.includes(name);
231
+ if (!inGlobal && derived.context[ctx]) derived.context[ctx].push(name);
232
+
233
+ if (p.staleDays != null) {
234
+ typeStaleDays[name] = p.staleDays;
235
+ derived.staleDays[name] = p.staleDays;
236
+ }
237
+
238
+ // `quiet: true` is sugar for skipStale + skipWarnings unless those are explicitly false.
239
+ const quietImpliesSkipStale = p.quiet && p.skipStale !== false;
240
+ const quietImpliesSkipWarnings = p.quiet && p.skipWarnings !== false;
241
+
242
+ // Contradiction diagnostics — emit at load so dead config doesn't fail silently.
243
+ const skipStaleEffective = p.skipStale === true || quietImpliesSkipStale;
244
+ const skipWarningsEffective = p.skipWarnings === true || quietImpliesSkipWarnings;
245
+ if (skipStaleEffective && p.staleDays != null) {
246
+ warn(`runlist config: status "${typeName}.${name}" has skipStale: true and staleDays: ${p.staleDays} — staleDays is ignored. Drop one to silence this warning.`);
247
+ }
248
+ if (skipWarningsEffective && p.requiresModule) {
249
+ warn(`runlist config: status "${typeName}.${name}" has skipWarnings: true and requiresModule: true — the module requirement can never fire. Drop one to silence this warning.`);
250
+ }
251
+
252
+ if (p.archive && !derived.archiveStatuses.includes(name)) derived.archiveStatuses.push(name);
253
+ if (p.startable && !derived.startableStatuses.includes(name)) derived.startableStatuses.push(name);
254
+ if ((p.skipStale || quietImpliesSkipStale) && !derived.skipStaleFor.includes(name)) derived.skipStaleFor.push(name);
255
+ if (p.skipWarnings || quietImpliesSkipWarnings) {
256
+ if (!derived.skipWarningsFor.includes(name)) derived.skipWarningsFor.push(name);
257
+ (derived.skipWarningsForByType[typeName] ??= []).push(name);
258
+ }
259
+ if (p.terminal) {
260
+ if (!derived.terminalStatuses.includes(name)) derived.terminalStatuses.push(name);
261
+ derived.terminalStatusesByType[typeName].push(name);
262
+ }
263
+ if (p.requiresModule && !derived.moduleRequiredFor.includes(name)) derived.moduleRequiredFor.push(name);
264
+ if (p.filed && !derived.filedStatuses[name]) {
265
+ // dirName defaults to the status name; users can override with
266
+ // `filed: 'custom-dir'` (string form) instead of `filed: true`.
267
+ derived.filedStatuses[name] = typeof p.filed === 'string' ? p.filed : name;
268
+ }
269
+
270
+ if (!derived.statusOrder.includes(name)) derived.statusOrder.push(name);
271
+ }
272
+
273
+ // Convert to array form for downstream pipeline
274
+ typeDef.statuses = statusNames;
275
+
276
+ // Derive type-level context/staleDays unless user explicitly provided them
277
+ const userTypeDef = userConfig.types?.[typeName];
278
+ if (!userTypeDef?.context) typeDef.context = typeContext;
279
+ if (!userTypeDef?.staleDays) typeDef.staleDays = typeStaleDays;
280
+ }
281
+
282
+ if (!hasRich) return null;
283
+ return derived;
284
+ }
285
+
286
+ /**
287
+ * Apply derived values from rich status definitions into the merged config.
288
+ * Explicit user config always wins over derived values.
289
+ */
290
+ function applyDerivedConfig(config, userConfig, derived) {
291
+ // statuses.order — derive from types if user didn't explicitly set
292
+ if (!userConfig.statuses?.order && derived.statusOrder.length) {
293
+ config.statuses.order = derived.statusOrder;
294
+ }
295
+
296
+ // statuses.staleDays — merge derived as base, user overrides
297
+ if (!userConfig.statuses?.staleDays && Object.keys(derived.staleDays).length) {
298
+ config.statuses.staleDays = derived.staleDays;
299
+ }
300
+
301
+ // lifecycle — each sub-key independently
302
+ if (!userConfig.lifecycle?.startableStatuses && derived.startableStatuses.length) {
303
+ config.lifecycle.startableStatuses = derived.startableStatuses;
304
+ }
305
+ if (!userConfig.lifecycle?.archiveStatuses && derived.archiveStatuses.length) {
306
+ config.lifecycle.archiveStatuses = derived.archiveStatuses;
307
+ }
308
+ if (!userConfig.lifecycle?.skipStaleFor && derived.skipStaleFor.length) {
309
+ config.lifecycle.skipStaleFor = derived.skipStaleFor;
310
+ }
311
+ // Assigned on BOTH branches, never conditionally: `config.lifecycle` can be the
312
+ // module-level DEFAULTS.lifecycle object itself (deepMerge shallow-copies, so a
313
+ // user config with no `lifecycle` key shares the reference), and a conditional
314
+ // write would leave a previous resolveConfig call's map in place.
315
+ if (!userConfig.lifecycle?.skipWarningsFor && derived.skipWarningsFor.length) {
316
+ config.lifecycle.skipWarningsFor = derived.skipWarningsFor;
317
+ config.lifecycle.skipWarningsForByType = derived.skipWarningsForByType;
318
+ } else {
319
+ // An explicit `lifecycle.skipWarningsFor` is a deliberate repo-wide statement
320
+ // and stays type-blind.
321
+ config.lifecycle.skipWarningsForByType = {};
322
+ }
323
+ if (!userConfig.lifecycle?.terminalStatuses && derived.terminalStatuses.length) {
324
+ config.lifecycle.terminalStatuses = derived.terminalStatuses;
325
+ config.lifecycle.terminalStatusesByType = derived.terminalStatusesByType;
326
+ } else {
327
+ // An explicit lifecycle list is intentionally repo-wide.
328
+ config.lifecycle.terminalStatusesByType = {};
329
+ }
330
+ if (!userConfig.lifecycle?.filedStatuses && Object.keys(derived.filedStatuses).length) {
331
+ config.lifecycle.filedStatuses = derived.filedStatuses;
332
+ }
333
+
334
+ // taxonomy.moduleRequiredFor
335
+ if (!userConfig.taxonomy?.moduleRequiredFor && derived.moduleRequiredFor.length) {
336
+ config.taxonomy.moduleRequiredFor = derived.moduleRequiredFor;
337
+ }
338
+
339
+ // context — only the status-related arrays, not recentDays/recentLimit/etc
340
+ if (!userConfig.context?.expanded) config.context.expanded = derived.context.expanded;
341
+ if (!userConfig.context?.listed) config.context.listed = derived.context.listed;
342
+ if (!userConfig.context?.counted) config.context.counted = derived.context.counted;
343
+ }
344
+
345
+ function findConfigFile(startDir) {
346
+ let dir = path.resolve(startDir);
347
+ const root = path.parse(dir).root;
348
+
349
+ while (dir !== root) {
350
+ for (const filename of CONFIG_FILENAMES) {
351
+ const candidate = path.join(dir, filename);
352
+ if (existsSync(candidate)) return candidate;
353
+ }
354
+ dir = path.dirname(dir);
355
+ }
356
+
357
+ return null;
358
+ }
359
+
360
+ const VALID_CONFIG_KEYS = new Set(Object.keys(DEFAULTS));
361
+
362
+ function validateConfig(userConfig, config, validStatuses, indexPath) {
363
+ const warnings = validateStageDefinitions(config);
364
+ warnings.push(...validateFilingConfig(config.filing));
365
+ warnings.push(...validateYardstickConfig(config.yardstick));
366
+
367
+ // statuses.order must be array
368
+ if (config.statuses && config.statuses.order !== undefined && !Array.isArray(config.statuses.order)) {
369
+ warnings.push('Config: statuses.order must be an array.');
370
+ }
371
+
372
+ // archiveDir must be string
373
+ if (config.archiveDir !== undefined && typeof config.archiveDir !== 'string') {
374
+ warnings.push('Config: archiveDir must be a string.');
375
+ }
376
+
377
+ // Lifecycle status lists must reference configured statuses.
378
+ if (config.lifecycle?.startableStatuses) {
379
+ for (const s of config.lifecycle.startableStatuses) {
380
+ if (!validStatuses.has(s)) {
381
+ warnings.push(`Config: lifecycle.startableStatuses contains unknown status '${s}'.`);
382
+ }
383
+ }
384
+ }
385
+
386
+ // lifecycle.archiveStatuses values must exist in validStatuses
387
+ if (config.lifecycle?.archiveStatuses) {
388
+ for (const s of config.lifecycle.archiveStatuses) {
389
+ if (!validStatuses.has(s)) {
390
+ warnings.push(`Config: lifecycle.archiveStatuses contains unknown status '${s}'.`);
391
+ }
392
+ }
393
+ }
394
+
395
+ // staleDays keys must exist in validStatuses — but only validate the map when
396
+ // the user actually wrote `statuses.staleDays`. When it's inherited from
397
+ // defaults (user overrode `statuses.order` but left staleDays unset), the
398
+ // default map is keyed by default statuses (`ready`, `scoping`, …) that the
399
+ // user's order may not include — warning there blames the user for keys they
400
+ // never authored and makes brownfield repos noisy on every command, hook
401
+ // included. Real typos in a user-provided map are still caught.
402
+ if (userConfig.statuses?.staleDays && config.statuses?.staleDays) {
403
+ for (const key of Object.keys(config.statuses.staleDays)) {
404
+ if (!validStatuses.has(key)) {
405
+ warnings.push(`Config: statuses.staleDays contains unknown status '${key}'.`);
406
+ }
407
+ }
408
+ }
409
+
410
+ // taxonomy.surfaces must be null or array
411
+ if (config.taxonomy?.surfaces !== undefined && config.taxonomy.surfaces !== null && !Array.isArray(config.taxonomy.surfaces)) {
412
+ warnings.push('Config: taxonomy.surfaces must be null or an array.');
413
+ }
414
+
415
+ // index path file exists (if configured)
416
+ if (indexPath && !existsSync(indexPath)) {
417
+ warnings.push(`Config: index path does not exist: ${indexPath}`);
418
+ }
419
+
420
+ if (config.index?.snapshot !== undefined && !['status', 'state'].includes(config.index.snapshot)) {
421
+ warnings.push("Config: index.snapshot must be 'status' or 'state'.");
422
+ }
423
+
424
+ for (const key of ['codeRoots', 'codeRefsUntouched']) {
425
+ if (config[key] !== undefined && !Array.isArray(config[key])) {
426
+ warnings.push(`Config: ${key} must be an array.`);
427
+ }
428
+ }
429
+ for (const key of ['codeExtensions', 'codeExcludes']) {
430
+ if (config[key] != null && !Array.isArray(config[key])) {
431
+ warnings.push(`Config: ${key} must be an array.`);
432
+ }
433
+ }
434
+
435
+ if (userConfig.decisions != null) {
436
+ const d = userConfig.decisions;
437
+ if (typeof d !== 'object' || Array.isArray(d)) warnings.push('Config: decisions must be an object.');
438
+ else if (d.register != null && (typeof d.register?.file !== 'string' || typeof d.register?.statusLine !== 'string')) {
439
+ warnings.push('Config: decisions.register needs a file and a statusLine.');
440
+ }
441
+ }
442
+
443
+ // Unknown top-level user config keys
444
+ for (const key of Object.keys(userConfig)) {
445
+ if (!VALID_CONFIG_KEYS.has(key)) {
446
+ warnings.push(`Config: unknown key '${key}'.`);
447
+ }
448
+ }
449
+
450
+ return warnings;
451
+ }
452
+
453
+ function deepMerge(defaults, overrides, parentPath = '') {
454
+ const result = { ...defaults };
455
+ for (const [key, value] of Object.entries(overrides)) {
456
+ const keyPath = parentPath ? `${parentPath}.${key}` : key;
457
+ if (value != null && typeof value === 'object' && !Array.isArray(value) &&
458
+ result[key] != null && typeof result[key] === 'object' && !Array.isArray(result[key]) &&
459
+ !REPLACE_KEYS.has(keyPath)) {
460
+ result[key] = deepMerge(result[key], value, keyPath);
461
+ } else {
462
+ result[key] = value;
463
+ }
464
+ }
465
+ return result;
466
+ }
467
+
468
+ export async function resolveConfig(cwd, explicitConfigPath) {
469
+ const configPath = explicitConfigPath
470
+ ? path.resolve(cwd, explicitConfigPath)
471
+ : findConfigFile(cwd);
472
+
473
+ let userConfig = {};
474
+ let hooks = {};
475
+ let configDir = cwd;
476
+
477
+ if (configPath && existsSync(configPath)) {
478
+ const configUrl = pathToFileURL(configPath).href;
479
+ let mod;
480
+ try {
481
+ mod = await import(configUrl);
482
+ } catch (err) {
483
+ die('Failed to load config: ' + configPath + '\n' + err.message + '\nCheck for syntax errors in your config file.');
484
+ }
485
+
486
+ configDir = path.dirname(configPath);
487
+
488
+ for (const [key, value] of Object.entries(mod)) {
489
+ if (key === 'default') continue;
490
+ if (typeof value === 'function') {
491
+ hooks[key] = value;
492
+ } else {
493
+ userConfig[key] = value;
494
+ }
495
+ }
496
+ }
497
+
498
+ // Backwards compat: `readme` config key maps to `index`
499
+ if (userConfig.readme && !userConfig.index) {
500
+ userConfig.index = userConfig.readme;
501
+ delete userConfig.readme;
502
+ }
503
+
504
+ const config = deepMerge(DEFAULTS, userConfig);
505
+ // Replacement links remain repairable even with custom reference-field lists
506
+ // or after the repository turns off goal display.
507
+ config.referenceFields.unidirectional = [...new Set([
508
+ ...(config.referenceFields.unidirectional ?? []), 'yardstick_into',
509
+ ])];
510
+
511
+ // Normalize rich status definitions (object form → array + derived config)
512
+ const derived = normalizeRichStatuses(config, userConfig);
513
+ if (derived) applyDerivedConfig(config, userConfig, derived);
514
+
515
+ const rootPaths = Array.isArray(config.root) ? config.root : [config.root];
516
+ const docsRoots = rootPaths.map(r => path.resolve(configDir, r));
517
+ const docsRoot = docsRoots[0]; // primary root for backwards compat
518
+
519
+ const earlyWarnings = [];
520
+ for (const dr of docsRoots) {
521
+ if (!existsSync(dr)) {
522
+ earlyWarnings.push('Config: docs root does not exist: ' + dr);
523
+ }
524
+ }
525
+
526
+ // Find repo root by walking up looking for .git
527
+ let repoRoot = configDir;
528
+ {
529
+ let dir = configDir;
530
+ const fsRoot = path.parse(dir).root;
531
+ while (dir !== fsRoot) {
532
+ if (existsSync(path.join(dir, '.git'))) {
533
+ repoRoot = dir;
534
+ break;
535
+ }
536
+ dir = path.dirname(dir);
537
+ }
538
+ }
539
+
540
+ // Resolve document types
541
+ const typesConfig = config.types ?? {};
542
+ const validTypes = new Set(Object.keys(typesConfig));
543
+ const typeStatuses = new Map();
544
+ const typeContextConfig = new Map();
545
+ for (const [typeName, typeDef] of Object.entries(typesConfig)) {
546
+ typeStatuses.set(typeName, new Set(typeDef.statuses ?? []));
547
+ if (typeDef.context) typeContextConfig.set(typeName, typeDef.context);
548
+ }
549
+
550
+ const statusOrder = config.statuses.order;
551
+ const validStatuses = new Set(statusOrder);
552
+ // Merge all type-specific statuses into the global valid set
553
+ for (const typeSet of typeStatuses.values()) {
554
+ for (const s of typeSet) validStatuses.add(s);
555
+ }
556
+ const staleDaysByStatus = {};
557
+ for (const status of statusOrder) {
558
+ staleDaysByStatus[status] = config.statuses.staleDays?.[status] ?? null;
559
+ }
560
+ // Merge type-specific staleDays
561
+ for (const typeDef of Object.values(typesConfig)) {
562
+ if (typeDef.staleDays) {
563
+ for (const [status, days] of Object.entries(typeDef.staleDays)) {
564
+ if (!(status in staleDaysByStatus)) staleDaysByStatus[status] = days;
565
+ }
566
+ }
567
+ }
568
+
569
+ // Per-root additional statuses (merged with global validStatuses)
570
+ const rootStatusesRaw = config.statuses.rootStatuses ?? {};
571
+ const rootLabels = new Set(rootPaths.map(r => path.relative(configDir, path.resolve(configDir, r)).split(path.sep).join('/')));
572
+ const rootValidStatuses = new Map();
573
+ for (const [rootKey, extraStatuses] of Object.entries(rootStatusesRaw)) {
574
+ const merged = new Set(validStatuses);
575
+ for (const s of extraStatuses) merged.add(s);
576
+ rootValidStatuses.set(rootKey, merged);
577
+ }
578
+
579
+ const validSurfaces = config.taxonomy.surfaces
580
+ ? new Set(config.taxonomy.surfaces)
581
+ : null;
582
+ const validModules = config.taxonomy.modules
583
+ ? new Set(config.taxonomy.modules)
584
+ : null;
585
+ const moduleRequiredStatuses = new Set(config.taxonomy.moduleRequiredFor);
586
+
587
+ const indexPath = config.index?.path
588
+ ? path.resolve(repoRoot, config.index.path)
589
+ : null;
590
+
591
+ // Compute docs root relative path for index link stripping
592
+ const docsRootRelative = path.relative(repoRoot, docsRoot).split(path.sep).join('/');
593
+ const docsRootPrefix = docsRootRelative ? docsRootRelative + '/' : '';
594
+
595
+ // Lifecycle config
596
+ const lifecycle = config.lifecycle;
597
+ const archiveStatuses = new Set(lifecycle.archiveStatuses);
598
+ const startableStatuses = new Set(lifecycle.startableStatuses ?? ['active', 'planned']);
599
+ const skipStaleFor = new Set(lifecycle.skipStaleFor);
600
+ const skipWarningsFor = new Set(lifecycle.skipWarningsFor);
601
+ const skipWarningsForByType = new Map(
602
+ Object.entries(lifecycle.skipWarningsForByType ?? {}).map(([type, names]) => [type, new Set(names)]),
603
+ );
604
+ // Does `status` suppress warning-only checks for a doc of this type? A type that
605
+ // declared rich statuses answers for itself — including answering "no" for a
606
+ // name some other type marked quiet. Everything else (no rich statuses, an
607
+ // explicit global `skipWarningsFor`, a doc with no type) reads the flat set.
608
+ const skipsWarnings = (status, type) => {
609
+ const own = type != null ? skipWarningsForByType.get(type) : undefined;
610
+ return own ? own.has(status) : skipWarningsFor.has(status);
611
+ };
612
+ const terminalStatuses = new Set(lifecycle.terminalStatuses);
613
+ const terminalStatusesByType = new Map(
614
+ Object.entries(lifecycle.terminalStatusesByType ?? {}).map(([type, names]) => [type, new Set(names)]),
615
+ );
616
+ const isTerminal = (status, type) => {
617
+ const own = type != null ? terminalStatusesByType.get(type) : undefined;
618
+ return own ? own.has(status) : terminalStatuses.has(status);
619
+ };
620
+ // F15: filedStatuses keyed by status name, value = directory name. Empty
621
+ // object when no status opts in via `filed: true` (or `filed: '<dirname>'`).
622
+ const filedStatuses = new Map(Object.entries(lifecycle.filedStatuses ?? {}));
623
+ // Types that archive into their own <typeDir>/<archiveDir> rather than the
624
+ // shared <root>/<archiveDir> (default: prompt).
625
+ const archiveNestedTypes = new Set(lifecycle.archiveNestedTypes ?? []);
626
+
627
+ // Warn if rootStatuses keys don't match any configured root
628
+ for (const rootKey of Object.keys(rootStatusesRaw)) {
629
+ if (!rootLabels.has(rootKey)) {
630
+ earlyWarnings.push(`Config: statuses.rootStatuses key '${rootKey}' does not match any configured root.`);
631
+ }
632
+ }
633
+
634
+ // A floor under the scan surface. Unset (the default) is off, so no existing
635
+ // repo changes behavior. See `applyScanFloor` in src/validate.mjs for why a
636
+ // silent zero-doc pass is the failure mode worth spending a config key on.
637
+ let minDocs = null;
638
+ if (config.minDocs !== undefined && config.minDocs !== null) {
639
+ if (!Number.isInteger(config.minDocs) || config.minDocs < 1) {
640
+ earlyWarnings.push(`Config: minDocs must be a positive integer; ignoring \`${config.minDocs}\`.`);
641
+ } else {
642
+ minDocs = config.minDocs;
643
+ }
644
+ }
645
+
646
+ const configWarnings = [...earlyWarnings, ...validateConfig(userConfig, config, validStatuses, indexPath)];
647
+
648
+ return {
649
+ raw: config,
650
+
651
+ docsRoot,
652
+ docsRoots,
653
+ repoRoot,
654
+ configDir,
655
+ configPath: configPath ?? null,
656
+ configFound: Boolean(configPath),
657
+ archiveDir: config.archiveDir,
658
+ excludeDirs: new Set(config.excludeDirs),
659
+ externalBodyLinkRoots: Array.isArray(config.externalBodyLinkRoots) ? config.externalBodyLinkRoots : [],
660
+ docsRootPrefix,
661
+ minDocs,
662
+
663
+ codeRefs: {
664
+ roots: Array.isArray(config.codeRoots) ? config.codeRoots : [],
665
+ extensions: Array.isArray(config.codeExtensions) ? config.codeExtensions : null,
666
+ excludes: Array.isArray(config.codeExcludes) ? config.codeExcludes : null,
667
+ untouched: Array.isArray(config.codeRefsUntouched) ? config.codeRefsUntouched : [],
668
+ },
669
+
670
+ statusOrder,
671
+ validStatuses,
672
+ validTypes,
673
+ typeStatuses,
674
+ typeContextConfig,
675
+ rootValidStatuses,
676
+ staleDaysByStatus,
677
+
678
+ lifecycle: { archiveStatuses, startableStatuses, skipStaleFor, skipWarningsFor, skipWarningsForByType, skipsWarnings, terminalStatuses, terminalStatusesByType, isTerminal, filedStatuses, archiveNestedTypes },
679
+
680
+ validSurfaces,
681
+ validModules,
682
+ moduleRequiredStatuses,
683
+
684
+ indexPath,
685
+ indexStartMarker: config.index?.startMarker ?? '<!-- GENERATED:runlist:start -->',
686
+ indexEndMarker: config.index?.endMarker ?? '<!-- GENERATED:runlist:end -->',
687
+ indexSnapshot: config.index?.snapshot ?? 'status',
688
+ archivedHighlightLimit: config.index?.archivedLimit ?? 8,
689
+
690
+ context: config.context,
691
+ display: config.display,
692
+ referenceFields: config.referenceFields,
693
+ filing: config.filing,
694
+ yardstick: config.yardstick,
695
+ presets: config.presets,
696
+ configuredPresetNames: new Set(Object.keys(userConfig.presets ?? {})),
697
+ journal: config.journal === true,
698
+ guard: {
699
+ deny: config.guard?.deny !== false,
700
+ promptReads: config.guard?.promptReads === 'deny' ? 'deny' : 'warn',
701
+ },
702
+ hooks,
703
+ configWarnings,
704
+ };
705
+ }