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,654 @@
1
+ """US1's landing surface: push, open, enqueue, poll, disable — and nothing else.
2
+
3
+ `factory/mergequeue/` is a library — a data model, a classifier, a client, a PR
4
+ renderer. This module turns those into things the workflow can call, which means
5
+ it owns the two concerns the library deliberately does not (the same split
6
+ `factory/activities/verify_activities.py` draws for verification): reading the
7
+ world at a known moment, and turning a library exception into a refusal the
8
+ interpreter can route without reading prose.
9
+
10
+ The activities are one landing's life, in the plan's order:
11
+
12
+ - `prepare_landing_pr` — renders the PASS node's PR body to a scratch file via
13
+ the pure `render_pr_body`, reading the renderer's secret inputs from the worker
14
+ environment so they never cross a workflow boundary (constitution V).
15
+ - `open_landing_pr` — salvage has already happened (the workflow salvages before
16
+ it ever calls this); this *pushes* the node branch to the target clone's
17
+ `origin` (FR-001 — the forge reads that clone, so the branch has to exist on
18
+ that remote), then offers it. Offered ready, and idempotent: an existing
19
+ proposal for the branch is reused — a forge holds one landing per head.
20
+ - `enqueue_landing` — asks the forge to land the proposal once its gates pass;
21
+ the factory never merges (D-024, FR-002). A refusal (the target stopped
22
+ accepting landings mid-flight, the spec edge case) is returned as rejection
23
+ data, never raised — the workflow routes it to escalation.
24
+ - `poll_landing` — one observation → a `PrSnapshot`, the classifier's input.
25
+ - `disable_auto_merge` — the kill-cleanup path: best-effort, so a killed epic's
26
+ landing stops trying to land even when the call fails.
27
+
28
+ No activity ever removes a branch (FR-008): the branch is the target's to land,
29
+ and this module never issues a branch-removal command — the string the
30
+ structural guard greps for must never appear in its command surface.
31
+
32
+ `_client_factory` is the seam in the same sense as `open_bot` and
33
+ `judge_transport`: since 049's US1 it resolves the *forge* the target repository
34
+ is on — since US5, the one that repository's own manifest declares — and since
35
+ US3 every landing activity speaks to that forge and nothing beneath it: none
36
+ constructs a forge-native client or names one (FR-009). It
37
+ keeps its name deliberately: twenty-one call sites in the landing suite bind it,
38
+ and US3-S1 requires that suite to pass with no assertion changed, so a rename
39
+ would rewrite the one file whose stillness is the evidence (trap 14).
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import asyncio
45
+ import os
46
+ from dataclasses import dataclass
47
+ from pathlib import Path
48
+ from typing import Callable
49
+
50
+ from temporalio import activity
51
+ from temporalio.exceptions import ApplicationError
52
+
53
+ from factory.mergequeue.forge import Forge, ForgeError, resolve_forge_for_repo
54
+ from factory.mergequeue.messages import pr_title, render_pr_body
55
+ from factory.mergequeue.models import CheckFailure, PrSnapshot, TargetRepoProfile
56
+ from factory.mergequeue.onboard import InitFacts, evaluate_init_facts, evaluate_repo
57
+ from factory.usage.litellm_client import MASTER_KEY_ENV, PROXY_URL_ENV
58
+ from factory.verify.factory_yaml import FactoryConfigError, load_factory_config, resolve_manifest_path
59
+ from factory.verify.models import VerificationResult
60
+ from factory.workgraph import worktree as worktrees
61
+ from factory.workgraph.adapter import transcript_dir
62
+
63
+ #: Where the worker host keeps worktrees — one override, one default, the same
64
+ #: resolver agent_activities uses. The merge surface must hand `push_branch` the
65
+ #: *same* root the worktree was prepared under: git resolves a relative worktree
66
+ #: path against the target clone, while Python resolves it against the worker's
67
+ #: cwd, and a relative root silently splits the two (see the absolute-root rule
68
+ #: in scripts/ergane-env.sh).
69
+ FACTORY_ROOT_ENV = "FACTORY_ROOT"
70
+
71
+ #: The activity error type for a landing that must not proceed — an enqueue the
72
+ #: queue will not accept for a reason that is not an outage. Non-retryable: the
73
+ #: rejection is data the workflow routes to escalation, not a transient fault.
74
+ #: (The client already classifies; this re-raises as data via `EnqueueResult`.)
75
+ LANDING_REFUSED = "LANDING_REFUSED"
76
+
77
+ #: The activity error type for a git push that failed. Retryable, like the
78
+ #: worktree operations: a lock or a slow filesystem is what a second attempt
79
+ #: fixes.
80
+ PUSH_FAILED = "PUSH_FAILED"
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class PrepareLandingPrInput:
85
+ """The non-secret facts the PR body is rendered from (constitution V).
86
+
87
+ `result` is the passing attempt's `VerificationResult` — its per-gate
88
+ results and judge word are what the body quotes. The secrets
89
+ `render_pr_body` also demands (`proxy_url`, `master_key`, `telegram_token`,
90
+ `transcript_path`) are deliberately *absent*: they live only in the worker
91
+ host environment and are read inside this activity, never carried in an
92
+ orchestration payload (architecture §10).
93
+ """
94
+
95
+ epic_id: str
96
+ node_id: str
97
+ branch: str
98
+ attempt: int
99
+ feature: str
100
+ requirement_keys: tuple[str, ...]
101
+ result: VerificationResult
102
+ story_title: str
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class PrepareLandingPrResult:
107
+ """The body file and title `open_landing_pr` needs — the workflow's side is pure."""
108
+
109
+ body_file: str
110
+ title: str
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class OpenLandingPrInput:
115
+ """What the workflow knows about a PASS node that must now land.
116
+
117
+ `body_file` is a path `prepare_landing_pr` wrote (rendered by
118
+ `factory/mergequeue/messages.py`); the activity passes it to
119
+ `gh pr create --body-file` so the body's quoting needs no shell care.
120
+ """
121
+
122
+ epic_id: str
123
+ node_id: str
124
+ target_repo: str
125
+ base: str
126
+ branch: str
127
+ title: str
128
+ body_file: str
129
+
130
+
131
+ @dataclass(frozen=True)
132
+ class OpenLandingPrResult:
133
+ """The PR the landing will ride: number, URL, and pushed sha.
134
+
135
+ US3: `pushed_sha` records the commit the branch was pushed to origin with,
136
+ so a later recovery can compare the tree the queue rejected against the tree
137
+ it is about to re-enqueue (FR-009). Default `None` keeps pre-spec histories
138
+ replayable.
139
+ """
140
+
141
+ number: int
142
+ url: str
143
+ pushed_sha: str | None = None
144
+
145
+
146
+ @dataclass(frozen=True)
147
+ class CompareTreesInput:
148
+ """US3: which two refs to compare for tree identity in a node's worktree.
149
+
150
+ `rejected_tip` is the recorded `enqueued_tip` — what the queue tested.
151
+ `current_head` is the worktree HEAD after the recovery sync and attempt.
152
+ The activity runs `git rev-parse <ref>^{tree}` for both and reports whether
153
+ the tree ids match.
154
+ """
155
+
156
+ epic_id: str
157
+ node_id: str
158
+ target_repo: str
159
+ rejected_tip: str
160
+ current_head: str
161
+
162
+
163
+ @dataclass(frozen=True)
164
+ class EnqueueLandingInput:
165
+ """Which PR to enqueue, how the queue should land it, and against which clone.
166
+
167
+ `target_repo` is the target clone path — `gh` runs with `cwd` = the clone
168
+ (FR-001), so the queue command is issued against the same repository the
169
+ branch was pushed to.
170
+ """
171
+
172
+ pr_number: int
173
+ merge_method: str
174
+ target_repo: str
175
+
176
+
177
+ @dataclass(frozen=True)
178
+ class EnqueueResult:
179
+ """The enqueue's outcome — success or a refusal an interpreter can route.
180
+
181
+ `None`-shaped on success is deliberately avoided: this dataclass always
182
+ exists, and `rejected=False` is the success case, so the workflow reads
183
+ one type whether the queue accepted the PR or refused it.
184
+ """
185
+
186
+ rejected: bool
187
+ reason: str
188
+
189
+
190
+ @dataclass(frozen=True)
191
+ class PollLandingInput:
192
+ """The PR to poll, and against which clone."""
193
+
194
+ pr_number: int
195
+ target_repo: str
196
+
197
+
198
+ @dataclass(frozen=True)
199
+ class DisableAutoMergeInput:
200
+ """The PR to take out of the queue — the kill-cleanup path."""
201
+
202
+ pr_number: int
203
+ target_repo: str
204
+
205
+
206
+ @dataclass(frozen=True)
207
+ class DisableResult:
208
+ """Best-effort outcome: whether disabling failed, and why, if it did."""
209
+
210
+ failed: bool
211
+ reason: str
212
+
213
+
214
+ @dataclass(frozen=True)
215
+ class FetchCheckFailureInput:
216
+ """US2: which PR's failing checks to fetch evidence for (FR-005/006/007).
217
+
218
+ `check_names` is the set of required checks the classifier named as failing;
219
+ the activity resolves their run links, fetches the per-run failing-step log,
220
+ and returns a frozen record per check. A `gh` failure anywhere in the chain
221
+ returns degraded evidence with the absence stated, never a raise — the cycle
222
+ must not be lost to its own evidence-gathering.
223
+ """
224
+
225
+ epic_id: str
226
+ node_id: str
227
+ pr_number: int
228
+ check_names: tuple[str, ...]
229
+ target_repo: str
230
+
231
+
232
+ @dataclass(frozen=True)
233
+ class SyncLandingBranchInput:
234
+ """Which node's branch to sync onto the target head (US2 recovery, FR-005).
235
+
236
+ A `CHECKS_FAILED` rejection means the target branch moved under the node; the
237
+ recovery cycle's first move is to sync the node branch onto the new target
238
+ head inside its worktree. `target_repo` is the clone whose `origin` is the
239
+ remote the queue operates against — the branch is pushed there, so the sync
240
+ fetches and merges from it.
241
+ """
242
+
243
+ epic_id: str
244
+ node_id: str
245
+ target_repo: str
246
+
247
+
248
+ @dataclass(frozen=True)
249
+ class SyncLandingBranchResult:
250
+ """The sync's outcome, as data the workflow can route (FR-005).
251
+
252
+ `clean` mirrors `worktree.SyncResult`: True when the target head merged in
253
+ without a conflict, with `base_ref` the merged-in target head (the new branch
254
+ point re-verification's diff is measured from, D-027 extended). `refused`
255
+ covers a recovery that could not run — a missing worktree or a wedged git —
256
+ surfaced as data with the reason, never a silent pass that would read as a
257
+ successful sync and re-enqueue work that was not actually synced.
258
+ """
259
+
260
+ clean: bool
261
+ base_ref: str | None
262
+ conflicted_files: tuple[str, ...]
263
+ refused: bool
264
+ reason: str
265
+
266
+
267
+ @dataclass(frozen=True)
268
+ class ValidateTargetRepoInput:
269
+ """Which target clone to validate — US3's preflight (FR-010).
270
+
271
+ `validate_target_repo` runs against the clone itself: `gh` resolves the
272
+ owner/repo from `origin`, and the repo's committed `factory.yaml` is read from
273
+ the same clone. There is nothing else to say — the repo path is the whole
274
+ input, which is what makes onboarding a property of a repo, not of a payload.
275
+ """
276
+
277
+ target_repo: str
278
+
279
+
280
+ #: The seam — a factory `(repo_path: str) -> Forge`. Production resolves the
281
+ #: forge the repository's own manifest declares (049-US5); tests replace this
282
+ #: with one over a scripted `gh` or a modelled repository. One factory for this
283
+ #: boundary, and no second.
284
+ _client_factory: Callable[..., Forge] = lambda *, repo_path: resolve_forge_for_repo(
285
+ repo_path=repo_path
286
+ )
287
+
288
+
289
+ def _forge(*, repo_path: str) -> Forge:
290
+ return _client_factory(repo_path=repo_path)
291
+
292
+
293
+ def _landing_body_dir() -> Path:
294
+ """Where prepared PR bodies are written, under the worker's state directory.
295
+
296
+ The same `FACTORY_ROOT` the worktree ops use, so one override locates all of
297
+ the worker host's state. Body files are scratch — the PR create reads them
298
+ once — so a fixed name is fine and cleanup is not this activity's job.
299
+ """
300
+ root, _choice, _source = worktrees.resolve_factory_root(FACTORY_ROOT_ENV)
301
+ return root
302
+
303
+
304
+ # --- the activities -----------------------------------------------------------
305
+
306
+
307
+ @activity.defn
308
+ async def prepare_landing_pr(request: PrepareLandingPrInput) -> PrepareLandingPrResult:
309
+ """Render a PASS node's PR body to a file — the one landing side effect that is a write.
310
+
311
+ The body is rendered by the pure `render_pr_body` (deterministic, secret-free)
312
+ and written to a scratch file for `gh pr create --body-file`. The renderer's
313
+ secret inputs — proxy URL, master key, telegram token, transcript path — are
314
+ read from the worker host environment *here*, inside the activity, so they
315
+ never cross a workflow boundary (constitution V, architecture §10).
316
+ """
317
+ factory_root = _landing_body_dir()
318
+ transcript = str(
319
+ transcript_dir(factory_root, request.epic_id, request.node_id, request.attempt)
320
+ )
321
+ body = render_pr_body(
322
+ epic_id=request.epic_id,
323
+ node_id=request.node_id,
324
+ branch=request.branch,
325
+ attempt=request.attempt,
326
+ feature=request.feature,
327
+ requirement_keys=request.requirement_keys,
328
+ result=request.result,
329
+ proxy_url=os.environ.get(PROXY_URL_ENV, ""),
330
+ master_key=os.environ.get(MASTER_KEY_ENV, ""),
331
+ telegram_token=os.environ.get("TELEGRAM_BOT_TOKEN", ""),
332
+ transcript_path=transcript,
333
+ )
334
+ title = pr_title(
335
+ epic_id=request.epic_id,
336
+ node_id=request.node_id,
337
+ story_title=request.story_title,
338
+ )
339
+ body_dir = factory_root / "landing" / request.epic_id / request.node_id
340
+ body_dir.mkdir(parents=True, exist_ok=True)
341
+ body_file = body_dir / f"attempt-{request.attempt}.md"
342
+ body_file.write_text(body, encoding="utf-8")
343
+ return PrepareLandingPrResult(body_file=str(body_file), title=title)
344
+
345
+
346
+ @activity.defn
347
+ async def open_landing_pr(request: OpenLandingPrInput) -> OpenLandingPrResult:
348
+ """Push the node branch, then offer it to the target (FR-001, FR-009).
349
+
350
+ Salvage has already happened; the branch holds the durable work. Pushing to
351
+ the target clone's `origin` is what makes the PR's head exist on the remote
352
+ the forge operates against, so the push comes first. Idempotent: an open PR
353
+ for the branch is reused, so a retry after an unrecorded success does not
354
+ open a second PR for one head.
355
+
356
+ Raises `PUSH_FAILED` (retryable) when git refused, and lets the forge's own
357
+ `ForgeError` through when it refused the offer.
358
+ """
359
+ try:
360
+ pushed_sha = await asyncio.to_thread(
361
+ worktrees.push_branch,
362
+ request.target_repo,
363
+ request.epic_id,
364
+ request.node_id,
365
+ factory_root=worktrees.resolve_factory_root(FACTORY_ROOT_ENV)[0],
366
+ )
367
+ except worktrees.WorktreeError as exc:
368
+ raise ApplicationError(str(exc), type=PUSH_FAILED) from exc
369
+
370
+ forge = _forge(repo_path=request.target_repo)
371
+
372
+ existing = forge.find_proposal(request.branch)
373
+ if existing is not None:
374
+ return OpenLandingPrResult(
375
+ number=existing.number, url=existing.url, pushed_sha=pushed_sha
376
+ )
377
+
378
+ created = forge.open_proposal(
379
+ base=request.base,
380
+ head=request.branch,
381
+ title=request.title,
382
+ body_file=request.body_file,
383
+ )
384
+ return OpenLandingPrResult(
385
+ number=created.number, url=created.url, pushed_sha=pushed_sha
386
+ )
387
+
388
+
389
+ @activity.defn
390
+ async def enqueue_landing(request: EnqueueLandingInput) -> EnqueueResult:
391
+ """Ask the forge to land the proposal once its gates pass (FR-002, FR-009).
392
+
393
+ The factory never merges: it asks (D-024). The operator's declared method
394
+ travels as stated intent; a branch whose landing policy owns the method
395
+ ignores it. A refusal — the target stopped accepting landings mid-flight,
396
+ the spec edge case — comes back as `EnqueueResult(rejected=True, reason=…)`,
397
+ never as a raised crash, so the workflow routes it to escalation.
398
+ """
399
+ forge = _forge(repo_path=request.target_repo)
400
+ try:
401
+ forge.request_landing(
402
+ request.pr_number, declared_method=request.merge_method
403
+ )
404
+ except ForgeError as exc:
405
+ return EnqueueResult(rejected=True, reason=exc.detail or str(exc))
406
+ return EnqueueResult(rejected=False, reason="")
407
+
408
+
409
+ @activity.defn
410
+ async def poll_landing(request: PollLandingInput) -> PrSnapshot:
411
+ """One observation of the proposal — the classifier's input (FR-009)."""
412
+ return _forge(repo_path=request.target_repo).observe_proposal(request.pr_number)
413
+
414
+
415
+ @activity.defn
416
+ async def disable_auto_merge(request: DisableAutoMergeInput) -> DisableResult:
417
+ """Withdraw the landing request — best-effort (FR-008, FR-009).
418
+
419
+ Called on the epic/node kill path so a killed epic does not keep landing. A
420
+ failure is reported, never raised: the kill sequence must not be blocked on
421
+ the forge's availability, and a landing that could not be withdrawn is a fact
422
+ the workflow surfaces, not a crash it dies on.
423
+ """
424
+ forge = _forge(repo_path=request.target_repo)
425
+ try:
426
+ forge.withdraw_landing(request.pr_number)
427
+ except ForgeError as exc:
428
+ return DisableResult(failed=True, reason=exc.detail or str(exc))
429
+ return DisableResult(failed=False, reason="")
430
+
431
+
432
+ @activity.defn
433
+ async def compare_trees(request: CompareTreesInput) -> bool:
434
+ """Compare the rejected tip's tree with the current worktree HEAD (US3).
435
+
436
+ Runs on a thread via `asyncio.to_thread` so the git subprocess never blocks the
437
+ worker's event loop (trap 4). Returns True when the two refs resolve to the
438
+ same tree id, False otherwise. A missing worktree or an unresolvable ref is
439
+ a `WorktreeError` surfaced as `ApplicationError(type=PUSH_FAILED)` so the
440
+ workflow escalates rather than guessing.
441
+ """
442
+
443
+ def _compare() -> bool:
444
+ return worktrees.trees_identical(
445
+ request.target_repo, request.rejected_tip, request.current_head
446
+ )
447
+
448
+ try:
449
+ return await asyncio.to_thread(_compare)
450
+ except worktrees.WorktreeError as exc:
451
+ raise ApplicationError(str(exc), type=PUSH_FAILED) from exc
452
+
453
+
454
+ @activity.defn
455
+ async def sync_landing_branch(request: SyncLandingBranchInput) -> SyncLandingBranchResult:
456
+ """Sync a rejected node's branch onto the target head (US2 recovery, FR-005).
457
+
458
+ The recovery cycle's first move after a `CHECKS_FAILED` rejection: the
459
+ `worktree.sync_with_target` helper fetches origin and merges the target head
460
+ into the node branch inside its worktree (merge, never rebase — the branch is
461
+ pushed, history stays fast-forward). The activity turns the helper's two
462
+ outcomes into the data the workflow routes:
463
+
464
+ - a clean merge → `clean=True` with the merged-in target head as `base_ref`;
465
+ - a conflict → `clean=False` with the conflicted file list, the markers left
466
+ in the tree for the debugger persona (FR-006);
467
+ - a helper failure (missing worktree, wedged git) → `refused=True` with the
468
+ reason, so the workflow escalates rather than re-enqueueing work that was
469
+ not synced. Never a silent pass.
470
+ """
471
+ try:
472
+ result = await asyncio.to_thread(
473
+ worktrees.sync_with_target,
474
+ request.target_repo,
475
+ request.epic_id,
476
+ request.node_id,
477
+ factory_root=worktrees.resolve_factory_root(FACTORY_ROOT_ENV)[0],
478
+ )
479
+ except worktrees.WorktreeError as exc:
480
+ return SyncLandingBranchResult(
481
+ clean=False,
482
+ base_ref=None,
483
+ conflicted_files=(),
484
+ refused=True,
485
+ reason=str(exc),
486
+ )
487
+ return SyncLandingBranchResult(
488
+ clean=result.clean,
489
+ base_ref=result.base_ref,
490
+ conflicted_files=result.conflicted_files,
491
+ refused=False,
492
+ reason="",
493
+ )
494
+
495
+
496
+ @activity.defn
497
+ async def fetch_check_failure(request: FetchCheckFailureInput) -> tuple[CheckFailure, ...]:
498
+ """Fetch each named failing check's evidence (US2, FR-005/006/007, FR-009).
499
+
500
+ Runs on a thread via `asyncio.to_thread` so the forge's I/O never blocks the
501
+ worker's event loop (trap 4). How the evidence is gathered was GitHub detail
502
+ living in an activity and is the forge's since US3; what survives here is the
503
+ promise the workflow depends on — one record per requested name, degraded
504
+ with the absence stated rather than raised.
505
+ """
506
+
507
+ def _fetch() -> tuple[CheckFailure, ...]:
508
+ forge = _forge(repo_path=request.target_repo)
509
+ return forge.failing_check_evidence(request.pr_number, request.check_names)
510
+
511
+ return await asyncio.to_thread(_fetch)
512
+
513
+
514
+ def onboard_target_repo(
515
+ forge: Forge,
516
+ target_repo: str,
517
+ *,
518
+ init_facts: "InitFacts | None" = None,
519
+ ) -> TargetRepoProfile:
520
+ """US3's preflight: gather a repo's facts through `forge` and judge it (FR-010).
521
+
522
+ The fact-gathering half of onboarding — `evaluate_repo` (in
523
+ `factory/mergequeue/onboard.py`) is the pure judgment. This reads the world
524
+ at one moment, and since 049's US1 it reads it through a forge rather than
525
+ by naming `gh`:
526
+
527
+ - the repository's address, default branch and forge-authored findings;
528
+ - the landing branch's policy — does it gate on named checks, which checks;
529
+ - the clone's committed `factory.yaml` via the 002 loader.
530
+
531
+ Every forge failure is returned as a failed validation with a finding — never
532
+ a pass — and a malformed manifest is a failing `factory_yaml` finding
533
+ carrying the loader's error, never a shrug (FR-010, spec US3 AS2). The
534
+ profile's `passed` is the conjunction of its findings; a failing profile
535
+ blocks dispatch before any key is issued or worktree created (SC-005).
536
+
537
+ `forge` is injected so both the activity (via the `_client_factory` seam) and
538
+ the offline CLI (`ergane repo onboard`) can drive the same logic against
539
+ whichever forge their caller resolved.
540
+
541
+ `init_facts` is 034 US4's second door: `ergane init --check` gathers what init
542
+ created (runtime root, registry entry, landing branch, control plane) and
543
+ passes it straight through to `evaluate_repo`, so terminal and dispatch
544
+ render the *same* parity findings rather than two implementations that agree
545
+ today. It travels the read-failure path too: a repo with no remote at all is
546
+ precisely the repo whose local findings must still render.
547
+ """
548
+ manifest_path, _ = resolve_manifest_path(target_repo)
549
+
550
+ try:
551
+ config = load_factory_config(manifest_path)
552
+ declared_gates = tuple(config.gates.keys())
553
+ manifest_error = None
554
+ except FactoryConfigError as error:
555
+ declared_gates = ()
556
+ manifest_error = str(error)
557
+
558
+ try:
559
+ repository = forge.describe_repository()
560
+ except ForgeError as error:
561
+ return _profile_from_forge_failure(
562
+ target_repo, visibility="", default_branch="", address="",
563
+ manifest_error=manifest_error, declared_gates=declared_gates,
564
+ error=error, init_facts=init_facts,
565
+ )
566
+
567
+ try:
568
+ policy = forge.landing_policy(repository.default_branch)
569
+ except ForgeError as error:
570
+ # A repo the factory cannot read is not dispatchable.
571
+ return _profile_from_forge_failure(
572
+ target_repo, visibility=repository.visibility,
573
+ default_branch=repository.default_branch,
574
+ address=repository.address, manifest_error=manifest_error,
575
+ declared_gates=declared_gates, error=error, init_facts=init_facts,
576
+ )
577
+
578
+ return evaluate_repo(
579
+ repo=repository.address or target_repo,
580
+ reading=repository,
581
+ policy=policy,
582
+ declared_gates=declared_gates,
583
+ factory_yaml_error=manifest_error,
584
+ init_facts=init_facts,
585
+ )
586
+
587
+
588
+ def _profile_from_forge_failure(
589
+ target_repo: str,
590
+ *,
591
+ visibility: str,
592
+ default_branch: str,
593
+ address: str,
594
+ manifest_error: str | None,
595
+ declared_gates: tuple[str, ...],
596
+ error: ForgeError,
597
+ init_facts: "InitFacts | None" = None,
598
+ ) -> TargetRepoProfile:
599
+ """A failed validation from a forge refusal — never a pass (FR-010).
600
+
601
+ A repo the factory cannot read is a repo the factory must not dispatch
602
+ against. The findings carry the refusal and name the remedy; `queue_enabled`
603
+ is False and the required checks are unknown, so `evaluate_repo` would report
604
+ them as failing — but the primary finding is the read failure itself.
605
+ """
606
+ # The read failure dominates: visibility, queue and checks are all unknown,
607
+ # so every check a known-value check would need is not judgeable. We return a
608
+ # profile whose single dominant finding is the refusal, plus the manifest's
609
+ # own finding if the manifest also failed.
610
+ from factory.mergequeue.models import Finding
611
+
612
+ # US3 left this naming `gh` and called it US2's; US2 left it too, and no
613
+ # per-module check either story wrote was pointed here. US6's package-wide
614
+ # sweep is what found it. `error.kind` already carries the forge's own
615
+ # taxonomy term, so the operator loses nothing.
616
+ detail = (
617
+ f"could not read the repo via its forge ({error.kind}): "
618
+ f"{error.detail or str(error)}"
619
+ )
620
+ findings = [Finding("repo_read", False, detail)]
621
+ if manifest_error is not None:
622
+ findings.append(
623
+ Finding(
624
+ "factory_yaml",
625
+ False,
626
+ f"manifest failed to load: {manifest_error}",
627
+ )
628
+ )
629
+ # A forge refusal is not a reason to stop judging the repo's own tree: init's
630
+ # findings come from the same function `evaluate_repo` calls, so the
631
+ # gitignore and registry checks survive a repo with no remote at all.
632
+ findings.extend(evaluate_init_facts(init_facts))
633
+ return TargetRepoProfile(
634
+ repo=address or target_repo,
635
+ default_branch=default_branch,
636
+ visibility=visibility,
637
+ queue_enabled=False,
638
+ required_checks=(),
639
+ declared_gates=declared_gates,
640
+ findings=tuple(findings),
641
+ passed=False,
642
+ )
643
+
644
+
645
+ @activity.defn
646
+ async def validate_target_repo(request: ValidateTargetRepoInput) -> TargetRepoProfile:
647
+ """US3's preflight activity: gather facts through the seam and judge (FR-010).
648
+
649
+ The thin wrapper: resolves the forge through the injectable `_client_factory`
650
+ seam and hands it to `onboard_target_repo`, which both this activity and the
651
+ offline CLI share. Tests replace the seam with one over a `FakeGh`; the CLI
652
+ resolves a forge against the clone the operator points at.
653
+ """
654
+ return onboard_target_repo(_forge(repo_path=request.target_repo), request.target_repo)