@miller-tech/uap 1.218.0 → 1.219.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.
Files changed (35) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/cli/deliver.js +16 -0
  3. package/dist/cli/deliver.js.map +1 -1
  4. package/dist/cli/guided-setup.d.ts.map +1 -1
  5. package/dist/cli/guided-setup.js +8 -6
  6. package/dist/cli/guided-setup.js.map +1 -1
  7. package/dist/cli/hooks.d.ts.map +1 -1
  8. package/dist/cli/hooks.js +32 -0
  9. package/dist/cli/hooks.js.map +1 -1
  10. package/dist/cli/wizard-config.d.ts +2 -2
  11. package/dist/cli/wizard-config.d.ts.map +1 -1
  12. package/dist/config/settings-registry.d.ts.map +1 -1
  13. package/dist/config/settings-registry.js +22 -7
  14. package/dist/config/settings-registry.js.map +1 -1
  15. package/dist/coordination/reactor.d.ts +8 -0
  16. package/dist/coordination/reactor.d.ts.map +1 -1
  17. package/dist/coordination/reactor.js +43 -12
  18. package/dist/coordination/reactor.js.map +1 -1
  19. package/dist/mcp-router/tools/deliver.js +1 -1
  20. package/dist/mcp-router/tools/deliver.js.map +1 -1
  21. package/dist/types/config.d.ts +40 -16
  22. package/dist/types/config.d.ts.map +1 -1
  23. package/dist/types/config.js +10 -2
  24. package/dist/types/config.js.map +1 -1
  25. package/docs/reference/CONFIGURATION_REFERENCE.md +44 -8
  26. package/package.json +1 -1
  27. package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
  28. package/src/policies/enforcers/delivery_enforcement.py +226 -8
  29. package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
  30. package/templates/hooks/deliver_autoroute.py +2 -2
  31. package/templates/hooks/escalation_tracker.py +252 -0
  32. package/templates/hooks/stop.sh +10 -0
  33. package/templates/hooks/uap-policy-gate.sh +5 -5
  34. package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
  35. package/tools/agents/scripts/anthropic_proxy.py +112 -4
@@ -42,24 +42,60 @@ uap config wizard # interactive expert configurator (also: uap set
42
42
  | | |
43
43
  |---|---|
44
44
  | **Where** | `.uap.json` |
45
- | **Type** | enum (block \| advisory \| off) |
45
+ | **Type** | enum (block \| advisory \| off \| escalate) |
46
46
  | **Default** | `block` |
47
47
 
48
- How the delivery gate treats a direct source edit outside `uap deliver`. `block` refuses it (exit 2), `advisory` warns but allows, `off` disables the gate. (The `UAP_ENFORCE_DELIVERY` env var overrides this at runtime.)
48
+ How the delivery gate treats a direct source edit outside `uap deliver`. `block` refuses it (exit 2) and routes it to deliver; `escalate` lets direct edits land and routes to deliver only as an ESCALATION — after `delivery.escalateAfterFailures` consecutive gate failures, `delivery.escalateAfterEdits` edits to one file with no green gate, or a single change above `delivery.complexEditChars`; `advisory` warns but allows; `off` disables the gate. (The `UAP_ENFORCE_DELIVERY` env var overrides this at runtime.)
49
49
 
50
- **Recommendation:** `block` for hands-free/local-model work so every change is gated and verified; `advisory` when a capable human/Opus is driving and you want warnings without friction.
50
+ **Recommendation:** `escalate` for everyday agent work: small and medium edits go straight to disk and are judged by the project's own build/test + the verify hooks, while deliver is reserved for complex multi-file work and for when direct editing is demonstrably not working. `block` only when EVERY change must pass the convergence loop (unattended hands-free runs); `advisory` when a capable human is driving.
51
51
 
52
52
  ### `delivery.localMode`
53
53
 
54
54
  | | |
55
55
  |---|---|
56
56
  | **Where** | `.uap.json` |
57
- | **Type** | enum (advisory \| deliver \| block) |
57
+ | **Type** | enum (advisory \| deliver \| block \| escalate) |
58
58
  | **Default** | `advisory` |
59
59
 
60
- How local-model sessions are routed through delivery. `deliver` runs builds through the convergence loop; `block` forbids raw edits; `advisory` warns.
60
+ How a local-model session resolves a `block` posture. `escalate` lets direct edits land and escalates to deliver on evidence (failed gates, churn, size); `deliver` routes every substantive edit through the convergence loop; `block` forbids raw edits; `advisory` warns.
61
61
 
