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.
- package/LICENSE +21 -0
- package/README.md +1112 -6
- package/assets/app/app.css +283 -0
- package/assets/app/app.mjs +381 -0
- package/assets/app/block-editor.mjs +182 -0
- package/assets/app/block-model.mjs +52 -0
- package/assets/app/document-create.mjs +86 -0
- package/assets/app/document-lifecycle.mjs +36 -0
- package/assets/app/document-stages.mjs +22 -0
- package/assets/app/document-views.mjs +33 -0
- package/assets/app/document-yardstick.mjs +258 -0
- package/assets/app/editor-navigation.mjs +51 -0
- package/assets/app/filing-navigation.mjs +138 -0
- package/assets/app/git-changes.mjs +80 -0
- package/assets/app/git-commit.mjs +46 -0
- package/assets/app/index.html +70 -0
- package/assets/app/library-navigation.mjs +93 -0
- package/assets/app/outline.mjs +18 -0
- package/assets/app/quick-navigation.mjs +52 -0
- package/assets/app/record-navigation.mjs +78 -0
- package/assets/app/recovery-center.mjs +53 -0
- package/assets/app/semantic-search.mjs +14 -0
- package/assets/app/settings.mjs +15 -0
- package/assets/app/shared.mjs +104 -0
- package/assets/app/stage-source.mjs +75 -0
- package/assets/app/template-editor.mjs +47 -0
- package/assets/app/theme.mjs +25 -0
- package/assets/app/transport.mjs +43 -0
- package/assets/contracts/source-editing-v1.md +232 -0
- package/assets/opencode/plugin.js +162 -0
- package/assets/schemas/runlist-record-v1.schema.json +912 -0
- package/bin/dotmd.mjs +5 -0
- package/bin/runlist.mjs +2714 -0
- package/package.json +53 -6
- package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
- package/plugins/runlist-codex/bin/runlist-hook +86 -0
- package/plugins/runlist-codex/hooks/hooks.json +16 -0
- package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
- package/runlist.config.example.mjs +370 -0
- package/scripts/postinstall.mjs +56 -0
- package/src/agent-context.mjs +201 -0
- package/src/ai.mjs +30 -0
- package/src/app-create.mjs +169 -0
- package/src/app-filing.mjs +139 -0
- package/src/app-git-commits.mjs +48 -0
- package/src/app-git.mjs +180 -0
- package/src/app-library.mjs +152 -0
- package/src/app-lifecycle-worker.mjs +12 -0
- package/src/app-lifecycle.mjs +79 -0
- package/src/app-records.mjs +134 -0
- package/src/app-search.mjs +80 -0
- package/src/app-semantic-search.mjs +105 -0
- package/src/app-service.mjs +208 -0
- package/src/app-yardstick.mjs +269 -0
- package/src/app.mjs +102 -0
- package/src/atomic-mutation.mjs +1736 -0
- package/src/baton.mjs +435 -0
- package/src/body-link.mjs +48 -0
- package/src/bulk-tag.mjs +174 -0
- package/src/check-collapse.mjs +75 -0
- package/src/claude-commands.mjs +75 -0
- package/src/cli-editor.mjs +8 -0
- package/src/code-refs.mjs +545 -0
- package/src/codex-integration.mjs +81 -0
- package/src/color.mjs +14 -0
- package/src/commands.mjs +374 -0
- package/src/completions.mjs +93 -0
- package/src/config-edit.mjs +606 -0
- package/src/config.mjs +705 -0
- package/src/decision-action.mjs +58 -0
- package/src/decision.mjs +220 -0
- package/src/decisions.mjs +787 -0
- package/src/deps.mjs +317 -0
- package/src/desktop-install.mjs +158 -0
- package/src/desktop-runtime.mjs +4 -0
- package/src/desktop.mjs +25 -0
- package/src/diff.mjs +101 -0
- package/src/doctor.mjs +591 -0
- package/src/durable-rename.mjs +55 -0
- package/src/errors-read.mjs +44 -0
- package/src/export.mjs +496 -0
- package/src/extractors.mjs +101 -0
- package/src/filing.mjs +255 -0
- package/src/fix-membership.mjs +134 -0
- package/src/fix-refs.mjs +174 -0
- package/src/flags.mjs +318 -0
- package/src/frontmatter-fix.mjs +195 -0
- package/src/frontmatter.mjs +252 -0
- package/src/git-commit-filters.mjs +32 -0
- package/src/git-commit-protocol.mjs +198 -0
- package/src/git-commit-signing.mjs +75 -0
- package/src/git-commit-store.mjs +26 -0
- package/src/git-commit-support.mjs +16 -0
- package/src/git-commit-worker.mjs +32 -0
- package/src/git.mjs +909 -0
- package/src/glossary-check.mjs +35 -0
- package/src/glossary.mjs +243 -0
- package/src/graph.mjs +298 -0
- package/src/guard.mjs +488 -0
- package/src/health.mjs +212 -0
- package/src/hints.mjs +129 -0
- package/src/host-integration.mjs +339 -0
- package/src/hub-membership.mjs +284 -0
- package/src/hub.mjs +296 -0
- package/src/hud.mjs +436 -0
- package/src/index-file.mjs +163 -0
- package/src/index.mjs +478 -0
- package/src/init.mjs +512 -0
- package/src/install.mjs +222 -0
- package/src/journal-read.mjs +116 -0
- package/src/journal.mjs +472 -0
- package/src/lifecycle.mjs +1444 -0
- package/src/lint.mjs +386 -0
- package/src/managed-path.mjs +192 -0
- package/src/markdown-code-spans.mjs +58 -0
- package/src/migrate-prompts.mjs +171 -0
- package/src/migrate-template.mjs +170 -0
- package/src/migrate.mjs +90 -0
- package/src/misuse-read.mjs +61 -0
- package/src/model-request.mjs +33 -0
- package/src/model.mjs +523 -0
- package/src/modules.mjs +355 -0
- package/src/naming.mjs +55 -0
- package/src/native-action.mjs +59 -0
- package/src/native-record.mjs +244 -0
- package/src/new.mjs +1321 -0
- package/src/output-identity.mjs +111 -0
- package/src/parent-plan.mjs +58 -0
- package/src/parse-cache.mjs +99 -0
- package/src/path-containment.mjs +15 -0
- package/src/pickup-card.mjs +293 -0
- package/src/pickup.mjs +671 -0
- package/src/prompt.mjs +25 -0
- package/src/prompts.mjs +585 -0
- package/src/public-inspection.mjs +19 -0
- package/src/query.mjs +977 -0
- package/src/record-action.mjs +11 -0
- package/src/record-create.mjs +82 -0
- package/src/reference-planner.mjs +388 -0
- package/src/reference-values.mjs +11 -0
- package/src/rename.mjs +107 -0
- package/src/render.mjs +678 -0
- package/src/roadmap.mjs +184 -0
- package/src/runlist.mjs +944 -0
- package/src/section.mjs +225 -0
- package/src/semantic-search-client.mjs +58 -0
- package/src/ship.mjs +166 -0
- package/src/show.mjs +101 -0
- package/src/skill-drift.mjs +98 -0
- package/src/source-editor.mjs +491 -0
- package/src/stages.mjs +73 -0
- package/src/state-migration.mjs +123 -0
- package/src/stats.mjs +200 -0
- package/src/status-metadata.mjs +87 -0
- package/src/statuses.mjs +721 -0
- package/src/summary.mjs +74 -0
- package/src/surfaces.mjs +28 -0
- package/src/sync-status.mjs +316 -0
- package/src/template-store.mjs +62 -0
- package/src/update.mjs +338 -0
- package/src/use.mjs +58 -0
- package/src/util.mjs +337 -0
- package/src/validate.mjs +795 -0
- package/src/watch.mjs +56 -0
- package/src/xref.mjs +406 -0
- package/src/yardstick-command.mjs +178 -0
- 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
|
+
}
|