@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.
Files changed (37) hide show
  1. package/dist/agent/instruction-loss.js +204 -0
  2. package/dist/agent/prompts.js +25 -4
  3. package/dist/agent/session.js +165 -33
  4. package/dist/agent/status.js +18 -0
  5. package/dist/cli/commands/pr.js +14 -0
  6. package/dist/cli/commands/run.js +35 -0
  7. package/dist/cli/session-commands.js +3 -1
  8. package/dist/cli/session-factory.js +54 -6
  9. package/dist/config/schema.js +9 -0
  10. package/dist/mcp/bounds.js +8 -1
  11. package/dist/plan/execute.js +4 -1
  12. package/dist/plan/service.js +42 -5
  13. package/dist/plan/step-message.js +49 -0
  14. package/dist/render/context-view.js +44 -1
  15. package/dist/render/status-view.js +13 -0
  16. package/dist/session/index.js +6 -3
  17. package/dist/session/log.js +97 -2
  18. package/dist/session/recorded-runs.js +56 -0
  19. package/dist/session/replay.js +75 -1
  20. package/dist/session/resume.js +88 -0
  21. package/dist/session/types.js +158 -0
  22. package/dist/testing/run-tests-tool.js +3 -1
  23. package/dist/tools/create-pull-request.js +8 -1
  24. package/dist/tools/file/apply-patch.js +6 -2
  25. package/dist/tools/file/edit-file.js +6 -2
  26. package/dist/tools/file/snapshot.js +9 -4
  27. package/dist/tools/file/write-file.js +7 -2
  28. package/dist/tools/registry.js +39 -8
  29. package/dist/tools/schema-depth.js +79 -6
  30. package/dist/tools/shell/exec.js +7 -0
  31. package/dist/tools/shell/run-command.js +45 -21
  32. package/dist/vcs/generate.js +48 -6
  33. package/dist/verification/index.js +15 -0
  34. package/dist/verification/ledger.js +99 -0
  35. package/dist/verification/types.js +26 -0
  36. package/dist/verification/view.js +87 -0
  37. package/package.json +1 -1
@@ -33,6 +33,7 @@ export function execShell(command, ctx) {
33
33
  * throw coded errors from `sandbox.exec` and propagate — never caught here). */
34
34
  async function runSandboxed(command, ctx) {
35
35
  const { timeoutMs, maxOutputBytes } = ctx.config.shell;
36
+ const startedAt = Date.now();
36
37
  const result = await ctx.sandbox.exec(command, {
37
38
  cwd: ctx.cwd,
38
39
  timeoutMs,
@@ -45,11 +46,13 @@ async function runSandboxed(command, ctx) {
45
46
  signal: null,
46
47
  output: result.output,
47
48
  truncated: result.outputTruncated,
49
+ durationMs: Date.now() - startedAt,
48
50
  };
49
51
  }
50
52
  /** Spawn the command on the host, capture bounded output, enforce the timeout. */
51
53
  function runBounded(command, ctx) {
52
54
  const { timeoutMs, maxOutputBytes } = ctx.config.shell;
55
+ const startedAt = Date.now();
53
56
  return new Promise((resolve) => {
54
57
  // `spawnShell` routes through the resolved shell (POSIX default shell, Git
55
58
  // Bash, or PowerShell — see resolve-shell.ts) as the head of a killable tree
@@ -96,6 +99,7 @@ function runBounded(command, ctx) {
96
99
  signal: null,
97
100
  output: "",
98
101
  truncated,
102
+ durationMs: Date.now() - startedAt,
99
103
  });
100
104
  }, timeoutMs);
101
105
  // External cancellation (C.33): a cancelled sibling's in-flight command is
@@ -114,6 +118,7 @@ function runBounded(command, ctx) {
114
118
  signal: "SIGKILL",
115
119
  output: Buffer.concat(chunks).toString("utf8"),
116
120
  truncated,
121
+ durationMs: Date.now() - startedAt,
117
122
  });
118
123
  }
119
124
  if (abortSignal?.aborted) {
@@ -136,6 +141,7 @@ function runBounded(command, ctx) {
136
141
  output: "",
137
142
  truncated: false,
138
143
  spawnError: err.message,
144
+ durationMs: Date.now() - startedAt,
139
145
  });
