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.
- package/README.md +175 -0
- package/lib/ansi.mjs +224 -0
- package/lib/cawdev.mjs +104 -0
- package/lib/code-map.mjs +164 -0
- package/lib/harness-prompt.mjs +197 -0
- package/lib/roadmap-format.mjs +453 -0
- package/lib/run-plugin.mjs +119 -0
- package/lib/secrets.mjs +290 -0
- package/lib/stage-tools.mjs +384 -0
- package/lib/tool-line.mjs +92 -0
- package/lib/tool-rules.mjs +282 -0
- package/lib/transcript-batch.mjs +88 -0
- package/lib/usage-limit.mjs +80 -0
- package/lib/usage-report.mjs +142 -0
- package/lib/usage.mjs +119 -0
- package/mcp/README.md +273 -0
- package/mcp/orchestration-smoke.mjs +267 -0
- package/mcp/server.mjs +2163 -0
- package/mcp/smoke.mjs +220 -0
- package/package.json +20 -0
- package/runner/README.md +930 -0
- package/runner/attach.mjs +2397 -0
- package/runner/banner.mjs +106 -0
- package/runner/bootstrap.mjs +501 -0
- package/runner/brand.mjs +57 -0
- package/runner/cawdev.mjs +414 -0
- package/runner/control.mjs +225 -0
- package/runner/history.mjs +91 -0
- package/runner/input.mjs +355 -0
- package/runner/macbook-laptop.json +48 -0
- package/runner/runner.mjs +7445 -0
- package/runner/scrollback.mjs +165 -0
- package/runner/select.mjs +316 -0
- package/runner/session-store.mjs +78 -0
- package/runner/sign-in.mjs +210 -0
- package/runner/stub-agent.mjs +212 -0
- package/runner/token-store.mjs +107 -0
|
@@ -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
|
+
}
|