agentic-dev-loop 1.0.0a11__tar.gz → 1.0.0a13__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 (70) hide show
  1. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/PKG-INFO +1 -1
  2. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/pyproject.toml +1 -1
  3. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/core/config.py +7 -0
  4. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/cli.py +5 -0
  5. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/isolation.py +145 -28
  6. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/loaders.py +41 -0
  7. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop.egg-info/PKG-INFO +1 -1
  8. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop.egg-info/SOURCES.txt +1 -0
  9. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_cli.py +48 -0
  10. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_config.py +11 -0
  11. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_e2e_fixtures.py +5 -4
  12. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_isolation.py +172 -3
  13. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_isolation_integration.py +79 -0
  14. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_loaders.py +110 -1
  15. agentic_dev_loop-1.0.0a13/tests/test_trigger_script.py +191 -0
  16. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/LICENSE +0 -0
  17. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/README.md +0 -0
  18. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/setup.cfg +0 -0
  19. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/__init__.py +0 -0
  20. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/core/__init__.py +0 -0
  21. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/core/results.py +0 -0
  22. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/core/verdict.py +0 -0
  23. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/__init__.py +0 -0
  24. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/actions.py +0 -0
  25. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/agents.py +0 -0
  26. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/deps.py +0 -0
  27. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  28. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  29. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  30. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/graph.py +0 -0
  31. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  32. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/llm.py +0 -0
  33. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/outputs.py +0 -0
  34. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/proc.py +0 -0
  35. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/registry.py +0 -0
  36. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/secrets.py +0 -0
  37. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/stages.py +0 -0
  38. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/py.typed +0 -0
  39. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/viz/__init__.py +0 -0
  40. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/viz/__main__.py +0 -0
  41. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/viz/server.py +0 -0
  42. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  43. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  44. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  45. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  46. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_actions.py +0 -0
  47. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_agents.py +0 -0
  48. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_aidlc.py +0 -0
  49. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_deps.py +0 -0
  50. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_dry_run.py +0 -0
  51. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_graph.py +0 -0
  52. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_hostboundary.py +0 -0
  53. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_hostboundary_integration.py +0 -0
  54. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_isolation_pbt.py +0 -0
  55. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_llm.py +0 -0
  56. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_module_contract.py +0 -0
  57. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_outputs.py +0 -0
  58. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_outputs_pbt.py +0 -0
  59. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_proc.py +0 -0
  60. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_publish.py +0 -0
  61. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_registry.py +0 -0
  62. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_results.py +0 -0
  63. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_sdk_compat.py +0 -0
  64. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_secrets.py +0 -0
  65. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_secrets_integration.py +0 -0
  66. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_secrets_pipeline.py +0 -0
  67. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_source_resolution.py +0 -0
  68. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_stages.py +0 -0
  69. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_verdict.py +0 -0
  70. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_viz.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a11
3
+ Version: 1.0.0a13
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentic-dev-loop"
7
- version = "1.0.0a11"
7
+ version = "1.0.0a13"
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"
@@ -79,6 +79,13 @@ class IsolationConfig(BaseModel):
79
79
  cpus: float | None = None # override exec container cpus
80
80
  allow_git_mutations: bool = False # BR-6 knob
81
81
  state_dir: str = "~/.agentic-dev-loop/state" # stamps + audit (host-only)
82
+ # 022 relocation opt-in: an out-of-workspace host dir that is bind-mounted
83
+ # at ``{ws}/.venv-sandbox`` (rw) in both the exec and provision containers,
84
+ # keeping the workspace free of container venv artifacts. Unset keeps the
85
+ # legacy workspace-local ``.venv-sandbox`` unchanged. Must resolve outside
86
+ # the workspace — validated at startup, not load (the workspace is unknown
87
+ # here).
88
+ sandbox_venv_dir: str | None = None
82
89
 
83
90
  @field_validator("memory")
84
91
  @classmethod
