agentic-dev-loop 0.2.0__tar.gz → 1.0.0a1__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 (57) hide show
  1. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/PKG-INFO +49 -22
  2. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/README.md +48 -21
  3. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/pyproject.toml +6 -1
  4. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/actions.py +45 -2
  5. agentic_dev_loop-1.0.0a1/src/agentic_dev_loop/agents.py +125 -0
  6. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/cli.py +23 -1
  7. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/config.py +56 -2
  8. agentic_dev_loop-1.0.0a1/src/agentic_dev_loop/dryrun.py +122 -0
  9. agentic_dev_loop-1.0.0a1/src/agentic_dev_loop/extensions/aidlc.py +124 -0
  10. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/graph.py +24 -27
  11. agentic_dev_loop-1.0.0a1/src/agentic_dev_loop/isolation.py +720 -0
  12. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/loaders.py +25 -0
  13. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/outputs.py +1 -1
  14. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/proc.py +46 -0
  15. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/stages.py +45 -19
  16. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/viz.py +10 -3
  17. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop.egg-info/PKG-INFO +49 -22
  18. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop.egg-info/SOURCES.txt +7 -0
  19. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/tests/test_actions.py +117 -6
  20. agentic_dev_loop-1.0.0a1/tests/test_agents.py +167 -0
  21. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/tests/test_aidlc.py +105 -108
  22. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/tests/test_cli.py +49 -3
  23. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/tests/test_config.py +90 -2
  24. agentic_dev_loop-1.0.0a1/tests/test_dry_run.py +137 -0
  25. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/tests/test_e2e_fixtures.py +15 -5
  26. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/tests/test_graph.py +33 -2
  27. agentic_dev_loop-1.0.0a1/tests/test_isolation.py +662 -0
  28. agentic_dev_loop-1.0.0a1/tests/test_isolation_integration.py +128 -0
  29. agentic_dev_loop-1.0.0a1/tests/test_isolation_pbt.py +140 -0
  30. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/tests/test_loaders.py +7 -7
  31. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/tests/test_outputs_pbt.py +3 -1
  32. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/tests/test_proc.py +72 -1
  33. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/tests/test_sdk_compat.py +22 -0
  34. agentic_dev_loop-1.0.0a1/tests/test_source_resolution.py +21 -0
  35. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/tests/test_stages.py +75 -8
  36. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/tests/test_viz.py +33 -2
  37. agentic_dev_loop-0.2.0/src/agentic_dev_loop/agents.py +0 -49
  38. agentic_dev_loop-0.2.0/src/agentic_dev_loop/extensions/aidlc.py +0 -152
  39. agentic_dev_loop-0.2.0/tests/test_agents.py +0 -88
  40. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/LICENSE +0 -0
  41. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/setup.cfg +0 -0
  42. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/__init__.py +0 -0
  43. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/extensions/__init__.py +0 -0
  44. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/llm.py +0 -0
  45. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/py.typed +0 -0
  46. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/registry.py +0 -0
  47. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop/results.py +0 -0
  48. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  49. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  50. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  51. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  52. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/tests/test_llm.py +0 -0
  53. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/tests/test_outputs.py +0 -0
  54. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/tests/test_publish.py +0 -0
  55. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/tests/test_registry.py +0 -0
  56. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/tests/test_results.py +0 -0
  57. {agentic_dev_loop-0.2.0 → agentic_dev_loop-1.0.0a1}/tests/test_verdict.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 0.2.0
3
+ Version: 1.0.0a1
4
4
  Summary: Opinionated LangGraph engine for OpenHands agent pipelines
5
5
  Author: Luke Wilson
6
6
  License-Expression: Apache-2.0
