@miller-tech/uap 1.189.3 → 1.190.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "1.189.3",
3
+ "version": "1.190.0",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -21,7 +21,7 @@
21
21
  "start": "node dist/bin/cli.js",
22
22
  "test": "vitest",
23
23
  "test:ci": "vitest run",
24
- "test:enforcers": "UAP_PROXY_ENV_AUTOLOAD=0 python3 -m unittest tools.agents.tests.test_enforcement_self_protect tools.agents.tests.test_schema_diff_gate tools.agents.tests.test_stream_telemetry tools.agents.tests.test_project_telemetry_events tools.agents.tests.test_workdir_scope_enforcer tools.agents.tests.test_gate_evidence tools.agents.tests.test_repeat_call_guard tools.agents.tests.test_gate_integrity tools.agents.tests.test_gate_failclosed_indirection tools.agents.tests.test_expert_review_pr_scope tools.agents.tests.test_path_containment tools.agents.tests.test_path_normalizer_hardened tools.agents.tests.test_sandbox_tool_strip tools.agents.tests.test_proxy_env_loader tools.agents.tests.test_stream_required_tool tools.agents.tests.test_tool_call_wrapper_profiles tools.agents.tests.test_tool_convert_cache tools.agents.tests.test_doubling_break tools.agents.tests.test_error_loop_ignores_correctives tools.agents.tests.test_attractor_detection tools.agents.tests.test_client_disconnect tools.agents.tests.test_confidence_escalation tools.agents.tests.test_coordination_ban tools.agents.tests.test_coordination_early_ban tools.agents.tests.test_cycle_break_exploration tools.agents.tests.test_deferral_break tools.agents.tests.test_deliver_autoroute tools.agents.tests.test_delivery_enforcement_all_langs tools.agents.tests.test_delivery_enforcement_exemptions tools.agents.tests.test_delivery_enforcement_filepath tools.agents.tests.test_delivery_enforcement_web_and_bash tools.agents.tests.test_disconnect_watcher tools.agents.tests.test_empty_maxtokens_recovery tools.agents.tests.test_empty_tool_loop_break tools.agents.tests.test_enforcer_escape_hatches tools.agents.tests.test_error_loop_break tools.agents.tests.test_finalize_suppression tools.agents.tests.test_malformed_unclosed_think tools.agents.tests.test_mandate_beats_recon tools.agents.tests.test_mandate_deliver tools.agents.tests.test_overflow_truncate_count_tokens tools.agents.tests.test_passthrough_oauth tools.agents.tests.test_project_telemetry tools.agents.tests.test_proxy_auth_headers tools.agents.tests.test_prune_preserve_force_write tools.agents.tests.test_recon_deliver_gate tools.agents.tests.test_session_admission tools.agents.tests.test_stream_heartbeat tools.agents.tests.test_stuck_break_reattach tools.agents.tests.test_turn_count_breaker_periodic tools.agents.tests.test_upstream_chokepoint tools.agents.tests.test_vision_passthrough tools.agents.tests.test_worktree_required tools.agents.tests.test_enforcer_suite_coverage tools.agents.tests.test_validate_plan_gate tools.agents.tests.test_validate_plan_inside_project tools.agents.tests.test_anthropic_proxy_streaming tools.agents.tests.test_delivery_enforcement_worktree tools.agents.tests.test_output_token_ceilings tools.agents.tests.test_tool_narrowing_core tools.agents.tests.test_models_context_window",
24
+ "test:enforcers": "UAP_PROXY_ENV_AUTOLOAD=0 python3 -m unittest tools.agents.tests.test_enforcement_self_protect tools.agents.tests.test_schema_diff_gate tools.agents.tests.test_stream_telemetry tools.agents.tests.test_project_telemetry_events tools.agents.tests.test_workdir_scope_enforcer tools.agents.tests.test_gate_evidence tools.agents.tests.test_repeat_call_guard tools.agents.tests.test_gate_integrity tools.agents.tests.test_gate_failclosed_indirection tools.agents.tests.test_expert_review_pr_scope tools.agents.tests.test_path_containment tools.agents.tests.test_path_normalizer_hardened tools.agents.tests.test_sandbox_tool_strip tools.agents.tests.test_proxy_env_loader tools.agents.tests.test_stream_required_tool tools.agents.tests.test_tool_call_wrapper_profiles tools.agents.tests.test_tool_convert_cache tools.agents.tests.test_doubling_break tools.agents.tests.test_error_loop_ignores_correctives tools.agents.tests.test_attractor_detection tools.agents.tests.test_client_disconnect tools.agents.tests.test_confidence_escalation tools.agents.tests.test_coordination_ban tools.agents.tests.test_coordination_early_ban tools.agents.tests.test_cycle_break_exploration tools.agents.tests.test_deferral_break tools.agents.tests.test_deliver_autoroute tools.agents.tests.test_delivery_enforcement_all_langs tools.agents.tests.test_delivery_enforcement_exemptions tools.agents.tests.test_delivery_enforcement_filepath tools.agents.tests.test_delivery_enforcement_write_complexity tools.agents.tests.test_delivery_enforcement_web_and_bash tools.agents.tests.test_disconnect_watcher tools.agents.tests.test_empty_maxtokens_recovery tools.agents.tests.test_empty_tool_loop_break tools.agents.tests.test_enforcer_escape_hatches tools.agents.tests.test_error_loop_break tools.agents.tests.test_finalize_suppression tools.agents.tests.test_malformed_unclosed_think tools.agents.tests.test_mandate_beats_recon tools.agents.tests.test_mandate_deliver tools.agents.tests.test_overflow_truncate_count_tokens tools.agents.tests.test_passthrough_oauth tools.agents.tests.test_project_telemetry tools.agents.tests.test_proxy_auth_headers tools.agents.tests.test_prune_preserve_force_write tools.agents.tests.test_recon_deliver_gate tools.agents.tests.test_session_admission tools.agents.tests.test_stream_heartbeat tools.agents.tests.test_stuck_break_reattach tools.agents.tests.test_turn_count_breaker_periodic tools.agents.tests.test_upstream_chokepoint tools.agents.tests.test_vision_passthrough tools.agents.tests.test_worktree_required tools.agents.tests.test_enforcer_suite_coverage tools.agents.tests.test_validate_plan_gate tools.agents.tests.test_validate_plan_inside_project tools.agents.tests.test_anthropic_proxy_streaming tools.agents.tests.test_delivery_enforcement_worktree tools.agents.tests.test_output_token_ceilings tools.agents.tests.test_tool_narrowing_core tools.agents.tests.test_models_context_window",
25
25
  "test:coverage": "vitest --coverage",
