@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/docs/import.md ADDED
@@ -0,0 +1,273 @@
1
+ # Importing an existing backlog
2
+
3
+ Trellis can import a backlog that already exists on a *foreign* schema — a folder
4
+ of Markdown files with their own front-matter, bold-inline metadata, or header
5
+ lines — and convert it into conformant Trellis items. The conversion is driven by
6
+ a declarative **mapping**; a reusable, named mapping is a **profile**.
7
+
8
+ This guide has two parts: [onboarding a repo that already has a
9
+ backlog](#onboard-a-repo-that-already-has-a-backlog), and the [mapping/profile
10
+ schema reference](#the-mapping-schema).
11
+
12
+ ## Onboard a repo that already has a backlog
13
+
14
+ ### One command: `init --import`
15
+
16
+ If the repo is not yet a Trellis backlog, scaffold and import in one step:
17
+
18
+ ```
19
+ npx @taprootio/trellis init --import planning/old-backlog --profile yaml-frontmatter
20
+ ```
21
+
22
+ This scaffolds the Trellis layout (config, `trellis/` tree, generated indexes, CI
23
+ check, AGENTS block, playbooks), then imports the backlog at the given path. A
24
+ relative `<path>` resolves against the repo being onboarded. Add `--dry-run` to
25
+ preview the scaffold without writing; to preview the import plan itself (counts,
26
+ id map, warnings), run `trellis import --dry-run` once the repo is initialized.
27
+
28
+ Use `--mapping <file.json>` instead of `--profile <name>` to supply your own
29
+ mapping. Provide exactly one of the two.
30
+
31
+ ### Two steps: `init`, then `import`
32
+
33
+ If the repo is already initialized (or you want to import again later), run the
34
+ importer on its own. It is **dry-run by default** — it prints the plan, the id
35
+ map, and per-field warnings without writing — so you can review before committing
36
+ to a write:
37
+
38
+ ```
39
+ npx @taprootio/trellis import planning/old-backlog --profile yaml-frontmatter # preview
40
+ npx @taprootio/trellis import planning/old-backlog --profile yaml-frontmatter --apply # write
41
+ ```
42
+
43
+ `trellis import --list-profiles` lists the built-in profiles. The target defaults
44
+ to `.`; pass `--target <dir>` to import into another repo.
45
+
46
+ ### Over MCP
47
+
48
+ The same operation is exposed as the MCP `import` tool, so an MCP-aware client can
49
+ onboard a backlog without a shell. It takes `source`, exactly one of `profile` or
50
+ `mapping` (an inline mapping object), and an optional `apply` flag. Like the CLI it
51
+ is **dry-run unless `apply: true`**, and it regenerates and **rolls back on any
52
+ failure**, so a refused import leaves the target exactly as it was.
53
+
54
+ ### What the importer guarantees
55
+
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.
61
+ - **A real run leaves the backlog `--check`-green**, or rolls back to the
62
+ pre-import state if anything fails.
63
+
64
+ ### Add the adoption tracker after import
65
+
66
+ When onboarding a repo that already has a backlog, import the legacy backlog
67
+ before creating any new Trellis task for the adoption work itself. Trellis assigns
68
+ imported ids from the target's current `nextId`, so an early "Adopt Trellis" item
69
+ would consume the first id and shift every imported task up by one. Import first,
70
+ review the id map, then create and work the adoption tracker at the next id.
71
+
72
+ ### After adopting: reconcile guidance, then retire the source
73
+
74
+ Importing copies items into Trellis but leaves two things for you to finish the
75
+ adoption — both deliberately **report-first**, so nothing author-written is lost.
76
+
77
+ **Reconcile stale guidance.** Every `trellis init` run scans a small set of root
78
+ guidance files (`AGENTS.md`, `AI_GUIDELINES.md`, `CLAUDE.md`) for pre-Trellis backlog
79
+ instructions — an "AI Backlog" section, a reference to the old backlog path — and
80
+ prints them as a `reconcile` checklist:
81
+
82
+ ```
83
+ reconcile (1) — pre-Trellis backlog guidance to rewrite by hand (init left these untouched):
84
+ - AI_GUIDELINES.md: has a "AI Backlog" section that looks like pre-Trellis backlog guidance — rewrite it to point at trellis/
85
+ ```
86
+
87
+ `init` **only ever reports** here — it appends its own marked block and never edits or
88
+ deletes your prose. The surgical rewrite (point the section at `trellis/`, drop the old
89
+ field names) is the onboarding agent's job; the checklist just says where to look. The
90
+ scan skips Trellis's own appended block and any section that already points at the new
91
+ root, so it won't flag guidance you've already migrated.
92
+
93
+ **Retire the old source tree.** The importer never touches the source, so after you have
94
+ imported, **reviewed, and committed** the result, the legacy tree is still on disk. Once
95
+ you're satisfied, retire it history-preservingly:
96
+
97
+ ```
98
+ npx @taprootio/trellis init --retire-source planning/old-backlog --dry-run # list what would go
99
+ npx @taprootio/trellis init --retire-source planning/old-backlog # stage the removal
100
+ ```
101
+
102
+ This runs `git rm -r` on the path — git keeps the files' history — and **stages** the
103
+ deletion for you to review (`git status`) and commit. It does not scaffold, import, or
104
+ commit, and it **cannot be combined with `--import`**: retirement is a separate, later
105
+ step so the source is intact if an import ever rolls back. The path must be inside the
106
+ repo and already tracked by git (commit the import first).
107
+
108
+ ## The mapping schema
109
+
110
+ A mapping is a JSON object. A profile is the same object shipped under
111
+ [`profiles/`](../profiles) and addressed by name; an optional top-level
112
+ `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).
117
+
118
+ ```json
119
+ {
120
+ "description": "optional, human-only",
121
+ "sources": {
122
+ "active": { "dirs": ["active"], "file": "*.md" },
123
+ "completed": { "dirs": ["completed"], "file": "*.md" },
124
+ "removed": { "dirs": ["removed"], "file": "*.md" }
125
+ },
126
+ "fields": {
127
+ "title": { "from": "h1" },
128
+ "priority": { "from": "inline", "label": "Priority" },
129
+ "milestone": { "from": "inline", "label": "Milestone" },
130
+ "effort": { "from": "inline", "label": "Effort", "fallback": { "from": "inline", "label": "Size" } },
131
+ "owner": { "from": "inline", "label": "Owner" }
132
+ },
133
+ "remap": { "priority": { "P1": "High" }, "milestone": { "Pre-Launch": "Alpha" }, "effort": { "S": 1, "M": 3, "L": 8 }, "owner": { "Jane Doe": "jane" } },
134
+ "defaults": { "milestone": "Alpha", "priority": "Low", "effort": 1 },
135
+ "summary": { "strategy": "firstSentence" }
136
+ }
137
+ ```
138
+
139
+ ### `sources` (required)
140
+
141
+ One entry per source **status** — at least one of `active`, `completed`,
142
+ `removed`. Each has `dirs` (a non-empty list of source-relative directories; no
143
+ absolute paths or `..` segments) and an optional `file` glob (default `*.md`,
144
+ `*`-only). An item's status is determined by **which source directory it is in** —
145
+ see [the directory caveat](#caveats).
146
+
147
+ ### `fields` (required)
148
+
149
+ How to locate each field on a source item. Recognized fields: `id`, `title`,
150
+ `priority`, `effort`, `milestone`, `summary`, `depends_on`, `owner`,
151
+ `collaborators`, `completed_on`, `removed_on`, `removed_reason`. Each maps to an
152
+ **extractor**:
153
+
154
+ | extractor | reads |
155
+ | --- | --- |
156
+ | `{ "from": "yaml", "key": "k" }` | front-matter key `k` |
157
+ | `{ "from": "inline", "label": "L" }` | a bold metadata line `**L:** value` |
158
+ | `{ "from": "header", "label": "L" }` | a header line `L: value` |
159
+ | `{ "from": "h1" }` | the first `# Heading` |
160
+ | `{ "from": "filename", "pattern": "^(\\d+)" }` | the filename (no extension); `pattern`'s first capture group if given |
161
+ | `{ "from": "const", "value": "v" }` | the literal `v` |
162
+
163
+ Any extractor may carry a `"fallback": { … }` extractor, tried when the primary
164
+ yields nothing (e.g. `completed_on` from a `Completed:` line, falling back to
165
+ `Created:`). Defaults: `id` → filename, `title` → `h1`. `summary` is
166
+ [synthesized](#how-fields-are-resolved) when absent. `owner` is a single roster
167
+ handle; `collaborators` is a list (an inline `[a, b]`, a `-` block, or a
168
+ comma/semicolon-separated value), resolved the same way as `owner`.
169
+
170
+ The `title` field accepts one extra option, `"stripIdPrefix": true`: when the
171
+ resolved title begins with the item's **own** source id followed by a separator —
172
+ whitespace, optionally around a single `. : - – —` — that leading token is dropped,
173
+ so a foreign `# 001 README Truth Pass` (source id `001`) imports as `README Truth
174
+ Pass`. The cleaned title also heads the rebuilt body. It is matched **exactly**
175
+ against the source id and only when a real whitespace break follows, so id `04` never
176
+ bites into a `047 …` title, an unbroken `001README` is left intact, and a genuinely
177
+ number-leading title (`2024 Roadmap` under a different id) is untouched. Off by
178
+ default; the [`taproot-ai-backlog`](../profiles/taproot-ai-backlog.json) profile
179
+ (numeric-prefix filenames) sets it on.
180
+
181
+ ### `remap` (optional)
182
+
183
+ Resolve foreign values to the target's configured vocabulary, by field
184
+ (`priority`, `milestone`, `effort`, `owner`). Keys are matched case-insensitively. A
185
+ milestone that mixes maturity gates with feature areas (which the spec disallows — see
186
+ SPEC §7.1) **must** be remapped to a single maturity axis; an unmapped value on an
187
+ active item is a hard error, never a silent guess. `remap.owner` maps a source assignee
188
+ (e.g. a display name or legacy username) to a roster `handle` (SPEC §7.2) and applies
189
+ to **both** `owner` and `collaborators` — they share one identity space. `remap.effort`
190
+ maps a foreign size token (`S`/`M`/`L`, or an off-scale number) to a canonical effort
191
+ value (SPEC §6); paired with an `effort` extractor that falls back to a `Size:` label,
192
+ that is how a legacy `**Size:**` field becomes Trellis effort.
193
+
194
+ ### `defaults` (optional)
195
+
196
+ Fill a field when the source has no value for it — chiefly the historical
197
+ descriptive metadata (`milestone`, `priority`, `effort`, and `removed_reason`)
198
+ that header-style legacy closed items lack but the schema still requires on
199
+ completed/removed items (SPEC §5.1). A defaulted value is treated like an
200
+ extracted one. `defaults.owner` is the fallback owner for **active** items whose
201
+ source owner doesn't resolve to an active roster member (closed items keep their
202
+ historical owner instead). `defaults.completed_on` / `defaults.removed_on` are an
203
+ optional **floor** for a close date with no header that git can't recover (see
204
+ [Import-time git and provenance](#import-time-git-and-provenance)); a defaulted close
205
+ date is flagged in the import summary.
206
+
207
+ ### `summary` (optional)
208
+
209
+ `{ "strategy": "firstSentence" }` (default) synthesizes a missing summary from the
210
+ first prose sentence, skipping headings and metadata-shaped lines; `"title"` uses
211
+ the title.
212
+
213
+ ### How fields are resolved
214
+
215
+ - **Active items** are validated in full against the target config: priority,
216
+ milestone, and effort must resolve, or the import is refused with an actionable
217
+ error.
218
+ - **Closed items** (completed/removed) keep their enum values as a *historical*
219
+ snapshot — those are not re-validated against the current config (SPEC §5.1,
220
+ §8.3) — but the metadata must be **present** (from the source or `defaults`).
221
+ - **Close dates** (`completed_on`/`removed_on`) resolve through a fallback chain when
222
+ the field is **absent**: the date header/field → the source file's **last git commit
223
+ date** (read at import time from the *source* repo) → an optional `defaults.<field>`
224
+ floor → a hard error. A field that is **present but malformed** (e.g. an impossible
225
+ `2024-02-31`) is refused outright — never papered over by git or a floor. A
226
+ git-derived or defaulted date is flagged, never silently passed as authored — see
227
+ [Import-time git and provenance](#import-time-git-and-provenance).
228
+ - **Owners and collaborators** resolve against the target's `team.json` roster
229
+ (SPEC §7.2). On an **active** item the resolution chain is: `remap.owner` (or a
230
+ direct case-insensitive handle match) → `defaults.owner` → **unassigned, with a
231
+ warning** — an owner that resolves to no active member is dropped, never invented.
232
+ An unresolved collaborator is likewise dropped with a warning. On a **closed**
233
+ item the value is historical: a member who has since gone inactive keeps their
234
+ canonical handle; a handle absent from the roster is kept (after `remap.owner`) only
235
+ if it is a valid handle — a former member — and otherwise dropped with a warning, so
236
+ a non-handle value can never corrupt the stored item. Carrying owners therefore requires the target
237
+ repo to have a `team.json` (with no roster, every owner drops or carries as
238
+ above); curating the roster can stay a manual post-import step.
239
+
240
+ ### Import-time git and provenance
241
+
242
+ Some legacy backlogs don't record everything the schema needs — most commonly a
243
+ closed item with no date header, or an item whose effort lives under a `Size:` label
244
+ or isn't recorded at all. The importer fills these as faithfully as it can and
245
+ **reports what it inferred**, so nothing passes as authored history:
246
+
247
+ - **Git is read at import time only.** When a close date has no header, the importer
248
+ reads the source file's last commit date from the *source* repo's git. This is the
249
+ one-time importer's privilege; the deterministic generator and `--check` never read
250
+ git (SPEC §8.4), so the gate stays reproducible.
251
+ - **It degrades, never fails on git alone.** Missing git, a non-repo or shallow
252
+ source, or an uncommitted file all make the lookup return nothing — the chain then
253
+ falls to a `defaults.<field>` floor if configured, and only errors if neither git
254
+ nor a floor yields a date. The importer never invents a close date out of nothing.
255
+ - **Inferred values are flagged.** A git-derived or floor-defaulted close date, and a
256
+ **closed** item whose effort fell back to `defaults.effort` (no `Effort:`/`Size:`
257
+ signal), each emit a per-item warning and increment a count in the import summary
258
+ (`N git-dated, M effort-estimated`). Provenance lives in that report — reviewed
259
+ before `--apply` — not in a new front-matter field: volatile git-derived data stays
260
+ out of the gated item files (SPEC §8.4). A higher-quality per-item effort estimate
261
+ is a job for the onboarding agent, not the dependency-free importer.
262
+
263
+ ### Caveats
264
+
265
+ - **Remap targets are the *target* repo's vocabulary.** The built-in profiles map
266
+ to the default Trellis vocabulary (`Alpha → Beta → v1 → Future`, `High`/`Medium`/
267
+ `Low`, Fibonacci effort). If your target repo configures a different vocabulary,
268
+ edit the profile's `remap` (and `defaults`) to match it.
269
+ - **Status comes from the source *directory*, not a per-item field.** The engine
270
+ routes items by which `sources.{active,completed,removed}.dirs` directory they
271
+ live in. A source that keeps everything in one folder with a `Status:` field is
272
+ not directly importable today; split it into per-status directories first, or
273
+ point each status at the directory that holds those items.
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@taprootio/trellis",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Trellis — a tool-agnostic toolkit for file-based backlogs; dogfoods its own conventions.",
6
+ "keywords": [
7
+ "backlog",
8
+ "tasks",
9
+ "task-management",
10
+ "project-management",
11
+ "cli",
12
+ "mcp",
13
+ "file-based",
14
+ "git",
15
+ "trellis"
16
+ ],
17
+ "homepage": "https://github.com/taprootio/trellis#readme",
18
+ "bugs": "https://github.com/taprootio/trellis/issues",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/taprootio/trellis.git"
22
+ },
23
+ "license": "MIT",
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "bin": {
28
+ "trellis": "./scripts/trellis.mjs",
29
+ "trellis-mcp": "./scripts/trellis-mcp.mjs"
30
+ },
31
+ "files": [
32
+ "scripts/",
33
+ "src/",
34
+ "profiles/",
35
+ "templates/",
36
+ "docs/import.md",
37
+ "README.md",
38
+ "SPEC.md"
39
+ ],
40
+ "publishConfig": {
41
+ "access": "public",
42
+ "registry": "https://registry.npmjs.org/"
43
+ },
44
+ "scripts": {
45
+ "trellis": "node scripts/trellis.mjs",
46
+ "backlog:readme": "node scripts/backlog-readme.mjs",
47
+ "backlog:check": "node scripts/backlog-readme.mjs --check",
48
+ "trellis:init": "node scripts/trellis-init.mjs",
49
+ "trellis:import": "node scripts/trellis-import.mjs",
50
+ "trellis:history": "node scripts/trellis-history.mjs",
51
+ "pr-title:lint": "node scripts/pr-title-lint.mjs",
52
+ "trellis:mcp": "node scripts/trellis-mcp.mjs",
53
+ "test": "node --test"
54
+ },
55
+ "dependencies": {
56
+ "@modelcontextprotocol/sdk": "^1.29.0",
57
+ "zod": "^4.4.3"
58
+ }
59
+ }
@@ -0,0 +1,28 @@
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).",
3
+ "sources": {
4
+ "active": { "dirs": ["active"], "file": "*.md" },
5
+ "completed": { "dirs": ["completed"], "file": "*.md" },
6
+ "removed": { "dirs": ["removed"], "file": "*.md" }
7
+ },
8
+ "fields": {
9
+ "id": { "from": "filename", "pattern": "^(\\d+)" },
10
+ "title": { "from": "h1", "stripIdPrefix": true },
11
+ "priority": { "from": "inline", "label": "Priority" },
12
+ "effort": { "from": "inline", "label": "Effort", "fallback": { "from": "inline", "label": "Size" } },
13
+ "milestone": { "from": "inline", "label": "Milestone" },
14
+ "owner": { "from": "inline", "label": "Owner" },
15
+ "collaborators": { "from": "inline", "label": "Collaborators" },
16
+ "depends_on": { "from": "inline", "label": "Depends on" },
17
+ "completed_on": { "from": "header", "label": "Completed", "fallback": { "from": "header", "label": "Created" } },
18
+ "removed_on": { "from": "header", "label": "Removed" },
19
+ "removed_reason": { "from": "header", "label": "Reason" }
20
+ },
21
+ "remap": {
22
+ "priority": { "P1": "High", "P2": "Medium", "P3": "Low" },
23
+ "milestone": { "Pre-Launch": "Alpha", "MCP Servers": "Beta", "Post-Launch": "v1", "Back-Burner": "Future", "Legal Research": "Future" },
24
+ "owner": { "Jane Doe": "jane", "John Smith": "john" }
25
+ },
26
+ "defaults": { "milestone": "Alpha", "priority": "Low", "effort": 1 },
27
+ "summary": { "strategy": "firstSentence" }
28
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "description": "Generic backlog where each item carries complete YAML front-matter (title, priority, milestone, effort, summary, depends_on, dates) under active/, completed/, and removed/ directories, in standard Trellis vocabulary. Exercises the `yaml` extractor with no remap or defaults — the differently-shaped second source that keeps the profile schema from over-fitting Taproot.",
3
+ "sources": {
4
+ "active": { "dirs": ["active"], "file": "*.md" },
5
+ "completed": { "dirs": ["completed"], "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
+ }
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ // Trellis backlog CLI — a thin wrapper over the reusable core in src/backlog.mjs.
3
+ //
4
+ // node scripts/backlog-readme.mjs # rewrite generated files in cwd
5
+ // node scripts/backlog-readme.mjs --check # fail if any generated file is stale
6
+ // node scripts/backlog-readme.mjs --target <repo> # operate on another repo
7
+ //
8
+ // Logic lives in src/backlog.mjs so the MCP server can share it. This stays
9
+ // dependency-free on purpose (see SPEC.md §5 / the front-matter parser).
10
+
11
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
12
+ import { relative } from "node:path";
13
+ import { loadConfig, readBacklog, generateArtifacts } from "../src/backlog.mjs";
14
+ import { optionToken, requiredValue, resolveRepoRoot, showHelp, usageError } from "../src/cli.mjs";
15
+
16
+ const HELP = `trellis generate/check — validate and regenerate Trellis artifacts
17
+
18
+ Usage:
19
+ trellis generate [--target <repo>]
20
+ trellis check [--target <repo>]
21
+ node scripts/backlog-readme.mjs [--check] [--target <repo>]
22
+
23
+ Flags:
24
+ --target <repo> repo root to operate on (default: cwd)
25
+ --repo <repo> alias for --target
26
+ --check validate only; fail if generated files are stale
27
+ -h, --help show this help
28
+ `;
29
+
30
+ function parseArgs(argv) {
31
+ const opts = { target: process.cwd(), check: false };
32
+ for (let i = 0; i < argv.length; i++) {
33
+ const a = argv[i];
34
+ const { key, inline } = optionToken(a);
35
+ switch (key) {
36
+ case "-h": case "--help": opts.help = true; break;
37
+ case "--check": opts.check = true; break;
38
+ case "--target": case "--repo": {
39
+ const next = requiredValue(argv, i, inline, key);
40
+ opts.target = next.value;
41
+ i = next.index;
42
+ break;
43
+ }
44
+ default:
45
+ if (a.startsWith("-")) usageError(`Unknown flag: ${a}`);
46
+ usageError(`Unexpected argument: ${a}`);
47
+ }
48
+ }
49
+ return opts;
50
+ }
51
+
52
+ const opts = parseArgs(process.argv.slice(2));
53
+ if (opts.help) showHelp(HELP);
54
+
55
+ const repoRoot = resolveRepoRoot(opts.target);
56
+ const isCheck = opts.check;
57
+ const rel = (p) => relative(repoRoot, p);
58
+
59
+ function die(errors) {
60
+ console.error("Backlog validation failed:\n" + errors.map((e) => " - " + e).join("\n"));
61
+ process.exit(1);
62
+ }
63
+
64
+ const { cfg, warnings, errors: cfgErrors } = loadConfig(repoRoot);
65
+ for (const w of warnings) console.warn(`warning: ${w}`);
66
+ if (cfgErrors.length) die(cfgErrors);
67
+
68
+ const data = readBacklog(repoRoot, cfg);
69
+ if (data.errors.length) die(data.errors);
70
+
71
+ const { files, nextId, errors } = generateArtifacts(repoRoot, cfg, data);
72
+ if (errors.length) die(errors);
73
+
74
+ if (isCheck) {
75
+ const stale = files.filter((f) => (existsSync(f.path) ? readFileSync(f.path, "utf8") : "") !== f.content);
76
+ if (stale.length) {
77
+ for (const f of stale) console.error(`${rel(f.path)} is stale - run: npx @taprootio/trellis generate`);
78
+ process.exit(1);
79
+ }
80
+ console.log("Backlog check OK.");
81
+ } else {
82
+ for (const f of files) writeFileSync(f.path, f.content);
83
+ console.log(`Backlog OK: ${data.active.length} active, ${data.completed.length} completed, ${data.removed.length} removed. Next id: ${nextId}`);
84
+ }
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+ // PR-title lint CLI — a thin wrapper over src/pr-title.mjs, run by the
3
+ // pr-title workflow on every pull_request.
4
+ //
5
+ // PR_TITLE="TRL0016: add the lint" node scripts/pr-title-lint.mjs
6
+ // PR_TITLE="DEMO0001: add the lint" trellis pr-title --repo <target>
7
+ //
8
+ // Reads the title from $PR_TITLE (set from the pull_request event title) and the
9
+ // id vocabulary from the target repo's backlog.config.json, then exits non-zero on any
10
+ // violation. Logic lives in src/pr-title.mjs so it stays dependency-free and
11
+ // `node --test`-able.
12
+
13
+ import { loadConfig } from "../src/backlog.mjs";
14
+ import { optionToken, requiredValue, resolveRepoRoot, showHelp, usageError } from "../src/cli.mjs";
15
+ import { lintPrTitle } from "../src/pr-title.mjs";
16
+
17
+ const HELP = `trellis pr-title — lint a pull request title
18
+
19
+ Usage:
20
+ PR_TITLE="TASK0001: concise title" trellis pr-title [--repo <target>]
21
+
22
+ Flags:
23
+ --repo <target> repo root whose Trellis id vocabulary should be used (default: cwd)
24
+ --target <target> alias for --repo
25
+ -h, --help show this help
26
+ `;
27
+
28
+ function parseArgs(argv) {
29
+ const opts = { repo: process.cwd() };
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const a = argv[i];
32
+ const { key, inline } = optionToken(a);
33
+ switch (key) {
34
+ case "-h": case "--help": opts.help = true; break;
35
+ case "--repo": case "--target": {
36
+ const next = requiredValue(argv, i, inline, key);
37
+ opts.repo = next.value;
38
+ i = next.index;
39
+ break;
40
+ }
41
+ default:
42
+ if (a.startsWith("-")) usageError(`Unknown flag: ${a}`);
43
+ usageError(`Unexpected argument: ${a}`);
44
+ }
45
+ }
46
+ return opts;
47
+ }
48
+
49
+ const opts = parseArgs(process.argv.slice(2));
50
+ if (opts.help) showHelp(HELP);
51
+
52
+ const repoRoot = resolveRepoRoot(opts.repo);
53
+
54
+ const { cfg, warnings, errors: cfgErrors } = loadConfig(repoRoot);
55
+ for (const w of warnings) console.log(`warning: ${w}`);
56
+ if (cfgErrors.length) {
57
+ console.error("Cannot lint the PR title:\n" + cfgErrors.map((e) => " - " + e).join("\n"));
58
+ process.exit(1);
59
+ }
60
+
61
+ const title = process.env.PR_TITLE ?? "";
62
+ const { ok, errors } = lintPrTitle(title, cfg);
63
+ if (!ok) {
64
+ console.error(`PR title does not conform to the standard:\n title: ${JSON.stringify(title)}`);
65
+ for (const e of errors) console.error(" - " + e);
66
+ console.error("\nSee .github/pull_request_template.md / trellis/playbooks/pr-draft.md.");
67
+ process.exit(1);
68
+ }
69
+ console.log(`PR title OK: ${title}`);
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ // Trellis history CLI — a thin wrapper over the deriver in src/history.mjs.
3
+ //
4
+ // node scripts/trellis-history.mjs [<id>] [--repo <path>] [--json] [--write [--out <file>]]
5
+ //
6
+ // Reconstructs per-task change history from git (SPEC §8.4 — a derived, NON-gated
7
+ // report). With an <id>, shows that task's history; without, the whole repo. `--write`
8
+ // materializes history.json for a static viewer / site build. This report is volatile
9
+ // (commit times, authors) and is deliberately NOT part of `backlog:check` and NOT
10
+ // produced by `backlog:readme` — git, not Trellis, is the authoritative record. Logic
11
+ // lives in src/history.mjs so the MCP `history` tool shares it; this stays
12
+ // dependency-free.
13
+
14
+ import { loadConfig } from "../src/backlog.mjs";
15
+ import { optionToken, requiredValue, resolveRepoRoot, showHelp, usageError } from "../src/cli.mjs";
16
+ import { deriveTaskHistory, deriveAllHistory, materializeHistory, HistoryError } from "../src/history.mjs";
17
+
18
+ const HELP = `trellis history — git-derived per-task history
19
+
20
+ Usage:
21
+ trellis history [<id>] [flags]
22
+
23
+ Flags:
24
+ --repo <path> repo root to derive from (default: cwd)
25
+ --json emit structured JSON instead of a human summary
26
+ --write materialize history.json (whole repo) for a static viewer / CI build
27
+ --out <file> output path for --write (default: <tasksDir>/history.json)
28
+ -h, --help show this help
29
+
30
+ With <id>: that task's entries (newest-first). Without: the whole repo. Entries are
31
+ { id, commit, date, author, subject, reason }; reason is the Trellis-Reason commit
32
+ trailer when present, else the subject. This report is NOT gated by backlog:check.
33
+ `;
34
+
35
+ function parseArgs(argv) {
36
+ const opts = {};
37
+ let id;
38
+ for (let i = 0; i < argv.length; i++) {
39
+ const a = argv[i];
40
+ const { key, inline } = optionToken(a);
41
+ // A value-taking flag must get a real value: not missing, not empty, and (for the
42
+ // space-separated form) not the next flag — so `--out --json` errors instead of
43
+ // silently swallowing `--json` as the path. Use `--out=<value>` to force an
44
+ // unusual value that begins with `-`.
45
+ switch (key) {
46
+ case "-h": case "--help": opts.help = true; break;
47
+ case "--json": opts.json = true; break;
48
+ case "--write": opts.write = true; break;
49
+ case "--repo": case "--target": {
50
+ const next = requiredValue(argv, i, inline, key);
51
+ opts.repo = next.value;
52
+ i = next.index;
53
+ break;
54
+ }
55
+ case "--out": {
56
+ const next = requiredValue(argv, i, inline, "--out");
57
+ opts.out = next.value;
58
+ i = next.index;
59
+ break;
60
+ }
61
+ default:
62
+ if (a.startsWith("-")) usageError(`Unknown flag: ${a}`);
63
+ if (id === undefined) id = a;
64
+ else usageError(`Unexpected extra argument: ${a}`);
65
+ }
66
+ }
67
+ return { id, opts };
68
+ }
69
+
70
+ const shortSha = (s) => s.slice(0, 9);
71
+ const shortDate = (s) => s.slice(0, 10);
72
+
73
+ function printTaskHuman(id, entries) {
74
+ if (!entries.length) { console.log(`${id}: no recorded history (not committed yet).`); return; }
75
+ console.log(`${id} — ${entries.length} entr${entries.length === 1 ? "y" : "ies"} (newest first):`);
76
+ for (const e of entries) {
77
+ console.log(` ${shortDate(e.date)} ${shortSha(e.commit)} ${e.author}`);
78
+ console.log(` ${e.subject}`);
79
+ if (e.reason !== e.subject) console.log(` reason: ${e.reason}`);
80
+ }
81
+ }
82
+
83
+ function printAllHuman(tasks) {
84
+ const ids = Object.keys(tasks);
85
+ const entryCount = ids.reduce((n, k) => n + tasks[k].length, 0);
86
+ console.log(`History — ${ids.length} task${ids.length === 1 ? "" : "s"}, ${entryCount} entr${entryCount === 1 ? "y" : "ies"}:`);
87
+ for (const id of ids) {
88
+ const es = tasks[id];
89
+ if (!es.length) { console.log(` ${id} (no recorded history)`); continue; }
90
+ const last = es[0];
91
+ console.log(` ${id} ${es.length} entr${es.length === 1 ? "y" : "ies"} last: ${shortDate(last.date)} ${last.author} — ${last.subject}`);
92
+ }
93
+ }
94
+
95
+ const { id, opts } = parseArgs(process.argv.slice(2));
96
+ if (opts.help) showHelp(HELP);
97
+
98
+ const repoRoot = resolveRepoRoot(opts.repo);
99
+ const { cfg, warnings, errors } = loadConfig(repoRoot);
100
+ for (const w of warnings) console.warn(`warning: ${w}`);
101
+ if (errors.length) { console.error(`error: config: ${errors.join("; ")}`); process.exit(2); }
102
+
103
+ try {
104
+ if (opts.write) {
105
+ // Materializing is whole-repo by nature (keyed by every id); an id would imply a
106
+ // partial file, so refuse it rather than silently ignore.
107
+ if (id !== undefined) { console.error("error: --write materializes the whole repo; omit the <id>"); process.exit(2); }
108
+ const res = materializeHistory(repoRoot, cfg, { out: opts.out });
109
+ if (opts.json) {
110
+ process.stdout.write(JSON.stringify(res, null, 2) + "\n");
111
+ } else {
112
+ console.log(`Wrote ${res.path} — ${res.taskCount} task${res.taskCount === 1 ? "" : "s"}, ${res.entryCount} entr${res.entryCount === 1 ? "y" : "ies"} (${res.bytes} bytes).`);
113
+ console.log("This is a regenerable, non-gated cache (git is authoritative); it is gitignored — regenerate at build time.");
114
+ }
115
+ } else if (id !== undefined) {
116
+ const { entries } = deriveTaskHistory(repoRoot, cfg, id);
117
+ if (opts.json) process.stdout.write(JSON.stringify({ id, entries }, null, 2) + "\n");
118
+ else printTaskHuman(id, entries);
119
+ } else {
120
+ const all = deriveAllHistory(repoRoot, cfg);
121
+ if (opts.json) process.stdout.write(JSON.stringify(all, null, 2) + "\n");
122
+ else printAllHuman(all.tasks);
123
+ }
124
+ } catch (e) {
125
+ if (e instanceof HistoryError) { console.error(`error: ${e.message}`); process.exit(1); }
126
+ throw e;
127
+ }