@visual-z/xeno 0.8.1 → 0.8.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/CHANGELOG.md +66 -0
- package/docs/install.md +25 -0
- package/package.json +1 -1
- package/src/apps/cli.ts +4 -1
- package/src/apps/shell.ts +13 -3
- package/src/apps/startup.ts +31 -6
- package/src/apps/ui/app.ts +32 -14
- package/src/apps/ui/project.ts +1 -1
- package/src/apps/ui/standalone.ts +4 -2
- package/src/apps/ui/surface.ts +2 -0
- package/src/core/activity-status.ts +27 -3
- package/src/core/command-contract.ts +121 -5
- package/src/core/completion-review.ts +1 -1
- package/src/core/derive.ts +9 -1
- package/src/core/environment.ts +7 -0
- package/src/core/event-validation.ts +19 -3
- package/src/core/events.ts +38 -2
- package/src/core/evidence.ts +149 -7
- package/src/core/export.ts +2 -0
- package/src/core/external-effects.ts +488 -0
- package/src/core/handoff.ts +24 -3
- package/src/core/history-query.ts +1 -1
- package/src/core/history.ts +131 -21
- package/src/core/observation-view.ts +24 -0
- package/src/core/path-suggestions.ts +59 -0
- package/src/core/permissions.ts +64 -3
- package/src/core/prompt-policy.ts +11 -2
- package/src/core/regex-dialect.ts +25 -0
- package/src/core/ripgrep-release.ts +64 -0
- package/src/core/secrets.ts +188 -0
- package/src/core/stall.ts +63 -18
- package/src/core/stop-report.ts +46 -4
- package/src/core/tool-contract.ts +2 -0
- package/src/core/tool-failure.ts +25 -4
- package/src/core/transcript.ts +3 -0
- package/src/core/update-check.ts +2 -1
- package/src/core/work-state.ts +7 -2
- package/src/core/working-set.ts +34 -6
- package/src/runtime/connection-reuse.ts +40 -0
- package/src/runtime/exec-sessions.ts +69 -19
- package/src/runtime/history-tools.ts +12 -2
- package/src/runtime/loop.ts +196 -26
- package/src/runtime/open-image.ts +42 -0
- package/src/runtime/openai-responses.ts +15 -7
- package/src/runtime/openai.ts +15 -7
- package/src/runtime/permission-resources.ts +11 -4
- package/src/runtime/pi-provider.ts +34 -9
- package/src/runtime/provider.ts +19 -8
- package/src/runtime/retry.ts +152 -9
- package/src/runtime/secrets.ts +79 -0
- package/src/runtime/session-control.ts +9 -1
- package/src/runtime/stream-idle.ts +80 -0
- package/src/runtime/tools.ts +139 -31
- package/src/runtime/turn-effects.ts +31 -0
- package/src/store/command.ts +3 -1
- package/src/store/context-files.ts +5 -1
- package/src/store/file-checkpoints.ts +6 -3
- package/src/store/log.ts +20 -0
- package/src/store/replay.ts +4 -3
- package/src/store/search.ts +9 -5
- package/src/store/secrets.ts +44 -0
- package/src/store/sessions.ts +49 -6
- package/src/store/tool-binaries.ts +90 -0
- package/src/store/worktree.ts +55 -10
- package/src/tui/ui/image-size.ts +6 -2
- package/src/tui/ui/items.ts +3 -1
- package/src/tui/ui/pictures.ts +14 -1
- package/src/tui/ui/screen.ts +6 -3
- package/src/tui/ui/status.ts +16 -0
|
@@ -10,10 +10,18 @@ export function commandArgv(command: string): string[] {
|
|
|
10
10
|
return ["bash", "-o", "pipefail", "-lc", command];
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Preserve inherited values except documented noninteractive defaults; never mutate the host.
|
|
15
|
+
*
|
|
16
|
+
* `appendPath` is xeno's own tool directory (store/tool-binaries.ts). It goes
|
|
17
|
+
* last, so anything the user installed wins and xeno only fills a gap.
|
|
18
|
+
*/
|
|
19
|
+
export function commandEnvironment(base: Readonly<Record<string, string | undefined>>, appendPath?: string): Record<string, string> {
|
|
15
20
|
const env: Record<string, string> = {};
|
|
16
21
|
for (const [key, value] of Object.entries(base)) if (value !== undefined) env[key] = value;
|
|
22
|
+
if (appendPath !== undefined && appendPath.length > 0 && !(env["PATH"] ?? "").split(":").includes(appendPath)) {
|
|
23
|
+
env["PATH"] = env["PATH"] ? `${env["PATH"]}:${appendPath}` : appendPath;
|
|
24
|
+
}
|
|
17
25
|
Object.assign(env, {
|
|
18
26
|
TERM: "dumb", PAGER: "cat", GIT_PAGER: "cat", NO_COLOR: "1",
|
|
19
27
|
GIT_TERMINAL_PROMPT: "0", DEBIAN_FRONTEND: "noninteractive",
|
|
@@ -22,14 +30,122 @@ export function commandEnvironment(base: Readonly<Record<string, string | undefi
|
|
|
22
30
|
return env;
|
|
23
31
|
}
|
|
24
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Codex's `max_output_tokens` as an inline byte budget for one call. It can
|
|
35
|
+
* only lower the session ceiling: full output is still captured and
|
|
36
|
+
* recoverable, so this shapes the preview, never the receipt.
|
|
37
|
+
*
|
|
38
|
+
* Assumed, not measured: 4 bytes per token rounds the ASCII estimate in
|
|
39
|
+
* tokens.ts (about 3.6) up, so the model never gets less than it asked for.
|
|
40
|
+
* The 1 KiB floor keeps room for the header and recovery hint (about 300
|
|
41
|
+
* bytes); below it a preview shows almost no output and forces a re-read.
|
|
42
|
+
*/
|
|
43
|
+
export const OUTPUT_BYTES_PER_TOKEN = 4;
|
|
44
|
+
export const MIN_OUTPUT_BYTES = 1_024;
|
|
45
|
+
export function outputBytesForTokens(tokens: unknown, ceiling: number): number {
|
|
46
|
+
if (typeof tokens !== "number" || !Number.isFinite(tokens) || tokens <= 0) return ceiling;
|
|
47
|
+
return Math.min(ceiling, Math.max(MIN_OUTPUT_BYTES, Math.floor(tokens) * OUTPUT_BYTES_PER_TOKEN));
|
|
48
|
+
}
|
|
49
|
+
|
|
25
50
|
/** Model seconds and host milliseconds meet once; a yield is never a command deadline. */
|
|
26
51
|
export function commandTimeout(requestedSeconds: number | undefined, defaultMs: number, maximumMs = defaultMs): number {
|
|
27
52
|
return Math.min(requestedSeconds === undefined ? defaultMs : requestedSeconds * 1000, maximumMs);
|
|
28
53
|
}
|
|
29
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Programs whose exit 1 is an answer rather than a fault (POSIX: grep's 1 is
|
|
57
|
+
* "no match" and >1 an error; diff and cmp use 1 for "differ"; test for
|
|
58
|
+
* false). Reporting them as failures tells the model to inspect and retry an
|
|
59
|
+
* answer it already holds -- the same reading Claude Code's command semantics
|
|
60
|
+
* give these programs.
|
|
61
|
+
*/
|
|
62
|
+
const NEGATIVE_ANSWERS: Readonly<Record<string, string>> = {
|
|
63
|
+
grep: "no match", egrep: "no match", fgrep: "no match", rg: "no match",
|
|
64
|
+
diff: "files differ", cmp: "files differ", test: "condition false", "[": "condition false",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The final simple command of `command` and the separator before it, or
|
|
69
|
+
* null when this scanner cannot read the command with certainty --
|
|
70
|
+
* unbalanced quotes, subshells, compound statements, substitutions or a
|
|
71
|
+
* background `&`. Only top-level `;`, newline, `&&`, `||` and `|` split.
|
|
72
|
+
*/
|
|
73
|
+
function finalStage(command: string): { separator: string; words: string[] } | null {
|
|
74
|
+
let quote: "'" | "\"" | null = null, start = 0, separator = "";
|
|
75
|
+
for (let i = 0; i < command.length; i++) {
|
|
76
|
+
const c = command[i]!;
|
|
77
|
+
if (quote !== null) {
|
|
78
|
+
if (c === "\\" && quote === "\"") i++;
|
|
79
|
+
else if (c === quote) quote = null;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (c === "\\") { i++; continue; }
|
|
83
|
+
if (c === "'" || c === "\"") { quote = c; continue; }
|
|
84
|
+
if (c === "`" || c === "(" || c === ")" || c === "{" || c === "}" || (c === "$" && command[i + 1] === "(")) return null;
|
|
85
|
+
const two = command.slice(i, i + 2);
|
|
86
|
+
if (two === "&&" || two === "||") { separator = two; start = i + 2; i++; continue; }
|
|
87
|
+
if (c === ";" || c === "\n" || c === "|") { separator = c; start = i + 1; continue; }
|
|
88
|
+
if (c === "&" && command[i + 1] !== ">" && command[i - 1] !== ">") return null;
|
|
89
|
+
}
|
|
90
|
+
if (quote !== null) return null;
|
|
91
|
+
return { separator, words: command.slice(start).trim().split(/\s+/).filter(word => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const programName = (words: readonly string[]) => (words[0] ?? "").replace(/^.*\//, "");
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* What exit 1 of the command's final simple command means, or null.
|
|
98
|
+
*
|
|
99
|
+
* Only the last segment after a top-level `;`, newline, `||` or `|` is read.
|
|
100
|
+
* After `&&` the 1 may come from the command that short-circuited, so it is
|
|
101
|
+
* not classified. Under pipefail an earlier pipeline stage can also set the
|
|
102
|
+
* status; the exit is kept in the receipt either way, and the label states
|
|
103
|
+
* the program's meaning rather than claiming success. Anything this scanner
|
|
104
|
+
* cannot read with certainty -- unbalanced quotes, subshells, compound
|
|
105
|
+
* statements, substitutions -- yields null.
|
|
106
|
+
*/
|
|
107
|
+
export function negativeAnswer(command: string, exit: number): string | null {
|
|
108
|
+
if (exit !== 1) return null;
|
|
109
|
+
const stage = finalStage(command);
|
|
110
|
+
if (stage === null || stage.separator === "&&") return null;
|
|
111
|
+
const program = programName(stage.words);
|
|
112
|
+
if (program === "git") return stage.words[1] === "grep" ? "no match" : null;
|
|
113
|
+
return Object.hasOwn(NEGATIVE_ANSWERS, program) ? NEGATIVE_ANSWERS[program]! : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Readers that may stop reading before their input ends (`head -n`, `sed Nq`,
|
|
118
|
+
* `awk '{exit}'`, `grep -m` / `-q`), closing the pipe on the writer before it.
|
|
119
|
+
*/
|
|
120
|
+
const EARLY_READERS: ReadonlySet<string> = new Set(["head", "sed", "awk", "gawk", "mawk", "grep", "egrep", "fgrep", "rg"]);
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* What exit 141 means when an early reader ends the pipeline, or null.
|
|
124
|
+
*
|
|
125
|
+
* Measured (pipe-early-close card): under `bash -o pipefail`, `seq 1 1000000
|
|
126
|
+
* | head -1` exits 141 -- pipefail reports the rightmost nonzero stage, here
|
|
127
|
+
* the writer that SIGPIPE killed after head closed its input, while every
|
|
128
|
+
* stage to its right, the final reader included, exited 0. The output the
|
|
129
|
+
* command asked for is complete. Only a final stage joined by a top-level
|
|
130
|
+
* `|` is read: after `;`, `&&` or `||` the 141 is the last command's own.
|
|
131
|
+
*/
|
|
132
|
+
export function earlyCloseAnswer(command: string, exit: number): string | null {
|
|
133
|
+
if (exit !== 141) return null;
|
|
134
|
+
const stage = finalStage(command);
|
|
135
|
+
if (stage === null || stage.separator !== "|") return null;
|
|
136
|
+
const program = programName(stage.words);
|
|
137
|
+
return EARLY_READERS.has(program) ? `${program} closed the pipe early; the earlier stage's SIGPIPE is not a failure` : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The program's own meaning of a nonzero exit that is an answer, not a fault. */
|
|
141
|
+
export function commandAnswer(command: string, exit: number): string | null {
|
|
142
|
+
return negativeAnswer(command, exit) ?? earlyCloseAnswer(command, exit);
|
|
143
|
+
}
|
|
144
|
+
|
|
30
145
|
/** A trap can exit zero after cancellation: successful exit alone does not erase the stop. */
|
|
31
|
-
export function commandCompletion(exit: number, stopped?: "cancelled" | "timeout", cleanup?: ToolDiagnostic) {
|
|
32
|
-
|
|
33
|
-
|
|
146
|
+
export function commandCompletion(exit: number, stopped?: "cancelled" | "timeout", cleanup?: ToolDiagnostic, command?: string) {
|
|
147
|
+
const answered = exit === 0 || command !== undefined && commandAnswer(command, exit) !== null;
|
|
148
|
+
return { ok: stopped === undefined && answered && cleanup === undefined,
|
|
149
|
+
outcome: executionOutcome(stopped ?? (answered && cleanup === undefined ? "tool_completed" : "tool_failed")),
|
|
34
150
|
...(cleanup ? { warnings: [cleanup] } : {}) };
|
|
35
151
|
}
|
|
@@ -69,7 +69,7 @@ export function requestCompletion(state: RequestState, remainingRequests: number
|
|
|
69
69
|
if (state.used_tools && text.length === 0) gaps.push("the final reply is empty");
|
|
70
70
|
// Strict verification counts only configured gates (O.11); say so, since a successful `cat` would otherwise look like a check.
|
|
71
71
|
if (freshCheck && freshCheck.status !== "pass") gaps.push(freshCheck.gate_only
|
|
72
|
-
? "files were changed but no configured verification gate (a test, typecheck, lint, build or check command) has passed since the last edit; reading or printing files does not count"
|
|
72
|
+
? "files were changed but no configured verification gate (a test, typecheck, lint, build or check command) has passed since the last edit; reading or printing files does not count, and a gate counts only when run as plain commands joined by && (trailing output redirects to fixed files are fine); ;, ||, pipes, $() and variables hide it"
|
|
73
73
|
: "files were changed but no command has succeeded since the last edit");
|
|
74
74
|
if (acceptance?.status === "needs_work") gaps.push(`declared acceptance criteria need evidence: ${[
|
|
75
75
|
...missingCommands.map(item => `${item.id}=${item.status}`),
|
package/src/core/derive.ts
CHANGED
|
@@ -425,7 +425,15 @@ export function deriveHandoff(input: DeriveInput): Handoff {
|
|
|
425
425
|
const processes = deriveProcesses(prev?.processes ?? [], events, input.self.node);
|
|
426
426
|
|
|
427
427
|
const hasRemaining = inFlight.length > 0 || blocked.length > 0 || openQuestions.length > 0;
|
|
428
|
-
|
|
428
|
+
// A next action is a statement about the current state of the work, so it
|
|
429
|
+
// follows the evidence rule above: only one this segment recorded or
|
|
430
|
+
// refreshed counts. A carried one stays listed but no longer suppresses the
|
|
431
|
+
// boundary and continuation reminders that ask the model to confirm it.
|
|
432
|
+
const thisSegment = new Set(events.map((e) => e.id));
|
|
433
|
+
const freshUnscoped = new Set(events.flatMap((e) => e.t === "next_action" && e.work_id === undefined ? [e.what] : []));
|
|
434
|
+
const freshWork = new Set(workItems.filter((item) => thisSegment.has(item.updated_at)).map((item) => item.id));
|
|
435
|
+
const hasNextAction = inFlight.some((i) => i.next_action.trim().length > 0 &&
|
|
436
|
+
(i.work_id === undefined ? freshUnscoped.has(i.what) : freshWork.has(i.work_id)));
|
|
429
437
|
|
|
430
438
|
return {
|
|
431
439
|
handoff_version: HANDOFF_VERSION,
|
package/src/core/environment.ts
CHANGED
|
@@ -19,6 +19,12 @@ export interface SessionEnvironment {
|
|
|
19
19
|
/** Local calendar date, YYYY-MM-DD. */
|
|
20
20
|
date: string;
|
|
21
21
|
git: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Whether `rg` resolves in tool shells at session start. Models reach for
|
|
24
|
+
* it by habit; without this line a missing one costs an exit-127 request.
|
|
25
|
+
* Optional so environments recorded before it existed still render.
|
|
26
|
+
*/
|
|
27
|
+
ripgrep?: boolean;
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
export function environmentSection(env: SessionEnvironment): string {
|
|
@@ -29,6 +35,7 @@ export function environmentSection(env: SessionEnvironment): string {
|
|
|
29
35
|
`- Working directory: ${env.cwd}`,
|
|
30
36
|
`- Date: ${env.date}`,
|
|
31
37
|
`- Git repository: ${env.git ? "yes" : "no"}`,
|
|
38
|
+
...(env.ripgrep === undefined ? [] : [`- ripgrep (rg) in the shell: ${env.ripgrep ? "yes" : "no — use the grep and find tools"}`]),
|
|
32
39
|
].join("\n");
|
|
33
40
|
}
|
|
34
41
|
|
|
@@ -25,6 +25,14 @@ export function validNodeRef(value: unknown): value is NodeRef {
|
|
|
25
25
|
(value["at_event"] === undefined || typeof value["at_event"] === "string");
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** A label is a short phrase or class name; anything longer or with control characters is not one the harness wrote. */
|
|
29
|
+
function validFailureCause(value: unknown): boolean {
|
|
30
|
+
return isRecord(value) && (value["source"] === "provider" || value["source"] === "harness") &&
|
|
31
|
+
typeof value["label"] === "string" && /^[^\u0000-\u001f\u007f]{1,64}$/.test(value["label"]) &&
|
|
32
|
+
(value["status"] === undefined || Number.isSafeInteger(value["status"])) &&
|
|
33
|
+
(value["retries"] === undefined || natural(value["retries"]));
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
export function isLogEvent(value: unknown): value is LogEvent {
|
|
29
37
|
if (!isRecord(value) || typeof value["id"] !== "string" || value["id"].length === 0 || typeof value["at"] !== "string") return false;
|
|
30
38
|
if (value["outcome"] !== undefined && !isExecutionOutcome(value["outcome"])) return false;
|
|
@@ -58,6 +66,14 @@ export function isLogEvent(value: unknown): value is LogEvent {
|
|
|
58
66
|
value["reason"] === "disposed" && isRecord(output) && typeof output["path"] === "string" &&
|
|
59
67
|
typeof output["sha256"] === "string" && /^[a-f0-9]{64}$/.test(output["sha256"]) && natural(output["bytes"]);
|
|
60
68
|
}
|
|
69
|
+
case "approval": {
|
|
70
|
+
const user = ["once", "command", "tool", "deny"].includes(String(value["decision"]));
|
|
71
|
+
const rule = value["decision"] === "allow" || value["decision"] === "deny";
|
|
72
|
+
return has("call_id", "tool") && value["call_id"] !== "" && value["tool"] !== "" &&
|
|
73
|
+
optionalString(value["command"]) && optionalString(value["rule"]) &&
|
|
74
|
+
(value["source"] === "user" ? user : value["source"] === "no_prompt" ? value["decision"] === "deny" :
|
|
75
|
+
["session_rule", "config", "auto"].includes(String(value["source"])) && rule);
|
|
76
|
+
}
|
|
61
77
|
case "skill_loaded": return has("name", "path", "sha256", "artifact") &&
|
|
62
78
|
["sha256", "artifact"].every(key => /^[a-f0-9]{64}$/.test(String(value[key]))) &&
|
|
63
79
|
["inline", "fork", "read"].includes(String(value["mode"])) && ["user", "model"].includes(String(value["origin"]));
|
|
@@ -81,7 +97,7 @@ export function isLogEvent(value: unknown): value is LogEvent {
|
|
|
81
97
|
}
|
|
82
98
|
case "extension": return has("customType", "content") && typeof value["display"] === "boolean" && typeof value["inContext"] === "boolean" && (value["data"] === undefined || isJsonValue(value["data"]));
|
|
83
99
|
case "user": return has("text") && (value["request"] === undefined || typeof value["request"] === "string" && value["request"] !== "");
|
|
84
|
-
case "outcome": return has("reason") && isExecutionOutcome(value["outcome"]);
|
|
100
|
+
case "outcome": return has("reason") && isExecutionOutcome(value["outcome"]) && (value["failure"] === undefined || validFailureCause(value["failure"]));
|
|
85
101
|
case "user_bash": {
|
|
86
102
|
const ref = value["content_ref"];
|
|
87
103
|
const outcome = value["outcome"];
|
|
@@ -92,7 +108,7 @@ export function isLogEvent(value: unknown): value is LogEvent {
|
|
|
92
108
|
isRecord(ref) && typeof ref["sha256"] === "string" &&
|
|
93
109
|
typeof ref["path"] === "string" && natural(ref["bytes"]);
|
|
94
110
|
}
|
|
95
|
-
case "guidance": return has("text") && ["continuation", "repetition", "budget", "boundary", "stall", "output_recovery", "completion_review", "churn", "deadline", "runtime_acceptance", "goal"].includes(String(value["reason"])) && strings(value["sources"]);
|
|
111
|
+
case "guidance": return has("text") && ["continuation", "repetition", "budget", "boundary", "stall", "output_recovery", "completion_review", "churn", "deadline", "runtime_acceptance", "goal", "secret"].includes(String(value["reason"])) && strings(value["sources"]);
|
|
96
112
|
case "interrupt": return has("user_id");
|
|
97
113
|
case "assistant": return (value["stop_reason"] === null || optionalString(value["stop_reason"])) &&
|
|
98
114
|
(value["replay_key"] === undefined || typeof value["replay_key"] === "string" && /^[a-f0-9]{64}$/.test(value["replay_key"])) &&
|
|
@@ -156,7 +172,7 @@ export function isLogEvent(value: unknown): value is LogEvent {
|
|
|
156
172
|
return effect["kind"] === "edit" && typeof effect["path"] === "string" && typeof effect["sha256_after"] === "string" &&
|
|
157
173
|
(effect["sha256_before"] === null || typeof effect["sha256_before"] === "string");
|
|
158
174
|
}
|
|
159
|
-
case "cut": return has("handoff_ref") && (value["working_set_version"] === undefined || value["working_set_version"] === 2) && ["trigger", "hard_limit", "model_checkpoint"].includes(String(value["reason"])) &&
|
|
175
|
+
case "cut": return has("handoff_ref") && (value["working_set_version"] === undefined || value["working_set_version"] === 2 || value["working_set_version"] === 3) && ["trigger", "hard_limit", "model_checkpoint"].includes(String(value["reason"])) &&
|
|
160
176
|
(value["guidance_tools"] === undefined || Array.isArray(value["guidance_tools"]) && value["guidance_tools"].every(item => typeof item === "string")) &&
|
|
161
177
|
optionalString(value["retained_from"]) && optionalString(value["next_context_ref"]) &&
|
|
162
178
|
(value["next_node"] === undefined || natural(value["next_node"])) && optionalString(value["next_model"]) &&
|
package/src/core/events.ts
CHANGED
|
@@ -109,6 +109,22 @@ export interface OutcomeEvent {
|
|
|
109
109
|
at: IsoTime;
|
|
110
110
|
reason: string;
|
|
111
111
|
outcome: ExecutionOutcome;
|
|
112
|
+
/** Why a failed request failed; absent on success and in logs written before it existed. */
|
|
113
|
+
failure?: FailureCause;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Credential-free facts about a failure (PLAYBOOK: provider error text never
|
|
118
|
+
* reaches the log). `label` is a phrase from retry.ts's fixed vocabulary or an
|
|
119
|
+
* error class name -- both chosen by code, never copied from a response body.
|
|
120
|
+
*/
|
|
121
|
+
export interface FailureCause {
|
|
122
|
+
/** provider: the model request failed after its retries; harness: xeno itself threw. */
|
|
123
|
+
source: "provider" | "harness";
|
|
124
|
+
label: string;
|
|
125
|
+
status?: number;
|
|
126
|
+
/** Retries the adapter actually made before giving up. */
|
|
127
|
+
retries?: number;
|
|
112
128
|
}
|
|
113
129
|
|
|
114
130
|
/** A factual harness notice, appended once rather than rewriting the prompt (N.1). */
|
|
@@ -116,7 +132,7 @@ export interface GuidanceEvent {
|
|
|
116
132
|
t: "guidance";
|
|
117
133
|
id: EventId;
|
|
118
134
|
at: IsoTime;
|
|
119
|
-
reason: "continuation" | "repetition" | "budget" | "boundary" | "stall" | "output_recovery" | "completion_review" | "churn" | "deadline" | "runtime_acceptance" | "goal";
|
|
135
|
+
reason: "continuation" | "repetition" | "budget" | "boundary" | "stall" | "output_recovery" | "completion_review" | "churn" | "deadline" | "runtime_acceptance" | "goal" | "secret";
|
|
120
136
|
text: string;
|
|
121
137
|
sources: readonly EventId[];
|
|
122
138
|
}
|
|
@@ -243,7 +259,8 @@ export interface CutEvent {
|
|
|
243
259
|
handoff_tokens?: number;
|
|
244
260
|
/** O.10: absent preserves the original D20 carried projection. */
|
|
245
261
|
/** R9: present when the source-priority working set was carried across this cut. */
|
|
246
|
-
|
|
262
|
+
/** 3: user turns are carried before other pages, with image references and one copy of each next action. */
|
|
263
|
+
working_set_version?: 2 | 3;
|
|
247
264
|
/** R10: declared builtin advice capabilities at this cut, not an authorization grant. Absent replays old text. */
|
|
248
265
|
guidance_tools?: readonly string[];
|
|
249
266
|
/** N.8: first retained event on the effective ancestry; no copied transcript. */
|
|
@@ -564,8 +581,26 @@ export interface ProcessExitEvent {
|
|
|
564
581
|
receipt?: import("./process-receipt.ts").ProcessReceipt;
|
|
565
582
|
}
|
|
566
583
|
|
|
584
|
+
/**
|
|
585
|
+
* Who let one call through the permission gate (feat-approval-events). A
|
|
586
|
+
* harness fact recorded between the call's invocation and its result, before
|
|
587
|
+
* the call starts; it never enters the model's context. `decision` is the
|
|
588
|
+
* person's prompt choice when `source` is `user`, otherwise the rule's own
|
|
589
|
+
* `allow` / `deny`. `rule` is the matched rule's text -- for `user`, the rule
|
|
590
|
+
* that asked. `no_prompt`: a rule asked and nobody could be asked.
|
|
591
|
+
*/
|
|
592
|
+
export interface ApprovalEvent {
|
|
593
|
+
t: "approval"; id: EventId; at: IsoTime;
|
|
594
|
+
call_id: string; tool: string;
|
|
595
|
+
decision: "once" | "command" | "tool" | "deny" | "allow";
|
|
596
|
+
source: "user" | "session_rule" | "config" | "auto" | "no_prompt";
|
|
597
|
+
command?: string;
|
|
598
|
+
rule?: string;
|
|
599
|
+
}
|
|
600
|
+
|
|
567
601
|
export type LogEvent =
|
|
568
602
|
| ToolInvocationEvent
|
|
603
|
+
| ApprovalEvent
|
|
569
604
|
| ProcessExitEvent
|
|
570
605
|
| SkillLoadedEvent
|
|
571
606
|
| AcceptanceContractEvent
|
|
@@ -606,6 +641,7 @@ export const HARNESS_EVENTS = [
|
|
|
606
641
|
"checkpoint", "workspace_observation",
|
|
607
642
|
"target_observation",
|
|
608
643
|
"process_exit",
|
|
644
|
+
"approval",
|
|
609
645
|
"skill_loaded",
|
|
610
646
|
"acceptance_contract", "acceptance_decision", "goal", "counter_check", "blind_check",
|
|
611
647
|
"user", "user_bash", "outcome", "guidance", "interrupt", "assistant", "tool_result", "cut", "rewind", "extension",
|
package/src/core/evidence.ts
CHANGED
|
@@ -74,15 +74,109 @@ export function exchangeProgressChecks(events: readonly LogEvent[]): Set<string>
|
|
|
74
74
|
return eligibleProgressChecks(events, claimed, new Set([...gates].filter(([, exit]) => exit !== 0).map(([gate]) => gate)));
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* D11: classify an actual verifier invocation, never a runner word in data.
|
|
79
|
+
*
|
|
80
|
+
* Only command shapes whose exit 0 proves every gate in them exited 0 are
|
|
81
|
+
* read: simple commands joined by `&&` (the chain stops at the first failure),
|
|
82
|
+
* each optionally ending in output redirects to fixed paths (a redirect that
|
|
83
|
+
* cannot open fails the command, it never masks one). Anything that can turn
|
|
84
|
+
* a failure into exit 0 or run text the log does not show -- `;`, `||`,
|
|
85
|
+
* pipes, background jobs, subshells, expansions, input redirects -- rejects
|
|
86
|
+
* the whole command, as before. A chain's gates join with `+` in order, so a
|
|
87
|
+
* failed chain is recorded as that chain failing, not as one of its parts.
|
|
88
|
+
*/
|
|
78
89
|
export function classifyGate(gates: Readonly<Record<string, string>>, command: string): string | undefined {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
90
|
+
const segments = gateSegments(command);
|
|
91
|
+
if (!segments) return undefined;
|
|
92
|
+
const names = [...new Set(segments.flatMap((words) => segmentGates(gates, words)))];
|
|
93
|
+
return names.length === 0 ? undefined : names.join("+");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
type ShellToken = { kind: "word"; value: string } | { kind: "and" } | { kind: "redirect"; target: boolean };
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A deliberately small tokenizer, not a shell parser (M.2): it accepts the
|
|
100
|
+
* subset above and returns undefined for everything else, so an unfamiliar
|
|
101
|
+
* construct can only lose a gate, never forge one. Quotes may hold plain text
|
|
102
|
+
* only; `$`, backticks and backslashes are rejected even inside them.
|
|
103
|
+
*/
|
|
104
|
+
function shellTokens(command: string): ShellToken[] | undefined {
|
|
105
|
+
const tokens: ShellToken[] = [];
|
|
106
|
+
let word: string | undefined, i = 0;
|
|
107
|
+
const end = () => { if (word !== undefined) tokens.push({ kind: "word", value: word }); word = undefined; };
|
|
108
|
+
while (i < command.length) {
|
|
109
|
+
const c = command[i]!;
|
|
110
|
+
if (c === " " || c === "\t") { end(); i++; continue; }
|
|
111
|
+
if (c === "#" && word === undefined) break; // a comment runs to the end of the (single) line
|
|
112
|
+
if (c === "'" || c === '"') {
|
|
113
|
+
const close = command.indexOf(c, i + 1);
|
|
114
|
+
if (close < 0) return undefined;
|
|
115
|
+
const quoted = command.slice(i + 1, close);
|
|
116
|
+
if (/[$`\\\n\r]/.test(quoted)) return undefined;
|
|
117
|
+
word = (word ?? "") + quoted; i = close + 1; continue;
|
|
118
|
+
}
|
|
119
|
+
if (c === "&" && command[i + 1] === "&") { end(); tokens.push({ kind: "and" }); i += 2; continue; }
|
|
120
|
+
if (c === ">" || c === "&" && command[i + 1] === ">") {
|
|
121
|
+
// An unquoted 1 or 2 directly before `>` is the descriptor, not a word.
|
|
122
|
+
if (c === ">" && (word === "1" || word === "2") && /[12]$/.test(command.slice(0, i))) word = undefined;
|
|
123
|
+
end();
|
|
124
|
+
i += c === "&" ? 2 : 1;
|
|
125
|
+
if (command[i] === ">") i++;
|
|
126
|
+
if (c === ">" && command[i] === "&") {
|
|
127
|
+
if (!/^[12](?![^\s&])/.test(command.slice(i + 1))) return undefined;
|
|
128
|
+
tokens.push({ kind: "redirect", target: false }); i += 2; continue;
|
|
129
|
+
}
|
|
130
|
+
if (command[i] === "|") return undefined;
|
|
131
|
+
tokens.push({ kind: "redirect", target: true }); continue;
|
|
132
|
+
}
|
|
133
|
+
if (/[\n\r;&|<`$(){}\\]/.test(c)) return undefined;
|
|
134
|
+
word = (word ?? "") + c; i++;
|
|
135
|
+
}
|
|
136
|
+
end();
|
|
137
|
+
return tokens;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The `&&`-joined segments' words with trailing output redirects removed. */
|
|
141
|
+
function gateSegments(command: string): string[][] | undefined {
|
|
142
|
+
const tokens = shellTokens(command);
|
|
143
|
+
if (!tokens) return undefined;
|
|
144
|
+
const segments: string[][] = [[]];
|
|
145
|
+
let redirected = false, needTarget = false;
|
|
146
|
+
for (const token of tokens) {
|
|
147
|
+
const words = segments.at(-1)!;
|
|
148
|
+
if (token.kind === "and") {
|
|
149
|
+
if (needTarget || words.length === 0) return undefined;
|
|
150
|
+
segments.push([]); redirected = false; continue;
|
|
151
|
+
}
|
|
152
|
+
if (token.kind === "redirect") {
|
|
153
|
+
if (needTarget || words.length === 0) return undefined;
|
|
154
|
+
redirected = true; needTarget = token.target; continue;
|
|
155
|
+
}
|
|
156
|
+
if (needTarget) { needTarget = false; continue; }
|
|
157
|
+
// Only trailing redirects are stripped; one before an argument is rejected.
|
|
158
|
+
if (redirected) return undefined;
|
|
159
|
+
words.push(token.value);
|
|
160
|
+
}
|
|
161
|
+
return needTarget || segments.at(-1)!.length === 0 ? undefined : segments;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const INFORMATIONAL = ["--version", "-V", "--help", "-h", "--collect-only", "--listTests", "--showConfig", "--init", "--fixtures", "--markers"];
|
|
165
|
+
|
|
166
|
+
/** One simple command's gates: none, the runner's gate, or a target runner's gate targets. */
|
|
167
|
+
function segmentGates(gates: Readonly<Record<string, string>>, tokens: readonly string[]): string[] {
|
|
168
|
+
if (tokens.some((t) => INFORMATIONAL.includes(t))) return [];
|
|
169
|
+
const first = tokens[0] ?? "";
|
|
170
|
+
const runner = own(TARGET_RUNNERS, first);
|
|
171
|
+
if (runner && !Object.hasOwn(gates, first)) return targetGates(gates, runner, tokens.slice(1));
|
|
172
|
+
const single = runnerGate(gates, tokens);
|
|
173
|
+
return single === undefined ? [] : [single];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function runnerGate(gates: Readonly<Record<string, string>>, tokens: readonly string[]): string | undefined {
|
|
83
177
|
let i = 0;
|
|
84
178
|
const first = tokens[0];
|
|
85
|
-
if (["bun", "npm", "pnpm", "yarn"].includes(first ?? "") && tokens[1] === "test") return gates
|
|
179
|
+
if (["bun", "npm", "pnpm", "yarn"].includes(first ?? "") && tokens[1] === "test") return own(gates, "test");
|
|
86
180
|
if (first === "python" || first === "python3") {
|
|
87
181
|
if (tokens[1] !== "-m") return undefined;
|
|
88
182
|
i = 2;
|
|
@@ -94,5 +188,53 @@ export function classifyGate(gates: Readonly<Record<string, string>>, command: s
|
|
|
94
188
|
const executable = tokens[i];
|
|
95
189
|
if (executable === "cargo" && !["test", "check", "clippy", "build"].includes(tokens[i + 1] ?? "")) return undefined;
|
|
96
190
|
if (executable === "vitest" && ["list", "init"].includes(tokens[i + 1] ?? "")) return undefined;
|
|
97
|
-
return gates
|
|
191
|
+
return own(gates, executable ?? "");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A table lookup that a word like `constructor` cannot satisfy through the prototype. */
|
|
195
|
+
function own<T>(table: Readonly<Record<string, T>>, key: string): T | undefined {
|
|
196
|
+
return Object.hasOwn(table, key) ? table[key] : undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Options a target runner may carry and still prove its targets ran. An
|
|
201
|
+
* allowlist on purpose: an unknown option might be a dry run, a touch or an
|
|
202
|
+
* ignore-errors switch (exit 0 without the targets passing), so it makes the
|
|
203
|
+
* segment not a gate rather than being guessed at.
|
|
204
|
+
*/
|
|
205
|
+
type TargetRunner = { withValue: readonly string[]; flags: readonly string[]; attached: RegExp; argsAfterDashes: boolean };
|
|
206
|
+
const TARGET_RUNNERS: Readonly<Record<string, TargetRunner>> = {
|
|
207
|
+
make: { withValue: ["-C", "-f", "--directory", "--file", "--makefile"],
|
|
208
|
+
flags: ["-k", "-s", "-B", "-w", "-r", "-R", "--keep-going", "--silent", "--quiet", "--always-make", "--print-directory", "--no-print-directory"],
|
|
209
|
+
attached: /^(?:-C.+|-f.+|-j\d*|--(?:directory|file|makefile|jobs)=.+|--jobs)$/, argsAfterDashes: false },
|
|
210
|
+
just: { withValue: ["-f", "-d", "--justfile", "--working-directory"], flags: ["-q", "-v", "--quiet", "--verbose"],
|
|
211
|
+
attached: /^--(?:justfile|working-directory)=.+$/, argsAfterDashes: false },
|
|
212
|
+
task: { withValue: ["-d", "-t", "--dir", "--taskfile"], flags: ["-s", "-v", "-p", "-f", "--silent", "--verbose", "--parallel", "--force"],
|
|
213
|
+
attached: /^--(?:dir|taskfile)=.+$/, argsAfterDashes: true },
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* `make build size test`: the runner exits 0 only when every target
|
|
218
|
+
* succeeded, so each target named after a configured gate key (exactly, or
|
|
219
|
+
* as `<key>-…` / `<key>:…`) is that gate; other targets do not weaken it. No
|
|
220
|
+
* gate target, or no target at all (the default goal is unknown), is no gate.
|
|
221
|
+
*/
|
|
222
|
+
function targetGates(gates: Readonly<Record<string, string>>, runner: TargetRunner, args: readonly string[]): string[] {
|
|
223
|
+
const keys = Object.keys(gates).sort((a, b) => b.length - a.length);
|
|
224
|
+
const names: string[] = [];
|
|
225
|
+
for (let i = 0; i < args.length; i++) {
|
|
226
|
+
const arg = args[i]!;
|
|
227
|
+
if (arg === "--") { if (runner.argsAfterDashes) break; return []; }
|
|
228
|
+
if (arg.startsWith("-")) {
|
|
229
|
+
if (runner.withValue.includes(arg)) { if (++i >= args.length) return []; continue; }
|
|
230
|
+
if (arg === "-j" && /^\d+$/.test(args[i + 1] ?? "")) { i++; continue; }
|
|
231
|
+
if (runner.flags.includes(arg) || runner.attached.test(arg)) continue;
|
|
232
|
+
return [];
|
|
233
|
+
}
|
|
234
|
+
if (arg.includes("=")) continue; // a variable override, not a target
|
|
235
|
+
if (/[*?[\]~]/.test(arg)) return [];
|
|
236
|
+
const key = keys.find((k) => arg === k || arg.startsWith(`${k}-`) || arg.startsWith(`${k}:`));
|
|
237
|
+
if (key !== undefined) names.push(gates[key]!);
|
|
238
|
+
}
|
|
239
|
+
return names;
|
|
98
240
|
}
|
package/src/core/export.ts
CHANGED
|
@@ -137,6 +137,8 @@ function eventHtml(e: LogEvent): string {
|
|
|
137
137
|
case "evidence_link":
|
|
138
138
|
return wrap("event", "evidence link", e.at, `${e.work}: ${e.what} → ${e.source}`);
|
|
139
139
|
case "work_owner": case "work_dependencies": return wrap("event", e.t, e.at, JSON.stringify(e));
|
|
140
|
+
case "approval": return wrap("event", "approval", e.at,
|
|
141
|
+
[`${e.tool} · ${e.call_id} · ${e.decision} · ${e.source}`, ...(e.rule ? [`rule: ${e.rule}`] : []), ...(e.command !== undefined ? [`$ ${e.command}`] : [])].join("\n"));
|
|
140
142
|
case "skill_loaded": return wrap("event", "skill loaded", e.at, `${e.name} · ${e.path} · ${e.sha256} · ${e.mode}`);
|
|
141
143
|
case "process_exit": return wrap("event", "process exit", e.at, `${e.session} · exit ${e.exit} · output ${e.output.sha256}`);
|
|
142
144
|
case "acceptance_contract": return wrap("event", "acceptance contract", e.at, JSON.stringify(e.contract));
|