@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.
Files changed (69) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/docs/install.md +25 -0
  3. package/package.json +1 -1
  4. package/src/apps/cli.ts +4 -1
  5. package/src/apps/shell.ts +13 -3
  6. package/src/apps/startup.ts +31 -6
  7. package/src/apps/ui/app.ts +32 -14
  8. package/src/apps/ui/project.ts +1 -1
  9. package/src/apps/ui/standalone.ts +4 -2
  10. package/src/apps/ui/surface.ts +2 -0
  11. package/src/core/activity-status.ts +27 -3
  12. package/src/core/command-contract.ts +121 -5
  13. package/src/core/completion-review.ts +1 -1
  14. package/src/core/derive.ts +9 -1
  15. package/src/core/environment.ts +7 -0
  16. package/src/core/event-validation.ts +19 -3
  17. package/src/core/events.ts +38 -2
  18. package/src/core/evidence.ts +149 -7
  19. package/src/core/export.ts +2 -0
  20. package/src/core/external-effects.ts +488 -0
  21. package/src/core/handoff.ts +24 -3
  22. package/src/core/history-query.ts +1 -1
  23. package/src/core/history.ts +131 -21
  24. package/src/core/observation-view.ts +24 -0
  25. package/src/core/path-suggestions.ts +59 -0
  26. package/src/core/permissions.ts +64 -3
  27. package/src/core/prompt-policy.ts +11 -2
  28. package/src/core/regex-dialect.ts +25 -0
  29. package/src/core/ripgrep-release.ts +64 -0
  30. package/src/core/secrets.ts +188 -0
  31. package/src/core/stall.ts +63 -18
  32. package/src/core/stop-report.ts +46 -4
  33. package/src/core/tool-contract.ts +2 -0
  34. package/src/core/tool-failure.ts +25 -4
  35. package/src/core/transcript.ts +3 -0
  36. package/src/core/update-check.ts +2 -1
  37. package/src/core/work-state.ts +7 -2
  38. package/src/core/working-set.ts +34 -6
  39. package/src/runtime/connection-reuse.ts +40 -0
  40. package/src/runtime/exec-sessions.ts +69 -19
  41. package/src/runtime/history-tools.ts +12 -2
  42. package/src/runtime/loop.ts +196 -26
  43. package/src/runtime/open-image.ts +42 -0
  44. package/src/runtime/openai-responses.ts +15 -7
  45. package/src/runtime/openai.ts +15 -7
  46. package/src/runtime/permission-resources.ts +11 -4
  47. package/src/runtime/pi-provider.ts +34 -9
  48. package/src/runtime/provider.ts +19 -8
  49. package/src/runtime/retry.ts +152 -9
  50. package/src/runtime/secrets.ts +79 -0
  51. package/src/runtime/session-control.ts +9 -1
  52. package/src/runtime/stream-idle.ts +80 -0
  53. package/src/runtime/tools.ts +139 -31
  54. package/src/runtime/turn-effects.ts +31 -0
  55. package/src/store/command.ts +3 -1
  56. package/src/store/context-files.ts +5 -1
  57. package/src/store/file-checkpoints.ts +6 -3
  58. package/src/store/log.ts +20 -0
  59. package/src/store/replay.ts +4 -3
  60. package/src/store/search.ts +9 -5
  61. package/src/store/secrets.ts +44 -0
  62. package/src/store/sessions.ts +49 -6
  63. package/src/store/tool-binaries.ts +90 -0
  64. package/src/store/worktree.ts +55 -10
  65. package/src/tui/ui/image-size.ts +6 -2
  66. package/src/tui/ui/items.ts +3 -1
  67. package/src/tui/ui/pictures.ts +14 -1
  68. package/src/tui/ui/screen.ts +6 -3
  69. 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
- /** Preserve inherited values except documented noninteractive defaults; never mutate the host. */
14
- export function commandEnvironment(base: Readonly<Record<string, string | undefined>>): Record<string, string> {
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
- return { ok: stopped === undefined && exit === 0 && cleanup === undefined,
33
- outcome: executionOutcome(stopped ?? (exit === 0 && cleanup === undefined ? "tool_completed" : "tool_failed")),
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}`),
@@ -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
- const hasNextAction = inFlight.some((i) => i.next_action.trim().length > 0);
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,
@@ -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"]) &&
@@ -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
- working_set_version?: 2;
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",
@@ -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
- /** D11: classify an actual verifier invocation, never a runner word in data. */
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
- if (/[\n\r;&|<>`$(){}'"\\]/.test(command)) return undefined;
80
- const tokens = command.trim().split(/\s+/);
81
- const informational = ["--version", "-V", "--help", "-h", "--collect-only", "--listTests", "--showConfig", "--init", "--fixtures", "--markers"];
82
- if (tokens.some((t) => informational.includes(t))) return undefined;
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["test"];
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[executable ?? ""];
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
  }
@@ -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));