agentic-dev-loop 1.0.0a38__tar.gz → 1.0.0a39__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.0a38/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a39}/PKG-INFO +13 -1
  2. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/README.md +12 -0
  3. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/pyproject.toml +1 -1
  4. agentic_dev_loop-1.0.0a39/src/agentic_dev_loop/engine/brief_coverage.py +96 -0
  5. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/graph.py +7 -0
  6. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/stages.py +87 -12
  7. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39/src/agentic_dev_loop.egg-info}/PKG-INFO +13 -1
  8. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop.egg-info/SOURCES.txt +2 -0
  9. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_033_brief_step.py +26 -10
  10. agentic_dev_loop-1.0.0a39/tests/test_041_brief_coverage.py +254 -0
  11. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_e2e_fixtures.py +12 -1
  12. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/LICENSE +0 -0
  13. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/setup.cfg +0 -0
  14. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/__init__.py +0 -0
  15. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/core/__init__.py +0 -0
  16. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/core/config.py +0 -0
  17. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/core/paths.py +0 -0
  18. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/core/results.py +0 -0
  19. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/core/verdict.py +0 -0
  20. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/__init__.py +0 -0
  21. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/actions.py +0 -0
  22. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/agent_error_guide.md +0 -0
  23. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/agent_error_guide.py +0 -0
  24. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/agents.py +0 -0
  25. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/allowlist.py +0 -0
  26. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/cli.py +0 -0
  27. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/deps.py +0 -0
  28. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  29. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
  30. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/egress_proxy.py +0 -0
  31. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  32. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  33. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/forbidden.py +0 -0
  34. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  35. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/isolation.py +0 -0
  36. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/llm.py +0 -0
  37. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/loaders.py +0 -0
  38. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/mounts.py +0 -0
  39. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/outputs.py +0 -0
  40. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/presets.py +0 -0
  41. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/proc.py +0 -0
  42. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/registry.py +0 -0
  43. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
  44. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
  45. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/secrets.py +0 -0
  46. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
  47. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/sync_back.py +0 -0
  48. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/py.typed +0 -0
  49. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/viz/__init__.py +0 -0
  50. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/viz/__main__.py +0 -0
  51. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/viz/server.py +0 -0
  52. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  53. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  54. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  55. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  56. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_028_declared_caches.py +0 -0
  57. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_029_merge_on_conflict.py +0 -0
  58. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_032_stop_when_green.py +0 -0
  59. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_035_finalize_nudge.py +0 -0
  60. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_036_banned_commands_and_lane_boundary.py +0 -0
  61. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_actions.py +0 -0
  62. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_agent_error_guide.py +0 -0
  63. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_agents.py +0 -0
  64. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_aidlc.py +0 -0
  65. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_cli.py +0 -0
  66. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_config.py +0 -0
  67. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_deps.py +0 -0
  68. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_dry_run.py +0 -0
  69. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_egress_proxy.py +0 -0
  70. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_graph.py +0 -0
  71. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_hostboundary.py +0 -0
  72. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_hostboundary_integration.py +0 -0
  73. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_isolation.py +0 -0
  74. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_isolation_integration.py +0 -0
  75. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_isolation_pbt.py +0 -0
  76. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_llm.py +0 -0
  77. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_loaders.py +0 -0
  78. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_module_contract.py +0 -0
  79. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_outputs.py +0 -0
  80. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_outputs_pbt.py +0 -0
  81. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_proc.py +0 -0
  82. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_publish.py +0 -0
  83. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_qa_prompt.py +0 -0
  84. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_registry.py +0 -0
  85. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_repo_ascii.py +0 -0
  86. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_results.py +0 -0
  87. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_sdk_compat.py +0 -0
  88. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_secrets.py +0 -0
  89. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_secrets_integration.py +0 -0
  90. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_secrets_pipeline.py +0 -0
  91. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_source_resolution.py +0 -0
  92. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_stages.py +0 -0
  93. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_step_workspace.py +0 -0
  94. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_sync_back.py +0 -0
  95. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_trigger_script.py +0 -0
  96. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_unit5_config.py +0 -0
  97. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_unit5_pbt.py +0 -0
  98. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_unit5_pure.py +0 -0
  99. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/tests/test_verdict.py +0 -0
  100. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a39}/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.0a38
3
+ Version: 1.0.0a39
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
@@ -260,6 +260,18 @@ run start (or when its inputs change), its `produces:` artifact survives attempt
260
260
  starts, and the dev/review agents read that digest instead of re-deriving repo
261
261
  rules and environment facts every attempt.
262
262
 
263
+ The brief digest is mechanically coverage-checked: the four section headings
264
+ (`Repo rules digest`, `Unit scope and lane digest`, `Environment facts`, and
265
+ `Open risks`) must appear, every anchor extracted from the `include` files
266
+ (markdown `##` headings, and YAML category names plus `enforcement_levels`)
267
+ must appear on its own line (case-insensitive, whitespace-collapsed, `**`
268
+ decoration allowed), and every `env_facts` entry must appear verbatim. A miss
269
+ re-runs the brief agent once per missing set with a `## Coverage retry`
270
+ correction note, bounded to three retries per input set; if the same items are
271
+ still dropped after the cap, the run fails and `last-error.md` names the
272
+ persistent drops. Include files with no extractable anchors pass vacuously, and
273
+ unusual YAML rule shapes are under-checked, never over-checked.
274
+
263
275
  ### Secrets (`secrets:`) - refs only, never values
264
276
 
265
277
  Secret values never belong in the pipeline yaml, the workspace, or `state_dir`.
@@ -218,6 +218,18 @@ run start (or when its inputs change), its `produces:` artifact survives attempt
218
218
  starts, and the dev/review agents read that digest instead of re-deriving repo
219
219
  rules and environment facts every attempt.
220
220
 
221
+ The brief digest is mechanically coverage-checked: the four section headings
222
+ (`Repo rules digest`, `Unit scope and lane digest`, `Environment facts`, and
223
+ `Open risks`) must appear, every anchor extracted from the `include` files
224
+ (markdown `##` headings, and YAML category names plus `enforcement_levels`)
225
+ must appear on its own line (case-insensitive, whitespace-collapsed, `**`
226
+ decoration allowed), and every `env_facts` entry must appear verbatim. A miss
227
+ re-runs the brief agent once per missing set with a `## Coverage retry`
228
+ correction note, bounded to three retries per input set; if the same items are
229
+ still dropped after the cap, the run fails and `last-error.md` names the
230
+ persistent drops. Include files with no extractable anchors pass vacuously, and
231
+ unusual YAML rule shapes are under-checked, never over-checked.
232
+
221
233
  ### Secrets (`secrets:`) - refs only, never values
222
234
 
223
235
  Secret values never belong in the pipeline yaml, the workspace, or `state_dir`.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentic-dev-loop"
7
- version = "1.0.0a38"
7
+ version = "1.0.0a39"
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"
@@ -0,0 +1,96 @@
1
+ """Mechanical coverage gate for the brief step's section-anchored digest (041).
2
+
3
+ The brief agent's prompt contract (Part A) promises a structure-preserving
4
+ digest: every top-level section or category name survives on its own line.
5
+ Prompt compliance is not a guarantee, so this module derives the required
6
+ anchors from the include files themselves and checks the captured digest
7
+ against them. The brief node owns the retry budget; these functions stay pure
8
+ and file-agnostic.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+
15
+ _REQUIRED_SECTIONS = (
16
+ "Repo rules digest",
17
+ "Unit scope and lane digest",
18
+ "Environment facts",
19
+ "Open risks",
20
+ )
21
+
22
+
23
+ def _normalize(text: str) -> str:
24
+ """Case-fold and collapse whitespace - the matching normal form."""
25
+ return " ".join(text.split()).casefold()
26
+
27
+
28
+ def _normalize_line(line: str) -> str:
29
+ """Normalize one digest line, stripping the decorations Part A permits.
30
+
31
+ A compliant digest writes anchors as headings on their own line, wrapped
32
+ in ``**`` and (for the four section headings) prefixed with ``##``. List
33
+ bullets are tolerated; the whole-line comparison is what makes a mid-line
34
+ prose mention fail."""
35
+ stripped = line.strip().replace("**", "")
36
+ stripped = re.sub(r"^#+\s*", "", stripped)
37
+ stripped = re.sub(r"^[-*+]\s+", "", stripped)
38
+ return _normalize(stripped)
39
+
40
+
41
+ def required_sections() -> list[str]:
42
+ """The four template headings the brief must emit."""
43
+ return list(_REQUIRED_SECTIONS)
44
+
45
+
46
+ def extract_anchors(content: str) -> list[str]:
47
+ """Derive the mechanical anchor set from one include file.
48
+
49
+ Markdown contributes every ``## `` heading. YAML contributes each
50
+ ``- name:`` value under a ``categories:`` block plus the
51
+ ``enforcement_levels`` key. Anchors are normalized once, at extraction,
52
+ and de-duplicated in first-seen order."""
53
+ anchors: list[str] = []
54
+ in_categories = False
55
+ for raw in content.splitlines():
56
+ line = raw.rstrip()
57
+ stripped = line.strip()
58
+ if not stripped:
59
+ continue
60
+ if stripped == "enforcement_levels:":
61
+ anchors.append(_normalize("enforcement_levels"))
62
+ if stripped == "categories:":
63
+ in_categories = True
64
+ continue
65
+ if in_categories:
66
+ if not line[:1].isspace():
67
+ in_categories = False
68
+ else:
69
+ match = re.match(r"-\s*name:\s*(.+)$", stripped)
70
+ if match:
71
+ anchors.append(_normalize(match.group(1).strip()))
72
+ if line.startswith("## "):
73
+ anchors.append(_normalize(line[3:].strip()))
74
+ return list(dict.fromkeys(anchors))
75
+
76
+
77
+ def missing_anchors(brief_text: str, anchors: list[str],
78
+ env_facts: list[str]) -> list[str]:
79
+ """Return every required item the captured brief fails to cover.
80
+
81
+ Required section headings and anchors are matched line-anchored
82
+ (case-insensitive, whitespace-collapsed, ``**`` decoration stripped). Env
83
+ facts are matched verbatim, exactly as handed to the brief agent. An empty
84
+ list means the digest passes coverage."""
85
+ missing: list[str] = []
86
+ lines = {_normalize_line(line) for line in brief_text.splitlines()}
87
+ for section in _REQUIRED_SECTIONS:
88
+ if _normalize(section) not in lines:
89
+ missing.append(section)
90
+ for anchor in anchors:
91
+ if anchor not in lines:
92
+ missing.append(anchor)
93
+ for fact in env_facts:
94
+ if fact not in brief_text:
95
+ missing.append(fact)
96
+ return missing
@@ -43,6 +43,11 @@ def _merge_artifacts(a: dict[str, str], b: dict[str, str]) -> dict[str, str]:
43
43
  return {**a, **b}
44
44
 
45
45
 
46
+ def _merge_counts(a: dict[str, int], b: dict[str, int]) -> dict[str, int]:
47
+ """Accumulate the per-step brief coverage retry counts (041)."""
48
+ return {**a, **b}
49
+
50
+
46
51
  class LoopState(TypedDict):
47
52
  """State threaded through the graph. `notes` accumulates across attempts
48
53
  (append reducer), `artifacts` accumulates across stages (merge reducer);
@@ -65,6 +70,7 @@ class LoopState(TypedDict):
65
70
  retry_target: str | None # where the bump node loops back to
66
71
  thread_id: str
67
72
  brief_hashes: Annotated[dict[str, str], _merge_artifacts] # step name -> input hash (033)
73
+ brief_retries: Annotated[dict[str, int], _merge_counts] # step name -> coverage retries used (041)
68
74
 
69
75
 
70
76
  def build_graph(runtime: Runtime, checkpointer=None):
@@ -215,6 +221,7 @@ def _run_pipeline(cfg, base, registry, state_dir, max_attempts, args, viz_bus,
215
221
  "retry_target": None,
216
222
  "thread_id": thread_id,
217
223
  "brief_hashes": {},
224
+ "brief_retries": {},
218
225
  }
219
226
  bus = viz_bus
220
227
  with SqliteSaver.from_conn_string(db) as saver:
@@ -34,6 +34,7 @@ from openhands.sdk import Conversation
34
34
 
35
35
  from .agent_error_guide import guide_section
36
36
  from .agents import load_agent, register_sandbox_terminal
37
+ from .brief_coverage import extract_anchors, missing_anchors
37
38
  from agentic_dev_loop.core.config import TERMINALS, PipelineConfig, Route, StepConfig, route_target
38
39
  from agentic_dev_loop.core.paths import _has_wildcards
39
40
  from .deps import extension_message, parse_missing_dependencies, policy_injection
@@ -57,6 +58,7 @@ if TYPE_CHECKING:
57
58
  DEFAULT_MAX_ITERATIONS = 500
58
59
  DEFAULT_COMMAND_TIMEOUT = 600.0 # seconds - a pytest-class suite budget
59
60
  LAST_ERROR = "last-error.md"
61
+ COVERAGE_RETRY_CAP = 3 # per-run brief coverage re-runs before the run fails (041)
60
62
  ARTIFACT_CONTEXT_CAP = 24_000 # chars per artifact injected into a step's context
61
63
  CUSTOM_PROMPT_HEADING = "Run-specific instructions"
62
64
 
@@ -554,6 +556,23 @@ def resolve_brief_inputs(runtime: Runtime, step: StepConfig
554
556
  return included, list(step.brief_inputs.env_facts)
555
557
 
556
558
 
559
+ def _brief_anchors(runtime: Runtime, step: StepConfig) -> list[str]:
560
+ """The mechanical anchor set for a brief step (041), derived from the
561
+ resolved ``brief_inputs.include`` files only - plan docs reach dev/review
562
+ verbatim through the loader channel, so their sections need no anchor
563
+ enforcement. Duplicate headings across files merge into one anchor."""
564
+ included, _ = resolve_brief_inputs(runtime, step)
565
+ anchors: list[str] = []
566
+ for content in included.values():
567
+ anchors.extend(extract_anchors(content))
568
+ return list(dict.fromkeys(anchors))
569
+
570
+
571
+ def _coverage_note_text(missing: list[str]) -> str:
572
+ """The missing-items list for the ``## Coverage retry`` section (041)."""
573
+ return "\n".join(f"- {item}" for item in missing)
574
+
575
+
557
576
  def validate_brief_inputs(workspace: Path, step: StepConfig) -> None:
558
577
  """Fail fast on ``brief_inputs.include`` entries that name no files (033).
559
578
 
@@ -620,13 +639,16 @@ def brief_inputs_hash(runtime: Runtime, step: StepConfig,
620
639
 
621
640
  def build_brief_context(runtime: Runtime, step: StepConfig,
622
641
  docs: dict[str, str],
623
- doc_sources: dict[str, str] | None = None) -> str:
642
+ doc_sources: dict[str, str] | None = None,
643
+ coverage_note: str | None = None) -> str:
624
644
  """Assemble the brief agent's task message (033): the always-on
625
645
  agent-facing error guide (034), the resolved unit/plan docs (re-read from
626
646
  disk where sources are known), the closed ``brief_inputs.include`` file
627
647
  set, the run-specific instructions appended from ``custom_prompt`` (040),
628
- and the env-facts block. Prior verdicts and feedback are deliberately
629
- absent - the notes channel delivers them per attempt."""
648
+ and the env-facts block. A ``coverage_note`` (041) appends a correction
649
+ request against the digest contract - it is not prior verdicts or
650
+ feedback, so the 033 rule is unaffected. Prior verdicts and feedback are
651
+ deliberately absent - the notes channel delivers them per attempt."""
630
652
  sections, env_facts = _brief_sections(runtime, step, docs, doc_sources)
631
653
  parts = [guide_section()]
632
654
  parts += [f"## {name}\n\n{content}" for name, content in sections.items()]
@@ -635,6 +657,13 @@ def build_brief_context(runtime: Runtime, step: StepConfig,
635
657
  if env_facts:
636
658
  parts.append("## Environment facts\n\n"
637
659
  + "\n".join(f"- {fact}" for fact in env_facts))
660
+ if coverage_note is not None:
661
+ parts.append(
662
+ "## Coverage retry\n\n"
663
+ "The captured brief is missing the following required items. "
664
+ "Regenerate the digest so every listed item appears on its own "
665
+ "line, verbatim where the contract requires verbatim:\n\n"
666
+ + coverage_note)
638
667
  return "\n\n".join(parts)
639
668
 
640
669
 
@@ -789,7 +818,13 @@ def make_brief_node(runtime: Runtime, step: StepConfig):
789
818
  LLM call is skipped entirely. A changed include file or env fact re-runs
790
819
  the step and refreshes the hash. Prior verdicts and feedback never enter
791
820
  the brief message - they already reach dev/review through the notes
792
- channel."""
821
+ channel.
822
+
823
+ The 041 coverage gate runs after every successful capture AND on the reuse
824
+ path: the four section headings, the include-file anchors, and the env
825
+ facts must all survive. A miss consumes one per-run retry unit (cap 3) and
826
+ re-runs the agent with a ``## Coverage retry`` correction note. Exhausting
827
+ the budget writes last-error.md and fails the run."""
793
828
  captures = step.artifact is not None or bool(step.produces)
794
829
 
795
830
  def node(state: dict) -> dict:
@@ -800,14 +835,9 @@ def make_brief_node(runtime: Runtime, step: StepConfig):
800
835
  try:
801
836
  current_hash = brief_inputs_hash(runtime, step, state["docs"],
802
837
  state.get("doc_sources"))
803
- if (state.get("artifacts", {}).get(step.name) is not None
804
- and state.get("brief_hashes", {}).get(step.name) == current_hash):
805
- print(f"[{subject}] {step.name} brief inputs unchanged - reusing.",
806
- flush=True)
807
- merged = {**state["step_verdicts"], step.name: "pass"}
808
- return {"step_verdicts": merged, "failed": False}
809
- message = build_brief_context(runtime, step, state["docs"],
810
- state.get("doc_sources"))
838
+ anchors = _brief_anchors(runtime, step)
839
+ env_facts = (list(step.brief_inputs.env_facts)
840
+ if step.brief_inputs is not None else [])
811
841
  except Exception:
812
842
  err = redact_values(traceback.format_exc(), runtime.secrets.values())
813
843
  print(err[-4000:], file=sys.stderr, flush=True)
@@ -817,6 +847,24 @@ def make_brief_node(runtime: Runtime, step: StepConfig):
817
847
  encoding="utf-8")
818
848
  return {"failed": True, "failed_step": step.name}
819
849
 
850
+ existing = state.get("artifacts", {}).get(step.name)
851
+ reused = (existing is not None
852
+ and state.get("brief_hashes", {}).get(step.name) == current_hash)
853
+
854
+ if reused and not missing_anchors(existing, anchors, env_facts):
855
+ print(f"[{subject}] {step.name} brief inputs unchanged - reusing.",
856
+ flush=True)
857
+ merged = {**state["step_verdicts"], step.name: "pass"}
858
+ return {"step_verdicts": merged, "failed": False}
859
+
860
+ coverage_note = None
861
+ if reused:
862
+ coverage_note = _coverage_note_text(
863
+ missing_anchors(existing, anchors, env_facts))
864
+ message = build_brief_context(runtime, step, state["docs"],
865
+ state.get("doc_sources"),
866
+ coverage_note=coverage_note)
867
+
820
868
  print(f"[{subject}] Running {step.agent} agent...", flush=True)
821
869
  result = runtime.run_agent(step.agent, message,
822
870
  max_iterations=max_iterations, step=step)
@@ -834,11 +882,38 @@ def make_brief_node(runtime: Runtime, step: StepConfig):
834
882
  result.truncated, max_iterations)],
835
883
  "retry_target": _unclear_target(runtime, step)}
836
884
 
885
+ missing = missing_anchors(result.response or "", anchors, env_facts)
886
+ if missing:
887
+ prev_hash = state.get("brief_hashes", {}).get(step.name)
888
+ retries = (0 if prev_hash != current_hash
889
+ else state.get("brief_retries", {}).get(step.name, 0))
890
+ if retries >= COVERAGE_RETRY_CAP:
891
+ dropped = "\n".join(f"- {item}" for item in missing)
892
+ runtime.state_dir.mkdir(parents=True, exist_ok=True)
893
+ (runtime.state_dir / LAST_ERROR).write_text(
894
+ f"# Brief coverage failed\n\n"
895
+ f"The brief step repeatedly dropped the following "
896
+ f"required items:\n\n{dropped}\n",
897
+ encoding="utf-8")
898
+ print(f"[{subject}] {step.name} brief coverage retries exhausted - "
899
+ f"persistent drops: {', '.join(missing)}", flush=True)
900
+ return {"failed": True, "failed_step": step.name}
901
+ print(f"[{subject}] {step.name} brief coverage miss on attempt "
902
+ f"{attempt}, retrying.", flush=True)
903
+ merged = {**state["step_verdicts"], step.name: None}
904
+ update = {"step_verdicts": merged, "retry_target": step.name}
905
+ if captures and result.response is not None:
906
+ update["artifacts"] = {step.name: result.response}
907
+ update["brief_hashes"] = {step.name: current_hash}
908
+ update["brief_retries"] = {step.name: retries + 1}
909
+ return update
910
+
837
911
  merged = {**state["step_verdicts"], step.name: "pass"}
838
912
  update = {"step_verdicts": merged, "failed": False}
839
913
  if captures and result.response is not None:
840
914
  update["artifacts"] = {step.name: result.response}
841
915
  update["brief_hashes"] = {step.name: current_hash}
916
+ update["brief_retries"] = {step.name: 0}
842
917
  return update
843
918
 
844
919
  return node
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a38
3
+ Version: 1.0.0a39
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
@@ -260,6 +260,18 @@ run start (or when its inputs change), its `produces:` artifact survives attempt
260
260
  starts, and the dev/review agents read that digest instead of re-deriving repo
261
261
  rules and environment facts every attempt.
262
262
 
263
+ The brief digest is mechanically coverage-checked: the four section headings
264
+ (`Repo rules digest`, `Unit scope and lane digest`, `Environment facts`, and
265
+ `Open risks`) must appear, every anchor extracted from the `include` files
266
+ (markdown `##` headings, and YAML category names plus `enforcement_levels`)
267
+ must appear on its own line (case-insensitive, whitespace-collapsed, `**`
268
+ decoration allowed), and every `env_facts` entry must appear verbatim. A miss
269
+ re-runs the brief agent once per missing set with a `## Coverage retry`
270
+ correction note, bounded to three retries per input set; if the same items are
271
+ still dropped after the cap, the run fails and `last-error.md` names the
272
+ persistent drops. Include files with no extractable anchors pass vacuously, and
273
+ unusual YAML rule shapes are under-checked, never over-checked.
274
+
263
275
  ### Secrets (`secrets:`) - refs only, never values
264
276
 
265
277
  Secret values never belong in the pipeline yaml, the workspace, or `state_dir`.
@@ -20,6 +20,7 @@ src/agentic_dev_loop/engine/agent_error_guide.md
20
20
  src/agentic_dev_loop/engine/agent_error_guide.py
21
21
  src/agentic_dev_loop/engine/agents.py
22
22
  src/agentic_dev_loop/engine/allowlist.py
23
+ src/agentic_dev_loop/engine/brief_coverage.py
23
24
  src/agentic_dev_loop/engine/cli.py
24
25
  src/agentic_dev_loop/engine/deps.py
25
26
  src/agentic_dev_loop/engine/dryrun.py
@@ -53,6 +54,7 @@ tests/test_032_stop_when_green.py
53
54
  tests/test_033_brief_step.py
54
55
  tests/test_035_finalize_nudge.py
55
56
  tests/test_036_banned_commands_and_lane_boundary.py
57
+ tests/test_041_brief_coverage.py
56
58
  tests/test_actions.py
57
59
  tests/test_agent_error_guide.py
58
60
  tests/test_agents.py
@@ -42,6 +42,15 @@ def _load(tmp_path, data):
42
42
  return load_config(p)
43
43
 
44
44
 
45
+ BRIEF_OK = (
46
+ "## Repo rules digest\n\nREPO RULES\n\n"
47
+ "## Unit scope and lane digest\n\nSCOPE\n\n"
48
+ "## Environment facts\n\n- offline; never fetch\n\n"
49
+ "## Open risks\n\nNone\n"
50
+ )
51
+ BRIEF_OK_NEW = BRIEF_OK.replace("SCOPE", "SCOPE NEW")
52
+
53
+
45
54
  BRIEF_STEP = {
46
55
  "name": "brief",
47
56
  "agent": "brief",
@@ -303,11 +312,11 @@ class TestBriefInputsHash:
303
312
 
304
313
  class TestBriefNode:
305
314
  def test_first_run_calls_agent_and_stores_hash(self, runtime, fake_agents):
306
- fake_agents.set("brief", [("write", "attempt-brief.md", "BRIEF BODY")])
315
+ fake_agents.set("brief", [("write", "attempt-brief.md", BRIEF_OK)])
307
316
  update = make_brief_node(runtime, runtime.cfg.steps[0])(make_state())
308
317
  assert update["step_verdicts"] == {"brief": "pass"}
309
318
  assert update["failed"] is False
310
- assert update["artifacts"] == {"brief": "BRIEF BODY"}
319
+ assert update["artifacts"] == {"brief": BRIEF_OK}
311
320
  assert update["brief_hashes"] == {
312
321
  "brief": brief_inputs_hash(runtime, runtime.cfg.steps[0],
313
322
  make_state()["docs"])}
@@ -316,7 +325,7 @@ class TestBriefNode:
316
325
  assert "## Environment facts\n\n- offline; never fetch" in msg
317
326
 
318
327
  def test_unchanged_inputs_skip_llm(self, runtime, fake_agents):
319
- fake_agents.set("brief", [("write", "attempt-brief.md", "BRIEF BODY")])
328
+ fake_agents.set("brief", [("write", "attempt-brief.md", BRIEF_OK)])
320
329
  node = make_brief_node(runtime, runtime.cfg.steps[0])
321
330
  first = node(make_state())
322
331
  second = node(make_state(artifacts=first["artifacts"],
@@ -326,15 +335,15 @@ class TestBriefNode:
326
335
 
327
336
  def test_changed_inputs_rerun(self, runtime, fake_agents):
328
337
  fake_agents.set("brief", [
329
- ("write", "attempt-brief.md", "OLD"),
330
- ("write", "attempt-brief.md", "NEW"),
338
+ ("write", "attempt-brief.md", BRIEF_OK),
339
+ ("write", "attempt-brief.md", BRIEF_OK_NEW),
331
340
  ])
332
341
  node = make_brief_node(runtime, runtime.cfg.steps[0])
333
342
  first = node(make_state())
334
343
  (runtime.workspace / "AGENTS.md").write_text("EDITED")
335
344
  second = node(make_state(artifacts=first["artifacts"],
336
345
  brief_hashes=first["brief_hashes"]))
337
- assert second["artifacts"] == {"brief": "NEW"}
346
+ assert second["artifacts"] == {"brief": BRIEF_OK_NEW}
338
347
  assert fake_agents.agents_called() == ["brief", "brief"]
339
348
 
340
349
  def test_loader_doc_disk_edit_reruns_brief(self, runtime, fake_agents,
@@ -342,8 +351,8 @@ class TestBriefNode:
342
351
  plan = tmp_path / "plan.md"
343
352
  plan.write_text("PLAN V1")
344
353
  fake_agents.set("brief", [
345
- ("write", "attempt-brief.md", "OLD"),
346
- ("write", "attempt-brief.md", "NEW"),
354
+ ("write", "attempt-brief.md", BRIEF_OK),
355
+ ("write", "attempt-brief.md", BRIEF_OK_NEW),
347
356
  ])
348
357
  node = make_brief_node(runtime, runtime.cfg.steps[0])
349
358
  first = node(make_state(docs={"plan.md": "PLAN V1"},
@@ -354,7 +363,7 @@ class TestBriefNode:
354
363
  doc_sources={"plan.md": str(plan)},
355
364
  artifacts=first["artifacts"],
356
365
  brief_hashes=first["brief_hashes"]))
357
- assert second["artifacts"] == {"brief": "NEW"}
366
+ assert second["artifacts"] == {"brief": BRIEF_OK_NEW}
358
367
  assert fake_agents.agents_called() == ["brief", "brief"]
359
368
 
360
369
  def test_empty_response_is_unclear(self, runtime, fake_agents):
@@ -569,7 +578,14 @@ class TestBriefPipeline:
569
578
  monkeypatch.chdir(repo)
570
579
  monkeypatch.setenv("TEST_LLM_KEY", "sk-test")
571
580
 
572
- fake_agents.set("brief", [("write", "attempt-brief.md", "BRIEF DIGEST")])
581
+ brief_digest = (
582
+ "## Repo rules digest\n\nREPO RULES\n\n"
583
+ "## Unit scope and lane digest\n\nBRIEF DIGEST\n\n"
584
+ "## Environment facts\n\n"
585
+ "- the workspace is an export with no .git; verify with find/ls, never git status\n\n"
586
+ "## Open risks\n\nNone\n"
587
+ )
588
+ fake_agents.set("brief", [("write", "attempt-brief.md", brief_digest)])
573
589
  fake_agents.set("dev", [("write", "implementation-summary.md", "did the work")])
574
590
 
575
591
  ok, attempts = run_pipeline("pipeline.yaml", _args())
@@ -0,0 +1,254 @@
1
+ """Brief coverage gate (041): anchor extraction and the coverage check over
2
+ the captured brief, plus the brief node's bounded retry and failure path."""
3
+
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from openhands.sdk import LLM
9
+
10
+ from agentic_dev_loop.core.config import PipelineConfig
11
+ from agentic_dev_loop.engine.brief_coverage import (
12
+ extract_anchors,
13
+ missing_anchors,
14
+ required_sections,
15
+ )
16
+ from agentic_dev_loop.engine.registry import default_registry
17
+ from agentic_dev_loop.engine.stages import (
18
+ Runtime,
19
+ brief_inputs_hash,
20
+ make_brief_node,
21
+ )
22
+
23
+ ROOT = Path(__file__).resolve().parents[1]
24
+ AGENTS_MD = (ROOT / "AGENTS.md").read_text(encoding="utf-8")
25
+ STANDARDS_YAML = (ROOT / "coding-standards-ddd.yaml").read_text(encoding="utf-8")
26
+
27
+
28
+ def _brief_ok() -> str:
29
+ return (
30
+ "## Repo rules digest\n\n"
31
+ "**Error handling**\nNever fall back silently.\n\n"
32
+ "**Mock policy**\nNo mocks unless unavoidable.\n\n"
33
+ "## Unit scope and lane digest\n\nunit scope\n\n"
34
+ "## Environment facts\n\n- offline; never fetch\n\n"
35
+ "## Open risks\n\nNone\n"
36
+ )
37
+
38
+
39
+ def _brief_missing_mock() -> str:
40
+ return _brief_ok().replace("**Mock policy**\nNo mocks unless unavoidable.\n\n", "")
41
+
42
+
43
+ def _merge_state(state, update):
44
+ merged = dict(state)
45
+ for key, value in update.items():
46
+ if key in ("artifacts", "brief_hashes", "brief_retries"):
47
+ merged[key] = {**state.get(key, {}), **value}
48
+ else:
49
+ merged[key] = value
50
+ return merged
51
+
52
+
53
+ def _make_cfg(steps):
54
+ return PipelineConfig.model_validate({
55
+ "name": "t",
56
+ "llm": {"model": "m", "api_key_env": "K"},
57
+ "agents_dir": "agents",
58
+ "lockfiles": ["uv.lock"],
59
+ "steps": steps,
60
+ })
61
+
62
+
63
+ def _make_state(**over):
64
+ state = {"subject": "unit u", "docs": {"plan.md": "THE PLAN"},
65
+ "artifacts": {}, "attempt": 1, "max_attempts": 10, "notes": [],
66
+ "failed": False, "step_verdicts": {}, "retry_target": None,
67
+ "thread_id": "t1", "brief_hashes": {}, "brief_retries": {}}
68
+ state.update(over)
69
+ return state
70
+
71
+
72
+ @pytest.fixture
73
+ def runtime(tmp_path, fake_sandbox):
74
+ state_dir = tmp_path / ".pr"
75
+ state_dir.mkdir()
76
+ (tmp_path / "AGENTS.md").write_text(
77
+ "## Error handling\n\nNever fall back silently.\n\n"
78
+ "## Mock policy\n\nNo mocks unless unavoidable.\n")
79
+ steps = [
80
+ {"name": "brief", "agent": "brief", "produces": "attempt-brief.md",
81
+ "max_iterations": 12,
82
+ "brief_inputs": {"include": ["AGENTS.md"],
83
+ "env_facts": ["offline; never fetch"]},
84
+ "on_pass": "dev"},
85
+ {"name": "dev", "agent": "dev", "produces": "implementation-summary.md",
86
+ "on_pass": "success"},
87
+ ]
88
+ return Runtime(cfg=_make_cfg(steps), registry=default_registry(),
89
+ llm=LLM(model="openai/test-model", api_key="sk-test"),
90
+ agents_dir=tmp_path / "agents", state_dir=state_dir,
91
+ workspace=tmp_path, sandbox=fake_sandbox)
92
+
93
+
94
+ class TestExtractAnchors:
95
+ def test_required_sections_are_the_four_template_headings(self):
96
+ assert required_sections() == [
97
+ "Repo rules digest",
98
+ "Unit scope and lane digest",
99
+ "Environment facts",
100
+ "Open risks",
101
+ ]
102
+
103
+ def test_markdown_headings_from_real_agents_md(self):
104
+ anchors = extract_anchors(AGENTS_MD)
105
+ assert anchors == [
106
+ "default development mode - ask before coding",
107
+ "slash commands",
108
+ "github urls",
109
+ "file search",
110
+ "group session mode",
111
+ "error handling",
112
+ "em dash ban",
113
+ "consumer name ban",
114
+ "debugging",
115
+ "reverse engineering undocumented systems",
116
+ "development workflow",
117
+ "backward compatibility",
118
+ "mock policy",
119
+ "sdk compat shims",
120
+ ]
121
+
122
+ def test_yaml_categories_and_enforcement_levels_from_real_file(self):
123
+ anchors = extract_anchors(STANDARDS_YAML)
124
+ assert anchors == [
125
+ "enforcement_levels",
126
+ "general_principles",
127
+ "architecture_and_boundaries",
128
+ "domain_and_persistence",
129
+ "error_handling",
130
+ "coding_style",
131
+ "services_are_exceptions",
132
+ "stack_conventions",
133
+ "forbidden_patterns",
134
+ ]
135
+
136
+ def test_plain_file_has_no_anchors(self):
137
+ assert extract_anchors("just prose\nno headings here\n") == []
138
+
139
+
140
+ class TestMissingAnchors:
141
+ def test_all_present_passes(self):
142
+ assert missing_anchors(_brief_ok(),
143
+ ["error handling", "mock policy"],
144
+ ["offline; never fetch"]) == []
145
+
146
+ def test_missing_anchor_is_named(self):
147
+ assert "mock policy" in missing_anchors(
148
+ _brief_missing_mock(), ["error handling", "mock policy"],
149
+ ["offline; never fetch"])
150
+
151
+ def test_missing_section_heading_is_named(self):
152
+ text = _brief_ok().replace("## Open risks\n\nNone\n", "")
153
+ assert "Open risks" in missing_anchors(
154
+ text, ["error handling", "mock policy"], ["offline; never fetch"])
155
+
156
+ def test_altered_env_fact_is_named(self):
157
+ text = _brief_ok().replace("offline; never fetch", "offline maybe")
158
+ assert "offline; never fetch" in missing_anchors(
159
+ text, ["error handling", "mock policy"], ["offline; never fetch"])
160
+
161
+ def test_mid_line_mention_does_not_satisfy(self):
162
+ text = _brief_ok().replace(
163
+ "**Error handling**\nNever fall back silently.\n\n",
164
+ "See error handling for the rules.\n\n")
165
+ assert "error handling" in missing_anchors(
166
+ text, ["error handling", "mock policy"], ["offline; never fetch"])
167
+
168
+ def test_case_and_whitespace_variants_pass(self):
169
+ text = _brief_ok().replace(
170
+ "**Error handling**", "** ERROR HANDLING **")
171
+ assert missing_anchors(text, ["error handling", "mock policy"],
172
+ ["offline; never fetch"]) == []
173
+
174
+ def test_bold_wrapped_heading_passes(self):
175
+ assert missing_anchors(_brief_ok(), ["error handling", "mock policy"],
176
+ ["offline; never fetch"]) == []
177
+
178
+
179
+ class TestBriefNodeCoverage:
180
+ def test_miss_returns_retry_note_and_reruns(self, runtime, fake_agents):
181
+ good = _brief_ok()
182
+ bad = _brief_missing_mock()
183
+ fake_agents.set("brief", [
184
+ ("write", "attempt-brief.md", bad),
185
+ ("write", "attempt-brief.md", good),
186
+ ])
187
+ node = make_brief_node(runtime, runtime.cfg.steps[0])
188
+ state = _make_state()
189
+
190
+ first = node(state)
191
+ assert first["step_verdicts"] == {"brief": None}
192
+ assert first["retry_target"] == "brief"
193
+ assert first["brief_retries"] == {"brief": 1}
194
+ assert first["artifacts"] == {"brief": bad}
195
+ assert first["brief_hashes"] == {
196
+ "brief": brief_inputs_hash(runtime, runtime.cfg.steps[0],
197
+ state["docs"])}
198
+
199
+ second = node(_merge_state(state, first))
200
+ assert second["step_verdicts"] == {"brief": "pass"}
201
+ assert second["failed"] is False
202
+ assert second["artifacts"] == {"brief": good}
203
+ assert second["brief_retries"] == {"brief": 0}
204
+ assert fake_agents.agents_called() == ["brief", "brief"]
205
+ messages = fake_agents.messages_for("brief")
206
+ assert "## Coverage retry" in messages[1]
207
+ assert "mock policy" in messages[1]
208
+
209
+ def test_cap_exhausted_writes_last_error_and_fails(self, runtime,
210
+ fake_agents):
211
+ bad = _brief_missing_mock()
212
+ fake_agents.set("brief", [("write", "attempt-brief.md", bad)])
213
+ node = make_brief_node(runtime, runtime.cfg.steps[0])
214
+ state = _make_state()
215
+
216
+ for _ in range(3):
217
+ update = node(state)
218
+ assert update.get("failed") is not True
219
+ state = _merge_state(state, update)
220
+
221
+ update = node(state)
222
+ assert update["failed"] is True
223
+ assert update["failed_step"] == "brief"
224
+ assert fake_agents.agents_called() == ["brief", "brief", "brief", "brief"]
225
+ error = (runtime.state_dir / "last-error.md").read_text(encoding="utf-8")
226
+ assert "Brief coverage failed" in error
227
+ assert "mock policy" in error
228
+
229
+ def test_reused_digest_failing_coverage_regenerates(self, runtime,
230
+ fake_agents):
231
+ good = _brief_ok()
232
+ bad = _brief_missing_mock()
233
+ fake_agents.set("brief", [("write", "attempt-brief.md", good)])
234
+ node = make_brief_node(runtime, runtime.cfg.steps[0])
235
+ state = _make_state(artifacts={"brief": bad},
236
+ brief_hashes={"brief": brief_inputs_hash(
237
+ runtime, runtime.cfg.steps[0],
238
+ _make_state()["docs"])})
239
+ update = node(state)
240
+ assert update["artifacts"] == {"brief": good}
241
+ assert fake_agents.agents_called() == ["brief"]
242
+ (message,) = fake_agents.messages_for("brief")
243
+ assert "## Coverage retry" in message
244
+
245
+ def test_passing_reused_digest_skips_llm(self, runtime, fake_agents):
246
+ good = _brief_ok()
247
+ node = make_brief_node(runtime, runtime.cfg.steps[0])
248
+ state = _make_state(artifacts={"brief": good},
249
+ brief_hashes={"brief": brief_inputs_hash(
250
+ runtime, runtime.cfg.steps[0],
251
+ _make_state()["docs"])})
252
+ update = node(state)
253
+ assert update == {"step_verdicts": {"brief": "pass"}, "failed": False}
254
+ assert fake_agents.agents_called() == []
@@ -62,9 +62,20 @@ def _script_test(*behaviors):
62
62
  _COMMAND_QUEUE[:] = list(behaviors)
63
63
 
64
64
 
65
+ _BRIEF_DIGEST = (
66
+ "## Repo rules digest\n\n# AGENTS.md\n# coding-standards-ddd.yaml\n\n"
67
+ "## Unit scope and lane digest\n\nplan scope\n\n"
68
+ "## Environment facts\n\n"
69
+ "- the workspace is an export with no .git; verify with find/ls, never git status\n"
70
+ "- the sandbox is network-off by default; never run package-manager fetches\n"
71
+ "- Python packages resolve from .venv-sandbox; run .venv-sandbox/bin/python\n\n"
72
+ "## Open risks\n\nNone\n"
73
+ )
74
+
75
+
65
76
  def _passing_scripts(fake_agents, **overrides):
66
77
  scripts = {
67
- "brief": [("write", "attempt-brief.md", "Brief digest.\n")],
78
+ "brief": [("write", "attempt-brief.md", _BRIEF_DIGEST)],
68
79
  "dev": [("write", "implementation-summary.md", "Implemented the fix.\n")],
69
80
  "review": [("write", "review-findings.md",
70
81
  "Looks good.\n\nVERDICT: APPROVED\n")],