@cruxy/cli 1.11.2 → 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/cli/commands/pr.js +14 -0
- package/dist/cli/commands/run.js +35 -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/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 +6 -3
- package/dist/session/log.js +97 -2
- package/dist/session/recorded-runs.js +56 -0
- package/dist/session/replay.js +75 -1
- package/dist/session/resume.js +88 -0
- package/dist/session/types.js +158 -0
- 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 +6 -2
- package/dist/tools/file/edit-file.js +6 -2
- package/dist/tools/file/snapshot.js +9 -4
- package/dist/tools/file/write-file.js +7 -2
- 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/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 +1 -1
package/dist/session/replay.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { modeFromFlags } from "../agent/mode.js";
|
|
3
3
|
import { redactMessages } from "./redact.js";
|
|
4
|
-
import { KNOWN_EVENT_KINDS, SessionEventSchema, SessionMetaSchema, } from "./types.js";
|
|
4
|
+
import { KNOWN_EVENT_KINDS, SessionEventSchema, SessionMetaSchema, emptyCompactionTally, } from "./types.js";
|
|
5
5
|
/**
|
|
6
6
|
* Replay: fold an append-only event log back into the state a session needs to
|
|
7
7
|
* resume (P2).
|
|
@@ -109,6 +109,10 @@ export function foldEvents(events, counts = {}) {
|
|
|
109
109
|
let planMode = false;
|
|
110
110
|
let mode = null;
|
|
111
111
|
let redactions = 0;
|
|
112
|
+
let lastVerification;
|
|
113
|
+
let externalChanges = 0;
|
|
114
|
+
let plan;
|
|
115
|
+
const compactions = emptyCompactionTally();
|
|
112
116
|
const resumes = [];
|
|
113
117
|
const usage = { input_tokens: 0, output_tokens: 0 };
|
|
114
118
|
for (const event of events) {
|
|
@@ -123,6 +127,22 @@ export function foldEvents(events, counts = {}) {
|
|
|
123
127
|
...asMessages(event.summary),
|
|
124
128
|
...messages.slice(event.replaced),
|
|
125
129
|
];
|
|
130
|
+
// And tallied (P3): the count is the events; the sums cover only the
|
|
131
|
+
// events that carry a cost, so a pre-P3 compaction counts as one that
|
|
132
|
+
// happened and not as one that freed nothing.
|
|
133
|
+
compactions.count++;
|
|
134
|
+
if (event.estimatedBefore !== undefined &&
|
|
135
|
+
event.estimatedAfter !== undefined) {
|
|
136
|
+
compactions.measured++;
|
|
137
|
+
compactions.freedTokens += Math.max(0, event.estimatedBefore - event.estimatedAfter);
|
|
138
|
+
compactions.summaryInputTokens += event.summaryInputTokens ?? 0;
|
|
139
|
+
compactions.summaryOutputTokens += event.summaryOutputTokens ?? 0;
|
|
140
|
+
}
|
|
141
|
+
break;
|
|
142
|
+
case "instruction-loss":
|
|
143
|
+
// Counted, not folded: the synopsis is already in the history as the
|
|
144
|
+
// model sees it. This is the fact that it may be missing something.
|
|
145
|
+
compactions.instructionLosses++;
|
|
126
146
|
break;
|
|
127
147
|
case "clear":
|
|
128
148
|
messages = [];
|
|
@@ -156,6 +176,56 @@ export function foldEvents(events, counts = {}) {
|
|
|
156
176
|
// could be added without touching `meta` or the message array.
|
|
157
177
|
resumes.push({ at: event.at, cwd: event.cwd });
|
|
158
178
|
break;
|
|
179
|
+
case "verification":
|
|
180
|
+
// Last-writer-wins, and never folded into the messages: a run is a
|
|
181
|
+
// fact ABOUT the turn, not a turn in it. The tool_result the model saw
|
|
182
|
+
// is already in the `append` events; this is the structured claim.
|
|
183
|
+
lastVerification = {
|
|
184
|
+
at: event.at,
|
|
185
|
+
tool: event.tool,
|
|
186
|
+
command: event.command,
|
|
187
|
+
...(event.source !== undefined ? { source: event.source } : {}),
|
|
188
|
+
passed: event.passed,
|
|
189
|
+
exitCode: event.exitCode,
|
|
190
|
+
durationMs: event.durationMs,
|
|
191
|
+
...(event.total !== undefined ? { total: event.total } : {}),
|
|
192
|
+
failureCount: event.failureCount,
|
|
193
|
+
failureNames: event.failureNames,
|
|
194
|
+
outputTruncated: event.outputTruncated,
|
|
195
|
+
substrate: event.substrate,
|
|
196
|
+
};
|
|
197
|
+
break;
|
|
198
|
+
case "external-change":
|
|
199
|
+
// Counted, not folded: the refusal wrote nothing, so there is nothing
|
|
200
|
+
// in the history to adjust — only a fact for the resume notice.
|
|
201
|
+
externalChanges++;
|
|
202
|
+
break;
|
|
203
|
+
case "plan-approved":
|
|
204
|
+
// A new approval replaces the last: every step starts `pending`, as
|
|
205
|
+
// the executor had it, and the `plan-step` events that follow move
|
|
206
|
+
// them. Never folded into the messages — the plan's text is already
|
|
207
|
+
// there as the model's own `submit_plan` call.
|
|
208
|
+
plan = {
|
|
209
|
+
approvedAt: event.at,
|
|
210
|
+
decision: event.decision,
|
|
211
|
+
steps: event.steps.map((s) => ({
|
|
212
|
+
id: s.id,
|
|
213
|
+
title: s.title,
|
|
214
|
+
kind: s.kind,
|
|
215
|
+
status: "pending",
|
|
216
|
+
})),
|
|
217
|
+
};
|
|
218
|
+
break;
|
|
219
|
+
case "plan-step": {
|
|
220
|
+
// A transition for a step the last approval did not name is skipped,
|
|
221
|
+
// not an error: a torn `plan-approved` line, or a newer build's
|
|
222
|
+
// vocabulary. Position matters — a step left `running` is one whose
|
|
223
|
+
// outcome the log never received.
|
|
224
|
+
const step = plan?.steps.find((s) => s.id === event.stepId);
|
|
225
|
+
if (step)
|
|
226
|
+
step.status = event.status;
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
159
229
|
case "meta":
|
|
160
230
|
break;
|
|
161
231
|
}
|
|
@@ -172,6 +242,10 @@ export function foldEvents(events, counts = {}) {
|
|
|
172
242
|
unknownEvents: counts.unknownEvents ?? 0,
|
|
173
243
|
resumes,
|
|
174
244
|
redactions,
|
|
245
|
+
...(lastVerification ? { lastVerification } : {}),
|
|
246
|
+
externalChanges,
|
|
247
|
+
compactions,
|
|
248
|
+
...(plan ? { plan } : {}),
|
|
175
249
|
};
|
|
176
250
|
}
|
|
177
251
|
/** Read and parse a session file into its events, counting unusable lines. */
|
package/dist/session/resume.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
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";
|
|
@@ -127,8 +128,95 @@ export function loadResume(session, cwd) {
|
|
|
127
128
|
warnings.push(`this session was redacted ${state.redactions === 1 ? "once" : `${state.redactions} times`} — ` +
|
|
128
129
|
`secrets are masked in the restored history, but the original text is still in ${session.file}`);
|
|
129
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
|
+
}
|
|
130
173
|
return { session, state, warnings };
|
|
131
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
|
+
}
|
|
132
220
|
/**
|
|
133
221
|
* VALIDATE `--resume <id>` — which session does this name? — without loading it.
|
|
134
222
|
*
|
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
|
+
}
|
|
@@ -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({
|
|
@@ -172,8 +172,12 @@ export const applyPatchTool = {
|
|
|
172
172
|
const drifted = [];
|
|
173
173
|
for (const p of planned) {
|
|
174
174
|
const moved = await changedSince(p.abs, p.approved, p.rel);
|
|
175
|
-
if (moved)
|
|
176
|
-
drifted.push(moved);
|
|
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
|
+
}
|
|
177
181
|
}
|
|
178
182
|
if (drifted.length > 0)
|
|
179
183
|
return { ok: false, error: drifted.join("\n") };
|
|
@@ -76,8 +76,12 @@ export const editFileTool = {
|
|
|
76
76
|
// changed the file during the wait makes `updated` a splice of stale
|
|
77
77
|
// content — refuse rather than overwrite what is there now (P1).
|
|
78
78
|
const moved = await changedSince(abs, approvedState, input.path);
|
|
79
|
-
if (moved)
|
|
80
|
-
|
|
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
|
+
}
|
|
81
85
|
try {
|
|
82
86
|
await fs.writeFile(abs, updated, "utf8");
|
|
83
87
|
return { ok: true, output: `edited ${input.path}` };
|
|
@@ -28,8 +28,9 @@ export async function snapshotFile(abs) {
|
|
|
28
28
|
/**
|
|
29
29
|
* Re-read `abs` and compare it with the state the approval was granted
|
|
30
30
|
* against. Returns `null` when the file is exactly as it was, otherwise the
|
|
31
|
-
* refusal
|
|
32
|
-
* the same next step every time: read it again and retry
|
|
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.
|
|
33
34
|
*
|
|
34
35
|
* Call this AFTER approval and IMMEDIATELY before the write, with nothing
|
|
35
36
|
* awaited in between — the point is to make the gap as small as the platform
|
|
@@ -58,6 +59,10 @@ export async function changedSince(abs, approved, rel) {
|
|
|
58
59
|
}
|
|
59
60
|
/** The one refusal shape: what moved, that nothing was written, what to do. */
|
|
60
61
|
function refusal(rel, what) {
|
|
61
|
-
return
|
|
62
|
-
|
|
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
|
+
};
|
|
63
68
|
}
|
|
@@ -65,8 +65,13 @@ export const writeFileTool = {
|
|
|
65
65
|
// appeared during the wait; an overwrite approved against one version must
|
|
66
66
|
// not land on another (P1).
|
|
67
67
|
const moved = await changedSince(abs, approvedState, input.path);
|
|
68
|
-
if (moved)
|
|
69
|
-
|
|
68
|
+
if (moved) {
|
|
69
|
+
// Told to the model as the error, and recorded for the user (P2
|
|
70
|
+
// verification): a file in their tree was written to by something else
|
|
71
|
+
// while they were being asked to approve a change to it.
|
|
72
|
+
ctx.verification?.record({ kind: "external-change", ...moved });
|
|
73
|
+
return { ok: false, error: moved.message };
|
|
74
|
+
}
|
|
70
75
|
try {
|
|
71
76
|
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
72
77
|
await fs.writeFile(abs, input.content, "utf8");
|
package/dist/tools/registry.js
CHANGED
|
@@ -2,7 +2,7 @@ import { zodToJsonSchema } from "zod-to-json-schema";
|
|
|
2
2
|
import { listFilesTool } from "./list-files.js";
|
|
3
3
|
import { gitStatusTool } from "./git-status.js";
|
|
4
4
|
import { readFileTool, writeFileTool, editFileTool, applyPatchTool, globTool, grepFilesTool, } from "./file/index.js";
|
|
5
|
-
import { runCommandTool } from "./shell/index.js";
|
|
5
|
+
import { makeRunCommandTool, runCommandTool, } from "./shell/index.js";
|
|
6
6
|
import { makeRunTestsTool, } from "../testing/run-tests-tool.js";
|
|
7
7
|
import { searchCodebaseTool } from "./search-codebase.js";
|
|
8
8
|
import { listSkillsTool } from "./list-skills.js";
|
|
@@ -54,20 +54,51 @@ function toInputSchema(schema) {
|
|
|
54
54
|
delete json.$ref;
|
|
55
55
|
return json;
|
|
56
56
|
}
|
|
57
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* Build the default registry with every always-on built-in registered.
|
|
59
|
+
*
|
|
60
|
+
* `tools` is the config block of the same name (P4). Its two keys —
|
|
61
|
+
* `fileEdit` and `shell` — were declared in the C.0 scaffold and, until P4,
|
|
62
|
+
* consumed by NOTHING: a user could set `tools.shell: false`, `cruxy config
|
|
63
|
+
* list` would show it, and `run_command` would register regardless. Deleting
|
|
64
|
+
* them was the other option and is ruled out by `initConfig`, which writes the
|
|
65
|
+
* full defaults into every generated config: the schema is `.strict()`, so a
|
|
66
|
+
* removed key would stop every `cruxy init`-generated config from loading on
|
|
67
|
+
* upgrade — a compat break for everyone, not the #296 kind that bites only
|
|
68
|
+
* whoever wrote the key. So the keys now mean what their names say:
|
|
69
|
+
*
|
|
70
|
+
* - `fileEdit: false` withholds the three tools that write files through the
|
|
71
|
+
* U.3 gate — `write_file`, `edit_file`, `apply_patch`. Reads stay.
|
|
72
|
+
* - `shell: false` withholds the two that execute commands — `run_command`
|
|
73
|
+
* and `run_tests` (the test runner is a shell command with a parser on it,
|
|
74
|
+
* and "no shell" that still ran `pnpm test` would be a setting that lies).
|
|
75
|
+
* `create_pull_request` runs git through its own approval and is not a
|
|
76
|
+
* shell for the model; it stays.
|
|
77
|
+
*
|
|
78
|
+
* Both default to `true`, so a config that never mentions them — every config
|
|
79
|
+
* today — gets the same registry it always did. Omitted → both on.
|
|
80
|
+
*/
|
|
58
81
|
export function buildDefaultRegistry(opts = {}) {
|
|
82
|
+
const fileEdit = opts.tools?.fileEdit ?? true;
|
|
83
|
+
const shell = opts.tools?.shell ?? true;
|
|
59
84
|
const registry = new ToolRegistry();
|
|
60
85
|
registry.register(listFilesTool);
|
|
61
86
|
registry.register(readFileTool);
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
87
|
+
if (fileEdit) {
|
|
88
|
+
registry.register(writeFileTool);
|
|
89
|
+
registry.register(editFileTool);
|
|
90
|
+
registry.register(applyPatchTool);
|
|
91
|
+
}
|
|
65
92
|
registry.register(globTool);
|
|
66
93
|
registry.register(grepFilesTool);
|
|
67
94
|
registry.register(gitStatusTool);
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
95
|
+
if (shell) {
|
|
96
|
+
registry.register(opts.onCommandResult
|
|
97
|
+
? makeRunCommandTool({ onResult: opts.onCommandResult })
|
|
98
|
+
: runCommandTool);
|
|
99
|
+
// A fresh tool per registry — its iteration budget (C.13) is session-scoped.
|
|
100
|
+
registry.register(makeRunTestsTool(opts.onTestResult ? { onResult: opts.onTestResult } : {}));
|
|
101
|
+
}
|
|
71
102
|
registry.register(searchCodebaseTool);
|
|
72
103
|
registry.register(listSkillsTool);
|
|
73
104
|
registry.register(loadSkillTool);
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
4
|
-
* CI gate on our own built-ins
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* passes here and dies on
|
|
2
|
+
* The gateway's three tool-schema bounds — depth, bytes, nodes — and the
|
|
3
|
+
* counters that measure against them. ONE home for all of it, because there
|
|
4
|
+
* are two consumers that must agree: the CI gate on our own built-ins
|
|
5
|
+
* (`schema-depth.test.ts`) and the runtime bound on third-party MCP schemas
|
|
6
|
+
* (`../mcp/bounds.ts`). A counter that disagreed with its bound, or two copies
|
|
7
|
+
* of either drifting apart, would mean a schema that passes here and dies on
|
|
8
|
+
* the wire.
|
|
9
|
+
*
|
|
10
|
+
* Until P4 only depth was mirrored. The gateway has always applied all three
|
|
11
|
+
* to every tool's `parameters` (cruxy-ai/api `internal/httpx/chat_tools.go`,
|
|
12
|
+
* `validateToolParameters`), from the same constants its structured-output
|
|
13
|
+
* validator uses, and a rejection names which one (`bound`: `bytes` | `depth`
|
|
14
|
+
* | `nodes`). Two of the three were caught by nothing here.
|
|
8
15
|
*/
|
|
9
16
|
/**
|
|
10
17
|
* Nesting depth of the JSON Schema we put on the wire, counted the way the
|
|
@@ -83,3 +90,69 @@ export function schemaDepth(node) {
|
|
|
83
90
|
* `depth >= MAX_SCHEMA_DEPTH`.
|
|
84
91
|
*/
|
|
85
92
|
export const MAX_SCHEMA_DEPTH = 8;
|
|
93
|
+
/**
|
|
94
|
+
* The gateway's byte bound on ONE tool's `parameters`, as serialized on the
|
|
95
|
+
* wire. Mirrors `maxSchemaBytes` (128 KiB) in cruxy-ai/api
|
|
96
|
+
* `internal/httpx/structured.go`, applied per tool by `validateToolParameters`
|
|
97
|
+
* as `len(raw) > maxSchemaBytes` — so, unlike {@link MAX_SCHEMA_DEPTH}, this
|
|
98
|
+
* bound is INCLUSIVE: a schema is safe AT the limit and dies one byte past it.
|
|
99
|
+
*
|
|
100
|
+
* NOT OURS TO CHOOSE, same as depth: raising it here moves the failure from CI
|
|
101
|
+
* to every user's terminal, and a rejection carrying `bound: "bytes"` with a
|
|
102
|
+
* `limit` that disagrees with this number means THIS number is stale. If a
|
|
103
|
+
* schema cannot fit, the schema changes. Every built-in is two orders of
|
|
104
|
+
* magnitude under it today (the largest, `spawn_subagents`, is ~1.2 KB); the
|
|
105
|
+
* bound is here so that stays a fact CI checks rather than one someone
|
|
106
|
+
* remembers.
|
|
107
|
+
*
|
|
108
|
+
* The MCP path is covered twice over: `mcp.maxSchemaBytes` has a config
|
|
109
|
+
* ceiling of 64 KiB (`config/schema.ts`, #237), half this limit, and a test
|
|
110
|
+
* pins that the ceiling stays under the mirror — so no MCP schema that loads
|
|
111
|
+
* from config can reach the gateway's byte bound at all.
|
|
112
|
+
*/
|
|
113
|
+
export const MAX_SCHEMA_BYTES = 128 * 1024;
|
|
114
|
+
/**
|
|
115
|
+
* The gateway's node bound on ONE tool's `parameters`: the number of
|
|
116
|
+
* containers (objects and arrays) in the schema. Mirrors `maxSchemaNodes`
|
|
117
|
+
* (400) in cruxy-ai/api `internal/httpx/structured.go`, applied per tool by
|
|
118
|
+
* `boundJSON` as `nodes > maxSchemaNodes` — INCLUSIVE, like bytes: safe at
|
|
119
|
+
* 400, dead at 401. {@link schemaNodes} counts exactly what `boundJSON`
|
|
120
|
+
* counts. Same provenance rule: a sanity ceiling that mirrors the gateway,
|
|
121
|
+
* never raised to make a schema fit. Flatten instead.
|
|
122
|
+
*
|
|
123
|
+
* Small and wide is the shape this catches that neither of the others does:
|
|
124
|
+
* a flat object with 400 string properties is 3 levels deep and a few KB, and
|
|
125
|
+
* the gateway refuses it.
|
|
126
|
+
*/
|
|
127
|
+
export const MAX_SCHEMA_NODES = 400;
|
|
128
|
+
/**
|
|
129
|
+
* Container count of a JSON tree, counted the way the gateway's `boundJSON`
|
|
130
|
+
* counts it: every object and every array is one node, scalars are not. So
|
|
131
|
+
* `{}` is 1, `{a: {}}` is 2, `{a: [{}, {}]}` is 4. Iterative for the same
|
|
132
|
+
* reason {@link schemaDepth} is — the MCP path can hand this a hostile input.
|
|
133
|
+
*/
|
|
134
|
+
export function schemaNodes(node) {
|
|
135
|
+
const isContainer = (n) => Array.isArray(n) || (typeof n === "object" && n !== null);
|
|
136
|
+
if (!isContainer(node))
|
|
137
|
+
return 0;
|
|
138
|
+
let count = 0;
|
|
139
|
+
const pending = [node];
|
|
140
|
+
while (pending.length > 0) {
|
|
141
|
+
const current = pending.pop();
|
|
142
|
+
count++;
|
|
143
|
+
const children = Array.isArray(current)
|
|
144
|
+
? current
|
|
145
|
+
: Object.values(current);
|
|
146
|
+
for (const child of children)
|
|
147
|
+
if (isContainer(child))
|
|
148
|
+
pending.push(child);
|
|
149
|
+
}
|
|
150
|
+
return count;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Bytes of a schema as it goes on the wire: compact `JSON.stringify`, UTF-8 —
|
|
154
|
+
* which is what the SDK serializes and the gateway measures with `len(raw)`.
|
|
155
|
+
*/
|
|
156
|
+
export function schemaBytes(node) {
|
|
157
|
+
return Buffer.byteLength(JSON.stringify(node) ?? "", "utf8");
|
|
158
|
+
}
|