@miller-tech/uap 1.217.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 (38) hide show
  1. package/config/llama-patches/dflash2-pr27342.patch +1365 -0
  2. package/config/llama-profiles/qwen38-27b-dflash2.env +211 -0
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/cli/deliver.js +16 -0
  5. package/dist/cli/deliver.js.map +1 -1
  6. package/dist/cli/guided-setup.d.ts.map +1 -1
  7. package/dist/cli/guided-setup.js +8 -6
  8. package/dist/cli/guided-setup.js.map +1 -1
  9. package/dist/cli/hooks.d.ts.map +1 -1
  10. package/dist/cli/hooks.js +32 -0
  11. package/dist/cli/hooks.js.map +1 -1
  12. package/dist/cli/wizard-config.d.ts +2 -2
  13. package/dist/cli/wizard-config.d.ts.map +1 -1
  14. package/dist/config/settings-registry.d.ts.map +1 -1
  15. package/dist/config/settings-registry.js +22 -7
  16. package/dist/config/settings-registry.js.map +1 -1
  17. package/dist/coordination/reactor.d.ts +8 -0
  18. package/dist/coordination/reactor.d.ts.map +1 -1
  19. package/dist/coordination/reactor.js +43 -12
  20. package/dist/coordination/reactor.js.map +1 -1
  21. package/dist/mcp-router/tools/deliver.js +1 -1
  22. package/dist/mcp-router/tools/deliver.js.map +1 -1
  23. package/dist/types/config.d.ts +40 -16
  24. package/dist/types/config.d.ts.map +1 -1
  25. package/dist/types/config.js +10 -2
  26. package/dist/types/config.js.map +1 -1
  27. package/docs/guides/QWEN36_LLAMACPP.md +96 -37
  28. package/docs/reference/CONFIGURATION_REFERENCE.md +44 -8
  29. package/package.json +1 -1
  30. package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
  31. package/src/policies/enforcers/delivery_enforcement.py +226 -8
  32. package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
  33. package/templates/hooks/deliver_autoroute.py +2 -2
  34. package/templates/hooks/escalation_tracker.py +252 -0
  35. package/templates/hooks/stop.sh +10 -0
  36. package/templates/hooks/uap-policy-gate.sh +5 -5
  37. package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
  38. package/tools/agents/scripts/anthropic_proxy.py +112 -4
@@ -172,43 +172,102 @@ overriding `enable_thinking=true`.
172
172
  Do **not** switch a Qwen profile to `embedded` — the model's own baked-in template
173
173
  still carries the `'System message must be at the beginning.'` raise.
174
174
 