@@ -12,6 +12,7 @@ from pathlib import Path
12
12
  from agentic_dev_loop.core.config import PipelineConfig, load_config
13
13
  from .dryrun import dry_run
14
14
  from .isolation import clean_stamps, isolation_state_dir
15
+ from .loaders import mark_plan_completed
15
16
  from .registry import Registry, default_registry, load_extensions
16
17
 
17
18
 
@@ -112,6 +113,10 @@ def main(argv: list[str] | None = None) -> int:
112
113
  # dry run, --clean-stamps, and --help stay side-effect free.
113
114
  from .graph import run_pipeline
114
115
  success, _attempts = run_pipeline(args.pipeline, args, registry=registry)
116
+ if success and args.plan is not None:
117
+ # Only a completed run earns the stamp — failed runs leave the plan
118
+ # as-is. AI-DLC runs never pass --plan, so aidlc docs stay untouched.
119
+ mark_plan_completed(args.plan)
115
120
  if not success:
116
121
  print("agentic-dev-loop: pipeline failed — exiting with code 1",
117
122
  file=sys.stderr)
@@ -7,10 +7,13 @@ concrete implementation (user directive 2026-10-19). Everything else here is
7
7
  either pure translation logic (the PBT-03 targets) or host-side audit/stamp
8
8
  helpers.
9
9
 
10
- Host-only vs sandboxed: the sandbox mount set is EXACTLY {workspace} (Q1).
11
- The isolation state dir (stamps + audit JSONL) and the pipeline run-state dir
12
- (checkpoint DB + last-error.md) are never mounted — sandboxed code cannot
13
- reach them.
10
+ Host-only vs sandboxed: the sandbox mount set is {workspace} plus the
11
+ host-only mask mounts (022): a read-only empty tmpfs at ``{ws}/.venv`` so the
12
+ host's throwaway venv can never be written from inside the sandbox. When
13
+ ``isolation.sandbox_venv_dir`` relocates the sandbox venv, one extra rw bind
14
+ mount at ``{ws}/.venv-sandbox`` is added (opt-in). The isolation state dir
15
+ (stamps + audit JSONL) and the pipeline run-state dir (checkpoint DB +
16
+ last-error.md) are never mounted — sandboxed code cannot reach them.
14
17
  """
15
18
 
16
19
  from __future__ import annotations
@@ -74,6 +77,10 @@ PROVISION_TIMEOUT_SECONDS = 3600
74
77
  # container, so it must be detected and re-provisioned there.
75
78
  SANDBOX_VENV_DIR = ".venv-sandbox"
76
79
  SANDBOX_VENV_PYTHON = ".venv-sandbox/bin/python"
80
+ # Host-only dirs masked inside the sandbox (022): each gets a read-only empty
81
+ # tmpfs submount at ``{ws}/<dir>`` so agents fail loudly on any write instead
82
+ # of silently mutating a host-owned throwaway venv.
83
+ HOST_ONLY_DIRS = (".venv",)
77
84
 
78
85
 
79
86
  class SandboxError(RuntimeError):
@@ -267,16 +274,58 @@ def is_git_mutation(argv: list[str]) -> bool:
267
274
  return True
268
275
 
269
276
 
277
+ def _expand_home(path: str) -> str:
278
+ """Expand a leading ``~`` against ``$HOME``, falling back to
279
+ ``USERPROFILE`` (Windows).
280
+
281
+ The engine is driven from bash on every platform (Windows via Git Bash),
282
+ where HOME is the authoritative home. CPython's Windows expanduser ignores
283
+ HOME in favour of USERPROFILE, so a literal ``~`` in a config file would
284
+ resolve inconsistently on Windows. Only a leading tilde (``~``, ``~/``,
285
+ ``~\\``) is handled here; ``~user`` forms and the no-HOME/no-USERPROFILE
286
+ case fall through to the stdlib below.
287
+ """
288
+ if path != "~" and not path.startswith(("~/", "~\\")):
289
+ return path
290
+ home = os.environ.get("HOME") or os.environ.get("USERPROFILE")
291
+ if home:
292
+ return home + path[1:]
293
+ return path
294
+
295
+
270
296
  def translate_mount_path(path: str) -> str:
271
297
  """expanduser + absolute resolution without symlink chasing. Identity on
272
298
  macOS/Windows/WSL2-with-Docker-Desktop (verbatim passthrough — Docker
273
- Desktop translates). Idempotent: translate(translate(p)) == translate(p)."""
274
- p = Path(path).expanduser()
299
+ Desktop translates). Idempotent: translate(translate(p)) == translate(p).
300
+ A leading ``~`` resolves against $HOME first (USERPROFILE fallback), then
301
+ the stdlib handles anything left (pwd on POSIX)."""
302
+ p = Path(_expand_home(path)).expanduser()
275
303
  if not p.is_absolute():
276
304
  p = p.absolute()
277
305
  return str(p)
278
306
 
279
307
 
308
+ def resolve_sandbox_venv_dir(sandbox_venv_dir: str | None,
309
+ workspace: str | Path) -> str | None:
310
+ """Resolve the opt-in relocated sandbox venv (022).
311
+
312
+ ``None`` keeps the legacy workspace-local ``.venv-sandbox`` (bit-compatible
313
+ default — payloads are unchanged). A set value is expanduser + absolute and
314
+ must resolve OUTSIDE the workspace: an in-workspace value defeats the whole
315
+ relocation and fails loud (AE-SBX-015).
316
+ """
317
+ if sandbox_venv_dir is None:
318
+ return None
319
+ resolved = Path(translate_mount_path(sandbox_venv_dir))
320
+ ws = Path(translate_mount_path(os.fspath(workspace)))
321
+ if resolved == ws or ws in resolved.parents:
322
+ raise SandboxError(
323
+ f"AE-SBX-015: isolation.sandbox_venv_dir {resolved!r} must resolve "
324
+ "outside the workspace — an in-workspace value defeats the 022 "
325
+ "relocation (docs/setup.md §3)")
326
+ return str(resolved)
327
+
328
+
280
329
  def setup_stamp_key(setup_cmd: str, lockfile_hashes: dict[str, str]) -> str:
281
330
  """sha256 hex of canonical JSON {setup_cmd, lockfiles: sorted hashes}."""
282
331
  canonical = {
@@ -323,10 +372,44 @@ def venv_probe_state(stdout: str, exit_code: int | None) -> str:
323
372
  return line if line in ("valid", "missing", "broken") else "unknown"
324
373
 
325
374
 
326
- def remove_sandbox_venv(workspace: str | Path) -> None:
327
- """Remove the workspace's sandbox venv before re-provision (016 self-heal).
328
- Fails loud when the venv exists but cannot be removed — a silent skip
329
- would re-create the ENOENT failure the next run."""
375
+ def _clear_dir_contents(path: Path) -> None:
376
+ """Delete every entry under ``path``, keeping ``path`` itself. The 022
377
+ relocated venv is the bind-mount source for the still-running execution
378
+ container, so the source dir must survive removal (deleting it would leave
379
+ the container pointed at a stale inode)."""
380
+ if not path.exists():
381
+ return
382
+ for child in path.iterdir():
383
+ if child.is_symlink() or child.is_file():
384
+ child.unlink()
385
+ elif child.is_dir():
386
+ shutil.rmtree(child)
387
+
388
+
389
+ def remove_sandbox_venv(workspace: str | Path, *,
390
+ sandbox_venv_dir: str | None = None) -> None:
391
+ """Remove the sandbox venv before re-provision (016 self-heal). Fails loud
392
+ when the venv exists but cannot be removed — a silent skip would re-create
393
+ the ENOENT failure the next run.
394
+
395
+ With the 022 relocation opt-in (``sandbox_venv_dir``), the venv lives at
396
+ that host path and is the source of a live bind mount in the execution
397
+ container — so only its CONTENTS are removed (the mount source dir stays),
398
+ leaving the container's ``{ws}/.venv-sandbox`` mount valid for re-provision.
399
+ The default (unset) path removes the whole workspace-local dir as before.
400
+ """
401
+ if sandbox_venv_dir is not None:
402
+ venv = Path(sandbox_venv_dir)
403
+ try:
404
+ if venv.is_symlink() or venv.is_file():
405
+ venv.unlink()
406
+ elif venv.is_dir():
407
+ _clear_dir_contents(venv)
408
+ except OSError as e:
409
+ raise SandboxError(
410
+ f"AE-SBX-014: cannot remove invalid sandbox venv at {venv}: {e} "
411
+ f"— remove it manually and re-run (docs/setup.md §5)") from e
412
+ return
330
413
  venv = Path(workspace) / SANDBOX_VENV_DIR
331
414
  try:
332
415
  if venv.is_symlink() or venv.is_file():
@@ -360,15 +443,34 @@ def exec_container_name(run_id: str, network: bool = False) -> str:
360
443
  return f"{run_id}-exec-net" if network else f"{run_id}-exec"
361
444
 
362
445
 
446
+ def _sandbox_mounts(workspace: str, sandbox_venv_dir: str | None) -> list[str]:
447
+ """The mount segment shared by the exec and provision payloads (022).
448
+
449
+ ``{workspace}`` rw is always first; each host-only dir is then shadowed by
450
+ a read-only empty tmpfs submount (Docker cannot exclude a subdirectory of a
451
+ bind — a submount is the only way to hide it, and ``ro`` makes any write
452
+ attempt fail loudly instead of silently succeeding against a decoy). When
453
+ ``sandbox_venv_dir`` relocates the sandbox venv, one rw bind mount maps it
454
+ to the unchanged container path ``{ws}/.venv-sandbox``.
455
+ """
456
+ mounts = ["-v", f"{workspace}:{workspace}"]
457
+ for host_only in HOST_ONLY_DIRS:
458
+ mounts += ["--tmpfs", f"{workspace}/{host_only}:ro"]
459
+ if sandbox_venv_dir is not None:
460
+ mounts += ["-v", f"{sandbox_venv_dir}:{workspace}/{SANDBOX_VENV_DIR}"]
461
+ return mounts
462
+
463
+
363
464
  def docker_create_payload(*, run_id: str, image: str, profile: EffectiveProfile,
364
- workspace: str, network: bool = False) -> list[str]:
465
+ workspace: str, network: bool = False,
466
+ sandbox_venv_dir: str | None = None) -> list[str]:
365
467
  """Exact ``docker run -d --rm`` argv for the execution container (§3.3, KD-8).
366
- Single mount {workspace} (Q1); ``--network none`` (bridge when ``network``
367
- is true — 014 per-stage networking), --read-only, --cap-drop ALL; --user
368
- host uid:gid (host-owned bind mounts, AE-SBX-009 root cause) with
369
- HOME=/tmp (the rootfs is read-only; /tmp is a 1777 tmpfs). ``--rm`` lets the
370
- daemon auto-remove the container the instant it exits, however it exits
371
- (D1) — a SIGKILLed CLI never reaches close()."""
468
+ Mount set {workspace} + host-only masks (022); ``--network none`` (bridge
469
+ when ``network`` is true — 014 per-stage networking), --read-only,
470
+ --cap-drop ALL; --user host uid:gid (host-owned bind mounts, AE-SBX-009
471
+ root cause) with HOME=/tmp (the rootfs is read-only; /tmp is a 1777 tmpfs).
472
+ ``--rm`` lets the daemon auto-remove the container the instant it exits,
473
+ however it exits (D1) — a SIGKILLed CLI never reaches close()."""
372
474
  payload = [
373
475
  "docker", "run", "--detach", "--rm", "--name",
374
476
  exec_container_name(run_id, network),
@@ -380,8 +482,8 @@ def docker_create_payload(*, run_id: str, image: str, profile: EffectiveProfile,
380
482
  user = host_user_flag()
381
483
  if user is not None:
382
484
  payload.extend(["--user", user, "--env", "HOME=/tmp"])
485
+ payload.extend(_sandbox_mounts(workspace, sandbox_venv_dir))
383
486
  payload.extend([
384
- "-v", f"{workspace}:{workspace}",
385
487
  "--workdir", workspace,
386
488
  image, "sleep", "infinity",
387
489
  ])
@@ -389,10 +491,12 @@ def docker_create_payload(*, run_id: str, image: str, profile: EffectiveProfile,
389
491
 
390
492
 
391
493
  def docker_provision_payload(*, run_id: str, image: str, workspace: str,
392
- setup_cmd: str) -> list[str]:
494
+ setup_cmd: str,
495
+ sandbox_venv_dir: str | None = None) -> list[str]:
393
496
  """Exact ``docker run --rm`` argv for the provision container (§3.4).
394
- Network ON (default bridge), rw rootfs, --cap-drop ALL, single mount
395
- {workspace} (Q1); --user host uid:gid (AE-SBX-009) with HOME=/tmp."""
497
+ Network ON (default bridge), rw rootfs, --cap-drop ALL, mount set
498
+ {workspace} + host-only masks (022); --user host uid:gid (AE-SBX-009) with
499
+ HOME=/tmp."""
396
500
  payload = [
397
501
  "docker", "run", "--rm", "--name", f"{run_id}-provision",
398
502
  "--network", "bridge", "--cap-drop", "ALL",
@@ -401,8 +505,8 @@ def docker_provision_payload(*, run_id: str, image: str, workspace: str,
401
505
  user = host_user_flag()
402
506
  if user is not None:
403
507
  payload.extend(["--user", user, "--env", "HOME=/tmp"])
508
+ payload.extend(_sandbox_mounts(workspace, sandbox_venv_dir))
404
509
  payload.extend([
405
- "-v", f"{workspace}:{workspace}",
406
510
  "--workdir", workspace,
407
511
  image, "sh", "-c", setup_cmd,
408
512
  ])
@@ -502,7 +606,7 @@ class DockerBackend(Sandbox):
502
606
 
503
607
  def __init__(self, *, image: str, workspace: str, iso_state_dir: str,
504
608
  profile: EffectiveProfile, runner: CommandRunner | None = None,
505
- network: bool = False):
609
+ network: bool = False, sandbox_venv_dir: str | None = None):
506
610
  self._image = image
507
611
  self._workspace = workspace
508
612
  self._iso_state_dir = Path(iso_state_dir) # host-only (audit)
@@ -512,6 +616,13 @@ class DockerBackend(Sandbox):
512
616
  self._container = exec_container_name(self._run_id, network=False)
513
617
  self._network_container = exec_container_name(self._run_id, network=True)
514
618
  self._network_enabled = network
619
+ self._sandbox_venv_dir = sandbox_venv_dir # 022 relocated venv (opt-in)
620
+ if sandbox_venv_dir is not None:
621
+ # 022: the relocated venv dir is a bind-mount source. On native
622
+ # Linux, Docker auto-creates a missing source as root, which the
623
+ # --user uid:gid containers then cannot write (the AE-SBX-009
624
+ # ownership class). Pre-create it host-side so it is uid-owned.
625
+ Path(sandbox_venv_dir).mkdir(parents=True, exist_ok=True)
515
626
  self._started = False
516
627
  self._network_started = False
517
628
 
@@ -551,7 +662,8 @@ class DockerBackend(Sandbox):
551
662
  infrastructure work happens mid-run (014 per-stage networking)."""
552
663
  payload = docker_create_payload(
553
664
  run_id=self._run_id, image=self._image, profile=self._profile,
554
- workspace=self._workspace, network=False)
665
+ workspace=self._workspace, network=False,
666
+ sandbox_venv_dir=self._sandbox_venv_dir)
555
667
  res = self._runner.run(payload, timeout=60)
556
668
  if res.exit_code != 0:
557
669
  raise SandboxError(
@@ -562,7 +674,8 @@ class DockerBackend(Sandbox):
562
674
  return
563
675
  net_payload = docker_create_payload(
564
676
  run_id=self._run_id, image=self._image, profile=self._profile,
565
- workspace=self._workspace, network=True)
677
+ workspace=self._workspace, network=True,
678
+ sandbox_venv_dir=self._sandbox_venv_dir)
566
679
  net_res = self._runner.run(net_payload, timeout=60)
567
680
  if net_res.exit_code != 0:
568
681
  # reclaim the already-started network-none container — never leak it.
@@ -587,12 +700,13 @@ class DockerBackend(Sandbox):
587
700
  """
588
701
  if not force and read_stamp(Path(stamp_dir), stamp_key):
589
702
  if self._probe_venv() == "broken":
590
- remove_sandbox_venv(self._workspace)
703
+ remove_sandbox_venv(self._workspace,
704
+ sandbox_venv_dir=self._sandbox_venv_dir)
591
705
  else:
592
706
  return
593
707
  payload = docker_provision_payload(
594
708
  run_id=self._run_id, image=self._image, workspace=self._workspace,
595
- setup_cmd=setup_cmd)
709
+ setup_cmd=setup_cmd, sandbox_venv_dir=self._sandbox_venv_dir)
596
710
  res = self._runner.run(payload, timeout=PROVISION_TIMEOUT_SECONDS)