62
- **Recommendation:** `deliver` when a local model does the writing (routes it through the verified loop); `advisory` for exploratory work.
62
+ **Recommendation:** `escalate` when a local model does the writing — deliver becomes the escalation point instead of a tax on every small task; `deliver` only for unattended runs where each change must be converged; `advisory` for exploratory work.
63
+
64
+ ### `delivery.escalateAfterFailures`
65
+
66
+ | | |
67
+ |---|---|
68
+ | **Where** | `.uap.json` |
69
+ | **Type** | number |
70
+ | **Default** | `2` |
71
+
72
+ Escalate mode: after this many CONSECUTIVE verification failures (uap verify, the stop hook, or a build/test command observed by the hooks) since the last green gate, the next non-trivial direct source edit is refused and routed to `uap deliver` with the failure attached. 0 disables this trigger.
73
+
74
+ **Recommendation:** Leave at 2: one failure is normal iteration; two in a row with no green in between is the signal that direct editing is not converging.
75
+
76
+ ### `delivery.escalateAfterEdits`
77
+
78
+ | | |
79
+ |---|---|
80
+ | **Where** | `.uap.json` |
81
+ | **Type** | number |
82
+ | **Default** | `10` |
83
+
84
+ Escalate mode: when one source file has been edited this many times with no green gate in between, the next edit to it escalates to `uap deliver` (thrashing guard). Any passing verification resets the count. 0 disables this trigger.
85
+
86
+ **Recommendation:** Leave at 10 — generous enough for honest iteration, low enough to stop a model that is patching the same file line by line.
87
+
88
+ ### `delivery.complexEditChars`
89
+
90
+ | | |
91
+ |---|---|
92
+ | **Where** | `.uap.json` |
93
+ | **Type** | number |
94
+ | **Default** | `6000` |
95
+
96
+ Escalate mode: a single edit or whole-file write above this many characters (or a write that guts a substantial file) is treated as complex work and routed through `uap deliver` so it is verified rather than trusted blind. 0 disables this trigger.
97
+
98
+ **Recommendation:** 6000 characters is roughly a whole module; anything that size deserves the convergence loop. Raise it for generated/vendored code, lower it for a weak executor.
63
99
 
64
100
  ### `deliver.escalateModel`
65
101
 
@@ -78,7 +114,7 @@ Stronger model id for deliver escalation ladders: repair passes, the phase-5 esc
78
114
  | | |
79
115
  |---|---|
80
116
  | **Where** | shell env |
81
- | **Type** | enum (block \| advisory \| off) |
117
+ | **Type** | enum (block \| advisory \| off \| escalate) |
82
118
  | **Default** | `block` |
83
119
 
84
120
  Runtime override of the delivery gate read by the hooks/enforcers from the shell env. Takes precedence over `delivery.enforcement`.
@@ -217,7 +253,7 @@ Base URL of an OpenAI-compatible, image_url-capable endpoint used for aesthetic
217
253
  | **Type** | string |
218
254
  | **Default** | `` |
219
255
 
220
- Model id sent to the vision endpoint for aesthetic review (e.g. qwen36-35b-a3b-iq4xs).
256
+ Model id sent to the vision endpoint for aesthetic review (e.g. qwen3.8-27b).
221
257
 
222
258
  **Recommendation:** Set by `uap setup` to your local vision model. Required for blocking vision review under `max` fidelity.
223
259
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "1.218.0",
3
+ "version": "1.219.0",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -17,6 +17,24 @@ Escape hatches (always honored, even in block mode):
17
17
  - UAP_DELIVER_ACTIVE=1 set by the deliver loop for its own subprocesses
18
18
  - UAP_DELIVER_BYPASS=1 explicit operator override for a sanctioned manual edit
19
19
 
