backpass 0.1.7 → 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.
@@ -0,0 +1,277 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { UserError } from "./logger.js";
6
+
7
+ /**
8
+ * Invocation-scoped model and effort overlays (`src/acpx.js` is the caller).
9
+ *
10
+ * A one-off model or reasoning level must ride on this spawn only. Isolated Pi
11
+ * reproduction (throwaway `PI_CODING_AGENT_DIR`): RPC `set_model` /
12
+ * `set_thinking_level` rewrite `settings.json`; `pi --model` / `--thinking` at
13
+ * process start select the values for that process and leave defaults untouched.
14
+ * acpx `--model` after `session/new` is ACP `setSessionModel`, which is that
15
+ * persist path for Pi, so Pi never gets `--model` on acpx or a later `set`.
16
+ *
17
+ * How each invoked harness applies a requested overlay for this spawn:
18
+ * pi process argv via `PI_ACP_PI_COMMAND` wrapper: `--model`, `--thinking`
19
+ * grok process argv via acpx `--agent` wrapper: `-m`, `--reasoning-effort`
20
+ * claude acpx `--model` at `sessions new` (session/new `_meta`); ACP `set effort`
21
+ * codex acpx `--model` at `sessions new`; ACP `set reasoning_effort`
22
+ * opencode acpx `--model` at `sessions new`; no effort overlay (report, never pretend)
23
+ *
24
+ * No overlay requested means no wrapper, no `--model`, no `set` - same spawn as before.
25
+ * A requested overlay with no proven invocation-scoped mechanism throws rather than
26
+ * writing persistent defaults or silently ignoring the request. OpenCode effort is the
27
+ * sole explicit exception: it is skipped with a report note.
28
+ */
29
+
30
+ /** ACP session-config ids used only when that `set` is session-local, not a persist path. */
31
+ const SESSION_LOCAL_EFFORT_KEYS = { codex: "reasoning_effort", claude: "effort" };
32
+
33
+ /**
34
+ * @typedef {{
35
+ * env: Record<string, string> | undefined,
36
+ * acpxModel: string | null,
37
+ * setEffortKey: string | null,
38
+ * acpxAgentCommand: string | null,
39
+ * requiredBuiltinAgent: string | null,
40
+ * notes: string[],
41
+ * dispose: () => void,
42
+ * }} HarnessInvocation
43
+ */
44
+
45
+ /**
46
+ * @param {{ agent: string, model?: string | null, effort?: string | null }} options
47
+ * @returns {HarnessInvocation}
48
+ */
49
+ export function prepareHarnessInvocation({ agent, model = null, effort = null }) {
50
+ const notes = [];
51
+ const cleanups = [];
52
+ const dispose = () => {
53
+ for (const fn of cleanups.splice(0)) {
54
+ try {
55
+ fn();
56
+ } catch {
57
+ // Temp wrappers are best-effort to remove.
58
+ }
59
+ }
60
+ };
61
+
62
+ const requestedModel = typeof model === "string" && model.trim() ? model.trim() : null;
63
+ const requestedEffort = typeof effort === "string" && effort.trim() ? effort.trim() : null;
64
+
65
+ if (!requestedModel && !requestedEffort) {
66
+ return {
67
+ env: undefined,
68
+ acpxModel: null,
69
+ setEffortKey: null,
70
+ acpxAgentCommand: null,
71
+ requiredBuiltinAgent: null,
72
+ notes,
73
+ dispose,
74
+ };
75
+ }
76
+
77
+ try {
78
+ if (agent === "pi") return piInvocation({ requestedModel, requestedEffort, notes, cleanups, dispose });
79
+ if (agent === "grok") return grokInvocation({ requestedModel, requestedEffort, notes, cleanups, dispose });
80
+ if (agent === "claude" || agent === "codex") {
81
+ return {
82
+ env: undefined,
83
+ acpxModel: requestedModel,
84
+ setEffortKey: requestedEffort ? SESSION_LOCAL_EFFORT_KEYS[agent] : null,
85
+ acpxAgentCommand: null,
86
+ requiredBuiltinAgent: agent,
87
+ notes,
88
+ dispose,
89
+ };
90
+ }
91
+ if (agent === "opencode") {
92
+ if (requestedEffort) {
93
+ notes.push(`${agent} does not advertise a reasoning-effort option; ran without effort=${requestedEffort}`);
94
+ }
95
+ return {
96
+ env: undefined,
97
+ acpxModel: requestedModel,
98
+ setEffortKey: null,
99
+ acpxAgentCommand: null,
100
+ requiredBuiltinAgent: agent,
101
+ notes,
102
+ dispose,
103
+ };
104
+ }
105
+ throw new UserError(
106
+ `${agent} has no proven invocation-scoped way to apply ${describeOverride(requestedModel, requestedEffort)} without writing persistent harness defaults`,
107
+ "pin pi, claude, codex, grok, or opencode, or omit the model and effort override",
108
+ );
109
+ } catch (err) {
110
+ dispose();
111
+ throw err;
112
+ }
113
+ }
114
+
115
+ function describeOverride(model, effort) {
116
+ const bits = [];
117
+ if (model) bits.push(`model=${model}`);
118
+ if (effort) bits.push(`effort=${effort}`);
119
+ return bits.join(" and ");
120
+ }
121
+
122
+ function piInvocation({ requestedModel, requestedEffort, notes, cleanups, dispose }) {
123
+ if (process.env.PI_ACP_PI_COMMAND) {
124
+ throw new UserError(
125
+ "cannot safely apply Pi model or effort overrides when PI_ACP_PI_COMMAND replaces the proven Pi command",
126
+ "unset PI_ACP_PI_COMMAND or omit the model and effort override",
127
+ );
128
+ }
129
+ const extra = [];
130
+ if (requestedModel) extra.push("--model", requestedModel);
131
+ if (requestedEffort) extra.push("--thinking", requestedEffort);
132
+ const real = resolveOnPath("pi");
133
+ if (!real || (process.platform === "win32" && /\.(?:cmd|bat)$/i.test(real))) {
134
+ throw new UserError(
135
+ `cannot apply ${describeOverride(requestedModel, requestedEffort)} as safe Pi process arguments on this platform`,
136
+ "install Pi as a directly executable binary, or omit the model and effort override",
137
+ );
138
+ }
139
+ const { wrapperPath, dir } = writeArgvWrapper({ realCommand: real, extraArgs: extra, binName: "pi" });
140
+ cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
141
+ return {
142
+ env: { PI_ACP_PI_COMMAND: wrapperPath },
143
+ acpxModel: null,
144
+ setEffortKey: null,
145
+ acpxAgentCommand: null,
146
+ requiredBuiltinAgent: "pi",
147
+ notes,
148
+ dispose,
149
+ };
150
+ }
151
+
152
+ function grokInvocation({ requestedModel, requestedEffort, notes, cleanups, dispose }) {
153
+ if (process.platform === "win32") {
154
+ throw new UserError(
155
+ `cannot apply ${describeOverride(requestedModel, requestedEffort)} through acpx --agent on Windows`,
156
+ "pin pi, claude, codex, or opencode, or omit the model and effort override",
157
+ );
158
+ }
159
+ const extra = [];
160
+ if (requestedModel) extra.push("-m", requestedModel);
161
+ if (requestedEffort) extra.push("--reasoning-effort", requestedEffort);
162
+ extra.push("agent", "stdio");
163
+ const real = resolveOnPath("grok");
164
+ if (!real) {
165
+ throw new UserError(
166
+ `cannot apply ${describeOverride(requestedModel, requestedEffort)} as grok process flags because grok was not found on PATH`,
167
+ "install the grok CLI, or pin a different agent / omit the model and effort override",
168
+ );
169
+ }
170
+ const { nodeCommand, dir } = writeArgvWrapper({ realCommand: real, extraArgs: extra, binName: "grok" });
171
+ cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
172
+ return {
173
+ env: undefined,
174
+ acpxModel: null,
175
+ setEffortKey: null,
176
+ acpxAgentCommand: nodeCommand,
177
+ requiredBuiltinAgent: null,
178
+ notes,
179
+ dispose,
180
+ };
181
+ }
182
+
183
+ /**
184
+ * Write a node wrapper that prepends `extraArgs` and execs `realCommand` with
185
+ * inherited stdio. Args are JSON-encoded so values with spaces or `$()` stay literal.
186
+ *
187
+ * @param {{ realCommand: string, extraArgs: string[], binName: string }} options
188
+ */
189
+ function writeArgvWrapper({ realCommand, extraArgs, binName }) {
190
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-harness-wrap-"));
191
+ const scriptPath = path.join(dir, `${binName}.cjs`);
192
+ const payload = JSON.stringify({ real: realCommand, extra: extraArgs });
193
+ fs.writeFileSync(
194
+ scriptPath,
195
+ `#!/usr/bin/env node
196
+ const { spawn } = require("node:child_process");
197
+ const { real, extra } = ${payload};
198
+ const signals = ["SIGTERM", "SIGINT", "SIGHUP"];
199
+ let child = null;
200
+ let pendingSignal = null;
201
+ let escalationTimer = null;
202
+ const forwardSignal = (signal) => {
203
+ if (!child) {
204
+ pendingSignal ??= signal;
205
+ return;
206
+ }
207
+ if (child.exitCode !== null || child.signalCode !== null) return;
208
+ try { child.kill(signal); } catch {}
209
+ if (!escalationTimer) {
210
+ escalationTimer = setTimeout(() => {
211
+ if (child.exitCode === null && child.signalCode === null) {
212
+ try { child.kill("SIGKILL"); } catch {}
213
+ }
214
+ }, 4000);
215
+ escalationTimer.unref();
216
+ }
217
+ };
218
+ const signalHandlers = new Map(signals.map((signal) => [signal, () => forwardSignal(signal)]));
219
+ for (const [signal, handler] of signalHandlers) process.on(signal, handler);
220
+ child = spawn(real, extra.concat(process.argv.slice(2)), { stdio: "inherit" });
221
+ if (pendingSignal) forwardSignal(pendingSignal);
222
+ child.on("error", (err) => {
223
+ console.error(err.message);
224
+ process.exit(1);
225
+ });
226
+ child.on("exit", (code, signal) => {
227
+ if (escalationTimer) clearTimeout(escalationTimer);
228
+ for (const [name, handler] of signalHandlers) process.removeListener(name, handler);
229
+ if (signal) {
230
+ try {
231
+ process.kill(process.pid, signal);
232
+ return;
233
+ } catch {}
234
+ }
235
+ process.exit(code ?? 1);
236
+ });
237
+ `,
238
+ );
239
+ let wrapperPath = scriptPath;
240
+ if (process.platform === "win32") {
241
+ wrapperPath = path.join(dir, `${binName}.cmd`);
242
+ fs.writeFileSync(wrapperPath, `@echo off\r\n${cmdQuote(process.execPath)} ${cmdQuote(scriptPath)} %*\r\n`);
243
+ } else {
244
+ fs.chmodSync(scriptPath, 0o755);
245
+ }
246
+ return { wrapperPath, nodeCommand: renderCommandArgv([process.execPath, scriptPath]), dir };
247
+ }
248
+
249
+ function renderCommandArgv(argv) {
250
+ return argv.map((arg) => `'${arg.replaceAll("'", "'\\''")}'`).join(" ");
251
+ }
252
+
253
+ function cmdQuote(value) {
254
+ return `"${value.replaceAll("%", "%%").replaceAll('"', '""')}"`;
255
+ }
256
+
257
+ function resolveOnPath(bin) {
258
+ if (path.isAbsolute(bin) && isExecutable(bin)) return bin;
259
+ const extensions = process.platform === "win32" ? (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";") : [""];
260
+ for (const dir of (process.env.PATH || "").split(path.delimiter)) {
261
+ if (!dir) continue;
262
+ for (const extension of extensions) {
263
+ const candidate = path.join(dir, process.platform === "win32" ? `${bin}${extension.toLowerCase()}` : bin);
264
+ if (isExecutable(candidate)) return candidate;
265
+ }
266
+ }
267
+ return null;
268
+ }
269
+
270
+ function isExecutable(file) {
271
+ try {
272
+ const st = fs.statSync(file);
273
+ return st.isFile() && (process.platform === "win32" || Boolean(st.mode & 0o111));
274
+ } catch {
275
+ return false;
276
+ }
277
+ }
@@ -0,0 +1,19 @@
1
+ You are joining a synthesis run already in progress, so read this before the changes.
2
+
3
+ An earlier session edited a staging copy of `{{MEMORY_PATH}}` for the {{REPO_NAME}} repository.
4
+ **The edits below are already made.** Your fresh session did not make them and may not have seen
5
+ prior annotation attempts. Your job is only to describe them - do not redo them, do not start
6
+ over, and do not revert anything you would have written differently. If a change genuinely
7
+ cannot ship, revert that one change in the staging copy first and say so.
8
+
9
+ - Repository: `{{REPO_ROOT}}` (read it by absolute path when a change needs grounding)
10
+ - Memory file under review: `{{MEMORY_PATH}}` - {{CURRENT_TOKENS}} tokens, {{BUDGET_STATE}}
11
+ - Staging copy you are working in: `{{WORKSPACE_ROOT}}`
12
+
13
+ Every edit you report needs a verbatim quote from the evidence below, so read it first.
14
+
15
+ ## Evidence from {{TRANSCRIPT_COUNT}} analyzed session(s)
16
+
17
+ {{EVIDENCE}}
18
+
19
+ ---
@@ -1,5 +1,5 @@
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.
1
+ {{PREFACE}}backpass measured the changes in the staging copy of {{MEMORY_PATH}}. Every change below
2
+ is identified by an id; annotate each one so a human can review it with its evidence.
3
3
 
