agentic-dev-loop 1.0.0a38__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.0a38/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a40}/PKG-INFO +13 -1
  2. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/README.md +12 -0
  3. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/pyproject.toml +1 -1
  4. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/core/config.py +4 -1
  5. agentic_dev_loop-1.0.0a40/src/agentic_dev_loop/engine/brief_coverage.py +96 -0
  6. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/graph.py +7 -0
  7. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/stages.py +112 -18
  8. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/viz/server.py +17 -6
  9. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40/src/agentic_dev_loop.egg-info}/PKG-INFO +13 -1
  10. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop.egg-info/SOURCES.txt +2 -0
  11. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_033_brief_step.py +26 -10
  12. agentic_dev_loop-1.0.0a40/tests/test_041_brief_coverage.py +254 -0
  13. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_config.py +37 -0
  14. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_e2e_fixtures.py +12 -1
  15. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_qa_prompt.py +16 -0
  16. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_stages.py +87 -0
  17. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_verdict.py +24 -0
  18. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_viz.py +20 -0
  19. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/LICENSE +0 -0
  20. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/setup.cfg +0 -0
  21. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/__init__.py +0 -0
  22. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/core/__init__.py +0 -0
  23. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/core/paths.py +0 -0
  24. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/core/results.py +0 -0
  25. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/core/verdict.py +0 -0
  26. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/__init__.py +0 -0
  27. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/actions.py +0 -0
  28. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/agent_error_guide.md +0 -0
  29. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/agent_error_guide.py +0 -0
  30. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/agents.py +0 -0
  31. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/allowlist.py +0 -0
  32. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/cli.py +0 -0
  33. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/deps.py +0 -0
  34. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  35. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
  36. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/egress_proxy.py +0 -0
  37. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  38. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  39. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/forbidden.py +0 -0
  40. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  41. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/isolation.py +0 -0
  42. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/llm.py +0 -0
  43. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/loaders.py +0 -0
  44. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/mounts.py +0 -0
  45. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/outputs.py +0 -0
  46. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/presets.py +0 -0
  47. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/proc.py +0 -0
  48. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/registry.py +0 -0
  49. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
  50. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
  51. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/secrets.py +0 -0
  52. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
  53. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/engine/sync_back.py +0 -0
  54. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/py.typed +0 -0
  55. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/viz/__init__.py +0 -0
  56. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop/viz/__main__.py +0 -0
  57. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  58. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  59. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  60. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  61. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_028_declared_caches.py +0 -0
  62. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_029_merge_on_conflict.py +0 -0
  63. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_032_stop_when_green.py +0 -0
  64. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_035_finalize_nudge.py +0 -0
  65. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_036_banned_commands_and_lane_boundary.py +0 -0
  66. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_actions.py +0 -0
  67. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_agent_error_guide.py +0 -0
  68. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_agents.py +0 -0
  69. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_aidlc.py +0 -0
  70. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_cli.py +0 -0
  71. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_deps.py +0 -0
  72. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_dry_run.py +0 -0
  73. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_egress_proxy.py +0 -0
  74. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_graph.py +0 -0
  75. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_hostboundary.py +0 -0
  76. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_hostboundary_integration.py +0 -0
  77. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_isolation.py +0 -0
  78. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_isolation_integration.py +0 -0
  79. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_isolation_pbt.py +0 -0
  80. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_llm.py +0 -0
  81. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_loaders.py +0 -0
  82. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_module_contract.py +0 -0
  83. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_outputs.py +0 -0
  84. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_outputs_pbt.py +0 -0
  85. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_proc.py +0 -0
  86. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_publish.py +0 -0
  87. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_registry.py +0 -0
  88. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_repo_ascii.py +0 -0
  89. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_results.py +0 -0
  90. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_sdk_compat.py +0 -0
  91. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_secrets.py +0 -0
  92. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_secrets_integration.py +0 -0
  93. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_secrets_pipeline.py +0 -0
  94. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_source_resolution.py +0 -0
  95. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_step_workspace.py +0 -0
  96. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_sync_back.py +0 -0
  97. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_trigger_script.py +0 -0
  98. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_unit5_config.py +0 -0
  99. {agentic_dev_loop-1.0.0a38 → agentic_dev_loop-1.0.0a40}/tests/test_unit5_pbt.py +0 -0
  100. {agentic_dev_loop-1.0.0a38 → 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.0a38
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
@@ -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.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(
@@ -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
 
@@ -654,13 +683,13 @@ def _unclear_note(runtime: Runtime, step: StepConfig, attempt: int,
654
683
  captured response when it exists, so real findings are never swallowed."""
655
684
  expected = ""
656
685
  if step.verdicts is not None:
686
+ keywords = [f"`VERDICT: {step.verdicts.pass_}`",
687
+ f"`VERDICT: {step.verdicts.fail}`"]
657
688
  if step.verdicts.unverifiable is not None:
658
- expected = (f" (expected `VERDICT: {step.verdicts.pass_}`, "
659
- f"`VERDICT: {step.verdicts.fail}`, or "
660
- f"`VERDICT: {step.verdicts.unverifiable}` on its own line)")
661
- else:
662
- expected = (f" (expected `VERDICT: {step.verdicts.pass_}` or "
663
- 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)"
664
693
  if truncated:
665
694
  lead = (f"{step.name} agent was cut short on attempt {attempt} "
666
695
  f"(hit the {max_iterations}-turn limit or was stopped "
@@ -689,6 +718,8 @@ def make_agent_node(runtime: Runtime, step: StepConfig):
689
718
  verdicts_map = {step.verdicts.pass_: "pass", step.verdicts.fail: "fail"}
690
719
  if step.verdicts.unverifiable is not None:
691
720
  verdicts_map[step.verdicts.unverifiable] = "unverifiable"
721
+ if step.verdicts.env is not None:
722
+ verdicts_map[step.verdicts.env] = "env"
692
723
  captures = step.artifact is not None or bool(step.produces)
693
724
 
694
725
  def node(state: dict) -> dict:
@@ -758,6 +789,21 @@ def make_agent_node(runtime: Runtime, step: StepConfig):
758
789
  if captures and result.response is not None:
759
790
  update["artifacts"] = {step.name: result.response}
760
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
761
807
  print(f"[{subject}] {step.name} verdict: fail on attempt {attempt}.",
762
808
  flush=True)
763
809
  update = {"step_verdicts": merged,
@@ -789,7 +835,13 @@ def make_brief_node(runtime: Runtime, step: StepConfig):
789
835
  LLM call is skipped entirely. A changed include file or env fact re-runs
790
836
  the step and refreshes the hash. Prior verdicts and feedback never enter
791
837
  the brief message - they already reach dev/review through the notes
792
- channel."""
838
+ channel.
839
+
840
+ The 041 coverage gate runs after every successful capture AND on the reuse
841
+ path: the four section headings, the include-file anchors, and the env
842
+ facts must all survive. A miss consumes one per-run retry unit (cap 3) and
843
+ re-runs the agent with a ``## Coverage retry`` correction note. Exhausting
844
+ the budget writes last-error.md and fails the run."""
793
845
  captures = step.artifact is not None or bool(step.produces)
794
846
 
795
847
  def node(state: dict) -> dict:
@@ -800,14 +852,9 @@ def make_brief_node(runtime: Runtime, step: StepConfig):
800
852
  try:
801
853
  current_hash = brief_inputs_hash(runtime, step, state["docs"],
802
854
  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"))
855
+ anchors = _brief_anchors(runtime, step)
856
+ env_facts = (list(step.brief_inputs.env_facts)
857
+ if step.brief_inputs is not None else [])
811
858
  except Exception:
812
859
  err = redact_values(traceback.format_exc(), runtime.secrets.values())
813
860
  print(err[-4000:], file=sys.stderr, flush=True)
@@ -817,6 +864,24 @@ def make_brief_node(runtime: Runtime, step: StepConfig):
817
864
  encoding="utf-8")
818
865
  return {"failed": True, "failed_step": step.name}
819
866
 
867
+ existing = state.get("artifacts", {}).get(step.name)
868
+ reused = (existing is not None
869
+ and state.get("brief_hashes", {}).get(step.name) == current_hash)
870
+
871
+ if reused and not missing_anchors(existing, anchors, env_facts):
872
+ print(f"[{subject}] {step.name} brief inputs unchanged - reusing.",
873
+ flush=True)
874
+ merged = {**state["step_verdicts"], step.name: "pass"}
875
+ return {"step_verdicts": merged, "failed": False}
876
+
877
+ coverage_note = None
878
+ if reused:
879
+ coverage_note = _coverage_note_text(
880
+ missing_anchors(existing, anchors, env_facts))
881
+ message = build_brief_context(runtime, step, state["docs"],
882
+ state.get("doc_sources"),
883
+ coverage_note=coverage_note)
884
+
820
885
  print(f"[{subject}] Running {step.agent} agent...", flush=True)
821
886
  result = runtime.run_agent(step.agent, message,
822
887
  max_iterations=max_iterations, step=step)
@@ -834,11 +899,38 @@ def make_brief_node(runtime: Runtime, step: StepConfig):
834
899
  result.truncated, max_iterations)],
835
900
  "retry_target": _unclear_target(runtime, step)}
836
901
 
902
+ missing = missing_anchors(result.response or "", anchors, env_facts)
903
+ if missing:
904
+ prev_hash = state.get("brief_hashes", {}).get(step.name)
905
+ retries = (0 if prev_hash != current_hash
906
+ else state.get("brief_retries", {}).get(step.name, 0))
907
+ if retries >= COVERAGE_RETRY_CAP:
908
+ dropped = "\n".join(f"- {item}" for item in missing)
909
+ runtime.state_dir.mkdir(parents=True, exist_ok=True)
910
+ (runtime.state_dir / LAST_ERROR).write_text(
911
+ f"# Brief coverage failed\n\n"
912
+ f"The brief step repeatedly dropped the following "
913
+ f"required items:\n\n{dropped}\n",
914
+ encoding="utf-8")
915
+ print(f"[{subject}] {step.name} brief coverage retries exhausted - "
916
+ f"persistent drops: {', '.join(missing)}", flush=True)
917
+ return {"failed": True, "failed_step": step.name}
918
+ print(f"[{subject}] {step.name} brief coverage miss on attempt "
919
+ f"{attempt}, retrying.", flush=True)
920
+ merged = {**state["step_verdicts"], step.name: None}
921
+ update = {"step_verdicts": merged, "retry_target": step.name}
922
+ if captures and result.response is not None:
923
+ update["artifacts"] = {step.name: result.response}
924
+ update["brief_hashes"] = {step.name: current_hash}
925
+ update["brief_retries"] = {step.name: retries + 1}
926
+ return update
927
+
837
928
  merged = {**state["step_verdicts"], step.name: "pass"}
838
929
  update = {"step_verdicts": merged, "failed": False}
839
930
  if captures and result.response is not None:
840
931
  update["artifacts"] = {step.name: result.response}
841
932
  update["brief_hashes"] = {step.name: current_hash}
933
+ update["brief_retries"] = {step.name: 0}
842
934
  return update
843
935
 
844
936
  return node
@@ -1040,6 +1132,8 @@ def make_router(runtime: Runtime, step: StepConfig):
1040
1132
  if step.on_unverifiable is not None:
1041
1133
  return resolve(step.on_unverifiable)
1042
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
1043
1137
  if verdict == "fail":
1044
1138
  if step.on_fail is None:
1045
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.0a38
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
@@ -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())