20
+ ESCALATE mode (`delivery.enforcement: escalate`, or `delivery.localMode:
21
+ escalate` for a local-model session under block): deliver is an ESCALATION
22
+ POINT, not the path every edit is forced through. Ordinary source edits go
23
+ straight to disk (the project's own build/test + the verify hooks judge them)
24
+ and the gate routes to `uap deliver` only when the evidence says direct editing
25
+ is not working or the change is too big to land unverified:
26
+
27
+ - failures : >= delivery.escalateAfterFailures (default 2) consecutive
28
+ verification failures since the last green gate
29
+ - churn : one file edited >= delivery.escalateAfterEdits (default 10)
30
+ times with no green gate in between (thrashing)
31
+ - complex : a single edit/write above delivery.complexEditChars (default
32
+ 6000) or a whole-file rewrite that guts a substantial file
33
+
34
+ The evidence lives in .uap/escalation-state.json, written by the
35
+ escalation_tracker.py hook (uap verify / stop hook / build+test shell results)
36
+ and cleared by any green verification or a landed deliver run.
37
+
20
38
  Exempt by construction: non-source files, docs/configs/scripts/policies, test
21
39
  files (deliver protects those itself), and tooling dot-dirs.
22
40
  """
@@ -277,12 +295,195 @@ def _is_local_model_session() -> bool:
277
295
  # Back-compat: UAP_DELIVER_LOCAL_ADVISORY=0 maps to "block".
278
296
  def _local_mode() -> str:
279
297
  m = os.environ.get("UAP_DELIVER_LOCAL_MODE", "").lower()
280
- if m in {"advisory", "deliver", "block"}:
298
+ if m in {"advisory", "deliver", "block", "escalate"}:
281
299
  return m
282
300
  adv = os.environ.get("UAP_DELIVER_LOCAL_ADVISORY", "on").lower()
283
301
  return "advisory" if adv not in {"0", "off", "false", "no", ""} else "block"
284
302
 
285
303
 
304
+ def _effective_mode() -> str:
305
+ """The gate posture for THIS operation: block | advisory | off | escalate.
306
+
307
+ `UAP_ENFORCE_DELIVERY` (config-authoritative via the policy gate) wins. A
308
+ local-model session under `block` is then resolved through
309
+ `UAP_DELIVER_LOCAL_MODE`: advisory -> advisory, escalate -> escalate,
310
+ deliver/block -> block (keep the route-through-deliver block).
311
+ """
312
+ mode = os.environ.get("UAP_ENFORCE_DELIVERY", "block").lower()
313
+ if mode == "block" and _is_local_model_session():
314
+ lm = _local_mode()
315
+ if lm in {"advisory", "escalate"}:
316
+ return lm
317
+ return mode
318
+
319
+
320
+ # ── ESCALATE mode: evidence-driven routing ───────────────────────────────────
321
+ ESCALATION_STATE_FILE = ".uap/escalation-state.json"
322
+
323
+
324
+ def _delivery_cfg_int(root: Path, key: str, env: str, default: int) -> int:
325
+ """An integer budget from the environment, else `.uap.json` delivery.<key>."""
326
+ raw = os.environ.get(env)
327
+ if raw is not None:
328
+ try:
329
+ return max(0, int(raw))
330
+ except ValueError:
331
+ pass
332
+ try:
333
+ import json as _json
334
+ cfg_root = Path(os.environ.get("UAP_MAIN_ROOT") or root)
335
+ cfg = _json.loads((cfg_root / ".uap.json").read_text(encoding="utf-8"))
336
+ value = (cfg.get("delivery") or {}).get(key)
337
+ if isinstance(value, bool):
338
+ return default
339
+ if isinstance(value, (int, float)):
340
+ return max(0, int(value))
341
+ except Exception:
342
+ pass
343
+ return default
344
+
345
+
346
+ def _escalation_state(root: Path) -> dict:
347
+ """The tracker's evidence, or an empty record. Evidence older than
348
+ UAP_DELIVER_ESCALATION_TTL_SEC (default 6h) is ignored: yesterday's red
349
+ build must not gate today's first edit."""
350
+ import json as _json
351
+ import time as _time
352
+ empty = {"failures": 0, "edits_since_green": {}, "last_failure": None}
353
+ try:
354
+ st = _json.loads((root / ESCALATION_STATE_FILE).read_text(encoding="utf-8"))
355
+ if not isinstance(st, dict):
356
+ return empty
357
+ except Exception:
358
+ return empty
359
+ try:
360
+ ttl = int(os.environ.get("UAP_DELIVER_ESCALATION_TTL_SEC", "21600"))
361
+ except ValueError:
362
+ ttl = 21600
363
+ # Age the FAILURE evidence by when it was recorded, not by the last edit:
364
+ # `updated` moves on every direct edit, which would keep a two-day-old red
365
+ # gate alive for as long as the agent keeps typing.
366
+ lf = st.get("last_failure") if isinstance(st.get("last_failure"), dict) else None
367
+ anchor = int((lf or {}).get("ts") or st.get("updated") or 0)
368
+ if ttl > 0 and int(_time.time()) - anchor > ttl:
369
+ return empty
370
+ st.setdefault("failures", 0)
371
+ st.setdefault("edits_since_green", {})
372
+ return st
373
+
374
+
375
+ def _record_escalation_edit(root: Path, rel: str) -> None:
376
+ """Count a direct edit against the file's churn budget (best-effort)."""
377
+ import json as _json
378
+ import time as _time
379
+ path = root / ESCALATION_STATE_FILE
380
+ try:
381
+ st = _json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
382
+ if not isinstance(st, dict):
383
+ st = {}
384
+ except Exception:
385
+ st = {}
386
+ edits = st.get("edits_since_green") if isinstance(st.get("edits_since_green"), dict) else {}
387
+ edits[rel] = int(edits.get(rel, 0)) + 1
388
+ st["edits_since_green"] = edits
389
+ st.setdefault("failures", 0)
390
+ st["updated"] = int(_time.time())
391
+ try:
392
+ path.parent.mkdir(parents=True, exist_ok=True)
393
+ tmp = path.with_suffix(".tmp")
394
+ tmp.write_text(_json.dumps(st, indent=1), encoding="utf-8")
395
+ os.replace(tmp, path)
396
+ except Exception:
397
+ pass
398
+
399
+
400
+ def _escalation_trigger(root: Path, rel: str, changed: "int | None", *, size_known: bool = True):
401
+ """(kind, human detail) when this edit must escalate to deliver, else None.
402
+
403
+ Order matters: a failing build is the strongest evidence, then thrashing on
404
+ one file, then sheer size. `changed is None` with size_known means a
405
+ whole-file rewrite the cost model refused (gutting) -> complex."""
406
+ st = _escalation_state(root)
407
+ n_fail = int(st.get("failures") or 0)
408
+ fail_budget = _delivery_cfg_int(root, "escalateAfterFailures", "UAP_DELIVER_ESCALATE_FAILURES", 2)
409
+ if fail_budget > 0 and n_fail >= fail_budget:
410
+ lf = st.get("last_failure") or {}
411
+ detail = str(lf.get("detail") or "").strip().replace("\n", " | ")[:400]
412
+ src = str(lf.get("source") or "gate")
413
+ return ("failures", f"{n_fail} consecutive verification failure(s) since the last green gate (last: {src}: {detail or 'no detail'})")
414
+ edits = st.get("edits_since_green") or {}
415
+ n_edits = int(edits.get(rel, 0)) if isinstance(edits, dict) else 0
416
+ churn_budget = _delivery_cfg_int(root, "escalateAfterEdits", "UAP_DELIVER_ESCALATE_EDITS", 10)
417
+ if churn_budget > 0 and n_edits >= churn_budget:
418
+ return ("churn", f"'{rel}' has been edited {n_edits} times with no green gate in between")
419
+ if size_known:
420
+ big = _delivery_cfg_int(root, "complexEditChars", "UAP_DELIVER_COMPLEX_EDIT_CHARS", 6000)
421
+ if changed is None:
422
+ return ("complex", "a whole-file rewrite that replaces most of a substantial file")
423
+ if big > 0 and changed > big:
424
+ return ("complex", f"a {changed}-character change (budget {big}) — a whole module's worth")
425
+ return None
426
+
427
+
428
+ def _escalation_block(rel: str, kind: str, detail: str, args: dict) -> None:
429
+ """Refuse the direct edit and hand the agent the deliver escalation, with
430
+ the evidence that triggered it. Terse and imperative on purpose (weak local
431
+ models retry or hallucinate completion on passive prose)."""
432
+ if kind == "failures":
433
+ msg = (
434
+ f"ESCALATE: {detail}. Direct edits are paused: editing '{rel}' again would repeat "
435
+ "what is not working. Call the `deliver` tool NOW with a one-line task that names the "
436
+ "failing gate and the fix (or run: uap deliver \"fix: <the failure>\"). Deliver "
437
+ "converges the change against the real gates; direct edits resume once it lands or "
438
+ "the gates go green. Do NOT retry this edit."
439
+ )
440
+ hint = f"fix the failing verification and make the gates green: {detail[:240]}"
441
+ intent = None
442
+ elif kind == "churn":
443
+ msg = (
444
+ f"ESCALATE: {detail} — you are thrashing. Stop patching it line by line. Call the "
445
+ "`deliver` tool with a one-line description of the INTENDED END STATE of this file "
446
+ "(what it must do, which tests must pass). Direct edits to it resume after a green "
447
+ "gate. Do NOT retry this edit."
448
+ )
449
+ hint = f"bring {rel} to a working state that passes the project gates"
450
+ intent = None
451
+ else:
452
+ msg = (
453
+ f"ESCALATE: this is {detail}. A change this size must land through the `deliver` "
454
+ "tool (or: uap deliver \"<one-line description>\") so it is verified against the "
455
+ "gates, not trusted blind. Your content is recorded as a replayable intent. Do NOT "
456
+ "retry this edit directly."
457
+ )
458
+ hint = f"implement the intended change to {rel}"
459
+ intent = {
460
+ k: v
461
+ for k, v in (
462
+ ("old_string", args.get("old_string", args.get("oldString"))),
463
+ ("new_string", args.get("new_string", args.get("newString"))),
464
+ ("content", args.get("content")),
465
+ )
466
+ if isinstance(v, str)
467
+ } or None
468
+ emit(False, msg, route="deliver", deliverHint=hint, editIntent=intent, escalation=kind)
469
+
470
+
471
+ def _handle_escalate_edit(args: dict, rel: str, root: Path, changed: "int | None") -> None:
472
+ trig = _escalation_trigger(root, rel, changed)
473
+ if trig is not None:
474
+ _escalation_block(rel, trig[0], trig[1], args)
475
+ return
476
+ _record_escalation_edit(root, rel)
477
+ print(
478
+ f"[delivery-enforcement] escalate mode: direct edit to '{rel}' allowed "
479
+ f"({'whole-file' if changed is None else changed} chars). Verify with the project's "
480
+ "build/test; deliver is the escalation point after repeated gate failures or for "
481
+ "large multi-file work.",
482
+ file=sys.stderr,
483
+ )
484
+ emit(True, "escalate mode: direct edit allowed; deliver is the escalation point")
485
+
486
+
286
487
  BASH_OPS = {"Bash", "bash", "run_bash", "shell"}
287
488
 
288
489
  # The delivery gate's own state. The pending log is the replay queue for
@@ -295,7 +496,7 @@ BASH_OPS = {"Bash", "bash", "run_bash", "shell"}
295
496
  # match any destructive verb (or a truncating redirect) against any path under
296
497
  # `.uap/` that names deliver state — including the directory itself and globs.
297
498
  _DELIVER_STATE_PATH = (
298
- r"(?:pending-deliver\.jsonl|deliver\.lock|deliver\.heartbeat"
499
+ r"(?:pending-deliver\.jsonl|deliver\.lock|deliver\.heartbeat|escalation-state\.json"
299
500
  r"|pending-[^\s'\"|;&]*|deliver\.[^\s'\"|;&]*|\*[^\s'\"|;&]*)"
300
501
  )
301
502
  _DELIVER_STATE_RM_RE = re.compile(
@@ -515,9 +716,17 @@ def _handle_bash(args: dict) -> None:
515
716
  m = _BASH_WRITE_RE.search(cmd)
516
717
  if m:
517
718
  target = m.group(1)
518
- mode = os.environ.get("UAP_ENFORCE_DELIVERY", "block").lower()
519
- if mode == "block" and _is_local_model_session() and _local_mode() == "advisory":
520
- mode = "advisory"
719
+ mode = _effective_mode()
720
+ if mode == "escalate":
721
+ # Size is unknowable for a shell write; failures/churn still decide.
722
+ root = repo_root()
723
+ trig = _escalation_trigger(root, target, None, size_known=False)
724
+ if trig is not None:
725
+ _escalation_block(target, trig[0], trig[1], {})
726
+ return
727
+ _record_escalation_edit(root, target)
728
+ emit(True, "escalate mode: shell source write allowed; deliver is the escalation point")
729
+ return
521
730
  msg = (
522
731
  f"BLOCKED: do not write source via the shell ('{target}'). Writing files with "
523
732
  "a redirect/heredoc/sed/tee bypasses the delivery gate. To create or change "
@@ -604,7 +813,7 @@ def main() -> None:
604
813
  # testing the same code underneath it — tell it to WAIT. route:wait so the
605
814
  # autoroute stands down instead of enqueuing a duplicate.
606
815
  holder = _deliver_lock_holder(root)
607
- if holder is not None and os.environ.get("UAP_ENFORCE_DELIVERY", "block").lower() == "block":
816
+ if holder is not None and os.environ.get("UAP_ENFORCE_DELIVERY", "block").lower() in {"block", "escalate"}:
608
817
  emit(
609
818
  False,
610
819
  f"WAIT: a `uap deliver` run (pid {holder}) is ALREADY in progress for this project and is "
@@ -618,6 +827,15 @@ def main() -> None:
618
827
  )
619
828
  return
620
829
 
830
+ # ESCALATE: direct edits land unless the evidence (failed gates, churn,
831
+ # size) says this change needs the convergence loop.
832
+ if _effective_mode() == "escalate":
833
+ changed = _changed_chars(args)
834
+ if changed is None:
835
+ changed = _write_cost(args, root)
836
+ _handle_escalate_edit(args, rel_posix, root, changed)
837
+ return
838
+
621
839
  # #3-F: terse, imperative, model-parseable. Weak local models otherwise
622
840
  # retry the blocked edit or hallucinate completion ("the files exist") when
623
841
  # the message is a passive explanation. State the exact next action.
@@ -649,8 +867,8 @@ def main() -> None:
649
867
  intent_payload = {
650
868
  k: v
651
869
  for k, v in (
652
- ("old_string", args.get("old_string")),
653
- ("new_string", args.get("new_string")),
870
+ ("old_string", args.get("old_string", args.get("oldString"))),
871
+ ("new_string", args.get("new_string", args.get("newString"))),
654
872
  ("content", args.get("content")),
655
873
  )
656
874
  if isinstance(v, str)
@@ -120,8 +120,8 @@ def decide(out: dict, tool: str, args: dict, autoroute_on: bool, seen_files: set
120
120
  edit_intent = out.get("editIntent") or {
121
121
  k: v
122
122
  for k, v in (
123
- ("old_string", args.get("old_string")),
124
- ("new_string", args.get("new_string")),
123
+ ("old_string", args.get("old_string", args.get("oldString"))),
124
+ ("new_string", args.get("new_string", args.get("newString"))),
125
125
  ("content", args.get("content")),
126
126
  )
127
127
  if isinstance(v, str)
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env python3
2
+ """escalation-tracker: the evidence store behind the `escalate` delivery mode.
3
+
4
+ Under `delivery.enforcement: escalate` (or `delivery.localMode: escalate`) a
5
+ coding agent makes ordinary source edits DIRECTLY and `uap deliver` becomes an
6
+ ESCALATION POINT instead of the path every edit is forced through. The gate
7
+ (delivery_enforcement.py) decides *when* to escalate from the state kept here:
8
+
9
+ .uap/escalation-state.json
10
+ failures consecutive verification failures since the last green
11
+ last_failure {ts, source, detail} — what failed, shown to the agent
12
+ last_green ts of the last passing verification
13
+ edits_since_green {relpath: n} — per-file churn with no green in between
14
+ updated ts of the last write
15
+
16
+ Writers (all best-effort, never block):
17
+ escalation_tracker.py fail --source verify|bash|stop --detail "<text>"
18
+ escalation_tracker.py pass --source verify|bash|stop
19
+ escalation_tracker.py edit --file <path> (the gate records this itself)
20
+ escalation_tracker.py reset (after a deliver run lands)
21
+ escalation_tracker.py status [--json]
22
+ escalation_tracker.py classify-bash --command "<cmd>" --output "<text>"
23
+ -> prints pass|fail|none for a build/test command's output, and records it
24
+
25
+ The project root is UAP_MAIN_ROOT (the checkout holding .uap/), else cwd.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import json
31
+ import os
32
+ import re
33
+ import sys
34
+ import time
35
+ from pathlib import Path
36
+
37
+ STATE_FILE = "escalation-state.json"
38
+ DETAIL_MAX = 1200
39
+
40
+ # A shell command that exercises the project's real gates. Only these commands'
41
+ # outcomes count as verification evidence — `ls` failing is not a gate failure.
42
+ _BUILD_TEST_RE = re.compile(
43
+ r"(?:^|[;&|]\s*)(?:"
44
+ r"cargo\s+(?:build|check|test|clippy)"
45
+ r"|npm\s+(?:run\s+)?(?:build|test|lint|typecheck|check)"
46
+ r"|(?:pnpm|yarn|bun)\s+(?:run\s+)?(?:build|test|lint|typecheck|check)"
47
+ r"|(?:npx\s+)?(?:tsc|vitest|jest|mocha|eslint)\b"
48
+ r"|(?:python3?\s+-m\s+)?pytest\b"
49
+ r"|go\s+(?:build|test|vet)"
50
+ r"|make(?:\s+(?:test|build|check|all))?\s*(?:$|[;&|])"
51
+ r"|mvn\s+(?:test|package|verify|compile)"
52
+ r"|gradle(?:w)?\s+(?:test|build|check)"
53
+ r"|dotnet\s+(?:build|test)"
54
+ r"|uap\s+verify\b"
55
+ r")",
56
+ re.IGNORECASE,
57
+ )
58
+ _FAIL_MARKERS = (
59
+ "error[e", # rustc
60
+ "could not compile",
61
+ "test result: failed",
62
+ "failed to compile",
63
+ "npm err!",
64
+ "error ts", # tsc: error TS2345
65
+ "assertionerror",
66
+ "traceback (most recent call last)",
67
+ "build failed",
68
+ "fatal error",
69
+ "compilation failed",
70
+ "panicked at",
71
+ "exit status 1",
72
+ "exit code 1",
73
+ )
74
+ _PASS_MARKERS = (
75
+ "test result: ok",
76
+ "finished `dev`", "finished `release`", "finished dev", "finished release",
77
+ "tests passed", "passed", "all tests passed",
78
+ "compiled successfully", "build succeeded", "build successful",
79
+ "0 errors", "no errors", "ok\n", " ok ",
80
+ "runtime gate: pass", "all gates pass", "verify: pass",
81
+ )
82
+
83
+
84
+ def root_dir() -> Path:
85
+ """The MAIN checkout's root — the same anchor the policy gate uses.
86
+
87
+ The gate resolves MAIN_ROOT by stripping `/.worktrees/NNN-slug` and points
88
+ the enforcer there, so evidence written from a session that runs INSIDE a
89
+ worktree must land in the same place or the trigger never fires."""
90
+ p = str(Path(os.environ.get("UAP_MAIN_ROOT") or ".").resolve())
91
+ if "/.worktrees/" in p:
92
+ p = p.split("/.worktrees/", 1)[0]
93
+ return Path(p)
94
+
95
+
96
+ def state_path(root: Path | None = None) -> Path:
97
+ return (root or root_dir()) / ".uap" / STATE_FILE
98
+
99
+
100
+ def load_state(root: Path | None = None) -> dict:
101
+ try:
102
+ data = json.loads(state_path(root).read_text(encoding="utf-8"))
103
+ if isinstance(data, dict):
104
+ data.setdefault("failures", 0)
105
+ data.setdefault("edits_since_green", {})
106
+ return data
107
+ except Exception:
108
+ pass
109
+ return {"failures": 0, "edits_since_green": {}, "last_failure": None,
110
+ "last_green": None, "updated": 0}
111
+
112
+
113
+ def save_state(state: dict, root: Path | None = None) -> None:
114
+ p = state_path(root)
115
+ try:
116
+ p.parent.mkdir(parents=True, exist_ok=True)
117
+ state["updated"] = int(time.time())
118
+ tmp = p.with_suffix(".tmp")
119
+ tmp.write_text(json.dumps(state, indent=1), encoding="utf-8")
120
+ os.replace(tmp, p)
121
+ except Exception:
122
+ pass # evidence is best-effort; never wedge the hook that records it
123
+
124
+
125
+ def record_fail(source: str, detail: str, root: Path | None = None) -> dict:
126
+ st = load_state(root)
127
+ st["failures"] = int(st.get("failures") or 0) + 1
128
+ st["last_failure"] = {
129
+ "ts": int(time.time()),
130
+ "source": source or "unknown",
131
+ "detail": (detail or "")[-DETAIL_MAX:],
132
+ }
133
+ save_state(st, root)
134
+ return st
135
+
136
+
137
+ def record_pass(source: str, root: Path | None = None) -> dict:
138
+ st = load_state(root)
139
+ st["failures"] = 0
140
+ st["edits_since_green"] = {}
141
+ st["last_green"] = {"ts": int(time.time()), "source": source or "unknown"}
142
+ st["last_failure"] = None
143
+ save_state(st, root)
144
+ return st
145
+
146
+
147
+ def record_edit(rel: str, root: Path | None = None) -> dict:
148
+ st = load_state(root)
149
+ edits = st.get("edits_since_green") or {}
150
+ edits[rel] = int(edits.get(rel, 0)) + 1
151
+ st["edits_since_green"] = edits
152
+ save_state(st, root)
153
+ return st
154
+
155
+
156
+ def reset(root: Path | None = None) -> dict:
157
+ st = {"failures": 0, "edits_since_green": {}, "last_failure": None,
158
+ "last_green": {"ts": int(time.time()), "source": "reset"}, "updated": 0}
159
+ save_state(st, root)
160
+ return st
161
+
162
+
163
+ def is_gate_command(command: str) -> bool:
164
+ return bool(command) and bool(_BUILD_TEST_RE.search(command))
165
+
166
+
167
+ def classify_output(command: str, output: str, exit_code: int | None = None) -> str:
168
+ """pass | fail | none for a shell command's outcome.
169
+
170
+ The exit code is authoritative when the harness supplies one. Without it
171
+ (opencode's after-hook exposes text only) fall back to markers, and to
172
+ `none` when the text says nothing either way — silence is not evidence.
173
+ """
174
+ if not is_gate_command(command):
175
+ return "none"
176
+ if exit_code is not None:
177
+ return "pass" if exit_code == 0 else "fail"
178
+ low = (output or "").lower()
179
+ if not low.strip():
180
+ return "none"
181
+ # A non-zero failure COUNT ("2 failed", "1 failures", "3 errors") or a
182
+ # specific failure marker. A bare "failed" substring is deliberately NOT a
183
+ # marker: `test_failed_login PASSED` and `--- PASS: TestFailedAuth` are green.
184
+ if any(m in low for m in _FAIL_MARKERS) or re.search(r"\b[1-9]\d* (?:failed|failures?|errors?)\b", low):
185
+ return "fail"
186
+ if any(m in low for m in _PASS_MARKERS) or re.search(r"(?m)^ok\b|--- pass:|\b\d+ passed\b", low):
187
+ return "pass"
188
+ return "none"
189
+
190
+
191
+ def main(argv: list[str] | None = None) -> int:
192
+ ap = argparse.ArgumentParser(prog="escalation_tracker.py")
193
+ sub = ap.add_subparsers(dest="cmd", required=True)
194
+ f = sub.add_parser("fail"); f.add_argument("--source", default="verify"); f.add_argument("--detail", default="")
195
+ p = sub.add_parser("pass"); p.add_argument("--source", default="verify"); p.add_argument("--detail", default="")
196
+ e = sub.add_parser("edit"); e.add_argument("--file", required=True)
197
+ sub.add_parser("reset")
198
+ s = sub.add_parser("status"); s.add_argument("--json", action="store_true")
199
+ c = sub.add_parser("classify-bash")
200
+ c.add_argument("--command", required=True); c.add_argument("--output", default="")
201
+ c.add_argument("--exit-code", type=int, default=None); c.add_argument("--dry-run", action="store_true")
202
+ raw = list(sys.argv[1:] if argv is None else argv)
203
+ folded: list[str] = []
204
+ k = 0
205
+ while k < len(raw):
206
+ tok = raw[k]
207
+ if tok in ("--detail", "--output", "--command") and k + 1 < len(raw):
208
+ folded.append(f"{tok}={raw[k + 1]}")
209
+ k += 2
210
+ continue
211
+ folded.append(tok)
212
+ k += 1
213
+ a = ap.parse_args(folded)
214
+
215
+ if a.cmd == "fail":
216
+ detail = a.detail
217
+ if detail == "-":
218
+ detail = sys.stdin.read()
219
+ st = record_fail(a.source, detail)
220
+ print(f"escalation: {st['failures']} consecutive failure(s) recorded ({a.source})")
221
+ elif a.cmd == "pass":
222
+ record_pass(a.source)
223
+ print(f"escalation: green ({a.source}) — counters reset")
224
+ elif a.cmd == "edit":
225
+ record_edit(a.file)
226
+ elif a.cmd == "reset":
227
+ reset()
228
+ print("escalation: state reset")
229
+ elif a.cmd == "status":
230
+ st = load_state()
231
+ if a.json:
232
+ print(json.dumps(st))
233
+ else:
234
+ lf = st.get("last_failure") or {}
235
+ print(f"failures={st.get('failures', 0)} files_churning={len(st.get('edits_since_green') or {})}"
236
+ + (f" last_failure=({lf.get('source')}) {str(lf.get('detail',''))[:120]!r}" if lf else ""))
237
+ elif a.cmd == "classify-bash":
238
+ output = a.output
239
+ if output == "-":
240
+ output = sys.stdin.read()
241
+ verdict = classify_output(a.command, output, a.exit_code)
242
+ if not a.dry_run:
243
+ if verdict == "fail":
244
+ record_fail("bash", f"$ {a.command[:200]}\n{output[-DETAIL_MAX:]}")
245
+ elif verdict == "pass":
246
+ record_pass("bash")
247
+ print(verdict)
248
+ return 0
249
+
250
+
251
+ if __name__ == "__main__":
252
+ sys.exit(main())
@@ -221,6 +221,16 @@ if [ "$CODE_CHANGED" = "true" ] && [ "${UAP_VERIFY_ON_STOP:-1}" != "0" ] && comm
221
221
  fi
222
222
  VERIFY_OUT="$(cd "$PROJECT_DIR" && $TIMEOUT_WRAP uap verify --runtime-only $UV_AUTO $ACC_AUTO --dir "$PROJECT_DIR" 2>&1)"
223
223
  VERIFY_RC=$?
224
+ # Evidence for the `escalate` delivery mode: a red verify counts toward the
225
+ # "escalate to deliver" threshold, a green one clears it. Best-effort.
226
+ TRACKER="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/escalation_tracker.py"
227
+ if [ -f "$TRACKER" ]; then
228
+ if [ "$VERIFY_RC" = "1" ]; then
229
+ (cd "$PROJECT_DIR" && UAP_MAIN_ROOT="$PROJECT_DIR" python3 "$TRACKER" fail --source stop --detail "$(printf '%s' "$VERIFY_OUT" | tail -c 1200)" >/dev/null 2>&1) || true
230
+ elif [ "$VERIFY_RC" = "0" ]; then
231
+ (cd "$PROJECT_DIR" && UAP_MAIN_ROOT="$PROJECT_DIR" python3 "$TRACKER" pass --source stop >/dev/null 2>&1) || true
232
+ fi
233
+ fi
224
234
  fi
225
235
  set -e
226
236
  # RC 1 = a REAL gate failure (code is broken) → block. Everything else fails