backpass 0.1.11 → 0.1.13

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/fold.js CHANGED
@@ -12,7 +12,10 @@ import { GAP_SIMILARITY_THRESHOLD, gapSource } from "./gap-ledger.js";
12
12
  * evidence / sessions analyzed. That ratio is what decides memory-file vs skill
13
13
  * placement in section 7.
14
14
  * 2. Near-duplicate gaps from different sessions are clustered, so "three sessions
15
- * re-derived the db schema" arrives as one item with three quotes.
15
+ * re-derived the db schema" arrives as one item with three quotes. Judged identity
16
+ * happens upstream (analysis citations and the consolidation pass reshape the
17
+ * ledger); the clustering here stays deterministic. Orchestration-domain sightings
18
+ * are excluded before clustering and surfaced only as a count.
16
19
  * 3. Gap clusters below `minGapEvidence` are dropped. Batch size > 1: one bad session
17
20
  * never rewrites the weights. Sessions are counted across runs, not per run: when the
18
21
  * caller passes `gapObservations` (the pruned gap ledger, `src/gap-ledger.js`) the
@@ -40,6 +43,7 @@ export function foldEvidence(evidenceRecords, { minGapEvidence = 2, memoryFile =
40
43
  positive: 0,
41
44
  negative: 0,
42
45
  sessions: new Set(),
46
+ harmSessions: new Set(),
43
47
  quotes: [],
44
48
  });
45
49
  }
@@ -55,8 +59,20 @@ export function foldEvidence(evidenceRecords, { minGapEvidence = 2, memoryFile =
55
59
  for (const item of record[polarity] || []) {
56
60
  const entry = touch(item.instruction);
57
61
  entry[polarity] += 1;
58
- entry.sessions.add(record.transcript.id);
59
- entry.quotes.push({ polarity, text: item.quote, effect: item.effect, moment: item.moment, source });
62
+ const sessionIdentity = record.transcript.identity || record.transcript.id;
63
+ entry.sessions.add(sessionIdentity);
64
+ // `class` is what a negative means (harm vs non-compliance vs irrelevant);
65
+ // `harmSessions` is what the removal-evidence floor counts. A record from
66
+ // before the class existed carries none and never counts as harm.
67
+ if (polarity === "negative" && item.class === "harm") entry.harmSessions.add(sessionIdentity);
68
+ entry.quotes.push({
69
+ polarity,
70
+ text: item.quote,
71
+ effect: item.effect,
72
+ moment: item.moment,
73
+ class: polarity === "negative" ? (item.class ?? null) : undefined,
74
+ source,
75
+ });
60
76
  if (polarity === "positive") positiveCount += 1;
61
77
  else negativeCount += 1;
62
78
  }
@@ -69,12 +85,20 @@ export function foldEvidence(evidenceRecords, { minGapEvidence = 2, memoryFile =
69
85
  quote: gap.quote,
70
86
  recurrenceRisk: gap.recurrenceRisk,
71
87
  source,
72
- sessionId: record.transcript.id,
88
+ sessionId: record.transcript.identity || record.transcript.id,
89
+ domain: gap.domain === "orchestration" ? "orchestration" : "project",
73
90
  });
74
91
  }
75
92
  }
76
93
 
77
- const gapClusters = clusterGapObservations(gapObservations ?? recordObservations);
94
+ // Orchestration-domain sightings are mistakes caused not by this repository but by the
95
+ // external agent harness or tooling that orchestrated the session; they are counted for
96
+ // legibility but never cluster, so they can never corroborate into a project proposal.
97
+ const allObservations = gapObservations ?? recordObservations;
98
+ const projectObservations = allObservations.filter((obs) => obs?.domain !== "orchestration");
99
+ const orchestrationGapSightings = allObservations.length - projectObservations.length;
100
+
101
+ const gapClusters = clusterGapObservations(projectObservations);
78
102
 
79
103
  // Instructions that exist in the file but drew no evidence at all are the strongest
80
104
  // removal / extraction candidates, so they must appear in the summary too.
