backthread 0.8.0 → 0.9.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.
@@ -2,7 +2,7 @@
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.8.0",
5
+ "version": "0.9.0",
6
6
  "author": {
7
7
  "name": "Backthread"
8
8
  },
@@ -34092,8 +34092,49 @@ var StdioServerTransport = class {
34092
34092
  };
34093
34093
 
34094
34094
  // src/query.ts
34095
+ import { execFileSync as execFileSync3 } from "node:child_process";
34095
34096
  var DEFAULT_QUESTION = "How does this project work?";
34096
- var GROUNDED_ASK_TIMEOUT_MS = 3e4;
34097
+ var GROUNDED_ASK_TIMEOUT_MS = 45e3;
34098
+ var GROUNDED_ASK_ATTEMPTS = 2;
34099
+ var defaultGitRunner2 = (args, cwd) => {
34100
+ try {
34101
+ execFileSync3("git", args, { cwd, stdio: ["ignore", "ignore", "ignore"], timeout: 3e3 });
34102
+ return 0;
34103
+ } catch (e) {
34104
+ const status = e.status;
34105
+ return typeof status === "number" ? status : 128;
34106
+ }
34107
+ };
34108
+ var SHA_RE = /^[0-9a-f]{7,40}$/i;
34109
+ function countCitationsAfterCheckout(citations, cwd, runGit = defaultGitRunner2) {
34110
+ const stale = /* @__PURE__ */ new Map();
34111
+ for (const c of citations) {
34112
+ if (c.anchorSha && SHA_RE.test(c.anchorSha)) stale.set(c.anchorSha, false);
34113
+ }
34114
+ if (stale.size === 0) return 0;
34115
+ for (const sha of stale.keys()) {
34116
+ const exists = runGit(["rev-parse", "--quiet", "--verify", `${sha}^{commit}`], cwd);
34117
+ if (exists === 1) {
34118
+ stale.set(sha, true);
34119
+ continue;
34120
+ }
34121
+ if (exists !== 0) return 0;
34122
+ const contained = runGit(["merge-base", "--is-ancestor", sha, "HEAD"], cwd);
34123
+ if (contained === 1) {
34124
+ stale.set(sha, true);
34125
+ continue;
34126
+ }
34127
+ if (contained !== 0) return 0;
34128
+ }
34129
+ let n = 0;
34130
+ for (const c of citations) {
34131
+ if (c.anchorSha && stale.get(c.anchorSha)) n += 1;
34132
+ }
34133
+ return n;
34134
+ }
34135
+ function stalenessNote(n) {
34136
+ return `Note: ${n} of the decisions cited above landed after your checkout \u2014 this answer reflects the tracked branch.`;
34137
+ }
34097
34138
  function parseSlug2(slug) {
34098
34139
  const parts = slug.trim().replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
34099
34140
  if (parts.length !== 2) return null;
@@ -34140,33 +34181,46 @@ async function queryDecisions(input, deps = {}) {
34140
34181
  }
34141
34182
  const deepLink = buildRepoDeepLink(repo.owner, repo.name, env);
34142
34183
  const question = typeof input.question === "string" && input.question.trim().length > 0 ? input.question.trim() : DEFAULT_QUESTION;
34143
- const ac = new AbortController();
34144
- const timer = setTimeout(() => ac.abort(), GROUNDED_ASK_TIMEOUT_MS);
34145
34184
  let res;
34146
- try {
34147
- res = await doFetch(buildGroundedAskUrl(env), {
34148
- method: "POST",
34149
- headers: {
34150
- // Bearer device token — never logged.
34151
- Authorization: `Bearer ${config2.device_token}`,
34152
- "Content-Type": "application/json",
34153
- ...versionHeaders()
34154
- // x-backthread-versionserver-side compat guard
34155
- },
34156
- // The server accepts `repo` as an "owner/name" slug (it re-resolves + gates).
34157
- body: JSON.stringify({ question, repo: `${repo.owner}/${repo.name}` }),
34158
- signal: ac.signal
34159
- });
34160
- } catch (e) {
34161
- const aborted2 = e.name === "AbortError";
34185
+ let failDetail = "";
34186
+ for (let attempt = 1; attempt <= GROUNDED_ASK_ATTEMPTS; attempt++) {
34187
+ const ac = new AbortController();
34188
+ const timer = setTimeout(() => ac.abort(), GROUNDED_ASK_TIMEOUT_MS);
34189
+ try {
34190
+ res = await doFetch(buildGroundedAskUrl(env), {
34191
+ method: "POST",
34192
+ headers: {
34193
+ // Bearer device token never logged.
34194
+ Authorization: `Bearer ${config2.device_token}`,
34195
+ "Content-Type": "application/json",
34196
+ ...versionHeaders()
34197
+ // x-backthread-version — server-side compat guard
34198
+ },
34199
+ // The server accepts `repo` as an "owner/name" slug (it re-resolves + gates).
34200
+ body: JSON.stringify({ question, repo: `${repo.owner}/${repo.name}` }),
34201
+ signal: ac.signal
34202
+ });
34203
+ if (res.status >= 500 && attempt < GROUNDED_ASK_ATTEMPTS) {
34204
+ failDetail = `grounded-ask rejected (${res.status})`;
34205
+ res = void 0;
34206
+ continue;
34207
+ }
34208
+ break;
34209
+ } catch (e) {
34210
+ const aborted2 = e.name === "AbortError";
34211
+ failDetail = aborted2 ? `grounded-ask timed out after ${GROUNDED_ASK_TIMEOUT_MS / 1e3}s` : `grounded-ask request failed: ${e.message}`;
34212
+ res = void 0;
34213
+ } finally {
34214
+ clearTimeout(timer);
34215
+ }
34216
+ }
34217
+ if (!res) {
34162
34218
  return {
34163
34219
  status: "read-failed",
34164
- detail: aborted2 ? `grounded-ask timed out after ${GROUNDED_ASK_TIMEOUT_MS / 1e3}s \u2014 try again.` : `grounded-ask request failed: ${e.message}`,
34220
+ detail: `${failDetail} (after ${GROUNDED_ASK_ATTEMPTS} attempts) \u2014 try again.`,
34165
34221
  repo,
34166
34222
  deepLink
34167
34223
  };
34168
- } finally {
34169
- clearTimeout(timer);
34170
34224
  }
34171
34225
  let payload;
34172
34226
  try {
@@ -34194,13 +34248,22 @@ async function queryDecisions(input, deps = {}) {
34194
34248
  };
34195
34249
  }
34196
34250
  const upgrade = typeof rec.upgrade === "string" && rec.upgrade.length > 0 ? rec.upgrade : void 0;
34251
+ const citations = normalizeCitations(rec.citations);
34252
+ let renderedAnswer = answer;
34253
+ try {
34254
+ const n = countCitationsAfterCheckout(citations, input.cwd ?? process.cwd(), deps.runGitImpl);
34255
+ if (n > 0) renderedAnswer = `${answer}
34256
+
34257
+ ${stalenessNote(n)}`;
34258
+ } catch {
34259
+ }
34197
34260
  return {
34198
34261
  status: "ok",
34199
34262
  detail: `grounded answer (${typeof rec.coverage === "string" ? rec.coverage : "partial"} coverage)`,
34200
34263
  repo,
34201
- answer,
34264
+ answer: renderedAnswer,
34202
34265
  coverage: typeof rec.coverage === "string" ? rec.coverage : void 0,
34203
- citations: normalizeCitations(rec.citations),
34266
+ citations,
34204
34267
  inferredSpans: Array.isArray(rec.inferredSpans) ? rec.inferredSpans.map(String) : [],
34205
34268
  // Prefer the server's deepLink; fall back to the locally-built one.
34206
34269
  deepLink: typeof rec.deepLink === "string" && rec.deepLink.length > 0 ? rec.deepLink : deepLink,
@@ -34220,7 +34283,8 @@ function normalizeCitations(raw) {
34220
34283
  title: String(r.title ?? ""),
34221
34284
  url: String(r.url ?? ""),
34222
34285
  moduleIds: Array.isArray(r.moduleIds) ? r.moduleIds.map(String) : [],
34223
- decidedAt: typeof r.decidedAt === "string" ? r.decidedAt : null
34286
+ decidedAt: typeof r.decidedAt === "string" ? r.decidedAt : null,
34287
+ anchorSha: typeof r.anchorSha === "string" ? r.anchorSha : null
34224
34288
  };
34225
34289
  });
34226
34290
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backthread",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Backthread keeps the thread on what your AI coding agent ships — it captures the why behind every change and turns it into a living 'How it works' view of your codebase you can actually query.",
5
5
  "license": "MIT",
6
6
  "author": "Backthread",