diff --git a/apps/cli/repo-context.ts b/apps/cli/repo-context.ts
index c74716f..d2ecf0e 100644
--- a/apps/cli/repo-context.ts
+++ b/apps/cli/repo-context.ts
@@ -39,7 +39,20 @@ function defaultBranch(repoRoot: string): string {
const remoteHead = gitOptional(repoRoot, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]);
if (remoteHead?.startsWith("origin/")) return remoteHead.slice("origin/".length);
- return "main";
+ // Network call — keep a short timeout so install/doctor/graph cannot hang on a bad remote.
+ const lsRemoteSymref = gitOptional(repoRoot, ["ls-remote", "--symref", "origin", "HEAD"], {
+ timeoutMs: 5_000,
+ });
+ const fromLsRemote = parseLsRemoteSymrefHead(lsRemoteSymref);
+ if (fromLsRemote !== undefined) return fromLsRemote;
+
+ return "unknown";
+}
+
+function parseLsRemoteSymrefHead(output: string | undefined): string | undefined {
+ if (output === undefined) return undefined;
+ const match = output.match(/^ref:\s+refs\/heads\/([^\s\t]+)/m);
+ return match?.[1];
}
function repoName(remoteUrl: string, repoRoot: string): string {
@@ -48,12 +61,17 @@ function repoName(remoteUrl: string, repoRoot: string): string {
return lastPart ?? basename(repoRoot);
}
-function gitOptional(cwd: string, args: string[]): string | undefined {
+function gitOptional(
+ cwd: string,
+ args: string[],
+ options: { timeoutMs?: number } = {},
+): string | undefined {
try {
const output = execFileSync("git", args, {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
+ ...(options.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
}).trim();
return output.length > 0 ? output : undefined;
} catch {
diff --git a/libs/hooks/worker.ts b/libs/hooks/worker.ts
index 6b9d245..fe0a720 100644
--- a/libs/hooks/worker.ts
+++ b/libs/hooks/worker.ts
@@ -102,12 +102,13 @@ async function maybeUpdateWorkingMemory(attempt: ClaimedMemoryUpdateAttempt): Pr
}
}
-function updateWorkingMemoryPrompt(
+export function updateWorkingMemoryPrompt(
transcriptMarkdown: string,
attempt: ClaimedMemoryUpdateAttempt,
sessionRef: string,
proposalPath: string,
): string {
+ const escapedTranscript = escapeFilteredSessionTranscript(transcriptMarkdown.trim());
return `Run the greplica-update-working-memory skill for a completed coding-agent session. If your runtime supports slash-command skills, invoke /greplica-update-working-memory for this task.
Use the filtered session transcript below as the session context. It has been projected to Markdown with session metadata and human/agent text messages only.
@@ -128,11 +129,19 @@ Session:
- due_reason: ${attempt.reason}
-${transcriptMarkdown.trim()}
+${escapedTranscript}
`;
}
+/** Neutralize literal closing tags so pasted transcript content cannot break the evidence fence. */
+function escapeFilteredSessionTranscript(transcriptMarkdown: string): string {
+ return transcriptMarkdown.replaceAll(
+ "",
+ "\u200Bfiltered_session_transcript>",
+ );
+}
+
function safePathSegment(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "unknown";
}
diff --git a/libs/knowledge-graph/graph-context/context-builder.ts b/libs/knowledge-graph/graph-context/context-builder.ts
index 0a43b02..49aae38 100644
--- a/libs/knowledge-graph/graph-context/context-builder.ts
+++ b/libs/knowledge-graph/graph-context/context-builder.ts
@@ -25,6 +25,8 @@ export interface BuildGraphContextOptions {
config?: GraphContextConfig;
repoRoot?: string;
resolveCodeAnchors?: boolean;
+ /** Optional embedder override (tests / hermetic callers). */
+ embedder?: Embedder;
}
interface ExistingEmbedding {
@@ -44,7 +46,7 @@ export class GraphContextBuilder {
const flowDocuments = buildFlowDocuments(graph);
const evidenceByClaim = buildEvidenceByClaim(graph);
const documents = [...claimDocuments, ...componentDocuments, ...flowDocuments];
- const embedder = createEmbedder(config.embedding);
+ const embedder = options.embedder ?? createEmbedder(config.embedding);
const embeddingStatus = await this.ensureEmbeddings(repoId, documents, embedder, config);
if (options.warnOnCreatedEmbeddings && embeddingStatus.created > 0) {
console.warn(`graph context created ${embeddingStatus.created} missing embedding(s); proposal apply should normally pre-create them.`);
@@ -120,14 +122,23 @@ export class GraphContextBuilder {
};
}
- async ensureForGraph(repoId: string, graph: GraphReadResult, config: GraphContextConfig = graphContextConfig): Promise {
+ async ensureForGraph(
+ repoId: string,
+ graph: GraphReadResult,
+ config: GraphContextConfig = graphContextConfig,
+ embedder?: Embedder,
+ ): Promise {
const documents = [
...buildClaimDocuments(graph),
...buildComponentDocuments(graph),
...buildFlowDocuments(graph),
];
- const embedder = createEmbedder(config.embedding);
- return this.ensureEmbeddings(repoId, documents, embedder, config);
+ return this.ensureEmbeddings(
+ repoId,
+ documents,
+ embedder ?? createEmbedder(config.embedding),
+ config,
+ );
}
private async ensureEmbeddings(
@@ -149,6 +160,7 @@ export class GraphContextBuilder {
);
const missing = documents.filter((document) => !existing.has(document.key));
const vectors = await embedder.embedBatch(missing.map((document) => document.text));
+ assertEmbedBatchComplete(vectors, missing.length, config.embedding.dimensions);
repository.insertGraphObjectEmbeddings(
missing.map((document, index) => ({
@@ -158,7 +170,7 @@ export class GraphContextBuilder {
provider: config.embedding.provider,
model: config.embedding.model,
dimensions: config.embedding.dimensions,
- embedding: float32ArrayToBuffer(vectors[index] ?? []),
+ embedding: float32ArrayToBuffer(vectors[index]),
})),
);
@@ -224,6 +236,30 @@ export class GraphContextBuilder {
}
}
+function assertEmbedBatchComplete(
+ vectors: number[][],
+ expectedCount: number,
+ dimensions: number,
+): void {
+ if (vectors.length !== expectedCount) {
+ throw new Error(
+ `embedBatch returned ${vectors.length} vector(s); expected ${expectedCount}.`,
+ );
+ }
+
+ for (let index = 0; index < vectors.length; index += 1) {
+ const vector = vectors[index];
+ if (vector === undefined || vector.length === 0) {
+ throw new Error(`embedBatch returned an empty vector at index ${index}.`);
+ }
+ if (vector.length !== dimensions) {
+ throw new Error(
+ `embedBatch vector at index ${index} has length ${vector.length}; expected ${dimensions}.`,
+ );
+ }
+ }
+}
+
function buildEvidenceByClaim(graph: GraphReadResult): Map {
const sources = new Map(graph.sources.map((source) => [source.id, source]));
const evidenceByClaim = new Map();
diff --git a/libs/storage/sqlite/repository.ts b/libs/storage/sqlite/repository.ts
index 2b43f28..498c9f3 100644
--- a/libs/storage/sqlite/repository.ts
+++ b/libs/storage/sqlite/repository.ts
@@ -224,6 +224,26 @@ export class SqliteRepository implements GraphReadRepository {
}
ensureScope(input: CreateScopeInput): GraphScope {
+ // One main scope per repo: reuse and rename/ref-update if default_branch changes
+ // (e.g. fabricated "main" → "unknown" or ls-remote-resolved name) instead of
+ // creating orphan kind=main rows keyed by the new branch string.
+ if (input.kind === "main") {
+ const existingMain = this.db
+ .prepare(
+ "SELECT * FROM graph_scopes WHERE repo_id = ? AND kind = 'main' ORDER BY created_at LIMIT 1",
+ )
+ .get(input.repo_id) as GraphScope | undefined;
+ if (existingMain) {
+ if (existingMain.name !== input.name || existingMain.ref !== input.ref) {
+ this.db
+ .prepare("UPDATE graph_scopes SET name = ?, ref = ? WHERE id = ?")
+ .run(input.name, input.ref, existingMain.id);
+ return { ...existingMain, name: input.name, ref: input.ref };
+ }
+ return existingMain;
+ }
+ }
+
const existing = this.db
.prepare("SELECT * FROM graph_scopes WHERE repo_id = ? AND kind = ? AND name = ?")
.get(input.repo_id, input.kind, input.name) as GraphScope | undefined;
diff --git a/package.json b/package.json
index 4c46f31..a117fcb 100644
--- a/package.json
+++ b/package.json
@@ -24,7 +24,7 @@
"smoke:copilot": "npm run build && node scripts/smoke-copilot-install.mjs",
"smoke:opencode": "npm run build && node scripts/smoke-opencode-install.mjs",
"smoke:cursor": "npm run build && node scripts/smoke-cursor-install.mjs",
- "test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-repo-installations.js && node scripts/check-managed-cli.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-source-memberships.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js && node scripts/check-find-similar-claims.js && node scripts/check-apply-proposal-dedupe.js && node scripts/check-opencode-sqlite-transcript.js",
+ "test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-repo-installations.js && node scripts/check-managed-cli.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-source-memberships.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js && node scripts/check-find-similar-claims.js && node scripts/check-apply-proposal-dedupe.js && node scripts/check-opencode-sqlite-transcript.js && node scripts/check-embed-batch-length.js && node scripts/check-worker-transcript-escape.js && node scripts/check-main-scope-reuse.js",
"test:repo-installations": "npm run build && node scripts/check-repo-installations.js",
"test:transcript-bundle": "npm run build && node scripts/check-transcript-bundle.js",
"test:repo-context": "npm run build && node scripts/check-repo-context.js",
diff --git a/scripts/check-embed-batch-length.js b/scripts/check-embed-batch-length.js
new file mode 100644
index 0000000..c9aa203
--- /dev/null
+++ b/scripts/check-embed-batch-length.js
@@ -0,0 +1,197 @@
+import assert from "node:assert/strict";
+import { mkdtempSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const root = new URL("..", import.meta.url);
+const { openDatabase } = await import(new URL("dist/libs/storage/sqlite/db.js", root));
+const { SqliteRepository } = await import(new URL("dist/libs/storage/sqlite/repository.js", root));
+const { GraphContextBuilder } = await import(
+ new URL("dist/libs/knowledge-graph/graph-context/context-builder.js", root)
+);
+
+const tmp = mkdtempSync(join(tmpdir(), "greplica-embed-batch-"));
+const db = openDatabase(join(tmp, "graph.db"));
+const repository = new SqliteRepository(db);
+const builder = new GraphContextBuilder(repository);
+
+const repo = {
+ repo_root: tmp,
+ repo_name: "embed-batch-check",
+ default_branch: "main",
+};
+const { id: repoId } = repository.upsertRepo(repo).repo;
+
+const dimensions = 8;
+const config = {
+ version: "test-embed-batch",
+ embedding: {
+ provider: "local",
+ model: "test-model",
+ dimensions,
+ batchSize: 16,
+ },
+ ranking: {
+ semanticThreshold: 0.1,
+ selectionThreshold: 0.8,
+ packetMinimumScore: 0.15,
+ packetAdditionalDirectScoreFloor: 0.15,
+ minimumSelectedClaims: 1,
+ weights: { semantic: 1, bm25: 0 },
+ bm25: { k1: 1.5, b: 0.75 },
+ claimSupport: { weight: 0, countBoost: 0 },
+ directObject: { weight: 0 },
+ graphBoost: {
+ claimAboutTarget: 0,
+ containsParentToChild: 0,
+ containsChildToParent: 0,
+ touchesComponentToFlow: 0,
+ maxSources: 1,
+ },
+ packetHubPenalty: {
+ weight: 0,
+ graphScoreThreshold: 0,
+ claimSupportThreshold: 0,
+ bm25Threshold: 0,
+ semanticThreshold: 0,
+ coherenceThreshold: 0,
+ },
+ coherence: {
+ weight: 0,
+ neighborThreshold: 0,
+ degreePenalty: 0,
+ aboutWeight: 0,
+ touchesWeight: 0,
+ containsWeight: 0,
+ maxSources: 1,
+ },
+ },
+ dedupe: { similarityThreshold: 0.75 },
+};
+
+const graph = {
+ components: [],
+ flows: [],
+ claims: [
+ {
+ id: "claim.one",
+ kind: "fact",
+ text: "first claim text for embedding",
+ truth: "source_verified",
+ intent: "intended",
+ },
+ {
+ id: "claim.two",
+ kind: "fact",
+ text: "second claim text for embedding",
+ truth: "source_verified",
+ intent: "intended",
+ },
+ ],
+ sources: [],
+ edges: [],
+};
+
+const shortBatchEmbedder = {
+ async embed() {
+ return Array.from({ length: dimensions }, () => 0.1);
+ },
+ async embedBatch(texts) {
+ // Deliberately return fewer vectors than texts.
+ return texts.slice(0, Math.max(0, texts.length - 1)).map(() => Array.from({ length: dimensions }, () => 0.2));
+ },
+};
+
+await assert.rejects(
+ () => builder.ensureForGraph(repoId, graph, config, shortBatchEmbedder),
+ (error) => {
+ assert.ok(error instanceof Error);
+ assert.match(error.message, /embedBatch|vector/i);
+ return true;
+ },
+);
+
+const rowsAfterShortBatch = repository.listGraphObjectEmbeddings({
+ repo_id: repoId,
+ provider: config.embedding.provider,
+ model: config.embedding.model,
+ dimensions: config.embedding.dimensions,
+});
+assert.equal(rowsAfterShortBatch.length, 0, "short embedBatch must not insert embedding rows");
+
+const emptyVectorEmbedder = {
+ async embed() {
+ return Array.from({ length: dimensions }, () => 0.1);
+ },
+ async embedBatch(texts) {
+ return texts.map(() => []);
+ },
+};
+
+await assert.rejects(
+ () => builder.ensureForGraph(repoId, graph, config, emptyVectorEmbedder),
+ (error) => {
+ assert.ok(error instanceof Error);
+ assert.match(error.message, /empty|length|dimension|vector/i);
+ return true;
+ },
+);
+
+const rowsAfterEmpty = repository.listGraphObjectEmbeddings({
+ repo_id: repoId,
+ provider: config.embedding.provider,
+ model: config.embedding.model,
+ dimensions: config.embedding.dimensions,
+});
+assert.equal(rowsAfterEmpty.length, 0, "empty vectors must not insert embedding rows");
+
+const wrongDimensionEmbedder = {
+ async embed() {
+ return Array.from({ length: dimensions }, () => 0.1);
+ },
+ async embedBatch(texts) {
+ return texts.map(() => Array.from({ length: dimensions - 1 }, () => 0.3));
+ },
+};
+
+await assert.rejects(
+ () => builder.ensureForGraph(repoId, graph, config, wrongDimensionEmbedder),
+ (error) => {
+ assert.ok(error instanceof Error);
+ assert.match(error.message, /dimension|length|vector/i);
+ return true;
+ },
+);
+
+const rowsAfterWrongDim = repository.listGraphObjectEmbeddings({
+ repo_id: repoId,
+ provider: config.embedding.provider,
+ model: config.embedding.model,
+ dimensions: config.embedding.dimensions,
+});
+assert.equal(rowsAfterWrongDim.length, 0, "wrong-dimension vectors must not insert embedding rows");
+
+const healthyEmbedder = {
+ async embed() {
+ return Array.from({ length: dimensions }, () => 0.1);
+ },
+ async embedBatch(texts) {
+ return texts.map((_, index) => Array.from({ length: dimensions }, () => 0.01 * (index + 1)));
+ },
+};
+
+const status = await builder.ensureForGraph(repoId, graph, config, healthyEmbedder);
+assert.equal(status.created, 2);
+const rowsHealthy = repository.listGraphObjectEmbeddings({
+ repo_id: repoId,
+ provider: config.embedding.provider,
+ model: config.embedding.model,
+ dimensions: config.embedding.dimensions,
+});
+assert.equal(rowsHealthy.length, 2);
+for (const row of rowsHealthy) {
+ assert.ok(row.embedding.byteLength > 0, "healthy embeddings must be non-empty BLOBs");
+}
+
+db.close();
+console.log("Embed batch length checks passed.");
diff --git a/scripts/check-main-scope-reuse.js b/scripts/check-main-scope-reuse.js
new file mode 100644
index 0000000..1911e31
--- /dev/null
+++ b/scripts/check-main-scope-reuse.js
@@ -0,0 +1,47 @@
+import assert from "node:assert/strict";
+import { mkdtempSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+// Caller: package.json "test". Covers ensureScope main-scope reuse when default_branch changes.
+// User: follow-up commits for code-review high/medium/low items.
+
+const root = new URL("..", import.meta.url);
+const { openDatabase } = await import(new URL("dist/libs/storage/sqlite/db.js", root));
+const { SqliteRepository } = await import(new URL("dist/libs/storage/sqlite/repository.js", root));
+
+const tmp = mkdtempSync(join(tmpdir(), "greplica-main-scope-reuse-"));
+const db = openDatabase(join(tmp, "graph.db"));
+const repository = new SqliteRepository(db);
+
+const { repo } = repository.upsertRepo({
+ repo_root: tmp,
+ repo_name: "scope-reuse",
+ default_branch: "main",
+});
+
+const mainFirst = repository.ensureScope({
+ repo_id: repo.id,
+ kind: "main",
+ name: "main",
+ ref: "main",
+});
+assert.equal(mainFirst.name, "main");
+
+const mainSecond = repository.ensureScope({
+ repo_id: repo.id,
+ kind: "main",
+ name: "unknown",
+ ref: "unknown",
+});
+assert.equal(mainSecond.id, mainFirst.id, "must reuse the same main scope id");
+assert.equal(mainSecond.name, "unknown");
+assert.equal(mainSecond.ref, "unknown");
+
+const mainCount = db
+ .prepare("SELECT COUNT(*) AS c FROM graph_scopes WHERE repo_id = ? AND kind = 'main'")
+ .get(repo.id).c;
+assert.equal(mainCount, 1, "re-init with a new default_branch must not create a second main scope");
+
+db.close();
+console.log("Main scope reuse checks passed.");
diff --git a/scripts/check-repo-context.js b/scripts/check-repo-context.js
index 410a9eb..482f7ab 100644
--- a/scripts/check-repo-context.js
+++ b/scripts/check-repo-context.js
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
-import { mkdtempSync, mkdirSync, realpathSync } from "node:fs";
+import { mkdtempSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
@@ -37,9 +37,30 @@ const branchContext = detectRepoContext(branchRepo);
assert.equal(branchContext.default_branch, "trunk");
const noRemoteHeadRepo = initRepo(join(tmp, "no-remote-head-repo"));
-git(noRemoteHeadRepo, "remote", "add", "origin", "https://github.com/Autoloops/greplica.git");
+git(noRemoteHeadRepo, "remote", "add", "origin", `file://${join(tmp, "missing-remote.git")}`);
const noRemoteHeadContext = detectRepoContext(noRemoteHeadRepo);
-assert.equal(noRemoteHeadContext.default_branch, "main");
+assert.equal(noRemoteHeadContext.default_branch, "unknown");
+
+const bareOrigin = join(tmp, "bare-origin.git");
+git(tmp, "init", "--bare", "--quiet", bareOrigin);
+git(bareOrigin, "symbolic-ref", "HEAD", "refs/heads/develop");
+const commitRepo = initRepo(join(tmp, "commit-for-bare"));
+writeFileSync(join(commitRepo, "README"), "develop tip\n");
+git(commitRepo, "add", "README");
+git(commitRepo, "-c", "user.name=Greplica Test", "-c", "user.email=test@example.com", "commit", "-m", "init");
+git(commitRepo, "branch", "-M", "develop");
+git(commitRepo, "remote", "add", "origin", bareOrigin);
+git(commitRepo, "push", "--quiet", "origin", "develop");
+git(bareOrigin, "symbolic-ref", "HEAD", "refs/heads/develop");
+
+const lsRemoteRepo = initRepo(join(tmp, "ls-remote-default-repo"));
+git(lsRemoteRepo, "remote", "add", "origin", bareOrigin);
+const lsRemoteContext = detectRepoContext(lsRemoteRepo);
+assert.equal(
+ lsRemoteContext.default_branch,
+ "develop",
+ "when origin/HEAD is missing, ls-remote --symref should resolve the remote default branch",
+);
console.log("Repo context checks passed.");
diff --git a/scripts/check-worker-transcript-escape.js b/scripts/check-worker-transcript-escape.js
new file mode 100644
index 0000000..b42f783
--- /dev/null
+++ b/scripts/check-worker-transcript-escape.js
@@ -0,0 +1,41 @@
+import assert from "node:assert/strict";
+
+const root = new URL("..", import.meta.url);
+const { updateWorkingMemoryPrompt } = await import(new URL("dist/libs/hooks/worker.js", root));
+
+const earlyClose = "before after hostile content";
+const attempt = {
+ reason: "test",
+ session: {
+ platform: "cursor",
+ session_id: "sess-escape-1",
+ },
+};
+const prompt = updateWorkingMemoryPrompt(earlyClose, attempt, "cursor:sess-escape-1", "/tmp/proposal.json");
+
+assert.ok(prompt.includes(""), "opening tag must remain");
+assert.ok(prompt.includes(""), "real closing tag must remain");
+
+const openTag = "";
+const closeTag = "";
+const openIndex = prompt.indexOf(openTag);
+const closeIndex = prompt.lastIndexOf(closeTag);
+assert.ok(openIndex >= 0);
+assert.ok(closeIndex > openIndex);
+
+const inner = prompt.slice(openIndex + openTag.length, closeIndex);
+assert.equal(
+ inner.includes(closeTag),
+ false,
+ "transcript body must not contain a raw early-close sequence",
+);
+assert.ok(
+ inner.includes("before") && inner.includes("after hostile content"),
+ "escaped transcript should still preserve surrounding text",
+);
+
+// Only one structural close tag for the evidence fence (the real closing tag).
+const closeMatches = prompt.match(/<\/filtered_session_transcript>/g) ?? [];
+assert.equal(closeMatches.length, 1, "exactly one raw closing tag should remain in the prompt");
+
+console.log("Worker transcript escape checks passed.");