@miller-tech/uap 2.39.0 → 2.44.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/config/capacity-policy.json +25 -0
  2. package/config/model-profiles/qwen38.json +8 -7
  3. package/config/model-registry.json +668 -20
  4. package/dist/.tsbuildinfo +1 -1
  5. package/dist/cli/deliver.js +1 -1
  6. package/dist/cli/deliver.js.map +1 -1
  7. package/dist/cli/models.d.ts +6 -0
  8. package/dist/cli/models.d.ts.map +1 -1
  9. package/dist/cli/models.js +11 -2
  10. package/dist/cli/models.js.map +1 -1
  11. package/dist/cli/verify.d.ts +9 -1
  12. package/dist/cli/verify.d.ts.map +1 -1
  13. package/dist/cli/verify.js +15 -3
  14. package/dist/cli/verify.js.map +1 -1
  15. package/dist/delivery/acceptance-judge.d.ts +12 -0
  16. package/dist/delivery/acceptance-judge.d.ts.map +1 -1
  17. package/dist/delivery/acceptance-judge.js +109 -8
  18. package/dist/delivery/acceptance-judge.js.map +1 -1
  19. package/dist/inference/model-files.d.ts.map +1 -1
  20. package/dist/inference/model-files.js +6 -1
  21. package/dist/inference/model-files.js.map +1 -1
  22. package/dist/models/types.d.ts.map +1 -1
  23. package/dist/models/types.js +14 -2
  24. package/dist/models/types.js.map +1 -1
  25. package/docs/INDEX.md +1 -0
  26. package/docs/architecture/adr/0015-session-hook-model-convergence-as-standing-consent.md +68 -0
  27. package/docs/benchmarks/PROMPT_UPLIFT_BLENDER.md +161 -0
  28. package/docs/benchmarks/README.md +1 -0
  29. package/docs/guides/LOCAL_MODELS.md +42 -0
  30. package/package.json +1 -1
  31. package/scripts/lib/droid-sync-contracts.sh +114 -0
  32. package/src/policies/enforcers/__pycache__/_common.cpython-311.pyc +0 -0
  33. package/src/policies/enforcers/__pycache__/expert_review_required.cpython-311.pyc +0 -0
  34. package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-311.pyc +0 -0
  35. package/src/policies/enforcers/delivery_enforcement.py +34 -12
  36. package/templates/hooks/__pycache__/deliver_autoroute.cpython-311.pyc +0 -0
  37. package/templates/hooks/pre-tool-use-bash.sh +34 -3
  38. package/templates/hooks/stop.sh +39 -0
  39. package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-311.pyc +0 -0
  40. package/tools/agents/scripts/anthropic_proxy.py +16 -4
  41. package/tools/agents/tests/test_delivery_enforcement_write_complexity.py +26 -0
