Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,39 @@ Every Comprar tap that reached the orchestrator appears here — including the a

---

## Ofertas: Wallapop "hacer oferta"

Wallapop lets a buyer propose a lower price ("hacer oferta"); the seller accepts, rejects, or counters. Salvager can send those offers — but, like Comprar, **only when the operator taps the "💰 Ofertar" button on a Telegram alert**. No offer is ever sent autonomously.

### Enabling offers for a wishlist entry

By default every entry has `offer.enabled: false` and nothing changes. To opt an entry in:

```bash
uv run salvager offer enable <entry-ref> # offers aim at the entry ceiling
uv run salvager offer enable <entry-ref> -t 70.00 # …or at a lower delivered-total target
```

Two things happen for an opted-in entry:

- **Negotiable alerts.** Wallapop listings whose delivered total (item + shipping + Protección) is over the entry ceiling but within `ceiling × (1 + offer.band_pct)` — listings that used to be silently filtered — now produce a `💰` alert showing the computed offer and an "Ofertar · Saltar · Ver" keyboard (never Comprar: they're over ceiling).
- **Ofertar on standard alerts.** When a computed offer would undercut the asking price (routine only if you set a target below the ceiling), the ordinary 📦/🟢 alerts grow a `💰 Ofertar` row.

The offer amount is never chosen by hand: it's the largest whole-euro item price whose delivered total fits your target, bounded by Wallapop's own floor of 70 % of the asking price. It's shown on the alert before you tap, and recomputed from a fresh re-fetch at tap time — drift aborts the send.

### What an Ofertar tap does — and does NOT do

Tap → preflight (entry still opted in, no lockout, daily budget free, no prior offer on this listing) → re-fetch by internal id (gone/reserved/price-drift aborts fail-closed) → TinyFish drives the offer form with EXACTLY the computed amount → `💰 Oferta enviada`.

**v1 ends there.** Salvager does not watch for the seller's response. If they accept, Wallapop gives you **24 h to buy at the accepted price — and the item is NOT reserved meanwhile**: follow through in the Wallapop app. Guardrails: one offer per listing ever; a self-imposed budget of `offer.daily_limit` sends per rolling 24 h (default 5, under Wallapop's 10/day account cap); `offer.lockout_threshold` consecutive failures lock the path until `salvager offer enable` clears it; `offer.kill_switch_global` kills it unconditionally. Every attempt lands in the append-only `offers` audit table.

```bash
uv run salvager offer status # enablement table + lockout + budget
uv run salvager offer disable --all # kill-everything path (typed confirmation)
```

---

## Planning artifacts

The BMAD planning artifacts that drove the design and implementation plan live in [`_bmad-output/planning-artifacts/`](_bmad-output/planning-artifacts):
Expand Down
11 changes: 11 additions & 0 deletions _bmad-output/planning-artifacts/prd.md
Original file line number Diff line number Diff line change
Expand Up @@ -791,6 +791,17 @@ This section provides the strategic context behind the Phase 1 / Phase 2 / Visio
- **FR47.** The operator can inspect agent health (`health`) — adapter status, scheduler status, last poll, last alert, last Phase 2 event — to diagnose problems without reading raw logs.
- **FR48.** All read-only operator commands support a `--format json` flag for scripting; daemon logs emit structured JSON Lines on stdout. Operator commands return stable, documented exit codes (0 success / 1 usage / 2 validation / 3 adapter / 4 auth / 5 Phase 2 guardrail).
- **FR49.** Configuration is split across `wishlist.yaml` (user content), `config.yaml` (operational tunables: rates, thresholds, paths, log level), and `.env` (credentials only, never logged); the agent loads `.env` once at process start with no hot-reload.

**Wallapop offer flow (wallapop-make-offer amendment, 2026-07-22; ids FR58-FR65 — FR50-FR54 were already taken by the ops/repo sections below):**

