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/query.mjs ADDED
@@ -0,0 +1,977 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { getStageDefinitions, readPlanStage } from './stages.mjs';
3
+ import path from 'node:path';
4
+ import { capitalize, toSlug, truncate, warn, die, suggestCandidates, isArchivedPath } from './util.mjs';
5
+ import { renderProgressBar, formatCurrentState } from './render.mjs';
6
+ import { computeDaysSinceUpdate, computeIsStale } from './validate.mjs';
7
+ import { getGitLastModifiedBatch } from './git.mjs';
8
+ import { extractFrontmatter } from './frontmatter.mjs';
9
+ import { summarizeDocBody } from './ai.mjs';
10
+ import { bold, dim, yellow, red, green, blue, magenta, cyan, brightYellow } from './color.mjs';
11
+ import { buildRunlistIndex, buildCoordinationIndex, buildRoadmapIndex, isRoadmapHub, hubLabel } from './runlist.mjs';
12
+
13
+ const STATUS_COLORS = {
14
+ 'in-session': (s) => bold(red(s)),
15
+ 'active': green,
16
+ 'planned': blue,
17
+ 'blocked': yellow,
18
+ 'partial': (s) => dim(green(s)),
19
+ 'paused': magenta,
20
+ 'awaiting': brightYellow,
21
+ 'queued-after': (s) => dim(cyan(s)),
22
+ 'archived': dim,
23
+ 'pending': bold,
24
+ 'claimed': dim,
25
+ };
26
+
27
+ function colorTag(status) {
28
+ const tag = `[${(status ?? 'unknown').toUpperCase()}]`;
29
+ const fn = STATUS_COLORS[status] ?? dim;
30
+ return fn(tag);
31
+ }
32
+
33
+ // Strip ANSI for length math
34
+ function visibleLen(s) { return s.replace(/\x1b\[[0-9;]*m/g, '').length; }
35
+
36
+ export function runFocus(index, argv, config) {
37
+ // Find first positional arg, skipping flag-value pairs like --root <name>
38
+ const FLAGS_WITH_VALUES = new Set(['--root']);
39
+ let statusFilter = 'active';
40
+ for (let i = 0; i < argv.length; i++) {
41
+ if (FLAGS_WITH_VALUES.has(argv[i])) { i++; continue; }
42
+ if (argv[i].startsWith('-')) continue;
43
+ statusFilter = argv[i];
44
+ break;
45
+ }
46
+
47
+ const docs = index.docs.filter(doc => doc.status === statusFilter);
48
+
49
+ if (argv.includes('--json')) {
50
+ process.stdout.write(JSON.stringify({ status: statusFilter, count: docs.length, docs }, null, 2) + '\n');
51
+ return;
52
+ }
53
+
54
+ if (docs.length === 0) {
55
+ process.stdout.write(`No docs found for status: ${statusFilter}\n`);
56
+ return;
57
+ }
58
+
59
+ process.stdout.write(`${statusFilter.toUpperCase()} Focus\n\n`);
60
+
61
+ for (const doc of docs) {
62
+ process.stdout.write(`- ${doc.title}\n`);
63
+ process.stdout.write(` path: ${doc.path}\n`);
64
+ const stateValue = formatCurrentState(doc);
65
+ if (stateValue) process.stdout.write(` state: ${stateValue}\n`);
66
+ if (doc.nextStep) {
67
+ process.stdout.write(` next: ${doc.nextStep}\n`);
68
+ }
69
+ if (doc.blockers?.length) {
70
+ process.stdout.write(` blockers: ${doc.blockers.join('; ')}\n`);
71
+ }
72
+ if (doc.checklist?.total) {
73
+ process.stdout.write(` checklist: ${doc.checklist.completed}/${doc.checklist.total} complete\n`);
74
+ }
75
+ process.stdout.write('\n');
76
+ }
77
+ }
78
+
79
+ export function runQuery(index, argv, config, opts = {}) {
80
+ const filters = parseQueryArgs(argv);
81
+ // Global --type/--root are stripped by the dispatcher and applied to the
82
+ // index before it reaches here; reflect them in the filter echo so JSON
83
+ // metadata isn't reported as unfiltered when the result set is narrowed.
84
+ if (opts.type && !filters.types) filters.types = opts.type.split(',').map(v => v.trim()).filter(Boolean);
85
+ if (opts.root && !filters.root) filters.root = opts.root;
86
+ if (filters.body && !filters.keyword) {
87
+ die('`--body` extends a keyword search into document bodies — pass `--keyword <term>` (or use `runlist grep <term>`).');
88
+ }
89
+ const docs = filterDocs(index.docs, filters, config, opts.gitMetadataOptions);
90
+ const summaryPreviewSkipped = filters.summarize && config._execution?.suppressSideEffects;
91
+
92
+ if (filters.json) {
93
+ if (filters.summarize && !summaryPreviewSkipped) {
94
+ for (let i = 0; i < docs.length && i < filters.summarizeLimit; i++) {
95
+ docs[i].aiSummary = getDocSummary(docs[i], config);
96
+ }
97
+ }
98
+ process.stdout.write(`${JSON.stringify({
99
+ filters,
100
+ count: docs.length,
101
+ docs,
102
+ ...(filters.group === 'stage' ? { groups: groupDocsByStage(docs, config) } : {}),
103
+ ...(summaryPreviewSkipped ? { summaryPreview: { status: 'skipped-preview', reason: 'side-effect-free preview' } } : {}),
104
+ }, null, 2)}\n`);
105
+ return;
106
+ }
107
+
108
+ if (opts.preset === 'plans' || opts.preset === 'prompts') {
109
+ // Runlist folding only applies to plans (prompts have no runlists).
110
+ const runlist = opts.preset === 'plans' ? buildRunlistIndex(index, config) : null;
111
+ const coordination = opts.preset === 'plans' ? buildCoordinationIndex(index, config) : null;
112
+ const roadmap = opts.preset === 'plans' ? buildRoadmapIndex(index, config, { coordination, runlist }) : null;
113
+ renderPlansOutput(docs, filters, config, { noun: opts.preset, runlist, coordination, roadmap });
114
+ if (docs.length === 0) writeUnknownFilterValueHint(filters, index);
115
+ return;
116
+ }
117
+
118
+ renderQueryResults(docs, filters, config);
119
+ if (docs.length === 0) writeUnknownFilterValueHint(filters, index);
120
+ }
121
+
122
+ // Sort comparator for the runlists dashboard. Default `age` puts the MOST STALE
123
+ // hub first — a triage lens (which nav-map has gone longest untouched?), echoing
124
+ // `runlist modules --sort cleanup`. `recent` is the old newest-first order.
125
+ // Unknown-age hubs sort last in both directions so they never dominate.
126
+ const RUNLIST_SORTS = new Set(['age', 'recent', 'related', 'title', 'status']);
127
+ function runlistSorter(sort, coordination, config) {
128
+ const cmpAge = (a, b, dir) => {
129
+ const av = a.daysSinceUpdate, bv = b.daysSinceUpdate;
130
+ if (av == null && bv == null) return 0;
131
+ if (av == null) return 1;
132
+ if (bv == null) return -1;
133
+ return dir * (av - bv);
134
+ };
135
+ const related = (d) => coordination.get(d.path)?.childCount ?? 0;
136
+ const byLabel = (a, b) => hubLabel(a).localeCompare(hubLabel(b));
137
+ if (sort === 'recent') return (a, b) => cmpAge(a, b, 1) || byLabel(a, b);
138
+ if (sort === 'related') return (a, b) => related(b) - related(a) || cmpAge(a, b, -1) || byLabel(a, b);
139
+ if (sort === 'title') return byLabel;
140
+ if (sort === 'status') {
141
+ return (a, b) => {
142
+ const ai = config.statusOrder.indexOf(a.status), bi = config.statusOrder.indexOf(b.status);
143
+ const aIdx = ai === -1 ? Number.MAX_SAFE_INTEGER : ai, bIdx = bi === -1 ? Number.MAX_SAFE_INTEGER : bi;
144
+ return aIdx - bIdx || cmpAge(a, b, -1) || byLabel(a, b);
145
+ };
146
+ }
147
+ return (a, b) => cmpAge(a, b, -1) || byLabel(a, b); // 'age' (default): most stale first
148
+ }
149
+
150
+ // `runlist runlists` — the dedicated coordination-hub dashboard: the `Runlists`
151
+ // section from `runlist plans`, on its own, showing every hub (no leaf list, no
152
+ // cap by default — runlists are a small bounded set). `--limit N` caps it,
153
+ // `--sort age|recent|related|title|status` orders it (default `age`, most stale
154
+ // first), `--json` emits structured rows.
155
+ export function runRunlists(index, argv, config) {
156
+ const json = argv.includes('--json');
157
+ let limit = Infinity;
158
+ const li = argv.indexOf('--limit');
159
+ if (li >= 0 && argv[li + 1]) { const n = Number.parseInt(argv[li + 1], 10); if (Number.isFinite(n)) limit = n; }
160
+ const si = argv.indexOf('--sort');
161
+ const sortArg = si >= 0 && argv[si + 1] ? argv[si + 1] : 'age';
162
+ if (!RUNLIST_SORTS.has(sortArg)) die(`Unknown --sort '${sortArg}'. Use one of: ${[...RUNLIST_SORTS].join(', ')}.`);
163
+
164
+ const coordination = buildCoordinationIndex(index, config);
165
+ const archived = new Set([
166
+ ...(config.lifecycle?.archiveStatuses ?? []),
167
+ ...(config.lifecycle?.terminalStatuses ?? []),
168
+ ]);
169
+ // Roadmaps live in `coordination` (held-out-hub plumbing) but are a tier above
170
+ // runlists — exclude them here; they get their own `runlist roadmaps` dashboard.
171
+ const hubs = index.docs
172
+ .filter(d => coordination.has(d.path) && !isRoadmapHub(d) && !archived.has(d.status) && !isArchivedPath(d.path, config))
173
+ .sort(runlistSorter(sortArg, coordination, config));
174
+ const roadmapCount = index.docs
175
+ .filter(d => isRoadmapHub(d) && !archived.has(d.status) && !isArchivedPath(d.path, config)).length;
176
+
177
+ if (json) {
178
+ const runlists = hubs.map(d => {
179
+ const info = coordination.get(d.path);
180
+ return {
181
+ path: d.path,
182
+ status: d.status,
183
+ title: d.title,
184
+ childCount: info?.childCount ?? 0,
185
+ total: info?.total ?? 0,
186
+ doneCount: info?.doneCount ?? 0,
187
+ parkedCount: info?.parkedCount ?? 0,
188
+ nextPickup: info?.nextPickup ?? null,
189
+ updated: d.updated,
190
+ nextStep: d.nextStep ?? null,
191
+ };
192
+ });
193
+ process.stdout.write(JSON.stringify({ count: runlists.length, runlists }, null, 2) + '\n');
194
+ return;
195
+ }
196
+
197
+ const roadmapPointer = roadmapCount > 0
198
+ ? dim(` ${roadmapCount} roadmap${roadmapCount === 1 ? '' : 's'} · runlist roadmaps\n`)
199
+ : '';
200
+
201
+ if (hubs.length === 0) {
202
+ process.stdout.write('No runlists found. A runlist is a plan with `execution_mode: coordination` (or a `*-runlist` slug).\n');
203
+ if (roadmapPointer) process.stdout.write(roadmapPointer);
204
+ return;
205
+ }
206
+
207
+ const maxWidth = process.stdout.columns || 100;
208
+ const shown = hubs.slice(0, limit);
209
+ renderCoordinationSection(shown, coordination, maxWidth, hubs.length);
210
+ const hidden = hubs.length - shown.length;
211
+ if (hidden > 0) process.stdout.write(dim(` ${hidden} more · runlist runlists --limit ${hubs.length}\n`));
212
+ if (roadmapPointer) process.stdout.write(roadmapPointer);
213
+ process.stdout.write('\n');
214
+ }
215
+
216
+ // When a query returns nothing AND a value-shaped filter (currently --module)
217
+ // names a value that doesn't exist anywhere in the index, the empty result is
218
+ // almost certainly a typo rather than a combination miss. Surface a hint so
219
+ // the agent doesn't have to grep modules to discover the right spelling.
220
+ function writeUnknownFilterValueHint(filters, index) {
221
+ if (filters.module) {
222
+ const allModules = new Set();
223
+ for (const d of index.docs) {
224
+ for (const m of (d.modules ?? [])) allModules.add(m);
225
+ }
226
+ const exists = [...allModules].some(m => m.toLowerCase() === filters.module.toLowerCase());
227
+ if (!exists) {
228
+ const suggestions = suggestCandidates(filters.module, [...allModules]);
229
+ const hint = suggestions.length ? ` Did you mean: ${suggestions.join(', ')}?` : '';
230
+ process.stdout.write(dim(`No module \`${filters.module}\` in index.${hint}`) + '\n');
231
+ }
232
+ }
233
+ }
234
+
235
+ export function parseQueryArgs(argv) {
236
+ const filters = {
237
+ types: null, statuses: null, keyword: null, body: false, owner: null, surface: null,
238
+ module: null, domain: null, audience: null, executionMode: null, stage: null,
239
+ updatedSince: null, limit: 20, all: false, sort: 'updated',
240
+ group: null,
241
+ stale: false, hasNextStep: false, hasBlockers: false,
242
+ checklistOpen: false, json: false, git: false,
243
+ summarize: false, summarizeLimit: 5, model: undefined,
244
+ positionalTerms: [],
245
+ };
246
+
247
+ for (let i = 0; i < argv.length; i += 1) {
248
+ const arg = argv[i];
249
+ const next = argv[i + 1];
250
+
251
+ if (arg === '--type' && next) { filters.types = next.split(',').map(v => v.trim()).filter(Boolean); i += 1; continue; }
252
+ if (arg === '--status' && next) { filters.statuses = next.split(',').map(v => v.trim()).filter(Boolean); i += 1; continue; }
253
+ if (arg === '--keyword' && next) { filters.keyword = next; i += 1; continue; }
254
+ if (arg === '--body') { filters.body = true; continue; }
255
+ if (arg === '--owner' && next) { filters.owner = next; i += 1; continue; }
256
+ if (arg === '--surface' && next) { filters.surface = next; i += 1; continue; }
257
+ if (arg === '--module' && next) { filters.module = next; i += 1; continue; }
258
+ if (arg === '--domain' && next) { filters.domain = next; i += 1; continue; }
259
+ if (arg === '--audience' && next) { filters.audience = next; i += 1; continue; }
260
+ if (arg === '--execution-mode' && next) { filters.executionMode = next; i += 1; continue; }
261
+ if (arg === '--stage' && next) { filters.stage = next; i += 1; continue; }
262
+ if (arg === '--updated-since' && next) { filters.updatedSince = next; i += 1; continue; }
263
+ if (arg === '--limit' && next) { filters.limit = Number.parseInt(next, 10) || 20; i += 1; continue; }
264
+ if (arg === '--sort' && next) { filters.sort = next; i += 1; continue; }
265
+ if ((arg === '--group' || arg === '--group-by') && next) { filters.group = next; i += 1; continue; }
266
+ if (arg === '--all') { filters.all = true; continue; }
267
+ if (arg === '--include-archived') { filters.includeArchived = true; continue; }
268
+ if (arg === '--exclude-archived') { filters.excludeArchived = true; continue; }
269
+ if (arg === '--stale') { filters.stale = true; continue; }
270
+ if (arg === '--has-next-step') { filters.hasNextStep = true; continue; }
271
+ if (arg === '--has-blockers') { filters.hasBlockers = true; continue; }
272
+ if (arg === '--checklist-open') { filters.checklistOpen = true; continue; }
273
+ if (arg === '--json') { filters.json = true; continue; }
274
+ if (arg === '--git') { filters.git = true; continue; }
275
+ if (arg === '--summarize') { filters.summarize = true; continue; }
276
+ if (arg === '--summarize-limit' && next) { filters.summarizeLimit = Number.parseInt(next, 10) || 5; i += 1; continue; }
277
+ if (arg === '--model' && next) { filters.model = next; i += 1; continue; }
278
+
279
+ // Positional terms: anything else that's not a flag becomes a substring
280
+ // filter token (AND-matched against slug + title). Lets users do:
281
+ // runlist plans rls → matches rls-platform-rows, rls-location-anchored
282
+ // runlist plans pii redesign → AND match: pii-data-model-redesign
283
+ if (typeof arg === 'string' && !arg.startsWith('-')) {
284
+ filters.positionalTerms.push(arg.toLowerCase());
285
+ }
286
+ }
287
+
288
+ if (filters.group && !['module', 'surface', 'owner', 'stage'].includes(filters.group)) {
289
+ die(`Unknown group '${filters.group}'. Use one of: module, surface, owner, stage.`);
290
+ }
291
+ return filters;
292
+ }
293
+
294
+ export function filterDocs(docs, filters, config, gitMetadataOptions) {
295
+ let result = [...docs];
296
+
297
+ if (filters.types?.length) result = result.filter(d => filters.types.includes(d.type));
298
+ if (filters.statuses?.length) result = result.filter(d => filters.statuses.includes(d.status));
299
+ // --exclude-archived strips terminal/archive statuses AND any file physically
300
+ // located under `archiveDir/` (issue #13: status can drift out of sync with
301
+ // the file's directory; the path is the source of truth for "is archived").
302
+ if (filters.excludeArchived && !filters.includeArchived) {
303
+ const archived = new Set([
304
+ ...(config.lifecycle?.archiveStatuses ?? []),
305
+ ...(config.lifecycle?.terminalStatuses ?? []),
306
+ ]);
307
+ result = result.filter(d => !archived.has(d.status) && !isArchivedPath(d.path, config));
308
+ }
309
+
310
+ const keywordNeedle = filters.keyword ? filters.keyword.toLowerCase() : null;
311
+ const matchesFrontmatter = d => [d.title, d.summary, d.currentState, d.nextStep, d.path, ...(d.blockers ?? [])].filter(Boolean).join(' ').toLowerCase().includes(keywordNeedle);
312
+ // With --body the keyword filter is deferred to after the cheap frontmatter
313
+ // filters (below) so body files are only read for surviving candidates.
314
+ if (keywordNeedle && !filters.body) {
315
+ result = result.filter(matchesFrontmatter);
316
+ }
317
+
318
+ // Positional substring filter: AND match against slug + title.
319
+ if (filters.positionalTerms?.length) {
320
+ result = result.filter(d => {
321
+ const haystack = [d.path, d.title].filter(Boolean).join(' ').toLowerCase();
322
+ return filters.positionalTerms.every(term => haystack.includes(term));
323
+ });
324
+ }
325
+
326
+ if (filters.owner) { const n = filters.owner.toLowerCase(); result = result.filter(d => (d.owner ?? '').toLowerCase().includes(n)); }
327
+ if (filters.surface) { const n = filters.surface.toLowerCase(); result = result.filter(d => (d.surfaces ?? []).some(s => s.toLowerCase() === n)); }
328
+ if (filters.module) { const n = filters.module.toLowerCase(); result = result.filter(d => (d.modules ?? []).some(m => m.toLowerCase() === n)); }
329
+ if (filters.domain) { const n = filters.domain.toLowerCase(); result = result.filter(d => (d.domain ?? '').toLowerCase() === n); }
330
+ if (filters.audience) { const n = filters.audience.toLowerCase(); result = result.filter(d => (d.audience ?? '').toLowerCase() === n); }
331
+ if (filters.executionMode) { const n = filters.executionMode.toLowerCase(); result = result.filter(d => (d.executionMode ?? '').toLowerCase() === n); }
332
+ if (filters.stage) {
333
+ const literalStage = filters.stage.startsWith('word:');
334
+ const stageWord = literalStage ? filters.stage.slice(5) : filters.stage;
335
+ const missingStage = !literalStage && (filters.stage === '@unset' || (filters.stage === 'unset' && !getStageDefinitions(config).some(stage => stage.word === 'unset')));
336
+ result = result.filter(d => {
337
+ const stage = readPlanStage(d.ships);
338
+ return missingStage ? stage.word === null && !stage.invalid : stage.word === stageWord;
339
+ });
340
+ }
341
+ if (filters.updatedSince) result = result.filter(d => d.updated && d.updated >= filters.updatedSince);
342
+
343
+ if (filters.git) {
344
+ const gitMetadata = getGitLastModifiedBatch(config.repoRoot, result.map(doc => doc.path), gitMetadataOptions);
345
+ const gitDates = gitMetadata.dates;
346
+ for (const doc of result) {
347
+ const gitDate = gitDates.get(doc.path) ?? null;
348
+ if (gitDate) {
349
+ doc.daysSinceUpdate = computeDaysSinceUpdate(gitDate);
350
+ doc.isStale = computeIsStale(doc.status, gitDate, config, doc.type);
351
+ }
352
+ }
353
+ if (!gitMetadata.complete) {
354
+ warn(`Git metadata is incomplete (${gitMetadata.reason}); query results use known dates only.`);
355
+ }
356
+ }
357
+
358
+ if (filters.stale) result = result.filter(d => d.isStale);
359
+ if (filters.hasNextStep) result = result.filter(d => d.hasNextStep);
360
+ if (filters.hasBlockers) result = result.filter(d => d.hasBlockers);
361
+ if (filters.checklistOpen) result = result.filter(d => (d.checklist?.open ?? 0) > 0);
362
+
363
+ // Lazy body scan: docs already matching on frontmatter fields keep their spot
364
+ // without a file read; only the rest get their bodies scanned. Hits carry
365
+ // 1-2 matching-line excerpts so the caller can rank without opening files.
366
+ if (keywordNeedle && filters.body) {
367
+ result = result.filter(d => {
368
+ if (matchesFrontmatter(d)) return true;
369
+ const matches = scanBodyForKeyword(d, keywordNeedle, config);
370
+ if (!matches.length) return false;
371
+ d.bodyMatches = matches;
372
+ return true;
373
+ });
374
+ }
375
+
376
+ result.sort(buildSorter(filters.sort, config));
377
+ // Stash pre-limit count and per-status breakdown on filters so renderers
378
+ // can show "N more" footers and accurate pipeline summaries even when the
379
+ // returned slice is limited.
380
+ filters._totalBeforeLimit = result.length;
381
+ filters._statusCounts = {};
382
+ for (const d of result) {
383
+ const s = d.status ?? 'unknown';
384
+ filters._statusCounts[s] = (filters._statusCounts[s] ?? 0) + 1;
385
+ }
386
+ // Keep a reference to the full pre-limit set so the plans header can
387
+ // reclassify runlist hubs out of the status breakdown (it needs per-doc
388
+ // identity, not just aggregate counts).
389
+ filters._matched = result;
390
+ return filters.all ? result : result.slice(0, filters.limit);
391
+ }
392
+
393
+ // Read one doc's body and return up to MAX_BODY_MATCHES matching-line
394
+ // excerpts: { line: <1-based file line>, text: <trimmed, windowed around the
395
+ // match> }. Line numbers are file-absolute (frontmatter included) so they can
396
+ // feed straight into a Read offset.
397
+ const MAX_BODY_MATCHES = 2;
398
+ const EXCERPT_WIDTH = 120;
399
+
400
+ function scanBodyForKeyword(doc, needle, config) {
401
+ let raw;
402
+ try {
403
+ raw = readFileSync(path.resolve(config.repoRoot, doc.path), 'utf8');
404
+ } catch (err) {
405
+ warn(`Could not read ${doc.path}: ${err.message}`);
406
+ return [];
407
+ }
408
+ const { body, bodyLineOffset } = extractFrontmatter(raw);
409
+ if (!body || !body.toLowerCase().includes(needle)) return [];
410
+
411
+ const lines = body.split('\n');
412
+ const matches = [];
413
+ for (let i = 0; i < lines.length && matches.length < MAX_BODY_MATCHES; i++) {
414
+ const raw = lines[i].trim();
415
+ const at = raw.toLowerCase().indexOf(needle);
416
+ if (at === -1) continue;
417
+ const { text, at: shown } = displayableExcerptLine(raw, at, needle);
418
+ matches.push({ line: bodyLineOffset + i + 1, text: excerptAround(text, shown, needle.length) });
419
+ }
420
+ return matches;
421
+ }
422
+
423
+ // HTML comments are invisible when the markdown renders, so they are noise in an
424
+ // excerpt — and runlist's own conventions put them inline (a managed status token in
425
+ // a hub table row reads as `| [x](x.md) | <!--s-->active<!--/s--> — next |`), which
426
+ // is paid on every agent read of every search result.
427
+ //
428
+ // Matching still happens against the raw line, so a needle that only occurs INSIDE
429
+ // a comment is never lost: if stripping would hide the match, the raw line is shown
430
+ // instead. Whitespace is squeezed only on lines a strip actually touched.
431
+ const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
432
+
433
+ export function displayableExcerptLine(raw, at, needle) {
434
+ if (!raw.includes('<!--')) return { text: raw, at };
435
+ const stripped = raw.replace(HTML_COMMENT_RE, '').replace(/[ \t]{2,}/g, ' ').trim();
436
+ const shown = stripped.toLowerCase().indexOf(needle);
437
+ if (shown === -1) return { text: raw, at };
438
+ return { text: stripped, at: shown };
439
+ }
440
+
441
+ // Window a long line around the match so the needle is always visible.
442
+ function excerptAround(text, at, needleLen) {
443
+ if (text.length <= EXCERPT_WIDTH) return text;
444
+ const start = Math.max(0, Math.min(at - Math.floor((EXCERPT_WIDTH - needleLen) / 2), text.length - EXCERPT_WIDTH));
445
+ const slice = text.slice(start, start + EXCERPT_WIDTH);
446
+ return `${start > 0 ? '…' : ''}${slice}${start + EXCERPT_WIDTH < text.length ? '…' : ''}`;
447
+ }
448
+
449
+ function getDocSummary(doc, config) {
450
+ try {
451
+ const absPath = path.resolve(config.repoRoot, doc.path);
452
+ const raw = readFileSync(absPath, 'utf8');
453
+ const { body } = extractFrontmatter(raw);
454
+ if (!body?.trim()) return null;
455
+ const meta = { title: doc.title, status: doc.status, path: doc.path };
456
+ if (config._execution?.suppressSideEffects) return null;
457
+ return config.hooks.summarizeDoc
458
+ ? config.hooks.summarizeDoc(body, meta)
459
+ : summarizeDocBody(body, meta);
460
+ } catch (err) { warn(`Could not summarize ${doc.path}: ${err.message}`); return null; }
461
+ }
462
+
463
+ function renderQueryResults(docs, filters, config) {
464
+ process.stdout.write('Query\n\n');
465
+ const total = filters._totalBeforeLimit ?? docs.length;
466
+ const truncated = !filters.all && total > docs.length;
467
+ if (truncated) {
468
+ process.stdout.write(`- results: ${docs.length} of ${total} ${dim('(use --all to see all)')}\n`);
469
+ } else {
470
+ process.stdout.write(`- results: ${docs.length}\n`);
471
+ }
472
+ if (filters.types?.length) process.stdout.write(`- type: ${filters.types.join(', ')}\n`);
473
+ if (filters.statuses?.length) process.stdout.write(`- status: ${filters.statuses.join(', ')}\n`);
474
+ if (filters.keyword) process.stdout.write(`- keyword: ${filters.keyword}${filters.body ? ' (bodies scanned)' : ''}\n`);
475
+ if (filters.owner) process.stdout.write(`- owner: ${filters.owner}\n`);
476
+ if (filters.surface) process.stdout.write(`- surface: ${filters.surface}\n`);
477
+ if (filters.module) process.stdout.write(`- module: ${filters.module}\n`);
478
+ if (filters.domain) process.stdout.write(`- domain: ${filters.domain}\n`);
479
+ if (filters.audience) process.stdout.write(`- audience: ${filters.audience}\n`);
480
+ if (filters.stage) process.stdout.write(`- stage: ${filters.stage}\n`);
481
+ if (filters.executionMode) process.stdout.write(`- execution-mode: ${filters.executionMode}\n`);
482
+ if (filters.updatedSince) process.stdout.write(`- updated-since: ${filters.updatedSince}\n`);
483
+ process.stdout.write(`- sort: ${filters.sort}\n`);
484
+ if (filters.stale) process.stdout.write('- stale-only: true\n');
485
+ if (filters.git) process.stdout.write('- using: git dates\n');
486
+ if (filters.hasNextStep) process.stdout.write('- has-next-step: true\n');
487
+ if (filters.hasBlockers) process.stdout.write('- has-blockers: true\n');
488
+ if (filters.checklistOpen) process.stdout.write('- checklist-open: true\n');
489
+ if (filters.summarize && config._execution?.suppressSideEffects) {
490
+ process.stdout.write('- summaries: skipped in side-effect-free preview\n');
491
+ }
492
+ process.stdout.write('\n');
493
+
494
+ if (docs.length === 0) { process.stdout.write('No matching docs.\n'); return; }
495
+
496
+ for (let idx = 0; idx < docs.length; idx++) {
497
+ const doc = docs[idx];
498
+ process.stdout.write(`- ${doc.title}\n`);
499
+ if (doc.type) process.stdout.write(` type: ${doc.type}\n`);
500
+ process.stdout.write(` status: ${doc.status}\n`);
501
+ process.stdout.write(` updated: ${doc.updated ?? 'n/a'}\n`);
502
+ if (doc.daysSinceUpdate != null) process.stdout.write(` days-since-update: ${doc.daysSinceUpdate}\n`);
503
+ process.stdout.write(` stale: ${doc.isStale ? 'yes' : 'no'}\n`);
504
+ process.stdout.write(` path: ${doc.path}\n`);
505
+ const stateValue = formatCurrentState(doc);
506
+ if (stateValue) process.stdout.write(` state: ${stateValue}\n`);
507
+ if (doc.nextStep) process.stdout.write(` next: ${doc.nextStep}\n`);
508
+ if (doc.owner) process.stdout.write(` owner: ${doc.owner}\n`);
509
+ if (doc.surfaces?.length) process.stdout.write(` surfaces: ${doc.surfaces.join(', ')}\n`);
510
+ if (doc.modules?.length) process.stdout.write(` modules: ${doc.modules.join(', ')}\n`);
511
+ if (doc.domain) process.stdout.write(` domain: ${doc.domain}\n`);
512
+ if (doc.audience) process.stdout.write(` audience: ${doc.audience}\n`);
513
+ if (doc.executionMode) process.stdout.write(` execution-mode: ${doc.executionMode}\n`);
514
+ if (doc.blockers?.length) process.stdout.write(` blockers: ${doc.blockers.join('; ')}\n`);
515
+ if (doc.checklist?.total) process.stdout.write(` checklist: ${doc.checklist.completed}/${doc.checklist.total} complete\n`);
516
+ for (const m of doc.bodyMatches ?? []) {
517
+ process.stdout.write(` match: ${dim(`L${m.line}:`)} ${m.text}\n`);
518
+ }
519
+ if (filters.summarize && idx < filters.summarizeLimit) {
520
+ const summary = getDocSummary(doc, config);
521
+ if (summary) process.stdout.write(` ${dim('ai-summary:')} ${summary}\n`);
522
+ }
523
+ process.stdout.write('\n');
524
+ }
525
+ }
526
+
527
+ function buildSorter(sort, config) {
528
+ if (sort === 'title') return (a, b) => a.title.localeCompare(b.title);
529
+ if (sort === 'status') {
530
+ return (a, b) => {
531
+ const ai = config.statusOrder.indexOf(a.status); const bi = config.statusOrder.indexOf(b.status);
532
+ const aIdx = ai === -1 ? Number.MAX_SAFE_INTEGER : ai; const bIdx = bi === -1 ? Number.MAX_SAFE_INTEGER : bi;
533
+ if (aIdx !== bIdx) return aIdx - bIdx;
534
+ return compareUpdatedDesc(a, b) || a.title.localeCompare(b.title);
535
+ };
536
+ }
537
+ return (a, b) => compareUpdatedDesc(a, b) || a.title.localeCompare(b.title);
538
+ }
539
+
540
+ function compareUpdatedDesc(a, b) {
541
+ const au = a.updated ?? ''; const bu = b.updated ?? '';
542
+ return au !== bu ? bu.localeCompare(au) : 0;
543
+ }
544
+
545
+ function renderPlansOutput(docs, filters, config, opts = {}) {
546
+ const noun = opts.noun ?? 'plans';
547
+ if (docs.length === 0) {
548
+ process.stdout.write(`No ${noun} found.\n`);
549
+ return;
550
+ }
551
+
552
+ const maxWidth = process.stdout.columns || 100;
553
+ const grouped = filters.sort === 'status' || filters.group;
554
+ // Runlist treatment (sprint-hub folding + the coordination-hub section) is
555
+ // scoped to the flat triage view; grouped views keep their existing shape.
556
+ const runlist = !grouped ? opts.runlist : null;
557
+ const coordination = !grouped ? opts.coordination : null;
558
+ const roadmap = !grouped ? opts.roadmap : null;
559
+ // A doc is a "runlist" for header/section purposes if it's either a
560
+ // frontmatter-`runlist:` sprint hub or an `execution_mode: coordination` hub.
561
+ // Roadmaps are also in `coordination` (held-out-hub plumbing), so they read as
562
+ // hubs here too — but `isRoadmap` is checked first wherever counts/sections
563
+ // split, lifting them into their own tier above runlists.
564
+ const isHub = (p) => Boolean(runlist?.hubs.has(p) || coordination?.has(p));
565
+ const isRoadmap = (p) => Boolean(roadmap?.has(p));
566
+
567
+ // Summary line: middle-dot separator, ALWAYS based on the full pre-limit
568
+ // pipeline so the top-of-page numbers stay honest when --limit is applied.
569
+ const totalShown = docs.length;
570
+ const totalAll = filters._totalBeforeLimit ?? totalShown;
571
+ const bySt = filters._statusCounts ?? (() => {
572
+ const counts = {};
573
+ for (const d of docs) { counts[d.status] = (counts[d.status] ?? 0) + 1; }
574
+ return counts;
575
+ })();
576
+ // With runlists present, pull hubs out of the per-status breakdown into a
577
+ // dedicated `N runlist` bucket so a hub reads as an active *runlist*, not one
578
+ // more active plan. Needs per-doc identity, so recompute from the pre-limit
579
+ // matched set rather than the aggregate counts.
580
+ let hubCount = 0;
581
+ let roadmapCount = 0;
582
+ let counts;
583
+ if ((runlist?.hubs.size || coordination?.size) && filters._matched) {
584
+ const reclassed = {};
585
+ for (const d of filters._matched) {
586
+ if (isRoadmap(d.path)) { roadmapCount += 1; continue; }
587
+ if (isHub(d.path)) { hubCount += 1; continue; }
588
+ const s = d.status ?? 'unknown';
589
+ reclassed[s] = (reclassed[s] ?? 0) + 1;
590
+ }
591
+ counts = Object.entries(reclassed).sort((a, b) => b[1] - a[1]).map(([s, n]) => `${n} ${s}`);
592
+ } else {
593
+ counts = Object.entries(bySt).sort((a, b) => b[1] - a[1]).map(([s, n]) => `${n} ${s}`);
594
+ }
595
+ const headerParts = [];
596
+ if (roadmapCount) headerParts.push(`${roadmapCount} roadmap${roadmapCount === 1 ? '' : 's'}`);
597
+ if (hubCount) headerParts.push(`${hubCount} runlist${hubCount === 1 ? '' : 's'}`);
598
+ headerParts.push(...counts);
599
+ // Hubs (runlists + roadmaps) are held OUT of the headline plan count — they
600
+ // read as separate siblings, never as actionable plans. So "N plans" counts
601
+ // leaves only and the status segments sum to it.
602
+ const headlineTotal = totalAll - hubCount - roadmapCount;
603
+ const header = `${headlineTotal} ${noun}${headerParts.length ? ' · ' + headerParts.join(' · ') : ''}`;
604
+ process.stdout.write(dim(header) + '\n');
605
+
606
+ // Active filter note
607
+ const activeFilters = [];
608
+ if (filters.statuses?.length) activeFilters.push(`status: ${filters.statuses.join(', ')}`);
609
+ if (filters.stage) activeFilters.push(`stage: ${filters.stage}`);
610
+ if (filters.module) activeFilters.push(`module: ${filters.module}`);
611
+ if (filters.surface) activeFilters.push(`surface: ${filters.surface}`);
612
+ if (filters.owner) activeFilters.push(`owner: ${filters.owner}`);
613
+ if (filters.keyword) activeFilters.push(`keyword: ${filters.keyword}`);
614
+ if (filters.stale) activeFilters.push('stale only');
615
+ if (filters.hasNextStep) activeFilters.push('has next step');
616
+ if (filters.hasBlockers) activeFilters.push('has blockers');
617
+ if (activeFilters.length) process.stdout.write(dim(` filtered: ${activeFilters.join(' | ')}`) + '\n');
618
+
619
+ if (filters.group === 'stage') {
620
+ for (const group of groupDocsByStage(docs, config)) {
621
+ process.stdout.write(`\n${bold(`${group.label} (${group.docs.length})`)}\n`);
622
+ if (group.meaning) process.stdout.write(` ${dim(group.meaning)}\n`);
623
+ renderPlanRows(group.docs, filters, maxWidth, { showTag: true });
624
+ }
625
+ } else if (filters.group === 'module') {
626
+ process.stdout.write('\n');
627
+ renderPlansByGroup(docs, d => d.modules?.length ? d.modules : ['(none)'], filters, maxWidth);
628
+ } else if (filters.group === 'surface') {
629
+ process.stdout.write('\n');
630
+ renderPlansByGroup(docs, d => d.surfaces?.length ? d.surfaces : ['(none)'], filters, maxWidth);
631
+ } else if (filters.group === 'owner') {
632
+ process.stdout.write('\n');
633
+ renderPlansByGroup(docs, d => [d.owner ?? '(none)'], filters, maxWidth);
634
+ } else if (grouped) {
635
+ // Pipeline view: group by status, ordered by config.statusOrder. Tag is implicit.
636
+ const statusGroups = new Map();
637
+ for (const d of docs) {
638
+ const s = d.status ?? 'unknown';
639
+ if (!statusGroups.has(s)) statusGroups.set(s, []);
640
+ statusGroups.get(s).push(d);
641
+ }
642
+ const orderedStatuses = [...config.statusOrder.filter(s => statusGroups.has(s)), ...([...statusGroups.keys()].filter(s => !config.statusOrder.includes(s)))];
643
+ for (const status of orderedStatuses) {
644
+ const group = statusGroups.get(status);
645
+ process.stdout.write(`\n${bold(`${capitalize(status)} (${group.length})`)}\n`);
646
+ renderPlanRows(group, filters, maxWidth, { showTag: false });
647
+ }
648
+ } else {
649
+ // Flat triage view, "capped like leaves": work from the full pre-limit
650
+ // matched set and cap each kind independently. Leaf plans (+ frontmatter
651
+ // `runlist:` sprint hubs, which still fold inline) fill the main list;
652
+ // coordination hubs are lifted into their own `Runlists` section. Each
653
+ // section caps at `--limit` with its own "N more" footer; `--all` lifts
654
+ // both caps. The Runlists section is pinned — it shows whenever hubs exist,
655
+ // independent of how the leaf list fills up.
656
+ const matched = filters._matched ?? docs;
657
+ const roadmapAll = [];
658
+ const coordAll = [];
659
+ const mainAll = [];
660
+ for (const d of matched) {
661
+ if (isRoadmap(d.path)) roadmapAll.push(d);
662
+ else if (coordination?.has(d.path)) coordAll.push(d);
663
+ else mainAll.push(d);
664
+ }
665
+ const mainShown = filters.all ? mainAll : mainAll.slice(0, filters.limit);
666
+ const coordShown = filters.all ? coordAll : coordAll.slice(0, filters.limit);
667
+ const roadmapShown = filters.all ? roadmapAll : roadmapAll.slice(0, filters.limit);
668
+
669
+ process.stdout.write('\n');
670
+ if (mainShown.length) {
671
+ if (runlist?.hubs.size) renderTriageWithRunlists(mainShown, runlist, maxWidth);
672
+ else renderPlanRows(mainShown, filters, maxWidth, { showTag: true });
673
+ }
674
+ const mainHidden = mainAll.length - mainShown.length;
675
+ if (mainHidden > 0) {
676
+ process.stdout.write('\n');
677
+ process.stdout.write(dim(` ${mainHidden} more ${noun} · runlist ${noun} --all · runlist ${noun} status\n`));
678
+ }
679
+
680
+ // Roadmaps tier — pinned above Runlists, with the recursive grand total.
681
+ if (roadmapShown.length) {
682
+ renderRoadmapsSection(roadmapShown, roadmap, maxWidth, roadmapAll.length);
683
+ const roadmapHidden = roadmapAll.length - roadmapShown.length;
684
+ if (roadmapHidden > 0) {
685
+ process.stdout.write(dim(` ${roadmapHidden} more roadmaps · runlist roadmaps\n`));
686
+ }
687
+ }
688
+
689
+ if (coordShown.length) {
690
+ renderCoordinationSection(coordShown, coordination, maxWidth, coordAll.length);
691
+ const coordHidden = coordAll.length - coordShown.length;
692
+ if (coordHidden > 0) {
693
+ process.stdout.write(dim(` ${coordHidden} more runlists · runlist ${noun} --all\n`));
694
+ }
695
+ }
696
+
697
+ // Runlist nav stays discoverable under a narrowing filter. The Runlists
698
+ // section respects the active filter (a coordination hub shows only when its
699
+ // own status matches), so `--status blocked` legitimately hides an `active`
700
+ // hub. Rather than silently dropping the map, point at `runlist runlists` when
701
+ // a filter hid live hubs — honest count, filter respected, map never lost.
702
+ if (coordination?.size && activeFilters.length) {
703
+ const archiveStatuses = config.lifecycle?.archiveStatuses ?? new Set(['archived']);
704
+ const shownHubs = new Set(coordAll.map(d => d.path));
705
+ const filterHidden = [...coordination.values()].filter(v =>
706
+ !shownHubs.has(v.doc.path)
707
+ && !archiveStatuses.has(v.doc.status)
708
+ && !isArchivedPath(v.doc.path, config)).length;
709
+ if (filterHidden > 0) {
710
+ process.stdout.write(dim(` ${filterHidden} runlist${filterHidden === 1 ? '' : 's'} hidden by filter · runlist runlists\n`));
711
+ }
712
+ }
713
+
714
+ process.stdout.write('\n');
715
+ return;
716
+ }
717
+
718
+ // Footer (grouped views) — emit when the result was capped.
719
+ const hidden = totalAll - totalShown;
720
+ if (hidden > 0) {
721
+ process.stdout.write('\n');
722
+ process.stdout.write(dim(` ${hidden} more ${noun} · runlist ${noun} --all · runlist ${noun} status\n`));
723
+ }
724
+
725
+ process.stdout.write('\n');
726
+ }
727
+
728
+ // Stage grouping never folds children or discards unconfigured/malformed values.
729
+ // Null and invalid keys remain distinct from every explicit ships string.
730
+ export function groupDocsByStage(docs, config) {
731
+ const definitions = getStageDefinitions(config);
732
+ const meanings = new Map(definitions.map(stage => [stage.word, stage.meaning]));
733
+ const invalidKey = Symbol('invalid');
734
+ const groups = new Map();
735
+ for (const doc of docs) {
736
+ const stage = readPlanStage(doc.ships);
737
+ const key = stage.invalid ? invalidKey : stage.word;
738
+ if (!groups.has(key)) groups.set(key, []);
739
+ groups.get(key).push(doc);
740
+ }
741
+ const configured = definitions.map(stage => stage.word).filter(word => groups.has(word));
742
+ const unknown = [...groups.keys()].filter(key => typeof key === 'string' && !meanings.has(key)).sort((a, b) => a.localeCompare(b));
743
+ const ordered = [...configured, ...unknown, ...(groups.has(null) ? [null] : []), ...(groups.has(invalidKey) ? [invalidKey] : [])];
744
+ return ordered.map(key => ({
745
+ word: typeof key === 'string' ? key : null,
746
+ label: key === invalidKey ? 'Invalid' : key === null ? 'Unset' : key,
747
+ meaning: meanings.get(key) ?? null,
748
+ ...(key === invalidKey ? { invalid: true } : {}),
749
+ docs: groups.get(key),
750
+ }));
751
+ }
752
+
753
+ function renderPlansByGroup(docs, keyFn, filters, maxWidth) {
754
+ const groups = new Map();
755
+ for (const d of docs) {
756
+ for (const key of keyFn(d)) {
757
+ if (!groups.has(key)) groups.set(key, []);
758
+ groups.get(key).push(d);
759
+ }
760
+ }
761
+
762
+ const ordered = [...groups.keys()].sort((a, b) => a === '(none)' ? 1 : b === '(none)' ? -1 : a.localeCompare(b));
763
+ for (const key of ordered) {
764
+ const group = groups.get(key);
765
+ process.stdout.write(`\n${bold(`${key} (${group.length})`)}\n`);
766
+ renderPlanRows(group, filters, maxWidth);
767
+ }
768
+ }
769
+
770
+ // Widest tag we render (status name in CAPS + brackets). Used to budget the
771
+ // next-step column when right-aligning tags.
772
+ const MAX_TAG_WIDTH = '[QUEUED-AFTER]'.length;
773
+
774
+ // Format a single triage row: `<indent><slug> <age> <pct> <next-step>` with
775
+ // an optional right-aligned tag. `indent` carries the left gutter (and, for
776
+ // runlist children, the `→` next-pickup marker), so its visible length must be
777
+ // stable across sibling rows for the slug column to align. `tag` overrides the
778
+ // status tag (used for the `[RUNLIST]` hub tag).
779
+ function formatPlanRow(doc, maxWidth, { slug, indent = ' ', maxSlug, showTag = false, tag } = {}) {
780
+ const slugCell = slug.padEnd(maxSlug ?? slug.length);
781
+ const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : '—';
782
+ const ageStr = doc.daysSinceUpdate != null && doc.isStale ? red(age.padStart(4)) : dim(age.padStart(4));
783
+
784
+ // Compact percentage cell (always 5 chars: "100% " / " 99% " / " 5% " / " ")
785
+ let pctCell = ' ';
786
+ if (doc.checklist?.total) {
787
+ const pct = Math.round((doc.checklist.completed / doc.checklist.total) * 100);
788
+ pctCell = `${pct.toString().padStart(3)}% `;
789
+ }
790
+
791
+ const leftBlock = `${indent}${slugCell} ${ageStr} ${dim(pctCell)}`;
792
+ const leftLen = visibleLen(leftBlock);
793
+
794
+ // Next-step / blocker text. Budget = maxWidth - leftLen - separator - (tag column if shown).
795
+ let nextText = '';
796
+ if (doc.blockers?.length && doc.status === 'blocked') {
797
+ nextText = `blocked by ${doc.blockers.join('; ')}`;
798
+ } else if (doc.nextStep) {
799
+ nextText = doc.nextStep;
800
+ }
801
+
802
+ const tagBudget = showTag ? MAX_TAG_WIDTH + 2 : 0; // 2 = gap before tag
803
+ const nextBudget = Math.max(10, maxWidth - leftLen - 2 - tagBudget); // -2 for ` ` separator
804
+ let nextRendered = nextText;
805
+ if (nextText.length > nextBudget) nextRendered = nextText.slice(0, nextBudget - 3) + '...';
806
+
807
+ // Coloring for "blocked by" stays yellow.
808
+ if (doc.blockers?.length && doc.status === 'blocked') nextRendered = yellow(nextRendered);
809
+
810
+ let line = `${leftBlock} ${nextRendered}`;
811
+ if (showTag) {
812
+ // Pad next column to push tag to the right column boundary.
813
+ const consumed = visibleLen(line);
814
+ const targetCol = maxWidth - MAX_TAG_WIDTH;
815
+ const padCount = Math.max(2, targetCol - consumed);
816
+ line = `${line}${' '.repeat(padCount)}${tag ?? colorTag(doc.status)}`;
817
+ }
818
+ return line;
819
+ }
820
+
821
+ function renderPlanRows(group, _filters, maxWidth, opts = {}) {
822
+ const { showTag = false } = opts;
823
+ const maxSlug = Math.min(30, Math.max(...group.map(d => toSlug(d).length)));
824
+ for (const doc of group) {
825
+ process.stdout.write(formatPlanRow(doc, maxWidth, { slug: toSlug(doc), maxSlug, showTag }) + '\n');
826
+ }
827
+ }
828
+
829
+ const RUNLIST_TAG = bold(cyan('[RUNLIST]'));
830
+
831
+ // Drop a hub's slug prefix off a child slug so a sprint's children read as
832
+ // `01-extract` rather than `auth-revamp-01-extract`. Falls back to the full
833
+ // slug when there's no shared prefix.
834
+ function stripHubPrefix(childSlug, hubSlug) {
835
+ return childSlug.startsWith(`${hubSlug}-`) ? childSlug.slice(hubSlug.length + 1) : childSlug;
836
+ }
837
+
838
+ // Flat triage view, runlist-aware. Standalone plans render as before; each hub
839
+ // becomes a `[RUNLIST]` header with its (filtered) children folded underneath
840
+ // in runlist order, the next pickup marked `→`. A child whose hub is absent
841
+ // from this filtered set (e.g. `--status active` hid the hub) renders
842
+ // standalone so it still surfaces. Render units sort by their most-recent
843
+ // member so an actively-worked sprint stays near the top.
844
+ function renderTriageWithRunlists(docs, runlist, maxWidth) {
845
+ const { hubs, childToHub } = runlist;
846
+ const docPathSet = new Set(docs.map(d => d.path));
847
+
848
+ const folded = new Set();
849
+ for (const d of docs) {
850
+ const hubPath = childToHub.get(d.path);
851
+ if (hubPath && docPathSet.has(hubPath)) folded.add(d.path);
852
+ }
853
+
854
+ const units = [];
855
+ for (const d of docs) {
856
+ if (folded.has(d.path)) continue; // emitted under its hub
857
+ const info = hubs.get(d.path);
858
+ if (info) {
859
+ const children = docs.filter(c => childToHub.get(c.path) === d.path);
860
+ const ages = [d.daysSinceUpdate, ...children.map(c => c.daysSinceUpdate)].filter(n => n != null);
861
+ units.push({ anchor: ages.length ? Math.min(...ages) : Infinity, kind: 'hub', hub: d, info, children });
862
+ } else {
863
+ units.push({ anchor: d.daysSinceUpdate ?? Infinity, kind: 'plan', doc: d });
864
+ }
865
+ }
866
+ // Stable sort by most-recent member ascending (docs arrive already sorted by
867
+ // `updated`, so equal anchors keep their incoming order).
868
+ units.sort((a, b) => a.anchor - b.anchor);
869
+
870
+ // Shared slug width for the left-most rows (standalone plans + hub headers).
871
+ const topSlugs = units.map(u => u.kind === 'hub' ? toSlug(u.hub) : toSlug(u.doc));
872
+ const topMaxSlug = Math.min(30, Math.max(...topSlugs.map(s => s.length)));
873
+
874
+ for (const u of units) {
875
+ if (u.kind === 'plan') {
876
+ process.stdout.write(formatPlanRow(u.doc, maxWidth, { slug: toSlug(u.doc), maxSlug: topMaxSlug, showTag: true }) + '\n');
877
+ } else {
878
+ renderHubBlock(u.hub, u.info, u.children, maxWidth, topMaxSlug);
879
+ }
880
+ }
881
+ }
882
+
883
+ function renderHubBlock(hub, info, children, maxWidth, topMaxSlug) {
884
+ const hubSlug = toSlug(hub);
885
+ const nextDoc = info.nextChildPath ? info.children.find(c => c.path === info.nextChildPath)?.doc : null;
886
+ const nextLabel = nextDoc ? stripHubPrefix(toSlug(nextDoc), hubSlug) : null;
887
+ // No pickup-able child: "N parked" when a live-but-unstartable child remains
888
+ // (the runlist is stuck, not finished), else "all archived" (truly done).
889
+ const descr = nextLabel
890
+ ? `runlist · ${info.doneCount}/${info.total} · next → ${nextLabel}`
891
+ : info.parkedCount > 0
892
+ ? `runlist · ${info.doneCount}/${info.total} · ${info.parkedCount} parked`
893
+ : `runlist · ${info.doneCount}/${info.total} · all archived`;
894
+
895
+ // Header row: hub slug + descriptor, with `[RUNLIST]` right-aligned like a tag.
896
+ const slugCell = hubSlug.padEnd(topMaxSlug);
897
+ let header = ` ${slugCell} ${dim(descr)}`;
898
+ const targetCol = maxWidth - MAX_TAG_WIDTH;
899
+ const pad = Math.max(2, targetCol - visibleLen(header));
900
+ header = `${header}${' '.repeat(pad)}${RUNLIST_TAG}`;
901
+ process.stdout.write(header + '\n');
902
+
903
+ if (children.length === 0) return;
904
+ // Render rows in runlist order (info.children), not the docs view's recency
905
+ // sort — a runlist reads in sequence, and a just-added child shouldn't jump to
906
+ // the top because its mtime is newest. Only children present in the current
907
+ // view are shown; any not tracked by the runlist index trail at the end.
908
+ const byPath = new Map(children.map(c => [c.path, c]));
909
+ const ordered = info.children.map(ic => byPath.get(ic.path)).filter(Boolean);
910
+ for (const c of children) if (!ordered.includes(c)) ordered.push(c);
911
+ const childMaxSlug = Math.min(28, Math.max(...children.map(c => stripHubPrefix(toSlug(c), hubSlug).length)));
912
+ for (const c of ordered) {
913
+ const isNext = c.path === info.nextChildPath;
914
+ const indent = isNext ? ` ${green('→')} ` : ' ';
915
+ process.stdout.write(formatPlanRow(c, maxWidth, {
916
+ slug: stripHubPrefix(toSlug(c), hubSlug), indent, maxSlug: childMaxSlug, showTag: true,
917
+ }) + '\n');
918
+ }
919
+ }
920
+
921
+ // Roadmap hubs (tier-3) render in their own pinned section above Runlists:
922
+ // label · age · recursive grand done/total · child-runlist count · descriptor.
923
+ // The done/total here is the SUM across the roadmap's child runlists (the real
924
+ // bird's-eye), distinct from the Runlists section's per-hub rollup. `runlist
925
+ // roadmap <hub>` expands one into its child rows.
926
+ function renderRoadmapsSection(roadmapDocs, roadmap, maxWidth, total) {
927
+ process.stdout.write(`\n${bold(`Roadmaps (${total ?? roadmapDocs.length})`)} ${dim('· runlist roadmap')}\n`);
928
+ const maxSlug = Math.min(34, Math.max(...roadmapDocs.map(d => hubLabel(d).length)));
929
+ for (const doc of roadmapDocs) {
930
+ const info = roadmap?.get(doc.path);
931
+ const slug = hubLabel(doc).padEnd(maxSlug);
932
+ const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : '—';
933
+ const ageStr = doc.daysSinceUpdate != null && doc.isStale ? red(age.padStart(4)) : dim(age.padStart(4));
934
+ const roll = (info ? `${info.grandDone}/${info.grandTotal}` : '').padStart(8);
935
+ const kids = info ? `${info.childCount} ${info.childCount === 1 ? 'runlist' : 'runlists'}` : '';
936
+ const statusTag = doc.status && doc.status !== 'active' ? ` ${colorTag(doc.status)}` : '';
937
+ const desc = (doc.nextStep || doc.currentState || doc.title || '').replace(/\s+/g, ' ').trim();
938
+ const left = ` ${slug} ${ageStr} ${dim(roll)} ${dim(kids.padEnd(11))} `;
939
+ const budget = Math.max(10, maxWidth - visibleLen(left) - visibleLen(statusTag) - 2);
940
+ const descR = desc.length > budget ? desc.slice(0, Math.max(0, budget - 3)) + '...' : desc;
941
+ process.stdout.write(`${left}${dim(descR)}${statusTag}\n`);
942
+ }
943
+ }
944
+
945
+ // Coordination hubs (prose-first runlists) render in their own compact section:
946
+ // label · age · done/total rollup · one-line descriptor. No fold, no per-row tag
947
+ // — the section header is the signal. The rollup counts archived vs. resolved
948
+ // `related_plans:` children; that cluster can include peer/parent runlists, so
949
+ // it's a progress hint, not a contract (see buildCoordinationIndex). Status shows
950
+ // only when it's not the expected `active` (e.g. a `partial` hub).
951
+ function renderCoordinationSection(coordDocs, coordination, maxWidth, total) {
952
+ process.stdout.write(`\n${bold(`Runlists (${total ?? coordDocs.length})`)}\n`);
953
+ const maxSlug = Math.min(34, Math.max(...coordDocs.map(d => hubLabel(d).length)));
954
+ for (const doc of coordDocs) {
955
+ const info = coordination.get(doc.path);
956
+ const slug = hubLabel(doc).padEnd(maxSlug);
957
+ const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : '—';
958
+ const ageStr = doc.daysSinceUpdate != null && doc.isStale ? red(age.padStart(4)) : dim(age.padStart(4));
959
+ const count = (info?.total ? `${info.doneCount}/${info.total}` : '').padStart(7);
960
+ const statusTag = doc.status && doc.status !== 'active' ? ` ${colorTag(doc.status)}` : '';
961
+ const desc = (doc.nextStep || doc.currentState || doc.title || '').replace(/\s+/g, ' ').trim();
962
+
963
+ // Next-pickup (first non-archived ranked child from the body) leads the
964
+ // description column when one resolves — it's the most actionable cell. Its
965
+ // status is shown only when it's not the expected `active`.
966
+ const next = info?.nextPickup;
967
+ const nextStr = next
968
+ ? `${green('→')} ${next.label}${next.status && next.status !== 'active' ? dim(` (${next.status})`) : ''}`
969
+ : '';
970
+ const nextPart = nextStr ? `${nextStr} ` : '';
971
+
972
+ const left = ` ${slug} ${ageStr} ${dim(count)} `;
973
+ const budget = Math.max(10, maxWidth - visibleLen(left) - visibleLen(nextPart) - visibleLen(statusTag) - 2);
974
+ const descR = desc.length > budget ? desc.slice(0, budget - 3) + '...' : desc;
975
+ process.stdout.write(`${left}${nextPart}${dim(descR)}${statusTag}\n`);
976
+ }
977
+ }