agentic-dev-loop 1.0.0a12__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.0a12 → agentic_dev_loop-1.0.0a13}/PKG-INFO +1 -1
  2. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/pyproject.toml +1 -1
  3. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/cli.py +5 -0
  4. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/isolation.py +23 -2
  5. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/loaders.py +41 -0
  6. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop.egg-info/PKG-INFO +1 -1
  7. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_cli.py +48 -0
  8. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_isolation.py +15 -0
  9. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_isolation_integration.py +9 -2
  10. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_loaders.py +110 -1
  11. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_trigger_script.py +80 -0
  12. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/LICENSE +0 -0
  13. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/README.md +0 -0
  14. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/setup.cfg +0 -0
  15. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/__init__.py +0 -0
  16. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/core/__init__.py +0 -0
  17. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/core/config.py +0 -0
  18. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/core/results.py +0 -0
  19. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/core/verdict.py +0 -0
  20. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/__init__.py +0 -0
  21. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/actions.py +0 -0
  22. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/agents.py +0 -0
  23. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/deps.py +0 -0
  24. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  25. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  26. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  27. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/graph.py +0 -0
  28. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  29. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/llm.py +0 -0
  30. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/outputs.py +0 -0
  31. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/proc.py +0 -0
  32. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/registry.py +0 -0
  33. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/secrets.py +0 -0
  34. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/stages.py +0 -0
  35. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/py.typed +0 -0
  36. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/viz/__init__.py +0 -0
  37. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/viz/__main__.py +0 -0
  38. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/viz/server.py +0 -0
  39. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop.egg-info/SOURCES.txt +0 -0
  40. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  41. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  42. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  43. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  44. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_actions.py +0 -0
  45. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_agents.py +0 -0
  46. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_aidlc.py +0 -0
  47. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_config.py +0 -0
  48. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_deps.py +0 -0
  49. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_dry_run.py +0 -0
  50. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_e2e_fixtures.py +0 -0
  51. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_graph.py +0 -0
  52. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_hostboundary.py +0 -0
  53. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_hostboundary_integration.py +0 -0
  54. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_isolation_pbt.py +0 -0
  55. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_llm.py +0 -0
  56. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_module_contract.py +0 -0
  57. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_outputs.py +0 -0
  58. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_outputs_pbt.py +0 -0
  59. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_proc.py +0 -0
  60. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_publish.py +0 -0
  61. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_registry.py +0 -0
  62. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_results.py +0 -0
  63. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_sdk_compat.py +0 -0
  64. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_secrets.py +0 -0
  65. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_secrets_integration.py +0 -0
  66. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_secrets_pipeline.py +0 -0
  67. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_source_resolution.py +0 -0
  68. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_stages.py +0 -0
  69. {agentic_dev_loop-1.0.0a12 → agentic_dev_loop-1.0.0a13}/tests/test_verdict.py +0 -0
  70. {agentic_dev_loop-1.0.0a12 → 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.0a12
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.0a12"
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"
@@ -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)
@@ -274,11 +274,32 @@ def is_git_mutation(argv: list[str]) -> bool:
274
274
  return True
275
275
 
276
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
+
277
296
  def translate_mount_path(path: str) -> str:
278
297
  """expanduser + absolute resolution without symlink chasing. Identity on
279
298
  macOS/Windows/WSL2-with-Docker-Desktop (verbatim passthrough — Docker
280
- Desktop translates). Idempotent: translate(translate(p)) == translate(p)."""
281
- 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()
282
303
  if not p.is_absolute():
283
304
  p = p.absolute()
284
305
  return str(p)
@@ -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.0a12
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
@@ -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):
@@ -459,6 +459,21 @@ class TestResolveSandboxVenvDir:
459
459
  assert out == str(tmp_path / "venvs" / "engine")
460
460
  assert os.path.isabs(out)
461
461
 
462
+ def test_home_wins_over_userprofile(self, tmp_path, monkeypatch):
463
+ # The engine is driven from bash, where HOME is the authoritative home
464
+ # on every platform — CPython's Windows expanduser ignores HOME in
465
+ # favour of USERPROFILE, which would resolve `~` inconsistently.
466
+ monkeypatch.setenv("HOME", str(tmp_path / "home"))
467
+ monkeypatch.setenv("USERPROFILE", str(tmp_path / "prof"))
468
+ out = resolve_sandbox_venv_dir("~/venvs/engine", tmp_path / "ws")
469
+ assert out == str(tmp_path / "home" / "venvs" / "engine")
470
+
471
+ def test_userprofile_fallback_when_home_unset(self, tmp_path, monkeypatch):
472
+ monkeypatch.delenv("HOME", raising=False)
473
+ monkeypatch.setenv("USERPROFILE", str(tmp_path / "prof"))
474
+ out = resolve_sandbox_venv_dir("~/venvs/engine", tmp_path / "ws")
475
+ assert out == str(tmp_path / "prof" / "venvs" / "engine")
476
+
462
477
  def test_outside_workspace_accepted(self, tmp_path):
463
478
  ws = tmp_path / "ws"
464
479
  out = resolve_sandbox_venv_dir(str(tmp_path / "venvs"), ws)