597
711
  if res.exit_code != 0:
598
712
  tail = (res.stdout or res.stderr or "")[-4000:]
@@ -846,9 +960,12 @@ def startup_sandbox(cfg: PipelineConfig, *, workspace: Path,
846
960
  image = resolve_image_ref(profile.image)
847
961
  iso_state_dir = isolation_state_dir(cfg)
848
962
  needs_network = any(step.network for step in cfg.steps)
963
+ sandbox_venv_dir = resolve_sandbox_venv_dir(
964
+ cfg.isolation.sandbox_venv_dir if cfg.isolation else None, workspace)
849
965
  backend = DockerBackend(
850
966
  image=image, workspace=str(workspace), iso_state_dir=str(iso_state_dir),
851
- profile=profile, runner=runner, network=needs_network)
967
+ profile=profile, runner=runner, network=needs_network,
968
+ sandbox_venv_dir=sandbox_venv_dir)
852
969
  backend.ensure_image()
853
970
  backend.create_execution_container()
854
971
  if refresh is None:
@@ -1,6 +1,7 @@
1
1
  """Built-in `plan` document loader + the loader-selection flow."""
2
2
 
3
3
  import argparse
4
+ import re
4
5
  from pathlib import Path
5
6
 
6
7
  from agentic_dev_loop.core.config import PipelineConfig
@@ -18,6 +19,46 @@ def resolve_plan_docs(plan_path: Path | str) -> dict[str, str]:
18
19
  return {path.name: path.read_text()}
19
20
 
20
21
 
