@miller-tech/uap 1.178.1 → 1.179.2
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/dist/.tsbuildinfo +1 -1
- package/dist/bin/cli.js +2 -2
- package/dist/bin/cli.js.map +1 -1
- package/dist/cli/plan.d.ts +49 -0
- package/dist/cli/plan.d.ts.map +1 -1
- package/dist/cli/plan.js +206 -4
- package/dist/cli/plan.js.map +1 -1
- package/package.json +2 -2
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/validate_plan_on_change.py +62 -1
- package/src/policies/schemas/policies/validate-plan-on-change.md +14 -2
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +55 -4
- package/tools/agents/tests/test_error_loop_ignores_correctives.py +91 -2
- package/tools/agents/tests/test_validate_plan_inside_project.py +106 -0
|
@@ -52,10 +52,12 @@ def _load_signature():
|
|
|
52
52
|
start = src.index("_ERROR_LINE_RE = re.compile(")
|
|
53
53
|
end = src.index("\n# ---", src.index("def _error_signature"))
|
|
54
54
|
exec(src[start:end], ns) # noqa: S102 - reading our own source, not input
|
|
55
|
-
return ns
|
|
55
|
+
return ns
|
|
56
56
|
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
_NS = _load_signature()
|
|
59
|
+
_error_signature = _NS["_error_signature"]
|
|
60
|
+
_error_signature_for_result = _NS["_error_signature_for_result"]
|
|
59
61
|
|
|
60
62
|
|
|
61
63
|
class TestHarnessCorrectivesAreNotFailures(unittest.TestCase):
|
|
@@ -164,3 +166,90 @@ class TestDeniedFailuresInPlainProse(unittest.TestCase):
|
|
|
164
166
|
"2 tests failed",
|
|
165
167
|
):
|
|
166
168
|
assert _error_signature(line) != "", line
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# The source file the model was reading when ERROR-LOOP fired on 2026-07-31.
|
|
172
|
+
# Nothing failed; this is a file's CONTENTS, delivered as a successful Read.
|
|
173
|
+
SOURCE_FILE_READ = '''def _load_state() -> dict:
|
|
174
|
+
try:
|
|
175
|
+
data = json.loads(_state_path().read_text())
|
|
176
|
+
return data if isinstance(data, dict) else {}
|
|
177
|
+
except Exception: # noqa: BLE001 - unreadable state must not break the tool call
|
|
178
|
+
return {}
|
|
179
|
+
'''
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class TestIsErrorFlagBeatsKeywords(unittest.TestCase):
|
|
183
|
+
"""A file that CONTAINS "Exception" is not a failure.
|
|
184
|
+
|
|
185
|
+
Keyword sniffing cannot tell a program reporting a failure from a file that
|
|
186
|
+
merely mentions one. Reading a file is the most common thing an agent does,
|
|
187
|
+
so any source containing `Exception`, `error` or `not found` could
|
|
188
|
+
manufacture a streak — and did: three reads of one file produced three
|
|
189
|
+
identical signatures, and ERROR-LOOP told the model to re-read the file it
|
|
190
|
+
had just read.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
def test_a_successful_read_never_produces_a_signature(self):
|
|
194
|
+
self.assertEqual(_error_signature_for_result(SOURCE_FILE_READ, False), "")
|
|
195
|
+
|
|
196
|
+
def test_three_successful_reads_cannot_arm_the_guard(self):
|
|
197
|
+
# Three was the threshold, and re-reading a file is not a failure streak.
|
|
198
|
+
sigs = {_error_signature_for_result(SOURCE_FILE_READ, False) for _ in range(3)}
|
|
199
|
+
self.assertEqual(sigs, {""})
|
|
200
|
+
|
|
201
|
+
def test_is_error_false_wins_over_a_real_looking_traceback(self):
|
|
202
|
+
# `cat` of a log file full of tracebacks is still a successful read.
|
|
203
|
+
text = "Traceback (most recent call last):\nTypeError: x is not a function"
|
|
204
|
+
self.assertEqual(_error_signature_for_result(text, False), "")
|
|
205
|
+
|
|
206
|
+
def test_is_error_true_still_produces_a_signature(self):
|
|
207
|
+
text = "ERROR: TypeError: x is not a function at /a/b.js:12"
|
|
208
|
+
self.assertNotEqual(_error_signature_for_result(text, True), "")
|
|
209
|
+
|
|
210
|
+
def test_is_error_true_with_an_unfamiliar_shape_still_forms_a_streak(self):
|
|
211
|
+
# The client declared a failure; losing it because no keyword matched
|
|
212
|
+
# would be the opposite mistake — a real repeated failure going untracked.
|
|
213
|
+
text = "the frobnicator declined\nmore detail here"
|
|
214
|
+
sig = _error_signature_for_result(text, True)
|
|
215
|
+
self.assertNotEqual(sig, "")
|
|
216
|
+
self.assertEqual(sig, _error_signature_for_result(text, True)) # stable
|
|
217
|
+
|
|
218
|
+
def test_without_the_flag_the_old_heuristics_still_apply(self):
|
|
219
|
+
# Clients that never send is_error must keep working exactly as before.
|
|
220
|
+
self.assertNotEqual(_error_signature_for_result("ERROR: SyntaxError: bad", None), "")
|
|
221
|
+
self.assertEqual(_error_signature_for_result("all tests passed", None), "")
|
|
222
|
+
self.assertEqual(
|
|
223
|
+
_error_signature_for_result(SOURCE_FILE_READ, None), _error_signature(SOURCE_FILE_READ)
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def test_an_empty_declared_error_does_not_crash(self):
|
|
227
|
+
self.assertEqual(_error_signature_for_result("", True), "")
|
|
228
|
+
self.assertEqual(_error_signature_for_result(" \n\n ", True), "")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class TestTheFlagIsActuallyWiredThrough(unittest.TestCase):
|
|
232
|
+
"""The logic above is worthless if the caller never passes the flag.
|
|
233
|
+
|
|
234
|
+
That was the whole bug: the request handler computed `_latest_err` from the
|
|
235
|
+
tool_result blocks and then called `note_tool_result_error(_latest_tr)`,
|
|
236
|
+
dropping it. Every unit test passed. Reverting the call site to the one-arg
|
|
237
|
+
form — i.e. restoring the live bug exactly — still passes the entire suite,
|
|
238
|
+
which is why this asserts on the source.
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
def test_the_request_handler_passes_the_is_error_flag(self):
|
|
242
|
+
src = PROXY.read_text()
|
|
243
|
+
assert "_latest_err" in src, "the handler no longer computes the is_error flag"
|
|
244
|
+
assert re.search(r"note_tool_result_error\(\s*_latest_tr\s*,\s*_latest_err\s*\)", src), (
|
|
245
|
+
"note_tool_result_error is called without the is_error flag — reading a file that "
|
|
246
|
+
"contains the word 'Exception' will manufacture a failure streak again"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def test_the_signature_helper_is_the_one_being_used(self):
|
|
250
|
+
# A refactor that quietly points the monitor back at _error_signature
|
|
251
|
+
# would reinstate the keyword-only behaviour.
|
|
252
|
+
src = PROXY.read_text()
|
|
253
|
+
assert re.search(
|
|
254
|
+
r"sig = _error_signature_for_result\(latest_result_text or \"\", result_error\)", src
|
|
255
|
+
), "note_tool_result_error no longer routes through _error_signature_for_result"
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""validate-plan-on-change: only track what `uap plan validate` can validate.
|
|
2
|
+
|
|
3
|
+
The enforcer recorded ANY plan-named file it saw written, including paths
|
|
4
|
+
outside the project. `uap plan validate` refuses those ("explicit plan file must
|
|
5
|
+
live under the project directory"), so the entry could never be cleared: every
|
|
6
|
+
build in the repo blocked, and the remedy the refusal named declined the file.
|
|
7
|
+
Observed live with a memory note at
|
|
8
|
+
~/.claude/projects/<slug>/memory/plan_gate_before_build.md — not a plan at all,
|
|
9
|
+
matched only because its filename contains "plan".
|
|
10
|
+
|
|
11
|
+
The rename case is the one to guard hardest. `mv PLAN.md PLAN2.md` is not an
|
|
12
|
+
edit op, so no new pending entry is recorded; auto-forgiving the old key on the
|
|
13
|
+
build path would make a rename a silent, unattended gate bypass with the plan
|
|
14
|
+
content fully intact. An earlier draft of this change did exactly that.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
import unittest
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
ENFORCER = Path(__file__).resolve().parents[3] / "src" / "policies" / "enforcers" / "validate_plan_on_change.py"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run(cwd: str, op: str, args: dict) -> str:
|
|
30
|
+
proc = subprocess.run(
|
|
31
|
+
[sys.executable, str(ENFORCER), "--operation", op, "--args", json.dumps(args)],
|
|
32
|
+
capture_output=True,
|
|
33
|
+
text=True,
|
|
34
|
+
cwd=cwd,
|
|
35
|
+
)
|
|
36
|
+
return proc.stdout + proc.stderr
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def state(cwd: str) -> dict:
|
|
40
|
+
path = Path(cwd) / ".uap" / "plan_state.json"
|
|
41
|
+
return json.loads(path.read_text()) if path.exists() else {}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TestPlanGateTracksOnlyProjectFiles(unittest.TestCase):
|
|
45
|
+
def setUp(self) -> None:
|
|
46
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
47
|
+
self.cwd = self._tmp.name
|
|
48
|
+
os.makedirs(os.path.join(self.cwd, "docs", "plans"), exist_ok=True)
|
|
49
|
+
|
|
50
|
+
def tearDown(self) -> None:
|
|
51
|
+
self._tmp.cleanup()
|
|
52
|
+
|
|
53
|
+
def test_outside_project_plan_is_not_tracked(self) -> None:
|
|
54
|
+
out = run(self.cwd, "Write", {"file_path": "/tmp/elsewhere/stray-plan.md"})
|
|
55
|
+
self.assertIn("outside the project", out)
|
|
56
|
+
self.assertEqual(state(self.cwd).get("pending", {}), {})
|
|
57
|
+
|
|
58
|
+
def test_in_project_plan_is_still_tracked(self) -> None:
|
|
59
|
+
Path(self.cwd, "docs", "plans", "real-plan.md").write_text("# real")
|
|
60
|
+
run(self.cwd, "Write", {"file_path": "docs/plans/real-plan.md"})
|
|
61
|
+
self.assertIn("docs/plans/real-plan.md", state(self.cwd).get("pending", {}))
|
|
62
|
+
|
|
63
|
+
def test_build_is_blocked_and_names_the_recovery_command(self) -> None:
|
|
64
|
+
Path(self.cwd, "docs", "plans", "real-plan.md").write_text("# real")
|
|
65
|
+
run(self.cwd, "Write", {"file_path": "docs/plans/real-plan.md"})
|
|
66
|
+
out = run(self.cwd, "Bash", {"command": "npm run build"})
|
|
67
|
+
self.assertIn("never validated", out)
|
|
68
|
+
# A wedged agent must not be sent to the one command that declines the file.
|
|
69
|
+
self.assertIn("uap plan clear", out)
|
|
70
|
+
|
|
71
|
+
def test_renaming_a_pending_plan_does_not_forgive_it(self) -> None:
|
|
72
|
+
plan = Path(self.cwd, "docs", "plans", "real-plan.md")
|
|
73
|
+
plan.write_text("# real")
|
|
74
|
+
run(self.cwd, "Write", {"file_path": "docs/plans/real-plan.md"})
|
|
75
|
+
plan.rename(Path(self.cwd, "docs", "plans", "real-plan-v2.md"))
|
|
76
|
+
|
|
77
|
+
out = run(self.cwd, "Bash", {"command": "npm run build"})
|
|
78
|
+
self.assertIn("never validated", out)
|
|
79
|
+
self.assertIn("docs/plans/real-plan.md", state(self.cwd).get("pending", {}))
|
|
80
|
+
|
|
81
|
+
def test_legacy_outside_entry_is_pruned_and_audited(self) -> None:
|
|
82
|
+
Path(self.cwd, "docs", "plans", "real-plan.md").write_text("# real")
|
|
83
|
+
run(self.cwd, "Write", {"file_path": "docs/plans/real-plan.md"})
|
|
84
|
+
st = state(self.cwd)
|
|
85
|
+
st.setdefault("pending", {})["/home/somewhere/legacy-plan.md"] = 1
|
|
86
|
+
Path(self.cwd, ".uap", "plan_state.json").write_text(json.dumps(st))
|
|
87
|
+
|
|
88
|
+
run(self.cwd, "Bash", {"command": "npm run build"})
|
|
89
|
+
st = state(self.cwd)
|
|
90
|
+
self.assertNotIn("/home/somewhere/legacy-plan.md", st.get("pending", {}))
|
|
91
|
+
# The in-project plan still gates the build.
|
|
92
|
+
self.assertIn("docs/plans/real-plan.md", st.get("pending", {}))
|
|
93
|
+
# A shrinking blocking set must leave a trail.
|
|
94
|
+
cleared = st.get("cleared", [])
|
|
95
|
+
self.assertEqual(len(cleared), 1)
|
|
96
|
+
self.assertEqual(cleared[0]["key"], "/home/somewhere/legacy-plan.md")
|
|
97
|
+
self.assertIn("outside the project", cleared[0]["reason"])
|
|
98
|
+
|
|
99
|
+
def test_non_plan_writes_are_untouched(self) -> None:
|
|
100
|
+
out = run(self.cwd, "Write", {"file_path": "src/index.ts"})
|
|
101
|
+
self.assertIn("not a plan artifact", out)
|
|
102
|
+
self.assertEqual(state(self.cwd).get("pending", {}), {})
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
unittest.main()
|