backpass 0.1.6 → 0.1.8
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/README.md +52 -11
- package/package.json +1 -1
- package/src/acpx.js +142 -52
- package/src/analyze.js +4 -3
- package/src/apply/terminal.js +4 -3
- package/src/apply/writer.js +327 -17
- package/src/cli.js +3 -3
- package/src/commands/apply.js +2 -2
- package/src/commands/propose.js +60 -6
- package/src/commands/run.js +6 -7
- package/src/harness-invoke.js +277 -0
- package/src/memory.js +10 -1
- package/src/prompts/annotate-preface.md +19 -0
- package/src/prompts/annotate.md +11 -6
- package/src/proposal.js +39 -8
- package/src/skills.js +156 -7
- package/src/state.js +5 -1
- package/src/subprocess.js +53 -5
- package/src/synthesize.js +311 -99
- package/src/tokens.js +11 -4
- package/src/tui/index.js +12 -0
- package/src/tui/render.js +16 -0
- package/templates/apply.html +26 -16
package/src/apply/writer.js
CHANGED
|
@@ -1,19 +1,28 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
3
4
|
|
|
4
5
|
import { applyEdit, projectWithDecisions } from "../proposal.js";
|
|
5
|
-
import {
|
|
6
|
+
import { memoryTextHash } from "../memory.js";
|
|
7
|
+
import { budgetGateKind, budgetStatus, formatTokens } from "../tokens.js";
|
|
6
8
|
import { recordRejection } from "../state.js";
|
|
7
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
CANONICAL_SKILLS_DIR,
|
|
11
|
+
CLAUDE_SKILLS_LINK,
|
|
12
|
+
editSkills,
|
|
13
|
+
ensureSkillsLayout,
|
|
14
|
+
removeOwnedSkillPaths,
|
|
15
|
+
writeSkill,
|
|
16
|
+
} from "../skills.js";
|
|
8
17
|
|
|
9
|
-
function acceptedSubsetBudgetFailure({ proposal, accepted, repo, capTokens }) {
|
|
18
|
+
function acceptedSubsetBudgetFailure({ proposal, accepted, repo, capTokens, memoryText }) {
|
|
10
19
|
if (!accepted.length) return null;
|
|
11
20
|
|
|
12
21
|
const relative = proposal.memoryFile.path;
|
|
13
22
|
const absolute = path.join(repo.root, relative);
|
|
14
|
-
if (!fs.existsSync(absolute)) return null;
|
|
23
|
+
if (memoryText === null && !fs.existsSync(absolute)) return null;
|
|
15
24
|
|
|
16
|
-
const before = fs.readFileSync(absolute, "utf8");
|
|
25
|
+
const before = memoryText ?? fs.readFileSync(absolute, "utf8");
|
|
17
26
|
const { budget } = projectWithDecisions(
|
|
18
27
|
before,
|
|
19
28
|
accepted,
|
|
@@ -41,14 +50,136 @@ function acceptedSubsetBudgetFailure({ proposal, accepted, repo, capTokens }) {
|
|
|
41
50
|
return null;
|
|
42
51
|
}
|
|
43
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Freshness before mutation.
|
|
55
|
+
*
|
|
56
|
+
* Every hunk was cut from one exact image of the memory file, and the proposal records
|
|
57
|
+
* that image's hash. If the file has disappeared or changed since - an upstream merge,
|
|
58
|
+
* a hand edit, another agent - then the hunks describe text that may no longer exist, and the ones
|
|
59
|
+
* that still happen to match would leave the file half-descended: part of a shrink plan
|
|
60
|
+
* applied against a file the plan was never measured against. So the run is refused
|
|
61
|
+
* before it writes anything, and the fix is to re-measure, not to salvage.
|
|
62
|
+
*
|
|
63
|
+
* A proposal saved before this field existed carries no hash and is left alone.
|
|
64
|
+
*/
|
|
65
|
+
function memoryFileSnapshot(proposal, repo) {
|
|
66
|
+
const expected = proposal.memoryFile?.hash;
|
|
67
|
+
const relative = proposal.memoryFile?.path;
|
|
68
|
+
if (!relative) return { text: null };
|
|
69
|
+
|
|
70
|
+
const absolute = path.join(repo.root, relative);
|
|
71
|
+
if (!fs.existsSync(absolute)) {
|
|
72
|
+
if (!expected) return { text: null };
|
|
73
|
+
return {
|
|
74
|
+
text: null,
|
|
75
|
+
failure: {
|
|
76
|
+
file: relative,
|
|
77
|
+
error:
|
|
78
|
+
`${relative} no longer exists, so its edits no longer describe the file on disk; nothing was written. ` +
|
|
79
|
+
`Run \`backpass\` to re-propose against the current repository.`,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const text = fs.readFileSync(absolute, "utf8");
|
|
85
|
+
if (!expected) return { text };
|
|
86
|
+
|
|
87
|
+
const observed = memoryTextHash(text);
|
|
88
|
+
if (observed === expected) return { text };
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
text,
|
|
92
|
+
failure: {
|
|
93
|
+
file: relative,
|
|
94
|
+
error:
|
|
95
|
+
`${relative} changed after this proposal was made (${expected} -> ${observed}), so its edits ` +
|
|
96
|
+
`no longer describe the file on disk; nothing was written. Run \`backpass\` to re-propose ` +
|
|
97
|
+
`against the current ${relative}.`,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function overBudgetWarning(relative, budget) {
|
|
103
|
+
return (
|
|
104
|
+
`${relative} is still ${formatTokens(budget.over)} tokens over the ${formatTokens(budget.capTokens)}-token ` +
|
|
105
|
+
"budget; run `backpass` again for the next shrink step"
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function atomicReplace(target, text) {
|
|
110
|
+
const temp = path.join(path.dirname(target), `.${path.basename(target)}.backpass-${randomUUID()}`);
|
|
111
|
+
const mode = fs.statSync(target).mode & 0o7777;
|
|
112
|
+
let fd;
|
|
113
|
+
let ownership;
|
|
114
|
+
try {
|
|
115
|
+
fd = fs.openSync(temp, "wx");
|
|
116
|
+
const stat = fs.fstatSync(fd);
|
|
117
|
+
ownership = [{ absolute: temp, identity: { dev: stat.dev, ino: stat.ino } }];
|
|
118
|
+
fs.fchmodSync(fd, mode);
|
|
119
|
+
fs.writeFileSync(fd, text);
|
|
120
|
+
fs.fsyncSync(fd);
|
|
121
|
+
fs.closeSync(fd);
|
|
122
|
+
fd = undefined;
|
|
123
|
+
fs.renameSync(temp, target);
|
|
124
|
+
return { absolute: target, identity: ownership[0].identity, text };
|
|
125
|
+
} catch (err) {
|
|
126
|
+
if (fd !== undefined) {
|
|
127
|
+
try {
|
|
128
|
+
fs.closeSync(fd);
|
|
129
|
+
} catch {
|
|
130
|
+
// Preserve the original write error.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
removeOwnedSkillPaths(ownership ?? []);
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function commitStillCurrent(commit) {
|
|
139
|
+
let stat;
|
|
140
|
+
try {
|
|
141
|
+
stat = fs.lstatSync(commit.absolute);
|
|
142
|
+
if (stat.dev !== commit.identity.dev || stat.ino !== commit.identity.ino) return false;
|
|
143
|
+
return fs.readFileSync(commit.absolute, "utf8") === commit.text;
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function absentParentDirectories(root, files) {
|
|
150
|
+
const directories = new Set();
|
|
151
|
+
for (const file of files) {
|
|
152
|
+
for (let current = path.dirname(file); current !== root; current = path.dirname(current)) {
|
|
153
|
+
if (!fs.existsSync(current)) directories.add(current);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return [...directories].sort((a, b) => b.length - a.length);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function removeEmptyDirectories(directories) {
|
|
160
|
+
for (const directory of directories) {
|
|
161
|
+
try {
|
|
162
|
+
fs.rmdirSync(directory);
|
|
163
|
+
} catch {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
44
169
|
/**
|
|
45
170
|
* The only place in backpass that writes to the repo.
|
|
46
171
|
*
|
|
47
172
|
* Everything upstream is read-only analysis; a run only changes the weights here, after
|
|
48
|
-
* a human accepted specific edits.
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
173
|
+
* a human accepted specific edits. Five gates run before the first byte is written:
|
|
174
|
+
* the memory file must still be the file the proposal was measured against
|
|
175
|
+
* (`memoryFileSnapshot`), the accepted subset must clear the same cap/shrink budget gate as
|
|
176
|
+
* the full proposal (`budgetGateKind`), every accepted edit for a file must compose
|
|
177
|
+
* against that file's single pre-write image, every created skill target must still be
|
|
178
|
+
* absent, and accepted paths must resolve to distinct targets. Any of them failing writes
|
|
179
|
+
* nothing and records no rejection.
|
|
180
|
+
*
|
|
181
|
+
* A file is therefore applied all at once or not at all. Skills are written only after
|
|
182
|
+
* every accepted edit has composed, and before the files that reference them.
|
|
52
183
|
*/
|
|
53
184
|
export function applyDecisions({ proposal, decisions, repo, state, config, dryRun = false }) {
|
|
54
185
|
const accepted = proposal.edits.filter((e) => decisions[e.id] === "accepted");
|
|
@@ -65,11 +196,22 @@ export function applyDecisions({ proposal, decisions, repo, state, config, dryRu
|
|
|
65
196
|
rejectionsRecorded: false,
|
|
66
197
|
};
|
|
67
198
|
|
|
199
|
+
let memoryText = null;
|
|
200
|
+
if (accepted.length || rejected.length) {
|
|
201
|
+
const snapshot = memoryFileSnapshot(proposal, repo);
|
|
202
|
+
if (snapshot.failure) {
|
|
203
|
+
results.failed.push(snapshot.failure);
|
|
204
|
+
return results;
|
|
205
|
+
}
|
|
206
|
+
memoryText = snapshot.text;
|
|
207
|
+
}
|
|
208
|
+
|
|
68
209
|
const budgetFailure = acceptedSubsetBudgetFailure({
|
|
69
210
|
proposal,
|
|
70
211
|
accepted,
|
|
71
212
|
repo,
|
|
72
213
|
capTokens: config.budgetTokens,
|
|
214
|
+
memoryText,
|
|
73
215
|
});
|
|
74
216
|
if (budgetFailure) {
|
|
75
217
|
results.failed.push(budgetFailure);
|
|
@@ -81,6 +223,11 @@ export function applyDecisions({ proposal, decisions, repo, state, config, dryRu
|
|
|
81
223
|
byFile.get(edit.file).push(edit);
|
|
82
224
|
}
|
|
83
225
|
|
|
226
|
+
// Compose first, write later. Each file's accepted edits are applied to one immutable
|
|
227
|
+
// image of that file; only a set that composes completely earns a write.
|
|
228
|
+
const planned = [];
|
|
229
|
+
const landed = new Set();
|
|
230
|
+
|
|
84
231
|
for (const [relative, edits] of byFile) {
|
|
85
232
|
const absolute = path.join(repo.root, relative);
|
|
86
233
|
if (!fs.existsSync(absolute)) {
|
|
@@ -88,35 +235,198 @@ export function applyDecisions({ proposal, decisions, repo, state, config, dryRu
|
|
|
88
235
|
continue;
|
|
89
236
|
}
|
|
90
237
|
|
|
91
|
-
const before =
|
|
238
|
+
const before =
|
|
239
|
+
relative === proposal.memoryFile?.path && memoryText !== null ? memoryText : fs.readFileSync(absolute, "utf8");
|
|
92
240
|
let text = before;
|
|
93
241
|
const applied = [];
|
|
242
|
+
const failures = [];
|
|
94
243
|
|
|
95
244
|
for (const edit of edits) {
|
|
96
245
|
try {
|
|
97
246
|
text = applyEdit(text, edit);
|
|
98
247
|
applied.push(edit.id);
|
|
99
248
|
} catch (err) {
|
|
100
|
-
|
|
249
|
+
failures.push({ file: relative, edit: edit.id, error: err.message });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (failures.length) {
|
|
254
|
+
results.failed.push(...failures);
|
|
255
|
+
if (applied.length) {
|
|
256
|
+
results.failed.push({
|
|
257
|
+
file: relative,
|
|
258
|
+
error: `${relative} was left unchanged: a file takes every accepted edit or none of them`,
|
|
259
|
+
});
|
|
101
260
|
}
|
|
261
|
+
continue;
|
|
102
262
|
}
|
|
103
263
|
|
|
104
264
|
if (text === before) continue;
|
|
265
|
+
planned.push({ relative, absolute, before, text, applied });
|
|
266
|
+
for (const id of applied) landed.add(id);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const plannedTargets = new Map();
|
|
270
|
+
const resolvedPlanned = [];
|
|
271
|
+
for (const item of planned) {
|
|
272
|
+
let resolved;
|
|
273
|
+
try {
|
|
274
|
+
resolved = fs.realpathSync(item.absolute);
|
|
275
|
+
} catch (err) {
|
|
276
|
+
results.failed.push({ file: item.relative, error: `${item.relative} could not be resolved: ${err.message}` });
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const existing = plannedTargets.get(resolved);
|
|
280
|
+
if (existing) {
|
|
281
|
+
results.failed.push({
|
|
282
|
+
file: item.relative,
|
|
283
|
+
error: `${item.relative} resolves to the same target as ${existing.relative}; nothing was written`,
|
|
284
|
+
});
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const resolvedItem = { ...item, resolved };
|
|
288
|
+
plannedTargets.set(resolved, resolvedItem);
|
|
289
|
+
resolvedPlanned.push(resolvedItem);
|
|
290
|
+
}
|
|
105
291
|
|
|
292
|
+
if (results.failed.length) return results;
|
|
293
|
+
|
|
294
|
+
// Skills go in before the memory file. A skill nothing points at yet is inert, while a
|
|
295
|
+
// memory file pointing at a skill that is not there is actively wrong - so if a skill
|
|
296
|
+
// cannot be written, the files that would reference it are left alone.
|
|
297
|
+
const plannedSkills = [];
|
|
298
|
+
const skillPaths = new Set();
|
|
299
|
+
for (const edit of accepted) {
|
|
300
|
+
if (edit.kind !== "extract" || !landed.has(edit.id)) continue;
|
|
301
|
+
for (const skill of editSkills(edit)) {
|
|
302
|
+
const absolute = path.join(repo.root, skill.path);
|
|
303
|
+
if (skillPaths.has(skill.path) || fs.existsSync(absolute)) {
|
|
304
|
+
results.failed.push({
|
|
305
|
+
file: skill.path,
|
|
306
|
+
edit: edit.id,
|
|
307
|
+
error: `${skill.path} already exists; nothing was written`,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
skillPaths.add(skill.path);
|
|
311
|
+
plannedSkills.push({ edit, skill });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (results.failed.length) return results;
|
|
315
|
+
|
|
316
|
+
const canonical = plannedSkills.find(
|
|
317
|
+
({ skill }) => skill.path === CANONICAL_SKILLS_DIR || skill.path.startsWith(`${CANONICAL_SKILLS_DIR}/`),
|
|
318
|
+
);
|
|
319
|
+
const createdDirectoryCandidates = absentParentDirectories(repo.root, [
|
|
320
|
+
...plannedSkills.map(({ skill }) => path.join(repo.root, skill.path)),
|
|
321
|
+
...(canonical ? [path.join(repo.root, CLAUDE_SKILLS_LINK)] : []),
|
|
322
|
+
]);
|
|
323
|
+
const skillFailures = [];
|
|
324
|
+
const ownedSkillPaths = [];
|
|
325
|
+
for (const { edit, skill } of plannedSkills) {
|
|
326
|
+
try {
|
|
327
|
+
const layout = dryRun
|
|
328
|
+
? { created: [], warnings: [] }
|
|
329
|
+
: writeSkill(repo.root, skill, { exclusive: true, ensureLayout: false });
|
|
330
|
+
results.skills.push({ path: skill.path, dryRun, created: layout.created });
|
|
331
|
+
if (!dryRun) {
|
|
332
|
+
ownedSkillPaths.push(...("ownership" in layout && Array.isArray(layout.ownership) ? layout.ownership : []));
|
|
333
|
+
}
|
|
334
|
+
for (const w of layout.warnings) if (!results.warnings.includes(w)) results.warnings.push(w);
|
|
335
|
+
} catch (err) {
|
|
336
|
+
skillFailures.push({ file: skill.path, edit: edit.id, error: err.message });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const rollbackSkills = () => {
|
|
341
|
+
const { removed, conflicts } = removeOwnedSkillPaths(ownedSkillPaths);
|
|
342
|
+
removeEmptyDirectories(createdDirectoryCandidates);
|
|
343
|
+
results.skills = [];
|
|
344
|
+
const removedPaths = removed.map((item) => item.relative).filter(Boolean);
|
|
345
|
+
if (removedPaths.length) {
|
|
346
|
+
results.failed.push({
|
|
347
|
+
error: `rolled back skill paths written earlier in this round: ${removedPaths.join(", ")}`,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
for (const item of conflicts) {
|
|
351
|
+
results.failed.push({
|
|
352
|
+
file: item.relative,
|
|
353
|
+
error: `${item.relative} rollback conflict: the skill changed after this apply wrote it; left untouched`,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
if (skillFailures.length) {
|
|
359
|
+
results.failed.push(...skillFailures);
|
|
360
|
+
rollbackSkills();
|
|
361
|
+
for (const { relative } of planned) {
|
|
362
|
+
results.failed.push({
|
|
363
|
+
file: relative,
|
|
364
|
+
error: `${relative} was left unchanged: its edits point at a skill that could not be written`,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
return results;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const orderedPlanned = [...resolvedPlanned].sort((a, b) => {
|
|
371
|
+
const aMemory = a.relative === proposal.memoryFile.path;
|
|
372
|
+
const bMemory = b.relative === proposal.memoryFile.path;
|
|
373
|
+
return Number(aMemory) - Number(bMemory);
|
|
374
|
+
});
|
|
375
|
+
const committed = [];
|
|
376
|
+
const rollbackCommitted = () => {
|
|
377
|
+
for (const written of [...committed].reverse()) {
|
|
378
|
+
if (!commitStillCurrent(written.commit)) {
|
|
379
|
+
results.failed.push({
|
|
380
|
+
file: written.relative,
|
|
381
|
+
error: `${written.relative} rollback conflict: the file changed after this apply wrote it; left untouched`,
|
|
382
|
+
});
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
atomicReplace(written.commit.absolute, written.before);
|
|
387
|
+
} catch (rollbackError) {
|
|
388
|
+
results.failed.push({
|
|
389
|
+
file: written.relative,
|
|
390
|
+
error: `${written.relative} could not be rolled back: ${rollbackError.message}`,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
results.written = [];
|
|
395
|
+
};
|
|
396
|
+
for (const item of orderedPlanned) {
|
|
397
|
+
const { relative, resolved, before, text, applied } = item;
|
|
106
398
|
const budget = relative === proposal.memoryFile.path ? budgetStatus(before, text, config.budgetTokens) : null;
|
|
107
399
|
|
|
108
|
-
|
|
400
|
+
let commit = null;
|
|
401
|
+
try {
|
|
402
|
+
if (!dryRun) commit = atomicReplace(resolved, text);
|
|
403
|
+
} catch (err) {
|
|
404
|
+
results.failed.push({
|
|
405
|
+
file: relative,
|
|
406
|
+
error: `${relative} could not be written: ${err.message}`,
|
|
407
|
+
});
|
|
408
|
+
rollbackCommitted();
|
|
409
|
+
rollbackSkills();
|
|
410
|
+
return results;
|
|
411
|
+
}
|
|
412
|
+
committed.push({ ...item, commit });
|
|
109
413
|
results.written.push({ file: relative, edits: applied, budget, dryRun });
|
|
414
|
+
|
|
415
|
+
// Shrinking over several runs is the design, so this is a heading, not a failure.
|
|
416
|
+
if (budget && !budget.withinBudget) results.warnings.push(overBudgetWarning(relative, budget));
|
|
110
417
|
}
|
|
111
418
|
|
|
112
|
-
|
|
113
|
-
if (edit.kind !== "extract" || !edit.skill) continue;
|
|
419
|
+
if (!dryRun && canonical) {
|
|
114
420
|
try {
|
|
115
|
-
const layout =
|
|
116
|
-
results.skills.
|
|
421
|
+
const layout = ensureSkillsLayout(repo.root);
|
|
422
|
+
const result = results.skills.find(({ path: skillPath }) => skillPath === canonical.skill.path);
|
|
423
|
+
result.created = [...new Set([...result.created, ...layout.created])];
|
|
117
424
|
for (const w of layout.warnings) if (!results.warnings.includes(w)) results.warnings.push(w);
|
|
118
425
|
} catch (err) {
|
|
119
|
-
results.failed.push({ file:
|
|
426
|
+
results.failed.push({ file: CLAUDE_SKILLS_LINK, edit: canonical.edit.id, error: err.message });
|
|
427
|
+
rollbackCommitted();
|
|
428
|
+
rollbackSkills();
|
|
429
|
+
return results;
|
|
120
430
|
}
|
|
121
431
|
}
|
|
122
432
|
|
package/src/cli.js
CHANGED
|
@@ -74,7 +74,7 @@ COMMANDS
|
|
|
74
74
|
gradient descent. Never writes.
|
|
75
75
|
scan collect samples only: which transcripts belong to this repo, and how we know
|
|
76
76
|
analyze calculate loss: one cheap model call per new transcript (tier 1)
|
|
77
|
-
propose aggregate gradients, then
|
|
77
|
+
propose aggregate gradients, then high-reasoning gradient descent
|
|
78
78
|
turning the aggregated evidence into edits (tier 2)
|
|
79
79
|
apply review the proposal and write the accepted edits (the only writer)
|
|
80
80
|
status cache state, evidence counts, and the budget bar
|
|
@@ -99,10 +99,10 @@ MODELS (two-tier: cheap analysis, smart synthesis - all through acpx)
|
|
|
99
99
|
Setting an agent pins that pass and skips its ladder.
|
|
100
100
|
--analysis-agent <a> acpx agent for the per-transcript pass [auto]
|
|
101
101
|
--analysis-model <id> model id for the analysis pass (needs --analysis-agent)
|
|
102
|
-
--analysis-effort <e> reasoning effort, when
|
|
102
|
+
--analysis-effort <e> one-off reasoning effort, when supported [medium]
|
|
103
103
|
--synthesis-agent <a> acpx agent for the final proposal pass [auto]
|
|
104
104
|
--synthesis-model <id> model id for the synthesis pass (needs --synthesis-agent)
|
|
105
|
-
--synthesis-effort <e> reasoning effort for synthesis
|
|
105
|
+
--synthesis-effort <e> one-off reasoning effort for synthesis [high]
|
|
106
106
|
--no-auto-agent skip the ladders and pin codex / claude (the pre-0.2 defaults)
|
|
107
107
|
--jobs <n> parallel analysis calls [4]
|
|
108
108
|
|
package/src/commands/apply.js
CHANGED
|
@@ -10,8 +10,8 @@ import { budgetBar, formatTokens } from "../tokens.js";
|
|
|
10
10
|
*
|
|
11
11
|
* By default it serves the shipped static template through lavish-axi and waits for one
|
|
12
12
|
* structured decision vector; `--no-ui` keeps the same ACCEPT/REJECT decision in the
|
|
13
|
-
* terminal. `applyDecisions`
|
|
14
|
-
*
|
|
13
|
+
* terminal. `applyDecisions` owns the pre-write freshness, budget, and composition gates;
|
|
14
|
+
* a failing gate records no rejections.
|
|
15
15
|
*/
|
|
16
16
|
export async function cmdApply(ctx) {
|
|
17
17
|
const { config, repo } = ctx;
|
package/src/commands/propose.js
CHANGED
|
@@ -35,6 +35,10 @@ export async function foldForRun(ctx, memoryFile) {
|
|
|
35
35
|
|
|
36
36
|
export async function runProposal(ctx, precomputed = null) {
|
|
37
37
|
const { repo, config } = ctx;
|
|
38
|
+
// Starting a new proposal run invalidates the previous result immediately. Discovery,
|
|
39
|
+
// folding, and agent resolution can all fail before synthesis starts; none of those
|
|
40
|
+
// failures may leave an older proposal available to apply as if it came from this run.
|
|
41
|
+
config.state.clearProposal();
|
|
38
42
|
const { file } = precomputed || primaryMemoryFile(repo, config);
|
|
39
43
|
const transcripts = precomputed?.transcripts || (await discoverForRun(ctx)).transcripts;
|
|
40
44
|
|
|
@@ -113,6 +117,60 @@ export function printProposal(proposal, { applied = false, analysisUsage = [] }
|
|
|
113
117
|
out("Review and apply with `backpass apply` (nothing has been written).");
|
|
114
118
|
}
|
|
115
119
|
|
|
120
|
+
/** True when a violation is about the always-loaded budget rather than the annotation. */
|
|
121
|
+
const isBudgetViolation = (v) => /-token budget/.test(v);
|
|
122
|
+
const isEditCapViolation = (v) => /per-run cap is \d+/.test(v);
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* What to actually try next, read off the condition the run ended on.
|
|
126
|
+
*
|
|
127
|
+
* The old advice - a stronger model, a bigger budget, a higher edit cap - was printed for
|
|
128
|
+
* every failure, including the ones where the model never spoke and the ones where the
|
|
129
|
+
* budget was never the constraint. Each terminal condition has a different repair.
|
|
130
|
+
*/
|
|
131
|
+
export function synthesisFailureHint(err) {
|
|
132
|
+
if (err.reason === "empty") {
|
|
133
|
+
return "the synthesis harness returned no text, so nothing about the model, the budget, or the edit cap was the constraint; run `backpass propose` again to start a fresh synthesis session";
|
|
134
|
+
}
|
|
135
|
+
if (err.reason === "unparseable") {
|
|
136
|
+
return "the model answered but not with a JSON object; run `backpass propose` again, or pin a different harness with --synthesis-agent";
|
|
137
|
+
}
|
|
138
|
+
if (err.reason === "editing") {
|
|
139
|
+
return "the agent kept rewriting the staging copy instead of describing it; run `backpass propose` again to start fresh";
|
|
140
|
+
}
|
|
141
|
+
const violations = err.violations || [];
|
|
142
|
+
if (violations.some(isBudgetViolation)) {
|
|
143
|
+
return "the edit set did not clear the budget gate: raise --budget, or let the shrink continue over more runs";
|
|
144
|
+
}
|
|
145
|
+
if (violations.some(isEditCapViolation)) {
|
|
146
|
+
return "the annotation proposed more edits than the per-run learning rate allows: raise --max-edits, or re-run and let the next pass take the rest";
|
|
147
|
+
}
|
|
148
|
+
return "the gates above are what the next synthesis must satisfy; run `backpass propose` again";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Report a synthesis that ended without a valid proposal: loudly, and about the turn that
|
|
153
|
+
* actually ended it (design section 6).
|
|
154
|
+
*
|
|
155
|
+
* The saved proposal and the terminal condition can be from different turns - a run whose
|
|
156
|
+
* last turn was empty leaves the rejected proposal of an earlier one on disk - so the
|
|
157
|
+
* provenance is printed rather than letting the older violations read as this turn's.
|
|
158
|
+
*/
|
|
159
|
+
export function printSynthesisFailure(err, state) {
|
|
160
|
+
info("");
|
|
161
|
+
for (const violation of err.violations) info(` ${color.red("x")} ${violation}`);
|
|
162
|
+
info("");
|
|
163
|
+
if (!err.saved) {
|
|
164
|
+
info(color.dim(" no proposal was saved: no annotation turn produced one"));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
info(color.dim(` the rejected proposal was saved to ${state.proposalPath}`));
|
|
168
|
+
if (err.reason !== "gates") {
|
|
169
|
+
info(color.dim(` it is from annotation attempt ${err.saved.attempt}, not the turn above, and it lists:`));
|
|
170
|
+
for (const violation of err.saved.violations) info(color.dim(` - ${violation}`));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
116
174
|
export async function cmdPropose(ctx) {
|
|
117
175
|
try {
|
|
118
176
|
const { proposal } = await runProposal(ctx);
|
|
@@ -124,12 +182,8 @@ export async function cmdPropose(ctx) {
|
|
|
124
182
|
return 0;
|
|
125
183
|
} catch (err) {
|
|
126
184
|
if (err instanceof ProposalViolation) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
for (const violation of err.violations) info(` ${color.red("x")} ${violation}`);
|
|
130
|
-
info("");
|
|
131
|
-
info(color.dim(` the rejected proposal was saved to ${ctx.config.state.proposalPath}`));
|
|
132
|
-
throw new UserError(err.message, "try a stronger synthesis model, or raise --budget / --max-edits");
|
|
185
|
+
printSynthesisFailure(err, ctx.config.state);
|
|
186
|
+
throw new UserError(err.message, synthesisFailureHint(err));
|
|
133
187
|
}
|
|
134
188
|
throw err;
|
|
135
189
|
}
|
package/src/commands/run.js
CHANGED
|
@@ -2,7 +2,7 @@ import { UserError, color, info, json, out } from "../logger.js";
|
|
|
2
2
|
import { ProposalViolation } from "../proposal.js";
|
|
3
3
|
import { runAnalysis } from "./analyze.js";
|
|
4
4
|
import { bootstrapJson, bootstrapRun, printBootstrap } from "./bootstrap.js";
|
|
5
|
-
import { printProposal, runProposal } from "./propose.js";
|
|
5
|
+
import { printProposal, printSynthesisFailure, runProposal, synthesisFailureHint } from "./propose.js";
|
|
6
6
|
import { budgetBar, formatTokens } from "../tokens.js";
|
|
7
7
|
import { startTui } from "../tui/index.js";
|
|
8
8
|
import { resolveMemoryFiles } from "../memory.js";
|
|
@@ -10,7 +10,7 @@ import { resolveMemoryFiles } from "../memory.js";
|
|
|
10
10
|
/**
|
|
11
11
|
* The default command: one full backward pass.
|
|
12
12
|
*
|
|
13
|
-
* discover -> distill -> analyze (cheap, fanned out) -> fold -> synthesize (
|
|
13
|
+
* discover -> distill -> analyze (cheap, fanned out) -> fold -> synthesize (high-reasoning turns)
|
|
14
14
|
*
|
|
15
15
|
* It never writes - with one exception: a repo with no memory file at all is
|
|
16
16
|
* bootstrapped (`./bootstrap.js`), which only ever creates files. Otherwise applying
|
|
@@ -31,6 +31,8 @@ export async function cmdRun(ctx) {
|
|
|
31
31
|
)}`,
|
|
32
32
|
);
|
|
33
33
|
|
|
34
|
+
config.state.clearProposal();
|
|
35
|
+
|
|
34
36
|
if (!resolveMemoryFiles(repo.root, config.memoryFiles).primary) {
|
|
35
37
|
const result = await bootstrapRun(ctx);
|
|
36
38
|
tui?.stop();
|
|
@@ -82,11 +84,8 @@ export async function cmdRun(ctx) {
|
|
|
82
84
|
} catch (err) {
|
|
83
85
|
tui?.stop();
|
|
84
86
|
if (err instanceof ProposalViolation) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
info("");
|
|
88
|
-
info(color.dim(` the rejected proposal was saved to ${config.state.proposalPath}`));
|
|
89
|
-
throw new UserError(err.message, "try a stronger synthesis model, or raise --budget / --max-edits");
|
|
87
|
+
printSynthesisFailure(err, config.state);
|
|
88
|
+
throw new UserError(err.message, synthesisFailureHint(err));
|
|
90
89
|
}
|
|
91
90
|
throw err;
|
|
92
91
|
} finally {
|