backpass 0.1.7 → 0.1.9

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,11 +1,19 @@
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
6
  import { memoryTextHash } from "../memory.js";
6
7
  import { budgetGateKind, budgetStatus, formatTokens } from "../tokens.js";
7
8
  import { recordRejection } from "../state.js";
8
- 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";
9
17
 
10
18
  function acceptedSubsetBudgetFailure({ proposal, accepted, repo, capTokens, memoryText }) {
11
19
  if (!accepted.length) return null;
@@ -68,7 +76,8 @@ function memoryFileSnapshot(proposal, repo) {
68
76
  file: relative,
69
77
  error:
70
78
  `${relative} no longer exists, so its edits no longer describe the file on disk; nothing was written. ` +
71
- `Run \`backpass\` to re-propose against the current repository.`,
79
+ `Run \`backpass\` to re-propose against the current repository - that pass reanalyzes transcripts ` +
80
+ `against the file that exists now, it does not reuse the judgments behind this proposal.`,
72
81
  },
73
82
  };
74
83
  }
@@ -86,7 +95,8 @@ function memoryFileSnapshot(proposal, repo) {
86
95
  error:
87
96
  `${relative} changed after this proposal was made (${expected} -> ${observed}), so its edits ` +
88
97
  `no longer describe the file on disk; nothing was written. Run \`backpass\` to re-propose ` +
89
- `against the current ${relative}.`,
98
+ `against the current ${relative} - that pass reanalyzes transcripts against the new file, it does ` +
99
+ `not reuse the stale judgments behind this proposal.`,
90
100
  },
91
101
  };
92
102
  }
@@ -98,16 +108,77 @@ function overBudgetWarning(relative, budget) {
98
108
  );
99
109
  }
100
110
 
