agentic-dev-loop 1.0.0a39__tar.gz → 1.0.0a40__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 (100) hide show
  1. {agentic_dev_loop-1.0.0a39/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a40}/PKG-INFO +1 -1
  2. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/pyproject.toml +1 -1
  3. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/core/config.py +4 -1
  4. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/stages.py +25 -6
  5. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/viz/server.py +17 -6
  6. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40/src/agentic_dev_loop.egg-info}/PKG-INFO +1 -1
  7. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_config.py +37 -0
  8. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_qa_prompt.py +16 -0
  9. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_stages.py +87 -0
  10. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_verdict.py +24 -0
  11. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_viz.py +20 -0
  12. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/LICENSE +0 -0
  13. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/README.md +0 -0
  14. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/setup.cfg +0 -0
  15. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/__init__.py +0 -0
  16. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/core/__init__.py +0 -0
  17. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/core/paths.py +0 -0
  18. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/core/results.py +0 -0
  19. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/core/verdict.py +0 -0
  20. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/__init__.py +0 -0
  21. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/actions.py +0 -0
  22. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/agent_error_guide.md +0 -0
  23. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/agent_error_guide.py +0 -0
  24. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/agents.py +0 -0
  25. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/allowlist.py +0 -0
  26. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/brief_coverage.py +0 -0
  27. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/cli.py +0 -0
  28. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/deps.py +0 -0
  29. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  30. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
  31. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/egress_proxy.py +0 -0
  32. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  33. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  34. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/forbidden.py +0 -0
  35. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/graph.py +0 -0
  36. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  37. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/isolation.py +0 -0
  38. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/llm.py +0 -0
  39. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/loaders.py +0 -0
  40. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/mounts.py +0 -0
  41. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/outputs.py +0 -0
  42. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/presets.py +0 -0
  43. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/proc.py +0 -0
  44. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/registry.py +0 -0
  45. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
  46. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
  47. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/secrets.py +0 -0
  48. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
  49. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/sync_back.py +0 -0
  50. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/py.typed +0 -0
  51. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/viz/__init__.py +0 -0
  52. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/viz/__main__.py +0 -0
  53. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop.egg-info/SOURCES.txt +0 -0
  54. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  55. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  56. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  57. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  58. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_028_declared_caches.py +0 -0
  59. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_029_merge_on_conflict.py +0 -0
  60. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_032_stop_when_green.py +0 -0
  61. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_033_brief_step.py +0 -0
  62. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_035_finalize_nudge.py +0 -0
  63. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_036_banned_commands_and_lane_boundary.py +0 -0
  64. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_041_brief_coverage.py +0 -0
  65. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_actions.py +0 -0
  66. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_agent_error_guide.py +0 -0
  67. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_agents.py +0 -0
  68. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_aidlc.py +0 -0
  69. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_cli.py +0 -0
  70. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_deps.py +0 -0
  71. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_dry_run.py +0 -0
  72. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_e2e_fixtures.py +0 -0
  73. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_egress_proxy.py +0 -0
  74. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_graph.py +0 -0
  75. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_hostboundary.py +0 -0
  76. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_hostboundary_integration.py +0 -0
  77. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_isolation.py +0 -0
  78. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_isolation_integration.py +0 -0
  79. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_isolation_pbt.py +0 -0
  80. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_llm.py +0 -0
  81. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_loaders.py +0 -0
  82. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_module_contract.py +0 -0
  83. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_outputs.py +0 -0
  84. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_outputs_pbt.py +0 -0
  85. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_proc.py +0 -0
  86. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_publish.py +0 -0
  87. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_registry.py +0 -0
  88. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_repo_ascii.py +0 -0
  89. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_results.py +0 -0
  90. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_sdk_compat.py +0 -0
  91. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_secrets.py +0 -0
  92. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_secrets_integration.py +0 -0
  93. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_secrets_pipeline.py +0 -0
  94. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_source_resolution.py +0 -0
  95. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_step_workspace.py +0 -0
  96. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_sync_back.py +0 -0
  97. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_trigger_script.py +0 -0
  98. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_unit5_config.py +0 -0
  99. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_unit5_pbt.py +0 -0
  100. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a40}/tests/test_unit5_pure.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a39
3
+ Version: 1.0.0a40
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.0a39"
7
+ version = "1.0.0a40"
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"
@@ -148,6 +148,7 @@ class VerdictSpec(BaseModel):
148
148
  pass_: str = Field(alias="pass")
