@arbiterforge/ca-pi 0.14.17 → 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 +1 -1
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +25 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-amd64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-arm64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-amd64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-arm64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-amd64.exe +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-arm64.exe +0 -0
- package/plugins/ca-pi/helpers/artifacts/release.json +6 -6
- package/plugins/ca-pi/hooks/_approvallib.py +134 -9
- package/plugins/ca-pi/hooks/_artifactauthoritylib.py +286 -38
- package/plugins/ca-pi/hooks/_artifactlib.py +1 -1
- package/plugins/ca-pi/hooks/_artifactpromptlib.py +4 -0
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/_prerequisitelib.py +15 -6
- package/plugins/ca-pi/hooks/_reconciliationlib.py +15 -4
- package/plugins/ca-pi/hooks/_replylib.py +470 -0
- package/plugins/ca-pi/hooks/_sprintapprovallib.py +12 -6
- package/plugins/ca-pi/hooks/artifact-authority-hook.py +39 -0
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- package/plugins/ca-pi/includes/artifacts.md +63 -7
- package/plugins/ca-pi/includes/sprint-authority.md +4 -2
- package/plugins/ca-pi/package.json +1 -1
- package/plugins/ca-pi/routines/writing-plans/SKILL.md +12 -1
|
@@ -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":
|
|
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(
|
|
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"(
|
|
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
|
-
|
|
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:]
|
|
981
|
-
|
|
982
|
-
duplicates = [name for name in required if
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1002
|
-
duplicates = [name for name in required if
|
|
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
|
|
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
|
-
|
|
1031
|
-
|
|
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
|
|
1034
|
-
|
|
1035
|
-
|
|
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[
|
|
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.
|
|
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 =
|
|
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) >=
|
|
1434
|
-
and
|
|
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
|
|
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
|
|
1698
|
+
if not wrapper_start:
|
|
1445
1699
|
return None
|
|
1446
1700
|
if (
|
|
1447
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -304,7 +304,7 @@ def _installed_workflow_preflight(installation: Path) -> dict[str, object]:
|
|
|
304
304
|
return result
|
|
305
305
|
|
|
306
306
|
for filename in ("_approvallib.py", "_prerequisitelib.py", "_sprintapprovallib.py",
|
|
307
|
-
"_reconciliationlib.py", "_artifactauthoritylib.py", "_gitexec.py",
|
|
307
|
+
"_reconciliationlib.py", "_artifactauthoritylib.py", "_gitexec.py", "_replylib.py",
|
|
308
308
|
"artifact-authority.py", "artifact-authority-hook.py", "prompt-submit.py"):
|
|
309
309
|
resource("hooks/" + filename)
|
|
310
310
|
if host == "claude":
|
|
@@ -11,6 +11,8 @@ from pathlib import Path
|
|
|
11
11
|
import stat
|
|
12
12
|
import tempfile
|
|
13
13
|
|
|
14
|
+
import _replylib
|
|
15
|
+
|
|
14
16
|
|
|
15
17
|
REGISTRY_PARENT = Path(tempfile.gettempdir())
|
|
16
18
|
FORMAT = "codearbiter.artifact-prompt-route/0.1.0"
|
|
@@ -147,6 +149,8 @@ def unregister(root: str | Path, route: str, artifact_id: str) -> None:
|
|
|
147
149
|
path.unlink()
|
|
148
150
|
except FileNotFoundError:
|
|
149
151
|
pass
|
|
152
|
+
# A request's short reply code dies with its route.
|
|
153
|
+
_replylib.retire_code(root, route, artifact_id)
|
|
150
154
|
|
|
151
155
|
|
|
152
156
|
def binding(root: str | Path, route: str, artifact_id: str) -> str | None:
|
|
@@ -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.
|
|
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"
|
|
@@ -40,6 +40,7 @@ from typing import Any
|
|
|
40
40
|
import _artifactlib
|
|
41
41
|
import _artifactauthoritylib
|
|
42
42
|
import _artifactpromptlib
|
|
43
|
+
import _replylib
|
|
43
44
|
from _hooklib import acquire_lock, release_lock
|
|
44
45
|
|
|
45
46
|
|
|
@@ -619,6 +620,9 @@ def arm_user_prerequisite(
|
|
|
619
620
|
"requirement": record["requirement"],
|
|
620
621
|
"review_packet_sha256": packet_sha256,
|
|
621
622
|
"reply": reply,
|
|
623
|
+
**_replylib.offer_code(root, "prerequisite", artifact_id, reply,
|
|
624
|
+
names=[artifact_id, prerequisite_id],
|
|
625
|
+
short_prefix=["satisfy-prerequisite"]),
|
|
622
626
|
"pending": relative.as_posix(),
|
|
623
627
|
}
|
|
624
628
|
|
|
@@ -914,14 +918,19 @@ def consume_from_hook(
|
|
|
914
918
|
host: str,
|
|
915
919
|
session_id: str,
|
|
916
920
|
) -> str:
|
|
917
|
-
|
|
921
|
+
raw = prompt
|
|
922
|
+
prepared = _replylib.prepare(raw, "prerequisite")
|
|
923
|
+
prompt = prepared["text"]
|
|
924
|
+
attempted = prepared["attempted"]
|
|
925
|
+
if prepared["notice"]:
|
|
926
|
+
_replylib.record_near_miss(root, raw, "code")
|
|
927
|
+
return prepared["notice"]
|
|
918
928
|
match = _match_prompt(prompt)
|
|
919
929
|
if match is None:
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
)
|
|
930
|
+
if attempted:
|
|
931
|
+
_replylib.record_near_miss(root, raw, "syntax")
|
|
932
|
+
return _replylib.miss_notice("satisfy-prerequisite K7MQ")
|
|
933
|
+
return ""
|
|
925
934
|
try:
|
|
926
935
|
routed = _artifactpromptlib.resolve("prerequisite", prompt)
|
|
927
936
|
root = routed or Path(root)
|
|
@@ -18,6 +18,7 @@ import _approvallib
|
|
|
18
18
|
import _artifactauthoritylib
|
|
19
19
|
import _artifactlib
|
|
20
20
|
import _artifactpromptlib
|
|
21
|
+
import _replylib
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
ID_RE = re.compile(r"[A-Z][A-Z0-9_-]{0,127}")
|
|
@@ -257,7 +258,11 @@ def _arm_reconciliation_unlocked(
|
|
|
257
258
|
except OSError:
|
|
258
259
|
pass
|
|
259
260
|
raise
|
|
260
|
-
|
|
261
|
+
code = _replylib.offer_code(
|
|
262
|
+
root, "reconciliation", artifact_id, reply, names=[artifact_id, target_id],
|
|
263
|
+
short_prefix=["reconcile", target_state] if operation == "task-reconcile" else ["reconcile"],
|
|
264
|
+
)
|
|
265
|
+
return {"artifact_id": artifact_id, "target_id": target_id, "operation": operation, "reply": reply, **code}
|
|
261
266
|
|
|
262
267
|
|
|
263
268
|
def _load(root: Path, artifact_id: str) -> dict[str, Any]:
|
|
@@ -458,13 +463,19 @@ def _consume_reconciliation_unlocked(
|
|
|
458
463
|
|
|
459
464
|
|
|
460
465
|
def consume_from_hook(*, root: str | Path, plugin_root: str | Path, prompt: str, host: str, session_id: str) -> str:
|
|
461
|
-
|
|
462
|
-
|
|
466
|
+
raw = prompt
|
|
467
|
+
prepared = _replylib.prepare(raw, "reconciliation")
|
|
468
|
+
prompt = prepared["text"]
|
|
469
|
+
if not prepared["attempted"]:
|
|
463
470
|
return ""
|
|
471
|
+
if prepared["notice"]:
|
|
472
|
+
_replylib.record_near_miss(root, raw, "code")
|
|
473
|
+
return prepared["notice"]
|
|
464
474
|
try:
|
|
465
475
|
routed = _artifactpromptlib.resolve("reconciliation", prompt)
|
|
466
476
|
if routed is None:
|
|
467
|
-
|
|
477
|
+
_replylib.record_near_miss(root, raw, "no-route")
|
|
478
|
+
raise ReconciliationError("no matching armed reconciliation; send only the reply line")
|
|
468
479
|
client = _artifactlib.ArtifactClient(routed, Path(plugin_root) / "helpers" / "artifacts")
|
|
469
480
|
result = consume_reconciliation(routed, client, prompt, host=host, session_id=session_id)
|
|
470
481
|
except (ReconciliationError, _artifactlib.ArtifactError, _artifactpromptlib.PromptRouteError, OSError) as exc:
|