140
146
  });
141
147
  child.on("close", (code, closeSignal) => {
@@ -150,6 +156,7 @@ function runBounded(command, ctx) {
150
156
  signal: closeSignal ?? null,
151
157
  output: Buffer.concat(chunks).toString("utf8"),
152
158
  truncated,
159
+ durationMs: Date.now() - startedAt,
153
160
  });
154
161
  });
155
162
  });
@@ -9,27 +9,51 @@ import { runGatedShell } from "./exec.js";
9
9
  * C.19 hook runner — the single, un-bypassable shell path); this tool only maps
10
10
  * the structured result back onto its `ToolResult` framing.
11
11
  */
12
- export const runCommandTool = {
13
- name: "run_command",
14
- description: "Run a shell command in the project root and return its exit code, stdout, and stderr. Use this for builds, tests, linters, and git. Prefer the dedicated file tools (read_file/write_file/edit_file) over shelling out to cat/sed/echo for inspecting or editing files. A non-zero exit is still returned so you can react to the failure output.",
15
- parameters: z.object({
16
- command: z
17
- .string()
18
- .describe("The shell command to run (executed via the system shell)."),
19
- }),
20
- async execute(input, ctx) {
21
- // A thrown CRUXY_E_APPROVAL_REQUIRED (non-interactive) / sandbox coded error
22
- // propagates from runGatedShell — do not catch (fail loud).
23
- const outcome = await runGatedShell(input.command, ctx);
24
- if (!outcome.approved) {
25
- return {
26
- ok: false,
27
- error: outcome.rejection ?? "command denied by the user",
28
- };
29
- }
30
- return mapExecResult(outcome.exec, ctx);
31
- },
32
- };
12
+ /** Build the `run_command` tool. `deps.onResult` is the record's seam. */
13
+ export function makeRunCommandTool(deps = {}) {
14
+ return {
15
+ name: "run_command",
16
+ description: "Run a shell command in the project root and return its exit code, stdout, and stderr. Use this for builds, tests, linters, and git. Prefer the dedicated file tools (read_file/write_file/edit_file) over shelling out to cat/sed/echo for inspecting or editing files. A non-zero exit is still returned so you can react to the failure output.",
17
+ parameters: z.object({
18
+ command: z
19
+ .string()
20
+ .describe("The shell command to run (executed via the system shell)."),
21
+ }),
22
+ async execute(input, ctx) {
23
+ // A thrown CRUXY_E_APPROVAL_REQUIRED (non-interactive) / sandbox coded error
24
+ // propagates from runGatedShell — do not catch (fail loud).
25
+ const outcome = await runGatedShell(input.command, ctx);
26
+ if (!outcome.approved) {
27
+ return {
28
+ ok: false,
29
+ error: outcome.rejection ?? "command denied by the user",
30
+ };
31
+ }
32
+ const exec = outcome.exec;
33
+ // Report from the same object the model's string is built from — and only
34
+ // when something ran. A spawn error never started, so there is no run.
35
+ if (exec.spawnError === undefined) {
36
+ try {
37
+ deps.onResult?.({
38
+ command: input.command,
39
+ exitCode: exec.exitCode,
40
+ timedOut: exec.timedOut,
41
+ durationMs: exec.durationMs,
42
+ outputTruncated: exec.truncated,
43
+ substrate: ctx.sandbox ? "sandbox" : "host",
44
+ });
45
+ }
46
+ catch {
47
+ // A recording problem is not a command problem.
48
+ }
49
+ }
50
+ return mapExecResult(exec, ctx);
51
+ },
52
+ };
53
+ }
54
+ /** The default instance — no observer. Registries that record build their own
55
+ * via {@link makeRunCommandTool}; everything else is byte-identical. */
56
+ export const runCommandTool = makeRunCommandTool();
33
57
  /**
34
58
  * Map a {@link ShellExecResult} onto the tool's `ToolResult` — the same "exit
35
59
  * code N" framing, truncation note, and timeout / spawn-error messages as
@@ -1,3 +1,4 @@
1
+ import { verificationMarkdown } from "../verification/view.js";
1
2
  /**
2
3
  * Turn a diff + session context into the PR publish content (C.15): a
3
4
  * conventional-commit subject, a structured body, a branch name, and the PR
@@ -8,6 +9,14 @@
8
9
  *
9
10
  * Secrets never leave: the diff is redacted before the LLM sees it, and the
10
11
  * generated bodies are redacted again (defense-in-depth).
12
+ *
13
+ * The `## Verification` section is the record's, on BOTH paths (P2
14
+ * verification). The model is handed a diff; it has no way to know what ran,
15
+ * and asking it to write that section produced the same manufactured claim
16
+ * cli#309 removed from the fallback body, one layer up. So the prompt no
17
+ * longer asks for it, `finalize` drops one the model writes anyway, and the
18
+ * section that does appear is built here from `GenerateInput.verification` —
19
+ * the runs that actually executed, with their exit codes and timestamps.
11
20
  */
