Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a7d02fd
docs: add anchor-drift auto-invalidation design spec
divo12 Jul 3, 2026
0cdf62d
feat: add invalidation_events storage and transactional applyAnchorIn…
divo12 Jul 3, 2026
dd30323
feat: add anchor-drift detection (Option A policy, resilient, cache-f…
divo12 Jul 3, 2026
98d71f6
feat: add pure anchor-invalidation rebuild logic
divo12 Jul 3, 2026
7f25f04
feat: add invalidateDriftedAnchors service operation
divo12 Jul 3, 2026
5156f53
feat: add 'graph audit anchors --invalidate' CLI flag
divo12 Jul 4, 2026
52bf969
test: add deterministic anchor-drift invalidation checks
divo12 Jul 4, 2026
5c3cf4d
docs: document graph audit anchors --invalidate flag
divo12 Jul 4, 2026
539138a
Delete docs/superpowers/specs/2026-07-03-anchor-drift-invalidation-de…
divo12 Jul 4, 2026
b4db306
feat: add hashAnchorSpan content fingerprint util
divo12 Jul 5, 2026
be65a30
feat: add classifyFreshness (structural + content) and share it with …
divo12 Jul 5, 2026
d55fc41
feat: add anchor_fingerprints storage + repository (cache + reverse i…
divo12 Jul 5, 2026
f6a0377
feat: write anchor fingerprints when a proposal is applied
divo12 Jul 5, 2026
52d070a
refactor: clarify freshness classification API
divo12 Jul 5, 2026
02cc5c9
fix: harden freshness fingerprint edge cases
divo12 Jul 5, 2026
2a6c118
Merge pull request #2 from divo12/feat/freshness-fingerprint
divo12 Jul 5, 2026
ad4853a
feat: freshness engine Phase 2 — foreground surface (read-only) (#3)
divo12 Jul 5, 2026
18844ec
Feat/freshness heal (#4)
divo12 Jul 5, 2026
a4b5ecf
feat: freshness engine Phase 4 — re-verify handoff (#5)
divo12 Jul 6, 2026
b47a96f
fix: address review — scope fingerprints per repo, harden span guard
divo12 Jul 6, 2026
b1ee1e2
refactor: fold changedFilesSince into libs/utils/git.ts
divo12 Jul 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ greplica config
greplica doctor [--check-embeddings]
greplica graph read
greplica graph context "<query>" [--debug]
greplica graph audit anchors
greplica graph audit anchors [--invalidate]
greplica graph view [--out <file>] [--no-open]
greplica graph export <dir>
greplica transcript bundle --platform codex|claude|copilot --file <path> [--file <path>...] --out <bundle.md>
Expand All @@ -133,6 +133,7 @@ greplica proposal apply <proposal.json>

- `greplica graph context "<query>"` - returns Markdown for agent use. Add `--debug` for the full retrieval payload with ranking signals.
- `greplica graph read` - prints the current graph view: all components, flows, claims, sources, and edges in scope.
- `greplica graph audit anchors` - reports `code_verified` claims whose code anchors no longer resolve (drift). Add `--invalidate` to auto-demote fully-drifted claims to `truth: unknown` (non-destructively, via supersession) and record why in the invalidation log. Without the flag it is report-only, so it doubles as a dry run.
- `greplica graph view` to visualise the current memory in a local HTML, opens in your default browser. Use `--out` to choose where the file is written; by default it goes to a temp path.
- `greplica transcript bundle` - converts one or more Codex, Claude Code, or GitHub Copilot CLI JSONL transcripts into a sanitized Markdown bundle for `greplica-fast-session-bootstrap`.
- `greplica doctor` - verifies installation and diagnoses configuration failures. Not a required preflight before every command.
Expand Down
54 changes: 48 additions & 6 deletions apps/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { isatty } from "node:tty";
import { basename, dirname, join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { createLocalKnowledgeGraphService, KnowledgeGraphService } from "../../libs/knowledge-graph/service.js";
import type { ClaimAnchorAuditResult, RepoRef } from "../../libs/knowledge-graph/service.js";
import type { AnchorInvalidationResult, ClaimAnchorAuditResult, RepoRef } from "../../libs/knowledge-graph/service.js";
import { envVarSource, loadRepoEnv, type LoadedRepoEnv } from "../../libs/env/load-local-env.js";
import {
ensureGreplicaConfig,
Expand Down Expand Up @@ -100,7 +100,7 @@ const cliCommands = [
{
key: "graphAuditAnchors",
path: ["graph", "audit", "anchors"],
usage: "graph audit anchors",
usage: "graph audit anchors [--invalidate]",
handler: runGraphAuditAnchorsCommand,
showInTopLevelHelp: true,
},
Expand Down Expand Up @@ -271,11 +271,53 @@ async function runGraphContextCommand(args: string[]): Promise<void> {
}
}

async function runGraphAuditAnchorsCommand(_args: string[]): Promise<void> {
async function runGraphAuditAnchorsCommand(args: string[]): Promise<void> {
const options = parseAuditAnchorsArgs(args);
const { repo, service } = createCommandContext();
const result = await service.auditCodeAnchors(repo);
printAnchorAudit(result);
if (anchorAuditIssueCount(result) > 0) process.exitCode = 1;

// Always show the diagnostic report first; --invalidate then demotes the
// fully-drifted claims. Report-only runs keep the exit-1 CI signal.
const audit = await service.auditCodeAnchors(repo);
printAnchorAudit(audit);

if (!options.invalidate) {
if (anchorAuditIssueCount(audit) > 0) process.exitCode = 1;
return;
}

const result = await service.invalidateDriftedAnchors(repo);
printAnchorInvalidation(result);
}

function parseAuditAnchorsArgs(args: string[]): { invalidate: boolean } {
let invalidate = false;
for (const arg of args) {
if (arg === "--invalidate") {
invalidate = true;
continue;
}
throw new Error(usage("graphAuditAnchors"));
}
return { invalidate };
}

function printAnchorInvalidation(result: AnchorInvalidationResult): void {
console.log("");
if (result.invalidated.length === 0) {
console.log("No drifted claims to invalidate.");
} else {
console.log(`Invalidated ${result.invalidated.length} drifted claim(s):`);
for (const record of result.invalidated) {
console.log(`- ${record.claim_id} -> ${record.superseding_claim_id} (${record.broken_anchor}, ${record.resolver_status})`);
}
console.log(`Memory commit: ${result.memory_commit_id}`);
}

if (result.errors.length > 0) {
console.log("");
console.log(`Skipped ${result.errors.length} claim(s) due to resolver errors:`);
for (const error of result.errors) console.log(`- ${error.claim_id}: ${error.message}`);
}
}

function runGraphExportCommand(args: string[]): void {
Expand Down
1 change: 1 addition & 0 deletions evals/ranking-optimizer/train.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ function toClaimResults(ranked: RankedContextDocument[], config: GraphContextCon
about: document.document.about,
evidence: [],
code_anchors: [],
freshness: { state: "fresh", reason: null, broken: [] },
};
});
}
Expand Down
3 changes: 3 additions & 0 deletions libs/config/greplica-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface SessionConfig {
timeThresholdMinutes: number;
currentGraceMinutes: number;
autoMemoryUpdates: boolean;
autoHealDrift: boolean;
}

export interface EmbeddingConfigInput {
Expand Down Expand Up @@ -51,6 +52,7 @@ export const defaultSessionConfig: SessionConfig = {
timeThresholdMinutes: 40,
currentGraceMinutes: 5,
autoMemoryUpdates: true,
autoHealDrift: true,
};

export const defaultGreplicaConfig: GreplicaConfig = {
Expand Down Expand Up @@ -158,6 +160,7 @@ function normalizeSessionConfig(value: unknown, path: string): SessionConfig {
path,
),
autoMemoryUpdates: parseBoolean(value.autoMemoryUpdates, defaultSessionConfig.autoMemoryUpdates, "session.autoMemoryUpdates", path),
autoHealDrift: parseBoolean(value.autoHealDrift, defaultSessionConfig.autoHealDrift, "session.autoHealDrift", path),
};
}

Expand Down
145 changes: 131 additions & 14 deletions libs/hooks/worker.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import { spawn } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { basename, join } from "node:path";
import type { ClaimedMemoryUpdateAttempt } from "./session-state.js";
import { HookSessionStore } from "./session-state.js";
import { WorkerLease } from "../utils/worker-lease.js";
import { ensureGreplicaConfig, type GreplicaConfig } from "../config/greplica-config.js";
import { platformInstaller } from "../install/platforms/index.js";
import type { PlatformInstaller } from "../install/platforms/types.js";
import { openDatabase } from "../storage/sqlite/db.js";
import { SqliteRepository } from "../storage/sqlite/repository.js";
import { KnowledgeGraphService, type HealResult, type RepoRef } from "../knowledge-graph/service.js";
import type { Claim } from "../knowledge-graph/claim.js";

const hookWorkerLockName = "hook-memory-update-worker";
const hookWorkerHeartbeatMs = 60 * 1000;
const reverifyLimit = 3; // ponytail: fixed per-cycle cap; make it config only if repos need different limits.

export function startHookWorker(): void {
const script = process.argv[1];
Expand Down Expand Up @@ -54,13 +59,67 @@ export async function runHookWorker(): Promise<void> {
if (!leaseValid || !lease.renew()) return;
await maybeUpdateWorkingMemory(attempt);
}

if (leaseValid) {
const service = new KnowledgeGraphService(new SqliteRepository(db));
const renew = () => leaseValid && lease.renew();
await runDriftHealPass(service, activeRepoRefs(attempts), config.session.autoHealDrift, renew);
if (config.session.autoHealDrift) {
await runReverifyForActiveRepos(service, attempts, reverifyLimit, renew);
}
}
} finally {
if (heartbeat !== undefined) clearInterval(heartbeat);
if (acquired) lease.release();
db.close();
}
}

interface DriftHealer {
healDriftedAnchorsFromCheckpoint(input: RepoRef): Promise<HealResult>;
}

/** One minimal RepoRef per distinct active repo root; repo_name/default_branch are unused by the heal. */
function activeRepoRefs(attempts: ClaimedMemoryUpdateAttempt[]): RepoRef[] {
const refs: RepoRef[] = [];
for (const attempt of attempts) {
const cwd = attempt.session.cwd;
if (cwd !== null) refs.push({ repo_root: cwd, repo_name: basename(cwd), default_branch: "main" });
}
return refs;
}

/**
* Heal drift for each distinct repo among the active sessions. Deduped by repo
* root, gated by `autoHealDrift`, and best-effort — a failure on one repo never
* breaks the worker. Stops early if the lease is lost.
*/
export async function runDriftHealPass(
service: DriftHealer,
refs: RepoRef[],
autoHealDrift: boolean,
renewLease: () => boolean = () => true,
log: (summary: Record<string, unknown>) => void = (summary) => console.error(JSON.stringify(summary)),
): Promise<void> {
if (!autoHealDrift) return;
const seen = new Set<string>();
for (const ref of refs) {
const key = ref.repo_root ?? ref.repo_name;
if (seen.has(key)) continue;
seen.add(key);
if (!renewLease()) return;
try {
const result = await service.healDriftedAnchorsFromCheckpoint(ref);
if (result.rechecked > 0 || result.demoted.length > 0) {
log({ event: "freshness_heal", repo: ref.repo_name, rechecked: result.rechecked, demoted: result.demoted.length });
}
} catch (error) {
// Best-effort: one repo's failure must not abort the pass. Log, keep going.
log({ event: "freshness_heal_error", repo: ref.repo_name, error: errorMessage(error) });
}
}
}

async function maybeUpdateWorkingMemory(attempt: ClaimedMemoryUpdateAttempt): Promise<void> {
const cwd = attempt.session.cwd;
const transcriptPath = attempt.session.transcript_path;
Expand All @@ -72,24 +131,86 @@ async function maybeUpdateWorkingMemory(attempt: ClaimedMemoryUpdateAttempt): Pr
const transcriptMarkdown = runner.transcriptToMarkdown(transcript);
if (transcriptMarkdown.trim().length === 0) return;

const runDir = mkdtempSync(
join(tmpdir(), `greplica-hook-${safePathSegment(attempt.session.platform)}-${safePathSegment(attempt.session.session_id)}-`),
await runMemoryAgent(runner, cwd, (proposalPath) =>
updateWorkingMemoryPrompt(transcriptMarkdown, attempt, sessionRef, proposalPath),
);
const proposalPath = join(runDir, "working-memory.proposal.json");
}

/** Re-verify demoted claims for each distinct active repo, best-effort. */
async function runReverifyForActiveRepos(
service: KnowledgeGraphService,
attempts: ClaimedMemoryUpdateAttempt[],
limit: number,
renewLease: () => boolean,
): Promise<void> {
const seen = new Set<string>();
for (const attempt of attempts) {
const cwd = attempt.session.cwd;
if (cwd === null || seen.has(cwd)) continue;
seen.add(cwd);
if (!renewLease()) return;
const ref: RepoRef = { repo_root: cwd, repo_name: basename(cwd), default_branch: "main" };
try {
await runReverifyPass(platformInstaller(attempt.session.platform), cwd, service.reverifyWorklist(ref, limit));
} catch (error) {
// One repo's failure (e.g. greplica not installed there) must not abort the
// whole pass; log it and keep draining the rest.
console.error(JSON.stringify({ event: "freshness_reverify_error", repo: basename(cwd), error: errorMessage(error) }));
}
}
}

function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

/** Hand the re-verify worklist to the agent runner. Empty worklist -> no agent spawn. */
export async function runReverifyPass(
runner: Pick<PlatformInstaller, "runWorkingMemoryUpdate">,
cwd: string,
claims: Claim[],
): Promise<void> {
if (claims.length === 0) return;
await runMemoryAgent(runner, cwd, (proposalPath) => reverifyPrompt(claims, proposalPath));
}

/** Prompt the agent to re-verify drift-demoted claims against the current code. */
export function reverifyPrompt(claims: Claim[], proposalPath: string): string {
const list = claims.map((claim) => `- ${claim.id}: ${claim.text}${anchorHint(claim)}`).join("\n");
return `Some code_verified memory claims were auto-demoted to truth: unknown because their anchored code drifted. Re-verify each against the CURRENT repository code.

For each claim: read the anchored code now. If the fact still holds, write a corrected code_verified claim that supersedes the unknown one; if it no longer holds, leave it demoted. Do not invent facts — verify against the files.

Write the proposal JSON to ${proposalPath} and apply it with greplica, per the greplica-update-working-memory skill.

Claims to re-verify:
${list}
`;
}

function anchorHint(claim: Claim): string {
const anchors = claim.code_anchors ?? [];
if (anchors.length === 0) return "";
return ` (anchors: ${anchors.map((anchor) => (anchor.symbol ? `${anchor.file}#${anchor.symbol}` : anchor.file)).join(", ")})`;
}

/** Run a platform agent with a fresh temp proposal path; best-effort, always cleans up. */
async function runMemoryAgent(
runner: Pick<PlatformInstaller, "runWorkingMemoryUpdate">,
cwd: string,
buildPrompt: (proposalPath: string) => string,
): Promise<void> {
const runDir = mkdtempSync(join(tmpdir(), "greplica-hook-agent-"));
try {
await runner.runWorkingMemoryUpdate({
cwd,
env: {
...process.env,
GREPLICA_HOOK_DISABLE: "1",
},
prompt: updateWorkingMemoryPrompt(transcriptMarkdown, attempt, sessionRef, proposalPath),
env: { ...process.env, GREPLICA_HOOK_DISABLE: "1" },
prompt: buildPrompt(join(runDir, "working-memory.proposal.json")),
transcriptPath: join(runDir, "agent-events.jsonl"),
finalMessagePath: join(runDir, "final-message.md"),
});
} catch {
// Failed background updates should not affect foreground hook sessions.
// Failed background agent runs must not affect foreground hook sessions.
} finally {
rmSync(runDir, { recursive: true, force: true });
}
Expand Down Expand Up @@ -125,7 +246,3 @@ ${transcriptMarkdown.trim()}
</filtered_session_transcript>
`;
}

function safePathSegment(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "unknown";
}
68 changes: 68 additions & 0 deletions libs/knowledge-graph/anchor-fingerprints.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { AnchorFingerprintRow } from "../storage/sqlite/repository.js";
import type { AnchorCheck } from "./code-anchors/freshness.js";
import { hashAnchorSpan, statAnchorFile } from "./code-anchors/span-hash.js";
import type { ResolvedCodeAnchor } from "./code-anchors/types.js";

/** Fingerprints for one claim, keyed by anchor identity (see {@link anchorKey}). */
type StoredByAnchor = Map<string, AnchorFingerprintRow>;

/** Stable map key for an anchor's identity; `''` symbol matches the storage sentinel. */
function anchorKey(file: string, symbol: string | null | undefined): string {
return JSON.stringify([file, symbol ?? ""]);
}

/**
* Group stored fingerprint rows into `claim_id -> (anchorKey -> row)` so the
* foreground can look up a claim's baseline hashes in O(1) after one batched read.
*/
export function indexFingerprintsByClaim(rows: AnchorFingerprintRow[]): Map<string, StoredByAnchor> {
const byClaim = new Map<string, StoredByAnchor>();
for (const row of rows) {
let byAnchor = byClaim.get(row.claim_id);
if (byAnchor === undefined) {
byAnchor = new Map();
byClaim.set(row.claim_id, byAnchor);
}
byAnchor.set(anchorKey(row.file, row.symbol), row);
}
return byClaim;
}

/**
* Build the `AnchorCheck[]` for one claim's resolved anchors, applying the stat
* prefilter (cache-aside): when the file's mtime+size still match the stored
* fingerprint, reuse the stored hash instead of re-reading and re-hashing the span.
* `stored` is that claim's slice of {@link indexFingerprintsByClaim}.
*/
export function freshnessChecks(
resolved: ResolvedCodeAnchor[],
stored: StoredByAnchor | undefined,
repoRoot: string | undefined,
): AnchorCheck[] {
return resolved.map((anchor) => {
const row = stored?.get(anchorKey(anchor.file, anchor.symbol));
return {
anchor,
storedHash: row?.content_hash,
currentHash: currentSpanHash(anchor, row, repoRoot),
};
});
}

/** The span's hash right now — reused from the fingerprint when the file is untouched. */
function currentSpanHash(
anchor: ResolvedCodeAnchor,
row: AnchorFingerprintRow | undefined,
repoRoot: string | undefined,
): string | undefined {
if (row !== undefined && fileUntouched(row, repoRoot, anchor.file)) {
return row.content_hash; // cache hit: skip the re-read + re-hash
}
return hashAnchorSpan(repoRoot, anchor);
}

/** True when the file's mtime+size still match the stored fingerprint (the stat prefilter). */
function fileUntouched(row: AnchorFingerprintRow, repoRoot: string | undefined, file: string): boolean {
const stat = statAnchorFile(repoRoot, file);
return stat.mtime_ms === row.file_mtime_ms && stat.size === row.file_size;
}
Loading