sortie-dogs 0.14.2 → 0.14.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
@@ -19,8 +19,8 @@ evidence-backed result, not more agents for their own sake.
19
19
  [![Node.js](https://img.shields.io/node/v/sortie-dogs)](https://www.npmjs.com/package/sortie-dogs)
20
20
  [![MIT License](https://img.shields.io/npm/l/sortie-dogs)](LICENSE)
21
21
 
22
- **Current release: [v0.14.2](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.14.2)**
23
- · [Release notes](docs/release-v0.14.2.md) · [Execution vision](docs/v0.14.0-execution-vision.md)
22
+ **Current release: [v0.14.3](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.14.3)**
23
+ · [Release notes](docs/release-v0.14.3.md) · [Execution vision](docs/v0.14.0-execution-vision.md)
24
24
 
25
25
  [Quick start](#quick-start) · [Workflow](#mission-workflow) · [Models](#default-models) ·
26
26
  [Measured results](#measured-results) · [Configuration](#configuration) · [Documentation](#documentation)
@@ -57,7 +57,7 @@ Use an existing Codex CLI signed in with **ChatGPT**. On Windows, PowerShell **7
57
57
  available; POSIX uses bash. Sortie does not install Codex, start login or use a metered API fallback.
58
58
 
59
59
  ```sh
60
- npm install --save-dev sortie-dogs@0.14.2
60
+ npm install --save-dev sortie-dogs@0.14.3
61
61
  npx --no-install sortie-dogs codex init .
62
62
  ```
63
63
 
@@ -86,7 +86,7 @@ bridge. [Codex guide: SDK, permissions, progress, resume and manifest route](doc
86
86
  Use **OpenCode V2**. In the target project:
87
87
 
88
88
  ```sh
89
- npm install --save-dev sortie-dogs@0.14.2
89
+ npm install --save-dev sortie-dogs@0.14.3
90
90
  npx --no-install sortie-dogs init .
91
91
  ```
92
92
 
@@ -242,7 +242,7 @@ Install the desired package version and rerun **your host's** initializer:
242
242
 
243
243
  An OpenCode config-local bridge may resolve a separate package; npm-global update alone does not
244
244
  update it. [Global/update instructions](docs/configuration.md#global-availability).
245
- The current Mission asset marker is `0.14.2-cold-resume-v1`; the unchanged Codex skill marker is
245
+ The current Mission asset marker is `0.14.3-workspace-proof-v1`; the unchanged Codex skill marker is
246
246
  `0.13.11-codex-skill-v2`. Installed markers alone do not prove a running host reloaded the new version.
247
247
 
248
248
  There is no supported uninstall command. Remove the dependency and only known Sortie-owned paths;
@@ -258,7 +258,7 @@ follow the [manual removal guide](docs/uninstall.md), never delete the entire `.
258
258
  [SWE-bench history](docs/benchmarks/swebench-lite-history.md) · [Historical local case study](docs/benchmark-reference.md).
259
259
  - **Development:** [Testing](docs/testing.md) · [CLI testing](docs/cli-testing.md) ·
260
260
  [Windows tests](docs/windows-tests.md) · [Release routine](docs/release-batch.md).
261
- - **Releases:** [v0.14.2](docs/release-v0.14.2.md) · [v0.14.1](docs/release-v0.14.1.md) · [v0.14.0](docs/release-v0.14.0.md) · [v0.13.22](docs/release-v0.13.22.md) · [v0.13.21](docs/release-v0.13.21.md) · [v0.13.20](docs/release-v0.13.20.md) · [v0.13.19](docs/release-v0.13.19.md) · [v0.13.18](docs/release-v0.13.18.md) · [v0.13.17](docs/release-v0.13.17.md) · [v0.13.16](docs/release-v0.13.16.md) · [v0.13.15](docs/release-v0.13.15.md) · [v0.13.14](docs/release-v0.13.14.md) · [v0.13.13](docs/release-v0.13.13.md) · [v0.13.12](docs/release-v0.13.12.md) · [v0.13.11](docs/release-v0.13.11.md) · [v0.13.10](docs/release-v0.13.10.md) ·
261
+ - **Releases:** [v0.14.3](docs/release-v0.14.3.md) · [v0.14.2](docs/release-v0.14.2.md) · [v0.14.1](docs/release-v0.14.1.md) · [v0.14.0](docs/release-v0.14.0.md) · [v0.13.22](docs/release-v0.13.22.md) · [v0.13.21](docs/release-v0.13.21.md) · [v0.13.20](docs/release-v0.13.20.md) · [v0.13.19](docs/release-v0.13.19.md) · [v0.13.18](docs/release-v0.13.18.md) · [v0.13.17](docs/release-v0.13.17.md) · [v0.13.16](docs/release-v0.13.16.md) · [v0.13.15](docs/release-v0.13.15.md) · [v0.13.14](docs/release-v0.13.14.md) · [v0.13.13](docs/release-v0.13.13.md) · [v0.13.12](docs/release-v0.13.12.md) · [v0.13.11](docs/release-v0.13.11.md) · [v0.13.10](docs/release-v0.13.10.md) ·
262
262
  [v0.13.9](docs/release-v0.13.9.md) · [All GitHub releases](https://github.com/zufall-upon/Sortie-dogs/releases).
263
263
 
264
264
  ## Community
@@ -3,6 +3,6 @@
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
5
  export declare const RUNTIME_ASSET_VERSION = "0.3.89-completion-proof-v1";
6
- export declare const V010_RUNTIME_ASSET_VERSION = "0.14.2-cold-resume-v1";
6
+ export declare const V010_RUNTIME_ASSET_VERSION = "0.14.3-workspace-proof-v1";
7
7
  export declare const CODEX_SKILL_ASSET_VERSION = "0.13.11-codex-skill-v2";
8
8
  export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION | typeof V010_RUNTIME_ASSET_VERSION | typeof CODEX_SKILL_ASSET_VERSION;
@@ -3,5 +3,5 @@
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
5
  export const RUNTIME_ASSET_VERSION = "0.3.89-completion-proof-v1";
6
- export const V010_RUNTIME_ASSET_VERSION = "0.14.2-cold-resume-v1";
6
+ export const V010_RUNTIME_ASSET_VERSION = "0.14.3-workspace-proof-v1";
7
7
  export const CODEX_SKILL_ASSET_VERSION = "0.13.11-codex-skill-v2";
@@ -45,6 +45,14 @@ export interface GoalEvidence {
45
45
  /** Non-generating native validation pins inputs/concrete outputs, not write-only directory bookkeeping.
46
46
  * Missing on old evidence: retain its full source/output recipe. */
47
47
  readonly validation_policy?: "inputs-and-concrete-outputs-v1";
48
+ /** Snapshot and recheck the exact Git workspace inside one validated Docker task container. */
49
+ readonly docker_workspace?: {
50
+ readonly container_id: string;
51
+ readonly cwd: string;
52
+ readonly repository_root: string;
53
+ readonly source: string;
54
+ readonly candidate: string;
55
+ };
48
56
  /** Fixed when validation starts. Full manifest_hash still identifies the historical execution contract. */
49
57
  readonly freshness?: {
50
58
  readonly contract_hash: string;
@@ -59,6 +59,7 @@ export function validGoalEvidence(value, state) {
59
59
  return false;
60
60
  const criteria = state.acceptance_contract?.criteria.filter(({ criterion_id }) => value.measurement.criterion_ids.includes(criterion_id)) ?? [];
61
61
  const protectedBinding = value.protected_binding;
62
+ const dockerWorkspace = protectedBinding?.docker_workspace;
62
63
  const protectedBindingValid = protectedBinding !== undefined && HASH.test(protectedBinding.manifest_hash) &&
63
64
  text(protectedBinding.project_root) && text(protectedBinding.manifest_path) && Array.isArray(protectedBinding.source_paths) &&
64
65
  protectedBinding.source_paths.every(text) && Array.isArray(protectedBinding.candidate_paths) &&
@@ -67,6 +68,10 @@ export function validGoalEvidence(value, state) {
67
68
  protectedBinding.source_policy === "declared-paths-v1") &&
68
69
  (protectedBinding.candidate_policy === undefined || protectedBinding.candidate_policy === "project-root-artifacts-v1") &&
69
70
  (protectedBinding.validation_policy === undefined || protectedBinding.validation_policy === "inputs-and-concrete-outputs-v1") &&
71
+ (dockerWorkspace === undefined || (typeof dockerWorkspace.container_id === "string" && /^[a-f0-9]{12,64}$/u.test(dockerWorkspace.container_id) &&
72
+ typeof dockerWorkspace.cwd === "string" && dockerWorkspace.cwd.startsWith("/") &&
73
+ typeof dockerWorkspace.repository_root === "string" && dockerWorkspace.repository_root.startsWith("/") &&
74
+ HASH.test(dockerWorkspace.source) && HASH.test(dockerWorkspace.candidate))) &&
70
75
  (protectedBinding.freshness === undefined || (protectedBinding.freshness !== null && typeof protectedBinding.freshness === "object" &&
71
76
  HASH.test(protectedBinding.freshness.contract_hash) &&
72
77
  Array.isArray(protectedBinding.freshness.scratch_paths) && protectedBinding.freshness.scratch_paths.every(text) &&
@@ -89,10 +94,10 @@ export function validGoalEvidence(value, state) {
89
94
  sameStrings(criterion.oracle_coverage, value.measurement.oracle_coverage) &&
90
95
  criterion.build_boundary === value.measurement.build_boundary &&
91
96
  (criterion.source_binding === "current-protected"
92
- ? protectedBindingValid && HASH.test(value.identity.source)
97
+ ? protectedBindingValid && HASH.test(value.identity.source) && (dockerWorkspace === undefined || dockerWorkspace.source === value.identity.source)
93
98
  : criterion.source === value.identity.source) &&
94
99
  (criterion.candidate_binding === "current-protected"
95
- ? protectedBindingValid && HASH.test(value.identity.candidate)
100
+ ? protectedBindingValid && HASH.test(value.identity.candidate) && (dockerWorkspace === undefined || dockerWorkspace.candidate === value.identity.candidate)
96
101
  : criterion.candidate === value.identity.candidate) &&
97
102
  (criterion.validation_command === undefined ||
98
103
  (value.execution.command.length === 1 && value.execution.command[0] === criterion.validation_command)) &&
@@ -0,0 +1,14 @@
1
+ export interface DockerWorkspaceSnapshot {
2
+ readonly container_id: string;
3
+ readonly cwd: string;
4
+ readonly repository_root: string;
5
+ readonly source: string;
6
+ readonly candidate: string;
7
+ }
8
+ /** Hash the fixed container's actual workspace, including non-Git and ignored inputs. */
9
+ export declare function captureDockerWorkspaceSnapshot(containerID: string, cwd: string): Promise<DockerWorkspaceSnapshot | undefined>;
10
+ export declare function isDockerContainerID(value: unknown): value is string;
11
+ export declare function isDockerWorkspacePath(value: unknown): value is string;
12
+ /** Inspect bytes, never execute caller-supplied code. The Review envelope's
13
+ * immutable Docker candidate must match both before and after the read. */
14
+ export declare function readDockerWorkspaceFile(containerID: string, cwd: string, candidate: string, path: string, offset?: number, limit?: number): Promise<Record<string, unknown>>;
@@ -0,0 +1,153 @@
1
+ import { createHash } from "node:crypto";
2
+ import { execFile } from "node:child_process";
3
+ import { posix } from "node:path";
4
+ import { promisify } from "node:util";
5
+ const exec = promisify(execFile);
6
+ const CONTAINER_ID = /^[a-f0-9]{12,64}$/u;
7
+ const SNAPSHOT_MARKER = "SORTIE_DOCKER_WORKSPACE_V1";
8
+ // Terminal tasks need not contain Git at all. Capture actual bytes and file
9
+ // identity, including ignored inputs; timestamps alone are not candidate edits.
10
+ const FILE_SNAPSHOT = String.raw `
11
+ import hashlib, json, os, stat, subprocess
12
+ cwd = os.path.realpath(os.getcwd())
13
+ try:
14
+ root = subprocess.check_output(["git", "rev-parse", "--show-toplevel"], stderr=subprocess.DEVNULL, text=True).strip()
15
+ head = subprocess.check_output(["git", "rev-parse", "--verify", "HEAD^{tree}"], stderr=subprocess.DEVNULL, text=True).strip()
16
+ except (OSError, subprocess.CalledProcessError):
17
+ root, head = cwd, "no-git"
18
+ root = os.path.realpath(root)
19
+ rows = []
20
+ def digest(path):
21
+ h = hashlib.sha256()
22
+ with open(path, "rb") as stream:
23
+ for block in iter(lambda: stream.read(1024 * 1024), b""):
24
+ h.update(block)
25
+ return h.hexdigest()
26
+ for directory, dirs, files in os.walk(root, followlinks=False, onerror=lambda error: (_ for _ in ()).throw(error)):
27
+ if directory == root:
28
+ dirs[:] = [name for name in dirs if name != ".git"]
29
+ files = [name for name in files if name != ".git"]
30
+ for name in [".", *dirs, *files]:
31
+ path = directory if name == "." else os.path.join(directory, name)
32
+ relative = os.path.relpath(path, root)
33
+ info = os.lstat(path)
34
+ mode = stat.S_IMODE(info.st_mode)
35
+ if stat.S_ISREG(info.st_mode):
36
+ value = [relative, "file", mode, digest(path)]
37
+ elif stat.S_ISLNK(info.st_mode):
38
+ target = os.path.realpath(path)
39
+ # Internal directory targets are captured at their own lexical path.
40
+ # External directory trees are not silently omitted from a proof.
41
+ if os.path.isdir(path) and os.path.commonpath([root, target]) != root:
42
+ raise RuntimeError("external-directory-link-snapshot-unavailable")
43
+ value = [relative, "link", mode, os.readlink(path), digest(path) if os.path.isfile(path) else None]
44
+ elif stat.S_ISDIR(info.st_mode):
45
+ value = [relative, "directory", mode]
46
+ else:
47
+ raise RuntimeError("unsupported-workspace-file-type")
48
+ rows.append(value)
49
+ tree = hashlib.sha256(json.dumps(sorted(rows), ensure_ascii=True, separators=(",", ":")).encode()).hexdigest()
50
+ print("SORTIE_DOCKER_WORKSPACE_V1")
51
+ print(cwd)
52
+ print(root)
53
+ print(tree)
54
+ print(hashlib.sha256(head.encode()).hexdigest())
55
+ print("0" * 64)
56
+ `;
57
+ const SNAPSHOT_COMMAND = [
58
+ "set -eu",
59
+ "if command -v python3 >/dev/null 2>&1; then",
60
+ "python3 -I -S - <<'SORTIE_WORKSPACE_PY'",
61
+ FILE_SNAPSHOT,
62
+ "SORTIE_WORKSPACE_PY",
63
+ "else",
64
+ "actual_cwd=$(pwd -P)",
65
+ "root=$(git rev-parse --show-toplevel)",
66
+ "cd \"$root\"",
67
+ "tree=$(git rev-parse --verify 'HEAD^{tree}')",
68
+ "tracked=$(git diff --binary HEAD -- | sha256sum | cut -d' ' -f1)",
69
+ "untracked=$(git ls-files -z --others | sort -z | xargs -0 -r sha256sum -- | sha256sum | cut -d' ' -f1)",
70
+ "printf '%s\\n%s\\n%s\\n%s\\n%s\\n%s\\n' 'SORTIE_DOCKER_WORKSPACE_V1' \"$actual_cwd\" \"$root\" \"$tree\" \"$tracked\" \"$untracked\"",
71
+ "fi",
72
+ ].join("\n");
73
+ /** Hash the fixed container's actual workspace, including non-Git and ignored inputs. */
74
+ export async function captureDockerWorkspaceSnapshot(containerID, cwd) {
75
+ if (!CONTAINER_ID.test(containerID) || !posix.isAbsolute(cwd) || posix.normalize(cwd) !== cwd || cwd.includes("\0"))
76
+ return undefined;
77
+ try {
78
+ const result = await exec("docker", ["exec", "--workdir", cwd, containerID, "sh", "-lc", SNAPSHOT_COMMAND], {
79
+ encoding: "buffer", timeout: 30_000, maxBuffer: 64 * 1024 * 1024,
80
+ });
81
+ const output = result.stdout;
82
+ let offset = 0;
83
+ const line = () => {
84
+ const end = output.indexOf(0x0a, offset);
85
+ if (end < 0)
86
+ return undefined;
87
+ const value = output.subarray(offset, end).toString("utf8");
88
+ offset = end + 1;
89
+ return value;
90
+ };
91
+ if (line() !== SNAPSHOT_MARKER)
92
+ return undefined;
93
+ const actualCwd = line(), repositoryRoot = line(), tree = line(), tracked = line(), untracked = line();
94
+ if (actualCwd !== cwd || !repositoryRoot || !posix.isAbsolute(repositoryRoot) || !tree || !/^[a-f0-9]{40,64}$/u.test(tree) ||
95
+ !tracked || !/^[a-f0-9]{64}$/u.test(tracked) || !untracked || !/^[a-f0-9]{64}$/u.test(untracked) || offset !== output.length)
96
+ return undefined;
97
+ const source = `sha256:${createHash("sha256").update(`git-tree\0${repositoryRoot}\0${tree}`).digest("hex")}`;
98
+ const candidate = `sha256:${createHash("sha256").update(JSON.stringify([containerID, cwd, repositoryRoot, tree, tracked, untracked])).digest("hex")}`;
99
+ return { container_id: containerID, cwd, repository_root: repositoryRoot, source, candidate };
100
+ }
101
+ catch {
102
+ return undefined;
103
+ }
104
+ }
105
+ export function isDockerContainerID(value) {
106
+ return typeof value === "string" && CONTAINER_ID.test(value);
107
+ }
108
+ export function isDockerWorkspacePath(value) {
109
+ return typeof value === "string" && value.length > 0 && posix.isAbsolute(value) &&
110
+ posix.normalize(value) === value && !value.includes("\0");
111
+ }
112
+ const READ_WORKSPACE = String.raw `
113
+ import hashlib, json, os, stat, sys
114
+ root, requested, offset, limit = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4])
115
+ path = os.path.realpath(os.path.join(root, requested))
116
+ if os.path.commonpath([root, path]) != root:
117
+ raise ValueError("file-outside-candidate-workspace")
118
+ if os.path.isdir(path):
119
+ entries = sorted(os.listdir(path))
120
+ result = {"kind": "directory", "entries": entries[offset - 1:offset - 1 + limit], "total": len(entries)}
121
+ elif stat.S_ISREG(os.stat(path).st_mode):
122
+ h = hashlib.sha256()
123
+ lines, total, size = [], 0, 0
124
+ with open(path, "rb") as stream:
125
+ for line in stream:
126
+ h.update(line)
127
+ total += 1
128
+ if offset <= total < offset + limit and size < 64000:
129
+ text = line[:64000-size].decode("utf-8", errors="replace")
130
+ lines.append([total, text])
131
+ size += len(line[:64000-size])
132
+ result = {"kind": "file", "lines": lines, "total_lines": total, "sha256": h.hexdigest()}
133
+ else:
134
+ raise ValueError("candidate-file-not-readable")
135
+ print(json.dumps({"path": path, **result}, ensure_ascii=True))
136
+ `;
137
+ /** Inspect bytes, never execute caller-supplied code. The Review envelope's
138
+ * immutable Docker candidate must match both before and after the read. */
139
+ export async function readDockerWorkspaceFile(containerID, cwd, candidate, path, offset = 1, limit = 200) {
140
+ if (!/^sha256:[a-f0-9]{64}$/u.test(candidate) || typeof path !== "string" || path.includes("\0") ||
141
+ !Number.isSafeInteger(offset) || offset < 1 || !Number.isSafeInteger(limit) || limit < 1 || limit > 2000) {
142
+ throw new Error("docker-candidate-read-invalid");
143
+ }
144
+ const before = await captureDockerWorkspaceSnapshot(containerID, cwd);
145
+ if (!before || before.candidate !== candidate)
146
+ throw new Error("docker-review-candidate-stale-or-unavailable");
147
+ const { stdout } = await exec("docker", ["exec", "--workdir", cwd, containerID, "python3", "-I", "-S", "-c", READ_WORKSPACE,
148
+ before.repository_root, path, String(offset), String(limit)], { encoding: "utf8", timeout: 30_000, maxBuffer: 256 * 1024 });
149
+ const after = await captureDockerWorkspaceSnapshot(containerID, cwd);
150
+ if (!after || after.candidate !== candidate)
151
+ throw new Error("docker-review-candidate-changed-during-read");
152
+ return { workspace: before, ...JSON.parse(stdout) };
153
+ }
@@ -0,0 +1,61 @@
1
+ {
2
+ "schema_version": 1,
3
+ "tool_prefix": "sortie_v010_",
4
+ "core_tools": ["execute", "subagent", "read"],
5
+ "runtime_agents": {
6
+ "dog-coordinator": "dog-operator",
7
+ "dog-worker": "dog-worker-v010",
8
+ "dog-luna-worker": "dog-luna-worker-v010",
9
+ "dog-scout": "dog-scout-v010",
10
+ "dog-reviewer": "dog-reviewer-v010",
11
+ "dog-advisor": "dog-advisor-v010",
12
+ "dog-operator": "dogs-coordinator"
13
+ },
14
+ "roles": {
15
+ "dog-coordinator": {
16
+ "tool_families": {
17
+ "mission_lifecycle": ["start_mission", "operator_next", "operator_status", "extend_mission_budget", "complete_mission", "cancel_operator", "reflection"],
18
+ "unit_execution": ["plan_units", "start_direct_unit", "finish_direct_unit", "retry_mission_unit", "expand_unit"],
19
+ "review": ["review_mission", "repair_review"]
20
+ },
21
+ "children": ["dog-operator", "dog-worker", "dog-reviewer"]
22
+ },
23
+ "dog-worker": {
24
+ "tool_families": {
25
+ "workspace": ["bind_write_gate", "release_write_gate", "operator_status", "expand_unit"]
26
+ },
27
+ "native_tool_families": {
28
+ "docker_task": ["fh_docker_exec"]
29
+ },
30
+ "core_tools": ["execute", "read"],
31
+ "children": []
32
+ },
33
+ "dog-luna-worker": {
34
+ "tool_families": {
35
+ "workspace": ["bind_write_gate", "release_write_gate", "operator_status", "expand_unit"]
36
+ },
37
+ "core_tools": ["execute", "read"],
38
+ "children": []
39
+ },
40
+ "dog-scout": { "tool_families": {}, "children": [] },
41
+ "dog-reviewer": {
42
+ "tool_families": { "review": ["repair_review"] },
43
+ "native_tool_families": { "docker_review": ["fh_docker_read"] },
44
+ "correction_native_tool_families": { "docker_task": ["fh_docker_exec"] },
45
+ "correction_tool_families": {
46
+ "workspace": ["bind_write_gate", "release_write_gate", "operator_status", "expand_unit"],
47
+ "correction": ["repair_review", "finish_direct_unit"]
48
+ },
49
+ "children": []
50
+ },
51
+ "dog-advisor": { "tool_families": {}, "children": [] },
52
+ "dog-operator": {
53
+ "tool_families": {
54
+ "mission_lifecycle": ["operator_next", "operator_status", "submit_mission", "skip_mission_consultation"],
55
+ "unit_execution": ["plan_units", "start_direct_unit", "finish_direct_unit", "retry_mission_unit", "rescue_mission_unit", "expand_unit"],
56
+ "review": ["review_mission", "repair_review"]
57
+ },
58
+ "children": ["dog-worker", "dog-reviewer"]
59
+ }
60
+ }
61
+ }
@@ -53,7 +53,8 @@ import { STABLE_RUNTIME_PROFILE } from "../core/runtime-profile.js";
53
53
  import { reviewerCorrectionValidationFresh } from "./mission-review.js";
54
54
  import { evidenceFromObservedExecution } from "../core/observed-goal-evidence.js";
55
55
  import { receiptBoundTerminalText } from "./receipt-presentation.js";
56
- import { operationInputSnapshot, protectedSnapshot, refreshProtectedSnapshot, validatedSourceSnapshot, validationInputSnapshot } from "./protected-snapshot.js";
56
+ import { operationInputSnapshot, protectedDockerSnapshot, protectedSnapshot, refreshProtectedSnapshot, validatedSourceSnapshot, validationInputSnapshot } from "./protected-snapshot.js";
57
+ import { isDockerContainerID, isDockerWorkspacePath } from "./docker-validation.js";
57
58
  import { settledUnitUsage } from "./unit-usage.js";
58
59
  import { goalCompletionReadiness } from "./goal-completion.js";
59
60
  const INPUT_LIMITS = { config: 64 * 1024, manifest: 512 * 1024, handoff: 2 * 1024 * 1024, parallel: 512 * 1024 };
@@ -205,10 +206,34 @@ function observedHostGoalEvidence(execution, goal, unitID) {
205
206
  function isRecord(value) {
206
207
  return value !== null && typeof value === "object" && !Array.isArray(value);
207
208
  }
208
- function validationEnvironment(directory) {
209
+ function validationEnvironment(directory, dockerWorkspace) {
210
+ if (dockerWorkspace)
211
+ return { platform: "docker-container", arch: "unknown", runtime: "docker",
212
+ toolchain: `container:${dockerWorkspace.container_id}`, directory: dockerWorkspace.cwd };
209
213
  return { platform: process.platform, arch: process.arch, runtime: process.version,
210
214
  ...(directory ? { directory } : {}) };
211
215
  }
216
+ function dockerToolExitCode(output) {
217
+ const metadata = isRecord(output.metadata) ? output.metadata : undefined;
218
+ const status = output.status ?? metadata?.status;
219
+ if (status === "error" || status === "cancel" || status === "cancelled" || status === "canceled" || status === "interrupted")
220
+ return undefined;
221
+ const recorded = metadata?.exit;
222
+ const text = typeof output.output === "string" ? output.output
223
+ : typeof output.content === "string" ? output.content
224
+ : Array.isArray(output.content) ? output.content.flatMap(part => isRecord(part) && typeof part.text === "string" ? [part.text] : []).join("\n")
225
+ : "";
226
+ const match = text.match(/^exit_code=(0|[1-9]\d*|-[1-9]\d*|unknown)(?:\r?\n|$)/u);
227
+ const header = match?.[1] === "unknown" ? null : match ? Number(match[1]) : undefined;
228
+ const explicit = typeof recorded === "number" && Number.isSafeInteger(recorded) ? recorded : undefined;
229
+ if (explicit !== undefined && header !== undefined && explicit !== header)
230
+ return undefined;
231
+ if (explicit !== undefined)
232
+ return explicit;
233
+ if (header !== undefined && (header === null || Number.isSafeInteger(header)))
234
+ return header;
235
+ return null;
236
+ }
212
237
  function validationCandidate(source, candidate) {
213
238
  return goalFingerprint({ source, candidate });
214
239
  }
@@ -1347,11 +1372,17 @@ export const SortieDogsPlugin = async (input, options) => {
1347
1372
  if (authorization === undefined || authorization.suspended || authorization.rootSessionID !== owner)
1348
1373
  return;
1349
1374
  const args = isRecord(output.args) ? output.args : undefined;
1375
+ const dockerTool = toolInput.tool === "fh_docker_exec" || toolInput.tool === "fh.docker_exec";
1376
+ const dockerRequest = dockerTool && args !== undefined && isDockerContainerID(args.container_id) &&
1377
+ isDockerWorkspacePath(args.cwd) && typeof args.command === "string"
1378
+ ? { containerID: args.container_id, cwd: args.cwd } : undefined;
1379
+ if (dockerTool && dockerRequest === undefined)
1380
+ return;
1350
1381
  const rawCommand = args !== undefined && typeof args.command === "string" ? normalizeCommand(args.command) : undefined;
1351
1382
  if (rawCommand === undefined || rawCommand.length === 0)
1352
1383
  return;
1353
1384
  const correcting = !!direct || await input.runtimeBridge?.ownsReviewerCorrection?.(toolInput.sessionID) === true;
1354
- const directory = resolve(input.directory, typeof args?.workdir === "string" ? args.workdir : ".");
1385
+ const directory = dockerRequest?.cwd ?? resolve(input.directory, typeof args?.workdir === "string" ? args.workdir : ".");
1355
1386
  // A check is proof only in its registered cwd. Snapshot the same admitted source
1356
1387
  // recipe at actual native start; never retrofit it to older external-cwd history.
1357
1388
  const members = correcting ? await input.runtimeBridge?.reviewerCorrectionValidationMembers?.(toolInput.sessionID, rawCommand, directory)
@@ -1367,9 +1398,14 @@ export const SortieDogsPlugin = async (input, options) => {
1367
1398
  ? await input.runtimeBridge?.missionDispatchCall?.(authorization.rootSessionID, toolInput.sessionID, authorization.taskID) : undefined);
1368
1399
  const correction = correcting && dispatchCallID && members
1369
1400
  ? { taskID: authorization.taskID, dispatchCallID, commands: members } : undefined;
1370
- const admittedSnapshot = await protectedSnapshot(authorization, { directory, command: args.command }).catch(() => undefined);
1401
+ const admittedSnapshot = dockerRequest
1402
+ ? await protectedDockerSnapshot(authorization, dockerRequest.containerID, dockerRequest.cwd).catch(() => undefined)
1403
+ : await protectedSnapshot(authorization, { directory, command: args.command }).catch(() => undefined);
1371
1404
  if (admittedSnapshot === undefined)
1372
1405
  return;
1406
+ const dockerWorkspace = admittedSnapshot.binding.docker_workspace;
1407
+ if (dockerRequest && (!dockerWorkspace || dockerWorkspace.container_id !== dockerRequest.containerID || dockerWorkspace.cwd !== dockerRequest.cwd))
1408
+ return;
1373
1409
  const root = goalRoot(owner);
1374
1410
  const ledger = await goalLedger(root);
1375
1411
  const goalSnapshot = await ledger.readGoal(), goal = goalSnapshot.state;
@@ -1410,7 +1446,7 @@ export const SortieDogsPlugin = async (input, options) => {
1410
1446
  const owner = validationOwner(scope);
1411
1447
  const request = { run_id: goal.goal_id, operation_id: toolInput.callID,
1412
1448
  source_snapshot: snapshot.source, candidate: validationCandidate(snapshot.source, snapshot.candidate),
1413
- command: [rawCommand], environment: validationEnvironment(directory), scope, owner,
1449
+ command: [rawCommand], environment: validationEnvironment(directory, dockerWorkspace), scope, owner,
1414
1450
  expected_evidence: [...new Set(criteria.flatMap((criterion) => [criterion.criterion_id, ...criterion.oracle_coverage,
1415
1451
  `unit:${unitID}`, "source_snapshot", "candidate", "command", "scope", "exit_code"]))],
1416
1452
  marginal_value: { unmet_criteria: criteria.map(criterion => criterion.criterion_id), risk_hypothesis: null },
@@ -1418,12 +1454,17 @@ export const SortieDogsPlugin = async (input, options) => {
1418
1454
  ? { required_execution: { admission: dispatchCallID, call_id: toolInput.callID } } : {}) };
1419
1455
  const evidenceKey = validationEvidenceKey(request);
1420
1456
  const durable = goalSnapshot.records.flatMap(({ event }) => event.kind === "unit.settled" || event.kind === "unit.evidence-reconciled" ? event.evidence : [])
1421
- .filter(entry => directory === resolve(input.directory) && validGoalEvidence(entry, goal) && (entry.proof_scope !== "requested-full" || coordinatorOwned) &&
1457
+ .filter(entry => (directory === resolve(input.directory) || dockerWorkspace !== undefined &&
1458
+ entry.protected_binding?.docker_workspace?.container_id === dockerWorkspace.container_id &&
1459
+ entry.protected_binding.docker_workspace.cwd === dockerWorkspace.cwd) && validGoalEvidence(entry, goal) &&
1460
+ (entry.proof_scope !== "requested-full" || coordinatorOwned) &&
1422
1461
  entry.identity.source === snapshot.source && entry.identity.candidate === snapshot.candidate &&
1423
1462
  entry.execution.command.length === 1 && entry.execution.command[0] === rawCommand)
1424
1463
  .map(entry => ({ ...entry, execution: { ...entry.execution, units: [unitID] } }));
1425
1464
  const live = [...hostGoalExecutions.values()].filter(execution => execution.root === root &&
1426
- execution.sessionID === toolInput.sessionID && execution.directory === directory && execution.endedAt !== undefined && execution.exitCode === 0 &&
1465
+ execution.sessionID === toolInput.sessionID && (execution.directory === directory || dockerWorkspace !== undefined &&
1466
+ execution.dockerWorkspace?.container_id === dockerWorkspace.container_id && execution.dockerWorkspace.cwd === dockerWorkspace.cwd) &&
1467
+ execution.endedAt !== undefined && execution.exitCode === 0 &&
1427
1468
  execution.outcome === "pass" && execution.immutableRef !== undefined && execution.fresh === true &&
1428
1469
  execution.source === snapshot.source && execution.candidate === snapshot.candidate &&
1429
1470
  execution.command.length === 1 && execution.command[0] === rawCommand)
@@ -1463,7 +1504,7 @@ export const SortieDogsPlugin = async (input, options) => {
1463
1504
  }
1464
1505
  if (reservation.decision === "SKIP") {
1465
1506
  reusedEvidence = reconciliationEvidence;
1466
- const notice = settledPassNoticeCommand();
1507
+ const notice = dockerRequest ? "true" : settledPassNoticeCommand();
1467
1508
  args.command = notice;
1468
1509
  settledPassNotices.set(toolInput.callID, { sessionID: toolInput.sessionID, command: notice });
1469
1510
  }
@@ -1475,10 +1516,10 @@ export const SortieDogsPlugin = async (input, options) => {
1475
1516
  }
1476
1517
  }
1477
1518
  const mission = await new OperatorMissionRuntime(input.directory, runtimeProfile).read(root);
1478
- const operationInputs = mission?.kind === "operation" && mission.execution?.commands.includes(rawCommand) &&
1519
+ const operationInputs = !dockerWorkspace && mission?.kind === "operation" && mission.execution?.commands.includes(rawCommand) &&
1479
1520
  resolve(input.directory, typeof args?.workdir === "string" ? args.workdir : ".") === mission.execution.directory
1480
1521
  ? await operationInputSnapshot(authorization.projectRoot, snapshot.binding).catch(() => undefined) : undefined;
1481
- const validationInputs = (validation !== undefined || correction !== undefined) && operationInputs === undefined
1522
+ const validationInputs = !dockerWorkspace && (validation !== undefined || correction !== undefined) && operationInputs === undefined
1482
1523
  ? await validationInputSnapshot(authorization.projectRoot, snapshot.binding).catch(() => undefined) : undefined;
1483
1524
  const validationSource = correction && validationInputs !== undefined
1484
1525
  ? await validatedSourceSnapshot(authorization.projectRoot, snapshot.binding).catch(() => undefined) : undefined;
@@ -1486,6 +1527,7 @@ export const SortieDogsPlugin = async (input, options) => {
1486
1527
  sessionID: toolInput.sessionID,
1487
1528
  callID: toolInput.callID, tool: toolInput.tool, command: [rawCommand], directory, startedAt: new Date().toISOString(),
1488
1529
  binding: snapshot.binding, source: snapshot.source, candidate: snapshot.candidate,
1530
+ ...(dockerWorkspace === undefined ? {} : { dockerWorkspace }),
1489
1531
  ...(operationInputs === undefined ? {} : { operationInputs }),
1490
1532
  ...(validationInputs === undefined ? {} : { validationInputs }),
1491
1533
  ...(validationSource === undefined ? {} : { validationSource }),
@@ -1499,7 +1541,8 @@ export const SortieDogsPlugin = async (input, options) => {
1499
1541
  return;
1500
1542
  const metadata = isRecord(output.metadata) ? output.metadata : undefined;
1501
1543
  const rawExit = metadata?.exit;
1502
- const exitCode = typeof rawExit === "number" && Number.isSafeInteger(rawExit) ? rawExit : undefined;
1544
+ const exitCode = execution.dockerWorkspace ? dockerToolExitCode(output)
1545
+ : typeof rawExit === "number" && Number.isSafeInteger(rawExit) ? rawExit : undefined;
1503
1546
  const rawStatus = metadata?.status ?? output.status;
1504
1547
  const outcome = exitCode === 0 ? execution.reusedEvidence === undefined ? "pass" : "skip" : exitCode !== undefined ? "fail"
1505
1548
  : rawStatus === "cancel" || rawStatus === "cancelled" ? "cancel" : undefined;
@@ -1519,11 +1562,13 @@ export const SortieDogsPlugin = async (input, options) => {
1519
1562
  ? new Date(timing.end).toISOString() : new Date().toISOString();
1520
1563
  // Declared outputs can change during a successful validation. Read inputs must not;
1521
1564
  // the evidence still binds to the complete post-command candidate and source.
1522
- const fresh = refreshed !== undefined && (execution.operationInputs !== undefined
1523
- ? await operationInputSnapshot(execution.projectRoot, execution.binding).catch(() => undefined) === execution.operationInputs
1524
- : execution.validationInputs !== undefined
1525
- ? await validationInputSnapshot(execution.projectRoot, execution.binding).catch(() => undefined) === execution.validationInputs
1526
- : refreshed.source === execution.source);
1565
+ const fresh = refreshed !== undefined && (execution.dockerWorkspace !== undefined
1566
+ ? refreshed.source === execution.source && refreshed.candidate === execution.candidate
1567
+ : execution.operationInputs !== undefined
1568
+ ? await operationInputSnapshot(execution.projectRoot, execution.binding).catch(() => undefined) === execution.operationInputs
1569
+ : execution.validationInputs !== undefined
1570
+ ? await validationInputSnapshot(execution.projectRoot, execution.binding).catch(() => undefined) === execution.validationInputs
1571
+ : refreshed.source === execution.source);
1527
1572
  const immutableRef = outcome === undefined || execution.reusedEvidence !== undefined ? undefined : goalFingerprint({ root: execution.root,
1528
1573
  child_session_id: execution.sessionID, call_id: execution.callID, command: execution.command, directory: execution.directory,
1529
1574
  started_at: observedStartedAt, ended_at: endedAt, exit_code: exitCode ?? null, outcome,
@@ -33,6 +33,13 @@ export declare function missionReviewValidation(run: OperatorState, statePath: s
33
33
  }[] | undefined;
34
34
  evidence: {
35
35
  protected_binding_ref?: {
36
+ docker_workspace?: {
37
+ readonly container_id: string;
38
+ readonly cwd: string;
39
+ readonly repository_root: string;
40
+ readonly source: string;
41
+ readonly candidate: string;
42
+ } | undefined;
36
43
  manifest_path: string;
37
44
  manifest_hash: string;
38
45
  source_policy: "project-files-v1" | "declared-paths-v1" | undefined;
@@ -75,6 +82,7 @@ export declare function observedMissionValidation(validation: readonly string[],
75
82
  command: string;
76
83
  native_command?: string;
77
84
  directory?: string;
85
+ directory_status?: "unobserved";
78
86
  observed_members?: readonly string[];
79
87
  exit_code: number | null;
80
88
  started_ms: number | null;
@@ -12,6 +12,7 @@ import { taskChildSessionID } from "./task-result-repair.js";
12
12
  import { normalizeManifestScope } from "../core/path.js";
13
13
  import { declaredArtifacts } from "./declared-artifacts.js";
14
14
  import { canonicalDeclaredValidationMembers, canonicalObservedValidationMembers, normalizeCommand } from "./gate.js";
15
+ import { isDockerContainerID, isDockerWorkspacePath } from "./docker-validation.js";
15
16
  import { currentSnapshotProtection, isRuntimeControlPath, refreshProtectedSnapshot, snapshotControlExclusion, snapshotScratchExclusion, validationInputSnapshot } from "./protected-snapshot.js";
16
17
  const exec = promisify(execFile);
17
18
  const notRepository = (error) => /fatal: not a git repository \(or (?:any of the parent directories|any parent up to mount point [^\r\n)]+)\)(?:: \.git)?/u
@@ -48,6 +49,7 @@ export function missionReviewValidation(run, statePath) {
48
49
  manifest_path: protected_binding.manifest_path,
49
50
  manifest_hash: protected_binding.manifest_hash,
50
51
  source_policy: protected_binding.source_policy,
52
+ ...(protected_binding.docker_workspace ? { docker_workspace: protected_binding.docker_workspace } : {}),
51
53
  } } : {}),
52
54
  })),
53
55
  ...(unit.reviewerCorrection ? { correction_checks: (unit.reviewerCorrection.checks ?? []).map(check => ({
@@ -60,6 +62,30 @@ export function missionReviewValidation(run, statePath) {
60
62
  field: "units[].evidence", omitted: "host snapshot recipe: source/candidate paths and freshness environment" },
61
63
  }));
62
64
  }
65
+ function nativeToolText(value) {
66
+ if (typeof value === "string")
67
+ return value;
68
+ if (!Array.isArray(value))
69
+ return undefined;
70
+ const texts = value.flatMap(item => record(item) && item.type === "text" && typeof item.text === "string" ? [item.text] : []);
71
+ return texts.length ? texts.join("\n") : undefined;
72
+ }
73
+ function dockerExecExitCode(state) {
74
+ if (state.status !== "completed")
75
+ return null;
76
+ const metadataStatus = record(state.metadata) ? state.metadata.status : undefined;
77
+ if (metadataStatus === "error" || metadataStatus === "cancel" || metadataStatus === "cancelled" ||
78
+ metadataStatus === "canceled" || metadataStatus === "interrupted")
79
+ return null;
80
+ const metadataExit = record(state.metadata) ? state.metadata.exit : undefined;
81
+ const output = nativeToolText(state.output) ?? nativeToolText(state.content);
82
+ const match = output?.match(/^exit_code=(0|[1-9]\d*|-[1-9]\d*|unknown)(?:\r?\n|$)/u);
83
+ const outputExit = match && match[1] !== "unknown" ? Number(match[1]) : null;
84
+ const explicit = typeof metadataExit === "number" && Number.isSafeInteger(metadataExit) ? metadataExit : undefined;
85
+ if (explicit !== undefined && match && explicit !== outputExit)
86
+ return null;
87
+ return explicit ?? outputExit;
88
+ }
63
89
  /** Show native command outcomes to the Reviewer without turning non-criterion checks into acceptance evidence. */
64
90
  export function observedMissionValidation(validation, childSessionID, history, notBefore, directory, directories) {
65
91
  const declared = validation.map(normalizeCommand);
@@ -69,27 +95,53 @@ export function observedMissionValidation(validation, childSessionID, history, n
69
95
  !Array.isArray(message.parts))
70
96
  return [];
71
97
  return message.parts.flatMap(part => {
72
- if (!record(part) || part.type !== "tool" || !["bash", "shell", "powershell", "pwsh"].includes(String(part.tool)) ||
73
- !record(part.state) || !["completed", "error"].includes(String(part.state.status)) || !record(part.state.input) ||
74
- typeof part.state.input.command !== "string")
98
+ if (!record(part) || part.type !== "tool" || !record(part.state) || !["completed", "error"].includes(String(part.state.status)))
75
99
  return [];
76
- // Match raw input first: whitespace normalization destroys Python heredoc boundaries.
77
- const command = part.state.input.command;
78
- const exit = record(part.state.metadata) ? part.state.metadata.exit : undefined;
79
100
  const started = record(part.time) ? time(part.time.ran) : record(part.state.time) ? time(part.state.time.start) : null;
80
101
  const completed = record(part.time) ? time(part.time.completed) : record(part.state.time) ? time(part.state.time.end) : null;
81
102
  if (notBefore !== undefined && (started === null || completed === null || started < notBefore || completed < started))
82
103
  return [];
83
- return [{ command, directory: typeof part.state.input.workdir === "string" ? part.state.input.workdir : undefined,
84
- exit_code: typeof exit === "number" && Number.isSafeInteger(exit) ? exit : null,
85
- started_ms: started, completed_ms: completed }];
104
+ if (["bash", "shell", "powershell", "pwsh"].includes(String(part.tool))) {
105
+ if (!record(part.state.input) || typeof part.state.input.command !== "string")
106
+ return [];
107
+ // Match raw input first: whitespace normalization destroys Python heredoc boundaries.
108
+ const exit = record(part.state.metadata) ? part.state.metadata.exit : undefined;
109
+ return [{ command: part.state.input.command,
110
+ directory: typeof part.state.input.workdir === "string" ? part.state.input.workdir : undefined,
111
+ exit_code: typeof exit === "number" && Number.isSafeInteger(exit) ? exit : null,
112
+ started_ms: started, completed_ms: completed }];
113
+ }
114
+ if ((part.tool === "fh_docker_exec" || part.tool === "fh.docker_exec") && record(part.state.input) &&
115
+ typeof part.state.input.command === "string") {
116
+ const input = part.state.input;
117
+ return [{ command: part.state.input.command,
118
+ ...(isDockerWorkspacePath(input.cwd) ? { directory: input.cwd } : { directory_status: "unobserved" }),
119
+ docker: true, exit_code: dockerExecExitCode(part.state),
120
+ started_ms: started, completed_ms: completed }];
121
+ }
122
+ // Codemode's outer `execute` record retains the real namespaced call in toolCalls,
123
+ // while its result carries the Docker plugin's explicit exit_code header. Only a
124
+ // single completed Docker call can be paired unambiguously with that result.
125
+ if (part.tool !== "execute" || part.state.status !== "completed" || !record(part.state.metadata) ||
126
+ !Array.isArray(part.state.metadata.toolCalls) || part.state.metadata.toolCalls.length !== 1)
127
+ return [];
128
+ const [call] = part.state.metadata.toolCalls;
129
+ if (!record(call) || call.tool !== "fh.docker_exec" || call.status !== "completed" ||
130
+ !record(call.input) || typeof call.input.command !== "string")
131
+ return [];
132
+ return [{ command: call.input.command,
133
+ ...(isDockerWorkspacePath(call.input.cwd) ? { directory: call.input.cwd } : { directory_status: "unobserved" }),
134
+ docker: true,
135
+ exit_code: dockerExecExitCode(part.state), started_ms: started, completed_ms: completed }];
86
136
  });
87
137
  });
88
138
  let occurrence = 0;
89
139
  const attempts = rawAttempts.sort((a, b) => (a.started_ms ?? 0) - (b.started_ms ?? 0) || (a.completed_ms ?? 0) - (b.completed_ms ?? 0)).flatMap(attempt => {
90
140
  const exact = canonicalDeclaredValidationMembers(attempt.command, declared, occurrence);
91
141
  const members = exact ?? (attempt.exit_code === 0 ? canonicalObservedValidationMembers(attempt.command, declared, occurrence) : undefined);
92
- if (!members || !validationDirectoryMatches(members, attempt.directory, directory, directories))
142
+ const directoryMatches = attempt.directory_status === "unobserved" ? directory === undefined
143
+ : validationDirectoryMatches(members ?? [], attempt.directory, directory, directories);
144
+ if (!members || !directoryMatches)
93
145
  return [];
94
146
  if (attempt.exit_code !== 0)
95
147
  occurrence = 0;
@@ -100,9 +152,10 @@ export function observedMissionValidation(validation, childSessionID, history, n
100
152
  if (command === declared[occurrence])
101
153
  occurrence = (occurrence + 1) % declared.length;
102
154
  }
103
- const { directory: actual, ...visible } = attempt;
104
- return [{ ...visible, command: members.join(" && "), ...(!exact ? { native_command: attempt.command } : {}), ...(directory && resolve(directory, actual ?? ".") !== resolve(directory)
105
- ? { directory: resolve(directory, actual ?? ".") } : {}), ...(members.length > 1 ? { observed_members: members } : {}) }];
155
+ const { directory: actual, docker, ...visible } = attempt;
156
+ return [{ ...visible, command: members.join(" && "), ...(!exact ? { native_command: attempt.command } : {}),
157
+ ...(actual && (docker || directory && resolve(directory, actual) !== resolve(directory))
158
+ ? { directory: docker ? actual : resolve(directory, actual) } : {}), ...(members.length > 1 ? { observed_members: members } : {}) }];
106
159
  });
107
160
  // Retain early attempts and the latest checks if a Worker retried many times.
108
161
  const shown = attempts.length > 32 ? [...attempts.slice(0, 8), ...attempts.slice(-24)] : attempts;
@@ -131,6 +184,23 @@ export function reviewerCorrectionValidation(validation, child, history, notBefo
131
184
  editedAt = Math.max(editedAt, end);
132
185
  continue;
133
186
  }
187
+ if (record(part) && part.type === "tool" && (part.tool === "fh_docker_exec" || part.tool === "fh.docker_exec") &&
188
+ record(part.state) && record(part.state.input) && typeof part.state.input.command === "string" &&
189
+ isDockerContainerID(part.state.input.container_id) && isDockerWorkspacePath(part.state.input.cwd)) {
190
+ const input = part.state.input, command = input.command, containerID = input.container_id, cwd = input.cwd;
191
+ const callID = typeof part.callID === "string" ? part.callID
192
+ : typeof part.id === "string" ? part.id : undefined;
193
+ const timing = record(part.time) ? { started: part.time.ran, completed: part.time.completed }
194
+ : record(part.state.time) ? { started: part.state.time.start, completed: part.state.time.end } : undefined;
195
+ if (!callID || seen.has(callID) || !timing || typeof timing.started !== "number" || typeof timing.completed !== "number" ||
196
+ !Number.isFinite(timing.started) || !Number.isFinite(timing.completed) || timing.started < notBefore || timing.completed < timing.started)
197
+ continue;
198
+ seen.add(callID);
199
+ attempts.push({ callID, command: command, directory: cwd,
200
+ dockerWorkspace: { container_id: containerID, cwd: cwd }, exit: dockerExecExitCode(part.state),
201
+ started: timing.started, completed: timing.completed });
202
+ continue;
203
+ }
134
204
  if (!record(part) || part.type !== "tool" || !["bash", "shell", "powershell", "pwsh"].includes(String(part.tool)) ||
135
205
  !record(part.state) || !["completed", "error"].includes(String(part.state.status)) ||
136
206
  !record(part.state.input) || typeof part.state.input.command !== "string")
@@ -153,6 +223,28 @@ export function reviewerCorrectionValidation(validation, child, history, notBefo
153
223
  }
154
224
  }
155
225
  const matched = [];
226
+ const checkDirectoryMatches = (check, attempt, command) => {
227
+ if (attempt.dockerWorkspace) {
228
+ const workspace = check.binding.docker_workspace;
229
+ return workspace?.container_id === attempt.dockerWorkspace.container_id && workspace.cwd === attempt.dockerWorkspace.cwd &&
230
+ check.directory === attempt.dockerWorkspace.cwd && validationDirectoryMatches([command], check.directory, directory, directories);
231
+ }
232
+ return check.binding?.docker_workspace === undefined && validationDirectoryMatches([command], check.directory, directory, directories);
233
+ };
234
+ // Reuse the completed-sequence binding predicate before retaining an incomplete
235
+ // prefix too; an exit-zero call with an invalid saved check is not reusable proof.
236
+ const bindingUnavailable = (item) => {
237
+ if (!checks)
238
+ return false;
239
+ const command = declared[item.occurrence];
240
+ const check = checks.find(check => check.callID === item.callID && check.childSessionID === child);
241
+ const attempt = attempts.find(attempt => attempt.callID === item.callID);
242
+ return !check || !attempt || !check.fresh || check.exitCode !== 0 || check.command[item.member] !== command ||
243
+ !checkDirectoryMatches(check, attempt, command) ||
244
+ directory !== undefined && resolve(directory, check.directory ?? ".") !== resolve(directory, attempt.directory ?? ".") ||
245
+ !Number.isFinite(Date.parse(check.startedAt)) || !Number.isFinite(Date.parse(check.endedAt)) ||
246
+ Date.parse(check.startedAt) < notBefore || Date.parse(check.endedAt) < Date.parse(check.startedAt);
247
+ };
156
248
  // Registration repairs replace only affected recipes. Retain the ordered actual
157
249
  // occurrences of unchanged checks and join the changed checks after their own
158
250
  // registration, rather than making an inserted/corrected command rerun its neighbors.
@@ -185,7 +277,11 @@ export function reviewerCorrectionValidation(validation, child, history, notBefo
185
277
  if (!commands || registeredAt && recorded && JSON.stringify(commands) !== JSON.stringify(recorded.command))
186
278
  continue;
187
279
  const members = commands.flatMap((command, member) => required.includes(command) &&
188
- validationDirectoryMatches([command], attempt.directory, directory, directories) &&
280
+ (attempt.dockerWorkspace
281
+ ? recorded?.binding?.docker_workspace?.container_id === attempt.dockerWorkspace.container_id &&
282
+ recorded.binding?.docker_workspace?.cwd === attempt.dockerWorkspace.cwd && recorded.directory === attempt.dockerWorkspace.cwd &&
283
+ validationDirectoryMatches([command], attempt.directory, directory, directories)
284
+ : recorded?.binding?.docker_workspace === undefined && validationDirectoryMatches([command], attempt.directory, directory, directories)) &&
189
285
  (registeredAt?.[command] === undefined || Number.isFinite(Date.parse(registeredAt[command])) &&
190
286
  attempt.started >= Date.parse(registeredAt[command])) ? [{ command, member }] : []);
191
287
  if (!members.length || !registeredAt && members.length !== commands.length)
@@ -212,8 +308,16 @@ export function reviewerCorrectionValidation(validation, child, history, notBefo
212
308
  failed = undefined;
213
309
  // A failed && chain proves no member, even if earlier members could have passed.
214
310
  if (attempt.exit !== 0) {
311
+ // A standalone, unique final check can be retried after setup repair without
312
+ // replaying its passed prefix. It still fails until an actual successful retry;
313
+ // the retained prefix and replacement tail must pass the usual binding/freshness checks.
314
+ const retryableTail = commands.length === 1 && members.length === 1 &&
315
+ partial.length === required.length - 1 && members[0].command === required.at(-1) &&
316
+ required.indexOf(members[0].command) === required.length - 1 &&
317
+ partial.every(item => !bindingUnavailable(item));
215
318
  completed = undefined;
216
- partial.length = 0;
319
+ if (!retryableTail)
320
+ partial.length = 0;
217
321
  failed = { command: commands, outcome: "fail", exitCode: attempt.exit };
218
322
  continue;
219
323
  }
@@ -231,7 +335,8 @@ export function reviewerCorrectionValidation(validation, child, history, notBefo
231
335
  }
232
336
  }
233
337
  if (failed)
234
- return { ready: false, reason: `mission-review-correction-validation-failed:${failed.command.join(" && ")}`, failure: failed, nextOccurrence: occurrences[0] };
338
+ return { ready: false, reason: `mission-review-correction-validation-failed:${failed.command.join(" && ")}`, failure: failed,
339
+ nextOccurrence: occurrences[partial.length] ?? occurrences[0] };
235
340
  if (!completed) {
236
341
  const next = occurrences[partial.length] ?? occurrences[0];
237
342
  if (!incomplete || next < incomplete.nextOccurrence)
@@ -248,13 +353,7 @@ export function reviewerCorrectionValidation(validation, child, history, notBefo
248
353
  complete.sort((a, b) => a.occurrence - b.occurrence);
249
354
  for (const item of complete) {
250
355
  const command = declared[item.occurrence];
251
- const check = checks?.find(check => check.callID === item.callID && check.childSessionID === child);
252
- const attempt = attempts.find(attempt => attempt.callID === item.callID);
253
- if (checks && (!check || !check.fresh || check.exitCode !== 0 || check.command[item.member] !== command ||
254
- !validationDirectoryMatches([command], check.directory, directory, directories) ||
255
- directory !== undefined && resolve(directory, check.directory ?? ".") !== resolve(directory, attempt.directory ?? ".") ||
256
- !Number.isFinite(Date.parse(check.startedAt)) || !Number.isFinite(Date.parse(check.endedAt)) ||
257
- Date.parse(check.startedAt) < notBefore || Date.parse(check.endedAt) < Date.parse(check.startedAt))) {
356
+ if (bindingUnavailable(item)) {
258
357
  return { ready: false, reason: `mission-review-correction-validation-binding-unavailable:${command}` };
259
358
  }
260
359
  }
@@ -299,10 +398,11 @@ export async function observedMissionValidationSummary(validation, childSessionI
299
398
  const observed = observedMissionValidation(validation, childSessionID, data.filter(record), notBefore, directory, directories);
300
399
  const grouped = new Map();
301
400
  for (const attempt of observed.attempts) {
302
- const key = JSON.stringify([attempt.command, attempt.native_command, attempt.directory, attempt.exit_code]);
401
+ const key = JSON.stringify([attempt.command, attempt.native_command, attempt.directory, attempt.directory_status, attempt.exit_code]);
303
402
  const previous = grouped.get(key);
304
403
  grouped.set(key, { command: attempt.command, ...(attempt.native_command ? { native_command: attempt.native_command } : {}),
305
- ...(attempt.directory ? { directory: attempt.directory } : {}), exit_code: attempt.exit_code,
404
+ ...(attempt.directory ? { directory: attempt.directory } : {}),
405
+ ...(attempt.directory_status ? { directory_status: attempt.directory_status } : {}), exit_code: attempt.exit_code,
306
406
  observed_attempts: (previous?.observed_attempts ?? 0) + 1,
307
407
  latest_started_ms: attempt.started_ms, latest_completed_ms: attempt.completed_ms });
308
408
  }
@@ -1,6 +1,5 @@
1
1
  /**
2
- * Native V2 truncates at whole-line boundaries. A large one-line full packet can
3
- * therefore hide every field. Put existing actionable facts before bulky proof
4
- * recipes, with one top-level field per line; keep the complete JSON unchanged.
2
+ * Native V2 truncates at whole-line boundaries. Put actionable facts and bounded
3
+ * model observations before bulky history/proof; preserve every original value.
5
4
  */
6
5
  export declare function serializeOperatorStatus(packet: Record<string, unknown>): string;
@@ -1,18 +1,53 @@
1
+ const RECENT_ATTEMPTS = 8;
2
+ const RECENT_MODEL_ATTEMPTS = 8;
3
+ const ATTEMPT_FIELDS = ["attemptID", "runID", "unitID", "kind", "childSessionID", "status", "nativeOutcome",
4
+ "selectedModel", "observedModel", "observedVariant", "resultClass"];
5
+ const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
6
+ /** Display-only projection: never infer an observed model from a selection. */
7
+ function recentModelObservations(attempts) {
8
+ const selected = new Set();
9
+ for (let index = Math.max(0, attempts.length - RECENT_ATTEMPTS); index < attempts.length; index++)
10
+ selected.add(index);
11
+ // Model-less direct work must not displace the most recent model observations.
12
+ let models = 0;
13
+ for (let index = attempts.length - 1; index >= 0 && models < RECENT_MODEL_ATTEMPTS; index--) {
14
+ const attempt = attempts[index];
15
+ if (record(attempt) && [attempt.selectedModel, attempt.observedModel].some(value => typeof value === "string" && value.length > 0)) {
16
+ selected.add(index);
17
+ models++;
18
+ }
19
+ }
20
+ const recent = [...selected].sort((left, right) => left - right).flatMap(index => {
21
+ const attempt = attempts[index];
22
+ if (!record(attempt))
23
+ return [];
24
+ const summary = Object.fromEntries(ATTEMPT_FIELDS.filter(key => Object.hasOwn(attempt, key)).map(key => [key, attempt[key]]));
25
+ if (record(attempt.failure)) {
26
+ const failure = attempt.failure;
27
+ summary.failure = Object.fromEntries(["category", "code"].filter(key => Object.hasOwn(failure, key)).map(key => [key, failure[key]]));
28
+ }
29
+ else if (attempt.failure === null)
30
+ summary.failure = null;
31
+ return [summary];
32
+ });
33
+ return { recent_attempts: recent, omitted_attempts: attempts.length - recent.length };
34
+ }
1
35
  /**
2
- * Native V2 truncates at whole-line boundaries. A large one-line full packet can
3
- * therefore hide every field. Put existing actionable facts before bulky proof
4
- * recipes, with one top-level field per line; keep the complete JSON unchanged.
36
+ * Native V2 truncates at whole-line boundaries. Put actionable facts and bounded
37
+ * model observations before bulky history/proof; preserve every original value.
5
38
  */
6
39
  export function serializeOperatorStatus(packet) {
40
+ const display = !Object.hasOwn(packet, "model_observations") && Array.isArray(packet.attempts) && packet.attempts.length > 0
41
+ ? { ...packet, model_observations: recentModelObservations(packet.attempts) } : packet;
7
42
  const keys = new Set([
8
43
  "profile", "view", "mission_id", "run_id", "phase", "status", "decision", "coordinator_session_id",
9
- "work_quantity", "budget", "accounting_scope", "attempts", "next_action", "execution_summary", "runtime", "completion",
10
- ...Object.keys(packet),
44
+ "work_quantity", "budget", "accounting_scope", "next_action", "execution_summary", "runtime", "model_observations", "completion", "attempts",
45
+ ...Object.keys(display),
11
46
  ]);
12
47
  const lines = [...keys].flatMap(key => {
13
- if (!Object.hasOwn(packet, key))
48
+ if (!Object.hasOwn(display, key))
14
49
  return [];
15
- const value = JSON.stringify(packet[key]);
50
+ const value = JSON.stringify(display[key]);
16
51
  return value === undefined ? [] : [` ${JSON.stringify(key)}: ${value}`];
17
52
  });
18
53
  return `{\n${lines.join(",\n")}\n}`;
@@ -239,8 +239,10 @@ export function createProfiledPlugin(profile, assetVersion) {
239
239
  if (!child)
240
240
  throw new Error("native-worker-history-session-unavailable");
241
241
  const direct = await directExecution(child) ?? await reviewerCorrection(child);
242
- return observedMissionValidationSummary(validation, child, typeof nativeSession?.messages === "function"
243
- ? () => session("messages", { path: { id: child }, query: { directory: input.directory } }) : undefined, notBefore, notBefore === undefined ? undefined : input.directory, direct?.unit.directExecution?.validationCwd ?? direct?.unit.unit.validation_cwd);
242
+ const method = typeof nativeSession?.reviewMessages === "function" ? "reviewMessages" : "messages";
243
+ return observedMissionValidationSummary(validation, child, typeof nativeSession?.[method] === "function"
244
+ ? () => session(method, { path: { id: child }, query: { directory: input.directory,
245
+ ...(notBefore === undefined ? {} : { notBefore: new Date(notBefore).toISOString() }) } }) : undefined, notBefore, notBefore === undefined ? undefined : input.directory, direct?.unit.directExecution?.validationCwd ?? direct?.unit.unit.validation_cwd);
244
246
  }
245
247
  async function reviewMessages(id, notBefore, requireAvailable = false) {
246
248
  const method = typeof nativeSession?.reviewMessages === "function" ? "reviewMessages" : "messages";
@@ -679,7 +681,8 @@ export function createProfiledPlugin(profile, assetVersion) {
679
681
  return undefined;
680
682
  const unit = correction.unit;
681
683
  const directories = unit.directExecution?.validationCwd ?? unit.unit.validation_cwd;
682
- const progress = reviewerCorrectionValidation(unit.unit.validation, child, await messages(child), Date.parse(unit.reviewerCorrection?.admittedAt ?? unit.directExecution?.startedAt ?? correction.run.createdAt), unit.reviewerCorrection?.checks ?? unit.directExecution?.checks ?? [], input.directory, directories, unit.reviewerCorrection?.validationRegisteredAt ?? unit.directExecution?.validationRegisteredAt);
684
+ const admittedAt = unit.reviewerCorrection?.admittedAt ?? unit.directExecution?.startedAt ?? correction.run.createdAt;
685
+ const progress = reviewerCorrectionValidation(unit.unit.validation, child, await reviewMessages(child, admittedAt), Date.parse(admittedAt), unit.reviewerCorrection?.checks ?? unit.directExecution?.checks ?? [], input.directory, directories, unit.reviewerCorrection?.validationRegisteredAt ?? unit.directExecution?.validationRegisteredAt);
683
686
  const members = canonicalObservedValidationMembers(command, unit.unit.validation, progress.nextOccurrence ?? 0);
684
687
  return members && validationDirectoryMatches(members, directory, input.directory, directories) ? members : undefined;
685
688
  },
@@ -697,8 +700,10 @@ export function createProfiledPlugin(profile, assetVersion) {
697
700
  (item.reviewerCorrection?.author === child || item.directExecution?.actor === child));
698
701
  if (!unit)
699
702
  return undefined;
703
+ // Compaction removes earlier tools from session.context, not from native history.
704
+ // Reuse the admission-window reader; saved checks still require their live input binding.
700
705
  try {
701
- return await reviewerCorrectionValidationFresh(unit.unit.validation, child, await messages(child), startedAt, unit.reviewerCorrection?.checks ?? unit.directExecution?.checks ?? [], input.directory, unit.directExecution?.validationCwd ?? unit.unit.validation_cwd, unit.reviewerCorrection?.validationRegisteredAt ?? unit.directExecution?.validationRegisteredAt);
706
+ return await reviewerCorrectionValidationFresh(unit.unit.validation, child, await reviewMessages(child, new Date(startedAt).toISOString()), startedAt, unit.reviewerCorrection?.checks ?? unit.directExecution?.checks ?? [], input.directory, unit.directExecution?.validationCwd ?? unit.unit.validation_cwd, unit.reviewerCorrection?.validationRegisteredAt ?? unit.directExecution?.validationRegisteredAt);
702
707
  }
703
708
  catch {
704
709
  return { ready: false, reason: "mission-review-correction-validation-history-unavailable" };
@@ -3515,7 +3520,8 @@ export function createProfiledPlugin(profile, assetVersion) {
3515
3520
  for (const unit of run.units) {
3516
3521
  if (!unit.reviewerCorrection || !unit.childSessionID)
3517
3522
  continue;
3518
- const checked = await reviewerCorrectionValidationFresh(unit.unit.validation, unit.childSessionID, await messages(unit.childSessionID), Date.parse(unit.reviewerCorrection.admittedAt ?? run.createdAt), unit.reviewerCorrection.checks ?? [], input.directory, unit.directExecution?.validationCwd ?? unit.unit.validation_cwd, unit.reviewerCorrection.validationRegisteredAt);
3523
+ const admittedAt = unit.reviewerCorrection.admittedAt ?? run.createdAt;
3524
+ const checked = await reviewerCorrectionValidationFresh(unit.unit.validation, unit.childSessionID, await reviewMessages(unit.childSessionID, admittedAt), Date.parse(admittedAt), unit.reviewerCorrection.checks ?? [], input.directory, unit.directExecution?.validationCwd ?? unit.unit.validation_cwd, unit.reviewerCorrection.validationRegisteredAt);
3519
3525
  if (!checked.ready)
3520
3526
  return checked.reason;
3521
3527
  }
@@ -3529,7 +3535,7 @@ export function createProfiledPlugin(profile, assetVersion) {
3529
3535
  async function observeNativeSelfRecheck(root, mission, run, author, callID, promptID, admittedAt, expectedSource, suppliedHistory) {
3530
3536
  if (!promptID || !Number.isFinite(admittedAt) || mission.runID !== run.runID || run.phase !== "awaiting-acceptance")
3531
3537
  return undefined;
3532
- const history = suppliedHistory ?? await messages(author);
3538
+ const history = suppliedHistory ?? await reviewMessages(author, new Date(admittedAt).toISOString());
3533
3539
  const prompt = history.findIndex(message => record(message.info) && message.info.id === promptID && message.info.role === "user" && message.info.sessionID === author);
3534
3540
  if (prompt < 0)
3535
3541
  return undefined;
@@ -53,6 +53,16 @@ export declare function protectedSnapshot(authorization: {
53
53
  readonly source: string;
54
54
  readonly candidate: string;
55
55
  } | undefined>;
56
+ /** Capture a task workspace that exists only inside the fixed Docker task container. */
57
+ export declare function protectedDockerSnapshot(authorization: {
58
+ manifestPath: string;
59
+ manifestHash: string;
60
+ projectRoot: string;
61
+ }, containerID: string, cwd: string): Promise<{
62
+ readonly binding: Binding;
63
+ readonly source: string;
64
+ readonly candidate: string;
65
+ } | undefined>;
56
66
  export declare function refreshProtectedSnapshot(projectRoot: string, binding: Binding): Promise<{
57
67
  readonly source: string;
58
68
  readonly candidate: string;
@@ -7,6 +7,7 @@ import { normalizeManifestScope } from "../core/path.js";
7
7
  import { RUNTIME_PROFILES } from "../core/runtime-profile.js";
8
8
  import { declaredArtifacts } from "./declared-artifacts.js";
9
9
  import { validationScratchPaths } from "./validation-scratch.js";
10
+ import { captureDockerWorkspaceSnapshot } from "./docker-validation.js";
10
11
  import { execFile } from "node:child_process";
11
12
  import { promisify } from "node:util";
12
13
  const controlRoots = new Set([".git", ...Object.values(RUNTIME_PROFILES).map(profile => profile.stateDirectory)]);
@@ -505,7 +506,34 @@ export async function protectedSnapshot(authorization, options = {}) {
505
506
  source_paths: sourcePaths.map(path => evidencePath(path).replaceAll("\\", "/")),
506
507
  candidate_paths: candidatePaths.map(path => evidencePath(path).replaceAll("\\", "/")) }, source, candidate };
507
508
  }
509
+ /** Capture a task workspace that exists only inside the fixed Docker task container. */
510
+ export async function protectedDockerSnapshot(authorization, containerID, cwd) {
511
+ const manifestSource = await readFile(authorization.manifestPath).catch(() => undefined);
512
+ if (manifestSource === undefined)
513
+ return undefined;
514
+ const manifestHash = createHash("sha256").update(manifestSource).digest("hex");
515
+ if (manifestHash !== authorization.manifestHash)
516
+ return undefined;
517
+ const relativePath = relative(authorization.projectRoot, authorization.manifestPath).replaceAll("\\", "/");
518
+ if (relativePath.startsWith("../") || isAbsolute(relativePath))
519
+ return undefined;
520
+ const workspace = await captureDockerWorkspaceSnapshot(containerID, cwd);
521
+ if (!workspace)
522
+ return undefined;
523
+ return { binding: { manifest_hash: `sha256:${manifestHash}`, project_root: authorization.projectRoot,
524
+ manifest_path: relativePath, source_policy: "declared-paths-v1", source_paths: [], candidate_paths: [],
525
+ docker_workspace: workspace }, source: workspace.source, candidate: workspace.candidate };
526
+ }
508
527
  export async function refreshProtectedSnapshot(projectRoot, binding) {
528
+ if (binding.docker_workspace) {
529
+ if (resolve(projectRoot) !== resolve(binding.project_root) || !await snapshotManifest(projectRoot, binding))
530
+ return undefined;
531
+ const workspace = await captureDockerWorkspaceSnapshot(binding.docker_workspace.container_id, binding.docker_workspace.cwd);
532
+ if (!workspace || workspace.container_id !== binding.docker_workspace.container_id ||
533
+ workspace.cwd !== binding.docker_workspace.cwd || workspace.repository_root !== binding.docker_workspace.repository_root)
534
+ return undefined;
535
+ return { source: workspace.source, candidate: workspace.candidate };
536
+ }
509
537
  const current = await snapshotManifest(projectRoot, binding);
510
538
  if (!current)
511
539
  return undefined;
@@ -1,4 +1,77 @@
1
1
  import type { OpenCodeHooks, OpenCodePlugin } from "./index.js";
2
+ export declare const V010_AGENT_TOOL_REGISTRY: {
3
+ schema_version: number;
4
+ tool_prefix: string;
5
+ core_tools: string[];
6
+ runtime_agents: {
7
+ "dog-coordinator": string;
8
+ "dog-worker": string;
9
+ "dog-luna-worker": string;
10
+ "dog-scout": string;
11
+ "dog-reviewer": string;
12
+ "dog-advisor": string;
13
+ "dog-operator": string;
14
+ };
15
+ roles: {
16
+ "dog-coordinator": {
17
+ tool_families: {
18
+ mission_lifecycle: string[];
19
+ unit_execution: string[];
20
+ review: string[];
21
+ };
22
+ children: string[];
23
+ };
24
+ "dog-worker": {
25
+ tool_families: {
26
+ workspace: string[];
27
+ };
28
+ native_tool_families: {
29
+ docker_task: string[];
30
+ };
31
+ core_tools: string[];
32
+ children: never[];
33
+ };
34
+ "dog-luna-worker": {
35
+ tool_families: {
36
+ workspace: string[];
37
+ };
38
+ core_tools: string[];
39
+ children: never[];
40
+ };
41
+ "dog-scout": {
42
+ tool_families: {};
43
+ children: never[];
44
+ };
45
+ "dog-reviewer": {
46
+ tool_families: {
47
+ review: string[];
48
+ };
49
+ native_tool_families: {
50
+ docker_review: string[];
51
+ };
52
+ correction_native_tool_families: {
53
+ docker_task: string[];
54
+ };
55
+ correction_tool_families: {
56
+ workspace: string[];
57
+ correction: string[];
58
+ };
59
+ children: never[];
60
+ };
61
+ "dog-advisor": {
62
+ tool_families: {};
63
+ children: never[];
64
+ };
65
+ "dog-operator": {
66
+ tool_families: {
67
+ mission_lifecycle: string[];
68
+ unit_execution: string[];
69
+ review: string[];
70
+ };
71
+ children: string[];
72
+ };
73
+ };
74
+ };
2
75
  type JsonObject = Record<string, unknown>;
3
76
  type NativePermissionRule = {
4
77
  action: string;
package/dist/plugin/v2.js CHANGED
@@ -15,6 +15,9 @@ import { modelLiveState } from "./model-live-state.js";
15
15
  import { serializeOperatorStatus } from "./operator-status-output.js";
16
16
  import { runtimeAssets as reviewerAssets } from "../runtime-assets-v010.js";
17
17
  import { MISSION_RESULT_HOLD_POLICY, MISSION_REFLECTION_WORKFLOW, LEGACY_MISSION_REFLECTION_WORKFLOW } from "../runtime-mission-assets.js";
18
+ import agentToolRegistry from "./frontierharness-agent-tool-registry.json" with { type: "json" };
19
+ import { canonicalAgent, V010_RUNTIME_PROFILE } from "../core/runtime-profile.js";
20
+ export const V010_AGENT_TOOL_REGISTRY = agentToolRegistry;
18
21
  // Capture once when this module evaluates. A later package replacement must not make an old
19
22
  // process report the replacement's bytes as its loaded adapter.
20
23
  const loadedAdapter = Object.freeze({ adapter_url: import.meta.url,
@@ -322,7 +325,7 @@ export function createV2ReturnReportFinalizer(context, hooks) {
322
325
  }
323
326
  const correctionAgent = (agent) => typeof agent === "string" && /^dog-reviewer-correction-v010-[a-f0-9]{16}$/u.test(agent);
324
327
  const reviewerCorrectionOnlyTools = new Set(["edit", "write", "patch", "sortie_v010_bind_write_gate",
325
- "sortie_v010_release_write_gate", "sortie_v010_operator_status", "sortie_v010_expand_unit", "sortie_v010_finish_direct_unit"]);
328
+ "sortie_v010_release_write_gate", "sortie_v010_operator_status", "sortie_v010_expand_unit", "sortie_v010_finish_direct_unit", "fh_docker_exec"]);
326
329
  // Order only the genuine, already-filtered native definitions. Initial read-only
327
330
  // tools remain a deterministic prefix when correction admits additional tools;
328
331
  // object identities, schemas, executors and native authority are unchanged.
@@ -375,6 +378,29 @@ function shippedReviewerBlock(rules) {
375
378
  }
376
379
  return undefined;
377
380
  }
381
+ function correctionGatewayReviewerRules(rules) {
382
+ const role = agentToolRegistry.roles["dog-reviewer"];
383
+ // Match product_agent_permissions()'s exact generated block, in place. A user's
384
+ // surrounding agent rules and later session rules retain native last-match order.
385
+ const generated = (correcting) => {
386
+ const actions = [...new Set([...Object.values(role.tool_families),
387
+ ...(correcting ? Object.values(role.correction_tool_families) : [])].flat())].sort();
388
+ const native = [...new Set([...Object.values(role.native_tool_families),
389
+ ...(correcting ? Object.values(role.correction_native_tool_families) : [])].flat())].sort();
390
+ return [{ action: "*", resource: "*", effect: "deny" },
391
+ ...actions.map(action => ({ action: agentToolRegistry.tool_prefix + action, resource: "*", effect: "allow" })),
392
+ ...native.map(action => ({ action, resource: "*", effect: "allow" }))];
393
+ };
394
+ const original = generated(false);
395
+ for (let start = rules.length - original.length; start >= 0; start--) {
396
+ if (original.every((rule, offset) => {
397
+ const actual = rules[start + offset];
398
+ return actual?.action === rule.action && actual.resource === rule.resource && actual.effect === rule.effect;
399
+ }))
400
+ return [...rules.slice(0, start), ...generated(true), ...rules.slice(start + original.length)];
401
+ }
402
+ return [...rules];
403
+ }
378
404
  const correctionAgents = new WeakMap();
379
405
  /** Use 2.0.18's agent.transform/switchAgent BEFORE native tool snapshot selection; never mutate session rules. */
380
406
  export async function syncReviewerCorrectionPermissions(context, hooks, sessionID, activate = true) {
@@ -470,7 +496,7 @@ async function syncReviewerCorrectionPermissionsOnce(context, hooks, sessionID,
470
496
  const reviewer = editor.get?.("dog-reviewer-v010");
471
497
  if (!reviewer?.permissions)
472
498
  throw new Error("native-reviewer-correction-role-permissions-unavailable");
473
- const rules = reviewer.permissions;
499
+ const rules = correctionGatewayReviewerRules(reviewer.permissions);
474
500
  const shipped = shippedReviewerBlock(rules);
475
501
  if (!shipped)
476
502
  throw new Error("native-reviewer-correction-readonly-role-block-unavailable");
@@ -479,8 +505,8 @@ async function syncReviewerCorrectionPermissionsOnce(context, hooks, sessionID,
479
505
  ...rules.slice(shipped.start + shipped.rules.length)];
480
506
  editor.update(id, agent => Object.assign(agent, reviewer, { id, name: id, mode: "subagent", hidden: true,
481
507
  model: { ...info.model },
482
- // Remove ONLY the shipped read-only edit/shell role entries. Native implementation
483
- // defaults and every real configured rule keep their original order/effective meaning.
508
+ // Project only the exact generated gateway block and shipped read-only edit/shell
509
+ // role entries. Other configured rules keep their native order/effective meaning.
484
510
  // No edit/shell grants, copied historical denies, command scanner or session-rule replacement.
485
511
  // The existing correction-control tools stay exposed beneath the shipped sortie_* deny.
486
512
  // Common implementation gates own known output scope; the ordinary Reviewer is untouched.
@@ -792,21 +818,24 @@ async function registerV2Hooks(context, hooks, background, liveState) {
792
818
  await hooks["experimental.chat.system.transform"]({ sessionID: String(event.sessionID ?? "") }, output);
793
819
  await reconcileCorrectionPermissions(String(event.sessionID));
794
820
  if (record(event.tools)) {
795
- const visible = {
796
- "dog-operator": ["start_mission", "plan_units", "start_direct_unit", "finish_direct_unit", "retry_mission_unit", "operator_next", "operator_status", "extend_mission_budget", "expand_unit", "review_mission", "repair_review", "complete_mission", "cancel_operator", "reflection"],
797
- "dogs-coordinator": ["plan_units", "start_direct_unit", "finish_direct_unit", "operator_next", "operator_status", "expand_unit", "review_mission", "repair_review", "submit_mission", "skip_mission_consultation", "retry_mission_unit", "rescue_mission_unit"],
798
- "dog-worker-v010": ["bind_write_gate", "release_write_gate", "operator_status", "expand_unit"],
799
- "dog-luna-worker-v010": ["bind_write_gate", "release_write_gate", "operator_status", "expand_unit"],
800
- "dog-reviewer-v010": await hooks.reviewerCorrectionScope?.(String(event.sessionID)) ? ["bind_write_gate", "release_write_gate", "operator_status", "expand_unit", "repair_review", "finish_direct_unit"] : ["repair_review"], "dog-scout-v010": [], "dog-advisor-v010": [],
801
- };
802
821
  const correcting = correctionAgent(event.agent) && !!await hooks.reviewerCorrectionScope?.(String(event.sessionID));
803
- const allowed = correctionAgent(event.agent) ? (correcting ? ["bind_write_gate", "release_write_gate", "operator_status", "expand_unit", "repair_review", "finish_direct_unit"] : []) : visible[String(event.agent)];
822
+ const logicalRole = canonicalAgent(V010_RUNTIME_PROFILE, String(event.agent));
823
+ const role = logicalRole ? agentToolRegistry.roles[logicalRole] : undefined;
824
+ const families = role ? [...Object.values(role.tool_families), ...(correcting ? Object.values(role.correction_tool_families ?? {}) : [])] : [];
825
+ const allowed = role ? [...new Set(families.flat())] : undefined;
826
+ const nativeFamilies = role ? [...Object.values(role.native_tool_families ?? {}),
827
+ ...(correcting ? Object.values(role.correction_native_tool_families ?? {}) : [])] : [];
828
+ const allowedNativeTools = new Set(nativeFamilies.flat());
804
829
  for (const key of Object.keys(event.tools)) {
805
830
  if ((event.agent === "dog-reviewer-v010" || correctionAgent(event.agent)) && !correcting &&
806
831
  ["edit", "write", "patch"].includes(key)) {
807
832
  delete event.tools[key];
808
833
  continue;
809
834
  }
835
+ if (key.startsWith("fh_") && !allowedNativeTools.has(key)) {
836
+ delete event.tools[key];
837
+ continue;
838
+ }
810
839
  if (!key.startsWith("sortie_"))
811
840
  continue;
812
841
  if (!allowed || !key.startsWith("sortie_v010_") || !allowed.includes(key.slice("sortie_v010_".length)))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sortie-dogs",
3
- "version": "0.14.2",
3
+ "version": "0.14.3",
4
4
  "description": "Bounded agent harness and validated orchestration loop plugin for OpenCode",
5
5
  "keywords": [
6
6
  "opencode",