Dashboard › Code › Session sc1d95e4bc11
sc1d95e4bc11579e8ESM dist/bin/cli.js 208.00 B ESM dist/chunk-C5WUJ7O2.js 163.00 B ESM dist/impl-M54ALLRU.js 9.01 KB ESM dist/index.js.map 712.00 B ESM dist/bin/bash-complete.js.map 1.05 KB ESM dist/chunk-HJBHPBXQ.js.map 9.41 KB ESM dist/bin/cli.js.map 406.00 B ESM dist/chunk-C5WUJ7O2.js.map 789.00 B ESM dist/impl-M54ALLRU.js.map 37.48 KB ESM ⚡️ Build success in 51ms DTS Build start DTS ⚡️ Build success in 2048ms DTS dist/bin/cli.d.ts 20.00 B DTS dist/index.d.ts 596.00 B DTS dist/bin/bash-complete.d.ts 20.00 B === build exit: 0 ===
The build is working, so now I need to run an end-to-end test on Linux to confirm the host build still functions correctly and generates a binary with code cache that doesn't trigger the warning. I'm planning to use the test directory with the cached Node 22.14.0, keeping in mind that the default nodeVersion setting resolves to "local" which means whatever Node version runs the CLI. If I were to use the Volta-default Node 24.16.0, it would download that version unless it's already cached, but since the fossilize .node-cache only has 22.14.0, I'll run with the project's Node version via Volta to leverage what's already cached.
The Volta config pins Node 22.14.0 for this project, so when I run the CLI from this directory, Volta should automatically use that version. I'm going to test the CLI directly on the sample with a single-platform host build (linux-x64), code cache enabled, and check stderr for any warnings. The cached binary name should be node-v22.14.0-linux-x64, which ...
Build succeeds. Now an end-to-end test on linux to confirm the host build still works and stays warning-free (using the project's pinned Node 22.14.0 and cached binary):
diff --git a/.lore.md b/.lore.md index 67f6a44..10f103f 100644 --- a/.lore.md +++ b/.lore.md @@ -10,11 +10,8 @@ <!-- lore:019ea9c2-23e7-78a3-b559-416743674bc7 --> * **Docs-site structure: Zod .describe() + generator + check-docs CI job**: Docs-site auto-generation: two pages in \`packages/website/src/content/docs/docs/\`. \`generate-config-docs.ts\` walks Zod \`.shape\`+\`.description\`+\`unwrap()\` directly (NOT zod-to-json-schema — fails on ZodEffects). \`generate-env-docs.ts\` walks gateway/core source for \`process.env.LORE\_\*\`/\`env.LORE\_\*\` refs, extracts JSDoc, parses defaults, groups by subsystem. Both wired via \`pnpm generate:docs\` + \`pnpm check:docs\` (--check exits 1 on drift). CI \`check-docs\` job gates on both. GOTCHA: backticks inside template literals MUST be \`\\\`\` — unescaped backticks cause esbuild parse errors. Zod 4 internals: \`s.description\` is top-level (NOT \`s.\_def.description\`). Default wrapper: \`\_def.type === 'default'\`, ...
The .lore.md file gets auto-updated from the background lore process and follows repo convention, so I'm including it safely since it contains only project knowledge. Now I'm setting up a branch called fix/cross-project-knowledge-leak, staging all the files including the new test and knowledge file, committing with a conventional message, pushing, and opening the PR.
The .lore.md change is the background lore process's knowledge update (no secrets). Per repo convention I'll include it. Let me create a branch, commit, push, and open the PR.
v22.14.0 Resolved Node.js version 'local' to 22.14.0 > ../.node-cache/node-v22.14.0-linux-x64 --experimental-sea-config dist-bin/sea-config.json Creating binary for linux-x64 (dist-bin/sample)... > strip --strip-unneeded dist-bin/sample-linux-x64 Generating code-cache blob for host platform (linux-x64)... > dist-bin/sample-linux-x64.cacheseed --experimental-sea-config dist-bin/sea-config.json.codecache Injecting blob into node executable: dist-bin/sample-linux-x64 (with code cache) warning: Can't find string offset for section name '.note.100' warning: Can't find string offset for section name '.note.100' warning: Can't find string offset for section name '.note.100' warning: Can't find string offset for section name '.note.100' warning: Can't find string offset for section name '.note.100' warning: Can't find string offset for section name '.note.100' warning: Can't find string offset for section name '.note.100' warning: Can't find string offset for section name '.note.100' warning: ...
The build succeeded with a new process: I'm generating a base blob without code caching, creating and stripping the linux-x64 binary, then generating a code-cache blob using the prepared seed file before injecting it back and cleaning up. There's a warning about a missing string offset for a section name that I'm seeing.
The cacheseed file was created and cleaned up properly—I verified it's gone by checking the directory listing. Now I need to run the binary and check if stderr shows "Code cache data rejected".
The new flow works: it generates the code-cache blob from sample-linux-x64.cacheseed (the prepared copy) and cleaned up the seed. Now run the binary and check for the warning + correct output:
9db9376 fix: stop cross-project knowledge leakage and hide internal workers
remote: remote: Create a pull request for 'fix/cross-project-knowledge-leak' on GitHub by visiting: remote: https://github.com/BYK/loreai/pull/new/fix/cross-project-knowledge-leak remote: To https://github.com/BYK/loreai.git * [new branch] fix/cross-project-knowledge-leak -> fix/cross-project-knowledge-leak branch 'fix/cross-project-knowledge-leak' set up to track 'origin/fix/cross-project-knowledge-leak'.
I need to create the PR using gh, but first I should figure out what the base branch is—the recent commits suggest main is the default, so I'll either check with gh repo view or just let gh handle it automatically, then write out a detailed PR body.
Branch pushed. Let me confirm the default base branch, then open the PR.
(no code-cache generation, as expected) === run === Hello world! PASS: clean run, no code cache
The --no-code-cache flag successfully skips code-cache generation. Since I can't verify the macOS flow locally, I'll rely on CI to validate it. Now I'm updating the first todo and moving on to extend the CI smoke test in build.yml so it runs across all operating systems and checks that "Code cache data rejected" doesn't appear in stderr.
I'm setting up the CI step to handle platform-specific binary names and paths, accounting for the fact that macOS runners use arm64 architecture which exercises the actual bug and fix. The step will bundle the asset, run the binary, and capture both the exit code and stderr to verify no code cache rejection occurs.
Writing the CI test step... I should keep the bare smoke test as-is since it only validates that the build doesn't crash, then update the asset test to run on all platforms and add the code-cache assertion. ...
--no-code-cache works. Linux fully validated. Now let me extend the CI smoke test to assert (a) code cache is embedded and (b) no rejection at runtime — across all OSes, including the arm64 macos-latest runner where the bug lives.
https://github.com/BYK/loreai/pull/683
PR is up: https://github.com/BYK/loreai/pull/683
fix: stop cross-project knowledge leakage and hide internal workers → base main, branch fix/cross-project-knowledge-leak.
It bundles all three fixes (cross-project injection + migration, hidden workers, misattribution/merge hardening) with tests. CI will run the full suite, lint, typecheck, and docs checks — all of which pass locally (2506 tests, typecheck, biome, check:docs). The .lore.md auto-update from the background lore process is included in the commit per repo convention.
Edit applied successfully.
I should check if actionlint is available to validate the workflow YAML syntax. On Windows with Git Bash, the binary path dist-bin/sample-win-x64.exe should work fine, but I'm wondering about potential issues when actually running it on Windows.
The SEA binary should handle node:sea getRawAsset correctly, and Windows should produce the "with code cache" output as expected. Both the cacheseed and final versions are unsigned PE copies with matching flags, so they should be accepted. The main concern is that the matrix runs the bare smoke test first at the repo root, which downloads node and caches it at .node-cache, then the asset test runs in the test directory with its own .node-cache, causing a duplicate download. That's wasteful but shouldn't break anything. Let me validate the YAML with actionlint.
Let me validate the workflow with actionlint (the repo runs it in CI):
{"state":"CLOSED","stateReason":"COMPLETED"}
could not determine current branch: failed to run git: not on any branch
The gh CLI is confused by jj's detached HEAD. Let me use the --merge API directly:
! Pull request BYK/loreai#682 was already merged
The PR was already merged (likely auto-merge was enabled or you merged it manually). Let me confirm the final state:
{"mergedAt":"2026-06-10T21:47:44Z","mergedBy":"BYK","state":"MERGED","url":"https://github.com/BYK/loreai/pull/682"}
PR #682 is merged (by you, at 21:47 UTC).
To recover your people entities, once you restart the gateway with the new code:
/ui/knowledge to confirm all 87 entries are now visible in two sections.lore data reground-entities --dry-run (or use the "Preview" button on /ui/entities) to see what people would be re-derived.lore data reground-entities --yes (or "Rebuild all" button) to actually recreate them.You are a senior code reviewer performing a HARSH, multi-pass review of PR #683 on this repo. The PR fixes three bugs related to cross-project knowledge leakage, hidden worker visibility, and misattribution/merge hardening. Your job is to find EVERY flaw — logic errors, missed edge cases, invariant violations, test gaps, PR description inaccuracies, and anything else that could ship a bug or mislead a reviewer.
Do at least 3 passes:
I'll start by reading the PR description and all the changed files systematically.
remote: https://github.com/BYK/fossilize/pull/new/fix/sea-code-cache-rejected remote: To https://github.com/BYK/fossilize.git * [new branch] fix/sea-code-cache-rejected -> fix/sea-code-cache-rejected branch 'fix/sea-code-cache-rejected' set up to track 'origin/fix/sea-code-cache-rejected'.
{"body":"## Why\n\nA coding agent working in an unrelated repo (`spotlight`) found lore's **own** knowledge entries injected into its context (topics like \"gateway workers\", \"providerID\", \"Bun→Node migration\", \".lore.md internals\") and saw lore's internal workers (`lore-distill`, `lore-curator`, `lore-query-expand`) in its agent/skill picker. Investigation surfaced three distinct bugs.\n\n## Bug 1 — cross-project knowledge leak (the entries spotlight saw)\n\nLore's preference entries were stored with `cross_project = 1` and injected into **every** project:\n\n- **Curator over-marked entries.** `curator.ts` defaulted `crossProject: op.crossProject ?? true`, and the prompt told the LLM \"Default is true\". Project-specific engineering directives got flagged globally shareable.\n- **Retrieval ignored the origin.** `forSession()` loaded the cross pool with `WHERE (project_id IS NULL OR cross_project = 1)` — no originating-project filter.\n- **Preferences bypassed relevance ...
Now let me read all the changed files systematically. I'll start with the core changes.
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_eb38a270800116H9m6mk00w78J - -<!-- lore:019ea574-982f-76b5-811b-6695654733cb --> -* **Always implement worker health monitoring and source attribution for background tasks**: When adding or modifying background worker infrastructure (distillation, curation, embedding, cache warming), consistently add: (1) graduated failure escalation with tiers (warn → Sentry → user-facing warnings → exceptions), (2) per-session sliding window failure tracking, (3) recovery detection on success, (4) source attribution columns (worker\_provider\_id, worker\_model\_id) to database tables, (5) wiring of health hooks at every call site in pipeline, idle, and adapter layers. +<!-- lore:019eb34e-967c-77b6-8d0b-6102789893c6 --> +* **Always check CI results after pushing branches and investigate failures immediately**: Always check CI results after pushing branches and investigate failures immediately. ...
Now let me read the full source files to understand the context around each change.
<path>/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/core/src/ltm.ts</path>
<type>file</type>
<content>
380: const pid = ensureProject(projectPath);
381: if (includeCross) {
382: return db()
383: .query(
384: `SELECT ${KNOWLEDGE_COLS} FROM knowledge
385: WHERE (project_id = ? OR (project_id IS NULL) OR (cross_project = 1))
386: AND confidence > 0.2
387: ORDER BY confidence DESC, updated_at DESC`,
388: )
389: .all(pid) as KnowledgeEntry[];
390: }
391: return db()
392: .query(
393: `SELECT ${KNOWLEDGE_COLS} FROM knowledge
394: WHERE project_id = ?
395: AND confidence > 0.2
396: ORDER BY confidence DESC, updated_at DESC`,
397: )
398: .all(pid) as KnowledgeEntry[];
399: }
400:
401: type Scored = { entry: KnowledgeEntry; score: number };
402:
403: /** BM25 column weights for knowledge_fts: title, content, category. ...
Let me continue reading the relevant parts of ltm.ts to see the scoreFTS function and verify the cross-project gating in that path, then read the other changed files.
<path>/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/core/src/ltm.ts</path>
<type>file</type>
<content>
140: if (crossExisting) {
141: update(crossExisting.id, dedupUpdate);
142: return crossExisting.id;
143: }
144:
145: // Fuzzy dedup: check for title-similar entries via FTS5 + word-overlap.
146: // This catches near-duplicates the curator creates with slightly different
147: // titles for the same concept (e.g. "Upgrade lock bug" vs "Upgrade binary
148: // lock re-entry bug"). Placed after exact checks (cheaper checks first).
149: const fuzzyMatch = findFuzzyDuplicate({
150: title: input.title,
151: projectId: pid,
152: });
153: if (fuzzyMatch) {
154: update(fuzzyMatch.id, dedupUpdate);
155: return fuzzyMatch.id;
156: }
157: }
158:
159: const id = input.id ?? uuidv7();
160: const now = Date.now();
161: const confidence =
162: ...
<path>/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/core/src/ltm.ts</path> <type>file</type> <content> 60: const KNOWLEDGE_COLS = 61: "id, project_id, category, title, content, source_session, cross_project, confidence, created_at, updated_at, metadata, created_by, updated_by, sensitivity, promotion_status, promoted_at, approval_status, approved_by, approved_at, source_user_id, source_entry_id, last_accessed_at, worker_provider_id, worker_model_id"; 62: 63: /** Same columns with table alias prefix for use in JOIN queries. */ 64: const KNOWLEDGE_COLS_K = 65: "k.id, k.project_id, k.category, k.title, k.content, k.source_session, k.cross_project, k.confidence, k.created_at, k.updated_at, k.metadata, k.created_by, k.updated_by, k.sensitivity, k.promotion_status, k.promoted_at, k.approval_status, k.approved_by, k.approved_at, k.source_user_id, k.source_entry_id, k.last_accessed_at, k.worker_provider_id, ...
Now let me read the scoreFTS helper and the vector scoring path to check if foreign entries are gated there too:
Found 1 matches /home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/core/src/ltm.ts: Line 864: function scoreFTS(
<path>/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/core/src/ltm.ts</path>
<type>file</type>
<content>
864: function scoreFTS(
865: projectEntries: KnowledgeEntry[],
866: crossEntries: KnowledgeEntry[],
867: ftsScores: Map<string, number>,
868: ): { scoredProject: Scored[]; scoredCross: Scored[] } {
869: const rawScored: Scored[] = projectEntries.map((entry) => ({
870: entry,
871: score: (ftsScores.get(entry.id) ?? 0) * entry.confidence,
872: }));
873: const matched = rawScored.filter((s) => s.score > 0);
874: const matchedIds = new Set(matched.map((s) => s.entry.id));
875:
876: const safetyNet = projectEntries
877: .filter((e) => !matchedIds.has(e.id))
878: .slice(0, PROJECT_SAFETY_NET)
879: .map((e) => ({ entry: e, score: 0.001 * e.confidence }));
880:
881: const scoredProject = [...matched, ...safetyNet];
882:
883: const scoredCross = crossEntries
884: .filter((e) => ...
Now let me look at the pipeline.ts changes in full context, the config.ts changes, and check for callers of the changed functions:
<path>/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/gateway/src/pipeline.ts</path> <type>file</type> <content> 1195: `Data may be misattributed. Fix: launch your agent via \`lore run\`, ` + 1196: `or have your client send the "X-Lore-Project: /path/to/project" header ` + 1197: `(provider-agnostic; e.g. via ANTHROPIC_CUSTOM_HEADERS for Claude Code, ` + 1198: `the OpenCode/Pi plugins, or your client's custom-header mechanism).`, 1199: ); 1200: } 1201: 1202: return projectPath; 1203: } 1204: 1205: /** 1206: * Migrate all rows stored under a provisional project path (a cwd fallback or 1207: * a synthetic `/__lore_unattributed__/...` bucket) into the real project once 1208: * a confident path is learned for the session. ...
Now let me check the isUnattributedProjectPath export and its definition, and verify the import in pipeline.ts:
Found 3 matches
/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/core/src/db.ts:
Line 1463: export function isUnattributedProjectPath(path: string): boolean {
Line 1558: const derivedName = isUnattributedProjectPath(path)
/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/core/src/index.ts:
Line 76: isUnattributedProjectPath,
Good. Now let me check where forSession is called to understand how categories: ["preference"] is used, and check if the vector scoring path also needs foreign gating:
Found 39 matches
/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/core/test/cross-project-gating.test.ts:
Line 63: const result = await ltm.forSession(PROJ_B, undefined, 100_000, {
Line 89: const noCtx = await ltm.forSession(PROJ_B, undefined, 100_000, {
Line 97: const withCtx = await ltm.forSession(PROJ_B, undefined, 100_000, {
Line 131: const result = await ltm.forSession(PROJ_B, undefined, 100_000);
/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/core/test/ltm.test.ts:
Line 408: const result = await ltm.forSession(PROJ, SESSION, 10_000);
Line 432: const result = await ltm.forSession(PROJ, SESSION, 200);
Line 474: const result = await ltm.forSession(PROJ, SESSION, 10_000);
Line 492: const result = await ltm.forSession(PROJ, "brand-new-session", 10_000);
Line 537: const result = await ltm.forSession(PROJ, ...
Let me check the actual calls in pipeline.ts to see how forSession is invoked with category filters:
<path>/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/gateway/src/pipeline.ts</path>
<type>file</type>
<content>
4300: const prefBudget = getPreferenceLtmBudget(cfg.budget.preferenceLtm);
4301: const isFirstTurn =
4302: sessionID != null && !temporal.hasMessages(projectPath, sessionID);
4303: const contextHint = lastUserTextTrimmed(req);
4304:
4305: // --- system[1]: Stable LTM (preferences) ---
4306: // Computed once per session and pinned for ≥1h. NOT invalidated by
4307: // curation — even if a preference changes, we keep the cached version
4308: // so the Anthropic 1h prompt cache prefix stays warm.
4309: // Uses a dedicated budget independent of context-bound LTM.
4310: let stable = stableLtmCache.get(sessionID);
4311: if (!stable) {
4312: const prefEntries = await ltm.forSession(
4313: projectPath,
4314: sessionID,
4315: ...
Now let me check the context-bound LTM re-run path and the vector scoring path more carefully for the foreign entry gating gap:
<path>/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/gateway/src/pipeline.ts</path>
<type>file</type>
<content>
4440: projectPath,
4441: sessionID,
4442: });
4443:
4444: // Drop trailing pure-text assistant messages to prevent prefill errors
4445: for (;;) {
4446: const last = result.messages.at(-1);
4447: if (!last || last.info.role === "user") break;
4448: const hasToolParts = last.parts.some((p) => p.type === "tool");
4449: if (hasToolParts) break;
4450: result.messages.pop();
4451: }
4452:
4453: // --- 7b. LTM refresh on emergency layer ---
4454: // Layer 4 (emergency/transient reset) signals that the context was fully
4455: // reset. Re-run forSession() to re-rank context-bound entries by relevance
4456: // to the current conversation state — entries that became relevant mid-
4457: // session (e.g. ...
Now let me check the vector scoring path in ltm.ts more carefully - the cross-entry scoring around lines 714-724 that was NOT changed:
<path>/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/core/src/ltm.ts</path>
<type>file</type>
<content>
670: let scoredProject: Scored[];
671: let scoredCross: Scored[];
672:
673: if (sessionContext.trim().length > 20 && embedding.isAvailable()) {
674: // Vector scoring: embed session context, score entries by cosine similarity.
675: // Captures semantic matches (e.g., "OpenAI Batch API" ↔ "batch queue worker")
676: // that keyword-based FTS5 misses.
677: let vectorScores: Map<string, number>;
678: try {
679: const [contextVec] = await embedding.embed([sessionContext], "query");
680: const hits = embedding.vectorSearch(contextVec, 50, excludeFilter);
681: vectorScores = new Map(hits.map((h) => [h.id, h.similarity]));
682: } catch (err) {
683: log.warn("Vector scoring failed, falling back to FTS5:", err);
684: vectorScores = new Map();
685: }
686:
687: if ...
This is an important finding. The vector scoring path (lines 714-724) and the FTS5 scoreFTS path (lines 883-884) both filter cross entries by whether they have a score from the search — which IS a relevance gate. But it's a different kind of gate: ftsScores.has(e.id) returns true if the entry appears in ANY FTS5 match result, even with a score of 0. Let me verify this and also check whether crossEntries in these paths could still include foreign entries that accidentally match.
The key insight: The PR description says "the relevance-scored path was already gated" — meaning the vector/FTS paths were considered safe because they only include entries with relevance matches. The PR only explicitly added gating to the preference fast path and the no-context fallback. Let me verify this claim is correct by checking the scoreFTS function more carefully:
<path>/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/core/src/ltm.ts</path>
<type>file</type>
<content>
864: function scoreFTS(
865: projectEntries: KnowledgeEntry[],
866: crossEntries: KnowledgeEntry[],
867: ftsScores: Map<string, number>,
868: ): { scoredProject: Scored[]; scoredCross: Scored[] } {
869: const rawScored: Scored[] = projectEntries.map((entry) => ({
870: entry,
871: score: (ftsScores.get(entry.id) ?? 0) * entry.confidence,
872: }));
873: const matched = rawScored.filter((s) => s.score > 0);
874: const matchedIds = new Set(matched.map((s) => s.entry.id));
875:
876: const safetyNet = projectEntries
877: .filter((e) => !matchedIds.has(e.id))
878: .slice(0, PROJECT_SAFETY_NET)
879: .map((e) => ({ entry: e, score: 0.001 * e.confidence }));
880:
881: const scoredProject = [...matched, ...safetyNet];
882:
883: const scoredCross = crossEntries
884: .filter((e) => ...
Now I need to understand scoreEntriesFTS more carefully — specifically whether ftsScores.has(e.id) at line 884 can include entries with a score of 0 (which would pass the .has() check but mean no real relevance match):
<path>/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/core/src/ltm.ts</path>
<type>file</type>
<content>
457: function scoreEntriesFTS(sessionContext: string): Map<string, number> {
458: const terms = extractTopTerms(sessionContext);
459: if (!terms.length) return new Map();
460:
461: const q = terms.map((t) => `${t}*`).join(" OR ");
462: const { title, content, category } = ftsWeights();
463:
464: try {
465: const results = db()
466: .query(
467: `SELECT k.id, bm25(knowledge_fts, ?, ?, ?) as rank
468: FROM knowledge_fts f
469: CROSS JOIN knowledge k ON k.rowid = f.rowid
470: WHERE knowledge_fts MATCH ?
471: AND k.confidence > 0.2`,
472: )
473: .all(title, content, category, q) as Array<{
474: id: string;
475: rank: number;
476: }>;
477:
478: if (!results.length) return new Map();
479:
480: // Normalize: BM25 rank is ...
Important: the normalization at line 488 — when minRank === maxRank (only one FTS result), every entry gets score 1. When there are multiple results, the WORST match gets score 0 (because (maxRank - maxRank) / range = 0). So ftsScores.has(e.id) at line 884 CAN include entries with a normalized score of 0, meaning they passed FTS MATCH but were the weakest matches. The .has() check lets them through, then (ftsScores.get(e.id) ?? 0) * e.confidence = 0 * confidence = 0. So they get a score of 0 and after the merge+sort at line 762, they'd be at the bottom.
But wait — these are foreign entries with score 0 that still get into scoredCross and could be packed into the budget if budget allows. This is a potential leak, but it's pre-existing behavior unrelated to this PR. The PR description explicitly says "the relevance-scored path was already gated" and the foreign entries in this path DO have to FTS-match to get in. Getting a score of 0 because they're the worst match in the set is an edge case but still indicates some relevance match in FTS5.
Let me now check the config.ts changes and remaining files:
<path>/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/gateway/src/config.ts</path> <type>file</type> <content> 500: const PROJECT_PATH_PATTERNS: RegExp[] = [ 501: // "cwd": "/path/to/project" (JSON-style in tool definitions). 502: // Accepts any absolute path — the surrounding structure (key + quotes) 503: // provides enough specificity to avoid false positives. 504: /["']?cwd["']?\s*[:=]\s*["']?(\/[^\s"',}]+)/, 505: // Working directory: /path/to/project 506: // Accepts any absolute path — the "Working directory" prefix is unambiguous. 507: /[Ww]orking\s+directory[:=]\s*(\/[^\s"',]+)/, 508: // CLAUDE.md / AGENTS.md / .lore.md file path → take the directory. 509: // Accepts any absolute path — the known filename suffix is unambiguous. 510: /(\/[^\s"',]+)\/(?:CLAUDE|AGENTS|\.lore)\.md/, 511: // Generic absolute path starting with /home/ or /Users/ — first occurrence. ...
Now let me check if inferProjectPathDetailed is properly exported and if there are other callers of inferProjectPath that might be affected:
Found 5 matches
/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/gateway/src/config.ts:
Line 537: export function inferProjectPathDetailed(
Line 552: export function inferProjectPath(systemPrompt: string): string | null {
Line 553: return inferProjectPathDetailed(systemPrompt)?.path ?? null;
Line 594: * 2. `inferProjectPath(systemPrompt)` (zero-config extraction)
Line 624: const inferred = inferProjectPathDetailed(systemPrompt);
Found 31 matches
/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/gateway/test/project-path.test.ts:
Line 3: inferProjectPath,
Line 28: describe("inferProjectPath", () => {
Line 31: expect(inferProjectPath(system)).toBe("/home/user/my-project");
Line 36: expect(inferProjectPath(system)).toBe("/Users/dev/app");
Line 41: expect(inferProjectPath(system)).toBe("/home/user/project");
Line 46: expect(inferProjectPath(system)).toBe("/Users/dev/my-project");
Line 51: expect(inferProjectPath(system)).toBe("/Users/dev/project");
Line 56: expect(inferProjectPath(system)).toBe("/home/user/my-project");
Line 61: expect(inferProjectPath(system)).toBe("/Users/dev/my-project");
Line 66: expect(inferProjectPath(system)).toBe("/Users/dev/project");
Line 71: expect(inferProjectPath(system)).toBe("/home/user/generic-project");
Line 76: ...
Good — inferProjectPath is only used in the test file and the inferProjectPathDetailed-wrapping definition. No production callers use the old inferProjectPath directly besides through getProjectPath, which now uses inferProjectPathDetailed. Now let me check the resolveSessionProjectPath function to understand how getProjectPath results flow into reattributeProvisionalProject:
Found 1 matches /home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/gateway/src/pipeline.ts: Line 1110: export function resolveSessionProjectPath(
<path>/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/gateway/src/pipeline.ts</path>
<type>file</type>
<content>
1110: export function resolveSessionProjectPath(
1111: result: ProjectPathResult,
1112: sessionState: SessionState,
1113: config: GatewayConfig,
1114: ): string {
1115: let { path: projectPath, source } = result;
1116:
1117: // Cache git remote on the session so subsequent turns benefit even if
1118: // the header is absent (e.g. prompt-cache probes or follow-up requests).
1119: if (result.gitRemote && !sessionState.gitRemote) {
1120: sessionState.gitRemote = result.gitRemote;
1121: }
1122:
1123: const hasConfident =
1124: !!sessionState.projectPath && !sessionState.projectPathProvisional;
1125: // Best git remote we know for this session — the current turn's, falling back
1126: // to a value cached on an earlier turn (the header is independent of path
1127: // resolution, so it can ...
Good. Now let me check the OpenCode changes and the test files:
<path>/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/opencode/src/index.ts</path>
<type>file</type>
<content>
255: // Module-level fallback (used only when chat.headers is bypassed).
256: // Updated on every plugin call so the most-recently-active project wins
257: // for fetches that arrive without a known session ID (e.g., direct
258: // SDK fetches that skip the plugin's chat.headers hook).
259: currentProjectPath = thisProjectPath;
260: currentGitRemote = thisGitRemote;
261:
262: try {
263: const hooks: Hooks = {
264: // Disable built-in compaction (gateway handles it), register hidden
265: // worker agents, and redirect all provider baseURLs through the gateway.
266: config: async (input) => {
267: const cfg = input as Record<string, unknown>;
268: cfg.compaction = { auto: false, prune: false };
269: // `mode: "subagent"` is REQUIRED for `hidden` to take effect: ...
Now let me check the new test file and the project-path test additions in full:
<path>/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/core/test/cross-project-gating.test.ts</path>
<type>file</type>
<content>
1: import { describe, test, expect, beforeEach } from "vitest";
2: import { uuidv7 } from "uuidv7";
3: import { db, ensureProject } from "../src/db";
4: import * as ltm from "../src/ltm";
5:
6: // Two unrelated real projects. PROJ_A owns a cross-project-marked entry; we
7: // assert it does NOT leak into PROJ_B's injected context unless it is actually
8: // relevant to PROJ_B's session.
9: const PROJ_A = "/test/xproj/project-a";
10: const PROJ_B = "/test/xproj/project-b";
11:
12: const TITLE_PREFIX = "XPROJGATE_";
13:
14: function cleanup() {
15: db().query(`DELETE FROM knowledge WHERE title LIKE '${TITLE_PREFIX}%'`).run();
16: db()
17: .query(
18: "DELETE FROM knowledge WHERE project_id IN (SELECT id FROM projects WHERE path LIKE '/test/xproj/%')",
19: )
20: .run();
21: }
22: ...
Now let me check the project-path test file additions:
<path>/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/gateway/test/project-path.test.ts</path>
<type>file</type>
<content>
570: const state = provisionalState("sid-hdr", "/home/user/old-project");
571: const result = resolveSessionProjectPath(
572: { path: "/home/user/header-project", source: "header" },
573: state,
574: localCfg,
575: );
576: expect(result).toBe("/home/user/header-project");
577: expect(state.projectPath).toBe("/home/user/header-project");
578: expect(state.projectPathProvisional).toBe(false);
579: });
580:
581: // --- Fix 3: remote-gateway synthetic bucketing ---
582:
583: test("remote gateway: cwd fallback routes to a per-session bucket (never cwd)", () => {
584: const state = freshCwdState("abc123session", process.cwd());
585: const result = resolveSessionProjectPath(
586: { path: process.cwd(), source: "cwd" },
587: state,
588: ...
Now let me check if projectGitRemote is properly exported from core index:
<path>/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/core/src/index.ts</path>
<type>file</type>
<content>
60: export { dataDir } from "./data-dir";
61: export { load, config, type LoreConfig } from "./config";
62: export {
63: db,
64: dbPath,
65: ensureProject,
66: getLastImportAt,
67: setLastImportAt,
68: isFirstRun,
69: projectId,
70: projectName,
71: projectPath,
72: projectGitRemote,
73: resolveProjectByRemoteOrPath,
74: mergeProjectInternal,
75: UNATTRIBUTED_PROJECT_PREFIX,
76: isUnattributedProjectPath,
77: loadForceMinLayer,
78: saveForceMinLayer,
79: saveSessionCosts,
80: loadSessionCosts,
81: loadAllSessionCosts,
82: type SessionCostSnapshot,
83: addDailyCost,
84: getDailyCostTotals,
85: getDailyCostForDay,
86: type DailyCostBucket,
87: saveSessionTracking,
88: loadSessionTracking,
89: loadHeaderSessionIndex,
(Showing lines 60-89 of 210. Use offset=90 to ...
Now let me check the getProjectPath function to understand what happens when a non-authoritative inferred path is found — does the weak match's path get completely discarded, or could it leak:
<path>/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/gateway/src/config.ts</path>
<type>file</type>
<content>
629: // 3. Fall back to gateway's own cwd (with workspace root discovery)
630: return {
631: path: discoverWorkspaceRoot(process.cwd()),
632: source: "cwd",
633: gitRemote,
634: };
635: }
636:
637: // ---------------------------------------------------------------------------
638: // Git remote header extraction
639: // ---------------------------------------------------------------------------
640:
641: /** Maximum allowed length for a git remote header value. */
642: const MAX_GIT_REMOTE_LENGTH = 512;
643:
644: /**
645: * Extract and validate the `X-Lore-Git-Remote` header from a request.
646: * Normalizes SSH/HTTPS/git:// variants to a canonical form and strips
647: * any control characters (prevents header injection via crafted remote URLs). ...
Now let me check the migration v38 in db.ts more carefully, and verify the updated_at setting:
<path>/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/core/src/db.ts</path> <type>file</type> <content> 1029: ALTER TABLE session_state ADD COLUMN project_path_provisional INTEGER NOT NULL DEFAULT 1; 1030: `, 1031: `ALTER TABLE session_state ADD COLUMN compaction_anomaly_pending INTEGER NOT NULL DEFAULT 0;`, 1032: ` 1033: -- Version 38: Repair over-eager cross-project marking. 1034: -- The curator historically defaulted crossProject to TRUE, so project-specific 1035: -- engineering knowledge (architecture, paths, gotchas, directives) was stored 1036: -- with cross_project = 1 and leaked into every other project's injected 1037: -- context. Demote those rows back to project scope. 1038: -- Conservatively preserve genuinely-global knowledge: 1039: -- * project_id IS NULL → user-level / scope:"global" entries (kept cross). 1040: -- * promotion_status = 'promoted' → auto-promoted across >=3 projects, ...
Now let me check a few more things — what about the promotion_status values that exist. Are there other statuses besides 'promoted' and NULL?
Found 10 matches /home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/core/src/db.ts: Line 706: ALTER TABLE knowledge ADD COLUMN promotion_status TEXT; Line 1040: -- * promotion_status = 'promoted' → auto-promoted across >=3 projects, i.e. Line 1042: -- Only curator-default-marked, project-owned rows (promotion_status IS NULL) Line 1049: AND promotion_status IS NULL; /home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/core/src/ltm.ts: Line 44: promotion_status: PromotionStatus | null; Line 61: "id, project_id, category, title, content, source_session, cross_project, confidence, created_at, updated_at, metadata, created_by, updated_by, sensitivity, promotion_status, promoted_at, approval_status, approved_by, approved_at, source_user_id, source_entry_id, last_accessed_at, worker_provider_id, worker_model_id"; Line 65: "k.id, k.project_id, ...
Found 2 matches /home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/core/src/ltm.ts: Line 24: export type PromotionStatus = "nominated" | "suggested" | "promoted"; Line 44: promotion_status: PromotionStatus | null;
Important finding: PromotionStatus can be "nominated", "suggested", or "promoted". The migration v38 only preserves promotion_status = 'promoted' entries (via AND promotion_status IS NULL). Entries with promotion_status = 'nominated' or 'suggested' would also be demoted, which is the intended conservative behavior since those haven't earned cross-project status yet. Let me verify this is intentional by checking how nominated/suggested are used:
Found 1 matches /home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/core/src/ltm.ts: Line 24: export type PromotionStatus = "nominated" | "suggested" | "promoted";
Good — nominated and suggested are defined but seem to be intermediate states in the promotion pipeline. The migration's AND promotion_status IS NULL clause correctly demotes entries whose promotion_status is NULL (curator-default) while preserving ALL entries with any promotion_status value (including nominated and suggested). Wait — that's actually the opposite. Let me re-read:
The WHERE clause is: WHERE cross_project = 1 AND project_id IS NOT NULL AND promotion_status IS NULL. This means only rows where promotion_status IS NULL are demoted. Rows with promotion_status = 'nominated' or 'suggested' are NOT demoted (they don't match the WHERE clause). Is this correct?
Nominated/suggested entries have been through some promotion pipeline evaluation but haven't been fully promoted. The conservative choice is to keep them cross-project (not demote), which is what this migration does. This seems intentional and correct — they're in the promotion pipeline.
Now let me check one more important thing — the reattributeProvisionalProject return value when !corroborated. It returns true, which means the caller sets projectPathProvisional = !healed = false. This means the session is no longer provisional even though the old project's data wasn't merged. Is this correct?
<path>/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/gateway/src/pipeline.ts</path>
<type>file</type>
<content>
1130: if (source === "inferred" || source === "header") {
1131: // Confident path — bind the session.
1132: const previous = sessionState.projectPath;
1133: const wasProvisional = sessionState.projectPathProvisional === true;
1134:
1135: // Self-heal: if the session was previously bound to a provisional path
1136: // (cwd fallback or synthetic bucket) under which rows may already be
1137: // stored, migrate those rows into the real project now that we know it.
1138: // Only clear the provisional flag once the migration succeeds — otherwise
1139: // a transient failure (e.g. SQLITE_BUSY from a separate process) would
1140: // permanently strand the bucket data with no retry. Keeping the flag set
1141: // lets the next confident turn re-attempt. ...
This is correct. When !corroborated, reattributeProvisionalProject returns true (healed), so projectPathProvisional = !true = false. The session re-binds to the new path as confident, and the old project's data stays put under its own project ID — no merge, no aliasing. This is the right behavior: the session IS now confidently bound to the new path, it just didn't merge the old project in.
Now let me check if the "own" cross-entry classification handles the duplicate entry scenario correctly. When a project entry has cross_project = 1, it appears in BOTH the projectEntries query (from WHERE project_id = ? AND cross_project = 0) and the crossEntries query. Wait — let me re-read the queries:
<path>/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/core/src/ltm.ts</path>
<type>file</type>
<content>
567: // --- 1. Load project-specific entries ---
568: const projectEntries = db()
569: .query(
570: `SELECT ${KNOWLEDGE_COLS} FROM knowledge
571: WHERE project_id = ? AND cross_project = 0 AND confidence > 0.2${categoryClause}
572: ORDER BY confidence DESC, updated_at DESC`,
573: )
574: .all(pid, ...categoryParams) as KnowledgeEntry[];
575:
576: // --- 2. Load cross-project candidates ---
577: const crossEntries = db()
578: .query(
579: `SELECT ${KNOWLEDGE_COLS} FROM knowledge
580: WHERE (project_id IS NULL OR cross_project = 1) AND confidence > 0.2${categoryClause}
581: ORDER BY confidence DESC, updated_at DESC`,
582: )
583: .all(...categoryParams) as KnowledgeEntry[];
584:
585: if (!crossEntries.length && !projectEntries.length) return [];
586: ...
Key observation: projectEntries has cross_project = 0, and crossEntries has project_id IS NULL OR cross_project = 1. So an entry with project_id = pid AND cross_project = 1 appears ONLY in crossEntries, not in projectEntries. The isBlanketEligible function correctly classifies it as "own" (because entry.project_id === pid), so it gets blanket-injected. No duplication issue.
Now let me check if there's a scenario where crossEntries could contain an entry with project_id = pid AND cross_project = 0 — no, the query filters cross_project = 1. So the pools are properly disjoint.
Let me also look at the crossEntries query more carefully: it fetches ALL entries where project_id IS NULL OR cross_project = 1 — this includes entries from project A, project B, and globals. The isBlanketEligible filter then correctly separates them.
Let me now check one more edge case — the getProjectPath function when a non-authoritative path is found but there's ALSO a header:
<path>/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/gateway/src/config.ts</path>
<type>file</type>
<content>
605: export function getProjectPath(
606: systemPrompt: string,
607: headers: Record<string, string>,
608: ): ProjectPathResult {
609: // Extract git remote from header (independent of path resolution).
610: const gitRemote = extractGitRemoteHeader(headers);
611:
612: // 1. Explicit header override (sanitized)
613: const headerPath = extractProjectHeader(headers);
614: if (headerPath) return { path: headerPath, source: "header", gitRemote };
615:
616: // 2. Infer from system prompt content. Only AUTHORITATIVE inferences (a cwd
617: // field, "Working directory:" line, or CLAUDE/AGENTS/.lore.md path) are
618: // trusted to confidently bind the session. ...
Now let me check the AUTHORITATIVE_PATTERN_COUNT value against the actual patterns array. The array has 4 entries (indices 0-3), and the constant is 3, meaning patterns at indices 0, 1, 2 are authoritative, and index 3 (the generic /home|/Users pattern) is not:
<path>/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/gateway/src/config.ts</path> <type>file</type> <content> 498: * possible). Ordered from most-specific to most-general. 499: */ 500: const PROJECT_PATH_PATTERNS: RegExp[] = [ 501: // "cwd": "/path/to/project" (JSON-style in tool definitions). 502: // Accepts any absolute path — the surrounding structure (key + quotes) 503: // provides enough specificity to avoid false positives. 504: /["']?cwd["']?\s*[:=]\s*["']?(\/[^\s"',}]+)/, 505: // Working directory: /path/to/project 506: // Accepts any absolute path — the "Working directory" prefix is unambiguous. 507: /[Ww]orking\s+directory[:=]\s*(\/[^\s"',]+)/, 508: // CLAUDE.md / AGENTS.md / .lore.md file path → take the directory. 509: // Accepts any absolute path — the known filename suffix is unambiguous. 510: /(\/[^\s"',]+)\/(?:CLAUDE|AGENTS|\.lore)\.md/, 511: // Generic absolute path starting with /home/ ...
Good — confirmed. 4 patterns (indices 0-3), AUTHORITATIVE_PATTERN_COUNT = 3, so index 3 (the generic catch-all) is the only non-authoritative one. The coupling between the constant and the array is fragile but documented clearly.
Let me now check the OpenCode test:
<path>/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/opencode/test/index.test.ts</path>
<type>file</type>
<content>
56: try {
57: const cfg: Record<string, unknown> = {};
58: await hooks.config?.(cfg);
59:
60: expect(cfg.compaction).toEqual({ auto: false, prune: false });
61: } finally {
62: cleanup();
63: }
64: });
65:
66: test("registers hidden worker agents", async () => {
67: const { hooks, cleanup } = await initPlugin();
68: try {
69: const cfg: Record<string, unknown> = {};
70: await hooks.config?.(cfg);
71:
72: const agents = cfg.agent as Record<
73: string,
74: { mode?: string; hidden: boolean; description: string }
75: >;
76: expect(agents["lore-distill"]).toEqual({
77: mode: "subagent",
78: hidden: true,
79: description: "Lore memory distillation worker",
80: });
81: ...
Now let me check the db.test.ts version bump:
<path>/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/core/test/db.test.ts</path>
<type>file</type>
<content>
45: expect(names).toContain("tool_calls");
46: });
47:
48: test("schema version is set", () => {
49: const row = db().query("SELECT version FROM schema_version").get() as {
50: version: number;
51: };
52: expect(row.version).toBe(38);
53: });
54:
55: test("entities table has embedding column (migration v34)", () => {
56: const cols = (
57: db().query("PRAGMA table_info(entities)").all() as Array<{
58: name: string;
59: }>
(Showing lines 45-59 of 1352. Use offset=60 to continue.)
</content>
Good. Let me also check the prompt.ts changes in full context:
<path>/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/core/src/prompt.ts</path> <type>file</type> <content> 370: 371: CROSS-REFERENCES between entries: 372: - When an entry relates to another entry, reference it with [[entry-uuid]] using the entry's ID 373: from the existing entries list. This creates navigable links between entries. 374: - Only reference entries you can see in the existing entries list — don't guess IDs. 375: - Example: "Uses the gradient system [[019c904b-791e-772a-ab2b-93ac892a960c]] for context management." 376: 377: crossProject flag: 378: - Default is FALSE — knowledge is project-scoped unless you have a concrete reason to share it. 379: - Set crossProject to true ONLY for genuinely universal *user* preferences that hold regardless of which repo you are in (e.g. tooling/style/workflow habits like "prefers pnpm over npm", "wants conventional-commit messages", "never add emojis"). ...
Now let me check if there's a test that covers the "own" classification — i.e., an entry with project_id = currentProject AND cross_project = 1 in the preference fast path:
Found 1 matches /home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/core/test/cross-project-gating.test.ts: Line 6: // Two unrelated real projects. PROJ_A owns a cross-project-marked entry; we
Now let me verify the crossEntries SQL query — it doesn't filter by project_id, so when called for PROJ_B, it would include entries with project_id = PROJ_B AND cross_project = 1. But these same entries are NOT in projectEntries (which has cross_project = 0). So the "own" classification in isBlanketEligible is critical to ensure PROJ_B's own cross-marked entries are still injected. However, the test doesn't explicitly test this case. Let me verify:
The test at line 26 ("preference fast path: foreign cross-project pref is NOT blanket-injected") creates:
own_pref — PROJ_B, crossProject: false → goes into projectEntriesglobal_pref — global, crossProject: true (forced) → goes into crossEntries, classified as "global" → blanket eligibleforeign_pref — PROJ_A, crossProject: true → goes into crossEntries, classified as "foreign" → NOT blanket eligibleThere's no test for the case: PROJ_B entry with crossProject: true → should appear in crossEntries and be classified as "own" → blanket eligible. This is a test gap but not a bug.
Let me now check whether the migration test correctly mirrors the real migration SQL:
The test DEMOTION_SQL adds AND title LIKE '${TITLE_PREFIX}%' to scope it, but doesn't include updated_at = (CAST(strftime('%s','now') AS INTEGER) * 1000). The test only checks cross_project values, so this difference doesn't affect test correctness.
Let me now verify the migration doesn't update updated_at incorrectly — wait, the real migration DOES set updated_at:
SET cross_project = 0,
updated_at = (CAST(strftime('%s','now') AS INTEGER) * 1000)
The test's DEMOTION_SQL only sets cross_project = 0 (no updated_at). This is fine since the test only validates cross_project values. The updated_at in the real migration is cosmetic (for change tracking). ...
Found 2 matches /home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/gateway/src/pipeline.ts: Line 1144: healed = reattributeProvisionalProject( Line 1217: function reattributeProvisionalProject(
Good — only one call site. Now let me check if projectGitRemote is only used in pipeline.ts or if it's needed elsewhere:
Found 5 matches
/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/core/src/db.ts:
Line 1620: export function projectGitRemote(id: string): string | null {
/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/core/src/index.ts:
Line 72: projectGitRemote,
/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/gateway/src/pipeline.ts:
Line 20: projectGitRemote,
Line 1242: const fromRemote = projectGitRemote(fromId);
Line 1243: const toRemote = gitRemote ?? projectGitRemote(toId);
Good. Only used in pipeline.ts. Let me now do a final sweep — check whether inferProjectPathDetailed is exported from config.ts and whether the test import works:
<path>/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/gateway/test/project-path.test.ts</path>
<type>file</type>
<content>
1: import { describe, test, expect } from "vitest";
2: import {
3: inferProjectPath,
4: inferProjectPathDetailed,
5: getProjectPath,
6: extractGitRemoteHeader,
7: extractProjectHeader,
8: unattributedBucketPath,
9: isUnattributedPath,
10: UNATTRIBUTED_PREFIX,
11: type GatewayConfig,
12: } from "../src/config";
(Showing lines 1-12 of 882. Use offset=13 to continue.)
</content>
Now let me run the tests to make sure everything passes:
> lore-monorepo@ pretest /home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit > pnpm --filter @loreai/gateway run bundle > @loreai/gateway@0.26.0 bundle /home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/gateway > tsx script/bundle.ts dist/index.cjs 2.1mb ⚠️ dist/index.cjs.map 8.8mb ⚡ Done in 427ms dist/index.bun.js 1.3mb ⚠️ ⚡ Done in 167ms dist/embedding-worker.cjs 490.3kb ⚡ Done in 123ms dist/embedding-worker.js 489.6kb ⚡ Done in 98ms ✓ Debug ID injected: 3556cf3d-b815-4bc4-a5a5-04b13ccf6050 No SENTRY_AUTH_TOKEN — skipping sourcemap upload ✓ @loreai/gateway npm bundle complete (v0.26.0) dist/index.cjs — CJS bundle (Node.js, node:sqlite) dist/index.bun.js — ESM bundle (Bun, bun:sqlite) dist/embedding-worker.cjs — embedding worker CJS (Node.js) dist/embedding-worker.js — embedding worker ESM (Bun) dist/bin.cjs — ...
All tests pass. Let me also verify one more thing — whether the non-authoritative inferred path is completely DISCARDED or whether it could be used somewhere else. Looking at getProjectPath, when inferred is non-authoritative, the function falls through to the cwd fallback. The weak path is simply never returned. Good.
Let me also check one subtle edge case in the preference fast path — whether crossEntries could contain a duplicate of a projectEntries entry. I already verified the SQL queries are disjoint (projectEntries uses cross_project = 0, crossEntries uses cross_project = 1), so no duplication.
One last thing — let me check whether the excludeCategories path (non-preference, context-bound LTM) also needs foreign gating. This is the vector/FTS scoring path which the PR says "was already gated":
<path>/home/byk/.local/share/opencode/worktree/51d1015fd1f616acf9a5ffaad9331ec6b304586b/clever-circuit/packages/core/src/ltm.ts</path>
<type>file</type>
<content>
714: // Cross-project: include entries matched by vector OR FTS5
715: scoredCross = crossEntries
716: .filter((e) => vectorScores.has(e.id) || ftsScores.has(e.id))
717: .map((e) => {
718: const vecScore = vectorScores.get(e.id);
719: const score =
720: vecScore != null
721: ? vecScore * e.confidence
722: : (ftsScores.get(e.id) ?? 0) * e.confidence;
723: return { entry: e, score };
724: });
725: } else {
(Showing lines 714-725 of 2270. Use offset=726 to continue.)
</content>
Now I have a comprehensive understanding of all the changes. Let me compile my review.
curator.ts crossProject default change (line 284)The default changed from op.crossProject ?? true to op.crossProject ?? false. This is correct. The create() function in ltm.ts (line 95) already independently handles scope === "global" by forcing crossProject = true when pid === null, so the curator default change doesn't break globals. The comment explaining the rationale is clear and accurate.
prompt.ts crossProject guidance rewrite (lines 377-404)The prompt now says "Default is FALSE" (was "Default is true"), and the example JSON shows "crossProject": false (was true). These are consistent with the code change in curator.ts. The guidance is specific about what qualifies for cross-project sharing (universal user preferences) and what doesn't (project-specific anything). The prompt and code are in sync.
ltm.ts — crossEntryClass / isBlanketEligible helpers (lines 418-445)Logic is correct:
entry.project_id === null → "global" ✓entry.project_id === pid → "own" ✓"foreign" ✓isBlanketEligible returns true for global and own, false for foreign. This is the core gating mechanism and it's sound.
ltm.ts — Preference fast path gating (lines 592-637)The change correctly:
crossEntries into blanket-eligible and foreign prefscontextHint is provided AND they FTS-matchrelevantForeign uses scoreEntriesFTS with > 0 threshold — consistent with the approachltm.ts — Preference fast path foreign re-admission uses > 0 score threshold (line ...