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,617 @@
1
+ """The activity surface of verification.
2
+
3
+ `factory/verify/` is a library of pure functions and one-purpose runners; this
4
+ module is where they become things the orchestrator can call, which means it
5
+ owns exactly two concerns the library deliberately does not: reading the world
6
+ at a known moment, and turning a library exception into an error a workflow can
7
+ branch on without reading prose.
8
+
9
+ `snapshot_criteria` is the first of those calls and the only one that runs
10
+ *before* an agent does any work. What it returns is the node's goalposts for the
11
+ rest of its life (FR-010) — every later attempt is judged against this value,
12
+ not against whatever the spec file says by then — so it stamps the moment it
13
+ read and hashes the bytes it read, and the difference between that hash and a
14
+ later one is the whole of drift detection (R8).
15
+
16
+ Its two failure modes are why the parser raises two different exception classes.
17
+ A spec the grammar refuses and a spec file that is not there both mean "this
18
+ node cannot be verified", but they send an operator to different places: one
19
+ goes and edits a requirement, the other fixes a wrong `specs_root` or a
20
+ misspelled feature. They come back as `CRITERIA_PARSE_FAILED` and
21
+ `CRITERIA_FILE_MISSING` so the workflow can tell them apart. Both are
22
+ non-retryable — neither a malformed spec nor an absent file becomes well-formed
23
+ by being read again a second later, and the ladder's retry budget exists for
24
+ proxies and worktrees, not for typos.
25
+
26
+ The parse error carries the parser's message verbatim. That message names the
27
+ one offending requirement and no sibling, and it is the operator's entire
28
+ diagnosis; an error that only said "spec did not parse" would make this activity
29
+ strictly less useful than the library call it wraps.
30
+
31
+ The four activities that follow verify an attempt once the agent has stopped, and
32
+ between them they draw the same line twice, in opposite directions:
33
+
34
+ - **A gate that fails is data.** `run_gates` returns one `GateResult` per declared
35
+ gate whatever they did, and one `CONFIG_ERROR` result for a manifest it cannot
36
+ use, because an exception here would cost the attempt the very output the retry
37
+ prompt quotes (FR-006) and an empty gate list is what a naive verdict reads as
38
+ "nothing failed" (SC-002).
39
+ - **A worktree that vanished is not.** `check_output` raises `WORKTREE_MISSING`
40
+ rather than reporting the clean worktree an absent directory resembles:
41
+ charging an infrastructure failure to the agent's attempt budget is the same
42
+ mistake as passing on an empty diff, pointed the other way. `run_judge` draws
43
+ the line in the same place — `JUDGE_UNAVAILABLE` is an outage, and whether it
44
+ becomes a gates-only PASS is `compose_result`'s decision, not this module's.
45
+
46
+ `record_verification` is the one write. It refuses a row it cannot attribute
47
+ before writing anything (the discipline component 1's teardown follows: every
48
+ rollup and every escalation summary reads this store by epic and node, so a row
49
+ missing one is not thin, it is absent from the query that would have shown the
50
+ gap), and it re-hashes the spec file to set `criteria_drift` when the caller has
51
+ not already found it — which is what makes drift a fact about the file rather
52
+ than a race.
53
+
54
+ Credentials are conspicuously absent from all of it. The judge authenticates with
55
+ the per-attempt virtual key component 1 minted, which arrives in the dispatch;
56
+ `LITELLM_MASTER_KEY` sits in the same worker environment and has no path into
57
+ these calls (FR-009).
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ import asyncio
63
+ import hashlib
64
+ import os
65
+ from contextlib import closing
66
+ from dataclasses import dataclass, field, replace
67
+ from datetime import datetime, timezone
68
+ from pathlib import Path
69
+
70
+ import httpx
71
+ from temporalio import activity
72
+ from temporalio.exceptions import ApplicationError
73
+
74
+ from factory.verify import diffcheck, gates, judge, store
75
+ from factory.verify.criteria import CriteriaParseError, load_criteria
76
+ from factory.verify.judge import DEFAULT_MAX_JUDGE_RETRIES
77
+ from factory.verify.models import (
78
+ CriteriaSet,
79
+ GateResult,
80
+ JudgeVerdict,
81
+ OutputCheck,
82
+ VerificationResult,
83
+ )
84
+ from factory.env import (
85
+ ERGANE_VERIFICATION_DB_PATH_ENV,
86
+ FACTORY_VERIFICATION_DB_PATH_ENV,
87
+ resolve_env_path,
88
+ )
89
+ from factory.verify.question import (
90
+ QUESTION_HEADING, # noqa: F401 -- re-exported for the prompt contract (T008)
91
+ QuestionMarker,
92
+ TranscriptReadError,
93
+ detect_operator_question,
94
+ )
95
+
96
+ #: The activity error type for a spec the grammar refuses (spec US1). The
97
+ #: message is the parser's, naming the one requirement at fault.
98
+ CRITERIA_PARSE_FAILED = "CRITERIA_PARSE_FAILED"
99
+
100
+ #: The activity error type for a spec file that is not where the dispatch said
101
+ #: it would be — a wiring mistake, not a spec to go fix.
102
+ CRITERIA_FILE_MISSING = "CRITERIA_FILE_MISSING"
103
+
104
+ #: The activity error type for a worktree that is not there, or that git refuses
105
+ #: to read — infrastructure, not a verdict (contracts/activities.md).
106
+ WORKTREE_MISSING = "WORKTREE_MISSING"
107
+
108
+ #: The activity error type for a proxy that stayed down through the judge's own
109
+ #: HTTP retries. Retryable, unlike every other error here: a backend that is down
110
+ #: now may be up in a minute.
111
+ JUDGE_UNAVAILABLE = "JUDGE_UNAVAILABLE"
112
+
113
+ #: The activity error type for a verification result no rollup could account for.
114
+ ATTRIBUTION_INCOMPLETE = "ATTRIBUTION_INCOMPLETE"
115
+
116
+ #: The activity error type for a transcript the marker detector could not read
117
+ #: (008-US1). Infrastructure, not a verdict: a vanished or unreadable archive must
118
+ #: never be read as a clean attempt that happened to ask nothing, so it raises the
119
+ #: way `WORKTREE_MISSING` does rather than returning "no question" (FR-010).
120
+ DETECT_FAILED = "DETECT_FAILED"
121
+
122
+ #: The system of record's filename under `<specs_root>/<feature>/` (D-023).
123
+ SPEC_FILENAME = "spec.md"
124
+
125
+ #: Where the evidence store lives when the worker does not say otherwise —
126
+ #: beside component 1's ledger under the same `.factory/` (quickstart §5).
127
+ DEFAULT_VERIFICATION_DB_PATH = ".factory/verification.db"
128
+
129
+ VERIFICATION_DB_PATH_ENV = "FACTORY_VERIFICATION_DB_PATH" # legacy re-export
130
+ ERGANE_VERIFICATION_DB_PATH_ENV = ERGANE_VERIFICATION_DB_PATH_ENV # re-export
131
+
132
+ #: Pause between the judge's HTTP attempts, at module scope so a test can zero it
133
+ #: without waiting out a backoff to prove an outage is an outage.
134
+ JUDGE_RETRY_BACKOFF_S = judge.DEFAULT_RETRY_BACKOFF_S
135
+
136
+ #: The dimensions the evidence store is queried by (FR-002, SC-005) — the ones
137
+ #: whose absence a reader cannot detect, because the row is simply not in the
138
+ #: answer they got.
139
+ _ATTRIBUTION_FIELDS = ("epic_id", "node_id", "spec_ref")
140
+
141
+
142
+ def judge_transport() -> httpx.AsyncBaseTransport | None:
143
+ """The transport the judge's chat completion goes out over (None = the network).
144
+
145
+ A seam, not a factory: tests replace it to point the one HTTP call at a fake
146
+ proxy without supplying a credential, so the per-attempt virtual key still has
147
+ to arrive from the dispatch and a wrong one is still refused.
148
+ """
149
+ return None
150
+
151
+
152
+ @dataclass(frozen=True)
153
+ class SnapshotCriteriaInput:
154
+ """A node's dispatch, in the terms the criteria parser needs.
155
+
156
+ `requirement_keys` is what this node owes — empty means the whole feature,
157
+ which is a legitimate dispatch and must not quietly become "no criteria"
158
+ (a node verified against nothing passes on an empty diff, the very failure
159
+ FR-004 exists to prevent). `spec_ref` is component 1's work-attribution key
160
+ and is carried through untouched: the activity has no business inventing one.
161
+ """
162
+
163
+ specs_root: str
164
+ feature: str
165
+ spec_ref: str
166
+ requirement_keys: list[str] = field(default_factory=list)
167
+
168
+
169
+ @activity.defn
170
+ async def snapshot_criteria(request: SnapshotCriteriaInput) -> CriteriaSet:
171
+ """Take the node's acceptance criteria from the spec, once (FR-010).
172
+
173
+ A pure read and parse, safe for Temporal to retry: two runs against
174
+ unchanged bytes agree on everything but the timestamp.
175
+
176
+ Raises `CRITERIA_FILE_MISSING` when there is no `spec.md` at the dispatched
177
+ path, `CRITERIA_PARSE_FAILED` when there is one and the grammar refuses it.
178
+ Both are non-retryable.
179
+ """
180
+ path = spec_path(request.specs_root, request.feature)
181
+ try:
182
+ return load_criteria(
183
+ path,
184
+ feature=request.feature,
185
+ spec_ref=request.spec_ref,
186
+ requirement_keys=request.requirement_keys,
187
+ )
188
+ except (FileNotFoundError, NotADirectoryError, IsADirectoryError) as exc:
189
+ # Every one of these means the same thing to the operator: nothing
190
+ # readable at that path. The path is in the message because the root,
191
+ # the feature name and the filename are all suspects.
192
+ raise ApplicationError(
193
+ f"no feature spec at {path}",
194
+ type=CRITERIA_FILE_MISSING,
195
+ non_retryable=True,
196
+ ) from exc
197
+ except CriteriaParseError as exc:
198
+ # Verbatim: the parser already named the offending requirement and said
199
+ # what is wrong with it, and that is the whole diagnosis.
200
+ raise ApplicationError(
201
+ str(exc),
202
+ type=CRITERIA_PARSE_FAILED,
203
+ non_retryable=True,
204
+ ) from exc
205
+
206
+
207
+ def spec_path(specs_root: str, feature: str) -> Path:
208
+ """The file D-023 designates as the system of record for one feature."""
209
+ return Path(specs_root) / feature / SPEC_FILENAME
210
+
211
+
212
+ # --- gates ------------------------------------------------------------------
213
+
214
+
215
+ @dataclass(frozen=True)
216
+ class RunGatesInput:
217
+ """Which worktree to check, and which manifest says how.
218
+
219
+ `factory_yaml_path` defaults to the worktree's own committed manifest, which
220
+ is the only shape the factory dispatches; it is settable because conflating
221
+ the two would make an operator's manifest unreadable from anywhere else.
222
+ `timeout_overrides` is the verification config an operator is holding right
223
+ now, and it wins over what the repo declared.
224
+ """
225
+
226
+ worktree_path: str
227
+ factory_yaml_path: str | None = None
228
+ timeout_overrides: dict[str, int] = field(default_factory=dict)
229
+
230
+
231
+ class _HeartbeatingExecutor:
232
+ """A gate executor that tells Temporal which gate is starting, then delegates.
233
+
234
+ A repo's test suite may legitimately run for the full 600s per-gate deadline,
235
+ so an activity silent until the last gate finished would be killed by its own
236
+ heartbeat timeout long before it had an answer. Reporting sits in the
237
+ `GateExecutor` seam (R3) rather than inside the runner because progress is a
238
+ property of running the gates as an activity, not of running them at all.
239
+ """
240
+
241
+ def __init__(
242
+ self, inner: gates.GateExecutor, loop: asyncio.AbstractEventLoop
243
+ ) -> None:
244
+ self._inner = inner
245
+ self._loop = loop
246
+
247
+ def run(self, invocation: gates.GateInvocation) -> gates.ExecutionOutcome:
248
+ # This runs inside `asyncio.to_thread`'s worker thread. The activity
249
+ # context rides in on the copied contextvars, but an async activity's
250
+ # heartbeat must be delivered from the event-loop thread — called here
251
+ # it dies in `asyncio.create_task` with no running loop (found live
252
+ # 2026-08-05; the test environment's synchronous heartbeat cannot see
253
+ # the difference). `call_soon_threadsafe` re-enters the loop carrying
254
+ # this thread's context, activity context included — and delivers the
255
+ # beat while the gate below still owns this thread, which is the whole
256
+ # point of beating per gate.
257
+ self._loop.call_soon_threadsafe(activity.heartbeat, invocation.name)
258
+ return self._inner.run(invocation)
259
+
260
+
261
+ @activity.defn
262
+ async def run_gates(request: RunGatesInput) -> list[GateResult]:
263
+ """Run the target repo's declared gates and report every one of them (FR-002).
264
+
265
+ Never raises for a gate: a non-zero exit, a deadline and an unusable
266
+ `factory.yaml` all come back as `GateResult`s, because failures are the
267
+ evidence the retry prompt quotes and the verdict truth table reads. A missing
268
+ or malformed manifest is one `CONFIG_ERROR` result, never zero results.
269
+
270
+ Runs in a worker thread: gates are subprocesses that own the wall clock for as
271
+ long as their deadline allows, and blocking the event loop for ten minutes
272
+ would stall every other activity this worker is running.
273
+ """
274
+ return await asyncio.to_thread(
275
+ gates.run_gates,
276
+ request.worktree_path,
277
+ manifest_path=request.factory_yaml_path,
278
+ executor=_HeartbeatingExecutor(
279
+ gates.resolve_gate_executor(
280
+ request.worktree_path,
281
+ manifest_path=request.factory_yaml_path,
282
+ ),
283
+ asyncio.get_running_loop(),
284
+ ),
285
+ timeout_overrides=request.timeout_overrides,
286
+ )
287
+
288
+
289
+ # --- output check -----------------------------------------------------------
290
+
291
+
292
+ @dataclass(frozen=True)
293
+ class CheckOutputInput:
294
+ """The anti-rubber-stamp question, in the terms the persona registry sets.
295
+
296
+ `write_scope` crosses this boundary as a plain string (component 1's
297
+ `WriteScope` value) and decides which half of the evidence is the criterion:
298
+ the diff, or the declared artifacts (R7).
299
+
300
+ `base_ref` is the node's branch point (D-027): "did the attempt do work" is
301
+ answered against where the node began, because an agent following the inner
302
+ ralph contract commits as it goes — against HEAD, a fully committed attempt
303
+ reads as no work at all, and committed out-of-scope changes read as none.
304
+ The default is R7's original semantics, correct only for a caller that
305
+ knows nothing committed mid-attempt; the epic workflow always passes the
306
+ prepared worktree's real base, and the interpreter suite asserts it does.
307
+ """
308
+
309
+ worktree_path: str
310
+ write_scope: str
311
+ base_ref: str = "HEAD"
312
+ expected_artifacts: list[str] = field(default_factory=list)
313
+
314
+
315
+ @activity.defn
316
+ async def check_output(request: CheckOutputInput) -> OutputCheck:
317
+ """Decide whether the node proved it did any work at all (FR-004).
318
+
319
+ Read-only, and safe to retry. Raises `WORKTREE_MISSING` when the worktree is
320
+ gone or git cannot read it: an absent directory resembles a clean worktree
321
+ exactly, and reporting one would charge an infrastructure failure to the
322
+ agent's attempt budget. It stays retryable for the same reason — a worktree
323
+ the worker could not reach this second is the workflow's retry budget to
324
+ spend, not the ladder's.
325
+ """
326
+ try:
327
+ return await asyncio.to_thread(
328
+ diffcheck.check_output,
329
+ request.worktree_path,
330
+ request.write_scope,
331
+ request.expected_artifacts,
332
+ request.base_ref,
333
+ )
334
+ except diffcheck.WorktreeMissingError as exc:
335
+ # The path travels with the error: by the time an operator reads this
336
+ # they are holding a node id, not a directory.
337
+ raise ApplicationError(str(exc), type=WORKTREE_MISSING) from exc
338
+
339
+
340
+ # --- operator-question detection (008-US1) ----------------------------------
341
+
342
+
343
+ @dataclass(frozen=True)
344
+ class DetectQuestionInput:
345
+ """The archived transcript the marker detector reads (D-018's evidence).
346
+
347
+ The detector reads ``stdout.log`` from the attempt's transcript directory —
348
+ the same ``AdapterResult.transcript_path`` the adapter points at the archive
349
+ on every termination path. The workflow owns the attribution (epic/node/
350
+ attempt) and the detector owns nothing but the read, so the only input it
351
+ needs is the path. No verdict travels in or out: the marker's only possible
352
+ effect is to park the node, never to grade it (FR-010).
353
+ """
354
+
355
+ transcript_path: str
356
+
357
+
358
+ @activity.defn
359
+ async def detect_operator_question_activity(
360
+ request: DetectQuestionInput,
361
+ ) -> QuestionMarker:
362
+ """Scan the archived transcript for the OPERATOR QUESTION marker (008-US1).
363
+
364
+ Read-only, and safe to retry. Raises ``DETECT_FAILED`` when the transcript is
365
+ missing or unreadable — the same line ``check_output`` draws for a vanished
366
+ worktree: an absent archive resembles a marker-free attempt exactly, and
367
+ reporting one would let an infrastructure failure masquerade as a clean
368
+ attempt that happened to ask nothing. ``None`` (no marker, or a marker with
369
+ an empty body) is the common case the workflow grades as today; an
370
+ unreadable archive is the one outcome that is neither QUESTION nor the common
371
+ case, and it stays retryable so the workflow's retry budget — not the
372
+ ladder's — spends it.
373
+ """
374
+ try:
375
+ marker = await asyncio.to_thread(
376
+ detect_operator_question, Path(request.transcript_path)
377
+ )
378
+ except TranscriptReadError as exc:
379
+ raise ApplicationError(str(exc), type=DETECT_FAILED) from exc
380
+ return marker if marker is not None else QuestionMarker(is_question=False)
381
+
382
+
383
+ # --- judge ------------------------------------------------------------------
384
+
385
+
386
+ @dataclass(frozen=True)
387
+ class RunJudgeInput:
388
+ """One bounded judge invocation (R4).
389
+
390
+ `virtual_key` is the per-attempt key component 1 minted for persona `judge`
391
+ and `model_alias` is that persona's registry alias — both arrive in the
392
+ dispatch, because this module reads no credential from the environment and
393
+ code never names a model (constitution V, VII). `judge_attempt` is the
394
+ caller's count: it is what turns an unreadable response into a retry now and
395
+ a FAIL once the cap is reached (SC-003).
396
+ """
397
+
398
+ criteria: CriteriaSet
399
+ diff_text: str
400
+ virtual_key: str
401
+ proxy_url: str
402
+ model_alias: str
403
+ judge_attempt: int = 1
404
+ prior_feedback: str | None = None
405
+ max_judge_retries: int = DEFAULT_MAX_JUDGE_RETRIES
406
+
407
+
408
+ @activity.defn
409
+ async def run_judge(request: RunJudgeInput) -> JudgeVerdict:
410
+ """Score the node's diff against its dispatched scenarios (FR-003).
411
+
412
+ Called only when `judge_required` says a verdict is still open — a node whose
413
+ lint gate failed in two seconds must not cost a completion to find that out.
414
+
415
+ A response the strict parser refuses is not an error: it consumes one judge
416
+ attempt and comes back as RETRY, or as FAIL once `judge_attempt` has reached
417
+ the cap, carrying the parse failure as the next prompt's feedback. Only an
418
+ unreachable proxy raises, as `JUDGE_UNAVAILABLE`, and it is retryable —
419
+ falling back to a gates-only verdict is `compose_result`'s decision to make
420
+ after the workflow's retry budget is spent, not this activity's.
421
+ """
422
+ try:
423
+ return await judge.run_judge(
424
+ request.criteria,
425
+ request.diff_text,
426
+ proxy_url=request.proxy_url,
427
+ virtual_key=request.virtual_key,
428
+ model_alias=request.model_alias,
429
+ prior_feedback=request.prior_feedback,
430
+ judge_attempt=request.judge_attempt,
431
+ max_judge_retries=request.max_judge_retries,
432
+ transport=judge_transport(),
433
+ retry_backoff_s=JUDGE_RETRY_BACKOFF_S,
434
+ )
435
+ except judge.JudgeUnavailableError as exc:
436
+ # The library already scrubbed its own message; nothing is added here
437
+ # that was not already safe to write down (FR-009).
438
+ raise ApplicationError(str(exc), type=JUDGE_UNAVAILABLE) from exc
439
+
440
+
441
+ # --- recording --------------------------------------------------------------
442
+
443
+
444
+ @dataclass(frozen=True)
445
+ class RecordVerificationInput:
446
+ """One attempt's composed evidence, plus the spec file to re-hash.
447
+
448
+ `criteria_source_path` is optional because an interpreter may not be holding
449
+ the path; without it the caller's `criteria_drift` stands, which is the only
450
+ honest answer available when there is nothing left to compare against.
451
+ """
452
+
453
+ result: VerificationResult
454
+ criteria_source_path: str | None = None
455
+
456
+
457
+ @dataclass(frozen=True)
458
+ class RecordedVerification:
459
+ """What the store did: the row's stable id, and the drift it ended up with.
460
+
461
+ `criteria_drift` comes back because the activity may have recomputed it, and
462
+ the caller's next step — the escalation summary, the retry prompt — should
463
+ read the same value the row does.
464
+ """
465
+
466
+ row_id: int
467
+ criteria_drift: bool
468
+
469
+
470
+ @activity.defn
471
+ async def record_verification(
472
+ request: RecordVerificationInput,
473
+ ) -> RecordedVerification:
474
+ """Write one attempt's evidence to `.factory/verification.db` (invariant 3).
475
+
476
+ Upserts on `(epic_id, node_id, attempt, form)`, so a Temporal re-run lands on
477
+ the first run's row and the returned id is stable.
478
+
479
+ Raises `ATTRIBUTION_INCOMPLETE`, before writing anything, for a result that no
480
+ rollup could account for. Non-retryable: the dispatch is wrong, not the
481
+ moment, and a rerun rebuilds exactly the same unaccountable row.
482
+ """
483
+ _require_attribution(request.result)
484
+ _require_provenance_when_external(request.result)
485
+ result = _with_drift(request.result, request.criteria_source_path)
486
+
487
+ with closing(store.connect(_store_path())) as conn:
488
+ row_id = store.upsert_result(conn, result)
489
+
490
+ return RecordedVerification(row_id=row_id, criteria_drift=result.criteria_drift)
491
+
492
+
493
+ def _require_provenance_when_external(result: VerificationResult) -> None:
494
+ """Refuse an external completion that would store no provenance (035-US1 FR-005).
495
+
496
+ The column is nullable in the schema because agent-completed work has none,
497
+ but the external-completion path must not be able to write a row without it.
498
+ """
499
+ if result.provenance is None:
500
+ return
501
+ if not result.provenance.strip():
502
+ raise ApplicationError(
503
+ "external completion provenance must be a non-empty string",
504
+ type=ATTRIBUTION_INCOMPLETE,
505
+ non_retryable=True,
506
+ )
507
+
508
+
509
+ def _require_attribution(result: VerificationResult) -> None:
510
+ """Refuse a verification row that could never be found again (SC-005).
511
+
512
+ Blank counts as absent: the store's `NOT NULL` would accept `""` and every
513
+ per-node query would then report a nameless group. `attempt` is checked the
514
+ same way for the same reason — the DDL's `CHECK (attempt >= 1)` would refuse
515
+ a zero anyway, but as an opaque `IntegrityError` rather than as the name of
516
+ the dimension the dispatch dropped.
517
+ """
518
+ missing = [name for name in _ATTRIBUTION_FIELDS if _is_blank(getattr(result, name))]
519
+ if not isinstance(result.attempt, int) or result.attempt < 1:
520
+ missing.append("attempt")
521
+ if not missing:
522
+ return
523
+
524
+ raise ApplicationError(
525
+ f"unattributable verification result for epic {result.epic_id!r} "
526
+ f"node {result.node_id!r}: missing {', '.join(sorted(missing))}",
527
+ type=ATTRIBUTION_INCOMPLETE,
528
+ non_retryable=True,
529
+ )
530
+
531
+
532
+ @dataclass(frozen=True)
533
+ class RecordExternalCompletionInput:
534
+ """One external-completion signal decision to log (035-US1)."""
535
+
536
+ epic_id: str
537
+ node_id: str
538
+ branch: str
539
+ provenance: str
540
+ accepted: bool
541
+ reason: str | None
542
+
543
+
544
+ @activity.defn
545
+ async def record_external_completion(
546
+ request: RecordExternalCompletionInput,
547
+ ) -> int:
548
+ """Log an accepted or refused external-completion signal.
549
+
550
+ Idempotent in intent, not by key: every signal the workflow decides on gets
551
+ its own row, because a refusal that is silently dropped is the defect this
552
+ feature exists to prevent.
553
+ """
554
+ with closing(store.connect(_store_path())) as conn:
555
+ return store.record_external_completion_signal(
556
+ conn,
557
+ epic_id=request.epic_id,
558
+ node_id=request.node_id,
559
+ branch=request.branch,
560
+ provenance=request.provenance,
561
+ accepted=request.accepted,
562
+ reason=request.reason,
563
+ recorded_at=_now(),
564
+ )
565
+
566
+
567
+ def _now() -> str:
568
+ """ISO-8601 UTC timestamp for store rows."""
569
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
570
+
571
+
572
+ # Keep the old module-level `_now` shadow visible for any internal callers.
573
+ now = _now
574
+
575
+
576
+ def _is_blank(value: object) -> bool:
577
+ return not isinstance(value, str) or not value.strip()
578
+
579
+
580
+ def _with_drift(
581
+ result: VerificationResult, criteria_source_path: str | None
582
+ ) -> VerificationResult:
583
+ """Set `criteria_drift` from the spec file's current bytes, if it is still open.
584
+
585
+ Drift the caller already found is never cleared: a verifier that watched the
586
+ spec change mid-attempt must not have its finding overwritten by a file that
587
+ has since been restored. The row keeps the *snapshot's* hash either way — the
588
+ node was judged against those bytes (FR-010), and recording the new ones would
589
+ make the evidence agree with a spec this attempt never saw.
590
+ """
591
+ if result.criteria_drift or criteria_source_path is None:
592
+ return result
593
+ return replace(
594
+ result,
595
+ criteria_drift=_has_drifted(Path(criteria_source_path), result.criteria_sha256),
596
+ )
597
+
598
+
599
+ def _has_drifted(source: Path, snapshot_sha256: str) -> bool:
600
+ """Whether the spec file's bytes still hash to the dispatch snapshot's (R8).
601
+
602
+ A file that cannot be read is drift, not "no drift detected": a deleted spec
603
+ is the loudest possible form of the system of record moving under a node.
604
+ """
605
+ try:
606
+ current = hashlib.sha256(source.read_bytes()).hexdigest()
607
+ except OSError:
608
+ return True
609
+ return current != snapshot_sha256
610
+
611
+
612
+ def _store_path() -> Path:
613
+ return resolve_env_path(
614
+ ERGANE_VERIFICATION_DB_PATH_ENV,
615
+ FACTORY_VERIFICATION_DB_PATH_ENV,
616
+ DEFAULT_VERIFICATION_DB_PATH,
617
+ )
File without changes