149
149
  fail: str = Field(alias="fail")
150
150
  unverifiable: str | None = None
151
+ env: str | None = None
151
152
 
152
153
 
153
154
  class Route(BaseModel):
@@ -404,6 +405,7 @@ class StepConfig(BaseModel):
404
405
  on_pass: RouteTarget | None = None
405
406
  on_fail: RouteTarget | None = None
406
407
  on_unverifiable: RouteTarget | None = None
408
+ on_env: RouteTarget | None = None
407
409
 
408
410
  @property
409
411
  def kind(self) -> str:
@@ -794,7 +796,8 @@ class PipelineConfig(BaseModel):
794
796
  raise ValueError(f"step name {n!r} collides with a reserved terminal")
795
797
  valid = set(names) | TERMINALS
796
798
  for step in self.steps:
797
- for route in (step.on_pass, step.on_fail, step.on_unverifiable):
799
+ for route in (step.on_pass, step.on_fail, step.on_unverifiable,
800
+ step.on_env):
798
801
  target = route_target(route)
799
802
  if target is not None and target not in valid:
800
803
  raise ValueError(
@@ -683,13 +683,13 @@ def _unclear_note(runtime: Runtime, step: StepConfig, attempt: int,
683
683
  captured response when it exists, so real findings are never swallowed."""
684
684
  expected = ""
685
685
  if step.verdicts is not None:
686
+ keywords = [f"`VERDICT: {step.verdicts.pass_}`",
687
+ f"`VERDICT: {step.verdicts.fail}`"]
686
688
  if step.verdicts.unverifiable is not None:
687
- expected = (f" (expected `VERDICT: {step.verdicts.pass_}`, "
688
- f"`VERDICT: {step.verdicts.fail}`, or "
689
- f"`VERDICT: {step.verdicts.unverifiable}` on its own line)")
690
- else:
691
- expected = (f" (expected `VERDICT: {step.verdicts.pass_}` or "
692
- f"`VERDICT: {step.verdicts.fail}` on its own line)")
689
+ keywords.append(f"`VERDICT: {step.verdicts.unverifiable}`")
690
+ if step.verdicts.env is not None:
691
+ keywords.append(f"`VERDICT: {step.verdicts.env}`")
692
+ expected = f" (expected {' or '.join(keywords)} on its own line)"
693
693
  if truncated:
694
694
  lead = (f"{step.name} agent was cut short on attempt {attempt} "
695
695
  f"(hit the {max_iterations}-turn limit or was stopped "
@@ -718,6 +718,8 @@ def make_agent_node(runtime: Runtime, step: StepConfig):
718
718
  verdicts_map = {step.verdicts.pass_: "pass", step.verdicts.fail: "fail"}
719
719
  if step.verdicts.unverifiable is not None:
720
720
  verdicts_map[step.verdicts.unverifiable] = "unverifiable"
721
+ if step.verdicts.env is not None:
722
+ verdicts_map[step.verdicts.env] = "env"
721
723
  captures = step.artifact is not None or bool(step.produces)
722
724
 
723
725
  def node(state: dict) -> dict:
@@ -787,6 +789,21 @@ def make_agent_node(runtime: Runtime, step: StepConfig):
787
789
  if captures and result.response is not None:
788
790
  update["artifacts"] = {step.name: result.response}
789
791
  return update
792
+ if verdict == "env":
793
+ print(f"[{subject}] {step.name} verdict: ENV "
794
+ f"(environmental, halting).", flush=True)
795
+ if step.on_env is None:
796
+ runtime.state_dir.mkdir(parents=True, exist_ok=True)
797
+ (runtime.state_dir / LAST_ERROR).write_text(
798
+ f"# Environmental failure (verdict: ENV)\n\n"
799
+ f"{result.response or 'no captured response'}\n",
800
+ encoding="utf-8")
801
+ return {"step_verdicts": merged,
802
+ "failed": True, "failed_step": step.name}
803
+ update = {"step_verdicts": merged, "failed": False}
804
+ if captures and result.response is not None:
805
+ update["artifacts"] = {step.name: result.response}
806
+ return update
790
807
  print(f"[{subject}] {step.name} verdict: fail on attempt {attempt}.",
791
808
  flush=True)
792
809
  update = {"step_verdicts": merged,
@@ -1115,6 +1132,8 @@ def make_router(runtime: Runtime, step: StepConfig):
1115
1132
  if step.on_unverifiable is not None:
1116
1133
  return resolve(step.on_unverifiable)
1117
1134
  return resolve(step.on_pass)
1135
+ if verdict == "env":
1136
+ return resolve(step.on_env) if step.on_env is not None else END
1118
1137
  if verdict == "fail":
1119
1138
  if step.on_fail is None:
1120
1139
  return END
@@ -546,6 +546,7 @@ PAGE = """<!doctype html>
546
546
  var verdictFail = new Set();
547
547
  var verdictPass = new Set();
548
548
  var verdictUnverifiable = new Set();
549
+ var verdictEnv = new Set();
549
550
  var fatalNodes = new Set();
550
551
  var maxAttempts = null;
551
552
  var attempt = null;
@@ -614,9 +615,15 @@ PAGE = """<!doctype html>
614
615
  if (verdictFail.has(n)) { return "fail"; }
615
616
  if (verdictPass.has(n)) { return "pass"; }
616
617
  if (verdictUnverifiable.has(n)) { return "unverifiable"; }
618
+ if (verdictEnv.has(n)) { return "env"; }
617
619
  return nodeStatus[n] || "pending";
618
620
  }
619
621
 
622
+ function renderStatusOf(n) {
623
+ var s = statusOf(n);
624
+ return s === "env" ? "fatal" : s;
625
+ }
626
+
620
627
  function updateBadges() {
621
628
  var box = el("verdicts");
622
629
  box.innerHTML = "";
@@ -627,6 +634,7 @@ PAGE = """<!doctype html>
627
634
  if (v === "pass") { b.className += " badge-pass"; }
628
635
  else if (v === "fail") { b.className += " badge-fail"; }
629
636
  else if (v === "unverifiable") { b.className += " badge-unverifiable"; }
637
+ else if (v === "env") { b.className += " badge-fail"; }
630
638
  else { b.className += " badge-unclear"; }
631
639
  b.textContent = step + ": " + (v === null || v === undefined ? "no verdict" : v);
632
640
  box.appendChild(b);
@@ -638,6 +646,7 @@ PAGE = """<!doctype html>
638
646
  verdictPass.clear();
639
647
  verdictFail.clear();
640
648
  verdictUnverifiable.clear();
649
+ verdictEnv.clear();
641
650
  currentVerdicts = {};
642
651
  nodeStatus = {};
643
652
  stageWord = {};
@@ -665,7 +674,7 @@ PAGE = """<!doctype html>
665
674
  var classLines = Object.keys(nodeStatus).filter(function (n) {
666
675
  return nodeIds.has(n);
667
676
  }).map(function (n) {
668
- return "class " + n + " " + statusOf(n) + ";";
677
+ return "class " + n + " " + renderStatusOf(n) + ";";
669
678
  });
670
679
  var src = mermaidSource + "\\n" + classDefs.join("\\n") + "\\n" + classLines.join("\\n");
671
680
  mermaid.render("graph-" + (renderCount++), src).then(function (res) {
@@ -756,9 +765,10 @@ PAGE = """<!doctype html>
756
765
  if (v === "pass") { suffix = " (verdict: PASS)"; }
757
766
  else if (v === "fail") { suffix = " (verdict: FAIL)"; }
758
767
  else if (v === "unverifiable") { suffix = " (verdict: UNVERIFIABLE)"; }
768
+ else if (v === "env") { suffix = " (verdict: ENV)"; }
759
769
  }
760
770
  status.textContent = (word ? " " + word : "") + suffix;
761
- header.className = "stage-header status-" + statusOf(node);
771
+ header.className = "stage-header status-" + renderStatusOf(node);
762
772
  }
763
773
 
764
774
  function setStageStatus(node, word) {
@@ -1072,10 +1082,11 @@ PAGE = """<!doctype html>
1072
1082
  Object.keys(ev.step_verdicts).forEach(function (k) {
1073
1083
  verdictSteps.add(k);
1074
1084
  var v = ev.step_verdicts[k];
1075
- if (v === "pass") { verdictPass.add(k); verdictFail.delete(k); verdictUnverifiable.delete(k); }
1076
- else if (v === "fail") { verdictFail.add(k); verdictPass.delete(k); verdictUnverifiable.delete(k); }
1077
- else if (v === "unverifiable") { verdictUnverifiable.add(k); verdictPass.delete(k); verdictFail.delete(k); }
1078
- else { verdictPass.delete(k); verdictFail.delete(k); verdictUnverifiable.delete(k); }
1085
+ if (v === "pass") { verdictPass.add(k); verdictFail.delete(k); verdictUnverifiable.delete(k); verdictEnv.delete(k); }
1086
+ else if (v === "fail") { verdictFail.add(k); verdictPass.delete(k); verdictUnverifiable.delete(k); verdictEnv.delete(k); }
1087
+ else if (v === "unverifiable") { verdictUnverifiable.add(k); verdictPass.delete(k); verdictFail.delete(k); verdictEnv.delete(k); }
1088
+ else if (v === "env") { verdictEnv.add(k); verdictPass.delete(k); verdictFail.delete(k); verdictUnverifiable.delete(k); }
1089
+ else { verdictPass.delete(k); verdictFail.delete(k); verdictUnverifiable.delete(k); verdictEnv.delete(k); }
1079
1090
  currentVerdicts[k] = v;
1080
1091
  });
1081
1092
  updateBadges();
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a39
3
+ Version: 1.0.0a40
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
@@ -637,3 +637,40 @@ class TestDepsPolicy:
637
637
  def test_lockfiles_accepts_a_single_string(self, tmp_path):
638
638
  data = _base() | {"lockfiles": "uv.lock"}
639
639
  assert _load(tmp_path, data).lockfiles == ["uv.lock"]
640
+
641
+
642
+ def test_four_keyword_verdict_and_on_env_load(tmp_path):
643
+ data = _base()
644
+ data["steps"][0] = {
645
+ "name": "qa", "agent": "qa", "artifact": "qa-report.md",
646
+ "verdicts": {"pass": "PASS", "fail": "FAIL",
647
+ "unverifiable": "UNVERIFIABLE", "env": "ENV"},
648
+ "on_pass": "success", "on_unverifiable": "success",
649
+ "on_env": "failure",
650
+ }
651
+ step = _load(tmp_path, data).steps[0]
652
+ assert step.verdicts.pass_ == "PASS"
653
+ assert step.verdicts.fail == "FAIL"
654
+ assert step.verdicts.unverifiable == "UNVERIFIABLE"
655
+ assert step.verdicts.env == "ENV"
656
+ assert step.on_env == "failure"
657
+
658
+
659
+ def test_dangling_on_env_rejected(tmp_path):
660
+ data = _base()
661
+ data["steps"][0]["on_env"] = "nope"
662
+ with pytest.raises(ValidationError, match="unknown route target 'nope'"):
663
+ _load(tmp_path, data)
664
+
665
+
666
+ def test_env_keyword_optional_three_keyword_still_loads(tmp_path):
667
+ data = _base()
668
+ data["steps"][0] = {
669
+ "name": "qa", "agent": "qa", "artifact": "qa-report.md",
670
+ "verdicts": {"pass": "PASS", "fail": "FAIL",
671
+ "unverifiable": "UNVERIFIABLE"},
672
+ "on_pass": "success", "on_unverifiable": "success",
673
+ }
674
+ step = _load(tmp_path, data).steps[0]
675
+ assert step.verdicts.env is None
676
+ assert step.on_env is None
@@ -35,3 +35,19 @@ def test_probe_plan_rules_present():
35
35
  assert "**Outcome probes.**" in text
36
36
  assert "**Concurrency probes.**" in text
37
37
  assert "**Error messages are assertions.**" in text
38
+
39
+
40
+ def test_env_verdict_rules_present():
41
+ text = _text()
42
+ assert "VERDICT: ENV" in text
43
+ assert "## ENV" in text
44
+ assert "environment" in text.lower()
45
+ assert "harness" in text
46
+ assert "2 attempts" in text
47
+
48
+
49
+ def test_env_unverifiable_distinction_present():
50
+ text = _text()
51
+ assert "UNVERIFIABLE" in text
52
+ assert "verification environment" in text
53
+ assert "product code" in text
@@ -362,6 +362,53 @@ class TestAgentNode:
362
362
  assert "VERDICT: FAIL" in note
363
363
  assert "VERDICT: UNVERIFIABLE" in note
364
364
 
365
+ def test_unclear_note_mentions_fourth_keyword(self, runtime, fake_agents):
366
+ step = make_cfg([
367
+ {"name": "qa", "agent": "qa", "artifact": "qa-report.md",
368
+ "verdicts": {"pass": "PASS", "fail": "FAIL",
369
+ "unverifiable": "UNVERIFIABLE", "env": "ENV"},
370
+ "on_pass": "success"}]).steps[0]
371
+ fake_agents.set("qa", [("write", "qa-report.md", "undecided")])
372
+ update = make_agent_node(runtime, step)(make_state())
373
+ (note,) = update["notes"]
374
+ assert "VERDICT: PASS" in note
375
+ assert "VERDICT: FAIL" in note
376
+ assert "VERDICT: UNVERIFIABLE" in note
377
+ assert "VERDICT: ENV" in note
378
+
379
+ def test_env_verdict_hard_fails_and_writes_last_error(self, runtime,
380
+ fake_agents):
381
+ step = make_cfg([
382
+ {"name": "qa", "agent": "qa", "artifact": "qa-report.md",
383
+ "verdicts": {"pass": "PASS", "fail": "FAIL",
384
+ "unverifiable": "UNVERIFIABLE", "env": "ENV"},
385
+ "on_pass": "success"}]).steps[0]
386
+ fake_agents.set("qa", [("write", "qa-report.md",
387
+ "probe timeout\nVERDICT: ENV")])
388
+ update = make_agent_node(runtime, step)(make_state())
389
+ assert update["step_verdicts"] == {"qa": "env"}
390
+ assert update["failed"] is True
391
+ assert update["failed_step"] == "qa"
392
+ assert "artifacts" not in update
393
+ err = (runtime.state_dir / "last-error.md").read_text()
394
+ assert "Environmental failure (verdict: ENV)" in err
395
+ assert "probe timeout" in err
396
+
397
+ def test_env_verdict_with_on_env_route_moves_without_hard_fail(
398
+ self, runtime, fake_agents):
399
+ step = make_cfg([
400
+ {"name": "qa", "agent": "qa", "artifact": "qa-report.md",
401
+ "verdicts": {"pass": "PASS", "fail": "FAIL",
402
+ "unverifiable": "UNVERIFIABLE", "env": "ENV"},
403
+ "on_pass": "success", "on_env": "failure"}]).steps[0]
404
+ fake_agents.set("qa", [("write", "qa-report.md",
405
+ "probe timeout\nVERDICT: ENV")])
406
+ update = make_agent_node(runtime, step)(make_state())
407
+ assert update["step_verdicts"] == {"qa": "env"}
408
+ assert update["failed"] is False
409
+ assert update["artifacts"] == {"qa": "probe timeout\nVERDICT: ENV"}
410
+ assert "failed_step" not in update
411
+
365
412
 
366
413
  class TestCommandNode:
367
414
  def _node(self, runtime, steps, idx=0):
@@ -640,6 +687,46 @@ class TestRouter:
640
687
  assert router(make_state(attempt=1,
641
688
  step_verdicts={"qa": "unverifiable"})) == "commit"
642
689
 
690
+ def test_env_routes_on_env(self, runtime):
691
+ cfg = make_cfg([
692
+ {"name": "dev", "agent": "dev", "on_pass": "qa"},
693
+ {"name": "qa", "agent": "qa", "artifact": "qa-report.md",
694
+ "verdicts": {"pass": "PASS", "fail": "FAIL",
695
+ "unverifiable": "UNVERIFIABLE", "env": "ENV"},
696
+ "on_pass": "commit", "on_fail": {"goto": "dev", "retry": True},
697
+ "on_env": "dev"},
698
+ {"name": "commit", "action": "commit", "on_pass": "success"},
699
+ ])
700
+ runtime = dataclasses.replace(runtime, cfg=cfg)
701
+ router = make_router(runtime, cfg.steps[1])
702
+ assert router(make_state(step_verdicts={"qa": "env"})) == "dev"
703
+
704
+ def test_env_with_on_env_failure_ends(self, runtime):
705
+ cfg = make_cfg([
706
+ {"name": "dev", "agent": "dev", "on_pass": "qa"},
707
+ {"name": "qa", "agent": "qa", "artifact": "qa-report.md",
708
+ "verdicts": {"pass": "PASS", "fail": "FAIL",
709
+ "unverifiable": "UNVERIFIABLE", "env": "ENV"},
710
+ "on_pass": "commit", "on_env": "failure"},
711
+ {"name": "commit", "action": "commit", "on_pass": "success"},
712
+ ])
713
+ runtime = dataclasses.replace(runtime, cfg=cfg)
714
+ router = make_router(runtime, cfg.steps[1])
715
+ assert router(make_state(step_verdicts={"qa": "env"})) == END
716
+
717
+ def test_env_without_on_env_is_defensive_end(self, runtime):
718
+ cfg = make_cfg([
719
+ {"name": "dev", "agent": "dev", "on_pass": "qa"},
720
+ {"name": "qa", "agent": "qa", "artifact": "qa-report.md",
721
+ "verdicts": {"pass": "PASS", "fail": "FAIL",
722
+ "unverifiable": "UNVERIFIABLE", "env": "ENV"},
723
+ "on_pass": "commit"},
724
+ {"name": "commit", "action": "commit", "on_pass": "success"},
725
+ ])
726
+ runtime = dataclasses.replace(runtime, cfg=cfg)
727
+ router = make_router(runtime, cfg.steps[1])
728
+ assert router(make_state(step_verdicts={"qa": "env"})) == END
729
+
643
730
 
644
731
  class TestBumpNode:
645
732
  def test_consumes_attempt_resets_verdicts_rebuilds_cleans(self, runtime):
@@ -217,3 +217,27 @@ class TestVerdictShape:
217
217
  a = _parse("VERDICT: PASS", TEST_VERDICTS).value
218
218
  b = _parse("VERDICT: **PASS**", TEST_VERDICTS).value
219
219
  assert a == b
220
+
221
+
222
+ QA_ENV_VERDICTS = {"PASS": "pass", "FAIL": "fail",
223
+ "UNVERIFIABLE": "unverifiable", "ENV": "env"}
224
+
225
+
226
+ class TestEnvVerdictParsing:
227
+ def test_parse_env(self):
228
+ _ok("VERDICT: ENV", QA_ENV_VERDICTS, "env")
229
+ _ok("VERDICT: ENV - sandbox network is dead",
230
+ QA_ENV_VERDICTS, "env")
231
+ _ok("verdict: env", QA_ENV_VERDICTS, "env")
232
+
233
+ def test_parse_verdict_env_returns_canonical_value(self):
234
+ assert parse_verdict("VERDICT: ENV", QA_ENV_VERDICTS) == "env"
235
+
236
+ def test_negated_env_stays_ambiguous_on_four_key_sets(self):
237
+ _err("VERDICT: NOT ENV", QA_ENV_VERDICTS, VerdictError.AMBIGUOUS)
238
+
239
+ def test_conflicting_pass_and_env_is_ambiguous(self):
240
+ _err("VERDICT: PASS\nVERDICT: ENV", QA_ENV_VERDICTS,
241
+ VerdictError.AMBIGUOUS)
242
+ assert parse_verdict("VERDICT: PASS\nVERDICT: ENV",
243
+ QA_ENV_VERDICTS) == "retry"
@@ -743,6 +743,13 @@ class TestServer:
743
743
  assert 'else if (v === "unverifiable")' in PAGE
744
744
  assert " (verdict: UNVERIFIABLE)" in PAGE
745
745
 
746
+ def test_page_constant_has_env_verdict_support(self):
747
+ assert "verdictEnv" in PAGE
748
+ # the new value maps to the fatal badge, never the unclear fallthrough
749
+ assert 'else if (v === "env")' in PAGE
750
+ assert " (verdict: ENV)" in PAGE
751
+ assert 'verdictEnv.has(n)' in PAGE
752
+
746
753
  def test_page_constant_color_palette(self):
747
754
  assert 'classDef running fill:#1976d2' in PAGE
748
755
  assert 'classDef fail fill:#ffb300' in PAGE
@@ -1119,6 +1126,19 @@ main();
1119
1126
  assert snaps[0]["badges"] == ["review: unverifiable"]
1120
1127
  assert snaps[0]["classes"]["review"] == "unverifiable"
1121
1128
 
1129
+ def test_env_verdict_badge_and_fatal_class(self, node, tmp_path):
1130
+ events = [
1131
+ {"type": "run_started", "subject": "s", "max_attempts": 5,
1132
+ "attempt": 1, "thread_id": "t"},
1133
+ {"type": "node_finished", "node": "review", "ok": True},
1134
+ {"type": "state", "attempt": 1,
1135
+ "step_verdicts": {"review": "env"}, "failed": False},
1136
+ ]
1137
+ snaps = self._run_page(node, events, tmp_path)
1138
+
1139
+ assert snaps[0]["badges"] == ["review: env"]
1140
+ assert snaps[0]["classes"]["review"] == "fatal"
1141
+
1122
1142
  def test_copy_button_copies_console_text(self, node, tmp_path):
1123
1143
  events = [
1124
1144
  {"type": "run_started", "subject": "s", "max_attempts": 5,