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.
- openfactory/__init__.py +7 -0
- openfactory/actions/__init__.py +344 -0
- openfactory/actions/base.py +334 -0
- openfactory/actions/catalog.py +4872 -0
- openfactory/actions/floor_intents.py +431 -0
- openfactory/adapters/__init__.py +0 -0
- openfactory/adapters/agent/__init__.py +71 -0
- openfactory/adapters/agent/base.py +358 -0
- openfactory/adapters/agent/claude_code.py +1023 -0
- openfactory/adapters/agent/codex.py +350 -0
- openfactory/adapters/agent/kimi.py +291 -0
- openfactory/adapters/agent/opencode.py +432 -0
- openfactory/adapters/agent/registry.py +485 -0
- openfactory/adapters/agent/roles.py +263 -0
- openfactory/adapters/agent/routes.py +255 -0
- openfactory/adapters/agent/session_store.py +221 -0
- openfactory/adapters/agent/stream.py +367 -0
- openfactory/adapters/agent/techlead.py +108 -0
- openfactory/adapters/agent/token_pool.py +67 -0
- openfactory/adapters/azure_devops.py +329 -0
- openfactory/adapters/board/__init__.py +10 -0
- openfactory/adapters/board/azure_devops.py +748 -0
- openfactory/adapters/board/base.py +193 -0
- openfactory/adapters/board/columns.py +68 -0
- openfactory/adapters/board/factory.py +218 -0
- openfactory/adapters/board/jira.py +252 -0
- openfactory/adapters/board/local.py +187 -0
- openfactory/adapters/board_db.py +276 -0
- openfactory/adapters/board_setup/__init__.py +8 -0
- openfactory/adapters/board_setup/base.py +49 -0
- openfactory/adapters/board_setup/local.py +63 -0
- openfactory/adapters/board_setup/registry.py +41 -0
- openfactory/adapters/channel/__init__.py +4 -0
- openfactory/adapters/channel/base.py +100 -0
- openfactory/adapters/channel/panel.py +107 -0
- openfactory/adapters/channel/registry.py +112 -0
- openfactory/adapters/credential/__init__.py +8 -0
- openfactory/adapters/credential/registry.py +164 -0
- openfactory/adapters/environment/__init__.py +30 -0
- openfactory/adapters/environment/azure_pipelines.py +412 -0
- openfactory/adapters/environment/base.py +35 -0
- openfactory/adapters/environment/github_actions.py +97 -0
- openfactory/adapters/environment/none.py +53 -0
- openfactory/adapters/environment/registry.py +160 -0
- openfactory/adapters/forge/__init__.py +30 -0
- openfactory/adapters/forge/azure_devops.py +1528 -0
- openfactory/adapters/forge/base.py +515 -0
- openfactory/adapters/forge/github.py +779 -0
- openfactory/adapters/forge/local.py +660 -0
- openfactory/adapters/forge/registry.py +210 -0
- openfactory/adapters/github_app.py +70 -0
- openfactory/adapters/notify/__init__.py +12 -0
- openfactory/adapters/notify/base.py +45 -0
- openfactory/adapters/notify/panel.py +43 -0
- openfactory/adapters/notify/registry.py +301 -0
- openfactory/adapters/reviewer/__init__.py +30 -0
- openfactory/adapters/reviewer/base.py +34 -0
- openfactory/adapters/reviewer/claude_code.py +140 -0
- openfactory/adapters/reviewer/harness.py +154 -0
- openfactory/adapters/sandbox/__init__.py +31 -0
- openfactory/adapters/sandbox/base.py +410 -0
- openfactory/adapters/sandbox/container.py +550 -0
- openfactory/adapters/sandbox/registry.py +490 -0
- openfactory/adapters/sandbox/timeouts.py +76 -0
- openfactory/adapters/sandbox/worktree.py +381 -0
- openfactory/adapters/tracker/__init__.py +30 -0
- openfactory/adapters/tracker/azure_devops.py +904 -0
- openfactory/adapters/tracker/base.py +549 -0
- openfactory/adapters/tracker/github.py +529 -0
- openfactory/adapters/tracker/github_board_setup.py +216 -0
- openfactory/adapters/tracker/github_project.py +733 -0
- openfactory/adapters/tracker/jira.py +575 -0
- openfactory/adapters/tracker/local.py +379 -0
- openfactory/adapters/tracker/parse.py +231 -0
- openfactory/adapters/tracker/registry.py +200 -0
- openfactory/after_merge.py +51 -0
- openfactory/api/__init__.py +4 -0
- openfactory/api/app.py +2627 -0
- openfactory/api/metrics_view.py +337 -0
- openfactory/api/panel.html +3419 -0
- openfactory/approvals.py +86 -0
- openfactory/box_prove.py +1458 -0
- openfactory/cli.py +3298 -0
- openfactory/cli_refusals.py +169 -0
- openfactory/conformance/__init__.py +19 -0
- openfactory/conformance/adapters.py +622 -0
- openfactory/contracts/__init__.py +65 -0
- openfactory/contracts/bot.py +42 -0
- openfactory/contracts/commands.py +183 -0
- openfactory/contracts/decision.py +286 -0
- openfactory/contracts/manifest.py +713 -0
- openfactory/contracts/product.py +95 -0
- openfactory/contracts/profile.py +214 -0
- openfactory/contracts/project.py +353 -0
- openfactory/contracts/refs.py +155 -0
- openfactory/contracts/review.py +52 -0
- openfactory/contracts/run.py +326 -0
- openfactory/contracts/state.py +64 -0
- openfactory/contracts/ticket.py +113 -0
- openfactory/credentials.py +356 -0
- openfactory/doctor.py +1577 -0
- openfactory/doors.py +238 -0
- openfactory/environ.py +251 -0
- openfactory/factory.py +460 -0
- openfactory/floor/__init__.py +31 -0
- openfactory/floor/ladder.py +655 -0
- openfactory/floor/reading.py +302 -0
- openfactory/identity/__init__.py +14 -0
- openfactory/identity/base.py +95 -0
- openfactory/identity/local.py +186 -0
- openfactory/identity/oidc.py +633 -0
- openfactory/identity/people.py +352 -0
- openfactory/identity/registry.py +91 -0
- openfactory/knowledge/__init__.py +76 -0
- openfactory/knowledge/bundle.py +219 -0
- openfactory/knowledge/check.py +167 -0
- openfactory/knowledge/contracts.py +407 -0
- openfactory/knowledge/experiment.py +118 -0
- openfactory/knowledge/gaps.py +118 -0
- openfactory/knowledge/gate.py +330 -0
- openfactory/knowledge/gather.py +156 -0
- openfactory/knowledge/generator.py +822 -0
- openfactory/knowledge/inventory.py +557 -0
- openfactory/knowledge/okf.py +369 -0
- openfactory/knowledge/pipeline.py +333 -0
- openfactory/knowledge/render.py +146 -0
- openfactory/knowledge/service.py +53 -0
- openfactory/knowledge/staleness.py +112 -0
- openfactory/language/__init__.py +15 -0
- openfactory/language/assent.py +130 -0
- openfactory/loader.py +156 -0
- openfactory/memory/__init__.py +29 -0
- openfactory/memory/ledger.py +215 -0
- openfactory/memory/messages.py +406 -0
- openfactory/memory/recall.py +303 -0
- openfactory/memory/store.py +157 -0
- openfactory/memory/transcript.py +267 -0
- openfactory/namespace.py +258 -0
- openfactory/observability/__init__.py +23 -0
- openfactory/observability/events.py +188 -0
- openfactory/observability/metrics.py +191 -0
- openfactory/observability/query.py +80 -0
- openfactory/observability/registry.py +253 -0
- openfactory/observability/sqlite_metrics.py +203 -0
- openfactory/observability/trajectory.py +243 -0
- openfactory/onboarding/__init__.py +64 -0
- openfactory/onboarding/concepts.py +450 -0
- openfactory/onboarding/context.py +2399 -0
- openfactory/onboarding/cover.py +97 -0
- openfactory/onboarding/deployment.py +807 -0
- openfactory/onboarding/firstrun.py +1637 -0
- openfactory/onboarding/history.py +363 -0
- openfactory/onboarding/infer.py +2736 -0
- openfactory/onboarding/live_ci.py +162 -0
- openfactory/onboarding/onboard.py +1070 -0
- openfactory/onboarding/propose_manifest.py +349 -0
- openfactory/onboarding/questions.py +137 -0
- openfactory/onboarding/readiness.py +1073 -0
- openfactory/onboarding/renew.py +239 -0
- openfactory/onboarding/spend.py +82 -0
- openfactory/ops/__init__.py +6 -0
- openfactory/ops/impediment.py +288 -0
- openfactory/orchestrator/__init__.py +26 -0
- openfactory/orchestrator/context.py +231 -0
- openfactory/orchestrator/errors.py +27 -0
- openfactory/orchestrator/machine.py +2617 -0
- openfactory/orchestrator/merge_policy.py +203 -0
- openfactory/orchestrator/promotion.py +249 -0
- openfactory/orchestrator/risk.py +191 -0
- openfactory/orchestrator/validation.py +172 -0
- openfactory/org_defaults/engineering.md +75 -0
- openfactory/org_defaults/floor.yaml +182 -0
- openfactory/org_defaults/profiles/prototype.yaml +16 -0
- openfactory/org_defaults/profiles/regulated.yaml +35 -0
- openfactory/org_defaults/requirements/0000-template.md +39 -0
- openfactory/org_defaults/roles/coordinator.md +48 -0
- openfactory/org_defaults/roles/executor.md +92 -0
- openfactory/org_defaults/roles/planner.md +117 -0
- openfactory/org_defaults/roles/product.md +71 -0
- openfactory/org_defaults/roles/recovery.md +33 -0
- openfactory/org_defaults/roles/sizer.md +62 -0
- openfactory/org_defaults/roles/techlead.md +61 -0
- openfactory/org_defaults/tdd.md +10 -0
- openfactory/own_work.py +41 -0
- openfactory/paths.py +117 -0
- openfactory/plugins.py +208 -0
- openfactory/policy/__init__.py +10 -0
- openfactory/policy/authz.py +105 -0
- openfactory/policy/census.py +161 -0
- openfactory/policy/conformance.py +300 -0
- openfactory/policy/floor.py +42 -0
- openfactory/policy/presets.py +172 -0
- openfactory/policy/profiles.py +275 -0
- openfactory/policy/protected.py +169 -0
- openfactory/policy/test_work.py +70 -0
- openfactory/preflight.py +577 -0
- openfactory/presets/node.yaml +6 -0
- openfactory/presets/python.yaml +9 -0
- openfactory/presets/security-oss.yaml +26 -0
- openfactory/presets/terraform.yaml +8 -0
- openfactory/product/__init__.py +56 -0
- openfactory/product/asked.py +172 -0
- openfactory/product/authoring.py +1791 -0
- openfactory/product/board.py +484 -0
- openfactory/product/brownfield.py +210 -0
- openfactory/product/case.py +409 -0
- openfactory/product/channel.py +1437 -0
- openfactory/product/config.py +364 -0
- openfactory/product/confirm.py +801 -0
- openfactory/product/conversation.py +49 -0
- openfactory/product/corpus.py +462 -0
- openfactory/product/domain.py +242 -0
- openfactory/product/facts.py +209 -0
- openfactory/product/followup.py +647 -0
- openfactory/product/intents.py +641 -0
- openfactory/product/loader.py +198 -0
- openfactory/product/module.py +3100 -0
- openfactory/product/needs_action.py +198 -0
- openfactory/product/onboard.py +331 -0
- openfactory/product/queue.py +273 -0
- openfactory/product/reading.py +130 -0
- openfactory/product/release.py +153 -0
- openfactory/product/requester.py +55 -0
- openfactory/product/role.py +1356 -0
- openfactory/product/staging.py +477 -0
- openfactory/product/triage.py +277 -0
- openfactory/product/voice.py +2080 -0
- openfactory/product/workspace.py +182 -0
- openfactory/registry.py +526 -0
- openfactory/review/__init__.py +11 -0
- openfactory/review/verdict.py +199 -0
- openfactory/runtime/__init__.py +0 -0
- openfactory/runtime/boxed_job.py +399 -0
- openfactory/runtime/card_repo.py +146 -0
- openfactory/runtime/host.py +91 -0
- openfactory/runtime/repo_cache.py +296 -0
- openfactory/runtime/temporal/__init__.py +26 -0
- openfactory/runtime/temporal/activities.py +5081 -0
- openfactory/runtime/temporal/connection.py +89 -0
- openfactory/runtime/temporal/io.py +639 -0
- openfactory/runtime/temporal/poller.py +185 -0
- openfactory/runtime/temporal/schedule.py +465 -0
- openfactory/runtime/temporal/starter.py +54 -0
- openfactory/runtime/temporal/view.py +884 -0
- openfactory/runtime/temporal/worker.py +325 -0
- openfactory/runtime/temporal/workflow.py +2459 -0
- openfactory/runtime/toolbox.py +163 -0
- openfactory/scheduler.py +75 -0
- openfactory/semver.py +35 -0
- openfactory/techlead/__init__.py +38 -0
- openfactory/techlead/classify.py +427 -0
- openfactory/techlead/conversation.py +1401 -0
- openfactory/techlead/diagnosis.py +251 -0
- openfactory/techlead/memory.py +264 -0
- openfactory/techlead/pack.py +169 -0
- openfactory/techlead/voice.py +679 -0
- openfactory/techlead/watch.py +638 -0
- openfactory/testing/__init__.py +1 -0
- openfactory/testing/local_flow.py +272 -0
- openfactory/util/__init__.py +0 -0
- openfactory/util/bounded.py +77 -0
- openfactory/util/causes.py +125 -0
- openfactory/util/scratch.py +80 -0
- openfactory-0.2.0.dist-info/METADATA +41 -0
- openfactory-0.2.0.dist-info/RECORD +270 -0
- openfactory-0.2.0.dist-info/WHEEL +5 -0
- openfactory-0.2.0.dist-info/entry_points.txt +2 -0
- openfactory-0.2.0.dist-info/licenses/LICENSE +202 -0
- openfactory-0.2.0.dist-info/licenses/NOTICE +22 -0
- 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)
|