12
21
  /** Conventional-commit types accepted by `@commitlint/config-conventional`. */
13
22
  export const CONVENTIONAL_TYPES = [
@@ -183,11 +192,19 @@ export async function generateWithLlm(provider, input, opts = {}) {
183
192
  /** Shared final assembly: normalize the subject, redact bodies, pick a branch. */
184
193
  function finalize(rawSubject, rawBody, input, extra = {}) {
185
194
  const commitSubject = normalizeSubject(rawSubject, input.scopes);
186
- const body = redactSecrets((rawBody ?? extra.prBody ?? "").trim() ||
187
- assembleBody({
188
- what: rawSubject || commitSubject,
189
- verification: "`pnpm -r typecheck && pnpm lint && pnpm -r test`",
190
- }));
195
+ // The body's prose is the caller's or the model's; its `## Verification`
196
+ // section is the record's. Any such section in the supplied body is dropped
197
+ // first the deterministic guarantee, same posture as `normalizeSubject`:
198
+ // the prompt says not to write one, and this holds even if the model slips.
199
+ // When no body was supplied, the fallback carries ONLY what is known: the
200
+ // subject. (It used to add a Verification section naming a typecheck + lint
201
+ // + test command on every PR, whether or not anything ran — cli#309.) Then
202
+ // the record's section is appended, when there is a record: a body that
203
+ // names what ran comes only from something that knows what ran, and a
204
+ // missing section is the honest shape for missing evidence.
205
+ const prose = stripVerificationSection((rawBody ?? extra.prBody ?? "").trim()) ||
206
+ assembleBody({ what: rawSubject || commitSubject });
207
+ const body = redactSecrets(withRecordedVerification(prose, input.verification));
191
208
  const commitBody = redactSecrets((extra.commitBody ?? rawBody ?? "").trim());
192
209
  const branchName = input.currentBranch ??
193
210
  sanitizeBranch(extra.branchName) ??
@@ -200,6 +217,31 @@ function finalize(rawSubject, rawBody, input, extra = {}) {
200
217
  prBody: body,
201
218
  };
202
219
  }
220
+ /**
221
+ * Drop a `## Verification` section — its heading through the line before the
222
+ * next `## ` heading, or the end — from a markdown body. Only the H2 shape the
223
+ * prompt itself describes is recognised; nothing else in the prose is touched.
224
+ */
225
+ export function stripVerificationSection(markdown) {
226
+ const out = [];
227
+ let dropping = false;
228
+ for (const line of markdown.split("\n")) {
229
+ if (/^##\s/.test(line))
230
+ dropping = /^##\s+verification\b/i.test(line);
231
+ if (!dropping)
232
+ out.push(line);
233
+ }
234
+ return out.join("\n").trim();
235
+ }
236
+ /** Append the record's `## Verification` section, when there is a record. */
237
+ function withRecordedVerification(body, verification) {
238
+ if (!verification)
239
+ return body;
240
+ const md = verificationMarkdown(verification.runs, {
241
+ ...(verification.from ? { from: verification.from } : {}),
242
+ });
243
+ return md === null ? body : `${body}\n\n## Verification\n\n${md}`;
244
+ }
203
245
  /** Accept a model-proposed branch only if it's a plausible ref; else null. */
204
246
  function sanitizeBranch(name) {
205
247
  if (!name)
@@ -251,7 +293,7 @@ function buildSystemPrompt(input) {
251
293
  if (input.scopes.length > 0) {
252
294
  lines.push(`- If you use a scope, it must be one of: ${input.scopes.join(", ")}.`);
253
295
  }
254
- lines.push("", "prBody: GitHub-flavored markdown with sections '## What changed', '## Why', '## Verification'.", "branchName: short kebab-case `type/slug`, no spaces.", "Never include secrets, tokens, or credentials in any field.");
296
+ lines.push("", "prBody: GitHub-flavored markdown with sections '## What changed' and '## Why'.", "Do NOT write a '## Verification' section, and do not state what was run, tested, built, or checked anywhere in prBody or commitBody:", "you have the diff, not the record of what executed. That section is appended afterwards from cruxy's record of the commands that actually ran, and one you write is dropped.", "branchName: short kebab-case `type/slug`, no spaces.", "Never include secrets, tokens, or credentials in any field.");
255
297
  if (input.skillBody) {
256
298
  lines.push("", "Repository commit conventions (authoritative):", input.skillBody);
257
299
  }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The verification record (P2 verification): evidence of what ran and how it
3
+ * exited, keyed to the turn it ran in. Evidence, not enforcement — see
4
+ * `types.ts` for why that decision stands.
5
+ *
6
+ * - `types.ts` — the record shapes and the honesty rules;
7
+ * - `ledger.ts` — the in-memory side a live session's surfaces read;
8
+ * - `view.ts` — the one formatter every surface shares.
9
+ *
10
+ * The durable side is the session log (`session/types.ts`: `verification` and
11
+ * `external-change` events), written from the same call as the ledger.
12
+ */
13
+ export * from "./types.js";
14
+ export * from "./ledger.js";
15
+ export * from "./view.js";
@@ -0,0 +1,99 @@
1
+ import { MAX_FAILURE_NAMES, } from "./types.js";
2
+ /**
3
+ * The in-memory side of the verification record — what the surfaces of a LIVE
4
+ * session read. The durable side is the session log, reached through `sink`.
5
+ *
6
+ * The split mirrors `Session.lastRun` against the usage store: the process
7
+ * keeps the few facts its own surfaces ask for (this turn's runs for the
8
+ * one-shot summary, the last run for `/status`), and the log keeps everything
9
+ * for as long as the session survives retention. Neither re-derives from the
10
+ * other; both are written from the same call.
11
+ *
12
+ * ONE LEDGER PER SESSION, shared by every tool instance and every subagent
13
+ * the session drives. A subagent's test run is still a run that happened in
14
+ * this turn, and a fan-out's refused write is still a file that moved.
15
+ */
16
+ export class VerificationLedger {
17
+ deps;
18
+ last;
19
+ /** Runs recorded this session, oldest first, bounded (see `SESSION_CAP`). */
20
+ runs = [];
21
+ turnRuns = [];
22
+ turnChanges = [];
23
+ constructor(deps = {}) {
24
+ this.deps = deps;
25
+ }
26
+ /**
27
+ * A new user turn begins: this turn's lists start empty. The session-wide
28
+ * facts (`lastVerification`, `sessionRuns`) are untouched — a run from an
29
+ * earlier turn is still the last thing that ran.
30
+ */
31
+ beginTurn() {
32
+ this.turnRuns = [];
33
+ this.turnChanges = [];
34
+ }
35
+ /**
36
+ * Record one observation: stamp it, keep it, and hand it to the sink.
37
+ *
38
+ * The sink is wrapped because a recording problem is not a test-run problem.
39
+ * The run already happened and its result is on its way to the model; a
40
+ * session log that cannot write must degrade to "not saved", never fail the
41
+ * tool call that produced the fact.
42
+ */
43
+ record(obs) {
44
+ const at = this.deps.now?.() ?? new Date().toISOString();
45
+ if (obs.kind === "verification") {
46
+ const rec = {
47
+ ...withoutKind(obs),
48
+ failureNames: obs.failureNames.slice(0, MAX_FAILURE_NAMES),
49
+ at,
50
+ };
51
+ this.last = rec;
52
+ this.turnRuns.push(rec);
53
+ this.runs.push(rec);
54
+ if (this.runs.length > SESSION_CAP)
55
+ this.runs.shift();
56
+ }
57
+ else {
58
+ this.turnChanges.push({ ...withoutKind(obs), at });
59
+ }
60
+ try {
61
+ this.deps.sink?.(obs);
62
+ }
63
+ catch {
64
+ // Persistence is not allowed to fail the run it is recording.
65
+ }
66
+ }
67
+ /**
68
+ * Adopt the last run a resumed session's log recorded, so `/status` after
69
+ * `--resume` shows it rather than "none this session". Absent when the log
70
+ * pre-dates the record — absence is "not recorded", never "did not happen".
71
+ */
72
+ seed(last) {
73
+ if (!last)
74
+ return;
75
+ this.last = last;
76
+ this.runs = [last];
77
+ }
78
+ /** The most recent run that executed, this session (or seeded on resume). */
79
+ get lastVerification() {
80
+ return this.last;
81
+ }
82
+ /** Every run recorded this session, oldest first, bounded. */
83
+ sessionRuns() {
84
+ return this.runs;
85
+ }
86
+ /** What THIS turn recorded — the one-shot summary's unit. */
87
+ turn() {
88
+ return { verifications: this.turnRuns, externalChanges: this.turnChanges };
89
+ }
90
+ }
91
+ /** Most runs kept in memory per session. The log keeps the rest. */
92
+ export const SESSION_CAP = 50;
93
+ /** The observation minus its discriminant — the record is what happened, and
94
+ * `kind` was only ever the routing tag. */
95
+ function withoutKind(obs) {
96
+ const copy = { ...obs };
97
+ delete copy.kind;
98
+ return copy;
99
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The verification record (P2 verification): what ran, and how it exited.
3
+ *
4
+ * The loop's only completion criterion is "the model emitted no tool calls"
5
+ * (`agent/loop.ts`). Nothing checks that tests ran, a build passed, or a diff
6
+ * was reviewed, and the system prompt's instruction to verify is exactly that:
7
+ * an instruction. This module makes the absence VISIBLE without making the
8
+ * presence MANDATORY — C.13 shipped voluntary verification and delegated the
9
+ * iteration to the ordinary loop, and that decision stands. Three reasons it
10
+ * is not reversed here:
11
+ *
12
+ * - `run_tests`' failing-run breaker latches for the turn after
13
+ * `test.maxIterations` runs, so a turn that MUST end green could never end.
14
+ * - A project with no test command has no defensible verdict: the coded
15
+ * not-found error already tells the model to ask the user, not to guess.
16
+ * - Enforcement changes the one-shot exit contract — "completed but
17
+ * unverified" would be a new stop kind, and CI reads that exit code today.
18
+ *
19
+ * So this is EVIDENCE: a record of each run that actually executed, keyed to
20
+ * the turn it ran in, read back by the surfaces a user checks afterwards. What
21
+ * it never does is INFER. `passed` is the exit code and nothing else; nothing
22
+ * here decides that a command was "a build" or "the tests" from its text. The
23
+ * record says what ran and how it exited; the reader judges.
24
+ */
25
+ /** Most failure names kept per record — an index, not a transcript. */
26
+ export const MAX_FAILURE_NAMES = 5;
@@ -0,0 +1,87 @@
1
+ import { formatDuration } from "../render/test-view.js";
2
+ /**
3
+ * Rendering the verification record — the shared formatter behind every
4
+ * surface that reads it, so the one-shot summary, `/status`, the resume
5
+ * notice and the PR body all describe a run the same way.
6
+ *
7
+ * The honesty rules are `verification/types`'s, carried through rather than
8
+ * re-derived: the exit code is the fact, the tool and command are named as
9
+ * they ran, and nothing here calls a run "the build" or "the tests" from its
10
+ * text. A non-zero exit is shown as that exit — never as "1 test failed"
11
+ * unless a parser actually counted one.
12
+ */
13
+ /** `run_tests pnpm test → exit 0 (4.2s)`, coloured by exit. */
14
+ export function describeRun(rec, t) {
15
+ const exit = rec.exitCode === null ? "no exit code" : `exit ${rec.exitCode}`;
16
+ const verdict = rec.passed ? t.muted(exit) : t.danger(exit);
17
+ const counts = countClause(rec);
18
+ return (`${t.muted(rec.tool)} ${rec.command} ${t.glyph.arrow} ${verdict}` +
19
+ (counts ? ` ${t.danger(counts)}` : "") +
20
+ ` ${t.muted(`(${formatDuration(rec.durationMs)})`)}`);
21
+ }
22
+ /** "3 of 128 failed" / "3 failures" / "" — only what a parser actually counted. */
23
+ function countClause(rec) {
24
+ if (rec.passed)
25
+ return "";
26
+ if (rec.total !== undefined && rec.failureCount > 0)
27
+ return `${rec.failureCount} of ${rec.total} failed`;
28
+ if (rec.failureCount > 0)
29
+ return `${rec.failureCount} failure${rec.failureCount === 1 ? "" : "s"}`;
30
+ return "";
31
+ }
32
+ /**
33
+ * The one-shot summary's block (the CI reader). Printed next to the exit code
34
+ * CI already trusts, for BOTH a completed run and one that gave up, so the
35
+ * line that says "completed" is never read on its own.
36
+ *
37
+ * "none ran this turn" is a sentence, not an omitted row: the whole point of
38
+ * the record is that the absence of verification is visible.
39
+ */
40
+ export function verificationTurnLines(turn, t) {
41
+ const lines = [];
42
+ const key = t.strong("verification");
43
+ if (turn.verifications.length === 0) {
44
+ lines.push(`${key} ${t.warning("none ran this turn")}`);
45
+ }
46
+ else {
47
+ turn.verifications.forEach((rec, i) => {
48
+ lines.push(`${i === 0 ? key : " ".repeat(12)} ${describeRun(rec, t)}`);
49
+ });
50
+ }
51
+ turn.externalChanges.forEach((change, i) => {
52
+ lines.push(`${i === 0 ? t.strong("refused") : " ".repeat(7)} ${t.warning(`${change.path} ${change.what} — nothing was written`)}`);
53
+ });
54
+ return lines;
55
+ }
56
+ /**
57
+ * The `## Verification` section for a PR body, from the runs a session
58
+ * recorded — newest first, with their timestamps, so a run from before the
59
+ * last edit is dated rather than presented as current. Returns `null` when
60
+ * nothing ran: a missing section is the honest shape for missing evidence.
61
+ *
62
+ * `from` names where the runs were read from when it is NOT the live session
63
+ * — `cruxy pr` runs outside any session and reads the project's latest log —
64
+ * so the header never says "this session" about a session that has ended.
65
+ */
66
+ export function verificationMarkdown(runs, opts = {}) {
67
+ if (runs.length === 0)
68
+ return null;
69
+ const shown = [...runs].reverse().slice(0, opts.limit ?? MARKDOWN_LIMIT);
70
+ const items = shown.map((rec) => {
71
+ const exit = rec.exitCode === null ? "no exit code" : `exit ${rec.exitCode}`;
72
+ const counts = countClause(rec);
73
+ return (`- \`${rec.command}\` — ${exit}${counts ? ` (${counts})` : ""}, ` +
74
+ `${formatDuration(rec.durationMs)}, via ${rec.tool} at ${rec.at}`);
75
+ });
76
+ const omitted = runs.length - shown.length;
77
+ return [
78
+ `Runs recorded by cruxy ${opts.from ?? "this session"}, newest first (exit codes as observed; nothing inferred):`,
79
+ "",
80
+ ...items,
81
+ ...(omitted > 0
82
+ ? ["", `…and ${omitted} earlier run${omitted === 1 ? "" : "s"}.`]
83
+ : []),
84
+ ].join("\n");
85
+ }
86
+ /** Most runs listed in a PR body. */
87
+ export const MARKDOWN_LIMIT = 8;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "1.11.2",
3
+ "version": "1.11.3",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {