@miller-tech/uap 1.183.3 → 1.184.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 (68) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/bin/cli.js +14 -0
  3. package/dist/bin/cli.js.map +1 -1
  4. package/dist/cli/principles.d.ts +10 -0
  5. package/dist/cli/principles.d.ts.map +1 -0
  6. package/dist/cli/principles.js +138 -0
  7. package/dist/cli/principles.js.map +1 -0
  8. package/dist/config/policy-recommendations.d.ts.map +1 -1
  9. package/dist/config/policy-recommendations.js +17 -0
  10. package/dist/config/policy-recommendations.js.map +1 -1
  11. package/dist/config/settings-registry.d.ts +1 -1
  12. package/dist/config/settings-registry.d.ts.map +1 -1
  13. package/dist/config/settings-registry.js +24 -0
  14. package/dist/config/settings-registry.js.map +1 -1
  15. package/dist/coordination/reactor.d.ts.map +1 -1
  16. package/dist/coordination/reactor.js +15 -0
  17. package/dist/coordination/reactor.js.map +1 -1
  18. package/dist/delivery/convergence-loop.d.ts +7 -1
  19. package/dist/delivery/convergence-loop.d.ts.map +1 -1
  20. package/dist/delivery/convergence-loop.js +32 -4
  21. package/dist/delivery/convergence-loop.js.map +1 -1
  22. package/dist/delivery/judge.d.ts.map +1 -1
  23. package/dist/delivery/judge.js +6 -0
  24. package/dist/delivery/judge.js.map +1 -1
  25. package/dist/delivery/task-orchestrator.d.ts.map +1 -1
  26. package/dist/delivery/task-orchestrator.js +8 -1
  27. package/dist/delivery/task-orchestrator.js.map +1 -1
  28. package/dist/principles/config.d.ts +10 -0
  29. package/dist/principles/config.d.ts.map +1 -0
  30. package/dist/principles/config.js +32 -0
  31. package/dist/principles/config.js.map +1 -0
  32. package/dist/principles/index.d.ts +16 -0
  33. package/dist/principles/index.d.ts.map +1 -0
  34. package/dist/principles/index.js +34 -0
  35. package/dist/principles/index.js.map +1 -0
  36. package/dist/principles/reactor-inject.d.ts +12 -0
  37. package/dist/principles/reactor-inject.d.ts.map +1 -0
  38. package/dist/principles/reactor-inject.js +69 -0
  39. package/dist/principles/reactor-inject.js.map +1 -0
  40. package/dist/principles/render.d.ts +14 -0
  41. package/dist/principles/render.d.ts.map +1 -0
  42. package/dist/principles/render.js +64 -0
  43. package/dist/principles/render.js.map +1 -0
  44. package/dist/principles/rules.d.ts +53 -0
  45. package/dist/principles/rules.d.ts.map +1 -0
  46. package/dist/principles/rules.js +101 -0
  47. package/dist/principles/rules.js.map +1 -0
  48. package/dist/principles/stance.d.ts +54 -0
  49. package/dist/principles/stance.d.ts.map +1 -0
  50. package/dist/principles/stance.js +154 -0
  51. package/dist/principles/stance.js.map +1 -0
  52. package/dist/types/config.d.ts +46 -0
  53. package/dist/types/config.d.ts.map +1 -1
  54. package/dist/types/config.js +17 -0
  55. package/dist/types/config.js.map +1 -1
  56. package/docs/guides/POLICIES.md +43 -0
  57. package/docs/reference/CONFIGURATION_REFERENCE.md +221 -0
  58. package/package.json +2 -2
  59. package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
  60. package/src/policies/enforcers/codebase_read_before_plan.py +33 -1
  61. package/src/policies/enforcers/expert_review_required.py +90 -5
  62. package/src/policies/enforcers/memory_before_plan.py +24 -5
  63. package/src/policies/enforcers/workdir_scope.py +150 -6
  64. package/src/policies/schemas/policies/engineering-principles.md +93 -0
  65. package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
  66. package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
  67. package/tools/agents/tests/test_expert_review_pr_scope.py +201 -0
  68. package/tools/agents/tests/test_workdir_scope_enforcer.py +152 -0
