autoforge-cli 0.1.0a0__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 (81) hide show
  1. autoforge/__init__.py +5 -0
  2. autoforge/agent_worktree.py +653 -0
  3. autoforge/certificate.py +495 -0
  4. autoforge/child_env.py +871 -0
  5. autoforge/claims.py +726 -0
  6. autoforge/claude_stream.py +501 -0
  7. autoforge/cli.py +2884 -0
  8. autoforge/config.py +2178 -0
  9. autoforge/config_fingerprint.py +374 -0
  10. autoforge/doctor.py +1551 -0
  11. autoforge/doctor_sandbox.py +691 -0
  12. autoforge/effect_ops.py +1272 -0
  13. autoforge/effects.py +3210 -0
  14. autoforge/engine.py +16637 -0
  15. autoforge/epic_commands.py +1486 -0
  16. autoforge/epic_graph.py +852 -0
  17. autoforge/errors.py +536 -0
  18. autoforge/evidence.py +1722 -0
  19. autoforge/executor.py +1601 -0
  20. autoforge/executor_duplex.py +985 -0
  21. autoforge/executor_supervised.py +144 -0
  22. autoforge/git_transport.py +970 -0
  23. autoforge/github.py +3402 -0
  24. autoforge/interaction.py +1027 -0
  25. autoforge/local_workspace.py +1253 -0
  26. autoforge/locking.py +1050 -0
  27. autoforge/loop_detect.py +660 -0
  28. autoforge/loop_guard.py +404 -0
  29. autoforge/materialize.py +722 -0
  30. autoforge/ownership.py +240 -0
  31. autoforge/pi_rpc.py +713 -0
  32. autoforge/premerge.py +181 -0
  33. autoforge/profiles.py +106 -0
  34. autoforge/progress.py +392 -0
  35. autoforge/prompts/__init__.py +173 -0
  36. autoforge/prompts/acceptance_actions.md +39 -0
  37. autoforge/prompts/analyze_execute.md +128 -0
  38. autoforge/prompts/common.md +132 -0
  39. autoforge/prompts/correction.md +12 -0
  40. autoforge/prompts/fix.md +252 -0
  41. autoforge/prompts/local_analyze_execute.md +69 -0
  42. autoforge/prompts/local_common.md +149 -0
  43. autoforge/prompts/local_fix.md +96 -0
  44. autoforge/prompts/local_review.md +103 -0
  45. autoforge/prompts/operator_acceptance.md +61 -0
  46. autoforge/prompts/replan_reexecute.md +991 -0
  47. autoforge/prompts/review.md +312 -0
  48. autoforge/prompts/update_epic.md +120 -0
  49. autoforge/prompts/update_epic_rerequest.md +85 -0
  50. autoforge/prompts/validation_evidence.md +26 -0
  51. autoforge/prompts/validation_failure.md +22 -0
  52. autoforge/prompts/worktree_state.md +45 -0
  53. autoforge/providers.py +2893 -0
  54. autoforge/read_context.py +389 -0
  55. autoforge/redaction.py +408 -0
  56. autoforge/replan.py +208 -0
  57. autoforge/replan_txn.py +1930 -0
  58. autoforge/result_parser.py +2234 -0
  59. autoforge/roadmap.py +151 -0
  60. autoforge/run_contract.py +408 -0
  61. autoforge/run_namespace.py +839 -0
  62. autoforge/runlog.py +764 -0
  63. autoforge/safefs.py +1717 -0
  64. autoforge/sandbox.py +1313 -0
  65. autoforge/sandbox_bwrap.py +815 -0
  66. autoforge/sandbox_pin.py +170 -0
  67. autoforge/sandbox_policy.py +1107 -0
  68. autoforge/scheduler.py +1143 -0
  69. autoforge/scheduler_state.py +2316 -0
  70. autoforge/scheduler_stop.py +421 -0
  71. autoforge/sdk_policy.py +988 -0
  72. autoforge/state.py +2417 -0
  73. autoforge/transitions.py +388 -0
  74. autoforge/validation.py +324 -0
  75. autoforge/worker.py +568 -0
  76. autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
  77. autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
  78. autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
  79. autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
  80. autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
  81. autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
