agents-gl 0.0.1__tar.gz

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 (116) hide show
  1. agents_gl-0.0.1/.gitignore +11 -0
  2. agents_gl-0.0.1/ARCHITECTURE.md +570 -0
  3. agents_gl-0.0.1/LICENSE +21 -0
  4. agents_gl-0.0.1/PKG-INFO +53 -0
  5. agents_gl-0.0.1/README.md +29 -0
  6. agents_gl-0.0.1/pyproject.toml +222 -0
  7. agents_gl-0.0.1/src/agl/__init__.py +0 -0
  8. agents_gl-0.0.1/src/agl/adapters/__init__.py +0 -0
  9. agents_gl-0.0.1/src/agl/adapters/claude_code/__init__.py +0 -0
  10. agents_gl-0.0.1/src/agl/adapters/claude_code/_session.py +127 -0
  11. agents_gl-0.0.1/src/agl/adapters/claude_code/_tools.py +72 -0
  12. agents_gl-0.0.1/src/agl/adapters/claude_code/fake.py +191 -0
  13. agents_gl-0.0.1/src/agl/adapters/claude_code/runner.py +160 -0
  14. agents_gl-0.0.1/src/agl/adapters/claude_code/translate.py +241 -0
  15. agents_gl-0.0.1/src/agl/adapters/filesystem/__init__.py +0 -0
  16. agents_gl-0.0.1/src/agl/adapters/filesystem/_documents.py +35 -0
  17. agents_gl-0.0.1/src/agl/adapters/filesystem/memory_store.py +67 -0
  18. agents_gl-0.0.1/src/agl/adapters/filesystem/store.py +140 -0
  19. agents_gl-0.0.1/src/agl/adapters/git/__init__.py +0 -0
  20. agents_gl-0.0.1/src/agl/adapters/git/_changes.py +53 -0
  21. agents_gl-0.0.1/src/agl/adapters/git/_conflicts.py +61 -0
  22. agents_gl-0.0.1/src/agl/adapters/git/_merging.py +144 -0
  23. agents_gl-0.0.1/src/agl/adapters/git/_patches.py +69 -0
  24. agents_gl-0.0.1/src/agl/adapters/git/_runner.py +192 -0
  25. agents_gl-0.0.1/src/agl/adapters/git/_snapshots.py +178 -0
  26. agents_gl-0.0.1/src/agl/adapters/git/_trees.py +139 -0
  27. agents_gl-0.0.1/src/agl/adapters/git/_working.py +82 -0
  28. agents_gl-0.0.1/src/agl/adapters/git/fake.py +306 -0
  29. agents_gl-0.0.1/src/agl/adapters/git/history.py +125 -0
  30. agents_gl-0.0.1/src/agl/adapters/git/integrator.py +98 -0
  31. agents_gl-0.0.1/src/agl/adapters/git/workspace.py +175 -0
  32. agents_gl-0.0.1/src/agl/adapters/openai/__init__.py +0 -0
  33. agents_gl-0.0.1/src/agl/adapters/openai/_http.py +137 -0
  34. agents_gl-0.0.1/src/agl/adapters/openai/_session.py +220 -0
  35. agents_gl-0.0.1/src/agl/adapters/openai/_tools.py +153 -0
  36. agents_gl-0.0.1/src/agl/adapters/openai/fake.py +183 -0
  37. agents_gl-0.0.1/src/agl/adapters/openai/runner.py +177 -0
  38. agents_gl-0.0.1/src/agl/adapters/openai/translate.py +247 -0
  39. agents_gl-0.0.1/src/agl/adapters/rich_terminal/__init__.py +0 -0
  40. agents_gl-0.0.1/src/agl/adapters/rich_terminal/_display.py +81 -0
  41. agents_gl-0.0.1/src/agl/adapters/rich_terminal/_render.py +49 -0
  42. agents_gl-0.0.1/src/agl/adapters/rich_terminal/headless.py +65 -0
  43. agents_gl-0.0.1/src/agl/adapters/rich_terminal/queues.py +117 -0
  44. agents_gl-0.0.1/src/agl/adapters/rich_terminal/scripted.py +95 -0
  45. agents_gl-0.0.1/src/agl/adapters/rich_terminal/terminal.py +235 -0
  46. agents_gl-0.0.1/src/agl/adapters/routing.py +55 -0
  47. agents_gl-0.0.1/src/agl/adapters/shell/__init__.py +0 -0
  48. agents_gl-0.0.1/src/agl/adapters/shell/fake.py +25 -0
  49. agents_gl-0.0.1/src/agl/adapters/shell/verifier.py +102 -0
  50. agents_gl-0.0.1/src/agl/adapters/system_clock.py +39 -0
  51. agents_gl-0.0.1/src/agl/api.py +204 -0
  52. agents_gl-0.0.1/src/agl/cli/__init__.py +0 -0
  53. agents_gl-0.0.1/src/agl/cli/commands/__init__.py +19 -0
  54. agents_gl-0.0.1/src/agl/cli/commands/clear.py +50 -0
  55. agents_gl-0.0.1/src/agl/cli/commands/init.py +35 -0
  56. agents_gl-0.0.1/src/agl/cli/commands/resume.py +51 -0
  57. agents_gl-0.0.1/src/agl/cli/commands/run.py +88 -0
  58. agents_gl-0.0.1/src/agl/cli/commands/workflows.py +68 -0
  59. agents_gl-0.0.1/src/agl/cli/exit_codes.py +21 -0
  60. agents_gl-0.0.1/src/agl/cli/main.py +173 -0
  61. agents_gl-0.0.1/src/agl/config/__init__.py +0 -0
  62. agents_gl-0.0.1/src/agl/config/container.py +198 -0
  63. agents_gl-0.0.1/src/agl/config/registry.py +69 -0
  64. agents_gl-0.0.1/src/agl/config/schema.py +81 -0
  65. agents_gl-0.0.1/src/agl/config/sources.py +202 -0
  66. agents_gl-0.0.1/src/agl/config/toml_file.py +347 -0
  67. agents_gl-0.0.1/src/agl/ports/__init__.py +0 -0
  68. agents_gl-0.0.1/src/agl/ports/agent.py +183 -0
  69. agents_gl-0.0.1/src/agl/ports/clock.py +13 -0
  70. agents_gl-0.0.1/src/agl/ports/errors.py +73 -0
  71. agents_gl-0.0.1/src/agl/ports/history.py +118 -0
  72. agents_gl-0.0.1/src/agl/ports/home_layout.py +169 -0
  73. agents_gl-0.0.1/src/agl/ports/ids.py +136 -0
  74. agents_gl-0.0.1/src/agl/ports/integration.py +82 -0
  75. agents_gl-0.0.1/src/agl/ports/run.py +253 -0
  76. agents_gl-0.0.1/src/agl/ports/store.py +69 -0
  77. agents_gl-0.0.1/src/agl/ports/terminal.py +105 -0
  78. agents_gl-0.0.1/src/agl/ports/tree_layout.py +90 -0
  79. agents_gl-0.0.1/src/agl/ports/verifier.py +24 -0
  80. agents_gl-0.0.1/src/agl/ports/workspace.py +91 -0
  81. agents_gl-0.0.1/src/agl/sdk/__init__.py +99 -0
  82. agents_gl-0.0.1/src/agl/sdk/_declarations.py +19 -0
  83. agents_gl-0.0.1/src/agl/sdk/_engine/__init__.py +0 -0
  84. agents_gl-0.0.1/src/agl/sdk/_engine/integration.py +251 -0
  85. agents_gl-0.0.1/src/agl/sdk/_engine/journal.py +376 -0
  86. agents_gl-0.0.1/src/agl/sdk/_engine/preflight.py +127 -0
  87. agents_gl-0.0.1/src/agl/sdk/_engine/services.py +31 -0
  88. agents_gl-0.0.1/src/agl/sdk/_engine/steps.py +188 -0
  89. agents_gl-0.0.1/src/agl/sdk/_engine/worktrees.py +48 -0
  90. agents_gl-0.0.1/src/agl/sdk/errors.py +23 -0
  91. agents_gl-0.0.1/src/agl/sdk/params.py +235 -0
  92. agents_gl-0.0.1/src/agl/sdk/roles.py +187 -0
  93. agents_gl-0.0.1/src/agl/sdk/terminal.py +23 -0
  94. agents_gl-0.0.1/src/agl/sdk/testing.py +45 -0
  95. agents_gl-0.0.1/src/agl/sdk/tools.py +333 -0
  96. agents_gl-0.0.1/src/agl/sdk/workflow.py +249 -0
  97. agents_gl-0.0.1/src/agl/testing.py +237 -0
  98. agents_gl-0.0.1/src/agl/workflows/__init__.py +0 -0
  99. agents_gl-0.0.1/src/agl/workflows/fix/__init__.py +20 -0
  100. agents_gl-0.0.1/src/agl/workflows/fix/asking.py +60 -0
  101. agents_gl-0.0.1/src/agl/workflows/fix/findings.py +51 -0
  102. agents_gl-0.0.1/src/agl/workflows/fix/prompts/implement.md +112 -0
  103. agents_gl-0.0.1/src/agl/workflows/fix/prompts/review.md +79 -0
  104. agents_gl-0.0.1/src/agl/workflows/fix/questions.py +33 -0
  105. agents_gl-0.0.1/src/agl/workflows/fix/roles.py +24 -0
  106. agents_gl-0.0.1/src/agl/workflows/fix/views/__init__.py +4 -0
  107. agents_gl-0.0.1/src/agl/workflows/fix/views/board.py +6 -0
  108. agents_gl-0.0.1/src/agl/workflows/fix/views/question.py +15 -0
  109. agents_gl-0.0.1/src/agl/workflows/split/__init__.py +35 -0
  110. agents_gl-0.0.1/src/agl/workflows/split/chunks.py +63 -0
  111. agents_gl-0.0.1/src/agl/workflows/split/prompts/implement.md +105 -0
  112. agents_gl-0.0.1/src/agl/workflows/split/prompts/plan.md +120 -0
  113. agents_gl-0.0.1/src/agl/workflows/split/roles.py +23 -0
  114. agents_gl-0.0.1/src/agl/workflows/split/views/__init__.py +4 -0
  115. agents_gl-0.0.1/src/agl/workflows/split/views/board.py +11 -0
  116. agents_gl-0.0.1/src/agl/workflows/split/views/conflict.py +24 -0
