@cruxy/cli 1.11.1 → 1.11.3
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/dist/agent/instruction-loss.js +204 -0
- package/dist/agent/prompts.js +25 -4
- package/dist/agent/session.js +165 -33
- package/dist/agent/status.js +18 -0
- package/dist/checkpoint/service.js +44 -3
- package/dist/cli/commands/pr.js +14 -0
- package/dist/cli/commands/run.js +35 -0
- package/dist/cli/commands/sessions.js +8 -0
- package/dist/cli/session-commands.js +3 -1
- package/dist/cli/session-factory.js +54 -6
- package/dist/config/schema.js +9 -0
- package/dist/errors/constructors.js +15 -6
- package/dist/errors/types.js +7 -0
- package/dist/indexing/embedder.js +34 -11
- package/dist/indexing/model-cache.js +399 -0
- package/dist/mcp/bounds.js +8 -1
- package/dist/plan/execute.js +4 -1
- package/dist/plan/service.js +42 -5
- package/dist/plan/step-message.js +49 -0
- package/dist/render/context-view.js +44 -1
- package/dist/render/status-view.js +13 -0
- package/dist/session/index.js +7 -3
- package/dist/session/log.js +163 -2
- package/dist/session/owner.js +123 -0
- package/dist/session/prune.js +11 -0
- package/dist/session/recorded-runs.js +56 -0
- package/dist/session/replay.js +75 -1
- package/dist/session/resume.js +110 -3
- package/dist/session/types.js +158 -0
- package/dist/subagent/orchestrator.js +2 -2
- package/dist/subagent/registry-scope.js +28 -5
- package/dist/testing/run-tests-tool.js +3 -1
- package/dist/tools/create-pull-request.js +8 -1
- package/dist/tools/file/apply-patch.js +53 -23
- package/dist/tools/file/edit-file.js +19 -1
- package/dist/tools/file/snapshot.js +68 -0
- package/dist/tools/file/write-file.js +31 -5
- package/dist/tools/registry.js +39 -8
- package/dist/tools/schema-depth.js +79 -6
- package/dist/tools/shell/exec.js +7 -0
- package/dist/tools/shell/run-command.js +45 -21
- package/dist/utils/process-owner.js +107 -0
- package/dist/vcs/generate.js +48 -6
- package/dist/verification/index.js +15 -0
- package/dist/verification/ledger.js +99 -0
- package/dist/verification/types.js +26 -0
- package/dist/verification/view.js +87 -0
- package/package.json +3 -2
package/dist/session/resume.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { formatTokens } from "../render/state.js";
|
|
1
2
|
import { selectList } from "../components/index.js";
|
|
2
3
|
import { usageError } from "../errors/index.js";
|
|
3
4
|
import { listSessionRefs, listSessions, matchSessionRefs, summarizeSession, } from "./list.js";
|
|
4
5
|
import { replaySession } from "./replay.js";
|
|
6
|
+
import { describeHolder, ownerFile, sessionHeldBy } from "./owner.js";
|
|
5
7
|
/** How many sessions the bare-`--resume` picker offers. */
|
|
6
8
|
export const PICKER_LIMIT = 10;
|
|
7
9
|
/** Short, stable id form — enough to identify a session, short enough to type. */
|
|
@@ -75,6 +77,18 @@ export function priorDirectoriesWarning(state, cwd) {
|
|
|
75
77
|
* worth failing loudly on, unlike an individual torn line.
|
|
76
78
|
*/
|
|
77
79
|
export function loadResume(session, cwd) {
|
|
80
|
+
// OWNERSHIP (P1). Asked BEFORE the replay, and answered by the OS rather
|
|
81
|
+
// than by a file's presence: a stamp whose process is gone is not a holder.
|
|
82
|
+
// Two processes appending to one log produce a transcript that replays
|
|
83
|
+
// cleanly and means nothing, so this is a refusal, not a warning.
|
|
84
|
+
const holder = sessionHeldBy(session.file);
|
|
85
|
+
if (holder) {
|
|
86
|
+
throw usageError(`session ${shortId(session.sessionId)} is open in another cruxy (${describeHolder(holder)})`, [
|
|
87
|
+
"continue it there, or quit that cruxy and resume here",
|
|
88
|
+
"start a new session with `cruxy`",
|
|
89
|
+
`if that process is not a cruxy, remove ${ownerFile(session.file)}`,
|
|
90
|
+
]);
|
|
91
|
+
}
|
|
78
92
|
let state;
|
|
79
93
|
try {
|
|
80
94
|
state = replaySession(session.file);
|
|
@@ -114,8 +128,95 @@ export function loadResume(session, cwd) {
|
|
|
114
128
|
warnings.push(`this session was redacted ${state.redactions === 1 ? "once" : `${state.redactions} times`} — ` +
|
|
115
129
|
`secrets are masked in the restored history, but the original text is still in ${session.file}`);
|
|
116
130
|
}
|
|
131
|
+
if (state.externalChanges > 0) {
|
|
132
|
+
// A refused write is a fact about the user's TREE, not about cruxy: while
|
|
133
|
+
// they were being asked to approve a change, something else wrote to the
|
|
134
|
+
// file. It reached the model as an error at the time; on resume it is
|
|
135
|
+
// said once more, because "was my tree written to by something else"
|
|
136
|
+
// is a question people ask after the fact.
|
|
137
|
+
warnings.push(`${state.externalChanges} write${state.externalChanges === 1 ? " was" : "s were"} refused in this session ` +
|
|
138
|
+
`because the file changed on disk while awaiting approval — nothing was written by cruxy; ` +
|
|
139
|
+
`the session log names each path`);
|
|
140
|
+
}
|
|
141
|
+
if (state.compactions.count > 0) {
|
|
142
|
+
// What compaction has cost this conversation (P3 context quality), said
|
|
143
|
+
// once on resume because it is the moment a user decides whether to
|
|
144
|
+
// continue in a history that has been summarised N times. The freed and
|
|
145
|
+
// spent figures cover only the compactions that recorded them; the clause
|
|
146
|
+
// saying so is not optional when they differ.
|
|
147
|
+
warnings.push(describeCompactions(state.compactions));
|
|
148
|
+
}
|
|
149
|
+
if (state.compactions.instructionLosses > 0) {
|
|
150
|
+
// The heuristic's finding, restated where it can still be acted on: the
|
|
151
|
+
// sentences are in the session log; the remedy is to say them again.
|
|
152
|
+
const n = state.compactions.instructionLosses;
|
|
153
|
+
warnings.push(`${n} compaction${n === 1 ? "" : "s"} in this session may have dropped an instruction you gave — ` +
|
|
154
|
+
`the session log names the sentences; restate anything that still applies`);
|
|
155
|
+
}
|
|
156
|
+
if (state.lastVerification) {
|
|
157
|
+
// Not a warning in spirit, but this is the one channel a resume has, and
|
|
158
|
+
// the sentence is the same one `/status` shows: what last ran and how it
|
|
159
|
+
// exited, dated. A resumed session's "the tests passed" is a claim about
|
|
160
|
+
// the tree as it was THEN; the age is what keeps it honest.
|
|
161
|
+
const v = state.lastVerification;
|
|
162
|
+
const exit = v.exitCode === null ? "no exit code" : `exit ${v.exitCode}`;
|
|
163
|
+
warnings.push(`last verification ${relativeAge(v.at)}: ${v.tool} \`${v.command}\` → ${exit}`);
|
|
164
|
+
}
|
|
165
|
+
if (state.plan) {
|
|
166
|
+
// The last approved plan and where it got to (plan-durability). Same
|
|
167
|
+
// channel, same reasoning as the verification line: a fact the user
|
|
168
|
+
// decides on before continuing. The sentence ends by saying the approval
|
|
169
|
+
// is not carried over, because the one thing a reader of "approved" might
|
|
170
|
+
// reasonably expect is that the consent came back with the session.
|
|
171
|
+
warnings.push(describePlan(state.plan));
|
|
172
|
+
}
|
|
117
173
|
return { session, state, warnings };
|
|
118
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* One sentence for the last approved plan (plan-durability): when it was
|
|
177
|
+
* approved and how, how many steps finished, which failed, and — the fact a
|
|
178
|
+
* resume exists to surface — whether a step was still running when the
|
|
179
|
+
* session ended. Says in so many words that the approval is not carried over:
|
|
180
|
+
* `[g]`'s grants lived in the allowlist of the process that asked, and this
|
|
181
|
+
* process has not asked.
|
|
182
|
+
*/
|
|
183
|
+
export function describePlan(p, now = Date.now()) {
|
|
184
|
+
const n = p.steps.length;
|
|
185
|
+
const count = (status) => p.steps.filter((s) => s.status === status).length;
|
|
186
|
+
const done = count("done");
|
|
187
|
+
const failed = count("failed");
|
|
188
|
+
const pending = count("pending");
|
|
189
|
+
const running = p.steps.find((s) => s.status === "running");
|
|
190
|
+
const how = p.decision === "approve-grant"
|
|
191
|
+
? "with safe steps auto-allowed"
|
|
192
|
+
: "step by step";
|
|
193
|
+
const parts = [`${done}/${n} step${n === 1 ? "" : "s"} done`];
|
|
194
|
+
if (failed > 0)
|
|
195
|
+
parts.push(`${failed} failed`);
|
|
196
|
+
if (running) {
|
|
197
|
+
parts.push(`step ${running.id} (${running.title}) was still running when the session ended`);
|
|
198
|
+
}
|
|
199
|
+
if (pending > 0)
|
|
200
|
+
parts.push(`${pending} never started`);
|
|
201
|
+
return (`last plan approved ${relativeAge(p.approvedAt, now)} ${how}: ${parts.join(", ")} — ` +
|
|
202
|
+
`that approval is not carried into this session; every action asks again`);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* One sentence for what compaction has cost a session (P3 context quality).
|
|
206
|
+
* Shared by the resume notice and nothing else; `/context` and the one-shot
|
|
207
|
+
* summary render the same tally as lines (`render/context-view.ts`).
|
|
208
|
+
*/
|
|
209
|
+
export function describeCompactions(c) {
|
|
210
|
+
const times = `compacted ${c.count === 1 ? "once" : `${c.count} times`} in this session`;
|
|
211
|
+
if (c.measured === 0) {
|
|
212
|
+
return `${times} — cost not recorded (written before it was measured)`;
|
|
213
|
+
}
|
|
214
|
+
const scope = c.measured < c.count
|
|
215
|
+
? ` (${c.count - c.measured} of them recorded no cost)`
|
|
216
|
+
: "";
|
|
217
|
+
return (`${times} — freed ~${formatTokens(c.freedTokens)} tokens; ` +
|
|
218
|
+
`the summaries cost ~${formatTokens(c.summaryInputTokens)} in / ~${formatTokens(c.summaryOutputTokens)} out${scope}`);
|
|
219
|
+
}
|
|
119
220
|
/**
|
|
120
221
|
* VALIDATE `--resume <id>` — which session does this name? — without loading it.
|
|
121
222
|
*
|
|
@@ -189,9 +290,15 @@ export async function resumePicker(cwd, opts = {}) {
|
|
|
189
290
|
];
|
|
190
291
|
const picked = await selectList(rows, {
|
|
191
292
|
title: "resume a session",
|
|
192
|
-
toLabel: (row) =>
|
|
193
|
-
|
|
194
|
-
|
|
293
|
+
toLabel: (row) => {
|
|
294
|
+
if (row.kind === "new")
|
|
295
|
+
return "+ new session";
|
|
296
|
+
const label = describeSession(row.session, now);
|
|
297
|
+
// A row another live cruxy holds is offered — the user may want to
|
|
298
|
+
// see it — but says so, so picking it is not a surprise refusal.
|
|
299
|
+
const holder = sessionHeldBy(row.session.file);
|
|
300
|
+
return holder ? `${label} · open in another cruxy` : label;
|
|
301
|
+
},
|
|
195
302
|
// Non-interactive with no id named: starting fresh is the safe default,
|
|
196
303
|
// never an arbitrary session picked on the user's behalf.
|
|
197
304
|
defaultValue: { kind: "new" },
|
package/dist/session/types.js
CHANGED
|
@@ -154,6 +154,43 @@ export const CompactionEventSchema = z
|
|
|
154
154
|
replaced: z.number().int().nonnegative(),
|
|
155
155
|
/** What replaced them (the synthetic pair). */
|
|
156
156
|
summary: z.array(MessageSchema),
|
|
157
|
+
/**
|
|
158
|
+
* What the compaction COST (P3 context quality) — the four facts in hand
|
|
159
|
+
* at the write site and, until P3, thrown away there. All optional: a log
|
|
160
|
+
* written before P3 carries none, and absence means "not measured", never
|
|
161
|
+
* zero. Freed tokens is `estimatedBefore - estimatedAfter` and is NOT
|
|
162
|
+
* stored — one derivation, in the fold, rather than a fifth field that
|
|
163
|
+
* could disagree with the other two.
|
|
164
|
+
*
|
|
165
|
+
* The estimates are `estimateTokens` over the history — the same chars/4
|
|
166
|
+
* heuristic the trigger uses — WITHOUT the fixed reserve, which is a
|
|
167
|
+
* setting and would cancel out of the difference anyway.
|
|
168
|
+
*/
|
|
169
|
+
estimatedBefore: z.number().int().nonnegative().optional(),
|
|
170
|
+
estimatedAfter: z.number().int().nonnegative().optional(),
|
|
171
|
+
/**
|
|
172
|
+
* The summarize call's own usage, as the provider reported it. Absent
|
|
173
|
+
* when the provider reported nothing — the same honesty rule as the usage
|
|
174
|
+
* record: unknown is not zero.
|
|
175
|
+
*/
|
|
176
|
+
summaryInputTokens: z.number().int().nonnegative().optional(),
|
|
177
|
+
summaryOutputTokens: z.number().int().nonnegative().optional(),
|
|
178
|
+
})
|
|
179
|
+
.passthrough();
|
|
180
|
+
/**
|
|
181
|
+
* A compaction after which the user's instructions could not all be found in
|
|
182
|
+
* the synopsis (P3 context quality). Written by the heuristic in
|
|
183
|
+
* `agent/instruction-loss.ts`, which can be wrong both ways and says so; the
|
|
184
|
+
* event is what makes a suspected loss auditable after the fact instead of a
|
|
185
|
+
* warning that scrolled away. `sentences` are the user's own words, bounded
|
|
186
|
+
* and truncated — enough to recognise what may need restating.
|
|
187
|
+
*/
|
|
188
|
+
export const InstructionLossEventSchema = z
|
|
189
|
+
.object({
|
|
190
|
+
kind: z.literal("instruction-loss"),
|
|
191
|
+
at: z.string(),
|
|
192
|
+
runId: z.string().optional(),
|
|
193
|
+
sentences: z.array(z.string()),
|
|
157
194
|
})
|
|
158
195
|
.passthrough();
|
|
159
196
|
/** `/clear`: history dropped, session kept. Replay resets to an empty array. */
|
|
@@ -283,6 +320,111 @@ export const ResumedEventSchema = z
|
|
|
283
320
|
cliVersion: z.string().optional(),
|
|
284
321
|
})
|
|
285
322
|
.passthrough();
|
|
323
|
+
/**
|
|
324
|
+
* One run that ACTUALLY EXECUTED (P2 verification): `run_tests`, or a
|
|
325
|
+
* `run_command` the model chose. An index over what happened, keyed to the
|
|
326
|
+
* turn it happened in — NOT a copy of the run's output. The `append` event
|
|
327
|
+
* already holds the tool_result the model saw, as the string built for the
|
|
328
|
+
* model; this is the structured claim beside it, written from the same
|
|
329
|
+
* object the tool built rather than re-parsed from that string (the same
|
|
330
|
+
* reasoning as the `run_tests` renderer side channel, P3).
|
|
331
|
+
*
|
|
332
|
+
* `passed` is the exit code and nothing else. `tool` and `source` are plain
|
|
333
|
+
* strings so a newer build's tool or provenance parses here instead of
|
|
334
|
+
* poisoning the line. A refused attempt, a denied approval or a spawn failure
|
|
335
|
+
* ran nothing, so no event is written for it — writing one would be a
|
|
336
|
+
* fabrication.
|
|
337
|
+
*
|
|
338
|
+
* Evidence, not enforcement: nothing reads this to decide whether a turn may
|
|
339
|
+
* end. See `verification/types.ts` for why that decision stands.
|
|
340
|
+
*/
|
|
341
|
+
export const VerificationEventSchema = z
|
|
342
|
+
.object({
|
|
343
|
+
kind: z.literal("verification"),
|
|
344
|
+
at: z.string(),
|
|
345
|
+
runId: z.string().optional(),
|
|
346
|
+
tool: z.string(),
|
|
347
|
+
command: z.string(),
|
|
348
|
+
source: z.string().optional(),
|
|
349
|
+
passed: z.boolean(),
|
|
350
|
+
exitCode: z.number().int().nullable(),
|
|
351
|
+
durationMs: z.number().nonnegative(),
|
|
352
|
+
total: z.number().int().nonnegative().optional(),
|
|
353
|
+
failureCount: z.number().int().nonnegative().default(0),
|
|
354
|
+
failureNames: z.array(z.string()).default([]),
|
|
355
|
+
outputTruncated: z.boolean().default(false),
|
|
356
|
+
substrate: z.string(),
|
|
357
|
+
})
|
|
358
|
+
.passthrough();
|
|
359
|
+
/**
|
|
360
|
+
* A write refused because its target moved during the approval wait (P1).
|
|
361
|
+
*
|
|
362
|
+
* The refusal itself is an error string handed to the model; before this
|
|
363
|
+
* event the user learned of it only by opening a rollback preview later.
|
|
364
|
+
* Something else wrote to a file in their tree while they were being asked to
|
|
365
|
+
* approve a change to it, and that is recorded here as a fact about the
|
|
366
|
+
* session — separate from `verification` so the fold never mistakes one for
|
|
367
|
+
* the other. No content is recorded: the path and what moved are the whole
|
|
368
|
+
* fact.
|
|
369
|
+
*/
|
|
370
|
+
export const ExternalChangeEventSchema = z
|
|
371
|
+
.object({
|
|
372
|
+
kind: z.literal("external-change"),
|
|
373
|
+
at: z.string(),
|
|
374
|
+
runId: z.string().optional(),
|
|
375
|
+
path: z.string(),
|
|
376
|
+
what: z.string(),
|
|
377
|
+
})
|
|
378
|
+
.passthrough();
|
|
379
|
+
/**
|
|
380
|
+
* The user approved a plan (plan-durability).
|
|
381
|
+
*
|
|
382
|
+
* Recorded as a FACT — the decision kind and the steps as approved, dated —
|
|
383
|
+
* because it is stronger than the mode toggle, which was already journaled,
|
|
384
|
+
* and it used to evaporate with the process: the plan lived in a local holder
|
|
385
|
+
* inside one `runPlanSession` call and nowhere else. The plan's text was
|
|
386
|
+
* already on disk as the model's own `submit_plan` tool_use; this is the
|
|
387
|
+
* typed record beside it, the one a reader can fold without parsing a tool
|
|
388
|
+
* call.
|
|
389
|
+
*
|
|
390
|
+
* IT IS NEVER RE-ARMED ON RESUME. `decision: "approve-grant"` says the user
|
|
391
|
+
* chose `[g]`, which wrote grants into the session allowlist — RAM, in the
|
|
392
|
+
* process that asked. A resumed session reading this event and restoring
|
|
393
|
+
* those grants would pre-authorize writes the user consented to in a
|
|
394
|
+
* different process, against a tree that has since changed. The fold hands
|
|
395
|
+
* back a description; nothing on the consent side reads it.
|
|
396
|
+
*
|
|
397
|
+
* `decision` and `kind` are plain strings so a log written by a newer build
|
|
398
|
+
* parses here rather than poisoning the line.
|
|
399
|
+
*/
|
|
400
|
+
export const PlanApprovedEventSchema = z
|
|
401
|
+
.object({
|
|
402
|
+
kind: z.literal("plan-approved"),
|
|
403
|
+
at: z.string(),
|
|
404
|
+
runId: z.string().optional(),
|
|
405
|
+
/** `approve` or `approve-grant`, as the user chose. */
|
|
406
|
+
decision: z.string(),
|
|
407
|
+
steps: z.array(z
|
|
408
|
+
.object({ id: z.string(), title: z.string(), kind: z.string() })
|
|
409
|
+
.passthrough()),
|
|
410
|
+
})
|
|
411
|
+
.passthrough();
|
|
412
|
+
/**
|
|
413
|
+
* One step of the last approved plan changed status (plan-durability):
|
|
414
|
+
* `running` when its turn began, `done` or `failed` when it ended. A step
|
|
415
|
+
* whose last event is `running` is the signature of an interrupted plan —
|
|
416
|
+
* the process died before the step could report either outcome — and that
|
|
417
|
+
* is exactly the fact the resume notice needs.
|
|
418
|
+
*/
|
|
419
|
+
export const PlanStepEventSchema = z
|
|
420
|
+
.object({
|
|
421
|
+
kind: z.literal("plan-step"),
|
|
422
|
+
at: z.string(),
|
|
423
|
+
runId: z.string().optional(),
|
|
424
|
+
stepId: z.string(),
|
|
425
|
+
status: z.string(),
|
|
426
|
+
})
|
|
427
|
+
.passthrough();
|
|
286
428
|
/** Every event, discriminated on `kind`. */
|
|
287
429
|
export const SessionEventSchema = z.discriminatedUnion("kind", [
|
|
288
430
|
SessionMetaSchema,
|
|
@@ -294,6 +436,11 @@ export const SessionEventSchema = z.discriminatedUnion("kind", [
|
|
|
294
436
|
UsageEventSchema,
|
|
295
437
|
RedactEventSchema,
|
|
296
438
|
ResumedEventSchema,
|
|
439
|
+
VerificationEventSchema,
|
|
440
|
+
ExternalChangeEventSchema,
|
|
441
|
+
InstructionLossEventSchema,
|
|
442
|
+
PlanApprovedEventSchema,
|
|
443
|
+
PlanStepEventSchema,
|
|
297
444
|
]);
|
|
298
445
|
/**
|
|
299
446
|
* Every `kind` this build understands, derived from the union itself so the two
|
|
@@ -304,3 +451,14 @@ export const SessionEventSchema = z.discriminatedUnion("kind", [
|
|
|
304
451
|
* they used to be counted as one.
|
|
305
452
|
*/
|
|
306
453
|
export const KNOWN_EVENT_KINDS = new Set(SessionEventSchema.options.map((option) => option.shape.kind.value));
|
|
454
|
+
/** A tally with nothing in it — a fresh session, or a log with no compaction. */
|
|
455
|
+
export function emptyCompactionTally() {
|
|
456
|
+
return {
|
|
457
|
+
count: 0,
|
|
458
|
+
measured: 0,
|
|
459
|
+
freedTokens: 0,
|
|
460
|
+
summaryInputTokens: 0,
|
|
461
|
+
summaryOutputTokens: 0,
|
|
462
|
+
instructionLosses: 0,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
@@ -7,7 +7,7 @@ import { UNRESOLVED_TIER, } from "../budget/index.js";
|
|
|
7
7
|
import { resolveTaskModel } from "../routing/index.js";
|
|
8
8
|
import { Workspace } from "../workspace/index.js";
|
|
9
9
|
import { Budget, resolveBudget } from "../agent/budget.js";
|
|
10
|
-
import {
|
|
10
|
+
import { isWriteTool, scopeRegistry } from "./registry-scope.js";
|
|
11
11
|
import { Semaphore } from "./semaphore.js";
|
|
12
12
|
import { makeSpawnSubagentTool } from "./spawn-tool.js";
|
|
13
13
|
/** Longest task excerpt shown in render chrome — display, not record. */
|
|
@@ -556,7 +556,7 @@ function taskLabel(task) {
|
|
|
556
556
|
/** A child that holds any mutating tool — the disjoint-scope check's unit. A
|
|
557
557
|
* spec with no `tools` gets the default READ-ONLY set, so it is never a writer. */
|
|
558
558
|
function isWriter(spec) {
|
|
559
|
-
return (spec.tools ?? []).some(
|
|
559
|
+
return (spec.tools ?? []).some(isWriteTool);
|
|
560
560
|
}
|
|
561
561
|
/**
|
|
562
562
|
* The tokens a child is KNOWN to have spent, or `undefined` when no request
|
|
@@ -17,8 +17,20 @@ export const SPAWN_SUBAGENTS_TOOL_NAME = "spawn_subagents";
|
|
|
17
17
|
/**
|
|
18
18
|
* Mutating tools (C.33): a child holding ANY of these is a "writer" for the
|
|
19
19
|
* disjoint-scope check. Two writers in one parallel batch must target distinct
|
|
20
|
-
* roots, or the batch is refused pre-dispatch.
|
|
21
|
-
*
|
|
20
|
+
* roots, or the batch is refused pre-dispatch.
|
|
21
|
+
*
|
|
22
|
+
* This is the set of REGISTERED tool names that gate on `ctx.requestApproval`
|
|
23
|
+
* and act on a workspace root: file writes, shell/test, the PR tool, and the
|
|
24
|
+
* background-job dispatcher (a job is a whole agent run that may be granted
|
|
25
|
+
* any of the others). `registry-scope.test.ts` pins every name here to a tool
|
|
26
|
+
* that actually exists — until P1 this set named three tools that never did
|
|
27
|
+
* (`git_commit`, `git_branch`, `open_pr`) and omitted `create_pull_request`,
|
|
28
|
+
* so a child holding the PR tool was never counted as a writer and two of them
|
|
29
|
+
* could be dispatched against one root.
|
|
30
|
+
*
|
|
31
|
+
* Deliberately NOT here: `remember` (writes the memory store, not a root —
|
|
32
|
+
* root-disjointness says nothing about it) and the spawn tools (stripped from
|
|
33
|
+
* every child scope). MCP tools are covered by prefix in {@link isWriteTool}.
|
|
22
34
|
*/
|
|
23
35
|
export const SUBAGENT_WRITE_TOOLS = new Set([
|
|
24
36
|
"write_file",
|
|
@@ -26,10 +38,21 @@ export const SUBAGENT_WRITE_TOOLS = new Set([
|
|
|
26
38
|
"apply_patch",
|
|
27
39
|
"run_command",
|
|
28
40
|
"run_tests",
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"open_pr",
|
|
41
|
+
"create_pull_request",
|
|
42
|
+
"run_in_background",
|
|
32
43
|
]);
|
|
44
|
+
/** The wire-name prefix every MCP-proxied tool carries (see `mcp/adapter.ts`). */
|
|
45
|
+
const MCP_TOOL_PREFIX = "mcp__";
|
|
46
|
+
/**
|
|
47
|
+
* Whether granting `name` to a child makes it a writer. The named set above,
|
|
48
|
+
* plus every MCP tool: the adapter gates each call on approval precisely
|
|
49
|
+
* because a server's tool can mutate anything, and the classifier never lets
|
|
50
|
+
* the server declare itself read-only — so the disjointness check must not
|
|
51
|
+
* either.
|
|
52
|
+
*/
|
|
53
|
+
export function isWriteTool(name) {
|
|
54
|
+
return SUBAGENT_WRITE_TOOLS.has(name) || name.startsWith(MCP_TOOL_PREFIX);
|
|
55
|
+
}
|
|
33
56
|
/**
|
|
34
57
|
* The default child toolset: read-only investigation plus skills. Mirrors the
|
|
35
58
|
* C.31 propose-phase set — no writes, no shell, no VCS unless the parent
|
|
@@ -147,7 +147,9 @@ export function makeRunTestsTool(deps = {}) {
|
|
|
147
147
|
// The structured result goes to the renderer here, from the same object
|
|
148
148
|
// the payload below is built from — never from re-reading that payload.
|
|
149
149
|
try {
|
|
150
|
-
deps.onResult?.(result, resolved
|
|
150
|
+
deps.onResult?.(result, resolved, {
|
|
151
|
+
substrate: ctx.sandbox ? "sandbox" : "host",
|
|
152
|
+
});
|
|
151
153
|
}
|
|
152
154
|
catch {
|
|
153
155
|
// A renderer problem is not a test-run problem.
|
|
@@ -25,7 +25,8 @@ const parameters = z.object({
|
|
|
25
25
|
body: z
|
|
26
26
|
.string()
|
|
27
27
|
.optional()
|
|
28
|
-
.describe("PR body in markdown (what changed · why
|
|
28
|
+
.describe("PR body in markdown (what changed · why). Omit to auto-generate. " +
|
|
29
|
+
"Do not write a Verification section: it is added from the record of what actually ran this session, and one you write is replaced by it."),
|
|
29
30
|
base: z
|
|
30
31
|
.string()
|
|
31
32
|
.optional()
|
|
@@ -58,6 +59,12 @@ export const createPullRequestTool = {
|
|
|
58
59
|
...i,
|
|
59
60
|
scopes: guidance.scopes,
|
|
60
61
|
skillBody: guidance.skillBody,
|
|
62
|
+
// The PR body's Verification section is the record's (P2): the
|
|
63
|
+
// runs this session actually executed, with their exit codes —
|
|
64
|
+
// or no such section at all. The model's own body keeps its
|
|
65
|
+
// prose; a Verification section it wrote is replaced by this.
|
|
66
|
+
// Never a manufactured "ran typecheck + lint + test".
|
|
67
|
+
verification: { runs: ctx.verification?.sessionRuns() ?? [] },
|
|
61
68
|
}),
|
|
62
69
|
});
|
|
63
70
|
const outcome = await service.openPullRequest({
|
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { resolveToolPath, toPosix } from "./paths.js";
|
|
5
5
|
import { applyEol, detectEol, findMatch, tierLabel } from "./match.js";
|
|
6
|
+
import { changedSince, snapshotFile, snapshotOf, } from "./snapshot.js";
|
|
6
7
|
/** How many leading lines of a created file the approval preview shows. */
|
|
7
8
|
const PREVIEW_LINES = 20;
|
|
8
9
|
/**
|
|
@@ -162,6 +163,24 @@ export const applyPatchTool = {
|
|
|
162
163
|
if (!decision.allow) {
|
|
163
164
|
return { ok: false, error: decision.feedback ?? "patch denied" };
|
|
164
165
|
}
|
|
166
|
+
// The approval covered THESE files in THE states they were read in. Any
|
|
167
|
+
// path that moved during the wait — rewritten, deleted, or created by
|
|
168
|
+
// something else — voids the whole patch: nothing is applied, so the model
|
|
169
|
+
// re-reads and resubmits rather than landing a half-stale patch (P1).
|
|
170
|
+
// Every drifted path is named, not just the first: a retry that knows one
|
|
171
|
+
// of two moved re-reads one file and trips over the other.
|
|
172
|
+
const drifted = [];
|
|
173
|
+
for (const p of planned) {
|
|
174
|
+
const moved = await changedSince(p.abs, p.approved, p.rel);
|
|
175
|
+
if (moved) {
|
|
176
|
+
drifted.push(moved.message);
|
|
177
|
+
// One record per path that moved (P2 verification): the user learns
|
|
178
|
+
// which files something else wrote to, not just that the patch failed.
|
|
179
|
+
ctx.verification?.record({ kind: "external-change", ...moved });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (drifted.length > 0)
|
|
183
|
+
return { ok: false, error: drifted.join("\n") };
|
|
165
184
|
// Validation passed and the user approved; apply everything. A mid-apply I/O
|
|
166
185
|
// failure is rare but reported with what already landed.
|
|
167
186
|
const applied = [];
|
|
@@ -195,28 +214,44 @@ async function openTrack(i, op, abs, ctx) {
|
|
|
195
214
|
// messages), consistent with every other path tool — see {@link toPosix}.
|
|
196
215
|
const rel = toPosix(path.relative(ctx.cwd, abs));
|
|
197
216
|
const base = { abs, rel, firstOp: i, hunks: [] };
|
|
198
|
-
if (op.type === "create") {
|
|
199
|
-
|
|
200
|
-
|
|
217
|
+
if (op.type === "create" || op.type === "delete") {
|
|
218
|
+
let approved;
|
|
219
|
+
try {
|
|
220
|
+
approved = await snapshotFile(abs);
|
|
201
221
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
222
|
+
catch (err) {
|
|
223
|
+
return { ok: false, error: opError(i, op, err.message) };
|
|
224
|
+
}
|
|
225
|
+
if (op.type === "create") {
|
|
226
|
+
if (approved.kind === "present") {
|
|
227
|
+
return { ok: false, error: opError(i, op, "file already exists") };
|
|
228
|
+
}
|
|
229
|
+
const content = op.content ?? "";
|
|
230
|
+
return {
|
|
231
|
+
ok: true,
|
|
232
|
+
track: {
|
|
233
|
+
...base,
|
|
234
|
+
kind: "create",
|
|
235
|
+
content,
|
|
236
|
+
eol: detectEol(content),
|
|
237
|
+
approved,
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
if (approved.kind === "absent") {
|
|
210
242
|
return { ok: false, error: opError(i, op, "file not found") };
|
|
211
243
|
}
|
|
212
244
|
return {
|
|
213
245
|
ok: true,
|
|
214
|
-
track: { ...base, kind: "delete", content: "", eol: "\n" },
|
|
246
|
+
track: { ...base, kind: "delete", content: "", eol: "\n", approved },
|
|
215
247
|
};
|
|
216
248
|
}
|
|
217
249
|
let content;
|
|
250
|
+
let approved;
|
|
218
251
|
try {
|
|
219
|
-
|
|
252
|
+
const bytes = await fs.readFile(abs);
|
|
253
|
+
approved = snapshotOf(bytes);
|
|
254
|
+
content = bytes.toString("utf8");
|
|
220
255
|
}
|
|
221
256
|
catch (err) {
|
|
222
257
|
if (err.code === "ENOENT") {
|
|
@@ -229,6 +264,7 @@ async function openTrack(i, op, abs, ctx) {
|
|
|
229
264
|
kind: "update",
|
|
230
265
|
content,
|
|
231
266
|
eol: detectEol(content),
|
|
267
|
+
approved,
|
|
232
268
|
};
|
|
233
269
|
const failure = applyHunk(i, op, track);
|
|
234
270
|
return failure ? { ok: false, error: failure } : { ok: true, track };
|
|
@@ -261,12 +297,12 @@ function applyHunk(i, op, track) {
|
|
|
261
297
|
}
|
|
262
298
|
/** Collapse a finished track into the single write it represents. */
|
|
263
299
|
function toPlanned(track) {
|
|
264
|
-
const { kind, abs, rel, content, hunks } = track;
|
|
300
|
+
const { kind, abs, rel, content, hunks, approved } = track;
|
|
265
301
|
if (kind === "delete")
|
|
266
|
-
return { op: "delete", abs, rel };
|
|
302
|
+
return { op: "delete", abs, rel, approved };
|
|
267
303
|
if (kind === "create")
|
|
268
|
-
return { op: "create", abs, rel, content };
|
|
269
|
-
return { op: "update", abs, rel, content, hunks };
|
|
304
|
+
return { op: "create", abs, rel, content, approved };
|
|
305
|
+
return { op: "update", abs, rel, content, hunks, approved };
|
|
270
306
|
}
|
|
271
307
|
/** Shape a planned op into its approval-preview form. */
|
|
272
308
|
function toPreview(p) {
|
|
@@ -287,9 +323,3 @@ function toPreview(p) {
|
|
|
287
323
|
function opError(i, op, reason) {
|
|
288
324
|
return `operation ${i + 1} (${op.type} ${op.path}): ${reason}`;
|
|
289
325
|
}
|
|
290
|
-
async function exists(abs) {
|
|
291
|
-
return fs
|
|
292
|
-
.access(abs)
|
|
293
|
-
.then(() => true)
|
|
294
|
-
.catch(() => false);
|
|
295
|
-
}
|
|
@@ -2,10 +2,15 @@ import { promises as fs } from "node:fs";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { resolveToolPath } from "./paths.js";
|
|
4
4
|
import { applyEol, detectEol, findMatch, tierLabel } from "./match.js";
|
|
5
|
+
import { changedSince, snapshotOf } from "./snapshot.js";
|
|
5
6
|
/**
|
|
6
7
|
* Replace one exact, unique occurrence of `old_str` with `new_str` in a file.
|
|
7
8
|
* The uniqueness requirement is checked before approval so the model can fix an
|
|
8
9
|
* ambiguous match without burning a prompt; gated on `ctx.approve` before writing.
|
|
10
|
+
*
|
|
11
|
+
* The bytes read before approval are the state the approval is granted
|
|
12
|
+
* against; the write is refused if the file is no longer in that state when
|
|
13
|
+
* the write is about to happen (P1 — see `snapshot.ts`).
|
|
9
14
|
*/
|
|
10
15
|
export const editFileTool = {
|
|
11
16
|
name: "edit_file",
|
|
@@ -29,8 +34,11 @@ export const editFileTool = {
|
|
|
29
34
|
return { ok: false, error: err.message };
|
|
30
35
|
}
|
|
31
36
|
let content;
|
|
37
|
+
let approvedState;
|
|
32
38
|
try {
|
|
33
|
-
|
|
39
|
+
const bytes = await fs.readFile(abs);
|
|
40
|
+
approvedState = snapshotOf(bytes);
|
|
41
|
+
content = bytes.toString("utf8");
|
|
34
42
|
}
|
|
35
43
|
catch (err) {
|
|
36
44
|
if (err.code === "ENOENT") {
|
|
@@ -64,6 +72,16 @@ export const editFileTool = {
|
|
|
64
72
|
const updated = content.slice(0, match.start) +
|
|
65
73
|
applyEol(input.new_str, detectEol(content)) +
|
|
66
74
|
content.slice(match.end);
|
|
75
|
+
// The approval covered a diff against the bytes read above. Anything that
|
|
76
|
+
// changed the file during the wait makes `updated` a splice of stale
|
|
77
|
+
// content — refuse rather than overwrite what is there now (P1).
|
|
78
|
+
const moved = await changedSince(abs, approvedState, input.path);
|
|
79
|
+
if (moved) {
|
|
80
|
+
// Told to the model as the error, and recorded for the user (P2
|
|
81
|
+
// verification) — see write-file.ts.
|
|
82
|
+
ctx.verification?.record({ kind: "external-change", ...moved });
|
|
83
|
+
return { ok: false, error: moved.message };
|
|
84
|
+
}
|
|
67
85
|
try {
|
|
68
86
|
await fs.writeFile(abs, updated, "utf8");
|
|
69
87
|
return { ok: true, output: `edited ${input.path}` };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import { ErrorCode } from "../../errors/index.js";
|
|
4
|
+
/** Snapshot the bytes a tool has ALREADY read (no second read). */
|
|
5
|
+
export function snapshotOf(bytes) {
|
|
6
|
+
return {
|
|
7
|
+
kind: "present",
|
|
8
|
+
digest: createHash("sha256").update(bytes).digest("hex"),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Read `abs` and snapshot it. A missing path is a legitimate state (`absent`)
|
|
13
|
+
* — it is what `write_file` and a patch `create` are approved against. Every
|
|
14
|
+
* other failure (a directory at the path, EACCES) propagates: the caller
|
|
15
|
+
* cannot build a truthful preview of a file it cannot read.
|
|
16
|
+
*/
|
|
17
|
+
export async function snapshotFile(abs) {
|
|
18
|
+
try {
|
|
19
|
+
return snapshotOf(await fs.readFile(abs));
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
if (err.code === "ENOENT") {
|
|
23
|
+
return { kind: "absent" };
|
|
24
|
+
}
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Re-read `abs` and compare it with the state the approval was granted
|
|
30
|
+
* against. Returns `null` when the file is exactly as it was, otherwise the
|
|
31
|
+
* refusal — its `message` is the tool's error (one sentence on what moved,
|
|
32
|
+
* and the same next step every time: read it again and retry), and its
|
|
33
|
+
* `path`/`what` are what the tool records so the user learns of it too.
|
|
34
|
+
*
|
|
35
|
+
* Call this AFTER approval and IMMEDIATELY before the write, with nothing
|
|
36
|
+
* awaited in between — the point is to make the gap as small as the platform
|
|
37
|
+
* allows, not to check early and then wait.
|
|
38
|
+
*/
|
|
39
|
+
export async function changedSince(abs, approved, rel) {
|
|
40
|
+
let now;
|
|
41
|
+
try {
|
|
42
|
+
now = await snapshotFile(abs);
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
return refusal(rel, `could not be re-read before the approved write (${err.message})`);
|
|
46
|
+
}
|
|
47
|
+
if (approved.kind === "absent") {
|
|
48
|
+
return now.kind === "absent"
|
|
49
|
+
? null
|
|
50
|
+
: refusal(rel, "was created by something else after it was approved as a new file");
|
|
51
|
+
}
|
|
52
|
+
if (now.kind === "absent") {
|
|
53
|
+
return refusal(rel, "was deleted after it was read");
|
|
54
|
+
}
|
|
55
|
+
if (now.digest !== approved.digest) {
|
|
56
|
+
return refusal(rel, "changed on disk after it was read");
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
/** The one refusal shape: what moved, that nothing was written, what to do. */
|
|
61
|
+
function refusal(rel, what) {
|
|
62
|
+
return {
|
|
63
|
+
path: rel,
|
|
64
|
+
what,
|
|
65
|
+
message: `${ErrorCode.FileChangedSinceRead}: ${rel} ${what}, so the approval no longer ` +
|
|
66
|
+
`covers this write; nothing was written — read the file again and retry`,
|
|
67
|
+
};
|
|
68
|
+
}
|