@jefuriiij/synthra 0.1.18 → 0.1.20

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,51 @@ For older versions, see [GitHub Releases](https://github.com/jefuriiij/synthra/r
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.1.20] — 2026-06-06
11
+
12
+ ### Fixed
13
+
14
+ - **Gate (Moat) no longer blocks Grep/Glob queries the graph cannot answer with a symbol.**
15
+ Previously, the PreToolUse gate blocked whenever retrieval confidence was `medium` or `high`,
16
+ but confidence is driven by keyword and path hits too — not only by symbol matches. This meant
17
+ literal/attribute/CSS-selector patterns (`data-tour=`, `class=`, `: 100%`, `.filter-bar`,
18
+ `<div>`) and path-only Globs were blocked and redirected to `graph_read`, which has no symbol
19
+ slice to return for those queries, so Claude fell back to Grep or a whole-file Read anyway —
20
+ a wasted round-trip. Found across multiple dogfood sessions including well-indexed Svelte
21
+ repos. Two new guards close the gap:
22
+ - **Query-shape pre-filter** — Grep patterns that target markup, CSS, attributes, or string
23
+ literals are allowed through up front, before the retrieval step runs.
24
+ - **Symbol-hit requirement** — the gate now only blocks when retrieval matched a symbol whose
25
+ name the query mentions exactly. `RetrievalResult` gained a `symbolMatched` flag; the scorer
26
+ exposes `exactSym`.
27
+
28
+ Net effect: genuine symbol lookups still block (verified: `fetchWith429Retry`,
29
+ `MAX_ROWS_PER_TABLE`, `verifyPin`, `SOCKET_AUTH_SECRET`, `seedCredentials`); queries that
30
+ could never have been answered by the graph now allow through without the wasted redirect.
31
+ No API, protocol, or policy-block change — purely server-side gate behavior.
32
+
33
+ - **Gate and rank test coverage added** (`tests/gate.test.ts`, `tests/rank.test.ts`).
34
+ Chips at the v0.2 backlog item to fill vitest tests beyond `it.todo` placeholders.
35
+
36
+ ---
37
+
38
+ ## [0.1.19] — 2026-06-01
39
+
40
+ ### Changed
41
+
42
+ - **Policy block v4: targeted Read-before-Edit for graph-discovered files.**
43
+ Claude Code's `Edit` tool requires a file to have been opened with its own
44
+ `Read` tool; a `graph_read` slice does not satisfy that gate. Previously,
45
+ editing a file known only through `graph_read` would fail with *"File has
46
+ not been read yet"* and force a whole-file `Read` — eroding token savings on
47
+ edit-heavy sessions. The v4 policy now instructs: take the line range already
48
+ reported in the `graph_read` header (e.g. `…::handler (L120-168)`), do a
49
+ targeted `Read` with matching `offset`/`limit`, then `Edit`. This satisfies
50
+ the gate while keeping the read small. Existing v3 blocks auto-upgrade on the
51
+ next `syn .` run.
52
+
53
+ ---
54
+
10
55
  ## [0.1.18] — 2026-06-01
11
56
 
12
57
  ### Fixed
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.1.18",
21
+ version: "0.1.20",
22
22
  publishConfig: {
23
23
  access: "public"
24
24
  },
@@ -3213,7 +3213,7 @@ import { basename as basename4 } from "path";
3213
3213
  // src/hooks/claude-md.ts
3214
3214
  import { readFile as readFile9, writeFile as writeFile4 } from "fs/promises";
3215
3215
  import { basename as basename3, dirname as dirname6 } from "path";
3216
- var POLICY_VERSION = 3;
3216
+ var POLICY_VERSION = 4;
3217
3217
  var POLICY_BEGIN = `<!-- synthra-policy v${POLICY_VERSION} BEGIN -->`;
3218
3218
  var POLICY_END = `<!-- synthra-policy v${POLICY_VERSION} END -->`;
3219
3219
  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;
