opencode-self-improvement 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/memory.ts ADDED
@@ -0,0 +1,326 @@
1
+ // Durable memory store engine. Reproduces the opencode-memory logfmt format
2
+ // (MIT, github.com/shuans/opencode-memory; license retained under
3
+ // vendor/opencode-memory/LICENSE) and extends it with the fields the write-gate
4
+ // needs: supersession markers, snapshots and audited deletion.
5
+ import { existsSync } from "node:fs";
6
+ import { join, basename } from "node:path";
7
+ import { memoryRoot, deletionsFile, snapshotDir } from "./config.ts";
8
+ import { appendLog, ensureDir, formatLine, parseLine, readLines } from "./logfmt.ts";
9
+ import { blockedTs } from "./blocklist.ts";
10
+
11
+ export type MemoryStatus = "open" | "settled";
12
+
13
+ export interface Memory {
14
+ ts: string;
15
+ type: string;
16
+ scope: string;
17
+ content: string;
18
+ issue?: string;
19
+ tags?: string[];
20
+ superseded_by?: string;
21
+ reason?: string;
22
+ status?: MemoryStatus;
23
+ file: string;
24
+ index: number;
25
+ }
26
+
27
+ const RESERVED = new Set([
28
+ "deletions.logfmt",
29
+ "deleted.logfmt",
30
+ "canonical.logfmt",
31
+ "blocklist.logfmt",
32
+ ]);
33
+
34
+ function toMemory(fields: Record<string, unknown>, file: string, index: number): Memory | null {
35
+ const ts = fields.ts;
36
+ const type = fields.type;
37
+ const scope = fields.scope;
38
+ if (typeof ts !== "string" || typeof type !== "string") return null;
39
+ if (typeof scope !== "string") return null;
40
+ return {
41
+ ts,
42
+ type,
43
+ scope,
44
+ content: typeof fields.content === "string" ? fields.content : "",
45
+ issue: typeof fields.issue === "string" ? fields.issue : undefined,
46
+ tags: typeof fields.tags === "string" ? fields.tags.split(",") : undefined,
47
+ superseded_by: typeof fields.superseded_by === "string" ? fields.superseded_by : undefined,
48
+ reason: typeof fields.reason === "string" ? fields.reason : undefined,
49
+ status:
50
+ fields.status === "open" || fields.status === "settled" ? fields.status : undefined,
51
+ file,
52
+ index,
53
+ };
54
+ }
55
+
56
+ export async function memoryFiles(): Promise<string[]> {
57
+ const root = memoryRoot();
58
+ // Bun.Glob.scan throws ENOENT on a missing root; a fresh store must read as
59
+ // empty so the system-digest hook never throws (it runs on every request,
60
+ // including the compaction model call).
61
+ if (!existsSync(root)) return [];
62
+ const glob = new Bun.Glob("*.logfmt");
63
+ const files: string[] = [];
64
+ for await (const f of glob.scan(root)) {
65
+ if (!RESERVED.has(f)) files.push(join(root, f));
66
+ }
67
+ return files.sort();
68
+ }
69
+
70
+ export async function loadAll(): Promise<Memory[]> {
71
+ const blocked = await blockedTs();
72
+ const out: Memory[] = [];
73
+ for (const file of await memoryFiles()) {
74
+ const lines = await readLines(file);
75
+ lines.forEach((line, index) => {
76
+ const m = toMemory(parseLine(line) ?? {}, file, index);
77
+ if (m && !blocked.has(m.ts)) out.push(m);
78
+ });
79
+ }
80
+ return out;
81
+ }
82
+
83
+ export async function loadScope(scope: string): Promise<Memory[]> {
84
+ return (await loadAll()).filter((m) => m.scope === scope);
85
+ }
86
+
87
+ export interface AppendMemoryInput {
88
+ type: string;
89
+ scope: string;
90
+ content: string;
91
+ issue?: string;
92
+ tags?: string[];
93
+ status?: MemoryStatus;
94
+ }
95
+
96
+ // Existing ts values across all memory files, used to guarantee uniqueness of
97
+ // newly appended entries (a fast seed->append within one millisecond would
98
+ // otherwise collide on the ISO timestamp).
99
+ async function existingTs(): Promise<Set<string>> {
100
+ const set = new Set<string>();
101
+ for (const file of await memoryFiles()) {
102
+ for (const line of await readLines(file)) {
103
+ const f = parseLine(line);
104
+ if (f && typeof f.ts === "string") set.add(f.ts);
105
+ }
106
+ }
107
+ return set;
108
+ }
109
+
110
+ // Append a durable memory. Only the gate's GATE-4 promotion step calls this.
111
+ export async function appendMemory(input: AppendMemoryInput): Promise<string> {
112
+ const taken = await existingTs();
113
+ let ts = new Date().toISOString();
114
+ let guard = 0;
115
+ while (taken.has(ts) && guard++ < 1000) {
116
+ ts = new Date(new Date(ts).getTime() + 1).toISOString();
117
+ }
118
+ const file = join(memoryRoot(), `${ts.split("T")[0]}.logfmt`);
119
+ await appendLog(
120
+ file,
121
+ formatLine({
122
+ ts,
123
+ type: input.type,
124
+ scope: input.scope,
125
+ content: input.content,
126
+ issue: input.issue,
127
+ tags: input.tags?.length ? input.tags.join(",") : undefined,
128
+ status: input.status,
129
+ }),
130
+ );
131
+ return ts;
132
+ }
133
+
134
+ export interface SupersedeFields {
135
+ superseded_by: string;
136
+ reason: string;
137
+ symbol?: string;
138
+ from?: string;
139
+ to?: string;
140
+ entity?: string;
141
+ }
142
+
143
+ // Copy `file` into snapshots/ before a mutation. Names follow
144
+ // `<stamp>-<original>` because /undo strips the stamp to find its target, so a
145
+ // same-millisecond collision would clobber an earlier, still-undoable snapshot
146
+ // (supersede + clear in one merge_review can land in the same millisecond).
147
+ async function snapshotBefore(file: string): Promise<void> {
148
+ await ensureDir(snapshotDir());
149
+ let stamp = Date.now();
150
+ let snap = join(snapshotDir(), `${stamp}-${basename(file)}`);
151
+ while (await Bun.file(snap).exists()) snap = join(snapshotDir(), `${++stamp}-${basename(file)}`);
152
+ await Bun.write(snap, (await Bun.file(file).text()) || "");
153
+ }
154
+
155
+ // Mark an existing memory as superseded IN PLACE (entry retained, recoverable).
156
+ // Idempotent: a second call is a no-op, so no double `superseded_by`.
157
+ export async function markSuperseded(ts: string, fields: SupersedeFields): Promise<boolean> {
158
+ for (const file of await memoryFiles()) {
159
+ const lines = await readLines(file);
160
+ const idx = lines.findIndex((l) => {
161
+ const f = parseLine(l);
162
+ return f && f.ts === ts;
163
+ });
164
+ if (idx === -1) continue;
165
+ const parsed = parseLine(lines[idx]);
166
+ if (parsed && typeof parsed.superseded_by === "string") return false;
167
+ const extra = formatLine({
168
+ superseded_by: fields.superseded_by,
169
+ reason: fields.reason,
170
+ symbol: fields.symbol,
171
+ from: fields.from,
172
+ to: fields.to,
173
+ entity: fields.entity,
174
+ });
175
+ lines[idx] = `${lines[idx]} ${extra}`;
176
+ await snapshotBefore(file);
177
+ await Bun.write(file, lines.join("\n") + "\n");
178
+ return true;
179
+ }
180
+ return false;
181
+ }
182
+
183
+ // Drop a row's supersession markers IN PLACE (snapshot-first), making it active
184
+ // again. Used by memory_merge_review: a declared survivor must be recallable
185
+ // even when an earlier row — now the loser — had superseded it first.
186
+ export async function clearSuperseded(ts: string): Promise<boolean> {
187
+ for (const file of await memoryFiles()) {
188
+ const lines = await readLines(file);
189
+ const idx = lines.findIndex((l) => {
190
+ const f = parseLine(l);
191
+ return f && f.ts === ts;
192
+ });
193
+ if (idx === -1) continue;
194
+ const parsed = parseLine(lines[idx]);
195
+ if (!parsed || typeof parsed.superseded_by !== "string") return false;
196
+ // These five keys are written only by markSuperseded (drift, canonical,
197
+ // update and merge_review), never by appendMemory, so they are all part of
198
+ // the marker and go with it. Field order round-trips because parseLine
199
+ // inserts keys in source order.
200
+ for (const key of ["superseded_by", "reason", "symbol", "from", "to", "entity"]) {
201
+ delete parsed[key];
202
+ }
203
+ lines[idx] = formatLine(parsed);
204
+ await snapshotBefore(file);
205
+ await Bun.write(file, lines.join("\n") + "\n");
206
+ return true;
207
+ }
208
+ return false;
209
+ }
210
+
211
+ export type TargetResolution =
212
+ | { status: "ok"; target: Memory }
213
+ | { status: "not_found" }
214
+ | { status: "ambiguous"; candidates: Memory[] };
215
+
216
+ // Score one row against a query: exact row id first, then content.
217
+ function targetScore(m: Memory, query: string): number {
218
+ const ts = m.ts.toLowerCase();
219
+ if (ts === query) return 100;
220
+ // A clock fragment ("00:11:25.867") is unique enough to identify a row.
221
+ if (query.length >= 8 && ts.includes(query)) return 95;
222
+ const content = m.content.toLowerCase();
223
+ if (content === query) return 90;
224
+ if (content.includes(query)) return 80;
225
+ if (query.includes(content)) return 70;
226
+ return 0;
227
+ }
228
+
229
+ // Pick the row an update/merge call means. Without a query the caller keeps the
230
+ // legacy "most recent active row of this scope+type" behaviour; with one, a
231
+ // miss or a tie fails CLOSED rather than silently rewriting a different row.
232
+ export function resolveMemoryTarget(rows: Memory[], query?: string): TargetResolution {
233
+ if (!query || !query.trim()) {
234
+ const last = rows[rows.length - 1];
235
+ return last ? { status: "ok", target: last } : { status: "not_found" };
236
+ }
237
+ const q = query.trim().toLowerCase();
238
+ let best: Memory | undefined;
239
+ let bestScore = 0;
240
+ const tied: Memory[] = [];
241
+ for (const m of rows) {
242
+ const score = targetScore(m, q);
243
+ if (score > bestScore) {
244
+ best = m;
245
+ bestScore = score;
246
+ tied.length = 0;
247
+ tied.push(m);
248
+ } else if (score === bestScore && score > 0) {
249
+ tied.push(m);
250
+ }
251
+ }
252
+ if (!best) return { status: "not_found" };
253
+ if (tied.length > 1) return { status: "ambiguous", candidates: tied };
254
+ return { status: "ok", target: best };
255
+ }
256
+
257
+ export interface DeletedMemory {
258
+ memory: Memory;
259
+ reason: string;
260
+ }
261
+
262
+ // Snapshot-before-mutate: copy each affected file into snapshots/ so /undo can
263
+ // restore it. Returns the snapshot paths keyed by original file.
264
+ export async function snapshotFiles(files: string[]): Promise<string[]> {
265
+ await ensureDir(snapshotDir());
266
+ const stamp = Date.now();
267
+ const snaps: string[] = [];
268
+ for (const file of files) {
269
+ const f = Bun.file(file);
270
+ if (!(await f.exists())) continue;
271
+ const snap = join(snapshotDir(), `${stamp}-${basename(file)}`);
272
+ await Bun.write(snap, await f.text());
273
+ snaps.push(snap);
274
+ }
275
+ return snaps;
276
+ }
277
+
278
+ export async function logDeletion(memory: Memory, reason: string): Promise<void> {
279
+ await appendLog(
280
+ deletionsFile(),
281
+ formatLine({
282
+ ts: new Date().toISOString(),
283
+ action: "deleted",
284
+ original_ts: memory.ts,
285
+ type: memory.type,
286
+ scope: memory.scope,
287
+ content: memory.content,
288
+ reason,
289
+ }),
290
+ );
291
+ }
292
+
293
+ export interface PruneResult {
294
+ deleted: DeletedMemory[];
295
+ snapshot: string[];
296
+ }
297
+
298
+ // Remove matching memories (used by memory_forget), snapshotting first and
299
+ // logging every deletion with its reason.
300
+ export async function prune(scope: string, type: string, reason: string): Promise<PruneResult> {
301
+ const files = await memoryFiles();
302
+ const snapshot = await snapshotFiles(files);
303
+ const deleted: DeletedMemory[] = [];
304
+ for (const file of files) {
305
+ const lines = await readLines(file);
306
+ const kept: string[] = [];
307
+ let changed = false;
308
+ for (const line of lines) {
309
+ const parsed = parseLine(line);
310
+ const m = parsed ? toMemory(parsed, file, 0) : null;
311
+ if (m && m.scope === scope && m.type === type) {
312
+ deleted.push({ memory: m, reason });
313
+ await logDeletion(m, reason);
314
+ changed = true;
315
+ continue;
316
+ }
317
+ kept.push(line);
318
+ }
319
+ if (changed) await Bun.write(file, kept.join("\n") + (kept.length ? "\n" : ""));
320
+ }
321
+ return { deleted, snapshot };
322
+ }
323
+
324
+ export function isActive(m: Memory): boolean {
325
+ return !m.superseded_by;
326
+ }
package/src/prune.ts ADDED
@@ -0,0 +1,23 @@
1
+ // Principle 3: a human can prune a bad memory. Deletion is auditable (reason),
2
+ // reversible (snapshot) and never touches a repo. The `prune` evidence line is
3
+ // emitted before/around the mutation so S5 can assert the reason and snapshot.
4
+ import { deletionsFile } from "./config.ts";
5
+ import { prune, type PruneResult } from "./memory.ts";
6
+ import { record } from "./evidence.ts";
7
+
8
+ export async function pruneMemory(
9
+ scope: string,
10
+ type: string,
11
+ reason: string,
12
+ ): Promise<PruneResult & { audit: string }> {
13
+ const res = await prune(scope, type, reason);
14
+ const memTs = res.deleted.map((d) => d.memory.ts).join(",") || "-";
15
+ await record("prune", {
16
+ mem: memTs,
17
+ reason,
18
+ snapshot: res.snapshot[0] ?? "-",
19
+ audit: deletionsFile(),
20
+ tool: "memory_forget",
21
+ });
22
+ return { ...res, audit: deletionsFile() };
23
+ }
@@ -0,0 +1,298 @@
1
+ // GATE-1 resolver. Extracts embedded path/symbol refs from a memory/evidence
2
+ // candidate and re-resolves them against the live source tree (and, when
3
+ // OPENCODE_CBM_BIN is set, the codebase-memory-mcp file graph). Deterministic:
4
+ // regex + balanced-paren scanning, no model involvement.
5
+ import { join } from "node:path";
6
+ import { type Memory } from "./memory.ts";
7
+
8
+ export interface CallForm {
9
+ symbol: string;
10
+ name: string;
11
+ ownerPath: string;
12
+ fileHint?: string;
13
+ argsText: string;
14
+ }
15
+
16
+ export interface DriftInfo {
17
+ symbol: string;
18
+ from: string;
19
+ to: string;
20
+ source: "candidate" | "indexed";
21
+ file?: string;
22
+ }
23
+
24
+ export interface Gate1Result {
25
+ refs: number;
26
+ resolved: string[];
27
+ drift: DriftInfo | null;
28
+ candidateStale: boolean;
29
+ }
30
+
31
+ export function balancedParen(text: string, openIdx: number): { inner: string; end: number } | null {
32
+ if (text[openIdx] !== "(") return null;
33
+ let depth = 0;
34
+ let quote: string | null = null;
35
+ for (let i = openIdx; i < text.length; i++) {
36
+ const c = text[i];
37
+ if (quote) {
38
+ if (c === "\\") i++;
39
+ else if (c === quote) quote = null;
40
+ continue;
41
+ }
42
+ if (c === '"' || c === "'" || c === "`") {
43
+ quote = c;
44
+ continue;
45
+ }
46
+ if (c === "(") depth++;
47
+ else if (c === ")") {
48
+ depth--;
49
+ if (depth === 0) return { inner: text.slice(openIdx + 1, i), end: i };
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+
55
+ const CALL_RE = /([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+)\s*\(/g;
56
+
57
+ export function extractCallForms(content: string): CallForm[] {
58
+ const forms: CallForm[] = [];
59
+ CALL_RE.lastIndex = 0;
60
+ let m: RegExpExecArray | null;
61
+ while ((m = CALL_RE.exec(content)) !== null) {
62
+ const symbol = m[1];
63
+ const openIdx = content.indexOf("(", m.index + symbol.length);
64
+ const paren = openIdx >= 0 ? balancedParen(content, openIdx) : null;
65
+ if (!paren) continue;
66
+ const parts = symbol.split(".");
67
+ const name = parts[parts.length - 1];
68
+ const ownerPath = parts.slice(0, -1).join(".");
69
+ forms.push({
70
+ symbol,
71
+ name,
72
+ ownerPath,
73
+ fileHint: parts.length >= 2 ? parts[parts.length - 2] : undefined,
74
+ argsText: paren.inner,
75
+ });
76
+ }
77
+ return forms;
78
+ }
79
+
80
+ const PATH_RE = /[A-Za-z0-9_@./-]+\.(?:ts|tsx|js|jsx|mjs|cjs)(?::\d+)?/g;
81
+
82
+ export function extractPathRefs(content: string): string[] {
83
+ return [...content.matchAll(PATH_RE)].map((m) => m[0]);
84
+ }
85
+
86
+ function splitTopLevel(argsText: string): string[] {
87
+ const parts: string[] = [];
88
+ let depth = 0;
89
+ let quote: string | null = null;
90
+ let current = "";
91
+ for (let i = 0; i < argsText.length; i++) {
92
+ const c = argsText[i];
93
+ if (quote) {
94
+ current += c;
95
+ if (c === "\\") {
96
+ current += argsText[++i] ?? "";
97
+ } else if (c === quote) quote = null;
98
+ continue;
99
+ }
100
+ if (c === '"' || c === "'" || c === "`") {
101
+ quote = c;
102
+ current += c;
103
+ continue;
104
+ }
105
+ if ("([{<".includes(c)) depth++;
106
+ if (")]}>".includes(c)) depth--;
107
+ if (c === "," && depth === 0) {
108
+ parts.push(current);
109
+ current = "";
110
+ continue;
111
+ }
112
+ current += c;
113
+ }
114
+ if (current.trim()) parts.push(current);
115
+ return parts;
116
+ }
117
+
118
+ // Canonicalize an argument list to its parameter names so that type
119
+ // annotations and defaults do not read as signature changes:
120
+ // `config: any, opts = {}` -> `config, opts`.
121
+ // A TS `_` prefix on an unused parameter (`_name`, `_size`) is stripped so the
122
+ // same identifier with and without the underscore reads as the same name.
123
+ export function normalizeArgs(argsText: string): string {
124
+ return splitTopLevel(argsText)
125
+ .map((a) => {
126
+ let t = a.split("=")[0] ?? a;
127
+ t = t.split(":")[0] ?? t;
128
+ t = t.trim().replace(/^_/, "").replace(/\?$/, "").trim();
129
+ return t;
130
+ })
131
+ .filter(Boolean)
132
+ .join(", ");
133
+ }
134
+
135
+ // Prefix-aware signature drift. A memory states only the args it actually
136
+ // names; trailing live params it omits (scene?, updatable?) are not drift.
137
+ // Drift is a positional mismatch at any stated index, or a claim that the live
138
+ // signature dropped (candidate has more args than live).
139
+ export function sameArgs(candidate: string, live: string): boolean {
140
+ const c = splitTopLevel(normalizeArgs(candidate));
141
+ const l = splitTopLevel(normalizeArgs(live));
142
+ if (c.length === 0) return true;
143
+ if (c.length > l.length) return false;
144
+ return c.every((a, i) => a === l[i]);
145
+ }
146
+
147
+ export function displayCall(name: string, argsText: string): string {
148
+ return `${name}(${normalizeArgs(argsText)})`;
149
+ }
150
+
151
+ // Deterministic source-tree scan used by GATE-1 live resolution. Excludes junk
152
+ // that poisons signature comparison: node_modules (type-decl .d.ts files often
153
+ // carry empty or spread args), build output (dist/lib), and VCS dirs. Prefers
154
+ // files under src/ over build output, .ts/.tsx sources over compiled .js, and
155
+ // case-insensitively ranks basenames matching the call's fileHint first so
156
+ // `MeshBuilder.CreateBox` resolves to `meshbuilder.pure.ts` instead of a
157
+ // different file that merely also calls CreateBox.
158
+ const SKIP_DIRS = ["node_modules", ".git", ".hg", ".svn"];
159
+ const SOURCE_EXTS = ["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
160
+
161
+ function relRank(rel: string): number {
162
+ const seg = rel.split("/");
163
+ if (seg.some((s) => SKIP_DIRS.includes(s))) return Infinity;
164
+ if (rel.endsWith(".d.ts")) return Infinity;
165
+ let rank = 0;
166
+ if (seg.includes("src")) rank -= 100;
167
+ if (rel.endsWith(".d.ts")) rank += 1000;
168
+ if (seg.some((s) => ["dist", "build", "out", "lib"].includes(s))) rank += 50;
169
+ if (/\.(js|mjs|cjs)$/.test(rel)) rank += 10;
170
+ return rank;
171
+ }
172
+
173
+ async function scanSourceFiles(liveRoot: string): Promise<string[]> {
174
+ const out = new Set<string>();
175
+ for (const ext of SOURCE_EXTS) {
176
+ try {
177
+ for await (const f of new Bun.Glob(`**/*.${ext}`).scan(liveRoot)) out.add(f);
178
+ } catch {
179
+ // Non-existent root -> no candidates.
180
+ }
181
+ }
182
+ return [...out].filter((f) => relRank(f) < Infinity);
183
+ }
184
+
185
+ function hintBoost(rel: string, hint: string): number {
186
+ const base = rel.split("/").pop()!.toLowerCase();
187
+ if (base.startsWith(hint)) return 4;
188
+ if (base.includes(hint)) return 3;
189
+ return 0;
190
+ }
191
+
192
+ // First live definition of `name` in the source tree, falling back to the
193
+ // first call site. A definition found anywhere beats a call site that sorts
194
+ // earlier: `MeshBuilder.CreateBox`'s real signature lives in
195
+ // boxBuilder.pure.ts (a facade property in meshBuilder.pure.ts re-imports it),
196
+ // but a call site in an alphabetically-earlier file must not shadow it.
197
+ export async function findLiveCall(
198
+ name: string,
199
+ fileHint: string | undefined,
200
+ liveRoot: string,
201
+ ): Promise<{ file: string; argsText: string } | null> {
202
+ let files = await scanSourceFiles(liveRoot);
203
+ const tiebreak = (a: string, b: string) => a.localeCompare(b);
204
+ if (fileHint) {
205
+ const h = fileHint.toLowerCase();
206
+ files.sort((a, b) => relRank(a) - relRank(b) || hintBoost(b, h) - hintBoost(a, h) || tiebreak(a, b));
207
+ } else {
208
+ files.sort((a, b) => relRank(a) - relRank(b) || tiebreak(a, b));
209
+ }
210
+
211
+ let fallback: { file: string; argsText: string } | null = null;
212
+ for (const rel of files) {
213
+ const file = join(liveRoot, rel);
214
+ let text: string;
215
+ try {
216
+ text = await Bun.file(file).text();
217
+ } catch {
218
+ continue;
219
+ }
220
+ if (!text.includes(name)) continue;
221
+ const defRe = new RegExp(`(?:function|const|class|var|static)\\s+${name}\\b`);
222
+ const defMatch = defRe.exec(text);
223
+ if (defMatch) {
224
+ const idx = text.indexOf("(", defMatch.index + defMatch[0].length);
225
+ const paren = idx >= 0 ? balancedParen(text, idx) : null;
226
+ if (paren) return { file, argsText: paren.inner };
227
+ }
228
+ if (!fallback) {
229
+ const callMatch = new RegExp(`\\b${name}\\s*\\(`).exec(text);
230
+ if (callMatch) {
231
+ const idx = text.indexOf("(", callMatch.index);
232
+ const paren = idx >= 0 ? balancedParen(text, idx) : null;
233
+ if (paren) fallback = { file, argsText: paren.inner };
234
+ }
235
+ }
236
+ }
237
+ return fallback;
238
+ }
239
+
240
+ // The signature a memory/evidence already embeds for `symbol` in `scope`.
241
+ export function indexedSignature(symbol: string, scope: Memory[]): CallForm | null {
242
+ for (const m of scope) {
243
+ for (const form of extractCallForms(m.content)) {
244
+ if (form.symbol === symbol) return form;
245
+ }
246
+ }
247
+ return null;
248
+ }
249
+
250
+ export interface ResolveInput {
251
+ content: string;
252
+ scope: string;
253
+ liveRoot: string;
254
+ scopeMemories: Memory[];
255
+ }
256
+
257
+ export async function gate1(input: ResolveInput): Promise<Gate1Result> {
258
+ const forms = extractCallForms(input.content);
259
+ const pathRefs = extractPathRefs(input.content);
260
+ const refs = forms.length + pathRefs.length;
261
+ if (refs === 0) return { refs: 0, resolved: [], drift: null, candidateStale: false };
262
+
263
+ let drift: DriftInfo | null = null;
264
+ const resolved: string[] = [];
265
+
266
+ for (const form of forms) {
267
+ resolved.push(form.symbol);
268
+ const live = await findLiveCall(form.name, form.fileHint, input.liveRoot);
269
+ if (!live) continue;
270
+ const indexed = indexedSignature(form.symbol, input.scopeMemories);
271
+ const candidateDiffers = !sameArgs(form.argsText, live.argsText);
272
+ const indexedDiffers = indexed ? !sameArgs(indexed.argsText, live.argsText) : false;
273
+ if (candidateDiffers) {
274
+ drift = {
275
+ symbol: form.symbol,
276
+ from: displayCall(form.name, form.argsText),
277
+ to: displayCall(form.name, live.argsText),
278
+ source: "candidate",
279
+ file: live.file,
280
+ };
281
+ } else if (indexedDiffers && indexed) {
282
+ drift = {
283
+ symbol: form.symbol,
284
+ from: displayCall(form.name, indexed.argsText),
285
+ to: displayCall(form.name, live.argsText),
286
+ source: "indexed",
287
+ file: live.file,
288
+ };
289
+ }
290
+ }
291
+
292
+ return {
293
+ refs,
294
+ resolved,
295
+ drift,
296
+ candidateStale: drift?.source === "candidate",
297
+ };
298
+ }