22
+ # A status line at the top of a planning doc, in any recorded form:
23
+ # `> **Status: COMPLETED** (date)`, `**Status:** ...`, `- **Status**: ...`,
24
+ # or bare `Status: ...`.
25
+ _STATUS_LINE = re.compile(r"^\s*(?:>\s*)?(?:[-*]\s+)?\*{0,2}Status\*{0,2}\s*:")
26
+ # The status only counts as "recorded at the top" when it appears early —
27
+ # buried history (e.g. `## Status history` or a later `Status: ...` line)
28
+ # is not the doc's current status.
29
+ _STATUS_SCAN_LINES = 10
30
+ _STAMP = "> **Status: COMPLETED**"
31
+
32
+
33
+ def mark_plan_completed(plan_path: Path | str) -> None:
34
+ """Stamp the plan doc as COMPLETED after a successful run.
35
+
36
+ Replaces the first status line found within the top of the doc, or
37
+ inserts the stamp right after the `# ` title (blank separators around
38
+ it) — or at the very top when there is no title. Trailing-newline
39
+ presence is preserved."""
40
+ path = Path(plan_path)
41
+ text = path.read_text(encoding="utf-8")
42
+ lines = text.splitlines()
43
+ trailing = "\n" if text.endswith("\n") else ""
44
+
45
+ for idx in range(min(len(lines), _STATUS_SCAN_LINES)):
46
+ if _STATUS_LINE.match(lines[idx]):
47
+ lines[idx] = _STAMP
48
+ break
49
+ else:
50
+ title_idx = next((i for i, line in enumerate(lines)
51
+ if line.startswith("# ")), None)
52
+ insert_at = 0 if title_idx is None else title_idx + 1
53
+ stamp_block = [_STAMP]
54
+ if insert_at > 0 and lines[insert_at - 1] != "":
55
+ stamp_block.insert(0, "") # blank separator above the stamp
56
+ if insert_at < len(lines) and lines[insert_at] != "":
57
+ stamp_block.append("") # blank separator below the stamp
58
+ lines[insert_at:insert_at] = stamp_block
59
+ path.write_text("\n".join(lines) + trailing, encoding="utf-8")
60
+
61
+
21
62
  class PlanDocumentLoader:
22
63
  name = "plan"
23
64
  defaultable = False # needs a path — never autodetects
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a11
3
+ Version: 1.0.0a13
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
@@ -63,5 +63,6 @@ tests/test_secrets_integration.py
63
63
  tests/test_secrets_pipeline.py
64
64
  tests/test_source_resolution.py
65
65
  tests/test_stages.py
66
+ tests/test_trigger_script.py
66
67
  tests/test_verdict.py
67
68
  tests/test_viz.py
@@ -202,6 +202,54 @@ class TestRun:
202
202
  # dev completed before the crash — only test re-ran on resume
203
203
  assert fake_agents.agents_called() == ["dev", "check", "check"]
204
204
 
205
+ def test_success_stamps_plan_completed(self, repo, fake_agents):
206
+ fake_agents.set("dev", [("write", "implementation-summary.md", "done")])
207
+
208
+ assert main(["pipeline.yaml", "--plan", "plan.md", "--no-viz"]) == 0
209
+
210
+ assert (repo / "plan.md").read_text() == (
211
+ "> **Status: COMPLETED**\n\nTHE PLAN")
212
+
213
+ def test_success_replaces_existing_status(self, repo, fake_agents):
214
+ (repo / "plan.md").write_text(
215
+ "# Plan\n\n> **Status: DONE**\n\nTHE PLAN\n")
216
+ git(repo, "add", "-A")
217
+ git(repo, "commit", "-m", "record status")
218
+ fake_agents.set("dev", [("write", "implementation-summary.md", "done")])
219
+
220
+ assert main(["pipeline.yaml", "--plan", "plan.md", "--no-viz"]) == 0
221
+
222
+ assert (repo / "plan.md").read_text() == (
223
+ "# Plan\n\n> **Status: COMPLETED**\n\nTHE PLAN\n")
224
+
225
+ def test_failed_run_does_not_stamp(self, repo, fake_agents):
226
+ write_pipeline(repo, "retry.yaml", RETRY_YAML)
227
+ fake_agents.set("dev", [("write", "implementation-summary.md", "done")])
228
+ fake_agents.set("check", [("write", "ci-fix.md", "VERDICT: FAIL")])
229
+
230
+ assert main(["retry.yaml", "--plan", "plan.md", "--no-viz"]) == 1
231
+
232
+ assert (repo / "plan.md").read_text() == "THE PLAN"
233
+
234
+ def test_aidlc_run_leaves_docs_untouched(self, repo, fake_agents):
235
+ write_pipeline(repo, "aidlc.yaml", with_extensions(
236
+ PLAIN_YAML, "agentic_dev_loop.engine.extensions.aidlc:AidlcExtension"))
237
+ unit_dir = repo / "aidlc-docs" / "construction" / "unit-3"
238
+ unit_dir.mkdir(parents=True)
239
+ (unit_dir / "design.md").write_text("# Design")
240
+ (repo / "aidlc-docs" / "aidlc-state.md").write_text(
241
+ "# State\n\n- **Current Stage**: CONSTRUCTION — Unit unit-3 (SCAN).\n")
242
+ git(repo, "add", "-A")
243
+ git(repo, "commit", "-m", "aidlc fixture")
244
+ fake_agents.set("dev", [("write", "implementation-summary.md", "done")])
245
+ state_before = (repo / "aidlc-docs" / "aidlc-state.md").read_text()
246
+
247
+ assert main(["aidlc.yaml", "--aidlc-unit", "unit-3", "--no-viz"]) == 0
248
+
249
+ assert (repo / "aidlc-docs" / "aidlc-state.md").read_text() == state_before
250
+ assert (repo / "aidlc-docs" / "construction" / "unit-3" / "design.md").read_text() == "# Design"
251
+ assert (repo / "plan.md").read_text() == "THE PLAN"
252
+
205
253
 
