walla-cli 0.1.14__tar.gz → 0.1.15__tar.gz

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 (130) hide show
  1. {walla_cli-0.1.14 → walla_cli-0.1.15}/AGENTS.md +2 -0
  2. {walla_cli-0.1.14 → walla_cli-0.1.15}/PKG-INFO +1 -1
  3. walla_cli-0.1.15/docs/cli_playbook.md +414 -0
  4. walla_cli-0.1.15/docs/objective_function.md +8 -0
  5. {walla_cli-0.1.14 → walla_cli-0.1.15}/pyproject.toml +1 -1
  6. {walla_cli-0.1.14 → walla_cli-0.1.15}/skills/walla/SKILL.md +1 -1
  7. {walla_cli-0.1.14 → walla_cli-0.1.15}/tests/test_desk.py +27 -0
  8. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/__init__.py +1 -1
  9. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/core/instruct.py +11 -5
  10. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/hunter/converge.py +37 -9
  11. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/hunter/negotiate.py +4 -0
  12. walla_cli-0.1.14/docs/objective_function.md +0 -7
  13. {walla_cli-0.1.14 → walla_cli-0.1.15}/.cursor/rules/quality.mdc +0 -0
  14. {walla_cli-0.1.14 → walla_cli-0.1.15}/.github/workflows/ci.yml +0 -0
  15. {walla_cli-0.1.14 → walla_cli-0.1.15}/.gitignore +0 -0
  16. {walla_cli-0.1.14 → walla_cli-0.1.15}/LICENSE +0 -0
  17. {walla_cli-0.1.14 → walla_cli-0.1.15}/README.md +0 -0
  18. {walla_cli-0.1.14 → walla_cli-0.1.15}/docs/WIRE.md +0 -0
  19. {walla_cli-0.1.14 → walla_cli-0.1.15}/docs/assets/walla-demo-poster.jpg +0 -0
  20. {walla_cli-0.1.14 → walla_cli-0.1.15}/docs/assets/walla-demo-preview.gif +0 -0
  21. {walla_cli-0.1.14 → walla_cli-0.1.15}/docs/assets/walla-demo.mp4 +0 -0
  22. {walla_cli-0.1.14 → walla_cli-0.1.15}/docs/assets/walla-github-banner.jpg +0 -0
  23. {walla_cli-0.1.14 → walla_cli-0.1.15}/docs/code_quality.md +0 -0
  24. {walla_cli-0.1.14 → walla_cli-0.1.15}/docs/data_engineering_standards.md +0 -0
  25. {walla_cli-0.1.14 → walla_cli-0.1.15}/docs/mcp.md +0 -0
  26. {walla_cli-0.1.14 → walla_cli-0.1.15}/docs/rate_limit_analysis.md +0 -0
  27. {walla_cli-0.1.14 → walla_cli-0.1.15}/fixtures/categories.json +0 -0
  28. {walla_cli-0.1.14 → walla_cli-0.1.15}/fixtures/edit_item_request.json +0 -0
  29. {walla_cli-0.1.14 → walla_cli-0.1.15}/fixtures/item_detail.json +0 -0
  30. {walla_cli-0.1.14 → walla_cli-0.1.15}/fixtures/offer_buyer_request.json +0 -0
  31. {walla_cli-0.1.14 → walla_cli-0.1.15}/fixtures/search_bicicleta.json +0 -0
  32. {walla_cli-0.1.14 → walla_cli-0.1.15}/fixtures/sell_item_request.json +0 -0
  33. {walla_cli-0.1.14 → walla_cli-0.1.15}/fixtures/sell_suggest_draft.json +0 -0
  34. {walla_cli-0.1.14 → walla_cli-0.1.15}/fixtures/wire_contracts.json +0 -0
  35. {walla_cli-0.1.14 → walla_cli-0.1.15}/incoming/teach/README.md +0 -0
  36. {walla_cli-0.1.14 → walla_cli-0.1.15}/scripts/check_code_quality.py +0 -0
  37. {walla_cli-0.1.14 → walla_cli-0.1.15}/scripts/dogfood_run.py +0 -0
  38. {walla_cli-0.1.14 → walla_cli-0.1.15}/scripts/probe_rate.py +0 -0
  39. {walla_cli-0.1.14 → walla_cli-0.1.15}/skills/walla-buy/SKILL.md +0 -0
  40. {walla_cli-0.1.14 → walla_cli-0.1.15}/skills/walla-sell/SKILL.md +0 -0
  41. {walla_cli-0.1.14 → walla_cli-0.1.15}/tests/test_account.py +0 -0
  42. {walla_cli-0.1.14 → walla_cli-0.1.15}/tests/test_account_more.py +0 -0
  43. {walla_cli-0.1.14 → walla_cli-0.1.15}/tests/test_catch.py +0 -0
  44. {walla_cli-0.1.14 → walla_cli-0.1.15}/tests/test_categories_find.py +0 -0
  45. {walla_cli-0.1.14 → walla_cli-0.1.15}/tests/test_chat_open.py +0 -0
  46. {walla_cli-0.1.14 → walla_cli-0.1.15}/tests/test_cli_json.py +0 -0
  47. {walla_cli-0.1.14 → walla_cli-0.1.15}/tests/test_core.py +0 -0
  48. {walla_cli-0.1.14 → walla_cli-0.1.15}/tests/test_edges.py +0 -0
  49. {walla_cli-0.1.14 → walla_cli-0.1.15}/tests/test_edit.py +0 -0
  50. {walla_cli-0.1.14 → walla_cli-0.1.15}/tests/test_export.py +0 -0
  51. {walla_cli-0.1.14 → walla_cli-0.1.15}/tests/test_http_more.py +0 -0
  52. {walla_cli-0.1.14 → walla_cli-0.1.15}/tests/test_live.py +0 -0
  53. {walla_cli-0.1.14 → walla_cli-0.1.15}/tests/test_negotiate.py +0 -0
  54. {walla_cli-0.1.14 → walla_cli-0.1.15}/tests/test_parsers.py +0 -0
  55. {walla_cli-0.1.14 → walla_cli-0.1.15}/tests/test_polite.py +0 -0
  56. {walla_cli-0.1.14 → walla_cli-0.1.15}/tests/test_regressions.py +0 -0
  57. {walla_cli-0.1.14 → walla_cli-0.1.15}/tests/test_search_api.py +0 -0
  58. {walla_cli-0.1.14 → walla_cli-0.1.15}/tests/test_sell.py +0 -0
  59. {walla_cli-0.1.14 → walla_cli-0.1.15}/tests/test_session_store.py +0 -0
  60. {walla_cli-0.1.14 → walla_cli-0.1.15}/tests/test_shortlist.py +0 -0
  61. {walla_cli-0.1.14 → walla_cli-0.1.15}/tests/test_suggest_watch.py +0 -0
  62. {walla_cli-0.1.14 → walla_cli-0.1.15}/tests/test_teach.py +0 -0
  63. {walla_cli-0.1.14 → walla_cli-0.1.15}/tests/test_verdict.py +0 -0
  64. {walla_cli-0.1.14 → walla_cli-0.1.15}/tests/test_wire_contracts.py +0 -0
  65. {walla_cli-0.1.14 → walla_cli-0.1.15}/uv.lock +0 -0
  66. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/account/__init__.py +0 -0
  67. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/account/actions.py +0 -0
  68. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/account/chat.py +0 -0
  69. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/account/cookies.py +0 -0
  70. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/account/desk.py +0 -0
  71. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/account/desk_watch.py +0 -0
  72. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/account/inbox.py +0 -0
  73. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/account/login.py +0 -0
  74. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/account/offer_wire.py +0 -0
  75. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/account/prompt.py +0 -0
  76. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/account/sell.py +0 -0
  77. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/account/sell_edit.py +0 -0
  78. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/account/sell_edit_wire.py +0 -0
  79. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/account/sell_photos.py +0 -0
  80. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/account/sell_steps.py +0 -0
  81. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/account/sell_suggest.py +0 -0
  82. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/account/sell_upload.py +0 -0
  83. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/account/sell_wire.py +0 -0
  84. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/account/session_store.py +0 -0
  85. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/cli/__init__.py +0 -0
  86. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/cli/banner.py +0 -0
  87. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/cli/catch.py +0 -0
  88. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/cli/cmd_account.py +0 -0
  89. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/cli/cmd_desk.py +0 -0
  90. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/cli/cmd_edit.py +0 -0
  91. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/cli/cmd_look.py +0 -0
  92. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/cli/cmd_ops.py +0 -0
  93. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/cli/cmd_sell.py +0 -0
  94. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/cli/cmd_teach.py +0 -0
  95. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/cli/console.py +0 -0
  96. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/cli/main.py +0 -0
  97. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/core/__init__.py +0 -0
  98. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/core/envelope.py +0 -0
  99. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/core/errors.py +0 -0
  100. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/core/exceptions.py +0 -0
  101. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/core/human_fix.py +0 -0
  102. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/core/path.py +0 -0
  103. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/core/teach.py +0 -0
  104. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/core/teach_pr.py +0 -0
  105. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/http/__init__.py +0 -0
  106. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/http/client.py +0 -0
  107. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/http/headers.py +0 -0
  108. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/http/polite.py +0 -0
  109. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/http/sign.py +0 -0
  110. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/hunter/__init__.py +0 -0
  111. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/hunter/export.py +0 -0
  112. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/hunter/export_pdf.py +0 -0
  113. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/hunter/profile_store.py +0 -0
  114. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/hunter/pursuits.py +0 -0
  115. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/hunter/shortlist.py +0 -0
  116. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/hunter/stack.py +0 -0
  117. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/hunter/verdict.py +0 -0
  118. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/hunter/watches.py +0 -0
  119. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/mcp/__init__.py +0 -0
  120. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/mcp/_entry.py +0 -0
  121. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/mcp/server.py +0 -0
  122. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/models/__init__.py +0 -0
  123. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/models/account.py +0 -0
  124. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/models/listing.py +0 -0
  125. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/models/profile.py +0 -0
  126. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/search/__init__.py +0 -0
  127. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/search/api.py +0 -0
  128. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/search/category_find.py +0 -0
  129. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/search/parse.py +0 -0
  130. {walla_cli-0.1.14 → walla_cli-0.1.15}/walla/search/pick_category.py +0 -0
