agentic-dev-loop 1.0.0a37__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.0a37/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a39}/PKG-INFO +13 -1
  2. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/README.md +12 -0
  3. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/pyproject.toml +1 -1
  4. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/core/config.py +41 -1
  5. agentic_dev_loop-1.0.0a39/src/agentic_dev_loop/engine/brief_coverage.py +96 -0
  6. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/graph.py +7 -0
  7. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/stages.py +108 -18
  8. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39/src/agentic_dev_loop.egg-info}/PKG-INFO +13 -1
  9. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop.egg-info/SOURCES.txt +2 -0
  10. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_033_brief_step.py +38 -10
  11. agentic_dev_loop-1.0.0a39/tests/test_041_brief_coverage.py +254 -0
  12. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_config.py +60 -0
  13. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_e2e_fixtures.py +12 -1
  14. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_stages.py +24 -0
  15. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/LICENSE +0 -0
  16. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/setup.cfg +0 -0
  17. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/__init__.py +0 -0
  18. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/core/__init__.py +0 -0
  19. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/core/paths.py +0 -0
  20. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/core/results.py +0 -0
  21. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/core/verdict.py +0 -0
  22. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/__init__.py +0 -0
  23. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/actions.py +0 -0
  24. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/agent_error_guide.md +0 -0
  25. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/agent_error_guide.py +0 -0
  26. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/agents.py +0 -0
  27. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/allowlist.py +0 -0
  28. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/cli.py +0 -0
  29. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/deps.py +0 -0
  30. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  31. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
  32. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/egress_proxy.py +0 -0
  33. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  34. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  35. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/forbidden.py +0 -0
  36. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  37. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/isolation.py +0 -0
  38. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/llm.py +0 -0
  39. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/loaders.py +0 -0
  40. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/mounts.py +0 -0
  41. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/outputs.py +0 -0
  42. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/presets.py +0 -0
  43. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/proc.py +0 -0
  44. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/registry.py +0 -0
  45. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
  46. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
  47. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/secrets.py +0 -0
  48. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
  49. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/engine/sync_back.py +0 -0
  50. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/py.typed +0 -0
  51. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/viz/__init__.py +0 -0
  52. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/viz/__main__.py +0 -0
  53. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop/viz/server.py +0 -0
  54. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  55. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  56. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  57. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  58. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_028_declared_caches.py +0 -0
  59. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_029_merge_on_conflict.py +0 -0
  60. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_032_stop_when_green.py +0 -0
  61. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_035_finalize_nudge.py +0 -0
  62. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_036_banned_commands_and_lane_boundary.py +0 -0
  63. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_actions.py +0 -0
  64. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_agent_error_guide.py +0 -0
  65. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_agents.py +0 -0
  66. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_aidlc.py +0 -0
  67. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_cli.py +0 -0
  68. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_deps.py +0 -0
  69. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_dry_run.py +0 -0
  70. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_egress_proxy.py +0 -0
  71. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_graph.py +0 -0
  72. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_hostboundary.py +0 -0
  73. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_hostboundary_integration.py +0 -0
  74. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_isolation.py +0 -0
  75. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_isolation_integration.py +0 -0
  76. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_isolation_pbt.py +0 -0
  77. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_llm.py +0 -0
  78. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_loaders.py +0 -0
  79. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_module_contract.py +0 -0
  80. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_outputs.py +0 -0
  81. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_outputs_pbt.py +0 -0
  82. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_proc.py +0 -0
  83. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_publish.py +0 -0
  84. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_qa_prompt.py +0 -0
  85. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_registry.py +0 -0
  86. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_repo_ascii.py +0 -0
  87. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_results.py +0 -0
  88. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_sdk_compat.py +0 -0
  89. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_secrets.py +0 -0
  90. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_secrets_integration.py +0 -0
  91. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_secrets_pipeline.py +0 -0
  92. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_source_resolution.py +0 -0
  93. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_step_workspace.py +0 -0
  94. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_sync_back.py +0 -0
  95. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_trigger_script.py +0 -0
  96. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_unit5_config.py +0 -0
  97. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_unit5_pbt.py +0 -0
  98. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_unit5_pure.py +0 -0
  99. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a39}/tests/test_verdict.py +0 -0
  100. {agentic_dev_loop-1.0.0a37 → 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.0a37
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.0a37"
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"
@@ -389,6 +389,7 @@ class StepConfig(BaseModel):
389
389
  stop_when: StopWhenSpec | None = None # agent steps only (032)
390
390
  finalize_nudge_turns: int | None = Field(default=None, gt=0) # agent steps only (035)
391
391
  brief_inputs: BriefInputsSpec | None = None # agent steps only (033)
392
+ custom_prompt: str | None = None # agent steps only (040); file path resolved at load
392
393
  env: dict[str, str | None] | None = None # NFR-1: null = inherit host value
393
394
  setup: str | None = None # BR-13: command steps only
394
395
  forbidden_commands: list[str] = [] # banned-command regex deny-list (036 D1)
@@ -441,6 +442,13 @@ class StepConfig(BaseModel):
441
442
  f"regex: {e}") from e
442
443
  return v
443
444
 
445
+ @field_validator("custom_prompt")
446
+ @classmethod
447
+ def _nonempty_custom_prompt(cls, v: str | None) -> str | None:
448
+ if v is not None and not v.strip():
449
+ raise ValueError("custom_prompt must be a non-empty string")
450
+ return v
451
+
444
452
  @model_validator(mode="after")
445
453
  def _check_shape(self) -> "StepConfig":
446
454
  for key in ("agent", "action", "command"):
@@ -501,6 +509,10 @@ class StepConfig(BaseModel):
501
509
  raise ValueError(
502
510
  f"step {self.name!r}: 'brief_inputs' requires 'agent'"
503
511
  )
512
+ if self.custom_prompt is not None and self.agent is None:
513
+ raise ValueError(
514
+ f"step {self.name!r}: 'custom_prompt' requires 'agent'"
515
+ )
504
516
  if (self.brief_inputs is not None
505
517
  and (self.artifact is not None or self.verdicts is not None)):
506
518
  raise ValueError(
@@ -797,6 +809,28 @@ class PipelineConfig(BaseModel):
797
809
  return self
798
810
 
799
811
 
812
+ def _custom_prompt_looks_like_path(value: str) -> bool:
813
+ """040/D2: a value counts as path-shaped when it carries a path
814
+ separator or a file extension (a dot). A path-shaped value that names no
815
+ existing file is a hard error, never silent text."""
816
+ return "/" in value or "\\" in value or Path(value).suffix != ""
817
+
818
+
819
+ def resolve_custom_prompt(value: str, pipeline_dir: Path, step_name: str) -> str:
820
+ """Resolve a ``custom_prompt`` value (040/D2): an existing file
821
+ (relative to the pipeline's directory, or absolute) is read as the
822
+ prompt; any other value is literal text. A path-shaped value that names
823
+ no file fails loud, naming the path."""
824
+ candidate = Path(value)
825
+ path = candidate if candidate.is_absolute() else pipeline_dir / candidate
826
+ if path.is_file():
827
+ return path.read_text(encoding="utf-8")
828
+ if _custom_prompt_looks_like_path(value):
829
+ raise ValueError(
830
+ f"step {step_name!r}: 'custom_prompt' file not found: {value}")
831
+ return value
832
+
833
+
800
834
  def default_state_dir(pipeline_path: Path) -> str:
801
835
  """The new run-state default (Q1): checkpoint DB + last-error.md live OUTSIDE
802
836
  the workspace mount, under ~/.agentic-dev-loop/runs/<pipeline-hash>/ where
@@ -816,4 +850,10 @@ def load_config(path: Path) -> PipelineConfig:
816
850
  )
817
851
  if "state_dir" not in data:
818
852
  data["state_dir"] = default_state_dir(path)
819
- return PipelineConfig.model_validate(data)
853
+ cfg = PipelineConfig.model_validate(data)
854
+ pipeline_dir = path.resolve().parent
855
+ for step in cfg.steps:
856
+ if step.custom_prompt is not None:
857
+ step.custom_prompt = resolve_custom_prompt(
858
+ step.custom_prompt, pipeline_dir, step.name)
859
+ return cfg
@@ -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,7 +58,15 @@ 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
63
+ CUSTOM_PROMPT_HEADING = "Run-specific instructions"
64
+
65
+
66
+ def custom_prompt_section(text: str) -> str:
67
+ """The per-step ``custom_prompt`` section (040): appended verbatim, no
68
+ rewording, under a fixed heading."""
69
+ return f"## {CUSTOM_PROMPT_HEADING}\n\n{text}"
61
70
 
62
71
 
63
72
  class _TurnPauseController:
@@ -432,15 +441,19 @@ def _artifact_file(runtime: Runtime, step_name: str) -> str | None:
432
441
 
433
442
  def build_context(runtime: Runtime, docs: dict[str, str], subject: str,
434
443
  notes: list[str] | None = None,
435
- artifacts: dict[str, str] | None = None) -> str:
444
+ artifacts: dict[str, str] | None = None,
445
+ custom_prompt: str | None = None) -> str:
436
446
  """Assemble the attempt's shared message: the always-on agent-facing error
437
- guide (034), subject, doc bundle, the canonical dependency-policy paragraph
438
- (021), the expected-output-paths section generated from step defs (a prompt
439
- merely naming files lets agents skip them), accumulated stage artifacts
440
- (027), and previous-attempt notes."""
447
+ guide (034), subject, doc bundle, the run-specific instructions appended
448
+ from the step's ``custom_prompt`` (040), the canonical dependency-policy
449
+ paragraph (021), the expected-output-paths section generated from step
450
+ defs (a prompt merely naming files lets agents skip them), accumulated
451
+ stage artifacts (027), and previous-attempt notes."""
441
452
  parts = [f"You are working on {subject}.\n", guide_section()]
442
453
  for name, content in docs.items():
443
454
  parts.append(f"## {name}\n\n{content}")
455
+ if custom_prompt:
456
+ parts.append(custom_prompt_section(custom_prompt))
444
457
  if artifacts:
445
458
  parts.append("## Stage artifacts")
446
459
  for step_name, content in artifacts.items():
@@ -543,6 +556,23 @@ def resolve_brief_inputs(runtime: Runtime, step: StepConfig
543
556
  return included, list(step.brief_inputs.env_facts)
544
557
 
545
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
+
546
576
  def validate_brief_inputs(workspace: Path, step: StepConfig) -> None:
547
577
  """Fail fast on ``brief_inputs.include`` entries that name no files (033).
548
578
 
@@ -609,18 +639,31 @@ def brief_inputs_hash(runtime: Runtime, step: StepConfig,
609
639
 
610
640
  def build_brief_context(runtime: Runtime, step: StepConfig,
611
641
  docs: dict[str, str],
612
- doc_sources: dict[str, str] | None = None) -> str:
642
+ doc_sources: dict[str, str] | None = None,
643
+ coverage_note: str | None = None) -> str:
613
644
  """Assemble the brief agent's task message (033): the always-on
614
645
  agent-facing error guide (034), the resolved unit/plan docs (re-read from
615
646
  disk where sources are known), the closed ``brief_inputs.include`` file
616
- set, and the env-facts block. Prior verdicts and feedback are deliberately
617
- absent - the notes channel delivers them per attempt."""
647
+ set, the run-specific instructions appended from ``custom_prompt`` (040),
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."""
618
652
  sections, env_facts = _brief_sections(runtime, step, docs, doc_sources)
619
653
  parts = [guide_section()]
620
654
  parts += [f"## {name}\n\n{content}" for name, content in sections.items()]
655
+ if step.custom_prompt:
656
+ parts.append(custom_prompt_section(step.custom_prompt))
621
657
  if env_facts:
622
658
  parts.append("## Environment facts\n\n"
623
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)
624
667
  return "\n\n".join(parts)
625
668
 
626
669
 
@@ -683,7 +726,8 @@ def make_agent_node(runtime: Runtime, step: StepConfig):
683
726
  max_iterations = step.max_iterations or DEFAULT_MAX_ITERATIONS
684
727
  message = build_context(runtime, state["docs"], state["subject"],
685
728
  state["notes"],
686
- artifacts=state.get("artifacts", {}))
729
+ artifacts=state.get("artifacts", {}),
730
+ custom_prompt=step.custom_prompt)
687
731
  print(f"[{subject}] Running {step.agent} agent...", flush=True)
688
732
  result = runtime.run_agent(step.agent, message,
689
733
  max_iterations=max_iterations, step=step)
@@ -774,7 +818,13 @@ def make_brief_node(runtime: Runtime, step: StepConfig):
774
818
  LLM call is skipped entirely. A changed include file or env fact re-runs
775
819
  the step and refreshes the hash. Prior verdicts and feedback never enter
776
820
  the brief message - they already reach dev/review through the notes
777
- 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."""
778
828
  captures = step.artifact is not None or bool(step.produces)
779
829
 
780
830
  def node(state: dict) -> dict:
@@ -785,14 +835,9 @@ def make_brief_node(runtime: Runtime, step: StepConfig):
785
835
  try:
786
836
  current_hash = brief_inputs_hash(runtime, step, state["docs"],
787
837
  state.get("doc_sources"))
788
- if (state.get("artifacts", {}).get(step.name) is not None
789
- and state.get("brief_hashes", {}).get(step.name) == current_hash):
790
- print(f"[{subject}] {step.name} brief inputs unchanged - reusing.",
791
- flush=True)
792
- merged = {**state["step_verdicts"], step.name: "pass"}
793
- return {"step_verdicts": merged, "failed": False}
794
- message = build_brief_context(runtime, step, state["docs"],
795
- 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 [])
796
841
  except Exception:
797
842
  err = redact_values(traceback.format_exc(), runtime.secrets.values())
798
843
  print(err[-4000:], file=sys.stderr, flush=True)
@@ -802,6 +847,24 @@ def make_brief_node(runtime: Runtime, step: StepConfig):
802
847
  encoding="utf-8")
803
848
  return {"failed": True, "failed_step": step.name}
804
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
+
805
868
  print(f"[{subject}] Running {step.agent} agent...", flush=True)
806
869
  result = runtime.run_agent(step.agent, message,
807
870
  max_iterations=max_iterations, step=step)
@@ -819,11 +882,38 @@ def make_brief_node(runtime: Runtime, step: StepConfig):
819
882
  result.truncated, max_iterations)],
820
883
  "retry_target": _unclear_target(runtime, step)}
821
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
+
822
911
  merged = {**state["step_verdicts"], step.name: "pass"}
823
912
  update = {"step_verdicts": merged, "failed": False}
824
913
  if captures and result.response is not None:
825
914
  update["artifacts"] = {step.name: result.response}
826
915
  update["brief_hashes"] = {step.name: current_hash}
916
+ update["brief_retries"] = {step.name: 0}
827
917
  return update
828
918
 
829
919
  return node
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a37
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",
@@ -266,6 +275,18 @@ class TestBuildBriefContext:
266
275
  assert ctx.count("## AGENTS.md") == 1
267
276
  assert "REPO RULES FROM DOCS" in ctx
268
277
 
278
+ def test_custom_prompt_between_docs_and_env_facts(self, runtime):
279
+ step = make_cfg([{
280
+ "name": "brief", "agent": "brief", "produces": "attempt-brief.md",
281
+ "brief_inputs": {"include": ["AGENTS.md"], "env_facts": ["offline"]},
282
+ "custom_prompt": "brief scope", "on_pass": "success",
283
+ }]).steps[0]
284
+ ctx = build_brief_context(runtime, step, {"plan.md": "THE PLAN"})
285
+ assert "## Run-specific instructions\n\nbrief scope" in ctx
286
+ assert ctx.count("## Run-specific instructions") == 1
287
+ assert ctx.index("## plan.md") < ctx.index("## Run-specific instructions")
288
+ assert ctx.index("## Run-specific instructions") < ctx.index("## Environment facts")
289
+
269
290
 
270
291
  class TestBriefInputsHash:
271
292
  def test_stable_and_content_sensitive(self, runtime):
@@ -291,11 +312,11 @@ class TestBriefInputsHash:
291
312
 
292
313
  class TestBriefNode:
293
314
  def test_first_run_calls_agent_and_stores_hash(self, runtime, fake_agents):
294
- fake_agents.set("brief", [("write", "attempt-brief.md", "BRIEF BODY")])
315
+ fake_agents.set("brief", [("write", "attempt-brief.md", BRIEF_OK)])
295
316
  update = make_brief_node(runtime, runtime.cfg.steps[0])(make_state())
296
317
  assert update["step_verdicts"] == {"brief": "pass"}
297
318
  assert update["failed"] is False
298
- assert update["artifacts"] == {"brief": "BRIEF BODY"}
319
+ assert update["artifacts"] == {"brief": BRIEF_OK}
299
320
  assert update["brief_hashes"] == {
300
321
  "brief": brief_inputs_hash(runtime, runtime.cfg.steps[0],
301
322
  make_state()["docs"])}
@@ -304,7 +325,7 @@ class TestBriefNode:
304
325
  assert "## Environment facts\n\n- offline; never fetch" in msg
305
326
 
306
327
  def test_unchanged_inputs_skip_llm(self, runtime, fake_agents):
307
- fake_agents.set("brief", [("write", "attempt-brief.md", "BRIEF BODY")])
328
+ fake_agents.set("brief", [("write", "attempt-brief.md", BRIEF_OK)])
308
329
  node = make_brief_node(runtime, runtime.cfg.steps[0])
309
330
  first = node(make_state())
310
331
  second = node(make_state(artifacts=first["artifacts"],
@@ -314,15 +335,15 @@ class TestBriefNode:
314
335
 
315
336
  def test_changed_inputs_rerun(self, runtime, fake_agents):
316
337
  fake_agents.set("brief", [
317
- ("write", "attempt-brief.md", "OLD"),
318
- ("write", "attempt-brief.md", "NEW"),
338
+ ("write", "attempt-brief.md", BRIEF_OK),
339
+ ("write", "attempt-brief.md", BRIEF_OK_NEW),
319
340
  ])
320
341
  node = make_brief_node(runtime, runtime.cfg.steps[0])
321
342
  first = node(make_state())
322
343
  (runtime.workspace / "AGENTS.md").write_text("EDITED")
323
344
  second = node(make_state(artifacts=first["artifacts"],
324
345
  brief_hashes=first["brief_hashes"]))
325
- assert second["artifacts"] == {"brief": "NEW"}
346
+ assert second["artifacts"] == {"brief": BRIEF_OK_NEW}
326
347
  assert fake_agents.agents_called() == ["brief", "brief"]
327
348
 
328
349
  def test_loader_doc_disk_edit_reruns_brief(self, runtime, fake_agents,
@@ -330,8 +351,8 @@ class TestBriefNode:
330
351
  plan = tmp_path / "plan.md"
331
352
  plan.write_text("PLAN V1")
332
353
  fake_agents.set("brief", [
333
- ("write", "attempt-brief.md", "OLD"),
334
- ("write", "attempt-brief.md", "NEW"),
354
+ ("write", "attempt-brief.md", BRIEF_OK),
355
+ ("write", "attempt-brief.md", BRIEF_OK_NEW),
335
356
  ])
336
357
  node = make_brief_node(runtime, runtime.cfg.steps[0])
337
358
  first = node(make_state(docs={"plan.md": "PLAN V1"},
@@ -342,7 +363,7 @@ class TestBriefNode:
342
363
  doc_sources={"plan.md": str(plan)},
343
364
  artifacts=first["artifacts"],
344
365
  brief_hashes=first["brief_hashes"]))
345
- assert second["artifacts"] == {"brief": "NEW"}
366
+ assert second["artifacts"] == {"brief": BRIEF_OK_NEW}
346
367
  assert fake_agents.agents_called() == ["brief", "brief"]
347
368
 
348
369
  def test_empty_response_is_unclear(self, runtime, fake_agents):
@@ -557,7 +578,14 @@ class TestBriefPipeline:
557
578
  monkeypatch.chdir(repo)
558
579
  monkeypatch.setenv("TEST_LLM_KEY", "sk-test")
559
580
 
560
- 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)])
561
589
  fake_agents.set("dev", [("write", "implementation-summary.md", "did the work")])
562
590
 
563
591
  ok, attempts = run_pipeline("pipeline.yaml", _args())