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.
- package/README.md +39 -15
- 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 +206 -19
- package/src/cli.js +3 -3
- package/src/commands/propose.js +60 -6
- package/src/commands/run.js +6 -7
- package/src/harness-invoke.js +277 -0
- 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/tui/index.js +12 -0
- package/src/tui/render.js +16 -0
- package/templates/apply.html +26 -16
|
@@ -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
|
+
---
|
package/src/prompts/annotate.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
backpass measured the changes
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
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) +
|
|
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
|
-
|
|
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
|
|
288
|
+
if (!created.length || !hunks.length || files[0] !== memoryFile.path) {
|
|
269
289
|
violations.push(
|
|
270
|
-
`edit ${edit.id}: kind "extract" must group
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
206
|
-
|
|
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: {} };
|