@taprootio/trellis 0.1.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/LICENSE +21 -0
- package/README.md +77 -0
- package/SPEC.md +405 -0
- package/docs/import.md +273 -0
- package/package.json +59 -0
- package/profiles/taproot-ai-backlog.json +28 -0
- package/profiles/yaml-frontmatter.json +23 -0
- package/scripts/backlog-readme.mjs +84 -0
- package/scripts/pr-title-lint.mjs +69 -0
- package/scripts/trellis-history.mjs +127 -0
- package/scripts/trellis-import.mjs +141 -0
- package/scripts/trellis-init.mjs +287 -0
- package/scripts/trellis-mcp.mjs +222 -0
- package/scripts/trellis.mjs +62 -0
- package/src/backlog.mjs +667 -0
- package/src/cli.mjs +33 -0
- package/src/history.mjs +204 -0
- package/src/import.mjs +583 -0
- package/src/init.mjs +644 -0
- package/src/mcp.mjs +449 -0
- package/src/pr-title.mjs +47 -0
- package/src/profiles.mjs +68 -0
- package/src/prompts.mjs +189 -0
- package/templates/.github/pull_request_template.md +31 -0
- package/templates/trellis/branch-protection.md +116 -0
- package/templates/trellis/playbooks/code-review.md +64 -0
- package/templates/trellis/playbooks/conventions.md +56 -0
- package/templates/trellis/playbooks/pr-draft.md +39 -0
- package/templates/trellis/playbooks/work-task.md +76 -0
package/src/backlog.mjs
ADDED
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
// Trellis backlog core (zero-dependency).
|
|
2
|
+
//
|
|
3
|
+
// Reusable logic for validating backlog items and generating the derived
|
|
4
|
+
// artifacts (README tables, completed/removed indexes, backlog.json). Both the
|
|
5
|
+
// CLI (scripts/backlog-readme.mjs) and the MCP server import these functions, so
|
|
6
|
+
// every entry point takes an explicit repoRoot and holds no process-wide state.
|
|
7
|
+
//
|
|
8
|
+
// Front-matter is the small, fixed YAML subset from SPEC.md §5, parsed in-house
|
|
9
|
+
// on purpose so Trellis stays drop-in with no install step.
|
|
10
|
+
|
|
11
|
+
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
12
|
+
import { join, relative, isAbsolute } from "node:path";
|
|
13
|
+
|
|
14
|
+
// Spec version this tool implements (SemVer major.minor); see SPEC.md §9.
|
|
15
|
+
export const SPEC_VERSION = "2.3";
|
|
16
|
+
|
|
17
|
+
// The backlog root defaults to `trellis/` and is overridable per repo via the
|
|
18
|
+
// config's `tasksDir` key (SPEC §2/§7). The config file itself lives at a FIXED
|
|
19
|
+
// path under `trellis/`, independent of `tasksDir`, so the tool can always find
|
|
20
|
+
// it before it knows where the task tree is — see paths().
|
|
21
|
+
export const DEFAULT_TASKS_DIR = "trellis";
|
|
22
|
+
export const CONFIG_DIR = "trellis";
|
|
23
|
+
|
|
24
|
+
export const MARKERS = {
|
|
25
|
+
milestones: ["<!-- BEGIN GENERATED:MILESTONES -->", "<!-- END GENERATED:MILESTONES -->"],
|
|
26
|
+
completed: ["<!-- BEGIN GENERATED:COMPLETED -->", "<!-- END GENERATED:COMPLETED -->"],
|
|
27
|
+
removed: ["<!-- BEGIN GENERATED:REMOVED -->", "<!-- END GENERATED:REMOVED -->"],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// The config path is fixed at `<repo>/trellis/backlog.config.json` and never
|
|
31
|
+
// depends on `cfg` — loadConfig calls paths() before any config is known. The
|
|
32
|
+
// task tree (active/completed/removed + generated artifacts) lives under
|
|
33
|
+
// `cfg.tasksDir` (default `trellis/`), so callers that touch those dirs MUST
|
|
34
|
+
// pass the loaded config.
|
|
35
|
+
export function paths(repoRoot, cfg) {
|
|
36
|
+
const tasksDir = (cfg && cfg.tasksDir) || DEFAULT_TASKS_DIR;
|
|
37
|
+
const tasks = join(repoRoot, tasksDir);
|
|
38
|
+
return {
|
|
39
|
+
config: join(repoRoot, CONFIG_DIR, "backlog.config.json"),
|
|
40
|
+
// The team roster lives at the FIXED config home (next to backlog.config.json),
|
|
41
|
+
// independent of tasksDir — like the config, it is authored input, not a
|
|
42
|
+
// generated artifact (SPEC §7.2).
|
|
43
|
+
team: join(repoRoot, CONFIG_DIR, "team.json"),
|
|
44
|
+
tasks,
|
|
45
|
+
active: join(tasks, "active"),
|
|
46
|
+
completedTasks: join(tasks, "completed", "tasks"),
|
|
47
|
+
removed: join(tasks, "removed"),
|
|
48
|
+
readme: join(tasks, "README.md"),
|
|
49
|
+
backlogJson: join(tasks, "backlog.json"),
|
|
50
|
+
completedIndex: join(tasks, "completed", "index.md"),
|
|
51
|
+
removedIndex: join(tasks, "removed", "index.md"),
|
|
52
|
+
// A derived, NON-gated report (SPEC §8.4), not a generated artifact: materialized
|
|
53
|
+
// from git history by `trellis history --write`, never written by the generator and
|
|
54
|
+
// never part of `--check`. Pathed here only so the deriver (src/history.mjs) and
|
|
55
|
+
// the CLI agree on its location; it is gitignored, not committed.
|
|
56
|
+
historyJson: join(tasks, "history.json"),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ----------------------------------------------------------------- config
|
|
61
|
+
export function loadConfig(repoRoot) {
|
|
62
|
+
const warnings = [];
|
|
63
|
+
const configPath = paths(repoRoot).config;
|
|
64
|
+
if (!existsSync(configPath)) return { cfg: null, warnings, errors: ["missing backlog.config.json"] };
|
|
65
|
+
let cfg;
|
|
66
|
+
try {
|
|
67
|
+
cfg = JSON.parse(readFileSync(configPath, "utf8"));
|
|
68
|
+
} catch (e) {
|
|
69
|
+
return { cfg: null, warnings, errors: [`backlog.config.json is not valid JSON (${e.message})`] };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const errors = [];
|
|
73
|
+
if (typeof cfg.idPrefix !== "string" || !cfg.idPrefix) errors.push("config: `idPrefix` must be a non-empty string");
|
|
74
|
+
if (!Number.isInteger(cfg.idWidth) || cfg.idWidth < 1) errors.push("config: `idWidth` must be a positive integer");
|
|
75
|
+
if (!Array.isArray(cfg.milestones) || cfg.milestones.length === 0) errors.push("config: `milestones` must be a non-empty array");
|
|
76
|
+
if (!Array.isArray(cfg.priorities) || cfg.priorities.length === 0) errors.push("config: `priorities` must be a non-empty array");
|
|
77
|
+
const effortValues = Array.isArray(cfg.effort) ? cfg.effort : cfg.effort && cfg.effort.values;
|
|
78
|
+
if (!Array.isArray(effortValues) || effortValues.length === 0) {
|
|
79
|
+
errors.push("config: `effort` must be an array of numbers (or an object with a `values` array)");
|
|
80
|
+
} else if (!effortValues.every((v) => typeof v === "number" && Number.isFinite(v))) {
|
|
81
|
+
errors.push("config: `effort` values must all be numbers");
|
|
82
|
+
}
|
|
83
|
+
cfg.effortValues = Array.isArray(effortValues) ? effortValues : [];
|
|
84
|
+
attachEffortScale(cfg, errors);
|
|
85
|
+
|
|
86
|
+
// `tasksDir` is optional (defaults to `trellis/`); when present it must be a
|
|
87
|
+
// non-empty repo-relative path that stays inside the repo — `join(repoRoot,
|
|
88
|
+
// tasksDir)` must not escape via an absolute path or a `..` segment. The config
|
|
89
|
+
// home stays fixed regardless (see paths()).
|
|
90
|
+
if (cfg.tasksDir != null) {
|
|
91
|
+
if (typeof cfg.tasksDir !== "string" || !cfg.tasksDir.trim()) {
|
|
92
|
+
errors.push("config: `tasksDir` must be a non-empty string when present");
|
|
93
|
+
} else if (isAbsolute(cfg.tasksDir) || cfg.tasksDir.split(/[/\\]/).includes("..")) {
|
|
94
|
+
errors.push("config: `tasksDir` must be a repo-relative path within the repo (no absolute path or `..` segments)");
|
|
95
|
+
} else {
|
|
96
|
+
// Canonicalize the stored value so consumers that build rel paths or
|
|
97
|
+
// messages by string interpolation (init skeletons, the AGENTS block, the
|
|
98
|
+
// CLI summary) don't inherit a doubled separator from a trailing slash.
|
|
99
|
+
// join() already tolerates it, but the echoed strings should be clean.
|
|
100
|
+
cfg.tasksDir = cfg.tasksDir.replace(/[/\\]+$/, "");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (cfg.specVersion == null) {
|
|
105
|
+
warnings.push(`config has no \`specVersion\`; assuming current spec ${SPEC_VERSION}`);
|
|
106
|
+
} else if (String(cfg.specVersion).split(".")[0] !== SPEC_VERSION.split(".")[0]) {
|
|
107
|
+
warnings.push(`config \`specVersion\` ${cfg.specVersion} differs in major version from this tool's spec ${SPEC_VERSION}`);
|
|
108
|
+
}
|
|
109
|
+
return { cfg, warnings, errors };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ------------------------------------------------------------- team roster
|
|
113
|
+
// The team roster (SPEC §7.2) is an authored `team.json` at the FIXED config home
|
|
114
|
+
// (next to backlog.config.json, independent of `tasksDir`), kept separate from the
|
|
115
|
+
// config so the core vocab stays stable. It is OPTIONAL: an absent file is an empty
|
|
116
|
+
// roster (not an error), so a repo that never assigns owners stays green. A present
|
|
117
|
+
// file is validated like the config — a malformed roster is a fatal, config-class
|
|
118
|
+
// error surfaced through readBacklog so `--check`/validate fail on it. Shape:
|
|
119
|
+
// { "members": [ { "handle", "name", "email"?, "status": "active"|"inactive" } ] }
|
|
120
|
+
//
|
|
121
|
+
// `handle` is the stable key used in front-matter (`owner`/`collaborators`); it is
|
|
122
|
+
// constrained to [A-Za-z0-9._-] so it survives the inline-list serialization of
|
|
123
|
+
// `collaborators`. `name`/`email` are display only — the identity model is not
|
|
124
|
+
// coupled to any external provider (that is the later cross-repo direction).
|
|
125
|
+
const HANDLE_RE = /^[A-Za-z0-9._-]+$/;
|
|
126
|
+
const MEMBER_KEYS = new Set(["handle", "name", "email", "status"]);
|
|
127
|
+
|
|
128
|
+
// The roster shape consumers use: the normalized member list plus a case-insensitive
|
|
129
|
+
// handle index. Case-insensitive matching mirrors effort labels and import remap.
|
|
130
|
+
export function emptyRoster() {
|
|
131
|
+
return { members: [], byHandle: new Map() };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// True when a string is a syntactically valid handle. The charset keeps a handle safe
|
|
135
|
+
// inside the inline serialization of `collaborators` (no comma/bracket), so any writer
|
|
136
|
+
// emitting a handle to a file MUST enforce it — not only the roster loader. Historical
|
|
137
|
+
// (closed-item) assignees skip roster membership but still must round-trip (SPEC §7.2).
|
|
138
|
+
export function isValidHandle(handle) {
|
|
139
|
+
return typeof handle === "string" && HANDLE_RE.test(handle.trim());
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Resolve a handle to its roster member regardless of status (case-insensitive),
|
|
143
|
+
// or undefined. Used by the importer to recover the canonical handle of a now-inactive
|
|
144
|
+
// member when carrying a historical owner on a closed item.
|
|
145
|
+
export function findMember(roster, handle) {
|
|
146
|
+
if (!roster || typeof handle !== "string" || !handle.trim()) return undefined;
|
|
147
|
+
return roster.byHandle.get(handle.trim().toLowerCase());
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Resolve a handle to its roster member only when that member is ACTIVE — the check
|
|
151
|
+
// behind active-item owner/collaborator validation and import resolution. Returns
|
|
152
|
+
// the member (carrying its canonical handle) or undefined.
|
|
153
|
+
export function findActiveMember(roster, handle) {
|
|
154
|
+
const m = findMember(roster, handle);
|
|
155
|
+
return m && m.status === "active" ? m : undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Load and validate the roster → { roster, warnings, errors }, mirroring loadConfig's
|
|
159
|
+
// result-object idiom. Absent file → empty roster, no errors. Never throws; the
|
|
160
|
+
// caller surfaces errors. Top-level extra keys are tolerated (room for the future
|
|
161
|
+
// cross-repo direction); member keys are validated strictly.
|
|
162
|
+
export function loadRoster(repoRoot) {
|
|
163
|
+
const warnings = [];
|
|
164
|
+
const teamPath = paths(repoRoot).team;
|
|
165
|
+
if (!existsSync(teamPath)) return { roster: emptyRoster(), warnings, errors: [] };
|
|
166
|
+
let raw;
|
|
167
|
+
try {
|
|
168
|
+
raw = JSON.parse(readFileSync(teamPath, "utf8"));
|
|
169
|
+
} catch (e) {
|
|
170
|
+
return { roster: emptyRoster(), warnings, errors: [`team.json is not valid JSON (${e.message})`] };
|
|
171
|
+
}
|
|
172
|
+
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
173
|
+
return { roster: emptyRoster(), warnings, errors: ["team.json must be an object with a `members` array"] };
|
|
174
|
+
}
|
|
175
|
+
if (!Array.isArray(raw.members)) {
|
|
176
|
+
return { roster: emptyRoster(), warnings, errors: ["team.json: `members` must be an array"] };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const errors = [];
|
|
180
|
+
const members = [];
|
|
181
|
+
const byHandle = new Map();
|
|
182
|
+
raw.members.forEach((m, i) => {
|
|
183
|
+
const at = `team.json member ${i + 1}`;
|
|
184
|
+
if (m == null || typeof m !== "object" || Array.isArray(m)) { errors.push(`${at}: must be an object`); return; }
|
|
185
|
+
for (const k of Object.keys(m)) if (!MEMBER_KEYS.has(k)) errors.push(`${at}: unknown key \`${k}\``);
|
|
186
|
+
const { handle, name, email, status } = m;
|
|
187
|
+
if (typeof handle !== "string" || !handle.trim()) { errors.push(`${at}: \`handle\` must be a non-empty string`); return; }
|
|
188
|
+
if (!isValidHandle(handle)) errors.push(`${at}: \`handle\` "${handle}" must use only letters, digits, ., _, -`);
|
|
189
|
+
if (typeof name !== "string" || !name.trim()) errors.push(`${at} (${handle}): \`name\` must be a non-empty string`);
|
|
190
|
+
if (email != null && typeof email !== "string") errors.push(`${at} (${handle}): \`email\` must be a string`);
|
|
191
|
+
// `status` is optional and defaults to active; a present-but-invalid value errors.
|
|
192
|
+
let st = status == null ? "active" : status;
|
|
193
|
+
if (st !== "active" && st !== "inactive") { errors.push(`${at} (${handle}): \`status\` must be "active" or "inactive"`); st = "active"; }
|
|
194
|
+
const key = handle.trim().toLowerCase();
|
|
195
|
+
if (byHandle.has(key)) { errors.push(`team.json: duplicate handle "${handle}"`); return; }
|
|
196
|
+
const member = { handle: handle.trim(), name: typeof name === "string" ? name.trim() : name, status: st };
|
|
197
|
+
if (typeof email === "string" && email.trim()) member.email = email.trim();
|
|
198
|
+
byHandle.set(key, member);
|
|
199
|
+
members.push(member);
|
|
200
|
+
});
|
|
201
|
+
return { roster: { members, byHandle }, warnings, errors };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// --------------------------------------------------------- effort scales
|
|
205
|
+
// The active effort scale is a 1:1 skin over the canonical numbers (SPEC §6).
|
|
206
|
+
// Shape: { isIdentity, name, byNumber: Map<number,{label,emoji?,image?}>,
|
|
207
|
+
// byLabel: Map<lowercased label, number> }. The identity ("fibonacci") scale
|
|
208
|
+
// labels each value with its own number and accepts no aliases.
|
|
209
|
+
// Resolve and attach the active effort scale to a config (SPEC §6.1). Exported
|
|
210
|
+
// so callers that build a config WITHOUT loadConfig — e.g. trellis init's
|
|
211
|
+
// synthetic effectiveConfig — still get a usable scale; otherwise resolveEffort
|
|
212
|
+
// and the rendering helpers dereference an undefined `effortScale`. Only attempts
|
|
213
|
+
// the skin when the canonical values are well-formed; otherwise falls back to
|
|
214
|
+
// identity so later stages have a usable (if empty) scale and the real config
|
|
215
|
+
// error is what surfaces.
|
|
216
|
+
export function attachEffortScale(cfg, errors = []) {
|
|
217
|
+
const values = Array.isArray(cfg.effortValues) ? cfg.effortValues : [];
|
|
218
|
+
cfg.effortScale = values.length && values.every((v) => typeof v === "number" && Number.isFinite(v))
|
|
219
|
+
? buildEffortScale(cfg, errors)
|
|
220
|
+
: identityScale(values);
|
|
221
|
+
return cfg.effortScale;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function identityScale(values) {
|
|
225
|
+
return {
|
|
226
|
+
isIdentity: true,
|
|
227
|
+
name: "fibonacci",
|
|
228
|
+
byNumber: new Map(values.map((v) => [v, { label: String(v) }])),
|
|
229
|
+
byLabel: new Map(),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function buildEffortScale(cfg, errors) {
|
|
234
|
+
const values = cfg.effortValues;
|
|
235
|
+
const obj = Array.isArray(cfg.effort) ? null : cfg.effort;
|
|
236
|
+
const scaleName = obj && obj.scale != null ? obj.scale : "fibonacci";
|
|
237
|
+
|
|
238
|
+
if (typeof scaleName !== "string") {
|
|
239
|
+
errors.push("config: effort `scale` must be a string");
|
|
240
|
+
return identityScale(values);
|
|
241
|
+
}
|
|
242
|
+
if (scaleName === "fibonacci") return identityScale(values);
|
|
243
|
+
|
|
244
|
+
const scales = obj && obj.scales;
|
|
245
|
+
if (scales == null || typeof scales !== "object" || Array.isArray(scales)) {
|
|
246
|
+
errors.push("config: effort `scales` must be an object when a non-identity `scale` is selected");
|
|
247
|
+
return identityScale(values);
|
|
248
|
+
}
|
|
249
|
+
const active = scales[scaleName];
|
|
250
|
+
if (active == null || typeof active !== "object" || Array.isArray(active)) {
|
|
251
|
+
errors.push(`config: effort \`scale\` "${scaleName}" is not defined in \`scales\``);
|
|
252
|
+
return identityScale(values);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Validate the active scale fully: every canonical value mapped, each entry a
|
|
256
|
+
// non-empty unique label with optional string emoji/image (SPEC §6.1).
|
|
257
|
+
const byNumber = new Map();
|
|
258
|
+
const byLabel = new Map();
|
|
259
|
+
for (const v of values) {
|
|
260
|
+
const entry = active[String(v)];
|
|
261
|
+
if (entry == null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
262
|
+
errors.push(`config: effort scale "${scaleName}" is missing a mapping for value ${v}`);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const { label, emoji, image } = entry;
|
|
266
|
+
if (typeof label !== "string" || !label.trim()) {
|
|
267
|
+
errors.push(`config: effort scale "${scaleName}" value ${v}: \`label\` must be a non-empty string`);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (emoji != null && typeof emoji !== "string") errors.push(`config: effort scale "${scaleName}" value ${v}: \`emoji\` must be a string`);
|
|
271
|
+
if (image != null && typeof image !== "string") errors.push(`config: effort scale "${scaleName}" value ${v}: \`image\` must be a string`);
|
|
272
|
+
const key = label.trim().toLowerCase();
|
|
273
|
+
if (byLabel.has(key)) errors.push(`config: effort scale "${scaleName}" has duplicate label "${label}"`);
|
|
274
|
+
byLabel.set(key, v);
|
|
275
|
+
const resolved = { label };
|
|
276
|
+
if (typeof emoji === "string") resolved.emoji = emoji;
|
|
277
|
+
if (typeof image === "string") resolved.image = image;
|
|
278
|
+
byNumber.set(v, resolved);
|
|
279
|
+
}
|
|
280
|
+
return { isIdentity: false, name: scaleName, byNumber, byLabel };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Resolve a front-matter `effort` (a canonical number or a case-insensitive
|
|
284
|
+
// label alias) against the active scale (SPEC §6.2). Returns
|
|
285
|
+
// { value, label, emoji?, image? } on success, or { error } with an actionable
|
|
286
|
+
// message. Shared by the generator (active-item validation) and the MCP writer.
|
|
287
|
+
export function resolveEffort(cfg, raw) {
|
|
288
|
+
const scale = cfg.effortScale;
|
|
289
|
+
let value;
|
|
290
|
+
if (typeof raw === "number") {
|
|
291
|
+
value = raw;
|
|
292
|
+
} else if (typeof raw === "string" && raw.trim()) {
|
|
293
|
+
const key = raw.trim().toLowerCase();
|
|
294
|
+
if (scale.byLabel.has(key)) value = scale.byLabel.get(key);
|
|
295
|
+
else if (/^-?\d+$/.test(raw.trim())) value = Number(raw.trim());
|
|
296
|
+
else return { error: effortError(cfg) };
|
|
297
|
+
} else {
|
|
298
|
+
return { error: effortError(cfg) };
|
|
299
|
+
}
|
|
300
|
+
if (!cfg.effortValues.includes(value)) return { error: effortError(cfg) };
|
|
301
|
+
const entry = scale.byNumber.get(value) || { label: String(value) };
|
|
302
|
+
const out = { value, label: entry.label };
|
|
303
|
+
if (entry.emoji) out.emoji = entry.emoji;
|
|
304
|
+
if (entry.image) out.image = entry.image;
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function effortError(cfg) {
|
|
309
|
+
const nums = cfg.effortValues.join(", ");
|
|
310
|
+
if (cfg.effortScale.isIdentity) return `effort must be one of ${nums}`;
|
|
311
|
+
const labels = [...cfg.effortScale.byNumber.values()].map((e) => e.label).join(", ");
|
|
312
|
+
return `effort must be a value (${nums}) or a label (${labels})`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// The README effort cell: `[emoji ]label · N` under a custom scale, bare `N`
|
|
316
|
+
// under identity (SPEC §6.3). Reads the resolved fields attached in readBacklog.
|
|
317
|
+
function effortCell(it, cfg) {
|
|
318
|
+
if (cfg.effortScale.isIdentity || !it._effortLabel) return String(it.effort ?? "");
|
|
319
|
+
const emoji = it._effortEmoji ? `${it._effortEmoji} ` : "";
|
|
320
|
+
return `${emoji}${it._effortLabel} · ${it.effort}`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ----------------------------------------------------------- front-matter
|
|
324
|
+
function unquote(s) {
|
|
325
|
+
const m = s.match(/^"([\s\S]*)"$/) || s.match(/^'([\s\S]*)'$/);
|
|
326
|
+
return m ? m[1] : s;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Parse the YAML-subset front-matter block. Tolerates CRLF, `#` comments, and
|
|
330
|
+
// colons inside values; flags malformed lines and duplicate keys via `errors`.
|
|
331
|
+
export function parseFrontMatter(text, where, errors = []) {
|
|
332
|
+
const block = text.replace(/\r\n/g, "\n").match(/^---\n([\s\S]*?)\n---/);
|
|
333
|
+
if (!block) { errors.push(`${where}: missing or unterminated YAML front-matter`); return null; }
|
|
334
|
+
const fm = {};
|
|
335
|
+
const seen = new Set();
|
|
336
|
+
const lines = block[1].split("\n");
|
|
337
|
+
for (let i = 0; i < lines.length; i++) {
|
|
338
|
+
const line = lines[i];
|
|
339
|
+
if (!line.trim() || line.trim().startsWith("#")) continue;
|
|
340
|
+
const kv = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
|
|
341
|
+
if (!kv) { errors.push(`${where}: cannot parse front-matter line: "${line.trim()}"`); continue; }
|
|
342
|
+
const key = kv[1];
|
|
343
|
+
if (seen.has(key)) errors.push(`${where}: duplicate key \`${key}\``);
|
|
344
|
+
seen.add(key);
|
|
345
|
+
const val = kv[2].trim();
|
|
346
|
+
if (FM_LIST_KEYS.has(key)) {
|
|
347
|
+
if (val.startsWith("[")) {
|
|
348
|
+
fm[key] = val.replace(/^\[|\]$/g, "").split(",").map((s) => unquote(s.trim())).filter(Boolean);
|
|
349
|
+
} else if (val === "") {
|
|
350
|
+
const tokens = [];
|
|
351
|
+
while (i + 1 < lines.length && /^\s*-\s+/.test(lines[i + 1])) {
|
|
352
|
+
tokens.push(unquote(lines[++i].replace(/^\s*-\s+/, "").trim()));
|
|
353
|
+
}
|
|
354
|
+
fm[key] = tokens;
|
|
355
|
+
} else {
|
|
356
|
+
fm[key] = [unquote(val)];
|
|
357
|
+
}
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
const quoted = /^"[\s\S]*"$/.test(val) || /^'[\s\S]*'$/.test(val);
|
|
361
|
+
const v = unquote(val);
|
|
362
|
+
// Coerce only *unquoted* all-digit values to numbers, so a quoted "404" round-
|
|
363
|
+
// trips as the string "404" — how the writer below preserves a numeric-looking
|
|
364
|
+
// title/summary (serializeFrontMatter) against the string contract.
|
|
365
|
+
fm[key] = !quoted && /^-?\d+$/.test(v) ? Number(v) : v;
|
|
366
|
+
}
|
|
367
|
+
return fm;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ------------------------------------------------------- front-matter (write)
|
|
371
|
+
// The serializer side of parseFrontMatter: turn an item object back into the
|
|
372
|
+
// YAML-subset front-matter the parser reads. Lives in the core so every writer —
|
|
373
|
+
// the MCP create/move ops (src/mcp.mjs) and the importer (src/import.mjs) — emits
|
|
374
|
+
// byte-identical, hand-authored-looking files with no string drift.
|
|
375
|
+
|
|
376
|
+
// Canonical field order, matching the hand-authored items (close date sits right
|
|
377
|
+
// after milestone; removed_reason last). Order is cosmetic — the parser is
|
|
378
|
+
// order-independent — but consistency keeps diffs clean.
|
|
379
|
+
export const FM_ORDER = [
|
|
380
|
+
"id", "title", "status", "milestone",
|
|
381
|
+
"completed_on", "removed_on",
|
|
382
|
+
"priority", "effort", "depends_on", "owner", "collaborators", "summary", "removed_reason",
|
|
383
|
+
];
|
|
384
|
+
|
|
385
|
+
// Front-matter keys whose value is a list of tokens, serialized inline as
|
|
386
|
+
// `key: [a, b]` and parsed back the same way (or from a `- ` block / bare scalar).
|
|
387
|
+
// `depends_on` is task ids; `collaborators` is roster handles (SPEC §5.1, §7.2).
|
|
388
|
+
export const FM_LIST_KEYS = new Set(["depends_on", "collaborators"]);
|
|
389
|
+
|
|
390
|
+
// Quote a value the parser would otherwise misread on the way back in: an all-digit
|
|
391
|
+
// string (it coerces unquoted digits to a number), one already wrapped in a quote
|
|
392
|
+
// (stripped by `unquote`), or the empty string. `unquote` is greedy, anchored, and
|
|
393
|
+
// does not unescape, so a bare `"` wrap round-trips for any single-line value.
|
|
394
|
+
// Numbers (e.g. effort) pass through bare.
|
|
395
|
+
export function emitScalar(v) {
|
|
396
|
+
if (typeof v === "number") return String(v);
|
|
397
|
+
const s = String(v);
|
|
398
|
+
if (s === "" || /^-?\d+$/.test(s) || /^["']/.test(s) || /["']$/.test(s)) return `"${s}"`;
|
|
399
|
+
return s;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Emit the front-matter the parser reads back: depends_on as an inline array,
|
|
403
|
+
// everything else as a (possibly quoted) scalar. Unknown keys are preserved after
|
|
404
|
+
// the known ones so nothing is silently dropped.
|
|
405
|
+
export function serializeFrontMatter(fm) {
|
|
406
|
+
const emit = (key) => {
|
|
407
|
+
const v = fm[key];
|
|
408
|
+
if (FM_LIST_KEYS.has(key)) return `${key}: [${(v ?? []).join(", ")}]`;
|
|
409
|
+
return `${key}: ${emitScalar(v)}`;
|
|
410
|
+
};
|
|
411
|
+
const keys = [...FM_ORDER.filter((k) => fm[k] !== undefined), ...Object.keys(fm).filter((k) => !FM_ORDER.includes(k))];
|
|
412
|
+
return keys.map(emit).join("\n");
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Compose a full item file: front-matter block + a body normalized to start at its
|
|
416
|
+
// first line and end with exactly one trailing newline.
|
|
417
|
+
export function composeFile(fm, body) {
|
|
418
|
+
const b = String(body).replace(/^\n+/, "").replace(/\n*$/, "\n");
|
|
419
|
+
return `---\n${serializeFrontMatter(fm)}\n---\n\n${b}`;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// --------------------------------------------------------------- reading
|
|
423
|
+
function idsFromDir(dir, fileRe) {
|
|
424
|
+
if (!existsSync(dir)) return [];
|
|
425
|
+
return readdirSync(dir).map((f) => f.match(fileRe)).filter(Boolean).map((m) => m[1]);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export function readBacklog(repoRoot, cfg) {
|
|
429
|
+
const p = paths(repoRoot, cfg);
|
|
430
|
+
const fileRe = new RegExp(`^(${cfg.idPrefix}\\d{${cfg.idWidth}})\\.md$`);
|
|
431
|
+
const errors = [];
|
|
432
|
+
|
|
433
|
+
// The roster lives at the fixed config home; its load/validation errors are
|
|
434
|
+
// config-class and surface here so `--check`/validate fail on a malformed team.json
|
|
435
|
+
// (SPEC §7.2). An absent roster is empty — owner/collaborators stay optional.
|
|
436
|
+
const { roster, errors: rosterErrors } = loadRoster(repoRoot);
|
|
437
|
+
errors.push(...rosterErrors);
|
|
438
|
+
|
|
439
|
+
const byId = new Map();
|
|
440
|
+
for (const [label, dir] of [["active", p.active], ["completed/tasks", p.completedTasks], ["removed", p.removed]]) {
|
|
441
|
+
for (const id of idsFromDir(dir, fileRe)) {
|
|
442
|
+
const at = byId.get(id) || []; at.push(label); byId.set(id, at);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
for (const [id, at] of byId) if (at.length > 1) errors.push(`${id}: duplicate id in ${at.join(", ")}`);
|
|
446
|
+
const ids = new Set(byId.keys());
|
|
447
|
+
|
|
448
|
+
const readDir = (dir, kind, isActive) => {
|
|
449
|
+
const out = [];
|
|
450
|
+
if (!existsSync(dir)) return out;
|
|
451
|
+
for (const f of readdirSync(dir).sort()) {
|
|
452
|
+
if (!fileRe.test(f)) {
|
|
453
|
+
if (isActive && /\.md$/.test(f)) errors.push(`active/${f}: filename must be ${cfg.idPrefix} + ${cfg.idWidth} digits`);
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
const fm = parseFrontMatter(readFileSync(join(dir, f), "utf8"), `${kind}/${f}`, errors);
|
|
457
|
+
if (fm) out.push({ ...fm, _file: f });
|
|
458
|
+
}
|
|
459
|
+
return out;
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const active = readDir(p.active, "active", true);
|
|
463
|
+
const completed = readDir(p.completedTasks, "completed", false);
|
|
464
|
+
const removed = readDir(p.removed, "removed", false);
|
|
465
|
+
|
|
466
|
+
// Validation. Active items are checked in full against the live config. Closed
|
|
467
|
+
// items are checked for lifecycle integrity (status, ISO close date, reason,
|
|
468
|
+
// required depends_on with referential checks); their historical enum values
|
|
469
|
+
// (milestone/priority/effort) are NOT re-validated against the current config
|
|
470
|
+
// (SPEC.md §5.1, §8.3).
|
|
471
|
+
const idRe = new RegExp(`^${cfg.idPrefix}\\d{${cfg.idWidth}}$`);
|
|
472
|
+
const isoDate = (s) => typeof s === "string" && /^\d{4}-\d{2}-\d{2}$/.test(s);
|
|
473
|
+
|
|
474
|
+
// Resolve `effort` (number or label alias) and attach the display fields used
|
|
475
|
+
// by rendering. Active items surface a resolution error; closed items resolve
|
|
476
|
+
// best-effort (historical values are not re-validated — SPEC §5.1, §8.3).
|
|
477
|
+
const attachEffort = (it, onError) => {
|
|
478
|
+
const r = resolveEffort(cfg, it.effort);
|
|
479
|
+
if (r.error) { if (onError) onError(r.error); return; }
|
|
480
|
+
it.effort = r.value;
|
|
481
|
+
it._effortLabel = r.label;
|
|
482
|
+
if (r.emoji) it._effortEmoji = r.emoji;
|
|
483
|
+
if (r.image) it._effortImage = r.image;
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
const checkCommon = (it, expected, err) => {
|
|
487
|
+
const fileId = it._file.replace(/\.md$/, "");
|
|
488
|
+
if (!idRe.test(fileId)) err(`filename must be ${cfg.idPrefix} + ${cfg.idWidth} digits`);
|
|
489
|
+
if (!it.id) err("missing `id`");
|
|
490
|
+
else if (it.id !== fileId) err(`id (${it.id}) does not match filename (${fileId})`);
|
|
491
|
+
if (!it.title) err("missing `title`");
|
|
492
|
+
if (it.status !== expected) err(`\`status\` must be "${expected}"`);
|
|
493
|
+
if (it.depends_on === undefined) err("missing `depends_on` (use [] for none)");
|
|
494
|
+
else for (const d of it.depends_on) if (!ids.has(d)) err(`depends_on ${d} is not a known task id`);
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
// The descriptive metadata is required on closed items too, as a historical
|
|
498
|
+
// snapshot (SPEC §5.1): its *presence* is enforced here, but its enum membership
|
|
499
|
+
// is NOT re-validated against the current config (§8.3) — a value that has since
|
|
500
|
+
// left the config still validates. Call before attachEffort, so a present-but-
|
|
501
|
+
// unresolvable historical effort (e.g. a retired scale label) counts as present.
|
|
502
|
+
const checkHistorical = (it, err) => {
|
|
503
|
+
if (!it.summary) err("missing `summary`");
|
|
504
|
+
if (it.milestone === undefined || it.milestone === "") err("missing `milestone`");
|
|
505
|
+
if (it.priority === undefined || it.priority === "") err("missing `priority`");
|
|
506
|
+
if (it.effort === undefined || it.effort === "") err("missing `effort`");
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
// Owner/collaborator validation (SPEC §5.1, §7.2, §8.3). On **every** item the
|
|
510
|
+
// values must be syntactically valid handles — a handle is charset-constrained even
|
|
511
|
+
// when historical, so the closed-item exemption is from roster *membership*, not from
|
|
512
|
+
// being a handle (else a hand-authored `owner: Jane Doe` would pass `--check`).
|
|
513
|
+
// **Active** items additionally require an ACTIVE roster member; on closed items
|
|
514
|
+
// membership is not re-checked, so a member who has since gone inactive or left the
|
|
515
|
+
// roster still validates. Both fields are optional.
|
|
516
|
+
const validateAssignees = (it, err, active) => {
|
|
517
|
+
if (it.owner !== undefined && it.owner !== "") {
|
|
518
|
+
if (!isValidHandle(it.owner)) err(`owner "${it.owner}" is not a valid handle (use letters, digits, ., _, -)`);
|
|
519
|
+
else if (active && !findActiveMember(roster, it.owner)) err(`owner "${it.owner}" is not an active roster member`);
|
|
520
|
+
}
|
|
521
|
+
if (it.collaborators !== undefined) {
|
|
522
|
+
if (!Array.isArray(it.collaborators)) err("`collaborators` must be a list of handles");
|
|
523
|
+
else for (const c of it.collaborators) {
|
|
524
|
+
if (!isValidHandle(c)) err(`collaborator "${c}" is not a valid handle (use letters, digits, ., _, -)`);
|
|
525
|
+
else if (active && !findActiveMember(roster, c)) err(`collaborator "${c}" is not an active roster member`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
for (const it of active) {
|
|
531
|
+
const err = (m) => errors.push(`active/${it._file}: ${m}`);
|
|
532
|
+
checkCommon(it, "active", err);
|
|
533
|
+
if (!it.summary) err("missing `summary`");
|
|
534
|
+
if (!cfg.priorities.includes(it.priority)) err(`priority must be one of ${cfg.priorities.join(", ")}`);
|
|
535
|
+
attachEffort(it, err);
|
|
536
|
+
if (!cfg.milestones.includes(it.milestone)) err(`milestone must be one of ${cfg.milestones.join(", ")}`);
|
|
537
|
+
validateAssignees(it, err, true);
|
|
538
|
+
}
|
|
539
|
+
for (const it of completed) {
|
|
540
|
+
const err = (m) => errors.push(`completed/${it._file}: ${m}`);
|
|
541
|
+
checkCommon(it, "completed", err);
|
|
542
|
+
checkHistorical(it, err);
|
|
543
|
+
attachEffort(it, null);
|
|
544
|
+
if (!isoDate(it.completed_on)) err("`completed_on` must be an ISO date (YYYY-MM-DD)");
|
|
545
|
+
validateAssignees(it, err, false);
|
|
546
|
+
}
|
|
547
|
+
for (const it of removed) {
|
|
548
|
+
const err = (m) => errors.push(`removed/${it._file}: ${m}`);
|
|
549
|
+
checkCommon(it, "removed", err);
|
|
550
|
+
checkHistorical(it, err);
|
|
551
|
+
attachEffort(it, null);
|
|
552
|
+
if (!isoDate(it.removed_on)) err("`removed_on` must be an ISO date (YYYY-MM-DD)");
|
|
553
|
+
if (!it.removed_reason) err("missing `removed_reason`");
|
|
554
|
+
validateAssignees(it, err, false);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return { active, completed, removed, ids, errors, roster };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ------------------------------------------------------------------ ids
|
|
561
|
+
export function nextId(ids, cfg) {
|
|
562
|
+
let max = 0;
|
|
563
|
+
for (const id of ids) {
|
|
564
|
+
const n = Number(id.slice(cfg.idPrefix.length));
|
|
565
|
+
if (Number.isFinite(n) && n > max) max = n;
|
|
566
|
+
}
|
|
567
|
+
return cfg.idPrefix + String(max + 1).padStart(cfg.idWidth, "0");
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ------------------------------------------------------------- rendering
|
|
571
|
+
function cell(s) { return String(s ?? "").replace(/\|/g, "\\|"); }
|
|
572
|
+
|
|
573
|
+
function fillMarkers(text, [begin, end], body, where, errors) {
|
|
574
|
+
const re = new RegExp(`${begin}[\\s\\S]*?${end}`);
|
|
575
|
+
if (!re.test(text)) { errors.push(`${where}: generated markers not found (${begin} ... ${end})`); return text; }
|
|
576
|
+
return text.replace(re, `${begin}\n${body}\n${end}`);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function activeTable(items, cfg) {
|
|
580
|
+
const rank = Object.fromEntries(cfg.priorities.map((p, i) => [p, i]));
|
|
581
|
+
const rows = items.slice()
|
|
582
|
+
.sort((a, b) => (rank[a.priority] - rank[b.priority]) || a.id.localeCompare(b.id))
|
|
583
|
+
.map((it) => `| [${it.id}](active/${it._file}) | ${cell(it.title)} | ${cell(it.owner ?? "")} | ${it.priority} | ${cell(effortCell(it, cfg))} |`);
|
|
584
|
+
return ["| ID | Title | Owner | Priority | Effort |", "| --- | --- | --- | --- | --- |", ...rows].join("\n");
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function completedTable(items) {
|
|
588
|
+
if (!items.length) return "_No completed items yet._";
|
|
589
|
+
const rows = items.slice()
|
|
590
|
+
.sort((a, b) => String(b.completed_on ?? "").localeCompare(String(a.completed_on ?? "")) || a.id.localeCompare(b.id))
|
|
591
|
+
.map((it) => `| [${it.id}](tasks/${it.id}.md) | ${cell(it.title)} | ${cell(it.summary)} | ${cell(it.completed_on)} |`);
|
|
592
|
+
return ["| ID | Title | Summary | Completed |", "| --- | --- | --- | --- |", ...rows].join("\n");
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function removedTable(items) {
|
|
596
|
+
if (!items.length) return "_No removed items yet._";
|
|
597
|
+
const rows = items.slice()
|
|
598
|
+
.sort((a, b) => String(b.removed_on ?? "").localeCompare(String(a.removed_on ?? "")) || a.id.localeCompare(b.id))
|
|
599
|
+
.map((it) => `| [${it.id}](${it.id}.md) | ${cell(it.title)} | ${cell(it.summary)} | ${cell(it.removed_on)} | ${cell(it.removed_reason)} |`);
|
|
600
|
+
return ["| ID | Title | Summary | Removed | Reason |", "| --- | --- | --- | --- | --- |", ...rows].join("\n");
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Resolved effort skin for backlog.json (SPEC §8.2): label + optional
|
|
604
|
+
// emoji/image, emitted only under a custom scale. Identity (array-form) repos
|
|
605
|
+
// emit no extra fields, so their backlog.json is unchanged.
|
|
606
|
+
function effortFields(a, cfg) {
|
|
607
|
+
if (cfg.effortScale.isIdentity || !a._effortLabel) return {};
|
|
608
|
+
const f = { effortLabel: a._effortLabel };
|
|
609
|
+
if (a._effortEmoji) f.effortEmoji = a._effortEmoji;
|
|
610
|
+
if (a._effortImage) f.effortImage = a._effortImage;
|
|
611
|
+
return f;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Ownership fields for backlog.json (SPEC §8.2): every task carries `owner` (a
|
|
615
|
+
// roster handle or null) and `collaborators` (handles, [] if none), for active and
|
|
616
|
+
// closed items alike — on closed items they are a historical snapshot, not
|
|
617
|
+
// re-validated against the current roster (§8.3).
|
|
618
|
+
function assigneeFields(a) {
|
|
619
|
+
return { owner: a.owner || null, collaborators: a.collaborators ?? [] };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export function buildBacklogJson(cfg, data) {
|
|
623
|
+
const backlog = {
|
|
624
|
+
prefix: cfg.idPrefix,
|
|
625
|
+
milestones: cfg.milestones,
|
|
626
|
+
nextId: nextId(data.ids, cfg),
|
|
627
|
+
counts: { active: data.active.length, completed: data.completed.length, removed: data.removed.length },
|
|
628
|
+
tasks: [
|
|
629
|
+
...data.active.map((a) => ({ id: a.id, title: a.title, status: "active", milestone: a.milestone, priority: a.priority, effort: a.effort, ...effortFields(a, cfg), depends_on: a.depends_on ?? [], ...assigneeFields(a), summary: a.summary })),
|
|
630
|
+
...data.completed.map((a) => ({ id: a.id, title: a.title, status: "completed", milestone: a.milestone ?? null, priority: a.priority ?? null, effort: a.effort ?? null, ...effortFields(a, cfg), depends_on: a.depends_on ?? [], ...assigneeFields(a), summary: a.summary ?? null, completed_on: a.completed_on ?? null })),
|
|
631
|
+
...data.removed.map((a) => ({ id: a.id, title: a.title, status: "removed", milestone: a.milestone ?? null, priority: a.priority ?? null, effort: a.effort ?? null, ...effortFields(a, cfg), depends_on: a.depends_on ?? [], ...assigneeFields(a), summary: a.summary ?? null, removed_on: a.removed_on ?? null, removed_reason: a.removed_reason ?? null })),
|
|
632
|
+
],
|
|
633
|
+
};
|
|
634
|
+
return JSON.stringify(backlog, null, 2) + "\n";
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Compute each generated artifact's path + new content. Pure except for reading
|
|
638
|
+
// the current files (needed to replace content between their markers).
|
|
639
|
+
export function generateArtifacts(repoRoot, cfg, data) {
|
|
640
|
+
const p = paths(repoRoot, cfg);
|
|
641
|
+
const errors = [];
|
|
642
|
+
const next = nextId(data.ids, cfg);
|
|
643
|
+
|
|
644
|
+
let block = "";
|
|
645
|
+
for (const ms of cfg.milestones) {
|
|
646
|
+
const items = data.active.filter((a) => a.milestone === ms);
|
|
647
|
+
if (items.length) block += `\n### ${ms}\n\n${activeTable(items, cfg)}\n`;
|
|
648
|
+
}
|
|
649
|
+
// The README publishes only the milestone tables; the next id lives in
|
|
650
|
+
// backlog.json (SPEC §8.1/§8.2), so `next` below feeds only that artifact and
|
|
651
|
+
// this function's return value (CLI/MCP callers), not the README body.
|
|
652
|
+
const readmeBody = block;
|
|
653
|
+
|
|
654
|
+
const readText = (path) => {
|
|
655
|
+
if (existsSync(path)) return readFileSync(path, "utf8");
|
|
656
|
+
errors.push(`missing ${relative(repoRoot, path)}`);
|
|
657
|
+
return "";
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
const files = [
|
|
661
|
+
{ path: p.readme, content: fillMarkers(readText(p.readme), MARKERS.milestones, readmeBody, relative(repoRoot, p.readme), errors) },
|
|
662
|
+
{ path: p.completedIndex, content: fillMarkers(readText(p.completedIndex), MARKERS.completed, completedTable(data.completed), relative(repoRoot, p.completedIndex), errors) },
|
|
663
|
+
{ path: p.removedIndex, content: fillMarkers(readText(p.removedIndex), MARKERS.removed, removedTable(data.removed), relative(repoRoot, p.removedIndex), errors) },
|
|
664
|
+
{ path: p.backlogJson, content: buildBacklogJson(cfg, data) },
|
|
665
|
+
];
|
|
666
|
+
return { files, nextId: next, errors };
|
|
667
|
+
}
|