@awak-app/simy-cli 0.2.1 → 0.2.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/README.md CHANGED
@@ -36,6 +36,14 @@ runs. It builds the requirement charter, runs Codex or Claude Code, audits
36
36
  structured completion evidence, and re-instructs the executor within the
37
37
  configured attempt budget.
38
38
 
39
+ Before Web creates an Agentic Loop, it classifies whether the request actually
40
+ needs a managed PR, verification, evidence, and merge lifecycle. A bounded
41
+ ordinary task is sent to CLI 0.2.2 or newer through the one-time direct executor
42
+ instead. CLI independently recomputes the guardrail, restricts read-only work at
43
+ the Provider command boundary, and permits exactly one Provider invocation. It
44
+ does not create an Agentic Loop ledger, audit session, or retry lifecycle for
45
+ that task.
46
+
39
47
  ## CLI updates
40
48
 
41
49
  The CLI checks the npm registry at startup and once per hour while it is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Local SIMY Agentic Loop executor for Codex and Claude Code.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,12 +25,14 @@
25
25
  "test:e2e:hypothesis-retries": "node ./scripts/hypothesis-retries-e2e.js",
26
26
  "test:e2e:budgets": "node ./scripts/budget-enforcement-e2e.js",
27
27
  "test:e2e:desktop-executor": "node ./scripts/desktop-executor-e2e.js",
28
+ "test:e2e:pre-guardrail": "node ./scripts/pre-guardrail-e2e.js",
29
+ "test:e2e:completion-contract": "node ./scripts/completion-contract-e2e.js",
28
30
  "test:e2e:console:real-provider": "node ./scripts/real-provider-console-e2e.js",
29
31
  "test:e2e:console:fullstack": "node ./scripts/fullstack-cli-smoke.js",
30
- "check": "npm run check:auto-update && node --check ./src/desktop-executor.js && node --check ./scripts/desktop-executor-e2e.js && node --check ./scripts/fixtures/fake-desktop-provider.js && npm run check:syntax && npm run check:real-provider-syntax && npm test && npm run check:package",
32
+ "check": "npm run check:auto-update && node --check ./src/desktop-executor.js && node --check ./scripts/desktop-executor-e2e.js && node --check ./scripts/pre-guardrail-e2e.js && node --check ./scripts/fixtures/fake-desktop-provider.js && npm run check:syntax && npm run check:real-provider-syntax && npm test && npm run check:package",
31
33
  "check:auto-update": "node --check ./src/auto-update.js && node --check ./scripts/auto-update-e2e.js",
32
34
  "check:real-provider-syntax": "node --check ./scripts/real-codex-probe.js && node --check ./scripts/real-claude-probe.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js",
33
- "check:syntax": "node --check ./src/index.js && node --check ./src/agent.js && node --check ./src/browser.js && node --check ./src/web-api.js && node --check ./src/run-registry.js && node --check ./src/backend-executable.js && node --check ./src/provider-stream.js && node --check ./src/console/index.js && node --check ./src/console/app.js && node --check ./src/console/commands.js && node --check ./src/local-attachments.js && node --check ./src/repository-inventory.js && node --check ./src/session-store.js && node --check ./src/web-origin.js && node --check ./src/workspace-context.js && node --check ./src/orchestrator/index.js && node --check ./src/orchestrator/shared.js && node --check ./src/orchestrator/recovery.js && node --check ./src/orchestrator/risk.js && node --check ./src/orchestrator/contract.js && node --check ./src/orchestrator/instruction.js && node --check ./src/orchestrator/problem-solving.js && node --check ./src/orchestrator/budget.js && node --check ./src/orchestrator/execution-io.js && node --check ./src/orchestrator/presentation.js && node --check ./src/orchestrator/result.js && node --check ./src/orchestrator/retry.js && node --check ./src/orchestrator/evidence.js && node --check ./src/orchestrator/independent-audit.js && node --check ./src/orchestrator/audit.js && node --check ./src/orchestrator/loop.js && node --check ./src/runner.js && node --check ./scripts/fixtures/fake-coding-backend.js && node --check ./scripts/e2e-terminal-evidence.js && node --check ./scripts/audit-gate-e2e.js && node --check ./scripts/retry-circuit-e2e.js && node --check ./scripts/follow-up-charter-e2e.js && node --check ./scripts/task-routing-e2e.js && node --check ./scripts/executor-version-preflight-e2e.js && node --check ./scripts/blocked-recovery-e2e.js && node --check ./scripts/process-localization-e2e.js && node --check ./scripts/hypothesis-retries-e2e.js && node --check ./scripts/budget-enforcement-e2e.js && node --check ./scripts/console-e2e-fixture.js && node --check ./scripts/console-e2e.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/fullstack-cli-smoke.js && node --check ./scripts/check-package-contents.js",
35
+ "check:syntax": "node --check ./src/index.js && node --check ./src/agent.js && node --check ./src/browser.js && node --check ./src/web-api.js && node --check ./src/run-registry.js && node --check ./src/direct-executor.js && node --check ./src/execution-guardrail.js && node --check ./src/backend-executable.js && node --check ./src/provider-stream.js && node --check ./src/console/index.js && node --check ./src/console/app.js && node --check ./src/console/commands.js && node --check ./src/local-attachments.js && node --check ./src/repository-inventory.js && node --check ./src/session-store.js && node --check ./src/web-origin.js && node --check ./src/workspace-context.js && node --check ./src/orchestrator/index.js && node --check ./src/orchestrator/shared.js && node --check ./src/orchestrator/recovery.js && node --check ./src/orchestrator/risk.js && node --check ./src/orchestrator/contract.js && node --check ./src/orchestrator/completion-contract.js && node --check ./src/orchestrator/instruction.js && node --check ./src/orchestrator/problem-solving.js && node --check ./src/orchestrator/budget.js && node --check ./src/orchestrator/execution-io.js && node --check ./src/orchestrator/presentation.js && node --check ./src/orchestrator/result.js && node --check ./src/orchestrator/retry.js && node --check ./src/orchestrator/evidence.js && node --check ./src/orchestrator/independent-audit.js && node --check ./src/orchestrator/audit.js && node --check ./src/orchestrator/loop.js && node --check ./src/runner.js && node --check ./scripts/fixtures/completion-contract-fixture.js && node --check ./scripts/fixtures/fake-coding-backend.js && node --check ./scripts/e2e-terminal-evidence.js && node --check ./scripts/audit-gate-e2e.js && node --check ./scripts/completion-contract-e2e.js && node --check ./scripts/retry-circuit-e2e.js && node --check ./scripts/follow-up-charter-e2e.js && node --check ./scripts/task-routing-e2e.js && node --check ./scripts/executor-version-preflight-e2e.js && node --check ./scripts/blocked-recovery-e2e.js && node --check ./scripts/process-localization-e2e.js && node --check ./scripts/hypothesis-retries-e2e.js && node --check ./scripts/budget-enforcement-e2e.js && node --check ./scripts/console-e2e-fixture.js && node --check ./scripts/console-e2e.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/fullstack-cli-smoke.js && node --check ./scripts/check-package-contents.js",
34
36
  "check:package": "node ./scripts/check-package-contents.js"
35
37
  },