- **FR58.** The operator can send a price offer ("hacer oferta") on a Wallapop listing by tapping a `💰 Ofertar` inline button; no offer is ever sent without an operator tap (the FR29 no-autonomous-action rule extends to offers).
- **FR59.** The offer amount is computed, not chosen: the largest whole-euro item price whose delivered buyer total fits the entry's offer target (`offer.target_total_eur`, defaulting to the entry ceiling), bounded by Wallapop's platform floor of 70 % of the asking price, shown on the alert before the tap and recomputed from the reconciled listing at tap time.
- **FR60.** Wallapop listings on offer-enabled entries whose buyer total exceeds the ceiling but sits within `ceiling × (1 + offer.band_pct)` produce a distinct negotiable alert (`💰` severity token, offer row, `Ofertar · Saltar · Ver` keyboard, never Comprar) instead of being silently filtered; listings beyond the band, on offer-disabled entries, or on eBay filter exactly as before.
- **FR61.** Offer sending enforces a self-imposed daily budget (`offer.daily_limit`, default 5 per rolling 24 h, deliberately under Wallapop's 10-per-calendar-day account cap) and recognises the platform's own exhausted-counter state; neither limit-hit increments the failure lockout.
- **FR62.** At most one successful offer is ever sent per listing (per-listing dedupe); a listing with a sent offer keeps a terminal `💰 Oferta enviada` badge across keyboard reconstructions.
- **FR63.** Offer outcomes use the closed `OfferFailureReason` set, each variant rendered with a Spanish cause label, detail rows, next steps, and the reassurance line "No se ha enviado ninguna oferta." (with a documented ambiguity variant for missing confirmation evidence); v1 ends at "offer sent" — seller responses are handled by the operator in the Wallapop app.
- **FR64.** Consecutive offer execution failures reaching `offer.lockout_threshold` disable the offer path globally until `salvager offer enable <entry>` clears the lockout; the offer lockout and `offer.kill_switch_global` are fully independent from the Phase 2 circuit breaker and kill switch.
- **FR65.** Offers are opt-in per wishlist entry (`offer.enabled`, default false, toggled via `salvager offer enable/disable`); with no entry opted in, alert filtering, rendering, and callbacks are byte-identical to the pre-offer behaviour, and every executed offer attempt is recorded in the append-only `offers` audit table.
- **FR50.** The agent handles SIGTERM gracefully — drains in-flight LLM evaluations, flushes the audit log, completes pending Telegram alerts, exits within 30 seconds.
Comment on lines +794 to 805

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Duplicate FR ids: FR50-FR54 now used twice with unrelated content.

The new offer-flow amendment claims FR50-FR57, but FR50 (SIGTERM handling, line 805) and FR51-FR54 (distribution artifacts, lines 809-812) already exist unchanged immediately below with the same ids. This breaks the FR-traceability contract this section states — every doc referencing "FR50-FR57" for the offer flow (the spec files, tasks.md, README) now also collides with the SIGTERM/distribution requirements.

🔧 Proposed fix: renumber the pre-existing FRs that follow the insertion point
-- **FR50.** The agent handles SIGTERM gracefully — drains in-flight LLM evaluations, flushes the audit log, completes pending Telegram alerts, exits within 30 seconds.
+- **FR58.** The agent handles SIGTERM gracefully — drains in-flight LLM evaluations, flushes the audit log, completes pending Telegram alerts, exits within 30 seconds.

 ### Project Distribution & Artifacts

-- **FR51.** The repository ships a single `docker-compose.yml` install path with example wishlist entries for common HDD and RAM models, an `.env.example`, and a `config.example.yaml`; user-specific files (`wishlist.yaml`, `config.yaml`, `.env`) are gitignored.
-- **FR52.** The repository includes a `CONTRIBUTING.md` with an explicit "no arbitrage PRs" rule and three named invitation categories (wishlist examples, prompt improvements, Wallapop selector patches), pointing to a separate-repo path for arbitrage forks.
-- **FR53.** The repository includes a `ROADMAP.md` naming future-multi-marketplace expansion, future-arbitrage-as-separate-repo, and "C&D-induced sunset" as a documented possible end state.
-- **FR54.** The README positions salvager as a personal monitoring tool (not a "Wallapop scraper"), includes a legal disclaimer covering Spanish ToS posture and the secondary-account recommendation, and contains no Wallapop trademarks, logos, or proprietary terms in titles, package names, or domain references.
+- **FR59.** The repository ships a single `docker-compose.yml` install path with example wishlist entries for common HDD and RAM models, an `.env.example`, and a `config.example.yaml`; user-specific files (`wishlist.yaml`, `config.yaml`, `.env`) are gitignored.
+- **FR60.** The repository includes a `CONTRIBUTING.md` with an explicit "no arbitrage PRs" rule and three named invitation categories (wishlist examples, prompt improvements, Wallapop selector patches), pointing to a separate-repo path for arbitrage forks.
+- **FR61.** The repository includes a `ROADMAP.md` naming future-multi-marketplace expansion, future-arbitrage-as-separate-repo, and "C&D-induced sunset" as a documented possible end state.
+- **FR62.** The README positions salvager as a personal monitoring tool (not a "Wallapop scraper"), includes a legal disclaimer covering Spanish ToS posture and the secondary-account recommendation, and contains no Wallapop trademarks, logos, or proprietary terms in titles, package names, or domain references.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@_bmad-output/planning-artifacts/prd.md` around lines 794 - 805, Resolve the
duplicate requirement identifiers in the PRD by renumbering the pre-existing
SIGTERM and distribution requirements immediately following the Wallapop
offer-flow amendment, preserving FR50–FR57 exclusively for the offer
requirements. Update all references to the renumbered requirements across the
specification, tasks, and README so traceability remains consistent.


### Project Distribution & Artifacts
Expand Down
20 changes: 20 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,26 @@ alerts:
# message (Telegram edits are silent; a big drop deserves a ping).
price_drop_ping_pct: 10

# ─────────────────────────────────────────────────────────────────────────
# offer — Wallapop "hacer oferta" flow (wallapop-offer-flow)
# ─────────────────────────────────────────────────────────────────────────
offer:
# Wallapop listings on offer-enabled entries with a buyer total over the
# ceiling but within ceiling × (1 + band_pct) produce a negotiable alert
# (💰 Ofertar, no Comprar) instead of being filtered. Wallapop's own
# -30 % offer floor makes bands much past 0.40 useless.
band_pct: 0.20
# Self-imposed budget of successful offer sends per rolling 24 h.
# Wallapop allows 10 offers per calendar day per account; keeping this
# under it leaves headroom for your own manual offers.
daily_limit: 5
# Consecutive execution failures that disable the offer path until
# `salvager offer enable` clears it. Independent from the Phase 2
# circuit breaker — offer failures never block real buys.
lockout_threshold: 3
# Master off-switch for offer sending, regardless of per-entry settings.
kill_switch_global: false

# ─────────────────────────────────────────────────────────────────────────
# telegram — delivery semantics (NFR-I6)
# ─────────────────────────────────────────────────────────────────────────
Expand Down
19 changes: 19 additions & 0 deletions docs/release-audits/v1.0/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,3 +383,22 @@ If any **critical** anomaly per the checklist (emoji collapse under
simulator · primary command unnavigable in VoiceOver · severity emoji
corruption on a Telegram client), flip to `BLOCKED — <one-line reason>`
and open a release-gating bug.

---

## Pending delta — wallapop-make-offer (2026-07-23)

The offer flow (OpenSpec change `wallapop-make-offer`) adds 21 rendering
variants to the catalog (registry 45 → 66): 2 negotiable listing shapes,
2 with-offer listing shapes, `offer_sent`, 12 `offer_failure_*`, and 4
operational events. When the change ships in a release:

- the code-level audit extends over the new surfaces (golden snapshots
already pin all 34 text+keyboard variants in
`tests/unit/__snapshots__/test_offer_renderer_snapshots.ambr`);
- the on-device capture pass gains the emit-able newcomers
(`dev emit-alert negotiable_listing_direct`, `offer_sent`, the 12
failures) plus a colorblind glance at the `💰` token (informational —
the banner word/keyboard carries state, per the §2 rule);
- the live-event items mirror the buy path: one real Ofertar tap
eyeballed through `🟡 Ofertando…` → `💰 Oferta enviada` / restored row.
2 changes: 2 additions & 0 deletions openspec/changes/wallapop-make-offer/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-22
28 changes: 28 additions & 0 deletions openspec/changes/wallapop-make-offer/captures/NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Capture notes — wallapop-make-offer

## App captures (2026-07-22, files in this directory)

- `app-listing-hacer-oferta-button.jpg` — listing page with "Hacer oferta"
next to Comprar.
- `app-offer-form-10-restantes.jpg` — offer form showing the
"10 ofertas restantes para hoy" counter.
- `app-offer-form-floor-30pct.jpg` — inline floor validation:
"Tu oferta debe ser de al menos 35€ (-30%)" on a 50 € item.

## Web verification (2026-07-23, operator screenshots — task 5.1)

Confirmed on es.wallapop.com (desktop web, authenticated session):

- The listing page (`/item/<slug>`) shows a "Hacer oferta" button
directly under Comprar — same entry point as the app.
- Clicking opens the "Hacer una oferta" modal, addressable at
`/app/chat/offer?itemId=<internal id>` (the SAME internal id the
reconciliation re-fetch uses; carried in the agent goal as a
recovery hint).
- The same "10 ofertas restantes para hoy" counter renders in the modal.
- The amount field accepts cents using a COMMA separator ("20,22"
observed accepted, Enviar enabled). Salvager's amounts are whole
euros, so no decimals are ever entered.
- No -30 % floor message was visible client-side at a below-floor
amount on web — validation may happen on submit; the domain
pre-validates the floor and `amount_rejected` covers a refusal.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading