backpass 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 +406 -0
- package/bin/backpass.js +4 -0
- package/package.json +62 -0
- package/src/acpx.js +576 -0
- package/src/agents.js +389 -0
- package/src/analyze.js +289 -0
- package/src/apply/lavish.js +128 -0
- package/src/apply/terminal.js +119 -0
- package/src/apply/writer.js +101 -0
- package/src/bootstrap.js +74 -0
- package/src/cli.js +261 -0
- package/src/commands/analyze.js +88 -0
- package/src/commands/apply.js +103 -0
- package/src/commands/bootstrap.js +172 -0
- package/src/commands/init.js +59 -0
- package/src/commands/propose.js +136 -0
- package/src/commands/run.js +95 -0
- package/src/commands/scan.js +90 -0
- package/src/commands/status.js +143 -0
- package/src/commands/usage.js +25 -0
- package/src/config.js +249 -0
- package/src/diff.js +305 -0
- package/src/discovery/adapters/claude.js +77 -0
- package/src/discovery/adapters/codex.js +162 -0
- package/src/discovery/adapters/cursor-cli.js +109 -0
- package/src/discovery/adapters/cursor-ide.js +130 -0
- package/src/discovery/adapters/grok.js +107 -0
- package/src/discovery/adapters/opencode.js +151 -0
- package/src/discovery/adapters/pi.js +87 -0
- package/src/discovery/adapters/shared.js +195 -0
- package/src/discovery/adapters/sqlite.js +50 -0
- package/src/discovery/association.js +100 -0
- package/src/discovery/index.js +226 -0
- package/src/discovery/self.js +62 -0
- package/src/distill.js +182 -0
- package/src/fold.js +214 -0
- package/src/gap-ledger.js +174 -0
- package/src/logger.js +74 -0
- package/src/memory.js +244 -0
- package/src/progress.js +29 -0
- package/src/prompts/analysis.md +48 -0
- package/src/prompts/annotate.md +48 -0
- package/src/prompts/synthesis.md +98 -0
- package/src/prompts.js +36 -0
- package/src/proposal.js +430 -0
- package/src/redact.js +36 -0
- package/src/repo.js +118 -0
- package/src/sample.js +99 -0
- package/src/skills.js +207 -0
- package/src/state.js +202 -0
- package/src/subprocess.js +47 -0
- package/src/synthesize.js +287 -0
- package/src/tokens.js +48 -0
- package/src/tui/index.js +336 -0
- package/src/tui/render.js +487 -0
- package/src/tui/term.js +130 -0
- package/src/tui/theme.js +111 -0
- package/src/workspace.js +162 -0
- package/templates/apply.html +928 -0
package/src/progress.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress event bus between the pipeline and the live progress view.
|
|
3
|
+
*
|
|
4
|
+
* Pipeline stages emit structured events through `emitProgress`; they are dropped
|
|
5
|
+
* unless a renderer registered a sink. This keeps every stage free of rendering
|
|
6
|
+
* concerns and guarantees that runs without a TTY behave exactly as before -
|
|
7
|
+
* the events simply go nowhere.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
let sink = null;
|
|
11
|
+
|
|
12
|
+
/** Register the single active sink (the TUI controller). */
|
|
13
|
+
export function setProgressSink(fn) {
|
|
14
|
+
sink = fn;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function clearProgressSink() {
|
|
18
|
+
sink = null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Emit one progress event. A throwing sink must never break the pipeline. */
|
|
22
|
+
export function emitProgress(event, data = {}) {
|
|
23
|
+
if (!sink) return;
|
|
24
|
+
try {
|
|
25
|
+
sink(event, data);
|
|
26
|
+
} catch {
|
|
27
|
+
// Rendering is best-effort; the run itself is what matters.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
You are auditing one past agent session against the repository's agent memory file.
|
|
2
|
+
|
|
3
|
+
Your job is NOT to review the code. It is to measure how well the memory file's
|
|
4
|
+
instructions actually steered this session, and to spot mistakes an instruction could
|
|
5
|
+
have prevented. This is the loss signal for a backward pass over the memory file.
|
|
6
|
+
|
|
7
|
+
## The memory file under audit: {{MEMORY_PATH}}
|
|
8
|
+
|
|
9
|
+
Each instruction has a stable id in [brackets]. Refer to instructions ONLY by these ids.
|
|
10
|
+
|
|
11
|
+
{{INSTRUCTION_INDEX}}
|
|
12
|
+
|
|
13
|
+
## The distilled session trace
|
|
14
|
+
|
|
15
|
+
Tool calls are one-line summaries and tool output is truncated. The raw transcript path
|
|
16
|
+
is at the end of the trace: open it ONLY if a specific claim you want to make cannot be
|
|
17
|
+
verified from the distilled trace. Reading it is allowed but costs time, so do not do it
|
|
18
|
+
by default. Set `usedRawTranscript` accordingly.
|
|
19
|
+
|
|
20
|
+
{{TRACE}}
|
|
21
|
+
|
|
22
|
+
## What to report
|
|
23
|
+
|
|
24
|
+
Return ONE JSON object and nothing else. No prose before or after, no markdown fence.
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
{
|
|
28
|
+
"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"}],
|
|
31
|
+
"usedRawTranscript": false
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Rules, in order of importance:
|
|
36
|
+
|
|
37
|
+
1. **Every item needs a verbatim `quote` copied exactly from the trace.** Items without
|
|
38
|
+
a real quote are discarded downstream, so an unquotable claim is wasted work.
|
|
39
|
+
2. **Negative evidence is the most valuable.** A visible violation, misreading, or
|
|
40
|
+
ignored instruction outranks a dozen "it went fine" observations.
|
|
41
|
+
3. **Do not confabulate influence.** Only call something positive when the trace shows
|
|
42
|
+
the agent doing the specific thing the instruction asks for. An outcome that would
|
|
43
|
+
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
|
|
47
|
+
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.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
backpass measured the changes you made in the staging copy. Every change below is
|
|
2
|
+
identified by an id; annotate each one so a human can review it with its evidence.
|
|
3
|
+
|
|
4
|
+
## Measured changes
|
|
5
|
+
|
|
6
|
+
{{CHANGES}}
|
|
7
|
+
|
|
8
|
+
## What to produce
|
|
9
|
+
|
|
10
|
+
Return ONE JSON object and nothing else. No prose, no markdown fence.
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
{
|
|
14
|
+
"edits": [
|
|
15
|
+
{
|
|
16
|
+
"changes": ["H1"],
|
|
17
|
+
"kind": "add" | "remove" | "rewrite" | "extract",
|
|
18
|
+
"title": "one line a human can decide on",
|
|
19
|
+
"rationale": "why the evidence supports this",
|
|
20
|
+
"instructions": ["AG-017"],
|
|
21
|
+
"evidence": [{"polarity": "negative", "text": "the verbatim quote", "source": "claude · abc123 · turn 12"}],
|
|
22
|
+
"transcripts": 3
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"verdicts": [
|
|
26
|
+
{"instruction": "AG-042", "verdict": "keep" | "strengthen" | "weaken" | "remove", "positive": 6, "negative": 1, "note": "one line"}
|
|
27
|
+
],
|
|
28
|
+
"notes": ["anything a human should know that is not an edit"]
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Hard rules - a violation fails the whole proposal:
|
|
33
|
+
|
|
34
|
+
1. **Every measured change belongs to exactly one edit.** Group the changes that make up
|
|
35
|
+
one decision (an extraction is the new `SKILL.md` plus the removal it pays for); do
|
|
36
|
+
not leave a change out. If a change should not ship, revert it in the file first.
|
|
37
|
+
2. **At most {{MAX_EDITS}} edits** - the learning rate. Regroup or revert if you are over.
|
|
38
|
+
3. **Every edit carries at least one verbatim quote in `evidence`**, with its source.
|
|
39
|
+
4. **New instructions need evidence from at least {{MIN_GAP_EVIDENCE}} distinct
|
|
40
|
+
sessions.** `transcripts` is how many distinct sessions back the edit; an edit that
|
|
41
|
+
only adds text is a new instruction whatever its `kind` says.
|
|
42
|
+
5. `kind: "extract"` is exactly an edit whose changes include one created `SKILL.md`
|
|
43
|
+
and at least one change to `{{MEMORY_PATH}}`. Any other edit must not include a
|
|
44
|
+
created file, and an edit changes one file only.
|
|
45
|
+
6. **Budget:** {{BUDGET_RULE}}
|
|
46
|
+
|
|
47
|
+
If you still need to change the files, do that first and then answer; backpass
|
|
48
|
+
re-measures after this reply and shows you the new ids if anything moved.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
You are performing the synthesis step of a backward pass over a repository's agent
|
|
2
|
+
memory file. Evidence from many past agent sessions has already been gathered and
|
|
3
|
+
folded. Your job is to turn that evidence into a small set of concrete, budget-aware
|
|
4
|
+
edits - one gradient step on the weights, not a rewrite - by editing the file directly.
|
|
5
|
+
|
|
6
|
+
## Repository
|
|
7
|
+
|
|
8
|
+
{{REPO_NAME}} - {{TRANSCRIPT_COUNT}} sessions analyzed across {{HARNESS_SUMMARY}}.
|
|
9
|
+
{{RUN_NOTE}}
|
|
10
|
+
|
|
11
|
+
## Where you are
|
|
12
|
+
|
|
13
|
+
Your working directory is a staging copy, not the repository. It holds exactly two
|
|
14
|
+
things: the memory file at `./{{MEMORY_PATH}}` and the skills directory at
|
|
15
|
+
`./{{SKILLS_DIR}}/`. The repository itself is at `{{REPO_ROOT}}` - open any file there
|
|
16
|
+
to ground or verify an edit against the real code, but NEVER write there. Nothing you
|
|
17
|
+
change in the staging copy reaches the repository until a human reviews each change.
|
|
18
|
+
|
|
19
|
+
**Make your edits by editing `./{{MEMORY_PATH}}` in place with your file tools.** Do not
|
|
20
|
+
paste the edited text into your reply; backpass measures what you changed in the file.
|
|
21
|
+
|
|
22
|
+
## Current memory file: {{MEMORY_PATH}}
|
|
23
|
+
|
|
24
|
+
Budget: {{CURRENT_TOKENS}} / {{BUDGET_TOKENS}} estimated tokens ({{BUDGET_STATE}}).
|
|
25
|
+
|
|
26
|
+
Every token in this file is paid on every future session, forever, and instruction
|
|
27
|
+
following dilutes as the file grows. The budget is the constraint you optimize under.
|
|
28
|
+
|
|
29
|
+
The index below is a lookup table, not the file: it names each instruction (`AG-nnn`),
|
|
30
|
+
its always-loaded cost, and the lines it occupies in `./{{MEMORY_PATH}}`. The evidence
|
|
31
|
+
refers to instructions by these ids.
|
|
32
|
+
|
|
33
|
+
{{INSTRUCTION_INDEX}}
|
|
34
|
+
|
|
35
|
+
## Existing skills (load-on-trigger, in {{SKILLS_DIR}})
|
|
36
|
+
|
|
37
|
+
{{SKILL_INDEX}}
|
|
38
|
+
|
|
39
|
+
To extract a section into a skill, create `./{{SKILLS_DIR}}/<skill-name>/SKILL.md` with
|
|
40
|
+
this exact shape, then remove the extracted detail from `./{{MEMORY_PATH}}`
|
|
41
|
+
(optionally leaving a one-line pointer):
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
---
|
|
45
|
+
name: <skill-name>
|
|
46
|
+
description: <one line - this IS the trigger condition>
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
<the full markdown body of the skill>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
To tune an existing skill's trigger, edit its `description:` line under `./{{SKILLS_DIR}}/`.
|
|
53
|
+
|
|
54
|
+
## Folded evidence
|
|
55
|
+
|
|
56
|
+
`sessions` is how many distinct sessions produced the item. `relevance` is the share of
|
|
57
|
+
analyzed sessions in which an instruction drew any evidence at all.
|
|
58
|
+
|
|
59
|
+
{{EVIDENCE}}
|
|
60
|
+
|
|
61
|
+
## Previously rejected edits - do not re-propose these
|
|
62
|
+
|
|
63
|
+
{{REJECTIONS}}
|
|
64
|
+
|
|
65
|
+
## Hard rules - a violation fails the whole proposal
|
|
66
|
+
|
|
67
|
+
1. **At most {{MAX_EDITS}} edits.** This is the learning rate. An edit is one change a
|
|
68
|
+
human can decide on; pick the highest-signal ones. A small correct step beats a large
|
|
69
|
+
speculative one.
|
|
70
|
+
2. **New instructions need evidence from at least {{MIN_GAP_EVIDENCE}} distinct
|
|
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
|
|
73
|
+
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
|
|
78
|
+
file. Do not create notes, scripts, or scratch files.
|
|
79
|
+
|
|
80
|
+
## Where an instruction belongs
|
|
81
|
+
|
|
82
|
+
| | Trigger fits in one description line | Trigger not detectable |
|
|
83
|
+
|--------------------------|--------------------------------------|------------------------|
|
|
84
|
+
| Broad (>= 20% of sessions, or safety-critical) | memory file | memory file |
|
|
85
|
+
| Conditional / narrow | **skill** (the description is the condition) | deletion candidate |
|
|
86
|
+
|
|
87
|
+
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.
|
|
89
|
+
|
|
90
|
+
**Skill descriptions are weights too.** If the evidence shows an agent lacked knowledge
|
|
91
|
+
an existing skill already contains, that is a failed trigger: rewrite that skill's
|
|
92
|
+
description line instead of duplicating content in the memory file.
|
|
93
|
+
|
|
94
|
+
## When you are done
|
|
95
|
+
|
|
96
|
+
Reply with a short plain-text summary of what you changed and why (a few lines). No
|
|
97
|
+
JSON yet - backpass will measure the changes and ask you to annotate each one next.
|
|
98
|
+
If the evidence does not justify any change, change nothing and say so.
|
package/src/prompts.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const PROMPT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "prompts");
|
|
6
|
+
|
|
7
|
+
const cache = new Map();
|
|
8
|
+
|
|
9
|
+
export function loadPrompt(name) {
|
|
10
|
+
if (!cache.has(name)) {
|
|
11
|
+
cache.set(name, fs.readFileSync(path.join(PROMPT_DIR, `${name}.md`), "utf8"));
|
|
12
|
+
}
|
|
13
|
+
return cache.get(name);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Simple, explicit {{TOKEN}} substitution - no template engine, no surprises. */
|
|
17
|
+
export function render(template, values) {
|
|
18
|
+
return template.replace(/\{\{([A-Z_]+)\}\}/g, (match, key) =>
|
|
19
|
+
Object.prototype.hasOwnProperty.call(values, key) ? String(values[key]) : match,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Every prompt backpass sends to a harness starts with this line. The harness records
|
|
25
|
+
* the prompt as the session's first user message in its own store - the very store
|
|
26
|
+
* discovery reads - so without a marker backpass's analysis and synthesis runs would
|
|
27
|
+
* be discovered as ordinary sessions on the next pass and the loop would analyze
|
|
28
|
+
* itself. Discovery (`src/discovery/self.js`) drops any transcript whose first user
|
|
29
|
+
* message begins with it. It keys on content backpass owns, so it survives harness
|
|
30
|
+
* format drift and needs nothing from acpx.
|
|
31
|
+
*/
|
|
32
|
+
export const SELF_SESSION_SENTINEL = "<!-- backpass:self-session -->";
|
|
33
|
+
|
|
34
|
+
export function renderPrompt(name, values) {
|
|
35
|
+
return `${SELF_SESSION_SENTINEL}\n${render(loadPrompt(name), values)}`;
|
|
36
|
+
}
|
package/src/proposal.js
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { renderHunkLines } from "./diff.js";
|
|
2
|
+
import { budgetStatus, estimateTokens } from "./tokens.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The proposal model: what a synthesis pass is allowed to produce, and the mechanical
|
|
6
|
+
* gates it must clear before a human ever sees it (design sections 3, 6, 7).
|
|
7
|
+
*
|
|
8
|
+
* The synthesis agent edits a staging copy of the memory file natively
|
|
9
|
+
* (`src/workspace.js`); backpass measures the result as anchored hunks (`src/diff.js`)
|
|
10
|
+
* and the agent annotates them - kind, title, rationale, evidence - by id. An edit is
|
|
11
|
+
* therefore a group of measured changes against one file:
|
|
12
|
+
*
|
|
13
|
+
* add only inserts text (gated like a new instruction)
|
|
14
|
+
* remove only deletes text
|
|
15
|
+
* rewrite replaces text
|
|
16
|
+
* extract memory-file change(s) + one created SKILL.md
|
|
17
|
+
*
|
|
18
|
+
* Each hunk carries a `find`/`replace` pair copied out of the original file by
|
|
19
|
+
* construction; `find` occurs exactly once there. That is what the writer applies later,
|
|
20
|
+
* against whatever the file is by then: anything that no longer matches is rejected
|
|
21
|
+
* rather than guessed at - a memory file is not something to fuzzy-patch.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const EDIT_KINDS = ["add", "remove", "rewrite", "extract"];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The per-run edit cap is adaptive (design section 6). Near or under budget it is the
|
|
28
|
+
* gentle learning rate: DEFAULT_MAX_EDITS small, reviewable steps. A file that is over
|
|
29
|
+
* budget needs a shrink plan, and a flat cap would stretch that plan across many runs,
|
|
30
|
+
* so the allowance scales with the overage: one edit per SHRINK_EDIT_TOKENS of overage,
|
|
31
|
+
* never below the default and never above SHRINK_MAX_EDITS, which keeps a single apply
|
|
32
|
+
* review manageable. An explicit `maxEditsPerRun` (flag or config) always wins.
|
|
33
|
+
*/
|
|
34
|
+
export const DEFAULT_MAX_EDITS = 5;
|
|
35
|
+
export const SHRINK_MAX_EDITS = 20;
|
|
36
|
+
/** A typical memory-file instruction removal or tightening trims about this many tokens. */
|
|
37
|
+
export const SHRINK_EDIT_TOKENS = 40;
|
|
38
|
+
|
|
39
|
+
export function effectiveMaxEdits(memoryFile, config) {
|
|
40
|
+
if (Number.isInteger(config.maxEditsPerRun) && config.maxEditsPerRun > 0) return config.maxEditsPerRun;
|
|
41
|
+
const overage = memoryFile.tokens - config.budgetTokens;
|
|
42
|
+
if (overage <= 0) return DEFAULT_MAX_EDITS;
|
|
43
|
+
return Math.min(SHRINK_MAX_EDITS, Math.max(DEFAULT_MAX_EDITS, Math.ceil(overage / SHRINK_EDIT_TOKENS)));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class ProposalViolation extends Error {
|
|
47
|
+
constructor(message, violations) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = "ProposalViolation";
|
|
50
|
+
this.violations = violations;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeEdit(raw, index) {
|
|
55
|
+
const kind = String(raw?.kind || "").toLowerCase();
|
|
56
|
+
const refs = Array.isArray(raw?.changes) ? raw.changes : Array.isArray(raw?.hunks) ? raw.hunks : [];
|
|
57
|
+
return {
|
|
58
|
+
id: `e${index + 1}`,
|
|
59
|
+
kind,
|
|
60
|
+
changeIds: refs.map((c) => String(c).trim().toUpperCase()).filter(Boolean),
|
|
61
|
+
title: String(raw?.title || "").trim() || "(untitled edit)",
|
|
62
|
+
rationale: String(raw?.rationale || "").trim(),
|
|
63
|
+
instructions: Array.isArray(raw?.instructions) ? raw.instructions.map(String) : [],
|
|
64
|
+
evidence: normalizeEvidence(raw?.evidence),
|
|
65
|
+
transcripts: Number.isFinite(raw?.transcripts) ? Number(raw.transcripts) : countSources(raw?.evidence),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizeEvidence(evidence) {
|
|
70
|
+
if (!Array.isArray(evidence)) return [];
|
|
71
|
+
return evidence
|
|
72
|
+
.filter((e) => e && typeof e.text === "string" && e.text.trim())
|
|
73
|
+
.map((e) => ({
|
|
74
|
+
polarity: e.polarity === "positive" ? "positive" : e.polarity === "neutral" ? "neutral" : "negative",
|
|
75
|
+
text: String(e.text).trim().slice(0, 600),
|
|
76
|
+
source: String(e.source || "unknown source").slice(0, 120),
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function countSources(evidence) {
|
|
81
|
+
if (!Array.isArray(evidence)) return 0;
|
|
82
|
+
return new Set(evidence.map((e) => e?.source).filter(Boolean)).size;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Overlapping count: a run of identical lines must not pass as unique. */
|
|
86
|
+
function occurrences(haystack, needle) {
|
|
87
|
+
if (!needle) return 0;
|
|
88
|
+
let count = 0;
|
|
89
|
+
let index = haystack.indexOf(needle);
|
|
90
|
+
while (index !== -1) {
|
|
91
|
+
count += 1;
|
|
92
|
+
index = haystack.indexOf(needle, index + 1);
|
|
93
|
+
}
|
|
94
|
+
return count;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function replaceOnce(text, find, replace, label, file) {
|
|
98
|
+
const found = occurrences(text, find);
|
|
99
|
+
if (found === 0) throw new Error(`${label}: "find" text does not appear in ${file}`);
|
|
100
|
+
if (found > 1) throw new Error(`${label}: "find" text appears ${found} times in ${file}; must be unique`);
|
|
101
|
+
return text.replace(find, () => replace);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Apply one edit to file text. Returns the new text, or throws with a precise reason.
|
|
106
|
+
*
|
|
107
|
+
* A measured edit applies each of its hunks; the hunks' windows never overlap, so they
|
|
108
|
+
* apply in any order and any subset. The legacy single `find`/`replace`/`anchor` shape
|
|
109
|
+
* is still honored so a proposal saved by an earlier version stays applicable.
|
|
110
|
+
*/
|
|
111
|
+
export function applyEdit(text, edit) {
|
|
112
|
+
if (Array.isArray(edit.hunks)) {
|
|
113
|
+
let current = text;
|
|
114
|
+
for (const hunk of edit.hunks) {
|
|
115
|
+
const label = `edit ${edit.id} (${hunk.id || "hunk"})`;
|
|
116
|
+
if (!hunk.find) {
|
|
117
|
+
if (current !== "") throw new Error(`${label}: ${edit.file} is no longer empty`);
|
|
118
|
+
current = hunk.replace;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
current = replaceOnce(current, hunk.find, hunk.replace, label, edit.file);
|
|
122
|
+
}
|
|
123
|
+
return current;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (edit.find) return replaceOnce(text, edit.find, edit.replace, `edit ${edit.id}`, edit.file);
|
|
127
|
+
|
|
128
|
+
if (!edit.replace) throw new Error(`edit ${edit.id}: nothing to add and nothing to remove`);
|
|
129
|
+
|
|
130
|
+
if (!edit.anchor) {
|
|
131
|
+
const separator = text.endsWith("\n") ? "" : "\n";
|
|
132
|
+
return `${text}${separator}\n${edit.replace}\n`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const found = occurrences(text, edit.anchor);
|
|
136
|
+
if (found === 0) throw new Error(`edit ${edit.id}: "anchor" text does not appear in ${edit.file}`);
|
|
137
|
+
if (found > 1)
|
|
138
|
+
throw new Error(`edit ${edit.id}: "anchor" text appears ${found} times in ${edit.file}; must be unique`);
|
|
139
|
+
const at = text.indexOf(edit.anchor) + edit.anchor.length;
|
|
140
|
+
return `${text.slice(0, at)}\n\n${edit.replace}${text.slice(at)}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Apply a set of edits to one file, in order. */
|
|
144
|
+
export function applyEdits(text, edits) {
|
|
145
|
+
return edits.reduce((current, edit) => applyEdit(current, edit), text);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** One-line label for a measured change, used in gate messages and the annotate prompt. */
|
|
149
|
+
export function describeChange(change) {
|
|
150
|
+
if (change.kind === "created") return `${change.id}: new file ${change.file}`;
|
|
151
|
+
if (change.kind === "deleted") return `${change.id}: deletes ${change.file}`;
|
|
152
|
+
const where = change.removed
|
|
153
|
+
? change.oldStart === change.oldEnd
|
|
154
|
+
? `line ${change.oldStart}`
|
|
155
|
+
: `lines ${change.oldStart}-${change.oldEnd}`
|
|
156
|
+
: `after line ${change.oldStart - 1}`;
|
|
157
|
+
return `${change.id}: ${change.file} ${where} (-${change.removed}/+${change.added})`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The measured changes as the annotate prompt shows them. */
|
|
161
|
+
export function renderChangesForPrompt(measured, memoryFile) {
|
|
162
|
+
if (!measured.changes.length) return "(no changes - the staging copy is identical to the original)";
|
|
163
|
+
const unitsAt = (change) => {
|
|
164
|
+
if (change.file !== memoryFile.path || change.kind !== "hunk") return "";
|
|
165
|
+
const from = change.oldStart;
|
|
166
|
+
const to = change.removed ? change.oldEnd : change.oldStart;
|
|
167
|
+
const ids = memoryFile.units.filter((u) => u.startLine <= to && u.endLine >= from).map((u) => u.id);
|
|
168
|
+
return ids.length ? ` · ${ids.join(", ")}` : "";
|
|
169
|
+
};
|
|
170
|
+
return measured.changes
|
|
171
|
+
.map((change) => {
|
|
172
|
+
const head = `[${describeChange(change)}${unitsAt(change)}]`;
|
|
173
|
+
if (change.kind === "deleted") return head;
|
|
174
|
+
if (change.kind === "created") {
|
|
175
|
+
return `${head}\n${renderHunkLines(
|
|
176
|
+
change.text.split("\n").map((text) => ({ type: "ins", text })),
|
|
177
|
+
{ maxLines: 80 },
|
|
178
|
+
)}`;
|
|
179
|
+
}
|
|
180
|
+
return `${head}\n${renderHunkLines(change.lines)}`;
|
|
181
|
+
})
|
|
182
|
+
.join("\n\n");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Validate the annotated, measured changes against the mechanical gates. Returns
|
|
187
|
+
* `{ proposal, violations }`; the caller decides whether to re-prompt or fail loudly.
|
|
188
|
+
*
|
|
189
|
+
* `context.measured` is the workspace measurement (`measureWorkspace`); `rawResult` is
|
|
190
|
+
* the model's annotation. Nothing textual is taken from the model: the hunks, their
|
|
191
|
+
* deltas, the projected budget, and even whether an edit is an addition are measured.
|
|
192
|
+
*/
|
|
193
|
+
export function buildProposal(rawResult, context) {
|
|
194
|
+
const {
|
|
195
|
+
memoryFile,
|
|
196
|
+
config,
|
|
197
|
+
repo,
|
|
198
|
+
summary,
|
|
199
|
+
measured = { changes: [], stray: [] },
|
|
200
|
+
harnessCounts = {},
|
|
201
|
+
rejections = { entries: {} },
|
|
202
|
+
isSuppressed = () => false,
|
|
203
|
+
} = context;
|
|
204
|
+
|
|
205
|
+
const violations = [];
|
|
206
|
+
const notes = Array.isArray(rawResult?.notes) ? rawResult.notes.map(String) : [];
|
|
207
|
+
const rawEdits = Array.isArray(rawResult?.edits) ? rawResult.edits : [];
|
|
208
|
+
const edits = rawEdits.map((raw, i) => normalizeEdit(raw, i));
|
|
209
|
+
const changesById = new Map(measured.changes.map((c) => [c.id, c]));
|
|
210
|
+
|
|
211
|
+
const maxEdits = effectiveMaxEdits(memoryFile, config);
|
|
212
|
+
if (edits.length > maxEdits) {
|
|
213
|
+
violations.push(`proposed ${edits.length} edits but the per-run cap is ${maxEdits} (the learning rate)`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Every measured change must be claimed exactly once.
|
|
217
|
+
const claimedBy = new Map();
|
|
218
|
+
for (const edit of edits) {
|
|
219
|
+
for (const id of edit.changeIds) {
|
|
220
|
+
if (!changesById.has(id)) {
|
|
221
|
+
violations.push(`edit ${edit.id} refers to ${id}, which is not a measured change`);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (claimedBy.has(id)) {
|
|
225
|
+
violations.push(`${id} is claimed by both edit ${claimedBy.get(id)} and edit ${edit.id}`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
claimedBy.set(id, edit.id);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
for (const change of measured.changes) {
|
|
232
|
+
if (change.kind === "deleted") {
|
|
233
|
+
violations.push(`${describeChange(change)}; backpass cannot propose deletions - restore the file`);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (!claimedBy.has(change.id)) {
|
|
237
|
+
violations.push(
|
|
238
|
+
`${describeChange(change)} is not part of any edit; every change needs an edit with evidence, or revert it`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const accepted = [];
|
|
244
|
+
for (const edit of edits) {
|
|
245
|
+
const changes = edit.changeIds.map((id) => changesById.get(id)).filter(Boolean);
|
|
246
|
+
const created = changes.filter((c) => c.kind === "created");
|
|
247
|
+
const hunks = changes.filter((c) => c.kind === "hunk");
|
|
248
|
+
const files = [...new Set(hunks.map((h) => h.file))];
|
|
249
|
+
|
|
250
|
+
if (!EDIT_KINDS.includes(edit.kind)) {
|
|
251
|
+
violations.push(`edit ${edit.id}: unknown kind "${edit.kind}"`);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (!changes.length) {
|
|
255
|
+
violations.push(`edit ${edit.id} ("${edit.title}") names no measured change`);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (files.length > 1) {
|
|
259
|
+
violations.push(`edit ${edit.id} ("${edit.title}") changes ${files.join(" and ")}; an edit changes one file`);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (!edit.evidence.length) {
|
|
263
|
+
violations.push(`edit ${edit.id} ("${edit.title}") carries no verbatim evidence quote`);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (edit.kind === "extract") {
|
|
267
|
+
if (created.length !== 1 || !hunks.length || files[0] !== memoryFile.path) {
|
|
268
|
+
violations.push(
|
|
269
|
+
`edit ${edit.id}: kind "extract" must group exactly one created SKILL.md with change(s) to ${memoryFile.path}`,
|
|
270
|
+
);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (!created[0].skill) {
|
|
274
|
+
violations.push(
|
|
275
|
+
`edit ${edit.id}: ${created[0].file} needs YAML frontmatter with \`name:\` and \`description:\``,
|
|
276
|
+
);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
} else if (created.length) {
|
|
280
|
+
violations.push(`edit ${edit.id}: only kind "extract" may include a created file (${created[0].id})`);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// An addition is measured, not declared: text that only goes in is a new instruction.
|
|
285
|
+
const onlyAdds = hunks.every((h) => h.removed === 0);
|
|
286
|
+
if (edit.kind !== "extract" && onlyAdds && edit.transcripts < config.minGapEvidence) {
|
|
287
|
+
violations.push(
|
|
288
|
+
`edit ${edit.id} ("${edit.title}") adds a new instruction backed by ${edit.transcripts} session(s); ` +
|
|
289
|
+
`${config.minGapEvidence} are required`,
|
|
290
|
+
);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const file = files[0];
|
|
295
|
+
const proposed = {
|
|
296
|
+
id: edit.id,
|
|
297
|
+
kind: edit.kind,
|
|
298
|
+
file,
|
|
299
|
+
title: edit.title,
|
|
300
|
+
rationale: edit.rationale,
|
|
301
|
+
instructions: edit.instructions,
|
|
302
|
+
evidence: edit.evidence,
|
|
303
|
+
transcripts: edit.transcripts,
|
|
304
|
+
skill: created[0]?.skill || null,
|
|
305
|
+
hunks: hunks.map((h) => ({
|
|
306
|
+
id: h.id,
|
|
307
|
+
find: h.find,
|
|
308
|
+
replace: h.replace,
|
|
309
|
+
oldStart: h.oldStart,
|
|
310
|
+
oldEnd: h.oldEnd,
|
|
311
|
+
removed: h.removed,
|
|
312
|
+
added: h.added,
|
|
313
|
+
lines: h.lines,
|
|
314
|
+
})),
|
|
315
|
+
targetsMemoryFile: file === memoryFile.path,
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
if (isSuppressed(proposed, rejections)) {
|
|
319
|
+
// Rejections are respected until materially new evidence arrives (captain tweak 3).
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
accepted.push(proposed);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Deltas are measured here, never taken from the model.
|
|
326
|
+
const running = new Map();
|
|
327
|
+
for (const edit of accepted) {
|
|
328
|
+
const before =
|
|
329
|
+
running.get(edit.file) ?? (edit.targetsMemoryFile ? memoryFile.text : (measured.originals?.get(edit.file) ?? ""));
|
|
330
|
+
let next;
|
|
331
|
+
try {
|
|
332
|
+
next = applyEdit(before, edit);
|
|
333
|
+
} catch (err) {
|
|
334
|
+
violations.push(err.message);
|
|
335
|
+
edit.applicable = false;
|
|
336
|
+
edit.deltaTokens = 0;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
edit.applicable = true;
|
|
340
|
+
edit.deltaTokens = estimateTokens(next) - estimateTokens(before);
|
|
341
|
+
running.set(edit.file, next);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const projectedText = running.get(memoryFile.path) ?? memoryFile.text;
|
|
345
|
+
const budget = budgetStatus(memoryFile.text, projectedText, config.budgetTokens);
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The budget gate has two modes (design section 6).
|
|
349
|
+
*
|
|
350
|
+
* Normally the post-edit file must fit the budget. But a file that is ALREADY over
|
|
351
|
+
* budget cannot be brought under it in one capped step - demanding that would fail
|
|
352
|
+
* every run on exactly the repos that need backpass most. There, the run is a shrink
|
|
353
|
+
* plan and the gate is progress: the edit set must be strictly net-negative.
|
|
354
|
+
*/
|
|
355
|
+
budget.mode = memoryFile.tokens > config.budgetTokens ? "shrink" : "cap";
|
|
356
|
+
budget.startedOverBudget = budget.mode === "shrink";
|
|
357
|
+
|
|
358
|
+
if (budget.mode === "cap" && !budget.withinBudget) {
|
|
359
|
+
violations.push(
|
|
360
|
+
`applying every proposed edit leaves ${memoryFile.path} at ${budget.projected} tokens, ` +
|
|
361
|
+
`${budget.over} over the ${config.budgetTokens}-token budget`,
|
|
362
|
+
);
|
|
363
|
+
} else if (budget.mode === "shrink" && budget.delta >= 0) {
|
|
364
|
+
violations.push(
|
|
365
|
+
`${memoryFile.path} is already ${budget.current - config.budgetTokens} tokens over the ` +
|
|
366
|
+
`${config.budgetTokens}-token budget, so this run must shrink it, but the proposed edits ` +
|
|
367
|
+
`change it by ${budget.delta >= 0 ? "+" : ""}${budget.delta} tokens`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
for (const file of measured.stray || [])
|
|
372
|
+
notes.push(`ignored ${file}: synthesis wrote it outside the memory file and skills`);
|
|
373
|
+
|
|
374
|
+
const proposal = {
|
|
375
|
+
version: 2,
|
|
376
|
+
tool: "backpass",
|
|
377
|
+
generatedAt: new Date().toISOString(),
|
|
378
|
+
repo: { name: repo.name, root: repo.root },
|
|
379
|
+
memoryFile: { path: memoryFile.path, hash: memoryFile.hash, tokens: memoryFile.tokens },
|
|
380
|
+
budget,
|
|
381
|
+
config: {
|
|
382
|
+
budgetTokens: config.budgetTokens,
|
|
383
|
+
maxEditsPerRun: maxEdits,
|
|
384
|
+
minGapEvidence: config.minGapEvidence,
|
|
385
|
+
skillsDir: config.skillsDir,
|
|
386
|
+
analysis: config.analysis,
|
|
387
|
+
synthesis: config.synthesis,
|
|
388
|
+
},
|
|
389
|
+
stats: {
|
|
390
|
+
transcripts: summary?.analyzedSessions ?? 0,
|
|
391
|
+
harnessCounts,
|
|
392
|
+
positive: summary?.totals?.positive ?? 0,
|
|
393
|
+
negative: summary?.totals?.negative ?? 0,
|
|
394
|
+
gapClusters: summary?.totals?.gapClusters ?? 0,
|
|
395
|
+
skillExtractions: accepted.filter((e) => e.kind === "extract").length,
|
|
396
|
+
},
|
|
397
|
+
edits: accepted,
|
|
398
|
+
verdicts: Array.isArray(rawResult?.verdicts) ? rawResult.verdicts : [],
|
|
399
|
+
notes,
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
return { proposal, violations };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function slug(text) {
|
|
406
|
+
return (
|
|
407
|
+
String(text)
|
|
408
|
+
.toLowerCase()
|
|
409
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
410
|
+
.replace(/^-+|-+$/g, "")
|
|
411
|
+
.slice(0, 60) || "skill"
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Project the memory file forward under a specific set of accepted edit ids - used by
|
|
417
|
+
* both the apply surface's live budget gauge and the actual writer.
|
|
418
|
+
*/
|
|
419
|
+
export function projectWithDecisions(memoryText, edits, acceptedIds, capTokens) {
|
|
420
|
+
const chosen = edits.filter((e) => acceptedIds.includes(e.id) && e.targetsMemoryFile && e.applicable !== false);
|
|
421
|
+
let text = memoryText;
|
|
422
|
+
for (const edit of chosen) {
|
|
423
|
+
try {
|
|
424
|
+
text = applyEdit(text, edit);
|
|
425
|
+
} catch {
|
|
426
|
+
// Skip an edit that no longer applies; the writer reports it.
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return { text, budget: budgetStatus(memoryText, text, capTokens) };
|
|
430
|
+
}
|