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
@@ -0,0 +1,577 @@
1
+ """The roadmap's pre-dispatch surface: clone, derive, preflight, onboarding, capacity.
2
+
3
+ US2's `RoadmapWorkflow` runs four checks before starting each dispatchable
4
+ spec's child `EpicWorkflow`, and one read to know how many epics are already
5
+ in flight. Each is an activity because workflow code touches nothing
6
+ (constitution IV): the clone is a git operation, derivation reads a file, the
7
+ preflight dials the proxy, onboarding reads the target repo, and the capacity
8
+ read lists open workflows through the Temporal client. None of it belongs in
9
+ workflow code, and none of it carries a credential across the boundary.
10
+
11
+ The shape mirrors `factory/activities/merge_activities.py`: a pure library
12
+ function does the work, a thin `@activity.defn` wrapper builds the side-effect
13
+ through an injectable seam, and tests script the seam. The seams are:
14
+
15
+ - `_clone_runner` — production refreshes the target clone to its default
16
+ branch (FR-006: "fresh clone at the current default branch"); tests hand
17
+ back a scripted `CloneResult` so the clone never touches a real repo.
18
+ - `_registry` — production reads `personas.yaml`; tests hand a fixed
19
+ registry so the preflight checks deterministic personas (the same split the
20
+ CLI's preflight draws, one host reading its own file).
21
+ - `_preflight_client` — production dials the proxy from the environment; the
22
+ activity passes it to `check_aliases`, the shared core.
23
+ - `_open_epics_provider` — production lists open `epic-*` workflows through
24
+ `activity.client()`; tests hand a scripted set, because the time-skipping
25
+ test server does not answer the list filter the production query uses and
26
+ the capacity read is a single per-pass call (not an interval poll) either
27
+ way (FR-004).
28
+
29
+ Onboarding reuses 003's `validate_target_repo` activity as it stands — the
30
+ roadmap inherits N onboarding checks for N epics and does not optimize (plan
31
+ § US2). A failing onboarding check parks the spec with its finding verbatim
32
+ (FR-006); the child epic re-runs onboarding at its own start, the same
33
+ belt-and-suspenders the epic already had.
34
+
35
+ FR-009 (the closed credential discipline 001 established, extended one level
36
+ up): the master key lives only in this process's environment and is read
37
+ inside the preflight client seam. No key value reaches any activity *input*
38
+ — `PreflightInput` carries the graph and a proxy *url* (not a credential),
39
+ `CloneInput` carries a path, `CountOpenInput` is empty. The sweep (T012)
40
+ asserts each surface.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ from dataclasses import dataclass
46
+ from pathlib import Path
47
+ from typing import Any, Awaitable, Callable, Mapping
48
+
49
+ from temporalio import activity
50
+
51
+ from factory.config import Persona, load_personas
52
+ from factory.mergequeue.models import TargetRepoProfile
53
+ from factory.usage.litellm_client import LiteLLMClient
54
+ from factory.workgraph.derive import DerivationError
55
+ from factory.workgraph.models import WorkGraph
56
+ from factory.workgraph.preflight import (
57
+ PreflightFinding,
58
+ check_aliases,
59
+ prompt_assembly_preflight,
60
+ )
61
+ from factory.workgraph.workflow import JUDGE_PERSONA
62
+ from factory.workgraph.worktree import landing_branch
63
+ from factory.verify.factory_yaml import load_loop_config
64
+ from factory.verify.models import VerificationConfig
65
+
66
+ from factory.activities.merge_activities import (
67
+ ValidateTargetRepoInput,
68
+ validate_target_repo,
69
+ )
70
+
71
+
72
+ # --- clone: refresh the target clone to its current default branch ------------
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class CloneInput:
77
+ """Where to refresh, and which spec the clone is for (FR-006).
78
+
79
+ `target_repo` is the worker-host path to the target clone; `spec_dir` is
80
+ the spec the clone is being prepared for, carried only so a finding can
81
+ name which spec's pre-dispatch failed. No credential crosses here.
82
+ """
83
+
84
+ target_repo: str
85
+ spec_dir: str
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class CloneResult:
90
+ """The clone's identity at refresh time: path, default branch, head ref.
91
+
92
+ `head_ref` is the commit the clone stood at after the refresh — the
93
+ branch point the epic's nodes will pin against (FR-006: derive from the
94
+ current default branch, not a stale one).
95
+ """
96
+
97
+ path: str
98
+ default_branch: str
99
+ head_ref: str
100
+
101
+
102
+ def _refresh_to_default(target_repo: str) -> CloneResult:
103
+ """Refresh the target clone to its default branch and report where it stands.
104
+
105
+ A fetch + hard reset to `origin/<default>` so the epic derives from the
106
+ trunk's current head, whatever other landings moved it since the last
107
+ epic. Reuses the workgraph's git helpers (the same `_git` discipline the
108
+ node worktrees already follow) so the clone never sees a factory credential
109
+ (constitution V).
110
+ """
111
+ from factory.workgraph.worktree import _default_branch, _git, _head
112
+ from pathlib import Path
113
+
114
+ repo = Path(target_repo)
115
+ default = _default_branch(repo)
116
+ _git(repo, "fetch", "--quiet", "origin")
117
+ _git(repo, "checkout", "--quiet", default)
118
+ _git(repo, "reset", "--quiet", "--hard", f"origin/{default}")
119
+ return CloneResult(path=str(repo), default_branch=default, head_ref=_head(repo))
120
+
121
+
122
+ #: The clone seam — production refreshes a real clone; tests hand back a
123
+ #: scripted result so the activity never spawns git.
124
+ _clone_runner: Callable[[str], CloneResult] = _refresh_to_default
125
+
126
+
127
+ @activity.defn
128
+ async def clone_target(request: CloneInput) -> CloneResult:
129
+ """Refresh the target clone to its default branch before dispatch (FR-006).
130
+
131
+ The thin wrapper: the clone runs through `_clone_runner`, which production
132
+ implements with git and tests replace with a scripted result. Never raises
133
+ on a refused clone — that is a pre-dispatch refusal the workflow parks —
134
+ but a git error propagates as an activity failure the workflow catches and
135
+ parks verbatim (FR-006).
136
+ """
137
+ return _clone_runner(request.target_repo)
138
+
139
+
140
+ # --- derive: the pure deriver behind a thin activity --------------------------
141
+
142
+
143
+ @dataclass(frozen=True)
144
+ class DeriveInput:
145
+ """One spec's text and the identity fields the deriver cannot infer.
146
+
147
+ The deriver is pure and is handed text (FR-011), so the epic id, feature,
148
+ specs root and target repo — the four identity fields — are the caller's.
149
+ No credential; the spec text is read by the workflow's `read_roadmap`
150
+ surface, not carried here.
151
+ """
152
+
153
+ spec_text: str
154
+ epic_id: str
155
+ feature: str
156
+ specs_root: str
157
+ target_repo: str
158
+
159
+
160
+ @activity.defn
161
+ async def derive_spec(request: DeriveInput) -> WorkGraph:
162
+ """Compile one spec into the delta graph the child epic runs (FR-010).
163
+
164
+ A thin activity around the pure `derive_delta`: the workflow cannot read
165
+ files or shell git, so derivation — which a spec that does not compile refuses
166
+ — runs as an activity whose `DerivationError` the workflow catches and parks
167
+ verbatim (FR-006). The delta baseline is read from the refreshed target repo
168
+ by `landed_facts` inside the activity; the workflow receives only the result.
169
+ Returns the compiled `WorkGraph` on success.
170
+
171
+ A `DerivationError` is a *deterministic* refusal (a spec that does not
172
+ compile the same way every time), so it is re-raised as a non-retryable
173
+ `ApplicationError` — the workflow parks it on the first attempt rather than
174
+ retrying a refusal that cannot change (and the activity's retry policy
175
+ would otherwise burn three attempts before the workflow ever saw it). The
176
+ workflow reads the original message off the `ApplicationError` verbatim.
177
+ """
178
+ from pathlib import Path
179
+
180
+ from factory.workgraph.delta import derive_delta
181
+ from factory.workgraph.landed import landed_facts
182
+
183
+ runner = _derive_runner
184
+ if runner is not None:
185
+ return runner(request)
186
+
187
+ try:
188
+ facts = landed_facts(
189
+ request.target_repo,
190
+ request.epic_id,
191
+ default_branch=landing_branch(Path(request.target_repo)),
192
+ )
193
+ baseline = {
194
+ story_key: {
195
+ "commit": fact.commit,
196
+ "fingerprint": _fingerprint_for_spec(
197
+ request.target_repo, request.epic_id, fact.commit, story_key
198
+ ),
199
+ }
200
+ for story_key, fact in facts.items()
201
+ }
202
+ delta = derive_delta(
203
+ request.spec_text,
204
+ baseline=baseline,
205
+ epic_id=request.epic_id,
206
+ feature=request.feature,
207
+ specs_root=request.specs_root,
208
+ target_repo=request.target_repo,
209
+ )
210
+ return delta.graph
211
+ except DerivationError as exc:
212
+ from temporalio.exceptions import ApplicationError
213
+
214
+ raise ApplicationError(str(exc), non_retryable=True, type="DerivationError")
215
+
216
+
217
+ #: Test seam for derive_spec: production is `None` so the real delta path runs;
218
+ #: scheduler tests set this to a scripted runner so they can drive the graph that
219
+ #: reaches the child without a real target clone.
220
+ _derive_runner: Callable[[DeriveInput], WorkGraph] | None = None
221
+
222
+
223
+ def _default_branch(repo: Path) -> str:
224
+ """The repo's default branch, or "main" as a safe fallback."""
225
+ from factory.workgraph.worktree import _default_branch as worktree_default_branch
226
+
227
+ try:
228
+ return worktree_default_branch(repo)
229
+ except Exception:
230
+ return "main"
231
+
232
+
233
+ def _fingerprint_for_spec(repo: str | Path, spec_dir: str, rev: str, story_key: str):
234
+ """Pinned fingerprint of one story at a landing commit."""
235
+ from factory.workgraph.landed import fingerprint
236
+
237
+ return fingerprint(repo, rev, spec_dir, story_key)
238
+
239
+
240
+ # --- drift: read-only fingerprint comparison for render -----------------------
241
+
242
+
243
+ @dataclass(frozen=True)
244
+ class DriftInput:
245
+ """Inputs for drift detection: which spec and which repo to compare against."""
246
+
247
+ target_repo: str
248
+ spec_dir: str
249
+ spec_text: str
250
+
251
+
252
+ #: The drift seam — production computes from git; tests script a boolean so the
253
+ #: scheduler tests do not need a real target clone at `TARGET_REPO`.
254
+ _drift_runner: Callable[[DriftInput], bool] | None = None
255
+
256
+
257
+ @activity.defn
258
+ async def drift_for_spec(request: DriftInput) -> bool:
259
+ """Return True when the spec's fingerprints differ from their landed baseline.
260
+
261
+ Read-only and repo-authoritative: computes per-story landed facts from the
262
+ refreshed target repo, pins each fact's fingerprint at its landing commit,
263
+ and compares it to the current spec text. A spec whose frontmatter is not
264
+ `landed` is never drifted. The workflow uses this via `compute_readiness`
265
+ so the render can show `amended` without dispatching (FR-009).
266
+ """
267
+ runner = _drift_runner
268
+ if runner is not None:
269
+ return runner(request)
270
+
271
+ from factory.workgraph.delta import fingerprint_for
272
+ from factory.workgraph.landed import landed_facts
273
+
274
+ repo_path = Path(request.target_repo)
275
+ default = landing_branch(repo_path)
276
+ facts = landed_facts(request.target_repo, request.spec_dir, default_branch=default)
277
+ if not facts:
278
+ return False
279
+
280
+ current_by_key = {
281
+ story_key: fingerprint_for(request.spec_text, story_key)
282
+ for story_key in facts
283
+ }
284
+ for story_key, fact in facts.items():
285
+ pinned = _fingerprint_for_spec(
286
+ request.target_repo, request.spec_dir, fact.commit, story_key
287
+ )
288
+ # Missing baselines are skipped, not errors (US1 FR-001/FR-002).
289
+ if pinned.digest is None:
290
+ continue
291
+ current = current_by_key.get(story_key)
292
+ if current is None or pinned.digest != current.digest:
293
+ return True
294
+ return False
295
+
296
+
297
+ # --- preflight: the shared alias checks behind the proxy seam -----------------
298
+
299
+
300
+ @dataclass(frozen=True)
301
+ class PreflightInput:
302
+ """The graph to preflight and the proxy url its key is honored at (FR-006).
303
+
304
+ `proxy_url` is a url, never a credential — the master key is read from the
305
+ environment inside the preflight client seam (FR-009). `spec_dir` names
306
+ which spec this preflight is for, so a parked finding can say so.
307
+
308
+ 044 US2 adds `specs_root`, and it is deliberately a *root* rather than the
309
+ three documents' text: `specs_root/spec_dir` is where the dispatch path's
310
+ own read (`load_prompt_sources`) will look, so the activity opens the same
311
+ files at the last moment before dispatch (044 FR-007). Carrying the texts
312
+ instead would mean checking bytes the workflow read on some earlier pass,
313
+ which is the hole this check exists to close. It carries a default only so
314
+ an activity task scheduled by a pre-044 worker deserializes into this shape
315
+ instead of failing the converter; every live caller passes it, and a task
316
+ that somehow arrives without one reads its trio relative to the worker's
317
+ working directory and parks naming the path it could not open — legible, and
318
+ never a silent pass.
319
+ """
320
+
321
+ graph: WorkGraph
322
+ proxy_url: str
323
+ spec_dir: str
324
+ specs_root: str = ""
325
+
326
+
327
+ def _preflight_registry() -> dict[str, Persona]:
328
+ """The worker host's view of the registry, read from its `personas.yaml`.
329
+
330
+ The same split the CLI's preflight draws: the worker reads its own file,
331
+ so the preflight states what *this* registry resolves — never claims the
332
+ CLI's resolution was validated (US2 Edge Cases).
333
+ """
334
+ return load_personas()
335
+
336
+
337
+ def _preflight_client(proxy_url: str) -> LiteLLMClient:
338
+ """The activity's one route to the proxy, credentials from the environment.
339
+
340
+ A seam, not a factory: production builds a `LiteLLMClient` against the
341
+ worker host's environment (the master key lives there and nowhere else);
342
+ tests replace it to inject a fake transport, the same way the usage
343
+ activities' `open_client` is replaced.
344
+ """
345
+ from factory.usage.litellm_client import LiteLLMClient
346
+
347
+ return LiteLLMClient(base_url=proxy_url, master_key=_master_key_from_env())
348
+
349
+
350
+ def _master_key_from_env() -> str:
351
+ """The proxy master key, read from the worker environment (FR-009).
352
+
353
+ Lives only in this process's environment; never enters an activity input,
354
+ a finding, or a status payload. Read here — inside the seam — so the
355
+ workflow never sees it.
356
+
357
+ *Which* variable holds it is the control plane's answer, not this module's
358
+ (048 FR-006): `LITELLM_MASTER_KEY` when the host exports it, otherwise the
359
+ variable the operator declared at install. Routed through the one resolver
360
+ so the roadmap and a hand-started epic cannot disagree about which host
361
+ they are on. Only the credential is resolved here — the seam above is
362
+ handed its `proxy_url` as an argument.
363
+ """
364
+ from factory.controlplane.resolve import resolve_master_key_env
365
+
366
+ return resolve_master_key_env().read()
367
+
368
+
369
+ @activity.defn
370
+ async def preflight_spec(request: PreflightInput) -> list[PreflightFinding]:
371
+ """Run the shared preflight before dispatch: prompts, then the proxy (FR-006).
372
+
373
+ The pure checks live in `factory/workgraph/preflight.py` and are shared
374
+ with the CLI's `ergane build start`, so the two surfaces cannot drift. The
375
+ activity owns the client (master key from the environment) and the
376
+ registry (the worker host's `personas.yaml`); the preflight module owns
377
+ the alias math and the wording. Returns `[]` when every check passes.
378
+
379
+ Prompt assembly runs **here, in the activity** (044 US2, FR-003). It is a
380
+ read of three files, and workflow code cannot read files — putting it in
381
+ `RoadmapWorkflow._dispatch` would make the roadmap non-deterministic, which
382
+ is the defect class that killed the schedule on 2026-08-13. The workflow
383
+ hands over the root it already holds; the activity opens the trio.
384
+
385
+ Assembly goes first, and both checks run. First because it is the cheaper
386
+ and more certain fact — no service is consulted, so it cannot be wrong about
387
+ a proxy that is merely slow — and the workflow parks on the first finding,
388
+ which makes it the one the operator is shown. Both, rather than returning
389
+ early, because an author fixing one refusal per run is the failure mode the
390
+ collected-findings discipline exists to avoid: a spec with a broken
391
+ `tasks.md` *and* an unserved alias needs one edit pass, not two roadmap
392
+ passes.
393
+ """
394
+ findings = prompt_assembly_preflight(
395
+ request.graph, Path(request.specs_root) / request.spec_dir
396
+ )
397
+ findings += await check_aliases(
398
+ request.graph, _preflight_registry(), _preflight_client(request.proxy_url)
399
+ )
400
+ return findings
401
+
402
+
403
+ # --- onboarding: reuse 003's activity as it stands ----------------------------
404
+
405
+
406
+ @dataclass(frozen=True)
407
+ class OnboardInput:
408
+ """The target repo to validate before dispatch (FR-006, reusing 003).
409
+
410
+ `spec_dir` names which spec this onboarding is for, so a parked finding
411
+ can say so; the onboarding check itself is the repo's, not the spec's.
412
+ """
413
+
414
+ target_repo: str
415
+ spec_dir: str
416
+
417
+
418
+ async def _onboard_profile(target_repo: str) -> TargetRepoProfile:
419
+ """Production onboarding: 003's `validate_target_repo` activity as it stands.
420
+
421
+ The child epic re-runs onboarding at its own start, so this is the roadmap's
422
+ pre-dispatch refusal gate — a repo that fails onboarding parks every spec
423
+ that would dispatch against it, with the finding verbatim, instead of
424
+ starting an epic that immediately fails (FR-006). Onboarding re-validates
425
+ per epic by design, uncached — inherit, do not optimize (plan § US2).
426
+ """
427
+ return await validate_target_repo(
428
+ ValidateTargetRepoInput(target_repo=target_repo)
429
+ )
430
+
431
+
432
+ #: The onboarding seam — production reuses 003's activity; tests replace it with
433
+ #: a scripted `TargetRepoProfile` so the scheduler tests are not also onboarding
434
+ #: tests (003 owns onboarding's own coverage).
435
+ _onboard: Callable[[str], "Awaitable[TargetRepoProfile]"] = _onboard_profile
436
+
437
+
438
+ @activity.defn
439
+ async def onboard_target(request: OnboardInput) -> TargetRepoProfile:
440
+ """Re-validate the target repo before each dispatch (FR-006, reusing 003).
441
+
442
+ Thin wrapper over the `_onboard` seam: production calls 003's
443
+ `validate_target_repo`; tests script a profile. `spec_dir` names which
444
+ spec this onboarding is for, so a parked finding can say so; the check
445
+ itself is the repo's, not the spec's.
446
+ """
447
+ return await _onboard(request.target_repo)
448
+
449
+
450
+ # --- capacity: count the open epics the roadmap dispatches around -------------
451
+
452
+
453
+ @dataclass(frozen=True)
454
+ class CountOpenInput:
455
+ """No arguments — the capacity read is against the worker's own client.
456
+
457
+ Empty on purpose: the open-epic count is a property of the Temporal
458
+ namespace the worker polls, not of any roadmap input. Carrying nothing
459
+ is what keeps a credential out of it (FR-009).
460
+ """
461
+
462
+
463
+ @dataclass(frozen=True)
464
+ class CountOpenResult:
465
+ """The open `epic-*` workflow ids at pass start.
466
+
467
+ Includes epics the roadmap started *and* operator-started epics mid-flight
468
+ when the roadmap booted — the roadmap dispatches around both, so the bound
469
+ is honest about everything consuming a slot (plan § US2, the double-
470
+ dispatch-after-restart guard).
471
+ """
472
+
473
+ open_ids: tuple[str, ...]
474
+
475
+
476
+ async def _list_open_epics() -> set[str]:
477
+ """Production capacity read: open `epic-*` workflows through the client.
478
+
479
+ A single read at the start of a scheduling pass, never an interval poll
480
+ (FR-004): the workflow wakes on child completion events and calls this once
481
+ per pass to know how many slots are free. `RUNNING` is the open state;
482
+ `CONTINUED_AS_NEW` is a closed run (the new run carries a fresh id), so it
483
+ does not count.
484
+ """
485
+ client = activity.client()
486
+ open_ids: set[str] = set()
487
+ # The list filter is the production query shape; the time-skipping test
488
+ # server does not answer it, which is exactly why the seam below lets a
489
+ # test script the count instead. The SDK's WorkflowExecutionStatus enum
490
+ # does not round-trip to the grammar's expected spelling by any of .name,
491
+ # .value, or str(), and .name.title() silently breaks on multi-word
492
+ # members, so the literal is pinned here and guarded by tests/test_live_capacity.py.
493
+ async for execution in client.list_workflows(
494
+ f'ExecutionStatus = "{_OPEN_EPIC_STATUS}"'
495
+ ):
496
+ if execution.id.startswith("epic-"):
497
+ open_ids.add(execution.id)
498
+ return open_ids
499
+
500
+
501
+ #: The visibility status the production capacity read treats as "open".
502
+ #: Temporal's visibility grammar wants the title-case enum name, not the
503
+ #: uppercase .name of the SDK enum; see the comment in _list_open_epics.
504
+ _OPEN_EPIC_STATUS = "Running"
505
+
506
+
507
+ #: The capacity seam — production lists open workflows through the client;
508
+ #: tests hand a scripted set so the count is deterministic without a real
509
+ #: Temporal list round trip.
510
+ _open_epics_provider: Callable[[], Awaitable[set[str]]] = _list_open_epics
511
+
512
+
513
+ @activity.defn
514
+ async def count_open_epics(request: CountOpenInput) -> CountOpenResult:
515
+ """How many `epic-*` workflows are open right now (FR-005 capacity accounting).
516
+
517
+ The roadmap's own in-flight children are open `epic-*` workflows, so this
518
+ count is the total slots in use — roadmap's plus operator-started — and the
519
+ roadmap dispatches around it (FR-005). Called once per scheduling pass, not
520
+ on an interval (FR-004).
521
+ """
522
+ open_ids = await _open_epics_provider()
523
+ return CountOpenResult(open_ids=tuple(sorted(open_ids)))
524
+
525
+
526
+ # --- loop config: dispatch-time ladder + order read ---------------------------
527
+
528
+
529
+ @dataclass(frozen=True)
530
+ class ReadLoopConfigInput:
531
+ """The operator clone whose manifest decides this spec's loop (023 FR-002).
532
+
533
+ The target repo is a worker-host path; the workflow never touches the
534
+ filesystem, so this read runs as an activity and is pinned before the
535
+ child starts. A manifest that does not parse is a non-retryable refusal:
536
+ it will not become valid by being read again a moment later.
537
+ """
538
+
539
+ target_repo: str
540
+
541
+
542
+ @dataclass(frozen=True)
543
+ class ReadLoopConfigResult:
544
+ """The two loop facts that ride `EpicInput` into the child epic."""
545
+
546
+ config: VerificationConfig
547
+ verify_order: tuple[str, ...]
548
+
549
+
550
+ #: Test seam for `read_loop_config`: production reads the clone's manifest;
551
+ #: scheduler tests script a fixed result so the manifest file is not the subject.
552
+ _read_loop_config_runner: Callable[[str], ReadLoopConfigResult] | None = None
553
+
554
+
555
+ @activity.defn
556
+ async def read_loop_config(request: ReadLoopConfigInput) -> ReadLoopConfigResult:
557
+ """Read the committed loop config from the operator clone (023 US2).
558
+
559
+ The read is pinned at dispatch time so the child epic's ladder and step
560
+ order are properties of the commit the roadmap saw, not of any worktree
561
+ the child may later write (trap 2, FR-002). `FactoryConfigError` is raised
562
+ as a non-retryable `ApplicationError` so the workflow parks the spec with
563
+ the rule named.
564
+ """
565
+ runner = _read_loop_config_runner
566
+ if runner is not None:
567
+ return runner(request.target_repo)
568
+
569
+ from factory.verify.factory_yaml import FactoryConfigError
570
+
571
+ try:
572
+ config, verify_order = load_loop_config(request.target_repo)
573
+ except FactoryConfigError as exc:
574
+ from temporalio.exceptions import ApplicationError
575
+
576
+ raise ApplicationError(str(exc), non_retryable=True, type="FactoryConfigError") from None
577
+ return ReadLoopConfigResult(config=config, verify_order=verify_order)