@cruxy/cli 1.11.1 → 1.11.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) 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/checkpoint/service.js +44 -3
  6. package/dist/cli/commands/pr.js +14 -0
  7. package/dist/cli/commands/run.js +35 -0
  8. package/dist/cli/commands/sessions.js +8 -0
  9. package/dist/cli/session-commands.js +3 -1
  10. package/dist/cli/session-factory.js +54 -6
  11. package/dist/config/schema.js +9 -0
  12. package/dist/errors/constructors.js +15 -6
  13. package/dist/errors/types.js +7 -0
  14. package/dist/indexing/embedder.js +34 -11
  15. package/dist/indexing/model-cache.js +399 -0
  16. package/dist/mcp/bounds.js +8 -1
  17. package/dist/plan/execute.js +4 -1
  18. package/dist/plan/service.js +42 -5
  19. package/dist/plan/step-message.js +49 -0
  20. package/dist/render/context-view.js +44 -1
  21. package/dist/render/status-view.js +13 -0
  22. package/dist/session/index.js +7 -3
  23. package/dist/session/log.js +163 -2
  24. package/dist/session/owner.js +123 -0
  25. package/dist/session/prune.js +11 -0
  26. package/dist/session/recorded-runs.js +56 -0
  27. package/dist/session/replay.js +75 -1
  28. package/dist/session/resume.js +110 -3
  29. package/dist/session/types.js +158 -0
  30. package/dist/subagent/orchestrator.js +2 -2
  31. package/dist/subagent/registry-scope.js +28 -5
  32. package/dist/testing/run-tests-tool.js +3 -1
  33. package/dist/tools/create-pull-request.js +8 -1
  34. package/dist/tools/file/apply-patch.js +53 -23
  35. package/dist/tools/file/edit-file.js +19 -1
  36. package/dist/tools/file/snapshot.js +68 -0
  37. package/dist/tools/file/write-file.js +31 -5
  38. package/dist/tools/registry.js +39 -8
  39. package/dist/tools/schema-depth.js +79 -6
  40. package/dist/tools/shell/exec.js +7 -0
  41. package/dist/tools/shell/run-command.js +45 -21
  42. package/dist/utils/process-owner.js +107 -0
  43. package/dist/vcs/generate.js +48 -6
  44. package/dist/verification/index.js +15 -0
  45. package/dist/verification/ledger.js +99 -0
  46. package/dist/verification/types.js +26 -0
  47. package/dist/verification/view.js +87 -0
  48. package/package.json +3 -2
@@ -6,6 +6,7 @@ import { authMissingKey, shouldUseColor } from "../../errors/index.js";
6
6
  import { themeForColor } from "../../theme/index.js";
7
7
  import { ApprovalService, defaultPromptIO, InteractivePolicy, SessionAllowlist, } from "../../approval/index.js";
8
8
  import { resolveTaskModel, routerForConfig } from "../../routing/index.js";
9
+ import { latestRecordedRuns, shortId } from "../../session/index.js";
9
10
  import { createForgeProvider, createPrService, generateWithLlm, loadCommitGuidance, resolveForgeToken, } from "../../vcs/index.js";
