@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/src/init.mjs ADDED
@@ -0,0 +1,644 @@
1
+ // Trellis init scaffolder (zero-dependency).
2
+ //
3
+ // Onboards any repo to the Trellis layout: writes trellis/backlog.config.json,
4
+ // the trellis/ layout, the generated index (filled by the TRL0002 core), the CI
5
+ // workflow, an AGENTS.md backlog block, and the process playbooks — idempotently,
6
+ // never clobbering existing files.
7
+ //
8
+ // The generator itself is NOT vendored. The onboarded repo runs Trellis via the
9
+ // package (TRL0010), so the scaffolded CI calls `npx @taprootio/trellis check` and the
10
+ // AGENTS block points at `npx @taprootio/trellis ...`. Like the core, every entry point
11
+ // takes an explicit targetRoot and holds no process-wide state, so the CLI and a
12
+ // future MCP tool (TRL0004) can share it.
13
+
14
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
15
+ import { dirname, join, relative, isAbsolute } from "node:path";
16
+ import { execFileSync } from "node:child_process";
17
+ import { SPEC_VERSION, DEFAULT_TASKS_DIR, CONFIG_DIR, MARKERS, loadConfig, loadRoster, readBacklog, generateArtifacts, attachEffortScale } from "./backlog.mjs";
18
+
19
+ // The config home is fixed at `trellis/backlog.config.json` (CONFIG_DIR),
20
+ // independent of `tasksDir`. A fresh scaffold writes a config that omits
21
+ // `tasksDir`, so its task tree defaults to `trellis/` too. But a *kept* existing
22
+ // config may set a custom `tasksDir`; init must scaffold the tree there, not at
23
+ // the default — otherwise it writes `trellis/*` skeletons that the core then
24
+ // fails to fill against the configured root, leaving a partial scaffold. So the
25
+ // tree/marker/generated paths derive from the effective config's root, while the
26
+ // config path stays fixed.
27
+ const CONFIG_REL = `${CONFIG_DIR}/backlog.config.json`;
28
+ // The team roster sits next to the config at the FIXED config home (SPEC §7.2),
29
+ // independent of tasksDir — it is authored input, not a generated artifact.
30
+ const TEAM_REL = `${CONFIG_DIR}/team.json`;
31
+ const tasksRootOf = (cfg) => (cfg && cfg.tasksDir) || DEFAULT_TASKS_DIR;
32
+
33
+ // Default per-repo vocabulary, overridable via options (CLI flags / prompts).
34
+ export const DEFAULTS = {
35
+ prefix: "TASK",
36
+ idWidth: 4,
37
+ milestones: ["Alpha", "Beta", "v1", "Future"],
38
+ priorities: ["High", "Medium", "Low"],
39
+ effort: [1, 2, 3, 5, 8, 13, 21],
40
+ };
41
+
42
+ // Marked block appended to (or used to create) AGENTS.md.
43
+ const AGENTS_MARKERS = ["<!-- BEGIN TRELLIS -->", "<!-- END TRELLIS -->"];
44
+
45
+ // Process files copied verbatim from the Trellis install (sourceRoot). Their
46
+ // value is the loop, not the exact command names; the AGENTS block below carries
47
+ // the authoritative `npx @taprootio/trellis` commands for the onboarded repo.
48
+ //
49
+ // The playbooks and branch-protection guide scaffold into the FIXED Trellis home
50
+ // (CONFIG_DIR), alongside backlog.config.json / team.json and independent of
51
+ // tasksDir — never into `docs/`, which a repo's static-site generator often
52
+ // publishes (TRL0028). They travel with the config, not the task tree, and the
53
+ // generator (which scans only active/, completed/tasks/, removed/) ignores them.
54
+ // The package ships these under templates/ so npm does not also include the live
55
+ // generated backlog index at trellis/README.md. In a source checkout, prefer the
56
+ // canonical in-repo files so Trellis keeps dogfooding the layout it emits.
57
+ const COPY_FILES = [
58
+ `${CONFIG_DIR}/playbooks/conventions.md`,
59
+ `${CONFIG_DIR}/playbooks/work-task.md`,
60
+ `${CONFIG_DIR}/playbooks/code-review.md`,
61
+ `${CONFIG_DIR}/playbooks/pr-draft.md`,
62
+ `${CONFIG_DIR}/branch-protection.md`,
63
+ ".github/pull_request_template.md",
64
+ ];
65
+
66
+ function copySourcePath(sourceRoot, rel) {
67
+ const local = join(sourceRoot, rel);
68
+ if (existsSync(local)) return local;
69
+ const packaged = join(sourceRoot, "templates", rel);
70
+ return existsSync(packaged) ? packaged : null;
71
+ }
72
+
73
+ // The marker-based generated indexes (skeleton-then-filled), each paired with the
74
+ // exact begin/end marker the core requires — the single source of truth shared by
75
+ // the skeletons and the preflight check. Derived from the effective backlog root.
76
+ const markerFiles = (root) => [
77
+ [`${root}/README.md`, MARKERS.milestones],
78
+ [`${root}/completed/index.md`, MARKERS.completed],
79
+ [`${root}/removed/index.md`, MARKERS.removed],
80
+ ];
81
+
82
+ // The four generated artifacts, produced by the core after the skeletons land.
83
+ const generatedFiles = (root) => [...markerFiles(root).map(([rel]) => rel), `${root}/backlog.json`];
84
+
85
+ export function resolveOptions(opts = {}) {
86
+ // `=== undefined` throughout, never `||` / `&& .length`: a value that was
87
+ // *provided but invalid* (width 0/NaN, or an all-non-numeric `--effort` that
88
+ // parsed to []) must reach validateOptions, not be silently masked by the
89
+ // default. Only an *omitted* (undefined) value falls back.
90
+ const pick = (v, d) => (v === undefined ? d : v);
91
+ return {
92
+ prefix: pick(opts.prefix, DEFAULTS.prefix),
93
+ idWidth: pick(opts.idWidth, DEFAULTS.idWidth),
94
+ milestones: pick(opts.milestones, DEFAULTS.milestones),
95
+ priorities: pick(opts.priorities, DEFAULTS.priorities),
96
+ effort: pick(opts.effort, DEFAULTS.effort),
97
+ };
98
+ }
99
+
100
+ // Validate the resolved vocabulary before any file is written, so a bad flag
101
+ // leaves the target untouched rather than half-scaffolded. Mirrors the core's
102
+ // config checks (SPEC.md §7).
103
+ export function validateOptions(o) {
104
+ const errors = [];
105
+ if (typeof o.prefix !== "string" || !o.prefix) errors.push("`--prefix` must be a non-empty string");
106
+ if (!Number.isInteger(o.idWidth) || o.idWidth < 1) errors.push("`--id-width` must be a positive integer");
107
+ if (!Array.isArray(o.milestones) || !o.milestones.length) errors.push("`--milestones` must be non-empty");
108
+ if (!Array.isArray(o.priorities) || !o.priorities.length) errors.push("`--priorities` must be non-empty");
109
+ if (!Array.isArray(o.effort) || !o.effort.length || !o.effort.every((n) => typeof n === "number" && Number.isFinite(n))) {
110
+ errors.push("`--effort` must be a non-empty list of numbers");
111
+ }
112
+ return errors;
113
+ }
114
+
115
+ // Whether the init CLI should interactively prompt for the missing vocabulary
116
+ // (prefix/milestones). It must NOT prompt for a dry run, a retire-only run, or a
117
+ // non-interactive stdin — and only when something is actually missing. The retire check
118
+ // is keyed on the flag's PRESENCE, not a truthy value: a valueless `--retire-source`
119
+ // (a usage error) must not prompt for scaffold vocabulary it will never use before it
120
+ // reports the missing path. Pure (takes isTTY) so it is unit-testable without a terminal.
121
+ export function shouldPromptVocab(opts, isTTY) {
122
+ if (opts.dryRun || "retireSource" in opts || !isTTY) return false;
123
+ return !opts.prefix || !opts.milestones;
124
+ }
125
+
126
+ // Derive scaffold options from an existing (already-validated) config, so a kept
127
+ // config — not the supplied flags — governs the rendered templates and AGENTS
128
+ // block. Otherwise a repo with `idPrefix: DEMO` could get an AGENTS block saying
129
+ // ids are `TASK`.
130
+ function optionsFromConfig(cfg) {
131
+ return {
132
+ prefix: cfg.idPrefix,
133
+ idWidth: cfg.idWidth,
134
+ milestones: cfg.milestones,
135
+ priorities: cfg.priorities,
136
+ effort: cfg.effortValues && cfg.effortValues.length ? cfg.effortValues : cfg.effort,
137
+ };
138
+ }
139
+
140
+ // True if the user explicitly supplied a vocabulary flag that disagrees with the
141
+ // effective (kept-config) value — used to warn that the flag was ignored.
142
+ function suppliedConflicts(opts, effective) {
143
+ return ["prefix", "idWidth", "milestones", "priorities", "effort"].some(
144
+ (k) => opts[k] !== undefined && JSON.stringify(opts[k]) !== JSON.stringify(effective[k]),
145
+ );
146
+ }
147
+
148
+ // The in-memory config that will govern the target after init (loadConfig shape,
149
+ // including effortValues), built from the resolved options.
150
+ function effectiveConfig(o) {
151
+ const cfg = {
152
+ specVersion: SPEC_VERSION,
153
+ idPrefix: o.prefix,
154
+ idWidth: o.idWidth,
155
+ milestones: o.milestones,
156
+ priorities: o.priorities,
157
+ effort: o.effort,
158
+ effortValues: o.effort,
159
+ };
160
+ attachEffortScale(cfg); // match loadConfig's shape so readBacklog can resolve effort
161
+ return cfg;
162
+ }
163
+
164
+ // Refuse to scaffold over a target whose *existing* backlog or config is already
165
+ // broken: validate any items already present against the config that will govern
166
+ // them after init (the existing one when we keep it, the resolved options when we
167
+ // write/overwrite it). Returns fatal messages; [] means safe to proceed. Read-only.
168
+ function preflight(targetRoot, options, force) {
169
+ const hasCfg = existsSync(join(targetRoot, CONFIG_REL));
170
+ const existing = loadConfig(targetRoot);
171
+ let cfg;
172
+ if (hasCfg && !force) {
173
+ if (existing.errors.length) return [`existing backlog.config.json is invalid; fix it before init: ${existing.errors.join("; ")}`];
174
+ cfg = existing.cfg;
175
+ } else {
176
+ cfg = effectiveConfig(options); // fresh target, or --force will overwrite it
177
+ }
178
+ const root = tasksRootOf(cfg); // a kept config may relocate the tree via tasksDir
179
+ const data = readBacklog(targetRoot, cfg);
180
+ // --force overwrites team.json with a fresh stub, so a pre-existing broken roster
181
+ // must not block (mirrors force overwriting a broken config/index below). Item
182
+ // errors still block — items are never overwritten. readBacklog bundles the roster
183
+ // errors into data.errors, so subtract exactly those under force.
184
+ let dataErrors = data.errors;
185
+ if (force) {
186
+ const rosterErrors = new Set(loadRoster(targetRoot).errors);
187
+ dataErrors = dataErrors.filter((e) => !rosterErrors.has(e));
188
+ }
189
+ if (dataErrors.length) return [`target backlog has errors; fix them before init: ${dataErrors.join("; ")}`];
190
+
191
+ // A generated index we will KEEP must already carry its begin→end marker pair,
192
+ // in order (begin before end), or the core can't fill it and the repo would
193
+ // never be --check-green. Catch it here and refuse before writing. Under --force
194
+ // we overwrite these files with fresh skeletons, so the check doesn't apply —
195
+ // matching `begin[\s\S]*?end` in fillMarkers, an out-of-order or wrong-section
196
+ // or unterminated pair fails.
197
+ if (!force) {
198
+ for (const [rel, [begin, end]] of markerFiles(root)) {
199
+ const abs = join(targetRoot, rel);
200
+ if (!existsSync(abs)) continue;
201
+ const text = readFileSync(abs, "utf8");
202
+ const bi = text.indexOf(begin), ei = text.indexOf(end);
203
+ if (bi === -1 || ei === -1 || bi >= ei) {
204
+ return [`${rel} exists but is missing its required \`${begin}\` … \`${end}\` markers in order; fix or remove it before init`];
205
+ }
206
+ }
207
+ }
208
+ return [];
209
+ }
210
+
211
+ // ------------------------------------------------------------- templates
212
+ function configContent(o) {
213
+ return JSON.stringify(
214
+ {
215
+ specVersion: SPEC_VERSION,
216
+ idPrefix: o.prefix,
217
+ idWidth: o.idWidth,
218
+ milestones: o.milestones,
219
+ priorities: o.priorities,
220
+ effort: o.effort,
221
+ },
222
+ null,
223
+ 2,
224
+ ) + "\n";
225
+ }
226
+
227
+ // The team roster stub (SPEC §7.2): one example active member showing the shape
228
+ // (`handle`/`name`/optional `email`/`status`). Authored, not generated; left for the
229
+ // repo to edit. An unused roster keeps the scaffold --check-green (no task owns it).
230
+ function teamContent() {
231
+ return JSON.stringify(
232
+ { members: [{ handle: "example", name: "Example Member", email: "example@example.com", status: "active" }] },
233
+ null,
234
+ 2,
235
+ ) + "\n";
236
+ }
237
+
238
+ // Skeletons carry the exact begin/end markers from the core (via MARKERS), so the
239
+ // core can fill them and preflight can verify them with no string drift.
240
+ function markerSkeleton([begin, end], heading, blurb) {
241
+ return `# ${heading}\n\n${blurb}\n\n${begin}\n${end}\n`;
242
+ }
243
+
244
+ function readmeSkeleton() {
245
+ return markerSkeleton(
246
+ MARKERS.milestones,
247
+ "Backlog",
248
+ "Managed with [Trellis](https://github.com/taprootio/trellis) — a file-based\n" +
249
+ "backlog. See `AGENTS.md` for the schema and conventions. The tables below are\n" +
250
+ "generated; do not hand-edit between the markers — edit the per-item files in\n" +
251
+ "`active/`, then regenerate (`npx @taprootio/trellis generate`).",
252
+ );
253
+ }
254
+
255
+ function completedSkeleton() {
256
+ return markerSkeleton(
257
+ MARKERS.completed,
258
+ "Completed tasks",
259
+ "Generated from `completed/tasks/`. Do not hand-edit between the markers — close\n" +
260
+ "a task and regenerate (`npx @taprootio/trellis generate`).",
261
+ );
262
+ }
263
+
264
+ function removedSkeleton() {
265
+ return markerSkeleton(
266
+ MARKERS.removed,
267
+ "Removed tasks",
268
+ "Generated from `removed/`. Do not hand-edit between the markers — remove a task\n" +
269
+ "and regenerate (`npx @taprootio/trellis generate`).",
270
+ );
271
+ }
272
+
273
+ function workflowContent() {
274
+ // The job's explicit `name: backlog` is the pinned required-check context
275
+ // (TRL0014, SPEC §10): the stable name a branch-protection rule requires, so a
276
+ // workflow/job rename can't silently drop the gate. It is distinct from the
277
+ // workflow's display name (`Backlog Hygiene`). Enable the gate against this
278
+ // context with trellis/branch-protection.md. The check runs via the Trellis package
279
+ // (TRL0010); it is red until that ships.
280
+ return `name: Backlog Hygiene
281
+ on:
282
+ pull_request:
283
+ push:
284
+ branches: [main]
285
+ jobs:
286
+ backlog:
287
+ name: backlog
288
+ runs-on: ubuntu-latest
289
+ steps:
290
+ - uses: actions/checkout@v4
291
+ - uses: actions/setup-node@v4
292
+ with:
293
+ node-version: "20"
294
+ - run: npx --yes @taprootio/trellis check
295
+ `;
296
+ }
297
+
298
+ export function agentsBlock(o, root = DEFAULT_TASKS_DIR) {
299
+ const [begin, end] = AGENTS_MARKERS;
300
+ // A sample id (number 1, zero-padded to the repo's width) so the branch example
301
+ // matches this repo's actual id format, not a hard-coded 4-digit one.
302
+ const idExample = `${o.prefix.toLowerCase()}${String(1).padStart(o.idWidth, "0")}`;
303
+ return `${begin}
304
+ ## Backlog (Trellis)
305
+
306
+ This repo uses [Trellis](https://github.com/taprootio/trellis) for a file-based
307
+ backlog. Work items live in \`${root}/{active,completed/tasks,removed}/\` as
308
+ Markdown files with YAML front-matter; ids are \`${o.prefix}\` + ${o.idWidth} digits.
309
+
310
+ - \`${root}/README.md\`, \`${root}/backlog.json\`, and the
311
+ \`${root}/completed/index.md\` / \`${root}/removed/index.md\` indexes are
312
+ **generated** — never hand-edit between the \`BEGIN/END GENERATED\` markers.
313
+ - Per-repo vocabulary (id prefix, milestones, priorities, effort) lives in
314
+ \`trellis/backlog.config.json\` (the backlog root is \`trellis/\` by default;
315
+ override it with a \`tasksDir\` key).
316
+ - The team roster lives in \`trellis/team.json\` (members with a \`handle\`, \`name\`,
317
+ optional \`email\`, and \`status\`). A task may set an optional \`owner\` (one handle)
318
+ and \`collaborators\` (handles); on active items they must be active roster members.
319
+ - After adding, moving, or editing an item, regenerate with \`npx @taprootio/trellis generate\`;
320
+ CI runs \`npx @taprootio/trellis check\`.
321
+ - \`main\` is protected — work on a branch, open a PR, and let the backlog check
322
+ (the pinned \`backlog\` job) gate the merge. Enable the gate with the recipe in
323
+ \`trellis/branch-protection.md\` (GitHub plus GitLab/Bitbucket/Azure DevOps).
324
+ - Commit messages and PR descriptions carry no AI/co-author attribution — never
325
+ add \`Co-Authored-By:\` trailers or "Generated with …" footers.
326
+
327
+ ### Loop contract
328
+
329
+ The playbooks in \`trellis/playbooks/\` are universal; they name **seam points** and
330
+ read this repo's values from here. See \`trellis/playbooks/conventions.md\` for the
331
+ contract, then set these to match your tooling:
332
+
333
+ | seam point | this repo's value |
334
+ | --- | --- |
335
+ | \`regenerate\` | \`npx @taprootio/trellis generate\` |
336
+ | \`check\` | \`npx @taprootio/trellis check\` |
337
+ | \`branch-naming\` | \`<initials>/<id-lowercase>/<slug>\` (e.g. \`ab/${idExample}/short-slug\`) |
338
+ | \`gates\` | \`npx @taprootio/trellis check\` (plus this repo's tests/lint) |
339
+ | \`attribution\` | none — no \`Co-Authored-By:\` trailers or "Generated with …" footers |
340
+
341
+ See \`trellis/playbooks/\` for the work-a-task and code-review loops.
342
+ ${end}
343
+ `;
344
+ }
345
+
346
+ // Build the template files (everything except the generated artifacts, which the
347
+ // core fills in afterward). Returns [{ rel, content }].
348
+ function templateFiles(o, sourceRoot, root) {
349
+ const files = [
350
+ { rel: CONFIG_REL, content: configContent(o) },
351
+ { rel: TEAM_REL, content: teamContent() },
352
+ { rel: `${root}/active/.gitkeep`, content: "" },
353
+ { rel: `${root}/completed/tasks/.gitkeep`, content: "" },
354
+ { rel: `${root}/README.md`, content: readmeSkeleton() },
355
+ { rel: `${root}/completed/index.md`, content: completedSkeleton() },
356
+ { rel: `${root}/removed/index.md`, content: removedSkeleton() },
357
+ { rel: ".github/workflows/backlog.yml", content: workflowContent() },
358
+ ];
359
+ const warnings = [];
360
+ for (const rel of COPY_FILES) {
361
+ const src = copySourcePath(sourceRoot, rel);
362
+ if (src) files.push({ rel, content: readFileSync(src, "utf8") });
363
+ else warnings.push(`source not found, skipped copy: ${rel}`);
364
+ }
365
+ return { files, warnings };
366
+ }
367
+
368
+ // -------------------------------------------------------- reconciliation
369
+ // Root guidance files an onboarding agent typically keeps backlog instructions in.
370
+ // Bounded on purpose: a precise, low-false-positive scan beats a broad one, since
371
+ // the output is a checklist a human acts on (TRL0027).
372
+ const RECONCILE_FILES = ["AGENTS.md", "AI_GUIDELINES.md", "CLAUDE.md"];
373
+
374
+ // Strip Trellis's own appended block from AGENTS.md so the scan never flags the
375
+ // guidance init itself just wrote — only the author's surrounding prose is examined.
376
+ function stripTrellisBlock(text) {
377
+ const [begin, end] = AGENTS_MARKERS;
378
+ const bi = text.indexOf(begin);
379
+ if (bi === -1) return text;
380
+ const ei = text.indexOf(end, bi);
381
+ if (ei === -1) return text;
382
+ return text.slice(0, bi) + text.slice(ei + end.length);
383
+ }
384
+
385
+ // READ-ONLY scan for stale, pre-Trellis backlog guidance the agent should rewrite
386
+ // by hand. `init` reports; it never edits or deletes author prose (TRL0027). Returns
387
+ // [{ file, note }]. `root` is the effective backlog root; `importSource` (when set) is
388
+ // the repo-relative path just imported — a reference to it is the strongest signal a
389
+ // doc points at the now-retired backlog. Precision over recall: a false positive is
390
+ // only a line the agent skips.
391
+ export function scanReconcile(targetRoot, { root = DEFAULT_TASKS_DIR, importSource } = {}) {
392
+ const notes = [];
393
+ const headingRe = /^[ \t]*#{1,6}[ \t]+(.*\bbacklog\b.*)$/im;
394
+ for (const file of RECONCILE_FILES) {
395
+ const abs = join(targetRoot, file);
396
+ if (!existsSync(abs)) continue;
397
+ const authorText = file === "AGENTS.md" ? stripTrellisBlock(readFileSync(abs, "utf8")) : readFileSync(abs, "utf8");
398
+
399
+ // (a) A backlog-ish heading in author prose that doesn't already point at the new
400
+ // root — likely guidance still describing the old backlog. Suppress once the
401
+ // author content references `<root>/`, i.e. it has already been reconciled.
402
+ const m = authorText.match(headingRe);
403
+ if (m && !authorText.includes(`${root}/`)) {
404
+ notes.push({ file, note: `has a "${m[1].trim()}" section that looks like pre-Trellis backlog guidance — rewrite it to point at ${root}/ (init only appends its own block; it won't touch this)` });
405
+ }
406
+
407
+ // (b) A reference to the just-imported source path — the doc names the backlog
408
+ // location that import copied out of and --retire-source can now remove.
409
+ if (importSource && authorText.includes(importSource)) {
410
+ notes.push({ file, note: `references the imported backlog path "${importSource}" — update or remove that reference now that items live under ${root}/` });
411
+ }
412
+ }
413
+ return notes;
414
+ }
415
+
416
+ // The just-imported source as a repo-relative path, or undefined when no --import was
417
+ // given or the source sits outside the repo (it can't be named as a repo path then,
418
+ // so signal (b) above doesn't apply).
419
+ function importSourceRel(targetRoot, importOpt) {
420
+ if (!importOpt) return undefined;
421
+ const abs = isAbsolute(importOpt) ? importOpt : join(targetRoot, importOpt);
422
+ const rel = relative(targetRoot, abs);
423
+ if (!rel || rel.startsWith("..") || isAbsolute(rel)) return undefined;
424
+ return rel;
425
+ }
426
+
427
+ // ----------------------------------------------------------------- plan
428
+ // Compute the per-file actions without touching disk. `action` is one of
429
+ // create | skip | append (append is AGENTS.md only). force turns skip → create.
430
+ export function planScaffold(targetRoot, opts = {}, sourceRoot) {
431
+ const resolved = resolveOptions(opts);
432
+ const force = !!opts.force;
433
+
434
+ // When a valid existing config is kept (no --force), it — not the supplied
435
+ // flags — governs the repo, so render the config template and AGENTS block from
436
+ // it. This keeps the AGENTS block's stated prefix consistent with backlog.config.json.
437
+ const existing = loadConfig(targetRoot);
438
+ const keepCfg = existsSync(join(targetRoot, CONFIG_REL)) && !existing.errors.length && !force;
439
+ const o = keepCfg ? optionsFromConfig(existing.cfg) : resolved;
440
+ // A kept config governs the backlog root via `tasksDir`; a fresh scaffold omits
441
+ // it and defaults to `trellis/`. Every scaffolded tree path derives from this.
442
+ const root = keepCfg ? tasksRootOf(existing.cfg) : DEFAULT_TASKS_DIR;
443
+
444
+ const actions = [];
445
+ const { files, warnings } = templateFiles(o, sourceRoot, root);
446
+ if (keepCfg && suppliedConflicts(opts, o)) {
447
+ warnings.push(`existing backlog.config.json kept; its vocabulary governs (prefix \`${o.prefix}\`) — supplied flags ignored (use --force to overwrite)`);
448
+ }
449
+
450
+ for (const f of files) {
451
+ const abs = join(targetRoot, f.rel);
452
+ const exists = existsSync(abs);
453
+ let action = exists && !force ? "skip" : "create";
454
+ // team.json holds authored roster data with no flag source, so a VALID existing
455
+ // roster is preserved even under --force — overwriting it would drop real members
456
+ // and could leave active owners dangling (a partial, broken scaffold). Only an
457
+ // absent or broken roster is (re)written with the stub. Mirrors the AGENTS block,
458
+ // which --force also never clobbers.
459
+ if (f.rel === TEAM_REL && exists && loadRoster(targetRoot).errors.length === 0) {
460
+ action = "skip";
461
+ }
462
+ actions.push({ rel: f.rel, content: f.content, action });
463
+ }
464
+
465
+ // AGENTS.md: create if absent, append the block if present without it, else skip.
466
+ const agentsAbs = join(targetRoot, "AGENTS.md");
467
+ const block = agentsBlock(o, root);
468
+ if (!existsSync(agentsAbs)) {
469
+ actions.push({ rel: "AGENTS.md", content: `# AGENTS\n\n${block}`, action: "create" });
470
+ } else {
471
+ const cur = readFileSync(agentsAbs, "utf8");
472
+ if (cur.includes(AGENTS_MARKERS[0])) {
473
+ actions.push({ rel: "AGENTS.md", content: "", action: "skip" });
474
+ } else {
475
+ const sep = cur.endsWith("\n") ? "\n" : "\n\n";
476
+ actions.push({ rel: "AGENTS.md", content: cur + sep + block, action: "append" });
477
+ }
478
+ }
479
+
480
+ // Read-only reconciliation scan (TRL0027): stale backlog guidance for the agent to
481
+ // rewrite by hand. Advisory — kept separate from `warnings` (scaffold mechanics) and
482
+ // from `errors` (it never blocks).
483
+ const reconcile = scanReconcile(targetRoot, { root, importSource: importSourceRel(targetRoot, opts.import) });
484
+
485
+ return { options: o, resolved, actions, warnings, root, reconcile };
486
+ }
487
+
488
+ // ---------------------------------------------------------------- apply
489
+ // Execute the plan, then run the TRL0002 core to fill the generated artifacts.
490
+ // Returns a summary { created, skipped, appended, generated, warnings }.
491
+ export function applyScaffold(targetRoot, opts = {}, { dryRun = false } = {}, sourceRoot) {
492
+ // `errors` is fatal (the run did not produce a --check-green scaffold);
493
+ // `warnings` is benign (e.g. a missing copy source). The CLI keys its exit code
494
+ // and "Refused" banner off `errors`, never off `warnings`.
495
+ const summary = { created: [], skipped: [], appended: [], generated: [], reconcile: [], warnings: [], errors: [] };
496
+
497
+ // planScaffold only reads, so the target stays untouched through every check
498
+ // below — a rejected run leaves nothing behind. It also resolves the options
499
+ // (no need to resolve again here).
500
+ const { options, resolved, actions, warnings, root, reconcile } = planScaffold(targetRoot, opts, sourceRoot);
501
+ summary.reconcile = reconcile; // advisory, populated even on a refusal (the CLI only prints it on success)
502
+ const GENERATED = generatedFiles(root); // artifact rel-paths under the effective backlog root
503
+ summary.root = root; // surfaced so the CLI can point at the actual scaffold root
504
+
505
+ // Reject malformed *supplied* flags before any write — even when a kept config
506
+ // means they won't be used to render, a bad flag is still a usage error.
507
+ const optErrors = validateOptions(resolved);
508
+ if (optErrors.length) {
509
+ summary.errors.push(...optErrors);
510
+ return { options, summary };
511
+ }
512
+
513
+ // Refuse to scaffold over an already-broken backlog/config — otherwise the
514
+ // core-generate step below would fail and leave a half-written scaffold.
515
+ const preErrors = preflight(targetRoot, options, !!opts.force);
516
+ if (preErrors.length) {
517
+ summary.errors.push(...preErrors);
518
+ return { options, summary };
519
+ }
520
+
521
+ summary.warnings.push(...warnings); // non-fatal (e.g. a missing copy source)
522
+
523
+ // README and the two indexes are written as marker skeletons here, then filled
524
+ // by the core below — so they are reported under `generated`, not created.
525
+ const genSet = new Set(GENERATED);
526
+ for (const a of actions) {
527
+ const isGen = genSet.has(a.rel);
528
+ if (a.action === "skip") { if (!isGen) summary.skipped.push(a.rel); continue; }
529
+ if (!dryRun) {
530
+ const abs = join(targetRoot, a.rel);
531
+ mkdirSync(dirname(abs), { recursive: true });
532
+ writeFileSync(abs, a.content);
533
+ }
534
+ if (isGen) continue;
535
+ (a.action === "append" ? summary.appended : summary.created).push(a.rel);
536
+ }
537
+
538
+ // Fill the generated artifacts via the shared core, so they are byte-identical
539
+ // to what the onboarded repo's own generator would produce (--check-green). A
540
+ // failure here is fatal, not benign: the repo would be left not --check-green,
541
+ // so it goes to `errors` (preflight should already have caught the usual causes).
542
+ if (dryRun) {
543
+ summary.generated.push(...GENERATED);
544
+ } else {
545
+ const { cfg, errors: cfgErrors } = loadConfig(targetRoot);
546
+ const data = cfgErrors.length ? null : readBacklog(targetRoot, cfg);
547
+ const genErrors = cfgErrors.length ? cfgErrors : data.errors;
548
+ if (genErrors.length) {
549
+ summary.errors.push(`generate failed: ${genErrors.join("; ")}`);
550
+ } else {
551
+ const { files, errors } = generateArtifacts(targetRoot, cfg, data);
552
+ if (errors.length) {
553
+ summary.errors.push(`generate failed: ${errors.join("; ")}`);
554
+ } else {
555
+ for (const f of files) writeFileSync(f.path, f.content);
556
+ summary.generated.push(...GENERATED);
557
+ }
558
+ }
559
+ }
560
+
561
+ return { options, summary };
562
+ }
563
+
564
+ // --------------------------------------------------------- retire source
565
+ // Run git rooted at repoRoot with an arg array (NO shell). Mirrors src/history.mjs:
566
+ // capture stderr so the caller can classify a failure; never inherit it.
567
+ function git(repoRoot, args) {
568
+ return execFileSync("git", ["-C", repoRoot, ...args], {
569
+ encoding: "utf8",
570
+ maxBuffer: 64 * 1024 * 1024,
571
+ stdio: ["ignore", "pipe", "pipe"],
572
+ });
573
+ }
574
+
575
+ // First line of the most useful diagnostic from a failed git call.
576
+ function gitErr(e) {
577
+ const s = (e && e.stderr ? String(e.stderr) : e && e.message ? e.message : String(e)).trim();
578
+ return s.split("\n")[0] || "git error";
579
+ }
580
+
581
+ // History-preserving retirement of an imported source tree (TRL0027). Stages a
582
+ // `git rm -r` of <sourcePath> and leaves the deletion for the user to review and
583
+ // commit — it never creates a commit, mirroring how scaffold/import leave changes for
584
+ // review. Deliberately separate from import: run it as a late step once the import is
585
+ // green and committed, so the source tree is intact if anything rolls back.
586
+ //
587
+ // Refuses (writes nothing) unless the path is inside the repo, exists, the repo is a
588
+ // git work tree, and the path is git-tracked — a history-preserving removal needs a
589
+ // tracked path (git keeps the file's history after the staged deletion is committed),
590
+ // and an untracked path has no history to preserve. `dryRun` lists the tracked files
591
+ // without touching git. Returns { summary: { path, removed, errors, warnings } }.
592
+ export function retireSource(targetRoot, sourcePath, { dryRun = false } = {}) {
593
+ const summary = { path: null, removed: [], errors: [], warnings: [] };
594
+ if (!sourcePath || !String(sourcePath).trim()) {
595
+ summary.errors.push("--retire-source requires a path");
596
+ return { summary };
597
+ }
598
+ const abs = isAbsolute(sourcePath) ? sourcePath : join(targetRoot, sourcePath);
599
+ const rel = relative(targetRoot, abs);
600
+ // Stay inside the repo — never `git rm` a path outside the tree we were pointed at,
601
+ // and never the repo root itself (empty rel).
602
+ if (!rel || rel.startsWith("..") || isAbsolute(rel)) {
603
+ summary.errors.push(`refusing to retire "${sourcePath}" — it is outside the repo (or is the repo root)`);
604
+ return { summary };
605
+ }
606
+ summary.path = rel;
607
+ if (!existsSync(abs)) {
608
+ summary.errors.push(`nothing to retire at "${rel}" (path does not exist)`);
609
+ return { summary };
610
+ }
611
+ // Must be a git work tree — retirement is history-preserving, so it needs git.
612
+ try {
613
+ if (git(targetRoot, ["rev-parse", "--is-inside-work-tree"]).trim() !== "true") throw new Error("not a work tree");
614
+ } catch (e) {
615
+ summary.errors.push(e && e.code === "ENOENT" ? "git is not available on PATH" : `not a git work tree: ${targetRoot}`);
616
+ return { summary };
617
+ }
618
+ // The path must be tracked: `ls-files` lists exactly the tracked files under <rel>.
619
+ // An untracked source has no history to preserve, and `git rm` would fail anyway.
620
+ let tracked;
621
+ try {
622
+ tracked = git(targetRoot, ["ls-files", "-z", "--", rel]).split("\0").filter(Boolean);
623
+ } catch (e) {
624
+ summary.errors.push(`git ls-files failed: ${gitErr(e)}`);
625
+ return { summary };
626
+ }
627
+ if (!tracked.length) {
628
+ summary.errors.push(`"${rel}" is not tracked by git — nothing to retire (commit the import first; a history-preserving removal needs a tracked path)`);
629
+ return { summary };
630
+ }
631
+ if (dryRun) {
632
+ summary.removed = tracked;
633
+ return { summary };
634
+ }
635
+ // `git rm -r` stages the deletion (index + working tree); the user reviews and commits.
636
+ try {
637
+ git(targetRoot, ["rm", "-r", "-q", "--", rel]);
638
+ } catch (e) {
639
+ summary.errors.push(`git rm failed: ${gitErr(e)}`);
640
+ return { summary };
641
+ }
642
+ summary.removed = tracked;
643
+ return { summary };
644
+ }