@@ -89,6 +113,7 @@ export function foldEvidence(evidenceRecords, { minGapEvidence = 2, memoryFile =
89
113
  instruction: entry.instruction,
90
114
  positive: entry.positive,
91
115
  negative: entry.negative,
116
+ harmSessions: entry.harmSessions.size,
92
117
  sessions: entry.sessions.size,
93
118
  relevance: analyzedSessions ? entry.sessions.size / analyzedSessions : 0,
94
119
  tokens: unit?.tokens ?? null,
@@ -120,6 +145,7 @@ export function foldEvidence(evidenceRecords, { minGapEvidence = 2, memoryFile =
120
145
  negative: negativeCount,
121
146
  gapClusters: gaps.length,
122
147
  droppedGapSingletons,
148
+ orchestrationGapSightings,
123
149
  usedRawTranscript: usedRawCount,
124
150
  },
125
151
  instructions: instructionRows,
@@ -179,20 +205,35 @@ export function renderEvidenceForPrompt(summary) {
179
205
  );
180
206
  lines.push("");
181
207
  lines.push("### Per-instruction evidence");
208
+ lines.push(
209
+ "A negative's class is what it means: `harm` = following the instruction caused damage " +
210
+ "(evidence against it); `non-compliance` = the agent ignored it (evidence it failed to " +
211
+ "steer - argues for reinforcement, never deletion); `irrelevant` = no real bearing.",
212
+ );
182
213
  for (const row of summary.instructions) {
183
214
  const relevance = `${(row.relevance * 100).toFixed(1)}%`;
184
215
  const cost = row.tokens === null ? "" : ` cost=${row.tokens}tok`;
216
+ const harm = row.negative > 0 ? ` harm-sessions=${row.harmSessions ?? 0}` : "";
185
217
  lines.push(
186
- `- [${row.instruction}] +${row.positive} -${row.negative} sessions=${row.sessions} relevance=${relevance}${cost}` +
218
+ `- [${row.instruction}] +${row.positive} -${row.negative}${harm} sessions=${row.sessions} relevance=${relevance}${cost}` +
187
219
  (row.known ? "" : " (id not found in current file - stale reference)"),
188
220
  );
189
221
  for (const quote of row.quotes.slice(0, 3)) {
190
- lines.push(` ${quote.polarity === "negative" ? "-" : "+"} "${oneLine(quote.text)}" (${quote.source})`);
222
+ const sign = quote.polarity === "negative" ? "-" : "+";
223
+ const cls = quote.polarity === "negative" ? ` [${quote.class ?? "unclassified"}]` : "";
224
+ const effect = quote.effect ? ` :: ${oneLine(quote.effect, 200)}` : "";
225
+ lines.push(` ${sign}${cls} "${oneLine(quote.text)}"${effect} (${quote.source})`);
191
226
  }
192
227
  }
193
228
 
194
229
  lines.push("");
195
230
  lines.push("### Gap clusters (mistakes no current instruction covers)");
231
+ if (summary.totals.orchestrationGapSightings) {
232
+ lines.push(
233
+ `- ${summary.totals.orchestrationGapSightings} orchestration-domain sighting(s) caused by the ` +
234
+ `orchestrating harness or tooling were excluded; they never enter this repository's memory file`,
235
+ );
236
+ }
196
237
  if (!summary.gaps.length) {
197
238
  lines.push("- none above the evidence threshold");
198
239
  }
@@ -206,9 +247,9 @@ export function renderEvidenceForPrompt(summary) {
206
247
  return lines.join("\n");
207
248
  }
208
249
 
209
- function oneLine(text) {
250
+ function oneLine(text, max = 240) {
210
251
  const flat = String(text || "")
211
252
  .replace(/\s+/g, " ")
212
253
  .trim();
213
- return flat.length > 240 ? `${flat.slice(0, 240)}...` : flat;
254
+ return flat.length > max ? `${flat.slice(0, max)}...` : flat;
214
255
  }
package/src/gap-ledger.js CHANGED
@@ -15,10 +15,23 @@ import { sha256 } from "./state.js";
15
15
  *
16
16
  * Identity and freshness rules:
17
17
  *
18
- * - A gap's identity is its proposed instruction, matched by the same bigram similarity
19
- * the in-run clustering uses (`GAP_SIMILARITY_THRESHOLD`), against the ledger's
20
- * canonical phrasing (the shortest seen). The entry id is a hash of the first phrasing
21
- * and never changes, so rephrasing does not split an entry.
18
+ * - A gap's identity is judged first and matched lexically second. The analysis turn is
19
+ * shown the ledger's open entries (`renderOpenGapIndex`) and cites an entry id
20
+ * (`matchesGap`) when its gap is one already on the books; a valid citation wins
21
+ * outright, because word overlap cannot recognize a paraphrase and the analysis model
22
+ * has both sentences in front of it. Without a citation, bigram similarity
23
+ * (`GAP_SIMILARITY_THRESHOLD`) against the canonical phrasing (the shortest seen) is
24
+ * the fallback. The entry id is a hash of the first phrasing and never changes, so
25
+ * rephrasing does not split an entry. Two entries later judged to be one gap are
26
+ * merged by the pre-synthesis consolidation pass (`mergeGapEntries`, driven by
27
+ * `src/consolidate.js`), which is what lets two same-run parallel sightings - neither
28
+ * of which could cite the other - still corroborate.
29
+ * - Every observation carries the `domain` the analysis judged: `orchestration` when the
30
+ * mistake was not caused by this repository but by an external agent harness or tooling
31
+ * that orchestrated the task, `project` for every other mistake.
32
+ * Orchestration sightings are recorded for legibility but never counted toward
33
+ * corroboration and never surface in a proposal; a missing domain counts as project,
34
+ * so evidence from before the field existed keeps its old behavior.
22
35
  * - Sessions are keyed by transcript id (harness + native session id), so re-analyzing
23
36
  * or re-sampling the same session overwrites its observation and never adds a count.
24
37
  * - A gap is a fact about its session: re-analysis that no longer mentions it is model
@@ -61,13 +74,20 @@ export function gapEntryId(memoryPath, proposedInstruction) {
61
74
  return sha256(`${memoryPath}\n${normalize(proposedInstruction)}`).slice(0, 16);
62
75
  }
63
76
 
77
+ function gapEntryById(ledger, id) {
78
+ const direct = ledger.entries[id];
79
+ if (direct) return direct;
80
+ return Object.values(ledger.entries).find((entry) => (entry.aliases || []).includes(id)) || null;
81
+ }
82
+
64
83
  /** The ledger entry a proposed instruction belongs to, or null. */
65
84
  export function findGapEntry(ledger, memoryPath, proposedInstruction) {
66
85
  let best = null;
67
86
  let bestScore = 0;
68
87
  for (const entry of Object.values(ledger.entries)) {
69
88
  if (entry.memoryPath !== memoryPath) continue;
70
- const score = similarity(entry.proposedInstruction, proposedInstruction);
89
+ const phrasings = [...new Set([entry.proposedInstruction, ...(entry.phrasings || [])])];
90
+ const score = Math.max(...phrasings.map((phrasing) => similarity(phrasing, proposedInstruction)));
71
91
  if (score >= GAP_SIMILARITY_THRESHOLD && score > bestScore) {
72
92
  best = entry;
73
93
  bestScore = score;
@@ -86,32 +106,56 @@ export function recordGapObservations(ledger, evidenceRecords, { now = new Date(
86
106
  for (const record of evidenceRecords) {
87
107
  if (!record || record.status !== "ok" || !record.memoryPath) continue;
88
108
  const transcript = record.transcript || {};
89
- if (!transcript.id) continue;
109
+ const sessionIdentity = transcript.identity || transcript.id;
110
+ if (!sessionIdentity) continue;
90
111
  for (const gap of record.gaps || []) {
91
112
  if (!gap || !gap.proposedInstruction) continue;
92
- let entry = findGapEntry(ledger, record.memoryPath, gap.proposedInstruction);
113
+ // A citation from the analysis turn wins over word overlap: the model saw both
114
+ // sentences and judged them the same gap. An id that names nothing (stale index,
115
+ // typo) falls back to the lexical match rather than failing the record.
116
+ const cited = gap.matchesGap ? gapEntryById(ledger, gap.matchesGap) : null;
117
+ const deterministicId = gapEntryId(record.memoryPath, gap.proposedInstruction);
118
+ const deterministic = gapEntryById(ledger, deterministicId);
119
+ let entry =
120
+ (cited && cited.memoryPath === record.memoryPath ? cited : null) ||
121
+ findGapEntry(ledger, record.memoryPath, gap.proposedInstruction) ||
122
+ (deterministic && deterministic.memoryPath === record.memoryPath ? deterministic : null);
93
123
  if (!entry) {
94
- const id = gapEntryId(record.memoryPath, gap.proposedInstruction);
95
- entry = ledger.entries[id] = {
96
- id,
124
+ entry = ledger.entries[deterministicId] = {
125
+ id: deterministicId,
97
126
  memoryPath: record.memoryPath,
98
127
  proposedInstruction: gap.proposedInstruction,
128
+ phrasings: [gap.proposedInstruction],
99
129
  sessions: {},
100
130
  };
101
- } else if (gap.proposedInstruction.length < entry.proposedInstruction.length) {
102
- // Keep the shortest phrasing: it generalizes best (same rule as the in-run fold).
103
- entry.proposedInstruction = gap.proposedInstruction;
131
+ } else {
132
+ entry.phrasings = [
133
+ ...new Set([entry.proposedInstruction, ...(entry.phrasings || []), gap.proposedInstruction]),
134
+ ];
135
+ if (gap.proposedInstruction.length < entry.proposedInstruction.length) {
136
+ // Keep the shortest phrasing: it generalizes best (same rule as the in-run fold).
137
+ entry.proposedInstruction = gap.proposedInstruction;
138
+ }
104
139
  }
105
- const prior = entry.sessions[transcript.id];
106
- entry.sessions[transcript.id] = {
107
- firstObservedAt: prior?.firstObservedAt || observedAt,
140
+ const identityPrior = entry.sessions[sessionIdentity];
141
+ const aliasPrior = transcript.id && transcript.id !== sessionIdentity ? entry.sessions[transcript.id] : null;
142
+ const priors = [identityPrior, aliasPrior].filter(Boolean);
143
+ const firstObservedAt = priors
144
+ .map((observation) => observation.firstObservedAt || observation.observedAt)
145
+ .filter((value) => Number.isFinite(Date.parse(value)))
146
+ .sort((a, b) => Date.parse(a) - Date.parse(b))[0];
147
+ if (aliasPrior) delete entry.sessions[transcript.id];
148
+ entry.sessions[sessionIdentity] = {
149
+ firstObservedAt: firstObservedAt || observedAt,
108
150
  observedAt,
109
- sessionStartedAt: transcript.startedAt ?? prior?.sessionStartedAt ?? null,
151
+ sessionStartedAt:
152
+ transcript.startedAt ?? identityPrior?.sessionStartedAt ?? aliasPrior?.sessionStartedAt ?? null,
110
153
  memoryHash: record.memoryHash || null,
111
154
  source: gapSource(transcript),
112
155
  mistake: gap.mistake,
113
156
  quote: gap.quote,
114
157
  recurrenceRisk: gap.recurrenceRisk,
158
+ domain: gap.domain === "orchestration" ? "orchestration" : "project",
115
159
  };
116
160
  recorded += 1;
117
161
  }
@@ -133,7 +177,7 @@ export function pruneGapLedger(
133
177
 
134
178
  for (const [id, entry] of Object.entries(ledger.entries)) {
135
179
  const applies = memoryPath === null || entry.memoryPath === memoryPath;
136
- if (applies && memoryFile && isCovered(memoryFile, entry.proposedInstruction)) {
180
+ if (applies && memoryFile && isCovered(memoryFile, entry)) {
137
181
  stats.covered += Object.keys(entry.sessions).length;
138
182
  delete ledger.entries[id];
139
183
  continue;
@@ -150,8 +194,11 @@ export function pruneGapLedger(
150
194
  return stats;
151
195
  }
152
196
 
153
- function isCovered(memoryFile, proposedInstruction) {
154
- return (memoryFile.units || []).some((unit) => similarity(unit.text, proposedInstruction) >= GAP_COVERED_THRESHOLD);
197
+ function isCovered(memoryFile, entry) {
198
+ const phrasings = [...new Set([entry.proposedInstruction, ...(entry.phrasings || [])])];
199
+ return (memoryFile.units || []).some((unit) =>
200
+ phrasings.some((phrasing) => similarity(unit.text, phrasing) >= GAP_COVERED_THRESHOLD),
201
+ );
155
202
  }
156
203
 
157
204
  /** Flatten the ledger into the observation list `foldEvidence` clusters over. */
@@ -167,8 +214,78 @@ export function ledgerGapObservations(ledger, memoryPath) {
167
214
  mistake: obs.mistake,
168
215
  quote: obs.quote,
169
216
  recurrenceRisk: obs.recurrenceRisk,
217
+ domain: obs.domain === "orchestration" ? "orchestration" : "project",
170
218
  });
171
219
  }
172
220
  }
173
221
  return observations;
174
222
  }
223
+
224
+ /**
225
+ * The ledger's open entries for one memory path, rendered for the analysis prompt so the
226
+ * model can cite an existing gap instead of coining a paraphrase of it. An accumulator,
227
+ * not a detector: a gap nobody has reported yet is simply absent, and the analysis
228
+ * reports it fresh.
229
+ */
230
+ export function renderOpenGapIndex(ledger, memoryPath, { max = 200 } = {}) {
231
+ const entries = Object.values(ledger.entries).filter((e) => e.memoryPath === memoryPath);
232
+ if (!entries.length) return "(none yet)";
233
+ const lines = entries.slice(0, max).map((e) => `[gap:${e.id}] ${e.proposedInstruction}`);
234
+ if (entries.length > max) lines.push(`... ${entries.length - max} more`);
235
+ return lines.join("\n");
236
+ }
237
+
238
+ /**
239
+ * Merge groups of ledger entries the consolidation pass judged to be one gap. Each group
240
+ * keeps the entry with the most sessions (its id stays citable), unions the session maps
241
+ * without ever double-counting a session (an observation already present keeps its
242
+ * earliest firstObservedAt), and keeps the shortest phrasing as canonical - the same rule
243
+ * recording uses. Unknown ids, cross-path groups, and groups that shrink below two known
244
+ * entries are dropped rather than guessed at. Returns how many entries were absorbed.
245
+ */
246
+ export function mergeGapEntries(ledger, groups) {
247
+ let absorbed = 0;
248
+ const claimed = new Set();
249
+ for (const group of Array.isArray(groups) ? groups : []) {
250
+ const ids = [...new Set((Array.isArray(group) ? group : []).map(String))].filter(
251
+ (id) => ledger.entries[id] && !claimed.has(id),
252
+ );
253
+ if (ids.length < 2) continue;
254
+ const paths = new Set(ids.map((id) => ledger.entries[id].memoryPath));
255
+ if (paths.size !== 1) continue;
256
+ for (const id of ids) claimed.add(id);
257
+
258
+ const entries = ids.map((id) => ledger.entries[id]);
259
+ const target = entries.reduce((best, e) =>
260
+ Object.keys(e.sessions).length > Object.keys(best.sessions).length ? e : best,
261
+ );
262
+ for (const entry of entries) {
263
+ if (entry === target) continue;
264
+ for (const [sessionId, obs] of Object.entries(entry.sessions)) {
265
+ const prior = target.sessions[sessionId];
266
+ if (!prior) {
267
+ target.sessions[sessionId] = obs;
268
+ } else {
269
+ const earlier =
270
+ Date.parse(obs.firstObservedAt || obs.observedAt) < Date.parse(prior.firstObservedAt || prior.observedAt);
271
+ if (earlier) prior.firstObservedAt = obs.firstObservedAt || obs.observedAt;
272
+ }
273
+ }
274
+ target.aliases = [...new Set([...(target.aliases || []), entry.id, ...(entry.aliases || [])])];
275
+ target.phrasings = [
276
+ ...new Set([
277
+ target.proposedInstruction,
278
+ ...(target.phrasings || []),
279
+ entry.proposedInstruction,
280
+ ...(entry.phrasings || []),
281
+ ]),
282
+ ];
283
+ if (entry.proposedInstruction.length < target.proposedInstruction.length) {
284
+ target.proposedInstruction = entry.proposedInstruction;
285
+ }
286
+ delete ledger.entries[entry.id];
287
+ absorbed += 1;
288
+ }
289
+ }
290
+ return absorbed;
291
+ }
@@ -10,6 +10,15 @@ Each instruction has a stable id in [brackets]. Refer to instructions ONLY by th
10
10
 
11
11
  {{INSTRUCTION_INDEX}}
12
12
 
13
+ ## Gaps already on the books
14
+
15
+ Earlier sessions reported these gaps; each has a stable id. If a gap you found is the
16
+ same underlying gap as one below - one instruction would prevent both - cite that id in
17
+ `matchesGap` and still describe what you saw. A gap that matches nothing here is new:
18
+ omit `matchesGap`.
19
+
20
+ {{OPEN_GAPS}}
21
+
13
22
  ## The distilled session trace
14
23
 
15
24
  Tool calls are one-line summaries and tool output is truncated. The raw transcript path
@@ -26,8 +35,8 @@ Return ONE JSON object and nothing else. No prose before or after, no markdown f
26
35
  ```
27
36
  {
28
37
  "positive": [{"instruction": "AG-042", "moment": "turn 12", "effect": "what following it achieved", "quote": "verbatim text from the trace"}],
29
- "negative": [{"instruction": "AG-017", "moment": "turn 3", "effect": "what going against it cost", "quote": "verbatim text from the trace"}],
30
- "gaps": [{"mistake": "what went wrong", "proposedInstruction": "one sentence that would have prevented it", "recurrenceRisk": "high|medium|low", "quote": "verbatim text from the trace"}],
38
+ "negative": [{"instruction": "AG-017", "moment": "turn 3", "effect": "what happened and what it cost", "class": "harm|non-compliance|irrelevant", "quote": "verbatim text from the trace"}],
39
+ "gaps": [{"mistake": "what went wrong", "proposedInstruction": "one sentence that would have prevented it", "recurrenceRisk": "high|medium|low", "domain": "project|orchestration", "matchesGap": "<id from the list above, omit when new>", "quote": "verbatim text from the trace"}],
31
40
  "usedRawTranscript": false
32
41
  }
33
42
  ```
@@ -38,11 +47,23 @@ Rules, in order of importance:
38
47
  a real quote are discarded downstream, so an unquotable claim is wasted work.
39
48
  2. **Negative evidence is the most valuable.** A visible violation, misreading, or
40
49
  ignored instruction outranks a dozen "it went fine" observations.
41
- 3. **Do not confabulate influence.** Only call something positive when the trace shows
50
+ 3. **`class` states what a negative means, and the difference decides the instruction's
51
+ fate.** `harm`: the agent FOLLOWED the instruction and following it caused damage or
52
+ cost - evidence against the instruction itself. `non-compliance`: the agent ignored
53
+ or violated the instruction - evidence the instruction failed to steer, which argues
54
+ for reinforcing it, never for deleting it. `irrelevant`: on inspection the moment
55
+ does not actually bear on this instruction. Never report a skipped rule as `harm`.
56
+ 4. **`domain` states what caused a gap.** A gap is `orchestration` when the mistake was
57
+ not caused by this repository, but by an external agent harness or tooling that
58
+ orchestrated the task (a task brief, a supervisor's process, the harness itself - by
59
+ way of illustration only, not a list to match against); every other gap is `project`.
60
+ Ask the causal question, not which category the wording resembles. Orchestration gaps
61
+ are counted but never proposed into this repository's memory file.
62
+ 5. **Do not confabulate influence.** Only call something positive when the trace shows
42
63
  the agent doing the specific thing the instruction asks for. An outcome that would
43
64
  have happened anyway is not evidence.
44
- 4. `gaps` are mistakes NOT covered by any current instruction. If an instruction exists
45
- and was ignored, that is `negative`, not a gap.
46
- 5. `proposedInstruction` must be one imperative sentence, specific enough to act on and
65
+ 6. `gaps` are mistakes NOT covered by any current instruction. If an instruction exists
66
+ and was ignored, that is `negative` with `class: "non-compliance"`, not a gap.
67
+ 7. `proposedInstruction` must be one imperative sentence, specific enough to act on and
47
68
  general enough to apply beyond this one session.
48
- 6. An empty array is a valid and useful answer. Report nothing rather than something weak.
69
+ 8. An empty array is a valid and useful answer. Report nothing rather than something weak.
@@ -39,14 +39,20 @@ Hard rules - a violation fails the whole proposal:
39
39
  4. **New instructions need evidence from at least {{MIN_GAP_EVIDENCE}} distinct
40
40
  sessions.** `transcripts` is how many distinct sessions back the edit; an edit that
41
41
  only adds text is a new instruction whatever its `kind` says.
42
- 5. `kind: "extract"` is an edit whose changes are one or more created `SKILL.md` files
43
- plus the change(s) to {{MEMORY_PATH}} that pay for them. One skill per extract is the
44
- normal shape. Several skills belong in ONE extract exactly when their removals landed
45
- in a **single** measured change: adjacent removals are merged into one change, and a
46
- merged change cannot be accepted in halves. If each skill has its own measured change,
47
- give each its own extract. Any other kind must not include a created file, and an edit
48
- changes one file only.
49
- 6. **Budget:** {{BUDGET_RULE}}
42
+ 5. **Removing an instruction outright needs harm evidence from at least
43
+ {{MIN_GAP_EVIDENCE}} distinct sessions** (`harm-sessions` in the evidence). Only
44
+ `harm` negatives argue against an instruction; `non-compliance` never justifies a
45
+ deletion. A change that only deletes text and is not part of an extract is a removal
46
+ whatever its `kind` says - if it lacks the evidence, revert it in the file first.
47
+ 6. `kind: "extract"` is an edit whose changes are one or more created `SKILL.md` files
48
+ plus the change(s) to {{MEMORY_PATH}} that pay for them. **The skills must carry every
49
+ line those changes remove** - a deletion is never part of an extract; give it its own
50
+ `remove` edit. One skill per extract is the normal shape. Several skills belong in ONE
51
+ extract exactly when their removals landed in a **single** measured change: adjacent
52
+ removals are merged into one change, and a merged change cannot be accepted in halves.
53
+ If each skill has its own measured change, give each its own extract. Any other kind
54
+ must not include a created file, and an edit changes one file only.
55
+ 7. **Budget:** {{BUDGET_RULE}}
50
56
 
51
57
  If you still need to change the files, do that first and then answer; backpass
52
58
  re-measures after this reply and shows you the new ids if anything moved. Re-measuring
@@ -0,0 +1,29 @@
1
+ You are consolidating the gap ledger of a backward pass over a repository's agent
2
+ memory file. Each entry below is a mistake some past agent session hit that no current
3
+ instruction covers, phrased as the instruction that would have prevented it. Different
4
+ sessions phrase the same underlying gap differently, and a gap only graduates into a
5
+ proposal once enough DISTINCT sessions have hit it - so paraphrases of one gap must be
6
+ recognized as one entry, or real recurrence stays invisible.
7
+
8
+ ## Open gap entries
9
+
10
+ {{GAP_ENTRIES}}
11
+
12
+ ## What to return
13
+
14
+ Return ONE JSON object and nothing else. No prose, no markdown fence.
15
+
16
+ ```
17
+ {"merges": [["<id>", "<id>", ...], ...]}
18
+ ```
19
+
20
+ Each inner array lists two or more entry ids that are the SAME underlying gap. Rules:
21
+
22
+ 1. Merge only when one instruction would prevent every entry in the group - the same
23
+ mistake, not merely the same topic. Two gaps about the same subsystem that call for
24
+ different instructions stay separate.
25
+ 2. When unsure, do not merge. A wrong merge fabricates corroboration and can write a
26
+ weakly-supported instruction into the memory file; a missed merge only waits for
27
+ another sighting.
28
+ 3. An id may appear in at most one group. Ids not listed stay untouched.
29
+ 4. `{"merges": []}` is a valid and common answer.
@@ -69,12 +69,21 @@ analyzed sessions in which an instruction drew any evidence at all.
69
69
  speculative one.
70
70
  2. **New instructions need evidence from at least {{MIN_GAP_EVIDENCE}} distinct
71
71
  sessions.** One bad session never rewrites the weights.
72
- 3. **Every edit must be backed by at least one verbatim quote** from the evidence. You
72
+ 3. **Removing an instruction outright needs harm evidence from at least
73
+ {{MIN_GAP_EVIDENCE}} distinct sessions** (`harm-sessions` in the evidence rows).
74
+ Only `harm` negatives - following the instruction caused damage - argue against an
75
+ instruction. `non-compliance` means it failed to steer: reinforce it, reposition it,
76
+ or improve its trigger, never delete it for being ignored. Text you delete that does
77
+ not land in a created skill is a removal, whatever the edit is called.
78
+ 4. **An extraction preserves every line it removes** in the SKILL.md it creates. A
79
+ deletion is never part of an extract: it is its own `remove` edit, decided on its
80
+ own evidence.
81
+ 5. **Every edit must be backed by at least one verbatim quote** from the evidence. You
73
82
  will attach the quotes in the next step, so only make changes you can back.
74
- 4. **Budget:** {{BUDGET_RULE}}
75
- 5. Prefer removing a dead instruction over adding a new one. Instructions with high
76
- token cost and zero positive evidence across many sessions are the best removals.
77
- 6. Change only `./{{MEMORY_PATH}}` and files under `./{{SKILLS_DIR}}/`. Never delete a
83
+ 6. **Budget:** {{BUDGET_RULE}}
84
+ 7. You can extract a long, narrow, crisply-triggered section instead of deleting it:
85
+ extraction frees the same always-loaded tokens and loses nothing.
86
+ 8. Change only `./{{MEMORY_PATH}}` and files under `./{{SKILLS_DIR}}/`. Never delete a
78
87
  file. Do not create notes, scripts, or scratch files.
79
88
 
80
89
  ## Where an instruction belongs
@@ -85,7 +94,7 @@ analyzed sessions in which an instruction drew any evidence at all.
85
94
  | Conditional / narrow | **skill** (the description is the condition) | deletion candidate |
86
95
 
87
96
  A skill's description is always loaded and its body is free until triggered, so moving a
88
- long, narrow, crisply-triggered section into a skill is nearly pure budget profit.
97
+ section into a skill trades its always-loaded cost for that one description line.
89
98
 
90
99
  **Skill descriptions are weights too.** If the evidence shows an agent lacked knowledge
91
100
  an existing skill already contains, that is a failed trigger: rewrite that skill's
package/src/proposal.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { renderHunkLines } from "./diff.js";
2
2
  import { editSkills } from "./skills.js";
3
3
  import { budgetGateKind, budgetStatus, estimateTokens } from "./tokens.js";
4
+ import { normalizeRecoveryLine, recoveredLineCounts } from "./workspace.js";
4
5
 
5
6
  /**
6
7
  * The proposal model: what a synthesis pass is allowed to produce, and the mechanical
@@ -103,6 +104,28 @@ function countSources(evidence) {
103
104
  return new Set(evidence.map((e) => e?.source).filter(Boolean)).size;
104
105
  }
105
106
 
107
+ /** The del-line texts of a hunk that are not carried by `lineCounts` (blank lines ignored). */
108
+ function unrecoveredRemovedLines(hunk, lineCounts) {
109
+ const missing = [];
110
+ for (const line of hunk.lines || []) {
111
+ if (line.type !== "del") continue;
112
+ const normalized = normalizeRecoveryLine(line.text);
113
+ if (!normalized) continue;
114
+ const remaining = lineCounts.get(normalized) || 0;
115
+ if (remaining > 0) lineCounts.set(normalized, remaining - 1);
116
+ else missing.push(line.text);
117
+ }
118
+ return missing;
119
+ }
120
+
121
+ /**
122
+ * The memory units a pure-removal hunk deletes. For a pure removal every file line in
123
+ * [oldStart, oldEnd] is removed, so this is a plain range intersection with unit lines.
124
+ */
125
+ function unitsRemovedBy(hunk, memoryFile) {
126
+ return memoryFile.units.filter((unit) => unit.startLine <= hunk.oldEnd && unit.endLine >= hunk.oldStart);
127
+ }
128
+
106
129
  /** Overlapping count: a run of identical lines must not pass as unique. */
107
130
  function occurrences(haystack, needle) {
108
131
  if (!needle) return 0;
@@ -308,6 +331,19 @@ export function buildProposal(rawResult, context) {
308
331
  violations.push(`edit ${edit.id}: ${unusable.file} needs YAML frontmatter with \`name:\` and \`description:\``);
309
332
  continue;
310
333
  }
334
+ // An extraction moves text; it never doubles as a deletion. Every line its hunks
335
+ // remove must land in the skills it creates - a real deletion goes in its own
336
+ // remove edit, where the removal-evidence floor below can judge it on its own.
337
+ const carried = recoveredLineCounts(created.map((c) => c.text));
338
+ const missing = hunks.flatMap((h) => unrecoveredRemovedLines(h, carried));
339
+ if (missing.length) {
340
+ violations.push(
341
+ `edit ${edit.id} ("${edit.title}") removes text its created skill(s) do not carry ` +
342
+ `(first: "${missing[0].trim().slice(0, 80)}"); an extraction preserves every line it removes - ` +
343
+ `revert that text, or make its deletion a separate "remove" edit`,
344
+ );
345
+ continue;
346
+ }
311
347
  } else if (created.length) {
312
348
  violations.push(`edit ${edit.id}: only kind "extract" may include a created file (${created[0].id})`);
313
349
  continue;
@@ -323,6 +359,34 @@ export function buildProposal(rawResult, context) {
323
359
  continue;
324
360
  }
325
361
 
362
+ // A removal is measured the same way: a hunk that only deletes text, outside an
363
+ // extraction, deletes instructions - whatever the edit's kind says. Deleting an
364
+ // instruction needs the same corroboration adding one does, and only negatives the
365
+ // analysis classified as `harm` (following the rule caused damage) count toward it.
366
+ // Non-compliance is the rule failing to steer; it never justifies deletion. This is
367
+ // the removal-evidence floor; the >= 20%-relevance placement table stays guidance.
368
+ if (edit.kind !== "extract" && files[0] === memoryFile.path) {
369
+ const rows = new Map((summary?.instructions ?? []).map((row) => [row.instruction, row]));
370
+ const unsupported = [];
371
+ for (const hunk of hunks) {
372
+ if (!hunk.removed || hunk.added) continue;
373
+ for (const unit of unitsRemovedBy(hunk, memoryFile)) {
374
+ const harm = rows.get(unit.id)?.harmSessions ?? 0;
375
+ if (harm < config.minGapEvidence) unsupported.push({ unit, harm });
376
+ }
377
+ }
378
+ if (unsupported.length) {
379
+ const worst = unsupported[0];
380
+ violations.push(
381
+ `edit ${edit.id} ("${edit.title}") deletes [${worst.unit.id}] "${worst.unit.text.slice(0, 80)}" backed by ` +
382
+ `${worst.harm} session(s) of harm-class negative evidence; removing an instruction needs ` +
383
+ `${config.minGapEvidence}, and non-compliance never counts - revert the deletion` +
384
+ (unsupported.length > 1 ? ` (${unsupported.length} unit(s) affected)` : ""),
385
+ );
386
+ continue;
387
+ }
388
+ }
389
+
326
390
  const file = files[0];
327
391
  const proposed = {
328
392
  id: edit.id,