10
11
  /**
11
12
  * `cruxy pr` — turn the current changes into a pull request (C.15). Generates the
@@ -48,6 +49,18 @@ export function prCommand() {
48
49
  // resolveForgeToken throws CRUXY_E_FORGE_AUTH (exit 4) if none is found.
49
50
  const forge = createForgeProvider(resolveForgeToken());
50
51
  const guidance = await loadCommitGuidance(cwd);
52
+ // The verification record (P2): this command runs outside any
53
+ // session, so the evidence is the project's latest session log — its
54
+ // runs, each dated, under a header naming that session rather than
55
+ // "this session". The model never writes the section (generate.ts);
56
+ // no session here, or one that ran nothing → no section at all.
57
+ const recorded = latestRecordedRuns(cwd);
58
+ const verification = recorded
59
+ ? {
60
+ runs: recorded.runs,
61
+ from: `in the latest session for this project (${shortId(recorded.sessionId)})`,
62
+ }
63
+ : undefined;
51
64
  // The allowlist here is provably inert, and stated rather than implied
52
65
  // (cli#251). `cruxy pr` submits exactly one kind of action — `vcs` — and
53
66
  // `vcsRequest` gives every one of them `scope: {kind: "none"}` and an
@@ -70,6 +83,7 @@ export function prCommand() {
70
83
  ...i,
71
84
  scopes: guidance.scopes,
72
85
  skillBody: guidance.skillBody,
86
+ ...(verification ? { verification } : {}),
73
87
  }, { model: genModel }),
74
88
  });
75
89
  logger.info(t.muted("generating pull request content…"));
@@ -16,6 +16,8 @@ import { apiKeyEnvVar, classifyCredentialLifetime, configSourceFile, globalDir,
16
16
  import { agentIncomplete, authMissingKey, shouldUseColor, usageError, } from "../../errors/index.js";
17
17
  import { createRenderer } from "../../render/index.js";
18
18
  import { themeForColor } from "../../theme/index.js";
19
+ import { verificationTurnLines } from "../../verification/index.js";
20
+ import { compactionTallyLines } from "../../render/context-view.js";
19
21
  import { summarizeRuns, renderSummary, } from "../../usage/index.js";
20
22
  import { CheckpointGate } from "../../checkpoint/index.js";
21
23
  import { SandboxService } from "../../sandbox/index.js";
@@ -383,6 +385,19 @@ export async function executeRun(promptParts, opts) {
383
385
  usage: restore.usage,
384
386
  sessionId: restore.meta.sessionId,
385
387
  mode: restore.mode,
388
+ // The last run the log recorded, so `/status` after a resume
389
+ // says what last ran instead of "none". Not re-derived.
390
+ ...(restore.lastVerification
391
+ ? { lastVerification: restore.lastVerification }
392
+ : {}),
393
+ // And what compaction cost it so far (P3), so `/context`
394
+ // continues the tally instead of restarting at zero.
395
+ compactions: restore.compactions,
396
+ // `restore.plan` is deliberately NOT passed (plan-durability).
397
+ // The resume notice has already described it. Handing it to
398
+ // the session would invite exactly the thing the record must
399
+ // not do: treat a past `approve-grant` as consent in this
400
+ // process. The allowlist starts empty; every action asks.
386
401
  },
387
402
  }
388
403
  : {}),
@@ -543,6 +558,12 @@ export async function executeRun(promptParts, opts) {
543
558
  if (config.usage.enabled && session.lastRun) {
544
559
  printRunUsage(session.lastRun);
545
560
  }
561
+ // The verification record (P2 verification), next to the exit code CI
562
+ // already trusts: what ran this turn and how it exited — or that nothing
563
+ // did. Printed for BOTH a completed run and one that gave up below, and
564
+ // whether or not usage is on, so "completed" is never read on its own.
565
+ // The record is evidence; the exit code below is still decided by `stop`.
566
+ printTurnVerification(session);
546
567
  // Fail loud on a non-completed stop (#3/#5): a one-shot run that hit the
547
568
  // iteration cap or a token budget (or was cancelled) MUST exit non-zero —
548
569
  // otherwise CI reads a gave-up run as success. The partial history already
@@ -561,6 +582,20 @@ export async function executeRun(promptParts, opts) {
561
582
  }
562
583
  }
563
584
  /** Render the just-finished run's usage as a single themed line (C.22). */
