backthread 0.5.1 → 0.7.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 +292 -211
- 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.7.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.
|
|
@@ -6892,109 +6892,6 @@ import { realpathSync } from "node:fs";
|
|
|
6892
6892
|
// src/login.ts
|
|
6893
6893
|
import { hostname } from "node:os";
|
|
6894
6894
|
|
|
6895
|
-
// src/loopback.ts
|
|
6896
|
-
import { createServer } from "node:http";
|
|
6897
|
-
import { randomBytes } from "node:crypto";
|
|
6898
|
-
function generateState() {
|
|
6899
|
-
return base64url(randomBytes(32));
|
|
6900
|
-
}
|
|
6901
|
-
function base64url(bytes) {
|
|
6902
|
-
return bytes.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
6903
|
-
}
|
|
6904
|
-
function validateCallback(method, rawUrl, expectedState) {
|
|
6905
|
-
if ((method ?? "").toUpperCase() !== "GET") return { ok: false, reason: "bad_method" };
|
|
6906
|
-
let url2;
|
|
6907
|
-
try {
|
|
6908
|
-
url2 = new URL(rawUrl ?? "", "http://127.0.0.1");
|
|
6909
|
-
} catch {
|
|
6910
|
-
return { ok: false, reason: "wrong_path" };
|
|
6911
|
-
}
|
|
6912
|
-
if (url2.pathname !== "/callback") return { ok: false, reason: "wrong_path" };
|
|
6913
|
-
const errorParam = url2.searchParams.get("error");
|
|
6914
|
-
if (errorParam) return { ok: false, reason: "error_param", error: errorParam };
|
|
6915
|
-
const state = url2.searchParams.get("state");
|
|
6916
|
-
if (state === null || state !== expectedState) return { ok: false, reason: "state_mismatch" };
|
|
6917
|
-
const token = url2.searchParams.get("token");
|
|
6918
|
-
if (!token || !/^backthread_pat_[A-Za-z0-9_-]+$/.test(token)) {
|
|
6919
|
-
return { ok: false, reason: "missing_token" };
|
|
6920
|
-
}
|
|
6921
|
-
return { ok: true, token };
|
|
6922
|
-
}
|
|
6923
|
-
function resultPage(ok) {
|
|
6924
|
-
const title = ok ? "You\u2019re connected" : "Something went wrong";
|
|
6925
|
-
const body = ok ? "Backthread is now authorized on this device. You can close this tab and return to your terminal." : "Backthread couldn\u2019t finish authorizing this device. Close this tab and run <code>backthread login</code> again.";
|
|
6926
|
-
return `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title>
|
|
6927
|
-
<style>body{font:16px/1.5 system-ui,sans-serif;max-width:32rem;margin:18vh auto;padding:0 1.5rem;color:#18181b}
|
|
6928
|
-
h1{font-size:1.25rem}code{background:#f4f4f5;padding:.1em .3em;border-radius:4px}</style></head>
|
|
6929
|
-
<body><h1>${title}</h1><p>${body}</p></body></html>`;
|
|
6930
|
-
}
|
|
6931
|
-
function startLoopbackServer() {
|
|
6932
|
-
return new Promise((resolveStart, rejectStart) => {
|
|
6933
|
-
const state = generateState();
|
|
6934
|
-
let outcome = null;
|
|
6935
|
-
let onOutcome = null;
|
|
6936
|
-
const deliver = (o) => {
|
|
6937
|
-
if (outcome) return;
|
|
6938
|
-
outcome = o;
|
|
6939
|
-
onOutcome?.();
|
|
6940
|
-
};
|
|
6941
|
-
const server = createServer((req, res) => {
|
|
6942
|
-
const result = validateCallback(req.method, req.url, state);
|
|
6943
|
-
if (!result.ok && result.reason === "wrong_path") {
|
|
6944
|
-
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
6945
|
-
res.end("not found");
|
|
6946
|
-
return;
|
|
6947
|
-
}
|
|
6948
|
-
res.writeHead(result.ok ? 200 : 400, { "Content-Type": "text/html; charset=utf-8" });
|
|
6949
|
-
res.end(resultPage(result.ok));
|
|
6950
|
-
if (result.ok && result.token) {
|
|
6951
|
-
deliver({ token: result.token });
|
|
6952
|
-
} else {
|
|
6953
|
-
deliver({
|
|
6954
|
-
error: new Error(
|
|
6955
|
-
result.reason === "error_param" ? `web app reported: ${result.error}` : `invalid callback (${result.reason})`
|
|
6956
|
-
)
|
|
6957
|
-
});
|
|
6958
|
-
}
|
|
6959
|
-
});
|
|
6960
|
-
server.on("error", (err) => {
|
|
6961
|
-
if (outcome === null && onOutcome === null) rejectStart(err);
|
|
6962
|
-
else deliver({ error: err });
|
|
6963
|
-
});
|
|
6964
|
-
server.listen(0, "127.0.0.1", () => {
|
|
6965
|
-
const addr = server.address();
|
|
6966
|
-
const port = addr.port;
|
|
6967
|
-
const close = () => {
|
|
6968
|
-
try {
|
|
6969
|
-
server.close();
|
|
6970
|
-
} catch {
|
|
6971
|
-
}
|
|
6972
|
-
};
|
|
6973
|
-
const waitForToken = (timeoutMs = 5 * 6e4) => new Promise((resolve, reject) => {
|
|
6974
|
-
const finish = () => {
|
|
6975
|
-
if (!outcome) return;
|
|
6976
|
-
close();
|
|
6977
|
-
if ("token" in outcome) resolve(outcome.token);
|
|
6978
|
-
else reject(outcome.error);
|
|
6979
|
-
};
|
|
6980
|
-
const timer = setTimeout(() => {
|
|
6981
|
-
close();
|
|
6982
|
-
reject(new Error("timed out waiting for the browser to authorize this device"));
|
|
6983
|
-
}, timeoutMs);
|
|
6984
|
-
onOutcome = () => {
|
|
6985
|
-
clearTimeout(timer);
|
|
6986
|
-
finish();
|
|
6987
|
-
};
|
|
6988
|
-
if (outcome) {
|
|
6989
|
-
clearTimeout(timer);
|
|
6990
|
-
finish();
|
|
6991
|
-
}
|
|
6992
|
-
});
|
|
6993
|
-
resolveStart({ port, state, waitForToken, close });
|
|
6994
|
-
});
|
|
6995
|
-
});
|
|
6996
|
-
}
|
|
6997
|
-
|
|
6998
6895
|
// src/urls.ts
|
|
6999
6896
|
var DEFAULT_APP_URL = "https://app.backthread.dev";
|
|
7000
6897
|
function appBaseUrl(env = process.env) {
|
|
@@ -7002,10 +6899,11 @@ function appBaseUrl(env = process.env) {
|
|
|
7002
6899
|
if (override && override.trim().length > 0) return override.replace(/\/+$/, "");
|
|
7003
6900
|
return DEFAULT_APP_URL;
|
|
7004
6901
|
}
|
|
7005
|
-
function buildCliAuthUrl(
|
|
6902
|
+
function buildCliAuthUrl(session, clientPubKey, env = process.env, label) {
|
|
7006
6903
|
const u = new URL("/cli-auth", appBaseUrl(env));
|
|
7007
|
-
u.searchParams.set("
|
|
7008
|
-
u.searchParams.set("
|
|
6904
|
+
u.searchParams.set("session", session);
|
|
6905
|
+
u.searchParams.set("k", clientPubKey);
|
|
6906
|
+
if (label && label.trim().length > 0) u.searchParams.set("label", label.trim());
|
|
7009
6907
|
return u.toString();
|
|
7010
6908
|
}
|
|
7011
6909
|
var DEFAULT_WORKER_URL = "https://clew-ingest-worker.arpy-183.workers.dev";
|
|
@@ -7017,6 +6915,9 @@ function workerBaseUrl(env = process.env) {
|
|
|
7017
6915
|
function buildInferDecisionsUrl(env = process.env) {
|
|
7018
6916
|
return new URL("/infer-decisions", workerBaseUrl(env)).toString();
|
|
7019
6917
|
}
|
|
6918
|
+
function buildGroundedAskUrl(env = process.env) {
|
|
6919
|
+
return new URL("/grounded-ask", workerBaseUrl(env)).toString();
|
|
6920
|
+
}
|
|
7020
6921
|
var DEFAULT_FUNCTIONS_URL = "https://yempemohevgpctkpstuf.supabase.co/functions/v1";
|
|
7021
6922
|
function functionsBaseUrl(env = process.env) {
|
|
7022
6923
|
const override = env.BACKTHREAD_FUNCTIONS_URL;
|
|
@@ -7026,12 +6927,12 @@ function functionsBaseUrl(env = process.env) {
|
|
|
7026
6927
|
function buildIngestDecisionsUrl(env = process.env) {
|
|
7027
6928
|
return new URL(`${functionsBaseUrl(env).replace(/\/+$/, "")}/ingest-decisions`).toString();
|
|
7028
6929
|
}
|
|
7029
|
-
function buildReadDecisionsUrl(env = process.env) {
|
|
7030
|
-
return new URL(`${functionsBaseUrl(env).replace(/\/+$/, "")}/read-decisions`).toString();
|
|
7031
|
-
}
|
|
7032
6930
|
function buildOnboardingStateUrl(env = process.env) {
|
|
7033
6931
|
return new URL(`${functionsBaseUrl(env).replace(/\/+$/, "")}/onboarding-state`).toString();
|
|
7034
6932
|
}
|
|
6933
|
+
function buildCliAuthPollUrl(env = process.env) {
|
|
6934
|
+
return new URL(`${functionsBaseUrl(env).replace(/\/+$/, "")}/cli-auth-poll`).toString();
|
|
6935
|
+
}
|
|
7035
6936
|
function buildRepoDeepLink(owner, name, env = process.env) {
|
|
7036
6937
|
const base = appBaseUrl(env);
|
|
7037
6938
|
return `${base}/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`;
|
|
@@ -7278,22 +7179,117 @@ function configLocationHint(env) {
|
|
|
7278
7179
|
return env.BACKTHREAD_CONFIG_DIR ? `${env.BACKTHREAD_CONFIG_DIR}/config.json` : "~/.backthread/config.json";
|
|
7279
7180
|
}
|
|
7280
7181
|
|
|
7182
|
+
// src/cliAuthCrypto.ts
|
|
7183
|
+
import { createECDH, hkdfSync, createDecipheriv, randomBytes } from "node:crypto";
|
|
7184
|
+
var HKDF_SALT = Buffer.from("backthread-cli-auth");
|
|
7185
|
+
var HKDF_INFO = Buffer.from("device-token-v1");
|
|
7186
|
+
function generateSessionId() {
|
|
7187
|
+
return toB64url(randomBytes(32));
|
|
7188
|
+
}
|
|
7189
|
+
function generateEphemeralKeypair() {
|
|
7190
|
+
const ecdh = createECDH("prime256v1");
|
|
7191
|
+
ecdh.generateKeys();
|
|
7192
|
+
return { ecdh, publicKeyB64url: toB64url(ecdh.getPublicKey()) };
|
|
7193
|
+
}
|
|
7194
|
+
function decryptToken(enc, ecdh) {
|
|
7195
|
+
const pagePub = fromB64url(enc.page_ephemeral_pubkey);
|
|
7196
|
+
const shared = ecdh.computeSecret(pagePub);
|
|
7197
|
+
const aesKey = Buffer.from(hkdfSync("sha256", shared, HKDF_SALT, HKDF_INFO, 32));
|
|
7198
|
+
const ctFull = fromB64url(enc.ciphertext);
|
|
7199
|
+
const tag = ctFull.subarray(ctFull.length - 16);
|
|
7200
|
+
const ct = ctFull.subarray(0, ctFull.length - 16);
|
|
7201
|
+
const iv = fromB64url(enc.iv);
|
|
7202
|
+
const decipher = createDecipheriv("aes-256-gcm", aesKey, iv);
|
|
7203
|
+
decipher.setAuthTag(tag);
|
|
7204
|
+
return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
|
|
7205
|
+
}
|
|
7206
|
+
function toB64url(buf) {
|
|
7207
|
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
7208
|
+
}
|
|
7209
|
+
function fromB64url(s) {
|
|
7210
|
+
return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
|
7211
|
+
}
|
|
7212
|
+
|
|
7213
|
+
// src/cliAuthPoll.ts
|
|
7214
|
+
var TOKEN_RE = /^backthread_pat_[A-Za-z0-9_-]+$/;
|
|
7215
|
+
async function pollForToken(sessionId, keypair, opts = {}) {
|
|
7216
|
+
const env = opts.env ?? process.env;
|
|
7217
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
7218
|
+
const interval = opts.intervalMs ?? 1500;
|
|
7219
|
+
const timeout = opts.timeoutMs ?? 5 * 6e4;
|
|
7220
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
7221
|
+
const now = opts.now ?? (() => Date.now());
|
|
7222
|
+
const url2 = buildCliAuthPollUrl(env);
|
|
7223
|
+
const deadline = now() + timeout;
|
|
7224
|
+
while (now() < deadline) {
|
|
7225
|
+
let res;
|
|
7226
|
+
try {
|
|
7227
|
+
res = await doFetch(url2, {
|
|
7228
|
+
method: "POST",
|
|
7229
|
+
headers: { "Content-Type": "application/json", ...versionHeaders() },
|
|
7230
|
+
// The CLI is the CONSUMING poller (default mode) — the browser peeks separately.
|
|
7231
|
+
body: JSON.stringify({ session_id: sessionId })
|
|
7232
|
+
});
|
|
7233
|
+
} catch {
|
|
7234
|
+
await sleep(interval);
|
|
7235
|
+
continue;
|
|
7236
|
+
}
|
|
7237
|
+
if (res.status === 429 || res.status >= 500) {
|
|
7238
|
+
await sleep(interval);
|
|
7239
|
+
continue;
|
|
7240
|
+
}
|
|
7241
|
+
const body = await res.json().catch(() => null);
|
|
7242
|
+
const status = typeof body?.status === "string" ? body.status : null;
|
|
7243
|
+
if (status === "ready") {
|
|
7244
|
+
const enc = extractPayload(body);
|
|
7245
|
+
if (!enc) return { ok: false, reason: "error", message: "incomplete token payload from the server" };
|
|
7246
|
+
let token;
|
|
7247
|
+
try {
|
|
7248
|
+
token = decryptToken(enc, keypair.ecdh);
|
|
7249
|
+
} catch {
|
|
7250
|
+
return { ok: false, reason: "error", message: "could not decrypt the token (key mismatch)" };
|
|
7251
|
+
}
|
|
7252
|
+
if (!TOKEN_RE.test(token)) {
|
|
7253
|
+
return { ok: false, reason: "error", message: "the decrypted token was malformed" };
|
|
7254
|
+
}
|
|
7255
|
+
return { ok: true, token };
|
|
7256
|
+
}
|
|
7257
|
+
if (status === "expired") {
|
|
7258
|
+
return { ok: false, reason: "expired", message: "the login session expired before you authorized" };
|
|
7259
|
+
}
|
|
7260
|
+
if (status === "consumed") {
|
|
7261
|
+
return { ok: false, reason: "error", message: "this login was already used \u2014 start a fresh `bt login`" };
|
|
7262
|
+
}
|
|
7263
|
+
await sleep(interval);
|
|
7264
|
+
}
|
|
7265
|
+
return { ok: false, reason: "timeout", message: "timed out waiting for the browser to authorize this device" };
|
|
7266
|
+
}
|
|
7267
|
+
function extractPayload(body) {
|
|
7268
|
+
if (!body) return null;
|
|
7269
|
+
const { page_ephemeral_pubkey, iv, ciphertext } = body;
|
|
7270
|
+
if (typeof page_ephemeral_pubkey === "string" && typeof iv === "string" && typeof ciphertext === "string") {
|
|
7271
|
+
return { page_ephemeral_pubkey, iv, ciphertext };
|
|
7272
|
+
}
|
|
7273
|
+
return null;
|
|
7274
|
+
}
|
|
7275
|
+
|
|
7281
7276
|
// src/login.ts
|
|
7282
7277
|
async function login(opts = {}) {
|
|
7283
7278
|
const env = opts.env ?? process.env;
|
|
7284
7279
|
const log = opts.log ?? ((m) => console.error(m));
|
|
7285
7280
|
if (opts.claim) {
|
|
7286
|
-
const
|
|
7287
|
-
log(
|
|
7288
|
-
return { ok:
|
|
7281
|
+
const result2 = await exchangeClaim(opts.claim, { env, label: deviceLabel() });
|
|
7282
|
+
log(result2.message);
|
|
7283
|
+
return { ok: result2.ok, message: result2.message };
|
|
7289
7284
|
}
|
|
7290
7285
|
if (opts.device) {
|
|
7291
7286
|
return deviceLogin(log);
|
|
7292
7287
|
}
|
|
7293
|
-
const
|
|
7294
|
-
const
|
|
7288
|
+
const sessionId = generateSessionId();
|
|
7289
|
+
const keypair = generateEphemeralKeypair();
|
|
7290
|
+
const authUrl = buildCliAuthUrl(sessionId, keypair.publicKeyB64url, env, deviceLabel());
|
|
7295
7291
|
log("Opening your browser to authorize this device\u2026");
|
|
7296
|
-
log(`If it doesn't open
|
|
7292
|
+
log(`If it doesn't open \u2014 or you're on a remote/SSH box \u2014 open this on any device:
|
|
7297
7293
|
|
|
7298
7294
|
${authUrl}
|
|
7299
7295
|
`);
|
|
@@ -7303,17 +7299,21 @@ async function login(opts = {}) {
|
|
|
7303
7299
|
log("(Could not open a browser automatically \u2014 use the URL above.)");
|
|
7304
7300
|
}
|
|
7305
7301
|
}
|
|
7306
|
-
|
|
7307
|
-
|
|
7308
|
-
|
|
7309
|
-
|
|
7310
|
-
handle.close();
|
|
7311
|
-
return { ok: false, message: `Login failed: ${err.message}` };
|
|
7302
|
+
const poll = opts.pollImpl ?? pollForToken;
|
|
7303
|
+
const result = await poll(sessionId, keypair, { env });
|
|
7304
|
+
if (!result.ok) {
|
|
7305
|
+
return { ok: false, message: pollFailureMessage(result) };
|
|
7312
7306
|
}
|
|
7313
|
-
await updateConfig({ device_token: token }, env);
|
|
7307
|
+
await updateConfig({ device_token: result.token }, env);
|
|
7314
7308
|
log(`Authorized. Token stored in ${configLocationHint2(env)} (chmod 0600).`);
|
|
7315
7309
|
return { ok: true, message: "Device authorized and token stored." };
|
|
7316
7310
|
}
|
|
7311
|
+
function pollFailureMessage(result) {
|
|
7312
|
+
if (result.reason === "expired" || result.reason === "timeout") {
|
|
7313
|
+
return `Login ${result.reason === "expired" ? "expired" : "timed out"}: ${result.message}. Re-run \`backthread login\` to try again.`;
|
|
7314
|
+
}
|
|
7315
|
+
return `Login failed: ${result.message}`;
|
|
7316
|
+
}
|
|
7317
7317
|
function deviceLabel() {
|
|
7318
7318
|
try {
|
|
7319
7319
|
const h = hostname();
|
|
@@ -7330,10 +7330,11 @@ function deviceLogin(log) {
|
|
|
7330
7330
|
[
|
|
7331
7331
|
"Headless (--device) login is not available yet.",
|
|
7332
7332
|
"",
|
|
7333
|
-
"
|
|
7334
|
-
"
|
|
7335
|
-
"
|
|
7336
|
-
|
|
7333
|
+
"You usually don\u2019t need it: `backthread login` prints a URL you can open on ANY",
|
|
7334
|
+
"device (phone, laptop) \u2014 the token is delivered by polling, so the browser doesn\u2019t",
|
|
7335
|
+
"have to be on this machine. For a fully browserless box, mint a token from the web",
|
|
7336
|
+
"app (Account \u2192 Connected devices) and place it in ~/.backthread/config.json under",
|
|
7337
|
+
'"device_token", or use `--claim <code>` from the web app.'
|
|
7337
7338
|
].join("\n")
|
|
7338
7339
|
);
|
|
7339
7340
|
return { ok: false, message: "--device fallback not implemented yet." };
|
|
@@ -14533,8 +14534,8 @@ function uint8ArrayToBase64(bytes) {
|
|
|
14533
14534
|
}
|
|
14534
14535
|
return btoa(binaryString);
|
|
14535
14536
|
}
|
|
14536
|
-
function base64urlToUint8Array(
|
|
14537
|
-
const base643 =
|
|
14537
|
+
function base64urlToUint8Array(base64url3) {
|
|
14538
|
+
const base643 = base64url3.replace(/-/g, "+").replace(/_/g, "/");
|
|
14538
14539
|
const padding = "=".repeat((4 - base643.length % 4) % 4);
|
|
14539
14540
|
return base64ToUint8Array(base643 + padding);
|
|
14540
14541
|
}
|
|
@@ -14791,7 +14792,7 @@ var safeDecodeAsync = /* @__PURE__ */ _safeDecodeAsync($ZodRealError);
|
|
|
14791
14792
|
var regexes_exports = {};
|
|
14792
14793
|
__export(regexes_exports, {
|
|
14793
14794
|
base64: () => base64,
|
|
14794
|
-
base64url: () =>
|
|
14795
|
+
base64url: () => base64url,
|
|
14795
14796
|
bigint: () => bigint,
|
|
14796
14797
|
boolean: () => boolean,
|
|
14797
14798
|
browserEmail: () => browserEmail,
|
|
@@ -14886,7 +14887,7 @@ var mac = (delimiter) => {
|
|
|
14886
14887
|
var cidrv4 = /^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$/;
|
|
14887
14888
|
var cidrv6 = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/;
|
|
14888
14889
|
var base64 = /^$|^(?:[0-9a-zA-Z+/]{4})*(?:(?:[0-9a-zA-Z+/]{2}==)|(?:[0-9a-zA-Z+/]{3}=))?$/;
|
|
14889
|
-
var
|
|
14890
|
+
var base64url = /^[A-Za-z0-9_-]*$/;
|
|
14890
14891
|
var hostname2 = /^(?=.{1,253}\.?$)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[-0-9a-zA-Z]{0,61}[0-9a-zA-Z])?)*\.?$/;
|
|
14891
14892
|
var domain = /^([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
|
|
14892
14893
|
var httpProtocol = /^https?$/;
|
|
@@ -15900,14 +15901,14 @@ var $ZodBase64 = /* @__PURE__ */ $constructor("$ZodBase64", (inst, def) => {
|
|
|
15900
15901
|
};
|
|
15901
15902
|
});
|
|
15902
15903
|
function isValidBase64URL(data) {
|
|
15903
|
-
if (!
|
|
15904
|
+
if (!base64url.test(data))
|
|
15904
15905
|
return false;
|
|
15905
15906
|
const base643 = data.replace(/[-_]/g, (c) => c === "-" ? "+" : "/");
|
|
15906
15907
|
const padded = base643.padEnd(Math.ceil(base643.length / 4) * 4, "=");
|
|
15907
15908
|
return isValidBase64(padded);
|
|
15908
15909
|
}
|
|
15909
15910
|
var $ZodBase64URL = /* @__PURE__ */ $constructor("$ZodBase64URL", (inst, def) => {
|
|
15910
|
-
def.pattern ?? (def.pattern =
|
|
15911
|
+
def.pattern ?? (def.pattern = base64url);
|
|
15911
15912
|
$ZodStringFormat.init(inst, def);
|
|
15912
15913
|
inst._zod.bag.contentEncoding = "base64url";
|
|
15913
15914
|
inst._zod.check = (payload) => {
|
|
@@ -25936,7 +25937,7 @@ __export(external_exports, {
|
|
|
25936
25937
|
any: () => any,
|
|
25937
25938
|
array: () => array,
|
|
25938
25939
|
base64: () => base642,
|
|
25939
|
-
base64url: () =>
|
|
25940
|
+
base64url: () => base64url2,
|
|
25940
25941
|
bigint: () => bigint2,
|
|
25941
25942
|
boolean: () => boolean2,
|
|
25942
25943
|
catch: () => _catch2,
|
|
@@ -26166,7 +26167,7 @@ __export(schemas_exports2, {
|
|
|
26166
26167
|
any: () => any,
|
|
26167
26168
|
array: () => array,
|
|
26168
26169
|
base64: () => base642,
|
|
26169
|
-
base64url: () =>
|
|
26170
|
+
base64url: () => base64url2,
|
|
26170
26171
|
bigint: () => bigint2,
|
|
26171
26172
|
boolean: () => boolean2,
|
|
26172
26173
|
catch: () => _catch2,
|
|
@@ -26788,7 +26789,7 @@ var ZodBase64URL = /* @__PURE__ */ $constructor("ZodBase64URL", (inst, def) => {
|
|
|
26788
26789
|
$ZodBase64URL.init(inst, def);
|
|
26789
26790
|
ZodStringFormat.init(inst, def);
|
|
26790
26791
|
});
|
|
26791
|
-
function
|
|
26792
|
+
function base64url2(params) {
|
|
26792
26793
|
return _base64url(ZodBase64URL, params);
|
|
26793
26794
|
}
|
|
26794
26795
|
var ZodE164 = /* @__PURE__ */ $constructor("ZodE164", (inst, def) => {
|
|
@@ -33696,6 +33697,8 @@ var StdioServerTransport = class {
|
|
|
33696
33697
|
};
|
|
33697
33698
|
|
|
33698
33699
|
// src/query.ts
|
|
33700
|
+
var DEFAULT_QUESTION = "How does this project work?";
|
|
33701
|
+
var GROUNDED_ASK_TIMEOUT_MS = 3e4;
|
|
33699
33702
|
function parseSlug2(slug) {
|
|
33700
33703
|
const parts = slug.trim().replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
|
|
33701
33704
|
if (parts.length !== 2) return null;
|
|
@@ -33741,9 +33744,12 @@ async function queryDecisions(input, deps = {}) {
|
|
|
33741
33744
|
};
|
|
33742
33745
|
}
|
|
33743
33746
|
const deepLink = buildRepoDeepLink(repo.owner, repo.name, env);
|
|
33747
|
+
const question = typeof input.question === "string" && input.question.trim().length > 0 ? input.question.trim() : DEFAULT_QUESTION;
|
|
33748
|
+
const ac = new AbortController();
|
|
33749
|
+
const timer = setTimeout(() => ac.abort(), GROUNDED_ASK_TIMEOUT_MS);
|
|
33744
33750
|
let res;
|
|
33745
33751
|
try {
|
|
33746
|
-
res = await doFetch(
|
|
33752
|
+
res = await doFetch(buildGroundedAskUrl(env), {
|
|
33747
33753
|
method: "POST",
|
|
33748
33754
|
headers: {
|
|
33749
33755
|
// Bearer device token — never logged.
|
|
@@ -33752,15 +33758,20 @@ async function queryDecisions(input, deps = {}) {
|
|
|
33752
33758
|
...versionHeaders()
|
|
33753
33759
|
// x-backthread-version — server-side compat guard
|
|
33754
33760
|
},
|
|
33755
|
-
|
|
33761
|
+
// The server accepts `repo` as an "owner/name" slug (it re-resolves + gates).
|
|
33762
|
+
body: JSON.stringify({ question, repo: `${repo.owner}/${repo.name}` }),
|
|
33763
|
+
signal: ac.signal
|
|
33756
33764
|
});
|
|
33757
33765
|
} catch (e) {
|
|
33766
|
+
const aborted2 = e.name === "AbortError";
|
|
33758
33767
|
return {
|
|
33759
33768
|
status: "read-failed",
|
|
33760
|
-
detail: `
|
|
33769
|
+
detail: aborted2 ? `grounded-ask timed out after ${GROUNDED_ASK_TIMEOUT_MS / 1e3}s \u2014 try again.` : `grounded-ask request failed: ${e.message}`,
|
|
33761
33770
|
repo,
|
|
33762
33771
|
deepLink
|
|
33763
33772
|
};
|
|
33773
|
+
} finally {
|
|
33774
|
+
clearTimeout(timer);
|
|
33764
33775
|
}
|
|
33765
33776
|
let payload;
|
|
33766
33777
|
try {
|
|
@@ -33768,60 +33779,53 @@ async function queryDecisions(input, deps = {}) {
|
|
|
33768
33779
|
} catch {
|
|
33769
33780
|
payload = null;
|
|
33770
33781
|
}
|
|
33782
|
+
const rec = payload && typeof payload === "object" ? payload : {};
|
|
33771
33783
|
if (!res.ok) {
|
|
33772
|
-
const
|
|
33773
|
-
const serverErr = typeof obj.message === "string" && obj.message.length > 0 ? obj.message : "error" in obj ? String(obj.error) : `HTTP ${res.status}`;
|
|
33784
|
+
const serverErr = typeof rec.message === "string" && rec.message.length > 0 ? rec.message : "error" in rec ? String(rec.error) : `HTTP ${res.status}`;
|
|
33774
33785
|
return {
|
|
33775
33786
|
status: "read-failed",
|
|
33776
|
-
detail: `
|
|
33787
|
+
detail: `grounded-ask rejected (${res.status}): ${serverErr}`,
|
|
33788
|
+
repo,
|
|
33789
|
+
deepLink
|
|
33790
|
+
};
|
|
33791
|
+
}
|
|
33792
|
+
const answer = typeof rec.answer === "string" ? rec.answer : "";
|
|
33793
|
+
if (!answer) {
|
|
33794
|
+
return {
|
|
33795
|
+
status: "read-failed",
|
|
33796
|
+
detail: "grounded-ask returned no answer.",
|
|
33777
33797
|
repo,
|
|
33778
33798
|
deepLink
|
|
33779
33799
|
};
|
|
33780
33800
|
}
|
|
33781
|
-
const rec = payload && typeof payload === "object" ? payload : {};
|
|
33782
|
-
const flows = normalizeFlows(rec.flows);
|
|
33783
|
-
const decisions = normalizeDecisions(rec.decisions);
|
|
33784
33801
|
const upgrade = typeof rec.upgrade === "string" && rec.upgrade.length > 0 ? rec.upgrade : void 0;
|
|
33785
|
-
const base = `${flows.length} flow(s), ${decisions.length} decision(s) for ${repo.owner}/${repo.name}.`;
|
|
33786
33802
|
return {
|
|
33787
33803
|
status: "ok",
|
|
33788
|
-
detail:
|
|
33804
|
+
detail: `grounded answer (${typeof rec.coverage === "string" ? rec.coverage : "partial"} coverage)`,
|
|
33789
33805
|
repo,
|
|
33790
|
-
|
|
33791
|
-
|
|
33792
|
-
|
|
33806
|
+
answer,
|
|
33807
|
+
coverage: typeof rec.coverage === "string" ? rec.coverage : void 0,
|
|
33808
|
+
citations: normalizeCitations(rec.citations),
|
|
33809
|
+
inferredSpans: Array.isArray(rec.inferredSpans) ? rec.inferredSpans.map(String) : [],
|
|
33810
|
+
// Prefer the server's deepLink; fall back to the locally-built one.
|
|
33811
|
+
deepLink: typeof rec.deepLink === "string" && rec.deepLink.length > 0 ? rec.deepLink : deepLink,
|
|
33793
33812
|
...upgrade ? { upgrade } : {}
|
|
33794
33813
|
};
|
|
33795
33814
|
} catch (e) {
|
|
33796
33815
|
return { status: "error", detail: `query failed (swallowed): ${e.message}` };
|
|
33797
33816
|
}
|
|
33798
33817
|
}
|
|
33799
|
-
function
|
|
33800
|
-
if (!Array.isArray(raw)) return [];
|
|
33801
|
-
return raw.map((f) => {
|
|
33802
|
-
const r = f && typeof f === "object" ? f : {};
|
|
33803
|
-
return {
|
|
33804
|
-
id: String(r.id ?? ""),
|
|
33805
|
-
name: String(r.name ?? ""),
|
|
33806
|
-
lifecycle: String(r.lifecycle ?? ""),
|
|
33807
|
-
salience: typeof r.salience === "number" ? r.salience : null,
|
|
33808
|
-
canonicalFlowId: typeof r.canonicalFlowId === "string" ? r.canonicalFlowId : null
|
|
33809
|
-
};
|
|
33810
|
-
});
|
|
33811
|
-
}
|
|
33812
|
-
function normalizeDecisions(raw) {
|
|
33818
|
+
function normalizeCitations(raw) {
|
|
33813
33819
|
if (!Array.isArray(raw)) return [];
|
|
33814
|
-
return raw.map((
|
|
33815
|
-
const r =
|
|
33820
|
+
return raw.map((c) => {
|
|
33821
|
+
const r = c && typeof c === "object" ? c : {};
|
|
33816
33822
|
return {
|
|
33817
|
-
|
|
33823
|
+
n: typeof r.n === "number" ? r.n : 0,
|
|
33824
|
+
decisionId: String(r.decisionId ?? ""),
|
|
33818
33825
|
title: String(r.title ?? ""),
|
|
33819
|
-
|
|
33820
|
-
|
|
33821
|
-
|
|
33822
|
-
decidedAt: typeof r.decidedAt === "string" ? r.decidedAt : null,
|
|
33823
|
-
flowIds: Array.isArray(r.flowIds) ? r.flowIds.map(String) : [],
|
|
33824
|
-
moduleIds: Array.isArray(r.moduleIds) ? r.moduleIds.map(String) : []
|
|
33826
|
+
url: String(r.url ?? ""),
|
|
33827
|
+
moduleIds: Array.isArray(r.moduleIds) ? r.moduleIds.map(String) : [],
|
|
33828
|
+
decidedAt: typeof r.decidedAt === "string" ? r.decidedAt : null
|
|
33825
33829
|
};
|
|
33826
33830
|
});
|
|
33827
33831
|
}
|
|
@@ -33857,7 +33861,7 @@ function isFailure(o) {
|
|
|
33857
33861
|
async function handleQueryTool(args = {}, deps = {}) {
|
|
33858
33862
|
const run = deps.queryDecisionsImpl ?? queryDecisions;
|
|
33859
33863
|
try {
|
|
33860
|
-
const outcome = await run({ repo: args.repo, cwd: args.cwd }, deps.queryDeps);
|
|
33864
|
+
const outcome = await run({ question: args.question, repo: args.repo, cwd: args.cwd }, deps.queryDeps);
|
|
33861
33865
|
let text = formatQueryOutcome(outcome, args.question);
|
|
33862
33866
|
const nudge = await (deps.upgradeNudgeImpl ?? maybeUpgradeNudge)(outcome.upgrade);
|
|
33863
33867
|
if (nudge) text += `
|
|
@@ -33868,42 +33872,11 @@ ${nudge}`;
|
|
|
33868
33872
|
return textResult(`query: error \u2014 ${e.message}`, true);
|
|
33869
33873
|
}
|
|
33870
33874
|
}
|
|
33871
|
-
function formatQueryOutcome(outcome,
|
|
33872
|
-
if (outcome.status !== "ok") {
|
|
33875
|
+
function formatQueryOutcome(outcome, _question) {
|
|
33876
|
+
if (outcome.status !== "ok" || !outcome.answer) {
|
|
33873
33877
|
return `query: ${outcome.status} \u2014 ${outcome.detail}`;
|
|
33874
33878
|
}
|
|
33875
|
-
|
|
33876
|
-
const q = question && question.trim().length > 0 ? ` for "${question.trim()}"` : "";
|
|
33877
|
-
const repoSlug = outcome.repo ? `${outcome.repo.owner}/${outcome.repo.name}` : "this repo";
|
|
33878
|
-
lines.push(`How ${repoSlug} works${q} \u2014 salience-ranked from the decision log:`);
|
|
33879
|
-
lines.push("");
|
|
33880
|
-
const flows = outcome.flows ?? [];
|
|
33881
|
-
if (flows.length > 0) {
|
|
33882
|
-
lines.push("Flows (most salient first):");
|
|
33883
|
-
for (const f of flows) {
|
|
33884
|
-
const sal = f.salience != null ? ` [salience ${f.salience}]` : "";
|
|
33885
|
-
lines.push(` - ${f.name} (${f.lifecycle})${sal}`);
|
|
33886
|
-
}
|
|
33887
|
-
} else {
|
|
33888
|
-
lines.push("Flows: none recorded yet.");
|
|
33889
|
-
}
|
|
33890
|
-
lines.push("");
|
|
33891
|
-
const decisions = outcome.decisions ?? [];
|
|
33892
|
-
if (decisions.length > 0) {
|
|
33893
|
-
lines.push('Decisions (the "why", most significant first):');
|
|
33894
|
-
for (const d of decisions) {
|
|
33895
|
-
const risk = d.domainRisk ? ` {${d.domainRisk}-risk}` : "";
|
|
33896
|
-
lines.push(` - ${d.title}${risk}`);
|
|
33897
|
-
if (d.why) lines.push(` why: ${d.why}`);
|
|
33898
|
-
}
|
|
33899
|
-
} else {
|
|
33900
|
-
lines.push("Decisions: none recorded yet.");
|
|
33901
|
-
}
|
|
33902
|
-
lines.push("");
|
|
33903
|
-
if (outcome.deepLink) {
|
|
33904
|
-
lines.push(`Open the "How it works" diagram: ${outcome.deepLink}`);
|
|
33905
|
-
}
|
|
33906
|
-
return lines.join("\n");
|
|
33879
|
+
return outcome.answer;
|
|
33907
33880
|
}
|
|
33908
33881
|
function buildMcpServer(deps = {}) {
|
|
33909
33882
|
const server = new McpServer({
|
|
@@ -33928,10 +33901,10 @@ function buildMcpServer(deps = {}) {
|
|
|
33928
33901
|
server.registerTool(
|
|
33929
33902
|
"query",
|
|
33930
33903
|
{
|
|
33931
|
-
title: "How does
|
|
33932
|
-
description:
|
|
33904
|
+
title: "How does it work? (grounded answer from this repo's decision log)",
|
|
33905
|
+
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.`,
|
|
33933
33906
|
inputSchema: {
|
|
33934
|
-
question: external_exports.string().optional().describe(
|
|
33907
|
+
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.`),
|
|
33935
33908
|
repo: external_exports.string().optional().describe('Optional repo override as "owner/name"; otherwise the configured repo or the cwd git remote.'),
|
|
33936
33909
|
cwd: external_exports.string().optional().describe("The session's working directory (repo fallback).")
|
|
33937
33910
|
}
|
|
@@ -33947,6 +33920,87 @@ async function startMcpServer(deps = {}) {
|
|
|
33947
33920
|
return server;
|
|
33948
33921
|
}
|
|
33949
33922
|
|
|
33923
|
+
// src/routingStats.ts
|
|
33924
|
+
import { join as join12 } from "node:path";
|
|
33925
|
+
import { readFile as readFile11, writeFile as writeFile9, mkdir as mkdir9, chmod as chmod8 } from "node:fs/promises";
|
|
33926
|
+
var STATS_FILE = "routing-stats.json";
|
|
33927
|
+
function statsPath(env) {
|
|
33928
|
+
return join12(configDir(env), STATS_FILE);
|
|
33929
|
+
}
|
|
33930
|
+
async function readRoutingStats(deps = {}) {
|
|
33931
|
+
const env = deps.env ?? process.env;
|
|
33932
|
+
const read = deps.readFileImpl ?? readFile11;
|
|
33933
|
+
try {
|
|
33934
|
+
const raw = await read(statsPath(env), "utf8");
|
|
33935
|
+
const obj = JSON.parse(raw);
|
|
33936
|
+
return {
|
|
33937
|
+
injected: typeof obj.injected === "number" && obj.injected >= 0 ? Math.floor(obj.injected) : 0,
|
|
33938
|
+
lastInjectedAt: typeof obj.lastInjectedAt === "string" ? obj.lastInjectedAt : void 0
|
|
33939
|
+
};
|
|
33940
|
+
} catch {
|
|
33941
|
+
return { injected: 0 };
|
|
33942
|
+
}
|
|
33943
|
+
}
|
|
33944
|
+
async function recordRoutingInjected(deps = {}) {
|
|
33945
|
+
const env = deps.env ?? process.env;
|
|
33946
|
+
const now = deps.now ?? (() => /* @__PURE__ */ new Date());
|
|
33947
|
+
const write = deps.writeFileImpl ?? writeFile9;
|
|
33948
|
+
const mkdirp = deps.mkdirImpl ?? mkdir9;
|
|
33949
|
+
const chmodp = deps.chmodImpl ?? chmod8;
|
|
33950
|
+
try {
|
|
33951
|
+
const prev = await readRoutingStats(deps);
|
|
33952
|
+
const next = { injected: prev.injected + 1, lastInjectedAt: now().toISOString() };
|
|
33953
|
+
await mkdirp(configDir(env), { recursive: true, mode: DIR_MODE });
|
|
33954
|
+
await write(statsPath(env), `${JSON.stringify(next, null, 2)}
|
|
33955
|
+
`, { mode: CONFIG_MODE });
|
|
33956
|
+
await chmodp(statsPath(env), CONFIG_MODE).catch(() => {
|
|
33957
|
+
});
|
|
33958
|
+
} catch {
|
|
33959
|
+
}
|
|
33960
|
+
}
|
|
33961
|
+
|
|
33962
|
+
// src/sessionStart.ts
|
|
33963
|
+
var ROUTING_CONTEXT = [
|
|
33964
|
+
'This repository is set up with Backthread, which has captured the "why" behind its',
|
|
33965
|
+
"changes into a decision log. The backthread `query` MCP tool (and the",
|
|
33966
|
+
'`/backthread:how` slash command) answer "how does X work?" and "why was Y decided?"',
|
|
33967
|
+
"with a short, CITED answer synthesized from that log \u2014 the real reasons, trade-offs,",
|
|
33968
|
+
"and superseded approaches the source code itself does not contain.",
|
|
33969
|
+
"",
|
|
33970
|
+
"When the user asks how or why any part of THIS codebase works, is structured, or was",
|
|
33971
|
+
"decided, call the backthread `query` tool FIRST \u2014 before grepping or reading source",
|
|
33972
|
+
"files. Pass the user's question and relay the cited answer it returns; only read",
|
|
33973
|
+
"source afterwards if it didn't cover what you need."
|
|
33974
|
+
].join("\n");
|
|
33975
|
+
function buildSessionStartOutput(isSetUp) {
|
|
33976
|
+
if (!isSetUp) return {};
|
|
33977
|
+
return {
|
|
33978
|
+
hookSpecificOutput: {
|
|
33979
|
+
hookEventName: "SessionStart",
|
|
33980
|
+
additionalContext: ROUTING_CONTEXT
|
|
33981
|
+
}
|
|
33982
|
+
};
|
|
33983
|
+
}
|
|
33984
|
+
async function runSessionStart(deps = {}) {
|
|
33985
|
+
const readConfig2 = deps.readConfig ?? readConfig;
|
|
33986
|
+
const record2 = deps.recordRoutingInjected ?? recordRoutingInjected;
|
|
33987
|
+
let isSetUp = false;
|
|
33988
|
+
try {
|
|
33989
|
+
const cfg = await readConfig2();
|
|
33990
|
+
isSetUp = !!cfg.device_token;
|
|
33991
|
+
} catch {
|
|
33992
|
+
isSetUp = false;
|
|
33993
|
+
}
|
|
33994
|
+
const output = buildSessionStartOutput(isSetUp);
|
|
33995
|
+
if (output.hookSpecificOutput) {
|
|
33996
|
+
try {
|
|
33997
|
+
await record2();
|
|
33998
|
+
} catch {
|
|
33999
|
+
}
|
|
34000
|
+
}
|
|
34001
|
+
return output;
|
|
34002
|
+
}
|
|
34003
|
+
|
|
33950
34004
|
// src/bin/backthread.ts
|
|
33951
34005
|
var USAGE = `backthread \u2014 capture the "why" of your AI-coded changes
|
|
33952
34006
|
|
|
@@ -33963,6 +34017,9 @@ Usage:
|
|
|
33963
34017
|
(no browser needed \u2014 codes expire in ~10 minutes)
|
|
33964
34018
|
backthread login --device Headless / SSH login (device-code flow \u2014 coming soon)
|
|
33965
34019
|
backthread whoami Show the current device's config (token is never printed)
|
|
34020
|
+
backthread how <question> Ask how/why something in this repo works \u2014 prints a
|
|
34021
|
+
grounded, cited answer from your Backthread decision log
|
|
34022
|
+
(backs the /backthread:how slash command). [--cwd <path>]
|
|
33966
34023
|
backthread capture Capture this session's decisions (run by the SessionEnd/Stop hook)
|
|
33967
34024
|
backthread capture --from-hook
|
|
33968
34025
|
Shared multi-agent hook entrypoint: read the hook payload off
|
|
@@ -33997,6 +34054,12 @@ function flagValue(rest, flag) {
|
|
|
33997
34054
|
if (!value || value.startsWith("--")) return void 0;
|
|
33998
34055
|
return value;
|
|
33999
34056
|
}
|
|
34057
|
+
function stripFlag(rest, flag) {
|
|
34058
|
+
const i = rest.indexOf(flag);
|
|
34059
|
+
if (i === -1) return rest;
|
|
34060
|
+
const dropValue = rest[i + 1] !== void 0 && !rest[i + 1].startsWith("--");
|
|
34061
|
+
return [...rest.slice(0, i), ...rest.slice(i + (dropValue ? 2 : 1))];
|
|
34062
|
+
}
|
|
34000
34063
|
async function runOnboarding(rest) {
|
|
34001
34064
|
const claim = parseClaimFlag(rest);
|
|
34002
34065
|
const result = await runStart({
|
|
@@ -34060,6 +34123,14 @@ async function main(argv, deps = {}) {
|
|
|
34060
34123
|
}
|
|
34061
34124
|
return 0;
|
|
34062
34125
|
}
|
|
34126
|
+
case "session-start": {
|
|
34127
|
+
const ssAgent = parseAgent(flagValue(rest, "--agent"));
|
|
34128
|
+
setRequestAgent(ssAgent === "unknown" ? "claude-code" : ssAgent);
|
|
34129
|
+
await readRawHookInput().catch(() => "");
|
|
34130
|
+
const output = await runSessionStart();
|
|
34131
|
+
console.log(JSON.stringify(output));
|
|
34132
|
+
return 0;
|
|
34133
|
+
}
|
|
34063
34134
|
case "mcp": {
|
|
34064
34135
|
await startMcpServer();
|
|
34065
34136
|
return null;
|
|
@@ -34083,6 +34154,15 @@ async function main(argv, deps = {}) {
|
|
|
34083
34154
|
});
|
|
34084
34155
|
return result.exitCode;
|
|
34085
34156
|
}
|
|
34157
|
+
case "how":
|
|
34158
|
+
case "ask": {
|
|
34159
|
+
const query = deps.queryDecisionsImpl ?? queryDecisions;
|
|
34160
|
+
const cwd = flagValue(rest, "--cwd") ?? process.cwd();
|
|
34161
|
+
const question = stripFlag(rest, "--cwd").join(" ").trim();
|
|
34162
|
+
const outcome = await query({ question, cwd });
|
|
34163
|
+
console.log(formatQueryOutcome(outcome, question));
|
|
34164
|
+
return outcome.status === "ok" ? 0 : 1;
|
|
34165
|
+
}
|
|
34086
34166
|
case void 0:
|
|
34087
34167
|
return onboarding(rest);
|
|
34088
34168
|
case "help":
|
|
@@ -34126,5 +34206,6 @@ if (isEntryPoint()) {
|
|
|
34126
34206
|
}
|
|
34127
34207
|
export {
|
|
34128
34208
|
main,
|
|
34129
|
-
runOnboarding
|
|
34209
|
+
runOnboarding,
|
|
34210
|
+
stripFlag
|
|
34130
34211
|
};
|
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.7.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",
|