@jefuriiij/synthra 0.11.0 → 0.13.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/CHANGELOG.md CHANGED
@@ -7,6 +7,46 @@ For older versions, see [GitHub Releases](https://github.com/jefuriiij/synthra/r
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.13.0] — 2026-06-24
11
+
12
+ ### Added
13
+
14
+ - **The resume digest now lists the symbols that changed since your last session.**
15
+ The SessionStart "Since you were last here" primer showed *files* touched; it now
16
+ leads its supporting context with the actual **symbols/signatures** that changed —
17
+ e.g. `src/auth.ts::login (function) — function login(creds: Creds): Promise<...>`.
18
+ Computed from a git diff against the previous session's HEAD (committed **and**
19
+ uncommitted changes), overlapped with the current graph. Best-effort: silently
20
+ omitted in non-git projects.
21
+ - **`call_path(from, to)` — trace control flow.** Returns the shortest chain of
22
+ calls from one symbol to another (`handler → service → repo`), so you can see how
23
+ one symbol reaches another. The forward complement to `blast_radius` (callers).
24
+ Each of `from`/`to` is a `file::symbol` target or a bare symbol name when unique.
25
+
26
+ Both reuse the existing call graph + git — no graph schema change, no new dependencies.
27
+
28
+ ---
29
+
30
+ ## [0.12.0] — 2026-06-24
31
+
32
+ ### Added
33
+
34
+ - **`find_symbol(name)` — reuse before you re-implement.** Before writing a new
35
+ helper, ask Synthra whether one already exists: `find_symbol` returns every
36
+ exact-name definition (with signatures + ready `graph_read` targets), or — if
37
+ there's no exact match — similarly-named symbols to reuse or extend. "No symbol
38
+ matching … — safe to create" is the green light that it's genuinely new. The
39
+ injected policy now nudges the agent to check first.
40
+ - **`duplicate_symbols` — consolidation candidates.** Lists symbol names defined
41
+ in more than one file (functions/classes/types; methods excluded, since shared
42
+ method names are normal). Advisory — duplicates can be intentional; it never
43
+ says "delete."
44
+
45
+ Both are built on the symbol index (exact name lookup) — no false-positive risk,
46
+ no new dependencies.
47
+
48
+ ---
49
+
10
50
  ## [0.11.0] — 2026-06-24
11
51
 
12
52
  ### Added