4
4
  ## Measured changes
5
5
 
@@ -39,10 +39,15 @@ Hard rules - a violation fails the whole proposal:
39
39
  4. **New instructions need evidence from at least {{MIN_GAP_EVIDENCE}} distinct
40
40
  sessions.** `transcripts` is how many distinct sessions back the edit; an edit that
41
41
  only adds text is a new instruction whatever its `kind` says.
42
- 5. `kind: "extract"` is 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.
42
+ 5. `kind: "extract"` is an edit whose changes are one or more created `SKILL.md` files
43
+ plus the change(s) to {{MEMORY_PATH}} that pay for them. One skill per extract is the
44
+ normal shape. Several skills belong in ONE extract exactly when their removals landed
45
+ in a **single** measured change: adjacent removals are merged into one change, and a
46
+ merged change cannot be accepted in halves. If each skill has its own measured change,
47
+ give each its own extract. Any other kind must not include a created file, and an edit
48
+ changes one file only.
45
49
  6. **Budget:** {{BUDGET_RULE}}
46
50
 
47
51
  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.
52
+ re-measures after this reply and shows you the new ids if anything moved. Re-measuring
53
+ does not use up an annotation attempt.
package/src/proposal.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { renderHunkLines } from "./diff.js";
2
+ import { editSkills } from "./skills.js";
2
3
  import { budgetGateKind, budgetStatus, estimateTokens } from "./tokens.js";
