ergane-cli 0.1.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 (121) hide show
  1. ergane_cli-0.1.0.dist-info/METADATA +148 -0
  2. ergane_cli-0.1.0.dist-info/RECORD +121 -0
  3. ergane_cli-0.1.0.dist-info/WHEEL +4 -0
  4. ergane_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. ergane_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
  6. ergane_cli-0.1.0.dist-info/licenses/NOTICE +27 -0
  7. factory/__init__.py +0 -0
  8. factory/activities/__init__.py +0 -0
  9. factory/activities/agent_activities.py +745 -0
  10. factory/activities/merge_activities.py +654 -0
  11. factory/activities/notify_activities.py +1078 -0
  12. factory/activities/roadmap_activities.py +577 -0
  13. factory/activities/usage_activities.py +593 -0
  14. factory/activities/verify_activities.py +617 -0
  15. factory/cli/__init__.py +0 -0
  16. factory/cli/completion.py +150 -0
  17. factory/cli/doctor.py +484 -0
  18. factory/cli/env.py +247 -0
  19. factory/cli/errors.py +77 -0
  20. factory/cli/init.py +1057 -0
  21. factory/cli/install.py +679 -0
  22. factory/cli/main.py +212 -0
  23. factory/cli/nouns/__init__.py +59 -0
  24. factory/cli/nouns/answer.py +134 -0
  25. factory/cli/nouns/build.py +1222 -0
  26. factory/cli/nouns/completion.py +13 -0
  27. factory/cli/nouns/doctor.py +13 -0
  28. factory/cli/nouns/env.py +13 -0
  29. factory/cli/nouns/escalations.py +93 -0
  30. factory/cli/nouns/findings.py +13 -0
  31. factory/cli/nouns/init.py +13 -0
  32. factory/cli/nouns/install.py +99 -0
  33. factory/cli/nouns/repo.py +13 -0
  34. factory/cli/nouns/roadmap.py +13 -0
  35. factory/cli/nouns/spec.py +511 -0
  36. factory/cli/nouns/status.py +18 -0
  37. factory/cli/nouns/usage.py +13 -0
  38. factory/cli/nouns/worker.py +69 -0
  39. factory/cli/repo.py +569 -0
  40. factory/cli/repo_export.py +282 -0
  41. factory/cli/roadmap.py +434 -0
  42. factory/cli/status.py +844 -0
  43. factory/cli/usage.py +129 -0
  44. factory/config.py +262 -0
  45. factory/controlplane/__init__.py +0 -0
  46. factory/controlplane/config.py +863 -0
  47. factory/controlplane/resolve.py +434 -0
  48. factory/controlplane/verify.py +827 -0
  49. factory/discovery/__init__.py +3 -0
  50. factory/discovery/llm_scanner.py +176 -0
  51. factory/doctor/__init__.py +0 -0
  52. factory/doctor/cli.py +392 -0
  53. factory/doctor/models.py +165 -0
  54. factory/doctor/probes.py +522 -0
  55. factory/doctor/scaffold.py +184 -0
  56. factory/doctor/store.py +449 -0
  57. factory/env.py +131 -0
  58. factory/escalation/__init__.py +0 -0
  59. factory/escalation/client.py +168 -0
  60. factory/escalation/question.py +161 -0
  61. factory/escalation/workflow.py +470 -0
  62. factory/locking.py +85 -0
  63. factory/mergequeue/__init__.py +21 -0
  64. factory/mergequeue/classify.py +133 -0
  65. factory/mergequeue/forge.py +401 -0
  66. factory/mergequeue/gh.py +484 -0
  67. factory/mergequeue/github_forge.py +349 -0
  68. factory/mergequeue/merge_queue_ruleset.json +32 -0
  69. factory/mergequeue/messages.py +141 -0
  70. factory/mergequeue/models.py +324 -0
  71. factory/mergequeue/onboard.py +537 -0
  72. factory/mergequeue/wiring.py +466 -0
  73. factory/notify/__init__.py +0 -0
  74. factory/notify/adapter.py +263 -0
  75. factory/notify/messages.py +474 -0
  76. factory/notify/service.py +809 -0
  77. factory/notify/webhook.py +151 -0
  78. factory/personas.yaml +161 -0
  79. factory/registry.py +583 -0
  80. factory/roadmap/__init__.py +8 -0
  81. factory/roadmap/cli.py +125 -0
  82. factory/roadmap/discovery.py +286 -0
  83. factory/roadmap/models.py +583 -0
  84. factory/roadmap/schedule.py +377 -0
  85. factory/roadmap/workflow.py +1364 -0
  86. factory/supervision/__init__.py +0 -0
  87. factory/supervision/alert.py +239 -0
  88. factory/supervision/probe.py +467 -0
  89. factory/supervision/temporal_server.py +121 -0
  90. factory/supervision/units.py +649 -0
  91. factory/usage/__init__.py +0 -0
  92. factory/usage/aggregate.py +113 -0
  93. factory/usage/cli.py +223 -0
  94. factory/usage/ledger.py +295 -0
  95. factory/usage/litellm_client.py +486 -0
  96. factory/usage/models.py +149 -0
  97. factory/verify/__init__.py +0 -0
  98. factory/verify/criteria.py +488 -0
  99. factory/verify/diffbounds.py +159 -0
  100. factory/verify/diffcheck.py +506 -0
  101. factory/verify/factory_yaml.py +842 -0
  102. factory/verify/gates.py +1341 -0
  103. factory/verify/judge.py +775 -0
  104. factory/verify/ladder.py +145 -0
  105. factory/verify/models.py +745 -0
  106. factory/verify/question.py +167 -0
  107. factory/verify/store.py +1366 -0
  108. factory/verify/toolchain.py +370 -0
  109. factory/worker.py +296 -0
  110. factory/workgraph/__init__.py +0 -0
  111. factory/workgraph/adapter.py +1333 -0
  112. factory/workgraph/cli.py +801 -0
  113. factory/workgraph/delta.py +278 -0
  114. factory/workgraph/derive.py +607 -0
  115. factory/workgraph/detector.py +509 -0
  116. factory/workgraph/landed.py +496 -0
  117. factory/workgraph/models.py +494 -0
  118. factory/workgraph/preflight.py +651 -0
  119. factory/workgraph/prompt.py +796 -0
  120. factory/workgraph/workflow.py +2733 -0
  121. factory/workgraph/worktree.py +1322 -0
