agentic-dev-loop 1.0.0a10__tar.gz → 1.0.0a11__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.

Potentially problematic release.


This version of agentic-dev-loop might be problematic. Click here for more details.

Files changed (69) hide show
  1. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/PKG-INFO +74 -1
  2. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/README.md +72 -0
  3. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/pyproject.toml +2 -1
  4. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop/core/config.py +30 -0
  5. agentic_dev_loop-1.0.0a11/src/agentic_dev_loop/engine/deps.py +77 -0
  6. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop/engine/stages.py +21 -2
  7. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop.egg-info/PKG-INFO +74 -1
  8. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop.egg-info/SOURCES.txt +2 -0
  9. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop.egg-info/requires.txt +1 -0
  10. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/tests/test_cli.py +4 -0
  11. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/tests/test_config.py +35 -0
  12. agentic_dev_loop-1.0.0a11/tests/test_deps.py +76 -0
  13. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/tests/test_dry_run.py +1 -0
  14. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/tests/test_graph.py +38 -0
  15. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/tests/test_hostboundary.py +1 -0
  16. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/tests/test_hostboundary_integration.py +1 -0
  17. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/tests/test_isolation.py +2 -0
  18. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/tests/test_secrets.py +1 -0
  19. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/tests/test_secrets_pipeline.py +2 -0
  20. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/tests/test_stages.py +52 -1
  21. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/tests/test_viz.py +1 -0
  22. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/LICENSE +0 -0
  23. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/setup.cfg +0 -0
  24. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop/__init__.py +0 -0
  25. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop/core/__init__.py +0 -0
  26. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop/core/results.py +0 -0
  27. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop/core/verdict.py +0 -0
  28. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop/engine/__init__.py +0 -0
  29. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop/engine/actions.py +0 -0
  30. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop/engine/agents.py +0 -0
  31. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop/engine/cli.py +0 -0
  32. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  33. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  34. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  35. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop/engine/graph.py +0 -0
  36. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  37. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop/engine/isolation.py +0 -0
  38. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop/engine/llm.py +0 -0
  39. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop/engine/loaders.py +0 -0
  40. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop/engine/outputs.py +0 -0
  41. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop/engine/proc.py +0 -0
  42. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop/engine/registry.py +0 -0
  43. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop/engine/secrets.py +0 -0
  44. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop/py.typed +0 -0
  45. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop/viz/__init__.py +0 -0
  46. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop/viz/__main__.py +0 -0
  47. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop/viz/server.py +0 -0
  48. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  49. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  50. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  51. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/tests/test_actions.py +0 -0
  52. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/tests/test_agents.py +0 -0
  53. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/tests/test_aidlc.py +0 -0
  54. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/tests/test_e2e_fixtures.py +0 -0
  55. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/tests/test_isolation_integration.py +0 -0
  56. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/tests/test_isolation_pbt.py +0 -0
  57. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/tests/test_llm.py +0 -0
  58. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/tests/test_loaders.py +0 -0
  59. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/tests/test_module_contract.py +0 -0
  60. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/tests/test_outputs.py +0 -0
  61. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/tests/test_outputs_pbt.py +0 -0
  62. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/tests/test_proc.py +0 -0
  63. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/tests/test_publish.py +0 -0
  64. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/tests/test_registry.py +0 -0
  65. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/tests/test_results.py +0 -0
  66. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/tests/test_sdk_compat.py +0 -0
  67. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/tests/test_secrets_integration.py +0 -0
  68. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/tests/test_source_resolution.py +0 -0
  69. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a11}/tests/test_verdict.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a10
3
+ Version: 1.0.0a11
4
4
  Summary: Turn an LLM into a gated, sandboxed, resumable construction loop over your codebase.
5
5
  Author: Luke Wilson
6
6
  License-Expression: Apache-2.0
@@ -35,6 +35,7 @@ Requires-Dist: langgraph-checkpoint-sqlite>=3.0
35
35
  Provides-Extra: dev
36
36
  Requires-Dist: hypothesis>=6.0; extra == "dev"
37
37
  Requires-Dist: pytest>=8.0; extra == "dev"
38
+ Requires-Dist: pip-audit>=2.0; extra == "dev"
38
39
  Dynamic: license-file
39
40
 
40
41
  # agentic-dev-loop
@@ -213,6 +214,8 @@ steps:
213
214
  | `isolation:` (optional) | Configures the sandbox — image override, resource caps, `allow_git_mutations`. It can never disable it. |
214
215
  | `state_dir` | Defaults to `~/.agentic-dev-loop/runs/<pipeline-hash>/` — artifacts and checkpoints live outside the sandbox mount. |
215
216
  | `extensions_dir` | Where consumer `extensions:` modules resolve — defaults to `~/.agentic-dev-loop/extensions`, **outside** the workspace. Workspace-resident extension/tool modules are refused at load. |