111
+ function atomicReplace(target, text) {
112
+ const temp = path.join(path.dirname(target), `.${path.basename(target)}.backpass-${randomUUID()}`);
113
+ const mode = fs.statSync(target).mode & 0o7777;
114
+ let fd;
115
+ let ownership;
116
+ try {
117
+ fd = fs.openSync(temp, "wx");
118
+ const stat = fs.fstatSync(fd);
119
+ ownership = [{ absolute: temp, identity: { dev: stat.dev, ino: stat.ino } }];
120
+ fs.fchmodSync(fd, mode);
121
+ fs.writeFileSync(fd, text);
122
+ fs.fsyncSync(fd);
123
+ fs.closeSync(fd);
124
+ fd = undefined;
125
+ fs.renameSync(temp, target);
126
+ return { absolute: target, identity: ownership[0].identity, text };
127
+ } catch (err) {
128
+ if (fd !== undefined) {
129
+ try {
130
+ fs.closeSync(fd);
131
+ } catch {
132
+ // Preserve the original write error.
133
+ }
134
+ }
135
+ removeOwnedSkillPaths(ownership ?? []);
136
+ throw err;
137
+ }
138
+ }
139
+
140
+ function commitStillCurrent(commit) {
141
+ let stat;
142
+ try {
143
+ stat = fs.lstatSync(commit.absolute);
144
+ if (stat.dev !== commit.identity.dev || stat.ino !== commit.identity.ino) return false;
145
+ return fs.readFileSync(commit.absolute, "utf8") === commit.text;
146
+ } catch {
147
+ return false;
148
+ }
149
+ }
150
+
151
+ function absentParentDirectories(root, files) {
152
+ const directories = new Set();
153
+ for (const file of files) {
154
+ for (let current = path.dirname(file); current !== root; current = path.dirname(current)) {
155
+ if (!fs.existsSync(current)) directories.add(current);
156
+ }
157
+ }
158
+ return [...directories].sort((a, b) => b.length - a.length);
159
+ }
160
+
161
+ function removeEmptyDirectories(directories) {
162
+ for (const directory of directories) {
163
+ try {
164
+ fs.rmdirSync(directory);
165
+ } catch {
166
+ continue;
167
+ }
168
+ }
169
+ }
170
+
101
171
  /**
102
172
  * The only place in backpass that writes to the repo.
103
173
  *
104
174
  * Everything upstream is read-only analysis; a run only changes the weights here, after
105
- * a human accepted specific edits. Three gates run before the first byte is written:
175
+ * a human accepted specific edits. Five gates run before the first byte is written:
106
176
  * the memory file must still be the file the proposal was measured against
107
177
  * (`memoryFileSnapshot`), the accepted subset must clear the same cap/shrink budget gate as
108
- * the full proposal (`budgetGateKind`), and every accepted edit for a file must compose
109
- * against that file's single pre-write image. Any of them failing writes nothing and
110
- * records no rejection.
178
+ * the full proposal (`budgetGateKind`), every accepted edit for a file must compose
179
+ * against that file's single pre-write image, every created skill target must still be
180
+ * absent, and accepted paths must resolve to distinct targets. Any of them failing writes
181
+ * nothing and records no rejection.
111
182
  *
112
183
  * A file is therefore applied all at once or not at all. Skills are written only after
113
184
  * every accepted edit has composed, and before the files that reference them.
@@ -197,33 +268,98 @@ export function applyDecisions({ proposal, decisions, repo, state, config, dryRu
197
268
  for (const id of applied) landed.add(id);
198
269
  }
199
270
 
271
+ const plannedTargets = new Map();
272
+ const resolvedPlanned = [];
273
+ for (const item of planned) {
274
+ let resolved;
275
+ try {
276
+ resolved = fs.realpathSync(item.absolute);
277
+ } catch (err) {
278
+ results.failed.push({ file: item.relative, error: `${item.relative} could not be resolved: ${err.message}` });
279
+ continue;
280
+ }
281
+ const existing = plannedTargets.get(resolved);
282
+ if (existing) {
283
+ results.failed.push({
284
+ file: item.relative,
285
+ error: `${item.relative} resolves to the same target as ${existing.relative}; nothing was written`,
286
+ });
287
+ continue;
288
+ }
289
+ const resolvedItem = { ...item, resolved };
290
+ plannedTargets.set(resolved, resolvedItem);
291
+ resolvedPlanned.push(resolvedItem);
292
+ }
293
+
200
294
  if (results.failed.length) return results;
201
295
 
202
296
  // Skills go in before the memory file. A skill nothing points at yet is inert, while a
203
297
  // memory file pointing at a skill that is not there is actively wrong - so if a skill
204
298
  // cannot be written, the files that would reference it are left alone.
205
- const skillFailures = [];
206
- const writtenSkillPaths = [];
299
+ const plannedSkills = [];
300
+ const skillPaths = new Set();
207
301
  for (const edit of accepted) {
208
- if (edit.kind !== "extract" || !edit.skill) continue;
209
- if (!landed.has(edit.id)) continue;
302
+ if (edit.kind !== "extract" || !landed.has(edit.id)) continue;
303
+ for (const skill of editSkills(edit)) {
304
+ const absolute = path.join(repo.root, skill.path);
305
+ if (skillPaths.has(skill.path) || fs.existsSync(absolute)) {
306
+ results.failed.push({
307
+ file: skill.path,
308
+ edit: edit.id,
309
+ error: `${skill.path} already exists; nothing was written`,
310
+ });
311
+ }
312
+ skillPaths.add(skill.path);
313
+ plannedSkills.push({ edit, skill });
314
+ }
315
+ }
316
+ if (results.failed.length) return results;
317
+
318
+ const canonical = plannedSkills.find(
319
+ ({ skill }) => skill.path === CANONICAL_SKILLS_DIR || skill.path.startsWith(`${CANONICAL_SKILLS_DIR}/`),
320
+ );
321
+ const createdDirectoryCandidates = absentParentDirectories(repo.root, [
322
+ ...plannedSkills.map(({ skill }) => path.join(repo.root, skill.path)),
323
+ ...(canonical ? [path.join(repo.root, CLAUDE_SKILLS_LINK)] : []),
324
+ ]);
325
+ const skillFailures = [];
326
+ const ownedSkillPaths = [];
327
+ for (const { edit, skill } of plannedSkills) {
210
328
  try {
211
- const layout = dryRun ? { created: [], warnings: [] } : writeSkill(repo.root, edit.skill);
212
- results.skills.push({ path: edit.skill.path, dryRun, created: layout.created });
213
- if (!dryRun) writtenSkillPaths.push(edit.skill.path);
329
+ const layout = dryRun
330
+ ? { created: [], warnings: [] }
331
+ : writeSkill(repo.root, skill, { exclusive: true, ensureLayout: false });
332
+ results.skills.push({ path: skill.path, dryRun, created: layout.created });
333
+ if (!dryRun) {
334
+ ownedSkillPaths.push(...("ownership" in layout && Array.isArray(layout.ownership) ? layout.ownership : []));
335
+ }
214
336
  for (const w of layout.warnings) if (!results.warnings.includes(w)) results.warnings.push(w);
215
337
  } catch (err) {
216
- skillFailures.push({ file: edit.skill.path, edit: edit.id, error: err.message });
338
+ skillFailures.push({ file: skill.path, edit: edit.id, error: err.message });
217
339
  }
218
340
  }
219
341
 
220
- if (skillFailures.length) {
221
- results.failed.push(...skillFailures);
222
- if (writtenSkillPaths.length) {
342
+ const rollbackSkills = () => {
343
+ const { removed, conflicts } = removeOwnedSkillPaths(ownedSkillPaths);
344
+ removeEmptyDirectories(createdDirectoryCandidates);
345
+ results.skills = [];
346
+ const removedPaths = removed.map((item) => item.relative).filter(Boolean);
347
+ if (removedPaths.length) {
223
348
  results.failed.push({
224
- error: `skill paths already written in this round: ${writtenSkillPaths.join(", ")}; remove them before retrying`,
349
+ error: `rolled back skill paths written earlier in this round: ${removedPaths.join(", ")}`,
225
350
  });
226
351
  }
352
+ for (const item of conflicts) {
353
+ results.failed.push({
354
+ file: item.relative,
355
+ error: `${item.relative} rollback conflict: the skill changed after this apply wrote it; left untouched`,
356
+ });
357
+ }
358
+ };
359
+
360
+ if (skillFailures.length) {
361
+ results.failed.push(...skillFailures);
362
+ rollbackSkills();
227
363
  for (const { relative } of planned) {
228
364
  results.failed.push({
229
365
  file: relative,
@@ -233,16 +369,69 @@ export function applyDecisions({ proposal, decisions, repo, state, config, dryRu
233
369
  return results;
234
370
  }
235
371
 
236
- for (const { relative, absolute, before, text, applied } of planned) {
372
+ const orderedPlanned = [...resolvedPlanned].sort((a, b) => {
373
+ const aMemory = a.relative === proposal.memoryFile.path;
374
+ const bMemory = b.relative === proposal.memoryFile.path;
375
+ return Number(aMemory) - Number(bMemory);
376
+ });
377
+ const committed = [];
378
+ const rollbackCommitted = () => {
379
+ for (const written of [...committed].reverse()) {
380
+ if (!commitStillCurrent(written.commit)) {
381
+ results.failed.push({
382
+ file: written.relative,
383
+ error: `${written.relative} rollback conflict: the file changed after this apply wrote it; left untouched`,
384
+ });
385
+ continue;
386
+ }
387
+ try {
388
+ atomicReplace(written.commit.absolute, written.before);
389
+ } catch (rollbackError) {
390
+ results.failed.push({
391
+ file: written.relative,
392
+ error: `${written.relative} could not be rolled back: ${rollbackError.message}`,
393
+ });
394
+ }
395
+ }
396
+ results.written = [];
397
+ };
398
+ for (const item of orderedPlanned) {
399
+ const { relative, resolved, before, text, applied } = item;
237
400
  const budget = relative === proposal.memoryFile.path ? budgetStatus(before, text, config.budgetTokens) : null;
238
401
 
239
- if (!dryRun) fs.writeFileSync(absolute, text);
402
+ let commit = null;
403
+ try {
404
+ if (!dryRun) commit = atomicReplace(resolved, text);
405
+ } catch (err) {
406
+ results.failed.push({
407
+ file: relative,
408
+ error: `${relative} could not be written: ${err.message}`,
409
+ });
410
+ rollbackCommitted();
411
+ rollbackSkills();
412
+ return results;
413
+ }
414
+ committed.push({ ...item, commit });
240
415
  results.written.push({ file: relative, edits: applied, budget, dryRun });
241
416
 
242
417
  // Shrinking over several runs is the design, so this is a heading, not a failure.
243
418
  if (budget && !budget.withinBudget) results.warnings.push(overBudgetWarning(relative, budget));
244
419
  }
245
420
 
421
+ if (!dryRun && canonical) {
422
+ try {
423
+ const layout = ensureSkillsLayout(repo.root);
424
+ const result = results.skills.find(({ path: skillPath }) => skillPath === canonical.skill.path);
425
+ result.created = [...new Set([...result.created, ...layout.created])];
426
+ for (const w of layout.warnings) if (!results.warnings.includes(w)) results.warnings.push(w);
427
+ } catch (err) {
428
+ results.failed.push({ file: CLAUDE_SKILLS_LINK, edit: canonical.edit.id, error: err.message });
429
+ rollbackCommitted();
430
+ rollbackSkills();
431
+ return results;
432
+ }
433
+ }
434
+
246
435
  // Rejections are remembered so the same edit is not re-proposed without new evidence.
247
436
  if (!dryRun && rejected.length) {
248
437
  const rejections = state.readRejections();
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
 
@@ -1,7 +1,8 @@
1
1
  import { analyzeTranscripts } from "../analyze.js";
2
2
  import { applyDecisions, writeBootstrapFiles } from "../apply/writer.js";
3
3
  import { bootstrapTargets, renderPointer, starterMemoryFile } from "../bootstrap.js";
4
- import { color, info, json, out, warn } from "../logger.js";
4
+ import { UserError, color, info, json, out, warn } from "../logger.js";
5
+ import { resolveMemoryFiles } from "../memory.js";
5
6
  import { emitProgress } from "../progress.js";
6
7
  import { ProposalViolation } from "../proposal.js";
7
8
  import { synthesizeProposal } from "../synthesize.js";
@@ -52,14 +53,25 @@ export async function bootstrapRun(ctx, deps = {}) {
52
53
  const seed = [{ path: canonical, text: starter.text }];
53
54
  if (pointer) seed.push({ path: pointer, text: renderPointer(canonical) });
54
55
  const seeded = writeBootstrapFiles(repo.root, seed);
56
+ const resolved = resolveMemoryFiles(repo.root, config.memoryFiles);
55
57
  for (const w of seeded.written) info(`${color.green("·")} wrote ${w.file}`);
56
58
  for (const s of seeded.skipped) warn(`${s.file} ${s.reason} - left untouched`);
57
59
 
60
+ const canonicalSkipped = seeded.skipped.some((entry) => entry.file === canonical);
61
+ if (canonicalSkipped || resolved.primary?.path !== starter.path || resolved.primary?.text !== starter.text) {
62
+ throw new UserError(
63
+ `${canonical} changed while backpass was bootstrapping it`,
64
+ "run `backpass` again to analyze the memory file that now exists",
65
+ );
66
+ }
67
+ const memoryFile = resolved.primary;
68
+ const memoryHash = resolved.hash;
69
+
58
70
  emitProgress("memory", {
59
- path: starter.path,
60
- tokens: starter.tokens,
71
+ path: memoryFile.path,
72
+ tokens: memoryFile.tokens,
61
73
  budget: config.budgetTokens,
62
- units: starter.units.length,
74
+ units: memoryFile.units.length,
63
75
  });
64
76
 
65
77
  const result = {
@@ -78,10 +90,10 @@ export async function bootstrapRun(ctx, deps = {}) {
78
90
 
79
91
  result.summary = await analyze({
80
92
  transcripts,
81
- memoryFile: starter,
93
+ memoryFile,
82
94
  config,
83
95
  repo,
84
- memoryHash: starter.hash,
96
+ memoryHash,
85
97
  force: Boolean(ctx.flags.force),
86
98
  });
87
99
  info(
@@ -89,7 +101,7 @@ export async function bootstrapRun(ctx, deps = {}) {
89
101
  `${result.summary.skipped} too short · ${result.summary.failed} failed`,
90
102
  );
91
103
 
92
- const folded = await foldForRun(ctx, starter);
104
+ const folded = await foldForRun(ctx, memoryFile, memoryHash);
93
105
  config.state.writeSummary(folded);
94
106
  emitProgress("fold:done", {
95
107
  instructions: folded.instructions.length,
@@ -102,7 +114,7 @@ export async function bootstrapRun(ctx, deps = {}) {
102
114
 
103
115
  try {
104
116
  const { proposal } = await synthesize({
105
- memoryFile: starter,
117
+ memoryFile,
106
118
  summary: folded,
107
119
  config,
108
120
  repo,
@@ -15,11 +15,21 @@ import { discoverForRun } from "./scan.js";
15
15
  * prune what the current file now covers or what aged out (after recording, because the
16
16
  * evidence files that fed an expired sighting are still on disk and would re-add it),
17
17
  * then cluster from the ledger.
18
+ *
19
+ * Evidence is also filtered to `memoryHash`: a transcript's evidence file is rewritten
20
+ * every time it is re-analyzed against a changed memory file, but a transcript that fell
21
+ * out of this run's sample (window, cap, or discovery drift) leaves its last evidence file
22
+ * on disk under whatever hash it was last judged against. That leftover file is real and
23
+ * reusable the moment its transcript is re-analyzed - or immediately, if the memory file's
24
+ * bytes return to that hash - but folding it into *this* proposal would score it against
25
+ * an instruction index it was never judged against (aliases are positional) and inflate
26
+ * `analyzedSessions` with a session this run never touched. Nothing is migrated, rewritten,
27
+ * or deleted here - only excluded from this run's fold.
18
28
  */
