openfactory 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (270) hide show
  1. openfactory/__init__.py +7 -0
  2. openfactory/actions/__init__.py +344 -0
  3. openfactory/actions/base.py +334 -0
  4. openfactory/actions/catalog.py +4872 -0
  5. openfactory/actions/floor_intents.py +431 -0
  6. openfactory/adapters/__init__.py +0 -0
  7. openfactory/adapters/agent/__init__.py +71 -0
  8. openfactory/adapters/agent/base.py +358 -0
  9. openfactory/adapters/agent/claude_code.py +1023 -0
  10. openfactory/adapters/agent/codex.py +350 -0
  11. openfactory/adapters/agent/kimi.py +291 -0
  12. openfactory/adapters/agent/opencode.py +432 -0
  13. openfactory/adapters/agent/registry.py +485 -0
  14. openfactory/adapters/agent/roles.py +263 -0
  15. openfactory/adapters/agent/routes.py +255 -0
  16. openfactory/adapters/agent/session_store.py +221 -0
  17. openfactory/adapters/agent/stream.py +367 -0
  18. openfactory/adapters/agent/techlead.py +108 -0
  19. openfactory/adapters/agent/token_pool.py +67 -0
  20. openfactory/adapters/azure_devops.py +329 -0
  21. openfactory/adapters/board/__init__.py +10 -0
  22. openfactory/adapters/board/azure_devops.py +748 -0
  23. openfactory/adapters/board/base.py +193 -0
  24. openfactory/adapters/board/columns.py +68 -0
  25. openfactory/adapters/board/factory.py +218 -0
  26. openfactory/adapters/board/jira.py +252 -0
  27. openfactory/adapters/board/local.py +187 -0
  28. openfactory/adapters/board_db.py +276 -0
  29. openfactory/adapters/board_setup/__init__.py +8 -0
  30. openfactory/adapters/board_setup/base.py +49 -0
  31. openfactory/adapters/board_setup/local.py +63 -0
  32. openfactory/adapters/board_setup/registry.py +41 -0
  33. openfactory/adapters/channel/__init__.py +4 -0
  34. openfactory/adapters/channel/base.py +100 -0
  35. openfactory/adapters/channel/panel.py +107 -0
  36. openfactory/adapters/channel/registry.py +112 -0
  37. openfactory/adapters/credential/__init__.py +8 -0
  38. openfactory/adapters/credential/registry.py +164 -0
  39. openfactory/adapters/environment/__init__.py +30 -0
  40. openfactory/adapters/environment/azure_pipelines.py +412 -0
  41. openfactory/adapters/environment/base.py +35 -0
  42. openfactory/adapters/environment/github_actions.py +97 -0
  43. openfactory/adapters/environment/none.py +53 -0
  44. openfactory/adapters/environment/registry.py +160 -0
  45. openfactory/adapters/forge/__init__.py +30 -0
  46. openfactory/adapters/forge/azure_devops.py +1528 -0
  47. openfactory/adapters/forge/base.py +515 -0
  48. openfactory/adapters/forge/github.py +779 -0
  49. openfactory/adapters/forge/local.py +660 -0
  50. openfactory/adapters/forge/registry.py +210 -0
  51. openfactory/adapters/github_app.py +70 -0
  52. openfactory/adapters/notify/__init__.py +12 -0
  53. openfactory/adapters/notify/base.py +45 -0
  54. openfactory/adapters/notify/panel.py +43 -0
  55. openfactory/adapters/notify/registry.py +301 -0
  56. openfactory/adapters/reviewer/__init__.py +30 -0
  57. openfactory/adapters/reviewer/base.py +34 -0
  58. openfactory/adapters/reviewer/claude_code.py +140 -0
  59. openfactory/adapters/reviewer/harness.py +154 -0
  60. openfactory/adapters/sandbox/__init__.py +31 -0
  61. openfactory/adapters/sandbox/base.py +410 -0
  62. openfactory/adapters/sandbox/container.py +550 -0
  63. openfactory/adapters/sandbox/registry.py +490 -0
  64. openfactory/adapters/sandbox/timeouts.py +76 -0
  65. openfactory/adapters/sandbox/worktree.py +381 -0
  66. openfactory/adapters/tracker/__init__.py +30 -0
  67. openfactory/adapters/tracker/azure_devops.py +904 -0
  68. openfactory/adapters/tracker/base.py +549 -0
  69. openfactory/adapters/tracker/github.py +529 -0
  70. openfactory/adapters/tracker/github_board_setup.py +216 -0
  71. openfactory/adapters/tracker/github_project.py +733 -0
  72. openfactory/adapters/tracker/jira.py +575 -0
  73. openfactory/adapters/tracker/local.py +379 -0
  74. openfactory/adapters/tracker/parse.py +231 -0
  75. openfactory/adapters/tracker/registry.py +200 -0
  76. openfactory/after_merge.py +51 -0
  77. openfactory/api/__init__.py +4 -0
  78. openfactory/api/app.py +2627 -0
  79. openfactory/api/metrics_view.py +337 -0
  80. openfactory/api/panel.html +3419 -0
  81. openfactory/approvals.py +86 -0
  82. openfactory/box_prove.py +1458 -0
  83. openfactory/cli.py +3298 -0
  84. openfactory/cli_refusals.py +169 -0
  85. openfactory/conformance/__init__.py +19 -0
  86. openfactory/conformance/adapters.py +622 -0
  87. openfactory/contracts/__init__.py +65 -0
  88. openfactory/contracts/bot.py +42 -0
  89. openfactory/contracts/commands.py +183 -0
  90. openfactory/contracts/decision.py +286 -0
  91. openfactory/contracts/manifest.py +713 -0
  92. openfactory/contracts/product.py +95 -0
  93. openfactory/contracts/profile.py +214 -0
  94. openfactory/contracts/project.py +353 -0
  95. openfactory/contracts/refs.py +155 -0
  96. openfactory/contracts/review.py +52 -0
  97. openfactory/contracts/run.py +326 -0
  98. openfactory/contracts/state.py +64 -0
  99. openfactory/contracts/ticket.py +113 -0
  100. openfactory/credentials.py +356 -0
  101. openfactory/doctor.py +1577 -0
  102. openfactory/doors.py +238 -0
  103. openfactory/environ.py +251 -0
  104. openfactory/factory.py +460 -0
  105. openfactory/floor/__init__.py +31 -0
  106. openfactory/floor/ladder.py +655 -0
  107. openfactory/floor/reading.py +302 -0
  108. openfactory/identity/__init__.py +14 -0
  109. openfactory/identity/base.py +95 -0
  110. openfactory/identity/local.py +186 -0
  111. openfactory/identity/oidc.py +633 -0
  112. openfactory/identity/people.py +352 -0
  113. openfactory/identity/registry.py +91 -0
  114. openfactory/knowledge/__init__.py +76 -0
  115. openfactory/knowledge/bundle.py +219 -0
  116. openfactory/knowledge/check.py +167 -0
  117. openfactory/knowledge/contracts.py +407 -0
  118. openfactory/knowledge/experiment.py +118 -0
  119. openfactory/knowledge/gaps.py +118 -0
  120. openfactory/knowledge/gate.py +330 -0
  121. openfactory/knowledge/gather.py +156 -0
  122. openfactory/knowledge/generator.py +822 -0
  123. openfactory/knowledge/inventory.py +557 -0
  124. openfactory/knowledge/okf.py +369 -0
  125. openfactory/knowledge/pipeline.py +333 -0
  126. openfactory/knowledge/render.py +146 -0
  127. openfactory/knowledge/service.py +53 -0
  128. openfactory/knowledge/staleness.py +112 -0
  129. openfactory/language/__init__.py +15 -0
  130. openfactory/language/assent.py +130 -0
  131. openfactory/loader.py +156 -0
  132. openfactory/memory/__init__.py +29 -0
  133. openfactory/memory/ledger.py +215 -0
  134. openfactory/memory/messages.py +406 -0
  135. openfactory/memory/recall.py +303 -0
  136. openfactory/memory/store.py +157 -0
  137. openfactory/memory/transcript.py +267 -0
  138. openfactory/namespace.py +258 -0
  139. openfactory/observability/__init__.py +23 -0
  140. openfactory/observability/events.py +188 -0
  141. openfactory/observability/metrics.py +191 -0
  142. openfactory/observability/query.py +80 -0
  143. openfactory/observability/registry.py +253 -0
  144. openfactory/observability/sqlite_metrics.py +203 -0
  145. openfactory/observability/trajectory.py +243 -0
  146. openfactory/onboarding/__init__.py +64 -0
  147. openfactory/onboarding/concepts.py +450 -0
  148. openfactory/onboarding/context.py +2399 -0
  149. openfactory/onboarding/cover.py +97 -0
  150. openfactory/onboarding/deployment.py +807 -0
  151. openfactory/onboarding/firstrun.py +1637 -0
  152. openfactory/onboarding/history.py +363 -0
  153. openfactory/onboarding/infer.py +2736 -0
  154. openfactory/onboarding/live_ci.py +162 -0
  155. openfactory/onboarding/onboard.py +1070 -0
  156. openfactory/onboarding/propose_manifest.py +349 -0
  157. openfactory/onboarding/questions.py +137 -0
  158. openfactory/onboarding/readiness.py +1073 -0
  159. openfactory/onboarding/renew.py +239 -0
  160. openfactory/onboarding/spend.py +82 -0
  161. openfactory/ops/__init__.py +6 -0
  162. openfactory/ops/impediment.py +288 -0
  163. openfactory/orchestrator/__init__.py +26 -0
  164. openfactory/orchestrator/context.py +231 -0
  165. openfactory/orchestrator/errors.py +27 -0
  166. openfactory/orchestrator/machine.py +2617 -0
  167. openfactory/orchestrator/merge_policy.py +203 -0
  168. openfactory/orchestrator/promotion.py +249 -0
  169. openfactory/orchestrator/risk.py +191 -0
  170. openfactory/orchestrator/validation.py +172 -0
  171. openfactory/org_defaults/engineering.md +75 -0
  172. openfactory/org_defaults/floor.yaml +182 -0
  173. openfactory/org_defaults/profiles/prototype.yaml +16 -0
  174. openfactory/org_defaults/profiles/regulated.yaml +35 -0
  175. openfactory/org_defaults/requirements/0000-template.md +39 -0
  176. openfactory/org_defaults/roles/coordinator.md +48 -0
  177. openfactory/org_defaults/roles/executor.md +92 -0
  178. openfactory/org_defaults/roles/planner.md +117 -0
  179. openfactory/org_defaults/roles/product.md +71 -0
  180. openfactory/org_defaults/roles/recovery.md +33 -0
  181. openfactory/org_defaults/roles/sizer.md +62 -0
  182. openfactory/org_defaults/roles/techlead.md +61 -0
  183. openfactory/org_defaults/tdd.md +10 -0
  184. openfactory/own_work.py +41 -0
  185. openfactory/paths.py +117 -0
  186. openfactory/plugins.py +208 -0
  187. openfactory/policy/__init__.py +10 -0
  188. openfactory/policy/authz.py +105 -0
  189. openfactory/policy/census.py +161 -0
  190. openfactory/policy/conformance.py +300 -0
  191. openfactory/policy/floor.py +42 -0
  192. openfactory/policy/presets.py +172 -0
  193. openfactory/policy/profiles.py +275 -0
  194. openfactory/policy/protected.py +169 -0
  195. openfactory/policy/test_work.py +70 -0
  196. openfactory/preflight.py +577 -0
  197. openfactory/presets/node.yaml +6 -0
  198. openfactory/presets/python.yaml +9 -0
  199. openfactory/presets/security-oss.yaml +26 -0
  200. openfactory/presets/terraform.yaml +8 -0
  201. openfactory/product/__init__.py +56 -0
  202. openfactory/product/asked.py +172 -0
  203. openfactory/product/authoring.py +1791 -0
  204. openfactory/product/board.py +484 -0
  205. openfactory/product/brownfield.py +210 -0
  206. openfactory/product/case.py +409 -0
  207. openfactory/product/channel.py +1437 -0
  208. openfactory/product/config.py +364 -0
  209. openfactory/product/confirm.py +801 -0
  210. openfactory/product/conversation.py +49 -0
  211. openfactory/product/corpus.py +462 -0
  212. openfactory/product/domain.py +242 -0
  213. openfactory/product/facts.py +209 -0
  214. openfactory/product/followup.py +647 -0
  215. openfactory/product/intents.py +641 -0
  216. openfactory/product/loader.py +198 -0
  217. openfactory/product/module.py +3100 -0
  218. openfactory/product/needs_action.py +198 -0
  219. openfactory/product/onboard.py +331 -0
  220. openfactory/product/queue.py +273 -0
  221. openfactory/product/reading.py +130 -0
  222. openfactory/product/release.py +153 -0
  223. openfactory/product/requester.py +55 -0
  224. openfactory/product/role.py +1356 -0
  225. openfactory/product/staging.py +477 -0
  226. openfactory/product/triage.py +277 -0
  227. openfactory/product/voice.py +2080 -0
  228. openfactory/product/workspace.py +182 -0
  229. openfactory/registry.py +526 -0
  230. openfactory/review/__init__.py +11 -0
  231. openfactory/review/verdict.py +199 -0
  232. openfactory/runtime/__init__.py +0 -0
  233. openfactory/runtime/boxed_job.py +399 -0
  234. openfactory/runtime/card_repo.py +146 -0
  235. openfactory/runtime/host.py +91 -0
  236. openfactory/runtime/repo_cache.py +296 -0
  237. openfactory/runtime/temporal/__init__.py +26 -0
  238. openfactory/runtime/temporal/activities.py +5081 -0
  239. openfactory/runtime/temporal/connection.py +89 -0
  240. openfactory/runtime/temporal/io.py +639 -0
  241. openfactory/runtime/temporal/poller.py +185 -0
  242. openfactory/runtime/temporal/schedule.py +465 -0
  243. openfactory/runtime/temporal/starter.py +54 -0
  244. openfactory/runtime/temporal/view.py +884 -0
  245. openfactory/runtime/temporal/worker.py +325 -0
  246. openfactory/runtime/temporal/workflow.py +2459 -0
  247. openfactory/runtime/toolbox.py +163 -0
  248. openfactory/scheduler.py +75 -0
  249. openfactory/semver.py +35 -0
  250. openfactory/techlead/__init__.py +38 -0
  251. openfactory/techlead/classify.py +427 -0
  252. openfactory/techlead/conversation.py +1401 -0
  253. openfactory/techlead/diagnosis.py +251 -0
  254. openfactory/techlead/memory.py +264 -0
  255. openfactory/techlead/pack.py +169 -0
  256. openfactory/techlead/voice.py +679 -0
  257. openfactory/techlead/watch.py +638 -0
  258. openfactory/testing/__init__.py +1 -0
  259. openfactory/testing/local_flow.py +272 -0
  260. openfactory/util/__init__.py +0 -0
  261. openfactory/util/bounded.py +77 -0
  262. openfactory/util/causes.py +125 -0
  263. openfactory/util/scratch.py +80 -0
  264. openfactory-0.2.0.dist-info/METADATA +41 -0
  265. openfactory-0.2.0.dist-info/RECORD +270 -0
  266. openfactory-0.2.0.dist-info/WHEEL +5 -0
  267. openfactory-0.2.0.dist-info/entry_points.txt +2 -0
  268. openfactory-0.2.0.dist-info/licenses/LICENSE +202 -0
  269. openfactory-0.2.0.dist-info/licenses/NOTICE +22 -0
  270. openfactory-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,334 @@
1
+ """What an action IS — the vocabulary the front ends and the catalog share (C-23).
2
+
3
+ Three types and nothing else, because every one of them exists to stop a specific thing from
4
+ leaking across the seam:
5
+
6
+ Actor who asked, so an action never has to guess and a front end never has to decide
7
+ Outcome values out — no HTTP status, no mrkdwn, no emoji, no exception
8
+ ActionSpec a row in a table, so "which actions exist" is data a test can walk
9
+
10
+ **Outcome, not exceptions.** Both front ends already had a hard rule that they must always reply —
11
+ the Slack bot's `act_job` docstring says "NEVER raises (the bot must always reply)" and the panel
12
+ turns everything into an `HTTPException` by hand. Two front ends, two hand-written translations of
13
+ the same failures, and they disagreed: a job that is not parked is a 409 in the panel and the
14
+ sentence "não está parado esperando ação" in Slack, with no shared notion that those are the same
15
+ answer. `code` is that shared notion; rendering it stays each front end's job, which is the only
16
+ part that is genuinely theirs.
17
+
18
+ **`ok` is about the platform, not about the answer.** `scan` finding nothing in TO-DO is `ok=True`
19
+ with a message saying so — nothing went wrong, the queue is empty. `ok=False` means the platform
20
+ could not do the thing it was asked to do. Getting this backwards would make every empty queue
21
+ look like an outage in whatever dashboard reads these.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from collections.abc import Awaitable, Callable, Mapping
27
+ from dataclasses import dataclass, field
28
+ from types import MappingProxyType
29
+
30
+ # ── the codes a front end maps ───────────────────────────────────────────────────────────────────
31
+ #
32
+ # A CLOSED SET, and `openfactory/api/app.py` holds a status for every member — asserted by a test,
33
+ # because
34
+ # a code with no mapping is a 500 wearing an action's name. Adding one here without adding it there
35
+ # fails the suite rather than production.
36
+
37
+ OK = ""
38
+ #: the caller asked for something malformed — a missing parameter, a ref no tracker could issue
39
+ INVALID = "invalid"
40
+ #: the caller is not allowed to do this
41
+ DENIED = "denied"
42
+ #: the project, ticket or job named does not exist
43
+ NOT_FOUND = "not_found"
44
+ #: the world is not in a state where this makes sense — a job not parked, one already running
45
+ CONFLICT = "conflict"
46
+ #: a dependency the platform needs is not answering — the durable engine, the forge
47
+ UNAVAILABLE = "unavailable"
48
+ #: it was attempted and it did not work
49
+ FAILED = "failed"
50
+ #: the action is catalogued but its implementation has not been moved into the layer yet
51
+ UNIMPLEMENTED = "unimplemented"
52
+
53
+ CODES = frozenset({OK, INVALID, DENIED, NOT_FOUND, CONFLICT, UNAVAILABLE, FAILED, UNIMPLEMENTED})
54
+
55
+
56
+ # ── where an action lives, and where a credential may go ─────────────────────────────────────────
57
+ #
58
+ # TWO AREAS TODAY, and the split is the one the product owner drew: *"if the person wants to write
59
+ # the tickets and drop them in TO-DO, fine — but the product role is available ON THE PLATFORM"*.
60
+ # A business analyst writing requirements is not an operator of the floor, and the panel's posture
61
+ # until now was that holding its credential made you both.
62
+
63
+ #: Jobs, the board, merges, the engine — the operator's console. The default for every row.
64
+ FLOOR = "floor"
65
+ #: The product role: requirements, their drafting, sign-off and withdrawal.
66
+ PRODUCT = "product"
67
+
68
+ #: Every area a row may declare. A registry rather than a free string, for the reason every other
69
+ #: registry in this codebase exists: a typo'd scope would otherwise be an area nobody can reach and
70
+ #: nothing would say so.
71
+ SCOPES = frozenset({FLOOR, PRODUCT})
72
+
73
+
74
+ # ── who asked ────────────────────────────────────────────────────────────────────────────────────
75
+
76
+ @dataclass(frozen=True)
77
+ class Actor:
78
+ """The human (or bot) on whose behalf an action runs.
79
+
80
+ EVERY ACTION TAKES ONE, including the read-only ones. Not because they all check it today —
81
+ most do not — but because the parameter is the seam C-26 fills, and a parameter added later to
82
+ forty call sites is a migration, while a parameter carried from the start is a rename. It is
83
+ also already useful: `perform` writes one audit line per action naming this, which is the first
84
+ time this platform can answer *who approved that production release*.
85
+
86
+ `admin` IS DECIDED BY THE TRANSPORT TODAY, and that is a stated limitation rather than a
87
+ design. Slack knows its answer (`project.admins`), the panel's answer is "did they hold the
88
+ panel token", and the CLI's is "they have a shell on the host, which outranks every action
89
+ here". C-26 (#55) moves the decision behind this field into a policy the Core owns; until then
90
+ the field is where the three front ends' three different answers meet, which is already one
91
+ place fewer than before.
92
+ """
93
+
94
+ #: Stable id in whatever namespace the transport owns: a Slack user id (`U04…`), a panel
95
+ #: principal, `cli`. Not required to be globally unique across transports — `via` disambiguates.
96
+ id: str
97
+ #: What to write in a note a human reads. Falls back to `id` when the transport has no name.
98
+ display: str = ""
99
+ #: Which front end carried the request: `panel`, `slack`, `cli`, `test`.
100
+ via: str = ""
101
+ #: Whether this actor may run an action marked `needs_admin`. See the caveat above.
102
+ admin: bool = False
103
+ #: WHICH AREAS OF THE PLATFORM this credential may act in at all, or `None` for one that is
104
+ #: not scoped. `None` and `frozenset()` are different answers and the distinction is the whole
105
+ #: point: `None` means nobody restricted this caller (the CLI, a Slack admin, the panel's own
106
+ #: token — every actor that existed before scopes did, which is why the default cannot be
107
+ #: `frozenset()` without denying all of them); `frozenset()` means somebody scoped it to
108
+ #: nothing. The same `None` vs `[]` rule this codebase pays for everywhere else.
109
+ #:
110
+ #: WHY THIS IS NOT `admin`. A business analyst who writes requirements needs `needs_admin` to
111
+ #: be TRUE for `product_accept` — accepting is the most consequential act on that surface — and
112
+ #: needs `merge` to be refused outright. Those are two different questions: *how much authority
113
+ #: within an area*, and *which areas at all*. Folding them into one flag is how handing a BA a
114
+ #: credential to write a requirement also handed them the button that lands a pull request.
115
+ scopes: frozenset[str] | None = None
116
+ #: THE CONVERSATION THIS ACTOR IS IN, when the transport keys one per person (#33). On Slack
117
+ #: a thread comes free and the rows take it as `thread`; on the web nothing did, so every
118
+ #: person who typed into the panel's box wrote into ONE conversation keyed by the project's
119
+ #: name, and the product role read A and B as one person. The panel fills this from the
120
+ #: subject it resolved — `person:<id>` for somebody known, `visitor:<cookie>` for a browser
121
+ #: nobody has identified yet — and a product row uses it when the caller passed no thread.
122
+ #: Empty means the transport keys nothing, which is every actor that predates this.
123
+ conversation: str = ""
124
+
125
+ def may_enter(self, scope: str) -> bool:
126
+ """Whether this actor may act in `scope`. Unscoped actors may enter anywhere."""
127
+ return self.scopes is None or scope in self.scopes
128
+
129
+ def __str__(self) -> str:
130
+ who = self.display or self.id or "anonymous"
131
+ return f"{who} (via {self.via})" if self.via else who
132
+
133
+
134
+ #: For the paths that genuinely have no subject yet — a scheduled poll, an internal call. Kept as a
135
+ #: named constant rather than `Actor("")` at each site so a grep for it finds every place the
136
+ #: platform acts on nobody's behalf, which is exactly the list C-26 will want.
137
+ SYSTEM = Actor(id="system", display="the factory", via="internal", admin=True)
138
+
139
+
140
+ # ── what comes back ──────────────────────────────────────────────────────────────────────────────
141
+
142
+ @dataclass(frozen=True)
143
+ class Outcome:
144
+ """Values out. No status code, no markup, no vendor.
145
+
146
+ `message` is ONE human sentence in plain text. It may be shown to a client, so it says what
147
+ happened and — when something is wrong — what to do about it, in the register the rest of this
148
+ platform uses. It must not contain mrkdwn, HTML, or a provider's link syntax: the front end
149
+ that knows the surface decorates it, and a message pre-decorated for Slack renders as literal
150
+ asterisks in the panel (which is how the first version of the impediment alert shipped).
151
+ """
152
+
153
+ ok: bool
154
+ message: str
155
+ #: Values for a machine — what a panel renders as a table, what a script greps. Free-form per
156
+ #: action and documented on its spec.
157
+ data: Mapping[str, object] = field(default_factory=dict)
158
+ #: One of CODES. Empty when `ok`.
159
+ code: str = OK
160
+
161
+
162
+ def done(message: str, **data: object) -> Outcome:
163
+ """It worked. `data` carries whatever the caller may want to render."""
164
+ return Outcome(ok=True, message=message, data=data, code=OK)
165
+
166
+
167
+ def refused(code: str, message: str, **data: object) -> Outcome:
168
+ """It did not work, and this is why — in a word a front end can map and a sentence a human can
169
+ read. Both are required: the word alone gives an operator nothing, and the sentence alone makes
170
+ every front end parse prose to decide a status."""
171
+ if code not in CODES or code == OK:
172
+ raise ValueError(f"unknown outcome code {code!r} — one of {sorted(CODES - {OK})}")
173
+ return Outcome(ok=False, message=message, data=data, code=code)
174
+
175
+
176
+ # ── the table ────────────────────────────────────────────────────────────────────────────────────
177
+
178
+ #: The signature every action has. Keyword-only, always async, always returns an Outcome.
179
+ Runner = Callable[..., Awaitable[Outcome]]
180
+
181
+
182
+ #: WHAT TO PUT IN EACH PARAMETER, keyed by its name — the vocabulary of the action layer (#172).
183
+ #:
184
+ #: SHARED RATHER THAN PER ROW, because `project` is required by 38 of the 40 rows and `issue` by
185
+ #: 12. Forty copies of "the project this concerns" is thirty-nine chances to drift, and the one
186
+ #: that drifts is the one somebody reads. A row that means something DIFFERENT by a shared word
187
+ #: says so on itself, in `ActionSpec.params`, and that override is the signal to a reader that this
188
+ #: row is unusual.
189
+ #:
190
+ #: THE AUDIENCE IS SOMEBODY WHO MUST FILL THE VALUE IN, which is why these say what goes in rather
191
+ #: than what the word denotes. Since #170 that audience includes a model composing a proposal for a
192
+ #: button a human presses: `adjust` required an `instruction` and nothing anywhere said what an
193
+ #: instruction should contain, so the only guidance was the English of the word.
194
+ PARAMS: dict[str, str] = {
195
+ # the two nearly every row takes
196
+ "project": "the project's name in this deployment's registry, e.g. `podbeam`",
197
+ "issue": "the ticket's number on the client's board, digits only, e.g. `87`",
198
+ # confirmation and shape
199
+ "yes": "`true` to actually do it — without it the action reports what it WOULD do",
200
+ "number": "the ticket's number on the client's board, digits only",
201
+ "numbers": "the ticket numbers, comma-separated, e.g. `87,88,91`",
202
+ "limit": "how many rows to return at most; the default is this deployment's",
203
+ "force": "`true` to proceed past a check that would otherwise stop it — say why in `reason`",
204
+ # words a human will read afterwards
205
+ "reason": "one sentence a person will read later, saying WHY — it is stored and shown",
206
+ "comment": "a sentence posted on the pull request as this platform's own voice",
207
+ "instruction": "WHAT TO CHANGE, in one or two concrete sentences the coding pass will work "
208
+ "from: name the file or behaviour and the change wanted, not the symptom. It "
209
+ "is shown on the button a person presses, so it must be readable by them too",
210
+ "message": "what you want to say, in your own words",
211
+ "question": "the question, in one sentence",
212
+ "query": "what to look for — a few words, a card number, a name",
213
+ "answer": "the answer, in your own words",
214
+ "answers": "answers to the questions asked, one per line as `field: value`",
215
+ "choice": "which of the options the parked job offered — its exact label",
216
+ "enabled": "`true` to let this project pick work up, `false` to hold it",
217
+ # the release gate
218
+ "version": "the version being released, exactly as it is tagged",
219
+ "approver": "who is approving, by name — it is written into the release record",
220
+ "password": "the release password this deployment was configured with",
221
+ "promote": "`true` to carry the change on through the promotion chain after it lands",
222
+ "sandbox": "`true` to run the job without letting it write anywhere real",
223
+ "durable": "`true` to run on the durable engine rather than in this process",
224
+ # the product role
225
+ "token": "the token identifying the proposal being answered — copy it from the proposal",
226
+ "thread": "the conversation this turn belongs to, if continuing one",
227
+ "in_favour_of": "the number of the card that STAYS — the one this duplicates",
228
+ "requirement": "the requirement's id in its register, e.g. `REQ-014`",
229
+ "decision": "the decision, stated as what will now be true",
230
+ "term": "the word or phrase being defined, as the business says it",
231
+ "body": "what it means, in the business's own words",
232
+ "restated": "the broken promise, restated as what should happen and what happens instead",
233
+ "title": "what the card is called, in the person's own words — short",
234
+ "column": "the column's name as it is written on the board, e.g. `TO-DO`",
235
+ "violates": "the id of the requirement this breaks, if one is known",
236
+ "severity": "how bad it is: `low`, `medium` or `high`",
237
+ # reading a repository
238
+ "target": "the repository to read, as a URL or an `owner/name`",
239
+ "ask": "a specific question to answer about the repository, if you have one",
240
+ "write": "`true` to write the result into the repository rather than only report it",
241
+ "accept": "which proposed fields to keep, comma-separated — the rest are dropped",
242
+ "out": "where to write the file, if not the default place",
243
+ "pr": "`true` to open a pull request with the change instead of committing directly",
244
+ }
245
+
246
+
247
+ @dataclass(frozen=True)
248
+ class ActionSpec:
249
+ """One row of the catalog: a name, what it needs, and the one implementation.
250
+
251
+ `required`/`optional` are declared rather than read off the function signature by
252
+ introspection. Introspection would be shorter and would also mean `perform` cannot tell a
253
+ parameter it forgot to pass from one the action does not want — and the whole reason the
254
+ parameters are checked centrally is that the two front ends validated different things.
255
+ """
256
+
257
+ name: str
258
+ #: One line, present tense, for `openfactory act --list` and `GET /api/actions`. This is the
259
+ #: text an operator reads when deciding what to run, so it says what happens, not what it is
260
+ #: called.
261
+ summary: str
262
+ run: Runner
263
+ required: tuple[str, ...] = ()
264
+ optional: tuple[str, ...] = ()
265
+ #: Whether `Actor.admin` is required. Default True: an action exists to CHANGE something, and
266
+ #: the read-only ones say so explicitly. A default of False would mean a new action is
267
+ #: unguarded unless somebody remembers, which is the wrong way round for this class of mistake.
268
+ needs_admin: bool = True
269
+ #: WHICH AREA of the platform this row belongs to — see `Actor.scopes`. Defaults to the floor
270
+ #: (jobs, the board, merges, the engine), so a row added without thinking about scope lands in
271
+ #: the area a scoped credential CANNOT reach. Same direction as `needs_admin`'s default: the
272
+ #: forgetful case is the closed one.
273
+ scope: str = FLOOR
274
+ #: WHEN A PROPOSER SHOULD PICK THIS ROW over its neighbours — the judgment, not the definition
275
+ #: (#172). `summary` says what the row does; this says when it is the right answer, which is a
276
+ #: different question and the one that actually decides. It lived in a private map inside
277
+ #: `techlead/conversation.py`, where it could disagree with the row it described — and did:
278
+ #: `adjust` had no entry at all, so the verb that most needed the guidance had the least.
279
+ #: Empty is honest for the rows nobody may propose; `proposable` is a much smaller set than
280
+ #: the catalogue and only it is offered as a choice.
281
+ choose_when: str = ""
282
+ #: WHAT THIS ROW means by a parameter, when the shared `PARAMS` word is wrong for it. An
283
+ #: override rather than a copy: a row that agrees says nothing, so the diff of this field is a
284
+ #: list of the rows that are unusual.
285
+ params: Mapping[str, str] = MappingProxyType({})
286
+
287
+ def prose_for(self, param: str) -> str:
288
+ """What to put in `param`, or `""` when nobody has said (#172).
289
+
290
+ THREE ANSWERS COLLAPSED TO TWO ON PURPOSE, unlike the reads elsewhere in the platform: a
291
+ parameter nobody wrote prose for and a parameter that needs no explanation are the same
292
+ thing to the person filling it in. What must NOT happen is inventing a sentence from the
293
+ name, which is what every front end would do on its own."""
294
+ return self.params.get(param) or PARAMS.get(param, "")
295
+
296
+ @property
297
+ def described(self) -> dict[str, str]:
298
+ """Every parameter this row takes, each with what to put in it — the shape a front end
299
+ renders and `/api/actions` serves. Includes the ones with no prose, as empty strings: a
300
+ parameter missing from this mapping would read as one the action does not take."""
301
+ return {p: self.prose_for(p) for p in self.parameters}
302
+
303
+ @property
304
+ def pending(self) -> str:
305
+ """Where the implementation still lives, or `""` when it has been moved into the layer.
306
+
307
+ Not a boolean: an operator who hits `unimplemented` needs to know which old route still
308
+ works, and a test needs to see the migration shrink."""
309
+ return getattr(self.run, "moved_from", "")
310
+
311
+ @property
312
+ def parameters(self) -> tuple[str, ...]:
313
+ return self.required + self.optional
314
+
315
+
316
+ def not_moved_yet(name: str, *, still_in: str) -> Runner:
317
+ """A placeholder implementation that REFUSES, naming where the working path is.
318
+
319
+ WHY A REFUSING RUNNER RATHER THAN AN ABSENT ROW. The catalog is what the guards walk, what the
320
+ panel lists and what `openfactory act --list` prints. An action left out of it until somebody
321
+ moves the code is invisible: no test can assert it is missing, and the migration has no
322
+ measurable end. A row that refuses is visible in all three places and its refusal is a sentence
323
+ somebody can act on, which is the same standard every other wait in this platform is held to.
324
+ """
325
+
326
+ async def run(**_params: object) -> Outcome:
327
+ return refused(
328
+ UNIMPLEMENTED,
329
+ f"'{name}' is catalogued but its implementation has not moved into the action layer "
330
+ f"yet — it still lives in {still_in}, which is where it works today (C-23).",
331
+ )
332
+
333
+ run.moved_from = still_in # type: ignore[attr-defined]
334
+ return run