truthtrail 0.0.0-stage → 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.
Files changed (3) hide show
  1. package/README.md +57 -2
  2. package/dist/index.js +808 -0
  3. package/package.json +11 -4
package/README.md CHANGED
@@ -1,3 +1,58 @@
1
- # Temporary Holding Version
1
+ # TruthTrail
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ ## Claude Code plugin
45
+
46
+ The plugin (in `plugin/` of the TruthTrail repo) adds:
47
+ - **SessionStart:** loads the ticket's decisions into Claude's context
48
+ - **Capture:** remembers questions you answered through Claude's "A or B?" prompts
49
+ - **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>`)
50
+
51
+ ## Config (`.truthtrail.json`)
52
+
53
+ | Key | Default | Meaning |
54
+ |---|---|---|
55
+ | `ticketPattern` | `[A-Z][A-Z0-9]+-\d+` | how ticket keys look (matched case-insensitively) |
56
+ | `decisionsDir` | `.decisions` | where records live |
57
+ | `jiraSite` | `null` | used by the TruthTrail server |
58
+ | `failOnUnconfirmed` | `false` | used by the TruthTrail server's PR check |
package/dist/index.js ADDED
@@ -0,0 +1,808 @@
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, writeFile as writeFile3 } from "fs/promises";
8
+ import { 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
+ **Before you implement,** run \`npx truthtrail list --ticket <KEY>\` and follow the decisions it shows. Treat \`rejected\` ones as must-rework.
297
+
298
+ **Record a decision** with \`npx truthtrail decide\` when:
299
+ - the ticket is silent or ambiguous and your choice changes behaviour a user or QA can see;
300
+ - following the ticket would break something, so you changed the behaviour;
301
+ - you assumed something that changes visible behaviour (use \`--type assumption\`).
302
+
303
+ Do not record pure implementation details with no visible effect.
304
+
305
+ **Approver:** pass \`--approver <name>\` only if the user explicitly says who approved it. Never guess a name.
306
+
307
+ **Superseding:** if your decision replaces an older one, pass \`--supersedes <id>\`.
308
+
309
+ **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.`;
310
+ var CLAUDE_POINTER = "See the TruthTrail rules in AGENTS.md (decisions are recorded in .decisions/).";
311
+ function upsertBlock(existing, inner) {
312
+ const block = `${BLOCK_START}
313
+ ${inner}
314
+ ${BLOCK_END}`;
315
+ const current = existing ?? "";
316
+ const starts = current.split(BLOCK_START).length - 1;
317
+ const ends = current.split(BLOCK_END).length - 1;
318
+ if (starts !== ends || starts > 1) {
319
+ throw new Error("unmatched TruthTrail marker: fix the <!-- truthtrail:start/end --> lines by hand, then run init again");
320
+ }
321
+ const start = current.indexOf(BLOCK_START);
322
+ const end = start === -1 ? -1 : current.indexOf(BLOCK_END, start);
323
+ let text;
324
+ if (start !== -1 && end !== -1) {
325
+ text = current.slice(0, start) + block + current.slice(end + BLOCK_END.length);
326
+ } else if (current === "") {
327
+ text = `${block}
328
+ `;
329
+ } else {
330
+ text = `${current}${current.endsWith("\n") ? "" : "\n"}
331
+ ${block}
332
+ `;
333
+ }
334
+ return { text, changed: text !== current };
335
+ }
336
+
337
+ // ../core/src/why.ts
338
+ import { readFile as readFile3 } from "fs/promises";
339
+ import { join as join3 } from "path";
340
+
341
+ // ../core/src/paths.ts
342
+ import { isAbsolute, relative, resolve } from "path";
343
+ function toRepoPath(root, cwd, file) {
344
+ const rel = relative(root, isAbsolute(file) ? file : resolve(cwd, file)).split("\\").join("/");
345
+ if (rel === "" || rel === ".." || rel.startsWith("../") || isAbsolute(rel)) return null;
346
+ return rel;
347
+ }
348
+
349
+ // ../core/src/why.ts
350
+ var WhyError = class extends Error {
351
+ constructor(message) {
352
+ super(message);
353
+ this.name = "WhyError";
354
+ }
355
+ };
356
+ function parseTarget(arg) {
357
+ const m = /^(.*):(\d+)$/.exec(arg);
358
+ return m ? { file: m[1], line: Number(m[2]) } : { file: arg };
359
+ }
360
+ async function whyLocal(root, cfg, target, cwd = root) {
361
+ const file = toRepoPath(root, cwd, target.file);
362
+ if (!file) throw new WhyError(`${target.file} is outside the repository`);
363
+ if (!await isTracked(root, file)) throw new WhyError(`not tracked by git: ${file}`);
364
+ let sha;
365
+ if (target.line !== void 0) {
366
+ const text = await readFile3(join3(root, file), "utf8");
367
+ const lineCount = text === "" ? 0 : text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
368
+ if (target.line < 1 || target.line > lineCount) {
369
+ throw new WhyError(`line ${target.line} is beyond end of ${file} (${lineCount} lines)`);
370
+ }
371
+ sha = await blameLine(root, file, target.line);
372
+ } else {
373
+ sha = await lastCommitForFile(root, file);
374
+ }
375
+ if (!sha || /^0+$/.test(sha)) throw new WhyError(`${file}:${target.line ?? ""} has uncommitted changes; commit first`);
376
+ const info = await commitInfo(root, sha);
377
+ const { records } = await listRecords(root, cfg);
378
+ const decisions = records.filter((r) => r.files.includes(file));
379
+ const ticket = detectTicket({ prTitle: info.message }, cfg.ticketPattern) ?? decisions[0]?.ticket ?? null;
380
+ return {
381
+ file,
382
+ line: target.line ?? null,
383
+ commit: { ...info, authorship: classifyCommit(info.message) },
384
+ ticket,
385
+ decisions,
386
+ status: ticket ? `status: see ticket ${ticket}` : "status: no ticket linked"
387
+ };
388
+ }
389
+
390
+ // ../core/src/check.ts
391
+ async function checkRepo(root, cfg, base) {
392
+ const ticket = detectTicket({ branch: await currentBranch(root) }, cfg.ticketPattern);
393
+ const { records, errors } = await listRecords(root, cfg, ticket ?? void 0);
394
+ const decisions = ticket ? records : [];
395
+ const shas = await branchCommits(root, base);
396
+ let aiCommits = 0;
397
+ for (const sha of shas) {
398
+ if (classifyCommit((await commitInfo(root, sha)).message).ai) aiCommits++;
399
+ }
400
+ const reminders = [];
401
+ if (!ticket) reminders.push("No ticket key found in branch name.");
402
+ else if (aiCommits > 0 && decisions.length === 0) {
403
+ reminders.push(
404
+ `This branch has ${aiCommits} AI-assisted commits but no decision records for ${ticket}. If behaviour changed, record it with \`npx truthtrail decide\`.`
405
+ );
406
+ }
407
+ return {
408
+ ticket,
409
+ invalid: errors,
410
+ decisions,
411
+ assumptions: decisions.filter((d) => d.type === "assumption"),
412
+ aiCommits,
413
+ totalCommits: shas.length,
414
+ reminders
415
+ };
416
+ }
417
+
418
+ // ../core/src/pending.ts
419
+ import { mkdir as mkdir2, readFile as readFile4, writeFile as writeFile2 } from "fs/promises";
420
+ import { join as join4 } from "path";
421
+ var pendingPath = (root) => join4(root, ".truthtrail", "pending.jsonl");
422
+ async function listPending(root) {
423
+ let text;
424
+ try {
425
+ text = await readFile4(pendingPath(root), "utf8");
426
+ } catch (e) {
427
+ if (e.code === "ENOENT") return [];
428
+ throw e;
429
+ }
430
+ const out = [];
431
+ for (const line of text.split("\n")) {
432
+ if (!line.trim()) continue;
433
+ try {
434
+ out.push(JSON.parse(line));
435
+ } catch {
436
+ }
437
+ }
438
+ return out;
439
+ }
440
+ async function save(root, entries) {
441
+ await mkdir2(join4(root, ".truthtrail"), { recursive: true });
442
+ await writeFile2(pendingPath(root), entries.map((e) => JSON.stringify(e)).join("\n") + (entries.length ? "\n" : ""));
443
+ }
444
+ async function appendPending(root, e) {
445
+ const entries = await listPending(root);
446
+ const entry = { index: entries.reduce((m, x) => Math.max(m, x.index + 1), 0), ...e };
447
+ await save(root, [...entries, entry]);
448
+ return entry;
449
+ }
450
+ async function removePending(root, index) {
451
+ const entries = await listPending(root);
452
+ const hit = entries.find((e) => e.index === index) ?? null;
453
+ if (hit) await save(root, entries.filter((e) => e.index !== index));
454
+ return hit;
455
+ }
456
+
457
+ // ../core/src/hooks.ts
458
+ var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
459
+ function answersFrom(payload, questionTexts) {
460
+ const resp = payload.tool_response;
461
+ const input = payload.tool_input;
462
+ if (isObj(resp) && isObj(resp.answers)) return resp.answers;
463
+ if (isObj(input.answers)) return input.answers;
464
+ if (isObj(resp) && questionTexts.some((q) => typeof resp[q] === "string")) return resp;
465
+ if (typeof resp === "string") {
466
+ const found = {};
467
+ for (const m of resp.matchAll(/"([^"]+)"="([^"]*)"/g)) found[m[1]] = m[2];
468
+ return Object.keys(found).length ? found : null;
469
+ }
470
+ return null;
471
+ }
472
+ function extractAskAnswers(payload) {
473
+ try {
474
+ if (!isObj(payload) || !isObj(payload.tool_input) || !Array.isArray(payload.tool_input.questions)) return [];
475
+ const qs = payload.tool_input.questions.filter(isObj).filter((q) => typeof q.question === "string");
476
+ const answers = answersFrom(payload, qs.map((q) => q.question));
477
+ if (!answers) return [];
478
+ const out = [];
479
+ for (const q of qs) {
480
+ const answer = answers[q.question];
481
+ if (typeof answer !== "string" || !answer) continue;
482
+ const options = Array.isArray(q.options) ? q.options.filter(isObj).map((o) => o.label).filter((l) => typeof l === "string") : [];
483
+ out.push({ question: q.question, options, answer });
484
+ }
485
+ return out;
486
+ } catch {
487
+ return [];
488
+ }
489
+ }
490
+ async function sessionStartContext(root, cfg, branch) {
491
+ const ticket = detectTicket({ branch }, cfg.ticketPattern);
492
+ if (!ticket) {
493
+ return "TruthTrail: no ticket key in this branch name. Before recording decisions, ask the user for the ticket key once.";
494
+ }
495
+ const { records } = await listRecords(root, cfg, ticket);
496
+ const lines = [`TruthTrail: working on ticket ${ticket}. Follow the TruthTrail rules in AGENTS.md.`];
497
+ if (records.length === 0) lines.push("No decisions recorded for this ticket yet.");
498
+ else {
499
+ lines.push(`Recorded decisions for ${ticket} (follow them):`);
500
+ for (const r of records) lines.push(`- ${r.id} [${r.type}] ${r.question} \u2192 ${r.chosen}`);
501
+ }
502
+ return lines.join("\n");
503
+ }
504
+ async function stopDecision(root, payload) {
505
+ if (isObj(payload) && payload.stop_hook_active === true) return null;
506
+ const pending = await listPending(root);
507
+ if (pending.length === 0) return null;
508
+ const items = pending.map((p) => ` #${p.index}: ${p.question} \u2192 ${p.answer}`).join("\n");
509
+ return {
510
+ decision: "block",
511
+ reason: `TruthTrail: ${pending.length} answered questions are not classified yet:
512
+ ${items}
513
+ For each one that changes behaviour a user or QA can see, run \`npx truthtrail decide --from-pending <n> --reason "\u2026"\`. Otherwise run \`npx truthtrail pending dismiss <n>\`.`
514
+ };
515
+ }
516
+
517
+ // src/commands/init.ts
518
+ async function readOrNull(path) {
519
+ try {
520
+ return await readFile5(path, "utf8");
521
+ } catch (e) {
522
+ if (e.code === "ENOENT") return null;
523
+ throw e;
524
+ }
525
+ }
526
+ var exists = (p) => access(p).then(() => true, () => false);
527
+ var real = (p) => realpath(p).catch(() => p);
528
+ function upsertIn(file, existing, inner) {
529
+ try {
530
+ return upsertBlock(existing, inner);
531
+ } catch (e) {
532
+ throw new Error(`${file}: ${e.message}`);
533
+ }
534
+ }
535
+ async function runInit(cwd, log) {
536
+ const root = await repoRoot(cwd);
537
+ const changes = [];
538
+ const note = (file, what) => {
539
+ changes.push(file);
540
+ log(`${what.padEnd(8)}${file}`);
541
+ };
542
+ const agentsPath = join5(root, "AGENTS.md");
543
+ const agents = await readOrNull(agentsPath);
544
+ const a = upsertIn("AGENTS.md", agents, RULES_TEXT);
545
+ const claudePath = join5(root, "CLAUDE.md");
546
+ const claude = await readOrNull(claudePath);
547
+ const claudeIsAgents = claude !== null && await real(claudePath) === await real(agentsPath);
548
+ const c = claude !== null && !claudeIsAgents ? upsertIn("CLAUDE.md", claude, CLAUDE_POINTER) : null;
549
+ const keep = join5(root, DEFAULT_CONFIG.decisionsDir, ".gitkeep");
550
+ if (!await exists(keep)) {
551
+ await mkdir3(join5(root, DEFAULT_CONFIG.decisionsDir), { recursive: true });
552
+ await writeFile3(keep, "");
553
+ note(".decisions/.gitkeep", "added");
554
+ }
555
+ if (a.changed) {
556
+ await writeFile3(agentsPath, a.text);
557
+ note("AGENTS.md", agents === null ? "added" : "updated");
558
+ }
559
+ if (c?.changed) {
560
+ await writeFile3(claudePath, c.text);
561
+ note("CLAUDE.md", "updated");
562
+ }
563
+ const configPath = join5(root, CONFIG_FILE);
564
+ if (!await exists(configPath)) {
565
+ await writeFile3(configPath, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}
566
+ `);
567
+ note(CONFIG_FILE, "added");
568
+ }
569
+ const ignorePath = join5(root, ".gitignore");
570
+ const ignore = await readOrNull(ignorePath);
571
+ if (!ignore?.split("\n").some((l) => l.trim() === ".truthtrail/")) {
572
+ const prefix = ignore && !ignore.endsWith("\n") ? "\n" : "";
573
+ await appendFile(ignorePath, `${prefix}.truthtrail/
574
+ `);
575
+ note(".gitignore", ignore === null ? "added" : "updated");
576
+ }
577
+ if (changes.length === 0) log("TruthTrail: no changes");
578
+ return { changes };
579
+ }
580
+
581
+ // src/commands/decide.ts
582
+ import { realpath as realpath2 } from "fs/promises";
583
+ var list = (s, sep) => (s ?? "").split(sep).map((x) => x.trim()).filter(Boolean);
584
+ async function repoFiles(root, cwd, files) {
585
+ const base = await realpath2(cwd);
586
+ return files.map((f) => {
587
+ const rel = toRepoPath(root, base, f);
588
+ if (!rel) throw new Error(`--files: ${f} is outside the repository`);
589
+ return rel;
590
+ });
591
+ }
592
+ function detectAgent(o, env) {
593
+ if (o.agent) {
594
+ if (!AGENT_NAMES.includes(o.agent)) throw new Error(`--agent must be one of ${AGENT_NAMES.join(", ")}`);
595
+ return o.agent;
596
+ }
597
+ return env.CLAUDECODE === "1" ? "claude-code" : "none";
598
+ }
599
+ async function runDecide(cwd, input, env, now) {
600
+ let o = input;
601
+ if (input.fromPending !== void 0) {
602
+ const entry = (await listPending(await repoRoot(cwd))).find((p) => p.index === Number(input.fromPending));
603
+ if (!entry) throw new Error(`No pending question #${input.fromPending}. Run \`npx truthtrail pending list\`.`);
604
+ const given = Object.fromEntries(Object.entries(input).filter(([, v]) => v !== void 0));
605
+ o = { question: entry.question, chosen: entry.answer, options: entry.options.join("|"), ...given };
606
+ }
607
+ const record = await decide(cwd, o, env, now);
608
+ if (input.fromPending !== void 0) await removePending(await repoRoot(cwd), Number(input.fromPending));
609
+ return record;
610
+ }
611
+ async function decide(cwd, o, env, now) {
612
+ if (!o.question?.trim()) throw new Error("--question is required");
613
+ if (!o.chosen?.trim()) throw new Error("--chosen is required");
614
+ const type = o.type ?? "decision";
615
+ if (!DECISION_TYPES.includes(type)) throw new Error(`--type must be one of ${DECISION_TYPES.join(", ")}`);
616
+ const root = await repoRoot(cwd);
617
+ const cfg = await loadConfig(root);
618
+ let ticket = o.ticket?.trim().toUpperCase() || null;
619
+ if (ticket && !new RegExp(`^(?:${cfg.ticketPattern})$`, "i").test(ticket)) {
620
+ throw new Error(`'${o.ticket}' is not a valid ticket key (expected pattern ${cfg.ticketPattern})`);
621
+ }
622
+ if (!ticket) {
623
+ const branch = await currentBranch(root);
624
+ ticket = detectTicket({ branch }, cfg.ticketPattern);
625
+ if (!ticket) throw new Error(`No ticket key found in branch '${branch ?? "(detached)"}'. Pass --ticket <KEY>.`);
626
+ }
627
+ const agent = detectAgent(o, env);
628
+ return writeRecord(
629
+ root,
630
+ cfg,
631
+ {
632
+ ticket,
633
+ type,
634
+ question: o.question.trim(),
635
+ options: list(o.options, "|"),
636
+ chosen: o.chosen.trim(),
637
+ reason: o.reason?.trim() ?? "",
638
+ decided_by: await userName(root),
639
+ approver_claimed: o.approver?.trim() || null,
640
+ agent,
641
+ source: agent === "none" ? "manual" : "ai-session",
642
+ files: await repoFiles(root, cwd, list(o.files, ",")),
643
+ supersedes: list(o.supersedes, ","),
644
+ body: o.body?.trim() ?? ""
645
+ },
646
+ now
647
+ );
648
+ }
649
+
650
+ // src/commands/list.ts
651
+ function formatRecord(r) {
652
+ const lines = [`${r.id} [${r.type}] ${r.ticket}`, ` ${r.question}`, ` \u2192 ${r.chosen}`];
653
+ if (r.reason) lines.push(` why: ${r.reason}`);
654
+ lines.push(` by ${r.decided_by}${r.approver_claimed ? `, approver (claimed): ${r.approver_claimed}` : ""}`);
655
+ if (r.supersedes.length) lines.push(` supersedes: ${r.supersedes.join(", ")}`);
656
+ return lines.join("\n");
657
+ }
658
+ async function runList(cwd, o) {
659
+ const root = await repoRoot(cwd);
660
+ const cfg = await loadConfig(root);
661
+ const ticket = o.ticket?.trim().toUpperCase() || void 0;
662
+ const { records, errors } = await listRecords(root, cfg, ticket);
663
+ if (o.json) return JSON.stringify(records, null, 2);
664
+ const out = errors.map((e) => `! ${e.path}: ${e.message}`);
665
+ if (records.length === 0) out.push(ticket ? `No decisions recorded for ${ticket}.` : "No decisions recorded.");
666
+ else out.push(...records.map(formatRecord));
667
+ return out.join("\n\n");
668
+ }
669
+
670
+ // src/commands/why.ts
671
+ import { realpath as realpath3 } from "fs/promises";
672
+ function formatWhy(r) {
673
+ const a = r.commit.authorship;
674
+ const who = a.ai ? `AI (${a.agent}${a.confidence === "medium" ? ", likely" : ""})` : "human";
675
+ const lines = [
676
+ `${r.file}${r.line !== null ? `:${r.line}` : ""}`,
677
+ ` commit ${r.commit.sha.slice(0, 7)} by ${r.commit.authorName} on ${r.commit.date.slice(0, 10)} \xB7 written by ${who}`,
678
+ ` message ${r.commit.message.split("\n")[0]}`,
679
+ ` ticket ${r.ticket ?? "none"}`
680
+ ];
681
+ if (r.decisions.length === 0) lines.push(" decisions: none recorded for this file");
682
+ for (const d of r.decisions) {
683
+ lines.push(
684
+ ` \u2022 ${d.id} [${d.type}] ${d.question} \u2192 ${d.chosen}`,
685
+ ` decided by ${d.decided_by}; ${d.approver_claimed ? `approver (claimed): ${d.approver_claimed}` : "unconfirmed approver"}`
686
+ );
687
+ if (d.reason) lines.push(` why: ${d.reason}`);
688
+ }
689
+ lines.push(` ${r.status}`);
690
+ return lines.join("\n");
691
+ }
692
+ async function runWhy(cwd, arg, o) {
693
+ const root = await repoRoot(cwd);
694
+ const result = await whyLocal(root, await loadConfig(root), parseTarget(arg), await realpath3(cwd));
695
+ return o.json ? JSON.stringify(result, null, 2) : formatWhy(result);
696
+ }
697
+
698
+ // src/errors.ts
699
+ function formatCliError(e) {
700
+ const msg = e instanceof Error ? e.message : String(e);
701
+ return `truthtrail: ${msg.split("\n")[0]}`;
702
+ }
703
+
704
+ // src/commands/check.ts
705
+ function formatCheck(r) {
706
+ const lines = [`TruthTrail check \xB7 ticket ${r.ticket ?? "none"} \xB7 ${r.aiCommits}/${r.totalCommits} commits AI-assisted`];
707
+ for (const i of r.invalid) lines.push(`\u2717 invalid record ${i.path}: ${i.message}`);
708
+ for (const d of r.decisions) lines.push(`\u2022 ${d.id} [${d.type}] ${d.question} \u2192 ${d.chosen}`);
709
+ if (r.assumptions.length) lines.push(`\u26A0 ${r.assumptions.length} assumption(s) need confirmation`);
710
+ for (const m of r.reminders) lines.push(`\u2192 ${m}`);
711
+ return lines.join("\n");
712
+ }
713
+ async function runCheck(cwd, o) {
714
+ const root = await repoRoot(cwd);
715
+ const report = await checkRepo(root, await loadConfig(root), o.base ?? "main");
716
+ return {
717
+ output: o.json ? JSON.stringify(report, null, 2) : formatCheck(report),
718
+ exitCode: report.invalid.length > 0 ? 1 : 0
719
+ };
720
+ }
721
+
722
+ // src/commands/pending.ts
723
+ async function runPendingList(cwd) {
724
+ const items = await listPending(await repoRoot(cwd));
725
+ if (items.length === 0) return "No pending questions.";
726
+ return items.map((p) => `#${p.index} ${p.question} \u2192 ${p.answer}`).join("\n");
727
+ }
728
+ async function runPendingDismiss(cwd, index) {
729
+ const hit = await removePending(await repoRoot(cwd), index);
730
+ if (!hit) throw new Error(`No pending question #${index}.`);
731
+ return `dismissed #${index}: ${hit.question}`;
732
+ }
733
+
734
+ // src/commands/hook.ts
735
+ async function runHook(event, stdin, cwd) {
736
+ try {
737
+ let payload = null;
738
+ try {
739
+ payload = stdin.trim() ? JSON.parse(stdin) : null;
740
+ } catch {
741
+ payload = null;
742
+ }
743
+ const dir = typeof payload?.cwd === "string" ? payload.cwd : cwd;
744
+ const root = await repoRoot(dir);
745
+ if (event === "session-start") {
746
+ return await sessionStartContext(root, await loadConfig(root), await currentBranch(root));
747
+ }
748
+ if (event === "capture") {
749
+ for (const a of extractAskAnswers(payload)) {
750
+ await appendPending(root, { ...a, at: (/* @__PURE__ */ new Date()).toISOString() });
751
+ }
752
+ return "";
753
+ }
754
+ const d = await stopDecision(root, payload);
755
+ return d ? JSON.stringify(d) : "";
756
+ } catch {
757
+ return "";
758
+ }
759
+ }
760
+ async function readStdin() {
761
+ if (process.stdin.isTTY) return "";
762
+ const chunks = [];
763
+ for await (const c of process.stdin) chunks.push(c);
764
+ return Buffer.concat(chunks).toString("utf8");
765
+ }
766
+
767
+ // src/index.ts
768
+ function buildProgram() {
769
+ const program = new Command();
770
+ program.name("truthtrail").description("Record the decisions your AI coding agent makes, right next to the code.").version("0.1.0");
771
+ program.command("init").description("Set up TruthTrail in this repo (.decisions/, AGENTS.md rules, config)").action(async () => {
772
+ await runInit(process.cwd(), (l) => console.log(l));
773
+ });
774
+ 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) => {
775
+ const r = await runDecide(process.cwd(), o, process.env, /* @__PURE__ */ new Date());
776
+ console.log(`recorded ${r.id}
777
+ ${r.path}`);
778
+ });
779
+ program.command("list").description("List recorded decisions").option("--ticket <key>", "only this ticket").option("--json", "machine-readable output").action(async (o) => {
780
+ console.log(await runList(process.cwd(), o));
781
+ });
782
+ 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) => {
783
+ console.log(await runWhy(process.cwd(), target, o));
784
+ });
785
+ 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) => {
786
+ const { output, exitCode } = await runCheck(process.cwd(), o);
787
+ console.log(output);
788
+ process.exitCode = exitCode;
789
+ });
790
+ const pending = program.command("pending").description("Questions captured by the Claude Code hook, not yet classified");
791
+ pending.command("list").description("List pending questions").action(async () => console.log(await runPendingList(process.cwd())));
792
+ 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)));
793
+ program.command("hook").description("Claude Code hook entry point (used by the TruthTrail plugin)").argument("<event>", "session-start | capture | stop").action(async (event) => {
794
+ const out = await runHook(event, await readStdin(), process.cwd());
795
+ if (out) console.log(out);
796
+ });
797
+ return program;
798
+ }
799
+ var isMain = process.argv[1] && /(?:index\.[jt]s|truthtrail)$/.test(process.argv[1]);
800
+ if (isMain) {
801
+ buildProgram().parseAsync(process.argv).catch((e) => {
802
+ console.error(formatCliError(e));
803
+ process.exit(1);
804
+ });
805
+ }
806
+ export {
807
+ buildProgram
808
+ };
package/package.json CHANGED
@@ -1,6 +1,13 @@
1
1
  {
2
2
  "name": "truthtrail",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Record the decisions your AI coding agent makes, right next to the code.",
5
+ "type": "module",
6
+ "bin": { "truthtrail": "dist/index.js" },
7
+ "files": ["dist", "README.md"],
8
+ "engines": { "node": ">=20" },
9
+ "scripts": { "build": "tsup" },
10
+ "dependencies": { "commander": "^12.1.0", "yaml": "^2.5.0" },
11
+ "devDependencies": { "@truthtrail/core": "*" },
12
+ "license": "MIT"
13
+ }