truthtrail 0.1.0 → 0.2.1

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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +28 -0
  3. package/dist/index.js +305 -68
  4. package/package.json +25 -7
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dhairya Modh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -41,6 +41,24 @@ npx truthtrail check --base main
41
41
 
42
42
  `--approver <name>` is only for when someone explicitly approved. TruthTrail never guesses approvers.
43
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
+
44
62
  ## Claude Code plugin
45
63
 
46
64
  The plugin (in `plugin/` of the TruthTrail repo) adds:
@@ -56,3 +74,13 @@ The plugin (in `plugin/` of the TruthTrail repo) adds:
56
74
  | `decisionsDir` | `.decisions` | where records live |
57
75
  | `jiraSite` | `null` | used by the TruthTrail server |
58
76
  | `failOnUnconfirmed` | `false` | used by the TruthTrail server's PR check |
77
+
78
+ ## Troubleshooting: help improve answer capture
79
+
80
+ The Claude Code hook that captures your answers to "A or B?" questions is built from documentation that doesn't describe the exact payload. If answered questions never show up in `npx truthtrail pending list`, run Claude Code with:
81
+
82
+ ```bash
83
+ TRUTHTRAIL_DEBUG_HOOKS=1 claude
84
+ ```
85
+
86
+ Answer one question, then open `.truthtrail/hook-payloads.jsonl` and share it in an issue (remove anything private first). It is git-ignored and stays on your machine.
package/dist/index.js CHANGED
@@ -4,8 +4,8 @@
4
4
  import { Command } from "commander";
5
5
 
6
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";
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
9
 
10
10
  // ../core/src/types.ts
11
11
  var DECISION_TYPES = ["decision", "assumption", "impact"];
@@ -48,7 +48,7 @@ function validateRecord(obj) {
48
48
  }
49
49
  var FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
