@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
|
@@ -25,6 +25,7 @@ import { MemoryService, buildMultiRootRecallBlock, rememberTool, } from "../memo
|
|
|
25
25
|
import { findDefinitionTool, findReferencesTool, getDiagnosticsTool, hoverTool, } from "../lsp/index.js";
|
|
26
26
|
import { createWebSearchTool, createWebFetchTool } from "../web/index.js";
|
|
27
27
|
import { appendRun } from "../usage/index.js";
|
|
28
|
+
import { VerificationLedger } from "../verification/index.js";
|
|
28
29
|
import { Semaphore, SubagentOrchestrator, makeSpawnSubagentTool, makeSpawnSubagentsTool, } from "../subagent/index.js";
|
|
29
30
|
import { ApprovalQueue, JobManager, makeRunInBackgroundTool, } from "../jobs/index.js";
|
|
30
31
|
/**
|
|
@@ -253,13 +254,26 @@ opts = {}) {
|
|
|
253
254
|
logger.warn(`${error.code}: ${error.title} — ${error.cause}`);
|
|
254
255
|
}
|
|
255
256
|
: undefined;
|
|
257
|
+
// The verification record (P2 verification): ONE ledger for the session,
|
|
258
|
+
// fed by the exec tools' side channels below and by the file tools through
|
|
259
|
+
// `ctx.verification`, and written through to the session log as its own
|
|
260
|
+
// event kinds. The in-memory side is what `/status` and the one-shot
|
|
261
|
+
// summary read; the log is what a resume and the transcript keep.
|
|
262
|
+
const verification = new VerificationLedger({
|
|
263
|
+
sink: opts.recorder ? (obs) => opts.recorder.observe(obs) : undefined,
|
|
264
|
+
});
|
|
256
265
|
// The `run_tests` side channel (P3): the structured outcome the tool already
|
|
257
266
|
// built, handed to the renderer to draw. Every field it needs is copied
|
|
258
267
|
// across as-is — nothing is derived here, so an absent `total` stays absent
|
|
259
|
-
// rather than becoming a number the parsers refused to claim.
|
|
268
|
+
// rather than becoming a number the parsers refused to claim. The record
|
|
269
|
+
// takes the same object, from the same call — never re-parsed from the
|
|
270
|
+
// string the model gets.
|
|
260
271
|
const execRegistry = buildDefaultRegistry({
|
|
261
|
-
|
|
262
|
-
|
|
272
|
+
// `tools.fileEdit` / `tools.shell` (P4): wired here, the one place the
|
|
273
|
+
// default registry is built for a session.
|
|
274
|
+
tools: config.tools,
|
|
275
|
+
onTestResult: (result, command, run) => {
|
|
276
|
+
renderer?.testResult({
|
|
263
277
|
passed: result.passed,
|
|
264
278
|
command: command.command,
|
|
265
279
|
durationMs: result.durationMs,
|
|
@@ -270,8 +284,38 @@ opts = {}) {
|
|
|
270
284
|
...(f.line !== undefined ? { line: f.line } : {}),
|
|
271
285
|
})),
|
|
272
286
|
outputTruncated: result.outputTruncated,
|
|
273
|
-
})
|
|
274
|
-
|
|
287
|
+
});
|
|
288
|
+
verification.record({
|
|
289
|
+
kind: "verification",
|
|
290
|
+
tool: "run_tests",
|
|
291
|
+
command: command.command,
|
|
292
|
+
source: command.source,
|
|
293
|
+
passed: result.passed,
|
|
294
|
+
exitCode: result.exitCode,
|
|
295
|
+
durationMs: result.durationMs,
|
|
296
|
+
...(result.total !== undefined ? { total: result.total } : {}),
|
|
297
|
+
failureCount: result.failures.length,
|
|
298
|
+
failureNames: result.failures.map((f) => f.name),
|
|
299
|
+
outputTruncated: result.outputTruncated,
|
|
300
|
+
substrate: run.substrate,
|
|
301
|
+
});
|
|
302
|
+
},
|
|
303
|
+
// `run_command` records what ran and how it exited — and NOTHING about
|
|
304
|
+
// what it was for. Whether "pnpm typecheck" was a typecheck is the
|
|
305
|
+
// reader's call; classifying it here from its text is the inference the
|
|
306
|
+
// record refuses to make.
|
|
307
|
+
onCommandResult: (result) => verification.record({
|
|
308
|
+
kind: "verification",
|
|
309
|
+
tool: "run_command",
|
|
310
|
+
command: result.command,
|
|
311
|
+
passed: result.exitCode === 0,
|
|
312
|
+
exitCode: result.exitCode,
|
|
313
|
+
durationMs: result.durationMs,
|
|
314
|
+
failureCount: 0,
|
|
315
|
+
failureNames: [],
|
|
316
|
+
outputTruncated: result.outputTruncated,
|
|
317
|
+
substrate: result.substrate,
|
|
318
|
+
}),
|
|
275
319
|
});
|
|
276
320
|
const git = getGitInfo(cwd);
|
|
277
321
|
const projectInstructions = loadProjectInstructions(cwd);
|
|
@@ -508,8 +552,9 @@ opts = {}) {
|
|
|
508
552
|
requestApproval: gate(approval),
|
|
509
553
|
checkpointsActive,
|
|
510
554
|
sandbox,
|
|
555
|
+
verification,
|
|
511
556
|
};
|
|
512
|
-
const planRunner = ({ messages, projectInstructions, recalledMemory: turnMemory, renderer: turnRenderer, onRequestUsage, }) => runPlanSession({
|
|
557
|
+
const planRunner = ({ messages, projectInstructions, recalledMemory: turnMemory, renderer: turnRenderer, onRequestUsage, compact, record, }) => runPlanSession({
|
|
513
558
|
provider,
|
|
514
559
|
config,
|
|
515
560
|
ctx,
|
|
@@ -524,6 +569,8 @@ opts = {}) {
|
|
|
524
569
|
renderer: turnRenderer,
|
|
525
570
|
router,
|
|
526
571
|
onRequestUsage,
|
|
572
|
+
compact,
|
|
573
|
+
record,
|
|
527
574
|
});
|
|
528
575
|
holder.session = new Session({
|
|
529
576
|
provider,
|
|
@@ -548,6 +595,7 @@ opts = {}) {
|
|
|
548
595
|
// not a second list that agrees with them only by luck.
|
|
549
596
|
allowlist,
|
|
550
597
|
recorder: opts.recorder,
|
|
598
|
+
verification,
|
|
551
599
|
restore: opts.restore,
|
|
552
600
|
});
|
|
553
601
|
return holder.session;
|
package/dist/config/schema.js
CHANGED
|
@@ -78,9 +78,18 @@ export const AgentConfigSchema = z
|
|
|
78
78
|
planMode: z.boolean().default(false),
|
|
79
79
|
})
|
|
80
80
|
.strict();
|
|
81
|
+
/**
|
|
82
|
+
* Which built-in tool families the model is handed (P4). Declared in the C.0
|
|
83
|
+
* scaffold and consumed by nothing until P4 — see `buildDefaultRegistry` for
|
|
84
|
+
* why they were wired rather than removed (`initConfig` writes them into every
|
|
85
|
+
* generated config, and the schema is `.strict()`).
|
|
86
|
+
*/
|
|
81
87
|
export const ToolsConfigSchema = z
|
|
82
88
|
.object({
|
|
89
|
+
/** `false` withholds `write_file`, `edit_file` and `apply_patch`. Reads stay. */
|
|
83
90
|
fileEdit: z.boolean().default(true),
|
|
91
|
+
/** `false` withholds `run_command` and `run_tests` — no command execution
|
|
92
|
+
* by the model at all. `create_pull_request` (git, own approval) stays. */
|
|
84
93
|
shell: z.boolean().default(true),
|
|
85
94
|
})
|
|
86
95
|
.strict();
|
package/dist/mcp/bounds.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { MAX_SCHEMA_DEPTH, schemaDepth } from "../tools/schema-depth.js";
|
|
1
|
+
import { MAX_SCHEMA_DEPTH, MAX_SCHEMA_NODES, schemaDepth, schemaNodes, } from "../tools/schema-depth.js";
|
|
2
2
|
const PERMISSIVE_SCHEMA = {
|
|
3
3
|
type: "object",
|
|
4
4
|
additionalProperties: true,
|
|
@@ -48,6 +48,13 @@ export function boundToolList(tools, bounds) {
|
|
|
48
48
|
inputSchema = { ...PERMISSIVE_SCHEMA };
|
|
49
49
|
notes.push(`input schema nests ${depth} levels deep, at or over the ${MAX_SCHEMA_DEPTH}-level provider limit, and was replaced with a permissive one`);
|
|
50
50
|
}
|
|
51
|
+
// Same order, same reasoning: nodes are counted on the post-cap value, and
|
|
52
|
+
// the gateway's bound is inclusive (P4) — 400 passes, 401 fails the request.
|
|
53
|
+
const nodes = schemaNodes(inputSchema);
|
|
54
|
+
if (nodes > MAX_SCHEMA_NODES) {
|
|
55
|
+
inputSchema = { ...PERMISSIVE_SCHEMA };
|
|
56
|
+
notes.push(`input schema has ${nodes} nodes, over the ${MAX_SCHEMA_NODES}-node provider limit, and was replaced with a permissive one`);
|
|
57
|
+
}
|
|
51
58
|
return { name: t.name, description, inputSchema, notes };
|
|
52
59
|
});
|
|
53
60
|
return { tools: bounded, droppedCount };
|
package/dist/plan/execute.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CruxyError } from "../errors/index.js";
|
|
2
2
|
import { promptContinueAfterFailure } from "./approve.js";
|
|
3
3
|
export async function executePlan(plan, deps) {
|
|
4
|
-
const { runStep, io, renderer } = deps;
|
|
4
|
+
const { runStep, io, renderer, record } = deps;
|
|
5
5
|
try {
|
|
6
6
|
for (const [index, step] of plan.steps.entries()) {
|
|
7
7
|
step.status = "running";
|
|
@@ -13,14 +13,17 @@ export async function executePlan(plan, deps) {
|
|
|
13
13
|
});
|
|
14
14
|
renderer?.setPhase({ kind: "executing-step" });
|
|
15
15
|
renderer?.setPlan(plan.steps);
|
|
16
|
+
record?.(step);
|
|
16
17
|
try {
|
|
17
18
|
await runStep(step);
|
|
18
19
|
step.status = "done";
|
|
19
20
|
renderer?.setPlan(plan.steps);
|
|
21
|
+
record?.(step);
|
|
20
22
|
}
|
|
21
23
|
catch (err) {
|
|
22
24
|
step.status = "failed";
|
|
23
25
|
renderer?.setPlan(plan.steps);
|
|
26
|
+
record?.(step);
|
|
24
27
|
// Surface the failure via the U.5 shape when we have it.
|
|
25
28
|
const detail = err instanceof CruxyError
|
|
26
29
|
? `${err.title}${err.cause ? ` — ${err.cause}` : ""}`
|
package/dist/plan/service.js
CHANGED
|
@@ -3,6 +3,7 @@ import { ToolRegistry } from "../tools/index.js";
|
|
|
3
3
|
import { runAgent } from "../agent/loop.js";
|
|
4
4
|
import { promptPlanDecision } from "./approve.js";
|
|
5
5
|
import { executePlan } from "./execute.js";
|
|
6
|
+
import { stepInstruction } from "./step-message.js";
|
|
6
7
|
import { makeSubmitPlanTool } from "./submit-plan.js";
|
|
7
8
|
/**
|
|
8
9
|
* Orchestrates a plan-mode turn (C.31): propose → approve/revise (capped) →
|
|
@@ -13,6 +14,32 @@ import { makeSubmitPlanTool } from "./submit-plan.js";
|
|
|
13
14
|
*
|
|
14
15
|
* A propose phase that ends with NO plan is a completed conversational turn, not
|
|
15
16
|
* an error — see the `!holder.plan` branch below.
|
|
17
|
+
*
|
|
18
|
+
* WHAT SURVIVES THE PROCESS, AND WHAT DOES NOT (plan-durability).
|
|
19
|
+
*
|
|
20
|
+
* The plan object lives in `holder` below for exactly one call of this
|
|
21
|
+
* function. Three things outlive it: the transcript (every step's messages
|
|
22
|
+
* reach the log as they happen, through `args.compact`), the typed record
|
|
23
|
+
* (`args.record` — the approval as a fact, and each step transition), and the
|
|
24
|
+
* checkpoint (see `execute.ts` for what that one actually covers). After a
|
|
25
|
+
* crash, a closed terminal, or Ctrl-C — which is a real SIGINT here, because
|
|
26
|
+
* the TUI releases stdin for the duration of a turn — `--resume` restores the
|
|
27
|
+
* mode and the history and DESCRIBES the plan; it does not continue it.
|
|
28
|
+
*
|
|
29
|
+
* NOT BUILT: a resumable executor. Re-entering `executePlan` at step N on
|
|
30
|
+
* resume needs three things this turn does not have: a re-entry point that
|
|
31
|
+
* rebuilds the plan from the record rather than from a `submit_plan` call; a
|
|
32
|
+
* fresh-consent decision, because the approval was given in another process
|
|
33
|
+
* against a tree that has since changed (and `[g]`'s grants are gone with
|
|
34
|
+
* that process, rightly); and a second checkpoint latch, which would split one
|
|
35
|
+
* undo unit into two. Once the transcript is durable the model can continue
|
|
36
|
+
* from step N+1 on a one-line nudge — it can see its own plan and every "Do
|
|
37
|
+
* ONLY step" message that ran — and there is no recorded interruption where
|
|
38
|
+
* that has failed.
|
|
39
|
+
*
|
|
40
|
+
* REVISIT TRIGGER: one observed case where transcript-driven continuation
|
|
41
|
+
* fails — a resumed session that, told to carry on, redoes a finished step or
|
|
42
|
+
* cannot tell where it stopped. Until then the record is the deliverable.
|
|
16
43
|
*/
|
|
17
44
|
/** Default cap on plan revisions before failing loud. */
|
|
18
45
|
export const MAX_PLAN_REVISIONS = 3;
|
|
@@ -82,6 +109,7 @@ export async function runPlanSession(args) {
|
|
|
82
109
|
router: args.router,
|
|
83
110
|
taskClass: "plan",
|
|
84
111
|
onRequestUsage: args.onRequestUsage,
|
|
112
|
+
compact: args.compact,
|
|
85
113
|
}));
|
|
86
114
|
if (!holder.plan) {
|
|
87
115
|
// NO PLAN IS NOT A FAILURE. The propose phase's registry is read-only plus
|
|
@@ -121,14 +149,18 @@ export async function runPlanSession(args) {
|
|
|
121
149
|
if (decision.kind === "approve-grant") {
|
|
122
150
|
args.planPolicy.enableSafeStepGrants();
|
|
123
151
|
}
|
|
152
|
+
// The approval, recorded as a fact (plan-durability): which choice, and
|
|
153
|
+
// the steps it covered. Written before the first step runs, so a plan
|
|
154
|
+
// that dies in step 1 still has its approval on disk.
|
|
155
|
+
args.record?.planApproved(decision.kind, plan.steps.map((s) => ({ id: s.id, title: s.title, kind: s.kind })));
|
|
124
156
|
// Execute step-by-step, driving one agent turn per step against the full
|
|
125
157
|
// registry. Each step's actions still pass through the U.3 gate.
|
|
126
158
|
const runStep = async (step) => {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
});
|
|
159
|
+
// The scoping instruction plus the whole plan's current statuses
|
|
160
|
+
// (plan-durability): the executor has already marked this step
|
|
161
|
+
// `running` and every earlier one `done` or `failed`, so the message
|
|
162
|
+
// says which — see `step-message.ts` for why the failed case matters.
|
|
163
|
+
messages.push({ role: "user", content: stepInstruction(plan, step) });
|
|
132
164
|
accumulate(await runAgent({
|
|
133
165
|
messages,
|
|
134
166
|
provider: args.provider,
|
|
@@ -142,12 +174,17 @@ export async function runPlanSession(args) {
|
|
|
142
174
|
router: args.router,
|
|
143
175
|
taskClass: "main-turn",
|
|
144
176
|
onRequestUsage: args.onRequestUsage,
|
|
177
|
+
// The step message pushed above reaches the log at this loop's
|
|
178
|
+
// first iteration, BEFORE the step's first model call — so a step
|
|
179
|
+
// that dies still leaves on disk which step it was.
|
|
180
|
+
compact: args.compact,
|
|
145
181
|
}));
|
|
146
182
|
};
|
|
147
183
|
await executePlan(plan, {
|
|
148
184
|
runStep,
|
|
149
185
|
io: args.io,
|
|
150
186
|
renderer: args.renderer,
|
|
187
|
+
record: (step) => args.record?.planStep(step.id, step.status),
|
|
151
188
|
});
|
|
152
189
|
return finish();
|
|
153
190
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-step instruction the executor sends the model (plan-durability).
|
|
3
|
+
*
|
|
4
|
+
* The first paragraph is the C.31 scoping instruction, unchanged: do this one
|
|
5
|
+
* step, nothing else, then stop. What follows it is new — the whole plan with
|
|
6
|
+
* each step's CURRENT status — and it exists because of what the model could
|
|
7
|
+
* not see without it.
|
|
8
|
+
*
|
|
9
|
+
* The model already has the plan: its own `submit_plan` call sits in the
|
|
10
|
+
* history with every step as the tool input. What it never had was status. A
|
|
11
|
+
* step that failed and that the user chose to continue past (`[c]` at the
|
|
12
|
+
* failure prompt) was followed by exactly the same message a successful step
|
|
13
|
+
* was — "Do ONLY step N+1" — so the model walked into step N+1 believing step N
|
|
14
|
+
* had landed, and built on work that was not there. The block below makes a
|
|
15
|
+
* failed step read as failed, in the message that asks for the next one.
|
|
16
|
+
*
|
|
17
|
+
* This is a prompt change, not a tool: no schema, no new tool, and no change to
|
|
18
|
+
* the tool set between phases (the prefix-cache argument in cli#150). It is
|
|
19
|
+
* appended to EVERY step message, a one-step plan included, so the shape the
|
|
20
|
+
* model learns is the same shape every time rather than one that appears from
|
|
21
|
+
* step 2 on. Plain words rather than the renderer's glyphs, because the reader
|
|
22
|
+
* here is the model and the status has to survive as text.
|
|
23
|
+
*/
|
|
24
|
+
export function stepInstruction(plan, step) {
|
|
25
|
+
const head = `The plan is approved. Do ONLY step ${step.id}: ${step.title}. ${step.rationale} ` +
|
|
26
|
+
"Do not start any other step. When this step is complete, stop.";
|
|
27
|
+
const n = plan.steps.length;
|
|
28
|
+
const lines = plan.steps.map((s) => ` ${s.id}. ${statusLabel(s, step)} — ${s.title}`);
|
|
29
|
+
return `${head}\n\nPlan status (${n} step${n === 1 ? "" : "s"}):\n${lines.join("\n")}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* One step's status as the model should read it. `failed` is the label that
|
|
33
|
+
* matters: it names the user's decision, so the model knows the step was not
|
|
34
|
+
* skipped by accident and is not going to be retried by the executor.
|
|
35
|
+
*/
|
|
36
|
+
function statusLabel(s, current) {
|
|
37
|
+
if (s.id === current.id)
|
|
38
|
+
return "this step";
|
|
39
|
+
switch (s.status) {
|
|
40
|
+
case "done":
|
|
41
|
+
return "done";
|
|
42
|
+
case "failed":
|
|
43
|
+
return "FAILED — the user chose to continue past it; do not assume its work exists";
|
|
44
|
+
case "running":
|
|
45
|
+
return "running";
|
|
46
|
+
case "pending":
|
|
47
|
+
return "pending";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -49,7 +49,9 @@ function share(tokens, of) {
|
|
|
49
49
|
* a table into a misaligned mess; the default is unbounded for callers that
|
|
50
50
|
* reflow themselves (the TUI's main column).
|
|
51
51
|
*/
|
|
52
|
-
export function contextReportLines(report, t, width = Infinity
|
|
52
|
+
export function contextReportLines(report, t, width = Infinity,
|
|
53
|
+
/** What compaction has cost so far (P3); omitted → the section is left out. */
|
|
54
|
+
tally) {
|
|
53
55
|
const { reading, compaction } = report;
|
|
54
56
|
const lines = [t.heading("context")];
|
|
55
57
|
// The headline, worded exactly as the panel words it — same estimate, same
|
|
@@ -88,6 +90,13 @@ export function contextReportLines(report, t, width = Infinity) {
|
|
|
88
90
|
lines.push(`${head}${c.excerpt === "" ? "" : t.muted(` — ${c.excerpt}`)}`);
|
|
89
91
|
}
|
|
90
92
|
}
|
|
93
|
+
// ── what compaction has cost so far (P3) ──────────────────────────────────
|
|
94
|
+
if (tally) {
|
|
95
|
+
lines.push("");
|
|
96
|
+
lines.push(t.strong("compaction so far"));
|
|
97
|
+
const so = compactionTallyLines(tally, t, " ");
|
|
98
|
+
lines.push(...(so.length > 0 ? so : [t.muted(" none yet this session")]));
|
|
99
|
+
}
|
|
91
100
|
// ── what compaction would do ──────────────────────────────────────────────
|
|
92
101
|
lines.push("");
|
|
93
102
|
lines.push(t.strong("if you compact now"));
|
|
@@ -113,3 +122,37 @@ export function contextReportLines(report, t, width = Infinity) {
|
|
|
113
122
|
? lines.map((l) => fit(l, width, t.glyph.ellipsis))
|
|
114
123
|
: lines;
|
|
115
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* What compaction has cost a session (P3 context quality), as lines — the
|
|
127
|
+
* one renderer behind `/context`'s "compaction so far" and the one-shot
|
|
128
|
+
* summary, so both describe the tally the same way. Empty when nothing has
|
|
129
|
+
* compacted: the one-shot prints nothing, `/context` says "none yet".
|
|
130
|
+
*
|
|
131
|
+
* The sums cover only the compactions that recorded a cost; when that is
|
|
132
|
+
* fewer than the count (a log written before P3), the line says so rather
|
|
133
|
+
* than presenting a partial sum as the whole. A suspected instruction loss is
|
|
134
|
+
* a WARNING line: it is the one figure here a user should act on.
|
|
135
|
+
*/
|
|
136
|
+
export function compactionTallyLines(tally, t, indent = "") {
|
|
137
|
+
if (tally.count === 0)
|
|
138
|
+
return [];
|
|
139
|
+
const lines = [];
|
|
140
|
+
const key = t.strong("compaction");
|
|
141
|
+
const times = `${tally.count} time${tally.count === 1 ? "" : "s"}`;
|
|
142
|
+
if (tally.measured === 0) {
|
|
143
|
+
lines.push(`${indent}${key} ${times} ${t.muted("(cost not recorded — written before it was measured)")}`);
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
const scope = tally.measured < tally.count
|
|
147
|
+
? t.muted(` (${tally.count - tally.measured} recorded no cost)`)
|
|
148
|
+
: "";
|
|
149
|
+
lines.push(`${indent}${key} ${times}${t.sep}freed ${approx(tally.freedTokens)}${t.sep}` +
|
|
150
|
+
t.muted(`summaries cost ${approx(tally.summaryInputTokens)} in / ${approx(tally.summaryOutputTokens)} out`) +
|
|
151
|
+
scope);
|
|
152
|
+
}
|
|
153
|
+
if (tally.instructionLosses > 0) {
|
|
154
|
+
const n = tally.instructionLosses;
|
|
155
|
+
lines.push(`${indent}${t.warning(`${n} compaction${n === 1 ? "" : "s"} may have dropped an instruction you gave — restate any that still apply`)}`);
|
|
156
|
+
}
|
|
157
|
+
return lines;
|
|
158
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { capacityLevel, formatCapacity, } from "../utils/disk.js";
|
|
2
2
|
import { fit } from "./layout.js";
|
|
3
3
|
import { formatTokens } from "./state.js";
|
|
4
|
+
import { formatDuration } from "./test-view.js";
|
|
4
5
|
/** `key value`, aligned on a fixed gutter so the column is scannable. */
|
|
5
6
|
function row(key, value, t) {
|
|
6
7
|
return ` ${t.muted(key.padEnd(11))} ${value}`;
|
|
@@ -96,6 +97,18 @@ export function sessionStatusLines(status, t, width = Infinity) {
|
|
|
96
97
|
lines.push(row("jobs", detail, t));
|
|
97
98
|
}
|
|
98
99
|
lines.push(row("tools", t.muted(`${status.tools} available to the model`), t));
|
|
100
|
+
// What last ran and how it exited, dated — the record's answer to "was
|
|
101
|
+
// this verified", which is the reader's question to settle, not this
|
|
102
|
+
// row's. "none recorded" is said out loud rather than omitted.
|
|
103
|
+
if (status.verification) {
|
|
104
|
+
const v = status.verification;
|
|
105
|
+
const exit = v.exitCode === null ? "no exit code" : `exit ${v.exitCode}`;
|
|
106
|
+
lines.push(row("verify", `${t.muted(v.tool)} ${v.command} ${t.glyph.arrow} ${v.passed ? t.muted(exit) : t.danger(exit)} ` +
|
|
107
|
+
t.muted(`(${formatDuration(v.durationMs)}) ${t.glyph.sep} ${v.age}`), t));
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
lines.push(row("verify", t.warning("none recorded this session"), t));
|
|
111
|
+
}
|
|
99
112
|
// Roots last: one line each, so a multi-root session shows which repo each
|
|
100
113
|
// change lands in — the fact the startup banner states once and then loses.
|
|
101
114
|
lines.push("");
|
package/dist/session/index.js
CHANGED
|
@@ -7,18 +7,21 @@
|
|
|
7
7
|
* cruxy's fields never make an older one discard a session;
|
|
8
8
|
* - `log.ts` — the writer: one line per event, `0600`, non-fatal on failure;
|
|
9
9
|
* - `replay.ts` — the fold back to state, tolerant of torn/unknown lines;
|
|
10
|
+
* - `recorded-runs.ts` — an ended session's verification record, for
|
|
11
|
+
* `cruxy pr` (P2 verification);
|
|
10
12
|
* - `list.ts` — what the picker and the TUI sidebar both read;
|
|
11
13
|
* - `prune.ts` — retention: what the tree is allowed to keep (#257);
|
|
12
14
|
* - `resume.ts` — `--resume <id>` and the bare-`--resume` picker;
|
|
13
15
|
* - `paths.ts` — the layout, including the subtrees reserved for P3+.
|
|
14
16
|
*/
|
|
15
17
|
export { PROJECTS_DIR_NAME, RESERVED_SUBDIRS, SESSION_FILE_EXT, projectDir, projectKey, projectsDir, reservedDir, sessionFile, } from "./paths.js";
|
|
16
|
-
export { SessionLog } from "./log.js";
|
|
18
|
+
export { SessionLog, } from "./log.js";
|
|
17
19
|
export { claimSession, describeHolder, ownerFile, readOwner, releaseSession, removeOwnerFile, sessionHeldBy, } from "./owner.js";
|
|
18
20
|
export { defaultExportName, exportMarkdown, } from "./export.js";
|
|
19
21
|
export { foldEvents, readEvents, readMeta, replaySession } from "./replay.js";
|
|
22
|
+
export { latestRecordedRuns, recordedRuns } from "./recorded-runs.js";
|
|
20
23
|
export { redactMessages } from "./redact.js";
|
|
21
24
|
export { findSession, isAmbiguous, listSessionRefs, listSessions, matchSessionRefs, sessionFilesByRecency, summarizeSession, } from "./list.js";
|
|
22
25
|
export { pruneSessions, } from "./prune.js";
|
|
23
|
-
export { cwdMismatchWarning, describeSession, loadResume, priorDirectoriesWarning, relativeAge, resolveSessionId, resumeById, resumePicker, shortId, PICKER_LIMIT, } from "./resume.js";
|
|
24
|
-
export { KNOWN_EVENT_KINDS, SESSION_FILE_VERSION, ResumedEventSchema, SessionEventSchema, SessionMetaSchema, } from "./types.js";
|
|
26
|
+
export { cwdMismatchWarning, describeCompactions, describeSession, loadResume, priorDirectoriesWarning, relativeAge, resolveSessionId, resumeById, resumePicker, shortId, PICKER_LIMIT, } from "./resume.js";
|
|
27
|
+
export { KNOWN_EVENT_KINDS, SESSION_FILE_VERSION, emptyCompactionTally, ExternalChangeEventSchema, InstructionLossEventSchema, ResumedEventSchema, SessionEventSchema, SessionMetaSchema, VerificationEventSchema, } from "./types.js";
|
package/dist/session/log.js
CHANGED
|
@@ -5,6 +5,7 @@ import { formatBytes } from "../utils/disk.js";
|
|
|
5
5
|
import { sessionFile } from "./paths.js";
|
|
6
6
|
import { claimSession, describeHolder, releaseSession, sessionHeldBy, } from "./owner.js";
|
|
7
7
|
import { pruneSessions } from "./prune.js";
|
|
8
|
+
import { MAX_FAILURE_NAMES } from "../verification/types.js";
|
|
8
9
|
import { SESSION_FILE_VERSION, } from "./types.js";
|
|
9
10
|
/**
|
|
10
11
|
* How many pruned sessions is worth telling the user about unprompted.
|
|
@@ -124,14 +125,44 @@ export class SessionLog {
|
|
|
124
125
|
messages,
|
|
125
126
|
});
|
|
126
127
|
}
|
|
127
|
-
/**
|
|
128
|
-
|
|
128
|
+
/**
|
|
129
|
+
* An older prefix of `replaced` messages was folded into `summary`. `cost`
|
|
130
|
+
* (P3 context quality) is what the compaction cost — the estimates either
|
|
131
|
+
* side of it and the summarize call's reported usage; the usage halves are
|
|
132
|
+
* omitted, not zeroed, when the provider reported nothing.
|
|
133
|
+
*/
|
|
134
|
+
compaction(replaced, summary, cost) {
|
|
129
135
|
this.write({
|
|
130
136
|
kind: "compaction",
|
|
131
137
|
at: new Date().toISOString(),
|
|
132
138
|
...this.runId(),
|
|
133
139
|
replaced,
|
|
134
140
|
summary,
|
|
141
|
+
...(cost
|
|
142
|
+
? {
|
|
143
|
+
estimatedBefore: cost.estimatedBefore,
|
|
144
|
+
estimatedAfter: cost.estimatedAfter,
|
|
145
|
+
...(cost.summaryInputTokens !== undefined
|
|
146
|
+
? { summaryInputTokens: cost.summaryInputTokens }
|
|
147
|
+
: {}),
|
|
148
|
+
...(cost.summaryOutputTokens !== undefined
|
|
149
|
+
? { summaryOutputTokens: cost.summaryOutputTokens }
|
|
150
|
+
: {}),
|
|
151
|
+
}
|
|
152
|
+
: {}),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* A compaction after which the user's instructions could not all be found
|
|
157
|
+
* in the synopsis (P3 context quality) — the heuristic's finding, recorded
|
|
158
|
+
* so it can be audited after the fact. Bounded by the detector, not here.
|
|
159
|
+
*/
|
|
160
|
+
instructionLoss(sentences) {
|
|
161
|
+
this.write({
|
|
162
|
+
kind: "instruction-loss",
|
|
163
|
+
at: new Date().toISOString(),
|
|
164
|
+
...this.runId(),
|
|
165
|
+
sentences,
|
|
135
166
|
});
|
|
136
167
|
}
|
|
137
168
|
/** `/clear` — history dropped, session kept. */
|
|
@@ -146,6 +177,30 @@ export class SessionLog {
|
|
|
146
177
|
mode(mode) {
|
|
147
178
|
this.write({ kind: "mode", at: new Date().toISOString(), mode });
|
|
148
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* The user approved a plan (plan-durability): the decision kind and the
|
|
182
|
+
* steps as approved. A fact about the session, dated — see the schema for
|
|
183
|
+
* why it is recorded and why a resume never acts on it.
|
|
184
|
+
*/
|
|
185
|
+
planApproved(decision, steps) {
|
|
186
|
+
this.write({
|
|
187
|
+
kind: "plan-approved",
|
|
188
|
+
at: new Date().toISOString(),
|
|
189
|
+
...this.runId(),
|
|
190
|
+
decision,
|
|
191
|
+
steps: steps.map((s) => ({ id: s.id, title: s.title, kind: s.kind })),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
/** One step of the approved plan changed status (plan-durability). */
|
|
195
|
+
planStep(stepId, status) {
|
|
196
|
+
this.write({
|
|
197
|
+
kind: "plan-step",
|
|
198
|
+
at: new Date().toISOString(),
|
|
199
|
+
...this.runId(),
|
|
200
|
+
stepId,
|
|
201
|
+
status,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
149
204
|
/**
|
|
150
205
|
* One turn's token usage. Copied here rather than referenced, because the
|
|
151
206
|
* usage store keeps only its newest 50 runs while a session keeps its own
|
|
@@ -182,6 +237,46 @@ export class SessionLog {
|
|
|
182
237
|
count,
|
|
183
238
|
});
|
|
184
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* One observation (P2 verification): a run that actually executed, or a
|
|
242
|
+
* write refused because its target moved. Written as its own event kind so
|
|
243
|
+
* the fold treats the two apart, and stamped with the turn's run id like
|
|
244
|
+
* every other per-turn event, so "what ran in the turn `cruxy rollback <id>`
|
|
245
|
+
* would undo" is one join, not a guess.
|
|
246
|
+
*
|
|
247
|
+
* The failure NAMES are bounded and the output is not copied: the `append`
|
|
248
|
+
* event already holds the tool_result the model saw. This is an index over
|
|
249
|
+
* what happened.
|
|
250
|
+
*/
|
|
251
|
+
observe(obs) {
|
|
252
|
+
const at = new Date().toISOString();
|
|
253
|
+
if (obs.kind === "verification") {
|
|
254
|
+
this.write({
|
|
255
|
+
kind: "verification",
|
|
256
|
+
at,
|
|
257
|
+
...this.runId(),
|
|
258
|
+
tool: obs.tool,
|
|
259
|
+
command: obs.command,
|
|
260
|
+
...(obs.source !== undefined ? { source: obs.source } : {}),
|
|
261
|
+
passed: obs.passed,
|
|
262
|
+
exitCode: obs.exitCode,
|
|
263
|
+
durationMs: obs.durationMs,
|
|
264
|
+
...(obs.total !== undefined ? { total: obs.total } : {}),
|
|
265
|
+
failureCount: obs.failureCount,
|
|
266
|
+
failureNames: obs.failureNames.slice(0, MAX_FAILURE_NAMES),
|
|
267
|
+
outputTruncated: obs.outputTruncated,
|
|
268
|
+
substrate: obs.substrate,
|
|
269
|
+
});
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
this.write({
|
|
273
|
+
kind: "external-change",
|
|
274
|
+
at,
|
|
275
|
+
...this.runId(),
|
|
276
|
+
path: obs.path,
|
|
277
|
+
what: obs.what,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
185
280
|
/**
|
|
186
281
|
* Enforce retention for this project, once, as this session opens.
|
|
187
282
|
*
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { sessionFilesByRecency } from "./list.js";
|
|
3
|
+
import { readEvents, readMeta } from "./replay.js";
|
|
4
|
+
/**
|
|
5
|
+
* The verification record of a session that has ENDED, read back from its log
|
|
6
|
+
* (P2 verification). `cruxy pr` runs outside any session, so there is no
|
|
7
|
+
* ledger in the process; the log is the durable side of the same record, and
|
|
8
|
+
* the runs it holds are the evidence there is for a PR opened from here.
|
|
9
|
+
*
|
|
10
|
+
* Every `verification` event is returned, in the order it was written — the
|
|
11
|
+
* fold in `replay.ts` keeps only the LAST run (what `/status` shows after a
|
|
12
|
+
* resume); a PR body lists them all. Nothing is filtered by content or age:
|
|
13
|
+
* each run carries its timestamp, and the reader judges whether a run from
|
|
14
|
+
* before the last edit still counts.
|
|
15
|
+
*/
|
|
16
|
+
export function recordedRuns(file) {
|
|
17
|
+
const runs = [];
|
|
18
|
+
for (const event of readEvents(file).events) {
|
|
19
|
+
if (event.kind !== "verification")
|
|
20
|
+
continue;
|
|
21
|
+
runs.push({
|
|
22
|
+
at: event.at,
|
|
23
|
+
tool: event.tool,
|
|
24
|
+
command: event.command,
|
|
25
|
+
...(event.source !== undefined ? { source: event.source } : {}),
|
|
26
|
+
passed: event.passed,
|
|
27
|
+
exitCode: event.exitCode,
|
|
28
|
+
durationMs: event.durationMs,
|
|
29
|
+
...(event.total !== undefined ? { total: event.total } : {}),
|
|
30
|
+
failureCount: event.failureCount,
|
|
31
|
+
failureNames: event.failureNames,
|
|
32
|
+
outputTruncated: event.outputTruncated,
|
|
33
|
+
substrate: event.substrate,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return runs;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The NEWEST session for `cwd` and the runs it recorded, or null when the
|
|
40
|
+
* project has no session whose meta names this directory (`projectKey` can
|
|
41
|
+
* collide — `a-b` and `a/b` — so meta.cwd is compared, as resume does).
|
|
42
|
+
*
|
|
43
|
+
* The newest session, not the newest session that ran something: if the last
|
|
44
|
+
* thing done here recorded no runs, the honest answer is no section, not the
|
|
45
|
+
* runs of an older session cherry-picked because it has some.
|
|
46
|
+
*/
|
|
47
|
+
export function latestRecordedRuns(cwd) {
|
|
48
|
+
const here = path.resolve(cwd);
|
|
49
|
+
for (const ref of sessionFilesByRecency(cwd)) {
|
|
50
|
+
const meta = readMeta(ref.file);
|
|
51
|
+
if (!meta || path.resolve(meta.cwd) !== here)
|
|
52
|
+
continue;
|
|
53
|
+
return { sessionId: meta.sessionId, runs: recordedRuns(ref.file) };
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|