36
38
  "engines": {
package/src/agent.js CHANGED
@@ -70,6 +70,16 @@ import {
70
70
  CLI_VERSION,
71
71
  withCliContract,
72
72
  } from "./cli-contract.js";
73
+ import {
74
+ createDirectTask,
75
+ directTaskSnapshot,
76
+ DirectTaskRegistry,
77
+ startDirectTask,
78
+ } from "./direct-executor.js";
79
+ import {
80
+ classifyExecutionRequirement,
81
+ EXECUTION_GUARDRAIL_POLICY_VERSION,
82
+ } from "./execution-guardrail.js";
73
83
 
74
84
  const DEVICE_HEARTBEAT_INTERVAL_MS = 20_000;
75
85
 
@@ -84,6 +94,7 @@ export async function startAgent({
84
94
  } = {}) {
85
95
  const apiOrigin = resolveWebOrigin(webOrigin);
86
96
  const registry = new LocalRunRegistry();
97
+ const directRegistry = new DirectTaskRegistry();
87
98
  const authNonce = randomBytes(16).toString("base64url");
88
99
  let session = await readSession(apiOrigin, sessionRoot);
89
100
  if (session && sessionRequiresWebAuthorization(session, apiOrigin)) session = null;
@@ -377,6 +388,112 @@ export async function startAgent({
377
388
  void synchronizeAuthorizedSession();
378
389
  return;
379
390
  }
391
+ const directExecutionMatch = url.pathname.match(/^\/v1\/direct-execution\/([^/]+)$/);
392
+ if (req.method === "GET" && directExecutionMatch) {
393
+ if (!isSessionValid(session, Date.now(), apiOrigin)) {
394
+ json(res, 401, { error: "simy session expired; run simy again" });
395
+ return;
396
+ }
397
+ const task = directRegistry.get(decodeURIComponent(directExecutionMatch[1]));
398
+ if (!task) {
399
+ json(res, 404, { error: "direct task not found" });
400
+ return;
401
+ }
402
+ json(res, 200, { task: directTaskSnapshot(task) });
403
+ return;
404
+ }
405
+ if (req.method === "POST" && url.pathname === "/v1/direct-execution/start") {
406
+ if (!acceptsNewWork(updateManager)) {
407
+ json(res, 503, updateInProgressResponse(updateManager));
408
+ return;
409
+ }
410
+ if (!isSessionValid(session, Date.now(), apiOrigin)) {
411
+ json(res, 401, { error: "simy session expired; run simy again" });
412
+ return;
413
+ }
414
+ const body = await readJson(req);
415
+ const instruction = String(body.instruction || "").trim();
416
+ if (!instruction) {
417
+ json(res, 400, { error: "instruction is required" });
418
+ return;
419
+ }
420
+ if (body.backend !== "codex" && body.backend !== "claude") {
421
+ json(res, 400, { error: "backend must be codex or claude" });
422
+ return;
423
+ }
424
+ const guardrail = classifyExecutionRequirement(instruction);
425
+ if (
426
+ guardrail.decision !== "direct_executor" ||
427
+ body.guardrail?.policy_version !== EXECUTION_GUARDRAIL_POLICY_VERSION ||
428
+ body.guardrail?.decision !== "direct_executor"
429
+ ) {
430
+ json(res, 409, {
431
+ error: "This request is not authorized for one-time direct execution.",
432
+ code: "DIRECT_EXECUTION_GUARD_REQUIRED",
433
+ guardrail,
434
+ });
435
+ return;
436
+ }
437
+ if (
438
+ body.operation !== guardrail.operation ||
439
+ body.permission_mode !== guardrail.permission_mode ||
440
+ body.max_provider_invocations !== 1
441
+ ) {
442
+ json(res, 409, {
443
+ error: "Direct execution permissions do not match the pre-guardrail decision.",
444
+ code: "DIRECT_EXECUTION_GUARD_MISMATCH",
445
+ guardrail,
446
+ });
447
+ return;
448
+ }
449
+ const inspection = normalizeBackendInspection(body.backend, availableCapabilities);
450
+ if (!inspection.compatible) {
451
+ const summary = backendPreflightSummary(body.backend, inspection);
452
+ json(res, 409, {
453
+ error: summary,
454
+ code: `desktop_executor_${inspection.status}`,
455
+ });
456
+ return;
457
+ }
458
+ const repository = String(body.repository || "").trim() || null;
459
+ if (guardrail.repository_required && !repository) {
460
+ json(res, 400, { error: "repository is required for this direct task" });
461
+ return;
462
+ }
463
+ const indexedRepository = repository ? await ensureRepositoryIndexed(repository) : null;
464
+ if (repository && !indexedRepository?.local_path) {
465
+ json(res, 409, {
466
+ error: `${repository} is not authorized on this SIMY CLI.`,
467
+ code: "repository_not_authorized",
468
+ });
469
+ return;
470
+ }
471
+ const repositoryPath = indexedRepository?.local_path || workspace.localPath;
472
+ if (!repositoryPath) {
473
+ json(res, 409, {
474
+ error: "No local workspace is available for this direct task.",
475
+ code: "local_workspace_unavailable",
476
+ });
477
+ return;
478
+ }
479
+ const task = createDirectTask({
480
+ instruction,
481
+ backend: body.backend,
482
+ operation: guardrail.operation,
483
+ permissionMode: guardrail.permission_mode,
484
+ repository,
485
+ repositoryPath,
486
+ });
487
+ directRegistry.create(task);
488
+ const runDirectTask = dependencies.runDirectTask || startDirectTask;
489
+ void Promise.resolve(runDirectTask(task)).catch((error) => {
490
+ task.status = "failed";
491
+ task.error = error instanceof Error ? error.message : String(error);
492
+ task.updated_at = new Date().toISOString();
493
+ });
494
+ json(res, 202, { ok: true, task: directTaskSnapshot(task) });
495
+ return;
496
+ }
380
497
  if (req.method === "POST" && agenticLoopPath === "/v1/agentic-loop/start") {
381
498
  if (!acceptsNewWork(updateManager)) {
382
499
  json(res, 503, updateInProgressResponse(updateManager));
@@ -387,6 +504,22 @@ export async function startAgent({
387
504
  return;
388
505
  }
389
506
  const { body, attachments } = await readCodingLoopStart(req);
507
+ const guardrailSource = String(body.source_message || "").trim();
508
+ if (guardrailSource) {
509
+ const guardrail = classifyExecutionRequirement(guardrailSource);
510
+ if (
511
+ guardrail.decision !== "agentic_loop" ||
512
+ body.guardrail?.policy_version !== EXECUTION_GUARDRAIL_POLICY_VERSION ||
513
+ body.guardrail?.decision !== "agentic_loop"
514
+ ) {
515
+ json(res, 409, {
516
+ error: "This task does not qualify for an Agentic Loop.",
517
+ code: "AGENTIC_LOOP_NOT_REQUIRED",
518
+ guardrail,
519
+ });
520
+ return;
521
+ }
522
+ }
390
523
  const verified = await verifyLaunchChallenge({
391
524
  apiOrigin,
392
525
  apiBaseUrl: session.api_base_url,
@@ -791,6 +924,7 @@ export async function startAgent({
791
924
  server,
792
925
  port,
793
926
  registry,
927
+ directRegistry,
794
928
  webOrigin: apiOrigin,
795
929
  get loginUrl() {
796
930
  return loginUrl?.toString() ?? null;
@@ -1077,6 +1211,7 @@ async function capabilities() {
1077
1211
  repository_scan_approval: true,
1078
1212
  executor_version_preflight: true,
1079
1213
  desktop_executor: true,
1214
+ direct_execution: true,
1080
1215
  },
1081
1216
  session_ttl_hours: 48,
1082
1217
  };
@@ -1,5 +1,5 @@
1
- export const CLI_VERSION = "0.2.1";
2
- export const CLI_API_CONTRACT_VERSION = 2;
1
+ export const CLI_VERSION = "0.2.3";
2
+ export const CLI_API_CONTRACT_VERSION = 3;
3
3
 
4
4
  export function withCliContract(capabilities = {}, installMode = null) {
5
5
  return {
@@ -49,11 +49,18 @@ export async function resolveDesktopExecutorCommand({
49
49
  backend,
50
50
  instruction,
51
51
  repositoryPath,
52
+ executionKind = "agentic_loop",
53
+ permissionMode = "workspace_write",
52
54
  environment = process.env,
53
55
  spawnImpl = spawn,
54
56
  inspectionOptions = {},
55
57
  } = {}) {
56
58
  const provider = normalizeBackend(backend);
59
+ const directExecution = executionKind === "direct";
60
+ if (executionKind !== "agentic_loop" && !directExecution) {
61
+ throw new Error("Desktop execution kind must be agentic_loop or direct.");
62
+ }
63
+ const normalizedPermissionMode = normalizePermissionMode(permissionMode);
57
64
  const target = normalizeDesktopExecutionTarget(DESKTOP_EXECUTION_TARGET);
58
65
  const override =
59
66
  provider === "claude" ? environment.SIMY_CLAUDE_COMMAND : environment.SIMY_CODEX_COMMAND;
@@ -62,6 +69,8 @@ export async function resolveDesktopExecutorCommand({
62
69
  ...shellCommand(override, repositoryPath, instruction, spawnImpl),
63
70
  backend: provider,
64
71
  execution_target: target,
72
+ execution_kind: executionKind,
73
+ permission_mode: normalizedPermissionMode,
65
74
  verification: "explicit_command_override",
66
75
  };
67
76
  }
@@ -77,12 +86,24 @@ export async function resolveDesktopExecutorCommand({
77
86
  bin: inspection.executable,
78
87
  args:
79
88
  provider === "claude"
80
- ? claudeBackendArgs(instruction)
81
- : ["exec", "--json", instruction],
89
+ ? directExecution
90
+ ? claudeDirectBackendArgs(instruction, normalizedPermissionMode)
91
+ : claudeBackendArgs(instruction)
92
+ : directExecution
93
+ ? [
94
+ "exec",
95
+ "--json",
96
+ "--sandbox",
97
+ normalizedPermissionMode === "read_only" ? "read-only" : "workspace-write",
98
+ instruction,
99
+ ]
100
+ : ["exec", "--json", instruction],
82
101
  env: {},
83
102
  spawn: spawnImpl,
84
103
  backend: provider,
85
104
  execution_target: target,
105
+ execution_kind: executionKind,
106
+ permission_mode: normalizedPermissionMode,
86
107
  verification: "compatible_version",
87
108
  installed_version: inspection.installed_version,
88
109
  };
@@ -102,11 +123,30 @@ export function claudeBackendArgs(instruction) {
102
123
  ];
103
124
  }
104
125
 
126
+ export function claudeDirectBackendArgs(instruction, permissionMode = "read_only") {
127
+ const normalized = normalizePermissionMode(permissionMode);
128
+ return [
129
+ "-p",
130
+ instruction,
131
+ "--output-format",
132
+ "stream-json",
133
+ "--verbose",
134
+ ...(normalized === "read_only"
135
+ ? ["--permission-mode", "plan"]
136
+ : ["--dangerously-skip-permissions"]),
137
+ ];
138
+ }
139
+
105
140
  function normalizeBackend(backend) {
106
141
  if (backend === "codex" || backend === "claude") return backend;
107
142
  throw new Error("Desktop executor backend must be codex or claude.");
108
143
  }
109
144
 
145
+ function normalizePermissionMode(value) {
146
+ if (value === "read_only" || value === "workspace_write") return value;
147
+ throw new Error("Desktop permission mode must be read_only or workspace_write.");
148
+ }
149
+
110
150
  function desktopExecutorUnavailableError(inspection) {
111
151
  const provider = inspection.provider_label;
112
152
  let message;
@@ -131,6 +171,7 @@ function shellCommand(command, cwd, instruction, spawnImpl) {
131
171
  env: {
132
172
  SIMY_AGENTIC_LOOP_REQUIREMENT: instruction,
133
173
  SIMY_CODING_LOOP_REQUIREMENT: instruction,
174
+ SIMY_DIRECT_EXECUTION_INSTRUCTION: instruction,
134
175
  },
135
176
  spawn: spawnImpl,
136
177
  };
@@ -0,0 +1,142 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import { resolveDesktopExecutorCommand } from "./desktop-executor.js";
4
+ import { createProviderStreamDecoder } from "./provider-stream.js";
5
+
6
+ const MAX_OUTPUT_LINES = 400;
7
+
8
+ export class DirectTaskRegistry {
9
+ #tasks = new Map();
10
+
11
+ create(task) {
12
+ if (this.#tasks.has(task.id)) throw new Error(`Direct task ${task.id} already exists.`);
13
+ this.#tasks.set(task.id, task);
14
+ return task;
15
+ }
16
+
17
+ get(taskId) {
18
+ return this.#tasks.get(taskId) ?? null;
19
+ }
20
+ }
21
+
22
+ export function createDirectTask({
23
+ instruction,
24
+ backend,
25
+ operation,
26
+ permissionMode,
27
+ repository = null,
28
+ repositoryPath,
29
+ }) {
30
+ const now = new Date().toISOString();
31
+ return {
32
+ id: `direct_task_${randomUUID()}`,
33
+ status: "queued",
34
+ instruction,
35
+ backend,
36
+ operation,
37
+ permission_mode: permissionMode,
38
+ repository,
39
+ repository_path: repositoryPath,
40
+ invocation_count: 0,
41
+ output: [],
42
+ result: null,
43
+ error: null,
44
+ token_usage: { records: [] },
45
+ created_at: now,
46
+ updated_at: now,
47
+ child: null,
48
+ };
49
+ }
50
+
51
+ export async function startDirectTask(
52
+ task,
53
+ { resolveCommand = resolveDesktopExecutorCommand, environment = process.env } = {},
54
+ ) {
55
+ if (!task || task.status !== "queued") throw new Error("Direct task must be queued.");
56
+ if (task.invocation_count >= 1) throw new Error("Direct tasks allow only one Provider invocation.");
57
+ const command = await resolveCommand({
58
+ backend: task.backend,
59
+ instruction: task.instruction,
60
+ repositoryPath: task.repository_path,
61
+ executionKind: "direct",
62
+ permissionMode: task.permission_mode,
63
+ environment,
64
+ });
65
+ task.status = "running";
66
+ task.invocation_count = 1;
67
+ task.updated_at = new Date().toISOString();
68
+
69
+ return new Promise((resolve) => {
70
+ let settled = false;
71
+ const rawStdout = [];
72
+ const tokenRecords = [];
73
+ const child = command.spawn(command.bin, command.args, {
74
+ cwd: task.repository_path,
75
+ env: { ...process.env, ...command.env },
76
+ stdio: ["ignore", "pipe", "pipe"],
77
+ detached: process.platform !== "win32",
78
+ });
79
+ task.child = child;
80
+ const onLine = (line) => {
81
+ const normalized = String(line || "").trim();
82
+ if (!normalized) return;
83
+ task.output.push(normalized);
84
+ if (task.output.length > MAX_OUTPUT_LINES) {
85
+ task.output.splice(0, task.output.length - MAX_OUTPUT_LINES);
86
+ }
87
+ task.updated_at = new Date().toISOString();
88
+ };
89
+ const stdoutDecoder = createProviderStreamDecoder({
90
+ backend: task.backend,
91
+ stream: "stdout",
92
+ onLine,
93
+ onUsage: (usage) => tokenRecords.push({ ...usage, phase: "direct", backend: task.backend }),
94
+ });
95
+ const stderrDecoder = createProviderStreamDecoder({
96
+ backend: task.backend,
97
+ stream: "stderr",
98
+ onLine,
99
+ });
100
+ child.stdout?.on("data", (chunk) => {
101
+ const text = chunk.toString("utf8");
102
+ rawStdout.push(text);
103
+ stdoutDecoder.push(text);
104
+ });
105
+ child.stderr?.on("data", (chunk) => stderrDecoder.push(chunk.toString("utf8")));
106
+
107
+ const finish = ({ exitCode = null, error = null } = {}) => {
108
+ if (settled) return;
109
+ settled = true;
110
+ stdoutDecoder.flush();
111
+ stderrDecoder.flush();
112
+ task.child = null;
113
+ task.token_usage = { records: tokenRecords };
114
+ task.status = error || exitCode !== 0 ? "failed" : "succeeded";
115
+ task.error = error || (exitCode !== 0 ? `Provider exited with code ${exitCode}.` : null);
116
+ task.result = task.output.at(-1) || rawStdout.join("").trim() || null;
117
+ task.updated_at = new Date().toISOString();
118
+ resolve(task);
119
+ };
120
+ child.on("error", (error) => finish({ error: error.message }));
121
+ child.on("close", (code) => finish({ exitCode: code }));
122
+ });
123
+ }
124
+
125
+ export function directTaskSnapshot(task) {
126
+ return {
127
+ id: task.id,
128
+ status: task.status,
129
+ instruction: task.instruction,
130
+ backend: task.backend,
131
+ operation: task.operation,
132
+ permission_mode: task.permission_mode,
133
+ repository: task.repository,
134
+ invocation_count: task.invocation_count,
135
+ output: [...task.output],
136
+ result: task.result,
137
+ error: task.error,
138
+ token_usage: structuredClone(task.token_usage),
139
+ created_at: task.created_at,
140
+ updated_at: task.updated_at,
141
+ };
142
+ }
@@ -0,0 +1,120 @@
1
+ export const EXECUTION_GUARDRAIL_POLICY_VERSION = "2026-07-21.1";
2
+
3
+ const OBSERVE_PATTERN =
4
+ /\b(monitor|watch|observe|track|status|progress|keep an eye on)\b|監視|見守|進捗|状況を?(?:見|確認)|监控|監控|观察|觀察|进度|進度|状态|狀態/i;
5
+ const READ_PATTERN =
6
+ /\b(explain|summari[sz]e|inspect|review|look at|read|check|diagnose|investigate)\b|説明|要約|確認|調査|レビュー|查看|解释|說明|总结|總結|检查|檢查|调查|調查|诊断|診斷/i;
7
+ const CHANGE_PATTERN =
8
+ /\b(implement|fix|build|create|change|update|refactor|add|remove|rename|ship|write|edit|merge|release|publish)\b|実装|修正|改善|変更|追加|削除|作成|開発|マージ|公開|发布|發佈|实现|實現|修复|修復|修改|改掉|改一下|新增|删除|刪除|重构|重構|合并|合併/i;
9
+ const OBJECT_PATTERN =
10
+ /\b(code|file|readme|api|ui|ux|feature|bug|pr|pull request|repository|repo|branch|test|migration|endpoint|component|page|cli|backend|extension)\b|コード|ファイル|機能|不具合|画面|ページ|テスト|移行|组件|組件|页面|頁面|代码|代碼|文件|功能|错误|錯誤|仓库|倉庫|分支/i;
11
+ const VAGUE_PATTERN = /^(?:fix|do|handle|continue|change)?\s*(?:it|this|that)?[.!。!]?$/i;
12
+ const VAGUE_CJK_PATTERN = /^(?:これ|それ|あれ|やって|直して|続けて|这个|這個|那个|那個|处理|處理|修一下)[。!]?$/i;
13
+ const UNSUPPORTED_PATTERN =
14
+ /\b(?:steal|exfiltrate|bypass authentication|disable audit)\b|認証を迂回|監査を無効|窃取凭证|繞過認證/i;
15
+
16
+ const MANAGED_SIGNAL_PATTERNS = [
17
+ ["pr_merge_release", /\b(?:pull request|pr|merge|release|publish|deploy)\b|PR|マージ|リリース|デプロイ|合并|合併|发布|發佈|部署/i],
18
+ ["verification", /\b(?:test|e2e|integration test|until green|ci)\b|テスト|検証|通过测试|通過測試|直到.*通过|直到.*通過/i],
19
+ ["browser_evidence", /\b(?:browser|screenshots?|visual evidence|evidence)\b|ブラウザ|スクリーンショット|証跡|截图|截圖|浏览器|瀏覽器|证据|證據/i],
20
+ ["cross_repository", /\b(?:cross[- ]repo|multiple repos|multi[- ]repo)\b|複数(?:の)?リポジトリ|跨仓库|跨倉庫|多个仓库|多個倉庫/i],
21
+ ["high_risk_change", /\b(?:migration|authentication|authorization|security|rollback)\b|移行|認証|認可|セキュリティ|ロールバック|迁移|遷移|认证|認證|授权|授權|安全|回滚|回滾/i],
22
+ ["diagnose_repair_reverify", /\bdiagnos\w*\b[\s\S]*\bfix\w*\b[\s\S]*\b(?:verify|retest)\b|調査[\s\S]*修正[\s\S]*再検証|诊断[\s\S]*修复[\s\S]*复验|診斷[\s\S]*修復[\s\S]*複驗/i],
23
+ ];
24
+
25
+ export function classifyExecutionRequirement(sourceMessage) {
26
+ const source = String(sourceMessage || "")
27
+ .replace(/^\s*\/(?:agentic-loop|coding-loop)\b\s*/i, "")
28
+ .trim();
29
+ if (!source || VAGUE_PATTERN.test(source) || VAGUE_CJK_PATTERN.test(source)) {
30
+ const vagueChangeRequested = CHANGE_PATTERN.test(source);
31
+ return guardrail("needs_clarification", {
32
+ change_requested: vagueChangeRequested,
33
+ operation: vagueChangeRequested ? "execute" : "read",
34
+ permission_mode: vagueChangeRequested ? "workspace_write" : "read_only",
35
+ reason_code: "goal_not_clear",
36
+ });
37
+ }
38
+ if (UNSUPPORTED_PATTERN.test(source)) {
39
+ return guardrail("unsupported", {
40
+ operation: "execute",
41
+ permission_mode: "workspace_write",
42
+ reason_code: "prohibited_operation",
43
+ });
44
+ }
45
+ const changeRequested = CHANGE_PATTERN.test(source);
46
+ const observeRequested = OBSERVE_PATTERN.test(source);
47
+ const readRequested = READ_PATTERN.test(source);
48
+ const hasObject = OBJECT_PATTERN.test(source) || source.length >= 24;
49
+ const agenticSignals = MANAGED_SIGNAL_PATTERNS.filter(([, pattern]) => pattern.test(source)).map(
50
+ ([signal]) => signal,
51
+ );
52
+ if (changeRequested && hasObject && agenticSignals.length >= 2) {
53
+ return guardrail("agentic_loop", {
54
+ change_requested: true,
55
+ managed_lifecycle_required: true,
56
+ repository_required: true,
57
+ operation: "execute",
58
+ permission_mode: "workspace_write",
59
+ agentic_signals: agenticSignals,
60
+ reason_code: "managed_change_lifecycle_required",
61
+ });
62
+ }
63
+ if (observeRequested && !changeRequested) {
64
+ return guardrail("direct_executor", {
65
+ repository_required: /\b(?:repo|repository|pr|branch)\b|リポジトリ|仓库|倉庫|分支/i.test(source),
66
+ operation: "observe",
67
+ permission_mode: "read_only",
68
+ direct_signals: ["bounded_observation", "no_persistent_change"],
69
+ reason_code: "observe_existing_provider_session",
70
+ });
71
+ }
72
+ if (changeRequested && hasObject) {
73
+ return guardrail("direct_executor", {
74
+ change_requested: true,
75
+ repository_required: true,
76
+ operation: "execute",
77
+ permission_mode: "workspace_write",
78
+ agentic_signals: agenticSignals,
79
+ direct_signals: ["single_bounded_change", "managed_lifecycle_not_required"],
80
+ reason_code: "bounded_change_direct_execution",
81
+ });
82
+ }
83
+ if (changeRequested) {
84
+ return guardrail("needs_clarification", {
85
+ change_requested: true,
86
+ operation: "execute",
87
+ permission_mode: "workspace_write",
88
+ reason_code: "change_target_missing",
89
+ });
90
+ }
91
+ if (readRequested || source.length >= 8) {
92
+ return guardrail("direct_executor", {
93
+ repository_required: /\b(?:repo|repository|pr|file|code)\b|コード|ファイル|仓库|倉庫|文件|代码|代碼/i.test(source),
94
+ operation: "read",
95
+ permission_mode: "read_only",
96
+ direct_signals: ["single_read_task", "no_managed_lifecycle"],
97
+ reason_code: "bounded_read_direct_execution",
98
+ });
99
+ }
100
+ return guardrail("needs_clarification", {
101
+ operation: "read",
102
+ permission_mode: "read_only",
103
+ reason_code: "action_missing",
104
+ });
105
+ }
106
+
107
+ function guardrail(decision, overrides) {
108
+ return {
109
+ policy_version: EXECUTION_GUARDRAIL_POLICY_VERSION,
110
+ decision,
111
+ change_requested: false,
112
+ managed_lifecycle_required: false,
113
+ repository_required: false,
114
+ operation: "read",
115
+ permission_mode: "read_only",
116
+ agentic_signals: [],
117
+ direct_signals: [],
118
+ ...overrides,
119
+ };
120
+ }
@@ -8,11 +8,23 @@ import {
8
8
  stringArray,
9
9
  } from "./shared.js";
10
10
  import { evaluateRetryProblemSolving } from "./problem-solving.js";
11
+ import {
12
+ completionEvidenceChecks,
13
+ normalizeVisualReview,
14
+ } from "./completion-contract.js";
11
15
 
12
16
  export async function auditAttempt(charter, attempt, { previousAttempt = null } = {}) {
13
17
  const checks = [];
14
- const addCheck = (id, label, passed, detail, severity = "blocker", repairability = "auto") => {
15
- checks.push({ id, label, passed, severity, detail, repairability, evidence_refs: [] });
18
+ const addCheck = (
19
+ id,
20
+ label,
21
+ passed,
22
+ detail,
23
+ severity = "blocker",
24
+ repairability = "auto",
25
+ evidenceRefs = [],
26
+ ) => {
27
+ checks.push({ id, label, passed, severity, detail, repairability, evidence_refs: evidenceRefs });
16
28
  };
17
29
  const local = attempt.observed_evidence?.local || {};
18
30
 
@@ -119,6 +131,18 @@ export async function auditAttempt(charter, attempt, { previousAttempt = null }
119
131
  }
120
132
  attempt.retry_problem_solving = retryProblemSolving;
121
133
 
134
+ for (const check of completionEvidenceChecks(charter, attempt)) {
135
+ addCheck(
136
+ check.id,
137
+ check.label,
138
+ check.passed,
139
+ check.detail,
140
+ "blocker",
141
+ "auto",
142
+ check.evidence_refs,
143
+ );
144
+ }
145
+
122
146
  const evidenceRequired = charter.expected_evidence.includes("ui_evidence_path");
123
147
  const evidenceArtifact = await inspectEvidence(charter, attempt.ui_evidence_path);
124
148
  if (evidenceRequired || attempt.ui_evidence_path) {
@@ -142,6 +166,32 @@ export async function auditAttempt(charter, attempt, { previousAttempt = null }
142
166
  ? `${evidenceArtifact.visual_file_count} screenshot or video artifact(s) found.`
143
167
  : "UI and browser-extension work requires a real screenshot or video; a path, text file, or empty directory is not sufficient.",
144
168
  );
169
+ const visualReview = normalizeVisualReview(attempt.visual_review);
170
+ const visualArtifactReview = await inspectVisualReviewArtifacts(charter, visualReview);
171
+ addCheck(
172
+ "visual_content_inspected",
173
+ "Visual evidence content was inspected against acceptance criteria",
174
+ Boolean(
175
+ visualReview.inspected &&
176
+ visualReview.artifacts.length > 0 &&
177
+ visualReview.acceptance_observations.length > 0,
178
+ ),
179
+ visualReview.inspected && visualReview.acceptance_observations.length > 0
180
+ ? visualReview.acceptance_observations.join("; ")
181
+ : "UI evidence must be opened and inspected; record the artifact paths and concrete acceptance observations.",
182
+ "blocker",
183
+ "auto",
184
+ visualReview.artifacts,
185
+ );
186
+ addCheck(
187
+ "visual_review_artifacts_verified",
188
+ "Claimed visual-review artifacts are real local media",
189
+ visualArtifactReview.valid,
190
+ visualArtifactReview.summary,
191
+ "blocker",
192
+ "auto",
193
+ visualArtifactReview.verified,
194
+ );
145
195
  }
146
196
 
147
197
  const findings = checks
@@ -409,3 +459,41 @@ async function isVisualEvidenceFile(filePath) {
409
459
  header.subarray(0, 4).equals(Buffer.from([26, 69, 223, 163]))
410
460
  );
411
461
  }
462
+
463
+ async function inspectVisualReviewArtifacts(charter, visualReview) {
464
+ const root = charter.ui_evidence_root ? path.resolve(charter.ui_evidence_root) : null;
465
+ const verified = [];
466
+ const invalid = [];
467
+ for (const artifact of visualReview.artifacts) {
468
+ const resolved = path.resolve(artifact);
469
+ const underRoot = Boolean(
470
+ root && (resolved === root || resolved.startsWith(`${root}${path.sep}`)),
471
+ );
472
+ let fileStat = null;
473
+ try {
474
+ fileStat = await stat(resolved);
475
+ } catch {
476
+ // The invalid reason below covers missing files.
477
+ }
478
+ if (
479
+ !underRoot ||
480
+ !fileStat?.isFile() ||
481
+ fileStat.size <= 0 ||
482
+ !(await isVisualEvidenceFile(resolved))
483
+ ) {
484
+ invalid.push(artifact);
485
+ continue;
486
+ }
487
+ verified.push(resolved);
488
+ }
489
+ return {
490
+ valid: visualReview.artifacts.length > 0 && invalid.length === 0,
491
+ verified,
492
+ summary:
493
+ visualReview.artifacts.length === 0
494
+ ? "No inspected visual artifact paths were recorded."
495
+ : invalid.length > 0
496
+ ? `Missing, non-visual, or out-of-root artifacts: ${invalid.join(", ")}`
497
+ : `${verified.length} inspected visual artifact(s) exist under the required local root with valid media signatures.`,
498
+ };
499
+ }
@@ -0,0 +1,159 @@
1
+ import { cleanString, stringArray } from "./shared.js";
2
+
3
+ export const COMPLETION_CONTRACT_VERSION = 2;
4
+
5
+ export function buildCompletionContract({
6
+ inherited,
7
+ requirement,
8
+ acceptanceCriteria,
9
+ nonGoals,
10
+ expectedEvidence,
11
+ }) {
12
+ const source = record(inherited);
13
+ const minimumScope = stringArray(source.minimum_scope);
14
+ const proofRequired = stringArray(source.proof_required);
15
+ return {
16
+ version: COMPLETION_CONTRACT_VERSION,
17
+ user_outcome: cleanString(source.user_outcome) || cleanString(requirement),
18
+ minimum_scope:
19
+ minimumScope.length > 0 ? minimumScope : stringArray(acceptanceCriteria),
20
+ non_goals: stringArray(nonGoals),
21
+ proof_required: uniqueStrings([
22
+ "criterion_evidence",
23
+ ...proofRequired,
24
+ ...stringArray(expectedEvidence),
25
+ ]),
26
+ deadline: cleanString(source.deadline) || null,
27
+ milestones: stringArray(source.milestones),
28
+ };
29
+ }
30
+
31
+ export function acceptanceCriterionRows(charter) {
32
+ return stringArray(charter?.acceptance_criteria).map((statement, index) => ({
33
+ id: `AC-${index + 1}`,
34
+ statement,
35
+ }));
36
+ }
37
+
38
+ export function normalizeUncertaintyVerification(value) {
39
+ const source = record(value);
40
+ return {
41
+ top_uncertainty: cleanString(source.top_uncertainty) || null,
42
+ first_verification: cleanString(source.first_verification) || null,
43
+ evidence_checked: uniqueStrings(source.evidence_checked),
44
+ observed_result: cleanString(source.observed_result) || null,
45
+ resolved: source.resolved === true,
46
+ fallback: cleanString(source.fallback) || null,
47
+ };
48
+ }
49
+
50
+ export function normalizeCriterionEvidence(value) {
51
+ if (!Array.isArray(value)) return [];
52
+ return value
53
+ .map((item) => {
54
+ const source = record(item);
55
+ const criterionId = cleanString(source.criterion_id);
56
+ if (!criterionId) return null;
57
+ return {
58
+ criterion_id: criterionId,
59
+ passed: source.passed === true,
60
+ verification_method: cleanString(source.verification_method) || null,
61
+ evidence_refs: uniqueStrings(source.evidence_refs),
62
+ observed_result: cleanString(source.observed_result) || null,
63
+ };
64
+ })
65
+ .filter(Boolean);
66
+ }
67
+
68
+ export function normalizeVisualReview(value) {
69
+ const source = record(value);
70
+ return {
71
+ inspected: source.inspected === true,
72
+ artifacts: uniqueStrings(source.artifacts),
73
+ acceptance_observations: uniqueStrings(source.acceptance_observations),
74
+ };
75
+ }
76
+
77
+ export function completionEvidenceChecks(charter, attempt) {
78
+ const contract = record(charter?.completion_contract);
79
+ if (contract.version !== COMPLETION_CONTRACT_VERSION) return [];
80
+
81
+ const uncertainty = normalizeUncertaintyVerification(attempt?.uncertainty_verification);
82
+ const criterionEvidence = normalizeCriterionEvidence(attempt?.criterion_evidence);
83
+ const rows = acceptanceCriterionRows(charter);
84
+ const checks = [
85
+ check(
86
+ "top_uncertainty_recorded",
87
+ "Highest uncertainty is explicit",
88
+ Boolean(uncertainty.top_uncertainty),
89
+ uncertainty.top_uncertainty || "No highest uncertainty was recorded.",
90
+ ),
91
+ check(
92
+ "first_verification_recorded",
93
+ "First discriminating verification is explicit",
94
+ Boolean(uncertainty.first_verification),
95
+ uncertainty.first_verification || "No first verification was recorded.",
96
+ ),
97
+ check(
98
+ "uncertainty_evidence_recorded",
99
+ "Uncertainty verification records concrete evidence",
100
+ uncertainty.evidence_checked.length > 0 && Boolean(uncertainty.observed_result),
101
+ uncertainty.evidence_checked.length > 0 && uncertainty.observed_result
102
+ ? `${uncertainty.evidence_checked.length} evidence item(s): ${uncertainty.observed_result}`
103
+ : "The uncertainty check needs evidence_checked and an observed_result.",
104
+ ),
105
+ check(
106
+ "top_uncertainty_resolved",
107
+ "Highest uncertainty is resolved before completion",
108
+ uncertainty.resolved,
109
+ uncertainty.resolved
110
+ ? "The executor marked the highest uncertainty resolved with observed evidence."
111
+ : uncertainty.fallback || "The highest uncertainty remains unresolved.",
112
+ ),
113
+ ];
114
+
115
+ for (const row of rows) {
116
+ const matches = criterionEvidence.filter((item) => item.criterion_id === row.id);
117
+ const evidence = matches[0];
118
+ checks.push(
119
+ check(
120
+ `criterion_evidence_${row.id.toLowerCase()}`,
121
+ `${row.id} has passing completion evidence`,
122
+ Boolean(
123
+ matches.length === 1 &&
124
+ evidence?.passed &&
125
+ evidence.verification_method &&
126
+ evidence.evidence_refs.length > 0 &&
127
+ evidence.observed_result,
128
+ ),
129
+ matches.length > 1
130
+ ? `Expected exactly one evidence result for ${row.id}; recorded ${matches.length}.`
131
+ : evidence
132
+ ? `${evidence.verification_method || "method missing"}: ${
133
+ evidence.observed_result || "observed result missing"
134
+ }; evidence ${evidence.evidence_refs.join(", ") || "missing"}.`
135
+ : `No criterion evidence was recorded for ${row.id}: ${row.statement}`,
136
+ evidence?.evidence_refs || [],
137
+ ),
138
+ );
139
+ }
140
+ return checks;
141
+ }
142
+
143
+ function check(id, label, passed, detail, evidenceRefs = []) {
144
+ return {
145
+ id,
146
+ label,
147
+ passed: Boolean(passed),
148
+ detail,
149
+ evidence_refs: evidenceRefs,
150
+ };
151
+ }
152
+
153
+ function record(value) {
154
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
155
+ }
156
+
157
+ function uniqueStrings(value) {
158
+ return [...new Set(stringArray(value))];
159
+ }
@@ -9,6 +9,7 @@ import {
9
9
  } from "./shared.js";
10
10
  import { budgetState, resolveRunBudgets } from "./budget.js";
11
11
  import { classifyRisk } from "./risk.js";
12
+ import { buildCompletionContract } from "./completion-contract.js";
12
13
  import {
13
14
  DESKTOP_EXECUTION_TARGET,
14
15
  normalizeDesktopExecutionTarget,
@@ -63,6 +64,19 @@ export function createCodingLoopSnapshot({ runId, request, metadata = {} }) {
63
64
  const inheritedNonGoals = stringArray(charterContext.non_goals);
64
65
  const inheritedArtifacts = stringArray(charterContext.artifacts_required);
65
66
  const inheritedAssumptions = stringArray(charterContext.assumptions);
67
+ const inheritedCompletionContract = record(charterContext.completion_contract);
68
+ const finalAcceptanceCriteria =
69
+ acceptanceCriteria.length > 0
70
+ ? acceptanceCriteria
71
+ : [
72
+ "The requested change is implemented without unrelated scope.",
73
+ "Relevant tests pass and are recorded.",
74
+ `A pull request targets ${baseBranch}.`,
75
+ ];
76
+ const finalNonGoals =
77
+ inheritedNonGoals.length > 0
78
+ ? inheritedNonGoals
79
+ : ["Unrelated refactors", "Server-side execution of repository code"];
66
80
  const designReviewRequired =
67
81
  risk.requires_design_review || inheritedDesignReview.required === true;
68
82
  const designReviewApproved =
@@ -101,14 +115,7 @@ export function createCodingLoopSnapshot({ runId, request, metadata = {} }) {
101
115
  retry_budget: budgets.retry_budget,
102
116
  token_budget: budgets.token_budget,
103
117
  ui_evidence_root: cleanString(request.ui_evidence_root),
104
- acceptance_criteria:
105
- acceptanceCriteria.length > 0
106
- ? acceptanceCriteria
107
- : [
108
- "The requested change is implemented without unrelated scope.",
109
- "Relevant tests pass and are recorded.",
110
- `A pull request targets ${baseBranch}.`,
111
- ],
118
+ acceptance_criteria: finalAcceptanceCriteria,
112
119
  acceptance_criteria_source: acceptanceCriteriaSource,
113
120
  expected_tests: stringArray(request.expected_tests),
114
121
  expected_evidence: expectedEvidence,
@@ -149,14 +156,18 @@ export function createCodingLoopSnapshot({ runId, request, metadata = {} }) {
149
156
  accepted_artifact_kinds: browserEvidenceRequired ? ["image", "video"] : [],
150
157
  visual_review_required: browserEvidenceRequired,
151
158
  },
159
+ completion_contract: buildCompletionContract({
160
+ inherited: inheritedCompletionContract,
161
+ requirement: cleanString(charterContext.user_goal) || cleanString(request.requirement),
162
+ acceptanceCriteria: finalAcceptanceCriteria,
163
+ nonGoals: finalNonGoals,
164
+ expectedEvidence,
165
+ }),
152
166
  thread_state: {
153
167
  original_request:
154
168
  cleanString(charterContext.original_request) || cleanString(request.requirement),
155
169
  user_goal: cleanString(charterContext.user_goal) || cleanString(request.requirement),
156
- non_goals:
157
- inheritedNonGoals.length > 0
158
- ? inheritedNonGoals
159
- : ["Unrelated refactors", "Server-side execution of repository code"],
170
+ non_goals: finalNonGoals,
160
171
  expected_finish_line:
161
172
  cleanString(charterContext.expected_finish_line) ||
162
173
  `A verified pull request targeting ${baseBranch}.`,
@@ -1,4 +1,10 @@
1
1
  import { bullets, cleanString, section } from "./shared.js";
2
+ import {
3
+ COMPLETION_CONTRACT_VERSION,
4
+ acceptanceCriterionRows,
5
+ normalizeCriterionEvidence,
6
+ normalizeVisualReview,
7
+ } from "./completion-contract.js";
2
8
 
3
9
  export function buildIndependentAuditInstruction(charter, attempt) {
4
10
  const localEvidence = attempt.observed_evidence?.local || {};
@@ -21,6 +27,8 @@ export function buildIndependentAuditInstruction(charter, attempt) {
21
27
  [
22
28
  `Requirement: ${charter.requirement}`,
23
29
  `Acceptance criteria:\n${bullets(charter.acceptance_criteria)}`,
30
+ `User-visible outcome: ${charter.completion_contract?.user_outcome || charter.requirement}`,
31
+ `Minimum scope:\n${bullets(charter.completion_contract?.minimum_scope || charter.acceptance_criteria)}`,
24
32
  `Risk level: ${charter.risk.level}`,
25
33
  `Risk tags: ${charter.risk.tags.join(", ") || "none"}`,
26
34
  `Base branch: ${charter.base_branch}`,
@@ -33,6 +41,9 @@ export function buildIndependentAuditInstruction(charter, attempt) {
33
41
  `Local UI evidence path: ${attempt.ui_evidence_path || "not reported"}`,
34
42
  `Local evidence verification: ${evidenceArtifact?.summary || "not available"}`,
35
43
  `Reported tests:\n${bullets(attempt.tests_run || [])}`,
44
+ `Executor uncertainty verification: ${JSON.stringify(attempt.uncertainty_verification || {})}`,
45
+ `Executor criterion evidence: ${JSON.stringify(attempt.criterion_evidence || [])}`,
46
+ `Executor visual review: ${JSON.stringify(attempt.visual_review || {})}`,
36
47
  `Observed local branch: ${localEvidence.branch_name || "unknown"}`,
37
48
  `Observed local HEAD: ${localEvidence.head_sha || "unknown"}`,
38
49
  `Observed changed files:\n${bullets(localEvidence.changed_files || [])}`,
@@ -48,6 +59,10 @@ export function buildIndependentAuditInstruction(charter, attempt) {
48
59
  "Inspect the final diff and related code directly from the repository.",
49
60
  "Look for unmet acceptance criteria, regressions, security issues, data and concurrency risks, missing tests, and unrelated changes.",
50
61
  "Treat executor claims as untrusted until supported by repository evidence.",
62
+ "For every AC-N criterion, return a criterion_results entry only after directly checking its cited proof and observed outcome.",
63
+ charter.evidence_policy?.browser_required
64
+ ? "Open and inspect the actual local screenshots or videos. Return visual_review.inspected=true only after checking their visible content against the acceptance criteria."
65
+ : "Do not require visual evidence when the Charter does not require browser evidence.",
51
66
  "Do not modify files, commit, push, or update the pull request.",
52
67
  ].join("\n"),
53
68
  ),
@@ -55,8 +70,10 @@ export function buildIndependentAuditInstruction(charter, attempt) {
55
70
  "result_contract",
56
71
  [
57
72
  "Finish with exactly one line beginning SIMY_AUDIT_JSON: followed by one JSON object.",
58
- "Required keys: passed, summary, findings.",
73
+ "Required keys: passed, summary, findings, criterion_results, visual_review.",
59
74
  "Each finding candidate must contain passed, code, severity, target, explanation, and repairability.",
75
+ "criterion_results must contain one entry per AC-N with criterion_id, passed, verification_method, evidence_refs, and observed_result.",
76
+ "visual_review must contain inspected, artifacts, and acceptance_observations.",
60
77
  "Only passed=false entries are findings. Omit passing checks, and never report passed=true evidence as a finding.",
61
78
  "Use passed=false when a blocker or major finding remains, or when evidence is unavailable.",
62
79
  ].join("\n"),
@@ -64,13 +81,19 @@ export function buildIndependentAuditInstruction(charter, attempt) {
64
81
  ].join("\n\n");
65
82
  }
66
83
 
67
- export function buildIndependentAudit(execution) {
84
+ export function buildIndependentAudit(execution, charter = null, attempt = null) {
68
85
  const result = execution?.result && typeof execution.result === "object" ? execution.result : {};
86
+ const completionContractV2 =
87
+ charter?.completion_contract?.version === COMPLETION_CONTRACT_VERSION;
69
88
  const valid =
70
89
  execution?.exitCode === 0 &&
71
90
  !execution?.error &&
72
91
  typeof result.passed === "boolean" &&
73
- Array.isArray(result.findings);
92
+ Array.isArray(result.findings) &&
93
+ (!completionContractV2 ||
94
+ (Array.isArray(result.criterion_results) &&
95
+ result.visual_review &&
96
+ typeof result.visual_review === "object"));
74
97
  if (!valid) {
75
98
  return {
76
99
  passed: false,
@@ -85,6 +108,8 @@ export function buildIndependentAudit(execution) {
85
108
  repairability: "manual",
86
109
  },
87
110
  ],
111
+ criterion_results: [],
112
+ visual_review: normalizeVisualReview(null),
88
113
  requires_human: true,
89
114
  };
90
115
  }
@@ -101,6 +126,64 @@ export function buildIndependentAudit(execution) {
101
126
  auto_fix_hint: cleanString(item.auto_fix_hint) || null,
102
127
  }));
103
128
  const findings = evaluations.filter((item) => item.passed === false);
129
+ const criterionResults = normalizeCriterionEvidence(result.criterion_results);
130
+ if (completionContractV2) {
131
+ for (const criterion of acceptanceCriterionRows(charter)) {
132
+ const matches = criterionResults.filter(
133
+ (item) => item.criterion_id === criterion.id,
134
+ );
135
+ const criterionResult = matches[0];
136
+ if (
137
+ matches.length === 1 &&
138
+ criterionResult?.passed &&
139
+ criterionResult.verification_method &&
140
+ criterionResult.evidence_refs.length > 0 &&
141
+ criterionResult.observed_result
142
+ ) {
143
+ continue;
144
+ }
145
+ findings.push({
146
+ passed: false,
147
+ code: `INDEPENDENT_${criterion.id.replace("-", "_")}_UNPROVEN`,
148
+ severity: "blocker",
149
+ target: criterion.id,
150
+ explanation: matches.length > 1
151
+ ? `The independent result returned ${matches.length} entries for ${criterion.id}; exactly one is required.`
152
+ : criterionResult
153
+ ? `The independent result did not positively prove ${criterion.id}: ${criterion.statement}`
154
+ : `The independent result omitted ${criterion.id}: ${criterion.statement}`,
155
+ repairability: "auto",
156
+ auto_fix_hint: "Re-audit this acceptance criterion and cite directly inspected evidence.",
157
+ });
158
+ }
159
+ }
160
+ const visualReview = normalizeVisualReview(result.visual_review);
161
+ const executorVisualReview = normalizeVisualReview(attempt?.visual_review);
162
+ const visualArtifactsOverlap = visualReview.artifacts.some((artifact) =>
163
+ executorVisualReview.artifacts.includes(artifact),
164
+ );
165
+ if (
166
+ completionContractV2 &&
167
+ charter.evidence_policy?.browser_required &&
168
+ !(
169
+ visualReview.inspected &&
170
+ visualReview.artifacts.length > 0 &&
171
+ visualReview.acceptance_observations.length > 0 &&
172
+ (!attempt || visualArtifactsOverlap)
173
+ )
174
+ ) {
175
+ findings.push({
176
+ passed: false,
177
+ code: "INDEPENDENT_VISUAL_REVIEW_UNPROVEN",
178
+ severity: "blocker",
179
+ target: "visual_review",
180
+ explanation: attempt && !visualArtifactsOverlap
181
+ ? "The independent auditor did not inspect any artifact from the executor's verified visual-review set."
182
+ : "The independent auditor did not record inspection of the actual visual evidence content.",
183
+ repairability: "auto",
184
+ auto_fix_hint: "Open the local screenshots or videos and record acceptance observations.",
185
+ });
186
+ }
104
187
  if (result.passed === false && findings.length === 0) {
105
188
  findings.push({
106
189
  passed: false,
@@ -117,6 +200,8 @@ export function buildIndependentAudit(execution) {
117
200
  passed: result.passed === true && findings.length === 0,
118
201
  summary: cleanString(result.summary) || "Independent audit completed.",
119
202
  findings,
203
+ criterion_results: criterionResults,
204
+ visual_review: visualReview,
120
205
  requires_human: requiresHuman,
121
206
  };
122
207
  }
@@ -1,5 +1,6 @@
1
1
  import { bullets, looksLikeUiTask, section } from "./shared.js";
2
2
  import { problemSolvingResultContract } from "./problem-solving.js";
3
+ import { acceptanceCriterionRows } from "./completion-contract.js";
3
4
 
4
5
  export function buildCodingInstruction(
5
6
  charter,
@@ -31,7 +32,24 @@ export function buildCodingInstruction(
31
32
  `Local evidence root: ${charter.ui_evidence_root || "not required"}`,
32
33
  ].join("\n"),
33
34
  ),
34
- section("acceptance_criteria", bullets(charter.acceptance_criteria)),
35
+ section(
36
+ "completion_contract",
37
+ [
38
+ `User-visible outcome: ${charter.completion_contract?.user_outcome || charter.thread_state.user_goal}`,
39
+ `Minimum scope:\n${bullets(charter.completion_contract?.minimum_scope || charter.acceptance_criteria)}`,
40
+ `Non-goals:\n${bullets(charter.completion_contract?.non_goals || charter.thread_state.non_goals)}`,
41
+ `Proof required:\n${bullets(charter.completion_contract?.proof_required || [])}`,
42
+ `Deadline: ${charter.completion_contract?.deadline || "not specified"}`,
43
+ ].join("\n"),
44
+ ),
45
+ section(
46
+ "acceptance_criteria",
47
+ bullets(
48
+ acceptanceCriterionRows(charter).map(
49
+ (criterion) => `${criterion.id}: ${criterion.statement}`,
50
+ ),
51
+ ),
52
+ ),
35
53
  section("expected_tests", bullets(charter.expected_tests)),
36
54
  section("must_not", bullets(charter.must_not)),
37
55
  ];
@@ -91,16 +109,17 @@ export function buildCodingInstruction(
91
109
  "Treat implementation as hypothesis testing: state assumptions, list plausible causes or approaches, inspect discriminating evidence, then choose the smallest strategy supported by that evidence.",
92
110
  attemptNumber > 1
93
111
  ? "This is a retry. Before editing, generate at least eight distinct MECE hypotheses, select a different primary hypothesis or approach, and do not repeat the previous strategy."
94
- : "For the first attempt, make uncertainty and the selected implementation hypothesis explicit before editing.",
112
+ : "For the first attempt, identify the single highest uncertainty and run the smallest discriminating verification before editing.",
95
113
  attemptNumber > 1
96
114
  ? "Explain exactly what changed from the previous attempt and why the new evidence supports this strategy."
97
- : "Record the selected hypothesis, evidence checked, and strategy in the structured result.",
115
+ : "Record the uncertainty, first verification, concrete evidence checked, observed result, selected hypothesis, and strategy in the structured result.",
98
116
  ].join("\n"),
99
117
  ),
100
118
  section(
101
119
  "execution_contract",
102
120
  [
103
121
  "Read the repository instructions and current git state before editing.",
122
+ "Before editing, resolve the highest uncertainty with the first discriminating verification. If it cannot be resolved, stop and report a fallback instead of claiming completion.",
104
123
  "Preserve user changes and keep the diff scoped to the requirement.",
105
124
  "Run relevant tests, inspect the final diff, commit, push, and create or update a PR.",
106
125
  `The PR must target ${charter.base_branch}.`,
@@ -111,8 +130,11 @@ export function buildCodingInstruction(
111
130
  "result_contract",
112
131
  [
113
132
  "Finish with exactly one line beginning SIMY_RESULT_JSON: followed by one JSON object.",
114
- "Required keys: outcome_kind, summary, branch_name, commit_sha, commit_message_headline, pr_url, pr_number, pr_title, pr_base_branch, tests_run, tests_passed, ui_evidence_path, unrelated_changes_detected, secret_scan_passed, changed_files, residual_risks, problem_solving.",
133
+ "Required keys: outcome_kind, summary, branch_name, commit_sha, commit_message_headline, pr_url, pr_number, pr_title, pr_base_branch, tests_run, tests_passed, ui_evidence_path, unrelated_changes_detected, secret_scan_passed, changed_files, residual_risks, problem_solving, uncertainty_verification, criterion_evidence, visual_review.",
115
134
  problemSolvingResultContract(),
135
+ "uncertainty_verification must contain top_uncertainty, first_verification, evidence_checked, observed_result, resolved, and fallback. Only set resolved=true when concrete observed evidence resolves it.",
136
+ "criterion_evidence must contain one entry for every AC-N with criterion_id, passed, verification_method, evidence_refs, and observed_result. Only set passed=true when the evidence directly proves that criterion.",
137
+ "For UI or browser-extension work, visual_review must contain inspected=true, the local screenshot or video paths in artifacts, and concrete acceptance_observations from viewing the actual content. Capturing a file without viewing it does not count.",
116
138
  "Use null for unavailable scalar values and [] for unavailable arrays. Do not claim evidence that was not observed.",
117
139
  ].join("\n"),
118
140
  ),
@@ -202,7 +202,11 @@ export async function runCodingLoop({
202
202
  }),
203
203
  );
204
204
  if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
205
- attempt.independent_audit = buildIndependentAudit(auditExecution);
205
+ attempt.independent_audit = buildIndependentAudit(
206
+ auditExecution,
207
+ snapshot.charter,
208
+ attempt,
209
+ );
206
210
  attempt.token_usage = mergeTokenUsage(attempt.token_usage, auditExecution.tokenUsage);
207
211
  attempt.budget = refreshBudgetState(snapshot);
208
212
 
@@ -1,5 +1,10 @@
1
1
  import { cleanString, stringArray } from "./shared.js";
2
2
  import { normalizeProblemSolving } from "./problem-solving.js";
3
+ import {
4
+ normalizeCriterionEvidence,
5
+ normalizeUncertaintyVerification,
6
+ normalizeVisualReview,
7
+ } from "./completion-contract.js";
3
8
 
4
9
  export function buildAttempt({ attemptNumber, charter, instruction, promptInterventions, execution }) {
5
10
  const result = execution.result && typeof execution.result === "object" ? execution.result : {};
@@ -28,6 +33,11 @@ export function buildAttempt({ attemptNumber, charter, instruction, promptInterv
28
33
  ui_evidence_path: cleanString(result.ui_evidence_path) || null,
29
34
  summary,
30
35
  problem_solving: normalizeProblemSolving(result.problem_solving),
36
+ uncertainty_verification: normalizeUncertaintyVerification(
37
+ result.uncertainty_verification,
38
+ ),
39
+ criterion_evidence: normalizeCriterionEvidence(result.criterion_evidence),
40
+ visual_review: normalizeVisualReview(result.visual_review),
31
41
  prompt_interventions: promptInterventions,
32
42
  work_log_signals: [],
33
43
  work_log_interventions: [],
package/src/runner.js CHANGED
@@ -158,6 +158,7 @@ export function restoreRun({ snapshot, session, apiOrigin, localPath = null }) {
158
158
  risk: charter.risk,
159
159
  design_review: charter.design_review,
160
160
  evidence_policy: charter.evidence_policy,
161
+ completion_contract: charter.completion_contract,
161
162
  prompt_policy_report: charter.prompt_policy_report,
162
163
  original_request: charter.thread_state?.original_request,
163
164
  user_goal: charter.thread_state?.user_goal,