truthtrail 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -2
- package/dist/index.js +1033 -0
- package/package.json +28 -4
package/README.md
CHANGED
|
@@ -1,3 +1,76 @@
|
|
|
1
|
-
#
|
|
1
|
+
# TruthTrail
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
When your AI coding agent hits something the ticket doesn't settle ("empty date: show an error or use today?"), the decision usually disappears into a chat or a call. Then QA's AI files a false defect against the stale ticket. TruthTrail records those decisions **next to the code**, in `.decisions/`, so every role and every AI reads the same truth.
|
|
4
|
+
|
|
5
|
+
This package is the local CLI. Syncing decisions into Jira, confirmation by the ticket reporter, and PR checks come with the TruthTrail server (coming next).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx truthtrail init
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`init` adds:
|
|
14
|
+
- `.decisions/`, where decision records live (commit them)
|
|
15
|
+
- a short rules block in `AGENTS.md`, read by Claude Code, Copilot, Cursor, Codex and others. Your own text is never touched.
|
|
16
|
+
- a pointer in `CLAUDE.md`, if you have one
|
|
17
|
+
- `.truthtrail.json` (config) and `.truthtrail/` in `.gitignore`
|
|
18
|
+
|
|
19
|
+
It prints exactly what it changed. Running it again is safe.
|
|
20
|
+
|
|
21
|
+
## Commands
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Record a decision for the ticket in your branch name (feature/JIRA-231-…)
|
|
25
|
+
npx truthtrail decide --question "Discount before or after tax?" \
|
|
26
|
+
--chosen "Before tax" --options "Before tax|After tax" \
|
|
27
|
+
--reason "Finance rule" --files billing/total.ts
|
|
28
|
+
|
|
29
|
+
# Record an assumption your AI made without asking
|
|
30
|
+
npx truthtrail decide --type assumption --question "Empty discount?" --chosen "Treat as 0"
|
|
31
|
+
|
|
32
|
+
# See decisions for a ticket
|
|
33
|
+
npx truthtrail list --ticket JIRA-231
|
|
34
|
+
|
|
35
|
+
# Who wrote this line, AI or human, and which decisions shaped it?
|
|
36
|
+
npx truthtrail why billing/total.ts:42
|
|
37
|
+
|
|
38
|
+
# Check this branch: ticket found? AI commits without any decision recorded?
|
|
39
|
+
npx truthtrail check --base main
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`--approver <name>` is only for when someone explicitly approved. TruthTrail never guesses approvers.
|
|
43
|
+
|
|
44
|
+
## MCP (Claude Code, Cursor, Copilot)
|
|
45
|
+
|
|
46
|
+
`npx truthtrail init` also writes a `truthtrail` MCP server entry for each AI tool:
|
|
47
|
+
|
|
48
|
+
| Tool | File |
|
|
49
|
+
|---|---|
|
|
50
|
+
| Claude Code | `.mcp.json` |
|
|
51
|
+
| Cursor | `.cursor/mcp.json` |
|
|
52
|
+
| Copilot (VS Code) | `.vscode/mcp.json` |
|
|
53
|
+
|
|
54
|
+
Commit these files, and every teammate's AI gets the TruthTrail tools: `record_decision`, `list_decisions`, `why`, `check_branch`, `list_pending` and `resolve_pending`. When you ask "A or B?" and answer, the AI saves the decision itself.
|
|
55
|
+
|
|
56
|
+
- Each tool asks once to allow or enable the `truthtrail` server. In Claude Code, approve the project MCP server. In Cursor, enable it under Settings → MCP. In VS Code, start or trust it from `mcp.json` and use Copilot Chat in **Agent** mode.
|
|
57
|
+
- Existing entries and comments in these files are left alone. Only a missing `truthtrail` entry is added.
|
|
58
|
+
- Skip all of this with `npx truthtrail init --no-mcp`.
|
|
59
|
+
- If your company blocks MCP servers, the AI falls back to the `npx truthtrail` commands. The rules in `AGENTS.md` describe both.
|
|
60
|
+
- For faster startup, install locally (`npm i -D truthtrail`) and point the entry at `node_modules/.bin/truthtrail`. `init` won't overwrite your change.
|
|
61
|
+
|
|
62
|
+
## Claude Code plugin
|
|
63
|
+
|
|
64
|
+
The plugin (in `plugin/` of the TruthTrail repo) adds:
|
|
65
|
+
- **SessionStart:** loads the ticket's decisions into Claude's context
|
|
66
|
+
- **Capture:** remembers questions you answered through Claude's "A or B?" prompts
|
|
67
|
+
- **Stop:** before Claude finishes, asks it to record each answered question that changes visible behaviour (`decide --from-pending <n>`) or dismiss it (`pending dismiss <n>`)
|
|
68
|
+
|
|
69
|
+
## Config (`.truthtrail.json`)
|
|
70
|
+
|
|
71
|
+
| Key | Default | Meaning |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| `ticketPattern` | `[A-Z][A-Z0-9]+-\d+` | how ticket keys look (matched case-insensitively) |
|
|
74
|
+
| `decisionsDir` | `.decisions` | where records live |
|
|
75
|
+
| `jiraSite` | `null` | used by the TruthTrail server |
|
|
76
|
+
| `failOnUnconfirmed` | `false` | used by the TruthTrail server's PR check |
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,1033 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/init.ts
|
|
7
|
+
import { access, appendFile, mkdir as mkdir3, readFile as readFile5, realpath as realpath2, writeFile as writeFile3 } from "fs/promises";
|
|
8
|
+
import { dirname as dirname2, join as join5 } from "path";
|
|
9
|
+
|
|
10
|
+
// ../core/src/types.ts
|
|
11
|
+
var DECISION_TYPES = ["decision", "assumption", "impact"];
|
|
12
|
+
var AGENT_NAMES = ["claude-code", "copilot", "cursor", "codex", "aider", "other", "none"];
|
|
13
|
+
var RECORD_SOURCES = ["ai-session", "manual"];
|
|
14
|
+
|
|
15
|
+
// ../core/src/record.ts
|
|
16
|
+
import { parse, stringify } from "yaml";
|
|
17
|
+
var RecordError = class extends Error {
|
|
18
|
+
constructor(problems) {
|
|
19
|
+
super(`Invalid decision record: ${problems.join("; ")}`);
|
|
20
|
+
this.problems = problems;
|
|
21
|
+
this.name = "RecordError";
|
|
22
|
+
}
|
|
23
|
+
problems;
|
|
24
|
+
};
|
|
25
|
+
var REQUIRED_STRINGS = ["id", "ticket", "question", "chosen", "decided_by", "created_at"];
|
|
26
|
+
var ARRAY_FIELDS = ["options", "files", "supersedes"];
|
|
27
|
+
function validateRecord(obj) {
|
|
28
|
+
if (typeof obj !== "object" || obj === null || Array.isArray(obj)) return ["record must be an object"];
|
|
29
|
+
const o = obj;
|
|
30
|
+
const problems = [];
|
|
31
|
+
for (const f of REQUIRED_STRINGS) {
|
|
32
|
+
if (typeof o[f] !== "string" || o[f].trim() === "") problems.push(`${f} is required`);
|
|
33
|
+
}
|
|
34
|
+
if (o.reason !== void 0 && typeof o.reason !== "string") problems.push("reason must be a string");
|
|
35
|
+
if (!DECISION_TYPES.includes(o.type)) problems.push(`type must be one of ${DECISION_TYPES.join(", ")}`);
|
|
36
|
+
if (!AGENT_NAMES.includes(o.agent)) problems.push(`agent must be one of ${AGENT_NAMES.join(", ")}`);
|
|
37
|
+
if (!RECORD_SOURCES.includes(o.source)) problems.push(`source must be one of ${RECORD_SOURCES.join(", ")}`);
|
|
38
|
+
if (o.approver_claimed !== void 0 && o.approver_claimed !== null && typeof o.approver_claimed !== "string") {
|
|
39
|
+
problems.push("approver_claimed must be a string or null");
|
|
40
|
+
}
|
|
41
|
+
for (const f of ARRAY_FIELDS) {
|
|
42
|
+
const v = o[f];
|
|
43
|
+
if (v !== void 0 && !(Array.isArray(v) && v.every((x) => typeof x === "string"))) {
|
|
44
|
+
problems.push(`${f} must be a list of strings`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return problems;
|
|
48
|
+
}
|
|
49
|
+
var FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
50
|
+
function parseRecord(text) {
|
|
51
|
+
const m = FRONT_MATTER.exec(text);
|
|
52
|
+
if (!m) throw new RecordError(["missing --- front matter"]);
|
|
53
|
+
let data;
|
|
54
|
+
try {
|
|
55
|
+
data = parse(m[1]);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
throw new RecordError([`invalid YAML: ${e.message.split("\n")[0]}`]);
|
|
58
|
+
}
|
|
59
|
+
const problems = validateRecord(data);
|
|
60
|
+
if (problems.length) throw new RecordError(problems);
|
|
61
|
+
const o = data;
|
|
62
|
+
return {
|
|
63
|
+
id: o.id,
|
|
64
|
+
ticket: o.ticket,
|
|
65
|
+
type: o.type,
|
|
66
|
+
question: o.question,
|
|
67
|
+
options: o.options ?? [],
|
|
68
|
+
chosen: o.chosen,
|
|
69
|
+
reason: o.reason ?? "",
|
|
70
|
+
decided_by: o.decided_by,
|
|
71
|
+
approver_claimed: o.approver_claimed ?? null,
|
|
72
|
+
agent: o.agent,
|
|
73
|
+
source: o.source,
|
|
74
|
+
files: o.files ?? [],
|
|
75
|
+
created_at: String(o.created_at),
|
|
76
|
+
supersedes: o.supersedes ?? [],
|
|
77
|
+
body: m[2].trim()
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function serializeRecord(r) {
|
|
81
|
+
const { body, ...front } = r;
|
|
82
|
+
const yaml = stringify(front, { lineWidth: 0 }).trimEnd();
|
|
83
|
+
return `---
|
|
84
|
+
${yaml}
|
|
85
|
+
---
|
|
86
|
+
${body.trim()}
|
|
87
|
+
`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ../core/src/ids.ts
|
|
91
|
+
import { createHash } from "crypto";
|
|
92
|
+
function slugify(text, maxLen = 40) {
|
|
93
|
+
const s = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, maxLen).replace(/-+$/g, "");
|
|
94
|
+
return s || "decision";
|
|
95
|
+
}
|
|
96
|
+
function stamp(d) {
|
|
97
|
+
return d.toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
|
|
98
|
+
}
|
|
99
|
+
function makeId(stem) {
|
|
100
|
+
return `D-${stem}`;
|
|
101
|
+
}
|
|
102
|
+
function recordRelPath(decisionsDir, ticket, stem) {
|
|
103
|
+
return `${decisionsDir.replace(/\/+$/, "")}/${ticket}/${stem}.md`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ../core/src/config.ts
|
|
107
|
+
import { readFile } from "fs/promises";
|
|
108
|
+
import { join } from "path";
|
|
109
|
+
var DEFAULT_CONFIG = {
|
|
110
|
+
ticketPattern: "[A-Z][A-Z0-9]+-\\d+",
|
|
111
|
+
jiraSite: null,
|
|
112
|
+
failOnUnconfirmed: false,
|
|
113
|
+
decisionsDir: ".decisions"
|
|
114
|
+
};
|
|
115
|
+
var CONFIG_FILE = ".truthtrail.json";
|
|
116
|
+
var ConfigError = class extends Error {
|
|
117
|
+
constructor(message) {
|
|
118
|
+
super(message);
|
|
119
|
+
this.name = "ConfigError";
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
async function loadConfig(repoRoot2) {
|
|
123
|
+
const path = join(repoRoot2, CONFIG_FILE);
|
|
124
|
+
let text;
|
|
125
|
+
try {
|
|
126
|
+
text = await readFile(path, "utf8");
|
|
127
|
+
} catch (e) {
|
|
128
|
+
if (e.code === "ENOENT") return { ...DEFAULT_CONFIG };
|
|
129
|
+
throw e;
|
|
130
|
+
}
|
|
131
|
+
let data;
|
|
132
|
+
try {
|
|
133
|
+
data = JSON.parse(text);
|
|
134
|
+
} catch (e) {
|
|
135
|
+
throw new ConfigError(`${CONFIG_FILE} is not valid JSON: ${e.message}`);
|
|
136
|
+
}
|
|
137
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
138
|
+
throw new ConfigError(`${CONFIG_FILE} must contain a JSON object`);
|
|
139
|
+
}
|
|
140
|
+
return { ...DEFAULT_CONFIG, ...data };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ../core/src/ticket.ts
|
|
144
|
+
function detectTicket(input, pattern = DEFAULT_CONFIG.ticketPattern) {
|
|
145
|
+
const re = new RegExp(pattern, "i");
|
|
146
|
+
for (const source of [input.branch, input.prTitle]) {
|
|
147
|
+
if (!source) continue;
|
|
148
|
+
const m = re.exec(source);
|
|
149
|
+
if (m) return m[0].toUpperCase();
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ../core/src/git.ts
|
|
155
|
+
import { execFile } from "child_process";
|
|
156
|
+
import { promisify } from "util";
|
|
157
|
+
var exec = promisify(execFile);
|
|
158
|
+
var GitError = class extends Error {
|
|
159
|
+
constructor(message) {
|
|
160
|
+
super(message);
|
|
161
|
+
this.name = "GitError";
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
async function git(cwd, args) {
|
|
165
|
+
try {
|
|
166
|
+
const { stdout } = await exec("git", args, { cwd, maxBuffer: 64 * 1024 * 1024 });
|
|
167
|
+
return stdout;
|
|
168
|
+
} catch (e) {
|
|
169
|
+
const err = e;
|
|
170
|
+
throw new GitError((err.stderr || err.message).trim());
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async function repoRoot(cwd) {
|
|
174
|
+
return (await git(cwd, ["rev-parse", "--show-toplevel"])).trim();
|
|
175
|
+
}
|
|
176
|
+
async function currentBranch(cwd) {
|
|
177
|
+
const b = (await git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
178
|
+
return b === "HEAD" ? null : b;
|
|
179
|
+
}
|
|
180
|
+
async function userName(cwd) {
|
|
181
|
+
try {
|
|
182
|
+
return (await git(cwd, ["config", "user.name"])).trim() || "unknown";
|
|
183
|
+
} catch {
|
|
184
|
+
return "unknown";
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async function isTracked(cwd, file) {
|
|
188
|
+
try {
|
|
189
|
+
await git(cwd, ["ls-files", "--error-unmatch", "--", file]);
|
|
190
|
+
return true;
|
|
191
|
+
} catch {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
async function blameLine(cwd, file, line) {
|
|
196
|
+
const out = await git(cwd, ["blame", "--porcelain", "-L", `${line},${line}`, "--", file]);
|
|
197
|
+
return out.split(" ")[0];
|
|
198
|
+
}
|
|
199
|
+
async function lastCommitForFile(cwd, file) {
|
|
200
|
+
const sha = (await git(cwd, ["log", "-1", "--format=%H", "--", file])).trim();
|
|
201
|
+
return sha || null;
|
|
202
|
+
}
|
|
203
|
+
var SEP = "";
|
|
204
|
+
async function commitInfo(cwd, sha) {
|
|
205
|
+
const out = await git(cwd, ["show", "-s", `--format=%H${SEP}%an${SEP}%ae${SEP}%aI${SEP}%B`, sha]);
|
|
206
|
+
const [full, authorName, authorEmail, date, ...rest] = out.split(SEP);
|
|
207
|
+
return { sha: full, authorName, authorEmail, date, message: rest.join(SEP).trim() };
|
|
208
|
+
}
|
|
209
|
+
async function branchCommits(cwd, base) {
|
|
210
|
+
const out = await git(cwd, ["rev-list", `${base}..HEAD`]);
|
|
211
|
+
return out.split("\n").filter(Boolean);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ../core/src/store.ts
|
|
215
|
+
import { mkdir, readdir, readFile as readFile2, writeFile } from "fs/promises";
|
|
216
|
+
import { dirname, join as join2 } from "path";
|
|
217
|
+
async function walk(dir) {
|
|
218
|
+
let entries;
|
|
219
|
+
try {
|
|
220
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
221
|
+
} catch (e) {
|
|
222
|
+
if (e.code === "ENOENT") return [];
|
|
223
|
+
throw e;
|
|
224
|
+
}
|
|
225
|
+
const out = [];
|
|
226
|
+
for (const e of entries) {
|
|
227
|
+
const p = join2(dir, e.name);
|
|
228
|
+
if (e.isDirectory()) out.push(...await walk(p));
|
|
229
|
+
else if (e.name.endsWith(".md")) out.push(p);
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
async function listRecords(root, cfg, ticket) {
|
|
234
|
+
const files = await walk(join2(root, cfg.decisionsDir));
|
|
235
|
+
const records = [];
|
|
236
|
+
const errors = [];
|
|
237
|
+
for (const abs of files) {
|
|
238
|
+
const rel = abs.slice(root.length + 1).split("\\").join("/");
|
|
239
|
+
try {
|
|
240
|
+
const r = parseRecord(await readFile2(abs, "utf8"));
|
|
241
|
+
if (!ticket || r.ticket === ticket) records.push({ ...r, path: rel });
|
|
242
|
+
} catch (e) {
|
|
243
|
+
errors.push({ path: rel, message: e instanceof RecordError ? e.problems.join("; ") : e.message });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
records.sort((a, b) => b.created_at.localeCompare(a.created_at) || b.id.localeCompare(a.id));
|
|
247
|
+
return { records, errors };
|
|
248
|
+
}
|
|
249
|
+
async function writeRecord(root, cfg, draft, now) {
|
|
250
|
+
const baseStem = `${stamp(now)}-${slugify(draft.question)}`;
|
|
251
|
+
for (let n = 1; n < 1e3; n++) {
|
|
252
|
+
const stem = n === 1 ? baseStem : `${baseStem}-${n}`;
|
|
253
|
+
const rel = recordRelPath(cfg.decisionsDir, draft.ticket, stem);
|
|
254
|
+
const record = { ...draft, id: makeId(stem), created_at: now.toISOString() };
|
|
255
|
+
await mkdir(dirname(join2(root, rel)), { recursive: true });
|
|
256
|
+
try {
|
|
257
|
+
await writeFile(join2(root, rel), serializeRecord(record), { flag: "wx" });
|
|
258
|
+
return { ...record, path: rel };
|
|
259
|
+
} catch (e) {
|
|
260
|
+
if (e.code !== "EEXIST") throw e;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
throw new Error(`could not find a free file name for ${baseStem}`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ../core/src/authorship.ts
|
|
267
|
+
var TRAILER_AGENTS = [
|
|
268
|
+
[/claude|anthropic/i, "claude-code"],
|
|
269
|
+
[/cursor/i, "cursor"],
|
|
270
|
+
[/copilot/i, "copilot"],
|
|
271
|
+
[/codex|openai/i, "codex"],
|
|
272
|
+
[/aider/i, "aider"]
|
|
273
|
+
];
|
|
274
|
+
var HUMAN = { ai: false, agent: null, confidence: null };
|
|
275
|
+
function classifyCommit(message) {
|
|
276
|
+
const trailers = message.split("\n").filter((l) => /^\s*co-authored-by:/i.test(l));
|
|
277
|
+
for (const line of trailers) {
|
|
278
|
+
for (const [re, agent] of TRAILER_AGENTS) {
|
|
279
|
+
if (re.test(line)) return { ai: true, agent, confidence: "high" };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (/Generated with \[?Claude Code/i.test(message)) return { ai: true, agent: "claude-code", confidence: "medium" };
|
|
283
|
+
if (/^aider:/i.test(message.trimStart())) return { ai: true, agent: "aider", confidence: "medium" };
|
|
284
|
+
return HUMAN;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ../core/src/rules.ts
|
|
288
|
+
var BLOCK_START = "<!-- truthtrail:start -->";
|
|
289
|
+
var BLOCK_END = "<!-- truthtrail:end -->";
|
|
290
|
+
var RULES_TEXT = `## TruthTrail: recording decisions
|
|
291
|
+
|
|
292
|
+
This repo records product decisions in \`.decisions/\`. Follow these rules.
|
|
293
|
+
|
|
294
|
+
**Find the ticket.** Take the ticket key from the branch name (e.g. \`feature/JIRA-231-\u2026\`). If there is none, ask the user once.
|
|
295
|
+
|
|
296
|
+
**Tools.** If TruthTrail MCP tools are available (\`record_decision\`, \`list_decisions\`, \`why\`, \`check_branch\`, \`list_pending\`, \`resolve_pending\`), use them. Otherwise use the \`npx truthtrail\` commands below.
|
|
297
|
+
|
|
298
|
+
**Before you implement,** run \`npx truthtrail list --ticket <KEY>\` and follow the decisions it shows. Treat \`rejected\` ones as must-rework.
|
|
299
|
+
|
|
300
|
+
**Record a decision** with \`npx truthtrail decide\` when:
|
|
301
|
+
- the ticket is silent or ambiguous and your choice changes behaviour a user or QA can see;
|
|
302
|
+
- following the ticket would break something, so you changed the behaviour;
|
|
303
|
+
- you assumed something that changes visible behaviour (use \`--type assumption\`).
|
|
304
|
+
|
|
305
|
+
Do not record pure implementation details with no visible effect.
|
|
306
|
+
|
|
307
|
+
**Approver:** pass \`--approver <name>\` only if the user explicitly says who approved it. Never guess a name.
|
|
308
|
+
|
|
309
|
+
**Superseding:** if your decision replaces an older one, pass \`--supersedes <id>\`.
|
|
310
|
+
|
|
311
|
+
**When testing (QA):** before filing a defect, read the ticket and \`npx truthtrail list --ticket <KEY>\`. Behaviour that matches a decision is not a defect. If that decision is unconfirmed, report "needs confirmation" instead.`;
|
|
312
|
+
var CLAUDE_POINTER = "See the TruthTrail rules in AGENTS.md (decisions are recorded in .decisions/).";
|
|
313
|
+
function upsertBlock(existing, inner) {
|
|
314
|
+
const block = `${BLOCK_START}
|
|
315
|
+
${inner}
|
|
316
|
+
${BLOCK_END}`;
|
|
317
|
+
const current = existing ?? "";
|
|
318
|
+
const starts = current.split(BLOCK_START).length - 1;
|
|
319
|
+
const ends = current.split(BLOCK_END).length - 1;
|
|
320
|
+
if (starts !== ends || starts > 1) {
|
|
321
|
+
throw new Error("unmatched TruthTrail marker: fix the <!-- truthtrail:start/end --> lines by hand, then run init again");
|
|
322
|
+
}
|
|
323
|
+
const start = current.indexOf(BLOCK_START);
|
|
324
|
+
const end = start === -1 ? -1 : current.indexOf(BLOCK_END, start);
|
|
325
|
+
let text;
|
|
326
|
+
if (start !== -1 && end !== -1) {
|
|
327
|
+
text = current.slice(0, start) + block + current.slice(end + BLOCK_END.length);
|
|
328
|
+
} else if (current === "") {
|
|
329
|
+
text = `${block}
|
|
330
|
+
`;
|
|
331
|
+
} else {
|
|
332
|
+
text = `${current}${current.endsWith("\n") ? "" : "\n"}
|
|
333
|
+
${block}
|
|
334
|
+
`;
|
|
335
|
+
}
|
|
336
|
+
return { text, changed: text !== current };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ../core/src/why.ts
|
|
340
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
341
|
+
import { join as join3 } from "path";
|
|
342
|
+
|
|
343
|
+
// ../core/src/paths.ts
|
|
344
|
+
import { isAbsolute, relative, resolve } from "path";
|
|
345
|
+
function toRepoPath(root, cwd, file) {
|
|
346
|
+
const rel = relative(root, isAbsolute(file) ? file : resolve(cwd, file)).split("\\").join("/");
|
|
347
|
+
if (rel === "" || rel === ".." || rel.startsWith("../") || isAbsolute(rel)) return null;
|
|
348
|
+
return rel;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ../core/src/why.ts
|
|
352
|
+
var WhyError = class extends Error {
|
|
353
|
+
constructor(message) {
|
|
354
|
+
super(message);
|
|
355
|
+
this.name = "WhyError";
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
function parseTarget(arg) {
|
|
359
|
+
const m = /^(.*):(\d+)$/.exec(arg);
|
|
360
|
+
return m ? { file: m[1], line: Number(m[2]) } : { file: arg };
|
|
361
|
+
}
|
|
362
|
+
async function whyLocal(root, cfg, target, cwd = root) {
|
|
363
|
+
const file = toRepoPath(root, cwd, target.file);
|
|
364
|
+
if (!file) throw new WhyError(`${target.file} is outside the repository`);
|
|
365
|
+
if (!await isTracked(root, file)) throw new WhyError(`not tracked by git: ${file}`);
|
|
366
|
+
let sha;
|
|
367
|
+
if (target.line !== void 0) {
|
|
368
|
+
const text = await readFile3(join3(root, file), "utf8");
|
|
369
|
+
const lineCount = text === "" ? 0 : text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
|
|
370
|
+
if (target.line < 1 || target.line > lineCount) {
|
|
371
|
+
throw new WhyError(`line ${target.line} is beyond end of ${file} (${lineCount} lines)`);
|
|
372
|
+
}
|
|
373
|
+
sha = await blameLine(root, file, target.line);
|
|
374
|
+
} else {
|
|
375
|
+
sha = await lastCommitForFile(root, file);
|
|
376
|
+
}
|
|
377
|
+
if (!sha || /^0+$/.test(sha)) throw new WhyError(`${file}:${target.line ?? ""} has uncommitted changes; commit first`);
|
|
378
|
+
const info = await commitInfo(root, sha);
|
|
379
|
+
const { records } = await listRecords(root, cfg);
|
|
380
|
+
const decisions = records.filter((r) => r.files.includes(file));
|
|
381
|
+
const ticket = detectTicket({ prTitle: info.message }, cfg.ticketPattern) ?? decisions[0]?.ticket ?? null;
|
|
382
|
+
return {
|
|
383
|
+
file,
|
|
384
|
+
line: target.line ?? null,
|
|
385
|
+
commit: { ...info, authorship: classifyCommit(info.message) },
|
|
386
|
+
ticket,
|
|
387
|
+
decisions,
|
|
388
|
+
status: ticket ? `status: see ticket ${ticket}` : "status: no ticket linked"
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ../core/src/check.ts
|
|
393
|
+
async function checkRepo(root, cfg, base) {
|
|
394
|
+
const ticket = detectTicket({ branch: await currentBranch(root) }, cfg.ticketPattern);
|
|
395
|
+
const { records, errors } = await listRecords(root, cfg, ticket ?? void 0);
|
|
396
|
+
const decisions = ticket ? records : [];
|
|
397
|
+
const shas = await branchCommits(root, base);
|
|
398
|
+
let aiCommits = 0;
|
|
399
|
+
for (const sha of shas) {
|
|
400
|
+
if (classifyCommit((await commitInfo(root, sha)).message).ai) aiCommits++;
|
|
401
|
+
}
|
|
402
|
+
const reminders = [];
|
|
403
|
+
if (!ticket) reminders.push("No ticket key found in branch name.");
|
|
404
|
+
else if (aiCommits > 0 && decisions.length === 0) {
|
|
405
|
+
reminders.push(
|
|
406
|
+
`This branch has ${aiCommits} AI-assisted commits but no decision records for ${ticket}. If behaviour changed, record it with \`npx truthtrail decide\`.`
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
return {
|
|
410
|
+
ticket,
|
|
411
|
+
invalid: errors,
|
|
412
|
+
decisions,
|
|
413
|
+
assumptions: decisions.filter((d) => d.type === "assumption"),
|
|
414
|
+
aiCommits,
|
|
415
|
+
totalCommits: shas.length,
|
|
416
|
+
reminders
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ../core/src/pending.ts
|
|
421
|
+
import { mkdir as mkdir2, readFile as readFile4, writeFile as writeFile2 } from "fs/promises";
|
|
422
|
+
import { join as join4 } from "path";
|
|
423
|
+
var pendingPath = (root) => join4(root, ".truthtrail", "pending.jsonl");
|
|
424
|
+
async function listPending(root) {
|
|
425
|
+
let text;
|
|
426
|
+
try {
|
|
427
|
+
text = await readFile4(pendingPath(root), "utf8");
|
|
428
|
+
} catch (e) {
|
|
429
|
+
if (e.code === "ENOENT") return [];
|
|
430
|
+
throw e;
|
|
431
|
+
}
|
|
432
|
+
const out = [];
|
|
433
|
+
for (const line of text.split("\n")) {
|
|
434
|
+
if (!line.trim()) continue;
|
|
435
|
+
try {
|
|
436
|
+
out.push(JSON.parse(line));
|
|
437
|
+
} catch {
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return out;
|
|
441
|
+
}
|
|
442
|
+
async function save(root, entries) {
|
|
443
|
+
await mkdir2(join4(root, ".truthtrail"), { recursive: true });
|
|
444
|
+
await writeFile2(pendingPath(root), entries.map((e) => JSON.stringify(e)).join("\n") + (entries.length ? "\n" : ""));
|
|
445
|
+
}
|
|
446
|
+
async function appendPending(root, e) {
|
|
447
|
+
const entries = await listPending(root);
|
|
448
|
+
const entry = { index: entries.reduce((m, x) => Math.max(m, x.index + 1), 0), ...e };
|
|
449
|
+
await save(root, [...entries, entry]);
|
|
450
|
+
return entry;
|
|
451
|
+
}
|
|
452
|
+
async function removePending(root, index) {
|
|
453
|
+
const entries = await listPending(root);
|
|
454
|
+
const hit = entries.find((e) => e.index === index) ?? null;
|
|
455
|
+
if (hit) await save(root, entries.filter((e) => e.index !== index));
|
|
456
|
+
return hit;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ../core/src/hooks.ts
|
|
460
|
+
var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
461
|
+
function answersFrom(payload, questionTexts) {
|
|
462
|
+
const resp = payload.tool_response;
|
|
463
|
+
const input = payload.tool_input;
|
|
464
|
+
if (isObj(resp) && isObj(resp.answers)) return resp.answers;
|
|
465
|
+
if (isObj(input.answers)) return input.answers;
|
|
466
|
+
if (isObj(resp) && questionTexts.some((q) => typeof resp[q] === "string")) return resp;
|
|
467
|
+
if (typeof resp === "string") {
|
|
468
|
+
const found = {};
|
|
469
|
+
for (const m of resp.matchAll(/"([^"]+)"="([^"]*)"/g)) found[m[1]] = m[2];
|
|
470
|
+
return Object.keys(found).length ? found : null;
|
|
471
|
+
}
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
function extractAskAnswers(payload) {
|
|
475
|
+
try {
|
|
476
|
+
if (!isObj(payload) || !isObj(payload.tool_input) || !Array.isArray(payload.tool_input.questions)) return [];
|
|
477
|
+
const qs = payload.tool_input.questions.filter(isObj).filter((q) => typeof q.question === "string");
|
|
478
|
+
const answers = answersFrom(payload, qs.map((q) => q.question));
|
|
479
|
+
if (!answers) return [];
|
|
480
|
+
const out = [];
|
|
481
|
+
for (const q of qs) {
|
|
482
|
+
const answer = answers[q.question];
|
|
483
|
+
if (typeof answer !== "string" || !answer) continue;
|
|
484
|
+
const options = Array.isArray(q.options) ? q.options.filter(isObj).map((o) => o.label).filter((l) => typeof l === "string") : [];
|
|
485
|
+
out.push({ question: q.question, options, answer });
|
|
486
|
+
}
|
|
487
|
+
return out;
|
|
488
|
+
} catch {
|
|
489
|
+
return [];
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
async function sessionStartContext(root, cfg, branch) {
|
|
493
|
+
const ticket = detectTicket({ branch }, cfg.ticketPattern);
|
|
494
|
+
if (!ticket) {
|
|
495
|
+
return "TruthTrail: no ticket key in this branch name. Before recording decisions, ask the user for the ticket key once.";
|
|
496
|
+
}
|
|
497
|
+
const { records } = await listRecords(root, cfg, ticket);
|
|
498
|
+
const lines = [`TruthTrail: working on ticket ${ticket}. Follow the TruthTrail rules in AGENTS.md.`];
|
|
499
|
+
if (records.length === 0) lines.push("No decisions recorded for this ticket yet.");
|
|
500
|
+
else {
|
|
501
|
+
lines.push(`Recorded decisions for ${ticket} (follow them):`);
|
|
502
|
+
for (const r of records) lines.push(`- ${r.id} [${r.type}] ${r.question} \u2192 ${r.chosen}`);
|
|
503
|
+
}
|
|
504
|
+
return lines.join("\n");
|
|
505
|
+
}
|
|
506
|
+
async function stopDecision(root, payload) {
|
|
507
|
+
if (isObj(payload) && payload.stop_hook_active === true) return null;
|
|
508
|
+
const pending = await listPending(root);
|
|
509
|
+
if (pending.length === 0) return null;
|
|
510
|
+
const items = pending.map((p) => ` #${p.index}: ${p.question} \u2192 ${p.answer}`).join("\n");
|
|
511
|
+
return {
|
|
512
|
+
decision: "block",
|
|
513
|
+
reason: `TruthTrail: ${pending.length} answered questions are not classified yet:
|
|
514
|
+
${items}
|
|
515
|
+
Record each one that changes behaviour a user or QA can see; dismiss the rest. If TruthTrail MCP tools are available, use \`resolve_pending\` (action "record" or "dismiss"); otherwise run \`npx truthtrail decide --from-pending <n> --reason "\u2026"\` or \`npx truthtrail pending dismiss <n>\`.`
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ../core/src/decide.ts
|
|
520
|
+
import { realpath } from "fs/promises";
|
|
521
|
+
var clean = (xs) => (xs ?? []).map((x) => x.trim()).filter(Boolean);
|
|
522
|
+
async function recordDecision(root, cwd, cfg, input, now) {
|
|
523
|
+
if (!input.question?.trim()) throw new Error("--question is required");
|
|
524
|
+
if (!input.chosen?.trim()) throw new Error("--chosen is required");
|
|
525
|
+
const type = input.type ?? "decision";
|
|
526
|
+
if (!DECISION_TYPES.includes(type)) throw new Error(`--type must be one of ${DECISION_TYPES.join(", ")}`);
|
|
527
|
+
let ticket = input.ticket?.trim().toUpperCase() || null;
|
|
528
|
+
if (ticket && !new RegExp(`^(?:${cfg.ticketPattern})$`, "i").test(ticket)) {
|
|
529
|
+
throw new Error(`'${input.ticket}' is not a valid ticket key (expected pattern ${cfg.ticketPattern})`);
|
|
530
|
+
}
|
|
531
|
+
if (!ticket) {
|
|
532
|
+
const branch = await currentBranch(root);
|
|
533
|
+
ticket = detectTicket({ branch }, cfg.ticketPattern);
|
|
534
|
+
if (!ticket) throw new Error(`No ticket key found in branch '${branch ?? "(detached)"}'. Pass --ticket <KEY>.`);
|
|
535
|
+
}
|
|
536
|
+
const base = await realpath(cwd).catch(() => cwd);
|
|
537
|
+
const files = clean(input.files).map((f) => {
|
|
538
|
+
const rel = toRepoPath(root, base, f);
|
|
539
|
+
if (!rel) throw new Error(`--files: ${f} is outside the repository`);
|
|
540
|
+
return rel;
|
|
541
|
+
});
|
|
542
|
+
return writeRecord(
|
|
543
|
+
root,
|
|
544
|
+
cfg,
|
|
545
|
+
{
|
|
546
|
+
ticket,
|
|
547
|
+
type,
|
|
548
|
+
question: input.question.trim(),
|
|
549
|
+
options: clean(input.options),
|
|
550
|
+
chosen: input.chosen.trim(),
|
|
551
|
+
reason: input.reason?.trim() ?? "",
|
|
552
|
+
decided_by: await userName(root),
|
|
553
|
+
approver_claimed: input.approver?.trim() || null,
|
|
554
|
+
agent: input.agent,
|
|
555
|
+
source: input.source,
|
|
556
|
+
files,
|
|
557
|
+
supersedes: clean(input.supersedes),
|
|
558
|
+
body: input.body?.trim() ?? ""
|
|
559
|
+
},
|
|
560
|
+
now
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
async function resolvePending(root, cwd, cfg, index, overrides, now) {
|
|
564
|
+
const entry = (await listPending(root)).find((p) => p.index === index);
|
|
565
|
+
if (!entry) throw new Error(`No pending question #${index}. Run \`npx truthtrail pending list\`.`);
|
|
566
|
+
const given = Object.fromEntries(Object.entries(overrides).filter(([, v]) => v !== void 0));
|
|
567
|
+
const record = await recordDecision(
|
|
568
|
+
root,
|
|
569
|
+
cwd,
|
|
570
|
+
cfg,
|
|
571
|
+
{ question: entry.question, chosen: entry.answer, options: entry.options, ...given },
|
|
572
|
+
now
|
|
573
|
+
);
|
|
574
|
+
await removePending(root, index);
|
|
575
|
+
return record;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ../core/src/mcpConfig.ts
|
|
579
|
+
import { execFile as execFile2 } from "child_process";
|
|
580
|
+
import { promisify as promisify2 } from "util";
|
|
581
|
+
import { applyEdits, findNodeAtLocation, modify, parseTree } from "jsonc-parser";
|
|
582
|
+
var exec2 = promisify2(execFile2);
|
|
583
|
+
var ARGS = ["-y", "truthtrail", "mcp"];
|
|
584
|
+
var MCP_TARGETS = [
|
|
585
|
+
{ file: ".mcp.json", key: "mcpServers", entry: { command: "npx", args: ARGS } },
|
|
586
|
+
{ file: ".cursor/mcp.json", key: "mcpServers", entry: { command: "npx", args: ARGS } },
|
|
587
|
+
{ file: ".vscode/mcp.json", key: "servers", entry: { type: "stdio", command: "npx", args: ARGS } }
|
|
588
|
+
];
|
|
589
|
+
var FORMAT = {};
|
|
590
|
+
function upsertMcpEntry(existing, target) {
|
|
591
|
+
if (existing === null || existing.trim() === "") {
|
|
592
|
+
return { status: "created", text: `${JSON.stringify({ [target.key]: { truthtrail: target.entry } }, null, 2)}
|
|
593
|
+
` };
|
|
594
|
+
}
|
|
595
|
+
const errors = [];
|
|
596
|
+
const tree = parseTree(existing, errors, { allowTrailingComma: true, disallowComments: false });
|
|
597
|
+
if (errors.length || !tree) return { status: "skipped", reason: "not valid JSON" };
|
|
598
|
+
if (tree.type !== "object") return { status: "skipped", reason: "top level is not a JSON object" };
|
|
599
|
+
const parent = findNodeAtLocation(tree, [target.key]);
|
|
600
|
+
if (parent && parent.type !== "object") return { status: "skipped", reason: `"${target.key}" is not an object` };
|
|
601
|
+
if (findNodeAtLocation(tree, [target.key, "truthtrail"])) return { status: "unchanged", text: existing };
|
|
602
|
+
try {
|
|
603
|
+
const text = applyEdits(existing, modify(existing, [target.key, "truthtrail"], target.entry, FORMAT));
|
|
604
|
+
return { status: "added", text };
|
|
605
|
+
} catch (e) {
|
|
606
|
+
return { status: "skipped", reason: e.message };
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
async function isGitIgnored(root, rel) {
|
|
610
|
+
try {
|
|
611
|
+
await exec2("git", ["check-ignore", "-q", "--", rel], { cwd: root });
|
|
612
|
+
return true;
|
|
613
|
+
} catch {
|
|
614
|
+
return false;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// src/commands/init.ts
|
|
619
|
+
async function readOrNull(path) {
|
|
620
|
+
try {
|
|
621
|
+
return await readFile5(path, "utf8");
|
|
622
|
+
} catch (e) {
|
|
623
|
+
if (e.code === "ENOENT") return null;
|
|
624
|
+
throw e;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
var exists = (p) => access(p).then(() => true, () => false);
|
|
628
|
+
var real = (p) => realpath2(p).catch(() => p);
|
|
629
|
+
function upsertIn(file, existing, inner) {
|
|
630
|
+
try {
|
|
631
|
+
return upsertBlock(existing, inner);
|
|
632
|
+
} catch (e) {
|
|
633
|
+
throw new Error(`${file}: ${e.message}`);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
async function runInit(cwd, log, opts = {}) {
|
|
637
|
+
const root = await repoRoot(cwd);
|
|
638
|
+
const changes = [];
|
|
639
|
+
const note = (file, what) => {
|
|
640
|
+
changes.push(file);
|
|
641
|
+
log(`${what.padEnd(8)}${file}`);
|
|
642
|
+
};
|
|
643
|
+
const agentsPath = join5(root, "AGENTS.md");
|
|
644
|
+
const agents = await readOrNull(agentsPath);
|
|
645
|
+
const a = upsertIn("AGENTS.md", agents, RULES_TEXT);
|
|
646
|
+
const claudePath = join5(root, "CLAUDE.md");
|
|
647
|
+
const claude = await readOrNull(claudePath);
|
|
648
|
+
const claudeIsAgents = claude !== null && await real(claudePath) === await real(agentsPath);
|
|
649
|
+
const c = claude !== null && !claudeIsAgents ? upsertIn("CLAUDE.md", claude, CLAUDE_POINTER) : null;
|
|
650
|
+
const mcpPlan = opts.mcp === false ? [] : await Promise.all(
|
|
651
|
+
MCP_TARGETS.map(async (t) => ({ target: t, result: upsertMcpEntry(await readOrNull(join5(root, t.file)), t) }))
|
|
652
|
+
);
|
|
653
|
+
const keep = join5(root, DEFAULT_CONFIG.decisionsDir, ".gitkeep");
|
|
654
|
+
if (!await exists(keep)) {
|
|
655
|
+
await mkdir3(join5(root, DEFAULT_CONFIG.decisionsDir), { recursive: true });
|
|
656
|
+
await writeFile3(keep, "");
|
|
657
|
+
note(".decisions/.gitkeep", "added");
|
|
658
|
+
}
|
|
659
|
+
if (a.changed) {
|
|
660
|
+
await writeFile3(agentsPath, a.text);
|
|
661
|
+
note("AGENTS.md", agents === null ? "added" : "updated");
|
|
662
|
+
}
|
|
663
|
+
if (c?.changed) {
|
|
664
|
+
await writeFile3(claudePath, c.text);
|
|
665
|
+
note("CLAUDE.md", "updated");
|
|
666
|
+
}
|
|
667
|
+
for (const { target, result } of mcpPlan) {
|
|
668
|
+
if (result.status === "skipped") {
|
|
669
|
+
log(`skipped ${target.file} (${result.reason})`);
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
if (result.status === "unchanged") continue;
|
|
673
|
+
const path = join5(root, target.file);
|
|
674
|
+
await mkdir3(dirname2(path), { recursive: true });
|
|
675
|
+
await writeFile3(path, result.text);
|
|
676
|
+
note(target.file, result.status === "created" ? "added" : "updated");
|
|
677
|
+
if (await isGitIgnored(root, target.file)) {
|
|
678
|
+
const dir = target.file.split("/")[0];
|
|
679
|
+
log(
|
|
680
|
+
`note: ${target.file} is git-ignored, so teammates won't get it (to share it: git add -f ${target.file}, or change "${dir}/" to "${dir}/*" and add !${target.file})`
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
const configPath = join5(root, CONFIG_FILE);
|
|
685
|
+
if (!await exists(configPath)) {
|
|
686
|
+
await writeFile3(configPath, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}
|
|
687
|
+
`);
|
|
688
|
+
note(CONFIG_FILE, "added");
|
|
689
|
+
}
|
|
690
|
+
const ignorePath = join5(root, ".gitignore");
|
|
691
|
+
const ignore = await readOrNull(ignorePath);
|
|
692
|
+
if (!ignore?.split("\n").some((l) => l.trim() === ".truthtrail/")) {
|
|
693
|
+
const prefix = ignore && !ignore.endsWith("\n") ? "\n" : "";
|
|
694
|
+
await appendFile(ignorePath, `${prefix}.truthtrail/
|
|
695
|
+
`);
|
|
696
|
+
note(".gitignore", ignore === null ? "added" : "updated");
|
|
697
|
+
}
|
|
698
|
+
if (changes.length === 0) log("TruthTrail: no changes");
|
|
699
|
+
return { changes };
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// src/commands/decide.ts
|
|
703
|
+
var split = (s, sep) => s === void 0 ? void 0 : s.split(sep);
|
|
704
|
+
function detectAgent(o, env) {
|
|
705
|
+
if (o.agent) {
|
|
706
|
+
if (!AGENT_NAMES.includes(o.agent)) throw new Error(`--agent must be one of ${AGENT_NAMES.join(", ")}`);
|
|
707
|
+
return o.agent;
|
|
708
|
+
}
|
|
709
|
+
return env.CLAUDECODE === "1" ? "claude-code" : "none";
|
|
710
|
+
}
|
|
711
|
+
async function runDecide(cwd, o, env, now) {
|
|
712
|
+
const root = await repoRoot(cwd);
|
|
713
|
+
const cfg = await loadConfig(root);
|
|
714
|
+
const agent = detectAgent(o, env);
|
|
715
|
+
const input = {
|
|
716
|
+
question: o.question,
|
|
717
|
+
chosen: o.chosen,
|
|
718
|
+
options: split(o.options, "|"),
|
|
719
|
+
reason: o.reason,
|
|
720
|
+
type: o.type,
|
|
721
|
+
ticket: o.ticket,
|
|
722
|
+
files: split(o.files, ","),
|
|
723
|
+
approver: o.approver,
|
|
724
|
+
supersedes: split(o.supersedes, ","),
|
|
725
|
+
body: o.body,
|
|
726
|
+
agent,
|
|
727
|
+
source: agent === "none" ? "manual" : "ai-session"
|
|
728
|
+
};
|
|
729
|
+
if (o.fromPending !== void 0) return resolvePending(root, cwd, cfg, Number(o.fromPending), input, now);
|
|
730
|
+
return recordDecision(root, cwd, cfg, input, now);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// src/commands/list.ts
|
|
734
|
+
function formatRecord(r) {
|
|
735
|
+
const lines = [`${r.id} [${r.type}] ${r.ticket}`, ` ${r.question}`, ` \u2192 ${r.chosen}`];
|
|
736
|
+
if (r.reason) lines.push(` why: ${r.reason}`);
|
|
737
|
+
lines.push(` by ${r.decided_by}${r.approver_claimed ? `, approver (claimed): ${r.approver_claimed}` : ""}`);
|
|
738
|
+
if (r.supersedes.length) lines.push(` supersedes: ${r.supersedes.join(", ")}`);
|
|
739
|
+
return lines.join("\n");
|
|
740
|
+
}
|
|
741
|
+
async function runList(cwd, o) {
|
|
742
|
+
const root = await repoRoot(cwd);
|
|
743
|
+
const cfg = await loadConfig(root);
|
|
744
|
+
const ticket = o.ticket?.trim().toUpperCase() || void 0;
|
|
745
|
+
const { records, errors } = await listRecords(root, cfg, ticket);
|
|
746
|
+
if (o.json) return JSON.stringify(records, null, 2);
|
|
747
|
+
const out = errors.map((e) => `! ${e.path}: ${e.message}`);
|
|
748
|
+
if (records.length === 0) out.push(ticket ? `No decisions recorded for ${ticket}.` : "No decisions recorded.");
|
|
749
|
+
else out.push(...records.map(formatRecord));
|
|
750
|
+
return out.join("\n\n");
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// src/commands/why.ts
|
|
754
|
+
import { realpath as realpath3 } from "fs/promises";
|
|
755
|
+
function formatWhy(r) {
|
|
756
|
+
const a = r.commit.authorship;
|
|
757
|
+
const who = a.ai ? `AI (${a.agent}${a.confidence === "medium" ? ", likely" : ""})` : "human";
|
|
758
|
+
const lines = [
|
|
759
|
+
`${r.file}${r.line !== null ? `:${r.line}` : ""}`,
|
|
760
|
+
` commit ${r.commit.sha.slice(0, 7)} by ${r.commit.authorName} on ${r.commit.date.slice(0, 10)} \xB7 written by ${who}`,
|
|
761
|
+
` message ${r.commit.message.split("\n")[0]}`,
|
|
762
|
+
` ticket ${r.ticket ?? "none"}`
|
|
763
|
+
];
|
|
764
|
+
if (r.decisions.length === 0) lines.push(" decisions: none recorded for this file");
|
|
765
|
+
for (const d of r.decisions) {
|
|
766
|
+
lines.push(
|
|
767
|
+
` \u2022 ${d.id} [${d.type}] ${d.question} \u2192 ${d.chosen}`,
|
|
768
|
+
` decided by ${d.decided_by}; ${d.approver_claimed ? `approver (claimed): ${d.approver_claimed}` : "unconfirmed approver"}`
|
|
769
|
+
);
|
|
770
|
+
if (d.reason) lines.push(` why: ${d.reason}`);
|
|
771
|
+
}
|
|
772
|
+
lines.push(` ${r.status}`);
|
|
773
|
+
return lines.join("\n");
|
|
774
|
+
}
|
|
775
|
+
async function runWhy(cwd, arg, o) {
|
|
776
|
+
const root = await repoRoot(cwd);
|
|
777
|
+
const result = await whyLocal(root, await loadConfig(root), parseTarget(arg), await realpath3(cwd));
|
|
778
|
+
return o.json ? JSON.stringify(result, null, 2) : formatWhy(result);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// src/errors.ts
|
|
782
|
+
function formatCliError(e) {
|
|
783
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
784
|
+
return `truthtrail: ${msg.split("\n")[0]}`;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// src/commands/check.ts
|
|
788
|
+
function formatCheck(r) {
|
|
789
|
+
const lines = [`TruthTrail check \xB7 ticket ${r.ticket ?? "none"} \xB7 ${r.aiCommits}/${r.totalCommits} commits AI-assisted`];
|
|
790
|
+
for (const i of r.invalid) lines.push(`\u2717 invalid record ${i.path}: ${i.message}`);
|
|
791
|
+
for (const d of r.decisions) lines.push(`\u2022 ${d.id} [${d.type}] ${d.question} \u2192 ${d.chosen}`);
|
|
792
|
+
if (r.assumptions.length) lines.push(`\u26A0 ${r.assumptions.length} assumption(s) need confirmation`);
|
|
793
|
+
for (const m of r.reminders) lines.push(`\u2192 ${m}`);
|
|
794
|
+
return lines.join("\n");
|
|
795
|
+
}
|
|
796
|
+
async function runCheck(cwd, o) {
|
|
797
|
+
const root = await repoRoot(cwd);
|
|
798
|
+
const report = await checkRepo(root, await loadConfig(root), o.base ?? "main");
|
|
799
|
+
return {
|
|
800
|
+
output: o.json ? JSON.stringify(report, null, 2) : formatCheck(report),
|
|
801
|
+
exitCode: report.invalid.length > 0 ? 1 : 0
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// src/commands/pending.ts
|
|
806
|
+
async function runPendingList(cwd) {
|
|
807
|
+
const items = await listPending(await repoRoot(cwd));
|
|
808
|
+
if (items.length === 0) return "No pending questions.";
|
|
809
|
+
return items.map((p) => `#${p.index} ${p.question} \u2192 ${p.answer}`).join("\n");
|
|
810
|
+
}
|
|
811
|
+
async function runPendingDismiss(cwd, index) {
|
|
812
|
+
const hit = await removePending(await repoRoot(cwd), index);
|
|
813
|
+
if (!hit) throw new Error(`No pending question #${index}.`);
|
|
814
|
+
return `dismissed #${index}: ${hit.question}`;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// src/commands/hook.ts
|
|
818
|
+
async function runHook(event, stdin, cwd) {
|
|
819
|
+
try {
|
|
820
|
+
let payload = null;
|
|
821
|
+
try {
|
|
822
|
+
payload = stdin.trim() ? JSON.parse(stdin) : null;
|
|
823
|
+
} catch {
|
|
824
|
+
payload = null;
|
|
825
|
+
}
|
|
826
|
+
const dir = typeof payload?.cwd === "string" ? payload.cwd : cwd;
|
|
827
|
+
const root = await repoRoot(dir);
|
|
828
|
+
if (event === "session-start") {
|
|
829
|
+
return await sessionStartContext(root, await loadConfig(root), await currentBranch(root));
|
|
830
|
+
}
|
|
831
|
+
if (event === "capture") {
|
|
832
|
+
for (const a of extractAskAnswers(payload)) {
|
|
833
|
+
await appendPending(root, { ...a, at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
834
|
+
}
|
|
835
|
+
return "";
|
|
836
|
+
}
|
|
837
|
+
const d = await stopDecision(root, payload);
|
|
838
|
+
return d ? JSON.stringify(d) : "";
|
|
839
|
+
} catch {
|
|
840
|
+
return "";
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
async function readStdin() {
|
|
844
|
+
if (process.stdin.isTTY) return "";
|
|
845
|
+
const chunks = [];
|
|
846
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
847
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// src/mcp/server.ts
|
|
851
|
+
import { fileURLToPath } from "url";
|
|
852
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
853
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
854
|
+
import { z } from "zod";
|
|
855
|
+
|
|
856
|
+
// src/mcp/agent.ts
|
|
857
|
+
function agentFromClient(name) {
|
|
858
|
+
const n = (name ?? "").toLowerCase();
|
|
859
|
+
if (n.includes("claude")) return "claude-code";
|
|
860
|
+
if (n.includes("cursor")) return "cursor";
|
|
861
|
+
if (n.includes("codex")) return "codex";
|
|
862
|
+
if (/visual studio code|vscode|copilot/.test(n)) return "copilot";
|
|
863
|
+
return "other";
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// src/mcp/server.ts
|
|
867
|
+
var NO_REPO = "TruthTrail: not inside a git repository (open the project folder)";
|
|
868
|
+
var ok = (value) => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] });
|
|
869
|
+
var fail = (e) => ({
|
|
870
|
+
isError: true,
|
|
871
|
+
content: [{ type: "text", text: (e instanceof Error ? e.message : String(e)).split("\n")[0] }]
|
|
872
|
+
});
|
|
873
|
+
var decisionFields = {
|
|
874
|
+
question: z.string().describe("What needed deciding, e.g. 'Discount before or after tax?'"),
|
|
875
|
+
chosen: z.string().describe("What was picked"),
|
|
876
|
+
options: z.array(z.string()).optional().describe("The choices that were considered"),
|
|
877
|
+
reason: z.string().optional().describe("Why it was chosen"),
|
|
878
|
+
type: z.enum(DECISION_TYPES).optional().describe("decision (default), assumption (you assumed it without asking), or impact"),
|
|
879
|
+
ticket: z.string().optional().describe("Ticket key; defaults to the key in the branch name"),
|
|
880
|
+
files: z.array(z.string()).optional().describe("Files this decision affects"),
|
|
881
|
+
approver: z.string().optional().describe("Only if the user explicitly named who approved it. Never guess."),
|
|
882
|
+
supersedes: z.array(z.string()).optional().describe("Ids of older decisions this replaces"),
|
|
883
|
+
body: z.string().optional().describe("Short extra explanation")
|
|
884
|
+
};
|
|
885
|
+
function createServer(opts) {
|
|
886
|
+
const now = opts.now ?? (() => /* @__PURE__ */ new Date());
|
|
887
|
+
const server = new McpServer({ name: "truthtrail", version: "0.2.0" });
|
|
888
|
+
async function workspace() {
|
|
889
|
+
const candidates = [];
|
|
890
|
+
if (server.server.getClientCapabilities()?.roots) {
|
|
891
|
+
try {
|
|
892
|
+
const { roots } = await server.server.listRoots();
|
|
893
|
+
for (const r of roots) if (r.uri.startsWith("file://")) candidates.push(fileURLToPath(r.uri));
|
|
894
|
+
} catch {
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
candidates.push(opts.cwd);
|
|
898
|
+
for (const dir of candidates) {
|
|
899
|
+
try {
|
|
900
|
+
const root = await repoRoot(dir);
|
|
901
|
+
return { root, cwd: dir, cfg: await loadConfig(root) };
|
|
902
|
+
} catch {
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
throw new Error(NO_REPO);
|
|
906
|
+
}
|
|
907
|
+
const who = () => ({ agent: agentFromClient(server.server.getClientVersion()?.name), source: "ai-session" });
|
|
908
|
+
const run = (fn) => fn().then(ok, fail);
|
|
909
|
+
server.registerTool(
|
|
910
|
+
"record_decision",
|
|
911
|
+
{
|
|
912
|
+
description: "Record a product decision or assumption for the current ticket. Use it when the ticket was silent or ambiguous and the choice changes behaviour a user or QA can see, when following the ticket would break something, or when you assumed something visible (type: assumption). Not for pure implementation details.",
|
|
913
|
+
inputSchema: decisionFields
|
|
914
|
+
},
|
|
915
|
+
(args) => run(async () => {
|
|
916
|
+
const w = await workspace();
|
|
917
|
+
const r = await recordDecision(w.root, w.cwd, w.cfg, { ...args, ...who() }, now());
|
|
918
|
+
return { id: r.id, path: r.path, ticket: r.ticket };
|
|
919
|
+
})
|
|
920
|
+
);
|
|
921
|
+
server.registerTool(
|
|
922
|
+
"list_decisions",
|
|
923
|
+
{
|
|
924
|
+
description: "List recorded decisions (optionally for one ticket). Read these before implementing or testing a ticket and follow them.",
|
|
925
|
+
inputSchema: { ticket: z.string().optional() }
|
|
926
|
+
},
|
|
927
|
+
({ ticket }) => run(async () => {
|
|
928
|
+
const w = await workspace();
|
|
929
|
+
return listRecords(w.root, w.cfg, ticket?.trim().toUpperCase() || void 0);
|
|
930
|
+
})
|
|
931
|
+
);
|
|
932
|
+
server.registerTool(
|
|
933
|
+
"why",
|
|
934
|
+
{
|
|
935
|
+
description: "Explain a file or line: last commit, AI or human authorship, linked decisions and the ticket.",
|
|
936
|
+
inputSchema: { target: z.string().describe("file or file:line, relative to the workspace") }
|
|
937
|
+
},
|
|
938
|
+
({ target }) => run(async () => {
|
|
939
|
+
const w = await workspace();
|
|
940
|
+
return whyLocal(w.root, w.cfg, parseTarget(target), w.cwd);
|
|
941
|
+
})
|
|
942
|
+
);
|
|
943
|
+
server.registerTool(
|
|
944
|
+
"check_branch",
|
|
945
|
+
{
|
|
946
|
+
description: "Check this branch: ticket key, decisions and assumptions recorded, AI-assisted commits, and reminders.",
|
|
947
|
+
inputSchema: { base: z.string().optional().describe("branch to compare against (default main)") }
|
|
948
|
+
},
|
|
949
|
+
({ base }) => run(async () => {
|
|
950
|
+
const w = await workspace();
|
|
951
|
+
return checkRepo(w.root, w.cfg, base ?? "main");
|
|
952
|
+
})
|
|
953
|
+
);
|
|
954
|
+
server.registerTool(
|
|
955
|
+
"list_pending",
|
|
956
|
+
{ description: "Questions the user answered during this work that are not yet recorded or dismissed." },
|
|
957
|
+
() => run(async () => {
|
|
958
|
+
const w = await workspace();
|
|
959
|
+
return listPending(w.root);
|
|
960
|
+
})
|
|
961
|
+
);
|
|
962
|
+
server.registerTool(
|
|
963
|
+
"resolve_pending",
|
|
964
|
+
{
|
|
965
|
+
description: "Resolve a pending answered question: action 'record' if it changes behaviour a user or QA can see (fields override the captured values), otherwise 'dismiss'.",
|
|
966
|
+
inputSchema: {
|
|
967
|
+
index: z.number().int(),
|
|
968
|
+
action: z.enum(["record", "dismiss"]),
|
|
969
|
+
...Object.fromEntries(Object.entries(decisionFields).map(([k, v]) => [k, v.optional()]))
|
|
970
|
+
}
|
|
971
|
+
},
|
|
972
|
+
(args) => run(async () => {
|
|
973
|
+
const w = await workspace();
|
|
974
|
+
const { index, action, ...fields } = args;
|
|
975
|
+
if (action === "dismiss") {
|
|
976
|
+
const hit = await removePending(w.root, index);
|
|
977
|
+
if (!hit) throw new Error(`No pending question #${index}.`);
|
|
978
|
+
return { dismissed: hit };
|
|
979
|
+
}
|
|
980
|
+
return resolvePending(w.root, w.cwd, w.cfg, index, { ...fields, ...who() }, now());
|
|
981
|
+
})
|
|
982
|
+
);
|
|
983
|
+
return server;
|
|
984
|
+
}
|
|
985
|
+
async function runMcp() {
|
|
986
|
+
await createServer({ cwd: process.cwd() }).connect(new StdioServerTransport());
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// src/index.ts
|
|
990
|
+
function buildProgram() {
|
|
991
|
+
const program = new Command();
|
|
992
|
+
program.name("truthtrail").description("Record the decisions your AI coding agent makes, right next to the code.").version("0.2.0");
|
|
993
|
+
program.command("init").description("Set up TruthTrail in this repo (.decisions/, AGENTS.md rules, MCP config, config)").option("--no-mcp", "don't write MCP config for Claude Code, Cursor and Copilot").action(async (o) => {
|
|
994
|
+
await runInit(process.cwd(), (l) => console.log(l), { mcp: o.mcp });
|
|
995
|
+
});
|
|
996
|
+
program.command("decide").description("Record a decision (or assumption) for the current ticket").option("--question <text>", "what needed deciding").option("--chosen <text>", "what was picked").option("--options <list>", "choices considered, separated by |").option("--reason <text>", "why").option("--type <type>", "decision | assumption | impact", "decision").option("--ticket <key>", "ticket key (default: from branch name)").option("--files <list>", "affected files, comma separated").option("--approver <name>", "who the user said approved it (never guess)").option("--supersedes <ids>", "ids of older decisions this replaces, comma separated").option("--agent <name>", "AI agent involved").option("--body <text>", "short extra explanation").option("--from-pending <n>", "record a question captured by the Claude Code hook", (v) => Number(v)).action(async (o) => {
|
|
997
|
+
const r = await runDecide(process.cwd(), o, process.env, /* @__PURE__ */ new Date());
|
|
998
|
+
console.log(`recorded ${r.id}
|
|
999
|
+
${r.path}`);
|
|
1000
|
+
});
|
|
1001
|
+
program.command("list").description("List recorded decisions").option("--ticket <key>", "only this ticket").option("--json", "machine-readable output").action(async (o) => {
|
|
1002
|
+
console.log(await runList(process.cwd(), o));
|
|
1003
|
+
});
|
|
1004
|
+
program.command("why").description("Explain who wrote a file or line, which decisions shaped it, and the ticket").argument("<target>", "file or file:line").option("--json", "machine-readable output").action(async (target, o) => {
|
|
1005
|
+
console.log(await runWhy(process.cwd(), target, o));
|
|
1006
|
+
});
|
|
1007
|
+
program.command("check").description("Check this branch: ticket, decisions, assumptions, AI commits").option("--base <ref>", "branch to compare against", "main").option("--json", "machine-readable output").action(async (o) => {
|
|
1008
|
+
const { output, exitCode } = await runCheck(process.cwd(), o);
|
|
1009
|
+
console.log(output);
|
|
1010
|
+
process.exitCode = exitCode;
|
|
1011
|
+
});
|
|
1012
|
+
program.command("mcp").description("Run the TruthTrail MCP server over stdio (used by Claude Code, Cursor, Copilot)").action(async () => {
|
|
1013
|
+
await runMcp();
|
|
1014
|
+
});
|
|
1015
|
+
const pending = program.command("pending").description("Questions captured by the Claude Code hook, not yet classified");
|
|
1016
|
+
pending.command("list").description("List pending questions").action(async () => console.log(await runPendingList(process.cwd())));
|
|
1017
|
+
pending.command("dismiss").description("Drop a pending question that does not change visible behaviour").argument("<n>", "pending index", (v) => Number(v)).action(async (n) => console.log(await runPendingDismiss(process.cwd(), n)));
|
|
1018
|
+
program.command("hook").description("Claude Code hook entry point (used by the TruthTrail plugin)").argument("<event>", "session-start | capture | stop").action(async (event) => {
|
|
1019
|
+
const out = await runHook(event, await readStdin(), process.cwd());
|
|
1020
|
+
if (out) console.log(out);
|
|
1021
|
+
});
|
|
1022
|
+
return program;
|
|
1023
|
+
}
|
|
1024
|
+
var isMain = process.argv[1] && /(?:index\.[jt]s|truthtrail)$/.test(process.argv[1]);
|
|
1025
|
+
if (isMain) {
|
|
1026
|
+
buildProgram().parseAsync(process.argv).catch((e) => {
|
|
1027
|
+
console.error(formatCliError(e));
|
|
1028
|
+
process.exit(1);
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
export {
|
|
1032
|
+
buildProgram
|
|
1033
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "truthtrail",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Record the decisions your AI coding agent makes, right next to the code.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"truthtrail": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsup"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
21
|
+
"commander": "^12.1.0",
|
|
22
|
+
"jsonc-parser": "^3.3.1",
|
|
23
|
+
"yaml": "^2.5.0",
|
|
24
|
+
"zod": "^4.6.7"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@truthtrail/core": "*"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT"
|
|
30
|
+
}
|