@@ -3282,6 +3282,17 @@ function policyBlock() {
3282
3282
  "- If `graph_continue`'s `Files` list contains a `::` entry, pass it",
3283
3283
  " verbatim to `graph_read`.",
3284
3284
  "",
3285
+ "### Editing a file",
3286
+ "",
3287
+ "Claude Code's `Edit` tool (and `Write` when overwriting) only accepts a",
3288
+ "file that was opened with the **`Read` tool** \u2014 a `graph_read` slice does",
3289
+ 'not count, and editing such a file fails with *"File has not been read',
3290
+ 'yet."* So before editing a file you only know through `graph_read`: take',
3291
+ "the line range from its header (e.g. `\u2026::handler (L120-168)`), `Read` that",
3292
+ "file with a matching `offset`/`limit`, then `Edit`. That satisfies the",
3293
+ "gate while keeping the read small \u2014 don't whole-file `Read` unless the",
3294
+ "edit spans most of the file.",
3295
+ "",
3285
3296
  "### Don'ts",
3286
3297
  "",
3287
3298
  "- Don't Grep / Glob before calling `graph_continue` when required \u2014 the",
@@ -3612,10 +3623,12 @@ function scoreFiles(inputs) {
3612
3623
  }
3613
3624
  const symbols = symbolsByFile.get(file.path) ?? [];
3614
3625
  let symHits = 0;
3626
+ let exactSym = 0;
3615
3627
  for (const sym of symbols) {
3616
3628
  const name = sym.name.toLowerCase();
3617
3629
  if (qTokens.has(name)) {
3618
3630
  symHits += 3;
3631
+ exactSym += 1;
3619
3632
  } else {
3620
3633
  for (const t of qTokens) {
3621
3634
  if (name.includes(t) || t.includes(name)) {
@@ -3640,7 +3653,7 @@ function scoreFiles(inputs) {
3640
3653
  score2 += 5;
3641
3654
  reasons.push("seed");
3642
3655
  }
3643
- scored.push({ file, score: score2, reasons });
3656
+ scored.push({ file, score: score2, reasons, symHits, exactSym });
3644
3657
  }
3645
3658
  const positivePaths = new Set(scored.filter((s) => s.score > 0).map((s) => s.file.path));
3646
3659
  if (positivePaths.size > 0) {
@@ -3675,7 +3688,8 @@ async function retrieve(graph, query, options = {}) {
3675
3688
  return {
3676
3689
  files: [],
3677
3690
  confidence: "low",
3678
- reason: qTokens.length === 0 ? "empty query" : "empty graph"
3691
+ reason: qTokens.length === 0 ? "empty query" : "empty graph",
3692
+ symbolMatched: false
3679
3693
  };
3680
3694
  }
3681
3695
  const rankInputs = {
@@ -3691,10 +3705,13 @@ async function retrieve(graph, query, options = {}) {
3691
3705
  return {
3692
3706
  files: [],
3693
3707
  confidence: "low",
3694
- reason: `no matches for ${JSON.stringify(qTokens)}`
3708
+ reason: `no matches for ${JSON.stringify(qTokens)}`,
3709
+ symbolMatched: false
3695
3710
  };
3696
3711
  }
3697
- const top = positive.slice(0, topK).map((s) => s.file);
3712
+ const topScored = positive.slice(0, topK);
3713
+ const top = topScored.map((s) => s.file);
3714
+ const symbolMatched = topScored.some((s) => s.exactSym > 0);
3698
3715
  const topScore = positive[0]?.score ?? 0;
3699
3716
  const secondScore = positive[1]?.score ?? 0;
3700
3717
  let confidence;
@@ -3706,7 +3723,8 @@ async function retrieve(graph, query, options = {}) {
3706
3723
  return {
3707
3724
  files: top,
3708
3725
  confidence,
3709
- reason: `top: ${reasons}`
3726
+ reason: `top: ${reasons}`,
3727
+ symbolMatched
3710
3728
  };
3711
3729
  }
3712
3730
 
@@ -4541,6 +4559,16 @@ function extractQuery(toolName, input) {
4541
4559
  }
4542
4560
  return null;
4543
4561
  }
4562
+ function looksLikeNonSymbolQuery(pattern) {
4563
+ if (/<\/?[a-zA-Z]/.test(pattern)) return true;
4564
+ if (/[a-zA-Z][\w-]*-[\w-]*\s*=/.test(pattern)) return true;
4565
+ if (/\{/.test(pattern)) return true;
4566
+ if (/\\\.[a-zA-Z]/.test(pattern)) return true;
4567
+ if (/:\s*\d/.test(pattern) || /\d(?:px|rem|em|vh|vw)\b/.test(pattern) || /\d%/.test(pattern)) {
4568
+ return true;
4569
+ }
4570
+ return false;
4571
+ }
4544
4572
  function recentlyTouchedMatchesQuery(recentPaths, queryTokens) {
4545
4573
  const matches = [];
4546
4574
  for (const path of recentPaths) {
@@ -4582,6 +4610,14 @@ async function handleGate(req, ctx) {
4582
4610
  await logDecision(ctx, req.tool_name, null, res2.decision, res2.reason);
4583
4611
  return res2;
4584
4612
  }
4613
+ if (req.tool_name === "Grep" && looksLikeNonSymbolQuery(query)) {
4614
+ const res2 = {
4615
+ decision: "allow",
4616
+ reason: `"${query}" targets markup/CSS/attributes, not code symbols \u2014 letting Grep through (the graph indexes symbols).`
4617
+ };
4618
+ await logDecision(ctx, req.tool_name, query, res2.decision, res2.reason);
4619
+ return res2;
4620
+ }
4585
4621
  const retrieval = await retrieve(ctx.graph, query);
4586
4622
  if (retrieval.confidence === "low") {
4587
4623
  const res2 = {
@@ -4602,6 +4638,14 @@ async function handleGate(req, ctx) {
4602
4638
  await logDecision(ctx, req.tool_name, query, res2.decision, res2.reason);
4603
4639
  return res2;
4604
4640
  }
4641
+ if (!retrieval.symbolMatched) {
4642
+ const res2 = {
4643
+ decision: "allow",
4644
+ reason: `confidence=${retrieval.confidence} but only keyword/path matched (no symbol the query names) \u2014 graph_read can't slice it, letting ${req.tool_name} through.`
4645
+ };
4646
+ await logDecision(ctx, req.tool_name, query, res2.decision, res2.reason);
4647
+ return res2;
4648
+ }
4605
4649
  const top = retrieval.files.slice(0, 3).map((f) => f.path).join(", ");
4606
4650
  const res = {
4607
4651
  decision: "block",