3333
3434Interpret the intent carefully:
3535- Doc/README intents → EDIT README.md (allowlisted). Never open `.ai_agent/**`.
36- - Make ONE meaningful edit that matches the intent ( not a tiny noop) .
36+ - Feature intents must be COMPLETE before publish — not a stub helper alone .
3737
3838Strict pipeline — CALL TOOLS in order; do not narrate:
39- 1. Survey: workspace_search action=search (or read with start_line/end_line).
40- 2. Edit ONCE with replace_in_file (preferred). Do not call replace_in_file again
41- after it succeeds.
42- 3. VERIFY: git_diff (must be NON-EMPTY) then run_checks preset=pytest.
39+ 1. Survey briefly: at most 2 workspace_search/workspace_list calls, then STOP surveying.
40+ 2. Implement the FULL intent with up to 3 related edits (write_file/replace_in_file).
41+ Example for a new CLI: helper module + CLI wiring + one test (and README row if asked).
42+ A helper file with no CLI/test is NOT done — keep editing until the feature is usable.
43+ 3. VERIFY: git_diff (must be NON-EMPTY and cover the real change) then run_checks preset=pytest.
43444. PUBLISH: git_commit alone (feature branch), then open_pull_request alone.
44455. Respond with the PR URL only after open_pull_request succeeds.
4546
4647If the requested text is already present and git_status is clean, respond that
4748the intent is already satisfied (no PR).
4849Never push to main. Never edit PathPolicy / MergePolicy / STOP / .env.
50+ Never treat the repo homepage URL as a PR URL.
4951"""
5052
5153
@@ -140,10 +142,16 @@ def is_stopped(*, root: Path = DEFAULT_EVOLVE_ROOT) -> bool:
140142 return (root / "STOP" ).is_file ()
141143
142144
143- def pipeline_progress (tool_results : list [dict [str , object ]]) -> PipelineProgress :
144- edited = any (
145- item .get ("tool_name" ) in EDIT_TOOLS and item .get ("success" ) for item in tool_results
145+ def pipeline_progress (
146+ tool_results : list [dict [str , object ]],
147+ * ,
148+ min_edits : int = 1 ,
149+ ) -> PipelineProgress :
150+ edit_count = sum (
151+ 1 for item in tool_results if item .get ("tool_name" ) in EDIT_TOOLS and item .get ("success" )
146152 )
153+ # Feature intents may need several related files before verify/publish.
154+ edited = edit_count >= max (1 , min_edits )
147155 diff_seen = False
148156 for item in tool_results :
149157 if item .get ("tool_name" ) != "git_diff" or not item .get ("success" ):
@@ -161,6 +169,9 @@ def pipeline_progress(tool_results: list[dict[str, object]]) -> PipelineProgress
161169 item .get ("tool_name" ) == "open_pull_request" and item .get ("success" )
162170 for item in tool_results
163171 )
172+ # Once verify/publish starts, treat edit phase as complete.
173+ if diff_seen or checks_ok or committed or pr_opened :
174+ edited = True
164175 return PipelineProgress (
165176 edited = edited ,
166177 diff_seen = diff_seen or committed or pr_opened ,
@@ -170,8 +181,74 @@ def pipeline_progress(tool_results: list[dict[str, object]]) -> PipelineProgress
170181 )
171182
172183
173- def continue_prompt_for (progress : PipelineProgress ) -> str :
184+ def intent_min_edits (intent : str ) -> int :
185+ """Feature-style intents need more than a stub helper before publish."""
186+ text = intent .lower ()
187+ markers = (
188+ "cli" ,
189+ "test" ,
190+ "readme" ,
191+ "wire" ,
192+ "status" ,
193+ "helper" ,
194+ "unit test" ,
195+ "modes table" ,
196+ )
197+ if sum (1 for m in markers if m in text ) >= 2 :
198+ return 2
199+ return 1
200+
201+
202+ def publish_block_reason (
203+ intent : str ,
204+ * ,
205+ workspace : Path = Path ("." ),
206+ ) -> str | None :
207+ """
208+ Block verify/publish when a CLI feature is still a stub.
209+
210+ Detects unused ``render_status`` imports and missing ``evolve status`` wiring.
211+ """
212+ text = intent .lower ()
213+ wants_cli = "cli" in text or ("evolve" in text and "status" in text )
214+ if not wants_cli :
215+ return None
216+
217+ cli_path = workspace / "src" / "ai_agent" / "entrypoints" / "cli.py"
218+ if not cli_path .is_file ():
219+ return "cli.py missing — implement the command before publish"
220+ body = cli_path .read_text (encoding = "utf-8" )
221+
222+ if "render_status" in body and "render_status(" not in body :
223+ return (
224+ "cli.py imports render_status but never calls it — "
225+ "wire `ai-agent evolve status` before publish"
226+ )
227+ if "status" in text and "_run_evolve_status" not in body and '== "status"' not in body :
228+ return (
229+ "cli.py has no evolve status handler — "
230+ "add `evolve status` command wiring before publish"
231+ )
232+ if "test" in text :
233+ tests_dir = workspace / "tests" / "features" / "evolve"
234+ has_status_test = (tests_dir / "test_status.py" ).is_file ()
235+ if not has_status_test :
236+ return "missing tests/features/evolve/test_status.py — add a test before publish"
237+ return None
238+
239+
240+ def continue_prompt_for (
241+ progress : PipelineProgress ,
242+ * ,
243+ block_reason : str | None = None ,
244+ ) -> str :
174245 """State-aware nudge so evolve does not re-edit forever."""
246+ if block_reason :
247+ return (
248+ f"CONTINUE: publish blocked — { block_reason } . "
249+ "Edit the missing wiring/tests with replace_in_file or write_file NOW. "
250+ "Do not git_commit or open_pull_request yet."
251+ )
175252 action = progress .next_action
176253 if action == "done" :
177254 return "PR already opened. Respond with the PR URL only."
@@ -192,12 +269,14 @@ def continue_prompt_for(progress: PipelineProgress) -> str:
192269 )
193270 if action == "git_diff" :
194271 return (
195- "CONTINUE: edit already succeeded. Call git_diff then run_checks. "
196- "Do NOT call replace_in_file/write_file/apply_patch again."
272+ "CONTINUE: if the intent still needs CLI wiring or a test, edit those "
273+ "files NOW (up to 3 total edits). Otherwise call git_diff then run_checks. "
274+ "Do not open a PR for a stub helper alone."
197275 )
198276 return (
199- "CONTINUE: make the intent edit with replace_in_file ONCE, then git_diff, "
200- "run_checks, git_commit, open_pull_request. Do not narrate."
277+ "CONTINUE: STOP surveying. Implement the FULL intent with write_file/"
278+ "replace_in_file (helper + CLI + test if the intent asks). Then git_diff, "
279+ "run_checks, git_commit, open_pull_request. Do not publish stubs."
201280 )
202281
203282
@@ -254,6 +333,7 @@ async def run_evolve(
254333 collected : list [dict [str , object ]] = []
255334 last_result : StepResult | None = None
256335 turns = max (1 , max_continue_turns )
336+ min_edits = intent_min_edits (cleaned )
257337
258338 try :
259339 for turn in range (turns ):
@@ -265,8 +345,20 @@ async def run_evolve(
265345 emit_ops_event (name = "evolve.stopped" , run_id = rid , success = False )
266346 return run
267347
268- progress = pipeline_progress (collected )
269- user_input = prompt if turn == 0 else continue_prompt_for (progress )
348+ progress = pipeline_progress (collected , min_edits = min_edits )
349+ block = None if progress .pr_opened else publish_block_reason (cleaned )
350+ if block and progress .next_action != "edit" :
351+ # Force another edit cycle instead of verify/publish on stubs.
352+ progress = PipelineProgress (
353+ edited = False ,
354+ diff_seen = False ,
355+ checks_ok = False ,
356+ committed = False ,
357+ pr_opened = False ,
358+ )
359+ user_input = (
360+ prompt if turn == 0 else continue_prompt_for (progress , block_reason = block )
361+ )
270362 if turn > 0 :
271363 status_by_next : dict [
272364 str ,
@@ -291,7 +383,23 @@ async def run_evolve(
291383 result = await agent .step (session = session , user_input = user_input )
292384 last_result = result
293385 collected .extend (result .tool_results or [])
294- progress = pipeline_progress (collected )
386+ progress = pipeline_progress (collected , min_edits = min_edits )
387+ block_after = None if progress .pr_opened else publish_block_reason (cleaned )
388+ if block_after and progress .next_action in {
389+ "git_diff" ,
390+ "run_checks" ,
391+ "git_commit" ,
392+ "open_pull_request" ,
393+ "done" ,
394+ }:
395+ # Stub still incomplete — keep evolving even if model raced to verify.
396+ progress = PipelineProgress (
397+ edited = False ,
398+ diff_seen = False ,
399+ checks_ok = False ,
400+ committed = False ,
401+ pr_opened = False ,
402+ )
295403 logger .info (
296404 "evolve_turn run_id=%s turn=%s kind=%s next=%s msg=%s" ,
297405 rid ,
@@ -316,9 +424,12 @@ async def run_evolve(
316424 raise RuntimeError (result .message )
317425
318426 pr_url = _extract_pr_url (result .message , collected )
319- if pr_url or progress .pr_opened :
427+ # Only finish when open_pull_request actually succeeded — never
428+ # treat a random github.com link from README/survey text as a PR.
429+ stub = publish_block_reason (cleaned )
430+ if progress .pr_opened and pr_url and not stub :
320431 latency_ms = int ((datetime .now (UTC ) - started ).total_seconds () * 1000 )
321- run .pr_url = pr_url or _extract_pr_url ( "" , collected )
432+ run .pr_url = pr_url
322433 run .status = "done"
323434 run .error = None
324435 run .last_check_log = _last_check_log (collected )
@@ -355,7 +466,7 @@ async def run_evolve(
355466 assert last_result is not None
356467 latency_ms = int ((datetime .now (UTC ) - started ).total_seconds () * 1000 )
357468 _write_result (artifact_dir , last_result , collected )
358- progress = pipeline_progress (collected )
469+ progress = pipeline_progress (collected , min_edits = min_edits )
359470 if progress .committed :
360471 run .status = "awaiting_approval"
361472 run .error = "commit succeeded but open_pull_request did not run or failed"
@@ -448,14 +559,15 @@ def _tool_succeeded(tool_results: list[dict[str, object]] | None, name: str) ->
448559
449560
450561def _extract_pr_url (message : str , tool_results : list [dict [str , object ]] | None ) -> str | None :
562+ """Accept only URLs from a successful open_pull_request tool result."""
563+ _ = message # never scrape free-form assistant text (README links false-positive)
451564 for item in tool_results or []:
452- if item .get ("tool_name" ) == "open_pull_request" and item .get ("success" ):
453- out = str (item .get ("output" ) or "" )
454- if out .startswith ("http" ):
455- return out .strip ()
456- match = re .search (r"https://github\.com/[^\s)]+" , message )
457- if match :
458- return match .group (0 )
565+ if item .get ("tool_name" ) != "open_pull_request" or not item .get ("success" ):
566+ continue
567+ out = str (item .get ("output" ) or "" ).strip ()
568+ if "/pull/" in out and out .startswith ("http" ):
569+ # First URL token only
570+ return out .split ()[0 ].rstrip ("\" ')].>" )
459571 return None
460572
461573
0 commit comments