50
50
  function parseRecord(text) {
51
- const m = FRONT_MATTER.exec(text);
51
+ const m = FRONT_MATTER.exec(text.replace(/^\uFEFF/, ""));
52
52
  if (!m) throw new RecordError(["missing --- front matter"]);
53
53
  let data;
54
54
  try {
@@ -226,7 +226,7 @@ async function walk(dir) {
226
226
  for (const e of entries) {
227
227
  const p = join2(dir, e.name);
228
228
  if (e.isDirectory()) out.push(...await walk(p));
229
- else if (e.name.endsWith(".md")) out.push(p);
229
+ else if (e.name.endsWith(".md") && e.name.toLowerCase() !== "readme.md") out.push(p);
230
230
  }
231
231
  return out;
232
232
  }
@@ -238,7 +238,7 @@ async function listRecords(root, cfg, ticket) {
238
238
  const rel = abs.slice(root.length + 1).split("\\").join("/");
239
239
  try {
240
240
  const r = parseRecord(await readFile2(abs, "utf8"));
241
- if (!ticket || r.ticket === ticket) records.push({ ...r, path: rel });
241
+ if (!ticket || r.ticket.toUpperCase() === ticket.toUpperCase()) records.push({ ...r, path: rel });
242
242
  } catch (e) {
243
243
  errors.push({ path: rel, message: e instanceof RecordError ? e.problems.join("; ") : e.message });
244
244
  }
@@ -293,6 +293,8 @@ This repo records product decisions in \`.decisions/\`. Follow these rules.
293
293
 
294
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
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
+
296
298
  **Before you implement,** run \`npx truthtrail list --ticket <KEY>\` and follow the decisions it shows. Treat \`rejected\` ones as must-rework.
297
299
 
298
300
  **Record a decision** with \`npx truthtrail decide\` when:
@@ -493,7 +495,10 @@ async function sessionStartContext(root, cfg, branch) {
493
495
  return "TruthTrail: no ticket key in this branch name. Before recording decisions, ask the user for the ticket key once.";
494
496
  }
495
497
  const { records } = await listRecords(root, cfg, ticket);
496
- const lines = [`TruthTrail: working on ticket ${ticket}. Follow the TruthTrail rules in AGENTS.md.`];
498
+ const lines = [
499
+ `TruthTrail: working on ticket ${ticket}. Follow the TruthTrail rules in AGENTS.md.`,
500
+ "If TruthTrail MCP tools are available, use record_decision to save decisions and list_pending / resolve_pending for answered questions; otherwise use the npx truthtrail commands."
501
+ ];
497
502
  if (records.length === 0) lines.push("No decisions recorded for this ticket yet.");
498
503
  else {
499
504
  lines.push(`Recorded decisions for ${ticket} (follow them):`);
@@ -510,10 +515,111 @@ async function stopDecision(root, payload) {
510
515
  decision: "block",
511
516
  reason: `TruthTrail: ${pending.length} answered questions are not classified yet:
512
517
  ${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>\`.`
518
+ 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>\`.`
514
519
  };
515
520
  }
516
521
 
522
+ // ../core/src/decide.ts
523
+ import { realpath } from "fs/promises";
524
+ var clean = (xs) => (xs ?? []).map((x) => x.trim()).filter(Boolean);
525
+ async function recordDecision(root, cwd, cfg, input, now) {
526
+ if (!input.question?.trim()) throw new Error("question is required");
527
+ if (!input.chosen?.trim()) throw new Error("chosen is required");
528
+ const type = input.type ?? "decision";
529
+ if (!DECISION_TYPES.includes(type)) throw new Error(`type must be one of ${DECISION_TYPES.join(", ")}`);
530
+ let ticket = input.ticket?.trim().toUpperCase() || null;
531
+ if (ticket && !new RegExp(`^(?:${cfg.ticketPattern})$`, "i").test(ticket)) {
532
+ throw new Error(`'${input.ticket}' is not a valid ticket key (expected pattern ${cfg.ticketPattern})`);
533
+ }
534
+ if (!ticket) {
535
+ const branch = await currentBranch(root);
536
+ ticket = detectTicket({ branch }, cfg.ticketPattern);
537
+ if (!ticket) throw new Error(`No ticket key found in branch '${branch ?? "(detached)"}'. Pass a ticket key (ticket, or --ticket <KEY> on the CLI).`);
538
+ }
539
+ const base = await realpath(cwd).catch(() => cwd);
540
+ const files = clean(input.files).map((f) => {
541
+ const rel = toRepoPath(root, base, f);
542
+ if (!rel) throw new Error(`files: ${f} is outside the repository`);
543
+ return rel;
544
+ });
545
+ return writeRecord(
546
+ root,
547
+ cfg,
548
+ {
549
+ ticket,
550
+ type,
551
+ question: input.question.trim(),
552
+ options: clean(input.options),
553
+ chosen: input.chosen.trim(),
554
+ reason: input.reason?.trim() ?? "",
555
+ decided_by: await userName(root),
556
+ approver_claimed: input.approver?.trim() || null,
557
+ agent: input.agent,
558
+ source: input.source,
559
+ files,
560
+ supersedes: clean(input.supersedes),
561
+ body: input.body?.trim() ?? ""
562
+ },
563
+ now
564
+ );
565
+ }
566
+ async function resolvePending(root, cwd, cfg, index, overrides, now) {
567
+ const entry = (await listPending(root)).find((p) => p.index === index);
568
+ if (!entry) throw new Error(`No pending question #${index}. List them with list_pending or \`npx truthtrail pending list\`.`);
569
+ const given = Object.fromEntries(Object.entries(overrides).filter(([, v]) => v !== void 0));
570
+ const record = await recordDecision(
571
+ root,
572
+ cwd,
573
+ cfg,
574
+ { question: entry.question, chosen: entry.answer, options: entry.options, ...given },
575
+ now
576
+ );
577
+ await removePending(root, index);
578
+ return record;
579
+ }
580
+
581
+ // ../core/src/mcpConfig.ts
582
+ import { execFile as execFile2 } from "child_process";
583
+ import { promisify as promisify2 } from "util";
584
+ import { applyEdits, findNodeAtLocation, modify, parseTree } from "jsonc-parser";
585
+ var exec2 = promisify2(execFile2);
586
+ var ARGS = ["-y", "truthtrail", "mcp"];
587
+ var MCP_TARGETS = [
588
+ { file: ".mcp.json", key: "mcpServers", entry: { command: "npx", args: ARGS } },
589
+ { file: ".cursor/mcp.json", key: "mcpServers", entry: { command: "npx", args: ARGS } },
590
+ { file: ".vscode/mcp.json", key: "servers", entry: { type: "stdio", command: "npx", args: ARGS } }
591
+ ];
592
+ var FORMAT = {};
593
+ function upsertMcpEntry(raw, target) {
594
+ const bom = raw?.startsWith("\uFEFF") ? "\uFEFF" : "";
595
+ const existing = raw === null ? null : raw.slice(bom.length);
596
+ if (existing === null || existing.trim() === "") {
597
+ return { status: "created", text: `${JSON.stringify({ [target.key]: { truthtrail: target.entry } }, null, 2)}
598
+ ` };
599
+ }
600
+ const errors = [];
601
+ const tree = parseTree(existing, errors, { allowTrailingComma: true, disallowComments: false });
602
+ if (errors.length || !tree) return { status: "skipped", reason: "not valid JSON" };
603
+ if (tree.type !== "object") return { status: "skipped", reason: "top level is not a JSON object" };
604
+ const parent = findNodeAtLocation(tree, [target.key]);
605
+ if (parent && parent.type !== "object") return { status: "skipped", reason: `"${target.key}" is not an object` };
606
+ if (findNodeAtLocation(tree, [target.key, "truthtrail"])) return { status: "unchanged", text: raw };
607
+ try {
608
+ const text = applyEdits(existing, modify(existing, [target.key, "truthtrail"], target.entry, FORMAT));
609
+ return { status: "added", text: bom + text };
610
+ } catch (e) {
611
+ return { status: "skipped", reason: e.message };
612
+ }
613
+ }
614
+ async function isGitIgnored(root, rel) {
615
+ try {
616
+ await exec2("git", ["check-ignore", "-q", "--", rel], { cwd: root });
617
+ return true;
618
+ } catch {
619
+ return false;
620
+ }
621
+ }
622
+
517
623
  // src/commands/init.ts
518
624
  async function readOrNull(path) {
519
625
  try {
@@ -524,7 +630,7 @@ async function readOrNull(path) {
524
630
  }
525
631
  }
526
632
  var exists = (p) => access(p).then(() => true, () => false);
527
- var real = (p) => realpath(p).catch(() => p);
633
+ var real = (p) => realpath2(p).catch(() => p);
528
634
  function upsertIn(file, existing, inner) {
529
635
  try {
530
636
  return upsertBlock(existing, inner);
@@ -532,7 +638,7 @@ function upsertIn(file, existing, inner) {
532
638
  throw new Error(`${file}: ${e.message}`);
533
639
  }
534
640
  }
535
- async function runInit(cwd, log) {
641
+ async function runInit(cwd, log, opts = {}) {
536
642
  const root = await repoRoot(cwd);
537
643
  const changes = [];
538
644
  const note = (file, what) => {
@@ -546,6 +652,9 @@ async function runInit(cwd, log) {
546
652
  const claude = await readOrNull(claudePath);
547
653
  const claudeIsAgents = claude !== null && await real(claudePath) === await real(agentsPath);
548
654
  const c = claude !== null && !claudeIsAgents ? upsertIn("CLAUDE.md", claude, CLAUDE_POINTER) : null;
655
+ const mcpPlan = opts.mcp === false ? [] : await Promise.all(
656
+ MCP_TARGETS.map(async (t) => ({ target: t, result: upsertMcpEntry(await readOrNull(join5(root, t.file)), t) }))
657
+ );
549
658
  const keep = join5(root, DEFAULT_CONFIG.decisionsDir, ".gitkeep");
550
659
  if (!await exists(keep)) {
551
660
  await mkdir3(join5(root, DEFAULT_CONFIG.decisionsDir), { recursive: true });
@@ -560,6 +669,23 @@ async function runInit(cwd, log) {
560
669
  await writeFile3(claudePath, c.text);
561
670
  note("CLAUDE.md", "updated");
562
671
  }
672
+ for (const { target, result } of mcpPlan) {
673
+ if (result.status === "skipped") {
674
+ log(`skipped ${target.file} (${result.reason})`);
675
+ continue;
676
+ }
677
+ if (result.status === "unchanged") continue;
678
+ const path = join5(root, target.file);
679
+ await mkdir3(dirname2(path), { recursive: true });
680
+ await writeFile3(path, result.text);
681
+ note(target.file, result.status === "created" ? "added" : "updated");
682
+ if (await isGitIgnored(root, target.file)) {
683
+ const dir = target.file.split("/")[0];
684
+ log(
685
+ `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})`
686
+ );
687
+ }
688
+ }
563
689
  const configPath = join5(root, CONFIG_FILE);
564
690
  if (!await exists(configPath)) {
565
691
  await writeFile3(configPath, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}
@@ -579,16 +705,7 @@ async function runInit(cwd, log) {
579
705
  }
580
706
 
581
707
  // 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
- }
708
+ var split = (s, sep) => s === void 0 ? void 0 : s.split(sep);
592
709
  function detectAgent(o, env) {
593
710
  if (o.agent) {
594
711
  if (!AGENT_NAMES.includes(o.agent)) throw new Error(`--agent must be one of ${AGENT_NAMES.join(", ")}`);
@@ -596,55 +713,26 @@ function detectAgent(o, env) {
596
713
  }
597
714
  return env.CLAUDECODE === "1" ? "claude-code" : "none";
598
715
  }
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(", ")}`);
716
+ async function runDecide(cwd, o, env, now) {
616
717
  const root = await repoRoot(cwd);
617
718
  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
719
  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
- );
720
+ const input = {
721
+ question: o.question,
722
+ chosen: o.chosen,
723
+ options: split(o.options, "|"),
724
+ reason: o.reason,
725
+ type: o.type,
726
+ ticket: o.ticket,
727
+ files: split(o.files, ","),
728
+ approver: o.approver,
729
+ supersedes: split(o.supersedes, ","),
730
+ body: o.body,
731
+ agent,
732
+ source: agent === "none" ? "manual" : "ai-session"
733
+ };
734
+ if (o.fromPending !== void 0) return resolvePending(root, cwd, cfg, Number(o.fromPending), input, now);
735
+ return recordDecision(root, cwd, cfg, input, now);
648
736
  }
649
737
 
650
738
  // src/commands/list.ts
@@ -732,6 +820,8 @@ async function runPendingDismiss(cwd, index) {
732
820
  }
733
821
 
734
822
  // src/commands/hook.ts
823
+ import { appendFile as appendFile2, mkdir as mkdir4 } from "fs/promises";
824
+ import { join as join6 } from "path";
735
825
  async function runHook(event, stdin, cwd) {
736
826
  try {
737
827
  let payload = null;
@@ -746,6 +836,11 @@ async function runHook(event, stdin, cwd) {
746
836
  return await sessionStartContext(root, await loadConfig(root), await currentBranch(root));
747
837
  }
748
838
  if (event === "capture") {
839
+ if (process.env.TRUTHTRAIL_DEBUG_HOOKS === "1") {
840
+ await mkdir4(join6(root, ".truthtrail"), { recursive: true });
841
+ await appendFile2(join6(root, ".truthtrail", "hook-payloads.jsonl"), `${JSON.stringify(payload)}
842
+ `);
843
+ }
749
844
  for (const a of extractAskAnswers(payload)) {
750
845
  await appendPending(root, { ...a, at: (/* @__PURE__ */ new Date()).toISOString() });
751
846
  }
@@ -764,12 +859,151 @@ async function readStdin() {
764
859
  return Buffer.concat(chunks).toString("utf8");
765
860
  }
766
861
 
862
+ // src/mcp/server.ts
863
+ import { fileURLToPath } from "url";
864
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
865
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
866
+ import { z } from "zod";
867
+
868
+ // src/mcp/agent.ts
869
+ function agentFromClient(name) {
870
+ const n = (name ?? "").toLowerCase();
871
+ if (n.includes("claude")) return "claude-code";
872
+ if (n.includes("cursor")) return "cursor";
873
+ if (n.includes("codex")) return "codex";
874
+ if (/visual studio code|vscode|copilot/.test(n)) return "copilot";
875
+ return "other";
876
+ }
877
+
878
+ // src/mcp/server.ts
879
+ var NO_REPO = "TruthTrail: not inside a git repository (open the project folder)";
880
+ var ok = (value) => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] });
881
+ var fail = (e) => ({
882
+ isError: true,
883
+ content: [{ type: "text", text: (e instanceof Error ? e.message : String(e)).split("\n")[0] }]
884
+ });
885
+ var decisionFields = {
886
+ question: z.string().describe("What needed deciding, e.g. 'Discount before or after tax?'"),
887
+ chosen: z.string().describe("What was picked"),
888
+ options: z.array(z.string()).optional().describe("The choices that were considered"),
889
+ reason: z.string().optional().describe("Why it was chosen"),
890
+ type: z.enum(DECISION_TYPES).optional().describe("decision (default), assumption (you assumed it without asking), or impact"),
891
+ ticket: z.string().optional().describe("Ticket key; defaults to the key in the branch name"),
892
+ files: z.array(z.string()).optional().describe("Files this decision affects"),
893
+ approver: z.string().optional().describe("Only if the user explicitly named who approved it. Never guess."),
894
+ supersedes: z.array(z.string()).optional().describe("Ids of older decisions this replaces"),
895
+ body: z.string().optional().describe("Short extra explanation")
896
+ };
897
+ function createServer(opts) {
898
+ const now = opts.now ?? (() => /* @__PURE__ */ new Date());
899
+ const server = new McpServer({ name: "truthtrail", version: "0.2.1" });
900
+ async function workspace() {
901
+ const candidates = [];
902
+ if (server.server.getClientCapabilities()?.roots) {
903
+ try {
904
+ const { roots } = await server.server.listRoots();
905
+ for (const r of roots) if (r.uri.startsWith("file://")) candidates.push(fileURLToPath(r.uri));
906
+ } catch {
907
+ }
908
+ }
909
+ candidates.push(opts.cwd);
910
+ for (const dir of candidates) {
911
+ try {
912
+ const root = await repoRoot(dir);
913
+ return { root, cwd: dir, cfg: await loadConfig(root) };
914
+ } catch {
915
+ }
916
+ }
917
+ throw new Error(NO_REPO);
918
+ }
919
+ const who = () => ({ agent: agentFromClient(server.server.getClientVersion()?.name), source: "ai-session" });
920
+ const run = (fn) => fn().then(ok, fail);
921
+ server.registerTool(
922
+ "record_decision",
923
+ {
924
+ 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.",
925
+ inputSchema: decisionFields
926
+ },
927
+ (args) => run(async () => {
928
+ const w = await workspace();
929
+ const r = await recordDecision(w.root, w.cwd, w.cfg, { ...args, ...who() }, now());
930
+ return { id: r.id, path: r.path, ticket: r.ticket };
931
+ })
932
+ );
933
+ server.registerTool(
934
+ "list_decisions",
935
+ {
936
+ description: "List recorded decisions (optionally for one ticket). Read these before implementing or testing a ticket and follow them.",
937
+ inputSchema: { ticket: z.string().optional() }
938
+ },
939
+ ({ ticket }) => run(async () => {
940
+ const w = await workspace();
941
+ return listRecords(w.root, w.cfg, ticket?.trim().toUpperCase() || void 0);
942
+ })
943
+ );
944
+ server.registerTool(
945
+ "why",
946
+ {
947
+ description: "Explain a file or line: last commit, AI or human authorship, linked decisions and the ticket.",
948
+ inputSchema: { target: z.string().describe("file or file:line, relative to the workspace") }
949
+ },
950
+ ({ target }) => run(async () => {
951
+ const w = await workspace();
952
+ return whyLocal(w.root, w.cfg, parseTarget(target), w.cwd);
953
+ })
954
+ );
955
+ server.registerTool(
956
+ "check_branch",
957
+ {
958
+ description: "Check this branch: ticket key, decisions and assumptions recorded, AI-assisted commits, and reminders.",
959
+ inputSchema: { base: z.string().optional().describe("branch to compare against (default main)") }
960
+ },
961
+ ({ base }) => run(async () => {
962
+ const w = await workspace();
963
+ return checkRepo(w.root, w.cfg, base ?? "main");
964
+ })
965
+ );
966
+ server.registerTool(
967
+ "list_pending",
968
+ { description: "Questions the user answered during this work that are not yet recorded or dismissed." },
969
+ () => run(async () => {
970
+ const w = await workspace();
971
+ return listPending(w.root);
972
+ })
973
+ );
974
+ server.registerTool(
975
+ "resolve_pending",
976
+ {
977
+ 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'.",
978
+ inputSchema: {
979
+ index: z.number().int(),
980
+ action: z.enum(["record", "dismiss"]),
981
+ ...Object.fromEntries(Object.entries(decisionFields).map(([k, v]) => [k, v.optional()]))
982
+ }
983
+ },
984
+ (args) => run(async () => {
985
+ const w = await workspace();
986
+ const { index, action, ...fields } = args;
987
+ if (action === "dismiss") {
988
+ const hit = await removePending(w.root, index);
989
+ if (!hit) throw new Error(`No pending question #${index}.`);
990
+ return { dismissed: hit };
991
+ }
992
+ return resolvePending(w.root, w.cwd, w.cfg, index, { ...fields, ...who() }, now());
993
+ })
994
+ );
995
+ return server;
996
+ }
997
+ async function runMcp() {
998
+ await createServer({ cwd: process.cwd() }).connect(new StdioServerTransport());
999
+ }
1000
+
767
1001
  // src/index.ts
768
1002
  function buildProgram() {
769
1003
  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));
1004
+ program.name("truthtrail").description("Record the decisions your AI coding agent makes, right next to the code.").version("0.2.1");
1005
+ 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) => {
1006
+ await runInit(process.cwd(), (l) => console.log(l), { mcp: o.mcp });
773
1007
  });
774
1008
  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
1009
  const r = await runDecide(process.cwd(), o, process.env, /* @__PURE__ */ new Date());
@@ -787,6 +1021,9 @@ function buildProgram() {
787
1021
  console.log(output);
788
1022
  process.exitCode = exitCode;
789
1023
  });
