@taprootio/trellis 0.1.1 → 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 +9 -2
- package/docs/import.md +34 -8
- package/package.json +1 -1
- package/profiles/taproot-ai-backlog.json +1 -1
- package/profiles/trellis.json +23 -0
- package/scripts/trellis-import.mjs +23 -5
- package/scripts/trellis-init.mjs +10 -1
- package/scripts/trellis-mcp.mjs +3 -1
- package/src/backlog.mjs +14 -2
- package/src/import.mjs +104 -23
- package/src/init.mjs +6 -4
- package/src/mcp.mjs +2 -1
package/SPEC.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Trellis Backlog Spec
|
|
2
2
|
|
|
3
|
-
**Version:** 2.
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
114
|
-
[`taproot-ai-backlog`](../profiles/taproot-ai-backlog.json) (bold-inline +
|
|
115
|
-
style)
|
|
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,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).
|
|
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
|
|
35
|
-
are assigned fresh-sequentially from the target's next id
|
|
36
|
-
deduped,
|
|
37
|
-
|
|
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);
|
package/scripts/trellis-init.mjs
CHANGED
|
@@ -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
|
}
|
package/scripts/trellis-mcp.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
191
|
-
//
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
559
|
-
//
|
|
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
|
|
228
|
-
// (`handle`/`name`/optional `email`/`status`)
|
|
229
|
-
//
|
|
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
|
|
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
|
}
|