openfactory 0.2.0__py3-none-any.whl

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 (270) hide show
  1. openfactory/__init__.py +7 -0
  2. openfactory/actions/__init__.py +344 -0
  3. openfactory/actions/base.py +334 -0
  4. openfactory/actions/catalog.py +4872 -0
  5. openfactory/actions/floor_intents.py +431 -0
  6. openfactory/adapters/__init__.py +0 -0
  7. openfactory/adapters/agent/__init__.py +71 -0
  8. openfactory/adapters/agent/base.py +358 -0
  9. openfactory/adapters/agent/claude_code.py +1023 -0
  10. openfactory/adapters/agent/codex.py +350 -0
  11. openfactory/adapters/agent/kimi.py +291 -0
  12. openfactory/adapters/agent/opencode.py +432 -0
  13. openfactory/adapters/agent/registry.py +485 -0
  14. openfactory/adapters/agent/roles.py +263 -0
  15. openfactory/adapters/agent/routes.py +255 -0
  16. openfactory/adapters/agent/session_store.py +221 -0
  17. openfactory/adapters/agent/stream.py +367 -0
  18. openfactory/adapters/agent/techlead.py +108 -0
  19. openfactory/adapters/agent/token_pool.py +67 -0
  20. openfactory/adapters/azure_devops.py +329 -0
  21. openfactory/adapters/board/__init__.py +10 -0
  22. openfactory/adapters/board/azure_devops.py +748 -0
  23. openfactory/adapters/board/base.py +193 -0
  24. openfactory/adapters/board/columns.py +68 -0
  25. openfactory/adapters/board/factory.py +218 -0
  26. openfactory/adapters/board/jira.py +252 -0
  27. openfactory/adapters/board/local.py +187 -0
  28. openfactory/adapters/board_db.py +276 -0
  29. openfactory/adapters/board_setup/__init__.py +8 -0
  30. openfactory/adapters/board_setup/base.py +49 -0
  31. openfactory/adapters/board_setup/local.py +63 -0
  32. openfactory/adapters/board_setup/registry.py +41 -0
  33. openfactory/adapters/channel/__init__.py +4 -0
  34. openfactory/adapters/channel/base.py +100 -0
  35. openfactory/adapters/channel/panel.py +107 -0
  36. openfactory/adapters/channel/registry.py +112 -0
  37. openfactory/adapters/credential/__init__.py +8 -0
  38. openfactory/adapters/credential/registry.py +164 -0
  39. openfactory/adapters/environment/__init__.py +30 -0
  40. openfactory/adapters/environment/azure_pipelines.py +412 -0
  41. openfactory/adapters/environment/base.py +35 -0
  42. openfactory/adapters/environment/github_actions.py +97 -0
  43. openfactory/adapters/environment/none.py +53 -0
  44. openfactory/adapters/environment/registry.py +160 -0
  45. openfactory/adapters/forge/__init__.py +30 -0
  46. openfactory/adapters/forge/azure_devops.py +1528 -0
  47. openfactory/adapters/forge/base.py +515 -0
  48. openfactory/adapters/forge/github.py +779 -0
  49. openfactory/adapters/forge/local.py +660 -0
  50. openfactory/adapters/forge/registry.py +210 -0
  51. openfactory/adapters/github_app.py +70 -0
  52. openfactory/adapters/notify/__init__.py +12 -0
  53. openfactory/adapters/notify/base.py +45 -0
  54. openfactory/adapters/notify/panel.py +43 -0
  55. openfactory/adapters/notify/registry.py +301 -0
  56. openfactory/adapters/reviewer/__init__.py +30 -0
  57. openfactory/adapters/reviewer/base.py +34 -0
  58. openfactory/adapters/reviewer/claude_code.py +140 -0
  59. openfactory/adapters/reviewer/harness.py +154 -0
  60. openfactory/adapters/sandbox/__init__.py +31 -0
  61. openfactory/adapters/sandbox/base.py +410 -0
  62. openfactory/adapters/sandbox/container.py +550 -0
  63. openfactory/adapters/sandbox/registry.py +490 -0
  64. openfactory/adapters/sandbox/timeouts.py +76 -0
  65. openfactory/adapters/sandbox/worktree.py +381 -0
  66. openfactory/adapters/tracker/__init__.py +30 -0
  67. openfactory/adapters/tracker/azure_devops.py +904 -0
  68. openfactory/adapters/tracker/base.py +549 -0
  69. openfactory/adapters/tracker/github.py +529 -0
  70. openfactory/adapters/tracker/github_board_setup.py +216 -0
  71. openfactory/adapters/tracker/github_project.py +733 -0
  72. openfactory/adapters/tracker/jira.py +575 -0
  73. openfactory/adapters/tracker/local.py +379 -0
  74. openfactory/adapters/tracker/parse.py +231 -0
  75. openfactory/adapters/tracker/registry.py +200 -0
  76. openfactory/after_merge.py +51 -0
  77. openfactory/api/__init__.py +4 -0
  78. openfactory/api/app.py +2627 -0
  79. openfactory/api/metrics_view.py +337 -0
  80. openfactory/api/panel.html +3419 -0
  81. openfactory/approvals.py +86 -0
  82. openfactory/box_prove.py +1458 -0
  83. openfactory/cli.py +3298 -0
  84. openfactory/cli_refusals.py +169 -0
  85. openfactory/conformance/__init__.py +19 -0
  86. openfactory/conformance/adapters.py +622 -0
  87. openfactory/contracts/__init__.py +65 -0
  88. openfactory/contracts/bot.py +42 -0
  89. openfactory/contracts/commands.py +183 -0
  90. openfactory/contracts/decision.py +286 -0
  91. openfactory/contracts/manifest.py +713 -0
  92. openfactory/contracts/product.py +95 -0
  93. openfactory/contracts/profile.py +214 -0
  94. openfactory/contracts/project.py +353 -0
  95. openfactory/contracts/refs.py +155 -0
  96. openfactory/contracts/review.py +52 -0
  97. openfactory/contracts/run.py +326 -0
  98. openfactory/contracts/state.py +64 -0
  99. openfactory/contracts/ticket.py +113 -0
  100. openfactory/credentials.py +356 -0
  101. openfactory/doctor.py +1577 -0
  102. openfactory/doors.py +238 -0
  103. openfactory/environ.py +251 -0
  104. openfactory/factory.py +460 -0
  105. openfactory/floor/__init__.py +31 -0
  106. openfactory/floor/ladder.py +655 -0
  107. openfactory/floor/reading.py +302 -0
  108. openfactory/identity/__init__.py +14 -0
  109. openfactory/identity/base.py +95 -0
  110. openfactory/identity/local.py +186 -0
  111. openfactory/identity/oidc.py +633 -0
  112. openfactory/identity/people.py +352 -0
  113. openfactory/identity/registry.py +91 -0
  114. openfactory/knowledge/__init__.py +76 -0
  115. openfactory/knowledge/bundle.py +219 -0
  116. openfactory/knowledge/check.py +167 -0
  117. openfactory/knowledge/contracts.py +407 -0
  118. openfactory/knowledge/experiment.py +118 -0
  119. openfactory/knowledge/gaps.py +118 -0
  120. openfactory/knowledge/gate.py +330 -0
  121. openfactory/knowledge/gather.py +156 -0
  122. openfactory/knowledge/generator.py +822 -0
  123. openfactory/knowledge/inventory.py +557 -0
  124. openfactory/knowledge/okf.py +369 -0
  125. openfactory/knowledge/pipeline.py +333 -0
  126. openfactory/knowledge/render.py +146 -0
  127. openfactory/knowledge/service.py +53 -0
  128. openfactory/knowledge/staleness.py +112 -0
  129. openfactory/language/__init__.py +15 -0
  130. openfactory/language/assent.py +130 -0
  131. openfactory/loader.py +156 -0
  132. openfactory/memory/__init__.py +29 -0
  133. openfactory/memory/ledger.py +215 -0
  134. openfactory/memory/messages.py +406 -0
  135. openfactory/memory/recall.py +303 -0
  136. openfactory/memory/store.py +157 -0
  137. openfactory/memory/transcript.py +267 -0
  138. openfactory/namespace.py +258 -0
  139. openfactory/observability/__init__.py +23 -0
  140. openfactory/observability/events.py +188 -0
  141. openfactory/observability/metrics.py +191 -0
  142. openfactory/observability/query.py +80 -0
  143. openfactory/observability/registry.py +253 -0
  144. openfactory/observability/sqlite_metrics.py +203 -0
  145. openfactory/observability/trajectory.py +243 -0
  146. openfactory/onboarding/__init__.py +64 -0
  147. openfactory/onboarding/concepts.py +450 -0
  148. openfactory/onboarding/context.py +2399 -0
  149. openfactory/onboarding/cover.py +97 -0
  150. openfactory/onboarding/deployment.py +807 -0
  151. openfactory/onboarding/firstrun.py +1637 -0
  152. openfactory/onboarding/history.py +363 -0
  153. openfactory/onboarding/infer.py +2736 -0
  154. openfactory/onboarding/live_ci.py +162 -0
  155. openfactory/onboarding/onboard.py +1070 -0
  156. openfactory/onboarding/propose_manifest.py +349 -0
  157. openfactory/onboarding/questions.py +137 -0
  158. openfactory/onboarding/readiness.py +1073 -0
  159. openfactory/onboarding/renew.py +239 -0
  160. openfactory/onboarding/spend.py +82 -0
  161. openfactory/ops/__init__.py +6 -0
  162. openfactory/ops/impediment.py +288 -0
  163. openfactory/orchestrator/__init__.py +26 -0
  164. openfactory/orchestrator/context.py +231 -0
  165. openfactory/orchestrator/errors.py +27 -0
  166. openfactory/orchestrator/machine.py +2617 -0
  167. openfactory/orchestrator/merge_policy.py +203 -0
  168. openfactory/orchestrator/promotion.py +249 -0
  169. openfactory/orchestrator/risk.py +191 -0
  170. openfactory/orchestrator/validation.py +172 -0
  171. openfactory/org_defaults/engineering.md +75 -0
  172. openfactory/org_defaults/floor.yaml +182 -0
  173. openfactory/org_defaults/profiles/prototype.yaml +16 -0
  174. openfactory/org_defaults/profiles/regulated.yaml +35 -0
  175. openfactory/org_defaults/requirements/0000-template.md +39 -0
  176. openfactory/org_defaults/roles/coordinator.md +48 -0
  177. openfactory/org_defaults/roles/executor.md +92 -0
  178. openfactory/org_defaults/roles/planner.md +117 -0
  179. openfactory/org_defaults/roles/product.md +71 -0
  180. openfactory/org_defaults/roles/recovery.md +33 -0
  181. openfactory/org_defaults/roles/sizer.md +62 -0
  182. openfactory/org_defaults/roles/techlead.md +61 -0
  183. openfactory/org_defaults/tdd.md +10 -0
  184. openfactory/own_work.py +41 -0
  185. openfactory/paths.py +117 -0
  186. openfactory/plugins.py +208 -0
  187. openfactory/policy/__init__.py +10 -0
  188. openfactory/policy/authz.py +105 -0
  189. openfactory/policy/census.py +161 -0
  190. openfactory/policy/conformance.py +300 -0
  191. openfactory/policy/floor.py +42 -0
  192. openfactory/policy/presets.py +172 -0
  193. openfactory/policy/profiles.py +275 -0
  194. openfactory/policy/protected.py +169 -0
  195. openfactory/policy/test_work.py +70 -0
  196. openfactory/preflight.py +577 -0
  197. openfactory/presets/node.yaml +6 -0
  198. openfactory/presets/python.yaml +9 -0
  199. openfactory/presets/security-oss.yaml +26 -0
  200. openfactory/presets/terraform.yaml +8 -0
  201. openfactory/product/__init__.py +56 -0
  202. openfactory/product/asked.py +172 -0
  203. openfactory/product/authoring.py +1791 -0
  204. openfactory/product/board.py +484 -0
  205. openfactory/product/brownfield.py +210 -0
  206. openfactory/product/case.py +409 -0
  207. openfactory/product/channel.py +1437 -0
  208. openfactory/product/config.py +364 -0
  209. openfactory/product/confirm.py +801 -0
  210. openfactory/product/conversation.py +49 -0
  211. openfactory/product/corpus.py +462 -0
  212. openfactory/product/domain.py +242 -0
  213. openfactory/product/facts.py +209 -0
  214. openfactory/product/followup.py +647 -0
  215. openfactory/product/intents.py +641 -0
  216. openfactory/product/loader.py +198 -0
  217. openfactory/product/module.py +3100 -0
  218. openfactory/product/needs_action.py +198 -0
  219. openfactory/product/onboard.py +331 -0
  220. openfactory/product/queue.py +273 -0
  221. openfactory/product/reading.py +130 -0
  222. openfactory/product/release.py +153 -0
  223. openfactory/product/requester.py +55 -0
  224. openfactory/product/role.py +1356 -0
  225. openfactory/product/staging.py +477 -0
  226. openfactory/product/triage.py +277 -0
  227. openfactory/product/voice.py +2080 -0
  228. openfactory/product/workspace.py +182 -0
  229. openfactory/registry.py +526 -0
  230. openfactory/review/__init__.py +11 -0
  231. openfactory/review/verdict.py +199 -0
  232. openfactory/runtime/__init__.py +0 -0
  233. openfactory/runtime/boxed_job.py +399 -0
  234. openfactory/runtime/card_repo.py +146 -0
  235. openfactory/runtime/host.py +91 -0
  236. openfactory/runtime/repo_cache.py +296 -0
  237. openfactory/runtime/temporal/__init__.py +26 -0
  238. openfactory/runtime/temporal/activities.py +5081 -0
  239. openfactory/runtime/temporal/connection.py +89 -0
  240. openfactory/runtime/temporal/io.py +639 -0
  241. openfactory/runtime/temporal/poller.py +185 -0
  242. openfactory/runtime/temporal/schedule.py +465 -0
  243. openfactory/runtime/temporal/starter.py +54 -0
  244. openfactory/runtime/temporal/view.py +884 -0
  245. openfactory/runtime/temporal/worker.py +325 -0
  246. openfactory/runtime/temporal/workflow.py +2459 -0
  247. openfactory/runtime/toolbox.py +163 -0
  248. openfactory/scheduler.py +75 -0
  249. openfactory/semver.py +35 -0
  250. openfactory/techlead/__init__.py +38 -0
  251. openfactory/techlead/classify.py +427 -0
  252. openfactory/techlead/conversation.py +1401 -0
  253. openfactory/techlead/diagnosis.py +251 -0
  254. openfactory/techlead/memory.py +264 -0
  255. openfactory/techlead/pack.py +169 -0
  256. openfactory/techlead/voice.py +679 -0
  257. openfactory/techlead/watch.py +638 -0
  258. openfactory/testing/__init__.py +1 -0
  259. openfactory/testing/local_flow.py +272 -0
  260. openfactory/util/__init__.py +0 -0
  261. openfactory/util/bounded.py +77 -0
  262. openfactory/util/causes.py +125 -0
  263. openfactory/util/scratch.py +80 -0
  264. openfactory-0.2.0.dist-info/METADATA +41 -0
  265. openfactory-0.2.0.dist-info/RECORD +270 -0
  266. openfactory-0.2.0.dist-info/WHEEL +5 -0
  267. openfactory-0.2.0.dist-info/entry_points.txt +2 -0
  268. openfactory-0.2.0.dist-info/licenses/LICENSE +202 -0
  269. openfactory-0.2.0.dist-info/licenses/NOTICE +22 -0
  270. openfactory-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1437 @@