@@ -0,0 +1,161 @@
1
+ # Prompt Uplift Field Study: a 2-bit Model Passes a Pixel Gate (2026-11)
2
+
3
+ **Date:** 2026-11-12 · **Subject:** `abliterated-iq2_xs` on a frozen Blender build
4
+ contract, driven by bespoke harness tooling (the repo's UAP install is 2.39.1; UAP
5
+ supplies the memory store and worktree workflow here, not the harness — there is no
6
+ uplift subsystem to attribute a result to)
7
+ **Scope:** one measured prompt-uplift run against a frozen multi-gate acceptance
8
+ contract, and the four transferable lessons it produced. This is a dated study, not
9
+ a feature doc: the harness lives in the experiment repo, not here. It is an
10
+ *uncontrolled field run* in the sense defined by
11
+ [benchmarks/README.md](README.md) — one model, no control arm, prompt changed at every
12
+ step — so it shows magnitude and mechanism, not causation; quote the paired harness for
13
+ causal claims. For the evergreen mechanism the study informs, see
14
+ [guides/DELIVER.md](../guides/DELIVER.md) (iterate against real completion gates) and
15
+ [guides/LOCAL_MODELS.md](../guides/LOCAL_MODELS.md).
16
+
17
+ ## The experiment
18
+
19
+ Task: write a Blender Python script that builds a rigged, animated, game-ready
20
+ robot. Acceptance is two independent gates, and the model is asked to produce exactly
21
+ one artifact, its build script:
22
+
23
+ - **Structural gate** — 12 checks: exactly 19 bones, 4 clips, under 12k tris, feet
24
+ on the floor, weapon grip at the palm, every mesh bound to the rig, FBX
25
+ round-trip, and so on. Hash-pinned in `.uap/gate-lock.json` (`a519af41c595…`),
26
+ along with the human-readable contract `gates.md`.
27
+ - **Visual gate** — 8 pixel-level checks over 36 rendered PNGs (4 clips × 3 keyframe
28
+ positions × 3 ortho cameras). Pixels are classified by HSV hue, so the gate reads
29
+ the render the way a reviewer would: is the weapon at the hand, are the feet on the
30
+ floor line, is any part floating off in space. Added after the lock file was
31
+ written, so it is pinned by recorded hash (`7672fece1d0e…`) rather than by
32
+ `gate-lock.json` — see the caveat under lesson 1.
33
+
34
+ Model: `abliterated-iq2_xs` (a ~2-bit quantized small model), temp 0, **first attempt
35
+ only, no repair turns**. Each stage is one prompt revision, scored by `prompt_sha`, so
36
+ a score is attributable to an exact prompt.
37
+
38
+ `n` counts scored attempts — every row in `.uap/uplift/results.jsonl` except the three
39
+ `--dry-prompt` rows, which print the prompt and record no score. A build that crashes is
40
+ scored 0, not excluded. `12/12` is the structural gate; `both gates` means 12/12 *and*
41
+ 8/8 on the same first attempt.
42
+
43
+ | stage | prompt change | n | 12/12 | both gates | `sys` crashes |
44
+ |-------|---------------|---|-------|------------|---------------|
45
+ | bare | raw prompt | 5 | 0 | n/a (no visual gate) | 0 |
46
+ | A-acceptance | 12-check contract + API crib sheet | 38 | 23 | n/a (no visual gate) | 0 |
47
+ | B-visual | + render/visual gate | 3 | 3 | 0 (visual 3/8–4/8) | 0 |
48
+ | B-visual-v2 | visual rules rewritten | 3 | 0 (all three builds crashed) | 0 | 0 |
49
+ | B-visual-v3 | tightened geometry rules | 3 | 2 | **0** (visual 6/8 twice) | 0 |
50
+ | B-visual-v4 | vertical limb chains, blade inside hand | 9 | 1 | **1** | 4 |
51
+ | B-visual-v5 | verbatim import header + copied fix loop | 6 | **6** | **4 (67%)** | 0 |
52
+
53
+ First-attempt rate at *both* gates went 0/3 → 1/9 → 4/6 across three prompt
54
+ revisions, with no change to the model, the gates, or the temperature. v4's nine runs
55
+ split four ways: 4 `sys` crashes, 3 reasoning truncations, 1 built-but-incomplete
56
+ (11/12, 6/8), 1 clean pass.
57
+
58
+ ## Four lessons
59
+
60
+ ### 1. A gate the model can edit is not a gate
61
+
62
+ The lock file states the rule in its own header: the model "may NOT edit these files.
63
+ A hash mismatch fails the run." The runner copies the validator, the visual gate, the
64
+ render script and `gates.md` fresh into each attempt workspace before the build runs,
65
+ then executes the model's script there. That keeps the frozen originals in `tooling/`
66
+ out of the way, but nothing enforces the header's sentence at runtime: the model's
67
+ script runs as ordinary Blender Python with write access to its workspace, and the
68
+ workspace sits inside the same repo as the originals. Integrity was therefore
69
+ established *after* the fact: pinned hash matches live, and every workspace carried
70
+ byte-identical gate copies (one distinct hash across all 63 validator and 24
71
+ visual-gate copies).
72
+
73
+ Be precise about the strength of that. The runner copies the gates but does not
74
+ re-verify the hashes *inside* the run, and the workspace copy is writable, so the
75
+ guarantee here is audit-after-the-fact rather than runtime enforcement. That is enough
76
+ to make the pass rate falsifiable — a tampered gate would show up as a hash mismatch
77
+ when checked — but it is not the same as a read-only mount or a pre-flight assertion.
78
+ Closing that gap is cheap: recompute the workspace gate hashes after the model's
79
+ script has written and before the gates execute, and score the attempt 0 on mismatch.
80
+ Four `hashlib` calls in the runner turn "no tampering was found afterwards" into
81
+ "tampering could not have helped". The transferable part stands either way: without a
82
+ pinned verifier, a 67% pass rate is unfalsifiable, because the cheapest path to 12/12
83
+ is editing the checker. This is the
84
+ same `enforce, don't suggest` stance as everywhere else in UAP, applied to the verifier
85
+ rather than the producer.
86
+
87
+ ### 2. Score the prompt, not the vibe
88
+
89
+ Every run records `prompt_sha` alongside its score, and prompt files compose with
90
+ `#extends <stage>` so a revision is a delta, not a rewrite. That is what made v4
91
+ diagnosable: the geometry rules demonstrably worked (attempt 5 hit both gates,
92
+ matching the offline geometry test that had been run before the stage shipped) while
93
+ the stage as a whole regressed. Without per-revision attribution, "v4 is worse than
94
+ v3" would have buried a working fix under an unrelated crash.
95
+
96
+ ### 3. A regression can be a missing word, not a wrong idea
97
+
98
+ 4 of 9 v4 runs died identically:
99
+
100
+ ```
101
+ NameError: name 'sys' is not defined. Did you forget to import 'sys'?
102
+ argv = sys.argv
103
+ ```
104
+
105
+ The composed prompt asks for an `argv` guard (`argv` appears 7 times) and never
106
+ contains the words `import sys` — 0 occurrences in 43,382 characters. So the import is
107
+ purely the model's own guess. Reading the generated headers confirms it: v3's three
108
+ runs each happened to write `import bpy, sys, os`, while v4's four crashing runs wrote
109
+ `import bpy` / `import math` / `from mathutils import …` and omitted `sys` — same
110
+ instruction, different coin flip. v5 states the header as four lines to copy verbatim,
111
+ `import sys` included, and the crash count went to 0/6 with the validator at 6/6.
112
+
113
+ The transferable part: when a prompt asks for a construct, state the import or
114
+ declaration that construct needs, as text to copy rather than prose to understand.
115
+ Small models rewrite headers; they do not reliably infer them.
116
+
117
+ ### 4. Reasoning budget is a hard constraint, not a tuning knob
118
+
119
+ Three v4 runs left reasoning on: the model spent 17k–27k reasoning characters before
120
+ writing any code and hit `finish_reason=length` mid-table, at both 8k and 12k max
121
+ tokens, producing an unterminated tuple and a `SyntaxError`. All three scored 0.
122
+
123
+ The pattern holds across the whole log, not just v4. Of 67 scored runs, 15 had
124
+ reasoning on and 52 had it off. Every one of the 52 reasoning-off runs finished with
125
+ `finish=stop` — none truncated. All 8 truncated runs in the log had reasoning on, and
126
+ all 8 scored 0. Reasoning on did not become safe at a bigger budget either: the 7
127
+ reasoning-on runs that were given 32k–64k tokens did finish, and their best score was
128
+ 11/12 — no reasoning-on run in the log ever cleared the structural gate. Meanwhile v5,
129
+ run with `--reasoning-effort none --max-tokens 12000`, scored 12/12 six times out of
130
+ six. A truncated run is a budget misconfiguration, not a model failure, and the budget
131
+ must be pinned in the invocation for scores to stay comparable across stages.
132
+
133
+ ## Where the last two failures sit
134
+
135
+ Both remaining failures are single-rung and both are animation amplitude, not
136
+ geometry. A walk cycle lifted the feet 17 px where the limit is 12; an attack pose
137
+ rotated the blade edge-on so its pixels left the camera plane entirely (gap 999 means
138
+ "no weapon pixels found in that frame", not "drifted a little").
139
+
140
+ The structural gate measures the rest pose, so both builds pass 12/12 and fail the
141
+ pixel gate. Its `feet-on-floor` rung reads the lowest world-Z of any `Foot*` vertex at
142
+ `|z| <= 0.005` m — a static measurement — while the visual gate measures the lift in
143
+ every rendered frame. That gap, a structural check that never exercises the animated
144
+ state, is the argument for the visual gate existing at all.
145
+
146
+ For scale, re-rendering the offline reference build (the hand-written geometry proof
147
+ that shipped the v4/v5 rules) through the same pixel gate puts its peak foot lift at
148
+ 7 px and its island ratio at 0.088 of the bbox diagonal (limit 0.30); the four passing
149
+ v5 runs land at 3–9 px. The failing walk cycle at 17 px is not marginally off — it is
150
+ over twice the reference and 5 px past the limit, on the one rung it misses.
151
+
152
+ ## Reproduce
153
+
154
+ Harness, prompts, findings, and per-attempt workspaces live in the experiment repo
155
+ (`blender-test-1`): `tooling/uplift_runner.py`, `.uap/uplift/prompts/`,
156
+ `.uap/uplift/FINDINGS-B-visual-v{4,5}.md`, `.uap/uplift/runs/`.
157
+
158
+ ```
159
+ python3 tooling/uplift_runner.py --stage B-visual-v5 --attempt N \
160
+ --reasoning-effort none --max-tokens 12000
161
+ ```
@@ -61,6 +61,7 @@ authoritative.
61
61
  | [Token Optimization](TOKEN_OPTIMIZATION.md) | Where the token savings come from |