217
+ | `deps_policy` | `locked` (default) or `open`. `locked` = the human-owned lockfile is the source of truth and the agent may not add dependencies; `open` = the bootstrap protocol (the agent may add exact-pinned manifest entries). See [Dependency policy](#dependency-policy-locked-by-default). |
218
+ | `lockfiles` | The pipeline's declared lockfile names (workspace-relative), injected into agent context and named by the missing-dependency fail-fast. Required (non-empty) when `deps_policy: locked` — a `locked` pipeline with no lockfiles fails fast at load. |
216
219
 
217
220
  ### Secrets (`secrets:`) — refs only, never values
218
221
 
@@ -278,6 +281,76 @@ steps:
278
281
  for stages that need it (QA, integration). The residual risk is documented
279
282
  in [docs/setup.md](docs/setup.md).
280
283
 
284
+ ## Dependency policy (locked by default)
285
+
286
+ The supply-chain dial is a pipeline-level `deps_policy:` field, **locked by
287
+ default**. The engine injects the policy into every agent step's context —
288
+ the rule lives in the engine, not in hand-authored `dev.agent.md` prose (the
289
+ class of rule that deadlocked the NuGet cache).
290
+
291
+ ```yaml
292
+ deps_policy: locked # or: open (default: locked)
293
+ lockfiles: [uv.lock] # or: [packages.lock.json, package-lock.json]
294
+ ```
295
+
296
+ - **`locked`** — the human-owned lockfile is the source of truth; the agent
297
+ may not add dependencies. The injected rule says *"You may only use
298
+ dependencies already present in `<lockfile>`. If the design requires a
299
+ dependency that is not present, declare it in your final response under the
300
+ fixed header `## Missing dependencies` (one `name==version` per line) and
301
+ finish what you can. Do not add it to the manifest."* A non-empty
302
+ `## Missing dependencies` declaration **fails the run fast** with
303
+ `Extend <lockfile> with these pins and re-run: …` — one human lockfile edit,
304
+ not a retry loop. The declaration is advisory to the human; the engine never
305
+ auto-fetches the declared packages.
306
+ - **`open`** — the bootstrap protocol (018): the agent may add exact-pinned
307
+ manifest entries directly (never a package-manager `add` command); the
308
+ network-on restore step fetches them. The engine injects the same rule
309
+ canonically. Nothing about `open` is default.
310
+
311
+ The **restore step** runs the toolchain's locked mode and fails when the
312
+ manifest and lockfile disagree:
313
+
314
+ | Toolchain | Locked restore | Lockfile |
315
+ | --- | --- | --- |
316
+ | .NET | `dotnet restore --locked-mode` | `packages.lock.json` (set `RestorePackagesWithLockFile=true`) |
317
+ | node | `npm ci` | `package-lock.json` |
318
+ | python | `uv sync --locked` (or pip-tools `pip-sync requirements.lock`) | `uv.lock` — pip has no native lockfile; `uv` is the canonical locked-install tool |
319
+
320
+ ### Audit escalation in the restore step
321
+
322
+ The restore step also fails on known-CVE findings, at a per-toolchain
323
+ threshold:
324
+
325
+ | Toolchain | Audit (fails the step) |
326
+ | --- | --- |
327
+ | .NET | `NuGetAudit` at `error` — any audit finding fails the restore |
328
+ | node | `npm audit --audit-level=high` |
329
+ | python | `pip-audit` — any finding fails |
330
+
331
+ Under `locked`, a failing audit means the lockfile pins a vulnerable version:
332
+ the run fails and the human regenerates the lockfile at a patched version.
333
+ Under `open`, the failure routes to `dev` like any restore failure — the dev
334
+ agent can bump the pin, but that pin is audited again on the next pass, so
335
+ the loop self-corrects.
336
+
337
+ ### Generating a lockfile
338
+
339
+ A human generates the lockfile once, on a network machine, and commits it —
340
+ the engine never fetches on the agent's behalf:
341
+
342
+ ```bash
343
+ uv lock # python — writes uv.lock from pyproject.toml
344
+ uv sync --locked # installs exactly uv.lock; fails on any drift
345
+ npm install --package-lock-only # node — writes package-lock.json
346
+ npm ci # installs exactly package-lock.json
347
+ dotnet restore --use-lock-file # .NET — writes packages.lock.json
348
+ dotnet restore --locked-mode # installs exactly packages.lock.json
349
+ ```
350
+
351
+ Keep the lockfile tracked (do not gitignore it) and re-run the locked restore
352
+ to prove it holds from a clean cache before setting `deps_policy: locked`.
353
+
281
354
  ## Host boundary (security model)
282
355
 
283
356
  The orchestrator, tool layer, and the `commit` action run in the **host
@@ -174,6 +174,8 @@ steps:
174
174
  | `isolation:` (optional) | Configures the sandbox — image override, resource caps, `allow_git_mutations`. It can never disable it. |
175
175
  | `state_dir` | Defaults to `~/.agentic-dev-loop/runs/<pipeline-hash>/` — artifacts and checkpoints live outside the sandbox mount. |
176
176
  | `extensions_dir` | Where consumer `extensions:` modules resolve — defaults to `~/.agentic-dev-loop/extensions`, **outside** the workspace. Workspace-resident extension/tool modules are refused at load. |
177
+ | `deps_policy` | `locked` (default) or `open`. `locked` = the human-owned lockfile is the source of truth and the agent may not add dependencies; `open` = the bootstrap protocol (the agent may add exact-pinned manifest entries). See [Dependency policy](#dependency-policy-locked-by-default). |
178
+ | `lockfiles` | The pipeline's declared lockfile names (workspace-relative), injected into agent context and named by the missing-dependency fail-fast. Required (non-empty) when `deps_policy: locked` — a `locked` pipeline with no lockfiles fails fast at load. |
177
179
 
178
180
  ### Secrets (`secrets:`) — refs only, never values
179
181
 
@@ -239,6 +241,76 @@ steps:
239
241
  for stages that need it (QA, integration). The residual risk is documented
240
242
  in [docs/setup.md](docs/setup.md).
241
243
 
244
+ ## Dependency policy (locked by default)
245
+
246
+ The supply-chain dial is a pipeline-level `deps_policy:` field, **locked by
247
+ default**. The engine injects the policy into every agent step's context —
248
+ the rule lives in the engine, not in hand-authored `dev.agent.md` prose (the
249
+ class of rule that deadlocked the NuGet cache).
250
+
251
+ ```yaml
252
+ deps_policy: locked # or: open (default: locked)
253
+ lockfiles: [uv.lock] # or: [packages.lock.json, package-lock.json]
254
+ ```
255
+
256
+ - **`locked`** — the human-owned lockfile is the source of truth; the agent
257
+ may not add dependencies. The injected rule says *"You may only use
258
+ dependencies already present in `<lockfile>`. If the design requires a
259
+ dependency that is not present, declare it in your final response under the
260
+ fixed header `## Missing dependencies` (one `name==version` per line) and
261
+ finish what you can. Do not add it to the manifest."* A non-empty
262
+ `## Missing dependencies` declaration **fails the run fast** with
263
+ `Extend <lockfile> with these pins and re-run: …` — one human lockfile edit,
264
+ not a retry loop. The declaration is advisory to the human; the engine never
265
+ auto-fetches the declared packages.
266
+ - **`open`** — the bootstrap protocol (018): the agent may add exact-pinned
267
+ manifest entries directly (never a package-manager `add` command); the
268
+ network-on restore step fetches them. The engine injects the same rule
269
+ canonically. Nothing about `open` is default.
270
+
271
+ The **restore step** runs the toolchain's locked mode and fails when the
272
+ manifest and lockfile disagree:
273
+
274
+ | Toolchain | Locked restore | Lockfile |
275
+ | --- | --- | --- |
276
+ | .NET | `dotnet restore --locked-mode` | `packages.lock.json` (set `RestorePackagesWithLockFile=true`) |
277
+ | node | `npm ci` | `package-lock.json` |
278
+ | python | `uv sync --locked` (or pip-tools `pip-sync requirements.lock`) | `uv.lock` — pip has no native lockfile; `uv` is the canonical locked-install tool |
279
+
280
+ ### Audit escalation in the restore step
281
+
282
+ The restore step also fails on known-CVE findings, at a per-toolchain
283
+ threshold:
284
+
285
+ | Toolchain | Audit (fails the step) |
286
+ | --- | --- |
287
+ | .NET | `NuGetAudit` at `error` — any audit finding fails the restore |
288
+ | node | `npm audit --audit-level=high` |
289
+ | python | `pip-audit` — any finding fails |
290
+
291
+ Under `locked`, a failing audit means the lockfile pins a vulnerable version:
292
+ the run fails and the human regenerates the lockfile at a patched version.
293
+ Under `open`, the failure routes to `dev` like any restore failure — the dev
294
+ agent can bump the pin, but that pin is audited again on the next pass, so
295
+ the loop self-corrects.
296
+
297
+ ### Generating a lockfile
298
+
299
+ A human generates the lockfile once, on a network machine, and commits it —
300
+ the engine never fetches on the agent's behalf:
301
+
302
+ ```bash
303
+ uv lock # python — writes uv.lock from pyproject.toml
304
+ uv sync --locked # installs exactly uv.lock; fails on any drift
305
+ npm install --package-lock-only # node — writes package-lock.json
306
+ npm ci # installs exactly package-lock.json
307
+ dotnet restore --use-lock-file # .NET — writes packages.lock.json
308
+ dotnet restore --locked-mode # installs exactly packages.lock.json
309
+ ```
310
+
311
+ Keep the lockfile tracked (do not gitignore it) and re-run the locked restore
312
+ to prove it holds from a clean cache before setting `deps_policy: locked`.
313
+
242
314
  ## Host boundary (security model)
243
315
 
244
316
  The orchestrator, tool layer, and the `commit` action run in the **host
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentic-dev-loop"
7
- version = "1.0.0a10"
7
+ version = "1.0.0a11"
8
8
  description = "Turn an LLM into a gated, sandboxed, resumable construction loop over your codebase."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -55,6 +55,7 @@ agentic-dev-loop = "agentic_dev_loop.engine.cli:main"
55
55
  dev = [
56
56
  "hypothesis>=6.0",
57
57
  "pytest>=8.0",
58
+ "pip-audit>=2.0",
58
59
  ]
59
60
 
60
61
  [tool.setuptools.packages.find]
@@ -214,6 +214,27 @@ class PipelineConfig(BaseModel):
214
214
  max_attempts: int = 3
215
215
  agents_dir: str
216
216
  steps: list[StepConfig] = Field(min_length=1)
217
+ # 021 supply-chain dependency dial: `locked` (default) = the human-owned
218
+ # lockfile is the source of truth and the agent may not add dependencies;
219
+ # `open` = the bootstrap protocol (the agent may add exact-pinned manifest
220
+ # entries for the restore step to fetch). `lockfiles` names the pipeline's
221
+ # declared lockfile(s) — injected into agent context and named by the
222
+ # missing-dependency fail-fast.
223
+ deps_policy: str = "locked"
224
+ lockfiles: list[str] = []
225
+
226
+ @field_validator("deps_policy")
227
+ @classmethod
228
+ def _known_deps_policy(cls, v: str) -> str:
229
+ if v not in ("locked", "open"):
230
+ raise ValueError(
231
+ f"deps_policy must be 'locked' or 'open', got {v!r}")
232
+ return v
233
+
234
+ @field_validator("lockfiles", mode="before")
235
+ @classmethod
236
+ def _coerce_lockfiles(cls, v: Any) -> Any:
237
+ return [v] if isinstance(v, str) else v
217
238
 
218
239
  @field_validator("secrets", mode="before")
219
240
  @classmethod
@@ -232,6 +253,15 @@ class PipelineConfig(BaseModel):
232
253
  "values never belong in the pipeline yaml")
233
254
  return v
234
255
 
256
+ @model_validator(mode="after")
257
+ def _locked_requires_lockfiles(self) -> "PipelineConfig":
258
+ if self.deps_policy == "locked" and not self.lockfiles:
259
+ raise ValueError(
260
+ "deps_policy 'locked' requires at least one entry in "
261
+ "'lockfiles:' — name the lockfile(s) the locked boundary "
262
+ "enforces, or set 'deps_policy: open'")
263
+ return self
264
+
235
265
  @model_validator(mode="after")
236
266
  def _check_graph(self) -> "PipelineConfig":
237
267
  names = [s.name for s in self.steps]
@@ -0,0 +1,77 @@
1
+ """Supply-chain dependency dial (021): canonical policy injection and
2
+ missing-dependency declaration parsing.
3
+
4
+ The rule text lives HERE, in the engine, not in consumer `dev.agent.md` prose
5
+ — the hand-authored rule is exactly the class that deadlocked the NuGet cache
6
+ in 018. `deps_policy: locked` (the default) bounds the agent to the
7
+ human-owned lockfile and fails a run fast when an agent declares a missing
8
+ dependency; `deps_policy: open` injects the bootstrap protocol (the agent may
9
+ add exact-pinned manifest entries for the restore step to fetch).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import TYPE_CHECKING
15
+
16
+ if TYPE_CHECKING:
17
+ from agentic_dev_loop.core.config import PipelineConfig
18
+
19
+ MISSING_DEPS_HEADER = "## Missing dependencies"
20
+
21
+ _LOCKED_TEMPLATE = (
22
+ "You may only use dependencies already present in {lockfiles}. If the "
23
+ "design requires a dependency that is not present, declare it in your "
24
+ "final response under the fixed header `{header}` (one `name==version` "
25
+ "per line) and finish what you can. Do not add it to the manifest."
26
+ )
27
+
28
+ _OPEN_TEXT = (
29
+ "If the design needs a dependency that is not already present, add the "
30
+ "exact-pinned entry to the dependency manifest directly (do not run a "
31
+ "package-manager `add` command — it may need the network). Your offline "
32
+ "builds may fail until the restore step fetches it — that is expected; "
33
+ "write the code to match and leave the manifest entry in place."
34
+ )
35
+
36
+
37
+ def _lockfile_label(cfg: "PipelineConfig") -> str:
38
+ if cfg.lockfiles:
39
+ return ", ".join(f"`{name}`" for name in cfg.lockfiles)
40
+ return "the lockfile"
41
+
42
+
43
+ def policy_injection(cfg: "PipelineConfig") -> str:
44
+ """The canonical dependency-policy paragraph injected into every agent
45
+ step's context, keyed on ``deps_policy`` and the declared lockfiles."""
46
+ if cfg.deps_policy == "open":
47
+ return _OPEN_TEXT
48
+ return _LOCKED_TEMPLATE.format(
49
+ lockfiles=_lockfile_label(cfg), header=MISSING_DEPS_HEADER)
50
+
51
+
52
+ def parse_missing_dependencies(text: str | None) -> list[str]:
53
+ """Parse a final agent response for the fixed ``## Missing dependencies``
54
+ section. Returns the declared pins (one non-empty line each) beneath the
55
+ header, until the next ``## `` section or end of text. An absent header or
56
+ an empty/malformed section yields an empty list — only a non-empty
57
+ declaration triggers the fail-fast."""
58
+ if not text:
59
+ return []
60
+ lines = text.splitlines()
61
+ for i, line in enumerate(lines):
62
+ if line.strip() == MISSING_DEPS_HEADER:
63
+ pins: list[str] = []
64
+ for rest in lines[i + 1:]:
65
+ if rest.startswith("## "):
66
+ break
67
+ stripped = rest.strip()
68
+ if stripped:
69
+ pins.append(stripped)
70
+ return pins
71
+ return []
72
+
73
+
74
+ def extension_message(cfg: "PipelineConfig", pins: list[str]) -> str:
75
+ """The fail-fast message naming the lockfile(s) and the declared pins."""
76
+ return (f"Extend {_lockfile_label(cfg)} with these pins and re-run:\n\n"
77
+ + "\n".join(pins))
@@ -32,6 +32,7 @@ from openhands.sdk import Conversation
32
32
 
33
33
  from .agents import load_agent, register_sandbox_terminal
34
34
  from agentic_dev_loop.core.config import TERMINALS, PipelineConfig, Route, StepConfig, route_target
35
+ from .deps import extension_message, parse_missing_dependencies, policy_injection
35
36
  from .isolation import SandboxExecutionRequest, command_argv, resolve_sandbox_env
36
37
  from .outputs import CapturedOutput
37
38
  from .secrets import redact_values, step_secret_env
@@ -198,11 +199,13 @@ def _run_command(command: str, workspace: Path, timeout: float,
198
199
  def build_context(runtime: Runtime, docs: dict[str, str], subject: str,
199
200
  notes: list[str] | None = None) -> str:
200
201
  """Assemble the attempt's shared message: subject, doc bundle, the
201
- expected-output-paths section generated from step defs (a prompt merely
202
- naming files lets agents skip them), and previous-attempt notes."""
202
+ canonical dependency-policy paragraph (021), the expected-output-paths
203
+ section generated from step defs (a prompt merely naming files lets agents
204
+ skip them), and previous-attempt notes."""
203
205
  parts = [f"You are working on {subject}.\n"]
204
206
  for name, content in docs.items():
205
207
  parts.append(f"## {name}\n\n{content}")
208
+ parts.append("## Dependency policy\n\n" + policy_injection(runtime.cfg))
206
209
  lines = []
207
210
  for step in runtime.cfg.steps:
208
211
  if step.agent is None:
@@ -301,6 +304,22 @@ def make_agent_node(runtime: Runtime, step: StepConfig):
301
304
  result.truncated, max_iterations)],
302
305
  "retry_target": _unclear_target(runtime, step)}
303
306
 
307
+ # 021: under `locked`, a declared missing dependency is a fail-fast —
308
+ # one human lockfile edit, never a retry loop. The extension message
309
+ # is written to last-error.md (the run-failure channel) and printed.
310
+ if (runtime.cfg.deps_policy == "locked"
311
+ and result.response is not None):
312
+ pins = parse_missing_dependencies(result.response)
313
+ if pins:
314
+ msg = extension_message(runtime.cfg, pins)
315
+ runtime.state_dir.mkdir(parents=True, exist_ok=True)
316
+ (runtime.state_dir / LAST_ERROR).write_text(
317
+ f"# Missing dependencies\n\n```\n{msg}\n```\n",
318
+ encoding="utf-8")
319
+ print(redact_values(msg, runtime.secrets.values()),
320
+ file=sys.stderr, flush=True)
321
+ return {"failed": True, "failed_step": step.name}
322
+
304
323
  if verdicts_map is None:
305
324
  merged = {**state["step_verdicts"], step.name: "pass"} if captures else \
306
325
  state["step_verdicts"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a10
3
+ Version: 1.0.0a11
4
4
  Summary: Turn an LLM into a gated, sandboxed, resumable construction loop over your codebase.
5
5
  Author: Luke Wilson
6
6
  License-Expression: Apache-2.0
@@ -35,6 +35,7 @@ Requires-Dist: langgraph-checkpoint-sqlite>=3.0
35
35
  Provides-Extra: dev
36
36
  Requires-Dist: hypothesis>=6.0; extra == "dev"
37
37
  Requires-Dist: pytest>=8.0; extra == "dev"
38
+ Requires-Dist: pip-audit>=2.0; extra == "dev"
38
39
  Dynamic: license-file
39
40
 
40
41
  # agentic-dev-loop
@@ -213,6 +214,8 @@ steps:
213
214
  | `isolation:` (optional) | Configures the sandbox — image override, resource caps, `allow_git_mutations`. It can never disable it. |
214
215
  | `state_dir` | Defaults to `~/.agentic-dev-loop/runs/<pipeline-hash>/` — artifacts and checkpoints live outside the sandbox mount. |
215
216
  | `extensions_dir` | Where consumer `extensions:` modules resolve — defaults to `~/.agentic-dev-loop/extensions`, **outside** the workspace. Workspace-resident extension/tool modules are refused at load. |
217
+ | `deps_policy` | `locked` (default) or `open`. `locked` = the human-owned lockfile is the source of truth and the agent may not add dependencies; `open` = the bootstrap protocol (the agent may add exact-pinned manifest entries). See [Dependency policy](#dependency-policy-locked-by-default). |
218
+ | `lockfiles` | The pipeline's declared lockfile names (workspace-relative), injected into agent context and named by the missing-dependency fail-fast. Required (non-empty) when `deps_policy: locked` — a `locked` pipeline with no lockfiles fails fast at load. |
216
219
 
217
220
  ### Secrets (`secrets:`) — refs only, never values
218
221
 
@@ -278,6 +281,76 @@ steps:
278
281
  for stages that need it (QA, integration). The residual risk is documented
279
282
  in [docs/setup.md](docs/setup.md).
280
283
 
284
+ ## Dependency policy (locked by default)
285
+
286
+ The supply-chain dial is a pipeline-level `deps_policy:` field, **locked by
287
+ default**. The engine injects the policy into every agent step's context —
288
+ the rule lives in the engine, not in hand-authored `dev.agent.md` prose (the
289
+ class of rule that deadlocked the NuGet cache).
290
+
291
+ ```yaml
292
+ deps_policy: locked # or: open (default: locked)
293
+ lockfiles: [uv.lock] # or: [packages.lock.json, package-lock.json]
294
+ ```
295
+
296
+ - **`locked`** — the human-owned lockfile is the source of truth; the agent
297
+ may not add dependencies. The injected rule says *"You may only use
298
+ dependencies already present in `<lockfile>`. If the design requires a
299
+ dependency that is not present, declare it in your final response under the
300
+ fixed header `## Missing dependencies` (one `name==version` per line) and
301
+ finish what you can. Do not add it to the manifest."* A non-empty
302
+ `## Missing dependencies` declaration **fails the run fast** with
303
+ `Extend <lockfile> with these pins and re-run: …` — one human lockfile edit,
304
+ not a retry loop. The declaration is advisory to the human; the engine never
305
+ auto-fetches the declared packages.
306
+ - **`open`** — the bootstrap protocol (018): the agent may add exact-pinned
307
+ manifest entries directly (never a package-manager `add` command); the
308
+ network-on restore step fetches them. The engine injects the same rule
309
+ canonically. Nothing about `open` is default.
310
+
311
+ The **restore step** runs the toolchain's locked mode and fails when the
312
+ manifest and lockfile disagree:
313
+
314
+ | Toolchain | Locked restore | Lockfile |
315
+ | --- | --- | --- |
316
+ | .NET | `dotnet restore --locked-mode` | `packages.lock.json` (set `RestorePackagesWithLockFile=true`) |
317
+ | node | `npm ci` | `package-lock.json` |
318
+ | python | `uv sync --locked` (or pip-tools `pip-sync requirements.lock`) | `uv.lock` — pip has no native lockfile; `uv` is the canonical locked-install tool |
319
+
320
+ ### Audit escalation in the restore step
321
+
322
+ The restore step also fails on known-CVE findings, at a per-toolchain
323
+ threshold:
324
+
325
+ | Toolchain | Audit (fails the step) |
326
+ | --- | --- |
327
+ | .NET | `NuGetAudit` at `error` — any audit finding fails the restore |
328
+ | node | `npm audit --audit-level=high` |
329
+ | python | `pip-audit` — any finding fails |
330
+
331
+ Under `locked`, a failing audit means the lockfile pins a vulnerable version:
332
+ the run fails and the human regenerates the lockfile at a patched version.
333
+ Under `open`, the failure routes to `dev` like any restore failure — the dev
334
+ agent can bump the pin, but that pin is audited again on the next pass, so
335
+ the loop self-corrects.
336
+
337
+ ### Generating a lockfile
338
+
339
+ A human generates the lockfile once, on a network machine, and commits it —
340
+ the engine never fetches on the agent's behalf:
341
+
342
+ ```bash
343
+ uv lock # python — writes uv.lock from pyproject.toml
344
+ uv sync --locked # installs exactly uv.lock; fails on any drift
345
+ npm install --package-lock-only # node — writes package-lock.json
346
+ npm ci # installs exactly package-lock.json
347
+ dotnet restore --use-lock-file # .NET — writes packages.lock.json
348
+ dotnet restore --locked-mode # installs exactly packages.lock.json
349
+ ```
350
+
351
+ Keep the lockfile tracked (do not gitignore it) and re-run the locked restore
352
+ to prove it holds from a clean cache before setting `deps_policy: locked`.
353
+
281
354
  ## Host boundary (security model)
282
355
 
283
356
  The orchestrator, tool layer, and the `commit` action run in the **host
@@ -17,6 +17,7 @@ src/agentic_dev_loop/engine/__init__.py
17
17
  src/agentic_dev_loop/engine/actions.py
18
18
  src/agentic_dev_loop/engine/agents.py
19
19
  src/agentic_dev_loop/engine/cli.py
20
+ src/agentic_dev_loop/engine/deps.py
20
21
  src/agentic_dev_loop/engine/dryrun.py
21
22
  src/agentic_dev_loop/engine/graph.py
22
23
  src/agentic_dev_loop/engine/hostboundary.py
@@ -38,6 +39,7 @@ tests/test_agents.py
38
39
  tests/test_aidlc.py
39
40
  tests/test_cli.py
40
41
  tests/test_config.py
42
+ tests/test_deps.py
41
43
  tests/test_dry_run.py
42
44
  tests/test_e2e_fixtures.py
43
45
  tests/test_graph.py
@@ -9,3 +9,4 @@ langgraph-checkpoint-sqlite>=3.0
9
9
  [dev]
10
10
  hypothesis>=6.0
11
11
  pytest>=8.0
12
+ pip-audit>=2.0
@@ -22,6 +22,7 @@ llm:
22
22
  api_key_env: TEST_LLM_KEY
23
23
  state_dir: .pr
24
24
  agents_dir: sdk_agents
25
+ deps_policy: open
25
26
  steps:
26
27
  - name: dev
27
28
  agent: dev
@@ -37,6 +38,7 @@ llm:
37
38
  api_key_env: TEST_LLM_KEY
38
39
  state_dir: .pr
39
40
  agents_dir: sdk_agents
41
+ deps_policy: open
40
42
  steps:
41
43
  - name: dev
42
44
  agent: dev
@@ -58,6 +60,7 @@ llm:
58
60
  api_key_env: TEST_LLM_KEY
59
61
  max_attempts: 1
60
62
  agents_dir: sdk_agents
63
+ deps_policy: open
61
64
  steps:
62
65
  - name: dev
63
66
  agent: dev
@@ -329,6 +332,7 @@ llm:
329
332
  api_key_env: TEST_LLM_KEY
330
333
  state_dir: .pr
331
334
  agents_dir: sdk_agents
335
+ deps_policy: open
332
336
  isolation:
333
337
  state_dir: {iso_state_dir}
334
338
  steps:
@@ -29,6 +29,7 @@ llm:
29
29
  state_dir: .pr
30
30
  max_attempts: 3
31
31
  agents_dir: sdk_agents
32
+ deps_policy: open
32
33
 
33
34
  steps:
34
35
  - name: dev
@@ -69,6 +70,7 @@ def _base() -> dict:
69
70
  "name": "test-pipeline",
70
71
  "llm": {"model": "m", "api_key_env": "K"},
71
72
  "agents_dir": "sdk_agents",
73
+ "lockfiles": ["uv.lock"],
72
74
  "steps": [{"name": "dev", "agent": "dev", "on_pass": "success"}],
73
75
  }
74
76
 
@@ -494,3 +496,36 @@ class TestStepNetwork:
494
496
  data["steps"] = [{"name": "commit", "action": "commit", "network": False,
495
497
  "on_pass": "success"}]
496
498
  assert _load(tmp_path, data).steps[0].network is False
499
+
500
+
501
+ class TestDepsPolicy:
502
+ def test_defaults_to_locked_with_declared_lockfiles(self, tmp_path):
503
+ cfg = _load(tmp_path, _base())
504
+ assert cfg.deps_policy == "locked"
505
+ assert cfg.lockfiles == ["uv.lock"]
506
+
507
+ def test_locked_without_lockfiles_rejected(self, tmp_path):
508
+ data = {k: v for k, v in _base().items() if k != "lockfiles"}
509
+ with pytest.raises(ValidationError,
510
+ match="requires at least one entry"):
511
+ _load(tmp_path, data)
512
+
513
+ def test_open_policy_loads_without_lockfiles(self, tmp_path):
514
+ data = {k: v for k, v in _base().items() if k != "lockfiles"} \
515
+ | {"deps_policy": "open"}
516
+ assert _load(tmp_path, data).deps_policy == "open"
517
+ assert _load(tmp_path, data).lockfiles == []
518
+
519
+ def test_unknown_policy_rejected(self, tmp_path):
520
+ data = _base() | {"deps_policy": "bootstrap"}
521
+ with pytest.raises(ValidationError,
522
+ match="deps_policy must be 'locked' or 'open'"):
523
+ _load(tmp_path, data)
524
+
525
+ def test_lockfiles_accepts_a_list(self, tmp_path):
526
+ data = _base() | {"lockfiles": ["uv.lock", "package-lock.json"]}
527
+ assert _load(tmp_path, data).lockfiles == ["uv.lock", "package-lock.json"]
528
+
529
+ def test_lockfiles_accepts_a_single_string(self, tmp_path):
530
+ data = _base() | {"lockfiles": "uv.lock"}
531
+ assert _load(tmp_path, data).lockfiles == ["uv.lock"]
@@ -0,0 +1,76 @@
1
+ """Dependency-policy injection and missing-dependency parsing (021)."""
2
+
3
+ from agentic_dev_loop.core.config import PipelineConfig
4
+ from agentic_dev_loop.engine.deps import (
5
+ MISSING_DEPS_HEADER, extension_message, parse_missing_dependencies,
6
+ policy_injection)
7
+
8
+
9
+ def _cfg(**over):
10
+ base = {"name": "t", "llm": {"model": "m", "api_key_env": "K"},
11
+ "agents_dir": "a", "lockfiles": ["uv.lock"],
12
+ "steps": [{"name": "d", "agent": "dev", "on_pass": "success"}]}
13
+ base.update(over)
14
+ return PipelineConfig.model_validate(base)
15
+
16
+
17
+ class TestPolicyInjection:
18
+ def test_locked_names_declared_lockfiles(self):
19
+ cfg = _cfg(deps_policy="locked", lockfiles=["uv.lock"])
20
+ text = policy_injection(cfg)
21
+ assert "`uv.lock`" in text
22
+ assert MISSING_DEPS_HEADER in text
23
+ assert "Do not add it to the manifest." in text
24
+
25
+ def test_locked_with_multiple_lockfiles_joins_them(self):
26
+ cfg = _cfg(lockfiles=["packages.lock.json", "package-lock.json"])
27
+ text = policy_injection(cfg)
28
+ assert "`packages.lock.json`, `package-lock.json`" in text
29
+
30
+ def test_open_injects_bootstrap_protocol(self):
31
+ cfg = _cfg(deps_policy="open")
32
+ text = policy_injection(cfg)
33
+ assert "add the exact-pinned entry to the dependency manifest" in text
34
+ assert MISSING_DEPS_HEADER not in text
35
+
36
+
37
+ class TestParseMissingDependencies:
38
+ def test_header_with_pins(self):
39
+ text = ("Done.\n\n"
40
+ + MISSING_DEPS_HEADER + "\n\nrequests==2.32.0\n"
41
+ "pydantic==2.7.0\n")
42
+ assert parse_missing_dependencies(text) == \
43
+ ["requests==2.32.0", "pydantic==2.7.0"]
44
+
45
+ def test_header_absent(self):
46
+ assert parse_missing_dependencies("No dependencies needed.") == []
47
+
48
+ def test_none_text(self):
49
+ assert parse_missing_dependencies(None) == []
50
+
51
+ def test_header_present_but_empty(self):
52
+ text = f"All done.\n\n{MISSING_DEPS_HEADER}\n\n\n"
53
+ assert parse_missing_dependencies(text) == []
54
+
55
+ def test_section_ends_at_next_header(self):
56
+ text = (MISSING_DEPS_HEADER + "\nrequests==2.32.0\n"
57
+ "## Summary\n\nDone.\n")
58
+ assert parse_missing_dependencies(text) == ["requests==2.32.0"]
59
+
60
+ def test_header_name_is_exact(self):
61
+ # Only the fixed header matches — decoration or case drift is malformed.
62
+ assert parse_missing_dependencies("### Missing dependencies\nfoo==1.0\n") == []
63
+ assert parse_missing_dependencies("## missing dependencies\nfoo==1.0\n") == []
64
+
65
+ def test_blank_and_padded_lines_are_pins(self):
66
+ text = f"{MISSING_DEPS_HEADER}\n\n requests==2.32.0 \n"
67
+ assert parse_missing_dependencies(text) == ["requests==2.32.0"]
68
+
69
+
70
+ class TestExtensionMessage:
71
+ def test_names_lockfile_and_pins(self):
72
+ cfg = _cfg(lockfiles=["uv.lock"])
73
+ msg = extension_message(cfg, ["requests==2.32.0"])
74
+ assert msg == ("Extend `uv.lock` with these pins and re-run:\n\n"
75
+ "requests==2.32.0")
76
+
@@ -26,6 +26,7 @@ llm:
26
26
  api_key_env: OPENAI_API_KEY
27
27
  state_dir: .pr
28
28
  agents_dir: sdk_agents
29
+ deps_policy: open
29
30
  steps:
30
31
  - name: dev
31
32
  agent: dev
@@ -34,6 +34,7 @@ llm:
34
34
  state_dir: .pr
35
35
  max_attempts: 3
36
36
  agents_dir: sdk_agents
37
+ deps_policy: open
37
38
  steps:
38
39
  - name: dev
39
40
  agent: dev
@@ -76,6 +77,7 @@ llm:
76
77
  state_dir: .pr
77
78
  max_attempts: 3
78
79
  agents_dir: sdk_agents
80
+ deps_policy: open
79
81
  steps:
80
82
  - name: dev
81
83
  agent: dev
@@ -265,6 +267,42 @@ class TestRunPipeline:
265
267
  assert fake_agents.calls == []
266
268
 
267
269
 
270
+ LOCKED_YAML = YOSK_YAML.replace(
271
+ "deps_policy: open\n",
272
+ "deps_policy: locked\nlockfiles: [uv.lock]\n",
273
+ 1)
274
+
275
+
276
+ class TestDependencyDial:
277
+ def _write_locked(self, repo):
278
+ (repo / "locked.yaml").write_text(LOCKED_YAML)
279
+ git(repo, "add", "-A")
280
+ git(repo, "commit", "-m", "add locked pipeline")
281
+
282
+ def test_missing_dependency_fails_fast_not_retry_loop(self, repo,
283
+ fake_agents):
284
+ self._write_locked(repo)
285
+ fake_agents.set("dev", [("write", "implementation-summary.md",
286
+ "## Missing dependencies\n\nrequests==2.32.0\n")])
287
+ ok, attempts = run_pipeline("locked.yaml", args())
288
+ assert (ok, attempts) == (False, 1)
289
+ assert fake_agents.agents_called() == ["dev"]
290
+ err = (repo / ".pr" / "last-error.md").read_text()
291
+ assert "Extend `uv.lock`" in err
292
+ assert "requests==2.32.0" in err
293
+
294
+ def test_locked_context_names_lockfile_in_dev_message(self, repo,
295
+ fake_agents):
296
+ self._write_locked(repo)
297
+ script_all_pass(fake_agents)
298
+ ok, attempts = run_pipeline("locked.yaml", args())
299
+ assert (ok, attempts) == (True, 1)
300
+ (dev_msg,) = fake_agents.messages_for("dev")
301
+ assert "## Dependency policy" in dev_msg
302
+ assert "`uv.lock`" in dev_msg
303
+ assert "Do not add it to the manifest." in dev_msg
304
+
305
+
268
306
  class TestCommandStep:
269
307
  def _write(self, repo: Path, text: str) -> None:
270
308
  (repo / "commandstage.yaml").write_text(text)
@@ -21,6 +21,7 @@ def _cfg(agents_dir="sdk_agents", additional_files=()):
21
21
  "llm": {"model": "m", "api_key_env": "K"},
22
22
  "agents_dir": agents_dir,
23
23
  "additional_files": list(additional_files),
24
+ "lockfiles": ["uv.lock"],
24
25
  "steps": [{"name": "dev", "agent": "dev", "on_pass": "success"}],
25
26
  })
26
27
 
@@ -60,6 +60,7 @@ def _runtime(backend, ws, script, tmp_path):
60
60
  "name": "probe",
61
61
  "llm": {"model": "openai/test-model", "api_key_env": "K"},
62
62
  "agents_dir": "agents",
63
+ "lockfiles": ["uv.lock"],
63
64
  "steps": [{"name": "probe", "action": "probe", "on_pass": "success"}],
64
65
  })
65
66
  runtime = Runtime(cfg=cfg, registry=reg,
@@ -790,6 +790,7 @@ class TestStartupSandbox:
790
790
  return PipelineConfig.model_validate({
791
791
  "name": "p", "llm": {"model": "m", "api_key_env": "K"},
792
792
  "agents_dir": "agents", "steps": steps,
793
+ "lockfiles": ["uv.lock"],
793
794
  "isolation": {"state_dir": str(tmp_path / "iso-state")},
794
795
  })
795
796
 
@@ -835,6 +836,7 @@ class TestStartupSandbox:
835
836
  cfg = PipelineConfig.model_validate({
836
837
  "name": "p", "llm": {"model": "m", "api_key_env": "K"},
837
838
  "agents_dir": "agents",
839
+ "lockfiles": ["uv.lock"],
838
840
  "steps": [{"name": "dev", "agent": "dev", "on_pass": "test"},
839
841
  {"name": "test", "command": "make", "network": True,
840
842
  "on_pass": "success"}],
@@ -23,6 +23,7 @@ def _base() -> dict:
23
23
  "name": "test-pipeline",
24
24
  "llm": {"model": "m", "api_key_env": "K"},
25
25
  "agents_dir": "sdk_agents",
26
+ "lockfiles": ["uv.lock"],
26
27
  "steps": [{"name": "dev", "agent": "dev", "on_pass": "success"}],
27
28
  }
28
29
 
@@ -19,6 +19,7 @@ llm:
19
19
  model: m
20
20
  api_key_env: TEST_LLM_KEY
21
21
  agents_dir: agents
22
+ deps_policy: open
22
23
  secrets:
23
24
  probe-secret:
24
25
  from: env
@@ -37,6 +38,7 @@ llm:
37
38
  model: m
38
39
  api_key_env: TEST_LLM_KEY
39
40
  agents_dir: agents
41
+ deps_policy: open
40
42
  steps:
41
43
  - name: dev
42
44
  agent: dev
@@ -24,7 +24,7 @@ from agentic_dev_loop.engine.stages import (
24
24
 
25
25
  def make_cfg(steps, **over):
26
26
  base = {"name": "t", "llm": {"model": "m", "api_key_env": "K"},
27
- "agents_dir": "agents", "steps": steps}
27
+ "agents_dir": "agents", "lockfiles": ["uv.lock"], "steps": steps}
28
28
  base.update(over)
29
29
  return PipelineConfig.model_validate(base)
30
30
 
@@ -115,6 +115,57 @@ class TestBuildContext:
115
115
  assert "test agent writes:" not in ctx
116
116
 
117
117
 
118
+ class TestDepsPolicyContext:
119
+ def test_locked_policy_injected(self, runtime):
120
+ cfg = make_cfg(STEPS, deps_policy="locked", lockfiles=["uv.lock"])
121
+ rt = dataclasses.replace(runtime, cfg=cfg)
122
+ ctx = build_context(rt, {}, "s", [])
123
+ assert "## Dependency policy" in ctx
124
+ assert "`uv.lock`" in ctx
125
+ assert "## Missing dependencies" in ctx
126
+ assert "Do not add it to the manifest." in ctx
127
+
128
+ def test_open_policy_injected(self, runtime):
129
+ cfg = make_cfg(STEPS, deps_policy="open")
130
+ rt = dataclasses.replace(runtime, cfg=cfg)
131
+ ctx = build_context(rt, {}, "s", [])
132
+ assert "## Dependency policy" in ctx
133
+ assert "add the exact-pinned entry to the dependency manifest" in ctx
134
+ assert "## Missing dependencies" not in ctx
135
+
136
+
137
+ class TestMissingDependencyFailFast:
138
+ def _rt(self, runtime, **over):
139
+ cfg = make_cfg(STEPS, **over)
140
+ return dataclasses.replace(runtime, cfg=cfg)
141
+
142
+ def test_locked_dev_declaring_missing_dep_fails_fast(self, runtime,
143
+ fake_agents):
144
+ rt = self._rt(runtime, deps_policy="locked", lockfiles=["uv.lock"])
145
+ fake_agents.set("dev", [("write", "implementation-summary.md",
146
+ "Built most of it.\n\n## Missing dependencies\n\n"
147
+ "requests==2.32.0\n")])
148
+ update = make_agent_node(rt, rt.cfg.steps[0])(make_state())
149
+ assert update == {"failed": True, "failed_step": "dev"}
150
+ err = (rt.state_dir / "last-error.md").read_text()
151
+ assert "Extend `uv.lock`" in err
152
+ assert "requests==2.32.0" in err
153
+
154
+ def test_no_header_no_fail(self, runtime, fake_agents):
155
+ rt = self._rt(runtime, deps_policy="locked", lockfiles=["uv.lock"])
156
+ fake_agents.set("dev", [("write", "implementation-summary.md",
157
+ "no new deps")])
158
+ update = make_agent_node(rt, rt.cfg.steps[0])(make_state())
159
+ assert update["failed"] is False
160
+
161
+ def test_open_policy_does_not_fail_on_header(self, runtime, fake_agents):
162
+ rt = self._rt(runtime, deps_policy="open")
163
+ fake_agents.set("dev", [("write", "implementation-summary.md",
164
+ "## Missing dependencies\n\nrequests==2.32.0\n")])
165
+ update = make_agent_node(rt, rt.cfg.steps[0])(make_state())
166
+ assert update["failed"] is False
167
+
168
+
118
169
  class TestAgentNode:
119
170
  def test_pass_verdict(self, runtime, fake_agents):
120
171
  fake_agents.set("check", [("write", "ci-fix.md", "all good\nVERDICT: PASS\n")])
@@ -141,6 +141,7 @@ class TestTopologyMermaid:
141
141
  "name": "viz-topology",
142
142
  "llm": {"model": "m", "api_key_env": "K"},
143
143
  "agents_dir": "sdk_agents",
144
+ "lockfiles": ["uv.lock"],
144
145
  "steps": steps,
145
146
  })
146
147