206
254
  class TestViz:
207
255
  def test_no_viz_flags_parse(self, repo):
@@ -402,6 +402,17 @@ class TestIsolationConfig:
402
402
  data = _base() | {"isolation": {"memory": memory}}
403
403
  assert _load(tmp_path, data).isolation.memory == memory
404
404
 
405
+ def test_sandbox_venv_dir_accepts_and_round_trips(self, tmp_path):
406
+ """022: `sandbox_venv_dir` is an optional relocation opt-in. It loads
407
+ verbatim here; the outside-workspace validation happens at startup
408
+ (where the workspace is known), not at config load."""
409
+ data = _base() | {"isolation": {"sandbox_venv_dir":
410
+ "~/.agentic-dev-loop/venvs/engine"}}
411
+ iso = _load(tmp_path, data).isolation
412
+ assert iso.sandbox_venv_dir == "~/.agentic-dev-loop/venvs/engine"
413
+ roundtripped = type(iso).model_validate(iso.model_dump())
414
+ assert roundtripped.sandbox_venv_dir == iso.sandbox_venv_dir
415
+
405
416
  @pytest.mark.parametrize("memory", ["8", "g", "8G", "8 g", ""])
406
417
  def test_invalid_memory_rejected(self, tmp_path, memory):
407
418
  data = _base() | {"isolation": {"memory": memory}}
@@ -27,10 +27,11 @@ EXAMPLE = Path(__file__).resolve().parent.parent / "agentic-dev-loop"
27
27
 
28
28
 
29
29
  # Scripted stand-in for the shipped example's mechanical test command. The
30
- # example runs `.venv/bin/python -m pytest tests/ -q`, which cannot run inside
31
- # the consumer-repo copy (no .venv, no tests/) — so this fixture scripts the
32
- # seam locally. The real subprocess path stays covered by test_stages.py and
33
- # test_graph.py. Behaviors: ("pass",) → (0, "", False); ("fail", output) →
30
+ # example runs `.venv-sandbox/bin/python -m pytest tests/ -q -m "not docker"`,
31
+ # which cannot run inside the consumer-repo copy (no .venv-sandbox, no tests/)
32
+ # — so this fixture scripts the seam locally. The real subprocess path stays
33
+ # covered by test_stages.py and test_graph.py. Behaviors: ("pass",) →
34
+ # (0, "", False); ("fail", output) →
34
35
  # (1, output, False); ("timeout", output) → (None, output, True); the last
35
36
  # behavior repeats; empty queue → default pass.
36
37
  _COMMAND_QUEUE: list[tuple] = []