19
- export async function foldForRun(ctx, memoryFile) {
29
+ export async function foldForRun(ctx, memoryFile, memoryHash) {
20
30
  const { state, minGapEvidence, gapLedgerMaxAge } = ctx.config;
21
31
  const evidence = state.listEvidence();
22
- const relevant = evidence.filter((e) => e.memoryPath === memoryFile.path);
32
+ const relevant = evidence.filter((e) => e.memoryPath === memoryFile.path && e.memoryHash === memoryHash);
23
33
 
24
34
  const ledger = state.readGapLedger();
25
35
  recordGapObservations(ledger, relevant);
@@ -35,11 +45,15 @@ export async function foldForRun(ctx, memoryFile) {
35
45
 
36
46
  export async function runProposal(ctx, precomputed = null) {
37
47
  const { repo, config } = ctx;
38
- const { file } = precomputed || primaryMemoryFile(repo, config);
48
+ // Starting a new proposal run invalidates the previous result immediately. Discovery,
49
+ // folding, and agent resolution can all fail before synthesis starts; none of those
50
+ // failures may leave an older proposal available to apply as if it came from this run.
51
+ config.state.clearProposal();
52
+ const { file, hash } = precomputed || primaryMemoryFile(repo, config);
39
53
  const transcripts = precomputed?.transcripts || (await discoverForRun(ctx)).transcripts;
40
54
 
41
55
  const foldStarted = Date.now();
42
- const summary = await foldForRun(ctx, file);
56
+ const summary = await foldForRun(ctx, file, hash);
43
57
  config.state.writeSummary(summary);
44
58
  emitProgress("fold:done", {
45
59
  instructions: summary.instructions.length,
@@ -113,6 +127,60 @@ export function printProposal(proposal, { applied = false, analysisUsage = [] }
113
127
  out("Review and apply with `backpass apply` (nothing has been written).");
114
128
  }
115
129
 
130
+ /** True when a violation is about the always-loaded budget rather than the annotation. */
131
+ const isBudgetViolation = (v) => /-token budget/.test(v);
132
+ const isEditCapViolation = (v) => /per-run cap is \d+/.test(v);
133
+
134
+ /**
135
+ * What to actually try next, read off the condition the run ended on.
136
+ *
137
+ * The old advice - a stronger model, a bigger budget, a higher edit cap - was printed for
138
+ * every failure, including the ones where the model never spoke and the ones where the
139
+ * budget was never the constraint. Each terminal condition has a different repair.
140
+ */
141
+ export function synthesisFailureHint(err) {
142
+ if (err.reason === "empty") {
143
+ 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";
144
+ }
145
+ if (err.reason === "unparseable") {
146
+ return "the model answered but not with a JSON object; run `backpass propose` again, or pin a different harness with --synthesis-agent";
147
+ }
148
+ if (err.reason === "editing") {
149
+ return "the agent kept rewriting the staging copy instead of describing it; run `backpass propose` again to start fresh";
150
+ }
151
+ const violations = err.violations || [];
152
+ if (violations.some(isBudgetViolation)) {
153
+ return "the edit set did not clear the budget gate: raise --budget, or let the shrink continue over more runs";
154
+ }
155
+ if (violations.some(isEditCapViolation)) {
156
+ 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";
157
+ }
158
+ return "the gates above are what the next synthesis must satisfy; run `backpass propose` again";
159
+ }
160
+
161
+ /**
162
+ * Report a synthesis that ended without a valid proposal: loudly, and about the turn that
163
+ * actually ended it (design section 6).
164
+ *
165
+ * The saved proposal and the terminal condition can be from different turns - a run whose
166
+ * last turn was empty leaves the rejected proposal of an earlier one on disk - so the
167
+ * provenance is printed rather than letting the older violations read as this turn's.
168
+ */
169
+ export function printSynthesisFailure(err, state) {
170
+ info("");
171
+ for (const violation of err.violations) info(` ${color.red("x")} ${violation}`);
172
+ info("");
173
+ if (!err.saved) {
174
+ info(color.dim(" no proposal was saved: no annotation turn produced one"));
175
+ return;
176
+ }
177
+ info(color.dim(` the rejected proposal was saved to ${state.proposalPath}`));
178
+ if (err.reason !== "gates") {
179
+ info(color.dim(` it is from annotation attempt ${err.saved.attempt}, not the turn above, and it lists:`));
180
+ for (const violation of err.saved.violations) info(color.dim(` - ${violation}`));
181
+ }
182
+ }
183
+
116
184
  export async function cmdPropose(ctx) {
117
185
  try {
118
186
  const { proposal } = await runProposal(ctx);
@@ -124,12 +192,8 @@ export async function cmdPropose(ctx) {
124
192
  return 0;
125
193
  } catch (err) {
126
194
  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");
195
+ printSynthesisFailure(err, ctx.config.state);
196
+ throw new UserError(err.message, synthesisFailureHint(err));
133
197
  }
134
198
  throw err;
135
199
  }
@@ -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 {