1024
+ program.command("mcp").description("Run the TruthTrail MCP server over stdio (used by Claude Code, Cursor, Copilot)").action(async () => {
1025
+ await runMcp();
1026
+ });
790
1027
  const pending = program.command("pending").description("Questions captured by the Claude Code hook, not yet classified");
791
1028
  pending.command("list").description("List pending questions").action(async () => console.log(await runPendingList(process.cwd())));
792
1029
  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)));
package/package.json CHANGED
@@ -1,13 +1,31 @@
1
1
  {
2
2
  "name": "truthtrail",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Record the decisions your AI coding agent makes, right next to the code.",
5
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": "*" },
6
+ "bin": {
7
+ "truthtrail": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "engines": {
15
+ "node": ">=20"
16
+ },
17
+ "scripts": {
18
+ "build": "tsup"
19
+ },
20
+ "dependencies": {
21
+ "@modelcontextprotocol/sdk": "^1.32.2",
22
+ "commander": "^12.1.0",
23
+ "jsonc-parser": "^3.3.1",
24
+ "yaml": "^2.5.0",
25
+ "zod": "^4.6.7"
26
+ },
27
+ "devDependencies": {
28
+ "@truthtrail/core": "*"
29
+ },
12
30
  "license": "MIT"
13
31
  }