26
26
  "bench": "vitest --config vitest.bench.config.ts",
27
27
  "lint": "eslint src --ext .ts",
@@ -167,6 +167,57 @@ def _changed_chars(args: dict) -> int | None:
167
167
  return None # Write (full content) — not a trivial edit
168
168
 
169
169
 
170
+ def _write_cost(args: dict, root: Path) -> int | None:
171
+ """Changed characters for a whole-file Write, or None when it is not small.
172
+
173
+ A Write used to be judged only by being a Write: a 120-character new file
174
+ and a 9000-character rewrite were the same answer. That is nature without
175
+ complexity, and it is why creating one small module cost a full
176
+ decompose -> epics -> gates cycle (~10 min on a local executor) while an
177
+ equivalent Edit went straight through.
178
+
179
+ Two things make a Write cheap, and BOTH are required:
180
+
181
+ - the content is small — same budget as an edit, so there is one number
182
+ to reason about and one env var to tune; and
183
+ - it does not REPLACE substantial existing content. Creating a file, or
184
+ growing a small one, risks little. Overwriting 8000 characters with 120
185
+ is the gutting signature, and the size of what is being destroyed is
186
+ the whole point — measuring only the new content would call the most
187
+ destructive write the cheapest.
188
+
189
+ None means "not trivially small", which is what every caller already treats
190
+ as "route it through deliver".
191
+ """
192
+ content = args.get("content")
193
+ if content is None:
194
+ content = args.get("new_string") or args.get("newString")
195
+ if content is None:
196
+ return None
197
+ new_len = len(str(content))
198
+ # No size check here on purpose: the caller already compares the returned
199
+ # cost against the same budget, and applying it twice was provably dead —
200
+ # a mutant deleting this check could not be distinguished by any test,
201
+ # because both paths refuse an oversized write. One place to change the
202
+ # threshold is worth more than an early return that saves one stat().
203
+ try:
204
+ prev_len = (root / _rel_of(args)).stat().st_size
205
+ except (OSError, ValueError, TypeError):
206
+ prev_len = 0 # new file: nothing to destroy
207
+ # Shrinking a substantial file is gutting, however small the new content.
208
+ # Thresholds match the executor's own anti-gutting predicate so the two
209
+ # cannot disagree about the same write.
210
+ if prev_len >= 1500 and new_len < prev_len * 0.35:
211
+ return None
212
+ return new_len
213
+
214
+
215
+ def _rel_of(args: dict) -> str:
216
+ """The write target as given, for a best-effort size lookup."""
217
+ p = args.get("file_path") or args.get("filePath") or args.get("path") or ""
218
+ return str(p)
219
+
220
+
170
221
  def _deliver_lock_holder(root: Path) -> str | None:
