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
package/src/render.mjs ADDED
@@ -0,0 +1,678 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { capitalize, toSlug, truncate, warn, isArchivedPath } from './util.mjs';
4
+ import { extractFrontmatter } from './frontmatter.mjs';
5
+ import { summarizeDocBody } from './ai.mjs';
6
+ import { bold, red, yellow, green, dim } from './color.mjs';
7
+ import { categorizeWarnings } from './check-collapse.mjs';
8
+ import { buildCoordinationIndex, isRoadmapHub } from './runlist.mjs';
9
+ import { resolveStatusMetadata, statusMetadataFor } from './status-metadata.mjs';
10
+ import { readPlanStage } from './stages.mjs';
11
+ import { scopeFilingCoverage } from './filing.mjs';
12
+
13
+ // Render `currentState` with an `(auto)` prefix when the value was body-scraped
14
+ // rather than read from frontmatter. Lets a reader see at a glance which docs
15
+ // have an explicit `current_state:` line versus a string inferred from the body
16
+ // — and that overriding it is as simple as adding the field to frontmatter.
17
+ export function formatCurrentState(doc) {
18
+ if (!doc.currentState) return null;
19
+ return doc.currentStateOrigin === 'body'
20
+ ? `(auto) ${doc.currentState}`
21
+ : doc.currentState;
22
+ }
23
+
24
+ export function renderCompactList(index, config) {
25
+ const defaultRenderer = (idx) => _renderCompactList(idx, config);
26
+ if (!config._execution?.suppressSideEffects && config.hooks.renderCompactList) {
27
+ try { return config.hooks.renderCompactList(index, defaultRenderer); }
28
+ catch (err) { warn(`Hook 'renderCompactList' threw: ${err.message}`); }
29
+ }
30
+ return defaultRenderer(index);
31
+ }
32
+
33
+ function _renderCompactList(index, config) {
34
+ const lines = ['Index', ''];
35
+ const maxWidth = config.display.lineWidth || process.stdout.columns || 120;
36
+ const hidden = config.lifecycle.archiveStatuses;
37
+
38
+ for (const status of config.statusOrder) {
39
+ if (hidden.has(status)) continue;
40
+ const docs = index.docs.filter(d => d.status === status);
41
+ if (!docs.length) continue;
42
+
43
+ lines.push(bold(`${capitalize(status)} (${docs.length})`));
44
+ const maxTitle = Math.min(config.display.truncateTitle || 30, Math.max(...docs.map(d => d.title.length)));
45
+
46
+ for (const doc of docs) {
47
+ const title = doc.title.length > maxTitle
48
+ ? doc.title.slice(0, maxTitle - 3) + '...'
49
+ : doc.title.padEnd(maxTitle);
50
+ const days = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : '';
51
+ const progress = renderProgressBar(doc.checklist);
52
+ const next = doc.nextStep ? `next: ${doc.nextStep}` : '';
53
+ const parts = [` ${title} ${days.padStart(4)}`];
54
+ if (progress) parts.push(progress);
55
+ if (next) parts.push(next);
56
+ const line = parts.join(' ');
57
+ lines.push(line.length > maxWidth ? line.slice(0, maxWidth - 3) + '...' : line);
58
+ }
59
+ lines.push('');
60
+ }
61
+
62
+ // Render docs with statuses not in statusOrder
63
+ const knownStatuses = new Set(config.statusOrder);
64
+ const otherStatuses = [...new Set(index.docs.filter(d => d.status && !knownStatuses.has(d.status) && !hidden.has(d.status)).map(d => d.status))].sort();
65
+ for (const status of otherStatuses) {
66
+ const docs = index.docs.filter(d => d.status === status);
67
+ if (!docs.length) continue;
68
+
69
+ lines.push(bold(`${capitalize(status)} (${docs.length})`));
70
+ const maxTitle = Math.min(config.display.truncateTitle || 30, Math.max(...docs.map(d => d.title.length)));
71
+
72
+ for (const doc of docs) {
73
+ const title = doc.title.length > maxTitle
74
+ ? doc.title.slice(0, maxTitle - 3) + '...'
75
+ : doc.title.padEnd(maxTitle);
76
+ const days = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : '';
77
+ const progress = renderProgressBar(doc.checklist);
78
+ const next = doc.nextStep ? `next: ${doc.nextStep}` : '';
79
+ const parts = [` ${title} ${days.padStart(4)}`];
80
+ if (progress) parts.push(progress);
81
+ if (next) parts.push(next);
82
+ const line = parts.join(' ');
83
+ lines.push(line.length > maxWidth ? line.slice(0, maxWidth - 3) + '...' : line);
84
+ }
85
+ lines.push('');
86
+ }
87
+
88
+ // Surface docs without a status (untagged — either no frontmatter at all,
89
+ // or frontmatter present but no `status:` key). Pre-fix these were silently
90
+ // dropped because every section filtered by status, so `runlist list` on a
91
+ // freshly-init'd brownfield repo with N existing .md files showed just
92
+ // "Index" and looked like the tool didn't see them. Now they get their own
93
+ // section with the path so the user can find them and add frontmatter.
94
+ const untagged = index.docs.filter(d => !d.status);
95
+ if (untagged.length > 0) {
96
+ lines.push(bold(`Untagged (${untagged.length}) ${dim('— missing `status:` in frontmatter')}`));
97
+ for (const doc of untagged) {
98
+ lines.push(` ${doc.path}`);
99
+ }
100
+ lines.push('');
101
+ }
102
+
103
+ return `${lines.join('\n').trimEnd()}\n`;
104
+ }
105
+
106
+ export function renderVerboseList(index, config) {
107
+ const lines = ['Index', ''];
108
+ const hidden = config.lifecycle.archiveStatuses;
109
+
110
+ for (const status of config.statusOrder) {
111
+ if (hidden.has(status)) continue;
112
+ const docs = index.docs.filter(doc => doc.status === status);
113
+ if (docs.length === 0) continue;
114
+
115
+ lines.push(`${capitalize(status)} (${docs.length})`);
116
+ for (const doc of docs) {
117
+ const stateValue = formatCurrentState(doc);
118
+ const stateLabel = stateValue
119
+ ? `${capitalize(status)}: ${stateValue}`
120
+ : capitalize(status);
121
+ const parts = [`- ${doc.title}`, stateLabel, `(${doc.path})`];
122
+ if (doc.nextStep) {
123
+ parts.push(`next: ${doc.nextStep}`);
124
+ }
125
+ lines.push(parts.join(' — '));
126
+ }
127
+ lines.push('');
128
+ }
129
+
130
+ // Render docs with statuses not in statusOrder
131
+ const knownStatuses = new Set(config.statusOrder);
132
+ const otherStatuses = [...new Set(index.docs.filter(d => d.status && !knownStatuses.has(d.status) && !hidden.has(d.status)).map(d => d.status))].sort();
133
+ for (const status of otherStatuses) {
134
+ const docs = index.docs.filter(doc => doc.status === status);
135
+ if (docs.length === 0) continue;
136
+
137
+ lines.push(`${capitalize(status)} (${docs.length})`);
138
+ for (const doc of docs) {
139
+ const stateValue = formatCurrentState(doc);
140
+ const stateLabel = stateValue
141
+ ? `${capitalize(status)}: ${stateValue}`
142
+ : capitalize(status);
143
+ const parts = [`- ${doc.title}`, stateLabel, `(${doc.path})`];
144
+ if (doc.nextStep) {
145
+ parts.push(`next: ${doc.nextStep}`);
146
+ }
147
+ lines.push(parts.join(' — '));
148
+ }
149
+ lines.push('');
150
+ }
151
+
152
+ // Surface untagged docs (no `status:` in frontmatter, or no frontmatter at
153
+ // all) — see _renderCompactList for the why.
154
+ const untagged = index.docs.filter(d => !d.status);
155
+ if (untagged.length > 0) {
156
+ lines.push(`Untagged (${untagged.length}) — missing \`status:\` in frontmatter`);
157
+ for (const doc of untagged) {
158
+ lines.push(`- ${doc.title} — (${doc.path})`);
159
+ }
160
+ lines.push('');
161
+ }
162
+
163
+ return `${lines.join('\n').trimEnd()}\n`;
164
+ }
165
+
166
+ export function renderContext(index, config, opts = {}) {
167
+ const defaultRenderer = (idx) => _renderContext(idx, config, opts);
168
+ if (!config._execution?.suppressSideEffects && config.hooks.renderContext) {
169
+ try { return config.hooks.renderContext(index, defaultRenderer); }
170
+ catch (err) { warn(`Hook 'renderContext' threw: ${err.message}`); }
171
+ }
172
+ return defaultRenderer(index);
173
+ }
174
+
175
+ function _renderContextSection(docs, ctx, opts, config, lines) {
176
+ const byStatus = {};
177
+ for (const doc of docs) {
178
+ const s = doc.status ?? 'unknown';
179
+ if (!byStatus[s]) byStatus[s] = [];
180
+ byStatus[s].push(doc);
181
+ }
182
+
183
+ for (const status of (ctx.expanded || [])) {
184
+ const sdocs = byStatus[status];
185
+ if (!sdocs?.length) continue;
186
+ lines.push(bold(`${capitalize(status)} (${sdocs.length}):`));
187
+ const maxSlug = Math.min(24, Math.max(...sdocs.map(d => toSlug(d).length)));
188
+ for (const doc of sdocs) {
189
+ const slug = toSlug(doc).padEnd(maxSlug);
190
+ const age = doc.created ? Math.floor((Date.now() - new Date(doc.created).getTime()) / 86400000) : null;
191
+ const ageTag = age !== null ? dim(` (${age}d)`) : '';
192
+ const next = doc.nextStep
193
+ ? truncate(doc.nextStep, ctx.truncateNextStep || 80)
194
+ : '(no next step)';
195
+ lines.push(` ${slug}${ageTag} next: ${next}`);
196
+ if (opts.summarize && !config._execution?.suppressSideEffects) {
197
+ try {
198
+ const absPath = path.resolve(config.repoRoot, doc.path);
199
+ const raw = readFileSync(absPath, 'utf8');
200
+ const { body } = extractFrontmatter(raw);
201
+ const meta = { title: doc.title, status: doc.status, path: doc.path };
202
+ const summary = config.hooks.summarizeDoc
203
+ ? config.hooks.summarizeDoc(body, meta)
204
+ : summarizeDocBody(body, meta, { model: opts.model });
205
+ if (summary) {
206
+ lines.push(` ${''.padEnd(maxSlug)} ${dim('ai: ' + truncate(summary, 120))}`);
207
+ }
208
+ } catch (err) { warn(`AI summary failed for ${doc.path}: ${err.message}`); }
209
+ }
210
+ }
211
+ lines.push('');
212
+ }
213
+
214
+ for (const status of (ctx.listed || [])) {
215
+ const sdocs = byStatus[status];
216
+ if (!sdocs?.length) continue;
217
+ lines.push(`${capitalize(status)} (${sdocs.length}): ${sdocs.map(toSlug).join(', ')}`);
218
+ }
219
+
220
+ const counts = (ctx.counted || [])
221
+ .filter(s => byStatus[s]?.length)
222
+ .map(s => `${capitalize(s)} (${byStatus[s].length})`);
223
+ if (counts.length) {
224
+ lines.push(counts.join(' | '));
225
+ }
226
+ }
227
+
228
+ function _renderContext(index, config, opts = {}) {
229
+ const today = new Date().toISOString().slice(0, 10);
230
+ const lines = [`BRIEFING (${today})`, ''];
231
+ if (opts.summarize && config._execution?.suppressSideEffects) {
232
+ lines.push(dim('[preview] AI summaries skipped; models and custom summarizeDoc hooks are not invoked.'), '');
233
+ }
234
+
235
+ // Group docs by type
236
+ const typeOrder = ['plan', 'doc', 'research'];
237
+ const byType = {};
238
+ const untyped = [];
239
+ for (const doc of index.docs) {
240
+ if (doc.type) {
241
+ if (!byType[doc.type]) byType[doc.type] = [];
242
+ byType[doc.type].push(doc);
243
+ } else {
244
+ untyped.push(doc);
245
+ }
246
+ }
247
+
248
+ const hasTypedDocs = Object.keys(byType).length > 0;
249
+ const typeLabels = { plan: 'PLANS', doc: 'DOCS', research: 'RESEARCH' };
250
+
251
+ if (hasTypedDocs) {
252
+ for (const typeName of typeOrder) {
253
+ const docs = byType[typeName];
254
+ if (!docs?.length) continue;
255
+ const typeCtx = config.typeContextConfig?.get(typeName) ?? config.context;
256
+ lines.push(bold(typeLabels[typeName] ?? typeName.toUpperCase()));
257
+ _renderContextSection(docs, typeCtx, opts, config, lines);
258
+ lines.push('');
259
+ }
260
+ // Any types not in typeOrder
261
+ for (const typeName of Object.keys(byType)) {
262
+ if (typeOrder.includes(typeName)) continue;
263
+ const docs = byType[typeName];
264
+ if (!docs?.length) continue;
265
+ const typeCtx = config.typeContextConfig?.get(typeName) ?? config.context;
266
+ lines.push(bold(typeName.toUpperCase()));
267
+ _renderContextSection(docs, typeCtx, opts, config, lines);
268
+ lines.push('');
269
+ }
270
+ }
271
+
272
+ // Render untyped docs (backward compat) or all docs if no types present
273
+ if (untyped.length > 0) {
274
+ if (hasTypedDocs) lines.push(bold('OTHER'));
275
+ _renderContextSection(untyped, config.context, opts, config, lines);
276
+ lines.push('');
277
+ } else if (!hasTypedDocs) {
278
+ // No types at all — fall back to original flat rendering
279
+ _renderContextSection(index.docs, config.context, opts, config, lines);
280
+ lines.push('');
281
+ }
282
+
283
+ const stale = index.docs.filter(d => d.isStale && !config.lifecycle.skipStaleFor.has(d.status));
284
+ if (stale.length) {
285
+ const cap = config.context?.staleTailLimit ?? 8;
286
+ const shown = stale.slice(0, cap).map(d => `${toSlug(d)} (${d.daysSinceUpdate}d)`).join(', ');
287
+ const overflow = stale.length - cap;
288
+ const tail = overflow > 0
289
+ ? `${shown}, …and ${overflow} more (run \`runlist stale\` for the full list)`
290
+ : shown;
291
+ lines.push(`Stale: ${tail}`);
292
+ } else {
293
+ lines.push('Stale: none');
294
+ }
295
+
296
+ const withErrors = index.docs.filter(d => d.errors.length > 0 && !config.lifecycle.skipsWarnings(d.status, d.type));
297
+ const withWarnings = index.docs.filter(d => d.warnings.length > 0 && !config.lifecycle.skipsWarnings(d.status, d.type));
298
+ if (withErrors.length || withWarnings.length) {
299
+ const parts = [];
300
+ if (withErrors.length) parts.push(`${withErrors.length} with errors`);
301
+ if (withWarnings.length) parts.push(`${withWarnings.length} with warnings`);
302
+ lines.push(`Non-compliant: ${parts.join(', ')} (run \`runlist check\` for details)`);
303
+ }
304
+
305
+ const ctx = config.context;
306
+ const recentStatuses = new Set(ctx.recentStatuses || ['active', 'ready', 'planned']);
307
+ const recentDays = ctx.recentDays ?? 3;
308
+ const recentLimit = ctx.recentLimit ?? 10;
309
+ const recent = index.docs
310
+ .filter(d => d.daysSinceUpdate != null && d.daysSinceUpdate <= recentDays && recentStatuses.has(d.status))
311
+ .sort((a, b) => (a.daysSinceUpdate ?? 99) - (b.daysSinceUpdate ?? 99))
312
+ .slice(0, recentLimit);
313
+ if (recent.length) {
314
+ const items = recent.map(d => {
315
+ const label = d.daysSinceUpdate === 0 ? 'today' : `${d.daysSinceUpdate}d ago`;
316
+ return `${toSlug(d)} (${label})`;
317
+ });
318
+ lines.push(`Recently updated: ${items.join(', ')}`);
319
+ }
320
+
321
+ return `${lines.join('\n').trimEnd()}\n`;
322
+ }
323
+
324
+ export function renderBriefing(index, config) {
325
+ const lines = [];
326
+
327
+ const plans = index.docs.filter(d => d.type === 'plan');
328
+ const docs = index.docs.filter(d => d.type === 'doc');
329
+ const research = index.docs.filter(d => d.type === 'research');
330
+ const untyped = index.docs.filter(d => !d.type);
331
+
332
+ // Coordination hubs (prose-first runlists) are navigation maps, not units of
333
+ // work — so lift them out of the live-plan status breakdown into their own
334
+ // `runlists` bucket and drop them from the actionable `>` list, mirroring
335
+ // `runlist plans` / `runlist runlists`. (Sprint `runlist:` hubs stay as ordinary
336
+ // plans here; their folding treatment is scoped to `runlist plans`.) On a repo
337
+ // with no coordination hubs this is a no-op and the output is unchanged.
338
+ const coordination = buildCoordinationIndex(index, config);
339
+ const isHub = (p) => coordination.has(p.path);
340
+
341
+ if (plans.length) {
342
+ // Headline counts LIVE plans first — "30 plans: 25 archived, …" skims as
343
+ // 30 open work items when zero are. "Live" mirrors the `runlist plans`
344
+ // filter: not in an archive/terminal status and not filed under archived/.
345
+ const closed = new Set([
346
+ ...(config.lifecycle?.archiveStatuses ?? []),
347
+ ...(config.lifecycle?.terminalStatuses ?? []),
348
+ ]);
349
+ const live = plans.filter(p => !closed.has(p.status) && !isArchivedPath(p.path, config));
350
+ const liveHubs = live.filter(isHub);
351
+ // Roadmaps (tier-3) are held out like runlists but pointed at separately.
352
+ const liveRoadmaps = live.filter(p => isRoadmapHub(p));
353
+ const liveRunlists = liveHubs.length - liveRoadmaps.length;
354
+ const liveLeaves = live.length - liveHubs.length;
355
+ const bySt = {};
356
+ for (const p of live) { if (isHub(p)) continue; bySt[p.status] = (bySt[p.status] ?? 0) + 1; }
357
+ // Coordination hubs are navigation maps, not units of work — held OUT of the
358
+ // headline plan count entirely (they get their own `N runlists · runlist
359
+ // runlists` pointer line below). The breakdown is leaf statuses only and sums
360
+ // back to the leaf count, so "N live plans" means N things to actually work on.
361
+ const planStatusOrder = (resolveStatusMetadata(config).byType.plan ?? []).map(item => item.name);
362
+ const orderedStatuses = [
363
+ ...planStatusOrder.filter(status => bySt[status]),
364
+ ...Object.keys(bySt).filter(status => !planStatusOrder.includes(status)).sort(),
365
+ ];
366
+ const counts = orderedStatuses.map(status => `${bySt[status]} ${status}`).join(', ');
367
+ const closedCount = plans.length - live.length;
368
+ const closedPart = closedCount ? ` (${closedCount} archived)` : '';
369
+ lines.push(liveLeaves ? `${liveLeaves} live plans${closedPart}: ${counts}` : `0 live plans${closedPart}`);
370
+ const show = plans.filter(p => statusMetadataFor(config, 'plan', p.status)?.context === 'expanded' && !isHub(p));
371
+ for (const p of show) {
372
+ const next = p.nextStep ? `next: ${p.nextStep}` : '(no next step)';
373
+ lines.push(` > ${path.basename(p.path, '.md')} (${p.status}) ${next}`);
374
+ }
375
+ if (liveRoadmaps.length) {
376
+ lines.push(` ${liveRoadmaps.length} roadmap${liveRoadmaps.length === 1 ? '' : 's'} ${dim('· runlist roadmaps')}`);
377
+ }
378
+ if (liveRunlists) {
379
+ lines.push(` ${liveRunlists} runlist${liveRunlists === 1 ? '' : 's'} ${dim('· runlist runlists')}`);
380
+ }
381
+ }
382
+
383
+ const parts = [];
384
+ if (docs.length) {
385
+ const active = docs.filter(d => !config.lifecycle.terminalStatuses.has(d.status)).length;
386
+ const rest = docs.length - active;
387
+ parts.push(`${active} docs active` + (rest ? `, ${rest} other` : ''));
388
+ }
389
+ if (research.length) {
390
+ const active = research.filter(d => d.status === 'active').length;
391
+ parts.push(`${active} research active`);
392
+ }
393
+ if (untyped.length) parts.push(`${untyped.length} untyped`);
394
+ if (parts.length) lines.push(parts.join(' | '));
395
+
396
+ const stale = index.docs.filter(d => d.isStale && !statusMetadataFor(config, d.type, d.status)?.skipStale).length;
397
+ // Append a hint when errors are present — otherwise the user sees `Errors: 1`
398
+ // with no clue what or where. `runlist check` is the canonical detail view.
399
+ const errorCount = index.errors.length;
400
+ const errorPart = errorCount > 0
401
+ ? `Errors: ${errorCount} ${dim('(run `runlist check` to see)')}`
402
+ : `Errors: ${errorCount}`;
403
+ lines.push(`Stale: ${stale} | ${errorPart} | Warnings: ${index.warnings.length}`);
404
+
405
+ return lines.join('\n') + '\n';
406
+ }
407
+
408
+ export function renderCheck(index, config, opts = {}) {
409
+ const defaultRenderer = (idx) => _renderCheck(idx, config, opts);
410
+ if (!config._execution?.suppressSideEffects && config.hooks.renderCheck) {
411
+ try { return config.hooks.renderCheck(index, defaultRenderer); }
412
+ catch (err) { warn(`Hook 'renderCheck' threw: ${err.message}`); }
413
+ }
414
+ return defaultRenderer(index);
415
+ }
416
+
417
+ export function classifyIssueAction(issue) {
418
+ const message = issue?.message ?? '';
419
+ const file = issue?.path ?? '<file>';
420
+
421
+ // Hub status rows carry a `meta.kind`, so classify them by that rather than by
422
+ // re-matching their prose. Drift is mechanically fixable; an unreadable cell
423
+ // is not — nothing can guess which word in it was meant to be the status.
424
+ if (issue?.meta?.kind === 'hub-status-drift') {
425
+ return { action: 'runlist sync-status', fixable: true, label: 'hub status drift' };
426
+ }
427
+ if (issue?.meta?.kind === 'hub-status-unreadable') {
428
+ return { action: `edit ${file}: lead the status cell with the status word, or wrap it in <!--s-->…<!--/s-->`, fixable: false, label: 'hub status rows' };
429
+ }
430
+ if (issue?.meta?.kind === 'hub-membership-backref') {
431
+ return { action: 'runlist fix-membership', fixable: true, label: 'membership back-references' };
432
+ }
433
+ if (issue?.meta?.kind === 'hub-membership-orphan') {
434
+ return { action: `edit ${file}: add the child to the hub's meaningful list, or correct the child's parent_plan`, fixable: false, label: 'orphan membership claims' };
435
+ }
436
+ if (issue?.meta?.kind === 'hub-membership-conflict') {
437
+ return { action: `edit ${file}: reconcile its existing parent_plan with ${issue.meta.hub}`, fixable: false, label: 'membership parent conflicts' };
438
+ }
439
+
440
+ if (/Missing frontmatter `status`/.test(message)) {
441
+ return { action: `runlist bulk-tag ${file}`, fixable: false, label: 'missing status' };
442
+ }
443
+ if (/Missing frontmatter `updated`/.test(message) || /frontmatter `updated: .*` is behind git history/.test(message)) {
444
+ return { action: 'runlist touch --git', fixable: true, label: 'dates' };
445
+ }
446
+ if ((/`(?:module|surface):` \(singular\) is deprecated/.test(message) && /Run `runlist lint --fix`/.test(message))
447
+ || /camelCase|nextStep|currentState|auditLevel/.test(message)) {
448
+ return { action: 'runlist lint --fix', fixable: true, label: 'frontmatter migrations' };
449
+ }
450
+ if (/Unknown surface/.test(message)) {
451
+ return { action: 'runlist surfaces', fixable: false, label: 'taxonomy' };
452
+ }
453
+ if (/Glossary config points at section|Glossary file configured/.test(message)) {
454
+ return { action: 'edit runlist.config.mjs glossary.path/glossary.section', fixable: false, label: 'glossary config' };
455
+ }
456
+ if (/under `.*\/` but `status: .*` is not an archive status/.test(message)) {
457
+ return { action: message.match(/Run `([^`]+)`/)?.[1] ?? `runlist set archived ${file}`, fixable: true, label: 'archive drift' };
458
+ }
459
+ if (/`status: .*` but file is a direct child/.test(message)) {
460
+ return { action: `runlist archive ${file}`, fixable: true, label: 'archive drift' };
461
+ }
462
+ if (/`current_state` is \d+ chars|`next_step` is \d+ chars/.test(message)) {
463
+ return { action: 'runlist doctor --frontmatter-fix', fixable: true, label: 'long frontmatter' };
464
+ }
465
+ if (/`modules` is required/.test(message)) {
466
+ return { action: `edit ${file} modules: or run runlist lint --fix if scaffolded empty`, fixable: false, label: 'module metadata' };
467
+ }
468
+ if (issue?.meta?.kind === 'body-link-resolution') {
469
+ if (issue.meta.targetKind === 'document' && issue.meta.reason === 'missing') {
470
+ return { action: 'runlist fix-refs --dry-run', fixable: true, label: 'document links' };
471
+ }
472
+ return { action: `edit ${file}: correct the linked file, asset, or directory`, fixable: false, label: 'file links' };
473
+ }
474
+ if (/entry `.*` does not resolve/.test(message)) {
475
+ return { action: 'runlist fix-refs --dry-run', fixable: true, label: 'references' };
476
+ }
477
+
478
+ return { action: `edit ${file}`, fixable: false, label: 'manual review' };
479
+ }
480
+
481
+ export function renderManualFixes(index) {
482
+ const issues = [...(index.errors ?? []), ...(index.warnings ?? [])];
483
+ const groups = new Map();
484
+ for (const issue of issues) {
485
+ const item = classifyIssueAction(issue);
486
+ const key = `${item.fixable ? 'fixable' : 'manual'}:${item.action}`;
487
+ if (!groups.has(key)) groups.set(key, { ...item, paths: new Set(), reasons: new Set() });
488
+ groups.get(key).paths.add(issue.path);
489
+ groups.get(key).reasons.add(issue.message);
490
+ }
491
+ if (groups.size === 0) return '';
492
+
493
+ const manual = [...groups.values()].filter(g => !g.fixable);
494
+ const fixable = [...groups.values()].filter(g => g.fixable);
495
+ const lines = [];
496
+
497
+ if (fixable.length > 0) {
498
+ lines.push('Fixable actions');
499
+ for (const group of fixable) {
500
+ const count = group.paths.size;
501
+ lines.push(`- ${group.action} (${count} ${count === 1 ? 'file' : 'files'}: ${[...group.paths].slice(0, 3).join(', ')}${count > 3 ? ', ...' : ''})`);
502
+ }
503
+ lines.push('');
504
+ }
505
+
506
+ if (manual.length > 0) {
507
+ lines.push('Manual fixes remaining');
508
+ for (const group of manual) {
509
+ const count = group.paths.size;
510
+ const reasons = [...group.reasons];
511
+ const reason = reasons.slice(0, 2).join('; ') + (reasons.length > 2 ? '; ...' : '');
512
+ lines.push(`- ${group.action}: ${reason} (${count} ${count === 1 ? 'file' : 'files'}: ${[...group.paths].slice(0, 3).join(', ')}${count > 3 ? ', ...' : ''})`);
513
+ }
514
+ lines.push('');
515
+ }
516
+
517
+ return lines.join('\n');
518
+ }
519
+
520
+ export function buildReferenceValidationCoverage(index, config) {
521
+ return { checkedDocs: index.docs.length, terminalDocsSkipped: 0 };
522
+ }
523
+
524
+ function _renderCheck(index, config, opts = {}) {
525
+ const { errorsOnly, noCollapse, verbose } = opts;
526
+ const referenceValidation = buildReferenceValidationCoverage(index, config);
527
+ const lines = ['Check', ''];
528
+ lines.push(`- docs scanned: ${index.docs.length}`);
529
+ const stages = index.docs.filter(doc=>doc.type==='plan').map(doc=>readPlanStage(doc.ships));
530
+ if(stages.length)lines.push(`- plan stages: ${stages.filter(stage=>stage.word).length} set; ${stages.filter(stage=>!stage.word&&!stage.invalid).length} Unset; ${stages.filter(stage=>stage.invalid).length} invalid`);
531
+ const filing = scopeFilingCoverage(index.filingCoverage, index.docs);
532
+ if (filing && !errorsOnly) lines.push(`- plan filing: ${filing.totals.unfiled} unfiled; ${filing.totals.multiplyFiled} multiply filed; ${filing.totals.noCategory} under no category; ${filing.totals.filedThroughParent} through parent${filing.complete ? '' : ' (incomplete scan)'}`);
533
+ lines.push(`- errors: ${index.errors.length}`);
534
+ lines.push(`- warnings: ${index.warnings.length}`);
535
+ lines.push(`- reference validation: ${referenceValidation.checkedDocs} docs checked; ${referenceValidation.terminalDocsSkipped} terminal docs skipped`);
536
+ lines.push('');
537
+
538
+ if (index.errors.length > 0) {
539
+ lines.push(red('Errors'));
540
+ for (const issue of index.errors) {
541
+ // Repo-level findings (e.g. the scan floor) carry no path.
542
+ lines.push(issue.path ? `- ${issue.path}: ${issue.message}` : `- ${issue.message}`);
543
+ }
544
+ lines.push('');
545
+ const actions = renderManualFixes({ errors: index.errors, warnings: [] }).trimEnd();
546
+ if (actions) {
547
+ lines.push(actions);
548
+ }
549
+ }
550
+
551
+ // Warnings: terse by default — print count + pointer. The full per-doc list
552
+ // grew long enough on real projects that it drowned the summary; agents now
553
+ // see warnings as a single line and opt in to detail when they need it.
554
+ // --verbose / --no-collapse expand to the full list (with collapse-by-category
555
+ // still applied unless --no-collapse is set).
556
+ if (!errorsOnly && index.warnings.length > 0) {
557
+ if (verbose || noCollapse) {
558
+ lines.push(yellow('Warnings'));
559
+ if (noCollapse) {
560
+ for (const issue of index.warnings) {
561
+ lines.push(`- ${issue.path}: ${issue.message}`);
562
+ }
563
+ } else {
564
+ const { passthrough, collapsed } = categorizeWarnings(index.warnings);
565
+ for (const issue of passthrough) {
566
+ lines.push(`- ${issue.path}: ${issue.message}`);
567
+ }
568
+ for (const sum of collapsed) {
569
+ lines.push(`- ${sum.count} ${sum.label} — run \`${sum.fix}\` to bulk-fix`);
570
+ }
571
+ }
572
+ lines.push('');
573
+ } else {
574
+ lines.push(dim(`Run \`runlist check --verbose\` for per-doc detail. \`runlist doctor --apply\` auto-fixes supported issues (bare \`runlist doctor\` previews only); remaining issues need the suggested manual command.`));
575
+ lines.push('');
576
+ }
577
+ }
578
+
579
+ if (index.errors.length === 0 && index.warnings.length === 0) {
580
+ lines.push(green('No issues found.'));
581
+ } else if (index.errors.length === 0) {
582
+ lines.push(green('Check passed with warnings.'));
583
+ } else {
584
+ lines.push(red('Check failed.'));
585
+ }
586
+
587
+ return `${lines.join('\n').trimEnd()}\n`;
588
+ }
589
+
590
+ export function renderCoverage(index, config) {
591
+ const coverage = buildCoverage(index, config);
592
+ const lines = ['Coverage', ''];
593
+ lines.push(`- scoped docs: ${coverage.totals.scopedDocs}`);
594
+ lines.push(`- missing surface: ${coverage.totals.missingSurface}`);
595
+ lines.push(`- missing module: ${coverage.totals.missingModule}`);
596
+ lines.push(`- module:platform: ${coverage.totals.modulePlatform}`);
597
+ lines.push(`- module:none: ${coverage.totals.moduleNone}`);
598
+ lines.push(`- audit_level:none: ${coverage.totals.auditLevelNone}`);
599
+ lines.push(`- audited (pass1/pass2/deep): ${coverage.totals.audited}`);
600
+ if (coverage.filing) {
601
+ const filing = coverage.filing;
602
+ lines.push(`- plan filing: ${filing.totals.filedDirect} direct, ${filing.totals.filedThroughParent} through parent, ${filing.totals.unfiled} unfiled, ${filing.totals.multiplyFiled} multiply filed, ${filing.totals.noCategory} under no category${filing.complete ? '' : ' (incomplete scan)'}`);
603
+ lines.push('');
604
+ lines.push('Plan homes');
605
+ for (const plan of filing.plans) {
606
+ const via = plan.filing === 'parent' ? ` through parent ${plan.filedThrough}` : '';
607
+ const homes = plan.rows.map(row => `${row.hub}:${row.line} [${row.categories.join(', ') || 'no category'}]`).join('; ');
608
+ lines.push(`- ${plan.path}: ${plan.filing}${via}${homes ? ` — ${homes}` : ''}`);
609
+ }
610
+ }
611
+ lines.push('');
612
+
613
+ for (const [label, list] of [['Missing surface', coverage.missingSurface], ['Missing module', coverage.missingModule], ['module:platform', coverage.modulePlatform], ['module:none', coverage.moduleNone], ['audit_level:none', coverage.auditLevelNone]]) {
614
+ if (list.length) {
615
+ lines.push(label);
616
+ for (const doc of list) lines.push(`- ${doc.path}`);
617
+ lines.push('');
618
+ }
619
+ }
620
+
621
+ return `${lines.join('\n').trimEnd()}\n`;
622
+ }
623
+
624
+ export function buildCoverage(index, config) {
625
+ const scope = [...new Set(index.docs.map(d => d.status).filter(s => s && !config.lifecycle.terminalStatuses.has(s)))];
626
+ const scoped = index.docs.filter(doc => doc.status && !config.lifecycle.terminalStatuses.has(doc.status));
627
+ const missingSurface = scoped.filter(doc => !doc.surfaces?.length);
628
+ const missingModule = scoped.filter(doc => !doc.modules?.length);
629
+ const modulePlatform = scoped.filter(doc => doc.modules?.includes('platform'));
630
+ const moduleNone = scoped.filter(doc => doc.modules?.includes('none'));
631
+ const auditLevelNone = scoped.filter(doc => doc.auditLevel === 'none');
632
+ const audited = scoped.filter(doc => ['pass1', 'pass2', 'deep'].includes(doc.auditLevel));
633
+
634
+ return {
635
+ generatedAt: index.generatedAt, scope,
636
+ filing: scopeFilingCoverage(index.filingCoverage, index.docs),
637
+ totals: { scopedDocs: scoped.length, missingSurface: missingSurface.length, missingModule: missingModule.length, modulePlatform: modulePlatform.length, moduleNone: moduleNone.length, auditLevelNone: auditLevelNone.length, audited: audited.length },
638
+ missingSurface, missingModule, modulePlatform, moduleNone, auditLevelNone, audited,
639
+ };
640
+ }
641
+
642
+ export function renderProgressBar(checklist) {
643
+ if (!checklist?.total) return '';
644
+ const ratio = checklist.completed / checklist.total;
645
+ const filled = Math.round(ratio * 10);
646
+ const bar = '█'.repeat(filled) + '░'.repeat(10 - filled);
647
+ return `${bar} ${checklist.completed}/${checklist.total}`;
648
+ }
649
+
650
+ export function formatSnapshot(doc, config) {
651
+ const defaultFormatter = (d) => _formatSnapshot(d, config);
652
+ if (!config._execution?.suppressSideEffects && config.hooks.formatSnapshot) {
653
+ try { return config.hooks.formatSnapshot(doc, defaultFormatter); }
654
+ catch (err) { warn(`Hook 'formatSnapshot' threw: ${err.message}`); }
655
+ }
656
+ return defaultFormatter(doc);
657
+ }
658
+
659
+ function _formatSnapshot(doc, config) {
660
+ // For terminal statuses (archived, reference, deprecated, etc.) a missing
661
+ // `current_state` is fine — these are settled docs, no one's expected to be
662
+ // tracking what they're "currently doing." Pre-fix, the bare-status fallback
663
+ // rendered as `Reference: No current_state set` everywhere, which looked
664
+ // like a noisy hint to add a field that the templates never scaffolded.
665
+ // Now: bare-status line when terminal AND no current_state.
666
+ // Paired with src/index.mjs's body-scrape suppression for terminal docs —
667
+ // both layers drop body-derived state when frontmatter is silent.
668
+ const terminal = config?.lifecycle?.terminalStatuses;
669
+ const isTerminal = doc.status && terminal && terminal.has(doc.status);
670
+ if (isTerminal && !doc.currentState) {
671
+ return capitalize(doc.status);
672
+ }
673
+ const state = formatCurrentState(doc) ?? 'No current_state set';
674
+ if (/^active:|^ready:|^planned:|^scoping:|^blocked:|^archived:/i.test(state)) {
675
+ return state;
676
+ }
677
+ return `${capitalize(doc.status ?? 'unknown')}: ${state}`;
678
+ }