@@ -8,6 +8,7 @@ You are working on **walla**: Wallapop.es CLI / library / MCP (look · offer ·
8
8
  - [`docs/data_engineering_standards.md`](docs/data_engineering_standards.md) — Gray / Stonebraker bar
9
9
  - [`docs/WIRE.md`](docs/WIRE.md) — captured wire
10
10
  - [`docs/objective_function.md`](docs/objective_function.md)
11
+ - [`docs/cli_playbook.md`](docs/cli_playbook.md) — copy this stack into a new public CLI
11
12
 
12
13
  Run before shipping:
13
14
 
@@ -58,6 +59,7 @@ Ranks: models 10 → exceptions 15 → http 20 → core 30 → search/account/hu
58
59
  - **Do** keep files <= 250 LOC.
59
60
  - **Do** fail closed; typed `error_type`.
60
61
  - **Do** require `--yes` for bare say/offer/sell/unsell/edit. `pursue` and `desk` send negotiation chat without a per-message prompt. Never pay.
62
+ - **Do** talk like a person. Answer their question first. Never append "cerramos en la app" or force a close on every reply.
61
63
  - **Do** use `walla edit <id> --eur N --yes` to change price/desc (no unsell+republish).
62
64
  - **Do** pass leaf id or `motor accesorios` when `--category Accesorios` is ambiguous.
63
65
  - **Don't** commit session.json, cookies, or tokens.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: walla-cli
3
- Version: 0.1.14
3
+ Version: 0.1.15
4
4
  Summary: Wallapop.es CLI - look · offer · talk · sell for humans and agents
5
5
  Project-URL: Homepage, https://github.com/berendgort/wallapop-cli
6
6
  Project-URL: Repository, https://github.com/berendgort/wallapop-cli
@@ -0,0 +1,414 @@
1
+ # Playbook: build a public CLI the way fli, guru, walla, and pakket were built
2
+
3
+ Follow this document in order. The result is a Python library, a Typer CLI, and a FastMCP server for one job. The same steps apply to Mercadona, LinkedIn outreach, Bizneo, WhatsApp, or anything else.
4
+
5
+ Evidence these steps come from:
6
+
7
+ - [fli](https://github.com/punitarani/fli): Google Flights library, `fli` CLI, `fli-mcp`. Offline fixtures by default. Live tests are a small canary (`pytest -m live`). Commit `546ab9e` put `error_type` and `retryable` on CLI JSON and MCP.
8
+ - [guru](https://github.com/berendgort/guru): commit `45e1b98` scaffolded Windguru by mirroring fli. Later commits added `instruct`, rider intake, sandbox unlock, polite spacing, and the Korotkevich pass. PyPI package `windguru`, command `guru`.
9
+ - [wallapop-cli](https://github.com/berendgort/wallapop-cli): same stack. Wire catalog in `fixtures/wire_contracts.json`. Mutations need `--yes`. Draft must not upload unless `--suggest`. Public item URL is the `web_slug`, not the hash. Polite spacing + 429/`Retry-After` + Forbidden circuit.
10
+ - [pakketadvies-cli](https://github.com/berendgort/pakketadvies-cli): same envelope, local JSONL instead of HTTP. Humans paste the GitHub link. The agent owns the runtime. Commit `ff8d8d8` fixed cite matching after agents field-tested the CLI.
11
+ - [mercadona-cli](https://github.com/berendgort/mercadona-cli), [linkedin-cli](https://github.com/berendgort/linkedin-cli), [bizneo-cli](https://github.com/berendgort/bizneo-cli): same envelope. Stop before pay / send / click. Empty wire catalog means the live mutation stays `unsupported`. Private only when the human says so (Bizneo).
12
+
13
+ Do not invent a second architecture. Copy this one and change the domain.
14
+
15
+ Every CLI repo ships a copy of this playbook at `docs/cli_playbook.md`. Keep that file in sync with this one when the bar changes. Household catalog: `AGENT_CLI_STORE.md`.
16
+
17
+ ---
18
+
19
+ ## 0. Create a GitHub repo with `gh`
20
+
21
+ Do this before the first commit. The account is **berendgort**. Default is **public**. Do not ask the human to click through github.com/new.
22
+
23
+ ```bash
24
+ # from an empty project directory, after git init and the first commit
25
+ gh repo create berendgort/<name>-cli --public --source=. --remote=origin --description "<one line>"
26
+ git push -u origin HEAD
27
+ ```
28
+
29
+ Rules:
30
+
31
+ - Name: `<command>-cli` when the PyPI name may collide (`walla-cli` serves command `walla`). Use the product name when it is free (`windguru` serves command `guru`).
32
+ - `gh auth status` must show the `berendgort` account. If it shows another account, stop and say so. Do not create the repo elsewhere.
33
+ - Confirm visibility before the push: `gh repo view berendgort/<name>-cli --json visibility,url`. Public is the default. Private only when the human said so in this chat (Bizneo).
34
+ - Add `https://github.com/berendgort/<name>-cli` to the README agent section the same day.
35
+ - Do not force-push `main`. Do not push secrets (`.env`, `session.json`, cookies).
36
+
37
+ ---
38
+
39
+ ## 1. Name the objective before any endpoint
40
+
41
+ Write `docs/objective_function.md` first. Ask, in order:
42
+
43
+ 1. What job does this CLI finish for a person?
44
+ 2. If it works, what is true in their day that was not true before?
45
+ 3. If JSON, tests, and MCP tool count are perfect and that day is not better, did we succeed? No. Those are proxies.
46
+ 4. What optimization looks like a better CLI while missing the job?
47
+ 5. What must never happen even if it raised usage?
48
+
49
+ Mercadona example. The job is: the household cart holds the right products for a delivery slot they can receive. The human taps pay in the Mercadona app or site. A green schema with the wrong milk is a failure. Never pay from the CLI. Never invent a product id. Never HTML-scrape the shop as the primary path.
50
+
51
+ Hard constraints go in that file and in `instruct`. They are not comments. Always include: stay polite on rate limits.
52
+
53
+ ---
54
+
55
+ ## 2. Korotkevich / Tourist bar (code)
56
+
57
+ Every line meets a competitive-programming bar: mathematical clarity, modular purity, machine readability. Sprawling monoliths, circular imports, hidden I/O on import, and ambiguous contracts are forbidden.
58
+
59
+ Copy `scripts/check_code_quality.py` from walla or guru and retarget the package name. The script is the law. A review comment is not.
60
+
61
+ ### Layers (strict downward DAG)
62
+
63
+ Lower ranks never import higher ranks. Cycles are fatal.
64
+
65
+ ```text
66
+ [10] models Frozen Pydantic DTOs. No disk, no clock, no HTTP.
67
+ [15] core.exceptions Exception types only.
68
+ [20] http or data curl_cffi client, polite spacing, headers; or the one JSONL loader.
69
+ [30] core Envelope, error_type, paths, human_fix. No live calls.
70
+ [40] domain Pure policy: search rank, cart math, cite, budget, day window.
71
+ [50] cli and mcp Typer and FastMCP. They do not import each other.
72
+ ```
73
+
74
+ Same-rank packages (walla `search`, `account`, `hunter`) may not form a cycle. If two domains need each other, extract the shared pure function down into `core` or `models`.
75
+
76
+ Invariants:
77
+
78
+ - Zero circular dependencies at module or package level.
79
+ - Downward imports only. `models` never imports `core`. `http` never imports `cli`. `search` never imports `mcp`.
80
+ - Zero import side effects. No network, no profile read, no client constructed at import.
81
+ - Package `__init__.py` files are thin re-exports.
82
+ - Type-only upward references use `if TYPE_CHECKING`.
83
+
84
+ ### File size
85
+
86
+ No file in the package exceeds **250 lines**. Split before you hit the cap. One responsibility per module.
87
+
88
+ ### Functional core, imperative shell
89
+
90
+ - Parsers, rankers, cookie parsers, and verdict math take data in and return data out.
91
+ - HTTP, disk writes, and Typer I/O live in the shell.
92
+ - Invalid input fails closed with `ValueError` or a typed error. No silent default that hides a bad id.
93
+ - Domain transfers are frozen Pydantic models.
94
+
95
+ ### Credentials
96
+
97
+ - Never log a bearer token, session cookie, or password.
98
+ - Session file mode is `0600`. Path: `~/.config/<name>/session.json` (or `$FOO_HOME`). Atomic write: `tempfile.mkstemp` in the same dir, write, `os.replace`.
99
+ - `doctor --json` reports `auth.session` as a boolean, never the secret.
100
+ - Login is cookie paste or an explicit token flag. Do not require a `.env` full of passwords. Do not open household `.env` files for CLI auth.
101
+ - Fixtures are redacted. No tokens in `fixtures/`.
102
+
103
+ ### Machine readability
104
+
105
+ - `mypy --strict` passes with 0 errors on the package and on `scripts/check_code_quality.py`.
106
+ - Every library module defines `__all__`.
107
+ - No em-dash (`\u2014`) or en-dash (`\u2013`) in code or agent-facing strings.
108
+ - No bare `print()` except an allowlist: CLI banner, console, catch, MCP entry.
109
+
110
+ ### Gate (run before any "it works" claim)
111
+
112
+ ```bash
113
+ python scripts/check_code_quality.py
114
+ ruff check .
115
+ mypy <package> scripts/check_code_quality.py
116
+ pytest -q -m "not live"
117
+ ```
118
+
119
+ All four must pass. Do not weaken the script to go green.
120
+
121
+ ---
122
+
123
+ ## 3. Gray / Stonebraker bar (data)
124
+
125
+ Every stored fact has one writer, one clock, and an idempotent sink. Dual-writes and invented live-API shapes are forbidden.
126
+
127
+ Jim Gray: put all the eggs in one basket and watch that basket. Exactly-once delivery across two systems does not exist. At-least-once plus an idempotent sink does.
128
+
129
+ ### One mode per fact
130
+
131
+ | Mode | What | Mutability | Clock |
132
+ |---|---|---|---|
133
+ | Working | `profile.json`, `session.json`, watches, invite log | mutable | user lifetime |
134
+ | Archive | redacted fixtures under `fixtures/` | append on capture | repo life |
135
+ | Live | the real API response | read | one request |
136
+
137
+ No database in v1. No Postgres, Redis, or a second warehouse copy of the same rows. The live API is not a second source of truth for parsers. **Fixtures are.**
138
+
139
+ Local-archive CLIs (pakket) replace Live with a JSONL basket. One ingest script is the only writer. The CLI only reads. Advice date or snapshot `as_of` is the domain clock. Unknown keys fail at ingest.
140
+
141
+ ### Hard constraints
142
+
143
+ - One writer. Writing the profile to disk and also to a side cache of the same fields is a defect.
144
+ - Declare the grain in `docs/data_engineering_standards.md` before the second feature. Profile: one home, one radius, one budget. Session: tokens only, never the password. Fixture: one captured response, secrets redacted, `schema_version` noted next to the parser.
145
+ - Schema-on-write. Pydantic is the contract. Consumers ignore unknown wire keys. Required fields fail closed. A breaking envelope change bumps `api_version` and updates `docs/WIRE.md`.
146
+ - Idempotent sinks. Session save overwrites one path. A watch check diffs ids against the stored set. Re-running the same result yields an empty new list. Invite log: one row per person; a repeat updates status and keeps the original day.
147
+ - Do not invent a request or response body. If there is no fixture, the call fails with `error_type: unsupported`. Empty wire catalog calls means the live mutation stays unsupported (LinkedIn invite POST, Bizneo clock).
148
+ - Trace with non-secret ids (product id, listing id, slug). Never trace the cookie.
149
+
150
+ Copy `docs/code_quality.md` and `docs/data_engineering_standards.md` into the new repo and replace the package name. The playbook states the bar. Those two files are what `AGENTS.md` points at inside the new repo.
151
+
152
+ ---
153
+
154
+ ## 4. Capture the wire before the feature
155
+
156
+ 1. Reproduce the call in the browser Network panel on the real site.
157
+ 2. Repeat it with `curl_cffi` (browser impersonation, the headers the site actually requires). Plain curl often 403s (Mercadona search).
158
+ 3. Redact secrets. Save the response under `fixtures/`.
159
+ 4. Write a parser test that loads that fixture and checks required fields.
160
+ 5. Update `docs/WIRE.md` with method, path, required headers, and the body keys.
161
+ 6. Add the call to a wire catalog (`fixtures/wire_contracts.json`). `scripts/check_code_quality.py` fails if a new `/api/` literal is missing from the catalog.
162
+
163
+ Never HTML-scrape as the primary path. Never Playwright as the product. Never CDP as the product.
164
+
165
+ Lessons that are now rules:
166
+
167
+ - Offer and sell bodies come from fixtures. The "obvious" JSON (`{item_id, amount, currency}`) was HTTP 400. Listing create without `Accept: application/vnd.upload-v2+json` was HTTP 405.
168
+ - A draft command stays local. It must not upload photos or open a remote session unless the user passed an explicit flag (`walla sell --suggest`).
169
+ - Public URLs use the site's slug, not an opaque internal hash.
170
+ - Category and product pickers accept a human name and return candidates when ambiguous. Numeric ids are optional. Duplicate keys raise `ambiguous` before a name fallback (LinkedIn resolve).
171
+ - No fixture means `unsupported`, not a generated stand-in. Inventing Bizneo clock blocks presented them as worked time. That is a defect. Delete the generator; refuse with a human_fix recipe.
172
+
173
+ ---
174
+
175
+ ## 5. Rate limits (required)
176
+
177
+ Every CLI that dials a network must respect rate limits. A local-only CLI (pakket) still classifies `rate_limited` in the envelope so agents share one contract. Policy caps (LinkedIn daily/weekly) are rate limits too.
178
+
179
+ ### What the HTTP client must do
180
+
181
+ 1. **Minimum interval** between outbound dials (one process-wide clock). Default from an env knob, documented in `docs/WIRE.md` (walla: `WALLA_MIN_INTERVAL=1.0`; merca: 0.3 s; guru: `GURU_MIN_INTERVAL`).
182
+ 2. **Honor `Retry-After`** on HTTP 429. Sleep that many seconds when present, then raise or retry with a bounded attempt count. Never spin.
183
+ 3. **Map 429** to `error_type: rate_limited` with `retryable: true` and `retry_after_s` when the header exists.
184
+ 4. **Circuit on hard bans** (403 Forbidden / anti-scrape body). Trip for `CIRCUIT_TTL`, refuse further dials, classify as `rate_limited` with `retryable: false` when it is an IP ban (guru). Document the knob.
185
+ 5. **Short TTL GET cache** when the same read repeats inside one process (optional but preferred; walla and guru do this).
186
+ 6. **Do not blast.** Reads may be exercised during development. Writes once against a safe target. Never loop offers, invites, or chat sends to probe.
187
+
188
+ Domain policy caps (LinkedIn): hard daily 15, soft target 8-12, weekly 80, rest days, accept-rate gate. Those live in pure policy with offline tests. They are not a substitute for HTTP spacing once a send path exists.
189
+
190
+ ### What to test (offline, default gate)
191
+
192
+ Ship `tests/test_polite.py` (or equivalent). Cover at least:
193
+
194
+ | Test | Assert |
195
+ |---|---|
196
+ | Spacing | Two `wait_turn` calls with `min_interval=0.15` take at least ~0.14 s |
197
+ | 429 classify | Fake transport returns 429 → `error_type: rate_limited`, `retryable: true` |
198
+ | `Retry-After` | Header `"2"` surfaces as `retry_after_s == 2` (or client sleeps then succeeds on next fake) |
199
+ | Circuit | After a Forbidden trip, a second dial does not hit the transport |
200
+ | Cache (if present) | Identical GET hits the transport once |
201
+ | Env knobs | Documented names appear in `docs/WIRE.md` or `docs/rate_limit_analysis.md` |
202
+ | Policy caps (if any) | Budget at the hard cap returns `remaining == 0`; accept-rate throttle tested with pinned dates |
203
+
204
+ Classify tests stay offline. Fake the transport. Do not hit the live site in the default gate.
205
+
206
+ ### What to test (live, opt-in)
207
+
208
+ Mark `pytest -m live`. A bounded probe only:
209
+
210
+ - Small fixed burst (for example 8 spaced reads).
211
+ - Stop at the first 429. Do not continue the burst.
212
+ - Assert either no 429, or the last status is 429 and the envelope is `rate_limited`.
213
+ - Write observed numbers into `docs/rate_limit_analysis.md` (walla pattern). Do not invent a limit you did not measure.
214
+
215
+ `scripts/probe_rate.py` is allowed as a manual tool. It must stop at the first 429 and must not hammer.
216
+
217
+ ### Envelope and docs
218
+
219
+ - `error_type` includes `rate_limited`. Agents slow down or stop; they do not retry in a tight loop.
220
+ - `docs/WIRE.md` lists the spacing env vars and the 429 behavior.
221
+ - `docs/objective_function.md` lists "stay polite on rate limits" in the never-list / constraints.
222
+ - `check_code_quality.py` may ban tokens that mean browser automation (`playwright`, CDP ports) so agents do not "fix" rate limits by switching to a browser hammer.
223
+
224
+ ---
225
+
226
+ ## 6. Scaffold
227
+
228
+ ```text
229
+ <pkg>/
230
+ models/ frozen DTOs
231
+ http/ client, polite spacing, headers
232
+ core/ envelope, errors, paths, human_fix, instruct
233
+ <domain>/ pure use-cases (catalog, basket, cite, budget, day)
234
+ cli/ Typer commands, Rich, --json
235
+ mcp/ FastMCP tools, same functions, no CLI import
236
+ scripts/check_code_quality.py
237
+ scripts/dogfood_run.py
238
+ fixtures/
239
+ docs/cli_playbook.md # copy of this playbook
240
+ docs/WIRE.md
241
+ docs/objective_function.md
242
+ docs/code_quality.md
243
+ docs/data_engineering_standards.md
244
+ docs/rate_limit_analysis.md # when the CLI dials a network
245
+ skills/<name>/SKILL.md
246
+ AGENTS.md
247
+ README.md agent protocol first
248
+ ```
249
+
250
+ Every new CLI copies this playbook to `docs/cli_playbook.md` on day one. When you change the bar here, update that file in each sibling repo the same day (or open a PR that does). `AGENTS.md` must link to `docs/cli_playbook.md`.
251
+
252
+ Mercadona sketch (names only):
253
+
254
+ - `merca/catalog` searches products.
255
+ - `merca/basket` adds lines. Add requires `--yes`.
256
+ - `checkout` prints the slot and stops. The human pays. Doctor field `pays: false`.
257
+
258
+ `pyproject.toml`: MIT, Python `>=3.10`, extras `[mcp]` and `[dev]` (`pytest`, `ruff`, `mypy`). Console script is the short command. Author: Dr. Berend Gort, berend.gort@gmail.com.
259
+
260
+ README agent section (required, matches `instruct`):
261
+
262
+ 1. `pipx install '<dist>[mcp]'` from PyPI. Clone only when hacking.
263
+ 2. `<cmd> doctor --json`. Never print tokens.
264
+ 3. If `error_type: auth`, one human fix: paste the session cookie. Agents may pass `--cookie` without putting the value in the chat log.
265
+ 4. `<cmd> instruct --json`, then do the task with `--json`.
266
+ 5. Narrate the result. Do not dump raw JSON on the human.
267
+
268
+ Optional MCP: `{ "mcpServers": { "<name>": { "command": "<name>-mcp" } } }`. MCP is the same library. It is not a second product. Shell CLI is the default path (guru).
269
+
270
+ ---
271
+
272
+ ## 7. Agent contract
273
+
274
+ Envelope for every `--json` command and every MCP tool:
275
+
276
+ ```text
277
+ ok: bool
278
+ api_version: int
279
+ data: object when ok
280
+ error: str when not ok
281
+ error_type: str auth | ambiguous | not_found | rate_limited |
282
+ validation_error | unsupported | parse_error |
283
+ timeout | http_error | error
284
+ retryable: bool
285
+ ```
286
+
287
+ `instruct --json` returns the recipe: objective, commands, which steps the agent may take alone, which need `--yes`, and what the human must finish in the real app.
288
+
289
+ Ambiguous input returns `error_type: ambiguous` plus `candidates`. Never silent first-match (guru spots, pakket cite, walla categories, LinkedIn leads).
290
+
291
+ `doctor --json` reports booleans: session present, network reachable, version, and the stop flags that matter (`pays`, `sends`, `clicks`). On a sandbox that cannot reach the API, unlock or print one `human_fix` recipe. Do not dump a tutorial before that attempt (guru unlock).
292
+
293
+ Auth and unsupported include `human_fix`. Never print cookie or token.
294
+
295
+ ---
296
+
297
+ ## 8. Writes
298
+
299
+ | Kind | Flag | Example |
300
+ |---|---|---|
301
+ | Read | none | search, item, instruct, doctor, act (decision only) |
302
+ | Mutate | `--yes` | cart add, send message, publish listing, delete |
303
+ | Pay / accept / place the real order / click the clock | never from a missing fixture | human finishes in the app; CLI sets `stopped_before_*` |
304
+
305
+ A loop may send negotiation text without a per-message prompt only when the objective says so (walla `pursue` and `desk`). It still never pays.
306
+
307
+ Preview/draft performs no network unless a named flag says so. `prepare --yes` that cannot send returns a draft with `stopped_before_send: true` and does not append the invite log.
308
+
309
+ Household automations call the CLI and assert the stop flag. They do not browser-automate around it.
310
+
311
+ ---
312
+
313
+ ## 9. Offline tests, then the parallel dogfood loop
314
+
315
+ Unit tests do not prove the CLI. After the gate is green, agents must drive the **installed command**.
316
+
317
+ ### Offline first
318
+
319
+ - One parser test per fixture.
320
+ - CLI tests invoke Typer with `--json` and assert `ok` and `error_type` (auth missing, `--yes` missing, ambiguous).
321
+ - Polite / rate-limit tests from section 5 are in the default gate.
322
+ - `pytest -m "not live"` never touches the network.
323
+ - Live tests are marked `live` and stay out of the default gate. A write against production needs an env flag such as `WALLA_LIVE_WRITE=1`.
324
+
325
+ ### Flow matrix
326
+
327
+ Build the matrix from `instruct` and `<cmd> --help`. One row per behavior:
328
+
329
+ - happy path
330
+ - empty result
331
+ - auth missing (`error_type: auth`, no secret in stdout)
332
+ - ambiguous id (`candidates`, no silent pick)
333
+ - validation error
334
+ - mutation without `--yes` (must refuse)
335
+ - mutation with `--yes` against a fixture or a dry-run, not a real charge
336
+ - rate limited (fake 429 → envelope)
337
+ - unsupported path when wire is missing (send, clock, pay)
338
+ - doctor / instruct shape, including stop flags
339
+
340
+ Seed pattern: `scripts/dogfood_run.py` runs one scenario as a subprocess of `.venv/bin/<cmd> --json` and writes a JSON report. Each new CLI gets the same script. One scenario, one process, one report file.
341
+
342
+ ### Parallel agents
343
+
344
+ After the binary exists, launch one Cursor agent per matrix row. They run at the same time.
345
+
346
+ Each agent:
347
+
348
+ - Receives the binary path, the exact argv, and the expected `ok` / `error_type`.
349
+ - May only run the CLI. It must not edit code, must not pay, and must not place a real order.
350
+ - Returns: argv, exit code, parsed envelope, and one line on whether a human was asked to do agent work.
351
+
352
+ The parent agent:
353
+
354
+ - Reads every report.
355
+ - A failure becomes a fixture or a regression test, then a code fix. Do not "fix" by weakening the expected `error_type`.
356
+ - Re-run the failed rows, then the full matrix.
357
+ - Stop only when every row passes.
358
+
359
+ ### CodeRabbit, then stop
360
+
361
+ When the matrix is green, review the diff. On a root commit with no useful parent, use an empty-parent `/tmp` clone (init, allow-empty on `main`, branch `review`, rsync source excluding `.git` / `.venv` / `*.egg-info`, then `coderabbit review --agent --base main`). Do not set git config. Do not force-push.
362
+
363
+ ```bash
364
+ coderabbit review --agent --base-commit <sha-before-this-work>
365
+ # or --base main in the /tmp empty-parent method
366
+ ```
367
+
368
+ Fix every finding, including minor ones, unless the finding is invalid after checking the code. Re-review until `review_completed` reports `findings: 0`. Trust that JSON over a stale `coderabbit review findings` dump. Free CLI allowance is 3 reviews; on rate limit wait the stated `waitTime` and retry.
369
+
370
+ Do not ship with a known finding left open because it is "minor". Do not invent hours, invite bodies, or pay paths to silence a finding; refuse with `unsupported` instead.
371
+
372
+ ---
373
+
374
+ ## 10. Ship only when the human asked
375
+
376
+ ```bash
377
+ # bump version in pyproject.toml and the package __version__
378
+ python scripts/check_code_quality.py && ruff check . && mypy <package> && pytest -q -m "not live"
379
+ git add <files>
380
+ git commit
381
+ git push -u origin HEAD
382
+ # PyPI upload with the token already on the machine. Never print the token.
383
+ pipx install '<dist>[mcp]' --force
384
+ ```
385
+
386
+ Deploy means PyPI **and** `git push` (guru commit `094fdc7`). Do not push and do not publish unless the human asked in this chat. Do not force-push `main`. Do not rebuild a published package without permission.
387
+
388
+ ---
389
+
390
+ ## 11. Definition of done
391
+
392
+ - Repo `berendgort/<name>-cli` exists. Visibility matches what the human asked (`PUBLIC` default; private only when stated).
393
+ - `docs/cli_playbook.md` is present (copy of this playbook). `AGENTS.md` links to it.
394
+ - `docs/objective_function.md` states the human outcome, the never-list, and polite rate limits.
395
+ - `docs/code_quality.md` and `docs/data_engineering_standards.md` match sections 2 and 3. `scripts/check_code_quality.py` enforces them.
396
+ - `docs/WIRE.md` matches fixtures. No invented body. Spacing / 429 knobs documented.
397
+ - Network dialers have polite spacing + offline rate-limit tests (section 5). Live probe is opt-in and bounded.
398
+ - `instruct --json` and the README agent section agree.
399
+ - The four-command gate is green.
400
+ - Every dogfood row passed under a real CLI subprocess.
401
+ - CodeRabbit on that diff reports zero findings.
402
+ - The human was not asked to install Python, pip, or click through GitHub unless the product truly needs one cookie paste.
403
+
404
+ ---
405
+
406
+ ## Learned
407
+
408
+ | Date | Pattern |
409
+ |---|---|
410
+ | 2026-10-09 | Empty wire catalog → live mutation stays `unsupported`. Do not invent invite POST, clock blocks, or pay. |
411
+ | 2026-10-09 | Stop flags in doctor and data: `pays`, `sends`, `clicks`, `stopped_before_pay` / `_send` / `_click` / `_submit`. Automations assert them. |
412
+ | 2026-10-09 | Private repo only when the human says so. Confirm with `gh repo view --json visibility`. |
413
+ | 2026-10-09 | CodeRabbit on a root commit needs an empty-parent `/tmp` review. Trust `review_completed` JSON. Wait out free-tier `waitTime`. |
414
+ | 2026-10-09 | Every CLI carries `docs/cli_playbook.md`. Rate-limit spacing, 429, Retry-After, and circuit are required and tested offline. |
@@ -0,0 +1,8 @@
1
+ # Objective function
2
+
3
+ 1. Find the listing (search + verdict GRAB / LOOK / PASS).
4
+ 2. Do not miss a reply (inbox / thread).
5
+ 3. Talk like a person. Answer their question. Do not force a close on every message.
6
+ 4. `walla say` / `walla offer` still need `--yes`. `walla pursue` and `walla desk` send negotiation chat on purpose. Never pay.
7
+ 5. Never leak session cookies or bearer tokens.
8
+ 6. Stay polite on rate limits.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "walla-cli"
3
- version = "0.1.14"
3
+ version = "0.1.15"
4
4
  description = "Wallapop.es CLI - look · offer · talk · sell for humans and agents"
5
5
  authors = [{ name = "Dr. Berend Gort", email = "berend.gort@gmail.com" }]
6
6
  readme = "README.md"
@@ -31,4 +31,4 @@ walla setup --lat 41.39 --lon 2.17 --km 30 --budget 400 \
31
31
  | Find items, message sellers, follow the inbox, pick a price | `skills/walla-buy/SKILL.md` |
32
32
  | Publish a listing, answer buyers, pick a price | `skills/walla-sell/SKILL.md` |
33
33
 
34
- The human closes the deal in the Wallapop app. walla never pays and never taps Accept.
34
+ Talk like a person: answer their question, then stop. Do not push "cerramos en la app" on every reply. The human finishes in the Wallapop app when ready. walla never pays and never taps Accept.
@@ -72,6 +72,33 @@ def test_counter_steps_up_but_not_over_ceiling() -> None:
72
72
  assert move.action == "send"
73
73
  assert move.offer_eur == 175
74
74
  assert "175" in move.text
75
+ assert "cerramos" not in move.text.lower()
76
+ assert "en la app" not in move.text.lower()
77
+
78
+
79
+ def test_question_waits_without_forced_close() -> None:
80
+ sale = _buy(side="sell", ask_eur=79, offer_eur=79, walk_away_eur=79, status="waiting")
81
+ move = next_move(
82
+ sale,
83
+ [
84
+ _msg("Hola, sigue disponible", mine=True, at=1),
85
+ _msg("¿Me podrías decir las medidas exactas?", mine=False, at=2),
86
+ ],
87
+ )
88
+ assert move.action == "wait"
89
+ assert "question" in move.why.lower()
90
+ assert move.text == ""
91
+
92
+
93
+ def test_soft_nudge_has_no_app_close() -> None:
94
+ move = next_move(
95
+ _buy(),
96
+ [_msg("Te propongo 164 €", mine=True, at=1), _msg("ok gracias", mine=False, at=2)],
97
+ )
98
+ assert move.action == "send"
99
+ assert "164" in move.text
100
+ assert "cerramos" not in move.text.lower()
101
+ assert "en la app" not in move.text.lower()
75
102
 
76
103
 
77
104
  def test_vale_converges_and_no_does_not() -> None:
@@ -4,4 +4,4 @@ from __future__ import annotations
4
4
 
5
5
  __all__ = ("__version__",)
6
6
 
7
- __version__ = "0.1.14"
7
+ __version__ = "0.1.15"
@@ -19,9 +19,10 @@ def instruct_recipe() -> dict[str, Any]:
19
19
  "tagline": "look · offer · talk · sell",
20
20
  "example": (
21
21
  "Human: find a used kite under 400eur and negotiate. "
22
- "Agent: walla pursue \"kite\" --json, then walla desk --json "
23
- "until stack.best is set. Present the why and the item link. "
24
- "Do not ask the human to send messages. Human closes in the app."
22
+ "Agent: walla pursue \"kite\" --json, then walla desk --json. "
23
+ "Answer their questions like a person. Do not force a close "
24
+ "on every reply. Present the why and the item link when a "
25
+ "price agrees. Human finishes in the app when ready."
25
26
  ),
26
27
  "mandate": {
27
28
  "budget": profile.spend_cap,
@@ -64,8 +65,10 @@ def instruct_recipe() -> dict[str, Any]:
64
65
  {
65
66
  "step": "desk",
66
67
  "agent": (
67
- "walla desk --json reads the inbox once, replies, "
68
- "and returns stack.best plus why. Repeat until a price agrees."
68
+ "walla desk --json reads the inbox once and advances "
69
+ "price threads. If they asked a question, desk waits: "
70
+ "answer with walla say in their words. No 'cerramos "
71
+ "en la app' on every turn. Repeat until a price agrees."
69
72
  ),
70
73
  "ask_human": "never for the messages",
71
74
  },
@@ -165,6 +168,9 @@ def instruct_recipe() -> dict[str, Any]:
165
168
  "Refuse in-person offers outside pickup radius.",
166
169
  "Refuse offer_eur above mandate.budget.",
167
170
  "Negotiate drafts are win-win: respectful Spanish, no lowballs, no pressure.",
171
+ "Talk like a person (Carnegie): answer their question first. "
172
+ "Never append 'cerramos en la app' or push a close on every reply. "
173
+ "Let them choose the next step.",
168
174
  "never_pay: walla stops before payment; human completes in the app.",
169
175
  "No .env credentials. Login is cookie paste only (optional --password).",
170
176
  "If the human asks to upload/save a transcript via the CLI, do it "
@@ -24,6 +24,14 @@ _DEJO = re.compile(
24
24
  re.IGNORECASE,
25
25
  )
26
26
  _ACCEPT = re.compile(r"\b(vale|hecho|acepto|de acuerdo|perfecto|cerrado|trato)\b", re.IGNORECASE)
27
+ _QUESTION = re.compile(
28
+ r"[?¿]|\b("
29
+ r"medidas?|capacidad|marca|modelo|incluye|llevan?|tiene|"
30
+ r"cu[aá]nto|cu[aá]ndo|d[oó]nde|c[oó]mo|qu[eé]|qui[eé]n|"
31
+ r"disponible|envio|env[ií]o|recogida|fotos?"
32
+ r")\b",
33
+ re.IGNORECASE,
34
+ )
27
35
 
28
36
 
29
37
  class Move(BaseModel):
@@ -42,7 +50,7 @@ def next_move(pursuit: Pursuit, messages: list[Message]) -> Move:
42
50
  action="converged",
43
51
  agreed_eur=pursuit.agreed_eur,
44
52
  offer_eur=pursuit.offer_eur,
45
- why=pursuit.why or "Already agreed. Close it in the app.",
53
+ why=pursuit.why or "Already agreed. Human finishes in the app when ready.",
46
54
  nudges=pursuit.nudges,
47
55
  )
48
56
  if pursuit.status == "walked":
@@ -81,12 +89,26 @@ def next_move(pursuit: Pursuit, messages: list[Message]) -> Move:
81
89
  price = _latest_price(fresh)
82
90
  if price is not None:
83
91
  return _on_price(pursuit, price)
84
- if _accepts(" ".join(m.text for m in fresh)):
92
+ fresh_text = " ".join(m.text for m in fresh)
93
+ if _accepts(fresh_text):
85
94
  return Move(
86
95
  action="converged",
87
96
  agreed_eur=pursuit.offer_eur,
88
97
  offer_eur=pursuit.offer_eur,
89
- why=f"They accepted {_fmt(pursuit.offer_eur)} EUR. Close it in the app.",
98
+ why=(
99
+ f"They accepted {_fmt(pursuit.offer_eur)} EUR. "
100
+ "Stop pitching. Human finishes in the app when ready."
101
+ ),
102
+ nudges=pursuit.nudges,
103
+ )
104
+ if _asks_question(fresh_text):
105
+ return Move(
106
+ action="wait",
107
+ offer_eur=pursuit.offer_eur,
108
+ why=(
109
+ "They asked a question. Answer it in their words. "
110
+ "Do not push a price close."
111
+ ),
90
112
  nudges=pursuit.nudges,
91
113
  )
92
114
  if pursuit.nudges >= 1:
@@ -101,7 +123,7 @@ def next_move(pursuit: Pursuit, messages: list[Message]) -> Move:
101
123
  action="send",
102
124
  text=text,
103
125
  offer_eur=pursuit.offer_eur,
104
- why="They wrote, but named no price. One nudge, then wait.",
126
+ why="They wrote without a number. Soft ask once, then wait.",
105
127
  nudges=pursuit.nudges + 1,
106
128
  )
107
129
 
@@ -115,7 +137,7 @@ def _on_price(pursuit: Pursuit, price: float) -> Move:
115
137
  offer_eur=pursuit.offer_eur,
116
138
  why=(
117
139
  f"They named {_fmt(price)} EUR, within the {_fmt(ceiling)} ceiling. "
118
- "Stop writing. Close it in the app."
140
+ "Stop writing. Human finishes in the app when ready."
119
141
  ),
120
142
  nudges=pursuit.nudges,
121
143
  )
@@ -126,7 +148,7 @@ def _on_price(pursuit: Pursuit, price: float) -> Move:
126
148
  offer_eur=pursuit.offer_eur,
127
149
  why=(
128
150
  f"Buyer named {_fmt(price)} EUR, at or above the {_fmt(ceiling)} floor. "
129
- "Stop writing. Accept it in the app."
151
+ "Stop writing. Human accepts in the app when ready."
130
152
  ),
131
153
  nudges=pursuit.nudges,
132
154
  )
@@ -192,6 +214,10 @@ def _accepts(text: str) -> bool:
192
214
  return _ACCEPT.search(text) is not None
193
215
 
194
216
 
217
+ def _asks_question(text: str) -> bool:
218
+ return _QUESTION.search(text) is not None
219
+
220
+
195
221
  def _opening(pursuit: Pursuit) -> str:
196
222
  thing = " ".join(pursuit.title.split()[:6])[:60]
197
223
  eur = _fmt(pursuit.offer_eur)
@@ -205,14 +231,16 @@ def _opening(pursuit: Pursuit) -> str:
205
231
 
206
232
  def _nudge(pursuit: Pursuit) -> str:
207
233
  eur = _fmt(pursuit.offer_eur)
208
- return f"Gracias. ¿Te encaja {eur} €? Si sí, lo cerramos en la app."
234
+ if pursuit.side == "sell":
235
+ return f"Gracias por escribir. El precio es {eur} €. ¿Te encaja?"
236
+ return f"Gracias. ¿Te vendría bien {eur} €?"
209
237
 
210
238
 
211
239
  def _counter(pursuit: Pursuit, eur: float) -> str:
212
240
  n = _fmt(eur)
213
241
  if pursuit.side == "sell":
214
- return f"Gracias. Te lo puedo dejar en {n} €. Si te encaja lo cerramos en la app."
215
- return f"Gracias por contestar. Puedo llegar a {n} €. Si te encaja lo cerramos en la app."
242
+ return f"Gracias. Te lo puedo dejar en {n} €. ¿Qué te parece?"
243
+ return f"Gracias por contestar. Puedo llegar a {n} €. ¿Te parece bien?"
216
244
 
217
245
 
218
246
  def _fmt(n: float) -> str:
@@ -28,6 +28,9 @@ BANNED_PHRASES = (
28
28
  "otros interesados",
29
29
  "tengo otra oferta",
30
30
  "take it or leave",
31
+ "cerramos en la app",
32
+ "lo cerramos",
33
+ "si si, lo cerramos",
31
34
  )
32
35
 
33
36
  _AGGRESSION_PCT: dict[Aggression, float] = {
@@ -205,6 +208,7 @@ def _tone_checks(opening: str, offer_line: str, offer: float, ask: float) -> lis
205
208
  "first_offer_ge_80pct_ask",
206
209
  "draft_only_needs_confirm",
207
210
  "never_pay_in_walla",
211
+ "no_forced_close",
208
212
  ]
209
213
  if any(p in blob for p in BANNED_PHRASES):
210
214
  checks.append("FAIL_banned_phrase")
@@ -1,7 +0,0 @@
1
- # Objective function
2
-
3
- 1. Find the listing (search + verdict GRAB / LOOK / PASS).
4
- 2. Do not miss a reply (inbox / thread).
5
- 3. `walla say` / `walla offer` still need `--yes`. `walla pursue` and `walla desk` send negotiation chat on purpose. Never pay.
6
- 4. Never leak session cookies or bearer tokens.
7
- 5. Stay polite on rate limits.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes