agentic-dev-loop 1.0.0a14__tar.gz → 1.0.0a15__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 (74) hide show
  1. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/PKG-INFO +1 -1
  2. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/pyproject.toml +1 -1
  3. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop/core/config.py +3 -1
  4. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop/engine/stages.py +17 -2
  5. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop/viz/server.py +12 -3
  6. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop.egg-info/PKG-INFO +1 -1
  7. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop.egg-info/SOURCES.txt +1 -0
  8. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_config.py +22 -0
  9. agentic_dev_loop-1.0.0a15/tests/test_qa_prompt.py +29 -0
  10. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_stages.py +59 -0
  11. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_verdict.py +20 -1
  12. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_viz.py +22 -0
  13. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/LICENSE +0 -0
  14. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/README.md +0 -0
  15. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/setup.cfg +0 -0
  16. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop/__init__.py +0 -0
  17. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop/core/__init__.py +0 -0
  18. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop/core/results.py +0 -0
  19. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop/core/verdict.py +0 -0
  20. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop/engine/__init__.py +0 -0
  21. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop/engine/actions.py +0 -0
  22. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop/engine/agents.py +0 -0
  23. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop/engine/cli.py +0 -0
  24. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop/engine/deps.py +0 -0
  25. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  26. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  27. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  28. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop/engine/graph.py +0 -0
  29. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  30. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop/engine/isolation.py +0 -0
  31. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop/engine/llm.py +0 -0
  32. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop/engine/loaders.py +0 -0
  33. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop/engine/outputs.py +0 -0
  34. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop/engine/proc.py +0 -0
  35. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop/engine/registry.py +0 -0
  36. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
  37. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
  38. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop/engine/secrets.py +0 -0
  39. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop/py.typed +0 -0
  40. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop/viz/__init__.py +0 -0
  41. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop/viz/__main__.py +0 -0
  42. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  43. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  44. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  45. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  46. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_actions.py +0 -0
  47. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_agents.py +0 -0
  48. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_aidlc.py +0 -0
  49. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_cli.py +0 -0
  50. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_deps.py +0 -0
  51. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_dry_run.py +0 -0
  52. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_e2e_fixtures.py +0 -0
  53. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_graph.py +0 -0
  54. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_hostboundary.py +0 -0
  55. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_hostboundary_integration.py +0 -0
  56. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_isolation.py +0 -0
  57. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_isolation_integration.py +0 -0
  58. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_isolation_pbt.py +0 -0
  59. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_llm.py +0 -0
  60. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_loaders.py +0 -0
  61. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_module_contract.py +0 -0
  62. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_outputs.py +0 -0
  63. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_outputs_pbt.py +0 -0
  64. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_proc.py +0 -0
  65. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_publish.py +0 -0
  66. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_registry.py +0 -0
  67. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_repo_ascii.py +0 -0
  68. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_results.py +0 -0
  69. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_sdk_compat.py +0 -0
  70. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_secrets.py +0 -0
  71. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_secrets_integration.py +0 -0
  72. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_secrets_pipeline.py +0 -0
  73. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_source_resolution.py +0 -0
  74. {agentic_dev_loop-1.0.0a14 → agentic_dev_loop-1.0.0a15}/tests/test_trigger_script.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a14
3
+ Version: 1.0.0a15
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.0a14"
7
+ version = "1.0.0a15"
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"
@@ -19,6 +19,7 @@ class VerdictSpec(BaseModel):
19
19
 
20
20
  pass_: str = Field(alias="pass")
21
21
  fail: str = Field(alias="fail")
22
+ unverifiable: str | None = None
22
23
 
23
24
 
24
25
  class Route(BaseModel):
@@ -131,6 +132,7 @@ class StepConfig(BaseModel):
131
132
  secrets: list[str] = [] # declared ref names; action steps reject
132
133
  on_pass: RouteTarget | None = None
133
134
  on_fail: RouteTarget | None = None
135
+ on_unverifiable: RouteTarget | None = None
134
136
 
135
137
  @property
136
138
  def kind(self) -> str:
@@ -286,7 +288,7 @@ class PipelineConfig(BaseModel):
286
288
  raise ValueError(f"step name {n!r} collides with a reserved terminal")
287
289
  valid = set(names) | TERMINALS
288
290
  for step in self.steps:
289
- for route in (step.on_pass, step.on_fail):
291
+ for route in (step.on_pass, step.on_fail, step.on_unverifiable):
290
292
  target = route_target(route)
291
293
  if target is not None and target not in valid:
292
294
  raise ValueError(
@@ -254,8 +254,13 @@ def _unclear_note(runtime: Runtime, step: StepConfig, attempt: int,
254
254
  captured response when it exists, so real findings are never swallowed."""
255
255
  expected = ""
256
256
  if step.verdicts is not None:
257
- expected = (f" (expected `VERDICT: {step.verdicts.pass_}` or "
258
- f"`VERDICT: {step.verdicts.fail}` on its own line)")
257
+ if step.verdicts.unverifiable is not None:
258
+ expected = (f" (expected `VERDICT: {step.verdicts.pass_}`, "
259
+ f"`VERDICT: {step.verdicts.fail}`, or "
260
+ f"`VERDICT: {step.verdicts.unverifiable}` on its own line)")
261
+ else:
262
+ expected = (f" (expected `VERDICT: {step.verdicts.pass_}` or "
263
+ f"`VERDICT: {step.verdicts.fail}` on its own line)")
259
264
  if truncated:
260
265
  lead = (f"{step.name} agent was cut short on attempt {attempt} "
261
266
  f"(hit the {max_iterations}-turn limit or was stopped "
@@ -282,6 +287,8 @@ def make_agent_node(runtime: Runtime, step: StepConfig):
282
287
  verdicts_map = None
283
288
  if step.verdicts is not None:
284
289
  verdicts_map = {step.verdicts.pass_: "pass", step.verdicts.fail: "fail"}
290
+ if step.verdicts.unverifiable is not None:
291
+ verdicts_map[step.verdicts.unverifiable] = "unverifiable"
285
292
  captures = step.artifact is not None or bool(step.produces)
286
293
 
287
294
  def node(state: dict) -> dict:
@@ -334,6 +341,10 @@ def make_agent_node(runtime: Runtime, step: StepConfig):
334
341
  print(f"[{subject}] {step.name} VERDICT: PASS on attempt {attempt}.",
335
342
  flush=True)
336
343
  return {"step_verdicts": merged, "failed": False}
344
+ if verdict == "unverifiable":
345
+ print(f"[{subject}] {step.name} qa verdict: UNVERIFIABLE "
346
+ f"(moves on).", flush=True)
347
+ return {"step_verdicts": merged, "failed": False}
337
348
  print(f"[{subject}] {step.name} verdict: fail on attempt {attempt}.",
338
349
  flush=True)
339
350
  update = {"step_verdicts": merged,
@@ -519,6 +530,10 @@ def make_router(runtime: Runtime, step: StepConfig):
519
530
  verdict = state["step_verdicts"].get(step.name)
520
531
  if verdict == "pass":
521
532
  return resolve(step.on_pass)
533
+ if verdict == "unverifiable":
534
+ if step.on_unverifiable is not None:
535
+ return resolve(step.on_unverifiable)
536
+ return resolve(step.on_pass)
522
537
  if verdict == "fail":
523
538
  if step.on_fail is None:
524
539
  return END
@@ -472,6 +472,7 @@ PAGE = """<!doctype html>
472
472
  background: #f0f0f0; color: #333; }
473
473
  .badge-pass { background: #e8f5e9; color: #2e7d32; }
474
474
  .badge-fail { background: #ffebee; color: #c62828; }
475
+ .badge-unverifiable { background: #ede7f6; color: #4527a0; }
475
476
  .badge-unclear { background: #fff8e1; color: #f57f17; }
476
477
  #stages { flex: 1 1 auto; min-height: 0; overflow-y: auto; display: flex;
477
478
  flex-direction: column; gap: 8px; }
@@ -487,6 +488,7 @@ PAGE = """<!doctype html>
487
488
  .status-pass { color: #2e7d32; }
488
489
  .status-fail { color: #b26a00; }
489
490
  .status-fatal { color: #c62828; }
491
+ .status-unverifiable { color: #7e57c2; }
490
492
  .console-wrap { max-height: 240px; overflow-y: auto; background: #1e1e1e; }
491
493
  .stage-console { margin: 0; padding: 8px 10px; background: #1e1e1e;
492
494
  color: #e0e0e0; font: 12px/1.5 ui-monospace, SFMono-Regular,
@@ -543,6 +545,7 @@ PAGE = """<!doctype html>
543
545
  var currentVerdicts = {};
544
546
  var verdictFail = new Set();
545
547
  var verdictPass = new Set();
548
+ var verdictUnverifiable = new Set();
546
549
  var fatalNodes = new Set();
547
550
  var maxAttempts = null;
548
551
  var attempt = null;
@@ -610,6 +613,7 @@ PAGE = """<!doctype html>
610
613
  if (nodeStatus[n] === "fatal") { return "fatal"; }
611
614
  if (verdictFail.has(n)) { return "fail"; }
612
615
  if (verdictPass.has(n)) { return "pass"; }
616
+ if (verdictUnverifiable.has(n)) { return "unverifiable"; }
613
617
  return nodeStatus[n] || "pending";
614
618
  }
615
619
 
@@ -622,6 +626,7 @@ PAGE = """<!doctype html>
622
626
  b.className = "badge";
623
627
  if (v === "pass") { b.className += " badge-pass"; }
624
628
  else if (v === "fail") { b.className += " badge-fail"; }
629
+ else if (v === "unverifiable") { b.className += " badge-unverifiable"; }
625
630
  else { b.className += " badge-unclear"; }
626
631
  b.textContent = step + ": " + (v === null || v === undefined ? "no verdict" : v);
627
632
  box.appendChild(b);
@@ -632,6 +637,7 @@ PAGE = """<!doctype html>
632
637
  verdictSteps.clear();
633
638
  verdictPass.clear();
634
639
  verdictFail.clear();
640
+ verdictUnverifiable.clear();
635
641
  currentVerdicts = {};
636
642
  nodeStatus = {};
637
643
  stageWord = {};
@@ -653,6 +659,7 @@ PAGE = """<!doctype html>
653
659
  "classDef pass fill:#2e7d32,stroke:#2e7d32,color:#fff",
654
660
  "classDef fail fill:#ffb300,stroke:#ffb300,color:#fff",
655
661
  "classDef fatal fill:#c62828,stroke:#c62828,color:#fff",
662
+ "classDef unverifiable fill:#7e57c2,stroke:#7e57c2,color:#fff",
656
663
  "classDef pending fill:#f2f0ff,stroke:#555,color:#000"
657
664
  ];
658
665
  var classLines = Object.keys(nodeStatus).filter(function (n) {
@@ -748,6 +755,7 @@ PAGE = """<!doctype html>
748
755
  var v = currentVerdicts[node];
749
756
  if (v === "pass") { suffix = " (verdict: PASS)"; }
750
757
  else if (v === "fail") { suffix = " (verdict: FAIL)"; }
758
+ else if (v === "unverifiable") { suffix = " (verdict: UNVERIFIABLE)"; }
751
759
  }
752
760
  status.textContent = (word ? " " + word : "") + suffix;
753
761
  header.className = "stage-header status-" + statusOf(node);
@@ -1064,9 +1072,10 @@ PAGE = """<!doctype html>
1064
1072
  Object.keys(ev.step_verdicts).forEach(function (k) {
1065
1073
  verdictSteps.add(k);
1066
1074
  var v = ev.step_verdicts[k];
1067
- if (v === "pass") { verdictPass.add(k); verdictFail.delete(k); }
1068
- else if (v === "fail") { verdictFail.add(k); verdictPass.delete(k); }
1069
- else { verdictPass.delete(k); verdictFail.delete(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); }
1070
1079
  currentVerdicts[k] = v;
1071
1080
  });
1072
1081
  updateBadges();
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a14
3
+ Version: 1.0.0a15
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
@@ -57,6 +57,7 @@ tests/test_outputs.py
57
57
  tests/test_outputs_pbt.py
58
58
  tests/test_proc.py
59
59
  tests/test_publish.py
60
+ tests/test_qa_prompt.py
60
61
  tests/test_registry.py
61
62
  tests/test_repo_ascii.py
62
63
  tests/test_results.py
@@ -195,6 +195,28 @@ def test_unknown_verdicts_key_rejected(tmp_path):
195
195
  _load(tmp_path, data)
196
196
 
197
197
 
198
+ def test_three_keyword_verdict_and_on_unverifiable_load(tmp_path):
199
+ data = _base()
200
+ data["steps"][0] = {
201
+ "name": "qa", "agent": "qa", "artifact": "qa-report.md",
202
+ "verdicts": {"pass": "PASS", "fail": "FAIL",
203
+ "unverifiable": "UNVERIFIABLE"},
204
+ "on_pass": "success", "on_unverifiable": "success",
205
+ }
206
+ step = _load(tmp_path, data).steps[0]
207
+ assert step.verdicts.pass_ == "PASS"
208
+ assert step.verdicts.fail == "FAIL"
209
+ assert step.verdicts.unverifiable == "UNVERIFIABLE"
210
+ assert step.on_unverifiable == "success"
211
+
212
+
213
+ def test_dangling_on_unverifiable_rejected(tmp_path):
214
+ data = _base()
215
+ data["steps"][0]["on_unverifiable"] = "nope"
216
+ with pytest.raises(ValidationError, match="unknown route target 'nope'"):
217
+ _load(tmp_path, data)
218
+
219
+
198
220
  def test_dangling_on_pass_rejected(tmp_path):
199
221
  data = _base()
200
222
  data["steps"][0]["on_pass"] = "nope"
@@ -0,0 +1,29 @@
1
+ """QA prompt contract (024): the no-substitution rule and the three-way
2
+ verdict rules live in the dogfood copy of sdk_agents/qa.agent.md."""
3
+
4
+ from pathlib import Path
5
+
6
+ QA_PROMPT = Path(__file__).parent.parent / "agentic-dev-loop" / "sdk_agents" / "qa.agent.md"
7
+
8
+
9
+ def _text() -> str:
10
+ return QA_PROMPT.read_text(encoding="utf-8")
11
+
12
+
13
+ def test_no_substitution_rule_present():
14
+ text = _text()
15
+ assert "Never substitute unrelated generic probes" in text
16
+ assert "UNVERIFIABLE" in text
17
+ assert "blocking reason" in text
18
+
19
+
20
+ def test_unverifiable_verdict_rules_present():
21
+ text = _text()
22
+ assert "VERDICT: PASS" in text
23
+ assert "VERDICT: UNVERIFIABLE" in text
24
+ assert "VERDICT: FAIL" in text
25
+ assert "## UNVERIFIABLE" in text
26
+
27
+
28
+ def test_inconclusive_budget_rule_retained():
29
+ assert "INCONCLUSIVE" in _text()
@@ -251,6 +251,36 @@ class TestAgentNode:
251
251
  make_agent_node(runtime, step)(make_state())
252
252
  assert fake_agents.calls[0]["max_iterations"] == 200
253
253
 
254
+ def test_unverifiable_verdict_records_and_moves_on(self, runtime,
255
+ fake_agents, capsys):
256
+ step = make_cfg([
257
+ {"name": "dev", "agent": "dev", "on_pass": "qa"},
258
+ {"name": "qa", "agent": "qa", "artifact": "qa-report.md",
259
+ "verdicts": {"pass": "PASS", "fail": "FAIL",
260
+ "unverifiable": "UNVERIFIABLE"},
261
+ "on_pass": "commit", "on_fail": {"goto": "dev", "retry": True},
262
+ "on_unverifiable": "commit"},
263
+ {"name": "commit", "action": "commit", "on_pass": "success"}]).steps[1]
264
+ fake_agents.set("qa", [("write", "qa-report.md", "VERDICT: UNVERIFIABLE")])
265
+ update = make_agent_node(runtime, step)(make_state())
266
+ assert update["step_verdicts"] == {"qa": "unverifiable"}
267
+ assert update["failed"] is False
268
+ assert "notes" not in update
269
+ assert "UNVERIFIABLE (moves on)" in capsys.readouterr().out
270
+
271
+ def test_unclear_note_mentions_third_keyword(self, runtime, fake_agents):
272
+ step = make_cfg([
273
+ {"name": "qa", "agent": "qa", "artifact": "qa-report.md",
274
+ "verdicts": {"pass": "PASS", "fail": "FAIL",
275
+ "unverifiable": "UNVERIFIABLE"},
276
+ "on_pass": "success"}]).steps[0]
277
+ fake_agents.set("qa", [("write", "qa-report.md", "undecided")])
278
+ update = make_agent_node(runtime, step)(make_state())
279
+ (note,) = update["notes"]
280
+ assert "VERDICT: PASS" in note
281
+ assert "VERDICT: FAIL" in note
282
+ assert "VERDICT: UNVERIFIABLE" in note
283
+
254
284
 
255
285
  class TestCommandNode:
256
286
  def _node(self, runtime, steps, idx=0):
@@ -464,6 +494,35 @@ class TestRouter:
464
494
  router = make_router(runtime, cfg.steps[1])
465
495
  assert router(make_state(step_verdicts={"c": "fail"})) == "a"
466
496
 
497
+ def test_unverifiable_routes_on_unverifiable(self, runtime):
498
+ cfg = make_cfg([
499
+ {"name": "dev", "agent": "dev", "on_pass": "qa"},
500
+ {"name": "qa", "agent": "qa", "artifact": "qa-report.md",
501
+ "verdicts": {"pass": "PASS", "fail": "FAIL",
502
+ "unverifiable": "UNVERIFIABLE"},
503
+ "on_pass": "commit", "on_fail": {"goto": "dev", "retry": True},
504
+ "on_unverifiable": "commit"},
505
+ {"name": "commit", "action": "commit", "on_pass": "success"},
506
+ ])
507
+ runtime = dataclasses.replace(runtime, cfg=cfg)
508
+ router = make_router(runtime, cfg.steps[1])
509
+ assert router(make_state(step_verdicts={"qa": "unverifiable"})) == "commit"
510
+
511
+ def test_unverifiable_falls_back_to_on_pass_and_never_retries(self, runtime):
512
+ cfg = make_cfg([
513
+ {"name": "dev", "agent": "dev", "on_pass": "qa"},
514
+ {"name": "qa", "agent": "qa", "artifact": "qa-report.md",
515
+ "verdicts": {"pass": "PASS", "fail": "FAIL",
516
+ "unverifiable": "UNVERIFIABLE"},
517
+ "on_pass": "commit", "on_fail": {"goto": "dev", "retry": True}},
518
+ {"name": "commit", "action": "commit", "on_pass": "success"},
519
+ ])
520
+ runtime = dataclasses.replace(runtime, cfg=cfg)
521
+ router = make_router(runtime, cfg.steps[1])
522
+ # unset on_unverifiable -> on_pass; never on_fail's bump retry
523
+ assert router(make_state(attempt=1,
524
+ step_verdicts={"qa": "unverifiable"})) == "commit"
525
+
467
526
 
468
527
  class TestBumpNode:
469
528
  def test_consumes_attempt_resets_verdicts_rebuilds_cleans(self, runtime):
@@ -3,10 +3,11 @@ Result API (BR-2): decisions route as `Result.ok(Verdict)`; verdict lines
3
3
  without a decision, or conflicting decisions, are typed errors that feed
4
4
  the attempt-bounded retry path - never exceptions, never silent routing."""
5
5
 
6
- from agentic_dev_loop.core.verdict import Verdict, VerdictError
6
+ from agentic_dev_loop.core.verdict import Verdict, VerdictError, parse_verdict
7
7
 
8
8
  REVIEW_VERDICTS = {"APPROVED": "approved", "NEEDS CHANGES": "needs_changes"}
9
9
  TEST_VERDICTS = {"PASS": "pass", "FAIL": "fail"}
10
+ QA_VERDICTS = {"PASS": "pass", "FAIL": "fail", "UNVERIFIABLE": "unverifiable"}
10
11
 
11
12
 
12
13
  def _parse(text, verdicts):
@@ -169,6 +170,24 @@ class TestVerdictParsing:
169
170
  def test_not_unknown_keyword_rejected(self):
170
171
  _err("VERDICT: NOT MAYBE", REVIEW_VERDICTS, VerdictError.AMBIGUOUS)
171
172
 
173
+ def test_parse_unverifiable(self):
174
+ _ok("VERDICT: UNVERIFIABLE", QA_VERDICTS, "unverifiable")
175
+ _ok("VERDICT: UNVERIFIABLE - no docker in the sandbox",
176
+ QA_VERDICTS, "unverifiable")
177
+ _ok("verdict: unverifiable", QA_VERDICTS, "unverifiable")
178
+
179
+ def test_parse_verdict_unverifiable_returns_canonical_value(self):
180
+ assert parse_verdict("VERDICT: UNVERIFIABLE", QA_VERDICTS) == "unverifiable"
181
+
182
+ def test_negated_unverifiable_stays_ambiguous_on_three_key_sets(self):
183
+ _err("VERDICT: NOT UNVERIFIABLE", QA_VERDICTS, VerdictError.AMBIGUOUS)
184
+
185
+ def test_conflicting_pass_and_unverifiable_is_ambiguous(self):
186
+ _err("VERDICT: PASS\nVERDICT: UNVERIFIABLE", QA_VERDICTS,
187
+ VerdictError.AMBIGUOUS)
188
+ assert parse_verdict("VERDICT: PASS\nVERDICT: UNVERIFIABLE",
189
+ QA_VERDICTS) == "retry"
190
+
172
191
 
173
192
  class TestConflictingDecisions:
174
193
  """BR-2.4: conflicting *decisions* are never silently resolved."""
@@ -734,6 +734,15 @@ class TestServer:
734
734
  assert "verdictFail" in PAGE
735
735
  assert "verdictPass" in PAGE
736
736
 
737
+ def test_page_constant_has_unverifiable_verdict_support(self):
738
+ assert "classDef unverifiable" in PAGE
739
+ assert ".badge-unverifiable" in PAGE
740
+ assert ".status-unverifiable" in PAGE
741
+ assert "verdictUnverifiable" in PAGE
742
+ # the new value maps to badge-unverifiable, not the unclear fallthrough
743
+ assert 'else if (v === "unverifiable")' in PAGE
744
+ assert " (verdict: UNVERIFIABLE)" in PAGE
745
+
737
746
  def test_page_constant_color_palette(self):
738
747
  assert 'classDef running fill:#1976d2' in PAGE
739
748
  assert 'classDef fail fill:#ffb300' in PAGE
@@ -1097,6 +1106,19 @@ main();
1097
1106
  assert snaps[1]["classes"]["test"] == "pass"
1098
1107
  assert snaps[1]["classes"]["review"] == "fail"
1099
1108
 
1109
+ def test_unverifiable_verdict_badge_and_class(self, node, tmp_path):
1110
+ events = [
1111
+ {"type": "run_started", "subject": "s", "max_attempts": 5,
1112
+ "attempt": 1, "thread_id": "t"},
1113
+ {"type": "node_finished", "node": "review", "ok": True},
1114
+ {"type": "state", "attempt": 1,
1115
+ "step_verdicts": {"review": "unverifiable"}, "failed": False},
1116
+ ]
1117
+ snaps = self._run_page(node, events, tmp_path)
1118
+
1119
+ assert snaps[0]["badges"] == ["review: unverifiable"]
1120
+ assert snaps[0]["classes"]["review"] == "unverifiable"
1121
+
1100
1122
  def test_copy_button_copies_console_text(self, node, tmp_path):
1101
1123
  events = [
1102
1124
  {"type": "run_started", "subject": "s", "max_attempts": 5,