File without changes
@@ -0,0 +1,239 @@
1
+ """The one alert that cannot be a workflow. 042-US1, FR-001 and FR-002.
2
+
3
+ 041 made escalation a Temporal workflow, which is right for every escalation
4
+ except one: the alert that says Temporal is down cannot be hosted by the thing
5
+ it reports dead. So the supervision probe calls the messenger seam **directly**
6
+ — a plain library call, out of band, with a local log as its only record. This
7
+ module is the caller 041 FR-002 exists for, and it runs when Temporal is gone.
8
+
9
+ Four properties carry the weight, and each is asserted rather than intended by
10
+ `tests/test_supervision_alert.py`:
11
+
12
+ - **No Temporal, at all.** Not a client, not an import, not a lazy one inside a
13
+ function body. The test that proves it runs in a subprocess whose import
14
+ system refuses `temporalio` outright, because a test that merely happens not
15
+ to reach Temporal passes identically either way.
16
+ - **Fire and forget.** Nothing is started, nothing requiring an answer is
17
+ written, and no reply is expected: the rendered message carries no actions,
18
+ so there is no button to press, and the outcome carries no handle to wait on.
19
+ *Silence from the operator is not a state this path tracks.* 041's
20
+ record-and-await semantics are the wrong shape here, and reusing them would
21
+ make this a second escalation system rather than the one visible exception.
22
+ - **Nothing raises.** A transport that explodes, one that reports it did not
23
+ send, and a name nothing is registered under are all *data*: a supervisor
24
+ that crashes on a failed send has stopped supervising, and the host it was
25
+ watching is usually the reason the send failed.
26
+ - **Loud when it cannot escalate.** An undelivered alert goes to the log and to
27
+ stderr, and `AlertOutcome.exit_code` is non-zero, because a probe that cannot
28
+ escalate is otherwise indistinguishable from a healthy floor (FR-015). That
29
+ is the exact failure this whole path exists to end.
30
+
31
+ What is deliberately *not* here: measuring anything, deciding whether to send,
32
+ and remembering what was sent last time. This module renders one alert and
33
+ hands it over. Liveness, memory headroom, orphan counts, the edge-trigger table
34
+ and the heartbeat interval are the probe's (US2), which keeps this module
35
+ callable from a process that has no state directory and no configuration.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import asyncio
41
+ import dataclasses
42
+ import logging
43
+ import sys
44
+
45
+ from factory.notify.adapter import (
46
+ RenderedMessage,
47
+ UnknownAdapterError,
48
+ configured_adapter_name,
49
+ resolve_adapter,
50
+ )
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+ #: What this path hands the transport in place of a correlation id.
55
+ #:
56
+ #: A constant, and a name rather than an identifier: there is nothing to
57
+ #: correlate. A minted id would imply a row somewhere that a reply could be
58
+ #: matched against, and this path writes none — the transport logs it, and no
59
+ #: press can carry it back because the message has no buttons.
60
+ SUPERVISION_CORRELATION_ID = "supervision"
61
+
62
+ #: The prefix every out-of-band alert reads under, so an operator can tell a
63
+ #: page from the supervisor apart from a page from an epic at a glance.
64
+ ALERT_PREFIX = "ergane supervision"
65
+
66
+
67
+ @dataclasses.dataclass(frozen=True)
68
+ class StackAlert:
69
+ """One thing the supervisor observed, in the three terms FR-002 requires.
70
+
71
+ `duration_s` arrives as data rather than being measured here: how long a
72
+ condition has held is the probe's reading (it owns the state files US2
73
+ adds), and a clock in this module would be a second answer to a question
74
+ that already has one.
75
+ """
76
+
77
+ service: str
78
+ condition: str
79
+ duration_s: float
80
+
81
+
82
+ @dataclasses.dataclass(frozen=True)
83
+ class AlertOutcome:
84
+ """What was said, and whether anybody got it.
85
+
86
+ Three fields and no fourth. A message handle or an escalation id here would
87
+ be the first half of a reply this path then has to wait for, which is the
88
+ property US1-S3 forbids.
89
+ """
90
+
91
+ text: str
92
+ delivered: bool
93
+ failure: str | None = None
94
+
95
+ @property
96
+ def exit_code(self) -> int:
97
+ """`0` iff the operator was paged — FR-015, in the shape a probe exits.
98
+
99
+ The probe's unit declares `SuccessExitStatus=0 1` in the prior art
100
+ precisely so a degraded verdict stays a report; what must never be
101
+ silent is an alert nobody received.
102
+ """
103
+ return 0 if self.delivered else 1
104
+
105
+
106
+ def format_duration(seconds: float) -> str:
107
+ """How long, in the units an operator reads on a phone at 3am.
108
+
109
+ Two terms at most, largest first: `45s`, `1m 35s`, `2h 3m`. A negative
110
+ reading — a probe subtracting a status file's mtime across a clock
111
+ correction — reads as `0s` rather than backwards, because an alert about
112
+ the supervisor's own arithmetic is not the alert that was sent.
113
+ """
114
+ total = int(max(0.0, seconds))
115
+ if total >= 3600:
116
+ return f"{total // 3600}h {(total % 3600) // 60}m"
117
+ if total >= 60:
118
+ return f"{total // 60}m {total % 60}s"
119
+ return f"{total}s"
120
+
121
+
122
+ def render_alert(alert: StackAlert) -> RenderedMessage:
123
+ """The service, the observed condition and the duration — FR-002.
124
+
125
+ A page that says only "degraded" sends the operator to a terminal to find
126
+ out what this code already knew, and the terminal is on the host that is
127
+ already failing.
128
+
129
+ No actions, deliberately: what a transport turns into buttons is what an
130
+ operator can answer, and this path expects no answer (US1-S3).
131
+ """
132
+ return RenderedMessage(
133
+ text=(
134
+ f"{ALERT_PREFIX}: {alert.service} — {alert.condition} "
135
+ f"(for {format_duration(alert.duration_s)})"
136
+ )
137
+ )
138
+
139
+
140
+ async def deliver_alert(
141
+ alert: StackAlert, *, adapter_name: str | None = None
142
+ ) -> AlertOutcome:
143
+ """Page the operator about `alert` through the configured transport.
144
+
145
+ The awaitable door, for a caller that already has a loop. `send_alert` is
146
+ the one a probe calls.
147
+
148
+ `adapter_name` names the transport; the default reads the same
149
+ configuration every other sender reads, so the supervisor and the factory
150
+ page the same place without a second setting to keep in step.
151
+ """
152
+ message = render_alert(alert)
153
+ transport = adapter_name or ""
154
+ try:
155
+ transport = transport or configured_adapter_name()
156
+ adapter = resolve_adapter(transport)
157
+ except Exception as exc:
158
+ # Broad on purpose. An unknown transport name, a builder that cannot
159
+ # construct, an unreadable config: the move is identical, and it is
160
+ # never "raise on the host that is already failing".
161
+ return _undelivered(
162
+ message.text,
163
+ f"no transport resolved for {transport or 'the configured transport'}"
164
+ f" ({type(exc).__name__}{_safe_reason(exc)})",
165
+ )
166
+
167
+ try:
168
+ receipt = await adapter.deliver(message, SUPERVISION_CORRELATION_ID)
169
+ except Exception as exc:
170
+ # The class, never the message: an unauthorized Bot API error quotes
171
+ # the bot token back at us, because the token is in the URL it failed
172
+ # on. `TelegramAdapter.deliver` logs the same way for the same reason.
173
+ return _undelivered(
174
+ message.text, f"{transport} raised {type(exc).__name__}"
175
+ )
176
+
177
+ if not receipt.delivered:
178
+ return _undelivered(
179
+ message.text, f"{transport} reported the alert was not delivered"
180
+ )
181
+
182
+ logger.info("supervision alert delivered: %s", message.text)
183
+ return AlertOutcome(text=message.text, delivered=True)
184
+
185
+
186
+ def send_alert(
187
+ alert: StackAlert, *, adapter_name: str | None = None
188
+ ) -> AlertOutcome:
189
+ """`deliver_alert` for a caller with no event loop — the probe's door.
190
+
191
+ A probe is a script run by a timer, and the messenger seam is async, so
192
+ something has to own the loop. `asyncio.run` refuses to be called from
193
+ inside a running one, and refusing is an exception; that is caught here
194
+ like every other failure, because "the supervision alert never raises" is
195
+ a claim about every caller and not only about the transport.
196
+ """
197
+ pending = deliver_alert(alert, adapter_name=adapter_name)
198
+ try:
199
+ return asyncio.run(pending)
200
+ except Exception as exc:
201
+ # A coroutine `asyncio.run` refused to start is still an unstarted
202
+ # coroutine, and leaving it unclosed warns on collection — noise in
203
+ # the output of the one process whose output is the alert of last
204
+ # resort.
205
+ pending.close()
206
+ return _undelivered(
207
+ render_alert(alert).text,
208
+ f"the alert could not be run ({type(exc).__name__}); a caller with "
209
+ "a running event loop must await deliver_alert",
210
+ )
211
+
212
+
213
+ def _undelivered(text: str, failure: str) -> AlertOutcome:
214
+ """Record an alert nobody received — twice, and never quietly.
215
+
216
+ Twice because the two readers are different: the log is what an operator
217
+ reading the journal after the fact finds, and stderr is the probe's own
218
+ output, which is the only thing a timer's failure mail carries. Trap 6 of
219
+ the plan is the prior art's line for this — *never fail silently here: a
220
+ probe that cannot escalate is indistinguishable from a healthy floor*.
221
+ """
222
+ logger.error("supervision alert NOT delivered (%s): %s", failure, text)
223
+ print(f"WARN: cannot escalate ({failure}): {text}", file=sys.stderr, flush=True)
224
+ return AlertOutcome(text=text, delivered=False, failure=failure)
225
+
226
+
227
+ def _safe_reason(exc: Exception) -> str:
228
+ """The part of a resolution failure that is safe to write down.
229
+
230
+ `UnknownAdapterError` names the transport and the registry, which is the
231
+ whole diagnosis and is not a credential — a deployment that asked for one
232
+ messenger and silently got another would page the wrong place and look
233
+ configured, so that name is the one thing worth quoting. Every other
234
+ exception may be quoting whatever it was handed, and only its class
235
+ survives.
236
+ """
237
+ if isinstance(exc, UnknownAdapterError):
238
+ return f": {exc}"
239
+ return ""