backthread 0.5.0 → 0.6.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/.claude-plugin/plugin.json +1 -2
- package/commands/how.md +27 -0
- package/dist-bundle/backthread.js +199 -92
- package/hooks/hooks.json +11 -0
- package/package.json +1 -1
|
@@ -2,12 +2,11 @@
|
|
|
2
2
|
"name": "backthread",
|
|
3
3
|
"displayName": "Backthread",
|
|
4
4
|
"description": "Backthread helps you understand your codebase while AI ships features. It captures the why behind every Claude Code session so you can ask \"how does X work?\" without digging through PRs.",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.6.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Backthread"
|
|
8
8
|
},
|
|
9
9
|
"homepage": "https://backthread.dev",
|
|
10
|
-
"hooks": "./hooks/hooks.json",
|
|
11
10
|
"mcpServers": {
|
|
12
11
|
"backthread": {
|
|
13
12
|
"command": "node",
|
package/commands/how.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Ask how or why something in THIS repo works — get a short, grounded, cited answer synthesized from your Backthread "How it works" decision log (the captured "why" the code doesn't contain), instead of digging through PRs or guessing.
|
|
3
|
+
argument-hint: "<your question, e.g. how does auth work?>"
|
|
4
|
+
disable-model-invocation: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# /backthread:how — how does it work?
|
|
8
|
+
|
|
9
|
+
Answers your "how/why does X work?" question about this repository from your
|
|
10
|
+
Backthread decision log. The server retrieves the question-relevant captured
|
|
11
|
+
decisions and synthesizes ONE short, grounded answer — every claim cited, anything
|
|
12
|
+
inferred flagged, and a partial-coverage note when the log is thin. Read-only:
|
|
13
|
+
nothing leaves the machine but the question.
|
|
14
|
+
|
|
15
|
+
## Grounded answer
|
|
16
|
+
|
|
17
|
+
!`BT="${CLAUDE_PLUGIN_ROOT}/dist-bundle/backthread.js"; if [ -f "$BT" ]; then node "$BT" how --cwd "$(pwd)" $ARGUMENTS; else npx backthread how --cwd "$(pwd)" $ARGUMENTS; fi`
|
|
18
|
+
|
|
19
|
+
## Your task
|
|
20
|
+
|
|
21
|
+
Relay the grounded answer above to the user **verbatim** — it is already written
|
|
22
|
+
for them, with its inline [n] citations, its Sources list, and the diagram link.
|
|
23
|
+
Do not re-answer from your own knowledge, re-run the command, or call any other
|
|
24
|
+
tool. If the result says "not logged in", tell the user to run `backthread login`.
|
|
25
|
+
If it says no repo could be determined, tell them to run from the repo directory or
|
|
26
|
+
`backthread connect` it. If it leads with a partial-coverage caveat, keep that
|
|
27
|
+
caveat in what you surface — do not present a partial answer as complete.
|
|
@@ -7017,6 +7017,9 @@ function workerBaseUrl(env = process.env) {
|
|
|
7017
7017
|
function buildInferDecisionsUrl(env = process.env) {
|
|
7018
7018
|
return new URL("/infer-decisions", workerBaseUrl(env)).toString();
|
|
7019
7019
|
}
|
|
7020
|
+
function buildGroundedAskUrl(env = process.env) {
|
|
7021
|
+
return new URL("/grounded-ask", workerBaseUrl(env)).toString();
|
|
7022
|
+
}
|
|
7020
7023
|
var DEFAULT_FUNCTIONS_URL = "https://yempemohevgpctkpstuf.supabase.co/functions/v1";
|
|
7021
7024
|
function functionsBaseUrl(env = process.env) {
|
|
7022
7025
|
const override = env.BACKTHREAD_FUNCTIONS_URL;
|
|
@@ -7026,9 +7029,6 @@ function functionsBaseUrl(env = process.env) {
|
|
|
7026
7029
|
function buildIngestDecisionsUrl(env = process.env) {
|
|
7027
7030
|
return new URL(`${functionsBaseUrl(env).replace(/\/+$/, "")}/ingest-decisions`).toString();
|
|
7028
7031
|
}
|
|
7029
|
-
function buildReadDecisionsUrl(env = process.env) {
|
|
7030
|
-
return new URL(`${functionsBaseUrl(env).replace(/\/+$/, "")}/read-decisions`).toString();
|
|
7031
|
-
}
|
|
7032
7032
|
function buildOnboardingStateUrl(env = process.env) {
|
|
7033
7033
|
return new URL(`${functionsBaseUrl(env).replace(/\/+$/, "")}/onboarding-state`).toString();
|
|
7034
7034
|
}
|
|
@@ -8333,6 +8333,9 @@ var execFileP = promisify(execFile);
|
|
|
8333
8333
|
var MCP_COMMAND = "npx";
|
|
8334
8334
|
var MCP_ARGS = ["-y", "backthread", "mcp"];
|
|
8335
8335
|
function hookCommand(agent) {
|
|
8336
|
+
return `npx -y backthread@latest capture --from-hook --agent ${agent} --detach`;
|
|
8337
|
+
}
|
|
8338
|
+
function legacyHookCommand(agent) {
|
|
8336
8339
|
return `npx -y backthread capture --from-hook --agent ${agent} --detach`;
|
|
8337
8340
|
}
|
|
8338
8341
|
var MIN_VERSION = {
|
|
@@ -8382,16 +8385,36 @@ function withMcpServer(settings) {
|
|
|
8382
8385
|
mcpServers.backthread = desired;
|
|
8383
8386
|
return { next: { ...settings, mcpServers }, changed: true };
|
|
8384
8387
|
}
|
|
8385
|
-
function
|
|
8388
|
+
function groupRunsCommand(group, command) {
|
|
8389
|
+
const inner = group?.hooks;
|
|
8390
|
+
return Array.isArray(inner) && inner.some((h) => h?.command === command);
|
|
8391
|
+
}
|
|
8392
|
+
function rewriteLegacyInGroup(group, legacyCommands, command) {
|
|
8393
|
+
const inner = group?.hooks;
|
|
8394
|
+
if (!Array.isArray(inner)) return group;
|
|
8395
|
+
let changed = false;
|
|
8396
|
+
const nextInner = inner.map((h) => {
|
|
8397
|
+
const cmd = h?.command;
|
|
8398
|
+
if (typeof cmd === "string" && cmd !== command && legacyCommands.includes(cmd)) {
|
|
8399
|
+
changed = true;
|
|
8400
|
+
return { ...h, command };
|
|
8401
|
+
}
|
|
8402
|
+
return h;
|
|
8403
|
+
});
|
|
8404
|
+
return changed ? { ...group, hooks: nextInner } : group;
|
|
8405
|
+
}
|
|
8406
|
+
function withNestedHook(settings, event, command, extra = {}, legacyCommands = []) {
|
|
8386
8407
|
const hooks = asObject(settings.hooks);
|
|
8387
8408
|
const list = Array.isArray(hooks[event]) ? [...hooks[event]] : [];
|
|
8388
|
-
|
|
8389
|
-
|
|
8390
|
-
|
|
8409
|
+
if (list.some((g) => groupRunsCommand(g, command))) return { next: settings, changed: false };
|
|
8410
|
+
let migrated = false;
|
|
8411
|
+
const nextList = list.map((g) => {
|
|
8412
|
+
const rewritten = rewriteLegacyInGroup(g, legacyCommands, command);
|
|
8413
|
+
if (rewritten !== g) migrated = true;
|
|
8414
|
+
return rewritten;
|
|
8391
8415
|
});
|
|
8392
|
-
if (
|
|
8393
|
-
|
|
8394
|
-
hooks[event] = list;
|
|
8416
|
+
if (!migrated) nextList.push({ hooks: [{ type: "command", command, ...extra }] });
|
|
8417
|
+
hooks[event] = nextList;
|
|
8395
8418
|
return { next: { ...settings, hooks }, changed: true };
|
|
8396
8419
|
}
|
|
8397
8420
|
async function writeJson(deps, path, obj) {
|
|
@@ -8405,7 +8428,9 @@ async function installGemini(home, deps) {
|
|
|
8405
8428
|
const path = join8(home, ".gemini", "settings.json");
|
|
8406
8429
|
const current = await loadJsonObject(doRead, path);
|
|
8407
8430
|
const a = withMcpServer(current);
|
|
8408
|
-
const b = withNestedHook(a.next, "SessionEnd", hookCommand("gemini-cli"), { name: "backthread-capture" }
|
|
8431
|
+
const b = withNestedHook(a.next, "SessionEnd", hookCommand("gemini-cli"), { name: "backthread-capture" }, [
|
|
8432
|
+
legacyHookCommand("gemini-cli")
|
|
8433
|
+
]);
|
|
8409
8434
|
if (a.changed || b.changed) await writeJson(deps, path, b.next);
|
|
8410
8435
|
return [{ path, wrote: a.changed || b.changed }];
|
|
8411
8436
|
}
|
|
@@ -8435,7 +8460,7 @@ args = [${MCP_ARGS.map((a) => `"${a}"`).join(", ")}]
|
|
|
8435
8460
|
}
|
|
8436
8461
|
const hooksPath = join8(home, ".codex", "hooks.json");
|
|
8437
8462
|
const current = await loadJsonObject(doRead, hooksPath);
|
|
8438
|
-
const h = withNestedHook(current, "Stop", hookCommand("codex"), { timeout: 60 });
|
|
8463
|
+
const h = withNestedHook(current, "Stop", hookCommand("codex"), { timeout: 60 }, [legacyHookCommand("codex")]);
|
|
8439
8464
|
if (h.changed) await writeJson(deps, hooksPath, h.next);
|
|
8440
8465
|
writes.push({ path: hooksPath, wrote: h.changed });
|
|
8441
8466
|
return writes;
|
|
@@ -8451,7 +8476,8 @@ async function installCursor(home, deps) {
|
|
|
8451
8476
|
await writeCursorScript(
|
|
8452
8477
|
deps,
|
|
8453
8478
|
captureScriptPath,
|
|
8454
|
-
|
|
8479
|
+
// capture hook → self-updating (@latest), like the other agents' hooks (ARP-739).
|
|
8480
|
+
cursorWrapperScript(nodeBinDir, "capture --from-hook --agent cursor --detach", true)
|
|
8455
8481
|
)
|
|
8456
8482
|
);
|
|
8457
8483
|
writes.push(await writeCursorScript(deps, mcpScriptPath, cursorWrapperScript(nodeBinDir, "mcp")));
|
|
@@ -8470,7 +8496,8 @@ async function installCursor(home, deps) {
|
|
|
8470
8496
|
function shSingleQuote(s) {
|
|
8471
8497
|
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
8472
8498
|
}
|
|
8473
|
-
function cursorWrapperScript(nodeBinDir, backthreadArgs) {
|
|
8499
|
+
function cursorWrapperScript(nodeBinDir, backthreadArgs, latest = false) {
|
|
8500
|
+
const pkg = latest ? "backthread@latest" : "backthread";
|
|
8474
8501
|
return [
|
|
8475
8502
|
"#!/bin/sh",
|
|
8476
8503
|
"# Backthread wrapper for Cursor \u2014 generated by `backthread install --agent cursor` (ARP-692).",
|
|
@@ -8489,7 +8516,7 @@ function cursorWrapperScript(nodeBinDir, backthreadArgs) {
|
|
|
8489
8516
|
' PATH="$NODE_BIN_DIR:$PATH"',
|
|
8490
8517
|
" export PATH",
|
|
8491
8518
|
"fi",
|
|
8492
|
-
`exec npx -y
|
|
8519
|
+
`exec npx -y ${pkg} ${backthreadArgs}`
|
|
8493
8520
|
].join("\n") + "\n";
|
|
8494
8521
|
}
|
|
8495
8522
|
async function writeCursorScript(deps, path, content) {
|
|
@@ -8523,7 +8550,7 @@ function withCursorMcpServer(settings, mcpScriptPath) {
|
|
|
8523
8550
|
return { next: { ...settings, mcpServers }, changed: true };
|
|
8524
8551
|
}
|
|
8525
8552
|
function withCursorStopHook(settings, captureScriptPath) {
|
|
8526
|
-
const legacyInline =
|
|
8553
|
+
const legacyInline = legacyHookCommand("cursor");
|
|
8527
8554
|
const hooks = asObject(settings.hooks);
|
|
8528
8555
|
const stop = Array.isArray(hooks.stop) ? [...hooks.stop] : [];
|
|
8529
8556
|
const hadDesired = stop.some((h) => h?.command === captureScriptPath);
|
|
@@ -33669,6 +33696,8 @@ var StdioServerTransport = class {
|
|
|
33669
33696
|
};
|
|
33670
33697
|
|
|
33671
33698
|
// src/query.ts
|
|
33699
|
+
var DEFAULT_QUESTION = "How does this project work?";
|
|
33700
|
+
var GROUNDED_ASK_TIMEOUT_MS = 3e4;
|
|
33672
33701
|
function parseSlug2(slug) {
|
|
33673
33702
|
const parts = slug.trim().replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
|
|
33674
33703
|
if (parts.length !== 2) return null;
|
|
@@ -33714,9 +33743,12 @@ async function queryDecisions(input, deps = {}) {
|
|
|
33714
33743
|
};
|
|
33715
33744
|
}
|
|
33716
33745
|
const deepLink = buildRepoDeepLink(repo.owner, repo.name, env);
|
|
33746
|
+
const question = typeof input.question === "string" && input.question.trim().length > 0 ? input.question.trim() : DEFAULT_QUESTION;
|
|
33747
|
+
const ac = new AbortController();
|
|
33748
|
+
const timer = setTimeout(() => ac.abort(), GROUNDED_ASK_TIMEOUT_MS);
|
|
33717
33749
|
let res;
|
|
33718
33750
|
try {
|
|
33719
|
-
res = await doFetch(
|
|
33751
|
+
res = await doFetch(buildGroundedAskUrl(env), {
|
|
33720
33752
|
method: "POST",
|
|
33721
33753
|
headers: {
|
|
33722
33754
|
// Bearer device token — never logged.
|
|
@@ -33725,15 +33757,20 @@ async function queryDecisions(input, deps = {}) {
|
|
|
33725
33757
|
...versionHeaders()
|
|
33726
33758
|
// x-backthread-version — server-side compat guard
|
|
33727
33759
|
},
|
|
33728
|
-
|
|
33760
|
+
// The server accepts `repo` as an "owner/name" slug (it re-resolves + gates).
|
|
33761
|
+
body: JSON.stringify({ question, repo: `${repo.owner}/${repo.name}` }),
|
|
33762
|
+
signal: ac.signal
|
|
33729
33763
|
});
|
|
33730
33764
|
} catch (e) {
|
|
33765
|
+
const aborted2 = e.name === "AbortError";
|
|
33731
33766
|
return {
|
|
33732
33767
|
status: "read-failed",
|
|
33733
|
-
detail: `
|
|
33768
|
+
detail: aborted2 ? `grounded-ask timed out after ${GROUNDED_ASK_TIMEOUT_MS / 1e3}s \u2014 try again.` : `grounded-ask request failed: ${e.message}`,
|
|
33734
33769
|
repo,
|
|
33735
33770
|
deepLink
|
|
33736
33771
|
};
|
|
33772
|
+
} finally {
|
|
33773
|
+
clearTimeout(timer);
|
|
33737
33774
|
}
|
|
33738
33775
|
let payload;
|
|
33739
33776
|
try {
|
|
@@ -33741,60 +33778,53 @@ async function queryDecisions(input, deps = {}) {
|
|
|
33741
33778
|
} catch {
|
|
33742
33779
|
payload = null;
|
|
33743
33780
|
}
|
|
33781
|
+
const rec = payload && typeof payload === "object" ? payload : {};
|
|
33744
33782
|
if (!res.ok) {
|
|
33745
|
-
const
|
|
33746
|
-
const serverErr = typeof obj.message === "string" && obj.message.length > 0 ? obj.message : "error" in obj ? String(obj.error) : `HTTP ${res.status}`;
|
|
33783
|
+
const serverErr = typeof rec.message === "string" && rec.message.length > 0 ? rec.message : "error" in rec ? String(rec.error) : `HTTP ${res.status}`;
|
|
33747
33784
|
return {
|
|
33748
33785
|
status: "read-failed",
|
|
33749
|
-
detail: `
|
|
33786
|
+
detail: `grounded-ask rejected (${res.status}): ${serverErr}`,
|
|
33787
|
+
repo,
|
|
33788
|
+
deepLink
|
|
33789
|
+
};
|
|
33790
|
+
}
|
|
33791
|
+
const answer = typeof rec.answer === "string" ? rec.answer : "";
|
|
33792
|
+
if (!answer) {
|
|
33793
|
+
return {
|
|
33794
|
+
status: "read-failed",
|
|
33795
|
+
detail: "grounded-ask returned no answer.",
|
|
33750
33796
|
repo,
|
|
33751
33797
|
deepLink
|
|
33752
33798
|
};
|
|
33753
33799
|
}
|
|
33754
|
-
const rec = payload && typeof payload === "object" ? payload : {};
|
|
33755
|
-
const flows = normalizeFlows(rec.flows);
|
|
33756
|
-
const decisions = normalizeDecisions(rec.decisions);
|
|
33757
33800
|
const upgrade = typeof rec.upgrade === "string" && rec.upgrade.length > 0 ? rec.upgrade : void 0;
|
|
33758
|
-
const base = `${flows.length} flow(s), ${decisions.length} decision(s) for ${repo.owner}/${repo.name}.`;
|
|
33759
33801
|
return {
|
|
33760
33802
|
status: "ok",
|
|
33761
|
-
detail:
|
|
33803
|
+
detail: `grounded answer (${typeof rec.coverage === "string" ? rec.coverage : "partial"} coverage)`,
|
|
33762
33804
|
repo,
|
|
33763
|
-
|
|
33764
|
-
|
|
33765
|
-
|
|
33805
|
+
answer,
|
|
33806
|
+
coverage: typeof rec.coverage === "string" ? rec.coverage : void 0,
|
|
33807
|
+
citations: normalizeCitations(rec.citations),
|
|
33808
|
+
inferredSpans: Array.isArray(rec.inferredSpans) ? rec.inferredSpans.map(String) : [],
|
|
33809
|
+
// Prefer the server's deepLink; fall back to the locally-built one.
|
|
33810
|
+
deepLink: typeof rec.deepLink === "string" && rec.deepLink.length > 0 ? rec.deepLink : deepLink,
|
|
33766
33811
|
...upgrade ? { upgrade } : {}
|
|
33767
33812
|
};
|
|
33768
33813
|
} catch (e) {
|
|
33769
33814
|
return { status: "error", detail: `query failed (swallowed): ${e.message}` };
|
|
33770
33815
|
}
|
|
33771
33816
|
}
|
|
33772
|
-
function
|
|
33773
|
-
if (!Array.isArray(raw)) return [];
|
|
33774
|
-
return raw.map((f) => {
|
|
33775
|
-
const r = f && typeof f === "object" ? f : {};
|
|
33776
|
-
return {
|
|
33777
|
-
id: String(r.id ?? ""),
|
|
33778
|
-
name: String(r.name ?? ""),
|
|
33779
|
-
lifecycle: String(r.lifecycle ?? ""),
|
|
33780
|
-
salience: typeof r.salience === "number" ? r.salience : null,
|
|
33781
|
-
canonicalFlowId: typeof r.canonicalFlowId === "string" ? r.canonicalFlowId : null
|
|
33782
|
-
};
|
|
33783
|
-
});
|
|
33784
|
-
}
|
|
33785
|
-
function normalizeDecisions(raw) {
|
|
33817
|
+
function normalizeCitations(raw) {
|
|
33786
33818
|
if (!Array.isArray(raw)) return [];
|
|
33787
|
-
return raw.map((
|
|
33788
|
-
const r =
|
|
33819
|
+
return raw.map((c) => {
|
|
33820
|
+
const r = c && typeof c === "object" ? c : {};
|
|
33789
33821
|
return {
|
|
33790
|
-
|
|
33822
|
+
n: typeof r.n === "number" ? r.n : 0,
|
|
33823
|
+
decisionId: String(r.decisionId ?? ""),
|
|
33791
33824
|
title: String(r.title ?? ""),
|
|
33792
|
-
|
|
33793
|
-
|
|
33794
|
-
|
|
33795
|
-
decidedAt: typeof r.decidedAt === "string" ? r.decidedAt : null,
|
|
33796
|
-
flowIds: Array.isArray(r.flowIds) ? r.flowIds.map(String) : [],
|
|
33797
|
-
moduleIds: Array.isArray(r.moduleIds) ? r.moduleIds.map(String) : []
|
|
33825
|
+
url: String(r.url ?? ""),
|
|
33826
|
+
moduleIds: Array.isArray(r.moduleIds) ? r.moduleIds.map(String) : [],
|
|
33827
|
+
decidedAt: typeof r.decidedAt === "string" ? r.decidedAt : null
|
|
33798
33828
|
};
|
|
33799
33829
|
});
|
|
33800
33830
|
}
|
|
@@ -33830,7 +33860,7 @@ function isFailure(o) {
|
|
|
33830
33860
|
async function handleQueryTool(args = {}, deps = {}) {
|
|
33831
33861
|
const run = deps.queryDecisionsImpl ?? queryDecisions;
|
|
33832
33862
|
try {
|
|
33833
|
-
const outcome = await run({ repo: args.repo, cwd: args.cwd }, deps.queryDeps);
|
|
33863
|
+
const outcome = await run({ question: args.question, repo: args.repo, cwd: args.cwd }, deps.queryDeps);
|
|
33834
33864
|
let text = formatQueryOutcome(outcome, args.question);
|
|
33835
33865
|
const nudge = await (deps.upgradeNudgeImpl ?? maybeUpgradeNudge)(outcome.upgrade);
|
|
33836
33866
|
if (nudge) text += `
|
|
@@ -33841,42 +33871,11 @@ ${nudge}`;
|
|
|
33841
33871
|
return textResult(`query: error \u2014 ${e.message}`, true);
|
|
33842
33872
|
}
|
|
33843
33873
|
}
|
|
33844
|
-
function formatQueryOutcome(outcome,
|
|
33845
|
-
if (outcome.status !== "ok") {
|
|
33874
|
+
function formatQueryOutcome(outcome, _question) {
|
|
33875
|
+
if (outcome.status !== "ok" || !outcome.answer) {
|
|
33846
33876
|
return `query: ${outcome.status} \u2014 ${outcome.detail}`;
|
|
33847
33877
|
}
|
|
33848
|
-
|
|
33849
|
-
const q = question && question.trim().length > 0 ? ` for "${question.trim()}"` : "";
|
|
33850
|
-
const repoSlug = outcome.repo ? `${outcome.repo.owner}/${outcome.repo.name}` : "this repo";
|
|
33851
|
-
lines.push(`How ${repoSlug} works${q} \u2014 salience-ranked from the decision log:`);
|
|
33852
|
-
lines.push("");
|
|
33853
|
-
const flows = outcome.flows ?? [];
|
|
33854
|
-
if (flows.length > 0) {
|
|
33855
|
-
lines.push("Flows (most salient first):");
|
|
33856
|
-
for (const f of flows) {
|
|
33857
|
-
const sal = f.salience != null ? ` [salience ${f.salience}]` : "";
|
|
33858
|
-
lines.push(` - ${f.name} (${f.lifecycle})${sal}`);
|
|
33859
|
-
}
|
|
33860
|
-
} else {
|
|
33861
|
-
lines.push("Flows: none recorded yet.");
|
|
33862
|
-
}
|
|
33863
|
-
lines.push("");
|
|
33864
|
-
const decisions = outcome.decisions ?? [];
|
|
33865
|
-
if (decisions.length > 0) {
|
|
33866
|
-
lines.push('Decisions (the "why", most significant first):');
|
|
33867
|
-
for (const d of decisions) {
|
|
33868
|
-
const risk = d.domainRisk ? ` {${d.domainRisk}-risk}` : "";
|
|
33869
|
-
lines.push(` - ${d.title}${risk}`);
|
|
33870
|
-
if (d.why) lines.push(` why: ${d.why}`);
|
|
33871
|
-
}
|
|
33872
|
-
} else {
|
|
33873
|
-
lines.push("Decisions: none recorded yet.");
|
|
33874
|
-
}
|
|
33875
|
-
lines.push("");
|
|
33876
|
-
if (outcome.deepLink) {
|
|
33877
|
-
lines.push(`Open the "How it works" diagram: ${outcome.deepLink}`);
|
|
33878
|
-
}
|
|
33879
|
-
return lines.join("\n");
|
|
33878
|
+
return outcome.answer;
|
|
33880
33879
|
}
|
|
33881
33880
|
function buildMcpServer(deps = {}) {
|
|
33882
33881
|
const server = new McpServer({
|
|
@@ -33901,10 +33900,10 @@ function buildMcpServer(deps = {}) {
|
|
|
33901
33900
|
server.registerTool(
|
|
33902
33901
|
"query",
|
|
33903
33902
|
{
|
|
33904
|
-
title: "How does
|
|
33905
|
-
description:
|
|
33903
|
+
title: "How does it work? (grounded answer from this repo's decision log)",
|
|
33904
|
+
description: `Call this FIRST whenever the user asks how or why any part of THIS repository works, is built, or was decided \u2014 before reading source files or guessing. It returns a short, grounded, CITED answer synthesized from the repo's Backthread decision log (the captured "why" that the code itself does not contain): the real reasons, trade-offs, and superseded approaches behind the current design. Pass the user's question. The answer is ready to relay verbatim \u2014 it already cites its sources and flags anything inferred. Read-only; nothing leaves the machine but the question.`,
|
|
33906
33905
|
inputSchema: {
|
|
33907
|
-
question: external_exports.string().optional().describe(
|
|
33906
|
+
question: external_exports.string().optional().describe(`The user's "how/why does X work?" question, in their words. Load-bearing: the server retrieves and synthesizes the answer against it.`),
|
|
33908
33907
|
repo: external_exports.string().optional().describe('Optional repo override as "owner/name"; otherwise the configured repo or the cwd git remote.'),
|
|
33909
33908
|
cwd: external_exports.string().optional().describe("The session's working directory (repo fallback).")
|
|
33910
33909
|
}
|
|
@@ -33920,6 +33919,87 @@ async function startMcpServer(deps = {}) {
|
|
|
33920
33919
|
return server;
|
|
33921
33920
|
}
|
|
33922
33921
|
|
|
33922
|
+
// src/routingStats.ts
|
|
33923
|
+
import { join as join12 } from "node:path";
|
|
33924
|
+
import { readFile as readFile11, writeFile as writeFile9, mkdir as mkdir9, chmod as chmod8 } from "node:fs/promises";
|
|
33925
|
+
var STATS_FILE = "routing-stats.json";
|
|
33926
|
+
function statsPath(env) {
|
|
33927
|
+
return join12(configDir(env), STATS_FILE);
|
|
33928
|
+
}
|
|
33929
|
+
async function readRoutingStats(deps = {}) {
|
|
33930
|
+
const env = deps.env ?? process.env;
|
|
33931
|
+
const read = deps.readFileImpl ?? readFile11;
|
|
33932
|
+
try {
|
|
33933
|
+
const raw = await read(statsPath(env), "utf8");
|
|
33934
|
+
const obj = JSON.parse(raw);
|
|
33935
|
+
return {
|
|
33936
|
+
injected: typeof obj.injected === "number" && obj.injected >= 0 ? Math.floor(obj.injected) : 0,
|
|
33937
|
+
lastInjectedAt: typeof obj.lastInjectedAt === "string" ? obj.lastInjectedAt : void 0
|
|
33938
|
+
};
|
|
33939
|
+
} catch {
|
|
33940
|
+
return { injected: 0 };
|
|
33941
|
+
}
|
|
33942
|
+
}
|
|
33943
|
+
async function recordRoutingInjected(deps = {}) {
|
|
33944
|
+
const env = deps.env ?? process.env;
|
|
33945
|
+
const now = deps.now ?? (() => /* @__PURE__ */ new Date());
|
|
33946
|
+
const write = deps.writeFileImpl ?? writeFile9;
|
|
33947
|
+
const mkdirp = deps.mkdirImpl ?? mkdir9;
|
|
33948
|
+
const chmodp = deps.chmodImpl ?? chmod8;
|
|
33949
|
+
try {
|
|
33950
|
+
const prev = await readRoutingStats(deps);
|
|
33951
|
+
const next = { injected: prev.injected + 1, lastInjectedAt: now().toISOString() };
|
|
33952
|
+
await mkdirp(configDir(env), { recursive: true, mode: DIR_MODE });
|
|
33953
|
+
await write(statsPath(env), `${JSON.stringify(next, null, 2)}
|
|
33954
|
+
`, { mode: CONFIG_MODE });
|
|
33955
|
+
await chmodp(statsPath(env), CONFIG_MODE).catch(() => {
|
|
33956
|
+
});
|
|
33957
|
+
} catch {
|
|
33958
|
+
}
|
|
33959
|
+
}
|
|
33960
|
+
|
|
33961
|
+
// src/sessionStart.ts
|
|
33962
|
+
var ROUTING_CONTEXT = [
|
|
33963
|
+
'This repository is set up with Backthread, which has captured the "why" behind its',
|
|
33964
|
+
"changes into a decision log. The backthread `query` MCP tool (and the",
|
|
33965
|
+
'`/backthread:how` slash command) answer "how does X work?" and "why was Y decided?"',
|
|
33966
|
+
"with a short, CITED answer synthesized from that log \u2014 the real reasons, trade-offs,",
|
|
33967
|
+
"and superseded approaches the source code itself does not contain.",
|
|
33968
|
+
"",
|
|
33969
|
+
"When the user asks how or why any part of THIS codebase works, is structured, or was",
|
|
33970
|
+
"decided, call the backthread `query` tool FIRST \u2014 before grepping or reading source",
|
|
33971
|
+
"files. Pass the user's question and relay the cited answer it returns; only read",
|
|
33972
|
+
"source afterwards if it didn't cover what you need."
|
|
33973
|
+
].join("\n");
|
|
33974
|
+
function buildSessionStartOutput(isSetUp) {
|
|
33975
|
+
if (!isSetUp) return {};
|
|
33976
|
+
return {
|
|
33977
|
+
hookSpecificOutput: {
|
|
33978
|
+
hookEventName: "SessionStart",
|
|
33979
|
+
additionalContext: ROUTING_CONTEXT
|
|
33980
|
+
}
|
|
33981
|
+
};
|
|
33982
|
+
}
|
|
33983
|
+
async function runSessionStart(deps = {}) {
|
|
33984
|
+
const readConfig2 = deps.readConfig ?? readConfig;
|
|
33985
|
+
const record2 = deps.recordRoutingInjected ?? recordRoutingInjected;
|
|
33986
|
+
let isSetUp = false;
|
|
33987
|
+
try {
|
|
33988
|
+
const cfg = await readConfig2();
|
|
33989
|
+
isSetUp = !!cfg.device_token;
|
|
33990
|
+
} catch {
|
|
33991
|
+
isSetUp = false;
|
|
33992
|
+
}
|
|
33993
|
+
const output = buildSessionStartOutput(isSetUp);
|
|
33994
|
+
if (output.hookSpecificOutput) {
|
|
33995
|
+
try {
|
|
33996
|
+
await record2();
|
|
33997
|
+
} catch {
|
|
33998
|
+
}
|
|
33999
|
+
}
|
|
34000
|
+
return output;
|
|
34001
|
+
}
|
|
34002
|
+
|
|
33923
34003
|
// src/bin/backthread.ts
|
|
33924
34004
|
var USAGE = `backthread \u2014 capture the "why" of your AI-coded changes
|
|
33925
34005
|
|
|
@@ -33936,6 +34016,9 @@ Usage:
|
|
|
33936
34016
|
(no browser needed \u2014 codes expire in ~10 minutes)
|
|
33937
34017
|
backthread login --device Headless / SSH login (device-code flow \u2014 coming soon)
|
|
33938
34018
|
backthread whoami Show the current device's config (token is never printed)
|
|
34019
|
+
backthread how <question> Ask how/why something in this repo works \u2014 prints a
|
|
34020
|
+
grounded, cited answer from your Backthread decision log
|
|
34021
|
+
(backs the /backthread:how slash command). [--cwd <path>]
|
|
33939
34022
|
backthread capture Capture this session's decisions (run by the SessionEnd/Stop hook)
|
|
33940
34023
|
backthread capture --from-hook
|
|
33941
34024
|
Shared multi-agent hook entrypoint: read the hook payload off
|
|
@@ -33970,6 +34053,12 @@ function flagValue(rest, flag) {
|
|
|
33970
34053
|
if (!value || value.startsWith("--")) return void 0;
|
|
33971
34054
|
return value;
|
|
33972
34055
|
}
|
|
34056
|
+
function stripFlag(rest, flag) {
|
|
34057
|
+
const i = rest.indexOf(flag);
|
|
34058
|
+
if (i === -1) return rest;
|
|
34059
|
+
const dropValue = rest[i + 1] !== void 0 && !rest[i + 1].startsWith("--");
|
|
34060
|
+
return [...rest.slice(0, i), ...rest.slice(i + (dropValue ? 2 : 1))];
|
|
34061
|
+
}
|
|
33973
34062
|
async function runOnboarding(rest) {
|
|
33974
34063
|
const claim = parseClaimFlag(rest);
|
|
33975
34064
|
const result = await runStart({
|
|
@@ -34033,6 +34122,14 @@ async function main(argv, deps = {}) {
|
|
|
34033
34122
|
}
|
|
34034
34123
|
return 0;
|
|
34035
34124
|
}
|
|
34125
|
+
case "session-start": {
|
|
34126
|
+
const ssAgent = parseAgent(flagValue(rest, "--agent"));
|
|
34127
|
+
setRequestAgent(ssAgent === "unknown" ? "claude-code" : ssAgent);
|
|
34128
|
+
await readRawHookInput().catch(() => "");
|
|
34129
|
+
const output = await runSessionStart();
|
|
34130
|
+
console.log(JSON.stringify(output));
|
|
34131
|
+
return 0;
|
|
34132
|
+
}
|
|
34036
34133
|
case "mcp": {
|
|
34037
34134
|
await startMcpServer();
|
|
34038
34135
|
return null;
|
|
@@ -34056,6 +34153,15 @@ async function main(argv, deps = {}) {
|
|
|
34056
34153
|
});
|
|
34057
34154
|
return result.exitCode;
|
|
34058
34155
|
}
|
|
34156
|
+
case "how":
|
|
34157
|
+
case "ask": {
|
|
34158
|
+
const query = deps.queryDecisionsImpl ?? queryDecisions;
|
|
34159
|
+
const cwd = flagValue(rest, "--cwd") ?? process.cwd();
|
|
34160
|
+
const question = stripFlag(rest, "--cwd").join(" ").trim();
|
|
34161
|
+
const outcome = await query({ question, cwd });
|
|
34162
|
+
console.log(formatQueryOutcome(outcome, question));
|
|
34163
|
+
return outcome.status === "ok" ? 0 : 1;
|
|
34164
|
+
}
|
|
34059
34165
|
case void 0:
|
|
34060
34166
|
return onboarding(rest);
|
|
34061
34167
|
case "help":
|
|
@@ -34099,5 +34205,6 @@ if (isEntryPoint()) {
|
|
|
34099
34205
|
}
|
|
34100
34206
|
export {
|
|
34101
34207
|
main,
|
|
34102
|
-
runOnboarding
|
|
34208
|
+
runOnboarding,
|
|
34209
|
+
stripFlag
|
|
34103
34210
|
};
|
package/hooks/hooks.json
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "The SessionEnd capture hook, declared in the PLUGIN MANIFEST (referenced from .claude-plugin/plugin.json). When Backthread is installed as a Claude Code plugin, this registers the hook automatically AT USER/GLOBAL SCOPE — no mutation of the user's project .claude/settings.json, and it fires across EVERY repo AND git worktree (ARP-680: a per-project, gitignored .claude/settings.json hook is exactly what froze the dogfood log — worktree sessions never carried it; an installed plugin's hooks follow the user, not the cwd). The command invokes the plugin's BUNDLED, self-contained bin via ${CLAUDE_PLUGIN_ROOT} (ARP-474's dist-bundle/backthread.js — committed to git so the marketplace-cloned plugin has it; CC runs no build step on install), NOT `npx backthread`, which would resolve a possibly-stale or absent global / npm-linked install (the second half of the ARP-680 dogfood freeze). The detached worker re-spawns via process.execPath + process.argv[1], so pointing argv[1] at the bundle propagates the same self-contained bin through the WHOLE chain (hook → detached worker). It routes through the shared `--from-hook` entrypoint with `--detach`: it reads the SessionEnd payload off stdin, then re-spawns a DETACHED worker that does the slow LOCALLY-redacted redact→infer→persist round-trip and returns immediately — so a ≥30s inference can't be SIGTERM'd by CC's SessionEnd hook timeout (or reaped on session exit). Best-effort, never blocks/delays the session, always exits 0 (ARP-682). `--agent claude-code` selects the CC payload shape. Mirrored by the USER-SCOPE ~/.claude/settings.json fallback that `backthread install` writes for the bare-npx (non-plugin) path (ARP-503). We register ONLY SessionEnd (once per session) on purpose — `runCapture` also handles a Stop payload, but Stop fires on every turn-end, which would capture far too aggressively, so Stop is intentionally NOT registered here.",
|
|
3
|
+
"$comment_sessionstart": "The SessionStart AMBIENT-ROUTING hook (ARP-763). Injects a one-time instruction into the session context telling Claude Code to call the `query` MCP tool FIRST on how/why-does-X questions, before grepping — so a plain 'how does X work?' routes to a grounded, cited answer with no new user habit. SYNCHRONOUS, NOT --detach: CC reads this command's STDOUT for hookSpecificOutput.additionalContext, so the bundle must print it (a detached re-spawn would print an ack instead). It does only a FAST LOCAL config read (no network), gates on a present device token (only routes when `query` can actually answer), and always exits 0 with valid JSON. PLUGIN-ONLY: runs the shipped self-contained bundle (fast, offline); the bare-npx settings.json fallback deliberately does NOT register it, because a synchronous `npx backthread session-start` would block every session start on npm's resolve (the capture hook only gets away with @latest because it's --detach'ed). npx-fallback users keep the `query` tool's imperative description.",
|
|
3
4
|
"hooks": {
|
|
5
|
+
"SessionStart": [
|
|
6
|
+
{
|
|
7
|
+
"hooks": [
|
|
8
|
+
{
|
|
9
|
+
"type": "command",
|
|
10
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/dist-bundle/backthread.js\" session-start --agent claude-code"
|
|
11
|
+
}
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
],
|
|
4
15
|
"SessionEnd": [
|
|
5
16
|
{
|
|
6
17
|
"hooks": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backthread",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Backthread helps you understand your codebase while AI ships features. The CLI captures the why behind every AI session and lets you ask how your codebase works, right from the terminal.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Backthread",
|