3
4
 
4
5
  /**
@@ -14,7 +15,7 @@ import { budgetGateKind, budgetStatus, estimateTokens } from "./tokens.js";
14
15
  * add only inserts text (gated like a new instruction)
15
16
  * remove only deletes text
16
17
  * rewrite replaces text
17
- * extract memory-file change(s) + one created SKILL.md
18
+ * extract memory-file change(s) + the created SKILL.md file(s) they pay for
18
19
  *
19
20
  * Each hunk carries a `find`/`replace` pair copied out of the original file by
20
21
  * construction; `find` occurs exactly once there. That is what the writer applies later,
@@ -44,11 +45,30 @@ export function effectiveMaxEdits(memoryFile, config) {
44
45
  return Math.min(SHRINK_MAX_EDITS, Math.max(DEFAULT_MAX_EDITS, Math.ceil(overage / SHRINK_EDIT_TOKENS)));
45
46
  }
46
47
 
48
+ /**
49
+ * A synthesis run that ended without a valid proposal, carrying *why* it ended.
50
+ *
51
+ * `reason` is the terminal condition - "gates", "empty", "unparseable", "editing" - and
52
+ * `saved` is the last parseable-but-gated proposal written to disk, if any, with the
53
+ * annotation attempt that produced it. They are separate because they can disagree: a run
54
+ * whose last turn was empty still leaves an older rejected proposal on disk, and reporting
55
+ * that proposal's violations as the empty turn's result is how a run gets diagnosed wrong.
56
+ */
47
57
  export class ProposalViolation extends Error {
48
- constructor(message, violations) {
58
+ /**
59
+ * @param {string} message
60
+ * @param {string[]} violations
61
+ * @param {{ reason?: string, attempts?: number,
62
+ * saved?: { attempt: number, violations: string[] } | null, proposalPath?: string | null }} [detail]
63
+ */
64
+ constructor(message, violations, detail = {}) {
49
65
  super(message);
50
66
  this.name = "ProposalViolation";
51
67
  this.violations = violations;
68
+ this.reason = detail.reason || "gates";
69
+ this.attempts = detail.attempts ?? 0;
70
+ this.saved = detail.saved || null;
71
+ this.proposalPath = detail.proposalPath || null;
52
72
  }
53
73
  }
54
74
 
@@ -265,18 +285,29 @@ export function buildProposal(rawResult, context) {
265
285
  continue;
266
286
  }
267
287
  if (edit.kind === "extract") {
268
- if (created.length !== 1 || !hunks.length || files[0] !== memoryFile.path) {
288
+ if (!created.length || !hunks.length || files[0] !== memoryFile.path) {
269
289
  violations.push(
270
- `edit ${edit.id}: kind "extract" must group exactly one created SKILL.md with change(s) to ${memoryFile.path}`,
290
+ `edit ${edit.id}: kind "extract" must group created SKILL.md file(s) with change(s) to ${memoryFile.path}`,
271
291
  );
272
292
  continue;
273
293
  }
274
- if (!created[0].skill) {
294
+ // Several skills may share one extract only when their removals were merged into a
295
+ // single measured change - a merged change cannot be accepted in halves, so that
296
+ // grouping is the measurement's, not the model's. Skills whose removals were measured
297
+ // separately stay separately decidable.
298
+ if (created.length > 1 && hunks.length > 1) {
275
299
  violations.push(
276
- `edit ${edit.id}: ${created[0].file} needs YAML frontmatter with \`name:\` and \`description:\``,
300
+ `edit ${edit.id}: groups ${created.length} created skills against ${hunks.length} separate changes to ` +
301
+ `${memoryFile.path} (${hunks.map((h) => h.id).join(", ")}); give each skill its own extract, or group ` +
302
+ `several skills only when they share one measured change`,
277
303
  );
278
304
  continue;
279
305
  }
306
+ const unusable = created.find((c) => !c.skill);
307
+ if (unusable) {
308
+ violations.push(`edit ${edit.id}: ${unusable.file} needs YAML frontmatter with \`name:\` and \`description:\``);
309
+ continue;
310
+ }
280
311
  } else if (created.length) {
281
312
  violations.push(`edit ${edit.id}: only kind "extract" may include a created file (${created[0].id})`);
282
313
  continue;
@@ -302,7 +333,7 @@ export function buildProposal(rawResult, context) {
302
333
  instructions: edit.instructions,
303
334
  evidence: edit.evidence,
304
335
  transcripts: edit.transcripts,
305
- skill: created[0]?.skill || null,
336
+ skills: created.map((c) => c.skill),
306
337
  hunks: hunks.map((h) => ({
307
338
  id: h.id,
308
339
  find: h.find,
@@ -394,7 +425,7 @@ export function buildProposal(rawResult, context) {
394
425
  positive: summary?.totals?.positive ?? 0,
395
426
  negative: summary?.totals?.negative ?? 0,
396
427
  gapClusters: summary?.totals?.gapClusters ?? 0,
397
- skillExtractions: accepted.filter((e) => e.kind === "extract").length,
428
+ skillExtractions: accepted.reduce((n, e) => n + editSkills(e).length, 0),
398
429
  },
399
430
  edits: accepted,
400
431
  verdicts: Array.isArray(rawResult?.verdicts) ? rawResult.verdicts : [],
package/src/skills.js CHANGED
@@ -1,5 +1,6 @@
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 { estimateTokens } from "./tokens.js";
5
6
 
@@ -103,20 +104,36 @@ export function renderSkillFile(skill) {
103
104
  return `---\n${frontmatter}\n---\n\n${skill.body.trim()}\n`;
104
105
  }
105
106
 
107
+ /**
108
+ * The skills one extract creates.
109
+ *
110
+ * Usually one. Several arrive together when the measurement merged their removals into a
111
+ * single change (`anchoredHunks`), which makes them one accept/reject decision - accepting
112
+ * half a merged change is not a thing a file can do. Proposals written before that was
113
+ * possible carry a single `skill`, so both shapes read the same way here.
114
+ *
115
+ * @returns {object[]}
116
+ */
117
+ export function editSkills(edit) {
118
+ if (Array.isArray(edit?.skills)) return edit.skills.filter(Boolean);
119
+ return edit?.skill ? [edit.skill] : [];
120
+ }
121
+
106
122
  /**
107
123
  * The budget arithmetic that makes extraction worth it, reported per edit:
108
124
  * "-1,900 tok always-loaded, +140 tok description".
109
125
  */
110
126
  export function extractionBudgetEffect(edit) {
111
- if (edit.kind !== "extract" || !edit.skill) return null;
127
+ const skills = editSkills(edit);
128
+ if (edit.kind !== "extract" || !skills.length) return null;
112
129
  const pairs = Array.isArray(edit.hunks) ? edit.hunks : [edit];
113
130
  const removedFromMemory = pairs.reduce((sum, p) => sum + estimateTokens(p.find) - estimateTokens(p.replace), 0);
114
- const descriptionCost = estimateTokens(edit.skill.description);
131
+ const descriptionCost = skills.reduce((sum, skill) => sum + estimateTokens(skill.description), 0);
115
132
  return {
116
133
  alwaysLoadedDelta: -removedFromMemory,
117
134
  descriptionCost,
118
135
  net: descriptionCost - removedFromMemory,
119
- skillBodyTokens: estimateTokens(edit.skill.body),
136
+ skillBodyTokens: skills.reduce((sum, skill) => sum + estimateTokens(skill.body), 0),
120
137
  };
121
138
  }
122
139
 
@@ -169,6 +186,110 @@ function inspectClaudeSkillsLink(repoRoot) {
169
186
  return { state: "other" };
170
187
  }
171
188
 
189
+ function pathIdentity(stat) {
190
+ return { dev: stat.dev, ino: stat.ino };
191
+ }
192
+
193
+ function restoreQuarantinedPath(quarantine, destination) {
194
+ const stat = fs.lstatSync(quarantine);
195
+ if (stat.isDirectory()) {
196
+ // Restore the directory object itself. Recursively copying it can fail for valid
197
+ // entries such as Unix sockets and would turn restoration into a lossy clone. On
198
+ // POSIX, an empty directory created with mkdir is an atomic no-clobber reservation;
199
+ // rename may replace that reservation, but cannot replace it after another process
200
+ // has populated it.
201
+ if (process.platform === "win32") {
202
+ fs.renameSync(quarantine, destination);
203
+ return;
204
+ }
205
+
206
+ fs.mkdirSync(destination, { mode: stat.mode & 0o7777 });
207
+ try {
208
+ fs.renameSync(quarantine, destination);
209
+ } catch (err) {
210
+ try {
211
+ fs.rmdirSync(destination);
212
+ } catch {
213
+ // Another process populated the reservation. Preserve both directory trees.
214
+ }
215
+ throw err;
216
+ }
217
+ return;
218
+ }
219
+ if (stat.isSymbolicLink()) {
220
+ fs.symlinkSync(fs.readlinkSync(quarantine), destination);
221
+ fs.unlinkSync(quarantine);
222
+ return;
223
+ }
224
+ fs.linkSync(quarantine, destination);
225
+ fs.unlinkSync(quarantine);
226
+ }
227
+
228
+ export function removeOwnedSkillPaths(paths) {
229
+ const removed = [];
230
+ const conflicts = [];
231
+ for (const item of [...paths].reverse()) {
232
+ let stat;
233
+ try {
234
+ stat = fs.lstatSync(item.absolute);
235
+ } catch {
236
+ continue;
237
+ }
238
+ const observed = pathIdentity(stat);
239
+ if (observed.dev !== item.identity.dev || observed.ino !== item.identity.ino) {
240
+ conflicts.push(item);
241
+ continue;
242
+ }
243
+ if (item.text !== undefined) {
244
+ let text;
245
+ try {
246
+ text = fs.readFileSync(item.absolute, "utf8");
247
+ } catch {
248
+ conflicts.push(item);
249
+ continue;
250
+ }
251
+ if (text !== item.text) {
252
+ conflicts.push(item);
253
+ continue;
254
+ }
255
+ }
256
+
257
+ // Move the pathname out of the way atomically, then validate the object that was
258
+ // actually moved. A second pathname check followed by unlink would let a concurrent
259
+ // replacement slip between those operations and be deleted as if it were ours.
260
+ const quarantine = path.join(
261
+ path.dirname(item.absolute),
262
+ `.${path.basename(item.absolute)}.backpass-rollback-${randomUUID()}`,
263
+ );
264
+ let quarantined = false;
265
+ try {
266
+ fs.renameSync(item.absolute, quarantine);
267
+ quarantined = true;
268
+ const moved = pathIdentity(fs.lstatSync(quarantine));
269
+ const identityMatches = moved.dev === item.identity.dev && moved.ino === item.identity.ino;
270
+ const textMatches = item.text === undefined || fs.readFileSync(quarantine, "utf8") === item.text;
271
+ if (identityMatches && textMatches) {
272
+ fs.unlinkSync(quarantine);
273
+ removed.push(item);
274
+ continue;
275
+ }
276
+
277
+ restoreQuarantinedPath(quarantine, item.absolute);
278
+ conflicts.push(item);
279
+ } catch {
280
+ if (quarantined) {
281
+ try {
282
+ restoreQuarantinedPath(quarantine, item.absolute);
283
+ } catch {
284
+ fs.existsSync(quarantine);
285
+ }
286
+ }
287
+ conflicts.push(item);
288
+ }
289
+ }
290
+ return { removed, conflicts };
291
+ }
292
+
172
293
  /**
173
294
  * Create the canonical skills dir and the `.claude/skills -> ../.agents/skills` symlink
174
295
  * so both harness families load the same files with no duplication. An existing
@@ -197,11 +318,39 @@ export function ensureSkillsLayout(repoRoot) {
197
318
  }
198
319
 
199
320
  /** Write an accepted skill extraction to disk, setting up the load layout on first use. */
200
- export function writeSkill(repoRoot, skill) {
321
+ export function writeSkill(repoRoot, skill, { exclusive = false, ensureLayout = true } = {}) {
201
322
  const inCanonical = skill.path === CANONICAL_SKILLS_DIR || skill.path.startsWith(`${CANONICAL_SKILLS_DIR}/`);
202
- const layout = inCanonical ? ensureSkillsLayout(repoRoot) : { created: [], warnings: [] };
323
+ const layout = inCanonical && ensureLayout ? ensureSkillsLayout(repoRoot) : { created: [], warnings: [] };
324
+ const canonicalWasMissing = inCanonical && !fs.existsSync(path.join(repoRoot, CANONICAL_SKILLS_DIR));
203
325
  const target = path.join(repoRoot, skill.path);
204
326
  fs.mkdirSync(path.dirname(target), { recursive: true });
205
- fs.writeFileSync(target, renderSkillFile(skill));
206
- return { target, ...layout };
327
+ if (!ensureLayout && canonicalWasMissing) layout.created.push(CANONICAL_SKILLS_DIR);
328
+ const text = renderSkillFile(skill);
329
+ if (!exclusive) {
330
+ fs.writeFileSync(target, text);
331
+ return { target, ...layout };
332
+ }
333
+
334
+ let fd;
335
+ /** @type {{ absolute: string, identity: { dev: number, ino: number }, relative: string, text?: string }[] | undefined} */
336
+ let ownership;
337
+ try {
338
+ fd = fs.openSync(target, "wx");
339
+ ownership = [{ absolute: target, identity: pathIdentity(fs.fstatSync(fd)), relative: skill.path }];
340
+ fs.writeFileSync(fd, text);
341
+ fs.closeSync(fd);
342
+ fd = undefined;
343
+ ownership[0].text = text;
344
+ } catch (err) {
345
+ if (fd !== undefined) {
346
+ try {
347
+ fs.closeSync(fd);
348
+ } catch {
349
+ // Preserve the original write error.
350
+ }
351
+ }
352
+ removeOwnedSkillPaths(ownership ?? []);
353
+ throw err;
354
+ }
355
+ return { target, ...layout, ownership };
207
356
  }
package/src/state.js CHANGED
@@ -18,7 +18,7 @@ export const STATE_EXCLUDE_LINE = `${STATE_DIRNAME}/`;
18
18
  * scan-cache.json path+mtime+size -> association verdict (design section 2.2)
19
19
  * evidence/<id>.json per-transcript tier-1 analysis output (design section 3)
20
20
  * evidence-summary.json folded evidence (stage 2)
21
- * proposal.json latest tier-2 synthesis (stage 3)
21
+ * proposal.json latest parseable tier-2 synthesis; absent if none was produced (stage 3)
22
22
  * rejections.json edits the human rejected, and the evidence weight behind them
23
23
  * gap-ledger.json gap observations by gap and session, accumulated across runs (src/gap-ledger.js)
24
24
  * agent-probe-cache.json TTL'd availability/auth verdicts per agent|model (src/agents.js)
@@ -113,6 +113,10 @@ export class State {
113
113
  this.writeJsonFile(this.proposalPath, proposal);
114
114
  }
115
115
 
116
+ clearProposal() {
117
+ fs.rmSync(this.proposalPath, { force: true });
118
+ }
119
+
116
120
  readRejections() {
117
121
  const value = this.readJsonFile(this.rejectionsPath, null);
118
122
  return value && value.version === 1 ? value : { version: 1, entries: {} };