@taprootio/trellis 0.1.0 → 0.2.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/SPEC.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Trellis Backlog Spec
2
2
 
3
- **Version:** 2.3.0 · **Status:** stable
3
+ **Version:** 2.4.0 · **Status:** stable
4
4
 
5
5
  Trellis is a tool-agnostic convention for running a software backlog as plain
6
6
  files in a git repository. Work items are Markdown files with YAML front-matter;
@@ -62,7 +62,8 @@ configured width — e.g. with prefix `AB` and width 4, `AB0042`.
62
62
 
63
63
  - The id MUST match the item's filename (`AB0042` ⇄ `AB0042.md`).
64
64
  - Ids are assigned monotonically from the `nextId` published in the generated
65
- `backlog.json` (§8.2).
65
+ `backlog.json` (§8.2); `nextId` is `max(highest-existing + 1, nextIdFloor)` when a
66
+ `nextIdFloor` is configured (§7).
66
67
  - An id is permanent and globally unique across all three directories. It MUST
67
68
  NOT be reused, even after an item is removed.
68
69
 
@@ -216,12 +217,18 @@ custom **effort scale** for display.
216
217
  | `priorities` | ordered priority names, highest first |
217
218
  | `effort` | canonical values, or the effort-scale object (§6.1) |
218
219
  | `tasksDir` | optional backlog-root path, repo-relative; defaults to `trellis/` |
220
+ | `nextIdFloor` | optional; floors the next organically-assigned id — `nextId` = max(floor, highest+1) — so hand-created tasks begin above a reserved band (must fit `idWidth`) |
219
221
 
220
222
  `tasksDir` locates the task tree and the generated artifacts; omit it to accept
221
223
  the `trellis/` default. The config file itself stays at `trellis/backlog.config.json`
222
224
  regardless (§2) — its location is **not** governed by `tasksDir`, so the spec
223
225
  example above omits the key.
224
226
 
227
+ `nextIdFloor` reserves the low id range: when set, the next organically-assigned id
228
+ is `max(nextIdFloor, highest-existing + 1)`, so newly created tasks begin above a
229
+ reserved band (e.g. one populated by an import) while ids within the band remain
230
+ available. Absent, the next id is the historical `highest + 1`.
231
+
225
232
  **Configurable** per repo: everything in the table above, including the backlog
226
233
  root via `tasksDir`. **Fixed** by the spec: the `trellis/backlog.config.json`
227
234
  config location, the in-root layout (`active/`, `completed/tasks/`, `removed/`,
package/docs/import.md CHANGED
@@ -54,13 +54,34 @@ failure**, so a refused import leaves the target exactly as it was.
54
54
  ### What the importer guarantees
55
55
 
56
56
  - **The source tree is read-only** — items are copied out, never moved or deleted.
57
- - **Ids are assigned fresh-sequentially** from the target's next id, so an import
58
- is safe even into a non-empty backlog. Colliding source ids are deduped by
59
- construction, and every `depends_on` is rewritten through the id map. A
60
- dependency on a collided (ambiguous) or unknown source id is a hard error.
57
+ - **Ids are assigned fresh-sequentially** from the target's next id by default (with
58
+ `--preserve-ids`, sound source ids are kept see below), so an import is safe even
59
+ into a non-empty backlog. Colliding source ids are deduped by construction, and every
60
+ `depends_on` is rewritten through the id map. A dependency on a collided (ambiguous)
61
+ or unknown source id is a hard error.
61
62
  - **A real run leaves the backlog `--check`-green**, or rolls back to the
62
63
  pre-import state if anything fails.
63
64
 