@@ -0,0 +1,11 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .mypy_cache/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+ .DS_Store
11
+ .idea/
@@ -0,0 +1,570 @@
1
+ # AGL architecture
2
+
3
+ Eight layers, one dependency rule, one composition root. For the gates, see `CLAUDE.md`.
4
+
5
+ ## The layers
6
+
7
+ **`ports/`** — The ABCs AGL is written against and the plain types they speak: `Store`,
8
+ `Workspace`, `WorkspaceProvider`, `Integrator`, `History`, `Verifier`, `Terminal`, `Clock`,
9
+ `AgentRunner`, plus `RunSpec`, the `AglError` hierarchy with the one exception-to-exit-code table
10
+ in the codebase, and the id types in `ids.py`, which expose a casefold-then-NFC `collision_key` and
11
+ do **not** compare through it — `RunLabel("T-01") != RunLabel("t-01")`, and a caller that must not
12
+ collide asks for the key, because two names differing only in case are one directory on a
13
+ case-insensitive filesystem. It imports nothing but stdlib and its own ring: everything imports
14
+ `ports`, so what `ports` drags in reaches every layer at once.
15
+
16
+ **`adapters/`** — The implementations. Anything that imports a vendor SDK, opens a socket or
17
+ shells out lives here and only here. What holds of every one of them is the grading: each class
18
+ here that implements a port is subclassed into a suite under `tests/contracts/`, which is what
19
+ keeps a stand-in from drifting from the thing it stands in for. "A fake beside a real" does not.
20
+ Four packages spell one `fake.py`, `filesystem/`'s is `memory_store.py`, `system_clock.py` is one
21
+ module holding both clocks and no package at all, and `rich_terminal/` has none: it ships three
22
+ terminals across two suites — `RichTerminal` for `real()`, `ScriptedTerminal` for `answering()`,
23
+ `HeadlessTerminal` for `fakes()` — no one of them a stand-in for another. And a fake here fakes
24
+ the *port*, never the vendor: `openai/fake.py` starts no process at all.
25
+
26
+ **`sdk/`** — What a workflow author builds from: `@workflow`, the `Run` a workflow is handed,
27
+ `@role`, `Tool` and the `tool()` and `reporting_tool()` that derive one from a payload dataclass,
28
+ `arg()`, the terminal components, `Stop`. `sdk/__init__.py` is the front door and re-exports the
29
+ authoring surface with `__all__` typed out rather than computed; `_engine/` is the private
30
+ machinery behind `Run` and is not on it. That underscore names the **workflow author's** surface
31
+ and nothing narrower — `api`, `config` and `sdk`'s own modules import those seven modules freely,
32
+ so there is no import for a contract to forbid and this sentence is the whole of the rule, unlike
33
+ the same underscore under `adapters/`, which means private to that package and is enforced by
34
+ `tests/test_naming_convention.py`. Something belongs here when two workflows would otherwise
35
+ write it themselves.
36
+
37
+ **`workflows/`** — One package per workflow, found through the `agl.workflows` entry points in
38
+ `pyproject.toml`; no central table to edit. `fix` is one worktree run sequentially, Claude
39
+ implementing and OpenAI reviewing; `split` is N chunks run concurrently, each landed into the
40
+ run's base. A workflow imports `sdk` — never an adapter, never `config`. `ports` sits below it and
41
+ is permitted, and neither shipped workflow names it: the authoring surface re-exports what a
42
+ workflow speaks.
43
+
44
+ **`config/`** — Settings and the composition root. `sources.py` resolves flags > env > file >
45
+ defaults once into an immutable object, `toml_file.py` is the only module that knows TOML,
46
+ `registry.py` resolves entry points, and **`container.py` is the only module that constructs an
47
+ adapter**.
48
+
49
+ **`cli/`** — argv in, exit code out. `main.py` dispatches to one module per subcommand (run,
50
+ resume, clear, init, workflows) and is the one place `Path.cwd()` is read. Composition is
51
+ per-command: the container sits behind a callable, so `init` and `workflows` never build one.
52
+ **Commands stay dumb.** A command declares its own arguments, reads them off the parsed namespace,
53
+ calls `api` and turns what comes back into output and an exit status; everything that decides
54
+ anything is one call away. So `clear` names one `api` function rather than a worktree walk and a
55
+ `shutil.rmtree` past the `Store` port, `init` one rather than build-tool detection and TOML
56
+ rendering, and `workflows` two only because the listing and the help are two operations — one of
57
+ them imports a package and the other must never. Each suite under `tests/cli/` scans its own
58
+ command's source for the `api.` names it reaches, so a use case moving back into the CLI fails a
59
+ test instead of passing review.
60
+
61
+ **`api.py`** — AGL's operations, callable without a terminal: `run`, `resume`, `clear`, `init`,
62
+ `list_workflows`, `workflow_help`.
63
+
64
+ **`testing.py`** — The workflow author's harness: `harness(tmp_path, agent=…)` builds an all-fakes
65
+ bundle, `run(...)` and `resume(...)` drive `api` over it, `recorded` is every journal entry,
66
+ `answering([...])` is a terminal that can answer a screen. A sibling of `cli/`, not a layer above
67
+ it — a second caller of `api`.
68
+
69
+ ## The dependency rule
70
+
71
+ ```
72
+ {cli, testing} → api → config → workflows → {sdk, adapters} → ports
73
+ ```
74
+
75
+ `sdk` and `adapters` are siblings and may not import each other; so are `cli` and `testing`.
76
+ `config` may import everything under it and nothing above it, and only `config/container.py` may
77
+ name an adapter. `.importlinter` holds six contracts and `lint-imports` enforces them: the layering
78
+ above, the inner ring (a pure type never imports the ABC that speaks it), vendor containment,
79
+ adapter independence, the composition root, and workflows-build-on-`sdk`-alone.
80
+
81
+ **One clause cannot be a contract.** "`ports` imports nothing but stdlib" is an *allow* list, and
82
+ every import-linter contract type names what is forbidden or how modules are ordered — saying it
83
+ there means enumerating every distribution that is not the standard library. It is enforced
84
+ instead by `tests/test_ports_stdlib_only.py`, an AST scan over every import under `ports/`. With
85
+ `import pydantic` in `ports/clock.py`, all six contracts still report kept.
86
+
87
+ ## Vendor containment
88
+
89
+ `claude_agent_sdk` may be imported only inside `agl.adapters.claude_code`, `rich` only inside
90
+ `agl.adapters.rich_terminal`; one contract holds both. The OpenAI adapter shells out to the Codex
91
+ CLI binary and has no import to contain, so its *name* is guarded by a grep gate in `scripts/check`
92
+ that fails on any mention in a `.py` under `src/` outside `agl/adapters/openai/`. The asymmetry is
93
+ deliberate: the two SDKs are pip extras, the Codex CLI is installed separately and resolved at
94
+ preflight, and installing one vendor never drags in the other's.
95
+
96
+ ## The terminal
97
+
98
+ **A view is a function, and `show` registers the function and its arguments rather than the
99
+ `Screen` they produce.** The adapter's redraw loop invokes a registered view again every frame,
100
+ compares the `Screen` it returns against the last one, and writes only on a change — so handing a
101
+ view the live dict of child runs is how a dashboard stays current, and a workflow re-`show`s only
102
+ to put a *different* view on screen. Every component is a frozen dataclass with value equality
103
+ because that comparison is the whole reason per-frame re-invocation is cheap: the expensive part is
104
+ the write, and the write is skipped when nothing moved.
105
+
106
+ `Screen`, `Rows`, `Row`, `Text`, `Choice` and `TextInput` live in `ports/terminal.py` and not
107
+ beside the authors who write views, because `Terminal.show` takes a view returning a `Screen[T]`
108
+ and a component under `sdk/` would invert the dependency rule on its lowest edge; `sdk/terminal.py`
109
+ is a re-export facade holding no logic. `Component` and `Response` are closed unions, so an adapter
110
+ can `match` them exhaustively and a fourth component is a build failure in every implementation
111
+ that has not learned to draw it. Anywhere a component is expected a bare `str` means `Text`,
112
+ coerced on the way in, which is why `Row`, `Rows` and `Screen` write their own `__init__` instead
113
+ of taking a generated one that would accept each field at the field's own type. `Screen` is a
114
+ dashboard and `Screen[T]` is a question — the parameter says which, and `responses` being empty is
115
+ what the terminal dispatches on at run time. Terminal-*shaped* is not
116
+ terminal-*implementation*-shaped: no styling, no colour, no sizes, no positions, no notion of a
117
+ screen having a size at all, because that is mechanism and mechanism is `adapters/`.
118
+
119
+ **One slot, two queues, and this is the whole contract that every implementation satisfies
120
+ identically.** A passive `Screen` goes to the slot — size one, replaced on write, no ordering, and
121
+ `priority` means nothing for one. A `Screen[T]` joins a queue at its priority, FIFO within a
122
+ priority, because a person answers one thing at a time. The highest-priority screen is always the
123
+ one shown, and the slot keeps updating while a question is up, so when the queue empties the
124
+ current dashboard reappears with no extra machinery. Preemption is not cosmetic: `integrate()`
125
+ leaves its target held mid-landing, so a conflict screen queued behind two agent questions would
126
+ stall the merge queue on something unrelated. Priority is a plain `int` and not named levels, which
127
+ would encode one workflow's concepts into the framework. Known and accepted: preemption loses text
128
+ somebody was part-way through typing, and there are no timeouts anywhere, so "stuck" and "waiting
129
+ for you" look alike from outside — `pending` exists because of the second, and reports every
130
+ priority the terminal has been asked for, zeroes included, excluding whatever is on screen.
131
+
132
+ `adapters/rich_terminal/queues.py` never dequeues in order to display. An entry joins its queue in
133
+ `queue()` and leaves it in `answer()`, and that is the whole of its movement; current is derived,
134
+ as the head of the highest non-empty queue, recomputed on every read. Preemption is then not an
135
+ operation at all — a higher-priority arrival changes what the derivation returns — and "the
136
+ displaced question keeps its place" becomes a thing the module could not express otherwise.
137
+ Identity is the `show` registration and never the `Screen` value, which is what `eq=False` on
138
+ `Registration` and `Queued` buys: a view returns a fresh equal `Screen` every frame, and two agents
139
+ asking the same question produce equal screens, so keying on the value would hand one agent the
140
+ answer to a question it never asked.
141
+
142
+ **Headless is this port's contract and not one adapter's quirk.** A terminal with no display no-ops
143
+ a passive screen and raises `UpstreamUnavailable` on an interactive one, because a workflow needing
144
+ human input genuinely cannot run without a person and saying so at the first question beats
145
+ blocking forever on nobody. That is what lets `HeadlessTerminal` double as the fake, and it shares
146
+ nothing with the terminal that draws — no queues, no slot, no loop, and no import of `rich`,
147
+ `terminal.py`, `queues.py`, `_display.py` or `_render.py`. `ScriptedTerminal` runs no loop either,
148
+ and a script that runs out idles rather than raising: a test that then shows a question hangs
149
+ instead of failing, which is what a real terminal with nobody sitting at it does, and is what lets
150
+ the contract suite show a question before any response exists.
151
+
152
+ **A `Terminal` is an async context manager, and the framework opens it around the workflow's
153
+ function and around nothing else.** `api._walk` holds the one `async with services.terminal`, so
154
+ `run` and `resume` cannot disagree about it. It opens after the record and after the base checkout
155
+ is provisioned, because the context is exactly the region in which `show` is legal
156
+ and the only thing there that can `show` is the workflow — opening earlier would widen that region
157
+ over code where a `show` is AGL's own bug, and would take a person's display over in order to draw
158
+ nothing across the refusals they have to read. A `show` outside it is `InternalError`. `__aexit__`
159
+ is annotated `-> None` on the port and suppressing an exception means returning something truthy,
160
+ so no conforming terminal can swallow a workflow's `Stop`.
161
+
162
+ **Agent activity is one string, held and handed back, and never written down.** Each adapter
163
+ formats its own line — `Bash: ./gradlew build`, `Edit: domain/usecase.kt` — and the router passes
164
+ it through untouched: no `Activity` type, no shared verb taxonomy, no framework lookup table, so no
165
+ later backend has to map its vocabulary onto another's and the cost is cosmetic inconsistency
166
+ between them. `Steps` holds the cell and `run.activity` reads it through a property; one `Steps`
167
+ per namespace means a child reports its own steps and not its parent's. It is `None` when nothing
168
+ is running, cleared in a `finally` around the dispatch alone — an assignment does not suspend, so a
169
+ `CancelledError` cannot land between deciding to clear the cell and clearing it. A replayed step
170
+ has no activity at all, structurally rather than by a check: `on_activity` is passed inside the
171
+ worker, and a hit returns the stored value without building one. Nothing about activity reaches an
172
+ `Entry`, a fingerprint or the store.
173
+
174
+ ## Errors at the boundary
175
+
176
+ **An adapter translates what it catches — a `CalledProcessError`, an `OSError`, a vendor
177
+ exception — into the `AglError` hierarchy at its own boundary, and nothing above an adapter ever
178
+ handles anything else.** A workflow catches `UpstreamUnavailable`, never whatever the thing
179
+ underneath happened to throw; nothing above `adapters/git/` sees a porcelain exit status, a
180
+ `MERGE_HEAD` or an unmerged-path listing, and nothing above `adapters/claude_code/` sees an SDK
181
+ exception or a vendor's stop string. This is a different rule from vendor containment, which is
182
+ about where vendor *code* may live. Which subclass is chosen is decided by what the reader of an
183
+ exit code should do — `DeniedError` for a refusal that stands until something changes,
184
+ `UpstreamUnavailable` for a state of the world the same call may get past later,
185
+ `UpstreamUnexpected` for a far side that answered in terms the adapter cannot read — and
186
+ `adapters/git/_trees.py`'s `_translated` is that decision in four lines, pinned over both of its
187
+ callers by `tests/adapters/test_git_denied.py`. The rule holds one layer up wherever a module does
188
+ its own I/O rather than reaching through a port: `config/toml_file.py` and `sdk/roles.py`'s
189
+ `prompt_file` each turn an `OSError` into an `InputError` at the line that raised it.
190
+
191
+ **`ports/errors.py` holds the one exception-to-exit-code table and `cli/exit_codes.py` consumes it
192
+ without adding a number of its own.** An exception that is not an `AglError` arriving at the top of
193
+ the CLI is a translation that did not happen in our code, so it exits 70 — the same answer
194
+ `exit_code_for` gives an `AglError` on a branch nobody mapped, because the two are one fault seen
195
+ from either side and a script cannot act on them differently. Resolution walks the class tree
196
+ rather than indexing the table, so a workflow's own `ReviewNotConverging(Stop)` exits 7 without
197
+ appearing anywhere, and there is no clause order for a handler to get wrong.
198
+
199
+ **A `TaskGroup` hands back several answers at once, so a group has its own rule**: unwrap a
200
+ single-exception group and map its leaf; several leaves whose codes agree take that code; leaves
201
+ that disagree take 70, naming all of them, because a run that failed several different ways is
202
+ genuinely not attributable to one code and a guess would be this module inventing a precedence over
203
+ the table. `leaves` flattens recursively — `split` opens a `TaskGroup` and a chunk may open its
204
+ own — so "a single-leaf group" is a fact about what the run did rather than about how deeply the
205
+ workflow nested its concurrency. Agreement is compared on the resolved *code* and never on the
206
+ class, which is why `UpstreamUnavailable` beside `UpstreamUnexpected` agrees at 6 with no second
207
+ rule to keep in step with the table. `exit_status` and `leaves` take `Exception` and walk
208
+ `ExceptionGroup`, deliberately not `BaseException` and `BaseExceptionGroup`: a Ctrl-C is the
209
+ operator taking the process back rather than an outcome to report, and the faithful way to end on
210
+ one is to die of the signal, which is what CPython does when nothing catches it. A shell tells the
211
+ two apart even though `$?` reads 130 for both — a child that *died of* `SIGINT` stops the enclosing
212
+ loop and one that merely exited 130 does not — so a handler answering 130 here would make
213
+ `for label in a b c; do agl run ...; done` unstoppable by the key that was pressed to stop it.
214
+ `mypy --strict` is the enforcement, since `except BaseException as error: return exit_status(error)`
215
+ will not type-check, and `tests/cli/test_exit_codes.py` pins both the annotation and the group rule.
216
+
217
+ ## Invariants where a mistake is silent
218
+
219
+ No gate catches these and no exception announces them. Everything else in AGL fails by raising or
220
+ by costing a re-run. **Three of them destroy work.**
221
+
222
+ **A step with no `commit=` wipes its worktree.** `Journal._ending` in `sdk/_engine/journal.py`
223
+ ends every step by committing everything or calling `Workspace.restore(last_good)` — `git reset
224
+ --hard` then `git clean -ffd`. Tracked edits, untracked files and any commit the agent made itself
225
+ all go, and none of it is on the ledger either: an entry is still written, and the head it records
226
+ is the one the worktree was restored to. Nothing checks the pairing. A step whose role can touch
227
+ the worktree must pass `commit=`; the only two that omit it are `fix`'s reviewer and `split`'s
228
+ planner, and both roles declare `Restriction.NO_FILE_WRITES`.
229
+
230
+ **A landing must be handed back to the parent's chain.** `Integration._conclude` in
231
+ `sdk/_engine/integration.py` settles a clean landing with `self._journal.advance(head)`. A child's
232
+ landing moves the parent's real head, but `last_good` is chained from step *entries* and
233
+ `integrate()` writes none, so skipping that call leaves the parent believing it is where its last
234
+ step ended. The next step to miss its fingerprint restores to that stale head and resets past every
235
+ landing at once.
236
+
237
+ **A red build gate discards a hand-resolved conflict.** `Integration._gated` reverts a landing
238
+ with `restore(self._before)` when the verifier fails. If a person resolved a merge by hand and
239
+ pressed retry, the text they typed lived only in that worktree — in no git object, on no entry —
240
+ and it goes. A known cost pinned by `tests/sdk/test_integrate_acceptance.py`: the gate has to run
241
+ on that landing too.
242
+
243
+ **Every path out of a hold must settle it.** `Integration.retry` and `Integration.abort` in
244
+ `sdk/_engine/integration.py` are the two verbs a workflow calls on a live conflict, and each reaches
245
+ an `Integrator` that may raise — `land` refuses over unrecorded work in the target, which is exactly
246
+ what a person editing that checkout at a refusal screen leaves behind. `integrate()` guards its own
247
+ construction with `except BaseException: lease.release()`, and `api.run` sweeps with `finally:
248
+ leases.release_all()`; neither covers a raise out of a verb called on an object the workflow is
249
+ already holding. The target's lease and its namespace's step lock then stay taken for the life of
250
+ the process, and the next landing into that parent blocks inside `Leases.claim` — a hang rather than
251
+ a failure, with nothing raised and no predicate to ask. So both verbs settle on the way out, and the
252
+ tests in `tests/sdk/test_run_integrate.py` bound the claim that follows rather than awaiting it.
253
+ Settling is also what ends the workflow's own loop: `Integration.conflicted` is *is there a conflict
254
+ here that has not settled*, so `while outcome.conflicted:` terminates for every path out of a hold
255
+ and never sends a workflow back to a `retry()` that would refuse it. The `Conflict` itself outlives
256
+ the settling — it is the record of why nothing landed, and a workflow reads it after the loop.
257
+
258
+ **A hold and a liveness claim are facts about the world, never about AGL's memory.**
259
+ `adapters/git/integrator.py` holds nothing of its own: a conflicted `git merge` writes `MERGE_HEAD`
260
+ into the target worktree's own git directory, and that file *is* the hold — no attribute records a
261
+ pending landing, and `_held` is the single predicate all three verbs ask. A `GitIntegrator` built
262
+ in a later process and handed the same target asks git the same question and gets the same answer,
263
+ so `abort` after a crash releases a hold this process never took. An in-memory hold makes a resumed
264
+ run's `abort()` a no-op that reports success and leaves the target half-combined forever, and no
265
+ contract suite catches it — both implementations pass with one — which is why
266
+ `tests/adapters/test_git_integrator.py` asserts it against the real adapter with a second
267
+ integrator over the same repository. Two consequences follow from `MERGE_HEAD` being per worktree:
268
+ a landing held in one run's `_base` is invisible to every other run, which is the isolation the
269
+ trees layout is built on, and only a merge is ever a hold, so a rebase or a cherry-pick somebody
270
+ left in the target is not something `abort` will touch. The run lock is the same primitive
271
+ answering a different question — `_trees.run_lock` is a non-blocking `flock` on `.trees/<label>/`,
272
+ taken by `run` and `resume` for the life of the process and briefly by `clear`, so a `clear` aimed
273
+ at a live run refuses at once rather than waiting hours for a lock or taking its checkouts away
274
+ underneath it. Both refuse the recorded alternative for the reason `ports/run.py` has no
275
+ `RunStatus`: a claim written down is a claim a crash leaves behind as a lie, and a claim the kernel
276
+ drops when its holder dies is the one kind no crash can falsify.
277
+
278
+ **A workflow branches only on step results.** Resume is not a continuation — `api.resume`
279
+ re-invokes the workflow from its first line in a fresh process, so every line runs again and only
280
+ `run.step(...)` short-circuits. It fingerprints the role, its tools, the inputs and the head the
281
+ previous step ended at, appends an ordinal for repeats, and looks it up. A hit returns the
282
+ recorded value; **a miss just runs the step — a miss is not an error, it is the definition of a
283
+ new step**, so divergence has nothing to raise. Branch on wall-clock time, an environment
284
+ variable, a directory listing, randomness or a mutable global, and a resume can take another path:
285
+ paid-for work is silently redone, and where an off-branch fingerprint happens to match, a recorded
286
+ result comes back for a call that never produced it. The ordinal is never persisted — it is
287
+ rebuilt by re-walking — so order counts too: swap two same-fingerprint steps and each returns the
288
+ other's answer.
289
+
290
+ **Fingerprint canonicalisation must be order-stable.** `_canonical` walks a value before
291
+ `json.dumps(..., sort_keys=True)` hashes it with SHA-256. Mappings get sorted keys; lists and
292
+ tuples keep their order, because for a sequence order *is* meaning; sets are emitted as
293
+ `sorted(..., key=_dumps)`, by each element's own serialised text, because set iteration order is
294
+ not stable across processes. Get it wrong and a step fingerprints differently in the process that
295
+ resumes it — and a miss is not an error, so nothing complains: the run wipes the worktree, re-runs
296
+ every step it had already recorded, finishes, and returns the right answer, the symptoms being the
297
+ bill and the wall clock. Hence tests that spawn interpreters under several `PYTHONHASHSEED`
298
+ values; an in-process one passes just as happily against the bug.
299
+
300
+ **A payload class's identity travels only inside its schema's `title`.** `_object_schema` in
301
+ `sdk/tools.py` writes `"title": f"{kind.__module__}.{kind.__qualname__}"` at every depth, and
302
+ `base_of` takes a tool's name, its description and its derived schema — so the payload *type* is a
303
+ fingerprint term reached through that one string and through nothing else. Both failure directions
304
+ are silent and they run opposite ways. Rename the payload class, move its module, or re-nest it,
305
+ and the digest moves although nothing about what the agent is asked has changed: every recorded step
306
+ that reported through it misses, and the run re-buys work it already had. Add a method to it — or a
307
+ `__post_init__` that *rejects values the old one accepted* — and the digest is byte-identical: a
308
+ vocabulary enforced in code and named nowhere else is invisible to the schema, so an entry recorded
309
+ under the old rules replays under the new ones, or stops converting with its fingerprint still
310
+ matching and surfaces as an `InternalError` out of `ReportingTool.read` on a resume.
311
+ `workflows/fix/findings.py` holds exactly such a `__post_init__` over `SEVERITIES` and takes the
312
+ price; `describe()` is the way out of it, a vocabulary interpolated into a field's description being
313
+ schema and therefore fingerprint. Four tests in `tests/sdk/test_tools.py` pin the pieces.
314
+
315
+ **Everything a step does must land inside its workspace.** A replayed step returns a recorded
316
+ value and never calls the worker, so an effect that is not a file in the checkout — an HTTP POST,
317
+ a write to `$HOME`, a database row — happens twice on a miss and not at all on a hit. The ledger
318
+ holds a value and a head, not what the world looked like.
319
+
320
+ **Bump `@workflow(version=…)` when a workflow's shape changes.** `api.resume` compares the
321
+ installed version against the one stamped in `run.json` and refuses a mismatch rather than
322
+ migrating — the only thing between edited code and a ledger replayed into reordered steps. Edits
323
+ reaching a fingerprint term merely re-run those steps; inserting, removing or reordering steps
324
+ without a bump is silently wrong.
325
+
326
+ **Preflight's registry scan is best-effort; containment at every step is the guarantee.**
327
+ `sdk/_engine/preflight.py`'s `check` runs once, before the record is written and before anything is
328
+ provisioned, and reads the `@role(model=…)` factories bound in the module the workflow's `def` ran
329
+ in and in any module bound there — exactly one level, never recursing — asking each distinct
330
+ model's backend `check_ready`. It over-approximates deliberately: a factory imported and never
331
+ stepped with demands its provider, and so does every other factory in a module imported for one of
332
+ them. That is a false refusal, which is loud, names its factory and both modules, and is one import
333
+ from being fixed. What the scan cannot see is the silent half — a factory held in a container, one
334
+ built at run time by a call or a comprehension, one bound two modules deep, one reached through
335
+ anything that is not a module — and there `Capabilities.require` at every `run.step` is what still
336
+ runs, over the role the workflow actually handed in. Delete that as a duplicate of preflight's work
337
+ and the failure has nothing to raise: the role a module declares and the role a workflow steps with
338
+ are different values, because `fix` writes `implementer(ask=asking(run.terminal))` inside its own
339
+ function — a tool whose handler closes over a `Run` that did not exist when the module was
340
+ imported — and `Role.__post_init__` folds `TOOL_CALLING` into `requires` behind it. A role
341
+ reaching a backend that cannot call a tool then ends its step with `RoleIncompleteError` — the
342
+ reporting tool never reaches the model, so the agent cannot fire it — instead of the refusal it was
343
+ owed. `capabilities()` is contracted stable for the duration of a run, which is what makes one
344
+ memoised call per model per run the whole bill; `check_ready` is deliberately not repeated per
345
+ step, because it costs a turn to re-learn a state of the world preflight already asked about.
346
+ `tests/sdk/test_preflight.py` asserts the over-approximation as behaviour and measures the two
347
+ halves against each other rather than separately.
348
+
349
+ **`check` asks more than the backends now, and the order it asks in is chosen on what a question
350
+ costs.** First the repository, through `History.check_committer_identity`: `commit_all` invents no
351
+ identity, so where git can derive none it refuses inside `Journal._ending` — after the agent has
352
+ finished and before the entry is written, which is the one preflight failure a resume cannot
353
+ repair, there being no entry for it to hit. Then the backends, cheapest probe leading, ranked by
354
+ `Provider` inside `preflight.py` rather than by a third member on `AgentRunner`: the OpenAI
355
+ adapter's `check_ready` spawns `codex login status` and the Claude adapter's spends a turn, so a
356
+ machine logged into one and out of the other is refused without buying anything. `sorted` is stable,
357
+ so binding order in the workflow's module namespace still decides between two models whose probes
358
+ cost the same. Both refusals are `UpstreamUnavailable` — a state of the world the operator changes,
359
+ after which the same run works — so both leave on exit 6.
360
+
361
+ **A run's label is held in two places, and the repository has to be asked as well as the store.**
362
+ `api.run` asks `History.exists(run_branch(label))` beside reading the record, and refuses before it
363
+ has written anything. Without that check `WorkspaceProvider.open` takes its **attaching** path,
364
+ because `base` is consulted only when provisioning: the new run continues that branch from its tip
365
+ with `--from` silently ignored, and nothing anywhere says so. The two questions look like one and
366
+ are not. `clear` takes a run's records and every branch it held away together, and `api.run` writes
367
+ a record before it cuts a checkout, so nothing of AGL's leaves `agl/<label>` standing with nothing
368
+ recorded beside it — which is what makes the check look redundant and is exactly why it is not: what
369
+ it catches is somebody's own `git branch agl/auth`, or a name that outlived the repository AGL was
370
+ pointed at. `git branch -D` is what frees such a label, a second `agl clear` having no record to
371
+ address.
372
+ `tests/test_api.py::test_a_deliverable_branch_that_already_exists_refuses_the_run` pins the refusal,
373
+ and `tests/test_clear.py::test_a_cleared_label_starts_a_fresh_run_because_clear_left_no_branch_behind`
374
+ walks the round trip.
375
+
376
+ **A view must be pure, and `TextInput.maps` is excluded from comparison because it is.** A view is
377
+ re-invoked every frame and builds a fresh function object each time, and two lambdas are never
378
+ equal — so comparing `maps` would make every frame of an interactive screen differ from the last,
379
+ and the terminal would rewrite the screen ten times a second on exactly the screens somebody is
380
+ part-way through typing into. `field(compare=False, repr=False)` is what makes the frame diff work
381
+ at all, and it is sound only while purity holds: when two frames compare equal, the two `maps` were
382
+ built by the same function from the same arguments and are interchangeable, so the adapter may keep
383
+ either object. A view that returns a *different* mapping from the same inputs breaks here in
384
+ silence, as does one that reads a store, does I/O, or sorts a thousand items ten times a second. No
385
+ port can enforce any of it. `tests/ports/test_terminal.py` pins the exclusion, so a later reader
386
+ has to break an assertion before they can tidy that `compare=False` away.
387
+
388
+ ## Deliberately not built
389
+
390
+ The reasoning is the point — without it these get re-proposed.
391
+
392
+ - **No `RunStatus` enum in `ports/run.py`.** A step is done when its entry file exists, so a
393
+ stored status would be a second source of truth that nothing updates.
394
+ - **No `presentation/` layer or `Display` port.** A shared abstraction would be the intersection
395
+ of a terminal and a browser, which is a worse terminal and a worse browser.
396
+ - **No config-level model override.** The choice is semantic — this role touches sensitive code,
397
+ that one needs judgement — so it is bound by `@role(model=…)`; an override buys only *why is my
398
+ Opus role running GPT-5?*
399
+ - **No scrubbed or replaced environment for an agent harness.** What each adapter closes is the
400
+ *target repository* as a configuration channel: `claude_code/runner.py` passes
401
+ `setting_sources=[]`, `strict_mcp_config=True`, `settings=None` and `add_dirs=[]`, and
402
+ `openai/runner.py` passes `--ignore-rules`, `--ignore-user-config`,
403
+ `-c project_doc_max_bytes=0` and `-c skills.include_instructions=false`, and hands the workspace
404
+ as `cwd=` rather than on argv so that no path is interpolated into a configuration expression.
405
+ What neither does is build an environment for the child — neither agent adapter passes `env=` at
406
+ all, so a harness inherits this process's and the operator's own machine stays visible to it
407
+ (`adapters/git/_runner.py` is the only adapter that touches the variable, and it *adds* one key
408
+ to what it inherited rather than replacing anything). The one
409
+ thing that would take that away is moving the harness's home directory, and that directory is
410
+ where its credential lives, so an isolated environment is an unauthenticated one: the choice is
411
+ between a run that inherits a machine and a run that cannot start. Two harnesses whose flags have
412
+ nothing in common landing on the same boundary independently is what settles that it is the real
413
+ one. `tests/contracts/_agent_hermeticity.py` asserts the half that is closed, against one
414
+ repository poisoned for every harness at once with markers that ride three channels, and it reads
415
+ no environment variable anywhere — deliberately, because the inherited half is not a thing it
416
+ could assert about without pinning the decision it declines to make.
417
+ - **No CLI positionals.** `agl run <workflow>` already occupies that slot, so `arg()` refuses a
418
+ flagless field where it is written rather than at the parse that would have gone wrong.
419
+ - **`@workflow` takes `version` and nothing else.** `params=`, `name=` and `roles=` each restated
420
+ something the framework could already read, and the copy is the half free to be wrong — `Run` is
421
+ covariant, so `@workflow(params=FixParams)` over `async def fix(run: Run)` type-checked fine.
422
+ - **No fan-out or parallelism helper.** The framework never spawns a task for a workflow; steps
423
+ serialise within a namespace, so real concurrency is more worktrees, and a helper would wrap
424
+ `asyncio.TaskGroup` while owning nothing.
425
+ - **No general subprocess helper.** Four modules run children — `shell/verifier.py`,
426
+ `git/_runner.py`, `openai/runner.py`, `openai/_session.py` — and disagree on six axes of how one
427
+ is *started and read*: shell or exec, buffered or streamed, stdin, stderr, deadline, failure
428
+ signal. A helper would take a flag per axis to say which caller it was being. Stopping is not a
429
+ seventh axis, because it is where all three of the modules that stop a child agree on purpose:
430
+ the `_signal` in `verifier.py`, in `_session.py` and in `git/_runner.py` escalates SIGTERM,
431
+ grace, SIGKILL; none of them signals a child whose `returncode` is already set,
432
+ because a reaped pid is the kernel's to hand out again and what dies is then whatever holds that
433
+ number now; and none of them lets a denied signal out of a stopping path, where a raw `OSError`
434
+ would replace whatever was being reported — a deadline, or an unwinding `CancelledError`. They
435
+ differ on the one line that names *what* is signalled, and that follows from how they start:
436
+ `verifier.py` and `_session.py` gave their child a session, so they signal the group and fall
437
+ back to the child itself when the group is denied; `git/_runner.py` gave its child none, so it
438
+ signals the process and has no group to fall back from. All three were brought into line rather
439
+ than born that way — a difference there was a defect, not a caller's business — and
440
+ `openai/runner.py`'s readiness probe was given a session so that it could spend `_session.py`'s
441
+ `_halt` and `_signal` unchanged rather than grow a fourth copy of them — a sibling module
442
+ inside one adapter, which is the one place a stopping sequence can be shared for free. It stopped
443
+ nothing at all until it was given both that session and a deadline. The helper would also have
444
+ nowhere to live: the adapter-independence contract in `.importlinter` forbids one adapter
445
+ importing another — the entry below is that sentence in its general form.
446
+ - **No shared module under `adapters/`.** The adapters repeat themselves, and every one of the
447
+ repeats stays. The port fakes are the bulk of it: `claude_code/fake.py` (204 lines) and
448
+ `openai/fake.py` (195) hold 182 lines in common line for line and the same four-name `__all__`,
449
+ with `Conversation`, `_payload`, `_value` and `_said` byte-identical and `_as_json` differing in
450
+ one clause of its error prose. All that differs is vendor-shaped: the model check — `_check_model`,
451
+ Claude-only, against `translate.model_slug` — the backend's name in three message constants, and
452
+ the activity line, `f"{declared.name}: {said}"` against `f"{_LABEL_CALLING}: {declared.name}"`,
453
+ each fake keeping the shape of the line its own real adapter emits. Beside them, `Caller` with
454
+ `_FAILED` and `_STOPPING` is 24 byte-identical lines in two `_tools.py` that are otherwise an MCP
455
+ server registration and a JSON-RPC listener; `_shortened` is seven byte-identical lines in
456
+ `claude_code/translate.py` and `openai/translate.py`; `_translated` is four lines that
457
+ `git/_trees.py` writes and `filesystem/store.py` writes again with one parameter renamed —
458
+ `git/_working.py` held a third copy and now imports `_trees.py`'s, a sibling inside one package
459
+ being the one place that is free; and
460
+ `_GRACE: Final = 5.0` stands in each of the three modules above that stop a child.
461
+ `_ENCODING: Final = "utf-8"` stands three times inside `git/` alone — the free kind — and is
462
+ refused anyway, at net zero lines: what each site decides is the handler beside it,
463
+ `surrogatepass` where `_snapshots.py` encodes a path into a commit digest and two paths must not
464
+ collapse into one, `replace` where `_patches.py` and `_runner.py` decode something only to be
465
+ read. Folding the half they agree on would leave the half they do not, and `_runner.py` imports
466
+ nothing from the package, so the real subprocess runner would be reaching into the fake
467
+ repository's module for a string. Contract 4 forbids one adapter importing another, so an
468
+ adapter-spanning duplicate folds into `ports/`, into
469
+ something new under `adapters/`, or nowhere. `ports/` is wrong for all of it — it is the ABCs and
470
+ the plain types they speak and everything imports it, so what lands there reaches every layer at
471
+ once: a fake implements a port and is not one, `Caller` is adapter mechanics, and how a filesystem
472
+ error or a vendor CLI's output line is phrased is that adapter's own business. **The peer package
473
+ under `adapters/` is not an available shape.** `adapter_drift` in
474
+ `tests/test_contract_listings.py` requires every *directory* under `src/agl/adapters/` to appear
475
+ in contract 4's `modules =`, and a directory has no exemption route at all — `ADAPTER_EXEMPT`
476
+ there is keyed by filename and holds only single-file members. The package would therefore be
477
+ listed, and being listed is exactly what forbids the two adapters importing it. The one shape that
478
+ folds is a top-level `.py` with an `ADAPTER_EXEMPT` entry — `routing.py`'s shape — and it has been
479
+ refused in writing already, for a structurally identical case: that constant's comment carries a
480
+ hypothetical shared `_process.py`, left there to say that nothing is pre-authorised, and warns
481
+ against taking an exemption to spare an edit to `.importlinter`, an exemption removing a module
482
+ from the rule where a listing applies it. `routing.py`'s own exemption is no precedent for a
483
+ second. It *is* an adapter: it implements `AgentRunner`, and dispatching on `task.model.provider`
484
+ to the vendor runners is its whole job, so importing them is the thing it does. A shared fake or a
485
+ shared `Caller` would be the first module under `adapters/` that is neither an adapter nor the
486
+ router — a library the adapters depend on, which is a different kind of thing and creates a
487
+ dependency edge contract 4 would see in neither direction. `tests/test_contract_listings.py`'s
488
+ docstring names that ending as the one contract 4 exists to catch: without the guard, "the first
489
+ sign of it would have been two vendors quietly sharing a helper". **The price is paid rather than
490
+ hidden.** Those 182 lines and those 24 get fixed twice, and both `_tools.py` have already been
491
+ edited in parallel once. What holds the two fakes together is grading and not sharing: each is
492
+ subclassed into the same `AgentContract` suite under `tests/contracts/`, so a divergence in what
493
+ they *promise* fails the build, while a divergence in how they spell it does not.
494
+ - **No single `Tool` class.** `ReportingTool[P]` in `sdk/tools.py` reads as "a `ports/` `Tool` with
495
+ a payload and no handler", and `tool()` beside it — an ordinary `Tool` whose schema is derived
496
+ from a payload dataclass and whose handler is called with the built instance — makes the
497
+ resemblance closer rather than weaker. What one class would buy is that resemblance written down.
498
+ What it costs is the only static check `Role[P]` has: that parameter binds from the one member of
499
+ `Sequence[Tool | ReportingTool[P]]` which carries a payload type, and it binds **because the two
500
+ classes are disjoint** — make `ReportingTool` a subclass of one `Tool` and every mismatched
501
+ declaration type-checks clean, a `ReportingTool[Other]` satisfying the bare `Tool` arm so that `P`
502
+ is never bound at all. Four things pay for the merge. *The layering*:
503
+ `tests/sdk/test_tools.py` already writes that half down — "`Tool` is a port type that must not
504
+ learn what a payload class is" — and one class is that sentence reversed, with either ~205 lines
505
+ of schema derivation following `payload` into `ports/`, or `payload: type[P]` going without them,
506
+ which is the split that lets a bare `Tool(payload=…)` be written carrying no schema at all. *The
507
+ price of recovering the check*: the one spelling that keeps it is
508
+ `tools: Sequence[Tool[P] | Tool[None]]` — more machinery rather than less, `Tool[Any]` in
509
+ `AgentTask.tools` and in `base_of`, and a hand-written overloaded `__init__` on a `ports/`
510
+ dataclass, a generated one being public and reopening what the overloads closed. *A refusal
511
+ deleted*: under that spelling a mixed **list** display quietly infers `Role[Findings | None]`
512
+ where today it is refused at the declaration
513
+ (`tests/sdk/test_roles.py::test_a_mixed_list_display_does_not_infer_p_and_says_so_at_the_declaration`,
514
+ whose docstring says "the `type: ignore` is the assertion"). *A new hole on the shape `tool()`
515
+ exists for*: a tool carrying both a payload and a handler binds `P`, so `Role(tools=(that_one,))`
516
+ infers `Role[Findings]` while `run.step` returns `None` and the `.summary` after it is an
517
+ `AttributeError` with mypy clean. And the distinction the merge would erase is not conventional.
518
+ A reporting tool's payload is the only value a tool call can put on the journal —
519
+ `sdk/_engine/steps.py`'s `return None if capture is None else capture.reported(outcome)` is the
520
+ whole of it, and that value becomes `Entry.value`; every other tool answers with a `ToolResult`
521
+ that each adapter turns into content for the model and that reaches no store. One class buries
522
+ that in `handler is None`.
523
+ - **No safe mode on `agl clear`.** It takes the whole run — every checkout, every branch, the run's
524
+ own included, and the records — whether the work is uncommitted, committed and unlanded, or
525
+ already in the base ref, and no flag changes that. The obvious alternative is a `git branch -d`
526
+ gate on the run's own branch, keeping it when `History.contains` says the base ref does not hold
527
+ it yet, with a `-f` to override. What that buys is a label that reads as free to the `Store` and
528
+ is taken in the repository; a warning an operator cannot act on, because the same call removed the
529
+ record any second `agl clear` would need; and a `--force` everybody learns to type by reflex,
530
+ which is a confirmation nobody reads. The honest ordering is the other way round — `git log
531
+ agl/<label>` before the verb, and a listing of the branches and the checkouts after it, so what
532
+ went is on the terminal rather than in a manual. `api.clear` therefore answers with a `Cleared`
533
+ rather than printing anything, `api.py` starting no output.
534
+ - **No `Integrator.revert()`.** Undoing a landing that succeeded is `Workspace.restore(head)`,
535
+ which already exists; a second spelling would be owed by every integrator.
536
+ - **No auto-generated commit message.** The message is domain vocabulary — `implement T-01` is
537
+ something only the workflow knows — so `commit=` on `run.step` is the workflow author's one
538
+ step-ending decision and the framework composes nothing to put beside it. Where AGL does need a
539
+ message it spends one the tool already wrote rather than inventing a second voice:
540
+ `adapters/git/integrator.py` merges and concludes with `--no-edit`, so a landing is called what
541
+ git calls it, and `adapters/git/fake.py`'s `_merged` writes that same sentence in the same words
542
+ — which is legitimate here because it describes an *event* and not a piece of work. What the
543
+ refusal owes in return is a way to read the author's sentence back, and `History.message` is that
544
+ member: without it a workflow's own test could assert that some commit happened, which is also
545
+ what a *missing* `commit=` produces. It promises that trailing whitespace is not part of a
546
+ message, because git stores one with a final newline and an implementation that kept what it was
547
+ given would not, and it deliberately promises nothing about the interior of a multi-line one —
548
+ requiring that would be the port asking every implementation for one program's text formatting.
549
+ - **No second `IntegrationOutcome` case for a build gate's refusal.** `Integration.conflicted` in
550
+ `sdk/_engine/integration.py` is one shape over two causes — a textual collision the `Integrator`
551
+ reported, and a landing that combined cleanly and was then reverted by `_gated` — because both
552
+ hold the lease and end with the same two verbs, so a workflow's conflict loop is written once. A
553
+ third case on the port would be a value no adapter can produce: the refusal is fabricated in the
554
+ engine, after `land` has already answered. `Integration.refused_by_the_gate` is what tells the two
555
+ apart, and it reads the verdict `_gated` sets and `_conclude` clears — never `paths == ()`, which
556
+ `adapters/git/_conflicts.py` also emits when git names no unmerged file. It answers about the
557
+ shape rather than about the record, so it is false wherever `conflicted` is: a settled outcome
558
+ still carries the verdict that refused it, and there is no longer a conflict for it to be the
559
+ cause of.
560
+ - **`Store` has six members and no more.** No `exists`, because a read returning `None` is that
561
+ question already answered; no listing of entries, because replay computes the digest it wants; no
562
+ transaction, because a batch needs a boundary and a flush would admit to a buffer.
563
+ - **No retry or exit-status cleverness on the build gate.** A dead daemon, an OOM kill and a real
564
+ test failure are not distinguishable from an exit code, so an OOM reads as a failed build and
565
+ the cost is a re-run.
566
+ - **No `auto()` on any enum.** Enum values reach the fingerprint as text and are therefore a
567
+ stored format; `auto()` would hand that format to declaration order.
568
+ - **No lock in `adapters/filesystem/`.** Every document has its own address, so a write is one
569
+ `os.replace`. A mutex would be correct, slower, and invisible — it leaves every store test
570
+ green, so `tests/adapters/test_filesystem_no_lock.py` reads the source instead.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.