@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/src/mcp.mjs
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
// Trellis MCP operations (zero-dependency, transport-agnostic).
|
|
2
|
+
//
|
|
3
|
+
// The backlog operations the MCP server exposes as tools, each a plain function
|
|
4
|
+
// over the TRL0002 core (src/backlog.mjs) that takes an explicit repoRoot and
|
|
5
|
+
// returns a structured result (the backlog.json shape or a slice; the import tool
|
|
6
|
+
// returns an import summary).
|
|
7
|
+
// The SDK + stdio wiring live in scripts/trellis-mcp.mjs; keeping these functions
|
|
8
|
+
// dependency-free means the whole tool surface is unit-testable with `node --test`
|
|
9
|
+
// and no transport.
|
|
10
|
+
//
|
|
11
|
+
// Mutating ops (create_task, move_task, regenerate) honour one rule (TRL0004
|
|
12
|
+
// Risk): apply the change, then re-read + validate the whole backlog and
|
|
13
|
+
// regenerate every artifact before returning. On any validation failure they roll
|
|
14
|
+
// back to the pre-call state, so the repo is never left invalid or with stale
|
|
15
|
+
// generated files — mirroring init's no-partial-write ethos.
|
|
16
|
+
|
|
17
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
|
|
18
|
+
import { join, relative, dirname, isAbsolute } from "node:path";
|
|
19
|
+
import {
|
|
20
|
+
loadConfig,
|
|
21
|
+
readBacklog,
|
|
22
|
+
generateArtifacts,
|
|
23
|
+
buildBacklogJson,
|
|
24
|
+
resolveEffort,
|
|
25
|
+
findActiveMember,
|
|
26
|
+
isValidHandle,
|
|
27
|
+
nextId,
|
|
28
|
+
parseFrontMatter,
|
|
29
|
+
paths,
|
|
30
|
+
composeFile,
|
|
31
|
+
} from "./backlog.mjs";
|
|
32
|
+
import { applyImport } from "./import.mjs";
|
|
33
|
+
import { loadProfile } from "./profiles.mjs";
|
|
34
|
+
import { deriveTaskHistory, deriveAllHistory, HistoryError } from "./history.mjs";
|
|
35
|
+
|
|
36
|
+
// A domain error the transport maps to an MCP tool error (isError result) rather
|
|
37
|
+
// than a protocol failure. `code` is a short slug for programmatic handling.
|
|
38
|
+
export class TrellisError extends Error {
|
|
39
|
+
constructor(message, code = "invalid_request") {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "TrellisError";
|
|
42
|
+
this.code = code;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ------------------------------------------------------------- loading
|
|
47
|
+
// Config must load for any op (we can't compute ids/validate without it).
|
|
48
|
+
function loadCfg(repoRoot) {
|
|
49
|
+
const { cfg, errors } = loadConfig(repoRoot);
|
|
50
|
+
if (errors.length) throw new TrellisError(`config: ${errors.join("; ")}`, "config_error");
|
|
51
|
+
return cfg;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Read ops tolerate per-item errors (they still list what parsed); mutating ops
|
|
55
|
+
// require a clean backlog up front, since you should not mutate an invalid one.
|
|
56
|
+
function loadClean(repoRoot) {
|
|
57
|
+
const cfg = loadCfg(repoRoot);
|
|
58
|
+
const data = readBacklog(repoRoot, cfg);
|
|
59
|
+
if (data.errors.length) {
|
|
60
|
+
throw new TrellisError(`backlog has errors; fix before mutating: ${data.errors.join("; ")}`, "invalid_backlog");
|
|
61
|
+
}
|
|
62
|
+
return { cfg, data };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The backlog.json shape as an object — parsed from the core's serializer so the
|
|
66
|
+
// tool result is byte-for-byte the same contract clients read from disk.
|
|
67
|
+
function backlogObject(cfg, data) {
|
|
68
|
+
return JSON.parse(buildBacklogJson(cfg, data));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ----------------------------------------------------------- front-matter
|
|
72
|
+
// The serializer (FM_ORDER / emitScalar / serializeFrontMatter / composeFile) lives
|
|
73
|
+
// in the core (src/backlog.mjs) so the importer shares one writer; only the
|
|
74
|
+
// MCP-specific input guard stays here.
|
|
75
|
+
function oneLine(value, field) {
|
|
76
|
+
if (typeof value !== "string" || !value.trim()) throw new TrellisError(`\`${field}\` is required`);
|
|
77
|
+
if (/[\r\n]/.test(value)) throw new TrellisError(`\`${field}\` must be a single line`);
|
|
78
|
+
return value.trim();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ----------------------------------------------------------------- ownership
|
|
82
|
+
// Resolve a create_task `owner` arg → a canonical active-roster handle, or undefined
|
|
83
|
+
// when omitted. A provided owner that is not an active member is a clear error up
|
|
84
|
+
// front (the post-write re-read is the backstop). Empty/whitespace is treated as unset.
|
|
85
|
+
function resolveOwnerArg(value, roster) {
|
|
86
|
+
if (value === undefined || value === null || String(value).trim() === "") return undefined;
|
|
87
|
+
const v = oneLine(value, "owner");
|
|
88
|
+
const m = findActiveMember(roster, v);
|
|
89
|
+
if (!m) throw new TrellisError(`owner "${v}" is not an active roster member`, "invalid_request");
|
|
90
|
+
return m.handle;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Resolve a create_task `collaborators` arg → canonical active-roster handles, deduped,
|
|
94
|
+
// or [] when omitted. Each must be an active member.
|
|
95
|
+
function resolveCollaboratorsArg(value, roster) {
|
|
96
|
+
if (value === undefined) return [];
|
|
97
|
+
if (!Array.isArray(value) || value.some((c) => typeof c !== "string")) {
|
|
98
|
+
throw new TrellisError("`collaborators` must be a list of roster handles");
|
|
99
|
+
}
|
|
100
|
+
const out = [];
|
|
101
|
+
for (const c of value) {
|
|
102
|
+
const v = c.trim();
|
|
103
|
+
if (!v) continue;
|
|
104
|
+
const m = findActiveMember(roster, v);
|
|
105
|
+
if (!m) throw new TrellisError(`collaborator "${v}" is not an active roster member`, "invalid_request");
|
|
106
|
+
if (!out.includes(m.handle)) out.push(m.handle);
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// A historical assignee skips roster membership (closed items are historical, SPEC
|
|
112
|
+
// §8.3) but MUST still be a valid handle, so it round-trips through the inline
|
|
113
|
+
// serializer — a comma/bracket would corrupt the `collaborators` array (SPEC §7.2).
|
|
114
|
+
function assertHandle(v) {
|
|
115
|
+
if (!isValidHandle(v)) throw new TrellisError(`invalid handle "${v}" (use letters, digits, ., _, -)`, "invalid_request");
|
|
116
|
+
return v.trim();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Apply an optional owner/collaborators override when closing a task. On close the
|
|
120
|
+
// values are historical (SPEC §8.3) — not re-validated against the roster — so this
|
|
121
|
+
// only shapes the front-matter (a now-inactive assignee, or who actually did it, is
|
|
122
|
+
// allowed); passing null/empty clears the field.
|
|
123
|
+
function applyOwnershipOverride(fm, args) {
|
|
124
|
+
if (args.owner !== undefined) {
|
|
125
|
+
if (args.owner === null || String(args.owner).trim() === "") delete fm.owner;
|
|
126
|
+
else fm.owner = assertHandle(oneLine(args.owner, "owner"));
|
|
127
|
+
}
|
|
128
|
+
if (args.collaborators !== undefined) {
|
|
129
|
+
if (!Array.isArray(args.collaborators) || args.collaborators.some((c) => typeof c !== "string")) {
|
|
130
|
+
throw new TrellisError("`collaborators` must be a list of roster handles");
|
|
131
|
+
}
|
|
132
|
+
const cleaned = [...new Set(args.collaborators.map((c) => c.trim()).filter(Boolean))].map(assertHandle);
|
|
133
|
+
if (cleaned.length) fm.collaborators = cleaned; else delete fm.collaborators;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Split an item file into its front-matter object and the Markdown body.
|
|
138
|
+
function splitItem(text, where) {
|
|
139
|
+
const norm = text.replace(/\r\n/g, "\n");
|
|
140
|
+
const errors = [];
|
|
141
|
+
const fm = parseFrontMatter(norm, where, errors);
|
|
142
|
+
if (!fm || errors.length) throw new TrellisError(`${where}: ${errors.join("; ") || "unparseable front-matter"}`, "invalid_item");
|
|
143
|
+
const m = norm.match(/^---\n[\s\S]*?\n---\n?/);
|
|
144
|
+
// Strip the blank line that follows the closing `---` so the body starts at its
|
|
145
|
+
// first heading — prependSection anchors on a leading H1.
|
|
146
|
+
const body = (m ? norm.slice(m[0].length) : "").replace(/^\n+/, "");
|
|
147
|
+
return { fm, body };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Insert a section (e.g. "## Completed") right after the body's H1, so a closeout
|
|
151
|
+
// note lands where the spec recommends (SPEC §5.2, prepended on closeout).
|
|
152
|
+
function prependSection(body, heading, note) {
|
|
153
|
+
if (!note) return body;
|
|
154
|
+
const block = `## ${heading}\n\n${note}`;
|
|
155
|
+
const m = body.match(/^(#\s[^\n]*\n)/);
|
|
156
|
+
if (!m) return `${block}\n\n${body}`;
|
|
157
|
+
const rest = body.slice(m[0].length).replace(/^\n+/, "");
|
|
158
|
+
return `${m[1]}\n${block}\n\n${rest}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function scaffoldBody(id, title) {
|
|
162
|
+
return `# ${id} — ${title}\n\n## Scope\n\n## Notes\n\n## Risks\n`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// --------------------------------------------------------------- dates
|
|
166
|
+
function isoDate(s) {
|
|
167
|
+
return typeof s === "string" && /^\d{4}-\d{2}-\d{2}$/.test(s);
|
|
168
|
+
}
|
|
169
|
+
function todayISO() {
|
|
170
|
+
// Local calendar date, not UTC — so a default close date doesn't slip a day
|
|
171
|
+
// when the server's wall clock is on the other side of midnight from UTC.
|
|
172
|
+
const d = new Date();
|
|
173
|
+
return new Date(d.getTime() - d.getTimezoneOffset() * 60_000).toISOString().slice(0, 10);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function idRegex(cfg) {
|
|
177
|
+
return new RegExp(`^${cfg.idPrefix}\\d{${cfg.idWidth}}$`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Reject a client-supplied id that is not a well-formed task id for this repo,
|
|
181
|
+
// before it is ever used to build a filesystem path — closes path traversal via an
|
|
182
|
+
// id like "../active/DEMO0001" (which, with to:"removed", could delete a task).
|
|
183
|
+
function assertId(id, cfg) {
|
|
184
|
+
const v = oneLine(id, "id");
|
|
185
|
+
if (!idRegex(cfg).test(v)) {
|
|
186
|
+
throw new TrellisError(`invalid task id: ${v} (expected ${cfg.idPrefix} + ${cfg.idWidth} digits)`, "invalid_request");
|
|
187
|
+
}
|
|
188
|
+
return v;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------- regenerate (shared)
|
|
192
|
+
// Write any stale artifact; return the repo-relative paths that changed. Pure
|
|
193
|
+
// w.r.t. the item files — only the four generated artifacts are touched.
|
|
194
|
+
function writeArtifacts(repoRoot, cfg, data) {
|
|
195
|
+
const { files, nextId: next, errors } = generateArtifacts(repoRoot, cfg, data);
|
|
196
|
+
if (errors.length) throw new TrellisError(`generate failed: ${errors.join("; ")}`, "generate_failed");
|
|
197
|
+
// Snapshot prior bytes first so a mid-loop write failure (ENOSPC/EACCES) restores
|
|
198
|
+
// every artifact — honouring the Risk's "never left … with stale generated files."
|
|
199
|
+
const prior = files.map((f) => ({ path: f.path, before: existsSync(f.path) ? readFileSync(f.path, "utf8") : null }));
|
|
200
|
+
const done = [];
|
|
201
|
+
try {
|
|
202
|
+
const changed = [];
|
|
203
|
+
for (const f of files) {
|
|
204
|
+
const { before } = prior.find((p) => p.path === f.path);
|
|
205
|
+
if (before !== f.content) {
|
|
206
|
+
writeFileSync(f.path, f.content);
|
|
207
|
+
done.push(f.path);
|
|
208
|
+
changed.push(relative(repoRoot, f.path));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return { changed, nextId: next };
|
|
212
|
+
} catch (e) {
|
|
213
|
+
for (const p of prior) {
|
|
214
|
+
if (!done.includes(p.path)) continue;
|
|
215
|
+
try { p.before === null ? rmSync(p.path, { force: true }) : writeFileSync(p.path, p.before); } catch { /* best-effort restore */ }
|
|
216
|
+
}
|
|
217
|
+
throw e;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ================================================================= tools
|
|
222
|
+
|
|
223
|
+
// list_tasks — the backlog header + tasks, optionally filtered. counts/nextId
|
|
224
|
+
// always describe the whole backlog; only `tasks` is narrowed by the filters.
|
|
225
|
+
export function listTasks(repoRoot, { status, milestone } = {}) {
|
|
226
|
+
const cfg = loadCfg(repoRoot);
|
|
227
|
+
const data = readBacklog(repoRoot, cfg);
|
|
228
|
+
const backlog = backlogObject(cfg, data);
|
|
229
|
+
let tasks = backlog.tasks;
|
|
230
|
+
if (status) tasks = tasks.filter((t) => t.status === status);
|
|
231
|
+
if (milestone) tasks = tasks.filter((t) => t.milestone === milestone);
|
|
232
|
+
return { ...backlog, tasks };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// get_task — the structured entry plus the raw Markdown body and file path.
|
|
236
|
+
export function getTask(repoRoot, { id } = {}) {
|
|
237
|
+
const cfg = loadCfg(repoRoot);
|
|
238
|
+
const taskId = assertId(id, cfg); // normalized (trimmed) — use it everywhere below
|
|
239
|
+
const data = readBacklog(repoRoot, cfg);
|
|
240
|
+
const entry = backlogObject(cfg, data).tasks.find((t) => t.id === taskId);
|
|
241
|
+
if (!entry) throw new TrellisError(`no task with id ${taskId}`, "not_found");
|
|
242
|
+
const p = paths(repoRoot, cfg);
|
|
243
|
+
const dir = entry.status === "active" ? p.active : entry.status === "completed" ? p.completedTasks : p.removed;
|
|
244
|
+
const file = join(dir, `${taskId}.md`);
|
|
245
|
+
const { body } = splitItem(readFileSync(file, "utf8"), `${entry.status}/${taskId}.md`);
|
|
246
|
+
return { ...entry, body: body.replace(/\n*$/, "\n"), file: relative(repoRoot, file) };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// next_id — the id a new task would receive.
|
|
250
|
+
export function nextIdOp(repoRoot) {
|
|
251
|
+
const cfg = loadCfg(repoRoot);
|
|
252
|
+
const data = readBacklog(repoRoot, cfg);
|
|
253
|
+
return { nextId: nextId(data.ids, cfg) };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// validate — config + item + marker validity, read-only. Never throws on backlog
|
|
257
|
+
// content; reporting those errors is the point. (`regenerate` owns staleness.)
|
|
258
|
+
export function validateOp(repoRoot) {
|
|
259
|
+
const { cfg, warnings, errors: cfgErrors } = loadConfig(repoRoot);
|
|
260
|
+
if (cfgErrors.length) return { ok: false, errors: cfgErrors, warnings };
|
|
261
|
+
const data = readBacklog(repoRoot, cfg);
|
|
262
|
+
const gen = generateArtifacts(repoRoot, cfg, data); // computes only; the CLI writes
|
|
263
|
+
const errors = [...data.errors, ...gen.errors];
|
|
264
|
+
return { ok: errors.length === 0, errors, warnings };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// regenerate — rewrite any stale artifact. Refuses on an invalid backlog rather
|
|
268
|
+
// than generating from bad input.
|
|
269
|
+
export function regenerateOp(repoRoot) {
|
|
270
|
+
const { cfg, data } = loadClean(repoRoot);
|
|
271
|
+
const { changed, nextId: next } = writeArtifacts(repoRoot, cfg, data);
|
|
272
|
+
return { changed, nextId: next, counts: backlogObject(cfg, data).counts };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// create_task — assign the next id, write active/<id>.md, then re-read + validate
|
|
276
|
+
// + regenerate. Rolls back (removes the new file) if the result does not validate.
|
|
277
|
+
export function createTask(repoRoot, args = {}) {
|
|
278
|
+
const title = oneLine(args.title, "title");
|
|
279
|
+
const summary = oneLine(args.summary, "summary");
|
|
280
|
+
const milestone = oneLine(args.milestone, "milestone");
|
|
281
|
+
const priority = oneLine(args.priority, "priority");
|
|
282
|
+
if (typeof args.effort !== "number" && typeof args.effort !== "string") {
|
|
283
|
+
throw new TrellisError("`effort` must be a number or a scale label");
|
|
284
|
+
}
|
|
285
|
+
const rawDeps = args.depends_on ?? [];
|
|
286
|
+
if (!Array.isArray(rawDeps) || rawDeps.some((d) => typeof d !== "string")) {
|
|
287
|
+
throw new TrellisError("`depends_on` must be a list of task ids");
|
|
288
|
+
}
|
|
289
|
+
const depends_on = [...new Set(rawDeps)];
|
|
290
|
+
|
|
291
|
+
const { cfg, data } = loadClean(repoRoot);
|
|
292
|
+
// Resolve a number or a case-insensitive scale label to the canonical number,
|
|
293
|
+
// which is what we store in front-matter (SPEC §6.2).
|
|
294
|
+
const effort = resolveEffort(cfg, args.effort);
|
|
295
|
+
if (effort.error) throw new TrellisError(effort.error, "invalid_request");
|
|
296
|
+
// Each dependency id must be a well-formed id for this repo — reject up front so a
|
|
297
|
+
// stray value can't be injected into the front-matter array (the core would catch
|
|
298
|
+
// it on re-read, but this fails faster and with a clearer message).
|
|
299
|
+
const idRe = idRegex(cfg);
|
|
300
|
+
for (const d of depends_on) {
|
|
301
|
+
if (!idRe.test(d)) throw new TrellisError(`invalid dependency id: ${d} (expected ${cfg.idPrefix} + ${cfg.idWidth} digits)`, "invalid_request");
|
|
302
|
+
}
|
|
303
|
+
// Ownership (optional): resolve each handle to an ACTIVE roster member up front for
|
|
304
|
+
// a clear error and canonical casing; the post-write re-read re-checks as a backstop.
|
|
305
|
+
const owner = resolveOwnerArg(args.owner, data.roster);
|
|
306
|
+
const collaborators = resolveCollaboratorsArg(args.collaborators, data.roster);
|
|
307
|
+
|
|
308
|
+
const id = nextId(data.ids, cfg);
|
|
309
|
+
const fm = { id, title, status: "active", milestone, priority, effort: effort.value, depends_on, summary };
|
|
310
|
+
if (owner) fm.owner = owner;
|
|
311
|
+
if (collaborators.length) fm.collaborators = collaborators;
|
|
312
|
+
const body = args.body ? args.body : scaffoldBody(id, title);
|
|
313
|
+
const file = join(paths(repoRoot, cfg).active, `${id}.md`);
|
|
314
|
+
|
|
315
|
+
// The write lives inside the try so a failure at any point — the item write, the
|
|
316
|
+
// re-validate, or regenerate — rolls the brand-new file back.
|
|
317
|
+
try {
|
|
318
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
319
|
+
writeFileSync(file, composeFile(fm, body));
|
|
320
|
+
const data2 = readBacklog(repoRoot, cfg);
|
|
321
|
+
if (data2.errors.length) throw new TrellisError(`created task is invalid: ${data2.errors.join("; ")}`, "invalid_backlog");
|
|
322
|
+
writeArtifacts(repoRoot, cfg, data2);
|
|
323
|
+
return { created: backlogObject(cfg, data2).tasks.find((t) => t.id === id) };
|
|
324
|
+
} catch (e) {
|
|
325
|
+
try { rmSync(file, { force: true }); } catch { /* best-effort */ }
|
|
326
|
+
throw e;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// move_task — active → completed/removed: move the file, update front-matter, and
|
|
331
|
+
// prepend the closeout note. Rolls back (restore source, remove target) on a
|
|
332
|
+
// validation failure.
|
|
333
|
+
export function moveTask(repoRoot, args = {}) {
|
|
334
|
+
const to = args.to;
|
|
335
|
+
if (to !== "completed" && to !== "removed") throw new TrellisError("`to` must be \"completed\" or \"removed\"");
|
|
336
|
+
const date = args.date === undefined ? todayISO() : args.date;
|
|
337
|
+
if (!isoDate(date)) throw new TrellisError("`date` must be an ISO date (YYYY-MM-DD)");
|
|
338
|
+
const reason = to === "removed" ? oneLine(args.reason, "reason") : undefined;
|
|
339
|
+
|
|
340
|
+
const { cfg } = loadClean(repoRoot);
|
|
341
|
+
const id = assertId(args.id, cfg); // validate the id format before building any path
|
|
342
|
+
const p = paths(repoRoot, cfg);
|
|
343
|
+
const src = join(p.active, `${id}.md`);
|
|
344
|
+
if (!existsSync(src)) {
|
|
345
|
+
const elsewhere = existsSync(join(p.completedTasks, `${id}.md`)) || existsSync(join(p.removed, `${id}.md`));
|
|
346
|
+
throw new TrellisError(elsewhere ? `task ${id} is not active; only active tasks can be moved` : `no active task with id ${id}`, "not_found");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const original = readFileSync(src, "utf8");
|
|
350
|
+
const { fm, body } = splitItem(original, `active/${id}.md`);
|
|
351
|
+
fm.status = to;
|
|
352
|
+
if (to === "completed") fm.completed_on = date;
|
|
353
|
+
else { fm.removed_on = date; fm.removed_reason = reason; }
|
|
354
|
+
// Ownership carries over from the active item automatically; allow an optional
|
|
355
|
+
// override at close (historical from here — not re-validated against the roster).
|
|
356
|
+
applyOwnershipOverride(fm, args);
|
|
357
|
+
const heading = to === "completed" ? "Completed" : "Removed";
|
|
358
|
+
const newBody = prependSection(body, heading, args.note ? oneLine(args.note, "note") : "");
|
|
359
|
+
|
|
360
|
+
const targetDir = to === "completed" ? p.completedTasks : p.removed;
|
|
361
|
+
const target = join(targetDir, `${id}.md`);
|
|
362
|
+
// The move (write target, remove source) lives inside the try so a failure at any
|
|
363
|
+
// point — including between the two writes — restores the source and undoes the
|
|
364
|
+
// target. Restores are best-effort so the original error is never masked.
|
|
365
|
+
try {
|
|
366
|
+
mkdirSync(targetDir, { recursive: true });
|
|
367
|
+
writeFileSync(target, composeFile(fm, newBody));
|
|
368
|
+
rmSync(src, { force: true });
|
|
369
|
+
const data2 = readBacklog(repoRoot, cfg);
|
|
370
|
+
if (data2.errors.length) throw new TrellisError(`move produced an invalid backlog: ${data2.errors.join("; ")}`, "invalid_backlog");
|
|
371
|
+
writeArtifacts(repoRoot, cfg, data2);
|
|
372
|
+
return { moved: backlogObject(cfg, data2).tasks.find((t) => t.id === id), counts: backlogObject(cfg, data2).counts };
|
|
373
|
+
} catch (e) {
|
|
374
|
+
try { writeFileSync(src, original); } catch { /* best-effort */ } // restore the source…
|
|
375
|
+
try { rmSync(target, { force: true }); } catch { /* best-effort */ } // …and undo the target write
|
|
376
|
+
throw e;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// import — bring an existing backlog into this repo via a named profile or an
|
|
381
|
+
// inline mapping. The engine (src/import.mjs) does the resolve → write →
|
|
382
|
+
// regenerate → roll-back-on-any-failure; this adapter only resolves the mapping,
|
|
383
|
+
// runs it against the target, and maps a refused/failed import (summary.errors) to
|
|
384
|
+
// a TrellisError so the transport returns an isError result rather than a silent
|
|
385
|
+
// partial. DRY-RUN unless `apply: true` — mirroring the `trellis import` CLI's safe
|
|
386
|
+
// default for a bulk, multi-file operation.
|
|
387
|
+
export function importOp(repoRoot, args = {}) {
|
|
388
|
+
if (typeof args.source !== "string" || !args.source.trim()) {
|
|
389
|
+
throw new TrellisError("`source` is required (path to the backlog to import)");
|
|
390
|
+
}
|
|
391
|
+
const hasProfile = args.profile != null && String(args.profile).trim() !== "";
|
|
392
|
+
const hasMapping = args.mapping != null;
|
|
393
|
+
// Exactly one mapping source — a profile name or an inline mapping object.
|
|
394
|
+
if (hasProfile === hasMapping) {
|
|
395
|
+
throw new TrellisError("provide exactly one of `profile` (a built-in profile name) or `mapping` (an inline mapping object)");
|
|
396
|
+
}
|
|
397
|
+
let mapping;
|
|
398
|
+
if (hasProfile) {
|
|
399
|
+
const r = loadProfile(String(args.profile).trim());
|
|
400
|
+
if (r.error) throw new TrellisError(r.error, "not_found");
|
|
401
|
+
mapping = r.mapping;
|
|
402
|
+
} else {
|
|
403
|
+
if (typeof args.mapping !== "object" || Array.isArray(args.mapping)) throw new TrellisError("`mapping` must be an object");
|
|
404
|
+
mapping = args.mapping;
|
|
405
|
+
}
|
|
406
|
+
// Relative source resolves against the target repo (you import a backlog that
|
|
407
|
+
// lives in the repo being onboarded); an absolute path is used as-is.
|
|
408
|
+
const src = args.source.trim();
|
|
409
|
+
const source = isAbsolute(src) ? src : join(repoRoot, src);
|
|
410
|
+
const dryRun = !args.apply;
|
|
411
|
+
const { summary } = applyImport(repoRoot, source, mapping, { dryRun });
|
|
412
|
+
if (summary.errors.length) throw new TrellisError(summary.errors.join("; "), "import_failed");
|
|
413
|
+
return { ...summary, dryRun };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// history — read-only git-derived task history (SPEC §8.4). By id → { id, entries };
|
|
417
|
+
// omit id → { generated, tasks } keyed by id (the same shape `trellis history --write`
|
|
418
|
+
// materializes, so a client reads the tool result and the file identically). Each
|
|
419
|
+
// entry is { id, commit, date, author, subject, reason }, newest-first. NEVER writes
|
|
420
|
+
// and is NOT part of `--check` — history is volatile and git is authoritative. Uses
|
|
421
|
+
// loadCfg (not loadClean): like get_task/list_tasks it is a tolerant read, and history
|
|
422
|
+
// is a forensic tool that should work even on a backlog that doesn't fully validate.
|
|
423
|
+
// The deriver's HistoryError (no git, not a work tree, bad/unknown id) maps to a
|
|
424
|
+
// TrellisError so the transport returns an isError result, not a stack trace.
|
|
425
|
+
export function historyOp(repoRoot, { id } = {}) {
|
|
426
|
+
const cfg = loadCfg(repoRoot);
|
|
427
|
+
try {
|
|
428
|
+
if (id !== undefined && id !== null && String(id).trim() !== "") {
|
|
429
|
+
return deriveTaskHistory(repoRoot, cfg, id);
|
|
430
|
+
}
|
|
431
|
+
return deriveAllHistory(repoRoot, cfg);
|
|
432
|
+
} catch (e) {
|
|
433
|
+
if (e instanceof HistoryError) throw new TrellisError(e.message, e.code);
|
|
434
|
+
throw e;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Dispatch table for the transport: tool name → (repoRoot, args) → result.
|
|
439
|
+
export const OPS = {
|
|
440
|
+
list_tasks: listTasks,
|
|
441
|
+
get_task: getTask,
|
|
442
|
+
next_id: nextIdOp,
|
|
443
|
+
create_task: createTask,
|
|
444
|
+
move_task: moveTask,
|
|
445
|
+
validate: validateOp,
|
|
446
|
+
regenerate: regenerateOp,
|
|
447
|
+
import: importOp,
|
|
448
|
+
history: historyOp,
|
|
449
|
+
};
|
package/src/pr-title.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// PR-title lint core (zero-dependency, like src/backlog.mjs).
|
|
2
|
+
//
|
|
3
|
+
// The standard: a PR title is `<ID>: <imperative summary>`, where `<ID>` is the
|
|
4
|
+
// repo's configured id prefix + width (read live from backlog.config.json, so
|
|
5
|
+
// the lint is prefix-agnostic per TRL0007) and the colon is the separator. A
|
|
6
|
+
// multi-item PR leads with the primary id and names the rest in the body, so the
|
|
7
|
+
// lint only anchors on a single leading id and ignores whatever follows the
|
|
8
|
+
// summary. Pure so the CLI wrapper (scripts/pr-title-lint.mjs) and `node --test`
|
|
9
|
+
// both call it; see .github/pull_request_template.md / trellis/playbooks/pr-draft.md.
|
|
10
|
+
|
|
11
|
+
export const MAX_TITLE_LENGTH = 72;
|
|
12
|
+
|
|
13
|
+
// Escape regex metacharacters so a configured idPrefix is matched literally — a
|
|
14
|
+
// prefix like `T+` must mean two characters, not "one or more T".
|
|
15
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
16
|
+
|
|
17
|
+
// Returns { ok, errors[] }. cfg is the loaded backlog.config.json (needs
|
|
18
|
+
// idPrefix + idWidth). Collects every violation so a malformed title reports all
|
|
19
|
+
// of its problems at once.
|
|
20
|
+
export function lintPrTitle(title, cfg) {
|
|
21
|
+
if (typeof title !== "string" || !title.trim()) {
|
|
22
|
+
return { ok: false, errors: ["title is empty"] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const errors = [];
|
|
26
|
+
// Lint the real, untrimmed title: stray leading/trailing whitespace is itself a
|
|
27
|
+
// defect (the start-anchored id pattern below only catches the leading case).
|
|
28
|
+
if (title !== title.trim()) {
|
|
29
|
+
errors.push("title has leading or trailing whitespace");
|
|
30
|
+
}
|
|
31
|
+
if (title.length > MAX_TITLE_LENGTH) {
|
|
32
|
+
errors.push(`title is ${title.length} chars; must be ≤ ${MAX_TITLE_LENGTH}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// `^<prefix><width digits>: ` then a non-space, so an exactly-formed id, the
|
|
36
|
+
// colon-space separator, and a non-empty summary are all required.
|
|
37
|
+
const idRe = new RegExp(`^${escapeRe(cfg.idPrefix)}\\d{${cfg.idWidth}}: \\S`);
|
|
38
|
+
if (!idRe.test(title)) {
|
|
39
|
+
const example = `${cfg.idPrefix}${"0".repeat(cfg.idWidth)}: add the widget`;
|
|
40
|
+
errors.push(
|
|
41
|
+
`title must start with \`${cfg.idPrefix}\` + ${cfg.idWidth} digits, then \`: \`, ` +
|
|
42
|
+
`then a summary (e.g. \`${example}\`)`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { ok: errors.length === 0, errors };
|
|
47
|
+
}
|
package/src/profiles.mjs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Trellis source-mapping profiles (zero-dependency).
|
|
2
|
+
//
|
|
3
|
+
// A profile is a reusable *mapping object* — the exact shape the import engine
|
|
4
|
+
// (src/import.mjs) consumes — shipped under `profiles/` and resolved by name. The
|
|
5
|
+
// engine stays pure (it takes a mapping object); this module is only the registry
|
|
6
|
+
// plus the loaders shared by the `trellis import` CLI, the `init --import` on-ramp,
|
|
7
|
+
// and the MCP `import` tool, so all three resolve `--profile <name>` /
|
|
8
|
+
// `--mapping <file>` / inline mappings the same way. An optional top-level
|
|
9
|
+
// `description` documents a profile and is ignored by the engine (validateMapping
|
|
10
|
+
// neither requires nor rejects it).
|
|
11
|
+
//
|
|
12
|
+
// Built-in profiles assume the DEFAULT Trellis vocabulary as their remap targets
|
|
13
|
+
// (Alpha → Beta → v1 → Future, High/Medium/Low, Fibonacci effort); a target on a
|
|
14
|
+
// different vocabulary edits the profile's `remap`. See docs/import.md.
|
|
15
|
+
|
|
16
|
+
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
17
|
+
import { join, dirname } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
// Built-in profiles ship next to the package (one level up from src/), so a
|
|
21
|
+
// packaged install (TRL0010) carries them. Computed from this module's location,
|
|
22
|
+
// never cwd, so resolution is the same wherever the tool runs.
|
|
23
|
+
export const BUILTIN_PROFILES_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "profiles");
|
|
24
|
+
|
|
25
|
+
// A profile name addresses exactly one file in the profiles dir; restrict it to a
|
|
26
|
+
// flat basename so `--profile ../../etc/passwd` (or any separator) can't escape.
|
|
27
|
+
const PROFILE_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
|
28
|
+
|
|
29
|
+
// list the built-in profiles → [{ name, description, path }], sorted by name.
|
|
30
|
+
// Returns [] when the dir is absent. Never throws: a malformed profile is still
|
|
31
|
+
// listed (with an empty description) and only errors when actually loaded.
|
|
32
|
+
export function listProfiles(dir = BUILTIN_PROFILES_DIR) {
|
|
33
|
+
if (!existsSync(dir)) return [];
|
|
34
|
+
return readdirSync(dir)
|
|
35
|
+
.filter((f) => f.endsWith(".json"))
|
|
36
|
+
.sort()
|
|
37
|
+
.map((f) => {
|
|
38
|
+
const path = join(dir, f);
|
|
39
|
+
let description = "";
|
|
40
|
+
try { description = String(JSON.parse(readFileSync(path, "utf8")).description ?? ""); } catch { /* listed even if unparseable */ }
|
|
41
|
+
return { name: f.replace(/\.json$/, ""), description, path };
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// loadProfile — resolve a built-in profile name to its mapping → { mapping, error }.
|
|
46
|
+
// Unknown name lists the available ones; a bad name or unreadable/!JSON file is a
|
|
47
|
+
// clear error. Mirrors loadConfig's result-object idiom (no throw), so each caller
|
|
48
|
+
// maps the error to its own surface (a CLI exit, or an MCP TrellisError).
|
|
49
|
+
export function loadProfile(name, dir = BUILTIN_PROFILES_DIR) {
|
|
50
|
+
if (typeof name !== "string" || !name.trim()) return { mapping: null, error: "a profile name is required" };
|
|
51
|
+
const safe = name.trim();
|
|
52
|
+
if (!PROFILE_NAME_RE.test(safe)) return { mapping: null, error: `invalid profile name "${safe}" (use letters, digits, ., _, -)` };
|
|
53
|
+
const file = join(dir, `${safe}.json`);
|
|
54
|
+
if (!existsSync(file)) {
|
|
55
|
+
const avail = listProfiles(dir).map((p) => p.name).join(", ") || "(none)";
|
|
56
|
+
return { mapping: null, error: `unknown profile "${safe}" (available: ${avail})` };
|
|
57
|
+
}
|
|
58
|
+
return loadMappingFile(file);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// loadMappingFile — read a mapping from an arbitrary JSON file → { mapping, error }.
|
|
62
|
+
// Backs the `--mapping <file>` flag; same result-object shape as loadProfile so the
|
|
63
|
+
// two are interchangeable at the call site.
|
|
64
|
+
export function loadMappingFile(file) {
|
|
65
|
+
let text;
|
|
66
|
+
try { text = readFileSync(file, "utf8"); } catch (e) { return { mapping: null, error: `cannot read mapping file ${file}: ${e.message}` }; }
|
|
67
|
+
try { return { mapping: JSON.parse(text), error: null }; } catch (e) { return { mapping: null, error: `mapping file ${file} is not valid JSON: ${e.message}` }; }
|
|
68
|
+
}
|