cawdev-cli 0.9.0

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.
@@ -0,0 +1,197 @@
1
+ /**
2
+ * What a session is told before its task — R107, R108, R109.
3
+ *
4
+ * In `lib/` for `run-plugin.mjs`'s reason: this is where R107's TWO LEVELS of
5
+ * configuration meet — the console's instincts and the repository's own
6
+ * `.ai-config.md` — and "did both halves arrive, labelled, in the right order"
7
+ * is a question about a string. It needs no daemon, no platform and no
8
+ * repository to answer, so it should not need them to test.
9
+ */
10
+
11
+ import { readFile, stat } from 'node:fs/promises';
12
+ import { join } from 'node:path';
13
+
14
+ /** Where R107's advanced half lives, in the target repository. */
15
+ export const AI_CONFIG = '.ai-config.md';
16
+
17
+ /** A ceiling on the repository's half. See `readRepoConfig`. */
18
+ const MAX_CONFIG_BYTES = 32 * 1024;
19
+
20
+ /**
21
+ * What this project makes an agent do without being asked, and what the last
22
+ * session left — R107, R108, R109.
23
+ *
24
+ * <p>APPENDED to the profile's prompt rather than woven into it, and the three
25
+ * blocks are labelled. A session that cannot tell "what this project always
26
+ * wants" from "what you were asked to do" will treat one as the other, and the
27
+ * failure mode of getting that backwards is a run that does the standing rule
28
+ * instead of the task.
29
+ *
30
+ * <p>The `.ai-config.md` half of R107 is read HERE, out of the checkout, and
31
+ * never through the platform: the repository is the source of truth for what is
32
+ * in it, and a file copied into a database is a reading that goes stale. So the
33
+ * two halves meet in this function and nowhere else.
34
+ */
35
+ export function harnessPrompt({
36
+ instincts, briefing, plan, lifecycle, repoConfig, experts, skills, cannotDelegate,
37
+ }) {
38
+ const parts = [];
39
+
40
+ // R147. What this session was handed, by the NAME the CLI answers to. Before
41
+ // this the experts and skills reached the session only through the CLI's own
42
+ // tool listing, beside its built-in agents, and nothing in a hundred and
43
+ // forty runs chose one. A capability nobody mentions is a capability nobody
44
+ // uses; this is the mention. Experts and skills are listed separately
45
+ // because they are used differently — one is delegated to, the other is read
46
+ // before work it covers — and each line carries its own description, which
47
+ // is what the model decides on.
48
+ //
49
+ // R161. A REQUIRED one is named FIRST and in its own block. Two lists rather
50
+ // than one annotated list, because a `(required)` suffix on a line in a list
51
+ // of twelve is a detail; a heading is an instruction. The AS_NEEDED wording
52
+ // below is byte-for-byte what it was — when a project requires nothing, and
53
+ // that is every project until somebody says otherwise, this function returns
54
+ // exactly what it returned before the entry.
55
+ const reach = [];
56
+ const requiredOf = (list) => (list ?? []).filter((each) => each.required);
57
+ const optionalOf = (list) => (list ?? []).filter((each) => !each.required);
58
+ const lines = (list) => list
59
+ .map((each) => `- \`${each.qualified}\` — ${each.description}`).join('\n');
60
+
61
+ const mustExperts = requiredOf(experts);
62
+ const mayExperts = optionalOf(experts);
63
+ if (mustExperts.length) {
64
+ reach.push('**Experts you MUST use** — this project requires each of these. Delegate '
65
+ + 'with the `Agent` tool, `subagent_type` exactly as written, before this stage is '
66
+ + `finished.\n${lines(mustExperts)}`);
67
+ }
68
+ if (mayExperts.length) {
69
+ reach.push('**Experts** — delegate with the `Agent` tool, `subagent_type` exactly as '
70
+ + 'written. Use one whenever its description fits the step you are on; they were '
71
+ + 'chosen for this project.\n'
72
+ + lines(mayExperts));
73
+ } else if (!mustExperts.length && cannotDelegate?.length) {
74
+ reach.push(`**Experts** — ${cannotDelegate.join(' ')}`);
75
+ }
76
+
77
+ const mustSkills = requiredOf(skills);
78
+ const maySkills = optionalOf(skills);
79
+ if (mustSkills.length) {
80
+ reach.push('**Skills you MUST use** — this project requires each of these. Invoke it '
81
+ + 'with the `Skill` tool, `skill` exactly as written, before this stage is '
82
+ + `finished.\n${lines(mustSkills)}`);
83
+ }
84
+ if (maySkills.length) {
85
+ reach.push('**Skills** — invoke with the `Skill` tool, `skill` exactly as written, BEFORE '
86
+ + 'work its description covers.\n'
87
+ + lines(maySkills));
88
+ }
89
+ if (reach.length) {
90
+ parts.push(`## What this session may reach\n\n${reach.join('\n\n')}`);
91
+ }
92
+
93
+ const rules = [
94
+ ...(instincts ?? []).map((each) => `- **${each.name}** — ${each.body}`),
95
+ ];
96
+ if (rules.length) {
97
+ parts.push(`## How work is done here\n\n${rules.join('\n')}`);
98
+ }
99
+ if (repoConfig) {
100
+ // Verbatim and last of the two, so a rule somebody put in the repository
101
+ // wins a disagreement with one set in the console. The file is the thing
102
+ // they can see from the checkout they are standing in.
103
+ parts.push(`## From \`${AI_CONFIG}\` in this repository\n\n${repoConfig}`);
104
+ }
105
+
106
+ if (briefing) {
107
+ parts.push(`## Where this work had got to\n\nA previous session on this branch left `
108
+ + `this. It is what it believed, not necessarily what is true now — check `
109
+ + `anything you are about to rely on.\n\n${briefing}`);
110
+ }
111
+
112
+ // R124. The plan somebody approved, handed to the phase that carries it out.
113
+ //
114
+ // AFTER the briefing and BEFORE the steps, which is the order a person would
115
+ // read them in: what this project always wants, where the work had got to,
116
+ // what was decided for this card, and then what to do about it.
117
+ //
118
+ // VERBATIM, for `stagePrompt`'s reason — a session that re-derived the plan
119
+ // from a précis would be planning again, which is the one thing a phase after
120
+ // an approved plan must not do.
121
+ //
122
+ // The staleness note is part of the same block rather than a warning
123
+ // somewhere else. A plan and the reason to doubt it belong in one place, or
124
+ // the plan gets read and the doubt does not.
125
+ //
126
+ // R145's collisions sit beside it for the same reason, and NOT in bold: bold
127
+ // is for the line that says the plan itself may be wrong. This one says
128
+ // something ELSE may be moving, and it is a heads-up rather than a stop.
129
+ if (plan?.body) {
130
+ parts.push(`## The plan for this card\n\n`
131
+ + `This was written and agreed in the plan phase. Follow it. If it turns out to be `
132
+ + `wrong, say so and say why — do not quietly do something else.`
133
+ + `${plan.staleness ? `\n\n**${plan.staleness}**` : ''}`
134
+ + `${plan.collisions ? `\n\n${plan.collisions}` : ''}`
135
+ + `\n\n${plan.body}`);
136
+ }
137
+
138
+ // i138. The steps are the DAEMON's to report, and the wording used to say the
139
+ // opposite. R109 handed the lifecycle to the session as an instruction —
140
+ // "report each one as you begin and end it" — and R112 took it back: one
141
+ // process per stage, the daemon reports each, and `toolsForStage` strips
142
+ // `report` from every stage because "done" ends the RUN. The sentence stayed.
143
+ // So every staged session was told to use a tool it did not hold, and a
144
+ // session told to do something finds a way: on a stage spawned with a
145
+ // permission prompt, `report` became a question on the inbox — one of the two
146
+ // i138 was filed about — and where a person allowed it, the run ended in the
147
+ // middle of the walk with the stages behind it SKIPPED. The gate line said the
148
+ // same thing about `ask_user`, and the daemon raises the gate itself.
149
+ if (lifecycle?.length) {
150
+ const steps = lifecycle.map((stage, at) => {
151
+ const gate = stage.gate === 'ASK'
152
+ ? ' — **a person decides here**: when this step ends, they read what it produced, '
153
+ + 'and the next step waits for their answer'
154
+ : '';
155
+ // R129. The one step whose MEANING the project can change. A person
156
+ // reading this list should not have to know the project's settings to
157
+ // understand what the TEST step was going to do.
158
+ const mode = stage.stage === 'TEST' && stage.testMode === 'TESTBOOK'
159
+ ? ' — writing the testbook, not running it'
160
+ : '';
161
+ return `${at + 1}. **${stage.stage}**${mode}${gate}`;
162
+ });
163
+ parts.push('## The steps to work in\n\n'
164
+ + 'Each step is its own session, and this session is one of them — which one is said '
165
+ + 'below. The daemon reports each step as it begins and ends, so the people watching '
166
+ + 'can see where you are; you do not, and `report` is not among this step\'s tools. '
167
+ + 'Where the instructions above say to finish with `report`, that is a run with no '
168
+ + 'steps: here, say it as your last message instead. That message is what is recorded '
169
+ + 'as this step\'s output, what the next step is handed, and what a person reads at a '
170
+ + `gate. The step ends when your turn does.\n\n${steps.join('\n')}`);
171
+ }
172
+
173
+ return parts.length ? `\n\n---\n\n${parts.join('\n\n')}` : '';
174
+ }
175
+
176
+ /**
177
+ * The repository's own half of R107, if it has one.
178
+ *
179
+ * <p>Capped, and the cap is not paranoia: this goes into an opening prompt, and
180
+ * a repository that committed a megabyte here would crowd out the task without
181
+ * anybody being told why.
182
+ */
183
+ export async function readRepoConfig(cwd) {
184
+ try {
185
+ const path = join(cwd, AI_CONFIG);
186
+ const size = (await stat(path)).size;
187
+ if (size > MAX_CONFIG_BYTES) {
188
+ return `(${AI_CONFIG} is ${Math.round(size / 1024)}kB and was not read — it is meant to `
189
+ + 'be a page of rules, not a document.)';
190
+ }
191
+ return (await readFile(path, 'utf8')).trim() || null;
192
+ } catch {
193
+ // Not having one is the ordinary case, not a failure.
194
+ return null;
195
+ }
196
+ }
197
+
@@ -0,0 +1,453 @@
1
+ // The ROADMAP.md format, in one place: parsing it and writing it.
2
+ //
3
+ // The importer and the exporter must agree exactly, or export → import → export
4
+ // is not a fixed point and the generated file churns on every release. Keeping
5
+ // both halves in one file is how they stay agreed.
6
+
7
+ /**
8
+ * What to call a card — R127 — and, since R221, what identifies one.
9
+ *
10
+ * <p>The API says, in `ref`. This is the fallback for an entry parsed out of a
11
+ * file, where the prefix in the heading is the only thing there is to read. One
12
+ * place on this side of the wire spells the prefix, so the parser and the
13
+ * writer cannot drift apart.
14
+ *
15
+ * <p>R221 gave each kind its own number sequence, so `R12` and `i12` are two
16
+ * cards and the ref — not the number — is what names one: in a heading, in a
17
+ * `Related:` line, and in what the importer sends back.
18
+ */
19
+ export function entryRef(entry) {
20
+ return entry.ref ?? ((entry.kind === 'ISSUE' ? 'i' : 'R') + entry.number);
21
+ }
22
+
23
+ /**
24
+ * A `Related:` / `After:` token, normalised — `R12`, `i12`; a bare `12` is a
25
+ * roadmap card, which is what a bare number meant before R221 and what the
26
+ * server reads it as. Null for a token that is not a card at all.
27
+ */
28
+ export function parseRefToken(token) {
29
+ const match = /^([RrIi])?(\d+)$/.exec(token.trim());
30
+ if (!match || Number(match[2]) <= 0) return null;
31
+ const kind = match[1]?.toLowerCase() === 'i' ? 'ISSUE' : 'ROADMAP';
32
+ return { ref: entryRef({ kind, number: Number(match[2]) }), kind, number: Number(match[2]) };
33
+ }
34
+
35
+ /**
36
+ * The order refs are written in: by number, a roadmap card before the issue
37
+ * sharing its number — the same order the server keeps them in, so an export
38
+ * does not churn on the order the ids happen to arrive in.
39
+ */
40
+ function byNumberThenKind(a, b) {
41
+ return a.number - b.number || (a.kind === 'ISSUE' ? 1 : 0) - (b.kind === 'ISSUE' ? 1 : 0);
42
+ }
43
+
44
+ /** Status as stored, from how the file writes it: "IN PROGRESS" -> IN_PROGRESS. */
45
+ export function statusFromDisplay(display) {
46
+ return display.trim().replace(/\s+/g, '_').toUpperCase();
47
+ }
48
+
49
+ /** Status as the file writes it: IN_PROGRESS -> "IN PROGRESS". */
50
+ export function statusToDisplay(status) {
51
+ return status.replace(/_/g, ' ');
52
+ }
53
+
54
+ /**
55
+ * Sections that come from a status rather than from the entry's own `section`.
56
+ * The file has always grouped these two this way, and deriving them means a
57
+ * declined entry cannot end up filed under a phase it is no longer part of.
58
+ *
59
+ * Two, and REVIEW is deliberately not a third: work waiting to be read is still
60
+ * part of the phase it belongs to, and moving it to a section of its own would
61
+ * take it out of its phase for a few days and put it back afterwards.
62
+ */
63
+ export const STATUS_SECTIONS = {
64
+ CONSIDERING: 'Considering — wanted, not settled',
65
+ DECLINED: 'Declined — decided against, with the reason',
66
+ };
67
+
68
+ /**
69
+ * ISSUES.md's sections — R85.
70
+ *
71
+ * Every one of them is derived from the status, because an issue has no phase:
72
+ * a defect does not belong to "Phase 3", it belongs to "open" or "fixed". That
73
+ * is the whole reason the two files are separate rather than one file with a
74
+ * kind column — the roadmap is organised by intent and this is organised by
75
+ * whether it is still true.
76
+ */
77
+ export const ISSUE_SECTIONS = {
78
+ NEW: 'New — filed, not yet triaged',
79
+ CONFIRMED: 'Confirmed — real, waiting for somebody',
80
+ IN_DEVELOPMENT: 'In development — somebody is fixing it',
81
+ MERGED: 'Resolved — the fix landed',
82
+ SHIPPED: 'Released — the fix shipped',
83
+ DECLINED: "Won't fix — decided against, with the reason",
84
+ };
85
+
86
+ /** The order ISSUES.md reads in: what needs somebody first, history last. */
87
+ export const ISSUE_SECTION_ORDER = Object.values(ISSUE_SECTIONS);
88
+
89
+ /** Where an issue belongs in ISSUES.md. */
90
+ export function issueSectionOf(entry) {
91
+ return ISSUE_SECTIONS[entry.status] ?? 'Open';
92
+ }
93
+
94
+ /**
95
+ * Issues, rendered as their own file.
96
+ *
97
+ * Sorted by severity inside each section, critical first, because that IS the
98
+ * question the file is read to answer. `renderRoadmap` sorts by number, which
99
+ * on a roadmap is chronological and useful and here would bury a critical
100
+ * defect behind six minor ones filed after it.
101
+ */
102
+ export function renderIssues(entries, { preamble }) {
103
+ const rank = { CRITICAL: 0, MEDIUM: 1, MINOR: 2 };
104
+ const bySection = new Map();
105
+ for (const entry of entries) {
106
+ const section = issueSectionOf(entry);
107
+ if (!bySection.has(section)) bySection.set(section, []);
108
+ bySection.get(section).push(entry);
109
+ }
110
+ for (const list of bySection.values()) {
111
+ list.sort((a, b) =>
112
+ (rank[a.severity] ?? 3) - (rank[b.severity] ?? 3) || a.number - b.number);
113
+ }
114
+
115
+ const ordered = [
116
+ ...ISSUE_SECTION_ORDER.filter((section) => bySection.has(section)),
117
+ ...[...bySection.keys()].filter((section) => !ISSUE_SECTION_ORDER.includes(section)),
118
+ ];
119
+
120
+ const parts = [preamble.trimEnd(), ''];
121
+ for (const section of ordered) {
122
+ parts.push('---', '', `## ${section}`, '');
123
+ for (const entry of bySection.get(section)) {
124
+ parts.push(renderEntry(entry), '');
125
+ }
126
+ }
127
+ return `${parts.join('\n').trimEnd()}\n`;
128
+ }
129
+
130
+ /** Where an entry belongs in the exported file. */
131
+ export function sectionOf(entry) {
132
+ return STATUS_SECTIONS[entry.status] ?? entry.section ?? 'Roadmap';
133
+ }
134
+
135
+ /**
136
+ * Sections these entries use that the given order does not name, with how many
137
+ * entries each holds.
138
+ *
139
+ * `renderRoadmap` appends an unknown section rather than refusing it — a new
140
+ * phase is legitimate, and an exporter that fails on one would block the
141
+ * release that introduces it. But appending silently is how the list fell
142
+ * behind in the first place, so the exporter says which sections it did not
143
+ * know about. Status-derived sections are never "unlisted": they are the
144
+ * format's own, and no `sectionOrder` should have to repeat them.
145
+ */
146
+ export function unlistedSections(entries, sectionOrder = []) {
147
+ const known = new Set([...sectionOrder, ...Object.values(STATUS_SECTIONS)]);
148
+ const counts = new Map();
149
+ for (const entry of entries) {
150
+ const section = sectionOf(entry);
151
+ if (known.has(section)) continue;
152
+ counts.set(section, (counts.get(section) ?? 0) + 1);
153
+ }
154
+ return [...counts].map(([section, count]) => ({ section, count }));
155
+ }
156
+
157
+ /**
158
+ * Parses a ROADMAP.md into entries.
159
+ *
160
+ * Only the part after the preamble's closing rule is considered. The Format
161
+ * section contains a fenced *example* heading — "### R7 — a short title" —
162
+ * which is not an entry; parsing the whole file silently shifts every number
163
+ * after it by one. That cost a run of misnumbered entries the first time.
164
+ */
165
+ export function parseRoadmap(text) {
166
+ const body = afterPreamble(text);
167
+ const entries = [];
168
+ const problems = [];
169
+
170
+ let currentSection = null;
171
+
172
+ for (const block of splitBlocks(body)) {
173
+ if (block.kind === 'section') {
174
+ currentSection = block.title;
175
+ continue;
176
+ }
177
+
178
+ // Either prefix — R127. An old export headed `### R90` still parses, which
179
+ // is what import.mjs needs; the prefix says which kind the card is.
180
+ const heading = /^([Ri])(\d+)\s+[—–-]\s+(.+)$/.exec(block.heading);
181
+ if (!heading) {
182
+ problems.push(
183
+ `Entry heading is not "R<number> — title" or "i<number> — title": ${block.heading}`,
184
+ );
185
+ continue;
186
+ }
187
+
188
+ const entry = {
189
+ number: Number(heading[2]),
190
+ kind: heading[1] === 'i' ? 'ISSUE' : 'ROADMAP',
191
+ title: heading[3].trim(),
192
+ status: 'PLANNED',
193
+ branch: null,
194
+ merge: null,
195
+ version: null,
196
+ reason: null,
197
+ related: [],
198
+ after: [],
199
+ section: currentSection,
200
+ sprint: null,
201
+ body: '',
202
+ };
203
+
204
+ const bodyLines = [];
205
+ for (const line of block.lines) {
206
+ const status = /^Status:\s*(.+)$/.exec(line.trim());
207
+ if (status) {
208
+ const value = status[1].trim();
209
+ const shipped = /^SHIPPED\s+(\S+)$/.exec(value);
210
+ if (shipped) {
211
+ entry.status = 'SHIPPED';
212
+ entry.version = shipped[1];
213
+ } else {
214
+ entry.status = statusFromDisplay(value);
215
+ }
216
+ continue;
217
+ }
218
+ const branch = /^Branch:\s*(.+)$/.exec(line.trim());
219
+ if (branch) {
220
+ entry.branch = branch[1].trim();
221
+ continue;
222
+ }
223
+ const severity = /^Severity:\s*(.+)$/.exec(line.trim());
224
+ if (severity) {
225
+ entry.severity = severity[1].trim();
226
+ continue;
227
+ }
228
+ const development = /^Development:\s*(.+)$/.exec(line.trim());
229
+ if (development) {
230
+ entry.development = statusFromDisplay(development[1].trim());
231
+ continue;
232
+ }
233
+ const merge = /^Merged:\s*(.+)$/.exec(line.trim());
234
+ if (merge) {
235
+ entry.merge = merge[1].trim();
236
+ continue;
237
+ }
238
+ const related = /^Related:\s*(.+)$/.exec(line.trim());
239
+ if (related) {
240
+ // Both prefixes — R127 — and the prefix is kept, because since R221
241
+ // it is the identity: `i91` and `R91` are two cards. Without that,
242
+ // render → parse → render would rewrite one as the other and the
243
+ // round trip would stop being a fixed point.
244
+ const tokens = related[1].split(/[,\s]+/).map(parseRefToken).filter(Boolean);
245
+ entry.related = tokens.map(({ number }) => number);
246
+ entry.relatedRefs = tokens.map(({ ref }) => ref);
247
+ continue;
248
+ }
249
+ const after = /^After:\s*(.+)$/.exec(line.trim());
250
+ if (after) {
251
+ // R181: the cards this one starts coding after. Same shape and same
252
+ // fixed-point reasoning as Related above.
253
+ const tokens = after[1].split(/[,\s]+/).map(parseRefToken).filter(Boolean);
254
+ entry.after = tokens.map(({ number }) => number);
255
+ entry.afterRefs = tokens.map(({ ref }) => ref);
256
+ continue;
257
+ }
258
+ const sprint = /^Sprint:\s*S(\d+)\s*(.*)$/.exec(line.trim());
259
+ if (sprint) {
260
+ // R257: `Sprint: S1 Notifications` — the sprint's number and its name
261
+ // as the file says it. The same shape the API's `sprint` has, so a
262
+ // re-render from either is byte-identical; the importer sends only
263
+ // the number, since a sprint's name is the database's to say.
264
+ const number = Number(sprint[1]);
265
+ entry.sprint = { number, ref: `S${number}`, name: sprint[2].trim() };
266
+ continue;
267
+ }
268
+ bodyLines.push(line);
269
+ }
270
+
271
+ // A horizontal rule at column 0 separates sections in this format, so a
272
+ // trailing one belongs to the file's structure, not to the last entry of a
273
+ // section. Left in, it reappears inside the body on the next export and the
274
+ // round trip stops being a fixed point — which is exactly how this was
275
+ // found.
276
+ while (bodyLines.length && /^\s*$|^-{3,}\s*$/.test(bodyLines.at(-1))) {
277
+ bodyLines.pop();
278
+ }
279
+ entry.body = bodyLines.join('\n').trim();
280
+
281
+ // DECLINED needs a reason; the file writes it as a "Reason:" paragraph.
282
+ if (entry.status === 'DECLINED') {
283
+ const found = /Reason:\s*([\s\S]*)/.exec(entry.body);
284
+ entry.reason = (found ? found[1] : entry.body).replace(/\s+/g, ' ').trim();
285
+ }
286
+
287
+ entries.push(entry);
288
+ }
289
+
290
+ entries.sort((left, right) => left.number - right.number);
291
+ return { entries, problems };
292
+ }
293
+
294
+ function afterPreamble(text) {
295
+ const separator = text.indexOf('\n---\n');
296
+ return separator === -1 ? text : text.slice(separator);
297
+ }
298
+
299
+ /** Yields section headings and entry blocks in document order. */
300
+ function* splitBlocks(text) {
301
+ const lines = text.split('\n');
302
+ let entry = null;
303
+
304
+ for (const line of lines) {
305
+ const section = /^##\s+(.+)$/.exec(line);
306
+ if (section && !line.startsWith('###')) {
307
+ if (entry) {
308
+ yield entry;
309
+ entry = null;
310
+ }
311
+ yield { kind: 'section', title: section[1].trim() };
312
+ continue;
313
+ }
314
+
315
+ const heading = /^###\s+(.+)$/.exec(line);
316
+ if (heading) {
317
+ if (entry) yield entry;
318
+ entry = { kind: 'entry', heading: heading[1].trim(), lines: [] };
319
+ continue;
320
+ }
321
+
322
+ if (entry) entry.lines.push(line);
323
+ }
324
+ if (entry) yield entry;
325
+ }
326
+
327
+ /**
328
+ * Writes entries as ROADMAP.md's entry sections.
329
+ *
330
+ * Sections appear in `sectionOrder`, then any others in first-appearance order,
331
+ * with the two status-derived sections last — a roadmap reads forward through
332
+ * the work and ends with what was set aside.
333
+ */
334
+ export function renderRoadmap(entries, { preamble, sectionOrder = [] }) {
335
+ const bySection = new Map();
336
+ for (const entry of [...entries].sort((left, right) => left.number - right.number)) {
337
+ const section = sectionOf(entry);
338
+ if (!bySection.has(section)) bySection.set(section, []);
339
+ bySection.get(section).push(entry);
340
+ }
341
+
342
+ const statusSections = Object.values(STATUS_SECTIONS);
343
+ const ordered = [
344
+ ...sectionOrder.filter((section) => bySection.has(section)),
345
+ ...[...bySection.keys()].filter(
346
+ (section) => !sectionOrder.includes(section) && !statusSections.includes(section),
347
+ ),
348
+ ...statusSections.filter((section) => bySection.has(section)),
349
+ ];
350
+
351
+ const parts = [preamble.trimEnd(), ''];
352
+ for (const section of ordered) {
353
+ parts.push('---', '', `## ${section}`, '');
354
+ for (const entry of bySection.get(section)) {
355
+ parts.push(renderEntry(entry), '');
356
+ }
357
+ }
358
+
359
+ // Exactly one trailing newline, so the file is stable under any editor.
360
+ return parts.join('\n').replace(/\n+$/, '') + '\n';
361
+ }
362
+
363
+ function renderEntry(entry) {
364
+ const lines = [`### ${entryRef(entry)} — ${entry.title}`, ''];
365
+
366
+ const status =
367
+ entry.status === 'SHIPPED' && entry.version
368
+ ? `SHIPPED ${entry.version}`
369
+ : statusToDisplay(entry.status);
370
+ lines.push(`Status: ${status}`);
371
+
372
+ // R85. An issue carries how badly it is broken, and it is a line rather than
373
+ // a prefix on the title: welded into a title it goes stale the moment
374
+ // somebody re-ranks it, which is exactly what triage is for.
375
+ if (entry.severity) {
376
+ lines.push(`Severity: ${entry.severity}`);
377
+ }
378
+
379
+ // Each status writes only what it currently claims. A MERGED entry keeps its
380
+ // branch in the database as history, but the file must not print it: the
381
+ // branch is expected to be deleted, and `--live` would then be reading a claim
382
+ // the entry is no longer making.
383
+ //
384
+ // IN DEVELOPMENT does print it, for the mirror-image reason: the branch is
385
+ // where the work is, and it is still there until MERGED says where it went.
386
+ // R84 — one status where CODING, REVIEW and DONE each printed one.
387
+ if (entry.status === 'IN_DEVELOPMENT' && entry.branch) {
388
+ lines.push(`Branch: ${entry.branch}`);
389
+ }
390
+ // How far that branch has got, when the platform knows — R84. It is the WORK
391
+ // ITEM's status and not the card's, which is exactly why it is a second line
392
+ // rather than a fifth spelling of Status. A file whose cards say only "in
393
+ // development" is still a valid roadmap; this line is what makes it readable.
394
+ const development = entry.development ?? entry.workItem?.status;
395
+ if (entry.status === 'IN_DEVELOPMENT' && development) {
396
+ lines.push(`Development: ${statusToDisplay(development)}`);
397
+ }
398
+ if (entry.status === 'MERGED' && entry.merge) {
399
+ lines.push(`Merged: ${entry.merge}`);
400
+ }
401
+ if (entry.related?.length) {
402
+ // Each card written the way its own card is written — R127 — and since
403
+ // R221 the ref is what tells R12 from i12, so the pair is kept together
404
+ // rather than keyed by a number two cards may share. Sorted, so an export
405
+ // does not churn on the order the ids happen to arrive in.
406
+ const written = entry.related
407
+ .map((number, index) => parseRefToken(entry.relatedRefs?.[index] ?? `R${number}`))
408
+ .filter(Boolean)
409
+ .sort(byNumberThenKind)
410
+ .map(({ ref }) => ref);
411
+ lines.push(`Related: ${written.join(', ')}`);
412
+ }
413
+ if (entry.after?.length) {
414
+ // R181. The API gives `after` as cards ({number, ref, ...}); the parser
415
+ // gives numbers beside `afterRefs`. Either way, written as its own card is
416
+ // written and sorted, for Related's reasons.
417
+ const written = entry.after
418
+ .map((item, index) =>
419
+ typeof item === 'number'
420
+ ? parseRefToken(entry.afterRefs?.[index] ?? `R${item}`)
421
+ : parseRefToken(item.ref ?? `R${item.number}`),
422
+ )
423
+ .filter(Boolean)
424
+ .sort(byNumberThenKind)
425
+ .map(({ ref }) => ref);
426
+ lines.push(`After: ${written.join(', ')}`);
427
+ }
428
+ if (entry.sprint) {
429
+ // R257. A line on the card and never a heading: the section is where the
430
+ // file groups, and a sprint is a set a person opened across sections. The
431
+ // API's `sprint` and the parser's are the same shape, which is what makes
432
+ // render → parse → render a fixed point. State is not written — the file
433
+ // says which sprint, the database says whether it is still open.
434
+ lines.push(`Sprint: ${entry.sprint.ref ?? `S${entry.sprint.number}`} ${entry.sprint.name}`.trimEnd());
435
+ }
436
+
437
+ if (entry.body?.trim()) {
438
+ lines.push('', entry.body.trim());
439
+ }
440
+
441
+ // i232. A card declined through the API keeps its body and its reason in two
442
+ // columns (`declinedReason` from the API, `reason` from the parser); the
443
+ // hand-written file kept the reason inside the body as a "Reason:"
444
+ // paragraph, and the parser still reads it back out of there. So the reason
445
+ // is written as its own paragraph after the body — unless the body already
446
+ // carries one, judged by the same test the parser uses, since writing a
447
+ // second would break the fixed point the round-trip test pins.
448
+ const reason = (entry.declinedReason ?? entry.reason)?.trim();
449
+ if (entry.status === 'DECLINED' && reason && !/Reason:/.test(entry.body ?? '')) {
450
+ lines.push('', `Reason: ${reason}`);
451
+ }
452
+ return lines.join('\n');
453
+ }