agentic-dev-loop 1.0.0a37__tar.gz → 1.0.0a38__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 (98) hide show
  1. {agentic_dev_loop-1.0.0a37/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a38}/PKG-INFO +1 -1
  2. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/pyproject.toml +1 -1
  3. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/core/config.py +41 -1
  4. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/stages.py +22 -7
  5. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38/src/agentic_dev_loop.egg-info}/PKG-INFO +1 -1
  6. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_033_brief_step.py +12 -0
  7. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_config.py +60 -0
  8. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_stages.py +24 -0
  9. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/LICENSE +0 -0
  10. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/README.md +0 -0
  11. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/setup.cfg +0 -0
  12. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/__init__.py +0 -0
  13. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/core/__init__.py +0 -0
  14. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/core/paths.py +0 -0
  15. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/core/results.py +0 -0
  16. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/core/verdict.py +0 -0
  17. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/__init__.py +0 -0
  18. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/actions.py +0 -0
  19. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/agent_error_guide.md +0 -0
  20. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/agent_error_guide.py +0 -0
  21. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/agents.py +0 -0
  22. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/allowlist.py +0 -0
  23. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/cli.py +0 -0
  24. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/deps.py +0 -0
  25. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  26. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
  27. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/egress_proxy.py +0 -0
  28. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  29. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  30. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/forbidden.py +0 -0
  31. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/graph.py +0 -0
  32. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  33. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/isolation.py +0 -0
  34. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/llm.py +0 -0
  35. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/loaders.py +0 -0
  36. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/mounts.py +0 -0
  37. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/outputs.py +0 -0
  38. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/presets.py +0 -0
  39. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/proc.py +0 -0
  40. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/registry.py +0 -0
  41. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
  42. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
  43. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/secrets.py +0 -0
  44. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
  45. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/sync_back.py +0 -0
  46. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/py.typed +0 -0
  47. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/viz/__init__.py +0 -0
  48. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/viz/__main__.py +0 -0
  49. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/viz/server.py +0 -0
  50. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop.egg-info/SOURCES.txt +0 -0
  51. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  52. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  53. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  54. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  55. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_028_declared_caches.py +0 -0
  56. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_029_merge_on_conflict.py +0 -0
  57. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_032_stop_when_green.py +0 -0
  58. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_035_finalize_nudge.py +0 -0
  59. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_036_banned_commands_and_lane_boundary.py +0 -0
  60. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_actions.py +0 -0
  61. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_agent_error_guide.py +0 -0
  62. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_agents.py +0 -0
  63. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_aidlc.py +0 -0
  64. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_cli.py +0 -0
  65. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_deps.py +0 -0
  66. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_dry_run.py +0 -0
  67. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_e2e_fixtures.py +0 -0
  68. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_egress_proxy.py +0 -0
  69. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_graph.py +0 -0
  70. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_hostboundary.py +0 -0
  71. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_hostboundary_integration.py +0 -0
  72. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_isolation.py +0 -0
  73. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_isolation_integration.py +0 -0
  74. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_isolation_pbt.py +0 -0
  75. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_llm.py +0 -0
  76. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_loaders.py +0 -0
  77. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_module_contract.py +0 -0
  78. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_outputs.py +0 -0
  79. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_outputs_pbt.py +0 -0
  80. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_proc.py +0 -0
  81. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_publish.py +0 -0
  82. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_qa_prompt.py +0 -0
  83. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_registry.py +0 -0
  84. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_repo_ascii.py +0 -0
  85. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_results.py +0 -0
  86. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_sdk_compat.py +0 -0
  87. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_secrets.py +0 -0
  88. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_secrets_integration.py +0 -0
  89. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_secrets_pipeline.py +0 -0
  90. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_source_resolution.py +0 -0
  91. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_step_workspace.py +0 -0
  92. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_sync_back.py +0 -0
  93. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_trigger_script.py +0 -0
  94. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_unit5_config.py +0 -0
  95. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_unit5_pbt.py +0 -0
  96. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_unit5_pure.py +0 -0
  97. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/tests/test_verdict.py +0 -0
  98. {agentic_dev_loop-1.0.0a37 → agentic_dev_loop-1.0.0a38}/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.0a38
4
4
  Summary: Turn an LLM into a gated, sandboxed, resumable construction loop over your codebase.
5
5
  Author: Luke Wilson
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentic-dev-loop"
7
- version = "1.0.0a37"
7
+ version = "1.0.0a38"
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
@@ -58,6 +58,13 @@ DEFAULT_MAX_ITERATIONS = 500
58
58
  DEFAULT_COMMAND_TIMEOUT = 600.0 # seconds - a pytest-class suite budget
59
59
  LAST_ERROR = "last-error.md"
60
60
  ARTIFACT_CONTEXT_CAP = 24_000 # chars per artifact injected into a step's context
61
+ CUSTOM_PROMPT_HEADING = "Run-specific instructions"
62
+
63
+
64
+ def custom_prompt_section(text: str) -> str:
65
+ """The per-step ``custom_prompt`` section (040): appended verbatim, no
66
+ rewording, under a fixed heading."""
67
+ return f"## {CUSTOM_PROMPT_HEADING}\n\n{text}"
61
68
 
