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,745 @@
1
+ """The activity surface of dispatch: resolve, prepare, run, salvage, remove.
2
+
3
+ `factory/workgraph/` is a library — a validator, a git wrapper, an adapter. This
4
+ module is where those become things a workflow can call, which means it owns the
5
+ two concerns the library deliberately does not: reading the world at a known
6
+ moment, and turning a library exception into an error the interpreter can branch
7
+ on without reading prose.
8
+
9
+ The activities are one node's life, in order. `resolve_graph` reads
10
+ `personas.yaml` once per epic and hands back a snapshot, so an operator editing
11
+ the registry mid-epic changes the *next* epic — the same discipline 002 applies
12
+ to criteria; `resolve_persona` is that read for the one role no node names, the
13
+ judge, which needs an alias and a key of its own without being routed to.
14
+ `prepare_worktree` opens (or re-opens) the node's one worktree.
15
+ `run_agent_attempt` is the only place an agent runs. `read_worktree_diff` is the
16
+ one place the resulting patch is read, because the workflow that hands it to the
17
+ judge may touch nothing itself. `salvage_worktree` and `remove_worktree` are the
18
+ terminal pair constitution VI requires on every path out, in that order.
19
+
20
+ Four things decided here rather than in the library:
21
+
22
+ - **Where `.factory/` is.** The library takes a root; the activities resolve one,
23
+ from `FACTORY_ROOT` or the documented default. Everything that reads this state
24
+ — the operator, the next worker, the sweep — has to agree with the writer about
25
+ the location, so there is exactly one function that answers the question.
26
+
27
+ - **Which failures are worth a retry.** A graph the registry cannot resolve, a
28
+ declared standards document that is not in the worktree, an agent binary that
29
+ is not on the host: all non-retryable, because re-running reproduces them
30
+ exactly and the ladder's budget exists for proxies and worktrees, not for
31
+ typos. A git command that failed stays retryable — a lock, a full disk and a
32
+ slow filesystem are all things a second attempt fixes.
33
+
34
+ - **What a rejection says.** `GRAPH_INVALID` carries the validator's message
35
+ verbatim, and that message names the offending node. An operator holding a
36
+ ten-node epic needs the offender, not "invalid workgraph". There is one error
37
+ type for every way a graph can fail to resolve, including an unreadable
38
+ registry, because the workflow's response to all of them is identical — the
39
+ epic does not start — and the message already says which file to go and edit.
40
+
41
+ - **How a kill reports itself.** Cancellation is how `kill_epic` reaches a
42
+ running agent. The adapter kills the process group and archives the evidence
43
+ before it re-raises; this module turns that into a `CancelledError` carrying
44
+ the `AdapterResult` in its details, so the workflow's kill sequence records the
45
+ KILLED classification the adapter *observed* rather than inferring it from the
46
+ fact that it asked. Temporal still sees a cancelled activity, which is what
47
+ makes the kill visible in the epic's history.
48
+
49
+ `load_prompt_sources` is the odd one out: a pure read, no worktree, no agent. It
50
+ exists so `factory/workgraph/prompt.py` can stay pure (FR-006, R9) — text in,
51
+ prompt out — with every file read on this side of the activity boundary.
52
+
53
+ No credential is read here. The per-attempt virtual key arrives inside the
54
+ `AttemptContext` the workflow assembled and goes no further than the child
55
+ environment the adapter builds; `LITELLM_MASTER_KEY` and `TELEGRAM_BOT_TOKEN`
56
+ sit in the same worker environment and have no path into any of these calls
57
+ (constitution V).
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ import asyncio
63
+ import logging
64
+ import os
65
+ from dataclasses import dataclass
66
+ from datetime import datetime, timezone
67
+ from pathlib import Path
68
+ from typing import Awaitable, Callable
69
+
70
+ from temporalio import activity
71
+ from temporalio.exceptions import ApplicationError, CancelledError
72
+
73
+ from factory.activities.usage_activities import open_client
74
+ from factory.activities.notify_activities import ferry_read_answer, ferry_send_question
75
+ from factory.config import ConfigError, Persona, load_personas
76
+ from factory.usage.models import Termination, UsageSnapshot
77
+ from factory.verify.factory_yaml import (
78
+ MANIFEST_NAME,
79
+ FactoryConfigError,
80
+ load_factory_config,
81
+ resolve_manifest_path,
82
+ )
83
+ from factory.workgraph import worktree as worktrees
84
+ from factory.workgraph.adapter import (
85
+ DEFAULT_HEARTBEAT_INTERVAL_S,
86
+ AdapterError,
87
+ ClaudeCodeAdapter,
88
+ adapter_for,
89
+ transcript_dir,
90
+ )
91
+ from factory.workgraph.models import (
92
+ AdapterResult,
93
+ AttemptContext,
94
+ ResolvedNode,
95
+ ResolvedPersona,
96
+ WorkGraph,
97
+ WorkGraphError,
98
+ WorkNode,
99
+ resolve_timeout_s,
100
+ validate_workgraph,
101
+ )
102
+ from factory.env import (
103
+ ERGANE_ROOT_ENV,
104
+ FACTORY_ROOT_ENV,
105
+ resolve_env_path,
106
+ )
107
+ from factory.workgraph.worktree import (
108
+ DEFAULT_FACTORY_ROOT,
109
+ PreparedWorktree,
110
+ WorktreeError,
111
+ )
112
+
113
+ #: Where the salvage mirror's outcome goes. A mirror failure is reported rather
114
+ #: than raised (047 FR-002), so this log line is the only place an operator
115
+ #: learns that a terminated node's work is still on one disk.
116
+ logger = logging.getLogger(__name__)
117
+
118
+ #: The activity error type for a graph that must not dispatch (FR-002): an
119
+ #: unknown persona, a persona resolving no timeout, a dangling dependency, a
120
+ #: cycle, or a registry the loader refuses. Always non-retryable — the graph and
121
+ #: the registry are both files, and reading them again a second later gives the
122
+ #: same answer.
123
+ GRAPH_INVALID = "GRAPH_INVALID"
124
+
125
+ #: The activity error type for a `factory.yaml` that declares a standards
126
+ #: document the worktree does not have (R11). A config error, caught before a key
127
+ #: is issued rather than by an agent told to read a file that is not there.
128
+ STANDARDS_MISSING = "STANDARDS_MISSING"
129
+
130
+ #: The activity error type for a git operation that failed. Retryable: a lock, a
131
+ #: full disk and a slow filesystem are all things a second attempt fixes, and a
132
+ #: worktree the worker could not reach this second is the workflow's retry budget
133
+ #: to spend, not the ladder's.
134
+ WORKTREE_FAILED = "WORKTREE_FAILED"
135
+
136
+ #: The activity error type for an agent that could not be started at all — no
137
+ #: such adapter, or no such binary on the worker host. Distinct from a non-zero
138
+ #: exit, which is an `AGENT_ERROR` termination and ordinary ladder input.
139
+ AGENT_LAUNCH_FAILED = "AGENT_LAUNCH_FAILED"
140
+
141
+ #: The activity error type for authored text the prompt cannot be assembled
142
+ #: without (contracts/prompt-assembly.md): a missing input is a loud dispatch
143
+ #: failure, never an omitted section.
144
+ PROMPT_SOURCE_MISSING = "PROMPT_SOURCE_MISSING"
145
+
146
+ #: Where the worker host keeps worktrees, transcripts and pid files when it does
147
+ #: not say otherwise. One override, one default, one resolver (`factory_root`).
148
+ FACTORY_ROOT_ENV = FACTORY_ROOT_ENV # re-export for existing callers
149
+ ERGANE_ROOT_ENV = ERGANE_ROOT_ENV # re-export for tests and callers
150
+
151
+ #: How often a running attempt tells Temporal it is alive. At module scope so a
152
+ #: test can shrink it without waiting out a production-sized interval, and read
153
+ #: at call time so shrinking it works at all.
154
+ HEARTBEAT_INTERVAL_S = DEFAULT_HEARTBEAT_INTERVAL_S
155
+
156
+ #: The adapter every producing persona in the shipped registry names (D-018).
157
+ #: `AttemptContext` carries no `agent` field, so the seam is exercised here
158
+ #: rather than per attempt; a second agent adds a class and a lookup, not an
159
+ #: orchestration change.
160
+ DEFAULT_AGENT = ClaudeCodeAdapter.name
161
+
162
+ #: The epic's authored text, under `<specs_root>/<feature>/`. `spec.md` is the
163
+ #: system of record for intent (D-023); the other two are the clarified context
164
+ #: set and the node's task slice.
165
+ SPEC_FILENAME = "spec.md"
166
+ PLAN_FILENAME = "plan.md"
167
+ TASKS_FILENAME = "tasks.md"
168
+
169
+
170
+ def factory_root() -> Path:
171
+ """The worker host's state directory (plan.md § Storage).
172
+
173
+ Relative by default, so it resolves against the worker's working directory
174
+ exactly the way 001's ledger and 002's evidence store do. Honors the legacy
175
+ `FACTORY_ROOT` env name and reports it once per process.
176
+ """
177
+ root, _choice, _source = worktrees.resolve_factory_root(FACTORY_ROOT_ENV)
178
+ return root
179
+
180
+
181
+ def _usage_reader(key: str) -> Callable[[], Awaitable[UsageSnapshot]]:
182
+ """A spend read for `key`, one client per call (plan US1, R9).
183
+
184
+ The adapter's monitor reads usage on its own `poll_interval_s` cadence and
185
+ carries the newest snapshot as heartbeat details; this is the production
186
+ closure the workflow's poll activity used to be. It reads the attempt's
187
+ virtual key through the proxy's master-key client, opens and closes a client
188
+ per read so a long attempt does not hold a connection open, and stamps the
189
+ snapshot with the moment it was true — a value teardown may record hours
190
+ later is only honest if its staleness is visible.
191
+
192
+ A failure raises the client's own `LiteLLMError`; the adapter isolates it so
193
+ a dead spend read never kills the beat.
194
+ """
195
+ async def read() -> UsageSnapshot:
196
+ client = open_client()
197
+ try:
198
+ spend_usd = await client.get_spend(key)
199
+ finally:
200
+ await client.aclose()
201
+ return UsageSnapshot(
202
+ spend_usd=spend_usd,
203
+ captured_at=datetime.now(timezone.utc)
204
+ .isoformat(timespec="seconds")
205
+ .replace("+00:00", "Z"),
206
+ )
207
+
208
+ return read
209
+
210
+
211
+ def _ferry_sender(
212
+ context: AttemptContext,
213
+ ) -> Callable[[str], Awaitable[str]] | None:
214
+ """The ferry's send callable, bound to this attempt's identity (008-US3).
215
+
216
+ Closes over the workflow id (the activity's own info), the epic/node/attempt
217
+ the question is attributed to (FR-002), and the question text the agent
218
+ writes — so the monitor loop calls one argument (the text) and the rest is
219
+ already pinned. Returns ``None`` when the workflow id is unavailable (a
220
+ bare-`ActivityEnvironment` test with no info), so the ferry degrades to the
221
+ US1-only path rather than raising: a sender that cannot attribute a question
222
+ ships nothing, and the agent degrades to the marker path if the window
223
+ elapses (FR-009).
224
+ """
225
+ workflow_id = activity.info().workflow_id if activity.in_activity() else None
226
+ if workflow_id is None:
227
+ return None
228
+
229
+ async def send(question_text: str) -> str:
230
+ return await ferry_send_question(
231
+ workflow_id,
232
+ context.epic_id,
233
+ context.node_id,
234
+ context.attempt,
235
+ question_text,
236
+ )
237
+
238
+ return send
239
+
240
+
241
+ # --- resolve_graph (the registry snapshot) ------------------------------------
242
+
243
+
244
+ @activity.defn
245
+ async def resolve_graph(graph: WorkGraph) -> list[ResolvedNode]:
246
+ """Read the persona registry against the graph, once, at epic start.
247
+
248
+ Returns one `ResolvedNode` per node in declaration order (R10), each carrying
249
+ the model alias, the issued key's model list, the write scope and the
250
+ resolved timeout. The node travels through untouched: what was validated is
251
+ what dispatches.
252
+
253
+ Read-only and idempotent — no filesystem beyond `personas.yaml`, no git, no
254
+ target repo — so Temporal may run it twice and both runs agree.
255
+
256
+ Raises non-retryable `GRAPH_INVALID`, naming the offending node, for anything
257
+ that would leave a node undispatchable.
258
+ """
259
+ try:
260
+ registry = load_personas()
261
+ except ConfigError as exc:
262
+ # The loader's message already names the registry file and the persona;
263
+ # a graph whose registry will not load is as undispatchable as one with
264
+ # a dangling edge, and the workflow's response to both is the same.
265
+ raise _graph_invalid(str(exc)) from exc
266
+
267
+ try:
268
+ validate_workgraph(graph, registry)
269
+ except WorkGraphError as exc:
270
+ raise _graph_invalid(str(exc)) from exc
271
+
272
+ return [_resolve_node(node, registry[node.persona]) for node in graph.nodes]
273
+
274
+
275
+ def _resolve_node(node: WorkNode, persona: Persona) -> ResolvedNode:
276
+ """One node with the registry read against it (data-model.md § ResolvedNode).
277
+
278
+ `models` is primary-then-fallback with nothing invented for a persona that
279
+ declares neither: it becomes the issued key's constraint list (001), so an
280
+ alias appearing here that the persona did not name would widen what the
281
+ attempt may call.
282
+ """
283
+ timeout_s = resolve_timeout_s(node, persona)
284
+ if persona.model is None or timeout_s is None:
285
+ # `validate_workgraph` has already rejected an unresolvable timeout, so
286
+ # reaching here means a deterministic persona carrying a per-story
287
+ # override: routable on paper, and with no model to dispatch under.
288
+ raise _graph_invalid(
289
+ f"node '{node.id}': persona '{node.persona}' runs no agent and names "
290
+ "no model — a producing node cannot be routed to it (constitution VII)"
291
+ )
292
+
293
+ return ResolvedNode(
294
+ node=node,
295
+ model_alias=persona.model,
296
+ models=[alias for alias in (persona.model, persona.fallback) if alias],
297
+ write_scope=persona.write_scope.value,
298
+ timeout_s=timeout_s,
299
+ context_window=persona.context_window,
300
+ )
301
+
302
+
303
+ def _graph_invalid(message: str) -> ApplicationError:
304
+ return ApplicationError(message, type=GRAPH_INVALID, non_retryable=True)
305
+
306
+
307
+ # --- resolve_persona (the roles no node names) --------------------------------
308
+
309
+
310
+ @dataclass(frozen=True)
311
+ class ResolvePersonaInput:
312
+ """One registry entry to resolve, by name."""
313
+
314
+ persona: str
315
+
316
+
317
+ @activity.defn
318
+ async def resolve_persona(request: ResolvePersonaInput) -> ResolvedPersona:
319
+ """Read one persona the graph does not name — the judge (constitution V).
320
+
321
+ `resolve_graph` answers for nodes, and no node is routed to the judge: it
322
+ scores what an implementer produced, on its own key and under its own alias.
323
+ Called once at epic start for the same reason the graph is resolved there —
324
+ an epic that cannot score its stories should fail before a key is spent
325
+ discovering it, not four attempts in.
326
+
327
+ Read-only and idempotent. Raises non-retryable `GRAPH_INVALID`, naming the
328
+ persona, when the registry has no such entry or the entry names no model.
329
+ """
330
+ try:
331
+ registry = load_personas()
332
+ except ConfigError as exc:
333
+ raise _graph_invalid(str(exc)) from exc
334
+
335
+ persona = registry.get(request.persona)
336
+ if persona is None:
337
+ raise _graph_invalid(
338
+ f"persona '{request.persona}' is not in the registry, so the work "
339
+ "that needs it cannot be dispatched"
340
+ )
341
+ if persona.model is None:
342
+ raise _graph_invalid(
343
+ f"persona '{request.persona}' runs no agent and names no model — "
344
+ "nothing can be dispatched to it (constitution VII)"
345
+ )
346
+
347
+ return ResolvedPersona(
348
+ persona=request.persona,
349
+ model_alias=persona.model,
350
+ models=[alias for alias in (persona.model, persona.fallback) if alias],
351
+ )
352
+
353
+
354
+ # --- prepare_worktree (FR-013, R11) -------------------------------------------
355
+
356
+
357
+ @dataclass(frozen=True)
358
+ class PrepareWorktreeInput:
359
+ """Which node's worktree to open, and what the target repo demands be in it.
360
+
361
+ `standards` is the `factory.yaml` path (R11) rather than its contents: the
362
+ prompt points the agent at the document, and the agent reads it in the
363
+ worktree. `None` means the repo declares nothing to obey, which is the
364
+ ordinary case.
365
+ """
366
+
367
+ epic_id: str
368
+ node_id: str
369
+ target_repo: str
370
+ standards: str | None = None
371
+
372
+
373
+ @activity.defn
374
+ async def prepare_worktree(request: PrepareWorktreeInput) -> PreparedWorktree:
375
+ """Open the node's one worktree, creating it on first dispatch (FR-013).
376
+
377
+ Idempotent across attempts and across activity re-runs: an existing worktree
378
+ is returned untouched, with the base ref it was pinned to at creation, so an
379
+ attempt opens the tree the previous attempt left behind.
380
+
381
+ Raises non-retryable `STANDARDS_MISSING` when the target repo declares a
382
+ standards document the worktree does not have, and retryable
383
+ `WORKTREE_FAILED` when git itself refused.
384
+
385
+ Runs in a worker thread: a first checkout of a large repository owns the wall
386
+ clock for as long as it takes, and blocking the event loop would stall every
387
+ other activity this worker is running.
388
+ """
389
+ try:
390
+ prepared = await asyncio.to_thread(
391
+ worktrees.ensure,
392
+ request.target_repo,
393
+ request.epic_id,
394
+ request.node_id,
395
+ factory_root=factory_root(),
396
+ )
397
+ except WorktreeError as exc:
398
+ raise ApplicationError(str(exc), type=WORKTREE_FAILED) from exc
399
+
400
+ _require_standards(request, prepared)
401
+ return prepared
402
+
403
+
404
+ def _require_standards(
405
+ request: PrepareWorktreeInput, prepared: PreparedWorktree
406
+ ) -> None:
407
+ """Refuse a dispatch whose declared standards document is not there (R11).
408
+
409
+ Checked in the worktree the agent will actually see rather than in the clone
410
+ it was branched from: those differ the moment a node's own attempt touches
411
+ the file. The message names both the path and the node, because either one of
412
+ them may be the thing that is wrong.
413
+ """
414
+ if not request.standards:
415
+ return
416
+
417
+ document = Path(prepared.path) / request.standards
418
+ if document.is_file():
419
+ return
420
+
421
+ raise ApplicationError(
422
+ f"node '{request.node_id}': {MANIFEST_NAME} declares "
423
+ f"standards '{request.standards}', which is not in the node's worktree "
424
+ f"(looked for {document})",
425
+ type=STANDARDS_MISSING,
426
+ non_retryable=True,
427
+ )
428
+
429
+
430
+ # --- run_agent_attempt (the one place an agent runs) --------------------------
431
+
432
+
433
+ @activity.defn
434
+ async def run_agent_attempt(context: AttemptContext) -> AdapterResult:
435
+ """Run one agent attempt to its end, whatever that end is (D-018, FR-005).
436
+
437
+ Returns the termination class and the archived transcript directory, and
438
+ nothing else: the diff is read from the worktree and usage from the ledger,
439
+ and nothing the agent *said* reaches node state (FR-012).
440
+
441
+ Beats roughly every `HEARTBEAT_INTERVAL_S` while the agent works, which is
442
+ what keeps a multi-hour attempt alive in Temporal's eyes and what makes a
443
+ cancellation land within one interval instead of at the deadline.
444
+
445
+ On cancellation the adapter has already ended the process group and archived
446
+ the evidence; this re-raises as `CancelledError` carrying the `AdapterResult`
447
+ so the kill sequence records the classification the adapter observed. Raises
448
+ non-retryable `AGENT_LAUNCH_FAILED` when there was no agent to run.
449
+ """
450
+ root = factory_root()
451
+ try:
452
+ # The launch backend is resolved from the target repo's `runtime:` key
453
+ # by the adapter itself (011-US2). Nothing overrides it here: an
454
+ # unconditional assignment on this line pinned every production attempt
455
+ # to the host launch for the whole of 011, while US3/US4/US5 proved a
456
+ # boundary no dispatch could reach. Tests that need the host launch
457
+ # declare `runtime: host` in their fixture manifest and get it through
458
+ # the same resolution production uses.
459
+ adapter = adapter_for(DEFAULT_AGENT)
460
+ return await adapter.run_attempt(
461
+ context,
462
+ factory_root=root,
463
+ heartbeat=activity.heartbeat,
464
+ heartbeat_interval_s=HEARTBEAT_INTERVAL_S,
465
+ read_usage=_usage_reader(context.virtual_key),
466
+ # 008-US3: the in-attempt ferry. An agent that asks mid-flight writes
467
+ # a `question` file to its archive directory; these callables ship it
468
+ # up (record + page, the same `send_question` path US1 takes) and
469
+ # poll the store for the operator's reply. Both are isolated by the
470
+ # adapter so a dead ferry call never kills the liveness beat (FR-009).
471
+ # The workflow id is the activity's own info — the same id the US1
472
+ # terminal path puts on the `SendQuestionInput` — so a ferried
473
+ # question is attributed to the same workflow that would have asked
474
+ # it at the end.
475
+ send_ferry_question=_ferry_sender(context),
476
+ read_ferry_answer=ferry_read_answer,
477
+ )
478
+ except asyncio.CancelledError:
479
+ raise CancelledError(
480
+ f"attempt {context.attempt} of {context.epic_id}/{context.node_id} "
481
+ "was cancelled; the agent's process group is dead and its evidence "
482
+ "is archived",
483
+ AdapterResult(
484
+ termination=Termination.KILLED,
485
+ transcript_path=str(
486
+ transcript_dir(
487
+ root, context.epic_id, context.node_id, context.attempt
488
+ )
489
+ ),
490
+ ),
491
+ ) from None
492
+ except AdapterError as exc:
493
+ # No adapter, or no binary: the worker host is misconfigured, and the
494
+ # ladder must not spend one of the node's attempts discovering that.
495
+ raise ApplicationError(
496
+ str(exc), type=AGENT_LAUNCH_FAILED, non_retryable=True
497
+ ) from exc
498
+
499
+
500
+ # --- read_worktree_diff (what the judge scores) -------------------------------
501
+
502
+
503
+ @dataclass(frozen=True)
504
+ class ReadWorktreeDiffInput:
505
+ """Which worktree to read the attempt's patch out of, and against what.
506
+
507
+ The path rather than the epic/node pair, because the two callers that read
508
+ this worktree in the same breath — 002's `run_gates` and `check_output` —
509
+ take the path the workflow is already holding, and a second way of naming the
510
+ same directory is a second thing that can disagree.
511
+
512
+ `base_ref` is the prepared worktree's branch point (D-027): the judge scores
513
+ everything the attempt changed since the node began, which an agent
514
+ following the inner ralph contract has partly *committed* by now — a diff
515
+ against HEAD would hand the judge everything except that work.
516
+ """
517
+
518
+ worktree_path: str
519
+ base_ref: str
520
+
521
+
522
+ @activity.defn
523
+ async def read_worktree_diff(request: ReadWorktreeDiffInput) -> str:
524
+ """Read what the attempt changed, for the judge to score (FR-001).
525
+
526
+ The workflow decides and touches nothing, so this is the one place a
527
+ worktree's patch is read. Read-only — the patch is assembled against a
528
+ scratch index, never the worktree's own — and safe to retry.
529
+
530
+ Raises retryable `WORKTREE_FAILED` when git could not read the worktree: an
531
+ unreachable directory resembles an empty diff exactly, and handing the judge
532
+ "the agent produced nothing" over a lost mount charges an infrastructure
533
+ failure to the attempt.
534
+
535
+ Runs in a worker thread: a patch over a large repository owns the wall clock
536
+ for as long as git takes.
537
+ """
538
+ try:
539
+ return await asyncio.to_thread(
540
+ worktrees.diff, request.worktree_path, base_ref=request.base_ref
541
+ )
542
+ except WorktreeError as exc:
543
+ raise ApplicationError(str(exc), type=WORKTREE_FAILED) from exc
544
+
545
+
546
+ # --- salvage_worktree / remove_worktree (constitution VI) ---------------------
547
+
548
+
549
+ @dataclass(frozen=True)
550
+ class SalvageWorktreeInput:
551
+ """One terminal attempt, in the terms the salvage commit's subject needs.
552
+
553
+ `termination` is the adapter's classification, travelling from the workflow
554
+ to the one commit subject that will outlive `.factory/` (SC-004). It arrives
555
+ as a `Termination` member rather than as a list of one-character strings
556
+ because the enum is a `StrEnum` — see `factory/usage/models.py` for why that
557
+ spelling is load-bearing on any field a workflow hands to an activity.
558
+ """
559
+
560
+ epic_id: str
561
+ node_id: str
562
+ termination: Termination
563
+ attempt: int
564
+
565
+
566
+ @activity.defn
567
+ async def salvage_worktree(request: SalvageWorktreeInput) -> str:
568
+ """Commit whatever the attempt left to the node's branch, mirror it, return the sha.
569
+
570
+ Runs on every termination path before any cleanup (constitution VI), and
571
+ commits an empty tree as readily as a dirty one so every terminal attempt is
572
+ observable from the ref alone (SC-004). Idempotent per attempt: a re-run
573
+ after an unrecorded success lands on the same commit.
574
+
575
+ The commit is the durable artifact; the mirror is what gets it off this
576
+ machine (047 US1), and the per-attempt ref is what keeps the sha this
577
+ activity returns resolvable after the branch has moved on (047 US2). Both
578
+ run after the commit and outside the conversion below, unconditionally —
579
+ including on the retry path where `salvage` short-circuits, because a retry
580
+ after a failed push is exactly when a mirror is worth running, and a record
581
+ written only inside the commit branch would be skipped on the very path
582
+ Temporal takes to re-make it. Their failures are returned as data and
583
+ logged, never raised: an unreachable remote that failed this activity would
584
+ turn a successful salvage into a failed one.
585
+
586
+ The ref is written before the mirror so the mirror can carry it (FR-008).
587
+
588
+ The return value stays a plain `str` (047 FR-005). Three call sites in
589
+ `EpicWorkflow` already schedule this activity and discard what it answers;
590
+ widening the payload or adding a fourth command would be a replay hazard for
591
+ every in-flight epic, and nothing here needs one — a linked worktree already
592
+ knows its own remote.
593
+ """
594
+ try:
595
+ sha = await asyncio.to_thread(
596
+ worktrees.salvage,
597
+ request.epic_id,
598
+ request.node_id,
599
+ termination=request.termination,
600
+ attempt=request.attempt,
601
+ factory_root=factory_root(),
602
+ )
603
+ except WorktreeError as exc:
604
+ raise ApplicationError(str(exc), type=WORKTREE_FAILED) from exc
605
+
606
+ recorded = await asyncio.to_thread(
607
+ worktrees.record_salvage_ref,
608
+ request.epic_id,
609
+ request.node_id,
610
+ attempt=request.attempt,
611
+ sha=sha,
612
+ factory_root=factory_root(),
613
+ )
614
+ logger.info(
615
+ "salvage ref for %s/%s attempt %s: written=%s %s",
616
+ request.epic_id,
617
+ request.node_id,
618
+ request.attempt,
619
+ recorded.written,
620
+ recorded.detail,
621
+ )
622
+
623
+ outcome = await asyncio.to_thread(
624
+ worktrees.mirror_node_branch,
625
+ request.epic_id,
626
+ request.node_id,
627
+ factory_root=factory_root(),
628
+ )
629
+ logger.info(
630
+ "salvage mirror for %s/%s attempt %s: pushed=%s %s; refs pushed=%s %s",
631
+ request.epic_id,
632
+ request.node_id,
633
+ request.attempt,
634
+ outcome.pushed,
635
+ outcome.detail,
636
+ outcome.refs_pushed,
637
+ outcome.refs_detail,
638
+ )
639
+ return sha
640
+
641
+
642
+ @dataclass(frozen=True)
643
+ class RemoveWorktreeInput:
644
+ """Which node's worktree to sweep, and the clone that administers it."""
645
+
646
+ epic_id: str
647
+ node_id: str
648
+ target_repo: str
649
+
650
+
651
+ @activity.defn
652
+ async def remove_worktree(request: RemoveWorktreeInput) -> None:
653
+ """Delete the node's worktree directory, leaving the branch and its history.
654
+
655
+ Cleanup, never deletion of the record: once `.factory/` is swept, the branch
656
+ and its salvage commits are the only thing left of the attempt. Idempotent —
657
+ an already-removed worktree, or one a node killed before dispatch never had,
658
+ is success.
659
+ """
660
+ try:
661
+ await asyncio.to_thread(
662
+ worktrees.remove,
663
+ request.target_repo,
664
+ request.epic_id,
665
+ request.node_id,
666
+ factory_root=factory_root(),
667
+ )
668
+ except WorktreeError as exc:
669
+ raise ApplicationError(str(exc), type=WORKTREE_FAILED) from exc
670
+
671
+
672
+ # --- load_prompt_sources (contracts/prompt-assembly.md) -----------------------
673
+
674
+
675
+ @dataclass(frozen=True)
676
+ class LoadPromptSourcesInput:
677
+ """Where the epic's authored text lives, and which repo declares standards."""
678
+
679
+ specs_root: str
680
+ feature: str
681
+ target_repo: str
682
+
683
+
684
+ @dataclass(frozen=True)
685
+ class PromptSources:
686
+ """Everything on disk that a dispatch prompt is assembled from (R9).
687
+
688
+ Text, verbatim and whole — the story sections and the task slice are cut out
689
+ of it by pure functions in `factory/workgraph/prompt.py`, which is what keeps
690
+ assembly deterministic and unit-testable. `standards` is the declared *path*,
691
+ not the document: the prompt points the agent at it and the agent reads it in
692
+ its own worktree, where `prepare_worktree` has already confirmed it exists.
693
+ """
694
+
695
+ spec_text: str
696
+ plan_text: str
697
+ tasks_text: str
698
+ standards: str | None = None
699
+
700
+
701
+ @activity.defn
702
+ async def load_prompt_sources(request: LoadPromptSourcesInput) -> PromptSources:
703
+ """Read the epic's spec, plan and tasks, plus the target repo's standards path.
704
+
705
+ The one read-only activity in the dispatch path, and the reason prompt
706
+ assembly itself touches no filesystem (FR-006). Raises non-retryable
707
+ `PROMPT_SOURCE_MISSING` naming the path when any of the three documents is
708
+ absent — the assembler never invents context, so a missing input is a loud
709
+ dispatch failure rather than an omitted section.
710
+ """
711
+ feature_dir = Path(request.specs_root) / request.feature
712
+ return PromptSources(
713
+ spec_text=_read_source(feature_dir / SPEC_FILENAME),
714
+ plan_text=_read_source(feature_dir / PLAN_FILENAME),
715
+ tasks_text=_read_source(feature_dir / TASKS_FILENAME),
716
+ standards=_declared_standards(request.target_repo),
717
+ )
718
+
719
+
720
+ def _read_source(path: Path) -> str:
721
+ """One authored document, verbatim; absent or unreadable is a dispatch failure."""
722
+ try:
723
+ return path.read_text(encoding="utf-8")
724
+ except (OSError, UnicodeDecodeError) as exc:
725
+ raise ApplicationError(
726
+ f"cannot read prompt source {path}: {exc}",
727
+ type=PROMPT_SOURCE_MISSING,
728
+ non_retryable=True,
729
+ ) from exc
730
+
731
+
732
+ def _declared_standards(target_repo: str) -> str | None:
733
+ """The standards document the target repo's manifest declares, if any (R11).
734
+
735
+ A manifest that is absent or that the schema refuses reads as "declares no
736
+ standards" rather than failing the dispatch. That is not a swallowed error:
737
+ 002's gate run reads the same manifest and reports an unusable one as a
738
+ `CONFIG_ERROR` gate result, which fails the attempt with the parser's own
739
+ diagnosis attached. Raising here would pre-empt that with strictly less.
740
+ """
741
+ try:
742
+ manifest_path, _ = resolve_manifest_path(target_repo)
743
+ return load_factory_config(manifest_path).standards
744
+ except FactoryConfigError:
745
+ return None