@@ -50,6 +50,17 @@ Publicly depends on [OpenHands SDK](https://github.com/OpenHands/software-agent-
50
50
  for agent execution and [LangGraph](https://github.com/langchain-ai/langgraph)
51
51
  (+ `langgraph-checkpoint-sqlite`) for the state graph.
52
52
 
53
+ ## Sandboxed execution
54
+
55
+ The agent `terminal` tool and command steps run inside an **always-on**,
56
+ Docker-based kernel-level sandbox — `--network none`, read-only rootfs,
57
+ dropped capabilities, writes only on the bind-mounted workspace. The
58
+ `isolation:` section configures the sandbox (image override, resource caps,
59
+ per-step `env:`/`setup:`, git-mutation blocking); it can never disable it. A
60
+ missing or unusable Docker engine fails fast with an `AE-SBX-*` message. See
61
+ [docs/setup.md](docs/setup.md) for the engine setup, first-run image pull, the
62
+ `pipeline.yaml` contract, stamp caching, and troubleshooting.
63
+
53
64
  ## Install & test
54
65
 
55
66
  ```bash
@@ -65,8 +76,9 @@ Requires Python ≥ 3.12.
65
76
  ```bash
66
77
  export OPENAI_API_KEY=... # whatever env var your pipeline's api_key_env names
67
78
  agentic-dev-loop agentic-dev-loop/pipeline.yaml --plan plan.md
68
- agentic-dev-loop agentic-dev-loop/pipeline.yaml --ai-dlc-unit U2
79
+ agentic-dev-loop agentic-dev-loop/pipeline.yaml --aidlc-unit unit-2
69
80
  agentic-dev-loop agentic-dev-loop/pipeline.yaml --resume <thread-id> --max-attempts 5
81
+ agentic-dev-loop agentic-dev-loop/pipeline.yaml --plan plan.md --dry-run
70
82
  ./trigger-agentic-dev-loop.sh --plan plan.md # same thing, from the repo root
71
83
  ```
72
84
 
@@ -84,10 +96,21 @@ expanded and live-tails its console. To eyeball the viz page without
84
96
  running a pipeline, `.venv/bin/python viz_demo.py` serves the real topology
85
97
  and replays a scripted run (every node state, verdict badges, retry cycle).
86
98
 
87
- `--ai-dlc-unit` accepts any unambiguous spelling of a unit number — `U2`,
88
- `2`, `U002`, `002` — or the full unit slug (`U002-custom-configuration`).
89
- If multiple unit directories match one id, the run fails loudly naming
90
- every candidate; pass the full slug to disambiguate.
99
+ `--dry-run` performs the full load-and-validate pass — YAML parse, step
100
+ config validation, extension and document-loader loading, agents_dir /
101
+ verdict / routing-target resolution, and pure image-ref resolution — without
102
+ executing anything, then prints a summary plan (pipeline path, subject,
103
+ resolved loader + docs, per-step kind and key params, and the resolved
104
+ isolation profile). It makes no LLM call, touches no Docker daemon, and
105
+ writes no state, so it needs no API key; it exits `0` on a clean load and
106
+ `1` with the usual load error otherwise.
107
+
108
+ `--aidlc-unit` takes the unit directory name under
109
+ `aidlc-docs/construction/` verbatim — `unit-2`,
110
+ `unit-002-custom-configuration`, or any slug AI-DLC's own naming uses
111
+ (AI-DLC imposes no naming convention on unit directories). No spelling
112
+ tolerance, no numeric aliases: the slug must name the directory exactly,
113
+ and a wrong slug fails loudly listing the directories that do exist.
91
114
 
92
115
  ## What a run looks like
93
116
 
@@ -105,11 +128,12 @@ dev ──▶ test ──▶ review ──▶ qa ──▶ commit
105
128
  `additional_files:` + auto-generated expected-output paths). Its writes
106
129
  are untracked; the pipeline only cares about declared `produces:` files.
107
130
  2. **test** is a mechanical command step: it runs the suite
108
- (`.venv/bin/python -m pytest tests/ -q`) in the workspace shell. Exit 0
109
- passes and moves on; anything else (including no exit by `timeout`) fails
110
- and rewinds to `dev` with the failing output in the retry note.
131
+ (`.venv-sandbox/bin/python -m pytest tests/ -q -m "not docker"`) inside the
132
+ sandbox. Exit 0 passes and moves on; anything else (including no exit by
133
+ `timeout`) fails and rewinds to `dev` with the failing output in the retry
134
+ note.
111
135
  3. **review / qa** are verdict agents: each must write its declared
112
- artifact (e.g. `.pr/qa-report.md`) whose **last** `VERDICT: <word>` line
136
+ artifact (e.g. `qa-report.md`) whose **last** `VERDICT: <word>` line
113
137
  decides routing. `pass` follows `on_pass`, `fail` follows `on_fail` —
114
138
  here `{goto: dev, retry: true}`, which consumes an attempt and feeds the
115
139
  artifact back to the dev agent as a retry note.
@@ -139,7 +163,8 @@ llm:
139
163
  api_key_env: OPENAI_API_KEY # missing env var → run fails fast, naming it
140
164
  # timeout: null # default = no client timeout (long agent runs)
141
165
 
142
- state_dir: .pr # artifacts + checkpoints live here
166
+ # state_dir omitted → default ~/.agentic-dev-loop/runs/<pipeline-hash>/
167
+ # (artifacts + checkpoints live OUTSIDE the sandbox mount)
143
168
  max_attempts: 10 # retries across the whole graph
144
169
  agents_dir: sdk_agents # consumer-owned <name>.agent.md files
145
170
 
@@ -150,8 +175,9 @@ steps:
150
175
  on_pass: test # explicit; omitted → next step in order
151
176
 
152
177
  - name: test
153
- command: .venv/bin/python -m pytest tests/ -q # mechanical: exit code is the verdict
154
- timeout: 600 # seconds; SIGKILLs the process group
178
+ command: .venv-sandbox/bin/python -m pytest tests/ -q -m "not docker" # sandboxed: exit code is the verdict
179
+ setup: python -m venv .venv-sandbox && .venv-sandbox/bin/pip install -e ".[dev]" # provision (network ON, stamp-cached)
180
+ timeout: 600 # seconds
155
181
  on_pass: review
156
182
  on_fail: {goto: dev, retry: true}
157
183
 
@@ -191,8 +217,9 @@ load. Graph-building dispatches on this `kind` (see `StepConfig.kind` in
191
217
  `artifact:` + `verdicts:` (verdict-parsed routing). `max_iterations` caps
192
218
  the agent's own loop.
193
219
  - **`command: <shell>`** — a *command step*: mechanical, never an LLM call.
194
- The command runs in the workspace shell; exit 0 passes, anything else
195
- (including no exit by `timeout`) fails. `timeout:` is its only extra field.
220
+ The command runs inside the sandbox (the bind-mounted workspace); exit 0
221
+ passes, anything else (including no exit by `timeout`) fails. `timeout:` and
222
+ `setup:` are its only extra fields.
196
223
  - **`action: <name>`** — an *action step*: invokes a registered node-shaped
197
224
  function `(state, params) -> state update` with the step's `params:`. The
198
225
  built-in is `commit`; extensions register more via `registry.add_action`.
@@ -203,8 +230,8 @@ load. Graph-building dispatches on this `kind` (see `StepConfig.kind` in
203
230
 
204
231
  ### Step semantics
205
232
 
206
- - A `command` step is **mechanical** — the command runs in the workspace
207
- shell; exit 0 passes, anything else (including no exit by `timeout`,
233
+ - A `command` step is **mechanical** — the command runs inside the
234
+ sandbox; exit 0 passes, anything else (including no exit by `timeout`,
208
235
  default 600s) fails and follows `on_fail` with the output tail in the
209
236
  retry note. The kind is the `command:` key, never the step's name.
210
237
  - `artifact` + `verdicts` mark a **verdict step**. `on_pass`/`on_fail` may be
@@ -258,10 +285,10 @@ Loader-selection flow: a loader's flag in argv selects it; flags from two
258
285
  loaders in one invocation error out; with no flag, the sole `defaultable`
259
286
  loader autoruns (the bundled `ai-dlc` loader autodetects the current unit
260
287
  from `aidlc-docs/aidlc-state.md`), otherwise the run proceeds with no
261
- injected docs and the pipeline `name:` as subject. Unit ids resolve by
262
- exact directory name or by numeric prefix (`unit-004-preset-management`
263
- answers to `4`, `004`, `U4`, `U004`, or its full slug); ambiguous ids fail
264
- loud listing all matches. A selected loader that
288
+ injected docs and the pipeline `name:` as subject. A unit id must name
289
+ its directory exactly (`--aidlc-unit unit-3`); there is no numeric
290
+ fallback or spelling tolerance, and an unmatched slug fails loud listing
291
+ the available directories. A selected loader that
265
292
  yields zero docs **fails fast** — context is never silently degraded.
266
293
  Duplicate CLI option strings across loaders are a load-time error naming both.
267
294
 
@@ -303,7 +330,7 @@ The development lifecycle workflow lives in
303
330
  phase details under `aws-aidlc-rule-details/`). Working docs — plans, user
304
331
  stories, units of work, state — live in `aidlc-docs/`, created as the
305
332
  workflow runs. The bundled `ai-dlc` document loader (see Extension points)
306
- wires the pipeline's `--ai-dlc-unit` flag to those docs.
333
+ wires the pipeline's `--aidlc-unit` flag to those docs.
307
334
 
308
335
  OpenCode commands over the workflow: `/epic-status`, `/ticket-details`,
309
336
  `/unit-status`, `/update-subtask` (ticket boards over `aidlc-docs/`),
@@ -12,6 +12,17 @@ Publicly depends on [OpenHands SDK](https://github.com/OpenHands/software-agent-
12
12
  for agent execution and [LangGraph](https://github.com/langchain-ai/langgraph)
13
13
  (+ `langgraph-checkpoint-sqlite`) for the state graph.
14
14
 
15
+ ## Sandboxed execution
16
+
17
+ The agent `terminal` tool and command steps run inside an **always-on**,
18
+ Docker-based kernel-level sandbox — `--network none`, read-only rootfs,
19
+ dropped capabilities, writes only on the bind-mounted workspace. The
20
+ `isolation:` section configures the sandbox (image override, resource caps,
21
+ per-step `env:`/`setup:`, git-mutation blocking); it can never disable it. A
22
+ missing or unusable Docker engine fails fast with an `AE-SBX-*` message. See
23
+ [docs/setup.md](docs/setup.md) for the engine setup, first-run image pull, the
24
+ `pipeline.yaml` contract, stamp caching, and troubleshooting.
25
+
15
26
  ## Install & test
16
27
 
17
28
  ```bash
@@ -27,8 +38,9 @@ Requires Python ≥ 3.12.
27
38
  ```bash
28
39
  export OPENAI_API_KEY=... # whatever env var your pipeline's api_key_env names
29
40
  agentic-dev-loop agentic-dev-loop/pipeline.yaml --plan plan.md
30
- agentic-dev-loop agentic-dev-loop/pipeline.yaml --ai-dlc-unit U2
41
+ agentic-dev-loop agentic-dev-loop/pipeline.yaml --aidlc-unit unit-2
31
42
  agentic-dev-loop agentic-dev-loop/pipeline.yaml --resume <thread-id> --max-attempts 5
43
+ agentic-dev-loop agentic-dev-loop/pipeline.yaml --plan plan.md --dry-run
32
44
  ./trigger-agentic-dev-loop.sh --plan plan.md # same thing, from the repo root
33
45
  ```
34
46
 
@@ -46,10 +58,21 @@ expanded and live-tails its console. To eyeball the viz page without
46
58
  running a pipeline, `.venv/bin/python viz_demo.py` serves the real topology
47
59
  and replays a scripted run (every node state, verdict badges, retry cycle).
48
60
 
49
- `--ai-dlc-unit` accepts any unambiguous spelling of a unit number — `U2`,
50
- `2`, `U002`, `002` — or the full unit slug (`U002-custom-configuration`).
51
- If multiple unit directories match one id, the run fails loudly naming
52
- every candidate; pass the full slug to disambiguate.
61
+ `--dry-run` performs the full load-and-validate pass — YAML parse, step
62
+ config validation, extension and document-loader loading, agents_dir /
63
+ verdict / routing-target resolution, and pure image-ref resolution — without
64
+ executing anything, then prints a summary plan (pipeline path, subject,
65
+ resolved loader + docs, per-step kind and key params, and the resolved
66
+ isolation profile). It makes no LLM call, touches no Docker daemon, and
67
+ writes no state, so it needs no API key; it exits `0` on a clean load and
68
+ `1` with the usual load error otherwise.
69
+
70
+ `--aidlc-unit` takes the unit directory name under
71
+ `aidlc-docs/construction/` verbatim — `unit-2`,
72
+ `unit-002-custom-configuration`, or any slug AI-DLC's own naming uses
73
+ (AI-DLC imposes no naming convention on unit directories). No spelling
74
+ tolerance, no numeric aliases: the slug must name the directory exactly,
75
+ and a wrong slug fails loudly listing the directories that do exist.
53
76
 
54
77
  ## What a run looks like
55
78
 
@@ -67,11 +90,12 @@ dev ──▶ test ──▶ review ──▶ qa ──▶ commit
67
90
  `additional_files:` + auto-generated expected-output paths). Its writes
68
91
  are untracked; the pipeline only cares about declared `produces:` files.
69
92
  2. **test** is a mechanical command step: it runs the suite
70
- (`.venv/bin/python -m pytest tests/ -q`) in the workspace shell. Exit 0
71
- passes and moves on; anything else (including no exit by `timeout`) fails
72
- and rewinds to `dev` with the failing output in the retry note.
93
+ (`.venv-sandbox/bin/python -m pytest tests/ -q -m "not docker"`) inside the
94
+ sandbox. Exit 0 passes and moves on; anything else (including no exit by
95
+ `timeout`) fails and rewinds to `dev` with the failing output in the retry
96
+ note.
73
97
  3. **review / qa** are verdict agents: each must write its declared
74
- artifact (e.g. `.pr/qa-report.md`) whose **last** `VERDICT: <word>` line
98
+ artifact (e.g. `qa-report.md`) whose **last** `VERDICT: <word>` line
75
99
  decides routing. `pass` follows `on_pass`, `fail` follows `on_fail` —
76
100
  here `{goto: dev, retry: true}`, which consumes an attempt and feeds the
77
101
  artifact back to the dev agent as a retry note.
@@ -101,7 +125,8 @@ llm:
101
125
  api_key_env: OPENAI_API_KEY # missing env var → run fails fast, naming it
102
126
  # timeout: null # default = no client timeout (long agent runs)
103
127
 
104
- state_dir: .pr # artifacts + checkpoints live here
128
+ # state_dir omitted → default ~/.agentic-dev-loop/runs/<pipeline-hash>/
129
+ # (artifacts + checkpoints live OUTSIDE the sandbox mount)
105
130
  max_attempts: 10 # retries across the whole graph
106
131
  agents_dir: sdk_agents # consumer-owned <name>.agent.md files
107
132
 
@@ -112,8 +137,9 @@ steps:
112
137
  on_pass: test # explicit; omitted → next step in order
113
138
 
114
139
  - name: test
115
- command: .venv/bin/python -m pytest tests/ -q # mechanical: exit code is the verdict
116
- timeout: 600 # seconds; SIGKILLs the process group
140
+ command: .venv-sandbox/bin/python -m pytest tests/ -q -m "not docker" # sandboxed: exit code is the verdict
141
+ setup: python -m venv .venv-sandbox && .venv-sandbox/bin/pip install -e ".[dev]" # provision (network ON, stamp-cached)
142
+ timeout: 600 # seconds
117
143
  on_pass: review
118
144
  on_fail: {goto: dev, retry: true}
119
145
 
@@ -153,8 +179,9 @@ load. Graph-building dispatches on this `kind` (see `StepConfig.kind` in
153
179
  `artifact:` + `verdicts:` (verdict-parsed routing). `max_iterations` caps
154
180
  the agent's own loop.
155
181
  - **`command: <shell>`** — a *command step*: mechanical, never an LLM call.
156
- The command runs in the workspace shell; exit 0 passes, anything else
157
- (including no exit by `timeout`) fails. `timeout:` is its only extra field.
182
+ The command runs inside the sandbox (the bind-mounted workspace); exit 0
183
+ passes, anything else (including no exit by `timeout`) fails. `timeout:` and
184
+ `setup:` are its only extra fields.
158
185
  - **`action: <name>`** — an *action step*: invokes a registered node-shaped
159
186
  function `(state, params) -> state update` with the step's `params:`. The
160
187
  built-in is `commit`; extensions register more via `registry.add_action`.
@@ -165,8 +192,8 @@ load. Graph-building dispatches on this `kind` (see `StepConfig.kind` in
165
192
 
166
193
  ### Step semantics
167
194
 
168
- - A `command` step is **mechanical** — the command runs in the workspace
169
- shell; exit 0 passes, anything else (including no exit by `timeout`,
195
+ - A `command` step is **mechanical** — the command runs inside the
196
+ sandbox; exit 0 passes, anything else (including no exit by `timeout`,
170
197
  default 600s) fails and follows `on_fail` with the output tail in the
171
198
  retry note. The kind is the `command:` key, never the step's name.
172
199
  - `artifact` + `verdicts` mark a **verdict step**. `on_pass`/`on_fail` may be
@@ -220,10 +247,10 @@ Loader-selection flow: a loader's flag in argv selects it; flags from two
220
247
  loaders in one invocation error out; with no flag, the sole `defaultable`
221
248
  loader autoruns (the bundled `ai-dlc` loader autodetects the current unit
222
249
  from `aidlc-docs/aidlc-state.md`), otherwise the run proceeds with no
223
- injected docs and the pipeline `name:` as subject. Unit ids resolve by
224
- exact directory name or by numeric prefix (`unit-004-preset-management`
225
- answers to `4`, `004`, `U4`, `U004`, or its full slug); ambiguous ids fail
226
- loud listing all matches. A selected loader that
250
+ injected docs and the pipeline `name:` as subject. A unit id must name
251
+ its directory exactly (`--aidlc-unit unit-3`); there is no numeric
252
+ fallback or spelling tolerance, and an unmatched slug fails loud listing
253
+ the available directories. A selected loader that
227
254
  yields zero docs **fails fast** — context is never silently degraded.
228
255
  Duplicate CLI option strings across loaders are a load-time error naming both.
229
256
 
@@ -265,7 +292,7 @@ The development lifecycle workflow lives in
265
292
  phase details under `aws-aidlc-rule-details/`). Working docs — plans, user
266
293
  stories, units of work, state — live in `aidlc-docs/`, created as the
267
294
  workflow runs. The bundled `ai-dlc` document loader (see Extension points)
268
- wires the pipeline's `--ai-dlc-unit` flag to those docs.
295
+ wires the pipeline's `--aidlc-unit` flag to those docs.
269
296
 
270
297
  OpenCode commands over the workflow: `/epic-status`, `/ticket-details`,
271
298
  `/unit-status`, `/update-subtask` (ticket boards over `aidlc-docs/`),
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentic-dev-loop"
7
- version = "0.2.0"
7
+ version = "1.0.0a1"
8
8
  description = "Opinionated LangGraph engine for OpenHands agent pipelines"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -57,3 +57,8 @@ where = ["src"]
57
57
 
58
58
  [tool.setuptools.package-data]
59
59
  agentic_dev_loop = ["py.typed"]
60
+
61
+ [tool.pytest.ini_options]
62
+ markers = [
63
+ "docker: real-Docker integration tests (require a running Linux daemon)",
64
+ ]
@@ -30,6 +30,15 @@ def _git(args: list[str], cwd: Path) -> subprocess.CompletedProcess:
30
30
  env=child_env())
31
31
 
32
32
 
33
+ def _trusted_hooks_dir() -> str:
34
+ """The agentic-dev-loop-managed hooks dir (Q3): repo hooks in ``.git/hooks``
35
+ never execute during the trusted commit action. Created empty; git runs
36
+ with ``-c core.hooksPath=<this dir>`` so there is no hook to run."""
37
+ d = Path("~/.agentic-dev-loop/state/hooks").expanduser()
38
+ d.mkdir(parents=True, exist_ok=True)
39
+ return str(d)
40
+
41
+
33
42
  def git_worktree_clean(path: Path) -> bool:
34
43
  """True only when `path` is a git repo with no uncommitted changes.
35
44
  Outside a repo there is no clean baseline — False (fail-safe, keeps the
@@ -155,6 +164,28 @@ def kill_listeners_on_port(port: int, match: str | None = None,
155
164
  shell=False, env=child_env())
156
165
 
157
166
 
167
+ def checkpoint_exclude_pathspec(state_dir: str | None, cwd: Path) -> str | None:
168
+ """git pathspec excluding the live checkpoint DB from ``git add -A``.
169
+
170
+ The checkpointer keeps ``state_dir/loop-checkpoints.sqlite`` open for the
171
+ whole run; on Windows an open SQLite file blocks other readers, so
172
+ ``git add`` fails to hash it (CI-red gate failure). When the state dir
173
+ sits inside the repo, exclude its sqlite files (db + wal/shm) from the
174
+ auto-commit — checkpoint state is runtime machinery, not constructed
175
+ work. Returns None when there is nothing in-repo to exclude (no state
176
+ dir known, or it lives outside the repo).
177
+ """
178
+ if not state_dir:
179
+ return None
180
+ rel = os.path.relpath(state_dir, cwd)
181
+ if rel == ".." or rel.startswith(".." + os.sep):
182
+ return None
183
+ rel = rel.replace(os.sep, "/")
184
+ if rel == ".":
185
+ return ":(exclude)*.sqlite*"
186
+ return f":(exclude){rel}/*.sqlite*"
187
+
188
+
158
189
  def commit(state: dict, params: dict) -> dict:
159
190
  """The pipeline's only commit point. Skipped loudly when the worktree was
160
191
  dirty at run start, so the auto-commit can never swallow pre-existing
@@ -172,12 +203,24 @@ def commit(state: dict, params: dict) -> dict:
172
203
  raise ValueError("commit action requires params.message "
173
204
  "(a template rendered with {subject})")
174
205
  cwd = Path.cwd()
175
- _git(["add", "-A"], cwd=cwd)
206
+ hooks = _trusted_hooks_dir()
207
+ add_argv = ["-c", f"core.hooksPath={hooks}", "add", "-A"]
208
+ exclude = checkpoint_exclude_pathspec(state.get("state_dir"), cwd)
209
+ if exclude:
210
+ add_argv += ["--", exclude]
211
+ added = _git(add_argv, cwd=cwd)
212
+ if added.returncode != 0:
213
+ # The add is the step that touches the most files (a locked DB, a
214
+ # permission problem) — its error is the diagnostic that matters.
215
+ print(f"WARNING: [{subject}] auto-commit failed — git add: "
216
+ f"{added.stderr.strip()}", file=sys.stderr, flush=True)
217
+ return {}
176
218
  if not _git(["status", "--porcelain"], cwd=cwd).stdout.strip():
177
219
  print(f"[{subject}] Nothing to commit.", flush=True)
178
220
  return {}
179
221
  message = params["message"].format(subject=subject)
180
- result = _git(["commit", "-m", message], cwd=cwd)
222
+ result = _git(["-c", f"core.hooksPath={hooks}", "commit", "-m", message],
223
+ cwd=cwd)
181
224
  if result.returncode != 0:
182
225
  # The work is built and verified — a git error is a loud warning,
183
226
  # not a pipeline failure.
@@ -0,0 +1,125 @@
1
+ """Agent file loading + SDK factory mechanics.
2
+
3
+ The SDK's built-in tools are registered at import (its registry is global);
4
+ consumer tools are registered by extensions via `Registry.add_tool`. Unit-3
5
+ adds the sandbox terminal seam: `SandboxTerminalExecutor` delegates each
6
+ terminal action to a `Sandbox.run`, and `register_sandbox_terminal` re-registers
7
+ the `terminal` tool name per run (KD-4 / §6)."""
8
+
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ from openhands.sdk import LLM
13
+ from openhands.sdk.tool import ToolExecutor
14
+ from openhands.tools.file_editor import FileEditorTool # noqa: F401 — registers "file_editor"
15
+ from openhands.tools.task_tracker import TaskTrackerTool # noqa: F401 — registers "task_tracker"
16
+ from openhands.tools.terminal import TerminalTool # noqa: F401 — registers "terminal"
17
+
18
+ from .isolation import SandboxExecutionRequest, command_argv, resolve_sandbox_env
19
+
20
+ if TYPE_CHECKING:
21
+ from openhands.sdk.agent.agent import Agent
22
+
23
+ from .isolation import Sandbox
24
+
25
+
26
+ def load_agent(agents_dir: Path | str, name: str, llm: LLM) -> "Agent":
27
+ """Load `<agents_dir>/<name>.agent.md` and build the Agent via the SDK
28
+ factory (frontmatter tool names resolved against the registry, unknown →
29
+ ValueError; the markdown body is delivered as the system prompt suffix).
30
+
31
+ Tool surface (BR-1): the frontmatter `tools:` list is the exact
32
+ capability allowlist — every name must resolve to a registered SDK tool.
33
+ `FinishTool` (done signal) and `ThinkTool` (reasoning scaffold) are added
34
+ by the SDK as default infra, never part of the allowlist and never
35
+ removable via `tools:`. There is no regex or default-tools knob.
36
+
37
+ Two pipeline policies on top of the SDK's native AgentDefinition loader:
38
+ the file must exist (fail fast — the SDK's dir loader would silently
39
+ skip it) and its frontmatter name must match its filename. A file with
40
+ no frontmatter or no name gets the SDK's stem fallback ("<name>.agent"),
41
+ which can never match the stem — so both degenerate cases fail the name
42
+ check."""
43
+ from openhands.sdk.subagent.registry import agent_definition_to_factory
44
+ from openhands.sdk.subagent.schema import AgentDefinition
45
+
46
+ path = Path(agents_dir) / f"{name}.agent.md"
47
+ if not path.is_file():
48
+ raise FileNotFoundError(f"Agent file not found: {path}")
49
+
50
+ agent_def = AgentDefinition.load(path)
51
+
52
+ stem = path.name.removesuffix(".agent.md")
53
+ if agent_def.name != stem:
54
+ raise ValueError(
55
+ f"Agent file {path} name {agent_def.name!r} does not match "
56
+ f"filename {stem!r}")
57
+ return agent_definition_to_factory(agent_def)(llm)
58
+
59
+
60
+ class SandboxTerminalExecutor(ToolExecutor):
61
+ """A stateless terminal executor: each action is a fresh ``Sandbox.run``
62
+ (no persistent shell session). ``is_input`` degrades to an unsupported
63
+ observation; ``reset`` is a no-op (there is no session to reset)."""
64
+
65
+ def __init__(self, sandbox: "Sandbox", *, workspace: str = "",
66
+ env: dict[str, str] | None = None,
67
+ pipeline: str | None = None, step: str | None = None):
68
+ self._sandbox = sandbox
69
+ self._workspace = workspace
70
+ self._env = env or {}
71
+ self._pipeline = pipeline
72
+ self._step = step
73
+
74
+ def __call__(self, action, conversation=None):
75
+ from openhands.tools.terminal import TerminalObservation
76
+ from openhands.tools.terminal.metadata import CmdOutputMetadata
77
+
78
+ if action.reset:
79
+ return TerminalObservation.from_text(
80
+ "[reset unsupported — stateless sandbox]",
81
+ command=action.command, exit_code=None, timeout=False,
82
+ metadata=CmdOutputMetadata(exit_code=-1))
83
+
84
+ if action.is_input:
85
+ return TerminalObservation.from_text(
86
+ "[input unsupported — stateless sandbox]",
87
+ command=action.command, exit_code=None, timeout=False,
88
+ metadata=CmdOutputMetadata(exit_code=-1))
89
+
90
+ request = SandboxExecutionRequest(
91
+ argv=command_argv(action.command), cwd=self._workspace,
92
+ env=self._env, timeout=action.timeout,
93
+ pipeline=self._pipeline, step=self._step)
94
+ result = self._sandbox.run(request)
95
+ output = result.stdout + result.stderr
96
+ return TerminalObservation.from_text(
97
+ output, is_error=not result.succeeded and not result.is_timeout,
98
+ command=action.command, exit_code=result.exit_code,
99
+ timeout=result.timed_out,
100
+ metadata=CmdOutputMetadata(
101
+ exit_code=result.exit_code if result.exit_code is not None else -1,
102
+ working_dir=self._workspace))
103
+
104
+ def close(self) -> None:
105
+ pass # the sandbox is closed by Runtime; idempotent
106
+
107
+
108
+ def register_sandbox_terminal(sandbox: "Sandbox", *, env: dict[str, str | None] | None = None,
109
+ workspace: str = "", pipeline: str | None = None,
110
+ step: str | None = None) -> None:
111
+ """Per-run re-registration of the `terminal` name (KD-4): a callable
112
+ factory that builds the SDK terminal with a `SandboxTerminalExecutor`.
113
+ Pipelines are sequential, so re-registering per agent step is safe."""
114
+ from openhands.sdk.tool import register_tool
115
+
116
+ resolved_env = resolve_sandbox_env(env)
117
+
118
+ def factory(conv_state, **params):
119
+ return TerminalTool.create(
120
+ conv_state,
121
+ executor=SandboxTerminalExecutor(
122
+ sandbox, workspace=workspace, env=resolved_env,
123
+ pipeline=pipeline, step=step))
124
+
125
+ register_tool("terminal", factory)
@@ -10,7 +10,8 @@ import sys
10
10
  from pathlib import Path
11
11
 
12
12
  from .config import load_config
13
- from .graph import run_pipeline
13
+ from .dryrun import dry_run
14
+ from .isolation import clean_stamps, isolation_state_dir
14
15
  from .registry import default_registry, load_extensions
15
16
 
16
17
 
@@ -49,6 +50,15 @@ def _core_parser() -> argparse.ArgumentParser:
49
50
  help="preferred port for the live graph view "
50
51
  "(default: 8321; falls back to a free port when "
51
52
  "busy)")
53
+ parser.add_argument("--refresh", action="store_true",
54
+ help="force re-provision of every setup: hook "
55
+ "(bypasses the stamp cache)")
56
+ parser.add_argument("--dry-run", action="store_true",
57
+ help="validate the pipeline and print a summary plan "
58
+ "without executing anything (no LLM, no "
59
+ "sandbox, no state writes)")
60
+ parser.add_argument("--clean-stamps", action="store_true",
61
+ help="delete cached provision stamps and exit")
52
62
  _add_version_flag(parser)
53
63
  return parser
54
64
 
@@ -75,6 +85,18 @@ def main(argv: list[str] | None = None) -> int:
75
85
  _core_parser().parse_args(argv)
76
86
  return 2 # unreachable — parse_args exits first
77
87
  args = build_parser(known.pipeline).parse_args(argv)
88
+ if args.clean_stamps:
89
+ cfg = load_config(args.pipeline)
90
+ state = isolation_state_dir(cfg)
91
+ removed = clean_stamps(state)
92
+ print(f"Removed {removed} cached provision stamp(s) from "
93
+ f"{state / 'stamps'}", flush=True)
94
+ return 0
95
+ if args.dry_run:
96
+ return dry_run(args.pipeline, args)
97
+ # The run path alone needs the heavy graph/SDK imports — defer so a
98
+ # dry run, --clean-stamps, and --help stay side-effect free.
99
+ from .graph import run_pipeline
78
100
  success, _attempts = run_pipeline(args.pipeline, args)
79
101
  if not success:
80
102
  print("agentic-dev-loop: pipeline failed — exiting with code 1",
@@ -1,5 +1,9 @@
1
1
  """Pipeline YAML schema and loader. Strict: unknown keys fail at every level."""
2
2
 
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
3
7
  from pathlib import Path
4
8
  from typing import Any
5
9
 
@@ -32,6 +36,36 @@ def route_target(route: RouteTarget | None) -> str | None:
32
36
  return route.goto if isinstance(route, Route) else route
33
37
 
34
38
 
39
+ class IsolationConfig(BaseModel):
40
+ """Optional `isolation:` section — configures the always-on sandbox (BR-2).
41
+ Absence means opinionated defaults, never unsandboxed behaviour. Unknown
42
+ keys fail at load (BR-5). `state_dir` is HOST-ONLY (stamps + audit) and is
43
+ never mounted into the sandbox (Q1)."""
44
+
45
+ model_config = ConfigDict(extra="forbid")
46
+
47
+ image: str | None = None # per-pipeline override (KD-2 ref rules)
48
+ refresh: bool = False # force re-provision (NFR-5)
49
+ memory: str | None = None # override exec container memory
50
+ cpus: float | None = None # override exec container cpus
51
+ allow_git_mutations: bool = False # BR-6 knob
52
+ state_dir: str = "~/.agentic-dev-loop/state" # stamps + audit (host-only)
53
+
54
+ @field_validator("memory")
55
+ @classmethod
56
+ def _check_memory(cls, v: str | None) -> str | None:
57
+ if v is not None and re.fullmatch(r"\d+[kmg]", v) is None:
58
+ raise ValueError(f"memory {v!r} must match ^\\d+[kmg]$")
59
+ return v
60
+
61
+ @field_validator("cpus")
62
+ @classmethod
63
+ def _check_cpus(cls, v: float | None) -> float | None:
64
+ if v is not None and v <= 0:
65
+ raise ValueError("cpus must be > 0")
66
+ return v
67
+
68
+
35
69
  class StepConfig(BaseModel):
36
70
  """One pipeline step. Its type IS the key it carries: exactly one of
37
71
  `agent`, `action`, or `command` — empty values do not count, anything
@@ -49,6 +83,8 @@ class StepConfig(BaseModel):
49
83
  verdicts: VerdictSpec | None = None
50
84
  max_iterations: int | None = None
51
85
  timeout: float | None = Field(default=None, gt=0) # seconds, command steps only
86
+ env: dict[str, str | None] | None = None # NFR-1: null = inherit host value
87
+ setup: str | None = None # BR-13: command steps only
52
88
  on_pass: RouteTarget | None = None
53
89
  on_fail: RouteTarget | None = None
54
90
 
@@ -102,6 +138,10 @@ class StepConfig(BaseModel):
102
138
  raise ValueError(
103
139
  f"step {self.name!r}: 'timeout' requires 'command'"
104
140
  )
141
+ if self.setup is not None and self.command is None:
142
+ raise ValueError(
143
+ f"step {self.name!r}: 'setup' is only allowed on 'command' steps"
144
+ )
105
145
  return self
106
146
 
107
147
 
@@ -122,7 +162,8 @@ class PipelineConfig(BaseModel):
122
162
  additional_files: list[str] = []
123
163
  env_allowlist: list[str] = []
124
164
  llm: LlmConfig
125
- state_dir: str = ".pr"
165
+ isolation: IsolationConfig | None = None # None = always-on defaults
166
+ state_dir: str = ".pr" # default relocated to ~/.agentic-dev-loop/runs/<hash>/ at load
126
167
  max_attempts: int = 3
127
168
  agents_dir: str
128
169
  steps: list[StepConfig] = Field(min_length=1)
@@ -147,10 +188,23 @@ class PipelineConfig(BaseModel):
147
188
  return self
148
189
 
149
190
 
191
+ def default_state_dir(pipeline_path: Path) -> str:
192
+ """The new run-state default (Q1): checkpoint DB + last-error.md live OUTSIDE
193
+ the workspace mount, under ~/.agentic-dev-loop/runs/<pipeline-hash>/ where
194
+ pipeline-hash is the first 12 chars of the sha256 of the absolute
195
+ pipeline-file path. Explicit `state_dir:` configs keep working unchanged."""
196
+ digest = hashlib.sha256(
197
+ str(Path(pipeline_path).resolve()).encode("utf-8")).hexdigest()[:12]
198
+ return str(Path("~/.agentic-dev-loop/runs").expanduser() / digest)
199
+
200
+
150
201
  def load_config(path: Path) -> PipelineConfig:
151
- data = yaml.safe_load(Path(path).read_text())
202
+ path = Path(path)
203
+ data = yaml.safe_load(path.read_text())
152
204
  if not isinstance(data, dict):
153
205
  raise ValueError(
154
206
  f"pipeline config must be a mapping, got {type(data).__name__}: {path}"
155
207
  )
208
+ if "state_dir" not in data:
209
+ data["state_dir"] = default_state_dir(path)
156
210
  return PipelineConfig.model_validate(data)