package/dist/cli/index.js CHANGED
@@ -18,7 +18,7 @@ var init_package = __esm({
18
18
  "package.json"() {
19
19
  package_default = {
20
20
  name: "@jefuriiij/synthra",
21
- version: "0.11.0",
21
+ version: "0.13.0",
22
22
  publishConfig: {
23
23
  access: "public"
24
24
  },
@@ -3017,7 +3017,7 @@ import { basename as basename5 } from "path";
3017
3017
  // src/hooks/claude-md.ts
3018
3018
  import { readFile as readFile12, writeFile as writeFile6 } from "fs/promises";
3019
3019
  import { basename as basename4, dirname as dirname9 } from "path";
3020
- var POLICY_VERSION = 7;
3020
+ var POLICY_VERSION = 8;
3021
3021
  var POLICY_BEGIN = `<!-- synthra-policy v${POLICY_VERSION} BEGIN -->`;
3022
3022
  var POLICY_END = `<!-- synthra-policy v${POLICY_VERSION} END -->`;
3023
3023
  var ANY_BLOCK_RE = /<!--\s*synthra-policy\s+v\d+\s+BEGIN\s*-->[\s\S]*?<!--\s*synthra-policy\s+v\d+\s+END\s*-->\s*/g;
@@ -3036,7 +3036,7 @@ function policyBlock() {
3036
3036
  "> `mcp__synthra__graph_register_edit`. **Short names will NOT resolve**",
3037
3037
  "> in ToolSearch or invocation \u2014 always use the full namespaced form.",
3038
3038
  "> If the tools are deferred, load their schemas with ToolSearch:",
3039
- "> `select:mcp__synthra__graph_continue,mcp__synthra__graph_read,mcp__synthra__graph_register_edit`.",
3039
+ "> `select:mcp__synthra__graph_continue,mcp__synthra__graph_read,mcp__synthra__graph_register_edit,mcp__synthra__find_symbol`.",
3040
3040
  "> Below, short names (`graph_continue` etc.) appear in prose for",
3041
3041
  "> readability only.",
3042
3042
  "",
@@ -3050,6 +3050,10 @@ function policyBlock() {
3050
3050
  " symbol is ~50 tokens, reading a whole file is thousands.",
3051
3051
  "- **`graph_register_edit(files)`** \u2014 after you edit files, call this so",
3052
3052
  " subsequent turns weight your changes and avoid stale snapshots.",
3053
+ "- **`find_symbol(name)`** \u2014 **reuse-first**: before writing a new helper,",
3054
+ " util, or function, call this to check whether one already exists. If it",
3055
+ " returns matches, reuse or extend them instead of re-implementing; only",
3056
+ ' "no match \u2014 safe to create" means it is genuinely new.',
3053
3057
  "",
3054
3058
  "### When to call `graph_continue` \u2014 and when to skip",
3055
3059
  "",
@@ -4219,6 +4223,40 @@ var TOOLS = [
4219
4223
  limit: { type: "number", description: "Cap on returned files. Default 50." }
4220
4224
  }
4221
4225
  }
4226
+ },
4227
+ {
4228
+ name: "find_symbol",
4229
+ description: "Find existing symbols by name BEFORE writing a new one \u2014 reuse beats re-implementing. Returns exact-name definitions (signatures + graph_read targets) or, if none, similarly-named symbols. 'No symbol matching \u2026 \u2014 safe to create' means it's genuinely new.",
4230
+ inputSchema: {
4231
+ type: "object",
4232
+ properties: {
4233
+ name: { type: "string", description: "Symbol name (or near-name) to look for." }
4234
+ },
4235
+ required: ["name"]
4236
+ }
4237
+ },
4238
+ {
4239
+ name: "duplicate_symbols",
4240
+ description: "List symbol names defined in more than one file (functions/classes/types; methods excluded) \u2014 consolidation candidates for review. Advisory: duplicates may be intentional.",
4241
+ inputSchema: {
4242
+ type: "object",
4243
+ properties: {
4244
+ limit: { type: "number", description: "Cap on returned names. Default 30." }
4245
+ }
4246
+ }
4247
+ },
4248
+ {
4249
+ name: "call_path",
4250
+ description: "Trace how one symbol reaches another through the call graph \u2014 the shortest chain of calls from 'from' to 'to'. Use to understand control flow ('how does this handler end up hitting the DB layer?'). Each of 'from'/'to' is a 'file::symbol' target or a bare symbol name when unique.",
4251
+ inputSchema: {
4252
+ type: "object",
4253
+ properties: {
4254
+ from: { type: "string", description: "Starting symbol ('file::symbol' or unique name)." },
4255
+ to: { type: "string", description: "Target symbol ('file::symbol' or unique name)." },
4256
+ depth: { type: "number", description: "Max call hops to search. Default 6." }
4257
+ },
4258
+ required: ["from", "to"]
4259
+ }
4222
4260
  }
4223
4261
  ];
4224
4262
  async function callTool(name, args, ctx) {
@@ -4241,6 +4279,12 @@ async function callTool(name, args, ctx) {
4241
4279
  return blastRadius(args, ctx);
4242
4280
  case "dead_code":
4243
4281
  return deadCode(args, ctx);
4282
+ case "find_symbol":
4283
+ return findSymbol(args, ctx);
4284
+ case "duplicate_symbols":
4285
+ return duplicateSymbols(args, ctx);
4286
+ case "call_path":
4287
+ return callPath(args, ctx);
4244
4288
  default:
4245
4289
  return errorContent(`Unknown tool: ${name}`);
4246
4290
  }
@@ -4397,6 +4441,97 @@ function testsCoveringLine(graph, filePaths) {
4397
4441
  const omitted = tests.length - shown.length;
4398
4442
  return `Tests covering the impact: ${shown.join(" \xB7 ")}${omitted > 0 ? ` \u2026+${omitted} more` : ""}`;
4399
4443
  }
4444
+ function resolveSymbolArg(ctx, arg) {
4445
+ const a = arg.trim();
4446
+ if (a.includes("::")) {
4447
+ const [rawFile, rawSym] = a.split("::", 2);
4448
+ const resolved = resolveFileTarget(ctx.graph, (rawFile ?? "").trim());
4449
+ if (!("node" in resolved)) return null;
4450
+ const name = (rawSym ?? "").trim();
4451
+ return ctx.graph.nodes.find(
4452
+ (n) => n.kind === "symbol" && n.file === resolved.node.path && n.name === name
4453
+ ) ?? null;
4454
+ }
4455
+ const matches = ctx.graph.nodes.filter(
4456
+ (n) => n.kind === "symbol" && n.name === a
4457
+ );
4458
+ return matches.length === 1 ? matches[0] : null;
4459
+ }
4460
+ function callPath(args, ctx) {
4461
+ const fromArg = typeof args?.from === "string" ? args.from : "";
4462
+ const toArg = typeof args?.to === "string" ? args.to : "";
4463
+ const maxDepth = typeof args?.depth === "number" && args.depth > 0 ? Math.floor(args.depth) : 6;
4464
+ if (!fromArg.trim() || !toArg.trim()) {
4465
+ return errorContent("call_path: 'from' and 'to' (strings) are required");
4466
+ }
4467
+ const from = resolveSymbolArg(ctx, fromArg);
4468
+ if (!from) {
4469
+ return errorContent(
4470
+ `call_path: could not resolve 'from': ${fromArg} (use file::symbol if the name is ambiguous)`
4471
+ );
4472
+ }
4473
+ const to = resolveSymbolArg(ctx, toArg);
4474
+ if (!to) {
4475
+ return errorContent(
4476
+ `call_path: could not resolve 'to': ${toArg} (use file::symbol if the name is ambiguous)`
4477
+ );
4478
+ }
4479
+ if (from.id === to.id) {
4480
+ return textContent(`# call_path
4481
+
4482
+ \`${from.name}\` and \`${to.name}\` are the same symbol.`);
4483
+ }
4484
+ const calleesBy = /* @__PURE__ */ new Map();
4485
+ for (const e of ctx.graph.edges) {
4486
+ if (e.kind !== "calls" || e.from === e.to) continue;
4487
+ (calleesBy.get(e.from) ?? calleesBy.set(e.from, []).get(e.from)).push(e.to);
4488
+ }
4489
+ const symById = /* @__PURE__ */ new Map();
4490
+ for (const n of ctx.graph.nodes) if (n.kind === "symbol") symById.set(n.id, n);
4491
+ const prevOf = /* @__PURE__ */ new Map();
4492
+ const visited = /* @__PURE__ */ new Set([from.id]);
4493
+ let frontier = [from.id];
4494
+ let found = false;
4495
+ for (let d = 0; d < maxDepth && !found && frontier.length > 0; d++) {
4496
+ const next = [];
4497
+ for (const cur2 of frontier) {
4498
+ for (const nb of calleesBy.get(cur2) ?? []) {
4499
+ if (visited.has(nb)) continue;
4500
+ visited.add(nb);
4501
+ prevOf.set(nb, cur2);
4502
+ if (nb === to.id) {
4503
+ found = true;
4504
+ break;
4505
+ }
4506
+ next.push(nb);
4507
+ }
4508
+ if (found) break;
4509
+ }
4510
+ frontier = next;
4511
+ }
4512
+ if (!found) {
4513
+ return textContent(
4514
+ `# call_path: ${from.name} \u2192 ${to.name}
4515
+
4516
+ _(no call path found within depth ${maxDepth})_`
4517
+ );
4518
+ }
4519
+ const chain = [];
4520
+ let cur = to.id;
4521
+ while (cur !== void 0) {
4522
+ chain.unshift(cur);
4523
+ if (cur === from.id) break;
4524
+ cur = prevOf.get(cur);
4525
+ }
4526
+ const syms = chain.map((id) => symById.get(id)).filter((s) => !!s);
4527
+ const hops = syms.length - 1;
4528
+ const rendered = syms.map((s) => `\`${s.name}\` (${s.file}:${s.start_line})`).join("\n \u2192 ");
4529
+ return textContent(
4530
+ `# call_path: ${from.name} \u2192 ${to.name} (${hops} hop${hops === 1 ? "" : "s"})
4531
+
4532
+ ${rendered}`
4533
+ );
4534
+ }
4400
4535
  var LIKELY_ENTRY_PATTERNS = [
4401
4536
  /(?:^|\/)main\.[a-z0-9_]+$/i,
4402
4537
  /(?:^|\/)index\.[a-z0-9_]+$/i,
@@ -4444,6 +4579,107 @@ _(no file is unreferenced \u2014 every file is either imported by another, has a
4444
4579
  );
4445
4580
  return textContent(lines.join("\n"));
4446
4581
  }
4582
+ var FIND_MAX = 12;
4583
+ var FIND_SIG_MAX = 140;
4584
+ function symbolEntry(s) {
4585
+ const sig = s.signature.trim().slice(0, FIND_SIG_MAX);
4586
+ return `\u2022 ${sig} \u2192 mcp__synthra__graph_read("${s.file}::${s.name}") [${s.symbol_kind}, L${s.start_line}]`;
4587
+ }
4588
+ var byFileLine = (a, b) => a.file === b.file ? a.start_line - b.start_line : a.file < b.file ? -1 : 1;
4589
+ function findSymbol(args, ctx) {
4590
+ const name = typeof args?.name === "string" ? args.name.trim() : "";
4591
+ if (!name) return errorContent("find_symbol: 'name' (string) is required");
4592
+ const symbols = ctx.graph.nodes.filter((n) => n.kind === "symbol");
4593
+ const lower = name.toLowerCase();
4594
+ const exact = symbols.filter((s) => s.name === name);
4595
+ const exactHits = exact.length > 0 ? exact : symbols.filter((s) => s.name.toLowerCase() === lower);
4596
+ if (exactHits.length > 0) {
4597
+ const sorted = exactHits.slice().sort(byFileLine);
4598
+ const shown2 = sorted.slice(0, FIND_MAX);
4599
+ const omitted2 = sorted.length - shown2.length;
4600
+ const lines2 = [
4601
+ `# find_symbol: "${name}"`,
4602
+ "",
4603
+ `Exact matches (${sorted.length}) \u2014 reuse one of these instead of writing a new one:`,
4604
+ ...shown2.map(symbolEntry)
4605
+ ];
4606
+ if (omitted2 > 0) lines2.push(`\u2026+${omitted2} more`);
4607
+ return textContent(lines2.join("\n"));
4608
+ }
4609
+ const tokens = new Set(tokenizeQuery(name));
4610
+ const scored = symbols.map((s) => {
4611
+ const n = s.name.toLowerCase();
4612
+ let score2 = 0;
4613
+ if (n.includes(lower) || lower.includes(n)) score2 += 2;
4614
+ for (const t of tokens) if (n.includes(t)) score2 += 1;
4615
+ return { s, score: score2 };
4616
+ }).filter((x) => x.score > 0).sort((a, b) => b.score - a.score || byFileLine(a.s, b.s));
4617
+ if (scored.length === 0) {
4618
+ return textContent(
4619
+ `# find_symbol: "${name}"
4620
+
4621
+ No symbol matching "${name}" \u2014 safe to create.`
4622
+ );
4623
+ }
4624
+ const shown = scored.slice(0, FIND_MAX);
4625
+ const omitted = scored.length - shown.length;
4626
+ const lines = [
4627
+ `# find_symbol: "${name}"`,
4628
+ "",
4629
+ `No exact match. Similar names (${scored.length}) \u2014 reuse or extend one before writing new:`,
4630
+ ...shown.map((x) => symbolEntry(x.s))
4631
+ ];
4632
+ if (omitted > 0) lines.push(`\u2026+${omitted} more`);
4633
+ return textContent(lines.join("\n"));
4634
+ }
4635
+ var DUP_INCLUDE = /* @__PURE__ */ new Set([
4636
+ "function",
4637
+ "class",
4638
+ "interface",
4639
+ "type",
4640
+ "enum",
4641
+ "const",
4642
+ "component"
4643
+ ]);
4644
+ function duplicateSymbols(args, ctx) {
4645
+ const limit = typeof args?.limit === "number" && args.limit > 0 ? Math.floor(args.limit) : 30;
4646
+ const defsByName = /* @__PURE__ */ new Map();
4647
+ const filesByName = /* @__PURE__ */ new Map();
4648
+ for (const n of ctx.graph.nodes) {
4649
+ if (n.kind !== "symbol" || !DUP_INCLUDE.has(n.symbol_kind)) continue;
4650
+ (defsByName.get(n.name) ?? defsByName.set(n.name, []).get(n.name)).push({
4651
+ file: n.file,
4652
+ line: n.start_line
4653
+ });
4654
+ (filesByName.get(n.name) ?? filesByName.set(n.name, /* @__PURE__ */ new Set()).get(n.name)).add(n.file);
4655
+ }
4656
+ const dups = [...defsByName.entries()].filter(([name]) => (filesByName.get(name)?.size ?? 0) >= 2).map(([name, defs]) => ({
4657
+ name,
4658
+ defs: defs.slice().sort((a, b) => a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1)
4659
+ })).sort((a, b) => b.defs.length - a.defs.length || a.name.localeCompare(b.name));
4660
+ if (dups.length === 0) {
4661
+ return textContent(
4662
+ "# Duplicate symbols\n\n_(no top-level symbol name is defined in more than one file)_"
4663
+ );
4664
+ }
4665
+ const shown = dups.slice(0, limit);
4666
+ const lines = [
4667
+ "# Duplicate symbols (consolidation candidates)",
4668
+ "",
4669
+ `${shown.length} of ${dups.length} name(s) defined in multiple files (functions/classes/types; methods excluded):`,
4670
+ ""
4671
+ ];
4672
+ for (const d of shown) {
4673
+ lines.push(
4674
+ `- \`${d.name}\` (${d.defs.length}): ${d.defs.map((x) => `${x.file}:${x.line}`).join(" \xB7 ")}`
4675
+ );
4676
+ }
4677
+ lines.push("");
4678
+ lines.push(
4679
+ "_advisory: the same name in multiple files may be intentional \u2014 verify before consolidating._"
4680
+ );
4681
+ return textContent(lines.join("\n"));
4682
+ }
4447
4683
  async function graphContinue(args, ctx) {
4448
4684
  const query = typeof args?.query === "string" ? args.query : "";
4449
4685
  if (!query) return errorContent("graph_continue: 'query' (string) is required");
@@ -4842,11 +5078,51 @@ async function getCommitsSince(projectRoot, sinceIso) {
4842
5078
  return [];
4843
5079
  }
4844
5080
  }
5081
+ async function getHeadSha(projectRoot) {
5082
+ try {
5083
+ const { stdout } = await execFileAsync3("git", ["rev-parse", "HEAD"], { cwd: projectRoot });
5084
+ return stdout.trim();
5085
+ } catch {
5086
+ return "";
5087
+ }
5088
+ }
5089
+ function parseDiffHunks(stdout) {
5090
+ const out = /* @__PURE__ */ new Map();
5091
+ let current = null;
5092
+ for (const line of stdout.split("\n")) {
5093
+ if (line.startsWith("+++ ")) {
5094
+ const p = line.slice(4).trim();
5095
+ current = p === "/dev/null" ? null : p.replace(/^b\//, "");
5096
+ } else if (current && line.startsWith("@@")) {
5097
+ const m = /\+(\d+)(?:,(\d+))?/.exec(line);
5098
+ if (!m) continue;
5099
+ const start = Number(m[1]);
5100
+ const count = m[2] === void 0 ? 1 : Number(m[2]);
5101
+ const end = count === 0 ? start : start + count - 1;
5102
+ const list = out.get(current) ?? [];
5103
+ list.push([start, end]);
5104
+ out.set(current, list);
5105
+ }
5106
+ }
5107
+ return out;
5108
+ }
5109
+ async function getChangedLineRanges(projectRoot, sinceRef) {
5110
+ if (!sinceRef) return /* @__PURE__ */ new Map();
5111
+ try {
5112
+ const { stdout } = await execFileAsync3("git", ["diff", "-U0", "--no-color", sinceRef, "--"], {
5113
+ cwd: projectRoot,
5114
+ maxBuffer: 16 * 1024 * 1024
5115
+ });
5116
+ return parseDiffHunks(stdout);
5117
+ } catch {
5118
+ return /* @__PURE__ */ new Map();
5119
+ }
5120
+ }
4845
5121
 
4846
5122
  // src/memory/session.ts
4847
5123
  import { mkdir as mkdir11, readFile as readFile17, writeFile as writeFile10 } from "fs/promises";
4848
5124
  import { dirname as dirname13 } from "path";
4849
- var SESSION_SCHEMA_VERSION = 1;
5125
+ var SESSION_SCHEMA_VERSION = 2;
4850
5126
  async function readSession(path) {
4851
5127
  try {
4852
5128
  const raw = await readFile17(path, "utf8");
@@ -4875,6 +5151,7 @@ async function captureSnapshot(ctx, branchOverride) {
4875
5151
  for (const p of ctx.activity.recentFilePaths(TOUCHED_WINDOW_MS)) touched.add(p);
4876
5152
  const prev = await readSession(ctx.paths.sessionState);
4877
5153
  const recentCommits = await getCommitsSince(ctx.paths.projectRoot, prev?.endedAt ?? "");
5154
+ const headSha = await getHeadSha(ctx.paths.projectRoot);
4878
5155
  const snapshot = {
4879
5156
  schema_version: SESSION_SCHEMA_VERSION,
4880
5157
  endedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -4885,7 +5162,8 @@ async function captureSnapshot(ctx, branchOverride) {
4885
5162
  tasks: tasks.entries.map((e) => e.content),
4886
5163
  decisions: decisions.entries.map((e) => e.content),
4887
5164
  next: next.entries.map((e) => e.content)
4888
- }
5165
+ },
5166
+ headSha
4889
5167
  };
4890
5168
  await writeSession(ctx.paths.sessionState, snapshot);
4891
5169
  }
@@ -5333,6 +5611,34 @@ var RESUME_PRIMER_MAX_CHARS = 2720;
5333
5611
  var MAX_FILES = 15;
5334
5612
  var MAX_COMMITS2 = 5;
5335
5613
  var MAX_BULLETS2 = 3;
5614
+ var MAX_CHANGED_SYMBOLS = 20;
5615
+ var CHANGED_SIG_MAX = 100;
5616
+ function changedSymbols(ranges, graph) {
5617
+ if (ranges.size === 0) return [];
5618
+ const hits = [];
5619
+ for (const n of graph.nodes) {
5620
+ if (n.kind !== "symbol") continue;
5621
+ const rs = ranges.get(n.file);
5622
+ if (!rs) continue;
5623
+ if (rs.some(([a, b]) => a <= n.end_line && b >= n.start_line)) hits.push(n);
5624
+ }
5625
+ hits.sort((a, b) => a.file === b.file ? a.start_line - b.start_line : a.file < b.file ? -1 : 1);
5626
+ return hits;
5627
+ }
5628
+ function changedSymbolsSection(ranges, graph) {
5629
+ const hits = changedSymbols(ranges, graph);
5630
+ if (hits.length === 0) return [];
5631
+ const shown = hits.slice(0, MAX_CHANGED_SYMBOLS);
5632
+ const lines = ["", "### Changed symbols (since last session)"];
5633
+ for (const s of shown) {
5634
+ lines.push(
5635
+ `- \`${s.file}::${s.name}\` (${s.symbol_kind}) \u2014 ${s.signature.trim().slice(0, CHANGED_SIG_MAX)}`
5636
+ );
5637
+ }
5638
+ const more = hits.length - shown.length;
5639
+ if (more > 0) lines.push(`_(+${more} more)_`);
5640
+ return lines;
5641
+ }
5336
5642
  function legacyPrimer(ctx) {
5337
5643
  const g = ctx.graph;
5338
5644
  return `Synthra context loaded for ${g.root}.
@@ -5343,7 +5649,7 @@ function hasContent(snap) {
5343
5649
  snap.recentCommits.length || snap.filesTouched.length || snap.summary.tasks.length || snap.summary.next.length || snap.summary.decisions.length
5344
5650
  );
5345
5651
  }
5346
- function buildResumeDigest(snap, branchNow) {
5652
+ function buildResumeDigest(snap, branchNow, changedSymbolLines = []) {
5347
5653
  const plural = (n) => n === 1 ? "" : "s";
5348
5654
  const head = `## Since you were last here \u2014 ${snap.branch} (${snap.recentCommits.length} commit${plural(snap.recentCommits.length)}, ${snap.filesTouched.length} file${plural(snap.filesTouched.length)} touched)`;
5349
5655
  const essential = [head];
@@ -5364,7 +5670,7 @@ function buildResumeDigest(snap, branchNow) {
5364
5670
  essential.push("", "### Recent decisions");
5365
5671
  for (const d of snap.summary.decisions.slice(0, MAX_BULLETS2)) essential.push(`- ${d}`);
5366
5672
  }
5367
- const extra = [];
5673
+ const extra = [...changedSymbolLines];
5368
5674
  if (snap.recentCommits.length) {
5369
5675
  extra.push("", "### Recent commits");
5370
5676
  for (const c of snap.recentCommits.slice(0, MAX_COMMITS2)) {
@@ -5391,7 +5697,12 @@ async function handlePrime(ctx, port) {
5391
5697
  return { primer: legacy, port };
5392
5698
  }
5393
5699
  const branchNow = await currentBranch(ctx.paths.projectRoot);
5394
- const digest = buildResumeDigest(snap, branchNow);
5700
+ let changedSymbolLines = [];
5701
+ if (snap.headSha) {
5702
+ const ranges = await getChangedLineRanges(ctx.paths.projectRoot, snap.headSha);
5703
+ changedSymbolLines = changedSymbolsSection(ranges, ctx.graph);
5704
+ }
5705
+ const digest = buildResumeDigest(snap, branchNow, changedSymbolLines);
5395
5706
  return { primer: `${digest}
5396
5707
 
5397
5708
  ---