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,537 @@
1
+ """The pure onboarding judgment: what a forge reported + gates → findings.
2
+
3
+ `onboard_target_repo` (the activity) reads a repository through a forge: what it
4
+ calls the repository, what the branch a landing goes into does to a proposal,
5
+ and the clone's committed `factory.yaml`. This module turns those readings into a
6
+ `TargetRepoProfile` whose `findings` say, one check at a time, whether the repo
7
+ is ready for the factory to dispatch against it. It is pure — no forge, no git,
8
+ no filesystem, no clock — so it is table-tested with no fakes at all
9
+ (`tests/test_onboard.py`).
10
+
11
+ 049's US2 made these questions forge-neutral: they were three settings only
12
+ GitHub has, so a target elsewhere failed onboarding not for being unready but
13
+ because they did not apply (D-046). Nothing here spells one forge's
14
+ configuration; `tests/test_forge_readiness.py` reads this source to keep it so.
15
+
16
+ - **`gated_landing`** (Q2) — the branch must refuse a landing until *named*
17
+ checks pass: the gates the manifest declares are the gates the forge runs.
18
+ - **`autonomous_landing`** (Q3) — and then complete the merge itself, separate
19
+ because separately actionable: a branch that gates and waits for a click is
20
+ one this factory cannot land through (D-024).
21
+ - **`factory_yaml`** — the repo must commit a valid, non-empty-gated
22
+ `factory.yaml`. A missing or malformed manifest is a failing finding carrying
23
+ the 002 loader's error, never a pass by default: a verifier that shrugged at
24
+ a broken manifest would find no gates, therefore see nothing fail.
25
+ - **`landing_title`** (Q5) — the commit the forge writes when it lands must
26
+ carry the proposal's title, because `ergane spec landed` reads the story out
27
+ of that subject line. The forge's spelling of the setting travels as evidence
28
+ and its remedy as the fix; neither is decided here.
29
+ - **`gate_check:<gate>`** — every declared gate must have a required check
30
+ named *exactly* after it. The naming convention is the contract between
31
+ `factory.yaml` and the repo's CI; a declared gate with no matching check
32
+ would land a PR that never runs that gate.
33
+ - **`unknown_check:<name>`** — every required check must map back to a declared
34
+ gate. Deterministic gates only is FR-003 made structural: a required check
35
+ that is not a declared gate is a check the factory does not control, and this
36
+ is precisely what keeps the LLM judge out of CI.
37
+
38
+ Each check fails closed: `passed` is the conjunction, and a repo that fails any
39
+ check is rejected for dispatch with instructions for the operator (spec US3 AS2).
40
+ What a forge alone knows arrives on `RepositoryDescription.findings` and is
41
+ appended here without this module knowing what it means (FR-007) — which is how
42
+ a forge-specific rule fails a repository without the judgment learning that
43
+ forge's vocabulary, and is where the rule that a GitHub repo must be public now
44
+ lives.
45
+
46
+ **034 US4 extends this judgment; it does not fork it.** `ergane init --check`
47
+ gathers four more facts and passes them as `init_facts`; the 003 dispatch path
48
+ passes nothing and sees exactly the checks it always saw, so a check added here
49
+ can never start refusing a dispatch it used to allow. Both doors share this
50
+ function, so the gate↔check parity an operator reads at their terminal is the
51
+ same `Finding`, detail and all, the interpreter reads at dispatch (FR-010). The
52
+ added checks are `runtime_root_ignored` (whose detail names the exact
53
+ `.gitignore` line, because a runtime root reaching git history is how a node
54
+ commits its own transcripts onto a landing branch), `runtime_root_migration`
55
+ (only for a repo still on legacy `.factory/`, trap 12), `registry_entry`,
56
+ `landing_branch`, and one `control_plane` summary over 033's probes — a summary
57
+ because the control plane is a property of the host rather than of this repo,
58
+ and `ergane install --verify` renders its checks individually.
59
+
60
+ **034 US6 adds one more**: `roadmap_schedule`, failing when the repo has no
61
+ Temporal schedule, when the one it has is paused, and when that schedule's
62
+ arguments disagree with the committed manifest. Here rather than in a second
63
+ judgment for the reason the rest of this module exists — "ready" is decided in
64
+ exactly one place, and a joined repo with no scheduler is not ready.
65
+ """
66
+
67
+ from __future__ import annotations
68
+
69
+ from dataclasses import dataclass, field
70
+ from typing import Mapping, Sequence
71
+
72
+ from factory.mergequeue.forge import LandingPolicy, RepositoryDescription
73
+ from factory.mergequeue.models import Finding, TargetRepoProfile
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class InitFacts:
78
+ """What `ergane init --check` gathered, for this module to judge (034 FR-010).
79
+
80
+ Facts only — every field is something the CLI read from git, the registry or
81
+ 033's probes — so the judgment over them stays pure and table-testable. The
82
+ `None` fields are "could not be determined", and each renders a *failing*
83
+ finding: `control_plane=()` with no error fails, because unknown readiness is
84
+ not readiness.
85
+ """
86
+
87
+ #: Absolute path of the repository judged; quoted in every remedy.
88
+ repo_root: str
89
+ #: The root this repo has — `.ergane`, or `.factory` when unmigrated.
90
+ runtime_root: str = ".ergane"
91
+ runtime_root_is_legacy: bool = False
92
+ runtime_root_ignored: bool = True
93
+ #: The `.gitignore` a remedy would edit, so the fix is copy-pasteable.
94
+ gitignore: str = ""
95
+ registry_path: str = ""
96
+ #: The slug whose entry points here, or None when no entry does.
97
+ registry_slug: str | None = None
98
+ registry_error: str | None = None
99
+ #: The manifest's landing branch, or None when the manifest did not load.
100
+ landing_branch: str | None = None
101
+ landing_branch_exists: bool = False
102
+ #: 033's probe findings, carried through rather than re-derived.
103
+ control_plane: tuple[Finding, ...] = field(default_factory=tuple)
104
+ control_plane_error: str | None = None
105
+ #: 034 US6. The schedule this repo's slug names, and what is wrong with it.
106
+ #: The defaults fail closed on purpose: a caller that gathered nothing has
107
+ #: not established that a scheduler exists, and a repo with no scheduler is
108
+ #: one whose `ready` specs dispatch nothing.
109
+ schedule_id: str = ""
110
+ schedule_present: bool = False
111
+ schedule_paused: bool = False
112
+ #: One line per declared field the live schedule states differently.
113
+ schedule_drift: tuple[str, ...] = field(default_factory=tuple)
114
+ #: Why the schedule could not be read at all — unreachable engine, or no
115
+ #: registry entry naming the slug that would identify it.
116
+ schedule_error: str | None = None
117
+
118
+
119
+ def evaluate_repo(
120
+ *,
121
+ repo: str,
122
+ reading: "RepositoryDescription",
123
+ policy: "LandingPolicy",
124
+ declared_gates: Sequence[str],
125
+ factory_yaml_error: str | None = None,
126
+ init_facts: "InitFacts | None" = None,
127
+ ) -> TargetRepoProfile:
128
+ """Judge a repository's two readings against what the factory needs.
129
+
130
+ Each argument is something the caller already read; this holds no I/O.
131
+ `reading` answers Q1 and carries what only this forge knows (FR-007),
132
+ appended without understanding it and read *first*, so a forge-authored
133
+ refusal lands where an operator has always found the repo's own health.
134
+ `policy` answers Q2, Q3 and Q5 for the branch a landing goes into and
135
+ carries the checks it requires, so Q4 can be asked without knowing where a
136
+ forge keeps them. `factory_yaml_error` is the 002 loader's message — a
137
+ broken manifest fails, never passes by default.
138
+ """
139
+
140
+ findings: list[Finding] = list(reading.findings)
141
+
142
+ # Q2: the branch must refuse a landing until named checks pass, and Q3: it
143
+ # must then finish the job itself. One forge may answer both from a single
144
+ # setting; that is its coincidence, not a property of forges, so they are
145
+ # two findings and an operator is told which one is missing.
146
+ _gated_landing_finding(findings, policy)
147
+ _autonomous_landing_finding(findings, policy)
148
+
149
+ # The manifest must be present, valid, and declare gates. A broken manifest
150
+ # is a failing finding carrying the loader's error — never a shrug that
151
+ # would read as "no gates, so nothing to fail".
152
+ _manifest_finding(findings, factory_yaml_error)
153
+
154
+ # Q5: the landing commit must carry the proposal's title so the landing
155
+ # grammar survives the merge. A forge that would not say fails closed,
156
+ # matching the factory_yaml precedent.
157
+ _landing_title_finding(findings, policy)
158
+
159
+ # Q4: the gate ↔ check mapping, by name (position is irrelevant).
160
+ declared = set(declared_gates)
161
+ required = set(policy.required_checks)
162
+ for gate in declared:
163
+ _gate_check_finding(findings, gate, gate in required)
164
+ for check in sorted(required - declared):
165
+ _unknown_check_finding(findings, check)
166
+
167
+ # Init's own checks, when the caller gathered them (034 FR-010). Appended
168
+ # rather than interleaved so the 003 report an operator already knows how to
169
+ # read does not change shape underneath them.
170
+ findings.extend(evaluate_init_facts(init_facts))
171
+
172
+ return TargetRepoProfile.from_readiness(
173
+ repo=repo,
174
+ reading=reading,
175
+ policy=policy,
176
+ declared_gates=tuple(declared_gates),
177
+ findings=tuple(findings),
178
+ passed=all(f.passed for f in findings),
179
+ )
180
+
181
+
182
+ # --- the checks ----------------------------------------------------------------
183
+
184
+
185
+ def _gated_landing_finding(findings: list[Finding], policy: "LandingPolicy") -> None:
186
+ if policy.gates_on_named_checks:
187
+ findings.append(Finding(
188
+ "gated_landing", True,
189
+ f"{policy.branch} refuses a landing until its named checks pass",
190
+ ))
191
+ else:
192
+ findings.append(Finding(
193
+ "gated_landing", False,
194
+ f"{policy.branch!r} does not refuse a landing until named checks pass, "
195
+ "so a change could land with no gate having run; configure the branch "
196
+ "to require the gates this repository declares",
197
+ ))
198
+
199
+
200
+ def _autonomous_landing_finding(
201
+ findings: list[Finding], policy: "LandingPolicy"
202
+ ) -> None:
203
+ if policy.lands_without_a_human:
204
+ findings.append(Finding(
205
+ "autonomous_landing", True,
206
+ f"{policy.branch} completes a landing without a human",
207
+ ))
208
+ else:
209
+ findings.append(Finding(
210
+ "autonomous_landing", False,
211
+ f"{policy.branch!r} will not complete a landing without a human; this "
212
+ "factory has no human in the loop by construction (D-024), so configure "
213
+ "the branch to land a proposal itself once its named checks pass",
214
+ ))
215
+
216
+
217
+ def _manifest_finding(
218
+ findings: list[Finding], factory_yaml_error: str | None
219
+ ) -> None:
220
+ if factory_yaml_error is None:
221
+ findings.append(Finding("factory_yaml", True, "manifest is valid"))
222
+ else:
223
+ findings.append(
224
+ Finding(
225
+ "factory_yaml",
226
+ False,
227
+ f"manifest failed to load: {factory_yaml_error} — fix the "
228
+ "manifest so the repo declares its gates",
229
+ )
230
+ )
231
+
232
+
233
+ def _landing_title_finding(findings: list[Finding], policy: "LandingPolicy") -> None:
234
+ if policy.landing_title_from_proposal:
235
+ findings.append(Finding(
236
+ "landing_title", True, "a landing commit takes the proposal's title",
237
+ ))
238
+ return
239
+
240
+ if policy.landing_title_source is None:
241
+ observed = "the forge would not report its title source, so it is unreadable"
242
+ else:
243
+ observed = f"the forge reports its title source as {policy.landing_title_source!r}"
244
+
245
+ findings.append(Finding(
246
+ "landing_title", False,
247
+ f"a landing commit will not take the proposal's title — {observed}; "
248
+ "`ergane spec landed` reads a story out of the landed subject line, so a "
249
+ "landing titled from anything else is work this factory cannot see it did"
250
+ + (f" — {policy.landing_title_remedy}" if policy.landing_title_remedy else ""),
251
+ ))
252
+
253
+
254
+ def _gate_check_finding(findings: list[Finding], gate: str, matched: bool) -> None:
255
+ if matched:
256
+ findings.append(
257
+ Finding(f"gate_check:{gate}", True, f"required check '{gate}' exists")
258
+ )
259
+ else:
260
+ findings.append(
261
+ Finding(
262
+ f"gate_check:{gate}",
263
+ False,
264
+ f"gate '{gate}' is declared in factory.yaml but the landing "
265
+ f"branch requires no check named '{gate}' — add it to the "
266
+ "branch's required checks so the forge runs the gate the "
267
+ "factory declares",
268
+ )
269
+ )
270
+
271
+
272
+ def _unknown_check_finding(findings: list[Finding], check: str) -> None:
273
+ findings.append(
274
+ Finding(
275
+ f"unknown_check:{check}",
276
+ False,
277
+ f"required check '{check}' is not a declared gate in factory.yaml — "
278
+ "deterministic gates only (FR-003): a required check must map to a "
279
+ "declared gate, so the LLM judge can never be a CI check",
280
+ )
281
+ )
282
+
283
+
284
+ # --- 034 US4: the checks init's own facts answer -------------------------------
285
+
286
+
287
+ def evaluate_init_facts(init_facts: "InitFacts | None") -> tuple[Finding, ...]:
288
+ """Judge the facts `ergane init --check` gathered — or nothing, when it did not.
289
+
290
+ Split out of `evaluate_repo` for one reason: `onboard_target_repo` short
291
+ circuits into `_profile_from_gh_failure` when `gh` cannot read the repo, and
292
+ a repo with no GitHub remote is exactly the repo whose *local* findings
293
+ matter most. Both paths call this, so a `gh` refusal never masks the
294
+ gitignore check ("no failure masking another").
295
+
296
+ Every check renders unconditionally: no early return, no exception skipping
297
+ the rest. An unknown answer is a failing finding, never an absent one.
298
+ """
299
+ if init_facts is None:
300
+ return ()
301
+
302
+ findings: list[Finding] = []
303
+ _runtime_root_findings(findings, init_facts)
304
+ _registry_finding(findings, init_facts)
305
+ _landing_branch_finding(findings, init_facts)
306
+ _control_plane_finding(findings, init_facts)
307
+ _roadmap_schedule_finding(findings, init_facts)
308
+ return tuple(findings)
309
+
310
+
311
+ def _runtime_root_findings(findings: list[Finding], facts: "InitFacts") -> None:
312
+ """The runtime root must be ignored by git — the root this repo *has*.
313
+
314
+ Trap 12: a repo that never ran `ergane repo migrate-runtime-root` keeps its
315
+ state in `.factory/`. Demanding `.ergane/` there reports a failure the
316
+ operator cannot act on while missing the directory actually holding their
317
+ evidence, so the resolved root is judged and the legacy name earns a second
318
+ finding naming its remedy.
319
+ """
320
+ root = facts.runtime_root
321
+ if facts.runtime_root_ignored:
322
+ findings.append(
323
+ Finding("runtime_root_ignored", True, f"{root}/ is ignored by git")
324
+ )
325
+ else:
326
+ findings.append(
327
+ Finding(
328
+ "runtime_root_ignored",
329
+ False,
330
+ f"{root}/ is not ignored by git; add the line `{root}/` to "
331
+ f"{facts.gitignore} — the runtime root holds worktrees, session "
332
+ "transcripts and verification evidence, and one that reaches git "
333
+ "history lands megabytes of agent output on a landing branch",
334
+ )
335
+ )
336
+
337
+ if facts.runtime_root_is_legacy:
338
+ findings.append(
339
+ Finding(
340
+ "runtime_root_migration",
341
+ False,
342
+ f"this repository's runtime root is still the legacy {root}/; run "
343
+ "`ergane repo migrate-runtime-root` to move it to .ergane/",
344
+ )
345
+ )
346
+
347
+
348
+ def _registry_finding(findings: list[Finding], facts: "InitFacts") -> None:
349
+ """The engine must know where this repo is; the registry is how it knows."""
350
+ if facts.registry_error is not None:
351
+ findings.append(
352
+ Finding(
353
+ "registry_entry",
354
+ False,
355
+ f"the engine registry {facts.registry_path} could not be read: "
356
+ f"{facts.registry_error} — it is a cache: re-derive it with "
357
+ f"`ergane repo rebuild {facts.repo_root}`",
358
+ )
359
+ )
360
+ return
361
+
362
+ if facts.registry_slug is not None:
363
+ findings.append(
364
+ Finding(
365
+ "registry_entry",
366
+ True,
367
+ f"registered as '{facts.registry_slug}' in {facts.registry_path}",
368
+ )
369
+ )
370
+ return
371
+
372
+ findings.append(
373
+ Finding(
374
+ "registry_entry",
375
+ False,
376
+ f"no entry in the engine registry {facts.registry_path} points at "
377
+ f"{facts.repo_root}; run `ergane init` here to register it — the "
378
+ "committed manifest is what makes a repo managed, but the engine "
379
+ "cannot enumerate repos it has no pointer to",
380
+ )
381
+ )
382
+
383
+
384
+ def _landing_branch_finding(findings: list[Finding], facts: "InitFacts") -> None:
385
+ """The branch the manifest declares must be a branch the repo has."""
386
+ if facts.landing_branch is None:
387
+ findings.append(
388
+ Finding(
389
+ "landing_branch",
390
+ False,
391
+ "the landing branch could not be judged: this repository's "
392
+ "manifest did not load (see the factory_yaml finding)",
393
+ )
394
+ )
395
+ return
396
+
397
+ branch = facts.landing_branch
398
+ if facts.landing_branch_exists:
399
+ findings.append(
400
+ Finding("landing_branch", True, f"landing branch '{branch}' exists")
401
+ )
402
+ return
403
+
404
+ findings.append(
405
+ Finding(
406
+ "landing_branch",
407
+ False,
408
+ f"the manifest declares `landing_branch: {branch}` but "
409
+ f"{facts.repo_root} has no such branch; create it with "
410
+ f"`git -C {facts.repo_root} branch {branch}`, or declare the branch "
411
+ "this repository actually lands on",
412
+ )
413
+ )
414
+
415
+
416
+ def _control_plane_finding(findings: list[Finding], facts: "InitFacts") -> None:
417
+ """One summary finding over 033's probes, carrying their detail through.
418
+
419
+ Fails closed three ways: the probes could not run, some probe failed, or no
420
+ probe ran at all. The last is the one worth naming — "reachable" because
421
+ nothing was probed would be worse than no check.
422
+ """
423
+ if facts.control_plane_error is not None:
424
+ findings.append(
425
+ Finding(
426
+ "control_plane",
427
+ False,
428
+ f"the control plane could not be probed: {facts.control_plane_error}"
429
+ " — `ergane install --verify` reports each subsystem separately",
430
+ )
431
+ )
432
+ return
433
+
434
+ if not facts.control_plane:
435
+ findings.append(
436
+ Finding(
437
+ "control_plane",
438
+ False,
439
+ "no control-plane probe ran, so control-plane readiness is "
440
+ "unknown; run `ergane install --verify` to see why",
441
+ )
442
+ )
443
+ return
444
+
445
+ failed: list[Finding] = []
446
+ for probe in facts.control_plane:
447
+ if not probe.passed:
448
+ failed.append(probe)
449
+
450
+ if not failed:
451
+ names: list[str] = []
452
+ for probe in facts.control_plane:
453
+ names.append(probe.check)
454
+ findings.append(
455
+ Finding(
456
+ "control_plane",
457
+ True,
458
+ f"control plane reachable: {len(names)} probes passed "
459
+ f"({', '.join(names)})",
460
+ )
461
+ )
462
+ return
463
+
464
+ reasons: list[str] = []
465
+ for probe in failed:
466
+ reasons.append(f"{probe.check}: {probe.detail}")
467
+ findings.append(
468
+ Finding(
469
+ "control_plane",
470
+ False,
471
+ f"{len(failed)} of {len(facts.control_plane)} control-plane probes "
472
+ f"failed — {'; '.join(reasons)}",
473
+ )
474
+ )
475
+
476
+
477
+ def _roadmap_schedule_finding(findings: list[Finding], facts: "InitFacts") -> None:
478
+ """The repo must have a scheduler, running, pointed here (034 FR-016).
479
+
480
+ One finding whose detail names *every* problem found rather than the first:
481
+ a schedule both paused and drifted has two things wrong with it, and naming
482
+ only one would be the masking this judgment forbids. Each failure ends in
483
+ the command that fixes it — the operator reading it is at a terminal.
484
+ """
485
+ if facts.schedule_error is not None:
486
+ findings.append(
487
+ Finding(
488
+ "roadmap_schedule",
489
+ False,
490
+ "this repository's roadmap schedule could not be judged: "
491
+ f"{facts.schedule_error}",
492
+ )
493
+ )
494
+ return
495
+
496
+ if not facts.schedule_present:
497
+ findings.append(
498
+ Finding(
499
+ "roadmap_schedule",
500
+ False,
501
+ f"no roadmap schedule '{facts.schedule_id}' exists on the control "
502
+ f"plane, so no tick will ever dispatch {facts.repo_root}'s specs — "
503
+ f"run `ergane init {facts.repo_root}` to create it; flipping a spec "
504
+ "to `ready` without one does nothing, with no error",
505
+ )
506
+ )
507
+ return
508
+
509
+ problems: list[str] = []
510
+ if facts.schedule_paused:
511
+ problems.append(
512
+ "it is paused, so no tick will start a run — resume it with "
513
+ "`ergane roadmap resume <specs root>` when you mean dispatch to run"
514
+ )
515
+ for line in facts.schedule_drift:
516
+ problems.append(line)
517
+
518
+ if not problems:
519
+ findings.append(
520
+ Finding(
521
+ "roadmap_schedule",
522
+ True,
523
+ f"roadmap schedule '{facts.schedule_id}' is running and matches "
524
+ "the manifest",
525
+ )
526
+ )
527
+ return
528
+
529
+ remedy = f" — re-run `ergane init {facts.repo_root}`" if facts.schedule_drift else ""
530
+ findings.append(
531
+ Finding(
532
+ "roadmap_schedule",
533
+ False,
534
+ f"roadmap schedule '{facts.schedule_id}' disagrees with this "
535
+ f"repository: {'; '.join(problems)}{remedy}",
536
+ )
537
+ )