@cruxy/cli 1.11.1 → 1.11.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/instruction-loss.js +204 -0
- package/dist/agent/prompts.js +25 -4
- package/dist/agent/session.js +165 -33
- package/dist/agent/status.js +18 -0
- package/dist/checkpoint/service.js +44 -3
- package/dist/cli/commands/pr.js +14 -0
- package/dist/cli/commands/run.js +35 -0
- package/dist/cli/commands/sessions.js +8 -0
- package/dist/cli/session-commands.js +3 -1
- package/dist/cli/session-factory.js +54 -6
- package/dist/config/schema.js +9 -0
- package/dist/errors/constructors.js +15 -6
- package/dist/errors/types.js +7 -0
- package/dist/indexing/embedder.js +34 -11
- package/dist/indexing/model-cache.js +399 -0
- package/dist/mcp/bounds.js +8 -1
- package/dist/plan/execute.js +4 -1
- package/dist/plan/service.js +42 -5
- package/dist/plan/step-message.js +49 -0
- package/dist/render/context-view.js +44 -1
- package/dist/render/status-view.js +13 -0
- package/dist/session/index.js +7 -3
- package/dist/session/log.js +163 -2
- package/dist/session/owner.js +123 -0
- package/dist/session/prune.js +11 -0
- package/dist/session/recorded-runs.js +56 -0
- package/dist/session/replay.js +75 -1
- package/dist/session/resume.js +110 -3
- package/dist/session/types.js +158 -0
- package/dist/subagent/orchestrator.js +2 -2
- package/dist/subagent/registry-scope.js +28 -5
- package/dist/testing/run-tests-tool.js +3 -1
- package/dist/tools/create-pull-request.js +8 -1
- package/dist/tools/file/apply-patch.js +53 -23
- package/dist/tools/file/edit-file.js +19 -1
- package/dist/tools/file/snapshot.js +68 -0
- package/dist/tools/file/write-file.js +31 -5
- package/dist/tools/registry.js +39 -8
- package/dist/tools/schema-depth.js +79 -6
- package/dist/tools/shell/exec.js +7 -0
- package/dist/tools/shell/run-command.js +45 -21
- package/dist/utils/process-owner.js +107 -0
- package/dist/vcs/generate.js +48 -6
- package/dist/verification/index.js +15 -0
- package/dist/verification/ledger.js +99 -0
- package/dist/verification/types.js +26 -0
- package/dist/verification/view.js +87 -0
- package/package.json +3 -2
|
@@ -2,11 +2,18 @@ import { promises as fs } from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { resolveToolPath } from "./paths.js";
|
|
5
|
+
import { changedSince, snapshotFile } from "./snapshot.js";
|
|
5
6
|
/** How many leading lines of new content the approval preview shows. */
|
|
6
7
|
const PREVIEW_LINES = 20;
|
|
7
8
|
/**
|
|
8
9
|
* Create or overwrite a file within the project root. Gated on `ctx.approve`
|
|
9
10
|
* before anything is written.
|
|
11
|
+
*
|
|
12
|
+
* The target is snapshotted before approval — its current bytes, or its
|
|
13
|
+
* absence — and the write is refused if that state has moved by the time the
|
|
14
|
+
* write is about to happen (P1 — see `snapshot.ts`). This tool used to have
|
|
15
|
+
* no read at all: an "overwrite" approved against one version of a file would
|
|
16
|
+
* land on whatever version was there by the time the user pressed `y`.
|
|
10
17
|
*/
|
|
11
18
|
export const writeFileTool = {
|
|
12
19
|
name: "write_file",
|
|
@@ -25,11 +32,19 @@ export const writeFileTool = {
|
|
|
25
32
|
catch (err) {
|
|
26
33
|
return { ok: false, error: err.message };
|
|
27
34
|
}
|
|
28
|
-
// Does the target already exist? Drives create-vs-overwrite in the preview
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
// Does the target already exist? Drives create-vs-overwrite in the preview,
|
|
36
|
+
// and its bytes (or absence) are the state the approval is granted against.
|
|
37
|
+
let approvedState;
|
|
38
|
+
try {
|
|
39
|
+
approvedState = await snapshotFile(abs);
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
error: `cannot read ${input.path}: ${err.message}`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const exists = approvedState.kind === "present";
|
|
33
48
|
// First N lines of the new content, with a count of what's omitted.
|
|
34
49
|
const allLines = input.content.split("\n");
|
|
35
50
|
const lines = allLines.slice(0, PREVIEW_LINES);
|
|
@@ -46,6 +61,17 @@ export const writeFileTool = {
|
|
|
46
61
|
error: decision.feedback ?? `write to ${input.path} denied`,
|
|
47
62
|
};
|
|
48
63
|
}
|
|
64
|
+
// A create approved against "no file here" must not overwrite a file that
|
|
65
|
+
// appeared during the wait; an overwrite approved against one version must
|
|
66
|
+
// not land on another (P1).
|
|
67
|
+
const moved = await changedSince(abs, approvedState, input.path);
|
|
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
|
+
}
|
|
49
75
|
try {
|
|
50
76
|
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
51
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
|
+
}
|
package/dist/tools/shell/exec.js
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
/** The token when the platform lookup failed: liveness falls back to pid-only. */
|
|
4
|
+
export const UNKNOWN_TOKEN = "unknown";
|
|
5
|
+
/** The start-time token for `pid`, or {@link UNKNOWN_TOKEN} when unobtainable. */
|
|
6
|
+
export function startToken(pid) {
|
|
7
|
+
try {
|
|
8
|
+
if (process.platform === "linux")
|
|
9
|
+
return linuxToken(pid);
|
|
10
|
+
if (process.platform === "darwin")
|
|
11
|
+
return darwinToken(pid);
|
|
12
|
+
if (process.platform === "win32")
|
|
13
|
+
return win32Token(pid);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// fall through — the platform said no; degrade to pid-only
|
|
17
|
+
}
|
|
18
|
+
return UNKNOWN_TOKEN;
|
|
19
|
+
}
|
|
20
|
+
/** This process's own stamp. Computed once — a process's identity never changes. */
|
|
21
|
+
export function selfStamp() {
|
|
22
|
+
if (!self) {
|
|
23
|
+
self = {
|
|
24
|
+
pid: process.pid,
|
|
25
|
+
token: startToken(process.pid),
|
|
26
|
+
startedAt: new Date(Date.now() - process.uptime() * 1000).toISOString(),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return self;
|
|
30
|
+
}
|
|
31
|
+
let self;
|
|
32
|
+
/** Whether a process with `pid` exists (EPERM counts: it exists, just not ours). */
|
|
33
|
+
export function pidAlive(pid) {
|
|
34
|
+
try {
|
|
35
|
+
process.kill(pid, 0);
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
return err.code === "EPERM";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Classify a stamp against the live process table. Order matters: `self` is
|
|
44
|
+
* decided by pid AND token, so a stamp our own pid inherited from a crashed
|
|
45
|
+
* predecessor (pid recycled onto us) still reads as stale.
|
|
46
|
+
*/
|
|
47
|
+
export function describeOwner(stamp) {
|
|
48
|
+
const me = selfStamp();
|
|
49
|
+
if (stamp.pid === me.pid) {
|
|
50
|
+
return stamp.token === me.token ? "self" : "stale";
|
|
51
|
+
}
|
|
52
|
+
if (!pidAlive(stamp.pid))
|
|
53
|
+
return "stale";
|
|
54
|
+
// Alive by pid. If both sides have a real token and they differ, the pid was
|
|
55
|
+
// recycled. An unknown token on either side cannot prove that, so the stamp
|
|
56
|
+
// is treated as live — the documented pid-only degradation.
|
|
57
|
+
if (stamp.token === UNKNOWN_TOKEN)
|
|
58
|
+
return "live";
|
|
59
|
+
const now = startToken(stamp.pid);
|
|
60
|
+
if (now === UNKNOWN_TOKEN)
|
|
61
|
+
return "live";
|
|
62
|
+
return now === stamp.token ? "live" : "stale";
|
|
63
|
+
}
|
|
64
|
+
// ── platform lookups ──────────────────────────────────────────────────────────
|
|
65
|
+
function linuxToken(pid) {
|
|
66
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
67
|
+
// The comm field is parenthesised and may itself contain spaces or parens;
|
|
68
|
+
// everything after the LAST `)` is the fixed-position numeric tail, in which
|
|
69
|
+
// starttime is the 20th entry (field 22 of the whole line).
|
|
70
|
+
const tail = stat
|
|
71
|
+
.slice(stat.lastIndexOf(")") + 2)
|
|
72
|
+
.trim()
|
|
73
|
+
.split(/\s+/);
|
|
74
|
+
const starttime = tail[19];
|
|
75
|
+
if (!starttime)
|
|
76
|
+
throw new Error("unexpected /proc stat shape");
|
|
77
|
+
let boot = "";
|
|
78
|
+
try {
|
|
79
|
+
boot = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Older kernels / locked-down containers: the token is still per-boot in
|
|
83
|
+
// practice (ticks since boot), just not provably so across reboots.
|
|
84
|
+
}
|
|
85
|
+
return `linux:${boot}:${starttime}`;
|
|
86
|
+
}
|
|
87
|
+
function darwinToken(pid) {
|
|
88
|
+
const out = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
89
|
+
encoding: "utf8",
|
|
90
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
91
|
+
timeout: 2000,
|
|
92
|
+
}).trim();
|
|
93
|
+
if (out === "")
|
|
94
|
+
throw new Error("no such process");
|
|
95
|
+
return `darwin:${out.replace(/\s+/g, " ")}`;
|
|
96
|
+
}
|
|
97
|
+
function win32Token(pid) {
|
|
98
|
+
const out = execFileSync("powershell", [
|
|
99
|
+
"-NoProfile",
|
|
100
|
+
"-NonInteractive",
|
|
101
|
+
"-Command",
|
|
102
|
+
`(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToString('o')`,
|
|
103
|
+
], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim();
|
|
104
|
+
if (out === "")
|
|
105
|
+
throw new Error("no such process");
|
|
106
|
+
return `win32:${out}`;
|
|
107
|
+
}
|
package/dist/vcs/generate.js
CHANGED
|
@@ -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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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'
|
|
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
|
+
}
|