1
+ """The product conversation: a client talking to their product, not an operator to a factory.
2
+
3
+ THIS IS THE PRODUCT ROLE'S CONVERSATION ENTRY, AND IT IS CORE. It lived in `runtime/slack/` from
4
+ the day it was written until 2026-08-25, and the address was a false claim: the file has zero Slack
5
+ imports and every one of its dependencies is `openfactory.product.*` or `openfactory.memory.*`. What
6
+ the address cost was measured by deleting the Slack package on a tree that had it: 56 failed, 98
7
+ errors, 25 test modules uncollectable — of which only five were about Slack; the other twenty were
8
+ the decision loop, the acceptance loop, confirmations, releases and memory recall, all of which had
9
+ been filed under a vendor. A channel (ADR-0038 D3) renders and parses; it does not own the
10
+ conversation. The Slack bot is one caller of `handle`; the panel's turn reaches `settle` — the
11
+ stage every surface shares — and nothing in here knows how either of them posts.
12
+
13
+ A project without a product channel never reaches any of this: the caller asks
14
+ `is_product_channel` first, so the tech-lead's path is untouched by construction.
15
+
16
+ THE CONVERSATION HAS EXACTLY ONE CONFIRMATION, and where it sits is the design:
17
+
18
+ someone describes a need → the role answers, and if it heard a REQUEST it drafts
19
+ the draft is shown → in the client's words, with any conflict stated FIRST
20
+ the person says yes → it is written up for the team
21
+
22
+ That single "yes" is the PROVENANCE — the record of who wanted this and when. It is asked for in
23
+ the conversation with the person who wanted it, rather than on an artefact they would never open.
24
+ Anything more is a form; anything less and a requirement enters the corpus that nobody agreed to.
25
+
26
+ READ IS OPEN, WRITE IS GATED (ADR-0016's model). Asking what the product already promises is not a
27
+ privileged operation. Recording a new promise is: only a listed approver's "yes" counts, and an
28
+ unauthorised one is answered rather than swallowed — a request that vanishes is indistinguishable
29
+ from a broken bot, so the person simply repeats it.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import logging
35
+ import threading
36
+ from dataclasses import dataclass
37
+
38
+ from openfactory.contracts.refs import canonical_ref
39
+
40
+ log = logging.getLogger("openfactory.product.channel")
41
+
42
+ # ── the CONFIRMATION EXECUTOR now lives in `openfactory/product/confirm.py` (#105)
43
+ # ───────────────────
44
+ #
45
+ # The ten branches that performed a confirmed write were the last thing keeping this file an
46
+ # implementation rather than a mapping. They were never Slack-specific: they read a staged entry,
47
+ # ask `may_act`, pop by compare-and-swap and call the product module. What made moving them worth
48
+ # doing is that the PANEL had to reach into this package to run one — the documented exception in
49
+ # `test_provider_seams`, now deleted.
50
+ #
51
+ # The FUNCTIONS are bound here, unlike the mutable state below, and deliberately so: a test that
52
+ # monkeypatches `pc.answer_staged` is patching what `confirm_by_click` calls, which is the point.
53
+ from openfactory.product.confirm import ( # noqa: E402 — after the docstring, like the staging import
54
+ _breakdown_reply,
55
+ _client_detail,
56
+ _is_requester,
57
+ _still_to_say,
58
+ _unfinished,
59
+ answer_staged,
60
+ receipt,
61
+ )
62
+ from openfactory.product.confirm import confirm as confirm_staged # noqa: E402
63
+
64
+ # ── the staged proposal now lives in `openfactory/product/staging.py` (#98 slice 3)
65
+ # ──────────────────
66
+ #
67
+ # It was never Slack-specific — its only dependencies are `openfactory.memory`,
68
+ # `openfactory.product.voice` and
69
+ # `openfactory.product.role`. It was written here first, which is exactly the coupling this card
70
+ # exists
71
+ # to undo, and it was already reached from a second surface before the move: `api/app.py` calls
72
+ # `answer_staged`, with the reach-into-Slack documented there as an explicit exception.
73
+ #
74
+ # RE-EXPORTED, NOT RE-IMPLEMENTED. `_PENDING` is bound BY IDENTITY here, never copied — but note
75
+ # that binding is NOT enough for a caller that REBINDS the name: eight test fixtures did exactly
76
+ # that (`monkeypatch.setattr(pc, "_PENDING", {})`) to isolate themselves, and after this move
77
+ # they must patch `openfactory.product.staging` instead, or they look like they isolate and do not.
78
+ from openfactory.product.staging import ( # noqa: F401,E402 — re-exported for callers and tests
79
+ _ENTRY_MODELS,
80
+ _MAX_PENDING,
81
+ _NO,
82
+ _PENDING_LOCK,
83
+ PROPOSAL_TTL_SECONDS,
84
+ _entry_models,
85
+ _expired_recently,
86
+ _freeze,
87
+ _pending_from_store,
88
+ _proposal_summary,
89
+ _thaw,
90
+ consume,
91
+ find_waiting,
92
+ forget,
93
+ is_no,
94
+ is_yes,
95
+ pending_for,
96
+ proposal_token,
97
+ remember,
98
+ )
99
+
100
+ #: THE MUTABLE STATE IS FORWARDED, NOT BOUND — and the difference is a defect this move already
101
+ #: produced once. `from … import _PENDING` copies the REFERENCE at import time, so the moment
102
+ #: anything rebinds the name on either side the two modules stop sharing: writes land in one dict
103
+ #: and reads come out of the other. Eight test fixtures rebind exactly this to isolate themselves
104
+ #: (`monkeypatch.setattr(staging, "_PENDING", {})`), so binding here would leave every read on
105
+ #: this module pointing at a dict nobody writes to — `KeyError` at best, and silently stale at
106
+ #: worst. PEP 562 module `__getattr__` resolves it on every access instead.
107
+ _FORWARDED = ("_PENDING", "_EXPIRED_TOMBSTONES")
108
+
109
+
110
+ def __getattr__(name: str):
111
+ if name in _FORWARDED:
112
+ from openfactory.product import staging
113
+
114
+ return getattr(staging, name)
115
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
116
+
117
+
118
+
119
+ class Posted(str):
120
+ """Text that is ALREADY on the channel — post it again and the person sees it twice.
121
+
122
+ IT IS THE TEXT, and it is TRUTHY EVEN WHEN EMPTY. Both matter, and both come from a bug this
123
+ class exists to make impossible:
124
+
125
+ `None` already means "this path could not answer — fall through to the conversational
126
+ model", and the first interactive version returned `None` for "already posted". A queue
127
+ proposal posted with buttons therefore read as an intent that had FAILED: the client got the
128
+ buttons AND an unrelated conversational reply, and paid for a model call to produce it. Two
129
+ different facts sharing one value — a class catalogued here four times, committed fresh.
130
+
131
+ Being the text keeps the transcript honest: what she said is recorded from this value
132
+ (ADR-0024), so a proposal posted interactively lands in her memory like any other turn
133
+ instead of vanishing from it.
134
+ """
135
+
136
+ def __bool__(self) -> bool:
137
+ return True
138
+
139
+
140
+ def offer_with_buttons(project, key: str, text: str, confirm) -> str | Posted:
141
+ """Post `text` as an interactive confirmation when the provider can. Returns None when it did
142
+ (already posted), or `text` for the caller to return as prose.
143
+
144
+ ONE helper for all four staging sites, because four copies of "try buttons, else prose" is how
145
+ three of them end up without the button and nobody notices — the shape of this repository's
146
+ signature defect. `confirm` comes from the listener and is None everywhere else (an activity, a
147
+ test, the panel), so those degrade to prose by construction rather than by remembering to.
148
+ """
149
+ entry = pending_for(key)
150
+ if confirm is None or entry is None:
151
+ return text
152
+ from openfactory.product.voice import confirm_labels, or_just_reply
153
+
154
+ lang = getattr(project, "language", None)
155
+ approve, reject = confirm_labels(language=lang)
156
+ # THE TYPED PATH IS ADVERTISED ALONGSIDE THE BUTTONS, and not as politeness. A click only
157
+ # reaches the worker when the Slack app has Interactivity enabled — a setting this code cannot
158
+ # check. With it off the button post still SUCCEEDS, so the prose fallback would not be sent and
159
+ # the proposal would wait for a click that can never arrive. This line makes the confirmation
160
+ # reachable either way, which beats a runbook step somebody has to remember.
161
+ try:
162
+ posted = confirm(f"{text}\n\n{or_just_reply(language=lang)}",
163
+ proposal_token(key, entry), approve, reject)
164
+ except Exception: # noqa: BLE001 — the affordance is optional; the proposal is not
165
+ log.warning("could not offer an interactive confirmation", exc_info=True)
166
+ return text
167
+ return Posted(text) if posted else text
168
+
169
+
170
+
171
+
172
+ def confirm_by_click(project, *, token: str, approved: bool, user: str, module=None,
173
+ notify=None) -> str | None:
174
+ """A person pressed Approve or Reject. Returns what to say, or None to stay quiet.
175
+
176
+ THE POINT OF THE WHOLE BUTTON PATH: nothing here is interpreted. The click names the proposal,
177
+ Slack names the clicker, and the only judgment left is authorisation — which is a lookup. The
178
+ prose path (a word list, then a model reading the sentence) remains for people who type, and it
179
+ is strictly the less certain of the two.
180
+
181
+ `notify` is the same seam the typed path carries, and it is here because an approval is the
182
+ slow path whichever way it arrives: without it a click could not even be acknowledged, so the
183
+ one person who pressed the button got less than the one who typed "sim".
184
+ """
185
+ _code, sentence = answer_staged(project, token=token, approved=approved, user=user,
186
+ module=module, notify=notify)
187
+ return sentence
188
+
189
+
190
+ def conversation_key(event: dict, channel: str) -> str:
191
+ """Which conversation a Slack event belongs to — the identity everything else keys on.
192
+
193
+ THE 14TH INSTANCE OF THE SIGNATURE DEFECT LIVED IN THIS DECISION. The listener used
194
+ `thread_ts or ts`: correct for replies inside a thread, but a bare channel message's fallback
195
+ is ITS OWN ts — so every bare message became a brand-new conversation. All ten memory tests
196
+ passed with a fixed thread id, and the real channel — where the product owner talks to Nina in
197
+ bare messages, as the screenshots show — never produces one. Memory built, tested, reached by
198
+ nothing. The same key also staged confirmations, so a bare "sim" could never find a proposal
199
+ made two messages earlier.
200
+
201
+ The fix: a bare message belongs to the CHANNEL's rolling conversation; only a message inside
202
+ a real thread belongs to that thread. In a 1:1 client channel the room IS the exchange.
203
+ """
204
+ return event.get("thread_ts") or channel
205
+
206
+
207
+ def is_product_channel(project, channel: str) -> bool:
208
+ cfg = getattr(project, "product", None)
209
+ if cfg is None or not getattr(cfg, "enabled", True):
210
+ return False
211
+ return bool(channel) and channel == cfg.channel_id
212
+
213
+
214
+ def handle(project, *, text: str, user: str, thread: str, module=None,
215
+ source: str = "", channel: str = "", notify=None, confirm=None,
216
+ fingerprint: str = "") -> str | None:
217
+ """One message in the product channel. Returns what to say, or None to stay quiet.
218
+
219
+ `fingerprint` is what a CLICK already verified, carried down to the pop (`consume`). Empty for
220
+ a typed message, which has verified nothing yet — this handler is where that happens.
221
+
222
+ Never raises: a chat caller runs this inside its listener (Socket Mode, for one), where an
223
+ exception takes the channel down for everyone until someone notices."""
224
+ from openfactory.memory import transcript
225
+
226
+ name = getattr(project, "name", "?")
227
+ reply: str | None = None
228
+ # ADR-0024 layer 0, the PERSON's turn: recorded on ARRIVAL, not after the reply. An answer here
229
+ # can take minutes (a model call, a checkout), and a follow-up message that lands meanwhile
230
+ # used to find no trace of this one — the handler answered the second message with amnesia
231
+ # about the first. The GSI may or may not surface this row to a concurrent read; late is the
232
+ # eventual-consistency cost either way, and absent-by-design was strictly worse.
233
+ arrival_ts = ""
234
+ try:
235
+ arrival_ts = transcript.record(name, thread=thread, role="person", text=text, actor=user,
236
+ channel=channel) or ""
237
+ except Exception: # noqa: BLE001 — the record must never cost the person their answer
238
+ log.warning("[%s] could not record the incoming turn", name, exc_info=True)
239
+ try:
240
+ reply = _handle(project, text=text, user=user, thread=thread, module=module,
241
+ source=source, channel=channel, notify=notify, confirm=confirm,
242
+ arrival_ts=arrival_ts, fingerprint=fingerprint)
243
+ except Exception: # noqa: BLE001 — a bad message must never kill the socket
244
+ # SILENCE IS THE WORST ANSWER. Returning None here meant the person wrote to their PO and
245
+ # got nothing — indistinguishable from being ignored, and invisible to us until they
246
+ # complained. It also broke the platform's own standing invariant: every stall either
247
+ # self-heals or asks a human; none of them is a quiet nothing.
248
+ log.exception("[%s] product channel handler failed", name)
249
+ # a distinct marker so ONE occurrence pages, rather than blending into the generic error
250
+ # burst threshold — a client hearing nothing is not a transient
251
+ log.error("OPENFACTORY_PRODUCT_MUTE project=%s thread=%s — the client got no "
252
+ "answer", name, thread)
253
+ from openfactory.product.voice import broke
254
+
255
+ reply = broke(language=getattr(project, "language", None))
256
+ finally:
257
+ # the AGENT's turn still lands after: what she said only exists once `_handle` returns
258
+ if reply:
259
+ # recorded from the value even when it was posted interactively — her memory must hold
260
+ # the proposal she made, whichever way it reached the channel
261
+ transcript.record(name, thread=thread, role="agent", text=str(reply), channel=channel)
262
+ # `Posted` is on the channel already; returning it would show the person the same text twice
263
+ return None if isinstance(reply, Posted) else reply
264
+
265
+
266
+ @dataclass(frozen=True)
267
+ class Settled:
268
+ """What one message settled BEFORE it is read as conversation.
269
+
270
+ `reply` is the sentence to say when the message answered something the role had asked — a
271
+ staged proposal (a yes, or a no), an open delivery ("did it work?"), or a proposal that
272
+ expired while the person was away. `None` means the message settled nothing and the turn goes
273
+ on to intents and conversation, carrying `waiting`: the proposal still staged, or None once a
274
+ rejection destroyed it. ONE fact per value: `reply=None` never means "stay quiet" — a branch
275
+ with nothing to say answers "" and the caller stays quiet on that, exactly as it always did.
276
+
277
+ ONLY WHAT A CALLER READS. The key the proposal is staged under travelled here for one day and
278
+ was read by nobody — every consume happens inside this stage — so it went; a field nothing
279
+ reads is a promise the next reader keeps for it.
280
+ """
281
+
282
+ reply: str | None
283
+ waiting: dict | None
284
+
285
+
286
+ def settle(project, *, text: str, user: str, thread: str, module, channel: str = "",
287
+ fingerprint: str = "", on_it=None, via: str = "slack") -> Settled:
288
+ """The message read as an ANSWER to what the role asked last — before it is read as anything.
289
+
290
+ THE ONE STAGE EVERY SURFACE SHARES, and until 2026-08-25 it was reached from exactly one:
291
+ these branches lived inside the Slack handler, so `ProductModule.settle_acceptance` — the
292
+ client's "worked / did not work" that closes the acceptance loop (ADR-0025), with the client's
293
+ release behind it — had ONE production caller, in `runtime/slack/`, while the panel's turn
294
+ went straight to `module.answer`. On a deployment without Slack the sweep opened acceptance
295
+ loops that no client could ever close. The chat handler (`_handle`) and the worker's turn
296
+ (`activities._product_conversation`) both call this now; each renders its own way, neither
297
+ implements it.
298
+
299
+ WHAT THE PANEL'S TURN ENTERS TODAY, STATED HONESTLY: the acceptance branch. The other two —
300
+ a typed yes or no on a staged proposal, and the notice for one that expired — run on the
301
+ panel's path and find nothing, because nothing STAGES a proposal under the panel's key: the
302
+ producers (`offer_draft`, the typed intents) are chat-only, and the panel proposes through its
303
+ own button (`product_propose`) and answers tokens through `product_answer`. They stay in the
304
+ shared stage so the day a producer arrives on the panel, a yes typed there is performed by the
305
+ executor the click uses rather than by a second copy of this. ONE HAS (ADR-0047, 2026-09-06):
306
+ `confirm()` stages the second yes — the acceptance on the card — under the key the first yes
307
+ was found under, so a yes typed on the panel after a draft's yes accepts on the card there.
308
+ A DRAFT still reaches the panel's key by no road of its own; the count is pinned by
309
+ `test_the_one_staging_producer_on_the_panel_s_path_is_the_second_yes`.
310
+
311
+ `via` IS PROVENANCE, NOT PERMISSION — the transport this message arrived through, handed to
312
+ every gate this stage reaches (`confirm`, the rejection, `_maybe_release`) so the record of who
313
+ authorised a write says where they were speaking from. Defaulted like `may_act` is, so the
314
+ chat handler keeps saying exactly what it said; the worker's turn passes what the panel sent.
315
+
316
+ RENDERED SENTENCES, ON PURPOSE. Every branch answers in the client's voice (`product/voice`),
317
+ which is core and takes the project's language; nothing here knows how a channel posts, and no
318
+ `Posted` can come out of it — the interactive seam belongs to the caller.
319
+
320
+ The order is load-bearing and each line of it was paid for:
321
+ the staged proposal first — a pending proposal is a question just asked, and it wins;
322
+ the acceptance second — "sim, resolveu" would otherwise be swallowed by the conversational
323
+ model: a polite reply, and a delivery still recorded as unconfirmed;
324
+ the expiry last — with a delivery loop open, a bare "sim" answers "did it work?".
325
+ """
326
+ from openfactory.product.module import may_act, unauthorized_message
327
+
328
+ lang = getattr(project, "language", None)
329
+ # A person confirms wherever they happen to be typing: inside the thread Nina replied in, or
330
+ # back at channel level. The proposal must be findable from both, and consumed from wherever
331
+ # it was staged — an approval that misses the draft falls through to the conversational model,
332
+ # which answers politely and writes NOTHING (the audit's worst simulated conversation).
333
+ waiting_key, waiting = find_waiting(thread, channel, project=project)
334
+
335
+ # A REPLY THE WORD LIST CANNOT READ IS NOT A "NO". `is_yes` is deliberately narrow (it accepts
336
+ # "sim", "pode registrar", and nothing needing interpretation); everything else used to fall
337
+ # straight through to conversation, so "Sim — registre." wrote nothing while the reply said it
338
+ # had. When a proposal is pending and neither gate fires, a model reads the sentence — the only
339
+ # thing that can tell an affirmation from a word that appears inside one (ADR-0028).
340
+ # initialised BEFORE the gate: the branches below read them unconditionally, and they are only
341
+ # assigned when the model is consulted at all
342
+ judged_yes = judged_no = False
343
+ if waiting and not is_yes(text) and not is_no(text):
344
+ verdict = "neither"
345
+ try:
346
+ verdict = module.confirmed(text, proposal=_proposal_summary(waiting))
347
+ except Exception: # noqa: BLE001 — an unreadable judgment leaves the proposal pending
348
+ log.warning("[%s] could not judge the confirmation", getattr(project, "name", "?"),
349
+ exc_info=True)
350
+ # THE VERDICT IS CARRIED, NOT WRITTEN OVER THE MESSAGE. Replacing `text` with "sim"/"não"
351
+ # was destructive in the reject direction: a rejection falls THROUGH to the conversation, so
352
+ # she received the bare word "não" instead of what the person wrote. It happened on the
353
+ # first real one — the product owner answered her open question, confirmed the #288 origin
354
+ # and agreed which number is authoritative, all in one message; the judge correctly read it
355
+ # as "revise before recording", and every word of it was then thrown away. She got "não"
356
+ # with three things on the table and rightly refused to guess which one it meant.
357
+ #
358
+ # A conditional yes IS a rejection of what is staged — and it is also the most informative
359
+ # message in the exchange. Both are true, and only one of them used to survive.
360
+ judged_yes = verdict == "approve"
361
+ judged_no = verdict == "reject"
362
+
363
+ # THE CONFIRMATION IS ONE CALL NOW (#105). Ten branches lived here — eight typed kinds, a
364
+ # generic yes on a draft, and the rejection below — and the eight opened with an IDENTICAL
365
+ # preamble: `may_act`, then a compare-and-swap pop, then "somebody answered first". That
366
+ # preamble and every body moved to `openfactory/product/confirm.py`, which the panel and the
367
+ # `product_answer` row now call as well: one implementation, three transports (ADR-0039).
368
+ #
369
+ # The receipt goes with it. A confirmed write is always the slow path — every branch reaches a
370
+ # checkout, the client's board or an agent — and `confirm` fires `on_it` before the
371
+ # authorisation, so nobody approves something irreversible and then waits in silence.
372
+ if waiting and (is_yes(text) or judged_yes):
373
+ return Settled(confirm_staged(project, key=waiting_key, entry=waiting,
374
+ fingerprint=fingerprint, module=module, user=user,
375
+ lang=lang, on_it=on_it, via=via),
376
+ waiting)
377
+
378
+ # THE REJECTION STAYS HERE, and it is not the executor's tenth branch. It performs nothing: it
379
+ # destroys the proposal and FALLS THROUGH, so whatever the person wrote is answered as the
380
+ # correction it usually is. `confirm` returns a sentence; this one has to keep going.
381
+ if waiting and (is_no(text) or judged_no):
382
+ # REFUSING IS AN ACT, so it is gated — but NOT by the approval rule, which would be wrong.
383
+ # Two different people can say "não" here and they are not the same case: the person whose
384
+ # request this is, saying "não, não é isso" to CORRECT their own wording, and a third party
385
+ # who would simply be destroying a proposal an admin was about to approve. The first is the
386
+ # point of the draft loop; the second is vandalism. So: an admin, or the requester.
387
+ if not may_act(project, user, via=via) and not _is_requester(waiting, user):
388
+ return Settled(unauthorized_message(project), waiting)
389
+ # the same compare-and-swap the approvals use, and the return is deliberately unread: a
390
+ # refusal that lost its race destroyed nothing, which is the outcome we wanted anyway. What
391
+ # replaced it announced itself when it was staged (`remember` returns that notice).
392
+ consume(waiting_key, waiting, fingerprint=fingerprint, project=project, by=user,
393
+ approved=True)
394
+ # the discarded proposal must not survive in this turn's PROMPT either: `waiting` fed the
395
+ # "still pending" section of the conversation, so after a rejection she was told the thing
396
+ # the person had just thrown away was still on the table — and said so
397
+ waiting_key, waiting = None, None
398
+ # fall through: whatever they said next is the correction, and it deserves an answer
399
+
400
+ # ---- an answer to "did it work?" — the loop that makes this a product role ----------------
401
+ # AFTER the staging block on purpose: a pending proposal is a question just asked, and it wins.
402
+ # BEFORE intents and conversation, because "sim, resolveu" would otherwise be swallowed by the
403
+ # conversational model — a polite reply, and a delivery still recorded as unconfirmed.
404
+ if not waiting:
405
+ answered = module.settle_acceptance(text)
406
+ if answered:
407
+ from openfactory.product.followup import accepted_text, rejected_text
408
+
409
+ verdict, loop, ambiguous = answered
410
+ cfg = getattr(project, "product", None)
411
+ agent = getattr(cfg, "agent_name", "") or ""
412
+ # THE ONE ANSWER THAT SPENDS SOMETHING (board #6). A release loop's "funcionou" does
413
+ # not merely record an opinion: it puts software in front of the client's own users.
414
+ # So it leaves this shared path immediately and is handled where its extra rules live.
415
+ released = _maybe_release(project, module, loop, verdict, user, agent, lang,
416
+ ambiguous=ambiguous, via=via)
417
+ if released is not None:
418
+ return Settled(released, waiting)
419
+ say = accepted_text if verdict == "worked" else rejected_text
420
+ # `ambiguous` NAMES what was settled when more than one delivery was waiting. The
421
+ # comment here used to promise exactly that and the code never did it — a silent
422
+ # choice quietly marked the wrong delivery accepted.
423
+ return Settled(say(loop, agent_name=agent, ambiguous=ambiguous), waiting)
424
+
425
+ # A LATE CONFIRMATION OF AN EXPIRED PROPOSAL HEARS SO. Without this, the "sim" of somebody who
426
+ # stepped away past the TTL found nothing staged and fell through to the conversational model —
427
+ # a polite answer to a confirmation of nothing, with the person left believing they confirmed.
428
+ # After the acceptance check on purpose: with a delivery loop open, a bare "sim" answers "did
429
+ # it work?", and the ledger read first.
430
+ if not waiting and (is_yes(text) or is_no(text)) and _expired_recently(thread, channel):
431
+ from openfactory.product.voice import proposal_expired
432
+
433
+ return Settled(proposal_expired(language=lang), waiting)
434
+
435
+ return Settled(None, waiting)
436
+
437
+
438
+ def _accepts_intake(module) -> bool:
439
+ """Whether this module's `answer` declares `intake` — by name, or through `**kwargs`. Read from
440
+ the signature, not by trying and catching: a `TypeError` raised INSIDE a real `answer` would
441
+ otherwise be mistaken for a module that does not take the keyword, and answered without it. A
442
+ callable with no readable signature is treated as taking it, because the shipped module does."""
443
+ import inspect
444
+
445
+ try:
446
+ params = inspect.signature(module.answer).parameters
447
+ except (TypeError, ValueError):
448
+ return True
449
+ return "intake" in params or any(p.kind is inspect.Parameter.VAR_KEYWORD
450
+ for p in params.values())
451
+
452
+
453
+ def _handle(project, *, text: str, user: str, thread: str, module,
454
+ source: str = "", channel: str = "", notify=None, confirm=None,
455
+ arrival_ts: str = "", fingerprint: str = "") -> str | None:
456
+ from openfactory.product.module import ProductModule, may_act
457
+ from openfactory.product.voice import unavailable
458
+
459
+ lang = getattr(project, "language", None)
460
+ module = module or ProductModule(project)
461
+ # ONE RECEIPT PER MESSAGE, and the factory is in the core so the click path gets the same one:
462
+ # seeded by the message, so consecutive questions differ while the same message always produces
463
+ # the same acknowledgement (deterministic tests, answerable support).
464
+ _on_it = receipt(project, notify, seed=text)
465
+
466
+ # ---- what she asked last is answered first --------------------------------------------------
467
+ # A staged proposal, an open delivery, an expired proposal: `settle` is the stage this handler
468
+ # shares with the worker's turn, so a chat surface and the panel give one answer to one message.
469
+ # What is left below is what a CHAT surface does with a message that answered nothing — typed
470
+ # intents, then the conversation with its staged drafts.
471
+ settled = settle(project, text=text, user=user, thread=thread, module=module, channel=channel,
472
+ fingerprint=fingerprint, on_it=_on_it)
473
+ if settled.reply is not None:
474
+ return settled.reply
475
+ waiting = settled.waiting
476
+
477
+ # ---- a human spoke, so the decisions SHE asked for were answered ---------------------------
478
+ # BEFORE anything else, and before her new reply can open fresh ones: this very message is the
479
+ # person responding to what she asked last round. Closing after would either close the ones she
480
+ # just opened, or leave last round's open for ever.
481
+ # ONLY WHEN THE MESSAGE WILL ACTUALLY REACH HER. The first version closed every open decision on
482
+ # ANY inbound message, resting on "a partial answer is safe because she re-asks what is still
483
+ # undecided". That holds only if she READS the message — and the intent shortcuts below
484
+ # (`status`, `triage`, a bare confirmation) answer from data already in hand and never reach the
485
+ # model. So a bare "status" silently closed three chased decisions as `answered` and nothing
486
+ # would ever ask again: precisely the silent loss the decision ledger exists to prevent.
487
+ #
488
+ # Deferred to the conversational path, which is the only one she reads. A message that only says
489
+ # "status" leaves the decisions open — one more reminder, which is the cheap direction.
490
+ _closed_decisions = False
491
+
492
+ def _close_decisions_if_she_reads_this() -> None:
493
+ nonlocal _closed_decisions
494
+ if _closed_decisions:
495
+ return
496
+ _closed_decisions = True
497
+ try:
498
+ module.close_decisions_answered(channel=channel)
499
+ except Exception: # noqa: BLE001 — bookkeeping must never cost the reply
500
+ log.warning("[%s] could not close answered decisions", getattr(project, "name", "?"),
501
+ exc_info=True)
502
+
503
+ # ---- an explicit ASK for one of the things this role does on its own -----------------------
504
+ # Matched before the corpus is consulted: "who are you" and "how are we doing" must answer even
505
+ # when the requirements cannot be read, which is exactly when someone asks.
506
+ from openfactory.product.intents import match_intent
507
+
508
+ matched = match_intent(text)
509
+ if matched:
510
+ intent, captures = matched
511
+ done = _run_intent(project, intent, captures, module=module, lang=lang, user=user,
512
+ on_it=_on_it, confirm=confirm,
513
+ thread=thread, channel=channel)
514
+ if done:
515
+ return done
516
+
517
+ ctx = module.context()
518
+ if not ctx.available:
519
+ log.warning("[%s] product module unavailable: %s", project.name, ctx.reason)
520
+ return unavailable(language=lang)
521
+
522
+ # ---- everything else is a conversation ----------------------------------------------------
523
+ # ADR-0024 layer 1. Without this every message was turn 1: no "e o segundo?", no correction,
524
+ # no knowing she had asked something when the answer arrived.
525
+ # THE RECEIPT GOES FIRST — before the transcript query, before the model. Everything below
526
+ # this line is work the person is waiting through, and the whole point is that they should not
527
+ # have to guess whether anything is happening.
528
+ _on_it()
529
+
530
+ from openfactory.memory import transcript
531
+
532
+ # the CURRENT message is excluded by the ts it was recorded under: it is already the
533
+ # "## Question" of this prompt, and history is strictly what came before it
534
+ said = transcript.render(
535
+ [t for t in transcript.recent(project.name, thread=thread, channel=channel)
536
+ if not (arrival_ts and t.ts == arrival_ts)],
537
+ agent_name=getattr(getattr(project, "product", None), "agent_name", ""),
538
+ # THE CLIENT'S LANGUAGE, said here rather than welded into the renderer (#168). This block
539
+ # is read by a model that is answering a pt-BR client; the tech-lead's identical block is
540
+ # read by one whose whole surface is English.
541
+ heading="## Conversa até aqui (mais antigo primeiro)", you="você", somebody="pessoa")
542
+ _close_decisions_if_she_reads_this()
543
+
544
+ # WHAT IS STILL WAITING — the fact whose absence let her announce five registered requirements
545
+ # she had only proposed. `waiting` is the staged entry, read at the top of this handler.
546
+ # THIS PERSON'S INTAKE IN THIS CONVERSATION, TYPED (#33 hole 7) — beside the transcript, so
547
+ # the fourth turn of "which screen?" is a continuation and not a re-reading.
548
+ from openfactory.product import case as _case
549
+ intake = _case.block_for(project, thread, user)
550
+ # PASSED ONLY WHEN THERE IS ONE, AND ONLY TO A MODULE THAT TAKES IT. "Only when there is one"
551
+ # alone deferred the break instead of preventing it: a module whose `answer` predates the
552
+ # intake answered the FIRST turn, and on the second — `note_turn` having opened a case with
553
+ # facts — received a keyword it did not declare, raised, and took the mute path below, paging
554
+ # on every turn for a day (review of #66, 2026-09-06). The shipped module declares it; a double
555
+ # or an add-on that does not is answered as before, every turn.
556
+ answer = module.answer(text, conversation=said,
557
+ pending=_proposal_summary(waiting) if waiting else "",
558
+ **({"intake": intake} if intake and _accepts_intake(module) else {}))
559
+ if not answer.ok:
560
+ return unavailable(language=lang)
561
+ try:
562
+ _case.note_turn(project, thread, user, text, answer)
563
+ except Exception: # noqa: BLE001 — the case is bookkeeping; the reply is the act
564
+ log.info("[%s] could not note the intake turn", project.name, exc_info=True)
565
+
566
+ # WHAT SHE ASKED A HUMAN FOR BECOMES A TRACKED LOOP. The product owner's second real
567
+ # conversation ended with three decisions requested and NOTHING recorded: loops were only ever
568
+ # opened by the board sweep, so a request made in conversation lived in a chat message and died
569
+ # when it scrolled away. Nobody would have been reminded, which is the silent-wait failure this
570
+ # platform exists to make impossible.
571
+ if getattr(answer, "decisions", None):
572
+ try:
573
+ module.record_decisions(answer.decisions, channel=channel)
574
+ except Exception: # noqa: BLE001
575
+ log.warning("[%s] could not record the decisions she asked for", project.name,
576
+ exc_info=True)
577
+
578
+ # SHE CANNOT SEE WHETHER A WRITE HAPPENED, so a reply that says one did is a claim she has no
579
+ # standing to make. This turn wrote nothing — the write paths are the staged-confirmation
580
+ # branches above, which all return before reaching here. Detected rather than edited: a wrong
581
+ # correction is worse than a flagged sentence, and what shipped was worse than both — a
582
+ # confirmation went unrecognised, nothing was written, and the reply announced five registered
583
+ # requirements to somebody who believed it.
584
+ from openfactory.product.voice import claims_a_write
585
+
586
+ # OBSERVED, NOT ACTED ON — and that is a retreat justified by evidence, not a shrug.
587
+ #
588
+ # The append shipped, and in production it fired TWICE, both times on sentences that were
589
+ # correct. Once on a retraction ("eu disse 'Registrado o Requisito 1' … Não foi") and once on an
590
+ # accurate history ("o texto foi gravado, o pedido de revisão não abriu"). Zero true positives
591
+ # in the same window. A word list cannot tell "I recorded it just now" from "it was recorded
592
+ # last time": that needs tense and temporal reference, which this is not able to read.
593
+ #
594
+ # And a WRONG correction is expensive in a way a missed one is not. It contradicts the agent in
595
+ # front of the client, so the reader learns to distrust both voices — and it lands hardest on
596
+ # exactly the honest, self-correcting messages the rule exists to produce.
597
+ #
598
+ # What actually removed the original harm was the prompt: she is now told, every turn, that this
599
+ # reply writes nothing and what is still pending, and she says so herself unprompted. This stays
600
+ # as an OBSERVATION so a real false claim is still visible to us — and if one appears in this
601
+ # log without a matching write, the append comes back with a model reading the sentence rather
602
+ # than a regex matching a word.
603
+ claim = claims_a_write(getattr(answer, "text", "") or "")
604
+ if claim:
605
+ # WARNING, not ERROR — recalibrated on the record: four firings in production, four false
606
+ # positives ("Anotado", "Anotei" — her acknowledging what she UNDERSTOOD, which no word
607
+ # list can tell from a write claim), zero true ones. An ERROR that is always wrong teaches
608
+ # whoever watches the log to ignore ERRORs, which is how the real one gets missed. The
609
+ # marker stays greppable; if a true positive ever shows up here, the promised model-read
610
+ # correction is what comes back — not the louder level.
611
+ log.warning("OPENFACTORY_PRODUCT_FALSE_CLAIM project=%s claim=%r — the reply mentions a "
612
+ "completed "
613
+ "write and this turn wrote nothing (NOT corrected in the channel; see "
614
+ "ADR-0031)", project.name, claim)
615
+
616
+ # A QUESTION ends here. A REQUEST becomes a draft, shown back for the one confirmation — this
617
+ # is the only route from a conversation to something written down, and without it the whole
618
+ # write path is unreachable from the channel: every message would get a polite answer and
619
+ # nothing would ever be recorded.
620
+ if getattr(answer, "is_defect", False):
621
+ # Who can actually unlock the pen. Asking the REPORTER to confirm and then refusing their
622
+ # confirmation — with a refusal written for the requirement flow ("registrar como
623
+ # requisito acordado") — was the single worst conversation the audit simulated: the person
624
+ # confirms their own report and gets turned away in the wrong vocabulary, with no hint of
625
+ # WHO to ask. The admins are known by id; name them.
626
+ # SHE decided this breaks an existing promise (the corpus is hers to know); the person
627
+ # confirms the restatement, an admin's yes files it. No requirement ceremony: the promise
628
+ # already exists — what is being recorded is that reality disagrees with it.
629
+ from openfactory.product.voice import defect_confirmation
630
+
631
+ replaced = remember(thread, {"kind": "defect", "restated": text.strip()[:400],
632
+ "reported_by": f"<@{user}>" if user else "",
633
+ "violates": getattr(answer, "violates", None),
634
+ # no severity: nobody judged one, and printing "média" as if somebody
635
+ # had is a fabricated classification the fix queue would sort by
636
+ "source": source or "", "channel": channel}, lang=lang, project=project)
637
+ ask = defect_confirmation(violates=getattr(answer, "violates", None), language=lang)
638
+ if not may_act(project, user):
639
+ admins = _admin_mentions(project)
640
+ if admins:
641
+ ask += f"\n\n({admins}: o registro precisa da sua confirmação.)"
642
+ # the defect proposal, offered with buttons when the provider has them. The agent's own
643
+ # words stay in front of it: the person confirms a RESTATEMENT, so they must read it.
644
+ body = replaced + ((answer.text + "\n\n") if answer.text else "") + ask
645
+ return offer_with_buttons(project, thread, body, confirm)
646
+
647
+ # A GESTURE THE MODEL RECOGNISED that the word list did not (role.QUEUE_MARKER). The pattern
648
+ # in `product_intents` still runs first and still short-circuits — this is the escape for the
649
+ # phrasings it does not carry, and it costs nothing extra because this call already happened.
650
+ #
651
+ # AFTER `is_defect` AND BEFORE `is_request`, and the order is load-bearing rather than
652
+ # aesthetic: `remember` holds ONE staged entry per conversation (see its docstring), so two
653
+ # branches staging in the same turn displace each other in silence. A broken promise outranks
654
+ # a request to start — it is about work already owed — and asking to START the agreed work is
655
+ # not asking for something NEW, so it must not fall through into a draft proposal.
656
+ if getattr(answer, "is_ticket", False):
657
+ # SHE decided the person asked for a card, as described — not a broken promise and not a
658
+ # wish to be argued into a requirement. The person confirms the title; an admin's yes opens
659
+ # it. The same gate as a defect, for the same reason: it puts a card on the client's board.
660
+ from openfactory.product.voice import ticket_confirmation
661
+
662
+ title = ((getattr(answer, "ticket_title", "") or "").strip() or text.strip())[:80]
663
+ replaced = remember(thread, {"kind": "ticket", "title": title,
664
+ "described": text.strip()[:1500],
665
+ "reported_by": f"<@{user}>" if user else "",
666
+ "source": source or "", "channel": channel},
667
+ lang=lang, project=project)
668
+ ask = ticket_confirmation(title=title, language=lang)
669
+ if not may_act(project, user):
670
+ admins = _admin_mentions(project)
671
+ if admins:
672
+ ask += f"\n\n({admins}: abrir o cartão precisa da sua confirmação.)"
673
+ body = replaced + ((answer.text + "\n\n") if answer.text else "") + ask
674
+ return offer_with_buttons(project, thread, body, confirm)
675
+ if getattr(answer, "is_reorder", False) and getattr(answer, "order", None):
676
+ # SHE READ AN ORDER FOR THE BACKLOG (#33 slice 9, the chat half of `reorder`). Staged like
677
+ # the queue: the person reads the order back and confirms it, an admin's yes writes it.
678
+ # Not the queue gesture — writing the order starts nothing — but it decides what the next
679
+ # start spends on, so it waits for the same yes. THE ORDER TRAVELS UNTOUCHED: no sort, no
680
+ # set, top first as they said it.
681
+ from openfactory.product.voice import reorder_confirmation
682
+
683
+ order = [str(n) for n in answer.order]
684
+ replaced = remember(thread, {"kind": "reorder", "numbers": order, "channel": channel},
685
+ lang=lang, project=project)
686
+ ask = reorder_confirmation(numbers=order, language=lang)
687
+ if not may_act(project, user):
688
+ admins = _admin_mentions(project)
689
+ if admins:
690
+ ask += f"\n\n({admins}: gravar a ordem precisa da sua confirmação.)"
691
+ body = replaced + ((answer.text + "\n\n") if answer.text else "") + ask
692
+ return offer_with_buttons(project, thread, body, confirm)
693
+ if getattr(answer, "gesture", "") == "queue":
694
+ # HER ANSWER TRAVELS WITH IT. Both sibling branches carry `answer.text` in front of what
695
+ # they stage — a person confirms a proposal, so they must read what she said about it —
696
+ # and this one dropped it: she answered the question and the reply was replaced by a bare
697
+ # queue. The gesture was recognised BY reading the message; throwing away the reading is
698
+ # the one thing that makes the marker path worse than the pattern it exists to rescue.
699
+ proposed = _run_intent(project, "queue",
700
+ {"preamble": f"{answer.text}\n\n" if answer.text else ""},
701
+ module=module, lang=lang, user=user,
702
+ on_it=_on_it, confirm=confirm, thread=thread, channel=channel)
703
+ if proposed:
704
+ # WHOLE AND UNTOUCHED, like `offered` below: `_queue_reply` can return a `Posted`, and
705
+ # interpolating one into an f-string turns it into a plain `str` — the boundary then
706
+ # cannot tell it was already posted and the proposal goes out twice.
707
+ return proposed
708
+
709
+ if answer.is_request:
710
+ offered = offer_draft(project, request=text, user=user, thread=thread, module=module,
711
+ on_it=_on_it, confirm=confirm, channel=channel,
712
+ preamble=f"{answer.text}\n\n" if answer.text else "",
713
+ asked_by=f"<@{user}>" if user else "", source=source or "")
714
+ if offered:
715
+ # returned WHOLE and untouched, so `Posted` survives: interpolating it here is exactly
716
+ # what posted the proposal twice
717
+ return offered
718
+ return answer.text
719
+
720
+
721
+ def offer_draft(project, *, request: str, user: str, thread: str, module,
722
+ asked_by: str = "", date: str = "", source: str = "", on_it=None,
723
+ confirm=None, preamble: str = "", channel: str = "") -> str | None:
724
+ """Draft what was asked for and show it back for confirmation.
725
+
726
+ Separate from `handle` because drafting is a deliberate step: it costs a model call and it is
727
+ what puts a proposal in front of a person, so the caller decides when a message deserves one
728
+ rather than every remark becoming a draft."""
729
+ from openfactory.product.voice import confirmation_request
730
+
731
+ lang = getattr(project, "language", None)
732
+ if on_it:
733
+ on_it()
734
+ answer = module.draft(request, asked_by=asked_by or user)
735
+ if not answer.ok or answer.draft is None:
736
+ return None # nothing to confirm; the conversation continues
737
+
738
+ draft = answer.draft
739
+ replaced = remember(thread, {"answer": answer, "asked_by": asked_by or user, "date": date,
740
+ "source": source, "kind": "draft", "channel": channel,
741
+ "number": _next_number(module)}, lang=lang, project=project)
742
+ # THE REASONING GOES ABOVE THE BUTTONS, IN THE SAME MESSAGE. Returned separately it was posted
743
+ # separately — and after the block it justifies, so the person read "confirm this?" before the
744
+ # argument for it. Worse, concatenating a `Posted` into an f-string produced a plain `str`, the
745
+ # boundary could no longer tell it had been posted, and the whole proposal went out TWICE.
746
+ # A sentinel that survives only until somebody interpolates it is not a sentinel.
747
+ return offer_with_buttons(project, thread, preamble + replaced + confirmation_request(
748
+ title=draft.title, must_be_true=draft.must_be_true,
749
+ conflicts=[_conflict_line(c) for c in draft.conflicts], language=lang), confirm)
750
+
751
+
752
+ def _conflict_line(conflict) -> str:
753
+ ref = f"requisito {conflict.requirement}" if conflict.requirement else "algo já decidido"
754
+ return f"{ref} — {conflict.explanation}"
755
+
756
+
757
+ def _waiting_line(project) -> str:
758
+ """What she is still waiting on, appended to the status.
759
+
760
+ A DELIVERY, NOT A CALCULATION (C-24). Opening the memory store, filtering loop kinds and
761
+ writing the Portuguese used to happen right here — the product role's own logic inside one
762
+ provider's adapter. It now lives in `product/followup.waiting_line`, where the panel and any
763
+ other channel can ask the same question and get the same sentence."""
764
+ from openfactory.product.followup import waiting_line
765
+
766
+ line = waiting_line(getattr(project, "name", "") or "",
767
+ language=getattr(project, "language", None))
768
+ return f"\n{line}" if line else ""
769
+
770
+
771
+ #: words that carry no identity — a term must not begin or end with one. Portuguese runs on
772
+ #: articles and prepositions, which is why "first six words" produced handles like "a firma usa
773
+ #: Primavera para a": truncated mid-article, unfindable in a glossary, and colliding with every
774
+ #: other sentence that starts the same way.
775
+ _STOPWORDS = frozenset(
776
+ "a o as os um uma uns umas de do da dos das em no na nos nas para pra por com sem que e ou "
777
+ "se ao aos à às é são foi ser estar tem têm há aí ai lá la isso isto aquilo the of in on at "
778
+ "to for and or is are was be".split())
779
+
780
+
781
+ def _term_of(fact: str) -> str:
782
+ """A findable handle for one fact: strip leading/trailing stopwords, keep up to five words.
783
+
784
+ Deterministic on purpose (no model call for a dictation), and honest about its limits: the
785
+ person is shown the term and can re-dictate. What it must never do is what "first six words"
786
+ did — cut mid-article and index a glossary by sentence fragments."""
787
+ words = fact.split()
788
+ while words and words[0].lower().strip(",.;:") in _STOPWORDS:
789
+ words.pop(0)
790
+ picked = words[:5]
791
+ while picked and picked[-1].lower().strip(",.;:") in _STOPWORDS:
792
+ picked.pop()
793
+ return " ".join(picked) if picked else " ".join(fact.split()[:4])
794
+
795
+
796
+ def _admin_mentions(project) -> str:
797
+ """The people whose yes unlocks the pen, as real mentions. Known by id from the deployment
798
+ config — this is the one place a raw `<@id>` is correct, because the id IS the config."""
799
+ cfg = getattr(project, "product", None)
800
+ admins = list(cfg.admins or [])[:3]
801
+ return " ".join(f"<@{a}>" for a in admins)
802
+
803
+
804
+ def _next_number(module) -> int:
805
+ from openfactory.product.authoring import next_number
806
+
807
+ try:
808
+ return next_number(module.context().corpus)
809
+ except Exception as exc: # noqa: BLE001 — a number is cosmetic in a chat message
810
+ log.info("could not work out the next requirement number (%s) — showing none", exc)
811
+ return 0
812
+
813
+
814
+ def _run_intent(project, intent: str, captures: dict, *, module, lang: str | None,
815
+ user: str = "", thread: str = "", on_it=None, confirm=None,
816
+ channel: str = "") -> str | None:
817
+ """Do the thing that was asked for. `None` falls back to conversation — a recognised intent
818
+ that cannot be carried out must not swallow the message."""
819
+ # IMPORTED ONCE, AT THE TOP — both of them, because a gate and its refusal are never used
820
+ # apart. It used to be imported inside the `fact` branch, which makes it a function-local name
821
+ # for the WHOLE function — so the next branch added above that line raised UnboundLocalError on
822
+ # its authorisation check and the client got "algo quebrou do meu lado". A landmine that arms
823
+ # itself for whoever writes the next intent is worth removing, not documenting.
824
+ from openfactory.product.module import may_act, unauthorized_message
825
+ from openfactory.product.voice import triage_report
826
+
827
+ name = getattr(getattr(project, "product", None), "agent_name", "") or ""
828
+
829
+ if intent == "announce":
830
+ return module.introduce()
831
+
832
+ if intent == "fact":
833
+ fact = (captures.get("fact") or "").strip().rstrip(".")
834
+ # "lembra que semana passada o sistema caiu?" is a rhetorical QUESTION, not a dictation —
835
+ # staging it produced "Vou anotar assim — *semana passada o sistema caiu?*", which reads
836
+ # as a bot that cannot tell being asked from being told. A question mark ends the intent.
837
+ if not fact or fact.endswith("?"):
838
+ return None
839
+ term = _term_of(fact)
840
+ from openfactory.product.voice import fact_confirmation
841
+
842
+ replaced = remember(thread, {"kind": "fact", "term": term, "body": fact,
843
+ "said_by": f"<@{user}>" if user else "", "source": "",
844
+ "channel": channel},
845
+ lang=lang, project=project)
846
+ ask = fact_confirmation(term=term, body=fact, language=lang)
847
+ if not may_act(project, user):
848
+ admins = _admin_mentions(project)
849
+ if admins:
850
+ ask += f"\n\n({admins}: a anotação precisa da sua confirmação.)"
851
+ return offer_with_buttons(project, thread, replaced + ask, confirm)
852
+
853
+ if intent == "status":
854
+ # COMPOSED HERE, NEVER `status_line()`. That one is `ProductContext.health()` — an operator
855
+ # line carrying the repository slug, English prose, a warning count and a manifest path,
856
+ # and it was the answer this channel gave to "como estamos?", the most-asked question on
857
+ # the surface, every day. The diagnostics still exist and still reach the panel and the log;
858
+ # what changes is who they are written for (ADR-0026).
859
+ from openfactory.product.voice import corpus_state
860
+
861
+ ctx = module.context()
862
+ corpus = getattr(ctx, "corpus", None)
863
+ if not ctx.available:
864
+ log.warning("[%s] status asked while the product base is unreadable: %s",
865
+ getattr(project, "name", "?"), str(getattr(ctx, "reason", ""))[:200])
866
+ return corpus_state(
867
+ available=bool(ctx.available),
868
+ requirements=len(getattr(corpus, "requirements", []) or []) if corpus else 0,
869
+ promises=len(corpus.promises()) if corpus else 0,
870
+ language=lang) + _waiting_line(project)
871
+
872
+ if intent == "triage":
873
+ if on_it:
874
+ on_it()
875
+ report, error = module.triage_board()
876
+ if report is None:
877
+ # `error` is loader/board prose written for an operator — English, with the repo slug
878
+ # in it. There is no client-readable version of it: the log keeps the diagnosis whole,
879
+ # the channel hears that the problem is ours.
880
+ log.warning("[%s] triage could not read the board: %s",
881
+ getattr(project, "name", "?"), str(error)[:400])
882
+ return ("Não consegui ler o quadro de trabalho agora — o problema é do meu lado, e o "
883
+ "time já tem o detalhe. Tente de novo daqui a pouco.")
884
+ return triage_report(report, language=lang, agent_name=name)
885
+
886
+ if intent == "needs_action":
887
+ # the REAL classification, not a proxy: it reads the diagnosis already on each ticket and
888
+ # decides whose problem it is
889
+ if on_it:
890
+ on_it()
891
+ review, error = module.review_needs_action()
892
+ if review is None:
893
+ log.warning("[%s] needs-action could not read the board: %s",
894
+ getattr(project, "name", "?"), str(error)[:400])
895
+ return ("Não consegui olhar o que está parado agora — o problema é do meu lado, e o "
896
+ "time já tem o detalhe. Tente de novo daqui a pouco.")
897
+ return _needs_action_reply(review, name, language=lang)
898
+
899
+ if intent == "breakdown":
900
+ number = int(captures.get("number") or 0)
901
+ # ONE OF THE TWO GESTURES THAT WRITE ON THE MATCH ALONE — a declared exception, argued in
902
+ # full at the top of product_intents.py: filing spends nothing, the way out of Backlog is
903
+ # separately staged and gated, and the four pattern guards are what pays for the missing
904
+ # confirmation. The gate is NOT part of the exception. Checked here rather than only in
905
+ # `file_issues`: a non-approver was buying a receipt and a round-trip before hearing no,
906
+ # and an authorisation that lives one layer down is one nobody writing the next branch sees.
907
+ if not may_act(project, user):
908
+ return unauthorized_message(project)
909
+ if on_it:
910
+ on_it()
911
+ results = module.break_down(number, actor=user)
912
+ return _breakdown_reply(results, number, name, lang, project)
913
+
914
+ if intent == "accept":
915
+ number = int(captures.get("number") or 0)
916
+ req, _corpus, instead = _named_requirement(project, module, number, name, lang)
917
+ if instead:
918
+ return instead
919
+ if req.is_promise:
920
+ return f"{name}: o requisito {number} já estava acordado." if name else \
921
+ f"o requisito {number} já estava acordado."
922
+ if not req.is_live:
923
+ # THE MODULE'S OWN QUESTION, ASKED HERE TOO — `drop` reads this same flag one branch
924
+ # below. This one compared a raw status to "accepted", so a retired requirement bought
925
+ # a confirmation from a person; and `module.accept` refuses only what is ALREADY
926
+ # agreed, so that yes would have written a text the client had taken off the table
927
+ # back into force as a promise the factory defends.
928
+ retired = (f"o requisito {number} já não vale, então acordá-lo agora seria trazer de "
929
+ f"volta um texto que vocês já tinham tirado da mesa. Se isso voltou a fazer "
930
+ f"sentido, me digam e eu proponho de novo para vocês confirmarem.")
931
+ return f"{name}: {retired}" if name else retired
932
+ from openfactory.product.voice import accept_confirmation
933
+
934
+ body = remember(thread, {"kind": "accept", "number": number, "channel": channel,
935
+ "asked_by": f"<@{user}>" if user else ""},
936
+ lang=lang, project=project)
937
+ return offer_with_buttons(project, thread, body + accept_confirmation(
938
+ number=number, title=req.title or req.slug, language=lang), confirm)
939
+
940
+ if intent == "drop":
941
+ number = int(captures.get("number") or 0)
942
+ req, _corpus, instead = _named_requirement(project, module, number, name, lang)
943
+ if instead:
944
+ return instead
945
+ if not req.is_live:
946
+ # already off the table — saying "confirm and I'll drop it" would stage a write that
947
+ # changes nothing, and the person would believe they had decided something
948
+ return (f"{name}: o requisito {number} já não estava valendo." if name
949
+ else f"o requisito {number} já não estava valendo.")
950
+ from openfactory.product.voice import drop_confirmation
951
+
952
+ was_a_promise = req.is_promise
953
+ body = remember(thread, {"kind": "drop", "number": number, "channel": channel,
954
+ "reason": (captures.get("reason") or "").strip()[:300],
955
+ "was_a_promise": was_a_promise,
956
+ "asked_by": f"<@{user}>" if user else ""},
957
+ lang=lang, project=project)
958
+ ask = drop_confirmation(number=number, title=req.title or req.slug,
959
+ was_a_promise=was_a_promise, language=lang)
960
+ if not may_act(project, user):
961
+ admins = _admin_mentions(project)
962
+ if admins:
963
+ ask += f"\n\n({admins}: a decisão precisa da sua confirmação.)"
964
+ return offer_with_buttons(project, thread, body + ask, confirm)
965
+
966
+ if intent == "decision":
967
+ number = int(captures.get("number") or 0)
968
+ decision = (captures.get("decision") or "").strip().rstrip(".")[:400]
969
+ req, _corpus, instead = _named_requirement(project, module, number, name, lang)
970
+ if instead:
971
+ return instead
972
+ if not req.is_live:
973
+ # writing into a document nobody is executing records the decision where nobody will
974
+ # go looking for it — and the person would believe it had landed somewhere useful
975
+ gone = (f"o requisito {number} já não vale, então uma decisão registrada nele ficaria "
976
+ f"guardada onde ninguém vai procurar. Em qual requisito isso deve entrar?")
977
+ return f"{name}: {gone}" if name else gone
978
+ from openfactory.product.voice import decision_confirmation
979
+
980
+ body = remember(thread, {"kind": "decision", "number": number, "channel": channel,
981
+ "decision": decision,
982
+ "asked_by": f"<@{user}>" if user else ""},
983
+ lang=lang, project=project)
984
+ # THE SENTENCE IS SHOWN BACK VERBATIM, and that is the point of staging this at all: the
985
+ # whole value of the register is that somebody reads these exact words in three months, so
986
+ # a paraphrase approved today is a paraphrase found then.
987
+ ask = decision_confirmation(number=number, decision=decision, language=lang)
988
+ if not may_act(project, user):
989
+ admins = _admin_mentions(project)
990
+ if admins:
991
+ ask += f"\n\n({admins}: a decisão precisa da sua confirmação.)"
992
+ return offer_with_buttons(project, thread, body + ask, confirm)
993
+
994
+ if intent == "close":
995
+ # A CARD, NOT A REQUIREMENT (C-05) — the tracker's own ref, whatever shape it takes.
996
+ # `int(... or 0)` sat on both of these and would have raised on a Jira ref, inside the
997
+ # chat handler, where the client sees "algo quebrou do meu lado".
998
+ number = canonical_ref(captures.get("number"))
999
+ in_favour_of = canonical_ref(captures.get("in_favour_of")) or None
1000
+ reason = (captures.get("reason") or "").strip()[:300]
1001
+ if not number:
1002
+ return None
1003
+ unclear = int(captures.get("in_favour_of_unclear") or 0) or None
1004
+ if unclear and not in_favour_of:
1005
+ # A SURVIVOR NAMED WITHOUT A `#` IS AMBIGUITY, AND AMBIGUITY COSTS A QUESTION. Closing
1006
+ # anyway would perform the other of the two acts these texts exist to keep apart: the
1007
+ # card would go with no pointer, under the wording for work being given up, in answer
1008
+ # to a sentence that said the work moved. Nothing is staged — a question that displaced
1009
+ # a pending proposal would be charging for the doubt twice.
1010
+ from openfactory.product.voice import survivor_unclear
1011
+
1012
+ asked = survivor_unclear(number=number, other=unclear, language=lang)
1013
+ return f"{name}: {asked}" if name else asked
1014
+ # NOTHING IS READ FIRST, deliberately, and it is the one place this branch differs from
1015
+ # `drop`. `drop` can check the requirement in the corpus it already holds in memory; the
1016
+ # equivalent check here is a read of the client's board — seconds, over the network, on the
1017
+ # listener thread, before the person has even confirmed. The contract puts "already closed"
1018
+ # and "no such card" behind `close_card`, which reports both as a sentence a client can
1019
+ # read, so the cost is paid once and only when somebody actually decided.
1020
+ from openfactory.product.voice import close_confirmation
1021
+
1022
+ body = remember(thread, {"kind": "close", "number": number,
1023
+ "in_favour_of": in_favour_of, "reason": reason,
1024
+ "channel": channel,
1025
+ "asked_by": f"<@{user}>" if user else ""},
1026
+ lang=lang, project=project)
1027
+ ask = close_confirmation(number=number, in_favour_of=in_favour_of, reason=reason,
1028
+ language=lang)
1029
+ if not may_act(project, user):
1030
+ admins = _admin_mentions(project)
1031
+ if admins:
1032
+ ask += f"\n\n({admins}: o encerramento precisa da sua confirmação.)"
1033
+ return offer_with_buttons(project, thread, body + ask, confirm)
1034
+
1035
+ if intent == "align":
1036
+ # THE TWO AXES IN ONE GESTURE, and the reason bare `number` is not a safe name to guard
1037
+ # on: `number` is the CARD (the tracker's ref, any shape) and `requirement` is a REQ
1038
+ # number this platform mints itself, which is genuinely an integer.
1039
+ number = canonical_ref(captures.get("number"))
1040
+ requirement = int(captures.get("requirement") or 0)
1041
+ if not number or not requirement:
1042
+ return None
1043
+ req, corpus, instead = _named_requirement(project, module, requirement, name, lang)
1044
+ if instead:
1045
+ return instead
1046
+ if not req.is_promise:
1047
+ refusal = _align_refusal(project, corpus, req, number=number,
1048
+ requirement=requirement, lang=lang)
1049
+ return f"{name}: {refusal}" if name else refusal
1050
+ from openfactory.product.voice import align_confirmation
1051
+
1052
+ body = remember(thread, {"kind": "align", "number": number, "requirement": requirement,
1053
+ "channel": channel,
1054
+ "asked_by": f"<@{user}>" if user else ""},
1055
+ lang=lang, project=project)
1056
+ ask = align_confirmation(number=number, requirement=requirement,
1057
+ title=req.title or req.slug, language=lang)
1058
+ if not may_act(project, user):
1059
+ admins = _admin_mentions(project)
1060
+ if admins:
1061
+ ask += f"\n\n({admins}: a mudança precisa da sua confirmação.)"
1062
+ return offer_with_buttons(project, thread, body + ask, confirm)
1063
+
1064
+ if intent == "refine":
1065
+ # A CARD (C-05) — see `close` above.
1066
+ number = canonical_ref(captures.get("number"))
1067
+ if not number:
1068
+ return None
1069
+ # the sibling of `breakdown`, under the same declared exception and the same reasoning:
1070
+ # this writes criteria only where there are NONE — `align` is the act that replaces
1071
+ # criteria somebody may already be working from, and that one is staged.
1072
+ if not may_act(project, user):
1073
+ return unauthorized_message(project)
1074
+ if on_it:
1075
+ on_it()
1076
+ return _refine_reply(module.refine(number, actor=user), number, name, lang, project)
1077
+
1078
+ if intent == "baseline":
1079
+ return _baseline_reply(project, module, name, user, on_it)
1080
+
1081
+ if intent == "queue":
1082
+ return _queue_reply(project, module, name, thread, confirm, channel=channel,
1083
+ preamble=captures.get("preamble", ""))
1084
+
1085
+ return None
1086
+
1087
+
1088
+ def _no_such_requirement(number: int, name: str, lang) -> str:
1089
+ """Three gestures name a requirement by number and all three can miss. One sentence, said the
1090
+ same way each time — the third hand-written copy is where the wording drifts."""
1091
+ from openfactory.product.voice import requirement_not_found
1092
+
1093
+ missing = requirement_not_found(number=number, language=lang)
1094
+ return f"{name}: {missing}" if name else missing
1095
+
1096
+
1097
+ def _named_requirement(project, module, number: int, name: str, lang):
1098
+ """What a gesture that names a requirement by number is really asking for:
1099
+ `(requirement, corpus, "")`, or `(None, None, what to say instead)`.
1100
+
1101
+ "I COULD NOT READ THE BASE" IS NOT "THAT REQUIREMENT DOES NOT EXIST", and the second was said
1102
+ for the first. Loading never raises: an unavailable `ProductContext` carries an EMPTY corpus,
1103
+ so `by_number` answers None for every number while the documentation base is unreachable. And
1104
+ `_handle`'s `ctx.available` gate is reached only when the dispatcher falls through, while each
1105
+ of these three branches returns a sentence — so on a docs outage the product owner told an
1106
+ accounting client, flatly, that a requirement they had written themselves does not exist. It is
1107
+ the same state that once answered "how many requirements are there" with "zero".
1108
+
1109
+ Read once for the three. `requirement_not_found` was written to stop the wording drifting
1110
+ between them, and unified the wrong sentence across all three instead.
1111
+ """
1112
+ ctx = module.context()
1113
+ if not ctx.available:
1114
+ log.warning("[%s] a requirement gesture arrived while the base could not be read: %s",
1115
+ getattr(project, "name", "?"), ctx.reason)
1116
+ from openfactory.product.voice import unavailable
1117
+
1118
+ return None, None, unavailable(language=lang)
1119
+ req = ctx.corpus.by_number(number) if number else None
1120
+ if req is None:
1121
+ return None, None, _no_such_requirement(number, name, lang)
1122
+ return req, ctx.corpus, ""
1123
+
1124
+
1125
+ def _replacement(corpus, number: int):
1126
+ """The last requirement a supersession chain NAMES, whatever its status — None when it names
1127
+ nothing that can be read.
1128
+
1129
+ NOT A SECOND ANSWER TO `_successor`'S QUESTION, A DIFFERENT ONE. `_successor` says which
1130
+ PROMISE took over, and it is the reading the repair path stands on; it answers None both when
1131
+ the chain dangles and when the text that took over is merely proposed. Those two are not the
1132
+ same fact and the person cannot act on them the same way: one is a text they can read and
1133
+ agree to, the other is an inconsistency of ours. Telling them apart is what this reads, and
1134
+ nothing else — where both answer they must agree, and a test holds them to it.
1135
+ """
1136
+ seen: set[int] = set()
1137
+ current = corpus.by_number(number)
1138
+ while current is not None and current.superseded_by is not None:
1139
+ if current.number in seen:
1140
+ return None
1141
+ seen.add(current.number)
1142
+ current = corpus.by_number(current.superseded_by)
1143
+ return current if current is not None and current.number != number else None
1144
+
1145
+
1146
+ def _align_refusal(project, corpus, req, *, number: str, requirement: int, lang) -> str:
1147
+ """Why this card will not be written from this requirement — said HERE, before anybody is asked.
1148
+
1149
+ THE PRE-CHECK IS THE MODULE'S OWN GATE. `align_card` writes only from a PROMISE; this branch
1150
+ admitted anything still `is_live`, so a proposed requirement bought a "this changes what gets
1151
+ built — Confirma?" from a person, displaced whatever else was awaiting confirmation in the
1152
+ thread, and was refused one call deeper by a gate that had never moved. Asking somebody to
1153
+ authorise an act that cannot happen spends the only thing this surface asks of a human.
1154
+
1155
+ THE DAMAGE THIS ACT EXISTS TO UNDO, ARRIVING FROM THE OTHER DIRECTION. Writing a card's
1156
+ criteria out of a retired text is what left thirteen cards executing REQ-0004 under a rule
1157
+ telling whoever works them not to go beyond it. Refused, and the refusal names the sentence
1158
+ that would work — a dead end is how somebody stops asking.
1159
+
1160
+ THE END OF THE CHAIN, NOT THE NEXT LINK, and "unagreed" is not "unreadable". `superseded_by`
1161
+ is a claim about one hop; a replacement written in the same commit that retires its
1162
+ predecessor is `proposed` for as long as nobody has said yes. Reading that as a broken chain
1163
+ told a client our base pointed at a text nobody could open — about a requirement that reads
1164
+ perfectly well — and raised an operator alarm with nothing behind it. What it needs is the
1165
+ fourth answer: the replacement exists, and confirming it is the step that unlocks this one.
1166
+
1167
+ AND "UNAGREED" IS NOT "REFUSED". `_replacement` answers which requirement the chain NAMES,
1168
+ whatever became of it; what this has to know is whether the person can still say yes to it. A
1169
+ replacement the client themselves dropped is neither missing nor pending, and offered as
1170
+ pending it invited them to reinstate the text they had cancelled — which `accept` would have
1171
+ written back into force. The status is read here, once, rather than assumed by the sentence.
1172
+ """
1173
+ from openfactory.product.module import _not_a_promise, _successor
1174
+ from openfactory.product.voice import (
1175
+ align_refused,
1176
+ align_to_dropped_replacement,
1177
+ align_to_unagreed,
1178
+ )
1179
+
1180
+ if req.superseded_by is not None:
1181
+ promise = _successor(corpus, requirement)
1182
+ if promise:
1183
+ return align_refused(number=number, requirement=requirement, successor=promise,
1184
+ language=lang)
1185
+ replacement = _replacement(corpus, requirement)
1186
+ if replacement is not None:
1187
+ if not replacement.is_live:
1188
+ return align_to_dropped_replacement(number=number, requirement=requirement,
1189
+ successor=replacement.number, language=lang)
1190
+ return align_to_unagreed(number=number, requirement=requirement,
1191
+ successor=replacement.number, language=lang)
1192
+ log.warning("OPENFACTORY_PRODUCT_CHAIN_BROKEN project=%s requirement=%s superseded_by=%s — "
1193
+ "a "
1194
+ "retired requirement points at a text the corpus cannot read, so nothing can "
1195
+ "be re-aimed at it", getattr(project, "name", "?"), requirement,
1196
+ req.superseded_by)
1197
+ return align_refused(number=number, requirement=requirement, replaced=True, language=lang)
1198
+ if not req.is_live:
1199
+ return align_refused(number=number, requirement=requirement, language=lang)
1200
+ # Live, and still not something the factory may be aimed at. THE MODULE'S OWN SENTENCE, not a
1201
+ # second one written here: `_not_a_promise` is what `align_card` and `break_down` both answer
1202
+ # with, and it separates a proposal from a reading of the code — a person told two different
1203
+ # things about one rule learns the rule is arbitrary.
1204
+ return _not_a_promise(requirement, req)
1205
+
1206
+
1207
+ def _queue_reply(project, module, name: str, thread: str, confirm=None, *,
1208
+ channel: str = "", preamble: str = "") -> str | None:
1209
+ """Propose what to start next, and stage it for one yes.
1210
+
1211
+ The proposal is the argument; the yes is the decision. Staged like a draft because it is the
1212
+ same kind of commitment: approving a specific ordered list, not a direction."""
1213
+ from openfactory.product.voice import queue_proposal
1214
+
1215
+ lang = getattr(project, "language", None)
1216
+ state, proposal, error = module.propose_queue()
1217
+ if state is None:
1218
+ log.warning("[%s] queue proposal could not read the board: %s",
1219
+ getattr(project, "name", "?"), str(error)[:400])
1220
+ return ("Não consegui olhar o quadro agora — o problema é do meu lado, e o time já tem o "
1221
+ "detalhe. Tente de novo daqui a pouco.")
1222
+
1223
+ titles = {}
1224
+ try:
1225
+ titles = {t.number: t.title for t in module._board_tickets or []}
1226
+ except Exception as exc: # noqa: BLE001 — titles are decoration, the numbers still go out
1227
+ log.info("could not read ticket titles (%s) — the message will carry numbers only", exc)
1228
+ titles = {}
1229
+
1230
+ text = queue_proposal(state, proposal, titles=titles, language=lang, agent_name=name)
1231
+ if proposal and proposal.items:
1232
+ replaced = remember(thread, {"kind": "queue", "channel": channel,
1233
+ "numbers": [i.ticket for i in proposal.items]},
1234
+ lang=lang, project=project)
1235
+ # THE PREAMBLE GOES INSIDE, never around the return. `offer_with_buttons` can hand back a
1236
+ # `Posted`, and interpolating one into an f-string turns it into a plain `str` — the
1237
+ # boundary then cannot tell it was posted and the proposal goes out twice. Same reason
1238
+ # `offer_draft` takes its preamble as an argument.
1239
+ return offer_with_buttons(project, thread, preamble + replaced + text, confirm)
1240
+ return text
1241
+
1242
+
1243
+ def _needs_action_reply(review, name: str, *, language=None) -> str:
1244
+ """The composer moved to `openfactory/product/voice.py` (#105); this is the channel's call
1245
+ into it.
1246
+
1247
+ It was the only sentence in the product's VOICE that lived in this package, and it was
1248
+ hardcoded pt-BR — so a client on another language read Portuguese and a deployment without
1249
+ Slack could not reach the words at all. `language` is threaded through rather than defaulted
1250
+ here, because defaulting in the transport is how the hardcoding happened the first time."""
1251
+ from openfactory.product.voice import needs_action_report
1252
+
1253
+ return needs_action_report(review, language=language, agent_name=name)
1254
+
1255
+
1256
+ def _waiting_release_refs(project) -> list[str]:
1257
+ """The refs of every release still waiting on the client's word, oldest first — or [].
1258
+
1259
+ Best-effort: this feeds ONE parenthesis in a chat reply, and an unreadable ledger must cost
1260
+ the parenthesis, never the answer."""
1261
+ try:
1262
+ from openfactory.memory import store as loop_store
1263
+ from openfactory.memory.ledger import ACCEPTANCE, waiting
1264
+ from openfactory.product.followup import OWNER, is_release
1265
+
1266
+ loops = [x for x in waiting(loop_store.read(project.name), owner=OWNER)
1267
+ if x.kind == ACCEPTANCE and is_release(x)]
1268
+ return [is_release(x) for x in sorted(loops, key=lambda x: x.ts)]
1269
+ except Exception as exc: # noqa: BLE001 — the parenthesis is decoration; the ask is not
1270
+ log.info("could not list the waiting releases for the ambiguity reply (%s)", exc)
1271
+ return []
1272
+
1273
+
1274
+ def _maybe_release(project, module, loop, verdict: str, user: str, agent: str, lang,
1275
+ *, ambiguous: bool, via: str = "slack") -> str | None:
1276
+ """The client's answer to "is it ready to go live?" — or None when this was an ordinary one.
1277
+
1278
+ None rather than a boolean, so the caller's normal path is untouched by a branch that does not
1279
+ apply: every other acceptance still reads exactly as it did.
1280
+
1281
+ THREE THINGS MAKE THIS DIFFERENT FROM ITS SIBLINGS, and each is a rule the ordinary acceptance
1282
+ does not need:
1283
+
1284
+ 1. AMBIGUITY IS REFUSED, NOT NAMED. With two deliveries waiting, `settle_acceptance` settles
1285
+ the newest and the reply says which — a wrong guess costs one correction. Here a wrong guess
1286
+ PUTS THE WRONG SOFTWARE IN FRONT OF THE CLIENT'S USERS, so the guess is not offered: they
1287
+ are asked which one, and nothing is released.
1288
+ 2. AUTHORISATION IS RE-CHECKED. Reading a message is not an act; releasing is. `may_act` is the
1289
+ deployment's own declared list, so who may do this is a registry line and never "whoever is
1290
+ in the room". ON EVERY SURFACE THAT REACHES THIS STAGE (2026-08-25): a "funcionou o #12"
1291
+ typed in the panel's product box reaches this same gate — by design, the panel is the
1292
+ reference surface (ADR-0038 D1) and the product box is not a second, softer approver — and
1293
+ `via` records which surface the approver was speaking from, never who may approve.
1294
+ 3. THE ACT IS OBSERVED BEFORE IT IS CLAIMED. `release()` re-asks the workflow whether it is
1295
+ still parked, and returns the honest sentence when it is not. A client told "subiu" over a
1296
+ signal that reached nothing is the worst outcome available on this path.
1297
+
1298
+ A "não funcionou" releases NOTHING and says so plainly — the loop is already closed as
1299
+ rejected by the caller, which is the record that matters.
1300
+ """
1301
+ # IMPORTED AT THE TOP OF THIS FUNCTION, never inside the branch that uses them. `may_act` was
1302
+ # imported inside one branch of `_run_intent` earlier today; the client read "algo quebrou do
1303
+ # meu lado" for an UnboundLocalError. The gate is the last thing that may be reached by luck.
1304
+ from openfactory.product.followup import is_release
1305
+ from openfactory.product.module import may_act, unauthorized_message
1306
+
1307
+ issue = is_release(loop)
1308
+ if not issue:
1309
+ return None # an ordinary delivery acceptance; the caller handles it
1310
+
1311
+ head = f"{agent}: " if agent else ""
1312
+ if verdict != "worked":
1313
+ return (f"{head}entendi — **não subi nada**. Vou devolver isso ao time com o que você "
1314
+ f"disse, e volto quando estiver corrigido para você conferir de novo.")
1315
+ if ambiguous:
1316
+ # NOTHING was released AND nothing was closed (module.settle_acceptance keeps an ambiguous
1317
+ # release open — #24 item 2): the question below is still pending, so the reply that names
1318
+ # the ref settles the right loop and releases it. The instruction gives the exact sentence
1319
+ # the parser understands, because "me diga o número" alone used to instruct a reply no code
1320
+ # path could read — an unfollowable instruction from the platform's own mouth.
1321
+ listed = _waiting_release_refs(project)
1322
+ which = f" ({', '.join(f'#{r}' for r in listed)})" if listed else ""
1323
+ return (f"{head}tem mais de uma coisa esperando a sua conferida{which}, então **não subi "
1324
+ f"nada** — prefiro não adivinhar qual delas você testou. Responda "
1325
+ f"«funcionou o #número» e eu coloco essa no ar.")
1326
+ if not may_act(project, user, via=via):
1327
+ return unauthorized_message(project)
1328
+
1329
+ from openfactory.product.release import release
1330
+
1331
+ ok, why = release(project, issue, approver=user,
1332
+ comment="aprovado pelo cliente no canal de produto")
1333
+ if not ok:
1334
+ return f"{head}{why}"
1335
+ return (f"{head}perfeito — **estou subindo para produção agora**, com o seu \"funcionou\" "
1336
+ f"como aprovação. Fica registrado que foi você quem liberou e quando. Eu volto aqui "
1337
+ f"quando estiver no ar.")
1338
+
1339
+
1340
+ # ── `_where_it_came_from`, `_also_broke_it_down` and `_breakdown_reply` moved with the executor ──
1341
+ #
1342
+ # They are the confirmation's own prose: the provenance cell of a decision row, the acceptance's
1343
+ # automatic breakdown, and what the client reads afterwards. `_breakdown_reply` is imported back
1344
+ # because `_run_intent` composes the same sentence for a typed "quebra o requisito 7" — one voice,
1345
+ # whether the breakdown was asked for or followed an acceptance.
1346
+
1347
+
1348
+
1349
+ def _refine_reply(result, number: str, name: str, lang=None, project=None) -> str:
1350
+ """What the client reads after a ticket was given something testable to be judged against.
1351
+
1352
+ Composed from the real `WriteResult`, like every other write reply — the agent never narrates
1353
+ this (ADR-0028). Three outcomes, three sentences: written, already had criteria, failed.
1354
+
1355
+ THE REFUSAL POINTS SOMEWHERE. It is still a refusal — this act unblocks a card with nothing
1356
+ written, and amending criteria somebody may already be working from is a different risk that
1357
+ costs a model call and changes what gets built. But it now names that other act, because a
1358
+ refusal with no alternative reads as "this cannot be done", and the person who hit it in
1359
+ production was holding exactly the card the other act was built for.
1360
+
1361
+ THE FOURTH TWO-WRITE BRANCH, and the one left out when `close`, `defect` and `align` learned
1362
+ the rule. `refine` writes the criteria and then comments to say who wrote them, and the comment
1363
+ failing comes back as a SUCCESS carrying the module's own sentence about it. This line
1364
+ announced the comment regardless AND put that sentence where the count goes, so the client read
1365
+ a denial and an assertion of the same note eight words apart.
1366
+ """
1367
+ head = f"{name}: " if name else ""
1368
+ if not result.ok:
1369
+ return f"{head}{_client_detail(result.detail, lang, project=project)}"
1370
+ if getattr(result, "existed", False):
1371
+ from openfactory.product.voice import refine_refused
1372
+
1373
+ return head + refine_refused(number=number, language=lang)
1374
+ from openfactory.product.voice import criteria_written
1375
+
1376
+ # the count is shown only when there is a count: the same field carries the residue instead
1377
+ # whenever the second write failed, and `_unfinished` is the one reading that tells them apart.
1378
+ # Through the sanitiser even on SUCCESS — every detail a client reads is sanitised, with no
1379
+ # exception anybody has to remember.
1380
+ residue = _unfinished(result)
1381
+ return _still_to_say(
1382
+ head + criteria_written(
1383
+ number=number, noted=not residue,
1384
+ measure="" if residue else _client_detail(result.detail, lang, project=project),
1385
+ language=lang),
1386
+ result, lang, project=project)
1387
+
1388
+
1389
+ def _baseline_reply(project, module, name: str, user: str, on_it=None) -> str:
1390
+ """Somebody asked for the brownfield first pass — a read of the whole codebase written up as
1391
+ OBSERVATIONS for a person to confirm (`brownfield.py`, ADR-0019).
1392
+
1393
+ ANNOUNCED, THEN RUN, OFF THE LISTENER THREAD. It reads an entire repository through an agent
1394
+ and comes back with a pull request: minutes, not seconds. Blocking here would time out the
1395
+ Socket Mode handler and take the channel down for everyone; saying nothing until it finishes
1396
+ is the "looks broken while working" failure this whole layer exists to remove.
1397
+
1398
+ For a while this replied that the pass was not wired — an honest admission that was better
1399
+ than pretending, and worse than doing it."""
1400
+ from openfactory.product.module import may_act, unauthorized_message
1401
+ from openfactory.product.voice import baseline_done, baseline_started
1402
+
1403
+ if not may_act(project, user):
1404
+ return unauthorized_message(project)
1405
+
1406
+ lang = getattr(project, "language", None)
1407
+ channel_id = getattr(getattr(project, "product", None), "channel_id", None)
1408
+
1409
+ def _run() -> None:
1410
+ try:
1411
+ if on_it:
1412
+ on_it()
1413
+ result = module.baseline()
1414
+ text = baseline_done(ok=result.ok, url=result.url, detail=result.detail,
1415
+ existed=result.existed, language=lang, agent_name=name)
1416
+ except Exception as exc: # noqa: BLE001 — a thread dying silently is the worst outcome
1417
+ log.exception("the baseline pass crashed for %s", getattr(project, "name", "?"))
1418
+ text = baseline_done(ok=False, detail=str(exc)[:200], language=lang, agent_name=name)
1419
+ try:
1420
+ from openfactory.adapters.channel import build_channel
1421
+
1422
+ told = build_channel(project).say(project=project, channel=channel_id, text=text)
1423
+ except Exception as exc: # noqa: BLE001 — the work happened; only the telling failed
1424
+ told = False
1425
+ log.error("the baseline finished but could not be announced (%s) — the pull request "
1426
+ "may exist and nobody was told", exc)
1427
+ if not told:
1428
+ # `say` reports refusal by returning False, not only by raising — the common failure
1429
+ # (a channel the bot is not in, a rejected post) came back on this path and the error
1430
+ # above never fired, which is precisely the silence it exists to name
1431
+ log.error("OPENFACTORY_PRODUCT_BASELINE_UNANNOUNCED project=%s — the baseline outcome "
1432
+ "never "
1433
+ "reached the channel; the client is still waiting on a 'done' that was "
1434
+ "computed and not delivered", getattr(project, "name", "?"))
1435
+
1436
+ threading.Thread(target=_run, daemon=True, name="product-baseline").start()
1437
+ return baseline_started(language=lang, agent_name=name)