@@ -26,6 +26,7 @@ uap config wizard # interactive expert configurator (also: uap set
26
26
  - [Orchestrator & hands-free](#orchestration) — Long-task autonomy: decompose, resume, and loop-to-100%.
27
27
  - [Reactor (auto-apply)](#reactor) — Per-prompt injection of the matching experts, skills, and patterns.
28
28
  - [Design system](#design) — DESIGN.md interrogation and the hard token gate for UI work.
29
+ - [Engineering principles](#principles) — How code should be written, and the backward-compatibility stance.
29
30
  - [Worktree workflow](#worktree) — Branch-per-feature isolation and auto-cleanup.
30
31
  - [Inference proxy tuning](#proxy) — Guardrails and context limits for a local model behind the proxy.
31
32
  - [Dashboard](#dashboard) — The live analytics server and its mutation token.
@@ -60,6 +61,18 @@ How local-model sessions are routed through delivery. `deliver` runs builds thro
60
61
 
61
62
  **Recommendation:** `deliver` when a local model does the writing (routes it through the verified loop); `advisory` for exploratory work.
62
63
 
64
+ ### `deliver.escalateModel`
65
+
66
+ | | |
67
+ |---|---|
68
+ | **Where** | `.uap.json` |
69
+ | **Type** | string |
70
+ | **Default** | `null` |
71
+
72
+ Stronger model id for deliver escalation ladders: repair passes, the phase-5 escalation tier, and the evaluator fallback. Same role as $UAP_ESCALATE_MODEL but persisted in .uap.json (reproducible).
73
+
74
+ **Recommendation:** Point at your strongest available preset/model (e.g. an Opus cloud id) when the executor is a local model — stuck epics then escalate instead of re-splitting into the same wall.
75
+
63
76
  ### `UAP_ENFORCE_DELIVERY`
64
77
 
65
78
  | | |
@@ -100,6 +113,30 @@ Installs the runtime-verify Stop-hook: at end of turn it actually runs the chang
100
113
 
101
114
  **Recommendation:** Enable for any project with a runnable artifact — it catches "declared done but never ran". Safe on empty projects (it skips when nothing is runnable).
102
115
 
116
+ ### `delivery.userValidation`
117
+
118
+ | | |
119
+ |---|---|
120
+ | **Where** | `.uap.json` |
121
+ | **Type** | string |
122
+ | **Default** | `block` |
123
+
124
+ User-path validation gate: deliver runs the .uap/user-paths.json critical journeys through the real client (headless browser / HTTP / built CLI) as the terminal gate rung. block = DELIVERED requires them green; advisory = report only; off = disabled.
125
+
126
+ **Recommendation:** Leave on block — it is the only gate that proves the artifact works for a real user, not just that tests pass.
127
+
128
+ ### `UAP_USER_VALIDATION`
129
+
130
+ | | |
131
+ |---|---|
132
+ | **Where** | shell env |
133
+ | **Type** | boolean |
134
+ | **Default** | `true` |
135
+
136
+ Runtime downgrade for the user-validation gate: `0` demotes block to advisory for this run only. Persisting `0` is blocked by the self-protect enforcer.
137
+
138
+ **Recommendation:** Leave unset. Use inline `UAP_USER_VALIDATION=0 uap deliver ...` only to unblock a run where the gate itself misfires.
139
+
103
140
  ### `UAP_VERIFY_ON_STOP`
104
141
 
105
142
  | | |
@@ -112,6 +149,78 @@ Master switch for the runtime execution gate in the Stop hook. `0` bypasses it.
112
149
 
113
150
  **Recommendation:** Leave on (default). Set `0` only to unblock a session where the runtime gate misfires.
114
151
 
152
+ ### `fidelity.mode`
153
+
154
+ | | |
155
+ |---|---|
156
+ | **Where** | `.uap.json` |
157
+ | **Type** | enum (standard \| max) |
158
+ | **Default** | `standard` |
159
+
160
+ Maximum-fidelity mode. `max` flips every verification default to its strongest: raised verifier floor (runtime+integration), acceptance judge required, blocking vision review, and a fail-CLOSED visual gate — a delivery is accepted only when it builds, runs, looks right, and matches the spec. (`UAP_FIDELITY` overrides at runtime.)
161
+
162
+ **Recommendation:** `max` when correctness matters more than speed (UI work, releases, hands-free autonomy). `standard` for fast exploratory iteration.
163
+
164
+ ### `UAP_FIDELITY`
165
+
166
+ | | |
167
+ |---|---|
168
+ | **Where** | shell env |
169
+ | **Type** | enum (standard \| max) |
170
+ | **Default** | `standard` |
171
+
172
+ Runtime override of `fidelity.mode`, read from the shell env by verify/deliver and the Python enforcers. Takes precedence over the config value.
173
+
174
+ **Recommendation:** Set inline (`UAP_FIDELITY=max <cmd>`) to force max fidelity for one command without editing config.
175
+
176
+ ### `fidelity.visionMinScore`
177
+
178
+ | | |
179
+ |---|---|
180
+ | **Where** | `.uap.json` |
181
+ | **Type** | number |
182
+ | **Default** | `6` |
183
+
184
+ Minimum aesthetic score (0–10) the vision judge must give a rendered UI before it passes under `max` fidelity.
185
+
186
+ **Recommendation:** 6 is a reasonable "looks like a real, polished app" bar. Raise toward 8 for design-critical surfaces; lower to 4 to only catch broken/blank UIs.
187
+
188
+ ### `fidelity.visualBaselines`
189
+
190
+ | | |
191
+ |---|---|
192
+ | **Where** | `.uap.json` |
193
+ | **Type** | boolean |
194
+ | **Default** | `true` |
195
+
196
+ Keep approved UI screenshots as regression baselines under `.uap/visual/baseline/` and block on visual drift beyond threshold on later runs.
197
+
198
+ **Recommendation:** Leave on so accepted UIs are pinned against regressions. Disable for throwaway prototypes where every render legitimately differs.
199
+
200
+ ### `UAP_VISION_ENDPOINT`
201
+
202
+ | | |
203
+ |---|---|
204
+ | **Where** | `.uap/proxy.env` |
205
+ | **Type** | string |
206
+ | **Default** | `` |
207
+
208
+ Base URL of an OpenAI-compatible, image_url-capable endpoint used for aesthetic screenshot review (e.g. http://127.0.0.1:8080/v1). Defaults to the local model when set by setup.
209
+
210
+ **Recommendation:** Point at your local vision-capable model so aesthetic review runs offline with no per-image cost.
211
+
212
+ ### `UAP_VISION_MODEL`
213
+
214
+ | | |
215
+ |---|---|
216
+ | **Where** | `.uap/proxy.env` |
217
+ | **Type** | string |
218
+ | **Default** | `` |
219
+
220
+ Model id sent to the vision endpoint for aesthetic review (e.g. qwen36-35b-a3b-iq4xs).
221
+
222
+ **Recommendation:** Set by `uap setup` to your local vision model. Required for blocking vision review under `max` fidelity.
223
+
115
224
  ## Model routing
116
225
 
117
226
  <a id="routing"></a>Which model handles planning, execution, and review.
@@ -482,6 +591,58 @@ Hard-blocks UI edits that hardcode off-token colors or off-scale spacing.
482
591
 
483
592
  **Recommendation:** Enable once your DESIGN.md tokens are stable — it keeps the UI on-system automatically.
484
593
 
594
+ ## Engineering principles
595
+
596
+ <a id="principles"></a>How code should be written, and the backward-compatibility stance.
597
+
598
+ ### `principles.enabled`
599
+
600
+ | | |
601
+ |---|---|
602
+ | **Where** | `.uap.json` |
603
+ | **Type** | boolean |
604
+ | **Default** | `true` |
605
+
606
+ Applies the engineering principles (simplest sufficient implementation, reuse over reinvention, no stopgaps, prior art first) to generated code.
607
+
608
+ **Recommendation:** Leave on. Turn it off only if your project has its own conflicting house style.
609
+
610
+ ### `principles.compat`
611
+
612
+ | | |
613
+ |---|---|
614
+ | **Where** | `.uap.json` |
615
+ | **Type** | enum (ask \| preserve \| remove) |
616
+ | **Default** | `ask` |
617
+
618
+ Backward-compatibility stance. `remove` deletes obsolete paths outright; `preserve` keeps them working and migrates callers; `ask` (default) prompts once per session instead of guessing.
619
+
620
+ **Recommendation:** Leave on `ask` unless the project's answer is settled. `remove` is for side projects — on anything published it tells the agent to delete migration paths.
621
+
622
+ ### `principles.maturity`
623
+
624
+ | | |
625
+ |---|---|
626
+ | **Where** | `.uap.json` |
627
+ | **Type** | enum (ask \| greenfield \| production) |
628
+ | **Default** | `ask` |
629
+
630
+ What breaking a caller costs. `production` adds caveats about existing callers and dependency cost; `greenfield` states the rules absolutely.
631
+
632
+ **Recommendation:** Set `production` for anything with real users; `greenfield` for a fresh side project.
633
+
634
+ ### `principles.injectDeliver`
635
+
636
+ | | |
637
+ |---|---|
638
+ | **Where** | `.uap.json` |
639
+ | **Type** | boolean |
640
+ | **Default** | `true` |
641
+
642
+ Injects the compact principles block into deliver prompts, so generated code follows them rather than only the agent preamble.
643
+
644
+ **Recommendation:** Leave on. Disable only if you are tight on prompt budget with a small-context model.
645
+
485
646
  ## Worktree workflow
486
647
 
487
648
  <a id="worktree"></a>Branch-per-feature isolation and auto-cleanup.
@@ -651,6 +812,66 @@ Dashboard data refresh interval in milliseconds (floor 250).
651
812
 
652
813
  **Recommendation:** 2000 is fine; lower for a more live feel at higher CPU cost.
653
814
 
815
+ ### `proxy.dashboard`
816
+
817
+ | | |
818
+ |---|---|
819
+ | **Where** | `.uap.json` |
820
+ | **Type** | boolean |
821
+ | **Default** | `true` |
822
+
823
+ Ride-along dashboard: `uap proxy ensure|start` also starts (or adopts) `uap dashboard serve`, and release/stop tears it down under the same ownership rules — so a session gets monitoring without running a second command. Only takes effect where the proxy itself runs (hooks gate on proxy.autostart).
824
+
825
+ **Recommendation:** Leave on. Turn off if you prefer to run `uap dash serve` yourself, or the port is spoken for.
826
+
827
+ ### `UAP_PROXY_DASHBOARD`
828
+
829
+ | | |
830
+ |---|---|
831
+ | **Where** | shell env |
832
+ | **Type** | boolean |
833
+ | **Default** | `true` |
834
+
835
+ Force the ride-along dashboard on (1/on/true) or off (0/off/false). Wins over .uap.json proxy.dashboard.
836
+
837
+ **Recommendation:** Use for one-off overrides; prefer `uap proxy dashboard on|off` for a durable project setting.
838
+
839
+ ### `UAP_DASH_PORT`
840
+
841
+ | | |
842
+ |---|---|
843
+ | **Where** | shell env |
844
+ | **Type** | number |
845
+ | **Default** | `3847` |
846
+
847
+ Port the ride-along dashboard binds and is probed on (1-65535). Does not change `uap dash serve --port`.
848
+
849
+ **Recommendation:** Change only on a port clash; keep it consistent across sessions so adoption works.
850
+
851
+ ### `UAP_DASH_HOST`
852
+
853
+ | | |
854
+ |---|---|
855
+ | **Where** | shell env |
856
+ | **Type** | string |
857
+ | **Default** | `localhost` |
858
+
859
+ Interface the ride-along dashboard binds. `0.0.0.0` exposes it beyond this machine.
860
+
861
+ **Recommendation:** Keep localhost. Bind wider only behind a trusted network, and set UAP_DASHBOARD_TOKEN when you do.
862
+
863
+ ### `UAP_DASH_HEALTH_WAIT_MS`
864
+
865
+ | | |
866
+ |---|---|
867
+ | **Where** | shell env |
868
+ | **Type** | number |
869
+ | **Default** | `10000` |
870
+
871
+ How long `uap proxy ensure` waits for the ride-along dashboard to serve before giving up.
872
+
873
+ **Recommendation:** Lower it if session start feels slow on a cold dashboard; the proxy is unaffected either way.
874
+
654
875
  ## Token & time optimization
655
876
 
656
877
  <a id="optimization"></a>Context budgets, caching, batching, and parallelism.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "1.183.3",
3
+ "version": "1.184.1",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -21,7 +21,7 @@
21
21
  "start": "node dist/bin/cli.js",
22
22
  "test": "vitest",
23
23
  "test:ci": "vitest run",
24
- "test:enforcers": "UAP_PROXY_ENV_AUTOLOAD=0 python3 -m unittest tools.agents.tests.test_enforcement_self_protect tools.agents.tests.test_schema_diff_gate tools.agents.tests.test_stream_telemetry tools.agents.tests.test_project_telemetry_events tools.agents.tests.test_workdir_scope_enforcer tools.agents.tests.test_path_containment tools.agents.tests.test_path_normalizer_hardened tools.agents.tests.test_sandbox_tool_strip tools.agents.tests.test_proxy_env_loader tools.agents.tests.test_stream_required_tool tools.agents.tests.test_tool_call_wrapper_profiles tools.agents.tests.test_tool_convert_cache tools.agents.tests.test_doubling_break tools.agents.tests.test_error_loop_ignores_correctives tools.agents.tests.test_attractor_detection tools.agents.tests.test_client_disconnect tools.agents.tests.test_confidence_escalation tools.agents.tests.test_coordination_ban tools.agents.tests.test_coordination_early_ban tools.agents.tests.test_cycle_break_exploration tools.agents.tests.test_deferral_break tools.agents.tests.test_deliver_autoroute tools.agents.tests.test_delivery_enforcement_all_langs tools.agents.tests.test_delivery_enforcement_exemptions tools.agents.tests.test_delivery_enforcement_filepath tools.agents.tests.test_delivery_enforcement_web_and_bash tools.agents.tests.test_disconnect_watcher tools.agents.tests.test_empty_maxtokens_recovery tools.agents.tests.test_empty_tool_loop_break tools.agents.tests.test_enforcer_escape_hatches tools.agents.tests.test_error_loop_break tools.agents.tests.test_finalize_suppression tools.agents.tests.test_malformed_unclosed_think tools.agents.tests.test_mandate_beats_recon tools.agents.tests.test_mandate_deliver tools.agents.tests.test_overflow_truncate_count_tokens tools.agents.tests.test_passthrough_oauth tools.agents.tests.test_project_telemetry tools.agents.tests.test_proxy_auth_headers tools.agents.tests.test_prune_preserve_force_write tools.agents.tests.test_recon_deliver_gate tools.agents.tests.test_session_admission tools.agents.tests.test_stream_heartbeat tools.agents.tests.test_stuck_break_reattach tools.agents.tests.test_turn_count_breaker_periodic tools.agents.tests.test_upstream_chokepoint tools.agents.tests.test_vision_passthrough tools.agents.tests.test_worktree_required tools.agents.tests.test_enforcer_suite_coverage tools.agents.tests.test_validate_plan_gate tools.agents.tests.test_validate_plan_inside_project tools.agents.tests.test_anthropic_proxy_streaming tools.agents.tests.test_delivery_enforcement_worktree tools.agents.tests.test_output_token_ceilings tools.agents.tests.test_tool_narrowing_core",
24
+ "test:enforcers": "UAP_PROXY_ENV_AUTOLOAD=0 python3 -m unittest tools.agents.tests.test_enforcement_self_protect tools.agents.tests.test_schema_diff_gate tools.agents.tests.test_stream_telemetry tools.agents.tests.test_project_telemetry_events tools.agents.tests.test_workdir_scope_enforcer tools.agents.tests.test_expert_review_pr_scope tools.agents.tests.test_path_containment tools.agents.tests.test_path_normalizer_hardened tools.agents.tests.test_sandbox_tool_strip tools.agents.tests.test_proxy_env_loader tools.agents.tests.test_stream_required_tool tools.agents.tests.test_tool_call_wrapper_profiles tools.agents.tests.test_tool_convert_cache tools.agents.tests.test_doubling_break tools.agents.tests.test_error_loop_ignores_correctives tools.agents.tests.test_attractor_detection tools.agents.tests.test_client_disconnect tools.agents.tests.test_confidence_escalation tools.agents.tests.test_coordination_ban tools.agents.tests.test_coordination_early_ban tools.agents.tests.test_cycle_break_exploration tools.agents.tests.test_deferral_break tools.agents.tests.test_deliver_autoroute tools.agents.tests.test_delivery_enforcement_all_langs tools.agents.tests.test_delivery_enforcement_exemptions tools.agents.tests.test_delivery_enforcement_filepath tools.agents.tests.test_delivery_enforcement_web_and_bash tools.agents.tests.test_disconnect_watcher tools.agents.tests.test_empty_maxtokens_recovery tools.agents.tests.test_empty_tool_loop_break tools.agents.tests.test_enforcer_escape_hatches tools.agents.tests.test_error_loop_break tools.agents.tests.test_finalize_suppression tools.agents.tests.test_malformed_unclosed_think tools.agents.tests.test_mandate_beats_recon tools.agents.tests.test_mandate_deliver tools.agents.tests.test_overflow_truncate_count_tokens tools.agents.tests.test_passthrough_oauth tools.agents.tests.test_project_telemetry tools.agents.tests.test_proxy_auth_headers tools.agents.tests.test_prune_preserve_force_write tools.agents.tests.test_recon_deliver_gate tools.agents.tests.test_session_admission tools.agents.tests.test_stream_heartbeat tools.agents.tests.test_stuck_break_reattach tools.agents.tests.test_turn_count_breaker_periodic tools.agents.tests.test_upstream_chokepoint tools.agents.tests.test_vision_passthrough tools.agents.tests.test_worktree_required tools.agents.tests.test_enforcer_suite_coverage tools.agents.tests.test_validate_plan_gate tools.agents.tests.test_validate_plan_inside_project tools.agents.tests.test_anthropic_proxy_streaming tools.agents.tests.test_delivery_enforcement_worktree tools.agents.tests.test_output_token_ceilings tools.agents.tests.test_tool_narrowing_core",
25
25
  "test:coverage": "vitest --coverage",
26
26
  "bench": "vitest --config vitest.bench.config.ts",
27
27
  "lint": "eslint src --ext .ts",
@@ -8,12 +8,41 @@ import time
8
8
  from pathlib import Path
9
9
 
10
10
  sys.path.insert(0, str(Path(__file__).parent))
11
- from _common import arg_str, emit, parse_cli # noqa: E402
11
+ from _common import arg_str, emit, parse_cli, repo_root # noqa: E402
12
12
 
13
13
  PLAN_OPS = {"ExitPlanMode", "Plan", "TodoWrite"}
14
14
  PLAN_WORD_RE = re.compile(r"(?<![-\w/])(plan the|design the|architect the|propose a plan|spec the)", re.I)
15
15
  READ_LOG = Path(os.environ.get("UAP_STATE_DIR", ".uap")) / "read_log.state"
16
16
  RECENT_SEC = 1800
17
+ # The PostToolUse hook that writes READ_LOG. This gate accepts evidence that
18
+ # ONLY that hook produces, so without it the evidence can never appear.
19
+ #
20
+ # Checked per platform, not just under .claude/: copyHookScripts() drops the
21
+ # script into EVERY platform's hook dir, but each platform needs its own
22
+ # PostToolUse wiring. A hardcoded .claude/ probe would report "writer present"
23
+ # for a Factory or Cursor session whose settings never invoke it — enforcing
24
+ # strictly against evidence that platform cannot produce, which is the same
25
+ # permanent block this fail-open exists to prevent.
26
+ WRITER_HOOK_DIRS = (".claude", ".factory", ".cursor", ".codex", ".forge", ".opencode")
27
+ WRITER_HOOK_NAME = "post-tool-use-read.sh"
28
+
29
+
30
+ def writer_installed() -> bool:
31
+ """True when the hook that populates READ_LOG is installed for this platform.
32
+
33
+ Load-bearing. For a long time nothing wrote read_log.state at all: the
34
+ matcher was never added to settings.json, the last entries aged past
35
+ RECENT_SEC, and this gate then refused every ExitPlanMode with a remedy
36
+ ("read the codebase first") that no amount of reading could clear. A gate
37
+ whose writer is missing silently escalates from advisory to a wall, so when
38
+ the writer is absent we degrade to advisory instead of bricking planning.
39
+ """
40
+ roots = (repo_root(), Path.cwd())
41
+ for root in roots:
42
+ for hook_dir in WRITER_HOOK_DIRS:
43
+ if (root / hook_dir / "hooks" / WRITER_HOOK_NAME).exists():
44
+ return True
45
+ return False
17
46
 
18
47
 
19
48
  def recent_reads() -> set[str]:
@@ -41,6 +70,9 @@ def main() -> None:
41
70
  if reads:
42
71
  emit(True, f"{len(reads)} recent codebase reads on record")
43
72
 
73
+ if not writer_installed():
74
+ emit(True, "read-log writer hook not installed — gate advisory (run `uap hooks install`)")
75
+
44
76
  emit(
45
77
  False,
46
78
  "codebase-read-before-plan: no Read/Grep/Glob within the last 30 min. "
@@ -2,8 +2,9 @@
2
2
  """expert-review-required enforcer: a parallel expert review must precede ship.
3
3
 
4
4
  Blocks ship actions (git commit / git push / gh pr create / merge / pr-ready /
5
- signoff) unless a review artifact exists for the current branch AND covers the
6
- current HEAD. This makes the `parallel-expert-review` skill's "REQUIRED by
5
+ signoff) unless a review artifact exists for the branch being shipped AND covers
6
+ its HEAD. For `gh pr merge <N>` the branch being shipped is the PR's head
7
+ branch, resolved via gh — not whatever branch the invoking shell is on. This makes the `parallel-expert-review` skill's "REQUIRED by
7
8
  policy" claim real rather than advisory.
8
9
 
9
10
  Review artifact: .uap/reviews/<branch-slug>.json, written by the
@@ -20,6 +21,7 @@ from __future__ import annotations
20
21
  import json
21
22
  import os
22
23
  import re
24
+ import shlex
23
25
  import sys
24
26
  from pathlib import Path
25
27
 
@@ -37,6 +39,19 @@ SHIP_PATTERNS = (
37
39
  re.compile(r"\b(pr[-_ ]?ready|sign[-_ ]?off|ready[-_ ]for[-_ ]review)\b", re.I),
38
40
  )
39
41
 
42
+ # A ship action that NAMES a pull request. The review that matters is the one
43
+ # for that PR's head branch, which is usually not the branch the shell is on.
44
+ PR_SHIP_VERBS = ("merge", "ready")
45
+
46
+ # Flags on those verbs that consume the NEXT token as their value. Without this,
47
+ # `gh pr merge -b 1 900` reads "1" as the PR — so the review for PR 1 authorises
48
+ # shipping PR 900. `gh pr merge --body Merging 123` misfires the same way by
49
+ # accident, which is the more likely path to it happening.
50
+ PR_VALUE_FLAGS = frozenset({
51
+ "-b", "--body", "-F", "--body-file", "-t", "--subject",
52
+ "-R", "--repo", "--match-head-commit", "-c", "--comment",
53
+ })
54
+
40
55
  # Risk-scope: a parallel expert review is required only for *substantive* diffs.
41
56
  # A diff that touches ONLY low-risk surfaces (frontend/styles, docs, config,
42
57
  # tests, assets) ships freely — trivial/frontend PRs aren't gated. High-risk
@@ -115,6 +130,64 @@ def slug_for(branch: str) -> str:
115
130
  return branch.replace("%", "%25").replace("/", "%2F")
116
131
 
117
132
 
133
+ def pr_reference(cmd: str) -> str | None:
134
+ """The pull request a `gh pr merge|ready` command names, or None.
135
+
136
+ Tokenized rather than pattern-matched on "digits right after the verb":
137
+ flags may come first (`gh pr merge --squash 645`), and gh accepts a number,
138
+ a URL, or a branch name interchangeably. The narrow form missed all of
139
+ those and fell back to the local branch — silently reinstating the very bug
140
+ this resolution exists to fix.
141
+
142
+ A bare `gh pr merge` (the current branch's PR) returns None, which is
143
+ correct: the local branch IS the right thing to check then.
144
+ """
145
+ try:
146
+ tokens = shlex.split(cmd, comments=True)
147
+ except ValueError:
148
+ return None
149
+ for i in range(len(tokens) - 2):
150
+ if (
151
+ os.path.basename(tokens[i]) == "gh"
152
+ and tokens[i + 1] == "pr"
153
+ and tokens[i + 2] in PR_SHIP_VERBS
154
+ ):
155
+ skip_value = False
156
+ for tok in tokens[i + 3:]:
157
+ if skip_value:
158
+ skip_value = False
159
+ continue
160
+ if tok.startswith("-"):
161
+ # `--body=x` carries its value inline; `--body x` does not,
162
+ # and that value can look exactly like a PR reference.
163
+ if tok in PR_VALUE_FLAGS:
164
+ skip_value = True
165
+ continue
166
+ return tok
167
+ return None
168
+ return None
169
+
170
+
171
+ def pr_target(root: Path, ref: str) -> tuple[str | None, str | None]:
172
+ """(head branch, head sha) of the PR being shipped, resolved via `gh`.
173
+
174
+ Returns (None, None) on any failure — no gh, no network, no auth, unknown
175
+ PR — so the caller falls back to the local branch and this stays fail-open.
176
+ `run` already bounds the call at 5s, and PR_SHIP_RE only matches an explicit
177
+ `gh pr merge/ready <N>`, so the cost is paid on ship actions, not per Bash.
178
+ """
179
+ rc, out, _ = run(
180
+ ["gh", "pr", "view", ref, "--json", "headRefName,headRefOid"], cwd=root
181
+ )
182
+ if rc != 0 or not out.strip():
183
+ return None, None
184
+ try:
185
+ data = json.loads(out)
186
+ except Exception: # noqa: BLE001
187
+ return None, None
188
+ return (data.get("headRefName") or None), (data.get("headRefOid") or None)
189
+
190
+
118
191
  def head_sha(root: Path) -> str | None:
119
192
  rc, out, _ = run(["git", "rev-parse", "HEAD"], cwd=root)
120
193
  return out.strip() if rc == 0 and out.strip() else None
@@ -158,7 +231,16 @@ def main() -> None:
158
231
  # to MAIN_ROOT by the gate, so it always read the main checkout's branch and
159
232
  # demanded a review for the wrong branch on every worktree commit/push.
160
233
  root = worktree_root()
161
- branch = current_branch(root)
234
+
235
+ # `gh pr merge 645` ships PR 645's branch. Reading the LOCAL branch here
236
+ # meant a merge run from the main checkout looked for .uap/reviews/master
237
+ # .json — an artifact for a branch that is not being shipped — and refused
238
+ # a PR whose own branch was reviewed and approved. Resolve the PR's head
239
+ # instead; fall back to the local branch when gh cannot answer.
240
+ pr_ref = pr_reference(cmd)
241
+ pr_branch, pr_sha = pr_target(root, pr_ref) if pr_ref else (None, None)
242
+
243
+ branch = pr_branch or current_branch(root)
162
244
  if branch is None:
163
245
  emit(True, "branch not resolvable (detached/non-git) — fail-open")
164
246
  slug = slug_for(branch)
@@ -172,7 +254,10 @@ def main() -> None:
172
254
  # migrations, or policy code — the change ships without a parallel review.
173
255
  # When the base diff is not resolvable (None) we do NOT skip: we can't prove
174
256
  # the change is low-risk, so the review requirement below still applies.
175
- changed = _changed_files(root)
257
+ # Skipped for a PR ship: this diffs the LOCAL working tree, which on a
258
+ # `gh pr merge` from the main checkout is not the PR's contents at all —
259
+ # it would grade the wrong change as low-risk.
260
+ changed = None if pr_branch else _changed_files(root)
176
261
  if changed and all(_is_low_risk(f) for f in changed):
177
262
  emit(
178
263
  True,
@@ -191,7 +276,7 @@ def main() -> None:
191
276
  "(no longer honoured inline), or a waiver file.",
192
277
  )
193
278
 
194
- head = head_sha(root)
279
+ head = pr_sha or head_sha(root)
195
280
  try:
196
281
  data = json.loads(review.read_text())
197
282
  except Exception: # noqa: BLE001
@@ -8,16 +8,34 @@ import time
8
8
  from pathlib import Path
9
9
 
10
10
  sys.path.insert(0, str(Path(__file__).parent))
11
- from _common import arg_str, emit, parse_cli, repo_root # noqa: E402
11
+ from _common import arg_str, emit, parse_cli, repo_root, worktree_root # noqa: E402
12
12
 
13
13
  PLAN_OPS = {"ExitPlanMode", "Plan", "TodoWrite", "plan", "design"}
14
14
  # Only match standalone words, not compounds like 'validate-plan-on-change'
15
15
  PLAN_WORD_RE = re.compile(r"(?<![-\w/])(plan the|design the|architect the|propose a plan|roadmap for)", re.I)
16
16
  RECENT_SEC = 300
17
+ DB_REL = Path("agents") / "data" / "memory" / "short_term.db"
17
18
 
18
19
 
19
- def recent_memory_query(root: Path) -> bool:
20
- db = root / "agents" / "data" / "memory" / "short_term.db"
20
+ def candidate_dbs() -> list[Path]:
21
+ """Every short-term DB that could hold the evidence, worktree first.
22
+
23
+ `uap memory query` writes to the DB under the CWD, which inside a worktree
24
+ is the WORKTREE's DB — but this gate used to read only repo_root()'s. An
25
+ agent doing the required query while working in a worktree (which the
26
+ worktree policy mandates) therefore produced evidence the gate never saw,
27
+ and the remedy could not clear it. Same repo_root()-vs-worktree_root() bug
28
+ already fixed in expert-review-required and local-build-before-push.
29
+ """
30
+ seen: list[Path] = []
31
+ for root in (worktree_root(), repo_root()):
32
+ db = root / DB_REL
33
+ if db not in seen:
34
+ seen.append(db)
35
+ return seen
36
+
37
+
38
+ def recent_memory_query(db: Path) -> bool:
21
39
  if not db.exists():
22
40
  return False
23
41
  try:
@@ -48,8 +66,9 @@ def main() -> None:
48
66
  if op not in PLAN_OPS and not PLAN_WORD_RE.search(blob):
49
67
  emit(True, "not a plan operation")
50
68
 
51
- if recent_memory_query(repo_root()):
52
- emit(True, "recent uap memory query on record")
69
+ for db in candidate_dbs():
70
+ if recent_memory_query(db):
71
+ emit(True, "recent uap memory query on record")
53
72
 
54
73
  emit(
55
74
  False,