@arbiterforge/ca-pi 0.15.0 → 0.15.1

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
@@ -10,7 +10,7 @@ project context. You decide. codeArbiter enforces.
10
10
  <img alt="Claude Code plugin" src="https://img.shields.io/badge/Claude_Code-plugin-d97757">
11
11
  <img alt="Codex plugin" src="https://img.shields.io/badge/OpenAI_Codex-plugin-10a37f">
12
12
  <img alt="Pi Feature Forge preview" src="https://img.shields.io/badge/ca--pi-Feature_Forge_preview-d97757">
13
- <img alt="version 2.22.0" src="https://img.shields.io/badge/version-2.22.0-2b7489">
13
+ <img alt="version 2.22.1" src="https://img.shields.io/badge/version-2.22.1-2b7489">
14
14
  <img alt="core lanes" src="https://img.shields.io/badge/core_lanes-18-555">
15
15
  <img alt="skills" src="https://img.shields.io/badge/skills-23-555">
16
16
  <img alt="agents" src="https://img.shields.io/badge/agents-20-555">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arbiterforge/ca-pi",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "license": "AGPL-3.0-only",
5
5
  "repository": {
6
6
  "type": "git",
@@ -4,6 +4,15 @@ All notable changes to `ca-pi` are documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.15.1] - 2026-09-26
8
+
9
+ ### Fixed
10
+
11
+ - Ship the shared whole-plan verification preflight, exact named-result collectors,
12
+ and pinned native Windows npm runner contract.
13
+ - Reject ineffective snapshot exclusions, prevent explicit roots from bypassing
14
+ exclusions, and retain actionable oversized-input diagnostics.
15
+
7
16
  ## [0.15.0] - 2026-09-26
8
17
 
9
18
  ### Added
@@ -6,32 +6,32 @@
6
6
  "binaries": {
7
7
  "darwin/amd64": {
8
8
  "file": "ca-artifact-darwin-amd64",
9
- "sha256": "8e2a1f3026d5d9a8de36f3f748a7140ecf1ef8e768022aaa8745884e09551f9a",
9
+ "sha256": "3c8d8deb48d85ba1c9a6cfaf1286543cc8193fc8db307fe35446f29e53d3ca7a",
10
10
  "native_tested": true
11
11
  },
12
12
  "darwin/arm64": {
13
13
  "file": "ca-artifact-darwin-arm64",
14
- "sha256": "cce4b1cd751ada483bffd2de53bd14cdfda50f5d7392b49ba3f020b58fc59da4",
14
+ "sha256": "af3a36a6926532c6e0b4da10397aae17a472e300f50d5c0dc7886aa722046d28",
15
15
  "native_tested": true
16
16
  },
17
17
  "linux/amd64": {
18
18
  "file": "ca-artifact-linux-amd64",
19
- "sha256": "7637fe233e8731f4f15ff36b2e80714edd7e6fd96117b41f911d0c8f46639615",
19
+ "sha256": "83300e32dd6514b4716707091d900611f42454097b177443c9c47f464cad5065",
20
20
  "native_tested": true
21
21
  },
22
22
  "linux/arm64": {
23
23
  "file": "ca-artifact-linux-arm64",
24
- "sha256": "daa4ed9f1f577c59afd0250701959718f501799c204df01a92f00876c088f619",
24
+ "sha256": "47f1e529140642c7574f29664bb9adacc2755458a1cd9a30c6fdc0f9df447f24",
25
25
  "native_tested": true
26
26
  },
27
27
  "windows/amd64": {
28
28
  "file": "ca-artifact-windows-amd64.exe",
29
- "sha256": "16a3a26c5c606fe77703cbc9ae69414cbbe93f2c50dda9301eb9784e2e842206",
29
+ "sha256": "fa1a11489cc4521c945a59d2dce9f270ba9fcf6fcdc220011579326d5e97569a",
30
30
  "native_tested": true
31
31
  },
32
32
  "windows/arm64": {
33
33
  "file": "ca-artifact-windows-arm64.exe",
34
- "sha256": "f64155cd951be94039ea85d4420b2d3bad75ebcb14e68aeb15be8ccf9ec1f690",
34
+ "sha256": "88e0e9c2cf7d5861ff1365d8aa4d9376f6fa1ff8cdf0eb708be78be88af45468",
35
35
  "native_tested": true
36
36
  }
37
37
  }
@@ -71,7 +71,102 @@ def _ready(client: Any, identity: dict[str, Any]) -> None:
71
71
  or result.get("model_sha256") != identity["model_sha256"]
72
72
  or result.get("normative_sha256") != identity["normative_sha256"]
73
73
  ):