65
+ ### Preserve source ids (`--preserve-ids`)
66
+
67
+ By default an import assigns fresh ids. With `--preserve-ids`, an item **keeps its own
68
+ id** when it matches the target format exactly (`<idPrefix><idWidth digits>`) and is
69
+ still free — so a same-prefix migration (a repo already Trellis-shaped, or one sharing
70
+ the target's prefix) keeps its identities. Anything that can't keep its id — a
71
+ collision (two sources claim one id: the first keeps it, the rest are reassigned) or a
72
+ mismatched prefix/width — is reassigned into the **gap just above the imported range**,
73
+ and every remap is reported.
74
+
75
+ A configured **`nextIdFloor`** (SPEC §7) is recorded in `backlog.config.json` so the
76
+ next *organically-created* id begins above the imported band: `nextId` becomes
77
+ `max(floor, highest+1)`. The floor is auto-derived to the next multiple of 1000 above
78
+ the band, or set explicitly with `--id-floor <N>`; the gap between the imported max and
79
+ the floor is deliberate room to resolve collisions.
80
+
81
+ `depends_on` is rewritten either way. Body **prose** is copied verbatim, so when an id
82
+ changes the importer **warns** for any item whose text still names the old id — fix
83
+ those by hand (the importer never rewrites prose).
84
+
64
85
  ### Add the adoption tracker after import
65
86
 
66
87
  When onboarding a repo that already has a backlog, import the legacy backlog
@@ -110,10 +131,15 @@ repo and already tracked by git (commit the import first).
110
131
  A mapping is a JSON object. A profile is the same object shipped under
111
132
  [`profiles/`](../profiles) and addressed by name; an optional top-level
112
133
  `description` documents it and is ignored by the engine. The worked examples below
113
- are the two built-in profiles:
114
- [`taproot-ai-backlog`](../profiles/taproot-ai-backlog.json) (bold-inline + header
115
- style) and [`yaml-frontmatter`](../profiles/yaml-frontmatter.json) (full YAML
116
- front-matter).
134
+ are the three built-in profiles:
135
+ [`taproot-ai-backlog`](../profiles/taproot-ai-backlog.json) (a historical bold-inline +
136
+ header-style reference), [`yaml-frontmatter`](../profiles/yaml-frontmatter.json) (full
137
+ YAML front-matter under flat `active/`, `completed/`, `removed/`), and
138
+ [`trellis`](../profiles/trellis.json) (a **Trellis-shaped** backlog — YAML front-matter
139
+ under `active/`, `completed/tasks/`, `removed/` — for adopting, splitting, or merging
140
+ Trellis repos). Source discovery skips the generator's own `index.md` / `README.md`
141
+ artifacts, so importing a Trellis-shaped tree never picks up a generated index as a
142
+ task.
117
143
 
118
144
  ```json
119
145
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taprootio/trellis",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Trellis — a tool-agnostic toolkit for file-based backlogs; dogfoods its own conventions.",
6
6
  "keywords": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "Taproot's planning/ai-backlog: bold-inline (**Field:**) metadata on active items, header-style (Created:/Completed:) closed items, numeric-prefix filenames whose id also leads the H1 (stripped from the title via `title.stripIdPrefix`), P1–P3 priorities, and milestones that mix maturity gates with feature areas. Effort falls back to a `Size:` label; a closed item lacking a date header is dated from the source file's last commit at import time — both are reported in the import summary. The `remap.owner` entries are placeholders — edit them to your target repo's roster handles (team.json). The proven reference profile (TRL0009 imports it).",
2
+ "description": "Taproot's planning/ai-backlog: bold-inline (**Field:**) metadata on active items, header-style (Created:/Completed:) closed items, numeric-prefix filenames whose id also leads the H1 (stripped from the title via `title.stripIdPrefix`), P1–P3 priorities, and milestones that mix maturity gates with feature areas. Effort falls back to a `Size:` label; a closed item lacking a date header is dated from the source file's last commit at import time — both are reported in the import summary. The `remap.owner` entries are placeholders — edit them to your target repo's roster handles (team.json). A historical bold-inline reference (Taproot's pre-migration ai-backlog; TRL0009 imported it) that documents and exercises the `from: inline` extractor; live Trellis-shaped repos import via the `trellis` profile.",
3
3
  "sources": {
4
4
  "active": { "dirs": ["active"], "file": "*.md" },
5
5
  "completed": { "dirs": ["completed"], "file": "*.md" },
@@ -0,0 +1,23 @@
1
+ {
2
+ "description": "Import a Trellis-shaped backlog into a Trellis repo: standard YAML front-matter items under active/, completed/tasks/, and removed/ (Trellis's own output layout), with the generator's index.md / README.md artifacts skipped. For adopting, splitting, or merging Trellis backlogs. Carries no remap or defaults — it assumes the target's vocabulary (milestones, priorities, effort) already matches the source's; configure the target's milestones at init, or supply a --mapping override, if they differ.",
3
+ "sources": {
4
+ "active": { "dirs": ["active"], "file": "*.md" },
5
+ "completed": { "dirs": ["completed/tasks"], "file": "*.md" },
6
+ "removed": { "dirs": ["removed"], "file": "*.md" }
7
+ },
8
+ "fields": {
9
+ "id": { "from": "yaml", "key": "id", "fallback": { "from": "filename" } },
10
+ "title": { "from": "yaml", "key": "title", "fallback": { "from": "h1" } },
11
+ "priority": { "from": "yaml", "key": "priority" },
12
+ "effort": { "from": "yaml", "key": "effort" },
13
+ "milestone": { "from": "yaml", "key": "milestone" },
14
+ "summary": { "from": "yaml", "key": "summary" },
15
+ "owner": { "from": "yaml", "key": "owner" },
16
+ "collaborators": { "from": "yaml", "key": "collaborators" },
17
+ "depends_on": { "from": "yaml", "key": "depends_on" },
18
+ "completed_on": { "from": "yaml", "key": "completed_on" },
19
+ "removed_on": { "from": "yaml", "key": "removed_on" },
20
+ "removed_reason": { "from": "yaml", "key": "removed_reason" }
21
+ },
22
+ "summary": { "strategy": "firstSentence" }
23
+ }
@@ -28,13 +28,17 @@ Flags:
28
28
  --target <dir> target Trellis repo (default: ".")
29
29
  --apply write items and regenerate (default: dry-run, write nothing)
30
30
  --dry-run report the plan only (the default)
31
+ --preserve-ids keep a source id that fits the target format (else reassign)
32
+ --id-floor <N> floor the organic next id (default: auto — next 1000 above the band)
31
33
  --list-profiles list the built-in profiles and exit
32
34
  -h, --help show this help
33
35
 
34
- Provide exactly one of --profile or --mapping. The source tree is read-only; ids
35
- are assigned fresh-sequentially from the target's next id, colliding source ids are
36
- deduped, and depends_on is rewritten accordingly. Relative <source> paths resolve
37
- against the target repo.
36
+ Provide exactly one of --profile or --mapping. The source tree is read-only. By
37
+ default ids are assigned fresh-sequentially from the target's next id (colliding
38
+ source ids deduped). With --preserve-ids, a source id matching the target format is
39
+ kept, collisions/mismatches are reassigned into the gap above the imported band, and a
40
+ nextIdFloor is recorded so organically-created ids begin above it. depends_on is
41
+ rewritten either way. Relative <source> paths resolve against the target repo.
38
42
  `;
39
43
 
40
44
  function parseArgs(argv) {
@@ -48,6 +52,15 @@ function parseArgs(argv) {
48
52
  case "--apply": opts.apply = true; break;
49
53
  case "--dry-run": opts.dryRun = true; break;
50
54
  case "--list-profiles": opts.listProfiles = true; break;
55
+ case "--preserve-ids": opts.preserveIds = true; break;
56
+ case "--id-floor": {
57
+ const next = requiredValue(argv, i, inline, "--id-floor");
58
+ const num = Number(next.value);
59
+ if (!Number.isInteger(num) || num < 0) usageError("--id-floor must be a non-negative integer");
60
+ opts.idFloor = num;
61
+ i = next.index;
62
+ break;
63
+ }
51
64
  case "--profile": {
52
65
  const next = requiredValue(argv, i, inline, "--profile");
53
66
  opts.profile = next.value;
@@ -101,6 +114,11 @@ function report(targetRoot, summary, dryRun) {
101
114
  if (pv.effortEstimated) parts.push(`${pv.effortEstimated} effort-estimated`);
102
115
  console.log(` estimated: ${parts.join(", ")}`);
103
116
  }
117
+ if (summary.preserved || (summary.reassigned && summary.reassigned.length)) {
118
+ console.log(` ids: ${summary.preserved} preserved, ${summary.reassigned.length} reassigned`);
119
+ for (const r of summary.reassigned) console.log(` reassigned ${r.sourceId} → ${r.newId}`);
120
+ }
121
+ if (summary.idFloor != null) console.log(` nextIdFloor: ${summary.idFloor}${dryRun ? " (would set)" : " (recorded in config)"}`);
104
122
  if (summary.idMap.length) {
105
123
  console.log(" id map:");
106
124
  for (const m of summary.idMap) console.log(` ${m.sourceId} (${m.sourceFile}) → ${m.newId}`);
@@ -136,6 +154,6 @@ const targetRoot = resolveRepoRoot(opts.target);
136
154
  const sourceRoot = resolve(targetRoot, source); // relative <source> resolves against the target repo
137
155
  const dryRun = opts.dryRun || !opts.apply; // dry-run by default; --apply writes, but an explicit --dry-run always wins
138
156
 
139
- const { summary } = applyImport(targetRoot, sourceRoot, mapping, { dryRun });
157
+ const { summary } = applyImport(targetRoot, sourceRoot, mapping, { dryRun, preserveIds: opts.preserveIds, idFloor: opts.idFloor });
140
158
  report(targetRoot, summary, dryRun);
141
159
  process.exit(summary.errors.length ? 1 : 0);
@@ -32,6 +32,8 @@ Flags:
32
32
  --import <path> after scaffolding, import an existing backlog at <path>
33
33
  --profile <name> source-mapping profile for --import (trellis import --list-profiles)
34
34
  --mapping <file> mapping file (JSON) for --import (alternative to --profile)
35
+ --preserve-ids with --import, keep a source id that fits the target format (else reassign)
36
+ --id-floor <N> with --import, floor the organic next id (default: auto — next 1000 above the band)
35
37
  --retire-source <p> history-preservingly git-rm an imported source tree at <p>
36
38
  (a separate, later step — see below; cannot combine with --import)
37
39
  --force overwrite existing files instead of skipping
@@ -79,6 +81,13 @@ function parseArgs(argv) {
79
81
  case "--import": opts.import = next(); break;
80
82
  case "--profile": opts.profile = next(); break;
81
83
  case "--mapping": opts.mapping = next(); break;
84
+ case "--preserve-ids": opts.preserveIds = true; break;
85
+ case "--id-floor": {
86
+ const v = Number(next());
87
+ if (!Number.isInteger(v) || v < 0) { console.error("--id-floor must be a non-negative integer"); process.exit(2); }
88
+ opts.idFloor = v;
89
+ break;
90
+ }
82
91
  case "--retire-source": opts.retireSource = next(); break;
83
92
  case "--prefix": opts.prefix = next(); break;
84
93
  case "--id-width": opts.idWidth = Number(next()); break;
@@ -278,7 +287,7 @@ if (opts.import) {
278
287
  console.log("Re-run without --dry-run to scaffold and import, or run `npx @taprootio/trellis import --dry-run` on the initialized repo to preview the import plan.");
279
288
  process.exit(0);
280
289
  }
281
- const { summary: imp } = applyImport(targetRoot, importSource, mapping, { dryRun: false });
290
+ const { summary: imp } = applyImport(targetRoot, importSource, mapping, { dryRun: false, preserveIds: opts.preserveIds, idFloor: opts.idFloor });
282
291
  reportImport(targetRoot, imp);
283
292
  process.exit(imp.errors.length ? 1 : 0);
284
293
  }
@@ -116,13 +116,15 @@ export const TOOLS = {
116
116
  inputSchema: { ...repoRootArg },
117
117
  },
118
118
  import: {
119
- description: "Import an existing backlog into this repo via a named profile or an inline mapping. Dry-run by default; pass apply:true to write items and regenerate (rolls back on any failure). Returns the import summary (counts, idMap, created, generated).",
119
+ description: "Import an existing backlog into this repo via a named profile or an inline mapping. Dry-run by default; pass apply:true to write items and regenerate (rolls back on any failure). Returns the import summary (counts, idMap, created, generated; preserved/reassigned/idFloor under preserveIds).",
120
120
  inputSchema: {
121
121
  ...repoRootArg,
122
122
  source: z.string().describe("path to the source backlog to import; a relative path resolves against the target repo"),
123
123
  profile: z.string().optional().describe("name of a built-in source-mapping profile (alternative to `mapping`)"),
124
124
  mapping: z.record(z.string(), z.any()).optional().describe("inline mapping object describing the source schema (alternative to `profile`)"),
125
125
  apply: z.boolean().optional().describe("write items and regenerate; omit or false for a dry-run (the default)"),
126
+ preserveIds: z.boolean().optional().describe("keep a source id that matches the target format exactly (else assign fresh); collisions/mismatches are reassigned into the gap above the imported band"),
127
+ idFloor: z.number().int().min(0).optional().describe("with preserveIds, the floor for the organic next id recorded in config; default auto — next 1000 above the imported band"),
126
128
  },
127
129
  },
128
130
  history: {
package/src/backlog.mjs CHANGED
@@ -12,7 +12,7 @@ import { readFileSync, readdirSync, existsSync } from "node:fs";
12
12
  import { join, relative, isAbsolute } from "node:path";
13
13
 
14
14
  // Spec version this tool implements (SemVer major.minor); see SPEC.md §9.
15
- export const SPEC_VERSION = "2.3";
15
+ export const SPEC_VERSION = "2.4";
16
16
 
17
17
  // The backlog root defaults to `trellis/` and is overridable per repo via the
18
18
  // config's `tasksDir` key (SPEC §2/§7). The config file itself lives at a FIXED
@@ -72,6 +72,15 @@ export function loadConfig(repoRoot) {
72
72
  const errors = [];
73
73
  if (typeof cfg.idPrefix !== "string" || !cfg.idPrefix) errors.push("config: `idPrefix` must be a non-empty string");
74
74
  if (!Number.isInteger(cfg.idWidth) || cfg.idWidth < 1) errors.push("config: `idWidth` must be a positive integer");
75
+ // Optional (TRL0032): floors the *organic* next id (max(floor, max+1)) so freshly
76
+ // created tasks begin above an imported band; must fit idWidth.
77
+ if (cfg.nextIdFloor != null) {
78
+ if (!Number.isInteger(cfg.nextIdFloor) || cfg.nextIdFloor < 0) {
79
+ errors.push("config: `nextIdFloor` must be a non-negative integer when present");
80
+ } else if (Number.isInteger(cfg.idWidth) && cfg.idWidth >= 1 && cfg.nextIdFloor >= 10 ** cfg.idWidth) {
81
+ errors.push(`config: \`nextIdFloor\` (${cfg.nextIdFloor}) must be < 10^idWidth (${10 ** cfg.idWidth}) to fit ${cfg.idWidth} digits`);
82
+ }
83
+ }
75
84
  if (!Array.isArray(cfg.milestones) || cfg.milestones.length === 0) errors.push("config: `milestones` must be a non-empty array");
76
85
  if (!Array.isArray(cfg.priorities) || cfg.priorities.length === 0) errors.push("config: `priorities` must be a non-empty array");
77
86
  const effortValues = Array.isArray(cfg.effort) ? cfg.effort : cfg.effort && cfg.effort.values;
@@ -564,7 +573,10 @@ export function nextId(ids, cfg) {
564
573
  const n = Number(id.slice(cfg.idPrefix.length));
565
574
  if (Number.isFinite(n) && n > max) max = n;
566
575
  }
567
- return cfg.idPrefix + String(max + 1).padStart(cfg.idWidth, "0");
576
+ // `nextIdFloor` (TRL0032) reserves the low range for an imported band: the next
577
+ // organically-created id is max(max+1, floor). Absent floor ⇒ the historical max+1.
578
+ const floor = Number.isInteger(cfg.nextIdFloor) && cfg.nextIdFloor > 0 ? cfg.nextIdFloor : 0;
579
+ return cfg.idPrefix + String(Math.max(max + 1, floor)).padStart(cfg.idWidth, "0");
568
580
  }
569
581
 
570
582
  // ------------------------------------------------------------- rendering
package/src/import.mjs CHANGED
@@ -49,7 +49,6 @@ import {
49
49
  resolveEffort,
50
50
  findMember,
51
51
  isValidHandle,
52
- nextId,
53
52
  parseFrontMatter,
54
53
  paths,
55
54
  composeFile,
@@ -187,19 +186,26 @@ function resolveEnum(raw, remap, allowed, label) {
187
186
  // First-sentence (or title) synthesis for a missing summary (SPEC §5.1: summary is
188
187
  // required and feeds the README). Skips the H1, blank lines, and metadata-shaped
189
188
  // lines (bold `**Field:**`, list bullets, short `Key: value` headers) so it lands
190
- // on real prose, then takes that line's first sentence, single-lined. A heuristic
191
- // summary is descriptive, not correctness-critical that fails safe to the title.
189
+ // on real prose, then flows that first prose paragraph and takes its first sentence,
190
+ // single-lined (a wrapped sentence isn't cut at the newline). A heuristic — summary is
191
+ // descriptive, not correctness-critical — that fails safe to the title.
192
192
  function synthSummary(body, title, strategy) {
193
193
  if (strategy === "title") return title;
194
194
  const isMeta = (l) => l.startsWith("**") || /^[-*+]\s/.test(l) || /^[A-Za-z][\w ()/-]{0,30}:\s+\S/.test(l);
195
- for (const line of body.split("\n")) {
196
- const l = line.trim();
197
- if (!l || l.startsWith("#") || isMeta(l)) continue;
198
- const m = l.match(/^(.+?[.!?])(\s|$)/);
199
- const s = (m ? m[1] : l).replace(/\s+/g, " ").trim();
200
- if (s) return s;
201
- }
202
- return title;
195
+ const lines = body.split("\n");
196
+ let i = 0;
197
+ while (i < lines.length) { const l = lines[i].trim(); if (l && !l.startsWith("#") && !isMeta(l)) break; i++; }
198
+ if (i >= lines.length) return title;
199
+ // Flow the soft-wrapped lines of the first prose paragraph into one string so a
200
+ // sentence that wraps across lines isn't truncated at the newline; the paragraph
201
+ // ends at a blank line, a heading, or a metadata-shaped line.
202
+ const para = [];
203
+ for (; i < lines.length; i++) { const l = lines[i].trim(); if (!l || l.startsWith("#") || isMeta(l)) break; para.push(l); }
204
+ const flowed = para.join(" ").replace(/\s+/g, " ").trim();
205
+ const m = flowed.match(/^(.+?[.!?])(\s|$)/);
206
+ // First sentence if the paragraph terminates one; else fall back to the first prose
207
+ // line (unchanged from prior behavior), single-lined.
208
+ return (m ? m[1] : para[0]).replace(/\s+/g, " ").trim() || title;
203
209
  }
204
210
 
205
211
  // Rebuild a source file's prose as a Trellis body: drop a leading YAML block and a
@@ -215,6 +221,14 @@ function buildBody(raw, newId, title) {
215
221
  }
216
222
 
217
223
  // --------------------------------------------------------------- discovery
224
+ // The source-relative PATHS the Trellis generator owns (SPEC §8): a source that is
225
+ // itself a Trellis backlog carries these alongside its tasks, so never select one as an
226
+ // importable item — a `*.md` glob would otherwise pull them in and fail validation.
227
+ // Matched by PATH, not basename, so a real task that merely happens to be named
228
+ // `active/index.md` still imports.
229
+ const GENERATED_ARTIFACTS = new Set(["README.md", "completed/index.md", "removed/index.md"]);
230
+ const isGeneratedArtifact = (rel) => GENERATED_ARTIFACTS.has(rel.split(/[/\\]/).join("/"));
231
+
218
232
  function listFiles(dir, re) {
219
233
  if (!existsSync(dir)) return null; // null = dir absent (a warning), [] = present-but-empty
220
234
  return readdirSync(dir)
@@ -235,7 +249,11 @@ function discoverSources(sourceRoot, mapping, warnings) {
235
249
  const dir = join(sourceRoot, d);
236
250
  const files = listFiles(dir, re);
237
251
  if (files === null) { warnings.push(`source dir not found, skipped: ${d}`); continue; }
238
- for (const f of files) hits.push({ status, dir, file: join(dir, f), basename: f.replace(/\.[^.]+$/, ""), rel: relative(sourceRoot, join(dir, f)) });
252
+ for (const f of files) {
253
+ const rel = relative(sourceRoot, join(dir, f));
254
+ if (isGeneratedArtifact(rel)) continue; // a Trellis generated artifact at its known path — not a task
255
+ hits.push({ status, dir, file: join(dir, f), basename: f.replace(/\.[^.]+$/, ""), rel });
256
+ }
239
257
  }
240
258
  hits.sort((a, b) => a.rel.localeCompare(b.rel));
241
259
  out.push(...hits);
@@ -299,11 +317,11 @@ export function planImport(targetRoot, sourceRoot, mapping, opts = {}) {
299
317
  const sources = discoverSources(sourceRoot, mapping, warnings);
300
318
  if (!sources.length) warnings.push("no source items matched — check `sources.dirs` and the `file` pattern");
301
319
 
302
- // Fresh-sequential id allocation from the target's current nextId.
303
320
  const fields = mapping.fields;
304
321
  const idEx = fields.id || { from: "filename" };
305
- let n = Number(nextId(data.ids, cfg).slice(cfg.idPrefix.length));
306
322
  const fmtId = (num) => cfg.idPrefix + String(num).padStart(cfg.idWidth, "0");
323
+ const numOf = (id) => Number(id.slice(cfg.idPrefix.length));
324
+ const preserve = opts.preserveIds === true;
307
325
 
308
326
  const errors = [];
309
327
  const items = [];
@@ -314,13 +332,44 @@ export function planImport(targetRoot, sourceRoot, mapping, opts = {}) {
314
332
  const gitDate = opts.gitDate || ((rel) => gitCommitDate(sourceRoot, rel));
315
333
  const bySourceId = new Map(); // source id → [newId, …]; >1 ⇒ a collision (ambiguous for deps)
316
334
 
317
- for (const src of sources) {
335
+ // Pre-pass: read each source and extract its source id, then assign a target id.
336
+ // Preserve mode (opt-in) keeps a source id that matches the target format exactly and
337
+ // is still free; collisions, format mismatches, and every id in the default mode are
338
+ // assigned fresh from the gap ABOVE the imported band. Allocation is floor-agnostic —
339
+ // the configured `nextIdFloor` governs only the organic next id (SPEC §7).
340
+ const parsed = sources.map((src) => {
318
341
  const raw = readFileSync(src.file, "utf8");
319
342
  const ctx = { raw, basename: src.basename, yaml: parseFrontMatter(raw, src.rel, []) || {} };
320
- const newId = fmtId(n++);
321
343
  const sourceId = String(runExtractor(idEx, ctx) ?? src.basename).trim();
322
- const at = bySourceId.get(sourceId) || []; at.push(newId); bySourceId.set(sourceId, at);
344
+ return { src, raw, ctx, sourceId, newId: null };
345
+ });
346
+ const exactRe = new RegExp(`^${escRe(cfg.idPrefix)}\\d{${cfg.idWidth}}$`);
347
+ const used = new Set(data.ids);
348
+ let maxBand = 0;
349
+ for (const id of data.ids) { const x = numOf(id); if (Number.isFinite(x) && x > maxBand) maxBand = x; }
350
+ const reassigned = []; // {sourceId, newId} — items whose id changed under preserve mode
351
+ if (preserve) {
352
+ for (const p of parsed) {
353
+ if (exactRe.test(p.sourceId) && !used.has(p.sourceId)) {
354
+ p.newId = p.sourceId; used.add(p.sourceId); maxBand = Math.max(maxBand, numOf(p.sourceId));
355
+ }
356
+ }
357
+ }
358
+ // Non-preserve mode seeds from the organic next id (honoring nextIdFloor, SPEC §7);
359
+ // preserve mode fills the gap above the imported band (floor-agnostic), so reassigned
360
+ // collisions/mismatches stay with the band rather than jumping to the floor.
361
+ const floorSeed = Number.isInteger(cfg.nextIdFloor) && cfg.nextIdFloor > 0 ? cfg.nextIdFloor : 0;
362
+ let n = preserve ? maxBand + 1 : Math.max(maxBand + 1, floorSeed);
363
+ for (const p of parsed) {
364
+ if (p.newId) continue;
365
+ while (used.has(fmtId(n))) n++;
366
+ p.newId = fmtId(n); used.add(p.newId); n++;
367
+ if (preserve) reassigned.push({ sourceId: p.sourceId, newId: p.newId });
368
+ }
369
+ for (const p of parsed) { const at = bySourceId.get(p.sourceId) || []; at.push(p.newId); bySourceId.set(p.sourceId, at); }
323
370
 
371
+ for (const p of parsed) {
372
+ const { src, raw, ctx, sourceId, newId } = p;
324
373
  const ierr = (m) => errors.push(`${src.rel}: ${m}`);
325
374
  const isActive = src.status === "active";
326
375
 
@@ -515,10 +564,29 @@ export function planImport(targetRoot, sourceRoot, mapping, opts = {}) {
515
564
  it.fm.depends_on = out;
516
565
  }
517
566
 
567
+ // Prose-ref report (preserve mode): depends_on is rewritten through the id map, but
568
+ // body prose is copied verbatim, so warn when an imported body still names an id that
569
+ // was reassigned (report-only — never rewrite prose).
570
+ for (const { sourceId: oldId, newId } of reassigned) {
571
+ const re = new RegExp(`\\b${escRe(oldId)}\\b`);
572
+ // Skip the canonical H1 (buildBody re-heads it with the NEW id); only prose refs matter.
573
+ for (const it of items) if (re.test(it.body.replace(/^#[^\n]*\n?/, ""))) warnings.push(`${it.sourceRel}: body still names "${oldId}" (reassigned to ${newId}) — update the prose`);
574
+ }
575
+ // New-id floor (preserve mode): reserve the imported band so the ORGANIC next id
576
+ // begins above it. Auto-derive to the next multiple of 1000 above the band unless
577
+ // `--id-floor` overrides; skip with a warning if it wouldn't fit idWidth.
578
+ let idFloor = null;
579
+ if (preserve) {
580
+ const want = opts.idFloor != null ? opts.idFloor : (Math.floor(maxBand / 1000) + 1) * 1000;
581
+ if (want >= 10 ** cfg.idWidth) warnings.push(`nextIdFloor ${want} does not fit idWidth ${cfg.idWidth} (max ${10 ** cfg.idWidth - 1}) — floor not set`);
582
+ else { idFloor = want; cfg.nextIdFloor = want; }
583
+ }
584
+
518
585
  const counts = { active: 0, completed: 0, removed: 0, total: items.length };
519
586
  for (const it of items) counts[it.status]++;
520
587
  const idMap = items.map((it) => ({ sourceFile: it.sourceRel, sourceId: it.sourceId, newId: it.newId, status: it.status }));
521
- return { cfg, root, items, idMap, counts, provenance, warnings, errors };
588
+ const preserved = preserve ? parsed.filter((p) => p.newId === p.sourceId).length : 0;
589
+ return { cfg, root, items, idMap, counts, provenance, warnings, errors, preserved, reassigned, idFloor };
522
590
  }
523
591
 
524
592
  // The four generated-artifact paths, repo-relative under the backlog root.
@@ -531,13 +599,16 @@ function generatedRels(root) {
531
599
  // the backlog is --check-green. On ANY failure, roll back (remove the new items,
532
600
  // restore the artifacts) so a rejected import leaves the target exactly as it was.
533
601
  // `dryRun` returns the plan without writing. The source tree is never written.
534
- export function applyImport(targetRoot, sourceRoot, mapping, { dryRun = false, gitDate } = {}) {
535
- const summary = { imported: [], created: [], generated: [], idMap: [], counts: null, provenance: null, root: null, warnings: [], errors: [] };
536
- const plan = planImport(targetRoot, sourceRoot, mapping, { gitDate });
602
+ export function applyImport(targetRoot, sourceRoot, mapping, { dryRun = false, gitDate, preserveIds = false, idFloor } = {}) {
603
+ const summary = { imported: [], created: [], generated: [], idMap: [], counts: null, provenance: null, root: null, preserved: 0, reassigned: [], idFloor: null, warnings: [], errors: [] };
604
+ const plan = planImport(targetRoot, sourceRoot, mapping, { gitDate, preserveIds, idFloor });
537
605
  summary.idMap = plan.idMap;
538
606
  summary.counts = plan.counts;
539
607
  summary.provenance = plan.provenance;
540
608
  summary.root = plan.root;
609
+ summary.preserved = plan.preserved;
610
+ summary.reassigned = plan.reassigned;
611
+ summary.idFloor = plan.idFloor;
541
612
  summary.warnings = plan.warnings;
542
613
  if (plan.errors.length) { summary.errors.push(...plan.errors); return { summary }; }
543
614
 
@@ -551,17 +622,26 @@ export function applyImport(targetRoot, sourceRoot, mapping, { dryRun = false, g
551
622
  const p = paths(targetRoot, plan.cfg);
552
623
  const artifacts = [p.readme, p.completedIndex, p.removedIndex, p.backlogJson];
553
624
  const priorArtifacts = artifacts.map((path) => ({ path, before: existsSync(path) ? readFileSync(path, "utf8") : null }));
625
+ // Persisting nextIdFloor mutates the config; snapshot it for rollback.
626
+ const priorConfig = plan.idFloor != null ? readFileSync(p.config, "utf8") : null;
554
627
  const written = [];
555
628
  try {
556
629
  for (const it of plan.items) {
557
630
  const abs = join(targetRoot, it.targetRel);
558
- // Fresh ids never collide with existing items; a pre-existing target file
559
- // would mean a corrupt plan, so refuse rather than clobber.
631
+ // A preserved id could match an existing item; a pre-existing target file means a
632
+ // corrupt plan (preservation checks the target's ids first), so refuse to clobber.
560
633
  if (existsSync(abs)) throw new Error(`refusing to overwrite existing ${it.targetRel}`);
561
634
  mkdirSync(dirname(abs), { recursive: true });
562
635
  writeFileSync(abs, composeFile(it.fm, it.body));
563
636
  written.push(abs);
564
637
  }
638
+ // Record the new-id floor before regenerating so backlog.json's nextId (and every
639
+ // future organic id) begins above the imported band; plan.cfg already carries it.
640
+ if (plan.idFloor != null) {
641
+ const obj = JSON.parse(priorConfig);
642
+ obj.nextIdFloor = plan.idFloor;
643
+ writeFileSync(p.config, JSON.stringify(obj, null, 2) + "\n");
644
+ }
565
645
  const data = readBacklog(targetRoot, plan.cfg);
566
646
  if (data.errors.length) throw new Error(`imported backlog is invalid: ${data.errors.join("; ")}`);
567
647
  const { files, errors } = generateArtifacts(targetRoot, plan.cfg, data);
@@ -577,6 +657,7 @@ export function applyImport(targetRoot, sourceRoot, mapping, { dryRun = false, g
577
657
  for (const a of priorArtifacts) {
578
658
  try { a.before === null ? rmSync(a.path, { force: true }) : writeFileSync(a.path, a.before); } catch { /* best-effort */ }
579
659
  }
660
+ if (priorConfig != null) { try { writeFileSync(p.config, priorConfig); } catch { /* best-effort */ } }
580
661
  summary.errors.push(e.message);
581
662
  return { summary };
582
663
  }
package/src/init.mjs CHANGED
@@ -224,12 +224,14 @@ function configContent(o) {
224
224
  ) + "\n";
225
225
  }
226
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).
227
+ // The team roster stub (SPEC §7.2): one INACTIVE placeholder member showing the shape
228
+ // (`handle`/`name`/optional `email`/`status`) without reading like a real teammate —
229
+ // an inactive member can't own active items, so it's clearly a stub to replace or
230
+ // delete rather than live config. Authored, not generated; left for the repo to edit.
231
+ // An unused roster keeps the scaffold --check-green (no task owns it).
230
232
  function teamContent() {
231
233
  return JSON.stringify(
232
- { members: [{ handle: "example", name: "Example Member", email: "example@example.com", status: "active" }] },
234
+ { members: [{ handle: "example", name: "Example teammate (replace or delete)", email: "you@example.com", status: "inactive" }] },
233
235
  null,
234
236
  2,
235
237
  ) + "\n";
package/src/mcp.mjs CHANGED
@@ -407,8 +407,9 @@ export function importOp(repoRoot, args = {}) {
407
407
  // lives in the repo being onboarded); an absolute path is used as-is.
408
408
  const src = args.source.trim();
409
409
  const source = isAbsolute(src) ? src : join(repoRoot, src);
410
+ if (args.idFloor != null && (!Number.isInteger(args.idFloor) || args.idFloor < 0)) throw new TrellisError("`idFloor` must be a non-negative integer");
410
411
  const dryRun = !args.apply;
411
- const { summary } = applyImport(repoRoot, source, mapping, { dryRun });
412
+ const { summary } = applyImport(repoRoot, source, mapping, { dryRun, preserveIds: args.preserveIds === true, idFloor: args.idFloor });
412
413
  if (summary.errors.length) throw new TrellisError(summary.errors.join("; "), "import_failed");
413
414
  return { ...summary, dryRun };
414
415
  }