62
62
  | [Accuracy Analysis](ACCURACY_ANALYSIS.md) | Success-rate and error analysis |
63
63
  | [Comprehensive Benchmarks](COMPREHENSIVE_BENCHMARKS.md) | Extended measurements |
64
+ | [Prompt Uplift Field Study](PROMPT_UPLIFT_BLENDER.md) | Uncontrolled field run: prompt revisions only, one 2-bit model, frozen structural + pixel gates (0/3 → 4/6). Descriptive, no control arm |
64
65
 
65
66
  ## Task suites and raw runs
66
67
 
@@ -171,6 +171,48 @@ For the full picture — the `uap proxy` lifecycle, the reliability guardrails
171
171
  that keep a small model from wedging, security, and serving recipes — see the
172
172
  **[Inference Proxy guide](PROXY.md)**.
173
173
 
174
+ ## Droid (Factory) custom models — auto-populated from the live arm
175
+
176
+ Droid reads its model list from `customModels` in `~/.factory/settings.json`
177
+ (user) and `<project>/.factory/settings.json` (project). Those entries are
178
+ static per file — Droid does not discover local servers — so a backend swap
179
+ would otherwise leave every Droid instance pinning a retired model id.
180
+
181
+ [`scripts/sync-local-agent-configs.sh`](../../scripts/sync-local-agent-configs.sh)
182
+ closes that gap, and never transcribes the alias: it reads the model id and
183
+ geometry from `config/model-profiles/qwen38.json` (the same single source of
184
+ truth the rails suite drift-checks against the live `/props` on :8080), then:
185
+
186
+ - migrates every retired local id (the registry's `retired_local_aliases`
187
+ list — the single source, never transcribed into the script) onto the
188
+ alias the current arm serves — proxy entries to `:4000`, direct-Responses
189
+ entries stay on `:8080`;
190
+ - **adds** the entry when a settings file has no local qwen model at all, so
191
+ every Droid instance (user-level settings cover all projects) can call it;
192
+ - sizes entries to the profile's per-session cap (114688 via the proxy, the
193
+ 131072 pool on the direct path) and the proxy tool-turn cap (32768);
194
+ - pins `baseModelId` on Anthropic-protocol entries (default
195
+ `claude-sonnet-4-6`, override `DROID_ANTHROPIC_BASE_MODEL`) so the entry
196
+ inherits model-specific prompts, tools, and Script-mode eligibility instead
197
+ of the "custom model does not map to a built-in" fallback behavior.
198
+
199
+ For "at any given time" freshness, run it with `MODELS_ONLY=1`: that mode
200
+ converges **only** the `.factory` custom-model lists, takes the alias from the
201
+ LIVE backend (`/props` on :8080, profile fallback when unreachable), and
202
+ touches nothing else. The wiring is machine-local, not shipped:
203
+ `.factory/` is gitignored, so the repo cannot carry the hook — wire it once
204
+ per machine (project or user `.factory/settings.json`) as a `SessionStart`
205
+ hook running exactly that, per [ADR
206
+ 0015](../architecture/adr/0015-session-hook-model-convergence-as-standing-consent.md),
207
+ and every Droid session starts with the qwen list pointed at whatever arm is
208
+ actually serving. `DRY_RUN=1` shows the plan; a run backs up only what it
209
+ changes.
210
+
211
+ ```bash
212
+ bash scripts/sync-local-agent-configs.sh # full operator sync
213
+ MODELS_ONLY=1 bash scripts/sync-local-agent-configs.sh # droid lists only
214
+ ```
215
+
174
216
  ## Related
175
217
 
176
218
  - [Deploy Batching](./DEPLOY_BATCHING.md) — what `uap deliver --deploy` queues.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "2.39.0",
3
+ "version": "2.44.1",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # droid-sync-contracts.sh — SHARED CONTRACT for Droid (Factory) model sync.
4
+ # -----------------------------------------------------------------------------
5
+ # Every Droid/Factory instance — the user-level ~/.factory/config.json and
6
+ # ~/.factory/settings.json AND the repo-level .factory/settings.json — must
7
+ # auto-converge its customModels onto the LIVE local qwen3.8 backend instead
8
+ # of drifting onto retired ids and stale hosts.
9
+ #
10
+ # This file is the single place the installer and the sandbox test agree on:
11
+ # the variable names and their defaults. It is SOURCED (never executed) by
12
+ # scripts/sync-local-agent-configs.sh and by scripts/test-sync-sandbox.sh, so
13
+ # the installer and the assertions read ONE contract instead of two
14
+ # transcriptions of it — a number or path transcribed in both places is
15
+ # exactly the failure class that left the sandbox suite red through the
16
+ # 1-rail era. The FUNCTION bodies live in sync-local-agent-configs.sh
17
+ # (sync_droid_models / droid_sync_lock); the sandbox asserts them textually
18
+ # against that file, so this contract stays free of dead stub code.
19
+ #
20
+ # CONTRACT RULES (this file is frozen once accepted):
21
+ # * Sourcing is SIDE-EFFECT FREE: no I/O, no mkdir, no network, no writes.
22
+ # Every value is a `: "${VAR:=default}"` assignment, so a caller may
23
+ # pre-set any of them (the sandbox does, to point at a throwaway $HOME)
24
+ # and the caller's value always wins.
25
+ # * Nothing here depends on bash features beyond 4.2.
26
+ #
27
+ # shellcheck shell=bash
28
+ #
29
+
30
+ # Double-source guard: the installer and the sandbox may both source this.
31
+ if [ -n "${_UAP_DROID_SYNC_CONTRACTS_LOADED:-}" ]; then
32
+ return 0 2>/dev/null || true
33
+ fi
34
+ _UAP_DROID_SYNC_CONTRACTS_LOADED=1
35
+
36
+ # ===========================================================================
37
+ # SHARED VARIABLES — all overridable from the environment.
38
+ # ===========================================================================
39
+
40
+ # MODELS_ONLY — when "1", the sync touches ONLY the customModels/custom_models
41
+ # arrays of the Droid config files. No service operations (systemctl/curl),
42
+ # no proxy env requirement, no other agent configs. This is the mode the
43
+ # auto-convergence session hook runs in: it must be safe to fire on every
44
+ # session start, on any machine state.
45
+ # "0" (default) = full sync "1" = customModels only
46
+ : "${MODELS_ONLY:=0}"
47
+
48
+ # REPO_ROOT — absolute path of the checkout the sync is running against.
49
+ # Derived from this file's own location (scripts/lib/ -> repo root), never
50
+ # from $PWD, so a hook fired from a subdirectory still resolves the repo
51
+ # .factory/settings.json correctly. A caller (the installer, the sandbox)
52
+ # may pre-set it to redirect the whole sync at a throwaway tree.
53
+ : "${REPO_ROOT:=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
54
+
55
+ # PROJECT_FACTORY_SET — the repo-level Droid settings file whose customModels
56
+ # must converge alongside the user-level ones. camelCase spelling
57
+ # ("customModels"), same shape as ~/.factory/settings.json.
58
+ : "${PROJECT_FACTORY_SET:=$REPO_ROOT/.factory/settings.json}"
59
+
60
+ # DROID_ANTHROPIC_BASE_MODEL — the Factory BUILT-IN catalog id written into
61
+ # `baseModelId` of anthropic-provider entries. A custom Anthropic-provider
62
+ # model inherits that built-in's prompt/tool-call behavior; without it Droid
63
+ # warns the entry "does not map to a built-in model". claude-sonnet-4-6 is the
64
+ # choice because it accepts reasoning-effort "off" (the proxy sends none) and
65
+ # speaks the Anthropic Messages class the :4000 proxy serves. This is NOT a
66
+ # local model id — it must never carry a qwen id.
67
+ : "${DROID_ANTHROPIC_BASE_MODEL:=claude-sonnet-4-6}"
68
+
69
+ # DROID_SYNC_LOCK — lock file serialising concurrent Droid config writes
70
+ # (session-start hook + manual installer run + a second Droid window).
71
+ # Without it two writers read-modify-write the same JSON and one set of
72
+ # customModels silently loses the other's update. Defaults under
73
+ # $XDG_RUNTIME_DIR so it is per-user and per-boot; /tmp is the fallback for
74
+ # environments without XDG_RUNTIME_DIR (containers, CI sandboxes).
75
+ # $XDG_RUNTIME_DIR is per-user (0700 tmpfs); the /tmp fallback is
76
+ # world-writable, so key it per-uid there or any local user could hold the
77
+ # lock forever and silently disable convergence.
78
+ if [ -n "${XDG_RUNTIME_DIR:-}" ]; then
79
+ : "${DROID_SYNC_LOCK:=$XDG_RUNTIME_DIR/uap-droid-sync.lock}"
80
+ else
81
+ : "${DROID_SYNC_LOCK:=/tmp/uap-droid-sync-$UID.lock}"
82
+ fi
83
+
84
+ # ===========================================================================
85
+ # FUNCTION CONTRACT (bodies live in sync-local-agent-configs.sh)
86
+ # ===========================================================================
87
+ # droid_sync_lock — acquire fd 9 on $DROID_SYNC_LOCK with `flock -n`.
88
+ # returns 0 = lock held for this script; 1 = another sync holds it.
89
+ #
90
+ # sync_droid_models <FILE> <CREATE> — converge ONE Droid config file.
91
+ # FILE : a Droid settings.json (camelCase customModels) or legacy
92
+ # config.json (snake_case custom_models).
93
+ # CREATE : "1" = create the file (with the canonical entry) when absent —
94
+ # only settings targets use this; the legacy file is patched
95
+ # in place or left alone. "0" = patch only when present.
96
+ # Reads from the caller's scope: MODEL_ALIAS, PROXY_URL, DIRECT_URL,
97
+ # CTX_SESSION, CTX_POOL, MAX_OUTPUT, DROID_ANTHROPIC_BASE_MODEL,
98
+ # PROXY_TOKEN (via UAP_SYNC_TOKEN env), DRY_RUN.
99
+ # Migration rules (idempotent; no change => file left byte-identical):
100
+ # * entries whose model is a RETIRED local id — or already the live
101
+ # alias, or model-less but pointed at :4000/:8080 — converge onto
102
+ # MODEL_ALIAS;
103
+ # * route by existing shape: baseUrl with :8080 -> direct strata route
104
+ # (pool-sized); :4000 WITHOUT /v1 -> Anthropic Messages root (proxy
105
+ # root + baseModelId + apiKey only if absent, session-sized); anything
106
+ # else local -> proxy chat route ($PROXY_URL/v1 + token);
107
+ # * cloud models (anything else, including the :8317 router entries) are
108
+ # deliberate user choices and are never repointed;
109
+ # * a settings.json with NO local entry after migration gains the
110
+ # canonical proxy entry (add-if-missing);
111
+ # * the 192.168.1.165:8317 split-brain host is fixed everywhere;
112
+ # * writes are atomic (tmp file in the same directory + os.replace),
113
+ # under the flock, with backups taken by the caller's backup().
114
+ # returns 0 always (a per-file failure is a printed WARN, not an abort).
@@ -193,16 +193,32 @@ def _trivial_edit_chars() -> int:
193
193
 
194
194
  def _changed_chars(args: dict) -> int | None:
195
195
  """Total changed characters for an Edit/MultiEdit, or None when it can't be
196
- measured (e.g. a whole-file Write) — None means 'not trivially small'."""
196
+ measured (e.g. a whole-file Write) — None means 'not trivially small'.
197
+
198
+ Harnesses spell the anchor fields three ways: Claude Code sends
199
+ old_string/new_string, some SDKs oldString/newString, and Droid (Factory)
200
+ old_str/new_str. An unrecognized spelling measured as None — read as a
201
+ whole-file rewrite — which force-escalated every Droid Edit as complex
202
+ regardless of size (observed live 2026-11-12: a 4-line comment insert was
203
+ refused as 'a whole-file rewrite that replaces most of a substantial
204
+ file'). grab() recognizes all three spellings at both call sites."""
205
+ def grab(d: dict) -> tuple[object, object]:
206
+ old = d.get("old_string") or d.get("oldString") or d.get("old_str")
207
+ new = d.get("new_string") or d.get("newString") or d.get("new_str")
208
+ return old, new
197
209
  def pair(o: object, n: object) -> int:
198
210
  return len(str(o or "")) + len(str(n or ""))
199
211
  if "edits" in args and isinstance(args["edits"], list):
200
- return sum(
201
- pair(e.get("old_string") or e.get("oldString"), e.get("new_string") or e.get("newString"))
202
- for e in args["edits"] if isinstance(e, dict)
203
- )
204
- old = args.get("old_string") or args.get("oldString")
205
- new = args.get("new_string") or args.get("newString")
212
+ total, measured = 0, False
213
+ for e in args["edits"]:
214
+ if not isinstance(e, dict):
215
+ continue
216
+ old, new = grab(e)
217
+ if old is not None or new is not None:
218
+ measured = True
219
+ total += pair(old, new)
220
+ return total if measured else None
221
+ old, new = grab(args)
206
222
  if old is not None or new is not None:
207
223
  return pair(old, new)
208
224
  return None # Write (full content) — not a trivial edit
@@ -232,7 +248,7 @@ def _write_cost(args: dict, root: Path) -> int | None:
232
248
  """
233
249
  content = args.get("content")
234
250
  if content is None:
235
- content = args.get("new_string") or args.get("newString")
251
+ content = args.get("new_string") or args.get("newString") or args.get("new_str")
236
252
  if content is None:
237
253
  return None
238
254
  new_len = len(str(content))
@@ -460,8 +476,11 @@ def _escalation_block(rel: str, kind: str, detail: str, args: dict) -> None:
460
476
  intent = {
461
477
  k: v
462
478
  for k, v in (
463
- ("old_string", args.get("old_string", args.get("oldString"))),
464
- ("new_string", args.get("new_string", args.get("newString"))),
479
+ # Same three spellings as _changed_chars/_write_cost — an
480
+ # escalated Droid edit (old_str/new_str) must be recorded as a
481
+ # replayable intent, not silently dropped to None.
482
+ ("old_string", args.get("old_string") or args.get("oldString") or args.get("old_str")),
483
+ ("new_string", args.get("new_string") or args.get("newString") or args.get("new_str")),
465
484
  ("content", args.get("content")),
466
485
  )
467
486
  if isinstance(v, str)
@@ -868,8 +887,11 @@ def main() -> None:
868
887
  intent_payload = {
869
888
  k: v
870
889
  for k, v in (
871
- ("old_string", args.get("old_string", args.get("oldString"))),
872
- ("new_string", args.get("new_string", args.get("newString"))),
890
+ # Same three spellings as _changed_chars/_write_cost — an
891
+ # escalated Droid edit (old_str/new_str) must be recorded as a
892
+ # replayable intent, not silently dropped to None.
893
+ ("old_string", args.get("old_string") or args.get("oldString") or args.get("old_str")),
894
+ ("new_string", args.get("new_string") or args.get("newString") or args.get("new_str")),
873
895
  ("content", args.get("content")),
874
896
  )
875
897
  if isinstance(v, str)
@@ -14,8 +14,18 @@ fi
14
14
  # Read tool input from stdin (JSON)
15
15
  INPUT=$(cat)
16
16
 
17
- # Extract command from tool_input
18
- CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true)
17
+ # Extract command from tool_input, with bounded retries. Under a full
18
+ # parallel suite (or a busy box) a jq spawn can fail transiently, and a
19
+ # fail-open on that would silently allow exactly the edits this guard
20
+ # exists to block — measured as the recurring jq '.version = "9.9.9"'
21
+ # semver-suite flake (3 strikes on 2026-11-12: two CI runs, one local).
22
+ # Fail-open stays the LAST resort for genuinely unparseable input.
23
+ CMD=""
24
+ for _attempt in 1 2 3 4 5; do
25
+ CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true)
26
+ [ -n "$CMD" ] && break
27
+ sleep 0.05
28
+ done
19
29
 
20
30
  # If we can't determine the command, fail open
21
31
  if [ -z "$CMD" ]; then
@@ -178,7 +188,28 @@ fi
178
188
  # That is a READ. Splitting on ; && || | and newline keeps every real edit
179
189
  # (each has sed/awk/jq and package.json inside one statement) and drops the
180
190
  # cross-statement coincidence. Verified against both corpora before and after.
181
- if printf '%s' "$CMD" | tr ';|&\n' '\n\n\n\n' | grep -qE "(sed|awk).*package\.json.*(version|\"version\")|((sed|awk).*version.*package\.json)|(jq.*\.version.*package\.json)"; then
191
+ #
192
+ # Evaluated with BUILT-INS ONLY: parameter expansion for the statement split,
193
+ # [[ =~ ]] for the match. An earlier `printf | tr | grep -qE` pipeline looked
194
+ # equivalent but had a load-shaped hole: in an `if` condition a grep that fails
195
+ # to spawn (exit 2/126/127 under fork pressure) is indistinguishable from a
196
+ # no-match (exit 1), so the rule silently failed OPEN under the full parallel
197
+ # suite — the canary still refused, so it looked like a genuine allow. That was
198
+ # the recurring semver-suite flake (3 strikes on 2026-11-12: two CI runs, one
199
+ # local version-bump gate). Zero forks here means no spawn can hiccup, and no
200
+ # pipeline stage can mask a match.
201
+ _semv_split="${CMD//[;|&]/$'\n'}"
202
+ _semv_a='(sed|awk).*package\.json.*version'
203
+ _semv_b='(sed|awk).*version.*package\.json'
204
+ _semv_c='jq.*\.version.*package\.json'
205
+ _semv_hit=""
206
+ while IFS= read -r _semv_stmt; do
207
+ if [[ "$_semv_stmt" =~ $_semv_a || "$_semv_stmt" =~ $_semv_b || "$_semv_stmt" =~ $_semv_c ]]; then
208
+ _semv_hit="yes"
209
+ break
210
+ fi
211
+ done <<< "$_semv_split"
212
+ if [ -n "$_semv_hit" ]; then
182
213
  echo "BLOCKED [semver-versioning]: Manual package.json version edits are prohibited. Use: npm run version:patch, version:minor, or version:major. See policies/semver-versioning.md" >&2
183
214
  exit 2
184
215
  fi
@@ -233,10 +233,49 @@ if [ "$CODE_CHANGED" = "true" ] && [ "${UAP_VERIFY_ON_STOP:-1}" != "0" ] && comm
233
233
  fi
234
234
  fi
235
235
  set -e
236
+ # A green verify (or a gate that never ran) resets the consecutive-block
237
+ # counter below — a fresh red streak starts from zero.
238
+ if [ "$VERIFY_RC" = "0" ]; then
239
+ rm -f "${PROJECT_DIR}/.uap/stop-gate/blocks" 2>/dev/null || true
240
+ fi
236
241
  # RC 1 = a REAL gate failure (code is broken) → block. Everything else fails
237
242
  # OPEN: RC 3 = infra (gate timed out / couldn't spawn), 124 = outer timeout,
238
243
  # 127 = missing, any other = internal — none should ever wedge a session.
239
244
  if [ "$VERIFY_RC" = "1" ]; then
245
+ # Bounded consecutive-block relief. The re-entrancy guard at the top
246
+ # relents only when the harness sends stop_hook_active:true (the Claude
247
+ # Code contract). Harnesses that send no Stop payload (observed: Factory
248
+ # Droid on pay2u, 2026-10-18) never trip that guard, so a persistently-red
249
+ # gate — e.g. max-fidelity interaction fail-closed on a repo with entry
250
+ # pages but no manifest — became an unbounded Stop→continue→Stop loop:
251
+ # exactly the wedge this hook's fail-open design promises cannot happen.
252
+ # Count consecutive RC-1 blocks per project; relent (allow the stop, with
253
+ # the red verdict still on record) after UAP_STOP_BLOCK_LIMIT (default 3).
254
+ # A block older than an hour starts a fresh count: the counter tracks a
255
+ # LOOP, not history — a new session gets a fresh budget.
256
+ mkdir -p "${PROJECT_DIR}/.uap/stop-gate" 2>/dev/null || true
257
+ BLOCK_FILE="${PROJECT_DIR}/.uap/stop-gate/blocks"
258
+ BLOCK_COUNT=0
259
+ BLOCK_TS=0
260
+ if [ -f "$BLOCK_FILE" ]; then
261
+ BLOCK_COUNT="$(head -1 "$BLOCK_FILE" 2>/dev/null || echo 0)"
262
+ BLOCK_TS="$(tail -1 "$BLOCK_FILE" 2>/dev/null || echo 0)"
263
+ fi
264
+ case "$BLOCK_COUNT" in ''|*[!0-9]*) BLOCK_COUNT=0 ;; esac
265
+ case "$BLOCK_TS" in ''|*[!0-9]*) BLOCK_TS=0 ;; esac
266
+ NOW="$(date +%s 2>/dev/null || echo 0)"
267
+ case "$NOW" in ''|*[!0-9]*) NOW=0 ;; esac
268
+ if [ "$NOW" -gt 0 ] && [ "$((NOW - BLOCK_TS))" -gt 3600 ]; then
269
+ BLOCK_COUNT=0
270
+ fi
271
+ BLOCK_LIMIT="${UAP_STOP_BLOCK_LIMIT:-3}"
272
+ case "$BLOCK_LIMIT" in ''|*[!0-9]*) BLOCK_LIMIT=3 ;; esac
273
+ if [ "$BLOCK_COUNT" -ge "$BLOCK_LIMIT" ]; then
274
+ echo ""
275
+ echo "## RUNTIME/USER-PATH GATE still red after $BLOCK_COUNT consecutive blocks — allowing stop so the session cannot wedge. The verdict above stays on record; fix the failure in a follow-up session. (Bound: UAP_STOP_BLOCK_LIMIT=$BLOCK_LIMIT; full bypass: UAP_VERIFY_ON_STOP=0.)"
276
+ exit 0
277
+ fi
278
+ printf '%s\n%s\n' "$((BLOCK_COUNT + 1))" "$NOW" > "$BLOCK_FILE" 2>/dev/null || true
240
279
  echo ""
241
280
  echo "## RUNTIME/USER-PATH GATE FAILED — the code does not run or does not work for a real user"
242
281
  echo "$VERIFY_OUT"
@@ -1621,11 +1621,23 @@ PROXY_EMPTY_TOOL_LOOP_BREAK = int(
1621
1621
  # ever-growing transcript (observed: ctx 936%, model emits tool_calls 18/min
1622
1622
  # despite tool_choice=auto). After this many consecutive turns at/above the
1623
1623
  # ratio, strip tools entirely so the only possible response is a terminal text
1624
- # summary (end_turn), which ends the client's agentic loop. Ratio is set high
1625
- # enough that only a true runaway trips it — a merely-full session tops out near
1626
- # 100-130%, never 300%. 0 disables.
1624
+ # summary (end_turn), which ends the client's agentic loop.
1625
+ #
1626
+ # Ratio sits above the merely-full band but inside the runaway band. A
1627
+ # merely-full session tops out near 100-141%: a client that honestly believes
1628
+ # a ~200k window (PROXY_CLIENT_ASSUMED_WINDOW) and compacts at 92.5% sends at
1629
+ # most ~185k against a 131,072 rail. A window-blind runaway plateaus wherever
1630
+ # its own belief caps it, and that plateau can sit far below 300%: live
1631
+ # 2026-11-06 (droid + flash-next, cognition-engine), a client with no
1632
+ # maxContextLimit (assumed 250k) ramped 132% -> 198% in 4 minutes and then sat
1633
+ # at 190-198% for 4+ HOURS — every turn CRITICAL-PRUNEd blind to ~6 messages
1634
+ # (~24-40k actually sent upstream), findings evaporating, the model re-reading
1635
+ # the same files — because the 3.0 gate was written for 936% runaways and this
1636
+ # one never crossed 200%. 1.6 clears the merely-full band (141% plus one
1637
+ # ~19k-token turn of headroom) while ending that class of treadmill within
1638
+ # STREAK turns. 0 disables.
1627
1639
  PROXY_RAW_CTX_FINALIZE_RATIO = float(
1628
- os.environ.get("PROXY_RAW_CTX_FINALIZE_RATIO", "3.0")
1640
+ os.environ.get("PROXY_RAW_CTX_FINALIZE_RATIO", "1.6")
1629
1641
  )
1630
1642
  PROXY_RAW_CTX_FINALIZE_STREAK = int(
1631
1643
  os.environ.get("PROXY_RAW_CTX_FINALIZE_STREAK", "2")
@@ -94,6 +94,32 @@ class WriteComplexityTest(unittest.TestCase):
94
94
  self.assertEqual(small[0], 0, small[1])
95
95
  self.assertEqual(big[0], 2, big[1])
96
96
 
97
+ def test_droid_edit_spelling_is_measured_the_same(self):
98
+ # Droid (Factory) sends old_str/new_str. Before 2026-11-12 those
99
+ # spellings measured as None — "whole-file rewrite" — so EVERY Droid
100
+ # Edit escalated as complex regardless of size. Same sizes as the
101
+ # Claude-spelling case must get the same verdicts.
102
+ root = project()
103
+ target = os.path.join(root, "src", "thing.ts")
104
+ small = run(root, {"file_path": target, "old_str": "a" * 20, "new_str": "b" * 20}, op="Edit")
105
+ big = run(root, {"file_path": target, "old_str": "a" * 300, "new_str": "b" * 300}, op="Edit")
106
+ self.assertEqual(small[0], 0, small[1])
107
+ self.assertEqual(big[0], 2, big[1])
108
+
109
+ def test_multiedit_droid_spelling_sums_across_edits(self):
110
+ root = project()
111
+ target = os.path.join(root, "src", "multi.ts")
112
+ code_ok, out_ok = run(root, {"file_path": target, "edits": [
113
+ {"old_str": "a" * 20, "new_str": "b" * 20},
114
+ {"old_str": "c" * 20, "new_str": "d" * 20},
115
+ ]}, op="MultiEdit")
116
+ code_big, _ = run(root, {"file_path": target, "edits": [
117
+ {"old_str": "a" * 200, "new_str": "b" * 200},
118
+ {"old_str": "c" * 200, "new_str": "d" * 200},
119
+ ]}, op="MultiEdit")
120
+ self.assertEqual(code_ok, 0, out_ok)
121
+ self.assertEqual(code_big, 2)
122
+
97
123
  def test_nature_exemptions_are_unchanged(self):
98
124
  root = project()
99
125
  docs = run(root, {"file_path": os.path.join(root, "docs", "n.md"), "content": "x" * 9000})