62
69
 
63
70
  class _TurnPauseController:
@@ -432,15 +439,19 @@ def _artifact_file(runtime: Runtime, step_name: str) -> str | None:
432
439
 
433
440
  def build_context(runtime: Runtime, docs: dict[str, str], subject: str,
434
441
  notes: list[str] | None = None,
435
- artifacts: dict[str, str] | None = None) -> str:
442
+ artifacts: dict[str, str] | None = None,
443
+ custom_prompt: str | None = None) -> str:
436
444
  """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."""
445
+ guide (034), subject, doc bundle, the run-specific instructions appended
446
+ from the step's ``custom_prompt`` (040), the canonical dependency-policy
447
+ paragraph (021), the expected-output-paths section generated from step
448
+ defs (a prompt merely naming files lets agents skip them), accumulated
449
+ stage artifacts (027), and previous-attempt notes."""
441
450
  parts = [f"You are working on {subject}.\n", guide_section()]
442
451
  for name, content in docs.items():
443
452
  parts.append(f"## {name}\n\n{content}")
453
+ if custom_prompt:
454
+ parts.append(custom_prompt_section(custom_prompt))
444
455
  if artifacts:
445
456
  parts.append("## Stage artifacts")
446
457
  for step_name, content in artifacts.items():
@@ -613,11 +624,14 @@ def build_brief_context(runtime: Runtime, step: StepConfig,
613
624
  """Assemble the brief agent's task message (033): the always-on
614
625
  agent-facing error guide (034), the resolved unit/plan docs (re-read from
615
626
  disk where sources are known), the closed ``brief_inputs.include`` file
616
- set, and the env-facts block. Prior verdicts and feedback are deliberately
627
+ set, the run-specific instructions appended from ``custom_prompt`` (040),
628
+ and the env-facts block. Prior verdicts and feedback are deliberately
617
629
  absent - the notes channel delivers them per attempt."""
618
630
  sections, env_facts = _brief_sections(runtime, step, docs, doc_sources)
619
631
  parts = [guide_section()]
620
632
  parts += [f"## {name}\n\n{content}" for name, content in sections.items()]
633
+ if step.custom_prompt:
634
+ parts.append(custom_prompt_section(step.custom_prompt))
621
635
  if env_facts:
622
636
  parts.append("## Environment facts\n\n"
623
637
  + "\n".join(f"- {fact}" for fact in env_facts))
@@ -683,7 +697,8 @@ def make_agent_node(runtime: Runtime, step: StepConfig):
683
697
  max_iterations = step.max_iterations or DEFAULT_MAX_ITERATIONS
684
698
  message = build_context(runtime, state["docs"], state["subject"],
685
699
  state["notes"],
686
- artifacts=state.get("artifacts", {}))
700
+ artifacts=state.get("artifacts", {}),
701
+ custom_prompt=step.custom_prompt)
687
702
  print(f"[{subject}] Running {step.agent} agent...", flush=True)
688
703
  result = runtime.run_agent(step.agent, message,
689
704
  max_iterations=max_iterations, step=step)
@@ -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.0a38
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
@@ -266,6 +266,18 @@ class TestBuildBriefContext:
266
266
  assert ctx.count("## AGENTS.md") == 1
267
267
  assert "REPO RULES FROM DOCS" in ctx
268
268
 
269
+ def test_custom_prompt_between_docs_and_env_facts(self, runtime):
270
+ step = make_cfg([{
271
+ "name": "brief", "agent": "brief", "produces": "attempt-brief.md",
272
+ "brief_inputs": {"include": ["AGENTS.md"], "env_facts": ["offline"]},
273
+ "custom_prompt": "brief scope", "on_pass": "success",
274
+ }]).steps[0]
275
+ ctx = build_brief_context(runtime, step, {"plan.md": "THE PLAN"})
276
+ assert "## Run-specific instructions\n\nbrief scope" in ctx
277
+ assert ctx.count("## Run-specific instructions") == 1
278
+ assert ctx.index("## plan.md") < ctx.index("## Run-specific instructions")
279
+ assert ctx.index("## Run-specific instructions") < ctx.index("## Environment facts")
280
+
269
281
 
270
282
  class TestBriefInputsHash:
271
283
  def test_stable_and_content_sensitive(self, runtime):
@@ -331,6 +331,66 @@ def test_timeout_without_command_rejected(tmp_path):
331
331
  _load(tmp_path, data)
332
332
 
333
333
 
334
+ class TestCustomPrompt:
335
+ def test_defaults_none(self, tmp_path):
336
+ assert _load(tmp_path, _base()).steps[0].custom_prompt is None
337
+
338
+ def test_literal_text_round_trips(self, tmp_path):
339
+ data = _base()
340
+ data["steps"][0]["custom_prompt"] = "verify the export tab first"
341
+ assert _load(tmp_path, data).steps[0].custom_prompt == \
342
+ "verify the export tab first"
343
+
344
+ def test_existing_file_path_reads_contents(self, tmp_path):
345
+ (tmp_path / "qa-plan.md").write_text("verify the export tab\n")
346
+ data = _base()
347
+ data["steps"][0]["custom_prompt"] = "qa-plan.md"
348
+ assert _load(tmp_path, data).steps[0].custom_prompt == \
349
+ "verify the export tab\n"
350
+
351
+ def test_absolute_file_path_reads_contents(self, tmp_path):
352
+ plan = tmp_path / "plan.txt"
353
+ plan.write_text("ABSOLUTE PLAN")
354
+ data = _base()
355
+ data["steps"][0]["custom_prompt"] = str(plan)
356
+ assert _load(tmp_path, data).steps[0].custom_prompt == "ABSOLUTE PLAN"
357
+
358
+ def test_missing_path_with_extension_rejected(self, tmp_path):
359
+ data = _base()
360
+ data["steps"][0]["custom_prompt"] = "qa-plan.md"
361
+ with pytest.raises(ValueError,
362
+ match="'custom_prompt' file not found: qa-plan.md"):
363
+ _load(tmp_path, data)
364
+
365
+ def test_missing_path_with_separator_rejected(self, tmp_path):
366
+ data = _base()
367
+ data["steps"][0]["custom_prompt"] = "tests/qa-plan"
368
+ with pytest.raises(ValueError, match="tests/qa-plan"):
369
+ _load(tmp_path, data)
370
+
371
+ def test_requires_agent(self, tmp_path):
372
+ data = _base()
373
+ data["steps"] = [{"name": "x", "command": "true",
374
+ "custom_prompt": "hi"}]
375
+ with pytest.raises(ValidationError,
376
+ match="'custom_prompt' requires 'agent'"):
377
+ _load(tmp_path, data)
378
+
379
+ def test_rejected_on_action_step(self, tmp_path):
380
+ data = _base()
381
+ data["steps"] = [{"name": "x", "action": "commit",
382
+ "custom_prompt": "hi"}]
383
+ with pytest.raises(ValidationError,
384
+ match="'custom_prompt' requires 'agent'"):
385
+ _load(tmp_path, data)
386
+
387
+ def test_empty_value_rejected(self, tmp_path):
388
+ data = _base()
389
+ data["steps"][0]["custom_prompt"] = ""
390
+ with pytest.raises(ValidationError, match="non-empty"):
391
+ _load(tmp_path, data)
392
+
393
+
334
394
  @pytest.mark.parametrize("field,value", [
335
395
  ("artifact", "a.md"),
336
396
  ("verdicts", {"pass": "P", "fail": "F"}),
@@ -137,6 +137,19 @@ class TestBuildContext:
137
137
  ctx = build_context(runtime, {}, "s", [])
138
138
  assert "## Stage artifacts" not in ctx
139
139
 
140
+ def test_custom_prompt_section_between_prompt_and_artifacts(self, runtime):
141
+ ctx = build_context(runtime, {"plan.md": "THE PLAN"}, "s", [],
142
+ artifacts={"dev": "summary"},
143
+ custom_prompt="verify export first")
144
+ assert "## Run-specific instructions\n\nverify export first" in ctx
145
+ assert ctx.count("## Run-specific instructions") == 1
146
+ assert ctx.index("## plan.md") < ctx.index("## Run-specific instructions")
147
+ assert ctx.index("## Run-specific instructions") < ctx.index("## Stage artifacts")
148
+
149
+ def test_no_custom_prompt_leaves_context_unchanged(self, runtime):
150
+ ctx = build_context(runtime, {"plan.md": "THE PLAN"}, "s", [])
151
+ assert "## Run-specific instructions" not in ctx
152
+
140
153
 
141
154
  class TestDepsPolicyContext:
142
155
  def test_locked_policy_injected(self, runtime):
@@ -210,6 +223,17 @@ class TestAgentNode:
210
223
  update = make_agent_node(runtime, runtime.cfg.steps[0])(make_state())
211
224
  assert update["artifacts"] == {"dev": "MARKER-DEV"}
212
225
 
226
+ def test_custom_prompt_reaches_agent_message(self, runtime, fake_agents):
227
+ cfg = make_cfg([{"name": "dev", "agent": "dev",
228
+ "produces": ["summary.md"],
229
+ "custom_prompt": "scope note", "on_pass": "success"}])
230
+ rt = dataclasses.replace(runtime, cfg=cfg)
231
+ fake_agents.set("dev", [("write", "summary.md", "done")])
232
+ make_agent_node(rt, rt.cfg.steps[0])(make_state())
233
+ (message,) = fake_agents.messages_for("dev")
234
+ assert "## Run-specific instructions\n\nscope note" in message
235
+ assert message.count("## Run-specific instructions") == 1
236
+
213
237
  def test_pass_verdict_captures_artifact(self, runtime, fake_agents):
214
238
  fake_agents.set("check", [("write", "ci-fix.md", "MARKER-CHECK\nVERDICT: PASS\n")])
215
239
  update = make_agent_node(runtime, runtime.cfg.steps[1])(make_state())