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.
@@ -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 { budgetGateKind, budgetStatus } from "../tokens.js";
6
+ import { memoryTextHash } from "../memory.js";
7
+ import { budgetGateKind, budgetStatus, formatTokens } from "../tokens.js";
6
8
  import { recordRejection } from "../state.js";
7
- import { writeSkill } from "../skills.js";
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. The accepted subset is rechecked with the same
49
- * cap/shrink budget gate as the full proposal (`budgetGateKind`); a failing subset
50
- * returns with no writes and no rejection ledger. Writes are grouped per file so a
51
- * memory file is rewritten once, atomically, rather than edit by edit.
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 = fs.readFileSync(absolute, "utf8");
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
- results.failed.push({ file: relative, edit: edit.id, error: err.message });
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
- if (!dryRun) fs.writeFileSync(absolute, text);
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
- for (const edit of accepted) {
113
- if (edit.kind !== "extract" || !edit.skill) continue;
419
+ if (!dryRun && canonical) {
114
420
  try {
115
- const layout = dryRun ? { created: [], warnings: [] } : writeSkill(repo.root, edit.skill);
116
- results.skills.push({ path: edit.skill.path, dryRun, created: layout.created });
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: edit.skill.path, edit: edit.id, error: err.message });
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 gradient descent: one high-reasoning call
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 the adapter advertises it [medium]
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 [high]
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
 
@@ -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` revalidates the accepted subset against the budget before
14
- * writing; a failing set records no rejections.
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;
@@ -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
- // Loud failure, never silent truncation (design section 6).
128
- info("");
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
  }
@@ -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 (one big call)
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
- info("");
86
- for (const violation of err.violations) info(` ${color.red("x")} ${violation}`);
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 {