portage-cli 0.6.4 → 0.7.3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 72d2d335f8f6d40abab7b9307f4e3dcfc21b2cb9dfc4fc5f67c490dd2d42e5ee
4
- data.tar.gz: 98065dbee850dd5db2141731f45078526b00b877bbe67278b13c5b6ffa7a448e
3
+ metadata.gz: 996ddc0acd1ec2676257f402fbb03148e8a38c40af9d5cf4fbe50d176416a334
4
+ data.tar.gz: a451d4e8130b1891fb2630d9b5e9edc1efde9486afda2ca3ff863342943d4a09
5
5
  SHA512:
6
- metadata.gz: 2ad81909c54bbf4ca6e588c8b3f72b19d8d7788d4762754b0f38783a51ce44dca60cd6e9a2aba7f05faeb0e7ca122bece589884194c44b982d7009615322432f
7
- data.tar.gz: 81a8c3098e6dad475ec1138c44de866be4a349a3bc57a133330c6684b9924b050eebd44f1440292f2f28d0cd87bb1f6c3cbd356c85ccc4f769e7162dbff1803b
6
+ metadata.gz: dd25c1c868d3e70f6ef469975f0d2b24f478c1f96bf83a739d2e4a473c38d2695fbd3586e0243ac21f9d75562b080413ad173c18924024ff1135279cfe8622be
7
+ data.tar.gz: e8578d6935466578e44e9e4f2e2caae61e1b9a1b2b52051934989fd28ec8ea9a163d943e3f68aac3704bdae72557d600f4694c7c81b8e9fe00b16685640a10b6
data/CHANGELOG.md CHANGED
@@ -4,6 +4,222 @@ All notable changes to this project are documented here. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.0.0/); this project is
5
5
  pre-1.0, so APIs may still shift between minor versions.
6
6
 
7
+ ## [0.7.3] - 2026-09-25
8
+
9
+ - **Proxy support** (`docs/plans/proxy-support.md` Phases 2-3). Every
10
+ network command (`buy`, `find`, `compare`, `doctor`, `payment enroll`)
11
+ takes `--proxy`, `--proxy-mode`, `--proxy-header`, `--no-proxy`,
12
+ `--proxy-route`, `--proxy-chain`, `--proxy-passthrough`, `--proxy-ca` and
13
+ `--no-env-proxy`, resolved per field against `PORTAGE_PROXY*` env vars
14
+ and `~/.portage/config.json`'s `proxy` section into core's
15
+ `Support::ProxyConfig`. `proxy.password_ref` resolves through Keychain or
16
+ Secret Service (service `portage-cli-proxy`). The payment route stays
17
+ direct unless a proxy is named for it explicitly, so payment traffic no
18
+ longer inherits a bare `$http_proxy`/`$https_proxy`. `portage doctor`
19
+ probes each configured proxy through `Support::Connection` and warns on
20
+ plaintext credentials in `config.json`.
21
+ - Requires `portage-ucp` `~> 0.10` (for `Support::Connection` and
22
+ `ProxyConfig`) and `portage-ucp-client` `>= 0.6.3` (for
23
+ `Client::USER_AGENT`, which the default User-Agent is built from). Both
24
+ floors were too low before: against `portage-ucp` 0.9.0 or
25
+ `portage-ucp-client` 0.6.2, `require "portage/cli"` raised `NameError`.
26
+ - `portage --version` (also `-v` and `portage version`) prints the gem's
27
+ version and exits 0. Needed so a packaged install — the Homebrew
28
+ formula's offline `test do` block, or anyone else scripting around a
29
+ release — can confirm which build is on `PATH` without hitting the
30
+ network.
31
+ - **`portage-console` failed to start on Ruby 4.0.** It requires `irb`,
32
+ which stopped being a default gem in Ruby 4.0 (it's a bundled gem now),
33
+ and the gemspec never declared it, so any isolated install
34
+ (Homebrew's, or anything under Bundler) raised `LoadError: cannot load
35
+ such file -- irb`. `irb` is now a runtime dependency.
36
+ - **Did the shopper finish the checkout? `portage orders reconcile`.**
37
+ Every checkout `portage buy` hands to the shopper's own browser
38
+ (`requires_escalation`, `permission_denied`, `no_payment_token`,
39
+ `policy_blocked`, `low_confidence`, `checkout_mismatch`) now reserves a
40
+ pending record in the transaction log (best-effort — a failed write is a
41
+ report warning, never blocks the hand-off). `portage orders reconcile
42
+ [--checkout ID] [--json]` re-fetches each pending checkout from the store
43
+ and settles it: `complete` only on a store-reported `completed` status,
44
+ `failed` on `canceled` or an unanswered expiry, otherwise stays pending.
45
+ Never infers success from a vanished checkout or a plain timeout. Safe to
46
+ run from cron/launchd. See `docs/plans/handoff-reconcile.md`.
47
+ - **`handoff_spend_mode`** (`PORTAGE_HANDOFF_SPEND_MODE`, config.json) —
48
+ whether a reconciled shopper purchase counts toward the buyer's own spend
49
+ cap/velocity limit. `block` (default): counts like any agent purchase.
50
+ `warn`: recorded but excluded from cap math. `precheck`: `block`, plus a
51
+ spend-cap check at hand-off time that suppresses auto-open (never the
52
+ URL) when this checkout would already exceed the cap.
53
+ - Phase 0 of the plan above (a live signal check against a real store) was
54
+ not run before this shipped — see `docs/design-log.md` §44.
55
+ - **`portage buy --wait [--wait-timeout DURATION|off]`.** After a hand-off,
56
+ polls `HandoffReconciler` with backoff (2s → 30s, plus jitter) until the
57
+ checkout settles or its deadline passes — the earlier of
58
+ `handoff_wait_timeout` (`PORTAGE_HANDOFF_WAIT_TIMEOUT`, config.json;
59
+ default 30m, `off` removes it) and the checkout's own `expires_at`.
60
+ Ctrl-C or the deadline leaves the record pending for a later `portage
61
+ orders reconcile`; it never settles from the wait itself. Under `--wait
62
+ --json`, stdout streams NDJSON (`handoff`, `handoff_status` on each
63
+ store-reported status change, `handoff_settled`) followed by the final
64
+ report object; plain `--json` with no `--wait` is unchanged byte-for-byte.
65
+ - **`reconcile_notify`** (`PORTAGE_RECONCILE_NOTIFY`, config.json) — a comma
66
+ list of channels a settled hand-off notifies on, from `--wait` or `portage
67
+ orders reconcile`. Default `webhook`. Adds `macos` (a native notification,
68
+ merchant/amount escaped into a fixed AppleScript template) and `terminal`
69
+ (a printed line, forced on for a plain-text `--wait` regardless of
70
+ configuration). `journal` is accepted for documentation — the Phase 1
71
+ order-snapshot journal write was already unconditional.
72
+ - **WebMCP outbound, opt-in (Phase 4, partial).** `Buy.new(webmcp_bridge:)`
73
+ takes a `portage-ucp-webmcp` outbound bridge already pointed at a
74
+ navigated page; when given one, `portage-cli` tries it after native-UCP
75
+ discovery finds nothing and before a platform-adapter fallback. Only
76
+ `webmcp_checkout_mode: express_stop` (the default) is implemented: it
77
+ builds the cart/checkout and always hands off (reason `express_stop`),
78
+ feeding Phases 1-3 unchanged, since a WebMCP page doesn't expose
79
+ `complete_checkout` by default anyway. `token` mode reports
80
+ `webmcp_token_unsupported` rather than attempting completion — it still
81
+ needs a `Confirmer`-swap seam in `Mcp::Server.build` and payment-token
82
+ enrollment that don't exist yet. No new hard runtime dependency:
83
+ `portage-ucp-webmcp` is lazily `require`d, same posture as an optional
84
+ platform adapter gem.
85
+
86
+ ## [0.7.2] - 2026-09-24
87
+
88
+ - The User-Agent every store-facing request sends (`Cli::UserAgent`, née
89
+ `Cli::USER_AGENT`/`Cli::HTTP_HEADERS`) is now configurable: `PORTAGE_USER_AGENT`
90
+ beats `~/.portage/config.json`'s `user_agent` key, both of which beat the
91
+ `portage-cli/<ver> portage-ucp-client/<ver> (+https://github.com/...)`
92
+ default — same precedence `Notifier`'s webhook URL already uses. `portage
93
+ doctor` (and its `configure`/`setup` aliases) now flags a configured value
94
+ containing a stray newline, since `Net::HTTP` would otherwise raise on it
95
+ mid-checkout instead of at setup time.
96
+
97
+ ## [0.7.1] - 2026-09-24
98
+
99
+ - Fix: `buy <url> --max-price` (and `--product-id` with `--max-price`) was
100
+ silently ignored — `add_search_options` only wired `--max-price` into the
101
+ `find` options, not `buy`, so a URL-driven buy could complete over the cap
102
+ the caller set. `Buy#select_product` now filters to products within
103
+ `max_price` first, using a variant's own price where available and
104
+ otherwise the product's lowest price (unpriced products stay eligible,
105
+ same rule `Find` already used). `no_match_message` now names the cap.
106
+ - Every store-facing request (`discover()` calls and the notifier webhook
107
+ POST) now sends `portage-cli/<ver> portage-ucp-client/<ver>
108
+ (+https://github.com/tomtom87/Portage)` instead of Ruby's or Faraday's
109
+ default User-Agent. New `Cli::USER_AGENT`/`Cli::HTTP_HEADERS`
110
+ (`lib/portage/cli/user_agent.rb`) replace the ad-hoc UA strings each of
111
+ `portage-buy`, `portage-find` and `portage-payment-enroll` built on their
112
+ own. Requires `portage-ucp-client >= 0.6.3`.
113
+
114
+ ## [0.7.0] - 2026-09-23
115
+
116
+ - **The decision layer is wired in.** `buy`/`find` make their judgment
117
+ calls through the same rules `portage-ucp-decision` wraps. Ranking,
118
+ escalation and the policy check live in `portage-ucp` core
119
+ (`Support::OfferRanking`, `Support::Escalation`, `PolicyGuard`), so they
120
+ run the same with or without that gem. `portage-ucp-decision` stays an
121
+ optional plugin that only the confidence gate needs. Every checkout
122
+ report carries the verdicts under `decisions:` (`escalation`, `policy`,
123
+ and `confidence` when enabled). Requires `portage-ucp ~> 0.9`.
124
+ - `find` ranks offers buyable first, then cheapest, then unpriced. The
125
+ order is unchanged.
126
+ - When a checkout both requires escalation and mismatches the request
127
+ under `PORTAGE_ABORT_ON_CHECKOUT_MISMATCH`, `buy` now reports the
128
+ store's escalation, not the mismatch. Both hand off the checkout.
129
+ - `buy --yes` now checks your spend policy before completing, on remote
130
+ native-UCP stores too. Before, only the own-store flow's in-process
131
+ `Dispatcher` enforced it, so a remote store never saw your caps,
132
+ allowlist or token scopes. A blocked purchase hands off the checkout.
133
+ - New opt-in confidence gate: `--decision-backend jev|laya` /
134
+ `PORTAGE_DECISION_BACKEND`, with a threshold from `--min-confidence` /
135
+ `PORTAGE_MIN_CONFIDENCE` (default `0.8`). Needs `portage-ucp-decision`.
136
+ A low score, a backend that can't answer, or a missing gem holds the
137
+ purchase and hands off the checkout.
138
+ - Every verdict carries a `reason`: a string, or null when the gate
139
+ passed, the same in-process as in `--json`. `confidence.reason` is
140
+ `below_threshold`, `backend_error` or `not_installed`, and
141
+ `confidence.error` holds the detail behind the last two.
142
+ - **`buy` checked out sold-out items when in-stock matches were right
143
+ there.** It took the top search hit and its first variant unconditionally,
144
+ so a sold-out top hit dead-ended on the store's "Sold out" refusal
145
+ (allbirds.com and billabong.com, 2026-09-23). It now takes the first hit
146
+ with stock, and that product's first in-stock variant. When nothing
147
+ reports stock it still falls back to the top hit, and `--product-id`
148
+ still buys exactly that product.
149
+ - `doctor` accepts `TYPESAFE_API_KEY` in place of `JEV_API_KEY`, matching
150
+ `portage-ucp-decision`'s Jev backend.
151
+ - **An agent couldn't tell a held purchase from a bought one without reading
152
+ prose.** `decisions:` only covers the three gates, so a missing payment
153
+ token, a permission-denied store, a dry run or a missing `--yes` all
154
+ showed `escalate: false, allowed: true`. Every `buy` report now carries
155
+ `outcome` (`purchased`, `no_payment_token`, `policy_blocked`, ... — the
156
+ full list is in the README), and the text output leads with it as
157
+ `[outcome]`. Checkout reports also carry `items`, what the checkout
158
+ actually holds, alongside `products`, the search results.
159
+ - **History recorded the wrong things.**
160
+ - A purchase entry listed all ten search results, not what was bought,
161
+ and had no way to tell a completed purchase from a dry run or a held
162
+ one. Entries now carry `outcome`, `items`, `total`/`currency`,
163
+ `checkout_id`, `source`, and the `checkout_url` for anything not
164
+ completed.
165
+ - A `buy` whose search matched nothing was logged as a purchase. It's now
166
+ a search at that store, as are browse-only and dead-end buys, which
167
+ weren't logged anywhere.
168
+ - The search behind a `buy` with no URL wasn't logged at all.
169
+ - **Checkout hand-off webhooks.** The body now includes the report's
170
+ `message`, the `store` and the shopper's `query`, so a Slack/Zapier relay
171
+ can post it without a lookup. A plain-text 2xx (Slack's `ok`) was
172
+ reported as a failed delivery, because the reply was parsed as JSON; any
173
+ 2xx now counts. The POST times out after 5s instead of stalling the buy
174
+ for up to two minutes.
175
+ - **The confidence gate could crash a buy after the checkout existed.** Only
176
+ `Decision::Error` was rescued, so a backend's timeout, parse error or
177
+ missing binary escaped as a backtrace with no report, hand-off or history
178
+ entry. Any failure now holds the purchase like a low score does.
179
+ - **The spend policy failed open on a checkout with no total.** PolicyGuard
180
+ skips both caps without an amount, so `buy --yes` completed past a
181
+ configured cap while reporting `allowed: true`. It's now denied as
182
+ `total_unknown` whenever a cap is set.
183
+ - `doctor` checks the confidence backend you selected
184
+ (`PORTAGE_DECISION_BACKEND`): the gem missing, an unknown name, a missing
185
+ `JEV_API_KEY` or Laya bridge, or a bad `PORTAGE_MIN_CONFIDENCE`. It no
186
+ longer asks for `JEV_API_KEY` when no backend is selected.
187
+ - The no-buyer-context warning names every variable it reads and says to
188
+ set at least `PORTAGE_SHIP_COUNTRY`.
189
+ - **Settings resolve one way everywhere.** The auto-open toggle, the
190
+ notify webhook, `PORTAGE_ABORT_ON_CHECKOUT_MISMATCH` and the confidence
191
+ gate's backend/threshold each parsed their own env var and repeated the
192
+ "flag, then env var, then `config.json`" order. They now share
193
+ `Portage::Cli::Setting`. Two edge cases change:
194
+ - A blank flag or env var (`PORTAGE_AUTO_OPEN_CHECKOUT=`,
195
+ `--notify-webhook ""`) is unset and falls through to the next level.
196
+ Before, an empty auto-open env var turned auto-open off, and an empty
197
+ `--notify-webhook` turned the webhook off.
198
+ - A `config.json` `"auto_open_checkout"` string is read like the env var,
199
+ so `"no"` means off. Before, any non-empty value meant on.
200
+ - Report totals and the policy check's amount come from core's
201
+ `Support::Totals.amount` instead of their own lookups.
202
+ - **Remote purchases didn't count toward the rolling cap or velocity
203
+ limit.** Both count the transaction log (`~/.portage/transactions.json`),
204
+ and only the own-store `Dispatcher` wrote to it, so `buy --yes` against a
205
+ remote native-UCP store never added to either. `buy` now records a remote
206
+ completion there under the merchant host, with its amount, currency and
207
+ token ref: reserved before the store is asked, settled `complete` only
208
+ when it comes back purchased, `failed` otherwise. The own-store loopback
209
+ path is still left to `Dispatcher`, so nothing is counted twice. If the
210
+ log can't be written after the purchase, the report says so in
211
+ `warnings` rather than losing the purchase to an exception.
212
+ - **A garbage `PORTAGE_MIN_CONFIDENCE` broke every `buy`**, even with no
213
+ decision backend selected, because the threshold was parsed up front
214
+ either way. The env var is now read, and validated, only once a backend
215
+ is selected. An explicit `--min-confidence` is still always validated.
216
+ - **`buy --json` could refuse a flag without any JSON.** An out-of-range
217
+ threshold went to stderr as a bare line, and a flag OptionParser couldn't
218
+ read (`--min-confidence high`, an unknown flag) escaped as a backtrace.
219
+ Under `--json` both now print a report with `outcome: "invalid_option"`
220
+ and exit 1; without it, both are one line on stderr. The threshold is
221
+ also checked before the search when `buy` has no URL, not after it.
222
+
7
223
  ## [0.6.4] - 2026-09-22
8
224
 
9
225
  - **Every dead-end hand-off pointed the shopper at the store's refund
data/README.md CHANGED
@@ -68,7 +68,9 @@ gem install portage-cli
68
68
 
69
69
  ```bash
70
70
  portage buy <url> --query "..." [--qty N] [--payment-token TOKEN] [--product-id ID]
71
- [--yes] [--dry-run] [--json]
71
+ [--yes] [--dry-run] [--decision-backend jev|laya]
72
+ [--min-confidence N] [--json]
73
+ [--wait [--wait-timeout DURATION|off]]
72
74
  portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
73
75
  portage find --query "..." [--max-price N] [--limit N] [--json]
74
76
  portage compare <url> --product-id ID [--id VALUE ...] [--results N]
@@ -87,8 +89,20 @@ portage policy set [--per-transaction-cap N --currency CUR]
87
89
  [--rolling-cap N --rolling-window-seconds N --currency CUR]
88
90
  [--velocity-count N --velocity-window-seconds N]
89
91
  [--allow HOST ...] [--clear-allowlist]
92
+ portage orders reconcile [--checkout ID] [--json]
90
93
  ```
91
94
 
95
+ `buy`/`find`/`compare`/`doctor`/`payment enroll` (the network-touching commands —
96
+ `orders reconcile` doesn't take these) also accept:
97
+
98
+ ```
99
+ [--proxy URL] [--proxy-mode forward|gateway] [--proxy-header "Name: value"]
100
+ [--no-proxy HOSTS] [--proxy-route ROUTE=URL|direct] [--proxy-chain URL,URL,...]
101
+ [--proxy-passthrough HEADER] [--proxy-ca FILE] [--no-env-proxy]
102
+ ```
103
+
104
+ See "Proxy" below.
105
+
92
106
  - `--query` — search term. Against the store's catalog when you name a store,
93
107
  against the search backends when you don't.
94
108
  - `--qty` — quantity, default `1`.
@@ -101,15 +115,27 @@ portage policy set [--per-transaction-cap N --currency CUR]
101
115
  search ranks first. If the id isn't in the results, nothing is bought.
102
116
  - `--store` — name the merchant without giving a full URL; skips the search.
103
117
  - `--max-price` — in major units (`400` means 400), compared per offer in that
104
- offer's own currency. No FX conversion.
118
+ offer's own currency. No FX conversion. Applies per unit, to the search and
119
+ to the store's own catalog once one is settled (a URL, `--store`, or a
120
+ picked offer): nothing priced above it is checked out, even with
121
+ `--product-id`. A product with no price is still eligible.
105
122
  - `--limit` — how many candidate stores to probe, capped at 12.
106
123
  - `--yes` — skip the confirmation prompt before completing checkout.
107
124
  - `--dry-run` — resolve and price the order without completing checkout.
125
+ - `--decision-backend` — opt into the confidence gate (see "Decisions" below):
126
+ `jev` or `laya`. Defaults to `PORTAGE_DECISION_BACKEND`; unset means off.
127
+ - `--min-confidence` — the gate's threshold, `0.0`–`1.0`. Defaults to
128
+ `PORTAGE_MIN_CONFIDENCE`, then `0.8`. The flag is always checked; the env
129
+ var is read (and checked) only while a backend is selected, so a stale
130
+ value can't block a buy that doesn't use the gate.
108
131
  - `--json` — machine-readable report instead of the human-readable summary.
109
132
 
110
133
  Exits `0` when a checkout completed (or a dry-run/browse/search resolved
111
134
  successfully), `1` otherwise — including the "no native manifest, no adapter
112
- credentials" dead-end case, so it's scriptable in CI.
135
+ credentials" dead-end case, so it's scriptable in CI. A flag that can't be
136
+ used (an unreadable value, an out-of-range threshold) stops the buy before
137
+ anything runs: on stderr normally, or as a JSON report with `outcome:
138
+ "invalid_option"` under `--json`.
113
139
 
114
140
  ### Compare
115
141
 
@@ -162,10 +188,15 @@ abandoned carts on someone else's site; left out of scope for now.
162
188
 
163
189
  ### History
164
190
 
165
- Every `find` (and `buy`, once it reaches a search) and every `buy` that
166
- reaches checkout is logged locally to `~/.portage/history.json` — most recent
167
- 200 entries each, purchases and searches kept separately. Browse-only `buy`
168
- reports (no checkout reached) aren't logged as purchases.
191
+ Every `portage buy` that creates a checkout is logged locally to
192
+ `~/.portage/history.json` as a purchase, whatever came of it. Each entry
193
+ carries the report's `outcome` (`purchased`, `dry_run`, `policy_blocked`,
194
+ ...), what the checkout held (`items`), its `total`, and, when it wasn't
195
+ completed, the `checkout_url` to finish it. "What did I already buy" is
196
+ the entries whose `outcome` is `purchased`. A `buy` that never reached a
197
+ checkout (no match, browse-only, a dead end) is logged as a search at that
198
+ store instead, as is every `find`, `compare`, and the search behind a
199
+ `buy` with no URL. The most recent 200 entries of each are kept.
169
200
 
170
201
  ```bash
171
202
  portage history # both lists, most recent last
@@ -255,6 +286,231 @@ opt-in guardrail, not a default-deny one. Per-token scopes (merchant/amount
255
286
  limits bound to one enrolled card) are set via `portage payment enroll
256
287
  --scope-*` above, not here.
257
288
 
289
+ ### Orders reconcile
290
+
291
+ Nearly every real checkout `portage buy` can't finish itself hands the
292
+ shopper a link — `requires_escalation`, `permission_denied`,
293
+ `no_payment_token`, `policy_blocked`, `low_confidence`, or
294
+ `checkout_mismatch`. That's a pending purchase this process knows nothing
295
+ more about until something asks the store. `portage orders reconcile`
296
+ re-fetches each pending hand-off from the store and settles it once the
297
+ store itself reports a terminal status:
298
+
299
+ ```bash
300
+ portage orders reconcile # every pending hand-off
301
+ portage orders reconcile --checkout chk_123
302
+ portage orders reconcile --json
303
+ ```
304
+
305
+ A `completed` checkout settles `complete` — using the store's own total at
306
+ settle time, not the hand-off-time snapshot — records the order (when one
307
+ exists) and a journal entry, and is counted toward your spend policy's
308
+ rolling cap/velocity per `handoff_spend_mode` below. A `canceled` checkout,
309
+ or one that expires with no answer, settles `failed`. Anything still
310
+ in-progress, or not currently reachable, is left pending for the next run —
311
+ safe to put on a cron/launchd schedule; two overlapping runs never
312
+ double-settle the same record.
313
+
314
+ `handoff_spend_mode` (`PORTAGE_HANDOFF_SPEND_MODE`, config.json's
315
+ `handoff_spend_mode`) controls whether a reconciled shopper purchase counts
316
+ toward the caps `portage policy set` configures:
317
+
318
+ - `block` (default) — counts like any agent-completed purchase.
319
+ - `warn` — recorded, but excluded from the cap/velocity math; still notifies
320
+ when it would have pushed spend over the cap.
321
+ - `precheck` — `block`, plus a spend-cap check at hand-off time
322
+ (`portage buy`, not reconcile): if this checkout's total would already
323
+ exceed your cap, the URL is still printed but auto-open is suppressed.
324
+
325
+ `--wait [--wait-timeout DURATION|off]` on `portage buy` polls the same
326
+ reconciler right after a hand-off, instead of waiting for a separate
327
+ `orders reconcile` run. It backs off (2s → 30s, plus jitter) until the
328
+ checkout settles or its deadline passes — the earlier of
329
+ `handoff_wait_timeout` (`PORTAGE_HANDOFF_WAIT_TIMEOUT`, config.json; default
330
+ `30m`, `off` removes it) and the checkout's own `expires_at`. Ctrl-C, or
331
+ the deadline, leaves the record pending for a later `portage orders
332
+ reconcile` — it never settles from the wait itself. Under `--wait --json`,
333
+ stdout streams NDJSON — `handoff`, `handoff_status` on each store-reported
334
+ status change, `handoff_settled` — followed by the final report object;
335
+ plain `--json` without `--wait` is unchanged.
336
+
337
+ `reconcile_notify` (`PORTAGE_RECONCILE_NOTIFY`, config.json's
338
+ `reconcile_notify`) is a comma list of channels a settled hand-off notifies
339
+ on, from `--wait` or `orders reconcile`: `webhook` (default), `macos` (a
340
+ native notification), `terminal` (a printed line — forced on for a
341
+ plain-text `--wait` regardless of configuration), and `journal` (the order
342
+ snapshot journal write, already unconditional — listing it just documents
343
+ that).
344
+
345
+ ### Proxy
346
+
347
+ `buy`, `find`, `compare`, `doctor`, and `payment enroll` accept the flags below,
348
+ resolved (per field, flag > env > `~/.portage/config.json`) into a
349
+ `Portage::Ucp::Support::ProxyConfig` by `Portage::Cli::ProxySettings` — see
350
+ [`docs/proxy.md`](../docs/proxy.md) for corporate egress, a rotating residential
351
+ pool, an API gateway, mitmproxy for debugging, and nginx/Cloudflare in front of the
352
+ MCP/WebMCP endpoints, worked through end to end.
353
+
354
+ | Flag | Meaning |
355
+ | --- | --- |
356
+ | `--proxy URL` | The default proxy's URL (`http://user:pass@host:port`). Overrides only `proxy.default.url`; every other configured field (`no_proxy`, `routes`, `chains`, ...) stays as set in config.json. |
357
+ | `--proxy-mode forward\|gateway` | The default profile's mode. `forward` (the default) is a standard HTTP proxy; `gateway` is a URL-rewriting gateway — see `docs/proxy.md`. |
358
+ | `--proxy-header "Name: value"` | Repeatable. Sent only to the proxy (on the `CONNECT` request, or the gateway request) — never to the real target. `Authorization`/`User-Agent`/`X-Shopify-*-Access-Token`/`X-Payment-Token` are refused here, always. |
359
+ | `--no-proxy HOSTS` | Comma-separated hostnames/suffixes/CIDRs (or a bare `*`) to always bypass the proxy for, whatever the route resolves to. |
360
+ | `--proxy-route ROUTE=URL\|direct` | Repeatable. Points one fixed route (`store`, `search`, `notify`, `payment`, `platform`, `probe`) at its own URL, or forces it `direct` regardless of `default`. |
361
+ | `--proxy-chain URL,URL,...` | An ad-hoc multi-hop chain for this run — each hop is `forward` unless prefixed `gateway+https://...`. Overrides the whole `default` profile (not just its `url`), since a chain has no single "url" field to merge with config.json's own. |
362
+ | `--proxy-passthrough HEADER` | Repeatable, server commands only. Allowlists an inbound request header to ride along on outbound calls made while serving it — see `docs/proxy.md`'s passthrough section. Refuses a protected header name at parse time. |
363
+ | `--proxy-ca FILE` | A PEM file trusted in addition to the system store — for a TLS-intercepting corporate proxy or a debugging proxy like mitmproxy. |
364
+ | `--no-env-proxy` | Ignore `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` (and their lowercase forms) entirely for this run — otherwise they're still the fallback for any route nothing else configured. |
365
+
366
+ **Env vars** (checked when the matching flag is absent, before config.json):
367
+
368
+ | Var | Matches |
369
+ | --- | --- |
370
+ | `PORTAGE_PROXY` | `--proxy` |
371
+ | `PORTAGE_PROXY_MODE` | `--proxy-mode` |
372
+ | `PORTAGE_NO_PROXY` | `--no-proxy` |
373
+ | `PORTAGE_PROXY_CA` | `--proxy-ca` |
374
+ | `PORTAGE_PROXY_HEADERS` | A JSON object of header name → value, merged under any `--proxy-header` flags (flags win on a name collision). |
375
+
376
+ Below all of the above, the standard `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` (and
377
+ lowercase) variables are still the fallback for any route nothing here configures
378
+ at all — existing environments keep working unless `--no-env-proxy` is set. The
379
+ **`payment` route is always forced `direct`** unless a proxy is named for it
380
+ explicitly (`--proxy-route payment=...` or config.json's `routes.payment`) — it
381
+ never inherits a bare `default`/env proxy the way every other route does, so an
382
+ egress proxy nobody meant to hand payment tokens to never sees them by accident.
383
+
384
+ `${ENV_VAR}` inside a config.json header value (`proxy_headers`, `forward_headers.add`)
385
+ is expanded from the process environment at resolve time, so a secret can live in
386
+ the environment rather than the file itself. A proxy password can also live in
387
+ `password_ref` (resolved through the same macOS Keychain/Linux Secret Service tiers
388
+ `portage payment` uses, under its own `portage-cli-proxy` service name) instead of
389
+ plaintext in the URL — `portage doctor` warns when it finds a plaintext one anyway.
390
+
391
+ `portage doctor` reports, per route, what's effectively configured (credentials
392
+ redacted), whether each configured proxy/gateway is actually reachable, and flags
393
+ plaintext proxy credentials and an intercepting proxy (`ca_file` or `gateway` mode)
394
+ sitting on the `payment` route.
395
+
396
+ ### WebMCP (library use, opt-in)
397
+
398
+ `portage buy` from the shell has no browser of its own, so there's no CLI
399
+ flag for this — it's for a caller embedding `Portage::Cli::Buy` directly
400
+ alongside its own browser automation:
401
+
402
+ ```ruby
403
+ Portage::Cli::Buy.new(url: "shop.example", query: "mug",
404
+ webmcp_bridge: my_portage_ucp_webmcp_bridge).call
405
+ ```
406
+
407
+ Given a `portage-ucp-webmcp` outbound bridge already pointed at a navigated
408
+ page, `Buy` tries it after native-UCP discovery finds nothing at that URL
409
+ and before falling back to a platform adapter. `webmcp_checkout_mode`
410
+ (`PORTAGE_WEBMCP_CHECKOUT_MODE`, config.json's `webmcp_checkout_mode`)
411
+ controls how it finishes: `express_stop` (default) builds the cart/checkout
412
+ and always hands off — reason `express_stop`, so `--wait`/`orders
413
+ reconcile`/`handoff_spend_mode` all apply exactly as they do to any other
414
+ hand-off. `token` isn't implemented yet; it reports
415
+ `webmcp_token_unsupported` rather than attempting completion. Requires
416
+ `gem install portage-ucp-webmcp` — not a hard dependency of `portage-cli`.
417
+
418
+ ### Decisions
419
+
420
+ `portage buy` and `portage find` make their judgment calls
421
+ (`docs/plans/system-one-decision-layer.md`) through rules that live in
422
+ `portage-ucp` core: `Support::OfferRanking`, `Support::Escalation` and
423
+ `PolicyGuard`. `portage-ucp-decision` wraps the same modules as typed
424
+ verdicts, so ranking, escalation and the policy check answer the same way
425
+ whether or not it's installed. It's an optional plugin, not a dependency:
426
+
427
+ ```bash
428
+ gem install portage-ucp-decision # only needed for the confidence gate
429
+ ```
430
+
431
+ The confidence gate is the one feature that needs the gem, because the
432
+ model backends live there.
433
+
434
+ Every `portage buy` report carries an `outcome`, so a script or agent loop
435
+ can branch on data rather than on the message text. The text output leads
436
+ with the same value, as `[outcome]`.
437
+
438
+ | `outcome` | Meaning | `checkout_url`? |
439
+ | --- | --- | --- |
440
+ | `purchased` | Completed. | no |
441
+ | `needs_confirmation` | Checkout ready; rerun with `--yes`. | no |
442
+ | `dry_run` | Checkout created, `--dry-run` stopped it. | no |
443
+ | `requires_escalation` | The store wants the shopper to finish. | yes |
444
+ | `checkout_mismatch` | Checkout differs from the request (`PORTAGE_ABORT_ON_CHECKOUT_MISMATCH`). | yes |
445
+ | `no_payment_token` | No `--payment-token` and no default payment method. | yes |
446
+ | `policy_blocked` | Your spend policy denied it; `decisions.policy.reason` says why. | yes |
447
+ | `low_confidence` | The confidence gate held it. | yes |
448
+ | `permission_denied` | The store doesn't let this agent complete checkout. | yes |
449
+ | `store_refused` | The store refused a cart/checkout call (e.g. sold out). | when the store gave one |
450
+ | `no_match` | Nothing in the store's results matched. | no |
451
+ | `browse_only` | The store has a catalog but no UCP checkout. | when an adapter offers a link |
452
+ | `agent_profile_missing`, `request_rejected`, `unsupported_wire_shape` | Native UCP setup problems; the message names the fix. | no |
453
+ | `adapter_error`, `adapter_misconfigured` | Your own-store adapter failed; the message quotes it. | no |
454
+ | `dead_end` | No UCP and no adapter for this store. | no |
455
+ | `invalid_option` | A flag was refused before the buy started (`--json` only); `message` says which. | no |
456
+
457
+ Checkout reports also carry `items` (what the checkout holds, as opposed to
458
+ `products`, the search results) and the verdicts under `decisions:`:
459
+
460
+ ```json
461
+ "decisions": {
462
+ "escalation": { "escalate": false, "reason": null },
463
+ "policy": { "allowed": true, "reason": null },
464
+ "confidence": { "proceed": false, "reason": "below_threshold", "confidence": 0.41,
465
+ "threshold": 0.8, "backend": "jev", "error": null }
466
+ }
467
+ ```
468
+
469
+ Every verdict has a `reason`: `null` when the gate let the purchase through,
470
+ otherwise a string naming why it stopped it.
471
+
472
+ - **escalation** — `Support::Escalation`. A `requires_escalation` checkout
473
+ always escalates (`reason: "requires_escalation"`). A checkout that doesn't
474
+ match the request escalates (`reason: "mismatch"`) only under
475
+ `PORTAGE_ABORT_ON_CHECKOUT_MISMATCH`; otherwise it's reported as `warnings`.
476
+ - **policy** — `PolicyGuard`, run on your policy file (see "Policy" above)
477
+ before any `--yes` completion. `reason` is the guard's own
478
+ (`per_transaction_cap_exceeded`, `rolling_spend_cap_exceeded`,
479
+ `velocity_exceeded`, `merchant_not_allowlisted`, `token_scope_merchant`,
480
+ `token_scope_amount`, `currency_mismatch`, or `total_unknown`, below).
481
+ It applies to remote native-UCP stores too. Before this check, only the
482
+ own-store adapter flow's in-process `Dispatcher` enforced the policy.
483
+ Rolling caps and velocity limits count the completed purchases at that
484
+ merchant in `~/.portage/transactions.json`. `Dispatcher` records
485
+ own-store purchases there, and `portage buy` records remote ones: reserved
486
+ before the store is asked to complete, then settled as `complete` only
487
+ when the store answers that it's purchased.
488
+ - **confidence** — `ConfidenceGate`, off unless `--decision-backend` or
489
+ `PORTAGE_DECISION_BACKEND` names a backend. Right before a `--yes`
490
+ completion it asks the backend whether the checkout matches the request
491
+ and is safe to complete unattended. It sends the query, merchant,
492
+ quantity, line items, totals and warnings, never the payment token. The
493
+ gate fails closed, so three things hold the purchase: a score below the
494
+ threshold (`reason: "below_threshold"`), a backend that can't answer
495
+ (`"backend_error"`), and naming a backend without `portage-ucp-decision`
496
+ installed (`"not_installed"`). For the last two, `error` says what went
497
+ wrong. `jev` needs `JEV_API_KEY`; `laya` needs `LAYA_BRIDGE_SCRIPT` (see
498
+ `portage-ucp-decision`'s README). `portage doctor` flags whichever of
499
+ these is missing for the selected backend.
500
+ - **policy** also denies a checkout with no `total` line as
501
+ `total_unknown` whenever a spend cap is set, rather than skipping the cap.
502
+
503
+ A blocked or held purchase hands the checkout off the same way an escalation
504
+ does (`checkout_url`, plus `--auto-open`/`--notify-webhook` if set). The
505
+ shopper can finish it themselves. The webhook body is JSON: `event`
506
+ (`checkout_handoff`), `reason` (the report's `outcome`), `message` (the
507
+ report's own sentence), `store`, `query`, `checkout_url`, `checkout_id`,
508
+ `source`, `totals` and `warnings`. Any 2xx counts as delivered; the POST
509
+ gives up after 5 seconds and reports `notify_error` instead.
510
+
511
+ `portage find`'s offer order comes from `Support::OfferRanking`: buyable
512
+ first, then cheapest, then unpriced.
513
+
258
514
  ### Shipping address (own-store checkouts only)
259
515
 
260
516
  When buying against your own store (`portage buy`'s step 2 adapter-credentials