585
+ /** The one-shot summary's verification block — see `verification/view.ts`. */
586
+ function printTurnVerification(session) {
587
+ const t = themeForColor(shouldUseColor(process.stdout));
588
+ for (const line of verificationTurnLines(session.turnVerification(), t)) {
589
+ logger.print(line);
590
+ }
591
+ // What compaction cost this run (P3 context quality), next to the
592
+ // verification block and for the same reason: a one-shot run that
593
+ // compacted three times and may have dropped an instruction is a fact CI
594
+ // reads nowhere else. Silent when nothing compacted.
595
+ for (const line of compactionTallyLines(session.compactions(), t)) {
596
+ logger.print(line);
597
+ }
598
+ }
564
599
  function printRunUsage(record) {
565
600
  if (record.entries.length === 0)
566
601
  return;
@@ -7,6 +7,7 @@ import { SESSION_FILE_EXT, matchSessionRefs, listSessionRefs, pruneSessions, ses
7
7
  import { themeForColor } from "../../theme/index.js";
8
8
  import { formatBytes } from "../../utils/disk.js";
9
9
  import { logger } from "../../utils/logger.js";
10
+ import { describeHolder, removeOwnerFile, sessionHeldBy, } from "../../session/owner.js";
10
11
  /**
11
12
  * `cruxy sessions` (#257) — see and bound what `~/.cruxy/projects/<project>/`
12
13
  * is holding.
@@ -149,8 +150,15 @@ export function sessionsCommand() {
149
150
  "sessions are per-directory; check you are in the right one",
150
151
  ]);
151
152
  }
153
+ // Not while another cruxy is writing it (P1): its next append would
154
+ // recreate the file headless and leave a session nothing can load.
155
+ const holder = sessionHeldBy(matches[0].file);
156
+ if (holder) {
157
+ throw usageError(`session ${shortId(matches[0].sessionId)} is open in another cruxy (${describeHolder(holder)})`, ["quit that cruxy first, then delete it"]);
158
+ }
152
159
  try {
153
160
  unlinkSync(matches[0].file);
161
+ removeOwnerFile(matches[0].file);
154
162
  }
155
163
  catch (err) {
156
164
  throw usageError(`could not delete session ${shortId(matches[0].sessionId)}`, [`${matches[0].file}: ${err.message}`]);
@@ -680,7 +680,9 @@ function handleExport(input, ctx) {
680
680
  function handleContext(ctx) {
681
681
  const { out, session } = ctx;
682
682
  const report = contextReport(session.messages, session.toolContext.config.context);
683
- for (const line of contextReportLines(report, out.theme)) {
683
+ // Plus what compaction has already cost (P3) — the one figure the
684
+ // preview above cannot give, because it is a record, not an estimate.
685
+ for (const line of contextReportLines(report, out.theme, Infinity, session.compactions())) {
684
686
  out.print(out.fit(line));
685
687
  }
686
688
  }
@@ -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
- onTestResult: renderer
262
- ? (result, command) => renderer.testResult({
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
- : undefined,
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;
@@ -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();
@@ -545,16 +545,25 @@ export function indexEmbedderUnavailable(underlying) {
545
545
  * exact "reads like success, isn't" trap C.17 forbids. The `search_codebase`
546
546
  * tool instead surfaces it as a tool error and points the model at `grep_files`.
547
547
  */
548
- export function indexEmbedderDownloadFailed(underlying) {
548
+ export function indexEmbedderDownloadFailed(underlying, opts = {}) {
549
+ // A refused archive is not a connectivity problem: the download succeeded
550
+ // and cruxy's extractor rejected an entry (path traversal, a link, an
551
+ // unexpected layout). Retrying will refuse it again, so say so.
552
+ const nextSteps = opts.archiveRefused
553
+ ? [
554
+ "the model archive was refused by cruxy's extraction guard and nothing was written; this is not a connectivity problem",
555
+ "do not retry blindly — if it persists, report it with the message above (the upstream archive may have changed shape)",
556
+ ]
557
+ : [
558
+ "check your internet connection — the model (bge-small-en-v1.5, ~77 MB) downloads once on first use",
559
+ "if you are behind a proxy or firewall, allow access to storage.googleapis.com (the qdrant-fastembed bucket); HTTPS_PROXY / NO_PROXY are honored",
560
+ "once the download succeeds it is cached under ~/.cruxy/models and never re-fetched",
561
+ ];
549
562
  return new CruxyError({
550
563
  code: ErrorCode.IndexEmbedderDownloadFailed,
551
564
  title: "the local embedding model could not be downloaded or initialized",
552
565
  cause: messageOf(underlying),
553
- nextSteps: [
554
- "check your internet connection — the model (bge-small-en-v1.5) downloads once on first use",
555
- "if you are behind a proxy or firewall, allow access to the model host (Hugging Face) and set HTTPS_PROXY",
556
- "once the download succeeds it is cached under ~/.cruxy/models and never re-fetched",
557
- ],
566
+ nextSteps,
558
567
  underlying,
559
568
  });
560
569
  }
@@ -99,6 +99,12 @@ export const ErrorCode = {
99
99
  PermissionDenied: "CRUXY_E_PERMISSION_DENIED",
100
100
  PathEscape: "CRUXY_E_PATH_ESCAPE",
101
101
  CheckpointFailed: "CRUXY_E_CHECKPOINT_FAILED",
102
+ /** A mutating file tool re-read its target immediately before the approved
103
+ * write and found it was not the file the approval was granted against (P1):
104
+ * changed, deleted, or created by something else during the approval wait.
105
+ * Refused with nothing written — the approval covered a diff against ONE
106
+ * specific state, and that state moved. See `tools/file/snapshot.ts`. */
107
+ FileChangedSinceRead: "CRUXY_E_FILE_CHANGED_SINCE_READ",
102
108
  // index (exit 8)
103
109
  /** The fastembed native module could not be LOADED (missing/broken install,
104
110
  * un-built onnxruntime-node addon). Fail-loud by design — the embedder never
@@ -341,6 +347,7 @@ const EXIT_CODES = {
341
347
  [ErrorCode.PermissionDenied]: 7,
342
348
  [ErrorCode.PathEscape]: 7,
343
349
  [ErrorCode.CheckpointFailed]: 7,
350
+ [ErrorCode.FileChangedSinceRead]: 7,
344
351
  [ErrorCode.IndexEmbedderUnavailable]: 8,
345
352
  [ErrorCode.IndexEmbedderDownloadFailed]: 8,
346
353
  [ErrorCode.IndexStoreUnavailable]: 8,
@@ -1,5 +1,7 @@
1
- import { promises as fs } from "node:fs";
1
+ import path from "node:path";
2
+ import { globalDir } from "../config/paths.js";
2
3
  import { indexEmbedderDownloadFailed, indexEmbedderUnavailable, } from "../errors/index.js";
4
+ import { ensureModelDir, MODEL_ONNX_FILE, } from "./model-cache.js";
3
5
  import { l2normalize } from "./util.js";
4
6
  /**
5
7
  * Output dimensionality of bge-small-en-v1.5, and the default size of the
@@ -69,6 +71,12 @@ export class HashingEmbedder {
69
71
  * registering the `search_codebase` tool stays cheap and the heavy ONNX runtime
70
72
  * only loads when an index is actually built or queried.
71
73
  *
74
+ * The model files are provisioned by cruxy ({@link ensureModelDir}), NOT by
75
+ * fastembed: fastembed is initialised with `model: CUSTOM` and an absolute
76
+ * directory, which in its `init` makes its own `retrieveModel` (tar@6-based
77
+ * download + extract, #306) unreachable. `CUSTOM` changes nothing else for this
78
+ * model — fastembed's only model-specific branch is for multilingual-e5.
79
+ *
72
80
  * Embedding is CPU-bound and single-threaded inside ONNX, so throughput is
73
81
  * bounded by `batchSize` (fed sequentially through fastembed's batching
74
82
  * generator) rather than by JS-level concurrency.
@@ -84,23 +92,29 @@ export class FastEmbedEmbedder {
84
92
  getModel() {
85
93
  if (!this.model) {
86
94
  this.model = (async () => {
95
+ const cacheDir = this.opts.cacheDir ?? path.join(globalDir(), "models");
96
+ const provision = this.opts.provision ?? ((dir) => ensureModelDir({ cacheDir: dir }));
97
+ let modelDir;
98
+ try {
99
+ modelDir = await provision(cacheDir);
100
+ }
101
+ catch (err) {
102
+ throw indexEmbedderDownloadFailed(err, {
103
+ archiveRefused: isRefusedArchive(err),
104
+ });
105
+ }
87
106
  try {
88
107
  const mod = await import("fastembed");
89
- // fastembed's init does a non-recursive mkdir of the cache dir, so it
90
- // fails if an ancestor (e.g. ~/.cruxy) doesn't exist yet. Create it first.
91
- if (this.opts.cacheDir) {
92
- await fs.mkdir(this.opts.cacheDir, { recursive: true });
93
- }
94
108
  return (await mod.FlagEmbedding.init({
95
- model: mod.EmbeddingModel.BGESmallENV15,
109
+ model: mod.EmbeddingModel.CUSTOM,
110
+ modelAbsoluteDirPath: modelDir,
111
+ modelName: MODEL_ONNX_FILE,
96
112
  maxLength: this.opts.maxLength ?? 512,
97
- cacheDir: this.opts.cacheDir,
98
- showDownloadProgress: this.opts.showDownloadProgress ?? false,
113
+ showDownloadProgress: false,
99
114
  }));
100
115
  }
101
116
  catch (err) {
102
- // The heavy path: first-run model download/decompress or ONNX-runtime
103
- // init failed (offline, unreachable model bucket, proxy). Surface a
117
+ // ONNX-runtime init failed on a verified model directory. Surface a
104
118
  // typed, actionable error instead of letting it collapse into the
105
119
  // generic CRUXY_E_INDEX_FAILED ("re-run --verbose"). Still fail-loud —
106
120
  // this never degrades to the lexical backend. (Module-*load* failure is
@@ -128,6 +142,15 @@ export class FastEmbedEmbedder {
128
142
  return l2normalize(Float32Array.from(await model.queryEmbed(text)));
129
143
  }
130
144
  }
145
+ /**
146
+ * Duck-typed on purpose: tests re-import this module across
147
+ * `vi.resetModules()`, which would defeat an `instanceof ModelCacheError`.
148
+ */
149
+ function isRefusedArchive(err) {
150
+ return (err instanceof Error &&
151
+ err.name === "ModelCacheError" &&
152
+ err.kind === "refused");
153
+ }
131
154
  /**
132
155
  * Build the **production** embedder: fastembed / bge-small-en-v1.5.
133
156
  *