171
222
  """PID string of a LIVE deliver run holding the project lock, else None."""
172
223
  lock = root / ".uap" / "deliver.lock"
@@ -512,6 +563,10 @@ def main() -> None:
512
563
  # than paying a full deliver cycle for a one-liner.
513
564
  if _fastpath_on():
514
565
  changed = _changed_chars(args)
566
+ if changed is None:
567
+ # Not an Edit — judge the Write on the same two axes rather than
568
+ # refusing it for being a Write.
569
+ changed = _write_cost(args, root)
515
570
  if changed is not None and changed <= _trivial_edit_chars():
516
571
  print(
517
572
  f"[delivery-enforcement] trivial edit to '{rel_posix}' "
@@ -24,14 +24,23 @@ def run(args, env=None):
24
24
  return p.returncode, p.stdout
25
25
 
26
26
 
27
+ # Content big enough to be past the trivial-write budget. These two tests are
28
+ # about the KEY being recognised, and a tiny sample can no longer show that: a
29
+ # small new file is now allowed on its own merits, so "allowed" would no longer
30
+ # distinguish "the key was not recognised" from "the write was trivial". The
31
+ # sample has to be substantial for the assertion to mean what the test name
32
+ # says. The assertions themselves are unchanged.
33
+ SUBSTANTIAL = "export const x = 1;\n" * 40
34
+
35
+
27
36
  class FilePathKeyTest(unittest.TestCase):
28
37
  def test_opencode_filePath_is_gated(self):
29
- code, out = run({"filePath": "src/newfeature.ts", "content": "export const x=1;"})
38
+ code, out = run({"filePath": "src/newfeature.ts", "content": SUBSTANTIAL})
30
39
  self.assertEqual(code, 2, out)
31
40
  self.assertIn("route", out)
32
41
 
33
42
  def test_claude_file_path_still_gated(self):
34
- code, _ = run({"file_path": "src/newfeature.ts", "content": "x"})
43
+ code, _ = run({"file_path": "src/newfeature.ts", "content": SUBSTANTIAL})
35
44
  self.assertEqual(code, 2)
36
45
 
37
46
  def test_test_file_allowed(self):
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env python3
2
+ """A Write should be judged on nature AND complexity, not on being a Write.
3
+
4
+ Before this, an Edit was judged by how much it changed and a Write was judged
5
+ only by being a Write: a 120-character new module and a 9000-character rewrite
6
+ got the same answer. Creating one small file therefore cost a full
7
+ decompose -> epics -> gates cycle — roughly ten minutes on a local executor —
8
+ while an equivalent Edit went straight through. That asymmetry is what made
9
+ "route everything through deliver" feel arbitrary to the caller, and a caller
10
+ that reads a gate as arbitrary goes looking for ways around it.
11
+
12
+ Two things make a Write cheap, and BOTH are required: the content is small, and
13
+ it does not replace substantial existing content. The second is the load-bearing
14
+ half — measuring only the new content would call the most destructive write
15
+ (120 characters over 8000) the cheapest one.
16
+ """
17
+ import json, os, subprocess, sys, tempfile, unittest
18
+ from pathlib import Path
19
+
20
+ ENF = Path(__file__).resolve().parents[3] / "src" / "policies" / "enforcers" / "delivery_enforcement.py"
21
+ BUDGET = 240 # UAP_DELIVER_TRIVIAL_EDIT_CHARS default
22
+
23
+
24
+ def project() -> str:
25
+ root = tempfile.mkdtemp(prefix="uap-writecx-")
26
+ for d in ("src", "docs", "test"):
27
+ os.makedirs(os.path.join(root, d), exist_ok=True)
28
+ Path(root, ".uap.json").write_text("{}")
29
+ os.makedirs(os.path.join(root, ".git"), exist_ok=True)
30
+ return root
31
+
32
+
33
+ def run(root: str, args: dict, op: str = "Write") -> tuple[int, str]:
34
+ e = {**os.environ, "UAP_ENFORCE_DELIVERY": "block", "UAP_REPO_ROOT": root}
35
+ # Same strips as the sibling tests: a local-model session downgrades block
36
+ # to advisory, which would make every block-expecting case here allow.
37
+ for k in ("UAP_DELIVER_ACTIVE", "UAP_DELIVER_BYPASS", "UAP_DELIVER_LOCAL_MODE",
38
+ "UAP_DELIVER_LOCAL_ADVISORY", "UAP_FASTPATH_ROUTED",
39
+ "ANTHROPIC_BASE_URL", "OPENAI_BASE_URL", "UAP_INFERENCE_ENDPOINT"):
40
+ e.pop(k, None)
41
+ p = subprocess.run([sys.executable, str(ENF), "--operation", op, "--args", json.dumps(args)],
42
+ capture_output=True, text=True, env=e, cwd=root)
43
+ return p.returncode, p.stdout
44
+
45
+
46
+ class WriteComplexityTest(unittest.TestCase):
47
+ def test_small_new_file_is_allowed(self):
48
+ root = project()
49
+ code, out = run(root, {"file_path": os.path.join(root, "src", "tiny.ts"),
50
+ "content": "x" * 120})
51
+ self.assertEqual(code, 0, out)
52
+
53
+ def test_write_at_the_budget_is_allowed(self):
54
+ root = project()
55
+ code, out = run(root, {"file_path": os.path.join(root, "src", "edge.ts"),
56
+ "content": "x" * BUDGET})
57
+ self.assertEqual(code, 0, out)
58
+
59
+ def test_write_just_over_the_budget_is_gated(self):
60
+ root = project()
61
+ code, _ = run(root, {"file_path": os.path.join(root, "src", "edge2.ts"),
62
+ "content": "x" * (BUDGET + 1)})
63
+ self.assertEqual(code, 2)
64
+
65
+ def test_large_new_file_is_gated(self):
66
+ root = project()
67
+ code, _ = run(root, {"file_path": os.path.join(root, "src", "huge.ts"),
68
+ "content": "x" * 9000})
69
+ self.assertEqual(code, 2)
70
+
71
+ def test_growing_a_small_file_is_allowed(self):
72
+ # Not a new file, but nothing substantial is destroyed.
73
+ root = project()
74
+ target = os.path.join(root, "src", "small.ts")
75
+ Path(target).write_text("x" * 100)
76
+ code, out = run(root, {"file_path": target, "content": "x" * 200})
77
+ self.assertEqual(code, 0, out)
78
+
79
+ def test_small_write_over_a_substantial_file_is_gated(self):
80
+ # The load-bearing case: judged by what is DESTROYED, not by how little
81
+ # arrives. Measuring only the new content would call this the cheapest
82
+ # write of all, when it is the most destructive.
83
+ root = project()
84
+ target = os.path.join(root, "src", "existing.ts")
85
+ Path(target).write_text("x" * 8000)
86
+ code, _ = run(root, {"file_path": target, "content": "x" * 120})
87
+ self.assertEqual(code, 2)
88
+
89
+ def test_edit_behaviour_is_unchanged(self):
90
+ root = project()
91
+ target = os.path.join(root, "src", "thing.ts")
92
+ small = run(root, {"file_path": target, "old_string": "a" * 20, "new_string": "b" * 20}, op="Edit")
93
+ big = run(root, {"file_path": target, "old_string": "a" * 300, "new_string": "b" * 300}, op="Edit")
94
+ self.assertEqual(small[0], 0, small[1])
95
+ self.assertEqual(big[0], 2, big[1])
96
+
97
+ def test_nature_exemptions_are_unchanged(self):
98
+ root = project()
99
+ docs = run(root, {"file_path": os.path.join(root, "docs", "n.md"), "content": "x" * 9000})
100
+ tests = run(root, {"file_path": os.path.join(root, "test", "a.test.ts"), "content": "x" * 9000})
101
+ self.assertEqual(docs[0], 0, docs[1])
102
+ self.assertEqual(tests[0], 0, tests[1])
103
+
104
+ def test_fastpath_off_gates_the_small_write_too(self):
105
+ # The new allowance rides the SAME switch as the trivial-edit one, so an
106
+ # operator turning the fast-path off gets the strict behaviour back for
107
+ # both rather than only for edits.
108
+ root = project()
109
+ code, _ = run(root, {"file_path": os.path.join(root, "src", "tiny.ts"),
110
+ "content": "x" * 120})
111
+ self.assertEqual(code, 0)
112
+ e = {"UAP_DELIVER_FASTPATH": "off"}
113
+ p = subprocess.run(
114
+ [sys.executable, str(ENF), "--operation", "Write", "--args",
115
+ json.dumps({"file_path": os.path.join(root, "src", "tiny.ts"), "content": "x" * 120})],
116
+ capture_output=True, text=True, cwd=root,
117
+ env={**{k: v for k, v in os.environ.items()
118
+ if k not in ("UAP_DELIVER_ACTIVE", "UAP_DELIVER_BYPASS", "UAP_DELIVER_LOCAL_MODE",
119
+ "ANTHROPIC_BASE_URL", "OPENAI_BASE_URL", "UAP_INFERENCE_ENDPOINT")},
120
+ "UAP_ENFORCE_DELIVERY": "block", "UAP_REPO_ROOT": root, **e})
121
+ self.assertEqual(p.returncode, 2, p.stdout)
122
+
123
+
124
+ if __name__ == "__main__":
125
+ unittest.main()