@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/LICENSE +21 -0
- package/README.md +77 -0
- package/SPEC.md +405 -0
- package/docs/import.md +273 -0
- package/package.json +59 -0
- package/profiles/taproot-ai-backlog.json +28 -0
- package/profiles/yaml-frontmatter.json +23 -0
- package/scripts/backlog-readme.mjs +84 -0
- package/scripts/pr-title-lint.mjs +69 -0
- package/scripts/trellis-history.mjs +127 -0
- package/scripts/trellis-import.mjs +141 -0
- package/scripts/trellis-init.mjs +287 -0
- package/scripts/trellis-mcp.mjs +222 -0
- package/scripts/trellis.mjs +62 -0
- package/src/backlog.mjs +667 -0
- package/src/cli.mjs +33 -0
- package/src/history.mjs +204 -0
- package/src/import.mjs +583 -0
- package/src/init.mjs +644 -0
- package/src/mcp.mjs +449 -0
- package/src/pr-title.mjs +47 -0
- package/src/profiles.mjs +68 -0
- package/src/prompts.mjs +189 -0
- package/templates/.github/pull_request_template.md +31 -0
- package/templates/trellis/branch-protection.md +116 -0
- package/templates/trellis/playbooks/code-review.md +64 -0
- package/templates/trellis/playbooks/conventions.md +56 -0
- package/templates/trellis/playbooks/pr-draft.md +39 -0
- package/templates/trellis/playbooks/work-task.md +76 -0
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
|
+
}
|