74
- raise ApprovalError("NOT_READY", "only the exact current ready artifact can be armed")
74
+ details = "; ".join(
75
+ f"{item.get('code', 'NOT_READY')} {item.get('symbol', '')} {item.get('field', '')}: {item.get('message', '')}"
76
+ for item in result.get("diagnostics", []) if isinstance(item, dict)
77
+ )
78
+ raise ApprovalError("NOT_READY", "only the exact current ready artifact can be armed" + (": " + details if details else ""))
79
+
80
+
81
+ def _preflight_plan_verification(client: Any, identity: dict[str, Any]) -> dict[str, Any]:
82
+ """Inspect the whole engine-owned plan before requesting approval.
83
+
84
+ This private admission check runs no declared command or test discovery.
85
+ Proposed tests may be absent. Existing input roots must already be readable;
86
+ a containing directory records future file creation in the input snapshot.
87
+ Runtime binding, host authorization and fresh evidence remain separate.
88
+ """
89
+ if identity.get("kind") != "plan":
90
+ raise _artifactlib.ArtifactError("WRONG_KIND", "verification preflight requires a plan")
91
+ keys = ("artifact_id", "revision", "model_sha256", "normative_sha256")
92
+ artifact_id = identity["artifact_id"]
93
+
94
+ def same_identity(value: dict[str, Any]) -> None:
95
+ if not isinstance(value, dict) or any(value.get(key) != identity.get(key) for key in keys):
96
+ raise _artifactlib.ArtifactError("STALE_PLAN_PREFLIGHT", "plan identity changed during verification preflight; reread the complete current plan")
97
+
98
+ same_identity(client.call("identity", {"artifact_id": artifact_id}))
99
+ root = _artifactlib._trusted_directory(client.root, "UNSAFE_ROOT")
100
+ diagnostics: list[str] = []
101
+ seen: set[str] = set()
102
+ command_count = 0
103
+ offset, total = 0, None
104
+ for _ in range(8193):
105
+ request = {"artifact_id": artifact_id, "offset": offset, "budget": 16384}
106
+ if offset:
107
+ request["model_sha256"] = identity["model_sha256"]
108
+ page = client.call("outline", request)
109
+ same_identity(page)
110
+ _artifactlib.ArtifactClient._check_page(page, offset, "records")
111
+ if (page.get("offset") != offset or type(page.get("total")) is not int
112
+ or page["total"] < offset + len(page["records"])
113
+ or total is not None and page["total"] != total):
114
+ raise _artifactlib.ArtifactError("INVALID_RESPONSE", "plan outline count or offset changed")
115
+ total = page["total"]
116
+ for row in page["records"]:
117
+ if (not isinstance(row, dict) or not isinstance(row.get("id"), str)
118
+ or row["id"] in seen or type(row.get("retired")) is not bool):
119
+ raise _artifactlib.ArtifactError("INVALID_RESPONSE", "plan outline contains an invalid or duplicate record")
120
+ seen.add(row["id"])
121
+ if row.get("kind") != "tasks" or row["retired"]:
122
+ continue
123
+ exact = client.call("read", {"artifact_id": artifact_id, "symbol": row["id"], "mode": "exact", "budget": 16384})
124
+ same_identity(exact)
125
+ task = exact.get("record")
126
+ if (not isinstance(task, dict) or task.get("id") != row["id"]
127
+ or not isinstance(task.get("verification"), list) or not task["verification"]):
128
+ raise _artifactlib.ArtifactError("INVALID_RESPONSE", "plan task lacks complete verification definitions")
129
+ for number, definition in enumerate(task["verification"], 1):
130
+ command_count += 1
131
+ try:
132
+ if not isinstance(definition, dict):
133
+ raise _artifactlib.ArtifactError("INVALID_RESPONSE", "verification definition is not an object")
134
+ label = definition.get("cwd")
135
+ cwd = None
136
+ if label in {".", "current worktree", "candidate worktree"}:
137
+ cwd = root
138
+ elif isinstance(label, str):
139
+ relative = Path(label)
140
+ if relative.is_absolute() or ".." in relative.parts:
141
+ raise _artifactlib.ArtifactError("UNSUPPORTED_WORKSPACE", "declared cwd must be a contained repository path or mapped worktree label")
142
+ candidate = root / relative
143
+ if candidate.exists():
144
+ cwd = _artifactlib._trusted_directory(candidate, "UNSUPPORTED_WORKSPACE")
145
+ _artifactauthoritylib.validate_command_definition(definition, cwd=cwd)
146
+ except (_artifactlib.ArtifactError, _artifactauthoritylib.AuthorityError) as exc:
147
+ argv = definition.get("argv") if isinstance(definition, dict) else None
148
+ diagnostics.append(f"{row['id']} verification[{number}] {json.dumps(argv, ensure_ascii=True)}: {exc}")
149
+ next_offset = page["next_offset"]
150
+ expected = offset + len(page["records"])
151
+ if next_offset is None:
152
+ if expected != total:
153
+ raise _artifactlib.ArtifactError("INVALID_RESPONSE", "plan outline ended before every record was inspected")
154
+ break
155
+ if next_offset != expected:
156
+ raise _artifactlib.ArtifactError("INVALID_RESPONSE", "plan outline skipped records")
157
+ offset = next_offset
158
+ else:
159
+ raise _artifactlib.ArtifactError("INVALID_RESPONSE", "plan outline did not terminate")
160
+ try:
161
+ snapshot = client.call("snapshot", {"artifact_id": artifact_id})
162
+ except _artifactlib.ArtifactError as exc:
163
+ diagnostics.append(f"verification_inputs: {exc}")
164
+ snapshot = None
165
+ same_identity(client.call("identity", {"artifact_id": artifact_id}))
166
+ if diagnostics:
167
+ raise _artifactlib.ArtifactError("PLAN_VERIFICATION_UNAVAILABLE", "; ".join(diagnostics))
168
+ return {"artifact_id": artifact_id, "model_sha256": identity["model_sha256"],
169
+ "command_count": command_count, "snapshot": snapshot}
75
170
 
76
171
 
77
172
  def _safe_directory(root: Path, relative: Path, *, create: bool) -> Path:
@@ -135,6 +230,8 @@ def arm_user_approval(
135
230
  root = Path(root).resolve(strict=True)
136
231
  identity = _identity(client, artifact_id)
137
232
  _ready(client, identity)
233
+ if identity["kind"] == "plan":
234
+ _preflight_plan_verification(client, identity)
138
235
  token = token or secrets.token_urlsafe(24)
139
236
  if not TOKEN_RE.fullmatch(token):
140
237
  raise ApprovalError("INVALID_TOKEN", "approval token has an invalid shape")
@@ -3,6 +3,7 @@
3
3
  """Supervise verification and correlate independent Codex or Claude review evidence.
4
4
 
5
5
  arm_request(...) -> dict
6
+ validate_command_definition(definition, cwd=None) -> str
6
7
  run_verification(...) -> dict
7
8
  observe_codex_hook(...) -> dict
8
9
  observe_claude_hook(...) -> dict
@@ -28,7 +29,7 @@ from __future__ import annotations
28
29
  import hashlib
29
30
  import json
30
31
  import os
31
- from pathlib import Path
32
+ from pathlib import Path, PureWindowsPath
32
33
  import re
33
34
  import secrets
34
35
  import shlex
@@ -706,21 +707,89 @@ def _bind_commands(
706
707
  for command in context["commands"]:
707
708
  definition = command["definition"]
708
709
  cwd, worktree, common = _workspace_for(root, definition["cwd"], workspace_roots)
710
+ collector = validate_command_definition(definition, cwd=cwd)
709
711
  executable = _resolve_executable(definition["argv"][0], cwd)
712
+ argv, launch_files = _native_launch(executable, definition["argv"][1:])
713
+ if (_command_name(definition["argv"][0]) in {"node", "node.exe"}
714
+ and len(definition["argv"]) > 1
715
+ and Path(definition["argv"][1]).suffix.casefold() in {".js", ".mjs", ".cjs"}):
716
+ # Match Node's lexical entrypoint normalization and the Go contract;
717
+ # retain this lookup path so linked-parent retargeting stays visible.
718
+ script = Path(os.path.normpath(cwd / definition["argv"][1]))
719
+ try:
720
+ _path_identity(script)
721
+ if not script.is_file():
722
+ raise OSError("runner entrypoint is not a file")
723
+ except OSError as exc:
724
+ raise AuthorityError("UNSUPPORTED_EXECUTABLE", "declared Node runner entrypoint is unavailable") from exc
725
+ launch_files.append(_launch_file(script, "runner-entrypoint"))
726
+ if definition["required_tests"] and _command_name(definition["argv"][0]) in {"npm", "npm.cmd"}:
727
+ _runner, manifest = _npm_runner(definition["argv"], cwd)
728
+ launch_files.append(_launch_file(manifest, "npm-manifest"))
710
729
  bindings.append({
711
730
  "definition_sha256": command["definition_sha256"],
712
- "argv": [str(executable), *definition["argv"][1:]],
731
+ "argv": argv,
732
+ "collector_profile": collector,
733
+ "launch_files": launch_files,
713
734
  "cwd": str(cwd),
714
735
  "cwd_filesystem_id": _path_identity(cwd),
715
736
  "workspace_root": str(worktree),
716
737
  "workspace_filesystem_id": _path_identity(worktree),
717
738
  "git_common_dir": str(common),
718
739
  "git_common_filesystem_id": _path_identity(common),
719
- "executable_sha256": _file_sha256(executable),
740
+ "executable_sha256": _file_sha256(Path(argv[0])),
720
741
  })
721
742
  return bindings
722
743
 
723
744
 
745
+ def _launch_file(path: Path, role: str) -> dict[str, str]:
746
+ return {"path": str(path), "sha256": _file_sha256(path),
747
+ "filesystem_id": _path_identity(path), "role": role}
748
+
749
+
750
+ def _native_launch(executable: Path, arguments: list[str]) -> tuple[list[str], list[dict[str, str]]]:
751
+ files = [_launch_file(executable, "declared-executable")]
752
+ if executable.suffix.casefold() not in {".cmd", ".bat"}:
753
+ return [str(executable), *arguments], files
754
+ if os.name != "nt" or executable.name.casefold() != "npm.cmd":
755
+ raise AuthorityError("UNSUPPORTED_EXECUTABLE", "batch executables are unsupported; declare a native executable")
756
+ # CreateProcess may send a .cmd file through cmd.exe despite shell=False.
757
+ # Support only npm's standard colocated installation; never execute the shim.
758
+ # This adapter deliberately selects the sibling CLI, not npm.cmd's optional
759
+ # global-prefix redirect. The wrapper, native runtime, and CLI are all pinned.
760
+ standard_wrapper = r''':: Created by npm, please don't edit manually.
761
+ @ECHO OFF
762
+ SETLOCAL
763
+ SET "NODE_EXE=%~dp0\node.exe"
764
+ IF NOT EXIST "%NODE_EXE%" (
765
+ SET "NODE_EXE=node"
766
+ )
767
+ SET "NPM_PREFIX_JS=%~dp0\node_modules\npm\bin\npm-prefix.js"
768
+ SET "NPM_CLI_JS=%~dp0\node_modules\npm\bin\npm-cli.js"
769
+ FOR /F "delims=" %%F IN ('CALL "%NODE_EXE%" "%NPM_PREFIX_JS%"') DO (
770
+ SET "NPM_PREFIX_NPM_CLI_JS=%%F\node_modules\npm\bin\npm-cli.js"
771
+ )
772
+ IF EXIST "%NPM_PREFIX_NPM_CLI_JS%" (
773
+ SET "NPM_CLI_JS=%NPM_PREFIX_NPM_CLI_JS%"
774
+ )
775
+ "%NODE_EXE%" "%NPM_CLI_JS%" %*'''
776
+ try:
777
+ actual = executable.read_text("utf-8")
778
+ except (OSError, UnicodeError) as exc:
779
+ raise AuthorityError("UNSUPPORTED_EXECUTABLE", "npm wrapper is unreadable") from exc
780
+ if [line for line in actual.splitlines() if line] != standard_wrapper.splitlines():
781
+ raise AuthorityError("UNSUPPORTED_EXECUTABLE", "npm wrapper is not a qualified standard npm.cmd; declare node.exe and npm-cli.js explicitly")
782
+ node = executable.parent / "node.exe"
783
+ cli = executable.parent / "node_modules" / "npm" / "bin" / "npm-cli.js"
784
+ if not node.is_file() or not cli.is_file():
785
+ raise AuthorityError("UNSUPPORTED_EXECUTABLE", "npm batch adapter requires sibling node.exe and node_modules/npm/bin/npm-cli.js")
786
+ for path, role in ((node, "node-runtime"), (cli, "npm-cli")):
787
+ files.append(_launch_file(path, role))
788
+ for directory in (cli.parent, cli.parent.parent, cli.parent.parent.parent):
789
+ _path_identity(directory)
790
+ return [str(node), str(cli), *arguments], files
791
+
792
+
724
793
  def _workspace_snapshots(bindings: list[dict[str, Any]]) -> list[dict[str, str]]:
725
794
  unique: dict[str, dict[str, str]] = {}
726
795
  for binding in bindings:
@@ -734,6 +803,10 @@ def _workspace_snapshots(bindings: list[dict[str, Any]]) -> list[dict[str, str]]
734
803
  or _file_sha256(Path(binding["argv"][0])) != binding["executable_sha256"]
735
804
  ):
736
805
  raise AuthorityError("WORKSPACE_DRIFT", "workspace or executable identity changed")
806
+ for item in binding.get("launch_files", []):
807
+ path = Path(item["path"])
808
+ if _launch_file(path, item["role"]) != item:
809
+ raise AuthorityError("WORKSPACE_DRIFT", "runner, npm wrapper, or script manifest identity changed")
737
810
  status = _git_text(root, "status", "--porcelain=v2", "-z", "--untracked-files=all")
738
811
  index = _git_text(root, "ls-files", "--stage", "-z")
739
812
  untracked = _git_text(root, "ls-files", "--others", "--exclude-standard", "-z")
@@ -958,12 +1031,14 @@ def _unittest_collector(stdout: bytes, stderr: bytes, required: list[str]) -> li
958
1031
  text = (stdout + b"\n" + stderr).decode("utf-8", "replace")
959
1032
  results = []
960
1033
  for name in required:
961
- pattern = re.compile(r"(?m)^" + re.escape(name) + r"(?:\s+\([^\r\n]*\))?\s+\.\.\.\s+ok\s*$")
1034
+ pattern = re.compile(r"(?m)^" + re.escape(name) + r"(?:[ \t]+\([^\r\n]*\))?[ \t]+\.\.\.[ \t]+([^\r\n]+)\r?$")
962
1035
  matches = pattern.findall(text)
963
1036
  if not matches:
964
1037
  raise AuthorityError("MISSING_TEST_RESULT", f"required test did not pass: {name}")
965
1038
  if len(matches) != 1:
966
1039
  raise AuthorityError("DUPLICATE_TEST_RESULT", f"required test appeared more than once: {name}")
1040
+ if matches[0].strip() != "ok":
1041
+ raise AuthorityError("MISSING_TEST_RESULT", f"required test did not pass: {name}")
967
1042
  results.append({"name": name, "status": "pass"})
968
1043
  return results
969
1044
 
@@ -973,16 +1048,16 @@ def _named_line_collector(stdout: bytes, stderr: bytes, required: list[str]) ->
973
1048
  # fall-damage harnesses emit umbrella summaries for some plan aggregates.
974
1049
  # Those summaries are not semantic evidence; only exact PASS names qualify.
975
1050
  text = (stdout + b"\n" + stderr).decode("utf-8", "replace")
976
- passed: dict[str, int] = {}
1051
+ observed: dict[str, list[bool]] = {}
977
1052
  for line in text.splitlines():
978
1053
  stripped = line.strip()
979
- if stripped.startswith("PASS: "):
980
- name = stripped[6:].rstrip(".")
981
- passed[name] = passed.get(name, 0) + 1
982
- duplicates = [name for name in required if passed.get(name, 0) > 1]
1054
+ if stripped.startswith(("PASS: ", "FAIL: ", "SKIP: ")):
1055
+ name = stripped[6:]
1056
+ observed.setdefault(name, []).append(stripped.startswith("PASS: "))
1057
+ duplicates = [name for name in required if len(observed.get(name, [])) > 1]
983
1058
  if duplicates:
984
1059
  raise AuthorityError("DUPLICATE_TEST_RESULT", "required PASS lines are duplicated: " + ", ".join(duplicates))
985
- missing = [name for name in required if passed.get(name, 0) != 1]
1060
+ missing = [name for name in required if observed.get(name) != [True]]
986
1061
  if missing:
987
1062
  raise AuthorityError(
988
1063
  "MISSING_TEST_RESULT", "required explicit PASS lines are absent: " + ", ".join(missing)
@@ -992,17 +1067,23 @@ def _named_line_collector(stdout: bytes, stderr: bytes, required: list[str]) ->
992
1067
 
993
1068
  def _vitest_verbose_collector(stdout: bytes, stderr: bytes, required: list[str]) -> list[dict[str, str]]:
994
1069
  text = (stdout + b"\n" + stderr).decode("utf-8", "replace")
995
- passed: dict[str, int] = {}
1070
+ text = re.sub(r"\x1b\[[0-9;]*m", "", text)
1071
+ observed: dict[str, list[bool]] = {}
996
1072
  for line in text.splitlines():
997
1073
  stripped = line.strip()
998
- if stripped.startswith(("✓ ", "√ ")):
999
- name = stripped[2:].rsplit(" > ", 1)[-1]
1074
+ if stripped.startswith(("✓ ", "√ ", "↓ ", "× ", "✗ ", "- ")) and " > " in stripped:
1075
+ # The full rendered identity after the filename retains every suite
1076
+ # and title segment. A short suffix cannot prove the exact test.
1077
+ name = stripped[2:].split(" > ", 1)[1]
1078
+ annotated = re.search(r" \((?:retry|repeat) x\d+\)| \d+ MB heap used| \[[^\r\n]*\]$", name)
1079
+ if annotated:
1080
+ name = name[:annotated.start()]
1000
1081
  name = re.sub(r"\s+\d+(?:\.\d+)?m?s$", "", name)
1001
- passed[name] = passed.get(name, 0) + 1
1002
- duplicates = [name for name in required if passed.get(name, 0) > 1]
1082
+ observed.setdefault(name, []).append(stripped[0] in {"✓", "√"} and not annotated)
1083
+ duplicates = [name for name in required if len(observed.get(name, [])) > 1]
1003
1084
  if duplicates:
1004
1085
  raise AuthorityError("DUPLICATE_TEST_RESULT", "required Vitest cases are duplicated: " + ", ".join(duplicates))
1005
- missing = [name for name in required if passed.get(name, 0) != 1]
1086
+ missing = [name for name in required if observed.get(name) != [True]]
1006
1087
  if missing:
1007
1088
  raise AuthorityError(
1008
1089
  "MISSING_TEST_RESULT", "required exact Vitest case names are absent: " + ", ".join(missing)
@@ -1010,6 +1091,68 @@ def _vitest_verbose_collector(stdout: bytes, stderr: bytes, required: list[str])
1010
1091
  return [{"name": name, "status": "pass"} for name in required]
1011
1092
 
1012
1093
 
1094
+ def _unique_json_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
1095
+ value: dict[str, Any] = {}
1096
+ for key, item in pairs:
1097
+ if key in value:
1098
+ raise ValueError("duplicate JSON key")
1099
+ value[key] = item
1100
+ return value
1101
+
1102
+
1103
+ def _invalid_json_constant(_value: str) -> Any:
1104
+ raise ValueError("nonfinite JSON")
1105
+
1106
+
1107
+ def _playwright_json_collector(stdout: bytes, _stderr: bytes, required: list[str]) -> list[dict[str, str]]:
1108
+ # Native JSONReporter reports spec.title separately from suites/projects and
1109
+ # preserves every retry. Never infer a case from a summary or title prefix.
1110
+ try:
1111
+ text = stdout.decode("utf-8")
1112
+ # npm run's two-line command banner precedes the native JSON report.
1113
+ text = re.sub(r"\A\s*>[^\r\n]*\r?\n>[^\r\n]*\r?\n\s*(?=\{)", "", text)
1114
+ report = json.loads(text, object_pairs_hook=_unique_json_pairs,
1115
+ parse_constant=_invalid_json_constant)
1116
+ except (ValueError, UnicodeError, RecursionError) as exc:
1117
+ raise AuthorityError("INVALID_TEST_RESULT", "Playwright requires one complete JSON reporter document") from exc
1118
+ if not isinstance(report, dict) or not isinstance(report.get("suites"), list) or report.get("errors") != []:
1119
+ raise AuthorityError("INVALID_TEST_RESULT", "Playwright report is malformed or contains global errors")
1120
+ observed: dict[str, list[dict[str, Any]]] = {}
1121
+ pending = list(report["suites"])
1122
+ while pending:
1123
+ suite = pending.pop()
1124
+ if not isinstance(suite, dict) or not isinstance(suite.get("specs"), list) or not isinstance(suite.get("suites", []), list):
1125
+ raise AuthorityError("INVALID_TEST_RESULT", "Playwright suite is malformed")
1126
+ pending.extend(suite.get("suites", []))
1127
+ for spec in suite["specs"]:
1128
+ if (not isinstance(spec, dict) or not isinstance(spec.get("title"), str)
1129
+ or not isinstance(spec.get("tests"), list) or not spec["tests"]):
1130
+ raise AuthorityError("INVALID_TEST_RESULT", "Playwright spec is malformed")
1131
+ if spec["title"] in required and spec.get("ok") is not True:
1132
+ raise AuthorityError("INVALID_TEST_RESULT", "required Playwright spec verdict is inconsistent")
1133
+ for test in spec["tests"]:
1134
+ if not isinstance(test, dict):
1135
+ raise AuthorityError("INVALID_TEST_RESULT", "Playwright test is malformed")
1136
+ observed.setdefault(spec["title"], []).append(test)
1137
+ for name in required:
1138
+ tests = observed.get(name, [])
1139
+ if not tests:
1140
+ raise AuthorityError("MISSING_TEST_RESULT", f"required exact Playwright title is absent: {name}")
1141
+ if len(tests) != 1:
1142
+ raise AuthorityError("DUPLICATE_TEST_RESULT", f"required Playwright title is ambiguous: {name}")
1143
+ test = tests[0]
1144
+ results = test.get("results")
1145
+ if (test.get("expectedStatus") != "passed" or test.get("status") != "expected"
1146
+ or not isinstance(results, list) or len(results) != 1):
1147
+ raise AuthorityError("FAILED_TEST_RESULT", f"required Playwright test has no single clean pass: {name}")
1148
+ result = results[0]
1149
+ if (not isinstance(result, dict) or result.get("status") != "passed"
1150
+ or type(result.get("retry")) is not int or result["retry"] != 0
1151
+ or result.get("errors") != [] or result.get("error") is not None):
1152
+ raise AuthorityError("FAILED_TEST_RESULT", f"required Playwright test did not pass without retry or error: {name}")
1153
+ return [{"name": name, "status": "pass"} for name in required]
1154
+
1155
+
1013
1156
  def _exit_only_collector(_stdout: bytes, _stderr: bytes, required: list[str]) -> list[dict[str, str]]:
1014
1157
  if required:
1015
1158
  raise AuthorityError("UNSUPPORTED_COLLECTOR", "exit-only profile cannot attest named tests")
@@ -1020,26 +1163,135 @@ COLLECTORS: dict[str, Callable[[bytes, bytes, list[str]], list[dict[str, str]]]]
1020
1163
  "python-unittest-text/0.1.0": _unittest_collector,
1021
1164
  "codearbiter-named-lines/0.1.0": _named_line_collector,
1022
1165
  "vitest-verbose/0.1.0": _vitest_verbose_collector,
1166
+ "playwright-json/0.1.0": _playwright_json_collector,
1023
1167
  "exit-only/0.1.0": _exit_only_collector,
1024
1168
  }
1025
1169
 
1026
1170
 
1171
+ def _command_name(value: str) -> str:
1172
+ return value.replace("\\", "/").rsplit("/", 1)[-1].casefold()
1173
+
1174
+
1175
+ def _command_tokens(command: str) -> list[str]:
1176
+ lexer = shlex.shlex(command, posix=True)
1177
+ lexer.whitespace_split = True
1178
+ lexer.commenters = ""
1179
+ lexer.escape = "" # Native Windows paths retain backslashes inside both quotes.
1180
+ return list(lexer)
1181
+
1182
+
1183
+ def _npm_runner(argv: list[str], cwd: Path | None) -> tuple[list[str], Path]:
1184
+ message = "declare a direct test runner with an explicit supported reporter; npm scripts must be a single runner command"
1185
+ if cwd is None:
1186
+ raise AuthorityError("UNSUPPORTED_COLLECTOR", "npm runner requires an inspectable package.json; " + message)
1187
+ rest = list(argv[1:])
1188
+ prefix = "."
1189
+ while rest and rest[0].startswith("-"):
1190
+ option = rest.pop(0)
1191
+ if option == "--prefix" and rest:
1192
+ prefix = rest.pop(0)
1193
+ elif option.startswith("--prefix="):
1194
+ prefix = option.split("=", 1)[1]
1195
+ elif option not in {"--silent", "-s"}:
1196
+ raise AuthorityError("UNSUPPORTED_COLLECTOR", "unsupported npm runner option; " + message)
1197
+ if len(rest) >= 2 and rest[0] in {"run", "run-script"}:
1198
+ script_name, forwarded = rest[1], rest[2:]
1199
+ elif rest and rest[0] in {"test", "t", "tst"}:
1200
+ script_name, forwarded = "test", rest[1:]
1201
+ else:
1202
+ raise AuthorityError("UNSUPPORTED_COLLECTOR", "unsupported npm runner shape; " + message)
1203
+ if forwarded:
1204
+ if forwarded[0] != "--":
1205
+ raise AuthorityError("UNSUPPORTED_COLLECTOR", "npm runner arguments must follow --; " + message)
1206
+ forwarded = forwarded[1:]
1207
+ relative = Path(prefix)
1208
+ if (not prefix or relative.anchor or PureWindowsPath(prefix).anchor
1209
+ or ".." in relative.parts or ".." in PureWindowsPath(prefix).parts):
1210
+ raise AuthorityError("UNSUPPORTED_COLLECTOR", "npm --prefix must stay within the declared cwd; " + message)
1211
+ manifest = cwd / relative / "package.json"
1212
+ try:
1213
+ directory = cwd
1214
+ for part in relative.parts:
1215
+ directory /= part
1216
+ _path_identity(directory)
1217
+ manifest.parent.resolve(strict=True).relative_to(cwd.resolve(strict=True))
1218
+ _path_identity(manifest)
1219
+ with manifest.open("rb") as stream:
1220
+ raw = stream.read(MAX_STATE + 1)
1221
+ if len(raw) > MAX_STATE:
1222
+ raise ValueError("oversized manifest")
1223
+ package = json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_json_pairs,
1224
+ parse_constant=_invalid_json_constant)
1225
+ scripts = package["scripts"]
1226
+ script = scripts[script_name]
1227
+ if not isinstance(script, str) or any(marker in script for marker in ("\n", "\r", ";", "&", "|", ">", "<", "`", "$")):
1228
+ raise ValueError("compound script")
1229
+ if scripts.get("pre" + script_name) or scripts.get("post" + script_name):
1230
+ raise ValueError("lifecycle scripts")
1231
+ tokens = _command_tokens(script)
1232
+ if not tokens or _command_name(tokens[0]) in {"npm", "npm.cmd"}:
1233
+ raise ValueError("nested npm script")
1234
+ except (OSError, ValueError, KeyError, TypeError, RecursionError, AuthorityError) as exc:
1235
+ raise AuthorityError("UNSUPPORTED_COLLECTOR", "npm package script is unavailable or compound; " + message) from exc
1236
+ return [*tokens, *forwarded], manifest
1237
+
1238
+
1239
+ def validate_command_definition(definition: dict[str, Any], *, cwd: Path | None = None) -> str:
1240
+ """Inspect declared runner grammar without executing or discovering tools."""
1241
+ argv, required = definition.get("argv"), definition.get("required_tests")
1242
+ if (not isinstance(argv, list) or not argv or any(not isinstance(item, str) or not item or "\0" in item for item in argv)
1243
+ or not isinstance(required, list) or any(not isinstance(item, str) or not item for item in required)
1244
+ or len(set(required)) != len(required)):
1245
+ raise AuthorityError("UNSUPPORTED_COLLECTOR", "runner argv and required_tests must be nonempty strings without duplicate tests")
1246
+ name = _command_name(argv[0])
1247
+ if name.endswith((".cmd", ".bat")) and name != "npm.cmd":
1248
+ raise AuthorityError("UNSUPPORTED_EXECUTABLE", "batch executables are unsupported; declare a native executable")
1249
+ if required and name in {"npm", "npm.cmd"}:
1250
+ argv, _manifest = _npm_runner(argv, cwd)
1251
+ return _collector_profile(argv, required)
1252
+
1253
+
1027
1254
  def _collector_profile(argv: list[str], required: list[str]) -> str:
1028
1255
  if not required:
1029
1256
  return "exit-only/0.1.0"
1030
- lowered = [part.casefold() for part in argv]
1031
- if len(lowered) >= 3 and lowered[1:3] == ["-m", "unittest"]:
1257
+ name = _command_name(argv[0])
1258
+ arguments = argv[1:]
1259
+ if re.fullmatch(r"(?:python(?:\d+(?:\.\d+)*)?|py)(?:\.exe)?", name) and arguments[:2] == ["-m", "unittest"]:
1260
+ if not any(item in {"-v", "--verbose"} for item in arguments[2:]):
1261
+ raise AuthorityError("UNSUPPORTED_COLLECTOR", "unittest named results require -v or --verbose")
1032
1262
  return "python-unittest-text/0.1.0"
1033
- if "vitest" in lowered and "--reporter=verbose" in lowered:
1034
- return "vitest-verbose/0.1.0"
1035
- if "tsx" in lowered or lowered[:3] == ["npm", "run", "test:client"] or lowered[:3] == ["npm", "run", "check"]:
1263
+ if name in {"node", "node.exe"} and arguments:
1264
+ script = arguments[0].replace("\\", "/")
1265
+ for suffix, runner in (("node_modules/playwright/cli.js", "playwright"),
1266
+ ("node_modules/@playwright/test/cli.js", "playwright"),
1267
+ ("node_modules/vitest/vitest.mjs", "vitest"),
1268
+ ("node_modules/tsx/dist/cli.mjs", "tsx")):
1269
+ if script == suffix or script.endswith("/" + suffix):
1270
+ name, arguments = runner, arguments[1:]
1271
+ break
1272
+ if name in {"playwright", "vitest"}:
1273
+ expected = "json" if name == "playwright" else "verbose"
1274
+ reporters = []
1275
+ for index, value in enumerate(arguments):
1276
+ if value.startswith("--reporter="):
1277
+ reporters.append(value.split("=", 1)[1])
1278
+ elif value == "--reporter":
1279
+ reporters.append(arguments[index + 1] if index + 1 < len(arguments) else "")
1280
+ mode = "test" if name == "playwright" else "run"
1281
+ if (not arguments or arguments[0] != mode or reporters != [expected]
1282
+ or "--" in arguments or "--list" in arguments
1283
+ or any(item.startswith("--outputFile") for item in arguments)):
1284
+ raise AuthorityError("UNSUPPORTED_COLLECTOR", f"declare {name} {mode} with exactly --reporter={expected} and stdout output")
1285
+ return "playwright-json/0.1.0" if name == "playwright" else "vitest-verbose/0.1.0"
1286
+ if name == "tsx" and arguments:
1036
1287
  return "codearbiter-named-lines/0.1.0"
1037
- raise AuthorityError("UNSUPPORTED_COLLECTOR", "declared runner has no qualified collector")
1288
+ raise AuthorityError("UNSUPPORTED_COLLECTOR", "declared runner has no qualified collector; declare a direct supported runner")
1038
1289
 
1039
1290
 
1040
1291
  def _minimal_environment() -> tuple[dict[str, str], str]:
1041
1292
  names = (
1042
1293
  "PATH", "PATHEXT", "SystemRoot", "WINDIR", "COMSPEC", "TEMP", "TMP",
1294
+ "ProgramFiles", "ProgramFiles(x86)", "ProgramW6432", "SystemDrive",
1043
1295
  "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "LANG", "LC_ALL",
1044
1296
  )
1045
1297
  environment = {name: os.environ[name] for name in names if name in os.environ}
@@ -1064,6 +1316,8 @@ def _run_contained(
1064
1316
  argv: list[str], *, cwd: str, env: dict[str, str], timeout_seconds: float = 1800.0
1065
1317
  ) -> subprocess.CompletedProcess:
1066
1318
  """Bound output and descendants with POSIX sessions or a Windows Job Object."""
1319
+ if argv and _command_name(argv[0]).endswith((".cmd", ".bat")):
1320
+ raise AuthorityError("UNSUPPORTED_EXECUTABLE", "batch execution requires a bound native adapter")
1067
1321
  if not isinstance(timeout_seconds, (int, float)) or timeout_seconds <= 0:
1068
1322
  raise AuthorityError("INVALID_PROCESS_TIMEOUT", "process timeout must be positive")
1069
1323
  job = None
@@ -1339,7 +1593,7 @@ def run_verification(
1339
1593
  commands = []
1340
1594
  for command, binding in zip(request["context"]["commands"], request["command_bindings"]):
1341
1595
  definition = command["definition"]
1342
- collector = COLLECTORS[_collector_profile(definition["argv"], definition["required_tests"])]
1596
+ collector = COLLECTORS[binding["collector_profile"]]
1343
1597
  cwd = _resolved_directory(binding["cwd"])
1344
1598
  try:
1345
1599
  completed = _run_contained(list(binding["argv"]), cwd=str(cwd), env=environment)
@@ -1384,7 +1638,7 @@ def run_verification(
1384
1638
  "commands": commands,
1385
1639
  }
1386
1640
  observation = _closed_observation(
1387
- request, payload, "declared-command/0.1.0", request["attempt"],
1641
+ request, payload, "declared-command/0.2.0", request["attempt"],
1388
1642
  producer_result,
1389
1643
  )
1390
1644
  _observation(root, request, observation)
@@ -1423,37 +1677,34 @@ def _verification_selector(event: dict[str, Any]) -> tuple[str, Path, str] | Non
1423
1677
  raise AuthorityError("UNSUPPORTED_HOST_SEAM", "exec command is absent")
1424
1678
  compound = any(marker in command for marker in ("\n", "\r", ";", "&", "|", ">", "<", "`", "$"))
1425
1679
  try:
1426
- tokens = [token.strip('"') for token in shlex.split(command, posix=False)]
1680
+ tokens = _command_tokens(command)
1427
1681
  except ValueError as exc:
1428
1682
  # An ordinary command with unbalanced quoting is not ours to deny.
1429
1683
  if "artifact-authority" not in command.casefold():
1430
1684
  return None
1431
1685
  raise AuthorityError("UNSUPPORTED_HOST_SEAM", "verifier wrapper command is malformed") from exc
1432
1686
  wrapper_start = (
1433
- len(tokens) >= 3
1434
- and Path(tokens[0]).name.lower() in {
1687
+ len(tokens) >= 2
1688
+ and _command_name(tokens[0]) in {
1435
1689
  "python", "python.exe", "python3", "python3.exe", "py", "py.exe",
1436
1690
  }
1437
- and Path(tokens[1]).name.lower() == "artifact-authority.py"
1438
- and tokens[2] == "verify"
1691
+ and _command_name(tokens[1]) == "artifact-authority.py"
1692
+ and (len(tokens) == 2 or tokens[2] == "verify")
1439
1693
  )
1440
1694
  if compound:
1441
1695
  if wrapper_start:
1442
1696
  raise AuthorityError("UNSUPPORTED_HOST_SEAM", "verifier wrapper command is compound")
1443
1697
  return None
1444
- if len(tokens) != 7:
1698
+ if not wrapper_start:
1445
1699
  return None
1446
1700
  if (
1447
- Path(tokens[0]).name.lower() not in {
1448
- "python", "python.exe", "python3", "python3.exe", "py", "py.exe",
1449
- }
1450
- or Path(tokens[1]).name.lower() != "artifact-authority.py"
1701
+ len(tokens) != 7
1451
1702
  or tokens[2] != "verify"
1452
1703
  or tokens[3] != "--root"
1453
1704
  or tokens[5] != "--request-id"
1454
1705
  or REQUEST_RE.fullmatch(tokens[6]) is None
1455
1706
  ):
1456
- return None
1707
+ raise AuthorityError("UNSUPPORTED_HOST_SEAM", "verifier wrapper selector is malformed")
1457
1708
  return tokens[6], _real_root(tokens[4]), tokens[1]
1458
1709
 
1459
1710
 
@@ -1506,9 +1757,6 @@ def observe_verifier_hook(
1506
1757
  current_bindings = _bind_commands(root, request["context"], request["workspace_roots"])
1507
1758
  if current_bindings != request["command_bindings"]:
1508
1759
  raise AuthorityError("WORKSPACE_DRIFT", "effective child argv or cwd changed")
1509
- for command in request["context"]["commands"]:
1510
- definition = command["definition"]
1511
- _collector_profile(definition["argv"], definition["required_tests"])
1512
1760
  workspace_before = _workspace_snapshots(current_bindings)
1513
1761
  request["wrapper"] = {
1514
1762
  "state": "AUTHORIZED", "session_id": session_id,
@@ -11,7 +11,7 @@ import hostapi # noqa: E402
11
11
  class PiHost(hostapi.Host):
12
12
  name = "pi"
13
13
  adapter_name = "@arbiterforge/ca-pi"
14
- adapter_version = "0.15.0"
14
+ adapter_version = "0.15.1"
15
15
  update_target = "ca-pi"
16
16
  update_tag_prefix = "ca-pi-v"
17
17
  update_command = "pi update npm:@arbiterforge/ca-pi"
@@ -121,6 +121,7 @@ def arm(root: str | Path, client: Any, spec_id: str, plan_id: str, *, delegate_m
121
121
  reply = f"approve-sprint {spec_id} {plan_id} {mode} {token}"
122
122
  ph = hashlib.sha256(reply.encode("utf-8")).hexdigest()
123
123
  context = client.call("sprint-approval-context", {"artifact_id": plan_id, "spec_id": spec_id, "delegate_methods": delegate_methods, "prompt_sha256": ph})
124
+ approval._preflight_plan_verification(client, context["pair"]["plan"])
124
125
  pending = {"format": FORMAT, "artifact_id": plan_id, "kind": "sprint-pair", "pair": context["pair"], "spec_context": context["spec_context"], "plan_context": context["plan_context"], "prompt_sha256": ph, "token_sha256": hashlib.sha256(token.encode("ascii")).hexdigest()}
125
126
  pending["request_key"] = _hash({"pair": pending["pair"], "prompt_sha256": ph})
126
127
  validate_pending(pending)
@@ -313,7 +313,7 @@ class Host:
313
313
 
314
314
  name = "claude"
315
315
  adapter_name = "ca"
316
- adapter_version = "2.22.0"
316
+ adapter_version = "2.22.1"
317
317
 
318
318
  # Update-notifier descriptor. Each independently versioned host overrides
319
319
  # these three values in its per-plugin _host.py. Keeping the target,
@@ -72,6 +72,30 @@ ADR compatibility review, open-question handling, harvest and user/SMARTS gates.
72
72
  One criterion may need multiple tests. Never invent a condition, oracle, source,
73
73
  command, result, approval or reviewer to satisfy a required field.
74
74
 
75
+ Before requesting review or approval of a plan, use the private
76
+ `_approvallib._preflight_plan_verification(client, identity)` with its current engine identity.
77
+ It checks all task verification definitions through complete bounded engine reads
78
+ and collects the current source-input snapshot, reporting every unsupported
79
+ command and any input-policy failure together. Both ordinary and initial paired
80
+ approval arming enforce this check. No declared command, test discovery, or test
81
+ module is executed or imported. Proposed test files and names may be absent;
82
+ the installed collector still has to support the proposed runner's output format.
83
+ Missing package-script definitions cannot establish a runner contract: declare a
84
+ supported direct runner or supply the reviewed script definition first. Runtime
85
+ executable/workspace binding and fresh named-result evidence remain required.
86
+
87
+ `verification_inputs.roots` and `exclude_directories` use exact slash-separated
88
+ repository-relative paths. Exclusions are directory paths, not globs or basename
89
+ matches and not relative to an individual root. For root `site`, a generated
90
+ dependency exclusion is `site/node_modules`, not `node_modules`. Review each
91
+ excluded output directory explicitly; `.gitignore` does not define this policy.
92
+ An exclusion outside all selected roots is rejected as ineffective. Overlapping
93
+ roots cannot reinclude an excluded directory. Roots must exist before execution;
94
+ use an existing containing directory for a planned new file so its creation
95
+ changes the snapshot. An oversized included file is reported with its path and
96
+ the 32 MiB per-file limit; revise the explicit policy rather than increasing the
97
+ limit or excluding source/test/governance inputs needed for the task.
98
+
75
99
  The existing user, SMARTS, reviewer, or verification boundary must first persist
76
100
  its actual policy-owned workflow event as canonical JSON in the reserved
77
101
  content-addressed authority-source store. Interactive prompt approval currently
@@ -230,6 +254,28 @@ workspace or executable drift. If an attempt is interrupted, use the adapter's
230
254
  `recover` operation to retain it as failed or abandoned; never rerun an uncertain
231
255
  attempt under the same request.
232
256
 
257
+ Named Playwright commands require `test --reporter=json`; each required name must
258
+ equal a unique native `spec.title`. Named Vitest commands require
259
+ `run --reporter=verbose`; use the complete rendered `suite > case` identity after
260
+ the filename, not a leaf-name alias. Named unittest commands require `-v` or
261
+ `--verbose`. A passing row cannot conceal a duplicate, skip, failure or retry.
262
+ Plain exit checks cannot attest named tests.
263
+
264
+ Named npm commands support inspectable single-runner scripts, a contained
265
+ repository-relative `--prefix`, and arguments forwarded after `--`. Compound
266
+ scripts and pre/post lifecycle scripts require separately declared direct-runner
267
+ commands; retain every check from the original suite when splitting commands.
268
+ On Windows, only the qualified standard `npm.cmd` layout is adapted: the runner
269
+ pins the wrapper, sibling `node.exe`, sibling `node_modules/npm/bin/npm-cli.js`,
270
+ and script manifest. It deliberately selects that sibling CLI rather than the
271
+ shim's optional global-prefix redirection. Unknown batch wrappers fail closed.
272
+ Direct Node script entrypoints are also pinned. These launch-file bindings do
273
+ not establish transitive dependency closure for deliberately excluded inputs.
274
+
275
+ New observations use the closed `declared-command/0.2.0` producer profile to
276
+ retain collector and launch-file identities. Historical `0.1.0` bindings remain
277
+ separate and cannot be relabelled as qualified launch evidence.
278
+
233
279
  For `spec_review` or `quality_review`, arm the corresponding activity without
234
280
  workspace mappings and dispatch the returned `launch_envelope` unchanged to one
235
281
  fresh host subagent. The host hooks bind the exact launch call, child start and
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arbiterforge/ca-pi",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "private": true,
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -118,7 +118,18 @@ returned same-slug `.codearbiter/plans/<slug>.html` target may be created. Creat
118
118
  that plan through the installed structured-artifact engine as `draft_preview`,
119
119
  using the source spec's exact artifact ID and normative digest. For ordinary sequential planning,
120
120
  this must be the approved spec's exact artifact ID and normative digest. Populate it only with typed operations
121
- and validate it at the ready gate. For initial combined review, retain `draft_preview` and return
121
+ and validate it at the ready gate. Before presenting either ordinary or combined review, call
122
+ the private `_approvallib._preflight_plan_verification(client, identity)` with the current engine identity.
123
+ It reads every task through bounded engine pages, checks each declared runner's supported
124
+ named-outcome format, snapshots current inputs, and rejects identity drift. Resolve the complete
125
+ reported set of command/input diagnostics before asking for approval. It does not run commands,
126
+ discover or import tests, or require proposed test files and test names to exist. Input roots must
127
+ already exist: choose an existing containing directory for files the plan will create. Roots and
128
+ excluded directories are exact repository-relative paths, not basenames, globs, or paths relative
129
+ to each root; for root `site`, use `site/node_modules`, `site/dist`, and other reviewed output paths.
130
+ The ordinary and paired approval adapters repeat this preflight before arming their user reply.
131
+ This admission check is not verification evidence; execution still binds actual commands and
132
+ requires current named outcomes. For initial combined review, retain `draft_preview` and return
122
133
  both ready identities to the caller: `sprint-approve` owns the binding and both approvals after one
123
134
  actual host-observed reply. Do not separately `plan-bind` or approve that pair. On ordinary approved-spec
124
135
  planning, use `plan-bind` to bind it to the approved spec before plan approval. This path must not create `.codearbiter/plans/<slug>.md`;