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