@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.
@@ -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
+ }