175
- ## Speculative decoding: what is running, and DFlash2
176
-
177
- Running today: `--spec-type draft-mtp`, reading the MTP head baked into `--model`
178
- (no drafter file). At `--spec-draft-n-max 2` a verification step accepts at most 3
179
- tokens; measured draft acceptance is 60-91%.
180
-
181
- **DFlash2 exists, fits this card at the current context tier, and is not
182
- installed.** Measured 2026-08-20 on the RTX 3090, holding `--parallel 2` and
183
- 131072 ctx/slot fixed:
184
-
185
- | | |
186
- |---|---|
187
- | Engine | [llama.cpp#27342](https://github.com/ggml-org/llama.cpp/pull/27342), **open, not merged**. One commit (`5ecbe1ac1`, ~676 LOC). Cherry-picks onto our build commit `f466cfa38` with **zero conflicts** — our tree is 29 commits ahead of the PR base — and builds clean for `sm_86`. |
188
- | Drafter | [z-lab/Qwen3.8-27B-DFlash2-GGUF](https://huggingface.co/z-lab/Qwen3.8-27B-DFlash2-GGUF), built for this exact target. Q4_K_M 1.1 GB / Q8_0 2.0 GB. Published acceptance length 5.39 / 5.13. |
189
- | Why v1 can't be used instead | The merged `draft-dflash` v1 engine cannot load a v2 checkpoint. Both declare `general.architecture = "dflash"`, so auto-detect selects `draft-dflash` and then fails: the v2 GGUF carries 23 conv/selector tensors and `dflash.conv_*` / `dflash.selector_*` metadata keys v1 has no loader for. A rebuild is mandatory; there is no config-only path. |
190
-
191
- VRAM, the constraint that decides it. The drafter is 5 layers with its **own 2048
192
- sliding window**, so its KV cache is ~80 MiB at f16 regardless of the target's
193
- 131072 — the cost is essentially just weights:
194
-
195
- | | measured / computed |
196
- |---|---|
197
- | Free VRAM at 131072/slot, `--parallel 2` | ~1.9 GiB (`nvidia-smi`: 22607 / 24576 MiB) |
198
- | Q4_K_M drafter (weights + KV + buffers) | ~1.25 GiB → **fits**, ~0.7 GiB margin |
199
- | Q8_0 drafter | ~2.1 GiB → does not fit at this tier |
200
-
201
- So it does not require dropping context or slot count. The open question is
202
- throughput, not memory: the PR's own reviewers report degradation at `-np >= 2`
203
- — the setting this box runs — and the headline 5.x acceptance figures are
204
- single-stream on Blackwell / Apple M5, not `sm_86`. The honest test is a paired
205
- A/B at fixed `-np 2` and 131072/slot against `draft-mtp` as configured today.
206
- Until that measurement exists, `draft-mtp` stays.
207
-
208
- > Note: the VRAM table under *Measured: Qwen3.8-27B* below was recorded from
209
- > llama.cpp's own buffer accounting (~0.95 GiB free); the ~1.9 GiB here is
210
- > `nvidia-smi` free memory on the whole card. They measure different things —
211
- > use the smaller one for headroom decisions.
175
+ ## Speculative decoding: DFlash2 is the default
176
+
177
+ `config/llama-profiles/qwen38-27b-dflash2.env` is the default profile as of
178
+ 2026-08-20. `qwen38-27b-mtp.env` is kept as the fallback — same model, same
179
+ context, same slot count; only the speculative-decoding config differs.
180
+
181
+ Measured on this card (RTX 3090, sm_86, temp 0, 4 prompts, 1032 tokens per arm,
182
+ **same binary across arms** so the spec config is the only variable):
183
+
184
+ | arm | aggregate tok/s | accept rate | tokens/verify step | |
185
+ |---|---|---|---|---|
186
+ | no speculation, `-np 1` | 41.3 | — | 1.00 | baseline |
187
+ | `draft-mtp` n-max 2, `-np 1` | 59.3 | 93.6% | 2.68 | |
188
+ | **DFlash2 n-max 4, `-np 1`** | **68.7** | 67.4% | 3.68 | **1.16×** |
189
+ | `draft-mtp` n-max 2, `-np 2` | 66.0 | 92.5% | 2.67 | |
190
+ | **DFlash2 n-max 4, `-np 2`** | **89.8** | 66.5% | 3.66 | **1.36×** |
191
+
192
+ The `-np 2` rows are two *genuinely concurrent* requests — the production shape.
193
+ Re-verified end-to-end through `run-llama-server-continuity.sh` with the shipped
194
+ profile: **92.6 tok/s**, tool calls parsing, peak 23459 MiB.
195
+
196
+ **Read `1.36×` as a blend, not a constant.** That run pushed 4 prompts through a
197
+ 2-slot pool, so the last prompt always finishes alone and the wall covers two
198
+ different regimes. Decomposing it from the server's own `launch_slot_`/`release`
199
+ timeline:
200
+
201
+ | regime | `draft-mtp` | DFlash2 n-max 4 | ratio |
202
+ |---|---|---|---|
203
+ | both slots decoding | 76.4 tok/s | 130.3 tok/s | **1.71×** |
204
+ | one request in flight on the 2-slot server | 57.9 tok/s | 60.4 tok/s | **1.04×** |
205
+ | the measured 4-prompt burst (what `1.36×` reports) | 66.0 | 89.8 | 1.36× |
206
+
207
+ So the gain is real but duty-cycle dependent: it is ~1.7× while both slots are
208
+ busy and close to nothing when only one request is in flight. Add prompts to the
209
+ burst and the headline drifts up toward 1.71×; remove them and it drifts down.
210
+ The `-np 1` row above is a `--parallel 1` server, which this service never runs —
211
+ for the single-request case on the real 2-slot server use the 1.04–1.2× range,
212
+ not 1.16×.
213
+
214
+ Two caveats on the numbers, both unclosed:
215
+
216
+ * **Every measurement was taken at `temperature 0.0`**, while this profile serves
217
+ at `LLAMA_TEMP=0.7 / TOP_P=0.8 / TOP_K=20`. Greedy is the most favourable
218
+ setting for speculative decoding, and it flatters a 4-token block more than a
219
+ 2-token one, so `1.36×` is an upper bound for the sampling that actually runs.
220
+ * **Acceptance is strongly prompt-dependent for DFlash2 and flat for MTP.**
221
+ Across the six prompts ever run, DFlash2 ranged 0.40–0.95 and `draft-mtp` sat
222
+ in a 0.91–0.97 band. The two lowest-acceptance prompts (prose explanation) were
223
+ never run against `draft-mtp`, so the regime where DFlash2 could lose is
224
+ untested. `n-max 7 < n-max 4` is direct evidence that drafting cost dominates
225
+ once acceptance falls.
226
+
227
+ Two results here are worth keeping, because both contradict what the upstream
228
+ material predicts:
229
+
230
+ * **DFlash2 does not degrade under concurrency on this card — it improves.** The
231
+ PR thread reports throughput collapsing at `-np >= 2`; here the advantage grows
232
+ from 1.16× to 1.36× going from one slot to two. Do not carry that assumption to
233
+ other hardware in either direction; measure it.
234
+ * **`n-max 4` beats `n-max 7`** (76.7 vs 69.8 tok/s), even though n-max 7 accepts
235
+ a longer block (4.86 vs 3.87 tokens/step). Acceptance falls from 72% to 55% as
236
+ the block lengthens, so the extra drafting outruns what it buys. The drafter
237
+ card's published acceptance length of 5.39 is real but is not the throughput
238
+ optimum.
239
+
240
+ ### The cost, and when to roll back
241
+
242
+ Peak VRAM in the production shape is **23459 / 24576 MiB — about 1.1 GiB free**.
243
+ There is **no paired measurement of `draft-mtp` taken the same way**, so treat the
244
+ drafter's marginal cost as un-measured rather than as the ~0.8 GiB the weights
245
+ imply: the table above reports ~0.95 GiB free for `draft-mtp` at this same tier,
246
+ which cannot both be true and leave DFlash2 roomier. Re-measure both profiles
247
+ with one harness before relying on either figure. Note also that 23459 was
248
+ sampled with `nvidia-smi` on a 1 s loop over a ~10 s run whose longest prefill
249
+ was 540 tokens — it never exercised a long-context prefill on two slots, which is
250
+ the real peak event at 131072/slot. The drafter's weights (Q4_K_M,
251
+ 1.1 GB) are essentially the whole difference: its KV cache stays around 80 MiB
252
+ because it carries its own 2048-token sliding window rather than scaling with the
253
+ target's 131072/slot. That is what lets this fit without cutting context or slots
254
+ — and it is why `LLAMA_DRAFT_CTX_SIZE` must stay unset.
255
+
256
+ This card has a history of *global* kernel OOM events selecting this service for
257
+ its RSS. The GPU side is now thinner too. **If the server starts failing to load
258
+ or dying, switch `~/.config/uap/llama-server.env` back to `qwen38-27b-mtp.env`
259
+ before changing anything else** — that is what the fallback profile is for.
260
+
261
+ ### The engine is an unmerged PR
262
+
263
+ [llama.cpp#27342](https://github.com/ggml-org/llama.cpp/pull/27342) is **open**.
264
+ The build is a cherry-pick of `5ecbe1ac1` onto `f466cfa38` (zero conflicts),
265
+ vendored as `config/llama-patches/dflash2-pr27342.patch` so it is reproducible if
266
+ the build worktree is lost. The merged `draft-dflash` **v1** engine cannot
267
+ substitute: a v2 checkpoint declares the same `general.architecture = "dflash"`,
268
+ so auto-detect selects `draft-dflash` and then fails on the 23 conv/selector
269
+ tensors and `dflash.conv_*` / `dflash.selector_*` metadata keys v1 has no loader
270
+ for. Return to plain upstream master once the PR lands.
212
271
 
213
272
  ## Point UAP at it
214
273
 
@@ -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.217.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)