opencode-self-improvement 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +122 -2
- package/command/repo-init.md +16 -0
- package/index.ts +2 -0
- package/package.json +51 -3
- package/src/blocklist.ts +56 -0
- package/src/command.ts +25 -0
- package/src/config.ts +60 -0
- package/src/evidence.ts +50 -0
- package/src/gates.ts +511 -0
- package/src/index.ts +461 -0
- package/src/init.ts +300 -0
- package/src/inject.ts +150 -0
- package/src/logfmt.ts +67 -0
- package/src/memory.ts +242 -0
- package/src/prune.ts +23 -0
- package/src/resolver.ts +298 -0
- package/src/state.ts +88 -0
- package/src/triggers.ts +292 -0
- package/src/undo.ts +44 -0
- package/src/viewer/mutate.ts +152 -0
- package/src/viewer/page.html +362 -0
- package/src/viewer/server.ts +191 -0
- package/vendor/opencode-memory/LICENSE +21 -0
package/src/gates.ts
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
// The 4-gate write pipeline: GATE-1
|
|
2
|
+
// canonical-graph correctness, GATE-2 stale-ref scan + supersession, GATE-3
|
|
3
|
+
// dedup, GATE-4 promotion/block. Every step emits an ordered evidence line.
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { memoryRoot, canonicalFile } from "./config.ts";
|
|
6
|
+
import { bracketList, parseLine, readLines } from "./logfmt.ts";
|
|
7
|
+
import {
|
|
8
|
+
appendMemory,
|
|
9
|
+
loadAll,
|
|
10
|
+
loadScope,
|
|
11
|
+
markSuperseded,
|
|
12
|
+
snapshotFiles,
|
|
13
|
+
type Memory,
|
|
14
|
+
} from "./memory.ts";
|
|
15
|
+
import { chainId, contentHash, record, recordWriteBlocked } from "./evidence.ts";
|
|
16
|
+
import {
|
|
17
|
+
displayCall,
|
|
18
|
+
extractCallForms,
|
|
19
|
+
findLiveCall,
|
|
20
|
+
gate1,
|
|
21
|
+
sameArgs,
|
|
22
|
+
type DriftInfo,
|
|
23
|
+
} from "./resolver.ts";
|
|
24
|
+
|
|
25
|
+
export interface WriteCandidate {
|
|
26
|
+
type: string;
|
|
27
|
+
scope: string;
|
|
28
|
+
content: string;
|
|
29
|
+
issue?: string;
|
|
30
|
+
tags?: string[];
|
|
31
|
+
status?: "open" | "settled";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface GateContext {
|
|
35
|
+
liveRoot: string;
|
|
36
|
+
evidenceCalls: string[];
|
|
37
|
+
hasGateCheck: boolean;
|
|
38
|
+
// True when the session exercised the code (read/grep/edit/bash results
|
|
39
|
+
// recorded). Empirical engagement substitutes for hand-copied refs (gate
|
|
40
|
+
// injection): the write encodes behavior that actually happened here, so the
|
|
41
|
+
// formal gate check is not the only admissible grounding.
|
|
42
|
+
hasEngagement?: boolean;
|
|
43
|
+
// Plugin-authored bookkeeping (e.g. init stamps, script memories) bypasses
|
|
44
|
+
// the GATE-4 ambiguity rule: it embeds no refs and has no completion-gate
|
|
45
|
+
// evidence by construction, but is still evaluated by GATE-1/2/3.
|
|
46
|
+
trusted?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface GateOutcome {
|
|
50
|
+
action: "promoted" | "blocked" | "collapsed";
|
|
51
|
+
reason?: string;
|
|
52
|
+
ts?: string;
|
|
53
|
+
chain: string;
|
|
54
|
+
hint?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const STOPWORDS = new Set([
|
|
58
|
+
"use",
|
|
59
|
+
"using",
|
|
60
|
+
"the",
|
|
61
|
+
"a",
|
|
62
|
+
"an",
|
|
63
|
+
"in",
|
|
64
|
+
"of",
|
|
65
|
+
"to",
|
|
66
|
+
"with",
|
|
67
|
+
"on",
|
|
68
|
+
"and",
|
|
69
|
+
"or",
|
|
70
|
+
"for",
|
|
71
|
+
"is",
|
|
72
|
+
"are",
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
function tokens(text: string): string[] {
|
|
76
|
+
return text
|
|
77
|
+
.toLowerCase()
|
|
78
|
+
.split(/[^a-z0-9_.]+/)
|
|
79
|
+
.filter((t) => t.length > 0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Case-preserving tokens, for reporting asserted values back to the user.
|
|
83
|
+
function rawTokens(text: string): string[] {
|
|
84
|
+
return text.split(/[^A-Za-z0-9_.]+/).filter(Boolean);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function jaccard(a: string[], b: string[]): number {
|
|
88
|
+
const sa = new Set(a);
|
|
89
|
+
const sb = new Set(b);
|
|
90
|
+
let inter = 0;
|
|
91
|
+
for (const t of sa) if (sb.has(t)) inter++;
|
|
92
|
+
const union = sa.size + sb.size - inter;
|
|
93
|
+
return union === 0 ? 0 : inter / union;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Planning-flavored content (frontier/thread/proposal language). Used only to
|
|
97
|
+
// turn GATE-4's ambiguous block into a hint pointing at the open-thread home.
|
|
98
|
+
const PLANNING_WORDS = new Set([
|
|
99
|
+
"plan", "plans", "planned", "planning", "frontier", "propos", "proposal",
|
|
100
|
+
"open", "goal", "goals", "decide", "decides", "decided", "deciding",
|
|
101
|
+
"reject", "rejects", "rejected", "thread", "should", "option", "options",
|
|
102
|
+
"candidate", "candidates", "next", "roadmap", "scale", "overhead", "bloat",
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
function looksPlanning(candidate: WriteCandidate): boolean {
|
|
106
|
+
return tokens(candidate.content).some((t) => PLANNING_WORDS.has(t));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface CanonEntry {
|
|
110
|
+
ts: string;
|
|
111
|
+
entity: string;
|
|
112
|
+
value: string;
|
|
113
|
+
scope: string;
|
|
114
|
+
source: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function loadCanonical(): Promise<CanonEntry[]> {
|
|
118
|
+
const lines = await readLines(canonicalFile());
|
|
119
|
+
const out: CanonEntry[] = [];
|
|
120
|
+
for (const line of lines) {
|
|
121
|
+
const f = parseLine(line);
|
|
122
|
+
if (!f || f.type !== "canonical") continue;
|
|
123
|
+
if (typeof f.entity !== "string" || typeof f.value !== "string") continue;
|
|
124
|
+
out.push({
|
|
125
|
+
ts: typeof f.ts === "string" ? f.ts : "?",
|
|
126
|
+
entity: f.entity,
|
|
127
|
+
value: f.value,
|
|
128
|
+
scope: typeof f.scope === "string" ? f.scope : "",
|
|
129
|
+
source: typeof f.source === "string" ? f.source : "?",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function canonicalMatch(
|
|
136
|
+
scope: string,
|
|
137
|
+
content: string,
|
|
138
|
+
registry: CanonEntry[],
|
|
139
|
+
): { entry: CanonEntry; aligns: boolean; conflictingValue?: string } | null {
|
|
140
|
+
const ct = tokens(content);
|
|
141
|
+
for (const entry of registry) {
|
|
142
|
+
if (entry.scope !== scope) continue;
|
|
143
|
+
const et = tokens(entry.entity);
|
|
144
|
+
if (!et.length || !et.every((t) => ct.includes(t))) continue;
|
|
145
|
+
const aligns = tokens(entry.value).every((t) => ct.includes(t));
|
|
146
|
+
let conflictingValue: string | undefined;
|
|
147
|
+
if (!aligns) {
|
|
148
|
+
const entitySet = new Set(et);
|
|
149
|
+
const asserted = rawTokens(content).filter(
|
|
150
|
+
(t) => !STOPWORDS.has(t.toLowerCase()) && !entitySet.has(t.toLowerCase()),
|
|
151
|
+
);
|
|
152
|
+
conflictingValue = asserted[0];
|
|
153
|
+
}
|
|
154
|
+
return { entry, aligns, conflictingValue };
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
interface StaleRef {
|
|
160
|
+
memory: Memory;
|
|
161
|
+
symbol: string;
|
|
162
|
+
from: string;
|
|
163
|
+
to: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function staleRefFor(m: Memory, liveRoot: string): Promise<StaleRef | null> {
|
|
167
|
+
for (const form of extractCallForms(m.content)) {
|
|
168
|
+
const live = await findLiveCall(form.name, form.fileHint, liveRoot);
|
|
169
|
+
if (!live) continue;
|
|
170
|
+
if (!sameArgs(form.argsText, live.argsText)) {
|
|
171
|
+
return {
|
|
172
|
+
memory: m,
|
|
173
|
+
symbol: form.symbol,
|
|
174
|
+
from: displayCall(form.name, form.argsText),
|
|
175
|
+
to: displayCall(form.name, live.argsText),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function deltaTokens(candidate: WriteCandidate, drift: DriftInfo | null): string[] {
|
|
183
|
+
const set = new Set<string>();
|
|
184
|
+
for (const form of extractCallForms(candidate.content)) {
|
|
185
|
+
if (form.fileHint) set.add(form.fileHint);
|
|
186
|
+
}
|
|
187
|
+
if (drift) {
|
|
188
|
+
const owner = drift.symbol.split(".").slice(0, -1).pop();
|
|
189
|
+
if (owner) set.add(owner);
|
|
190
|
+
}
|
|
191
|
+
return [...set];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function runWriteGate(
|
|
195
|
+
candidate: WriteCandidate,
|
|
196
|
+
ctx: GateContext,
|
|
197
|
+
): Promise<GateOutcome> {
|
|
198
|
+
const chain = chainId();
|
|
199
|
+
const ch = contentHash(candidate.content);
|
|
200
|
+
await record("gate_start", {
|
|
201
|
+
chain,
|
|
202
|
+
op: "write",
|
|
203
|
+
scope: candidate.scope,
|
|
204
|
+
type: candidate.type,
|
|
205
|
+
ch,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const scopeMems = candidate.scope ? await loadScope(candidate.scope) : [];
|
|
209
|
+
|
|
210
|
+
// ---- GATE-1: canonical graph correct ------------------------------------
|
|
211
|
+
const g1 = await gate1({
|
|
212
|
+
content: candidate.content,
|
|
213
|
+
scope: candidate.scope,
|
|
214
|
+
liveRoot: ctx.liveRoot,
|
|
215
|
+
scopeMemories: scopeMems,
|
|
216
|
+
});
|
|
217
|
+
if (g1.drift) {
|
|
218
|
+
await record("graph_drift", {
|
|
219
|
+
gate: 1,
|
|
220
|
+
symbol: g1.drift.symbol,
|
|
221
|
+
from: g1.drift.from,
|
|
222
|
+
to: g1.drift.to,
|
|
223
|
+
corrected: true,
|
|
224
|
+
evidence: ctx.evidenceCalls[0] ?? "-",
|
|
225
|
+
chain,
|
|
226
|
+
});
|
|
227
|
+
} else {
|
|
228
|
+
await record("graph_current", {
|
|
229
|
+
gate: 1,
|
|
230
|
+
refs: g1.refs,
|
|
231
|
+
resolved: g1.resolved.length ? g1.resolved.join(",") : undefined,
|
|
232
|
+
chain,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Canonical registry is consulted at GATE-2 but needed for block decisions.
|
|
237
|
+
const registry = await loadCanonical();
|
|
238
|
+
const canon = candidate.scope
|
|
239
|
+
? canonicalMatch(candidate.scope, candidate.content, registry)
|
|
240
|
+
: null;
|
|
241
|
+
|
|
242
|
+
// ---- GATE-2: no stale memories apply here -------------------------------
|
|
243
|
+
const delta = deltaTokens(candidate, g1.drift);
|
|
244
|
+
const stale: StaleRef[] = [];
|
|
245
|
+
for (const m of scopeMems) {
|
|
246
|
+
const s = await staleRefFor(m, ctx.liveRoot);
|
|
247
|
+
if (s) stale.push(s);
|
|
248
|
+
}
|
|
249
|
+
const bailed = scopeMems.length === 0;
|
|
250
|
+
await record("ref_stale_scan", {
|
|
251
|
+
gate: 2,
|
|
252
|
+
scope: candidate.scope,
|
|
253
|
+
delta: bracketList(delta),
|
|
254
|
+
scanned: scopeMems.length,
|
|
255
|
+
found: stale.length,
|
|
256
|
+
bailed,
|
|
257
|
+
chain,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
let supersededTs: string | undefined;
|
|
261
|
+
for (const s of stale) {
|
|
262
|
+
await record("ref_stale", {
|
|
263
|
+
gate: 2,
|
|
264
|
+
mem: s.memory.ts,
|
|
265
|
+
scope: candidate.scope,
|
|
266
|
+
ref: s.symbol,
|
|
267
|
+
from: s.from,
|
|
268
|
+
to: s.to,
|
|
269
|
+
chain,
|
|
270
|
+
});
|
|
271
|
+
// Drift-fed supersession is AUTO (provable) and target depends on whether a
|
|
272
|
+
// valid write will land; stale data is neutralized either way.
|
|
273
|
+
const target = g1.candidateStale ? "<-" : chain;
|
|
274
|
+
const marked = await markSuperseded(s.memory.ts, {
|
|
275
|
+
superseded_by: target,
|
|
276
|
+
reason: "drift",
|
|
277
|
+
symbol: s.symbol,
|
|
278
|
+
from: s.from,
|
|
279
|
+
to: s.to,
|
|
280
|
+
});
|
|
281
|
+
if (marked) {
|
|
282
|
+
supersededTs = s.memory.ts;
|
|
283
|
+
await record("superseded_by", {
|
|
284
|
+
gate: 2,
|
|
285
|
+
mem: s.memory.ts,
|
|
286
|
+
target,
|
|
287
|
+
reason: "drift",
|
|
288
|
+
symbol: s.symbol,
|
|
289
|
+
from: s.from,
|
|
290
|
+
to: s.to,
|
|
291
|
+
chain,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (canon) {
|
|
297
|
+
await record("canonical_hit", {
|
|
298
|
+
gate: 2,
|
|
299
|
+
entity: canon.entry.entity,
|
|
300
|
+
value: canon.entry.value,
|
|
301
|
+
scope: canon.entry.scope,
|
|
302
|
+
source: canon.entry.source,
|
|
303
|
+
chain,
|
|
304
|
+
});
|
|
305
|
+
// Any in-scope entry that contradicts the canonical value is neutralized.
|
|
306
|
+
const entitySet = new Set(tokens(canon.entry.entity));
|
|
307
|
+
const valueSet = new Set(tokens(canon.entry.value));
|
|
308
|
+
for (const m of scopeMems) {
|
|
309
|
+
if (m.superseded_by) continue;
|
|
310
|
+
if (tokens(m.content).some((t) => valueSet.has(t))) continue;
|
|
311
|
+
const from = rawTokens(m.content).find(
|
|
312
|
+
(t) => !STOPWORDS.has(t.toLowerCase()) && !entitySet.has(t.toLowerCase()) && !valueSet.has(t.toLowerCase()),
|
|
313
|
+
);
|
|
314
|
+
const target = canon.aligns ? chain : "<-";
|
|
315
|
+
const marked = await markSuperseded(m.ts, {
|
|
316
|
+
superseded_by: target,
|
|
317
|
+
reason: "canonical",
|
|
318
|
+
entity: canon.entry.entity,
|
|
319
|
+
from: from ?? undefined,
|
|
320
|
+
to: canon.entry.value,
|
|
321
|
+
});
|
|
322
|
+
if (marked) {
|
|
323
|
+
if (!supersededTs) supersededTs = m.ts;
|
|
324
|
+
await record("superseded_by", {
|
|
325
|
+
gate: 2,
|
|
326
|
+
mem: m.ts,
|
|
327
|
+
target,
|
|
328
|
+
reason: "canonical",
|
|
329
|
+
entity: canon.entry.entity,
|
|
330
|
+
from: from ?? undefined,
|
|
331
|
+
to: canon.entry.value,
|
|
332
|
+
chain,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ---- GATE-3: no dupes ----------------------------------------------------
|
|
339
|
+
const conflictProne = candidate.type === "decision" || candidate.type === "preference";
|
|
340
|
+
const active = scopeMems.filter((m) => !m.superseded_by);
|
|
341
|
+
// Exact duplicates are detected against the full scope (a drift-superseded
|
|
342
|
+
// twin still collapses), but overlap/merge only considers live entries.
|
|
343
|
+
const exactPoll = scopeMems.filter(
|
|
344
|
+
(m) => m.superseded_by === undefined || m.reason === "drift",
|
|
345
|
+
);
|
|
346
|
+
const exact = exactPoll.find(
|
|
347
|
+
(m) => m.type === candidate.type && m.content === candidate.content,
|
|
348
|
+
);
|
|
349
|
+
const divergent = active.filter(
|
|
350
|
+
(m) => m.type === candidate.type && m.content !== candidate.content,
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
let collapseMatch: Memory | undefined;
|
|
354
|
+
let overlapMatch: Memory | undefined;
|
|
355
|
+
let overlapScore = 0;
|
|
356
|
+
|
|
357
|
+
if (
|
|
358
|
+
exact &&
|
|
359
|
+
!exact.superseded_by &&
|
|
360
|
+
(canon?.aligns || !(conflictProne && divergent.length))
|
|
361
|
+
) {
|
|
362
|
+
// Byte-identical duplicate collapses (Principle 2) — exactness is
|
|
363
|
+
// independent of the canonical registry, so a decided/preferred fact
|
|
364
|
+
// restated verbatim is never "conflicting" content on its own. Two
|
|
365
|
+
// exceptions keep the S4/S4b semantics: a canonical that aligns resolves
|
|
366
|
+
// the rivalry (echo collapses), but a scope already holding RIVAL
|
|
367
|
+
// decision/preference entries with no resolving canon keeps the exact
|
|
368
|
+
// restatement review-gated — dedup must not silently bury a conflict.
|
|
369
|
+
collapseMatch = exact;
|
|
370
|
+
} else if (conflictProne) {
|
|
371
|
+
if (!canon && divergent.length) {
|
|
372
|
+
// Conflicting decisions without a registry entry -> review-gated merge.
|
|
373
|
+
overlapMatch = divergent[0];
|
|
374
|
+
overlapScore =
|
|
375
|
+
Math.round(jaccard(tokens(candidate.content), tokens(divergent[0].content)) * 100) / 100;
|
|
376
|
+
}
|
|
377
|
+
} else {
|
|
378
|
+
let best: { m: Memory; score: number } | undefined;
|
|
379
|
+
for (const m of active) {
|
|
380
|
+
if (m.type !== candidate.type) continue;
|
|
381
|
+
const score = jaccard(tokens(candidate.content), tokens(m.content));
|
|
382
|
+
if (score >= 0.9 && (!best || score > best.score)) best = { m, score };
|
|
383
|
+
}
|
|
384
|
+
if (best) {
|
|
385
|
+
overlapMatch = best.m;
|
|
386
|
+
overlapScore = Math.round(best.score * 100) / 100;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (collapseMatch) {
|
|
391
|
+
await record("dedup_collapse", {
|
|
392
|
+
gate: 3,
|
|
393
|
+
survivors: scopeMems.length,
|
|
394
|
+
match: collapseMatch.ts,
|
|
395
|
+
ch,
|
|
396
|
+
action: "canonical_existing",
|
|
397
|
+
incoming_ignored: true,
|
|
398
|
+
chain,
|
|
399
|
+
});
|
|
400
|
+
} else if (overlapMatch) {
|
|
401
|
+
await record("dedup_overlap", {
|
|
402
|
+
gate: 3,
|
|
403
|
+
survivors: scopeMems.length,
|
|
404
|
+
candidate: overlapMatch.ts,
|
|
405
|
+
score: overlapScore,
|
|
406
|
+
action: "propose_merge",
|
|
407
|
+
chain,
|
|
408
|
+
});
|
|
409
|
+
} else {
|
|
410
|
+
await record("dedup_clean", {
|
|
411
|
+
gate: 3,
|
|
412
|
+
survivors: scopeMems.length,
|
|
413
|
+
candidate: 0,
|
|
414
|
+
chain,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ---- GATE-4: write or block ---------------------------------------------
|
|
419
|
+
const flags: string[] = [];
|
|
420
|
+
if (candidate.scope === "") flags.push("empty_scope");
|
|
421
|
+
if (g1.refs === 0) flags.push("no_embedded_refs");
|
|
422
|
+
if (!ctx.hasGateCheck) flags.push("no_evidence_refs");
|
|
423
|
+
// A canonical-registry hit is itself authority, so it lifts the ambiguity
|
|
424
|
+
// rule (S4b-i promotes with no embedded refs and no completion-gate call).
|
|
425
|
+
// An explicit status="open" is the planning-object home: those entries are
|
|
426
|
+
// provisional by definition (not claims about code), so only a missing scope
|
|
427
|
+
// keeps them ambiguous. Empirical engagement (a completion-gate run, or any
|
|
428
|
+
// read/grep/edit/run of the code in-session) grounds settled claims too:
|
|
429
|
+
// the write is the residue of behavior that actually happened here, so
|
|
430
|
+
// hand-copied refs are redundant (gate injection).
|
|
431
|
+
const planningObject = candidate.status === "open";
|
|
432
|
+
const behaviorGrounded = ctx.hasGateCheck || ctx.hasEngagement === true;
|
|
433
|
+
const ambiguous =
|
|
434
|
+
!ctx.trusted && !canon &&
|
|
435
|
+
(candidate.scope === "" || (!planningObject && g1.refs === 0 && !behaviorGrounded));
|
|
436
|
+
|
|
437
|
+
let blockReason: string | undefined;
|
|
438
|
+
let blockFlags: string[] = [];
|
|
439
|
+
let hint: string | undefined;
|
|
440
|
+
if (g1.candidateStale) {
|
|
441
|
+
blockReason = "drift_ref_stale_incoming";
|
|
442
|
+
blockFlags = ["incoming_embeds_pre_drift_ref"];
|
|
443
|
+
} else if (canon && !canon.aligns) {
|
|
444
|
+
blockReason = "conflicts_with_canonical";
|
|
445
|
+
blockFlags = [
|
|
446
|
+
`canonical=${canon.entry.value}`,
|
|
447
|
+
`incoming=${canon.conflictingValue ?? "?"}`,
|
|
448
|
+
];
|
|
449
|
+
} else if (overlapMatch) {
|
|
450
|
+
blockReason = "superseded_overlap";
|
|
451
|
+
const newest = scopeMems[scopeMems.length - 1];
|
|
452
|
+
blockFlags = [`supersedes_check_${newest ? newest.ts : "-"}`, "review_gated"];
|
|
453
|
+
} else if (ambiguous && !collapseMatch) {
|
|
454
|
+
blockReason = "ambiguous_memory";
|
|
455
|
+
blockFlags = flags;
|
|
456
|
+
if (looksPlanning(candidate)) {
|
|
457
|
+
hint =
|
|
458
|
+
'Looks like an open planning thread. Retry with status="open" and a non-empty scope to store it as a frontier item, or run the completion gates to ground it.';
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (blockReason) {
|
|
463
|
+
await recordWriteBlocked(4, blockReason, blockFlags, chain);
|
|
464
|
+
if (blockReason === "superseded_overlap" && overlapMatch) {
|
|
465
|
+
const newest = scopeMems[scopeMems.length - 1] ?? overlapMatch;
|
|
466
|
+
await record("merge_proposal", {
|
|
467
|
+
candidate: overlapMatch.ts,
|
|
468
|
+
incoming: chain,
|
|
469
|
+
canonical: newest.ts,
|
|
470
|
+
source: `durable_${candidate.type}`,
|
|
471
|
+
review: "required",
|
|
472
|
+
chain,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
return { action: "blocked", reason: blockReason, chain, hint };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (collapseMatch) {
|
|
479
|
+
return { action: "collapsed", chain };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// The write is specifically promoted on engagement (no refs, no gate check,
|
|
483
|
+
// not canonical, not an open object). Emitted so the precedent is auditable.
|
|
484
|
+
const groundedByEngagement =
|
|
485
|
+
!ctx.trusted && !canon && !planningObject &&
|
|
486
|
+
g1.refs === 0 && !ctx.hasGateCheck &&
|
|
487
|
+
ctx.hasEngagement === true && candidate.scope !== "";
|
|
488
|
+
if (groundedByEngagement) {
|
|
489
|
+
await record("gate_injected", { gate: 4, chain });
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const snapshot = await snapshotFiles([join(memoryRoot(), `${new Date().toISOString().split("T")[0]}.logfmt`)]);
|
|
493
|
+
const ts = await appendMemory(candidate);
|
|
494
|
+
await record("write_promoted", {
|
|
495
|
+
gate: 4,
|
|
496
|
+
op: "write",
|
|
497
|
+
mem: ts,
|
|
498
|
+
scope: candidate.scope,
|
|
499
|
+
type: candidate.type,
|
|
500
|
+
status: candidate.status ?? "settled",
|
|
501
|
+
ch,
|
|
502
|
+
supersedes: supersededTs ?? "-",
|
|
503
|
+
canonical: canon ? `canonical.logfmt:${canon.entry.ts}` : "-",
|
|
504
|
+
evidence_refs: bracketList(ctx.evidenceCalls),
|
|
505
|
+
snapshot: snapshot[0] ?? "-",
|
|
506
|
+
trusted: ctx.trusted ? true : undefined,
|
|
507
|
+
grounded_by: groundedByEngagement ? "engagement" : undefined,
|
|
508
|
+
chain,
|
|
509
|
+
});
|
|
510
|
+
return { action: "promoted", ts, chain };
|
|
511
|
+
}
|