autoforge/cli.py ADDED
@@ -0,0 +1,2884 @@
1
+ """AutoForge command-line interface.
2
+
3
+ Commands:
4
+ doctor read-only environment checks (git, gh, auth, claude, opencode, repo, config,
5
+ default-branch required checks)
6
+ run create a run (Issue -> ...) and advance it until READY_FOR_MERGE/DONE/BLOCKED/FAILED
7
+ step execute exactly one phase step from persisted state
8
+ resume continue a persisted run until a stop phase / max-steps; step, resume and
9
+ unblock refuse a REMOTE run whose recorded configuration (profile
10
+ routing, agent worktree root) this invocation's differs from, and any
11
+ run whose pinned sandbox.mode this invocation's is weaker than, unless
12
+ --accept-config-change (#219, #280). Under sandbox.mode required or
13
+ preferred every command refuses a --config file inside a tree the
14
+ repository's sandboxed launches write, or a directory a provider's
15
+ launches are given read-write (#280)
16
+ unblock the operator's explicit exit from BLOCKED: the controller re-inspects
17
+ live GitHub state, re-enters a phase through validate_transition or
18
+ refuses and stays BLOCKED; the reason is recorded in state and the log.
19
+ --approve-action / --decline-action decide on an acceptance action
20
+ a FIX plan awaits (#211); --bind-evidence binds an existing comment
21
+ to an operator action and --decline-action (repeatable) declines
22
+ operator actions (#243)
23
+ status show the persisted run summary (--json for machine output; --action
24
+ prints an acceptance or operator action's exact payload)
25
+ local LOCAL mode: feature Markdown -> implement -> review -> fix -> DONE,
26
+ with no GitHub involved at any point (init / run / doctor)
27
+ epic the parallel EPIC scheduler (ADR 0008, #183), REMOTE only: run (with
28
+ --dry-run and --force), resume, status (lock-free, --json), stop
29
+ (lock-free, --now) and archive; every 'epic' command refuses
30
+ --state-dir, since each issue's state lives in its scheduler namespace,
31
+ and an EPIC of another repository than the checkout's 'origin'.
32
+ 'epic _worker' is the scheduler's hidden worker entry point
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import io
39
+ import json
40
+ import os
41
+ import re
42
+ import select
43
+ import sys
44
+ import time
45
+ import unicodedata
46
+ from collections.abc import Callable, Iterator
47
+ from contextlib import contextmanager
48
+ from typing import TextIO
49
+
50
+ from . import __version__
51
+ from .certificate import NOT_A_PERMISSION
52
+ from .certificate import describe as describe_certificate
53
+ from .config import MAX_PARALLEL_BOUNDS, AutoForgeConfig, load_config_file
54
+ from .config_fingerprint import RecordedConfiguration
55
+ from .doctor import CheckResult, run_doctor, run_local_doctor
56
+ from .effects import (
57
+ AUTHORIZATION_POLICY,
58
+ MAX_EFFECT_ATTEMPTS,
59
+ EffectKind,
60
+ EffectRecord,
61
+ Stage,
62
+ sha256_text,
63
+ )
64
+ from .engine import (
65
+ ControllerEngine,
66
+ StepOutcome,
67
+ StepPlan,
68
+ UnblockOutcome,
69
+ config_file_placement_problem,
70
+ sandbox_mode_summary,
71
+ )
72
+ from .epic_commands import (
73
+ DRAIN_HELP,
74
+ EXIT_OPEN_CLAIMS,
75
+ archive_issue,
76
+ checkout_epic,
77
+ claims_left_open,
78
+ collect_status,
79
+ coordination_root_of,
80
+ dry_run_plan,
81
+ epic_run_guard,
82
+ left_open_lines,
83
+ stop_scheduler,
84
+ )
85
+ from .epic_graph import GraphReader
86
+ from .errors import (
87
+ AutoForgeError,
88
+ ConfigurationError,
89
+ InteractionDataError,
90
+ LockError,
91
+ StateError,
92
+ StateTransitionError,
93
+ )
94
+ from .evidence import (
95
+ MAX_FAILURE_COUNT,
96
+ LocalCandidate,
97
+ Outcome,
98
+ RemoteCandidate,
99
+ parse_marker,
100
+ record_summary,
101
+ )
102
+ from .github import GitHubClient
103
+ from .interaction import (
104
+ Allow,
105
+ AllowScope,
106
+ Answer,
107
+ Cancel,
108
+ DecisionKind,
109
+ Deny,
110
+ InteractionDecision,
111
+ InteractionRequest,
112
+ Question,
113
+ QuestionResponse,
114
+ RequestKind,
115
+ Unavailable,
116
+ UnavailableReason,
117
+ )
118
+ from .local_workspace import init_feature_file
119
+ from .locking import ControllerLock, repository_lock_path
120
+ from .ownership import refuse_scheduler_owned_issue, standalone_ownership_predicate
121
+ from .redaction import redact, redact_argv, redact_dict, redact_obj
122
+ from .replan_txn import ReplanTransaction
123
+ from .run_namespace import namespace_issue_of_state_dir
124
+ from .sandbox_pin import SandboxPin
125
+ from .sandbox_policy import absolute_spelling
126
+ from .scheduler import EpicScheduler, SchedulerInputs
127
+ from .scheduler_stop import StopMode
128
+ from .sdk_policy import MAX_ANSWER_CHARS
129
+ from .state import AutoForgeState
130
+ from .transitions import TERMINAL_PHASES, Phase, WorkflowMode
131
+ from .validation import parse_issue_url
132
+
133
+
134
+ def _positive_int(text: str) -> int:
135
+ """argparse type for ``--max-steps``: an integer >= 1.
136
+
137
+ Rejecting the value at parse time keeps ``run --max-steps 0`` from
138
+ writing a fresh state file and then crashing in ``engine.run()``.
139
+ """
140
+ try:
141
+ value = int(text)
142
+ except ValueError:
143
+ raise argparse.ArgumentTypeError(f"invalid int value: {text!r}") from None
144
+ if value < 1:
145
+ raise argparse.ArgumentTypeError(f"must be >= 1, got {value}")
146
+ return value
147
+
148
+
149
+ def _max_parallel(text: str) -> int:
150
+ """argparse type for ``--max-parallel``: within ``scheduler.max_parallel``'s bounds."""
151
+ low, high = MAX_PARALLEL_BOUNDS
152
+ try:
153
+ value = int(text)
154
+ except ValueError:
155
+ raise argparse.ArgumentTypeError(f"invalid int value: {text!r}") from None
156
+ if not low <= value <= high:
157
+ raise argparse.ArgumentTypeError(
158
+ f"must be between {low} and {high}, got {value} (there is no unbounded mode)"
159
+ )
160
+ return value
161
+
162
+
163
+ def build_parser() -> argparse.ArgumentParser:
164
+ p = argparse.ArgumentParser(
165
+ prog="autoforge",
166
+ description=(
167
+ "AutoForge — deterministic orchestration layer for AI-driven "
168
+ "software development (Issue → Claude Code implementation → PR → "
169
+ "OpenCode review → Claude Code fix → ... → READY_FOR_MERGE)."
170
+ ),
171
+ )
172
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
173
+ p.add_argument("--config", default=None, help="config file (.yaml/.yml/.toml/.json)")
174
+ p.add_argument(
175
+ "--state-dir",
176
+ default=None,
177
+ help="state directory (default: from config, else .autoforge)",
178
+ )
179
+ sub = p.add_subparsers(dest="command", required=True)
180
+
181
+ d = sub.add_parser("doctor", help="check git/gh/claude/opencode/repo/config (read-only)")
182
+ d.add_argument("--json", action="store_true", dest="as_json")
183
+ d.add_argument(
184
+ "--epic",
185
+ default=None,
186
+ metavar="EPIC_URL",
187
+ help=(
188
+ "also run the parallel EPIC scheduler's checks: the coordination root's "
189
+ "filesystem and the scheduler root's permissions, and whether gh can read this "
190
+ "EPIC's sub-issues and dependencies (read-only; nothing is created)"
191
+ ),
192
+ )
193
+
194
+ r = sub.add_parser("run", help="create a run and advance it")
195
+ r.add_argument("--epic", required=True, help="EPIC issue URL")
196
+ r.add_argument("--issue", required=True, help="first issue URL")
197
+ r.add_argument("--dry-run", action="store_true", help="plan only; no side effects")
198
+ r.add_argument(
199
+ "--force",
200
+ action="store_true",
201
+ help=(
202
+ "discard an existing non-terminal run; an unreadable state entry (bad JSON, "
203
+ "foreign protocol, invalid UTF-8, dangling symlink, FIFO/socket/device) is "
204
+ "moved aside as state.json.corrupt-<timestamp> instead of being deleted "
205
+ "(a directory is refused and must be moved by hand)"
206
+ ),
207
+ )
208
+ r.add_argument(
209
+ "--max-steps", type=_positive_int, default=50, help="steps for this invocation (>= 1)"
210
+ )
211
+ r.add_argument(
212
+ "--allow-merge",
213
+ action="store_true",
214
+ help="unlock MERGE execution (also needs config safety.allow_merge=true)",
215
+ )
216
+ r.add_argument(
217
+ "--full-prompt", action="store_true", help="print full rendered prompt in dry-run"
218
+ )
219
+
220
+ s = sub.add_parser("step", help="execute exactly one step from persisted state")
221
+ s.add_argument("--dry-run", action="store_true")
222
+ s.add_argument("--allow-merge", action="store_true")
223
+ s.add_argument("--full-prompt", action="store_true")
224
+ _add_accept_config_change(s)
225
+
226
+ re_ = sub.add_parser("resume", help="continue a persisted run")
227
+ re_.add_argument("--dry-run", action="store_true")
228
+ re_.add_argument(
229
+ "--max-steps", type=_positive_int, default=50, help="steps for this invocation (>= 1)"
230
+ )
231
+ re_.add_argument("--allow-merge", action="store_true")
232
+ re_.add_argument("--full-prompt", action="store_true")
233
+ _add_accept_config_change(re_)
234
+
235
+ ub = sub.add_parser(
236
+ "unblock",
237
+ help=(
238
+ "leave BLOCKED explicitly: re-inspect GitHub, re-enter a phase or refuse; then 'resume'"
239
+ ),
240
+ description=(
241
+ "Requires a BLOCKED run. The controller re-runs its recovery inspection "
242
+ "against live GitHub and either re-enters the one phase that inspection "
243
+ "supports (through the same transition validation as any other step) or "
244
+ "refuses and stays BLOCKED. No agent runs; the operator action is recorded "
245
+ "in state and in the run log."
246
+ ),
247
+ )
248
+ ub.add_argument(
249
+ "--reason",
250
+ required=True,
251
+ help="why the operator is unblocking (recorded in state and in the run log)",
252
+ )
253
+ ub.add_argument(
254
+ "--dry-run",
255
+ action="store_true",
256
+ help="read GitHub and report the decision without writing state or the log",
257
+ )
258
+ _add_accept_config_change(ub)
259
+ action = ub.add_mutually_exclusive_group()
260
+ action.add_argument(
261
+ "--approve-action",
262
+ default="",
263
+ metavar="ACTION_ID",
264
+ help=(
265
+ "approve the acceptance action a FIX plan awaits (its id from 'autoforge status'); "
266
+ "'resume' then publishes exactly its payload on the run's issue"
267
+ ),
268
+ )
269
+ action.add_argument(
270
+ "--decline-action",
271
+ action="append",
272
+ default=None,
273
+ metavar="ACTION_ID",
274
+ help=(
275
+ "decline the acceptance action (A-...) a FIX plan awaits, or one or more operator "
276
+ "actions (X-..., repeatable; the plan's other pending operator actions are "
277
+ "withdrawn and its verified ones kept): the unsent plan is dropped and FIX "
278
+ "launches the fixer again with the decline in its prompt"
279
+ ),
280
+ )
281
+ action.add_argument(
282
+ "--bind-evidence",
283
+ action="append",
284
+ nargs=2,
285
+ default=None,
286
+ metavar=("ACTION_ID", "COMMENT_URL"),
287
+ help=(
288
+ "bind an existing comment on an operator action's target issue as its evidence "
289
+ "(repeatable): 'resume' verifies the comment is there and unchanged; the "
290
+ "controller never judges whether it is adequate"
291
+ ),
292
+ )
293
+
294
+ st = sub.add_parser("status", help="show persisted run status")
295
+ shown = st.add_mutually_exclusive_group()
296
+ shown.add_argument("--json", action="store_true", dest="as_json")
297
+ shown.add_argument(
298
+ "--action",
299
+ default="",
300
+ metavar="ACTION_ID",
301
+ help=(
302
+ "print the exact payload of a pending acceptance (A-...) or operator (X-...) "
303
+ "action and nothing else (exit 1 when no such action is persisted)"
304
+ ),
305
+ )
306
+
307
+ _add_local_parser(sub)
308
+ _add_epic_parser(sub)
309
+ return p
310
+
311
+
312
+ def _add_accept_config_change(parser: argparse.ArgumentParser) -> None:
313
+ parser.add_argument(
314
+ "--accept-config-change",
315
+ action="store_true",
316
+ help=(
317
+ "continue a REMOTE run although this invocation's profile routing or agent "
318
+ "worktree root differs from the configuration the run was recorded under, or any "
319
+ "run although this invocation's sandbox.mode is weaker than the one it is pinned "
320
+ "to: the change is written to the run log and becomes the run's configuration "
321
+ "(#219, #280)"
322
+ ),
323
+ )
324
+
325
+
326
+ def _add_local_parser(sub) -> None:
327
+ """`autoforge local ...`: the GitHub-free workflow.
328
+
329
+ ``status``/``step``/``resume`` stay mode-agnostic (they read the mode from
330
+ persisted state), so only the commands that *differ* live here.
331
+ """
332
+ loc = sub.add_parser(
333
+ "local",
334
+ help="local mode: drive a feature Markdown file with no GitHub involved",
335
+ description=(
336
+ "LOCAL mode: features/<slug>.md -> ANALYZE_EXECUTE -> REVIEW -> FIX -> "
337
+ "REVIEW -> DONE, entirely in the working tree. No gh, no PR, no push, "
338
+ "no merge, no commits. Use 'autoforge status/step/resume' as usual."
339
+ ),
340
+ )
341
+ lsub = loc.add_subparsers(dest="local_command", required=True)
342
+
343
+ li = lsub.add_parser("init", help="create features/<slug>.md from the template")
344
+ li.add_argument("slug", help="feature slug, e.g. add-transaction-filter")
345
+ li.add_argument(
346
+ "--feature-dir",
347
+ default=None,
348
+ help="directory for the file (default: config local.feature_dir)",
349
+ )
350
+ li.add_argument(
351
+ "--force",
352
+ action="store_true",
353
+ help="overwrite an existing feature file (refused by default)",
354
+ )
355
+
356
+ lr = lsub.add_parser(
357
+ "run", help="create a local run from a feature Markdown file and advance it"
358
+ )
359
+ lr.add_argument("feature", help="path to the feature Markdown file (inside this repository)")
360
+ lr.add_argument("--dry-run", action="store_true", help="plan only; no side effects")
361
+ lr.add_argument(
362
+ "--force",
363
+ action="store_true",
364
+ help="discard an existing non-terminal run (see 'run --force')",
365
+ )
366
+ lr.add_argument(
367
+ "--max-steps", type=_positive_int, default=50, help="steps for this invocation (>= 1)"
368
+ )
369
+ lr.add_argument(
370
+ "--allow-dirty",
371
+ action="store_true",
372
+ help=(
373
+ "start even though the working tree has changes other than the feature file; "
374
+ "they are recorded in the run and reported to the reviewer, never treated as "
375
+ "part of the implementation"
376
+ ),
377
+ )
378
+ lr.add_argument(
379
+ "--full-prompt", action="store_true", help="print full rendered prompt in dry-run"
380
+ )
381
+
382
+ ld = lsub.add_parser(
383
+ "doctor", help="check config/git/agent CLIs for local mode (never touches GitHub)"
384
+ )
385
+ ld.add_argument("--json", action="store_true", dest="as_json")
386
+ ld.add_argument("--feature", default=None, help="also validate this feature Markdown path")
387
+
388
+
389
+ def _add_epic_parser(sub) -> None:
390
+ """`autoforge epic ...`: the parallel EPIC scheduler (ADR 0008, #183).
391
+
392
+ ``epic _worker`` is matched in :func:`main` before this parser, so it is
393
+ neither a choice here nor in ``--help``.
394
+ """
395
+ ep = sub.add_parser(
396
+ "epic",
397
+ help="parallel EPIC scheduler: run, resume, status, stop, archive (REMOTE only)",
398
+ description=(
399
+ "Run up to --max-parallel issues of one EPIC at once, each in its own scheduler "
400
+ "namespace and worktree, admitted in dependency order from GitHub's native "
401
+ "sub-issues and blocked-by links. Every worker stops at its settlement boundary "
402
+ "(READY_FOR_MERGE, BLOCKED, DONE, FAILED); nothing is merged. REMOTE only; "
403
+ "--state-dir is refused, and so is an EPIC of another repository than this "
404
+ "checkout's 'origin' remote, before anything is read or written."
405
+ ),
406
+ )
407
+ esub = ep.add_subparsers(dest="epic_command", required=True)
408
+
409
+ er = esub.add_parser(
410
+ "run",
411
+ help="start a scheduler for an EPIC (refused while a claim of it is open)",
412
+ description=(
413
+ "Start a scheduler invocation: it holds the controller lock for its whole life, "
414
+ "admits ready issues up to --max-parallel and exits when no worker runs and "
415
+ "nothing is admissible. Refused while any claim of this EPIC is open: an earlier "
416
+ "scheduler ended without closing it, and 'epic resume' classifies it."
417
+ ),
418
+ )
419
+ er.add_argument("epic", metavar="EPIC_URL", help="the EPIC issue URL")
420
+ _add_max_parallel(er)
421
+ er.add_argument(
422
+ "--dry-run",
423
+ action="store_true",
424
+ help=(
425
+ "print the snapshot, the ordered verdicts and the admission plan; no lock, "
426
+ "namespace, claim, worktree or worker"
427
+ ),
428
+ )
429
+ er.add_argument(
430
+ "--force",
431
+ action="store_true",
432
+ help=(
433
+ "move an unreadable scheduler.json aside (scheduler.json.corrupt-<timestamp>); "
434
+ "it does nothing else: it never closes a claim or moves a state"
435
+ ),
436
+ )
437
+
438
+ eres = esub.add_parser(
439
+ "resume",
440
+ help="classify an EPIC's namespaces and claims, then continue like 'epic run'",
441
+ description=(
442
+ "Reconcile every namespace of the EPIC first (a settled one's open claim is "
443
+ "closed, a resumable one is admitted as a resume, an uncertain one is held), "
444
+ "then admit like 'epic run'. Refused (exit 2) while a worker of an earlier "
445
+ "invocation still runs: it keeps the controller lock until it exits."
446
+ ),
447
+ )
448
+ eres.add_argument("epic", metavar="EPIC_URL", help="the EPIC issue URL")
449
+ _add_max_parallel(eres)
450
+ _add_accept_config_change(eres)
451
+
452
+ est = esub.add_parser(
453
+ "status",
454
+ help="every child's category and next step (lock-free, read-only)",
455
+ description=(
456
+ "Take no lock and change nothing: the snapshot from GitHub, and each child's "
457
+ "namespace, claim and run lock read lock-free. Each child gets exactly one "
458
+ "category; a claim's worker PID is shown as a diagnostic and never signalled."
459
+ ),
460
+ )
461
+ est.add_argument("epic", metavar="EPIC_URL", help="the EPIC issue URL")
462
+ est.add_argument(
463
+ "--json",
464
+ action="store_true",
465
+ dest="as_json",
466
+ help="print one autoforge.epic-status/1 document (docs/usage.md)",
467
+ )
468
+
469
+ esp = esub.add_parser(
470
+ "stop",
471
+ help="ask the EPIC's scheduler to stop (lock-free; default: drain)",
472
+ description=(
473
+ "Publish a stop request for the invocation scheduler.json names. A drain "
474
+ "closes admission and lets every worker return at its next step boundary; "
475
+ f"--now behaves like Ctrl-C in the scheduler's terminal. Note: {DRAIN_HELP}. "
476
+ "With no live scheduler, surviving workers drain at their next step boundary, "
477
+ "and --now cannot signal them. Refused when no scheduler.json exists; nothing "
478
+ "is ever removed."
479
+ ),
480
+ )
481
+ esp.add_argument("epic", metavar="EPIC_URL", help="the EPIC issue URL")
482
+ esp.add_argument(
483
+ "--now",
484
+ action="store_true",
485
+ help="interrupt the workers, as Ctrl-C in the scheduler's terminal does",
486
+ )
487
+
488
+ ea = esub.add_parser(
489
+ "archive",
490
+ help="release one settled or uncertain issue for a fresh run (never automatic)",
491
+ description=(
492
+ "Under the controller lock and the issue's run lock: close an open claim as "
493
+ "archived (an unusable claim file is moved aside), then move state/state.json "
494
+ "aside as state.json.archived-<timestamp>. The logs, the worktree, the binding "
495
+ "and the claim history are kept; the run's loop-bound evidence then lives only "
496
+ "in the archived state. Refused while the issue has a live owner. Re-running it "
497
+ "completes an interrupted archive."
498
+ ),
499
+ )
500
+ ea.add_argument("epic", metavar="EPIC_URL", help="the EPIC issue URL")
501
+ ea.add_argument("issue", type=_positive_int, metavar="ISSUE", help="the issue number")
502
+
503
+
504
+ def _add_max_parallel(parser: argparse.ArgumentParser) -> None:
505
+ low, high = MAX_PARALLEL_BOUNDS
506
+ parser.add_argument(
507
+ "--max-parallel",
508
+ type=_max_parallel,
509
+ default=None,
510
+ metavar="N",
511
+ help=(
512
+ f"workers at once, {low} to {high} (default: scheduler.max_parallel in the "
513
+ "configuration)"
514
+ ),
515
+ )
516
+
517
+
518
+ def _load_cfg(args) -> AutoForgeConfig:
519
+ """The ``--config`` file, or the built-in defaults; refused when a launch could rewrite it.
520
+
521
+ The file is parsed first, since the ``sandbox.mode`` it sets decides
522
+ whether the placement check applies at all. Under ``required`` or
523
+ ``preferred``, a file inside a tree the repository's enforced launches
524
+ write, or a directory a provider's launches are given read-write, is
525
+ then a ConfigurationError (exit 2) for every command, before
526
+ anything acts on what it says: no phase, launch or write follows, and no
527
+ command it names runs (#280,
528
+ :func:`autoforge.engine.config_file_placement_problem`). The check is
529
+ given the path as the loader was, never normalized: a ``..`` after a
530
+ link names another file once collapsed.
531
+ """
532
+ cfg = load_config_file(args.config)
533
+ problem = config_file_placement_problem(cfg, args.config or "")
534
+ if problem:
535
+ raise ConfigurationError(f"{problem}. Nothing was run, launched or written.")
536
+ return cfg
537
+
538
+
539
+ def main(argv: list[str] | None = None) -> int:
540
+ words = sys.argv[1:] if argv is None else list(argv)
541
+ if words[:2] == ["epic", "_worker"]:
542
+ # The scheduler's hidden worker entry point (ADR 0008 D9.1, #182): not
543
+ # a command an operator types, so it is not in the parser or --help.
544
+ from .worker import run_worker
545
+
546
+ return run_worker(words[2:])
547
+ parser = build_parser()
548
+ args = parser.parse_args(words)
549
+ try:
550
+ if args.command == "doctor":
551
+ return cmd_doctor(args)
552
+ if args.command == "run":
553
+ return cmd_run(args)
554
+ if args.command == "step":
555
+ return cmd_step(args)
556
+ if args.command == "resume":
557
+ return cmd_resume(args)
558
+ if args.command == "unblock":
559
+ return cmd_unblock(args)
560
+ if args.command == "status":
561
+ return cmd_status(args)
562
+ if args.command == "epic":
563
+ return cmd_epic(args)
564
+ if args.command == "local":
565
+ if args.local_command == "init":
566
+ return cmd_local_init(args)
567
+ if args.local_command == "run":
568
+ return cmd_local_run(args)
569
+ if args.local_command == "doctor":
570
+ return cmd_local_doctor(args)
571
+ parser.error(f"unknown local command {args.local_command}")
572
+ return 2
573
+ parser.error(f"unknown command {args.command}")
574
+ return 2
575
+ except (ConfigurationError, StateError, StateTransitionError, LockError) as exc:
576
+ print(f"autoforge: error: {redact(str(exc))}", file=sys.stderr)
577
+ return 2
578
+ except AutoForgeError as exc:
579
+ print(f"autoforge: error: {redact(str(exc))}", file=sys.stderr)
580
+ return 1
581
+
582
+
583
+ # -- commands ---------------------------------------------------------------
584
+ def _global_flags(args) -> tuple[str, ...]:
585
+ """``--config`` and ``--state-dir`` as this process was given them, and nothing else.
586
+
587
+ An agent failure's ``resume`` line repeats them (#189): the CLI has no
588
+ configuration discovery, so a bare ``resume`` would find no run or run on
589
+ the built-in defaults. They are paths, as typed; no configuration value.
590
+ """
591
+ flags: list[str] = []
592
+ if args.config:
593
+ flags += ["--config", args.config]
594
+ if args.state_dir:
595
+ flags += ["--state-dir", args.state_dir]
596
+ return tuple(flags)
597
+
598
+
599
+ def _engine_for(args, cfg: AutoForgeConfig | None = None) -> ControllerEngine:
600
+ cfg = cfg or _load_cfg(args)
601
+ state_dir = args.state_dir or cfg.state_dir
602
+ engine = ControllerEngine(config=cfg, state_dir=state_dir)
603
+ engine.cli_global_flags = _global_flags(args)
604
+ engine.config_file = absolute_spelling(args.config) if args.config else ""
605
+ return engine
606
+
607
+
608
+ def _check_configuration(engine: ControllerEngine, args, *words: str, dry_run: bool) -> None:
609
+ """The recorded-configuration and sandbox-pin gate of ``step``, ``resume`` and ``unblock``.
610
+
611
+ Refuses (``ConfigurationDriftError``, exit 2) before any phase executes
612
+ when this invocation's configuration differs from the run's (#219) or
613
+ its ``sandbox.mode`` is weaker than the run's pin (#280), unless
614
+ ``--accept-config-change``; prints the engine's notice on stderr.
615
+ """
616
+ notice = engine.check_run_configuration(
617
+ words, accept_change=bool(getattr(args, "accept_config_change", False)), dry_run=dry_run
618
+ )
619
+ if notice:
620
+ print(redact(f"autoforge: {notice}"), file=sys.stderr)
621
+
622
+
623
+ def _local_engine_for(args, cfg: AutoForgeConfig | None = None) -> ControllerEngine:
624
+ """An engine for a LOCAL command, with its state directory resolved.
625
+
626
+ LOCAL state never lives in the reviewed working tree; see
627
+ :meth:`ControllerEngine.bind_local_state_dir`.
628
+ """
629
+ engine = _engine_for(args, cfg)
630
+ engine.bind_local_state_dir(args.state_dir)
631
+ return engine
632
+
633
+
634
+ def _bind_existing_run(engine: ControllerEngine) -> None:
635
+ """Resolve which run a mode-agnostic command (`resume`, `step`, `status`) means.
636
+
637
+ A REMOTE run keeps its state under `config.state_dir` (`.autoforge` by
638
+ default); a LOCAL run keeps it in the git directory, because the LOCAL
639
+ fingerprint covers the working tree. The two locations are disjoint and
640
+ the mode is not known until state is read, so the command looks for a
641
+ `state.json` in both. Exactly one match is used. Two matches is a real
642
+ ambiguity and is refused rather than resolved by precedence: picking one
643
+ would silently `resume` the wrong workflow. Zero matches leaves the
644
+ configured location so the error names it.
645
+
646
+ `--state-dir` is an answer to this question, so it is never second-guessed
647
+ (the caller does not reach here when it was given).
648
+ """
649
+ configured = engine.paths
650
+ try:
651
+ local = engine.local_state_paths()
652
+ except (AutoForgeError, OSError):
653
+ # Not a git repository (or git is unusable): only the configured
654
+ # location can hold a run, and any real problem surfaces there.
655
+ return
656
+ if os.path.abspath(local.state_dir) == os.path.abspath(configured.state_dir):
657
+ return
658
+ here = os.path.lexists(configured.state_file)
659
+ there = os.path.lexists(local.state_file)
660
+ if here and there:
661
+ raise StateError(
662
+ f"two runs exist: a remote-style one at {configured.state_file} and a local "
663
+ f"one at {local.state_file}. Pass --state-dir to say which one this command "
664
+ "means; the controller will not pick for you."
665
+ )
666
+ if there:
667
+ engine.paths = local
668
+
669
+
670
+ def _guard_scheduler_ownership(
671
+ engine: ControllerEngine, state: AutoForgeState, command: str
672
+ ) -> None:
673
+ """ADR 0008 D17.2's forward guard, under L0 and before anything is written.
674
+
675
+ Refuses (``SchedulerOwnedIssueError``, exit 2) when a scheduler
676
+ namespace owns the issue ``command`` would act on, the run's
677
+ ``current_issue_url``, then hands the engine the same predicate, which
678
+ ``_verify_issue_selectable`` applies to every issue it is asked about
679
+ (``next_issue_url`` included). ``unblock`` of a namespace's own state
680
+ directory exempts that namespace. LOCAL runs name no issue and are not
681
+ checked.
682
+ """
683
+ if state.mode is WorkflowMode.LOCAL:
684
+ return
685
+ root = engine.coordination_root()
686
+ exempt = (
687
+ namespace_issue_of_state_dir(engine.paths.state_dir, root) if command == "unblock" else None
688
+ )
689
+ refuse_scheduler_owned_issue(root, state.current_issue_url, command, exempt_issue=exempt)
690
+ engine.issue_ownership = standalone_ownership_predicate(root, exempt_issue=exempt)
691
+
692
+
693
+ def _bind_standalone_run(engine: ControllerEngine, args, command: str) -> None:
694
+ """:func:`_bind_existing_run` for a standalone ``step`` or ``resume`` (ADR 0008 D17.3).
695
+
696
+ The state directory this invocation names (``--state-dir`` or
697
+ ``config.state_dir``) is judged before the binding looks for a run in it
698
+ or in LOCAL's location: one inside the scheduler root that holds no
699
+ ``state.json`` would otherwise give way to a LOCAL run's, and the command
700
+ would continue that run instead of refusing. The directory bound is
701
+ judged again. Both before the lock and before any state is read; a dry
702
+ run is judged by neither (:meth:`ControllerEngine.refuse_scheduler_state_dir`).
703
+ """
704
+ if not args.dry_run:
705
+ engine.refuse_scheduler_state_dir(command)
706
+ if args.state_dir:
707
+ return
708
+ _bind_existing_run(engine)
709
+ if not args.dry_run:
710
+ engine.refuse_scheduler_state_dir(command)
711
+
712
+
713
+ def _doctor_runner():
714
+ """Command runner used by `doctor` (indirection so tests can inject a fake)."""
715
+ return None
716
+
717
+
718
+ def cmd_doctor(args) -> int:
719
+ results = run_doctor(
720
+ config_path=args.config,
721
+ state_dir=args.state_dir,
722
+ runner=_doctor_runner(),
723
+ epic_url=args.epic,
724
+ )
725
+ return _report_checks(results, args.as_json, "AutoForge doctor (read-only checks)")
726
+
727
+
728
+ def cmd_local_doctor(args) -> int:
729
+ """`autoforge local doctor`: no gh, no gh auth, no 'origin' remote."""
730
+ results = run_local_doctor(
731
+ config_path=args.config,
732
+ state_dir=args.state_dir,
733
+ runner=_doctor_runner(),
734
+ feature_spec_path=args.feature,
735
+ )
736
+ return _report_checks(results, args.as_json, "AutoForge doctor — local mode (read-only checks)")
737
+
738
+
739
+ def _report_checks(results: list[CheckResult], as_json: bool, title: str) -> int:
740
+ if as_json:
741
+ checks = [
742
+ {
743
+ "name": r.name,
744
+ "ok": r.ok,
745
+ "required": r.required,
746
+ "skipped": r.skipped,
747
+ "detail": redact(r.detail),
748
+ }
749
+ for r in results
750
+ ]
751
+ print(
752
+ json.dumps(
753
+ {"ok": all(c["ok"] or not c["required"] for c in checks), "checks": checks},
754
+ indent=2,
755
+ )
756
+ )
757
+ else:
758
+ print(title)
759
+ print()
760
+ for r in results:
761
+ print(f" [{r.label}] {r.name}: {redact(r.detail)}")
762
+ print()
763
+ failed = [r for r in results if not r.ok and r.required]
764
+ if failed:
765
+ print(f"{len(failed)} required check(s) failed.", file=sys.stderr)
766
+ return 1
767
+ # Keep stdout to the JSON document alone in --json mode.
768
+ print("All checks passed.", file=sys.stderr if as_json else sys.stdout)
769
+ return 0
770
+
771
+
772
+ def _progress_line(line: str) -> None:
773
+ """One live progress line (already bounded and redacted), on stderr at once."""
774
+ print(line, file=sys.stderr, flush=True)
775
+
776
+
777
+ # What one line typed at the terminal resolver may hold: an answer of
778
+ # MAX_ANSWER_CHARS characters of up to four UTF-8 bytes each, and a little
779
+ # more. A longer line is read to its end and refused, never kept.
780
+ _MAX_TERMINAL_LINE_BYTES = 4 * MAX_ANSWER_CHARS + 64
781
+ # An option number as typed: ASCII digits, and few of them (a question has
782
+ # at most sdk_policy.MAX_OPTIONS options), so int() takes every one.
783
+ _OPTION_NUMBER = re.compile(r"[0-9]{1,3}")
784
+ # What the resolver may have written to a terminal that has not taken it yet
785
+ # (its output suspended, or not read): past this it reads no more keys until
786
+ # the terminal takes some or the deadline comes, so what waits stays bounded.
787
+ _MAX_UNSHOWN_BYTES = 64 * 1024
788
+
789
+
790
+ class _Unusable(Exception):
791
+ """A line the terminal resolver cannot use: the message names the rule."""
792
+
793
+
794
+ class _TerminalWriter:
795
+ """Writes to the terminal of ``fd`` that never block (#134).
796
+
797
+ A terminal's output can be suspended (its stop key under IXON, another
798
+ program's ``tcflow``) or left unread, and a blocking write then waits
799
+ until it resumes: past any deadline, and the resolver with it. So the
800
+ resolver writes to a terminal through a descriptor of its own on its
801
+ device, opened non-blocking: an open file description of its own, so
802
+ stdin, stderr and the shell's descriptors stay as they are. What the
803
+ terminal does not take at once waits here, and is written as it takes
804
+ it while the resolver waits for a key, never past the deadline
805
+ (:meth:`_Terminal.line`); what it has not taken when the resolver is
806
+ done is dropped (:meth:`close`).
807
+ """
808
+
809
+ def __init__(self, fd: int) -> None:
810
+ self.fd = os.open(os.ttyname(fd), os.O_WRONLY | os.O_NOCTTY | os.O_CLOEXEC | os.O_NONBLOCK)
811
+ self.device = os.fstat(self.fd).st_rdev
812
+ self._unshown = bytearray()
813
+
814
+ @property
815
+ def unshown(self) -> int:
816
+ """How many bytes the terminal has not taken yet."""
817
+ return len(self._unshown)
818
+
819
+ def write(self, data: bytes) -> None:
820
+ self._unshown += data
821
+ self.flush()
822
+
823
+ def flush(self) -> None:
824
+ """Write what the terminal takes now."""
825
+ while self._unshown:
826
+ try:
827
+ written = os.write(self.fd, self._unshown)
828
+ except BlockingIOError:
829
+ return
830
+ if not written:
831
+ return
832
+ del self._unshown[:written]
833
+
834
+ def close(self) -> None:
835
+ try:
836
+ self.flush()
837
+ except OSError: # the terminal hung up: there is no one to show it to
838
+ pass
839
+ finally:
840
+ os.close(self.fd)
841
+
842
+
843
+ class _LineEditing:
844
+ """A terminal whose line editing the resolver took over: its keys, the
845
+ writer it echoes what is typed through, and the settings it reads under
846
+ (:func:`_line_editing`)."""
847
+
848
+ def __init__(self, fd: int, saved: list, writer: _TerminalWriter) -> None:
849
+ import termios
850
+
851
+ cc = saved[6]
852
+
853
+ def key(index: int) -> int | None:
854
+ value = cc[index]
855
+ code = value[0] if isinstance(value, bytes) else value
856
+ # 0 is Linux's _POSIX_VDISABLE, 0xff macOS's; a key past ASCII
857
+ # would cut into a UTF-8 character.
858
+ return code if 0 < code < 0x80 else None
859
+
860
+ # Backspace and delete erase whatever the terminal's erase key is:
861
+ # a control character is never part of a usable answer.
862
+ self.erase = frozenset(k for k in (key(termios.VERASE), 0x08, 0x7F) if k is not None)
863
+ self.word_erase = key(termios.VWERASE)
864
+ self.kill = key(termios.VKILL)
865
+ self.eof = key(termios.VEOF)
866
+ # Under IXON the stop and start keys are never input either.
867
+ flow = (key(termios.VSTOP), key(termios.VSTART)) if saved[0] & termios.IXON else ()
868
+ self.flow = frozenset(k for k in flow if k is not None)
869
+ self._fd = fd
870
+ self.writer = writer
871
+ # Canonical mode, echo and output flow control off, and a read
872
+ # returns once a key is typed.
873
+ self.raw = list(saved)
874
+ self.raw[0] = saved[0] & ~termios.IXON
875
+ self.raw[3] = saved[3] & ~(termios.ICANON | termios.ECHO)
876
+ self.raw[6] = list(cc)
877
+ self.raw[6][termios.VMIN], self.raw[6][termios.VTIME] = 1, 0
878
+
879
+ def echo(self, data: bytes) -> None:
880
+ self.writer.write(data)
881
+
882
+ def retake(self) -> bool:
883
+ """Whether the terminal's own line editing came back on while the
884
+ resolver waited (a stop and continue under a shell that resets the
885
+ terminal, another program's ``stty``): what was just read went
886
+ through it and may have been cut. It is turned off again, and so is
887
+ output flow control when that came back on."""
888
+ import termios
889
+
890
+ attrs = termios.tcgetattr(self._fd)
891
+ if attrs[3] & (termios.ICANON | termios.ECHO) or attrs[0] & termios.IXON:
892
+ termios.tcsetattr(self._fd, termios.TCSANOW, self.raw)
893
+ return bool(attrs[3] & termios.ICANON)
894
+
895
+
896
+ @contextmanager
897
+ def _line_editing(fd: int) -> Iterator[_LineEditing | None]:
898
+ """Take over the line editing of the terminal ``fd`` while the resolver
899
+ asks (#134); ``None`` for a descriptor that is not a terminal, whose
900
+ lines arrive whole.
901
+
902
+ In canonical mode a terminal edits a line before a reader gets it, and
903
+ Linux keeps at most 4095 bytes of it: what is typed past that is dropped
904
+ without a word, under any bound a reader could hold the line to, and an
905
+ answer or guidance would reach the agent cut short. So canonical mode
906
+ and echo are off while the resolver reads, and it edits the line itself
907
+ (:class:`_Terminal`): it echoes what it keeps, on the terminal and
908
+ never on stderr, and takes the terminal's own erase, word-erase, kill
909
+ and end-of-file keys. A line read while canonical mode came back on is
910
+ refused (:meth:`_LineEditing.retake`).
911
+
912
+ Output flow control (IXON) is off too: under it the stop key suspends
913
+ the terminal's output until the start key, and the echo would wait for
914
+ it past the deadline. The two keys are still never input, and suspend
915
+ nothing; output a stop key suspended before the resolver asked comes
916
+ back where turning IXON off restarts it (Linux). Whatever else holds the
917
+ output, the resolver writes to the terminal only as it takes it
918
+ (:class:`_TerminalWriter`). Everything else stays the terminal's: Ctrl-C
919
+ still raises ``KeyboardInterrupt`` (ISIG is untouched), and the settings
920
+ are restored however the reading ends.
921
+ """
922
+ try:
923
+ import termios
924
+ except ImportError: # pragma: no cover - a platform without termios
925
+ yield None
926
+ return
927
+ try:
928
+ saved = termios.tcgetattr(fd)
929
+ except (OSError, termios.error):
930
+ saved = None
931
+ if saved is None:
932
+ yield None
933
+ return
934
+ # Its own descriptor, opened to write, also echoes a terminal read
935
+ # through a read-only one.
936
+ writer = _TerminalWriter(fd)
937
+ try:
938
+ editing = _LineEditing(fd, saved, writer)
939
+ termios.tcsetattr(fd, termios.TCSANOW, editing.raw)
940
+ try:
941
+ yield editing
942
+ finally:
943
+ termios.tcsetattr(fd, termios.TCSANOW, saved)
944
+ finally:
945
+ writer.close()
946
+
947
+
948
+ def _in_memory(output: TextIO | None) -> bool:
949
+ """Whether ``output`` is a stream without a descriptor (a StringIO),
950
+ whose writes never wait. ``None`` is no stream at all: Python's stderr
951
+ when its descriptor was closed at startup; a closed stream is none
952
+ either."""
953
+ if output is None:
954
+ return False
955
+ try:
956
+ output.fileno()
957
+ except (AttributeError, io.UnsupportedOperation):
958
+ return True
959
+ except (OSError, ValueError):
960
+ return False
961
+ return False
962
+
963
+
964
+ def _terminal_fd(output: TextIO | None) -> int | None:
965
+ """The descriptor ``output`` writes to when that is a terminal."""
966
+ if output is None:
967
+ return None
968
+ try:
969
+ fd = output.fileno()
970
+ except (AttributeError, OSError, ValueError):
971
+ return None
972
+ return fd if os.isatty(fd) else None
973
+
974
+
975
+ def _can_show(output: TextIO | None, editing: _LineEditing | None) -> bool:
976
+ """Whether the resolver has somewhere to show its text that no write
977
+ holds past the deadline (:func:`_shown_on`)."""
978
+ return editing is not None or _in_memory(output) or _terminal_fd(output) is not None
979
+
980
+
981
+ @contextmanager
982
+ def _shown_on(
983
+ output: TextIO | None, editing: _LineEditing | None
984
+ ) -> Iterator[_TerminalWriter | None]:
985
+ """The writer the resolver shows its text through, so that no write holds
986
+ it past the deadline (R4-F1).
987
+
988
+ An ``output`` that is a terminal is written to as it takes it: through
989
+ the echo's writer when it is the terminal the resolver reads, so that
990
+ the two keep their order. Any other output (stderr redirected to a
991
+ pipe, a file or a socket: ``2>log``, ``2>&1 | tee log``; or closed) is
992
+ never written to, since a pipe no one reads holds a blocking write
993
+ until it is read: the text is shown on the terminal the resolver reads
994
+ instead, where the answer is typed, as ``getpass`` does. ``None`` for
995
+ an in-memory stream, printed to as before. Entered only when
996
+ :func:`_can_show`: with no terminal to show the text on instead, the
997
+ resolver asks nothing."""
998
+ if _in_memory(output):
999
+ yield None
1000
+ return
1001
+ fd = _terminal_fd(output)
1002
+ if editing is not None and (fd is None or os.fstat(fd).st_rdev == editing.writer.device):
1003
+ yield editing.writer
1004
+ return
1005
+ assert fd is not None # _can_show
1006
+ writer = _TerminalWriter(fd)
1007
+ try:
1008
+ yield writer
1009
+ finally:
1010
+ writer.close()
1011
+
1012
+
1013
+ def _utf8_length(lead: int) -> int:
1014
+ """How many bytes the UTF-8 character starting with ``lead`` has; 1 for
1015
+ a byte no character starts with (kept alone, so its line is refused)."""
1016
+ if 0xC2 <= lead <= 0xDF:
1017
+ return 2
1018
+ if 0xE0 <= lead <= 0xEF:
1019
+ return 3
1020
+ if 0xF0 <= lead <= 0xF4:
1021
+ return 4
1022
+ return 1
1023
+
1024
+
1025
+ def _echoed(typed: bytes) -> tuple[bytes, int]:
1026
+ """What the terminal is shown of one typed character, and its columns: a
1027
+ control character as ^X, one that is not UTF-8 or would act on the
1028
+ terminal as U+FFFD, a combining mark in no column of its own and a wide
1029
+ character in two."""
1030
+ try:
1031
+ ch = typed.decode("utf-8")
1032
+ except UnicodeDecodeError:
1033
+ return "\ufffd".encode(), 1
1034
+ if ord(ch) < 0x20:
1035
+ return b"^" + bytes((ord(ch) + 0x40,)), 2
1036
+ category = unicodedata.category(ch)
1037
+ if category in ("Cc", "Cf", "Zl", "Zp"):
1038
+ return "\ufffd".encode(), 1
1039
+ if category in ("Mn", "Me"):
1040
+ return typed, 0
1041
+ return typed, 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
1042
+
1043
+
1044
+ class _Terminal:
1045
+ """The operator's terminal while the resolver asks: lines read from
1046
+ ``fd`` until ``deadline`` (a string, or :class:`Unavailable`), and the
1047
+ resolver's text shown on ``output`` (:meth:`show`).
1048
+
1049
+ At a terminal (``editing``) each key is taken as it is typed and the
1050
+ line is edited here (:func:`_line_editing`); any other descriptor
1051
+ delivers whole lines. A line past :data:`_MAX_TERMINAL_LINE_BYTES` is
1052
+ refused whole when it ends: at a terminal what is typed past the bound
1053
+ is neither kept nor echoed (the bell rings once), and the kill key
1054
+ starts the line again.
1055
+
1056
+ What the resolver writes to a terminal, its echo and, unless ``output``
1057
+ is an in-memory stream (``shown``, :func:`_shown_on`), its text, goes
1058
+ through a :class:`_TerminalWriter`: what the terminal has not taken is
1059
+ written while a line is awaited, and a terminal whose output is
1060
+ suspended never holds the resolver past the deadline. While more than
1061
+ :data:`_MAX_UNSHOWN_BYTES` wait, no key is read.
1062
+ """
1063
+
1064
+ def __init__(
1065
+ self,
1066
+ fd: int,
1067
+ clock: Callable[[], float],
1068
+ deadline: float,
1069
+ editing: _LineEditing | None = None,
1070
+ output: TextIO | None = None,
1071
+ shown: _TerminalWriter | None = None,
1072
+ ) -> None:
1073
+ self._fd = fd
1074
+ self._clock = clock
1075
+ self._deadline = deadline
1076
+ self._editing = editing
1077
+ self._output = output
1078
+ self._shown = shown
1079
+ self._writers: list[_TerminalWriter] = []
1080
+ for writer in (editing.writer if editing is not None else None, shown):
1081
+ if writer is not None and writer not in self._writers:
1082
+ self._writers.append(writer)
1083
+ self._pending = b""
1084
+ self._overlong = False
1085
+ # The line being typed at a terminal: each character's bytes and
1086
+ # columns, and the first bytes of one not yet whole.
1087
+ self._typed: list[tuple[bytes, int]] = []
1088
+ self._size = 0
1089
+ self._partial = b""
1090
+
1091
+ def show(self, text: str, end: str = "\n") -> None:
1092
+ if self._shown is None: # an in-memory stream (_shown_on)
1093
+ print(text, end=end, file=self._output, flush=True)
1094
+ return
1095
+ encoding = getattr(self._output, "encoding", None) or "utf-8"
1096
+ self._shown.write((text + end).encode(encoding, "backslashreplace"))
1097
+
1098
+ def line(self) -> str | Unavailable:
1099
+ while (raw := self._take()) is None:
1100
+ remaining = self._deadline - self._clock()
1101
+ if remaining <= 0:
1102
+ return Unavailable(UnavailableReason.EXPIRED)
1103
+ unshown = [writer for writer in self._writers if writer.unshown]
1104
+ reading = [self._fd] if sum(w.unshown for w in unshown) < _MAX_UNSHOWN_BYTES else []
1105
+ readable, writable, _ = select.select(reading, [w.fd for w in unshown], [], remaining)
1106
+ for writer in unshown:
1107
+ if writer.fd in writable:
1108
+ writer.flush()
1109
+ if not readable:
1110
+ if writable:
1111
+ continue
1112
+ return Unavailable(UnavailableReason.EXPIRED)
1113
+ chunk = os.read(self._fd, 4096)
1114
+ if not chunk:
1115
+ return Unavailable(UnavailableReason.END_OF_INPUT)
1116
+ if self._editing is not None and self._editing.retake():
1117
+ self._pending, self._partial, self._overlong = b"", b"", False
1118
+ self._typed, self._size = [], 0
1119
+ raise _Unusable("the terminal's line editing came back on while the line was typed")
1120
+ self._pending += chunk
1121
+ if isinstance(raw, Unavailable):
1122
+ return raw
1123
+ overlong, self._overlong = self._overlong, False
1124
+ if overlong:
1125
+ raise _Unusable("the line is too long")
1126
+ try:
1127
+ return raw.decode("utf-8").rstrip("\r")
1128
+ except UnicodeDecodeError:
1129
+ raise _Unusable("the line is not UTF-8") from None
1130
+
1131
+ def seconds_left(self) -> int:
1132
+ return max(0, int(self._deadline - self._clock()))
1133
+
1134
+ def _take(self) -> bytes | Unavailable | None:
1135
+ """The next line of what was read, or ``None`` until one has ended."""
1136
+ if self._editing is not None:
1137
+ return self._edit(self._editing)
1138
+ raw, newline, rest = self._pending.partition(b"\n")
1139
+ if not newline:
1140
+ if len(self._pending) > _MAX_TERMINAL_LINE_BYTES:
1141
+ self._pending, self._overlong = b"", True
1142
+ return None
1143
+ self._pending = rest
1144
+ if len(raw) > _MAX_TERMINAL_LINE_BYTES:
1145
+ self._overlong = True
1146
+ return raw
1147
+
1148
+ def _edit(self, keys: _LineEditing) -> bytes | Unavailable | None:
1149
+ pending, self._pending = self._pending, b""
1150
+ for at, byte in enumerate(pending):
1151
+ if byte in keys.flow: # the stop and start keys, taken as under IXON
1152
+ continue
1153
+ if self._partial and 0x80 <= byte <= 0xBF:
1154
+ self._partial += bytes((byte,))
1155
+ if len(self._partial) == _utf8_length(self._partial[0]):
1156
+ self._keep(self._partial, keys)
1157
+ self._partial = b""
1158
+ continue
1159
+ if self._partial: # a character cut short: kept, so the line is refused
1160
+ self._keep(self._partial, keys)
1161
+ self._partial = b""
1162
+ if byte >= 0x80:
1163
+ if _utf8_length(byte) == 1:
1164
+ self._keep(bytes((byte,)), keys)
1165
+ else:
1166
+ self._partial = bytes((byte,))
1167
+ elif byte in b"\r\n":
1168
+ keys.echo(b"\n")
1169
+ self._pending = pending[at + 1 :]
1170
+ line = b"".join(typed for typed, _ in self._typed)
1171
+ self._typed, self._size = [], 0
1172
+ return line
1173
+ elif byte in keys.erase:
1174
+ self._erase(1, keys)
1175
+ elif byte == keys.word_erase:
1176
+ while self._typed and self._typed[-1][0].decode("utf-8", "replace").isspace():
1177
+ self._erase(1, keys)
1178
+ while self._typed and not self._typed[-1][0].decode("utf-8", "replace").isspace():
1179
+ self._erase(1, keys)
1180
+ elif byte == keys.kill:
1181
+ self._erase(len(self._typed), keys)
1182
+ self._overlong = False
1183
+ elif byte == keys.eof:
1184
+ # As in canonical mode: the end of input on an empty line,
1185
+ # and nothing within one.
1186
+ if not self._typed and not self._overlong:
1187
+ return Unavailable(UnavailableReason.END_OF_INPUT)
1188
+ else:
1189
+ self._keep(bytes((byte,)), keys)
1190
+ return None
1191
+
1192
+ def _keep(self, typed: bytes, keys: _LineEditing) -> None:
1193
+ if self._overlong or self._size + len(typed) > _MAX_TERMINAL_LINE_BYTES:
1194
+ if not self._overlong:
1195
+ keys.echo(b"\a")
1196
+ self._overlong = True
1197
+ return
1198
+ shown, columns = _echoed(typed)
1199
+ keys.echo(shown)
1200
+ self._typed.append((typed, columns))
1201
+ self._size += len(typed)
1202
+
1203
+ def _erase(self, count: int, keys: _LineEditing) -> None:
1204
+ for _ in range(min(count, len(self._typed))):
1205
+ typed, columns = self._typed.pop()
1206
+ self._size -= len(typed)
1207
+ keys.echo(b"\b" * columns + b" " * columns + b"\b" * columns)
1208
+
1209
+
1210
+ def _discard_typeahead(fd: int) -> None:
1211
+ """Drop what was typed before the request was shown, so a key pressed
1212
+ earlier never answers it. Nothing to drop on a descriptor that is not a
1213
+ terminal."""
1214
+ try:
1215
+ import termios
1216
+ except ImportError: # pragma: no cover - a platform without termios
1217
+ return
1218
+ try:
1219
+ termios.tcflush(fd, termios.TCIFLUSH)
1220
+ except (OSError, termios.error):
1221
+ pass
1222
+
1223
+
1224
+ class TerminalResolver:
1225
+ """Asks the operator at this terminal (ADR 0007 D5.1, D7.4, D7.7; #134).
1226
+
1227
+ The engine's resolver for a real ``run``, ``local run``, ``step`` and
1228
+ ``resume``. It reads stdin and shows the request on stderr, where the
1229
+ live progress is, when stderr is a terminal; stdout stays the step
1230
+ outcomes'. A redirected stderr (a pipe, a file) or none at all is never
1231
+ written to: the request is shown on the terminal it reads instead
1232
+ (:func:`_shown_on`). Before it asks it checks that stdin is a terminal
1233
+ (D7.4) and drops anything typed ahead. While it asks it edits the line
1234
+ itself, since the terminal's own line editing would drop what is typed
1235
+ past its buffer without a word (:func:`_line_editing`): an answer
1236
+ reaches the agent whole or is refused. Each line is read until the
1237
+ deadline the broker gives: its expiry is ``expired``, the end of the
1238
+ input ``end_of_input``, and the broker decides what either means.
1239
+ No write holds it past that deadline: the stop key suspends nothing
1240
+ while it reads, it writes to a terminal only as the terminal takes it
1241
+ (:class:`_TerminalWriter`), so a terminal whose output is suspended or
1242
+ unread neither holds a decision back nor keeps the wait open, and it
1243
+ writes to no pipe, which a reader that stopped reading would hold.
1244
+ Ctrl-C raises ``KeyboardInterrupt`` as anywhere else: the step is
1245
+ interrupted and nothing is decided (D7.7). An input it cannot use is
1246
+ explained, by rule and never by value, and asked again within the same
1247
+ deadline. Only what the request offers is offered; a modified input
1248
+ never is, here.
1249
+
1250
+ Everything shown is the request's own rendering, already bounded,
1251
+ cleaned and redacted (:mod:`autoforge.interaction`).
1252
+ """
1253
+
1254
+ def __init__(
1255
+ self,
1256
+ *,
1257
+ input_fd: int | None = None,
1258
+ output: TextIO | None = None,
1259
+ clock: Callable[[], float] = time.monotonic,
1260
+ is_terminal: Callable[[int], bool] = os.isatty,
1261
+ ) -> None:
1262
+ self._input_fd = input_fd
1263
+ self._output = output
1264
+ self._clock = clock
1265
+ self._is_terminal = is_terminal
1266
+
1267
+ def resolve(
1268
+ self, request: InteractionRequest, deadline: float
1269
+ ) -> InteractionDecision | Unavailable:
1270
+ fd = self._stdin()
1271
+ if fd is None or not self._is_terminal(fd):
1272
+ return Unavailable(UnavailableReason.NO_TERMINAL)
1273
+ _discard_typeahead(fd)
1274
+ output = self._output or sys.stderr
1275
+ with _line_editing(fd) as editing:
1276
+ if not _can_show(output, editing):
1277
+ return Unavailable(UnavailableReason.NO_TERMINAL)
1278
+ with _shown_on(output, editing) as shown:
1279
+ terminal = _Terminal(fd, self._clock, deadline, editing, output, shown)
1280
+ return self._resolve(request, terminal)
1281
+
1282
+ # -- internals -------------------------------------------------------------
1283
+ def _resolve(
1284
+ self, request: InteractionRequest, terminal: _Terminal
1285
+ ) -> InteractionDecision | Unavailable:
1286
+ source = f", subagent {request.agent}" if request.agent is not None else ""
1287
+ if request.kind is RequestKind.PERMISSION:
1288
+ terminal.show(
1289
+ f"autoforge: the agent asks permission to use {request.tool_name} "
1290
+ f"(request {request.request_id}{source}); answer within "
1291
+ f"{terminal.seconds_left()}s, Ctrl-C stops the step"
1292
+ )
1293
+ return self._permission(request, terminal)
1294
+ terminal.show(
1295
+ f"autoforge: the agent asks {len(request.questions)} question(s) "
1296
+ f"(request {request.request_id}{source}); answer within "
1297
+ f"{terminal.seconds_left()}s, Ctrl-C stops the step"
1298
+ )
1299
+ responses: list[QuestionResponse] = []
1300
+ for n, question in enumerate(request.questions, 1):
1301
+ response = self._question(request, question, n, terminal)
1302
+ if not isinstance(response, QuestionResponse):
1303
+ return response
1304
+ responses.append(response)
1305
+ return Answer(tuple(responses))
1306
+
1307
+ def _stdin(self) -> int | None:
1308
+ if self._input_fd is not None:
1309
+ return self._input_fd
1310
+ try:
1311
+ return sys.stdin.fileno() if sys.stdin is not None else None
1312
+ except (OSError, ValueError):
1313
+ return None
1314
+
1315
+ def _ask(self, terminal: _Terminal, prompt: str) -> str | Unavailable:
1316
+ """One usable line for ``prompt``, or :class:`Unavailable`."""
1317
+ while True:
1318
+ terminal.show(prompt, end="")
1319
+ try:
1320
+ line = terminal.line()
1321
+ except _Unusable as exc:
1322
+ terminal.show(f" not used: {exc}; try again")
1323
+ continue
1324
+ if isinstance(line, Unavailable):
1325
+ terminal.show("")
1326
+ return line
1327
+ if line.strip():
1328
+ return line.strip()
1329
+
1330
+ def _question(
1331
+ self,
1332
+ request: InteractionRequest,
1333
+ question: Question,
1334
+ n: int,
1335
+ terminal: _Terminal,
1336
+ ) -> QuestionResponse | Cancel | Unavailable:
1337
+ own = question.custom and DecisionKind.CUSTOM_ANSWER in request.offers
1338
+ cancel = DecisionKind.CANCEL in request.offers
1339
+ title = f" ({n}/{len(request.questions)})"
1340
+ terminal.show(f"{title} {question.header}" if question.header else title)
1341
+ terminal.show(f" {question.text}")
1342
+ for number, option in enumerate(question.options, 1):
1343
+ described = f" - {option.description}" if option.description else ""
1344
+ terminal.show(f" {number}. {option.shown}{described}")
1345
+ if own:
1346
+ terminal.show(" 0. something else, in your own words")
1347
+ several = "numbers separated by commas" if question.multiple else "a number"
1348
+ prompt = f" choose {several}{', or c to cancel' if cancel else ''}: "
1349
+ while True:
1350
+ line = self._ask(terminal, prompt)
1351
+ if isinstance(line, Unavailable):
1352
+ return line
1353
+ if line.lower() == "c" and cancel:
1354
+ return Cancel()
1355
+ picked = self._picks(line, question, own)
1356
+ if isinstance(picked, str):
1357
+ terminal.show(f" not used: {picked}; try again")
1358
+ continue
1359
+ custom: str | None = None
1360
+ if 0 in picked:
1361
+ text = self._ask(terminal, " your answer: ")
1362
+ if isinstance(text, Unavailable):
1363
+ return text
1364
+ custom = text
1365
+ labels = tuple(question.options[number - 1].label for number in picked if number)
1366
+ try:
1367
+ return QuestionResponse(choices=labels, custom_text=custom)
1368
+ except InteractionDataError as exc:
1369
+ terminal.show(f" not used: {exc}; try again")
1370
+
1371
+ @staticmethod
1372
+ def _picks(line: str, question: Question, own: bool) -> list[int] | str:
1373
+ """The option numbers ``line`` picks, or why it picks none."""
1374
+ words = [word.strip() for word in line.split(",")]
1375
+ if not all(_OPTION_NUMBER.fullmatch(word) for word in words):
1376
+ return "expected option numbers"
1377
+ picked = [int(word) for word in words]
1378
+ lowest = 0 if own else 1
1379
+ if any(not lowest <= number <= len(question.options) for number in picked):
1380
+ return f"choose from {lowest} to {len(question.options)}"
1381
+ if len(set(picked)) != len(picked):
1382
+ return "an option is chosen twice"
1383
+ if len(picked) > 1 and not question.multiple:
1384
+ return "this question takes one answer"
1385
+ return picked
1386
+
1387
+ def _permission(
1388
+ self, request: InteractionRequest, terminal: _Terminal
1389
+ ) -> Allow | Deny | Cancel | Unavailable:
1390
+ offers = request.offers
1391
+ if request.summary:
1392
+ terminal.show(f" {request.summary}")
1393
+ terminal.show(f" input sha256 {request.input_sha256}")
1394
+ choices: dict[str, str] = {}
1395
+ if DecisionKind.ALLOW_ONCE in offers:
1396
+ choices["y"] = "allow this call once"
1397
+ if DecisionKind.ALLOW_SESSION in offers:
1398
+ choices["s"] = "allow it for the rest of this session"
1399
+ if DecisionKind.DENY in offers or DecisionKind.DENY_GUIDANCE in offers:
1400
+ choices["n"] = (
1401
+ "deny it (n <text>: and tell the agent what to do instead)"
1402
+ if DecisionKind.DENY_GUIDANCE in offers
1403
+ else "deny it"
1404
+ )
1405
+ if DecisionKind.CANCEL in offers:
1406
+ choices["c"] = "cancel: decline to decide"
1407
+ for key, text in choices.items():
1408
+ terminal.show(f" {key} {text}")
1409
+ while True:
1410
+ line = self._ask(terminal, f" choose {'/'.join(choices)}: ")
1411
+ if isinstance(line, Unavailable):
1412
+ return line
1413
+ key, _, rest = line.partition(" ")
1414
+ key, rest = key.lower(), rest.strip()
1415
+ if key not in choices or (rest and key != "n"):
1416
+ terminal.show(f" not used: expected one of {', '.join(choices)}; try again")
1417
+ continue
1418
+ if key == "y":
1419
+ return Allow(AllowScope.ONCE)
1420
+ if key == "s":
1421
+ return Allow(AllowScope.SESSION)
1422
+ if key == "c":
1423
+ return Cancel()
1424
+ if not rest and DecisionKind.DENY not in offers:
1425
+ terminal.show(" not used: say what the agent should do instead (n <text>)")
1426
+ continue
1427
+ if rest and DecisionKind.DENY_GUIDANCE not in offers:
1428
+ terminal.show(" not used: this provider takes no guidance with a denial (n)")
1429
+ continue
1430
+ try:
1431
+ return Deny(rest or None)
1432
+ except InteractionDataError as exc:
1433
+ terminal.show(f" not used: {exc}; try again")
1434
+
1435
+
1436
+ def _attach_terminal(engine: ControllerEngine) -> None:
1437
+ """Stream a real run's agent progress to stderr (#192), and ask the
1438
+ operator at this terminal when an agent asks for a decision (ADR 0007
1439
+ D5.1, #134; only a profile whose ``interaction_policy`` is ``prompt``
1440
+ ever reaches it).
1441
+
1442
+ Only the non-dry paths call this: a dry run launches nothing. stdout is
1443
+ left to the step outcomes, so nothing about progress or a question
1444
+ reaches output a script reads.
1445
+ """
1446
+ engine.progress_output = _progress_line
1447
+ engine.interaction_resolver = TerminalResolver()
1448
+
1449
+
1450
+ def _print_outcome_now(o: StepOutcome) -> None:
1451
+ """Print one step's outcome as soon as the step finished, not after the loop."""
1452
+ print_step_outcome(o)
1453
+ sys.stdout.flush()
1454
+
1455
+
1456
+ def _finish(
1457
+ engine: ControllerEngine,
1458
+ outcomes: list[StepOutcome],
1459
+ allow_merge: bool,
1460
+ *,
1461
+ printed: bool = False,
1462
+ ) -> int:
1463
+ """Print the outcomes (unless ``printed`` already, step by step) and the run's end."""
1464
+ if not printed:
1465
+ for o in outcomes:
1466
+ print_step_outcome(o)
1467
+ state = engine.state
1468
+ assert state is not None
1469
+ if state.phase == Phase.READY_FOR_MERGE:
1470
+ print_ready_banner(
1471
+ state,
1472
+ gate_open=engine.merge_gate_open(allow_merge),
1473
+ certificate=_review_certificate_check(engine, state),
1474
+ )
1475
+ return 0
1476
+ if state.phase in (Phase.BLOCKED, Phase.FAILED):
1477
+ # The whole line is state-derived (`run_id` is a validated path
1478
+ # component, not a checked-for-secrets value), so the line crosses
1479
+ # the redaction boundary assembled, not field by field.
1480
+ print(redact(f"\nrun {state.run_id} is {state.phase.value}: {state.block_reason or '-'}"))
1481
+ return 1
1482
+ return 0
1483
+
1484
+
1485
+ def _existing_run_guard(
1486
+ engine: ControllerEngine, force: bool, command: str = "run"
1487
+ ) -> tuple[int | None, bool]:
1488
+ """Decide whether a fresh run may overwrite the run at ``engine.paths``.
1489
+
1490
+ Returns ``(exit_code_or_None, corrupt)``. Must be called with the
1491
+ controller lock held: a verdict taken before the lock could go stale.
1492
+ The inspection goes through the engine's held state root -- the same
1493
+ capability the first save and every later write will use -- so the
1494
+ directory that is inspected is the directory that is written.
1495
+
1496
+ ``command`` is the subcommand the operator actually typed, so the advice
1497
+ names a command that can be copied (`run --force`, `local run --force`)
1498
+ rather than a bare flag.
1499
+ """
1500
+ paths = engine.paths
1501
+ try:
1502
+ existing = engine.existing_run()
1503
+ except StateError as exc:
1504
+ # Unreadable / foreign-protocol state is fatal: a fresh run must
1505
+ # never silently replace it (merge counters etc. would be lost).
1506
+ if not force:
1507
+ # The error quotes the field it could not read (an unknown phase
1508
+ # is echoed verbatim), so the refusal is state-derived output
1509
+ # like the terminal lines and crosses the boundary assembled.
1510
+ print(
1511
+ redact(
1512
+ f"autoforge: error: {exc}\n"
1513
+ "autoforge: error: refusing to start a new run over an unreadable "
1514
+ f"state file — repair it, or use '{command} --force' to move it "
1515
+ f"aside as {paths.state_file.name}.corrupt-<timestamp> and start over"
1516
+ ),
1517
+ file=sys.stderr,
1518
+ )
1519
+ return 2, False
1520
+ return None, True
1521
+ if existing is None:
1522
+ return None, False
1523
+ if existing.phase not in TERMINAL_PHASES and not force:
1524
+ print(
1525
+ redact(
1526
+ f"autoforge: error: existing run {existing.run_id} "
1527
+ f"in phase {existing.phase.value} — use 'resume' to continue "
1528
+ f"or '{command} --force' to discard it"
1529
+ ),
1530
+ file=sys.stderr,
1531
+ )
1532
+ return 2, False
1533
+ operator = _operator_records(existing) if existing.phase in _KEEPING_PHASES else []
1534
+ if operator and not force:
1535
+ # #243: a BLOCKED or FAILED run keeps its FIX plan, and an operator
1536
+ # action of it may already be on GitHub; replacing the run silently
1537
+ # would forget what the operator was asked to post.
1538
+ named = "; ".join(
1539
+ f"{r.action_id} on {r.target['issue_url']}"
1540
+ + (f" ({(r.observed or {}).get('url')})" if r.stage is Stage.OBSERVED else "")
1541
+ for r in operator
1542
+ )
1543
+ print(
1544
+ redact(
1545
+ f"autoforge: error: existing run {existing.run_id} in phase "
1546
+ f"{existing.phase.value} holds operator actions ({named}) — see 'status', "
1547
+ f"or use '{command} --force' to discard the run (the comments stay on GitHub)"
1548
+ ),
1549
+ file=sys.stderr,
1550
+ )
1551
+ return 2, False
1552
+ return None, False
1553
+
1554
+
1555
+ def cmd_run(args) -> int:
1556
+ cfg = _load_cfg(args)
1557
+ engine = _engine_for(args, cfg)
1558
+ engine.validate_config()
1559
+
1560
+ if args.dry_run:
1561
+ # Fully side-effect-free: in-memory state, plan printed, nothing written.
1562
+ engine.new_run(args.epic, args.issue)
1563
+ outcomes = engine.run(max_steps=args.max_steps, dry_run=True, allow_merge=args.allow_merge)
1564
+ for o in outcomes:
1565
+ assert o.plan is not None
1566
+ print_plan(o.plan, full_prompt=args.full_prompt)
1567
+ return 0
1568
+
1569
+ # A state directory inside the scheduler root is a namespace's, never a
1570
+ # standalone run's (ADR 0008 D17.3); refused before the lock is taken.
1571
+ engine.refuse_scheduler_state_dir("run")
1572
+ # Inspect, decide, quarantine, write the first state AND execute under
1573
+ # one continuous lock: a verdict taken before the lock could go stale
1574
+ # (another controller may have repaired, replaced or created state.json
1575
+ # in the meantime) and would then quarantine or overwrite a perfectly
1576
+ # valid run; releasing the lock between the first save and the execution
1577
+ # would let a second controller take over the repository and both would
1578
+ # then run agents and persist state over each other.
1579
+ with engine.locked():
1580
+ rc, corrupt = _existing_run_guard(engine, args.force)
1581
+ if rc is not None:
1582
+ return rc
1583
+ engine.new_run(args.epic, args.issue)
1584
+ assert engine.state is not None
1585
+ # A scheduler-owned issue is refused before anything is written
1586
+ # (ADR 0008 D17.2): no quarantine, no state, no run log.
1587
+ _guard_scheduler_ownership(engine, engine.state, "run")
1588
+ # What later commands of the run are checked against (#219).
1589
+ engine.record_run_configuration()
1590
+ if corrupt:
1591
+ moved = engine.quarantine_state()
1592
+ print(f"autoforge: moved unreadable state file aside: {moved}", file=sys.stderr)
1593
+ engine.save()
1594
+ _attach_terminal(engine)
1595
+ outcomes = engine.run(
1596
+ max_steps=args.max_steps,
1597
+ dry_run=False,
1598
+ allow_merge=args.allow_merge,
1599
+ on_outcome=_print_outcome_now,
1600
+ )
1601
+ return _finish(engine, outcomes, args.allow_merge, printed=True)
1602
+
1603
+
1604
+ def cmd_local_init(args) -> int:
1605
+ """Create features/<slug>.md under the repository lock. No --force, no overwrite.
1606
+
1607
+ The controller lock covers this the same way it covers `local run`. A
1608
+ feature specification is not a scratch file: a run freezes its SHA-256 and
1609
+ re-checks it around every phase, so `local init --force` racing an active
1610
+ run could rewrite the specification while the controller was hashing it,
1611
+ rendering it into a prompt or verifying it — which is precisely the
1612
+ "an agent cannot rewrite its own acceptance criteria" guarantee, undone
1613
+ from the operator's side. One controller at a time owns the repository,
1614
+ and creating this file is a controller write like any other.
1615
+ """
1616
+ cfg = _load_cfg(args)
1617
+ engine = _local_engine_for(args, cfg)
1618
+ with engine.locked():
1619
+ path = init_feature_file(
1620
+ engine.workspace(),
1621
+ args.slug,
1622
+ feature_dir=args.feature_dir or cfg.local.feature_dir,
1623
+ overwrite=args.force,
1624
+ )
1625
+ rel = os.path.relpath(path, os.getcwd())
1626
+ print(f"Created {rel}")
1627
+ print()
1628
+ print("Next:")
1629
+ print(f" 1. Describe the feature in {rel} (problem, requirements, acceptance criteria).")
1630
+ print(f" 2. autoforge local run {rel}")
1631
+ print()
1632
+ print("The specification is frozen (SHA-256) when the run starts: edit it before, not during.")
1633
+ return 0
1634
+
1635
+
1636
+ def cmd_local_run(args) -> int:
1637
+ """Create a LOCAL run from a feature Markdown file and advance it.
1638
+
1639
+ Mirrors ``cmd_run`` minus everything GitHub: no EPIC, no issue, no merge
1640
+ gate. The engine never constructs a GitHubClient for a LOCAL run.
1641
+ """
1642
+ cfg = _load_cfg(args)
1643
+ engine = _local_engine_for(args, cfg)
1644
+
1645
+ if args.dry_run:
1646
+ # Fully side-effect-free: in-memory state, plan printed, nothing
1647
+ # written, no agent invoked and no validation command executed.
1648
+ engine.new_local_run(args.feature, allow_dirty=args.allow_dirty)
1649
+ engine.validate_config()
1650
+ outcomes = engine.run(max_steps=args.max_steps, dry_run=True)
1651
+ for o in outcomes:
1652
+ assert o.plan is not None
1653
+ print_plan(o.plan, full_prompt=args.full_prompt)
1654
+ return 0
1655
+
1656
+ engine.refuse_scheduler_state_dir("local run")
1657
+ # One continuous lock over inspect -> decide -> first save -> execute,
1658
+ # for the same reasons as `cmd_run`.
1659
+ with engine.locked():
1660
+ rc, corrupt = _existing_run_guard(engine, args.force, "local run")
1661
+ if rc is not None:
1662
+ return rc
1663
+ engine.new_local_run(args.feature, allow_dirty=args.allow_dirty)
1664
+ engine.validate_config()
1665
+ assert engine.state is not None
1666
+ # The sandbox mode later commands of the run are held to (#280).
1667
+ engine.record_run_configuration()
1668
+ if corrupt:
1669
+ moved = engine.quarantine_state()
1670
+ print(f"autoforge: moved unreadable state file aside: {moved}", file=sys.stderr)
1671
+ engine.save()
1672
+ _attach_terminal(engine)
1673
+ outcomes = engine.run(
1674
+ max_steps=args.max_steps, dry_run=False, on_outcome=_print_outcome_now
1675
+ )
1676
+ return _finish(engine, outcomes, allow_merge=False, printed=True)
1677
+
1678
+
1679
+ def cmd_step(args) -> int:
1680
+ engine = _engine_for(args)
1681
+ _bind_standalone_run(engine, args, "step")
1682
+ if args.dry_run:
1683
+ # Read-only: no lock, nothing written.
1684
+ state = engine.load()
1685
+ if state.phase not in TERMINAL_PHASES:
1686
+ _check_configuration(engine, args, "step", dry_run=True)
1687
+ outcome = engine.step(dry_run=True, allow_merge=args.allow_merge)
1688
+ if outcome.plan is not None:
1689
+ print_plan(outcome.plan, full_prompt=args.full_prompt)
1690
+ return 0
1691
+ # Load and execute under one lock: a snapshot loaded before the lock
1692
+ # could already have been replaced by another controller.
1693
+ with engine.locked():
1694
+ state = engine.load()
1695
+ _guard_scheduler_ownership(engine, state, "step")
1696
+ if state.phase not in TERMINAL_PHASES:
1697
+ _check_configuration(engine, args, "step", dry_run=False)
1698
+ _attach_terminal(engine)
1699
+ outcome = engine.step(dry_run=False, allow_merge=args.allow_merge)
1700
+ return _finish(engine, [outcome], args.allow_merge)
1701
+
1702
+
1703
+ def cmd_resume(args) -> int:
1704
+ engine = _engine_for(args)
1705
+ _bind_standalone_run(engine, args, "resume")
1706
+ if args.dry_run:
1707
+ # Read-only: no lock, nothing written.
1708
+ state = engine.load()
1709
+ rc = _resume_holding_state(engine, state, args.allow_merge)
1710
+ if rc is not None:
1711
+ return rc
1712
+ _check_configuration(engine, args, "resume", dry_run=True)
1713
+ outcomes = engine.run(max_steps=args.max_steps, dry_run=True, allow_merge=args.allow_merge)
1714
+ for o in outcomes:
1715
+ assert o.plan is not None
1716
+ print_plan(o.plan, full_prompt=args.full_prompt)
1717
+ return 0
1718
+ # Load, decide and execute under one lock: a snapshot loaded before the
1719
+ # lock could already have been replaced by another controller.
1720
+ with engine.locked():
1721
+ state = engine.load()
1722
+ _guard_scheduler_ownership(engine, state, "resume")
1723
+ rc = _resume_holding_state(engine, state, args.allow_merge)
1724
+ if rc is not None:
1725
+ return rc
1726
+ _check_configuration(engine, args, "resume", dry_run=False)
1727
+ _attach_terminal(engine)
1728
+ outcomes = engine.run(
1729
+ max_steps=args.max_steps,
1730
+ dry_run=False,
1731
+ allow_merge=args.allow_merge,
1732
+ on_outcome=_print_outcome_now,
1733
+ )
1734
+ return _finish(engine, outcomes, args.allow_merge, printed=True)
1735
+
1736
+
1737
+ def cmd_unblock(args) -> int:
1738
+ """``autoforge unblock --reason ...``: exit 0 when a phase was re-entered.
1739
+
1740
+ The decision is the engine's (:meth:`ControllerEngine.unblock`): a
1741
+ refusal leaves the run BLOCKED, prints why, and exits 1; a run that is
1742
+ not BLOCKED is a StateTransitionError (exit 2). The run is not resumed
1743
+ here: the operator reviews the outcome and runs ``resume``.
1744
+ """
1745
+ engine = _engine_for(args)
1746
+ if not args.state_dir:
1747
+ _bind_existing_run(engine)
1748
+ approve = str(getattr(args, "approve_action", "") or "")
1749
+ raw_decline = getattr(args, "decline_action", None) or []
1750
+ decline = [raw_decline] if isinstance(raw_decline, str) else [str(d) for d in raw_decline]
1751
+ bind = [(str(a), str(u)) for a, u in getattr(args, "bind_evidence", None) or []]
1752
+ # The words a configuration refusal prints for this command (#219).
1753
+ words = ("unblock", "--reason", "<why>")
1754
+ if args.dry_run:
1755
+ # Reads GitHub; writes nothing (no state, no run log, no lock).
1756
+ state = engine.load()
1757
+ if state.phase == Phase.BLOCKED:
1758
+ _check_configuration(engine, args, *words, dry_run=True)
1759
+ outcome = engine.unblock(
1760
+ args.reason,
1761
+ dry_run=True,
1762
+ approve_action=approve,
1763
+ decline_action=decline,
1764
+ bind_evidence=bind,
1765
+ )
1766
+ print_unblock_outcome(outcome)
1767
+ return 0
1768
+ # Load, decide and write under one lock, like `step`. An accepted
1769
+ # configuration change is its own recorded write, made before the
1770
+ # decision: a refused unblock leaves it in place.
1771
+ with engine.locked():
1772
+ state = engine.load()
1773
+ _guard_scheduler_ownership(engine, state, "unblock")
1774
+ if state.phase == Phase.BLOCKED:
1775
+ _check_configuration(engine, args, *words, dry_run=False)
1776
+ outcome = engine.unblock(
1777
+ args.reason, approve_action=approve, decline_action=decline, bind_evidence=bind
1778
+ )
1779
+ print_unblock_outcome(outcome)
1780
+ if not outcome.unblocked:
1781
+ return 1
1782
+ if engine.state_dir_in_scheduler_root():
1783
+ # A scheduler namespace's run (ADR 0008 D17.3): a standalone
1784
+ # 'resume' would refuse this state directory.
1785
+ print(
1786
+ "continue with the scheduler: 'autoforge epic resume' "
1787
+ "(a standalone 'resume' refuses this state directory)"
1788
+ )
1789
+ return 0
1790
+ # With the flags this command was given, as a block reason's 'resume'
1791
+ # line does (#189): a bare 'resume' would find no run or other defaults.
1792
+ print(redact(f"continue with: {engine.resume_command()}"))
1793
+ return 0
1794
+
1795
+
1796
+ def print_unblock_outcome(o: UnblockOutcome) -> None:
1797
+ # State-derived and operator-supplied text; redacted whole like a step outcome.
1798
+ print(redact(f"[{o.run_id}] {o.message}"))
1799
+
1800
+
1801
+ def _resume_holding_state(
1802
+ engine: ControllerEngine, state: AutoForgeState, allow_merge: bool
1803
+ ) -> int | None:
1804
+ """Exit code when ``resume`` has nothing to execute; None when it does."""
1805
+ if state.phase == Phase.READY_FOR_MERGE and not engine.merge_gate_open(allow_merge):
1806
+ # Holding state: nothing runs unless the merge gate is open. With the
1807
+ # gate open, engine.run() performs the controller-side pre-merge
1808
+ # verification instead (bounded re-checks of inconclusive GitHub data
1809
+ # happen there, one attempt per resume), the review certificate's
1810
+ # live check included (#175); the banner says only whether it
1811
+ # applies against state and the configuration in force.
1812
+ print_ready_banner(state, certificate=_review_certificate_check(engine, state))
1813
+ return 0
1814
+ if state.phase in TERMINAL_PHASES:
1815
+ if state.phase == Phase.DONE:
1816
+ print(redact(f"workflow already DONE (run {state.run_id}) — nothing to do"))
1817
+ return 0
1818
+ how = (
1819
+ "and run 'autoforge unblock --reason ...' to have the controller re-inspect "
1820
+ "GitHub and re-enter a phase, or start a new run"
1821
+ if state.phase == Phase.BLOCKED and state.mode != WorkflowMode.LOCAL
1822
+ else "and start a new run"
1823
+ )
1824
+ print(
1825
+ redact(
1826
+ f"run {state.run_id} is in terminal phase {state.phase.value}: "
1827
+ f"{state.block_reason or '-'} — inspect {engine.paths.logs_dir}/ {how}"
1828
+ )
1829
+ )
1830
+ return 1
1831
+ return None
1832
+
1833
+
1834
+ def _load_replan_journal(state: AutoForgeState) -> ReplanTransaction | None:
1835
+ """Read the persisted replan journal the way the engine reads it.
1836
+
1837
+ ``None`` when the run has no journal. A journal whose fields cannot be
1838
+ read comes back ``REJECTED`` with ``journal_defects`` naming them, for
1839
+ status to describe rather than die on. The one shape ``from_dict``
1840
+ refuses outright -- a journal that is not a JSON object -- never reaches
1841
+ here: ``AutoForgeState.from_dict`` rejects it, so ``load()`` fails both
1842
+ output modes at the state boundary (exit 2) before anything is printed.
1843
+ """
1844
+ if not state.replan_transaction:
1845
+ return None
1846
+ return ReplanTransaction.from_dict(state.replan_transaction)
1847
+
1848
+
1849
+ def _replan_journal_summary(txn: ReplanTransaction | None) -> dict | None:
1850
+ """The derived ``replan_journal`` field of ``status --json``.
1851
+
1852
+ ``status --json`` prints ``replan_transaction`` as persisted, so the
1853
+ forensic evidence of a corrupt journal is never rewritten -- which leaves
1854
+ a consumer no way to tell a usable journal from a corrupt one without
1855
+ reimplementing the validation. This sibling field answers that from the
1856
+ same ``ReplanTransaction`` the human ``status`` prints its ``stage`` and
1857
+ ``journal`` lines from, so the two outputs cannot disagree. It is never
1858
+ merged into the raw journal.
1859
+ """
1860
+ if txn is None:
1861
+ return None
1862
+ return {
1863
+ "readable": not txn.journal_defects,
1864
+ "stage": txn.stage.value,
1865
+ "defects": list(txn.journal_defects),
1866
+ }
1867
+
1868
+
1869
+ # -- epic commands (ADR 0008, #183) ----------------------------------------------
1870
+
1871
+
1872
+ def _epic_graph(cfg: AutoForgeConfig) -> GraphReader:
1873
+ """The GitHub reads the ``epic`` commands make (indirection so tests can inject a fake)."""
1874
+ return GitHubClient(gh_command=cfg.github.command, timeout_seconds=cfg.github.timeout_seconds)
1875
+
1876
+
1877
+ def _epic_scheduler_options() -> dict:
1878
+ """Extra :class:`SchedulerInputs` for ``epic run``/``resume`` (a test's injection point)."""
1879
+ return {}
1880
+
1881
+
1882
+ def _epic_flags(args) -> tuple[str, ...]:
1883
+ """What every command an ``epic`` command prints repeats: ``--config``, as typed."""
1884
+ return ("--config", args.config) if args.config else ()
1885
+
1886
+
1887
+ def _say(text: str) -> None:
1888
+ print(redact(f"autoforge epic: {text}"), file=sys.stderr, flush=True)
1889
+
1890
+
1891
+ def cmd_epic(args) -> int:
1892
+ if args.state_dir:
1893
+ raise ConfigurationError(
1894
+ "'epic' commands refuse --state-dir: each issue's state lives in its own "
1895
+ "scheduler namespace, which 'autoforge epic status <epic>' names"
1896
+ )
1897
+ # Before anything is read or written here: an EPIC of another repository
1898
+ # than this checkout's would bind its issues in this checkout's namespaces,
1899
+ # or stop or archive this checkout's EPIC of the same number.
1900
+ checkout_epic(os.getcwd(), args.epic)
1901
+ if args.epic_command == "run":
1902
+ return cmd_epic_run(args, resume=False)
1903
+ if args.epic_command == "resume":
1904
+ return cmd_epic_run(args, resume=True)
1905
+ if args.epic_command == "status":
1906
+ return cmd_epic_status(args)
1907
+ if args.epic_command == "stop":
1908
+ return cmd_epic_stop(args)
1909
+ if args.epic_command == "archive":
1910
+ return cmd_epic_archive(args)
1911
+ raise ConfigurationError(f"unknown epic command {args.epic_command}")
1912
+
1913
+
1914
+ def cmd_epic_run(args, *, resume: bool) -> int:
1915
+ """``epic run`` and ``epic resume``: one scheduler invocation (#182) and its exit status.
1916
+
1917
+ The scheduler makes no run/resume split; ``epic run``'s refusal of open
1918
+ claims and its ``--force`` run inside it, holding L0, before anything is
1919
+ written. A run that ends with nothing admissible while claims of the
1920
+ EPIC are still open exits :data:`~autoforge.epic_commands.EXIT_OPEN_CLAIMS`.
1921
+ """
1922
+ cfg = _load_cfg(args)
1923
+ flags = _epic_flags(args)
1924
+ epic_url = parse_issue_url(args.epic).canonical
1925
+ graph = _epic_graph(cfg)
1926
+ repository = os.getcwd()
1927
+ standalone = os.path.abspath(cfg.state_dir)
1928
+ if not resume and args.dry_run:
1929
+ plan = dry_run_plan(
1930
+ graph,
1931
+ repository_root=repository,
1932
+ epic_url=epic_url,
1933
+ max_parallel=args.max_parallel or cfg.scheduler.max_parallel,
1934
+ standalone_state_dir=standalone,
1935
+ force=args.force,
1936
+ flags=flags,
1937
+ )
1938
+ for line in plan.lines:
1939
+ print(line)
1940
+ return plan.exit_code
1941
+ inputs = SchedulerInputs(
1942
+ repository=repository,
1943
+ epic_url=epic_url,
1944
+ config=cfg,
1945
+ graph=graph,
1946
+ config_path=absolute_spelling(args.config) if args.config else "",
1947
+ max_parallel=args.max_parallel,
1948
+ accept_config_change=resume and args.accept_config_change,
1949
+ standalone_state_dir=standalone,
1950
+ **_epic_scheduler_options(),
1951
+ )
1952
+ scheduler = EpicScheduler(inputs)
1953
+ if not resume:
1954
+ inputs.before_start = epic_run_guard(
1955
+ force=args.force, flags=flags, epic_url=epic_url, say=scheduler.say
1956
+ )
1957
+ code = scheduler.run()
1958
+ if code != 0:
1959
+ return code
1960
+ left = claims_left_open(scheduler.coordination_root, scheduler.epic_ref)
1961
+ if not left:
1962
+ return code
1963
+ for line in left_open_lines(left, flags, epic_url):
1964
+ scheduler.say(line)
1965
+ return EXIT_OPEN_CLAIMS
1966
+
1967
+
1968
+ def cmd_epic_status(args) -> int:
1969
+ cfg = _load_cfg(args)
1970
+ report = collect_status(
1971
+ _epic_graph(cfg),
1972
+ repository_root=os.getcwd(),
1973
+ epic_url=args.epic,
1974
+ standalone_state_dir=os.path.abspath(cfg.state_dir),
1975
+ flags=_epic_flags(args),
1976
+ )
1977
+ if args.as_json:
1978
+ print(json.dumps(report.to_dict(), indent=2))
1979
+ else:
1980
+ for line in report.lines():
1981
+ print(line)
1982
+ return report.exit_code
1983
+
1984
+
1985
+ def _epic_repository_id(args) -> Callable[[str], int]:
1986
+ """GitHub's numeric id of a repository, as ``epic stop`` and ``epic archive`` read it.
1987
+
1988
+ Called once a ``scheduler.json`` or a binding is found to compare with,
1989
+ so the configuration is loaded then.
1990
+ """
1991
+
1992
+ def read(repository: str) -> int:
1993
+ return _epic_graph(_load_cfg(args)).get_repository_id(repository)
1994
+
1995
+ return read
1996
+
1997
+
1998
+ def cmd_epic_stop(args) -> int:
1999
+ """``epic stop``: lock-free; GitHub is read for the repository id ``scheduler.json`` records."""
2000
+ epic = parse_issue_url(args.epic)
2001
+ mode = StopMode.NOW if args.now else StopMode.DRAIN
2002
+ outcome = stop_scheduler(
2003
+ coordination_root_of(os.getcwd()), epic, mode, _epic_repository_id(args)
2004
+ )
2005
+ for line in outcome.lines:
2006
+ print(line)
2007
+ return outcome.exit_code
2008
+
2009
+
2010
+ def cmd_epic_archive(args) -> int:
2011
+ """``epic archive``: under L0 (a held one is exit 2); GitHub is read for the binding's id."""
2012
+ epic = parse_issue_url(args.epic)
2013
+ lock_path = repository_lock_path(os.getcwd())
2014
+ with ControllerLock(lock_path):
2015
+ report = archive_issue(
2016
+ lock_path.parent.parent, epic.canonical, args.issue, _epic_repository_id(args)
2017
+ )
2018
+ for line in report.lines():
2019
+ print(line)
2020
+ return 0
2021
+
2022
+
2023
+ def cmd_status(args) -> int:
2024
+ engine = _engine_for(args)
2025
+ if not args.state_dir:
2026
+ _bind_existing_run(engine)
2027
+ state = engine.load() # StateError when missing/corrupt
2028
+ if getattr(args, "action", ""):
2029
+ return _print_action_payload(state, args.action)
2030
+ # One load of the journal feeds every output mode, so what `--json`
2031
+ # derives and what the human summary prints cannot disagree.
2032
+ txn = _load_replan_journal(state)
2033
+ # Everything status shows is state-derived, and state carries text the
2034
+ # agents controlled (`block_reason`, findings, a hand-edited journal), so
2035
+ # both output modes cross the same redaction boundary as the run log.
2036
+ certificate = _review_certificate_check(engine, state)
2037
+ if args.as_json:
2038
+ # to_dict() already carries "mode" plus the local fields, so the JSON
2039
+ # shape stays additive for remote consumers; `replan_journal` and
2040
+ # `acceptance_actions` are derived beside the raw fields, never
2041
+ # merged into them.
2042
+ document = {
2043
+ **state.to_dict(),
2044
+ "replan_journal": _replan_journal_summary(txn),
2045
+ "acceptance_actions": _acceptance_actions(state),
2046
+ "operator_actions": _operator_actions(state),
2047
+ "review_certificate_status": _review_certificate_status(state, certificate),
2048
+ "outer_sandbox": {
2049
+ "mode": engine.config.sandbox.mode,
2050
+ "summary": sandbox_mode_summary(
2051
+ engine.config.sandbox, state.mode == WorkflowMode.LOCAL
2052
+ ),
2053
+ },
2054
+ }
2055
+ print(json.dumps(redact_dict(document), indent=2, sort_keys=True))
2056
+ return 0
2057
+ evidence = _render_outer_sandbox(state, engine.config) + _render_validation_evidence(
2058
+ state, engine.config, certificate
2059
+ )
2060
+ if state.mode == WorkflowMode.LOCAL:
2061
+ print(redact(_render_local_status(state, txn, evidence)))
2062
+ return 0
2063
+ if engine.state_dir_in_scheduler_root() and not engine.continue_command:
2064
+ # A scheduler namespace's run (ADR 0008 D17.3) is continued by the
2065
+ # scheduler, as a worker names it: a standalone 'resume' refuses it.
2066
+ config = ("--config", args.config) if args.config else ()
2067
+ engine.continue_command = (*config, "epic", "resume", state.epic_url)
2068
+ actions = _render_acceptance_actions(state, engine) + _render_operator_actions(state, engine)
2069
+ print(redact(_render_status(state, txn, actions, evidence)))
2070
+ return 0
2071
+
2072
+
2073
+ # How many records of the current candidate ``status`` lists, newest last.
2074
+ _STATUS_RECORDS = 16
2075
+ _STATUS_TAIL_CHARS = 200
2076
+
2077
+
2078
+ def _render_outer_sandbox(state: AutoForgeState, config: AutoForgeConfig) -> list[str]:
2079
+ """``sandbox.mode`` for ``status`` (ADR 0005 D12.4): configuration only, no probe.
2080
+
2081
+ A mode that may fall back says so; a ``disabled`` run reads "logical
2082
+ isolation only". Whether this host can enforce is ``doctor``'s to show.
2083
+ """
2084
+ local = state.mode == WorkflowMode.LOCAL
2085
+ summary = sandbox_mode_summary(config.sandbox, local)
2086
+ return ["", "Outer sandbox:", f" {summary}", f" {_sandbox_pin_line(state, config, local)}"]
2087
+
2088
+
2089
+ def _sandbox_pin_line(state: AutoForgeState, config: AutoForgeConfig, local: bool) -> str:
2090
+ """The run's sandbox pin (#280) for ``status``, and whether this configuration keeps it."""
2091
+ if not state.sandbox_pin:
2092
+ return (
2093
+ "pinned: none (the state file predates #280); the next step, resume or unblock "
2094
+ "pins the sandbox.mode it runs under"
2095
+ )
2096
+ pin = SandboxPin.from_dict(state.sandbox_pin)
2097
+ line = f"pinned: {pin.describe()}"
2098
+ if pin.direction(config.sandbox.mode, local=local) < 0:
2099
+ return (
2100
+ f"{line}; this configuration's sandbox.mode, {config.sandbox.mode}, is weaker, so "
2101
+ "step, resume and unblock refuse it unless --accept-config-change"
2102
+ )
2103
+ return f"{line}; step, resume and unblock refuse a weaker sandbox.mode"
2104
+
2105
+
2106
+ def _review_certificate_check(
2107
+ engine: ControllerEngine, state: AutoForgeState
2108
+ ) -> tuple[bool | None, str] | None:
2109
+ """Whether the held review certificate applies now (ADR 0006 D12.3), for ``status``.
2110
+
2111
+ ``None`` when none is held; otherwise ``(applicable, reason)``, with
2112
+ ``applicable`` ``None`` and the reason saying why when the check could
2113
+ not run (a LOCAL contract that no longer matches this invocation, say).
2114
+ Reads the configuration in force, never GitHub: the live comparison is
2115
+ the merge gate's.
2116
+ """
2117
+ if not state.review_certificate:
2118
+ return None
2119
+ try:
2120
+ problem = engine.review_certificate_problem()
2121
+ except AutoForgeError as exc:
2122
+ return None, f"not checked: {exc}"
2123
+ return not problem, problem
2124
+
2125
+
2126
+ def _review_certificate_status(
2127
+ state: AutoForgeState, check: tuple[bool | None, str] | None
2128
+ ) -> dict | None:
2129
+ """The derived ``review_certificate_status`` of ``status --json`` (#174); ``None`` for none.
2130
+
2131
+ Beside the raw ``review_certificate``, never merged into it: the fields
2132
+ a consumer reads (kind, candidate, round, set and policy digests,
2133
+ ``issued_at``), its applicability against state and the configuration
2134
+ in force with the reason when it does not apply, and the fixed note that
2135
+ it is no merge permission.
2136
+ """
2137
+ certificate = state.review_certificate
2138
+ if not certificate or check is None:
2139
+ return None
2140
+ applicable, problem = check
2141
+ return {
2142
+ "kind": certificate["kind"],
2143
+ "candidate": certificate["candidate"],
2144
+ "review_round": certificate["review_round"],
2145
+ "validation_set_digest": certificate["validation_set_digest"],
2146
+ "policy_digest": certificate["policy_digest"],
2147
+ "issued_at": certificate["issued_at"],
2148
+ "applicable": applicable,
2149
+ "problem": problem,
2150
+ "note": NOT_A_PERMISSION,
2151
+ }
2152
+
2153
+
2154
+ def _render_validation_evidence(
2155
+ state: AutoForgeState,
2156
+ config: AutoForgeConfig,
2157
+ certificate: tuple[bool | None, str] | None = None,
2158
+ ) -> list[str]:
2159
+ """Candidate validation evidence for ``status`` (ADR 0006 D8.6; the caller redacts).
2160
+
2161
+ The evidence policy (the configured set's size and the mode records are
2162
+ made under, and under ``disabled`` that validators run unsandboxed), a
2163
+ pending attempt marker, the override watermark, a producer's validation
2164
+ failure counter, whether a binding is held, the review certificate (what
2165
+ it names, whether it applies, as ``certificate`` says, and that it is no
2166
+ merge permission), and the records of the current candidate: the REMOTE
2167
+ current HEAD's or the LOCAL bound workspace's, else the newest record's.
2168
+ Read-only: a marker is reported, never converted.
2169
+ """
2170
+ local = state.mode == WorkflowMode.LOCAL
2171
+ if local:
2172
+ key = "local.validation_commands (the run contract)"
2173
+ count = len(state.local_contract().validation_commands)
2174
+ else:
2175
+ key = "validation.commands"
2176
+ count = len(config.validation.commands)
2177
+ mode = config.sandbox.mode
2178
+ policy = f"{count} definition(s) in {key}; records are made under sandbox.mode {mode}"
2179
+ if not config.sandbox.enabled:
2180
+ policy += (
2181
+ ", so validators run unsandboxed as the operator's UID and their records say "
2182
+ "enforced: false"
2183
+ )
2184
+ elif local and mode == "preferred":
2185
+ policy += (
2186
+ ", so on a host that cannot enforce the outer sandbox validators run unsandboxed "
2187
+ "as the operator's UID and their records say enforced: false"
2188
+ )
2189
+ lines = ["", "Validation evidence:", f" Policy: {policy}"]
2190
+ records = state.validation_evidence()
2191
+ if state.validation_attempt:
2192
+ marker = parse_marker(state.validation_attempt)
2193
+ lines.append(
2194
+ f" Pending attempt: definition [{marker.index}] {' '.join(marker.argv)!r} for "
2195
+ f"{marker.candidate.describe()}, marked {state.validation_attempt['marked_at']} "
2196
+ "(the next step or resume records it INTERRUPTED and runs it again)"
2197
+ )
2198
+ if state.validation_override_seq is not None:
2199
+ lines.append(f" Override watermark: seq {state.validation_override_seq}")
2200
+ counter = state.producer_validation_failure
2201
+ if counter:
2202
+ lines.append(
2203
+ f" {counter['phase']} validation failures: {counter['count']} of "
2204
+ f"{MAX_FAILURE_COUNT} (last: record #{counter['last_record_seq']}, commit "
2205
+ f"{counter['candidate_commit'][:12]})"
2206
+ )
2207
+ if state.review_evidence:
2208
+ lines.append(
2209
+ f" REVIEW evidence: round {state.review_evidence['review_round']}, "
2210
+ f"{len(state.review_evidence['records'])} record(s)"
2211
+ )
2212
+ if state.review_certificate:
2213
+ lines.append(f" Review certificate: {describe_certificate(state.review_certificate)}")
2214
+ if certificate is None:
2215
+ applies = "not checked"
2216
+ else:
2217
+ applicable, problem = certificate
2218
+ applies = "yes" if applicable else problem if applicable is None else f"no: {problem}"
2219
+ lines.append(f" Applicable: {applies}")
2220
+ lines.append(f" Note: {NOT_A_PERMISSION}")
2221
+ if not records:
2222
+ lines.append(" Records: none")
2223
+ return lines
2224
+ current = records[-1].candidate
2225
+ for view in reversed(records):
2226
+ candidate = view.candidate
2227
+ if (
2228
+ isinstance(candidate, RemoteCandidate) and candidate.commit == state.current_head_sha
2229
+ ) or (
2230
+ isinstance(candidate, LocalCandidate)
2231
+ and candidate.fingerprint == state.workspace_fingerprint
2232
+ ):
2233
+ current = candidate
2234
+ break
2235
+ mine = [r for r in records if r.candidate.identity == current.identity]
2236
+ shown = mine[-_STATUS_RECORDS:]
2237
+ lines.append(
2238
+ f" Records for {current.describe()}: {len(mine)} of {len(records)} retained"
2239
+ + (f" (the newest {len(shown)} shown)" if len(shown) < len(mine) else "")
2240
+ )
2241
+ for view in shown:
2242
+ lines.append(f" {record_summary(view)}")
2243
+ if view.outcome in (Outcome.FAIL, Outcome.TIMEOUT):
2244
+ output = view.raw["output"]
2245
+ tail = " ".join((output["stderr_tail"] or output["stdout_tail"]).split())
2246
+ if tail:
2247
+ lines.append(f" output tail: {tail[-_STATUS_TAIL_CHARS:]}")
2248
+ return lines
2249
+
2250
+
2251
+ def _acceptance_records(state: AutoForgeState) -> list[EffectRecord]:
2252
+ """The persisted K9 records of the current plan (#211); none for a LOCAL run."""
2253
+ if state.mode == WorkflowMode.LOCAL:
2254
+ return []
2255
+ return [r for r in state.phase_effects().records if r.kind is EffectKind.ACCEPTANCE_COMMENT]
2256
+
2257
+
2258
+ def _print_action_payload(state: AutoForgeState, action_id: str) -> int:
2259
+ """``status --action``: the exact payload bytes of a persisted action (#211, #243).
2260
+
2261
+ Exactly the body the controller would publish (and an operator may post
2262
+ instead), with nothing before or after it; it is redaction-invariant by
2263
+ the record's validation, so nothing is rewritten here. An id no
2264
+ persisted record has (a declined or stale action keeps no payload), or a
2265
+ LOCAL run, exits 1 and prints nothing.
2266
+ """
2267
+ for record in (*_acceptance_records(state), *_operator_records(state)):
2268
+ if record.action_id == action_id.strip():
2269
+ sys.stdout.write(record.body)
2270
+ sys.stdout.flush()
2271
+ return 0
2272
+ return 1
2273
+
2274
+
2275
+ def _acceptance_label(record: EffectRecord) -> tuple[str, str]:
2276
+ """``(state, label)`` of a persisted K9 record, as ``status`` names it (#211)."""
2277
+ if record.stage is Stage.OBSERVED:
2278
+ return "verified", f"verified: {(record.observed or {}).get('url', '-')}"
2279
+ if record.stage is Stage.CONFLICT:
2280
+ return "verification_failed", f"verification failed: {record.reason}"
2281
+ if record.stage is Stage.ATTEMPTED:
2282
+ return (
2283
+ "published_unverified",
2284
+ f"published, not yet verified ({record.attempts}/{MAX_EFFECT_ATTEMPTS})",
2285
+ )
2286
+ if not record.authorized:
2287
+ return "awaiting_approval", "awaiting approval"
2288
+ if record.precondition["authorization"] == AUTHORIZATION_POLICY:
2289
+ return "authorized_by_policy", "authorized by policy, not yet published"
2290
+ return "approved", "approved, not yet published"
2291
+
2292
+
2293
+ def _acceptance_actions(state: AutoForgeState) -> list[dict]:
2294
+ """The derived ``acceptance_actions`` of ``status --json`` (#211).
2295
+
2296
+ One entry per persisted K9 record of the current plan, then one per
2297
+ ``acceptance_outcomes`` entry (declined, stale or verified actions whose
2298
+ plan is gone), each with its ``state``: awaiting_approval, approved,
2299
+ authorized_by_policy, published_unverified, verified,
2300
+ verification_failed, declined or stale. ``verified`` comes only from an
2301
+ observed record or a recorded outcome, never from an acknowledgement.
2302
+ """
2303
+ from .claims import ACCEPTANCE, scan
2304
+
2305
+ out: list[dict] = []
2306
+ for record in _acceptance_records(state):
2307
+ claim = scan(ACCEPTANCE, record.marker).claims[0]
2308
+ status, label = _acceptance_label(record)
2309
+ out.append(
2310
+ {
2311
+ "action_id": record.action_id,
2312
+ "kind": "issue_comment",
2313
+ "target": record.target["issue_url"],
2314
+ "pr": record.owner.pr_url,
2315
+ "round": claim.round,
2316
+ "finding_id": claim.finding_id,
2317
+ "authorization": record.precondition["authorization"],
2318
+ "state": status,
2319
+ "label": label,
2320
+ "payload_chars": len(record.body),
2321
+ "payload_sha256": sha256_text(record.body),
2322
+ "comment_url": str((record.observed or {}).get("url", "")),
2323
+ }
2324
+ )
2325
+ for outcome in state.acceptance_outcomes:
2326
+ out.append(
2327
+ {
2328
+ "action_id": outcome["action_id"],
2329
+ "kind": "issue_comment",
2330
+ "target": state.current_issue_url,
2331
+ "pr": state.current_pr_url,
2332
+ "round": outcome["round"],
2333
+ "finding_id": outcome["finding_id"],
2334
+ "state": outcome["outcome"],
2335
+ "label": (
2336
+ f"verified: {outcome['comment_url']}"
2337
+ if outcome["outcome"] == "verified"
2338
+ else outcome["outcome"]
2339
+ ),
2340
+ "comment_url": outcome["comment_url"],
2341
+ "reason": outcome["reason"],
2342
+ "at": outcome["at"],
2343
+ }
2344
+ )
2345
+ return out
2346
+
2347
+
2348
+ def _render_acceptance_actions(state: AutoForgeState, engine: ControllerEngine) -> list[str]:
2349
+ """The human ``Acceptance action`` blocks of ``status`` (#211); none without one."""
2350
+ from .claims import ACCEPTANCE, scan
2351
+
2352
+ lines: list[str] = []
2353
+ records = state.phase_effects().records if state.mode != WorkflowMode.LOCAL else ()
2354
+ push = next((r for r in records if r.kind is EffectKind.PUSH), None)
2355
+ for record in _acceptance_records(state):
2356
+ claim = scan(ACCEPTANCE, record.marker).claims[0]
2357
+ action = record.action_id
2358
+ _, label = _acceptance_label(record)
2359
+ title = next(
2360
+ (
2361
+ str(f.get("title", ""))
2362
+ for f in state.open_findings
2363
+ if f.get("id") == claim.finding_id
2364
+ ),
2365
+ "",
2366
+ )
2367
+ if record.precondition["authorization"] == AUTHORIZATION_POLICY:
2368
+ authorization = "authorized by policy (safety.acceptance_actions: auto)"
2369
+ else:
2370
+ authorization = (
2371
+ "operator approval required (safety.acceptance_actions: "
2372
+ f"{engine.config.safety.acceptance_actions})"
2373
+ )
2374
+ lines += [
2375
+ f"Acceptance action {action} (FIX, review round {claim.round}, finding "
2376
+ f"{claim.finding_id})",
2377
+ " kind: issue_comment",
2378
+ f" target: {record.target['issue_url']}",
2379
+ f" PR: {record.owner.pr_url}",
2380
+ f" finding: {claim.finding_id} {title}".rstrip(),
2381
+ f" authorization: {authorization}",
2382
+ f" state: {label}",
2383
+ f" payload: {len(record.body)} chars, sha256 "
2384
+ f"{sha256_text(record.body)} ({engine.cli_command('status', '--action', action)})",
2385
+ ]
2386
+ if record.stage is Stage.OBSERVED:
2387
+ continue
2388
+ if push is not None and push.stage is not Stage.OBSERVED:
2389
+ branch = str(push.target["ref"]).removeprefix("refs/heads/")
2390
+ lines += [
2391
+ f" held back: push of {str(push.payload['sha'])[:12]} to {branch}, after "
2392
+ "the action is verified",
2393
+ f" worktree: {engine.agent_worktree_description()} holds the unpushed "
2394
+ "candidate; do not reset it",
2395
+ ]
2396
+ if record.stage is Stage.INTENDED and not record.authorized:
2397
+ why = '"..."'
2398
+ lines += [
2399
+ f" next: approve: "
2400
+ f"{engine.cli_command('unblock', '--approve-action', action, '--reason')} {why}"
2401
+ f" ; {engine.resume_command()}",
2402
+ " or post exactly that payload yourself, then: "
2403
+ f"{engine.cli_command('unblock', '--reason')} {why} ; {engine.resume_command()}",
2404
+ " or decline: "
2405
+ f"{engine.cli_command('unblock', '--decline-action', action, '--reason')} {why}"
2406
+ " (FIX runs again)",
2407
+ ]
2408
+ elif record.stage is Stage.CONFLICT:
2409
+ lines.append(
2410
+ " next: repair or remove the comment(s) named above, then "
2411
+ f'{engine.cli_command("unblock", "--reason")} "..."'
2412
+ )
2413
+ else:
2414
+ lines.append(f" next: {engine.resume_command()}")
2415
+ for outcome in state.acceptance_outcomes if state.mode != WorkflowMode.LOCAL else ():
2416
+ label = (
2417
+ f"verified: {outcome['comment_url']}"
2418
+ if outcome["outcome"] == "verified"
2419
+ else outcome["outcome"]
2420
+ )
2421
+ reason = f" ({outcome['reason']})" if outcome["reason"] else ""
2422
+ lines.append(
2423
+ f"Acceptance action {outcome['action_id']} (review round {outcome['round']}, finding "
2424
+ f"{outcome['finding_id']}): {label}{reason}"
2425
+ )
2426
+ return lines
2427
+
2428
+
2429
+ # The terminal phases that keep a FIX plan for the operator (D2.4): a fresh
2430
+ # run over one holding operator actions is refused without --force (#243).
2431
+ _KEEPING_PHASES = (Phase.BLOCKED, Phase.FAILED)
2432
+
2433
+
2434
+ def _operator_records(state: AutoForgeState) -> list[EffectRecord]:
2435
+ """The persisted K10 records of the current plan (#243); none for a LOCAL run."""
2436
+ if state.mode == WorkflowMode.LOCAL:
2437
+ return []
2438
+ return [r for r in state.phase_effects().records if r.kind is EffectKind.OPERATOR_COMMENT]
2439
+
2440
+
2441
+ def _operator_label(record: EffectRecord) -> tuple[str, str]:
2442
+ """``(state, label)`` of a persisted K10 record, as ``status`` names it (#243).
2443
+
2444
+ "pending" means not yet verified, never "not posted": ``status`` reads
2445
+ nothing from GitHub. ``verification_failed`` is a conflict, or what
2446
+ FIX's last verification of a pending action found wrong (a changed or
2447
+ deleted bound comment, a target that cannot be read or no longer
2448
+ qualifies), kept on the record as its reason (PR #254 R3-F3).
2449
+ """
2450
+ if record.stage is Stage.OBSERVED:
2451
+ observed = record.observed or {}
2452
+ how = (
2453
+ "exact"
2454
+ if observed.get("evidence") == "exact"
2455
+ else "operator-bound; adequacy not verified by the controller"
2456
+ )
2457
+ return "verified", f"verified ({how}): {observed.get('url', '-')}"
2458
+ if record.stage is Stage.CONFLICT:
2459
+ return "verification_failed", f"verification failed: {record.reason}"
2460
+ if record.reason:
2461
+ return (
2462
+ "verification_failed",
2463
+ f"verification failed at FIX's last check: {record.reason}",
2464
+ )
2465
+ if record.binding is not None:
2466
+ return (
2467
+ "evidence_bound",
2468
+ f"evidence bound, not yet verified: {record.binding['comment_url']}",
2469
+ )
2470
+ return "pending", "pending (not yet verified)"
2471
+
2472
+
2473
+ def _operator_actions(state: AutoForgeState) -> list[dict]:
2474
+ """The derived ``operator_actions`` of ``status --json`` (#243).
2475
+
2476
+ One entry per persisted K10 record of the current plan, then one per
2477
+ ``operator_comment_outcomes`` entry, each with its ``state``: pending,
2478
+ evidence_bound, verified, verification_failed, declined, withdrawn or
2479
+ stale. ``verified`` comes only from an observed record or a recorded
2480
+ outcome, never from an assertion.
2481
+ """
2482
+ from .claims import OPERATOR_COMMENT, scan
2483
+
2484
+ out: list[dict] = []
2485
+ for record in _operator_records(state):
2486
+ claim = scan(OPERATOR_COMMENT, record.marker).claims[0]
2487
+ status, _ = _operator_label(record)
2488
+ observed = record.observed or {}
2489
+ out.append(
2490
+ {
2491
+ "action_id": record.action_id,
2492
+ "kind": "related_issue_comment",
2493
+ "performer": "operator",
2494
+ "target": record.target["issue_url"],
2495
+ "pr": record.owner.pr_url,
2496
+ "round": claim.round,
2497
+ "finding_id": claim.finding_id,
2498
+ "allowed_by": list(record.precondition["allowed_by"]),
2499
+ "state": status,
2500
+ "evidence": str(observed.get("evidence", "")),
2501
+ "binding": record.binding,
2502
+ "payload_chars": len(record.body),
2503
+ "payload_sha256": sha256_text(record.body),
2504
+ "comment_url": str(observed.get("url", "")),
2505
+ "reason": record.reason,
2506
+ }
2507
+ )
2508
+ for outcome in state.operator_comment_outcomes if state.mode != WorkflowMode.LOCAL else ():
2509
+ out.append(
2510
+ {
2511
+ "action_id": outcome["action_id"],
2512
+ "kind": "related_issue_comment",
2513
+ "performer": "operator",
2514
+ "target": outcome["target"],
2515
+ "pr": state.current_pr_url,
2516
+ "round": outcome["round"],
2517
+ "finding_id": outcome["finding_id"],
2518
+ "allowed_by": [],
2519
+ "state": outcome["outcome"],
2520
+ "evidence": outcome["evidence"],
2521
+ "binding": None,
2522
+ "payload_chars": None,
2523
+ "payload_sha256": outcome["payload_sha256"],
2524
+ "comment_url": outcome["comment_url"],
2525
+ "reason": outcome["reason"],
2526
+ }
2527
+ )
2528
+ return out
2529
+
2530
+
2531
+ def _render_operator_actions(state: AutoForgeState, engine: ControllerEngine) -> list[str]:
2532
+ """The human ``Operator actions`` blocks of ``status`` (#243); none without one."""
2533
+ from .claims import OPERATOR_COMMENT, scan
2534
+
2535
+ records = _operator_records(state)
2536
+ lines: list[str] = []
2537
+ why = '"..."'
2538
+ findings: dict[str, list[EffectRecord]] = {}
2539
+ for record in records:
2540
+ fid = scan(OPERATOR_COMMENT, record.marker).claims[0].finding_id
2541
+ findings.setdefault(fid, []).append(record)
2542
+ allowed_text = {"issue_body": "referenced by the run's issue body", "epic": "the run's EPIC"}
2543
+ for fid, mine in findings.items():
2544
+ claim = scan(OPERATOR_COMMENT, mine[0].marker).claims[0]
2545
+ title = next(
2546
+ (str(f.get("title", "")) for f in state.open_findings if f.get("id") == fid), ""
2547
+ )
2548
+ lines.append(
2549
+ f"Operator actions (FIX, review round {claim.round}, finding {fid} {title})".rstrip()
2550
+ )
2551
+ for record in mine:
2552
+ action = record.action_id
2553
+ _, label = _operator_label(record)
2554
+ lines.append(f" {action} comment on {record.target['issue_url']} {label}")
2555
+ if record.stage is Stage.OBSERVED:
2556
+ continue
2557
+ allowed = "; ".join(
2558
+ allowed_text.get(str(b), str(b)) for b in record.precondition["allowed_by"]
2559
+ )
2560
+ lines += [
2561
+ f" allowed: {allowed} (issue body sha256 "
2562
+ f"{record.precondition['issue_body_sha256']})",
2563
+ f" payload: {len(record.body)} chars, sha256 {sha256_text(record.body)}",
2564
+ " evidence: exactly one comment on the target with this action's marker "
2565
+ "and exactly these bytes, or an existing comment you bind",
2566
+ ]
2567
+ pending = [r for r in records if r.stage is not Stage.OBSERVED]
2568
+ if pending:
2569
+ # The rest of the plan follows the K10 prefix: the push and the
2570
+ # follow-up writes are found in the whole saved plan (PR #254 R2-F2).
2571
+ plan = state.phase_effects().records
2572
+ push = next((r for r in plan if r.kind is EffectKind.PUSH), None)
2573
+ if push is not None and push.stage is not Stage.OBSERVED:
2574
+ lines += [
2575
+ f" held back: push of {str(push.payload['sha'])[:12]}, after every operator "
2576
+ "action is verified",
2577
+ f" worktree: {engine.agent_worktree_description()} holds the unpushed "
2578
+ "candidate; do not reset it",
2579
+ ]
2580
+ else:
2581
+ lines.append(" held back: nothing was committed, so nothing is pushed")
2582
+ others = sum(
2583
+ 1 for r in plan if r.kind in (EffectKind.FOLLOW_UP_ISSUE, EffectKind.FOLLOW_UP_APPEND)
2584
+ )
2585
+ if others:
2586
+ lines.append(f" {others} follow-up write(s) are held back as well")
2587
+ first = pending[0]
2588
+ action = first.action_id
2589
+ kept = [r.action_id for r in records if r.stage is Stage.OBSERVED]
2590
+ declines = [a for r in pending for a in ("--decline-action", r.action_id)]
2591
+ lines += [
2592
+ f" next: read it: {engine.cli_command('status', '--action', action)}",
2593
+ f" post it: {engine.cli_command('status', '--action', action)} | "
2594
+ f"gh issue comment {first.target['issue_url']} --body-file -",
2595
+ " or bind: "
2596
+ f"{engine.cli_command('unblock', '--bind-evidence', action)} <comment URL> "
2597
+ f"--reason {why}",
2598
+ f" then: {engine.cli_command('unblock', '--reason')} {why} && "
2599
+ f"{engine.resume_command()}",
2600
+ f" withdraw: {engine.cli_command('unblock', *declines, '--reason')} {why}"
2601
+ + (f" (keeps {', '.join(kept)} verified)" if kept else ""),
2602
+ ]
2603
+ for outcome in state.operator_comment_outcomes if state.mode != WorkflowMode.LOCAL else ():
2604
+ label = (
2605
+ f"verified ({outcome['evidence']}): {outcome['comment_url']}"
2606
+ if outcome["outcome"] == "verified"
2607
+ else outcome["outcome"]
2608
+ )
2609
+ reason = f" ({outcome['reason']})" if outcome["reason"] else ""
2610
+ lines.append(
2611
+ f"Operator action {outcome['action_id']} (review round {outcome['round']}, finding "
2612
+ f"{outcome['finding_id']}, comment on {outcome['target']}): {label}{reason}"
2613
+ )
2614
+ return lines
2615
+
2616
+
2617
+ def _render_replan_journal(txn: ReplanTransaction | None) -> list[str]:
2618
+ """The human lines for a persisted replan journal; none without one.
2619
+
2620
+ Shared by both modes so a journal a state carries is described the same
2621
+ way wherever it is found -- a LOCAL run never writes one, but a state
2622
+ file that has one anyway is shown, not silently skipped, and the human
2623
+ output then agrees with ``replan_journal`` in ``--json``.
2624
+ """
2625
+ if txn is None:
2626
+ return []
2627
+ lines = [
2628
+ f"Replan transaction: {txn.transaction_id or '(not yet created)'}",
2629
+ f" stage: {txn.stage.value}",
2630
+ ]
2631
+ if txn.journal_defects:
2632
+ lines.append(f" journal: CORRUPT ({'; '.join(txn.journal_defects)})")
2633
+ lines += [
2634
+ f" source PR: {txn.source_pr_url or '-'}",
2635
+ f" replacement: {txn.replacement_pr_url or '-'}",
2636
+ # A structure from state, redacted before it is serialized (#281).
2637
+ f" escalation: {json.dumps(redact_obj(txn.escalation), sort_keys=True)}",
2638
+ ]
2639
+ if txn.rejection_reason:
2640
+ lines.append(f" rejected: {txn.rejection_reason}")
2641
+ return lines
2642
+
2643
+
2644
+ def _render_status(
2645
+ state: AutoForgeState,
2646
+ txn: ReplanTransaction | None,
2647
+ acceptance: list[str] | None = None,
2648
+ evidence: list[str] | None = None,
2649
+ ) -> str:
2650
+ """Human status for a GitHub run (unredacted; the caller redacts)."""
2651
+
2652
+ def _num(url: str) -> str:
2653
+ return url.rstrip("/").rsplit("/", 1)[-1] if url else "-"
2654
+
2655
+ lines = [
2656
+ "AutoForge",
2657
+ "",
2658
+ f"Run: {state.run_id}",
2659
+ f"Repository: {state.repository}",
2660
+ f"EPIC: #{_num(state.epic_url)} {state.epic_url}",
2661
+ f"Issue: #{_num(state.current_issue_url)} {state.current_issue_url or '-'}",
2662
+ f"PR: #{_num(state.current_pr_url)} {state.current_pr_url or '-'}",
2663
+ f"Branch: {state.current_branch or '-'}",
2664
+ f"Config: {_recorded_configuration(state)}",
2665
+ "",
2666
+ f"Phase: {state.phase.value}",
2667
+ f"Review rounds completed: {state.review_round}",
2668
+ f"Execution attempt: {state.execution_attempt}",
2669
+ f"Replan count: {state.escalation_count}",
2670
+ f"Current HEAD: {state.current_head_sha or '-'}",
2671
+ f"Current base: {state.current_base_ref or '-'}",
2672
+ f"Current merge base: {state.current_merge_base_sha or '-'}",
2673
+ f"Reviewed PR: {state.reviewed_pr_url or '-'}",
2674
+ f"Reviewed HEAD: {state.reviewed_head_sha or '-'}",
2675
+ f"Reviewed base: {state.reviewed_base_ref or '-'}",
2676
+ f"Reviewed merge base: {state.reviewed_merge_base_sha or '-'}",
2677
+ f"Last review: {state.last_review_result or '-'}",
2678
+ f"Review comment: {state.last_review_comment_url or '-'}",
2679
+ f"Open findings: {len(state.open_findings)}",
2680
+ f"Prior findings to re-check: {len(state.prior_findings)}",
2681
+ ]
2682
+ if state.superseded_prs:
2683
+ lines.append("Superseded PRs:")
2684
+ for item in state.superseded_prs:
2685
+ lines.append(f" {item.get('pr_url', '-')} -> {item.get('replacement_pr_url', '-')}")
2686
+ lines += _render_replan_journal(txn)
2687
+ if state.block_reason:
2688
+ lines.append(f"Reason: {state.block_reason}")
2689
+ lines += acceptance or []
2690
+ if state.unblock_history:
2691
+ last = state.unblock_history[-1]
2692
+ action = f" [{last['action']}]" if last.get("action") else ""
2693
+ lines.append(
2694
+ f"Unblocked: {len(state.unblock_history)} time(s); last at {last.get('at', '-')} "
2695
+ f"-> {last.get('phase', '-')}{action}: {last.get('reason', '-')}"
2696
+ )
2697
+ lines += evidence or []
2698
+ lines += [
2699
+ "",
2700
+ f"Steps executed: {state.step_count}",
2701
+ f"Merged since EPIC update: {state.merged_since_epic_update}",
2702
+ "",
2703
+ f"Created: {state.created_at}",
2704
+ f"Updated: {state.updated_at}",
2705
+ ]
2706
+ return "\n".join(lines)
2707
+
2708
+
2709
+ def _recorded_configuration(state: AutoForgeState) -> str:
2710
+ """Where a REMOTE run's recorded configuration came from, and when (#219)."""
2711
+ if not state.config_fingerprint:
2712
+ return "not recorded (state from before #219; the next step, resume or unblock records it)"
2713
+ recorded = RecordedConfiguration.from_dict(state.config_fingerprint)
2714
+ return f"{recorded.origin()} ({recorded.source} {recorded.recorded_at})"
2715
+
2716
+
2717
+ def _render_local_status(
2718
+ state: AutoForgeState, txn: ReplanTransaction | None, evidence: list[str] | None = None
2719
+ ) -> str:
2720
+ """Human status for a LOCAL run (unredacted; the caller redacts).
2721
+
2722
+ Deliberately omits Issue/PR/branch/merge fields: a local run has none, and
2723
+ printing empty ones would suggest the GitHub lifecycle is merely stalled.
2724
+ A replan journal is shown only when the state carries one.
2725
+ """
2726
+ fix_budget = state.local_fix_rounds
2727
+
2728
+ lines = [
2729
+ "AutoForge (local mode)",
2730
+ "",
2731
+ f"Run: {state.run_id}",
2732
+ f"Mode: {state.mode.value} (no GitHub: no issue, no PR, no push, no merge)",
2733
+ f"Feature: {state.feature_spec_path or '-'}",
2734
+ f"Frozen SHA: {state.feature_spec_sha256 or '-'}",
2735
+ "",
2736
+ f"Phase: {state.phase.value}",
2737
+ f"Review rounds completed: {state.review_round}",
2738
+ f"Fix rounds completed: {fix_budget}",
2739
+ f"Last review: {state.last_review_result or '-'}",
2740
+ f"Open findings: {len(state.open_findings)}",
2741
+ ]
2742
+ for finding in state.open_findings:
2743
+ lines.append(
2744
+ f" {finding.get('id', '?')} [{finding.get('severity', '?')}] "
2745
+ f"{str(finding.get('title', '')).strip()[:80]}"
2746
+ )
2747
+ lines += [
2748
+ "",
2749
+ f"Base HEAD: {state.base_head_sha or '(unborn)'}",
2750
+ f"Workspace fingerprint: {state.workspace_fingerprint or '-'}",
2751
+ f"Reviewed fingerprint: {state.reviewed_workspace_fingerprint or '-'}",
2752
+ ]
2753
+ if state.baseline_dirty_paths:
2754
+ lines.append("Pre-existing working-tree changes at run creation (--allow-dirty):")
2755
+ for path in state.baseline_dirty_paths[:20]:
2756
+ lines.append(f" {path}")
2757
+ if len(state.baseline_dirty_paths) > 20:
2758
+ lines.append(f" ... and {len(state.baseline_dirty_paths) - 20} more")
2759
+ lines += _render_replan_journal(txn)
2760
+ if state.block_reason:
2761
+ lines.append(f"Reason: {state.block_reason}")
2762
+ lines += evidence or []
2763
+ lines += [
2764
+ "",
2765
+ f"Steps executed: {state.step_count}",
2766
+ "",
2767
+ f"Created: {state.created_at}",
2768
+ f"Updated: {state.updated_at}",
2769
+ ]
2770
+ return "\n".join(lines)
2771
+
2772
+
2773
+ # -- pretty printing ----------------------------------------------------------
2774
+ def print_ready_banner(
2775
+ state: AutoForgeState,
2776
+ gate_open: bool = False,
2777
+ certificate: tuple[bool | None, str] | None = None,
2778
+ ) -> None:
2779
+ """The READY_FOR_MERGE banner; ``certificate`` is :func:`_review_certificate_check`'s answer.
2780
+
2781
+ Whether the review certificate applies is read from state and the
2782
+ configuration in force, never GitHub: the merge gate compares it with
2783
+ the live PR itself (#175), and refuses to merge without one.
2784
+ """
2785
+ if not state.review_certificate:
2786
+ held = "none held; the merge gate would refuse to merge"
2787
+ elif certificate is None or certificate[0] is None:
2788
+ held = certificate[1] if certificate is not None else "not checked"
2789
+ elif certificate[0]:
2790
+ held = (
2791
+ f"applies (round {state.review_certificate.get('review_round')}, against state and "
2792
+ "the configuration in force; the merge gate checks it against GitHub too)"
2793
+ )
2794
+ else:
2795
+ held = f"does not apply ({certificate[1]}); the merge gate would refuse to merge"
2796
+ lines = [
2797
+ "",
2798
+ "=" * 72,
2799
+ "AutoForge workflow reached READY_FOR_MERGE.",
2800
+ f" Issue: {state.current_issue_url}",
2801
+ f" PR: {state.current_pr_url}",
2802
+ f" Review round: {state.review_round}",
2803
+ f" Reviewed HEAD: {state.reviewed_head_sha}",
2804
+ f" Reviewed base: {state.reviewed_base_ref or '-'}",
2805
+ f" Reviewed merge base: {state.reviewed_merge_base_sha or '-'}",
2806
+ f" Review: {state.last_review_comment_url or '-'}",
2807
+ f" Review certificate: {held}",
2808
+ ]
2809
+ if gate_open:
2810
+ lines += [
2811
+ "Merge gate is open but the step budget (--max-steps) ran out before MERGE.",
2812
+ "'resume --allow-merge' continues with the controller-side pre-merge verification.",
2813
+ ]
2814
+ else:
2815
+ lines += [
2816
+ "Automatic merge is disabled in this milestone.",
2817
+ "A human must review and merge the PR.",
2818
+ ]
2819
+ lines.append("=" * 72)
2820
+ # State-derived like the status output: the same redaction boundary.
2821
+ print(redact("\n".join(lines)))
2822
+
2823
+
2824
+ def print_plan(plan: StepPlan, full_prompt: bool = False) -> None:
2825
+ print(f"Phase: {plan.phase}")
2826
+ print(
2827
+ f"Profile: {plan.profile_name} "
2828
+ f"(provider={plan.provider}, model={plan.model}, effort={plan.effort})"
2829
+ )
2830
+ if plan.review_round and plan.phase in ("REVIEW", "FIX"):
2831
+ print(f"Review round: {plan.review_round}")
2832
+ if plan.template:
2833
+ print(f"Template: {plan.template}")
2834
+ if plan.command:
2835
+ # The prompt is previewed below, so the argument that carries it is
2836
+ # shown as a placeholder. Only that argument: a provider that sends
2837
+ # the prompt on stdin has none, and its last argument is a flag value.
2838
+ shown = [
2839
+ "<prompt>" if plan.prompt_full and arg == plan.prompt_full else arg
2840
+ for arg in plan.command
2841
+ ]
2842
+ print(f"Command: {' '.join(redact_argv(shown))}")
2843
+ if plan.limits is not None:
2844
+ print(f"Limits: {plan.limits.describe()}")
2845
+ if plan.loop_detection is not None:
2846
+ print(f"Loops: {plan.loop_detection.describe()}")
2847
+ print(f"Routing: {plan.routing}")
2848
+ print(f"Expected next: {plan.expected_next or '-'}")
2849
+ if plan.legal_next:
2850
+ print(f"Legal next: {', '.join(plan.legal_next)}")
2851
+ # A note can quote state (a recorded refusal, an effect's target, the
2852
+ # replan escalation), so it crosses the boundary the variables do.
2853
+ for note in plan.notes:
2854
+ print(f"Note: {redact(note)}")
2855
+ if plan.variables:
2856
+ print("Variables:")
2857
+ for k, v in sorted(plan.variables.items()):
2858
+ text = redact(v).replace("\n", " ")
2859
+ print(f" {k} = {text[:120]}")
2860
+ if plan.prompt_length:
2861
+ print(f"Prompt: {plan.prompt_length} chars")
2862
+ print("--- prompt preview ---")
2863
+ print(redact(plan.prompt_preview if not full_prompt else plan.prompt_full))
2864
+ print("--- end preview ---")
2865
+ print()
2866
+
2867
+
2868
+ def print_step_outcome(o: StepOutcome) -> None:
2869
+ # Every field here came out of state or an agent result, `run_id`
2870
+ # included, so the first line is redacted whole rather than per field.
2871
+ # Both lines reach the terminal and a scheduler worker's persisted log.
2872
+ print(redact(f"[{o.run_id}] {o.previous_phase} -> {o.next_phase}: {o.message}"))
2873
+ if o.plan is not None and o.plan.command:
2874
+ print(f" profile={o.plan.profile_name} model={o.plan.model}")
2875
+ if o.result is not None:
2876
+ # The agent's result is redacted string by string before it is
2877
+ # serialized, never as its JSON text (#281): the text escapes what
2878
+ # stands before a value (`\n`, `\"`, `\u00e9`), and a redaction of
2879
+ # the escaped text is only as good as the patterns' escape rules.
2880
+ print(f" result={json.dumps(redact_obj(o.result), sort_keys=True)}")
2881
+
2882
+
2883
+ if __name__ == "__main__":
2884
+ raise SystemExit(main())