@@ -3,6 +3,7 @@ reason when no daemon or integration image is present; never silently skipped
3
3
  in CI (the workflows pre-install Docker and the image)."""
4
4
  from __future__ import annotations
5
5
 
6
+ import os
6
7
  import shutil
7
8
 
8
9
  import pytest
@@ -249,8 +250,14 @@ class TestRelocatedVenvE2E:
249
250
  try:
250
251
  b.provision("python -m venv .venv-sandbox", "k1",
251
252
  str(tmp_path / "stamps"), force=False)
252
- assert (venv_dir / "bin" / "python").exists()
253
- assert not (ws / ".venv-sandbox").exists()
253
+ # 022: the venv materialized OUTSIDE the workspace. bin/python is
254
+ # a symlink to the CONTAINER's interpreter, which dangles on the
255
+ # host — existence must be checked without following links.
256
+ assert os.path.lexists(venv_dir / "bin" / "python")
257
+ assert (venv_dir / "pyvenv.cfg").exists()
258
+ # No venv content leaked into the workspace. Docker auto-creates
259
+ # the empty mountpoint dir, so its mere existence is not a leak.
260
+ assert not (ws / ".venv-sandbox" / "pyvenv.cfg").exists()
254
261
  req = SandboxExecutionRequest(
255
262
  argv=[".venv-sandbox/bin/python", "-c", "import sys; print('ok')"],
256
263
  cwd=ws,
@@ -5,7 +5,9 @@ from pathlib import Path
5
5
 
6
6
  import pytest
7
7
 
8
- from agentic_dev_loop.engine.loaders import PlanDocumentLoader, resolve_plan_docs, select_loader
8
+ from agentic_dev_loop.engine.loaders import (
9
+ PlanDocumentLoader, mark_plan_completed, resolve_plan_docs, select_loader,
10
+ )
9
11
  from agentic_dev_loop.engine.registry import Registry, default_registry
10
12
 
11
13
 
@@ -116,3 +118,110 @@ class TestSelectLoader:
116
118
 
117
119
  with pytest.raises(ValueError, match="[Dd]efaultable"):
118
120
  select_loader(reg, _args(a=None, b=None))
121
+
122
+
123
+ class TestMarkPlanCompleted:
124
+ def _write(self, tmp_path, text):
125
+ plan = tmp_path / "plan.md"
126
+ plan.write_text(text)
127
+ return plan
128
+
129
+ @pytest.mark.parametrize("status_line", [
130
+ "> **Status: COMPLETED** (2026-10-23)",
131
+ "> **Status: DONE**",
132
+ "Status: COMPLETED — shipped",
133
+ "**Status:** COMPLETED (2026-10-10)",
134
+ "- **Status**: COMPLETED — all stages done",
135
+ ])
136
+ def test_replaces_existing_status_line(self, tmp_path, status_line):
137
+ plan = self._write(tmp_path, f"# Plan\n\n{status_line}\n\nBody\n")
138
+
139
+ mark_plan_completed(plan)
140
+
141
+ assert plan.read_text() == "# Plan\n\n> **Status: COMPLETED**\n\nBody\n"
142
+
143
+ def test_replaces_inline_bold_status_line_entirely(self, tmp_path):
144
+ """The 013-style line with trailing prose is replaced wholesale."""
145
+ plan = self._write(
146
+ tmp_path,
147
+ "# Plan\n\n"
148
+ "**Status**: Completed — shipped: ... **Status: COMPLETED** (2026-10-23).\n\n"
149
+ "Body\n")
150
+
151
+ mark_plan_completed(plan)
152
+
153
+ assert plan.read_text() == "# Plan\n\n> **Status: COMPLETED**\n\nBody\n"
154
+
155
+ def test_inserts_after_title_when_no_status(self, tmp_path):
156
+ plan = self._write(tmp_path, "# Plan\n\nBody\n")
157
+
158
+ mark_plan_completed(plan)
159
+
160
+ assert plan.read_text() == "# Plan\n\n> **Status: COMPLETED**\n\nBody\n"
161
+
162
+ def test_inserts_at_top_when_no_title(self, tmp_path):
163
+ plan = self._write(tmp_path, "THE PLAN")
164
+
165
+ mark_plan_completed(plan)
166
+
167
+ assert plan.read_text() == "> **Status: COMPLETED**\n\nTHE PLAN"
168
+
169
+ def test_title_without_blank_separator_still_gets_separators(self, tmp_path):
170
+ plan = self._write(tmp_path, "# Plan\nBody\n")
171
+
172
+ mark_plan_completed(plan)
173
+
174
+ assert plan.read_text() == "# Plan\n\n> **Status: COMPLETED**\n\nBody\n"
175
+
176
+ def test_status_heading_is_not_treated_as_status_line(self, tmp_path):
177
+ plan = self._write(tmp_path, "# Plan\n\n## Status history\n\nBody\n")
178
+
179
+ mark_plan_completed(plan)
180
+
181
+ assert plan.read_text() == (
182
+ "# Plan\n\n> **Status: COMPLETED**\n\n## Status history\n\nBody\n")
183
+
184
+ def test_deep_status_line_is_ignored_and_stamp_inserted_after_title(self, tmp_path):
185
+ """Only the top of the doc is scanned; a status buried beyond the
186
+ first ten lines is not the 'existing status recorded' — the stamp
187
+ goes in after the title and the deep line stays untouched."""
188
+ filler = "\n".join(f"line {i}" for i in range(1, 15))
189
+ plan = self._write(tmp_path,
190
+ f"# Plan\n\n{filler}\n> **Status: IN PROGRESS**\n")
191
+
192
+ mark_plan_completed(plan)
193
+
194
+ text = plan.read_text()
195
+ assert text.startswith("# Plan\n\n> **Status: COMPLETED**\n\n")
196
+ assert "> **Status: IN PROGRESS**" in text
197
+
198
+ def test_idempotent_rerun(self, tmp_path):
199
+ plan = self._write(tmp_path, "# Plan\n\n> **Status: DONE**\n\nBody\n")
200
+
201
+ mark_plan_completed(plan)
202
+ once = plan.read_text()
203
+ mark_plan_completed(plan)
204
+
205
+ assert once == "# Plan\n\n> **Status: COMPLETED**\n\nBody\n"
206
+ assert plan.read_text() == once
207
+
208
+ def test_preserves_trailing_newline_absence(self, tmp_path):
209
+ plan = self._write(tmp_path, "# Plan\n\nBody") # no trailing newline
210
+
211
+ mark_plan_completed(plan)
212
+
213
+ assert plan.read_text() == "# Plan\n\n> **Status: COMPLETED**\n\nBody"
214
+
215
+ def test_preserves_trailing_newline(self, tmp_path):
216
+ plan = self._write(tmp_path, "# Plan\n\nBody\n")
217
+
218
+ mark_plan_completed(plan)
219
+
220
+ assert plan.read_text() == "# Plan\n\n> **Status: COMPLETED**\n\nBody\n"
221
+
222
+ def test_accepts_string_path(self, tmp_path):
223
+ plan = self._write(tmp_path, "THE PLAN")
224
+
225
+ mark_plan_completed(str(plan))
226
+
227
+ assert plan.read_text() == "> **Status: COMPLETED**\n\nTHE PLAN"
@@ -109,3 +109,83 @@ def test_trigger_errors_when_cli_absent():
109
109
 
110
110
  assert result.returncode != 0
111
111
  assert "pipx install" in result.stderr
112
+
113
+
114
+ def _stub_env(tmp_path, *, cli_body='echo "cli-ran: $*"', pipx_body="exit 0"):
115
+ bin_dir = tmp_path / "bin"
116
+ bin_dir.mkdir()
117
+ _make_stub(bin_dir, "agentic-dev-loop", cli_body)
118
+ _make_stub(bin_dir, "pipx", pipx_body)
119
+ return bin_dir
120
+
121
+
122
+ def test_trigger_rejects_both_flags(tmp_path):
123
+ bin_dir = _stub_env(tmp_path)
124
+
125
+ result = _run_script("--plan", "plan.md", "--aidlc-unit", "unit-3",
126
+ env=_env_with(bin_dir))
127
+
128
+ assert result.returncode != 0
129
+ assert ("pass exactly one of --plan <path> or --aidlc-unit <slug>, "
130
+ "not both") in result.stderr
131
+ assert "cli-ran:" not in result.stdout
132
+
133
+
134
+ def test_trigger_requires_exactly_one_flag(tmp_path):
135
+ bin_dir = _stub_env(tmp_path)
136
+
137
+ result = _run_script(env=_env_with(bin_dir))
138
+
139
+ assert result.returncode != 0
140
+ assert ("pass exactly one of --plan <path> or --aidlc-unit <slug>"
141
+ in result.stderr)
142
+ assert "cli-ran:" not in result.stdout
143
+
144
+
145
+ def test_trigger_forwards_aidlc_unit(tmp_path):
146
+ bin_dir = _stub_env(tmp_path)
147
+
148
+ result = _run_script("--aidlc-unit", "unit-3", env=_env_with(bin_dir))
149
+
150
+ assert result.returncode == 0, result.stderr
151
+ assert "cli-ran:" in result.stdout
152
+ assert "--aidlc-unit unit-3" in result.stdout
153
+
154
+
155
+ def test_trigger_unit_positional_is_aidlc_shorthand(tmp_path):
156
+ bin_dir = _stub_env(tmp_path)
157
+
158
+ result = _run_script("unit-3", "--dry-run", env=_env_with(bin_dir))
159
+
160
+ assert result.returncode == 0, result.stderr
161
+ assert "--aidlc-unit unit-3" in result.stdout
162
+ assert "--dry-run" in result.stdout
163
+
164
+
165
+ def test_trigger_passes_other_flags_through(tmp_path):
166
+ bin_dir = _stub_env(tmp_path)
167
+
168
+ result = _run_script("--plan", "plan.md", "--dry-run", "--max-attempts", "3",
169
+ env=_env_with(bin_dir))
170
+
171
+ assert result.returncode == 0, result.stderr
172
+ assert "--plan plan.md --dry-run --max-attempts 3" in result.stdout
173
+
174
+
175
+ def test_trigger_flag_missing_value_is_usage_error(tmp_path):
176
+ bin_dir = _stub_env(tmp_path)
177
+
178
+ result = _run_script("--plan", env=_env_with(bin_dir))
179
+
180
+ assert result.returncode != 0
181
+ assert "--plan requires a path" in result.stderr
182
+ assert "cli-ran:" not in result.stdout
183
+
184
+
185
+ def test_trigger_equals_forms_accepted(tmp_path):
186
+ bin_dir = _stub_env(tmp_path)
187
+
188
+ result = _run_script("--aidlc-unit=unit-3", env=_env_with(bin_dir))
189
+
190
+ assert result.returncode == 0, result.stderr
191
+ assert "--aidlc-unit unit-3" in result.stdout