@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/cli.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Shared helpers for the dependency-free CLI wrappers.
|
|
2
|
+
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
export function optionToken(arg) {
|
|
6
|
+
const eq = arg.indexOf("=");
|
|
7
|
+
return {
|
|
8
|
+
key: arg.startsWith("--") && eq !== -1 ? arg.slice(0, eq) : arg,
|
|
9
|
+
inline: arg.startsWith("--") && eq !== -1 ? arg.slice(eq + 1) : null,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function usageError(message) {
|
|
14
|
+
console.error(message);
|
|
15
|
+
process.exit(2);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function showHelp(help) {
|
|
19
|
+
process.stdout.write(help);
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function requiredValue(argv, index, inline, flag) {
|
|
24
|
+
const value = inline !== null ? inline : argv[index + 1];
|
|
25
|
+
if (value === undefined || value === "" || (inline === null && value.startsWith("-"))) {
|
|
26
|
+
usageError(`error: ${flag} requires a value`);
|
|
27
|
+
}
|
|
28
|
+
return { value, index: inline !== null ? index : index + 1 };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resolveRepoRoot(value) {
|
|
32
|
+
return resolve(value || process.cwd());
|
|
33
|
+
}
|
package/src/history.mjs
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Trellis git-derived task history (SPEC §8.4 — a derived, NON-gated report).
|
|
2
|
+
//
|
|
3
|
+
// A lightweight per-task change log — who changed an item, when, and why —
|
|
4
|
+
// reconstructed from git, plus a materialized `history.json` a static viewer can
|
|
5
|
+
// read without a git runtime. This is deliberately a SEPARATE module from the
|
|
6
|
+
// generator core (src/backlog.mjs): keeping every `git` invocation out of the core
|
|
7
|
+
// structurally guarantees that `backlog:check` (the deterministic gate, SPEC §8.3)
|
|
8
|
+
// never depends on git history. History is volatile by nature (commit times,
|
|
9
|
+
// authors), so it is never part of `--check` and never written by the generator.
|
|
10
|
+
//
|
|
11
|
+
// git is the authoritative deep record; this module only reads it. Imported items
|
|
12
|
+
// (TRL0021/22) carry history from the import commit forward — a single-commit or
|
|
13
|
+
// unborn-HEAD history yields `[]`, never a crash. Per-task derivation uses
|
|
14
|
+
// `git log --follow` so history survives the active→completed move (and, as it
|
|
15
|
+
// happens, the `PL→TRL` prefix migration and the `docs/tasks → trellis/`
|
|
16
|
+
// relocation, which `--follow` also tracks through the renames).
|
|
17
|
+
|
|
18
|
+
import { existsSync, readdirSync, writeFileSync, mkdirSync } from "node:fs";
|
|
19
|
+
import { join, relative, dirname, isAbsolute } from "node:path";
|
|
20
|
+
import { execFileSync } from "node:child_process";
|
|
21
|
+
import { paths } from "./backlog.mjs";
|
|
22
|
+
|
|
23
|
+
// A typed failure the callers classify: the CLI prints it and exits non-zero; the
|
|
24
|
+
// MCP adapter maps it to a TrellisError (an isError result), so neither surfaces a
|
|
25
|
+
// raw stack trace. `code` is a short slug for programmatic handling.
|
|
26
|
+
export class HistoryError extends Error {
|
|
27
|
+
constructor(message, code = "history_failed") {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "HistoryError";
|
|
30
|
+
this.code = code;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Field/record delimiters for the `git log` custom format. ASCII unit/record
|
|
35
|
+
// separators (0x1F/0x1E) cannot appear in commit subjects, author names, dates, or
|
|
36
|
+
// single-line trailer values, so the output parses unambiguously without quoting.
|
|
37
|
+
const FIELD = "\x1f";
|
|
38
|
+
const RECORD = "\x1e";
|
|
39
|
+
|
|
40
|
+
// commit (full SHA) · author date (strict ISO 8601) · author name · subject ·
|
|
41
|
+
// the Trellis-Reason trailer value (empty when absent → reason falls back to the
|
|
42
|
+
// subject). Multiple Trellis-Reason trailers are joined with a comma.
|
|
43
|
+
const FORMAT =
|
|
44
|
+
"%H%x1f%aI%x1f%an%x1f%s%x1f%(trailers:key=Trellis-Reason,valueonly,separator=%x2c)%x1e";
|
|
45
|
+
|
|
46
|
+
// Run git with an arg array (NO shell) rooted at repoRoot. Errors propagate to the
|
|
47
|
+
// caller, which classifies them (missing git, not a repo, unborn HEAD, real failure).
|
|
48
|
+
function git(repoRoot, args) {
|
|
49
|
+
// Capture stderr (don't inherit it — the *Sync exec default): it feeds gitMsg() on
|
|
50
|
+
// failure, and the MCP server reserves its own stderr for diagnostics.
|
|
51
|
+
return execFileSync("git", ["-C", repoRoot, ...args], {
|
|
52
|
+
encoding: "utf8",
|
|
53
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
54
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// First line of the most useful diagnostic from a failed git call.
|
|
59
|
+
function gitMsg(e) {
|
|
60
|
+
const s = (e && e.stderr ? String(e.stderr) : e && e.message ? e.message : String(e)).trim();
|
|
61
|
+
return s.split("\n")[0] || "git error";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Assert repoRoot is inside a git work tree, distinguishing "git not installed"
|
|
65
|
+
// (ENOENT) from "not a git repo" so the message is actionable.
|
|
66
|
+
export function assertGitRepo(repoRoot) {
|
|
67
|
+
let out;
|
|
68
|
+
try {
|
|
69
|
+
out = git(repoRoot, ["rev-parse", "--is-inside-work-tree"]).trim();
|
|
70
|
+
} catch (e) {
|
|
71
|
+
if (e && e.code === "ENOENT") throw new HistoryError("git is not available on PATH", "git_unavailable");
|
|
72
|
+
throw new HistoryError(`not a git work tree: ${repoRoot}`, "not_a_git_repo");
|
|
73
|
+
}
|
|
74
|
+
if (out !== "true") throw new HistoryError(`not a git work tree: ${repoRoot}`, "not_a_git_repo");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// True when HEAD resolves to a commit. A freshly `git init`-ed repo (unborn HEAD)
|
|
78
|
+
// has no history yet, so `git log` would error rather than return empty; callers
|
|
79
|
+
// short-circuit to `[]` instead.
|
|
80
|
+
function hasHead(repoRoot) {
|
|
81
|
+
try {
|
|
82
|
+
git(repoRoot, ["rev-parse", "--verify", "--quiet", "HEAD"]);
|
|
83
|
+
return true;
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Escape regex metacharacters so a configured idPrefix is matched literally — a
|
|
90
|
+
// prefix like `T+` must mean two characters, not "one or more T". Mirrors
|
|
91
|
+
// src/pr-title.mjs. (The core validator's own id regexes in src/backlog.mjs and
|
|
92
|
+
// src/mcp.mjs share this gap; hardening them is tracked separately.)
|
|
93
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
94
|
+
|
|
95
|
+
function idRegex(cfg) {
|
|
96
|
+
return new RegExp(`^${escapeRe(cfg.idPrefix)}\\d{${cfg.idWidth}}$`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Resolve an id to its current file by probing the three status dirs, newest status
|
|
100
|
+
// last (active wins a hypothetical duplicate, which the generator flags separately).
|
|
101
|
+
// Validates the id format BEFORE building any path, so a crafted id (e.g.
|
|
102
|
+
// "../../etc/passwd") cannot escape the task tree. Returns { id, status, file } or null.
|
|
103
|
+
export function resolveTaskFile(repoRoot, cfg, id) {
|
|
104
|
+
if (typeof id !== "string" || !idRegex(cfg).test(id.trim())) {
|
|
105
|
+
throw new HistoryError(`invalid task id: ${id} (expected ${cfg.idPrefix} + ${cfg.idWidth} digits)`, "invalid_request");
|
|
106
|
+
}
|
|
107
|
+
const tid = id.trim();
|
|
108
|
+
const p = paths(repoRoot, cfg);
|
|
109
|
+
for (const [status, dir] of [["active", p.active], ["completed", p.completedTasks], ["removed", p.removed]]) {
|
|
110
|
+
const file = join(dir, `${tid}.md`);
|
|
111
|
+
if (existsSync(file)) return { id: tid, status, file };
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Every task id currently on disk, scanned straight from the three status dirs
|
|
117
|
+
// (not readBacklog) so history works as a forensic tool even on a backlog that
|
|
118
|
+
// doesn't fully validate. Sorted by id for a stable key order in history.json.
|
|
119
|
+
export function taskIds(repoRoot, cfg) {
|
|
120
|
+
const p = paths(repoRoot, cfg);
|
|
121
|
+
const fileRe = new RegExp(`^(${escapeRe(cfg.idPrefix)}\\d{${cfg.idWidth}})\\.md$`);
|
|
122
|
+
const out = [];
|
|
123
|
+
for (const [status, dir] of [["active", p.active], ["completed", p.completedTasks], ["removed", p.removed]]) {
|
|
124
|
+
if (!existsSync(dir)) continue;
|
|
125
|
+
for (const f of readdirSync(dir)) {
|
|
126
|
+
const m = f.match(fileRe);
|
|
127
|
+
if (m) out.push({ id: m[1], status, file: join(dir, f) });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return out.sort((a, b) => a.id.localeCompare(b.id));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Parse the delimited `git log` output into entries. `reason` is the Trellis-Reason
|
|
134
|
+
// trailer when present, else the subject — so it is ALWAYS populated and a consumer
|
|
135
|
+
// reads one field. Each entry carries its own id, matching the structured-entry
|
|
136
|
+
// contract shared by the CLI and the MCP tool.
|
|
137
|
+
function parseLog(out, id) {
|
|
138
|
+
return out
|
|
139
|
+
.split(RECORD)
|
|
140
|
+
.map((r) => r.trim()) // strip the inter-record newline git inserts between entries
|
|
141
|
+
.filter(Boolean)
|
|
142
|
+
.map((rec) => {
|
|
143
|
+
const [commit = "", date = "", author = "", subject = "", reasonTrailer = ""] = rec.split(FIELD);
|
|
144
|
+
const reason = reasonTrailer.trim() || subject.trim();
|
|
145
|
+
return { id, commit: commit.trim(), date: date.trim(), author: author.trim(), subject: subject.trim(), reason };
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// `git log --follow` over one task file → entries newest-first. A path that was
|
|
150
|
+
// never committed (e.g. a brand-new, unstaged item) yields no output → `[]`.
|
|
151
|
+
function logFollow(repoRoot, id, relPath) {
|
|
152
|
+
let out;
|
|
153
|
+
try {
|
|
154
|
+
out = git(repoRoot, ["log", "--follow", `--format=${FORMAT}`, "--", relPath]);
|
|
155
|
+
} catch (e) {
|
|
156
|
+
throw new HistoryError(`git log failed for ${relPath}: ${gitMsg(e)}`, "git_failed");
|
|
157
|
+
}
|
|
158
|
+
return parseLog(out, id);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// One task's history → { id, entries }, newest-first. Throws HistoryError for a
|
|
162
|
+
// non-git repo, an invalid id, or an unknown id.
|
|
163
|
+
export function deriveTaskHistory(repoRoot, cfg, id) {
|
|
164
|
+
assertGitRepo(repoRoot);
|
|
165
|
+
const resolved = resolveTaskFile(repoRoot, cfg, id);
|
|
166
|
+
if (!resolved) throw new HistoryError(`no task file for id ${id}`, "not_found");
|
|
167
|
+
const entries = hasHead(repoRoot) ? logFollow(repoRoot, resolved.id, relative(repoRoot, resolved.file)) : [];
|
|
168
|
+
return { id: resolved.id, entries };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Whole-repo history → { generated: true, tasks: { <id>: [entries…] } }, keyed by
|
|
172
|
+
// task id (every id on disk, `[]` if uncommitted). `generated: true` marks the file
|
|
173
|
+
// as a regenerable cache if it is ever committed despite being gitignored. One
|
|
174
|
+
// `git log --follow` per task file — `--follow` permits a single pathspec, so this
|
|
175
|
+
// cannot be one batched call; fine at backlog scale.
|
|
176
|
+
export function deriveAllHistory(repoRoot, cfg) {
|
|
177
|
+
assertGitRepo(repoRoot);
|
|
178
|
+
const head = hasHead(repoRoot);
|
|
179
|
+
const tasks = {};
|
|
180
|
+
for (const { id, file } of taskIds(repoRoot, cfg)) {
|
|
181
|
+
tasks[id] = head ? logFollow(repoRoot, id, relative(repoRoot, file)) : [];
|
|
182
|
+
}
|
|
183
|
+
return { generated: true, tasks };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// The history.json bytes — pretty-printed with a trailing newline, matching the
|
|
187
|
+
// backlog.json serializer's style.
|
|
188
|
+
export function buildHistoryJson(repoRoot, cfg) {
|
|
189
|
+
return JSON.stringify(deriveAllHistory(repoRoot, cfg), null, 2) + "\n";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Materialize history.json to disk (the build-time / CI use). Writes to
|
|
193
|
+
// `<tasksDir>/history.json` by default, or `out` (repo-relative or absolute).
|
|
194
|
+
// Returns a small summary for the CLI to report.
|
|
195
|
+
export function materializeHistory(repoRoot, cfg, { out } = {}) {
|
|
196
|
+
const content = buildHistoryJson(repoRoot, cfg);
|
|
197
|
+
const target = out ? (isAbsolute(out) ? out : join(repoRoot, out)) : paths(repoRoot, cfg).historyJson;
|
|
198
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
199
|
+
writeFileSync(target, content);
|
|
200
|
+
const parsed = JSON.parse(content);
|
|
201
|
+
const taskCount = Object.keys(parsed.tasks).length;
|
|
202
|
+
const entryCount = Object.values(parsed.tasks).reduce((n, e) => n + e.length, 0);
|
|
203
|
+
return { path: relative(repoRoot, target), taskCount, entryCount, bytes: Buffer.byteLength(content) };
|
|
204
|
+
}
|