autoforge-ai 0.1.13 → 0.1.15

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.
@@ -55,10 +55,10 @@ Pull request(s): $ARGUMENTS
55
55
  - Reviewing large, unfocused PRs is impractical and error-prone; the review cannot provide adequate assurance for such changes
56
56
 
57
57
  6. **Vision Alignment Check**
58
- - Read the project's README.md and CLAUDE.md to understand the application's core purpose
59
- - Assess whether this PR aligns with the application's intended functionality
60
- - If the changes deviate significantly from the core vision or add functionality that doesn't serve the application's purpose, note this in the review
61
- - This is not a blocker, but should be flagged for the reviewer's consideration
58
+ - **VISION.md protection**: First, check whether the PR diff modifies `VISION.md` in any way (edits, deletions, renames). If it does, **stop the review immediately** — verdict is **DON'T MERGE**. VISION.md is immutable and no PR is permitted to alter it. Explain this to the user and skip all remaining steps.
59
+ - Read the project's `VISION.md`, `README.md`, and `CLAUDE.md` to understand the application's core purpose and mandatory architectural constraints
60
+ - Assess whether this PR aligns with the vision defined in `VISION.md`
61
+ - **Vision deviation is a merge blocker.** If the PR introduces functionality, integrations, or architectural changes that conflict with `VISION.md`, the verdict must be **DON'T MERGE**. This is not negotiable — the vision document takes precedence over any PR rationale.
62
62
 
63
63
  7. **Safety Assessment**
64
64
  - Provide a review on whether the PR is safe to merge as-is
package/agent.py CHANGED
@@ -74,46 +74,65 @@ async def run_agent_session(
74
74
  await client.query(message)
75
75
 
76
76
  # Collect response text and show tool use
77
+ # Retry receive_response() on MessageParseError — the SDK raises this for
78
+ # unknown CLI message types (e.g. "rate_limit_event") which kills the async
79
+ # generator. The subprocess is still alive so we restart to read remaining
80
+ # messages from the buffered channel.
77
81
  response_text = ""
78
- async for msg in client.receive_response():
79
- msg_type = type(msg).__name__
80
-
81
- # Handle AssistantMessage (text and tool use)
82
- if msg_type == "AssistantMessage" and hasattr(msg, "content"):
83
- for block in msg.content:
84
- block_type = type(block).__name__
85
-
86
- if block_type == "TextBlock" and hasattr(block, "text"):
87
- response_text += block.text
88
- print(block.text, end="", flush=True)
89
- elif block_type == "ToolUseBlock" and hasattr(block, "name"):
90
- print(f"\n[Tool: {block.name}]", flush=True)
91
- if hasattr(block, "input"):
92
- input_str = str(block.input)
93
- if len(input_str) > 200:
94
- print(f" Input: {input_str[:200]}...", flush=True)
95
- else:
96
- print(f" Input: {input_str}", flush=True)
97
-
98
- # Handle UserMessage (tool results)
99
- elif msg_type == "UserMessage" and hasattr(msg, "content"):
100
- for block in msg.content:
101
- block_type = type(block).__name__
102
-
103
- if block_type == "ToolResultBlock":
104
- result_content = getattr(block, "content", "")
105
- is_error = getattr(block, "is_error", False)
106
-
107
- # Check if command was blocked by security hook
108
- if "blocked" in str(result_content).lower():
109
- print(f" [BLOCKED] {result_content}", flush=True)
110
- elif is_error:
111
- # Show errors (truncated)
112
- error_str = str(result_content)[:500]
113
- print(f" [Error] {error_str}", flush=True)
114
- else:
115
- # Tool succeeded - just show brief confirmation
116
- print(" [Done]", flush=True)
82
+ max_parse_retries = 50
83
+ parse_retries = 0
84
+ while True:
85
+ try:
86
+ async for msg in client.receive_response():
87
+ msg_type = type(msg).__name__
88
+
89
+ # Handle AssistantMessage (text and tool use)
90
+ if msg_type == "AssistantMessage" and hasattr(msg, "content"):
91
+ for block in msg.content:
92
+ block_type = type(block).__name__
93
+
94
+ if block_type == "TextBlock" and hasattr(block, "text"):
95
+ response_text += block.text
96
+ print(block.text, end="", flush=True)
97
+ elif block_type == "ToolUseBlock" and hasattr(block, "name"):
98
+ print(f"\n[Tool: {block.name}]", flush=True)
99
+ if hasattr(block, "input"):
100
+ input_str = str(block.input)
101
+ if len(input_str) > 200:
102
+ print(f" Input: {input_str[:200]}...", flush=True)
103
+ else:
104
+ print(f" Input: {input_str}", flush=True)
105
+
106
+ # Handle UserMessage (tool results)
107
+ elif msg_type == "UserMessage" and hasattr(msg, "content"):
108
+ for block in msg.content:
109
+ block_type = type(block).__name__
110
+
111
+ if block_type == "ToolResultBlock":
112
+ result_content = getattr(block, "content", "")
113
+ is_error = getattr(block, "is_error", False)
114
+
115
+ # Check if command was blocked by security hook
116
+ if "blocked" in str(result_content).lower():
117
+ print(f" [BLOCKED] {result_content}", flush=True)
118
+ elif is_error:
119
+ # Show errors (truncated)
120
+ error_str = str(result_content)[:500]
121
+ print(f" [Error] {error_str}", flush=True)
122
+ else:
123
+ # Tool succeeded - just show brief confirmation
124
+ print(" [Done]", flush=True)
125
+
126
+ break # Normal completion
127
+ except Exception as inner_exc:
128
+ if type(inner_exc).__name__ == "MessageParseError":
129
+ parse_retries += 1
130
+ if parse_retries > max_parse_retries:
131
+ print(f"Too many unrecognized CLI messages ({parse_retries}), stopping")
132
+ break
133
+ print(f"Ignoring unrecognized message from Claude CLI: {inner_exc}")
134
+ continue
135
+ raise # Re-raise to outer except
117
136
 
118
137
  print("\n" + "-" * 70 + "\n")
119
138
  return "continue", response_text
@@ -1049,8 +1049,21 @@ def feature_request_human_input(
1049
1049
  ftype = field.get("type", "text")
1050
1050
  if ftype not in VALID_FIELD_TYPES:
1051
1051
  return json.dumps({"error": f"Field at index {i} has invalid type '{ftype}'. Must be one of: {', '.join(sorted(VALID_FIELD_TYPES))}"})
1052
- if ftype == "select" and not field.get("options"):
1053
- return json.dumps({"error": f"Field at index {i} is type 'select' but missing 'options' array"})
1052
+ if ftype == "select":
1053
+ options = field.get("options")
1054
+ if not options or not isinstance(options, list):
1055
+ return json.dumps({"error": f"Field at index {i} is type 'select' but missing or invalid 'options' array"})
1056
+ for j, opt in enumerate(options):
1057
+ if not isinstance(opt, dict):
1058
+ return json.dumps({"error": f"Field at index {i}, option {j} must be an object with 'value' and 'label'"})
1059
+ if "value" not in opt or "label" not in opt:
1060
+ return json.dumps({"error": f"Field at index {i}, option {j} missing required 'value' or 'label'"})
1061
+ if not isinstance(opt["value"], str) or not opt["value"].strip():
1062
+ return json.dumps({"error": f"Field at index {i}, option {j} has empty or invalid 'value'"})
1063
+ if not isinstance(opt["label"], str) or not opt["label"].strip():
1064
+ return json.dumps({"error": f"Field at index {i}, option {j} has empty or invalid 'label'"})
1065
+ elif field.get("options"):
1066
+ return json.dumps({"error": f"Field at index {i} has 'options' but type is '{ftype}' (only 'select' uses options)"})
1054
1067
 
1055
1068
  request_data = {
1056
1069
  "prompt": prompt,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autoforge-ai",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Autonomous coding agent with web UI - build complete apps with AI",
5
5
  "license": "AGPL-3.0",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  # Production runtime dependencies only
2
2
  # For development, use requirements.txt (includes ruff, mypy, pytest)
3
- claude-agent-sdk>=0.1.0,<0.2.0
3
+ claude-agent-sdk>=0.1.39,<0.2.0
4
4
  python-dotenv>=1.0.0
5
5
  sqlalchemy>=2.0.0
6
6
  fastapi>=0.115.0
@@ -25,7 +25,11 @@ from .assistant_database import (
25
25
  create_conversation,
26
26
  get_messages,
27
27
  )
28
- from .chat_constants import ROOT_DIR
28
+ from .chat_constants import (
29
+ ROOT_DIR,
30
+ check_rate_limit_error,
31
+ safe_receive_response,
32
+ )
29
33
 
30
34
  # Load environment variables from .env file if present
31
35
  load_dotenv()
@@ -394,38 +398,46 @@ class AssistantChatSession:
394
398
  full_response = ""
395
399
 
396
400
  # Stream the response
397
- async for msg in self.client.receive_response():
398
- msg_type = type(msg).__name__
399
-
400
- if msg_type == "AssistantMessage" and hasattr(msg, "content"):
401
- for block in msg.content:
402
- block_type = type(block).__name__
403
-
404
- if block_type == "TextBlock" and hasattr(block, "text"):
405
- text = block.text
406
- if text:
407
- full_response += text
408
- yield {"type": "text", "content": text}
409
-
410
- elif block_type == "ToolUseBlock" and hasattr(block, "name"):
411
- tool_name = block.name
412
- tool_input = getattr(block, "input", {})
413
-
414
- # Intercept ask_user tool calls -> yield as question message
415
- if tool_name == "mcp__features__ask_user":
416
- questions = tool_input.get("questions", [])
417
- if questions:
418
- yield {
419
- "type": "question",
420
- "questions": questions,
421
- }
422
- continue
423
-
424
- yield {
425
- "type": "tool_call",
426
- "tool": tool_name,
427
- "input": tool_input,
428
- }
401
+ try:
402
+ async for msg in safe_receive_response(self.client, logger):
403
+ msg_type = type(msg).__name__
404
+
405
+ if msg_type == "AssistantMessage" and hasattr(msg, "content"):
406
+ for block in msg.content:
407
+ block_type = type(block).__name__
408
+
409
+ if block_type == "TextBlock" and hasattr(block, "text"):
410
+ text = block.text
411
+ if text:
412
+ full_response += text
413
+ yield {"type": "text", "content": text}
414
+
415
+ elif block_type == "ToolUseBlock" and hasattr(block, "name"):
416
+ tool_name = block.name
417
+ tool_input = getattr(block, "input", {})
418
+
419
+ # Intercept ask_user tool calls -> yield as question message
420
+ if tool_name == "mcp__features__ask_user":
421
+ questions = tool_input.get("questions", [])
422
+ if questions:
423
+ yield {
424
+ "type": "question",
425
+ "questions": questions,
426
+ }
427
+ continue
428
+
429
+ yield {
430
+ "type": "tool_call",
431
+ "tool": tool_name,
432
+ "input": tool_input,
433
+ }
434
+ except Exception as exc:
435
+ is_rate_limit, _ = check_rate_limit_error(exc)
436
+ if is_rate_limit:
437
+ logger.warning(f"Rate limited: {exc}")
438
+ yield {"type": "error", "content": "Rate limited. Please try again later."}
439
+ return
440
+ raise
429
441
 
430
442
  # Store the complete response in the database
431
443
  if full_response and self.conversation_id:
@@ -9,9 +9,10 @@ project root and is re-exported here for convenience so that existing
9
9
  imports (``from .chat_constants import API_ENV_VARS``) continue to work.
10
10
  """
11
11
 
12
+ import logging
12
13
  import sys
13
14
  from pathlib import Path
14
- from typing import AsyncGenerator
15
+ from typing import Any, AsyncGenerator
15
16
 
16
17
  # -------------------------------------------------------------------
17
18
  # Root directory of the autoforge project (repository root).
@@ -32,6 +33,59 @@ if _root_str not in sys.path:
32
33
  # imports continue to work unchanged.
33
34
  # -------------------------------------------------------------------
34
35
  from env_constants import API_ENV_VARS # noqa: E402, F401
36
+ from rate_limit_utils import is_rate_limit_error, parse_retry_after # noqa: E402, F401
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+
41
+ def check_rate_limit_error(exc: Exception) -> tuple[bool, int | None]:
42
+ """Inspect an exception and determine if it represents a rate-limit.
43
+
44
+ Returns ``(is_rate_limit, retry_seconds)``. ``retry_seconds`` is the
45
+ parsed Retry-After value when available, otherwise ``None`` (caller
46
+ should use exponential backoff).
47
+ """
48
+ # MessageParseError = unknown CLI message type (e.g. "rate_limit_event").
49
+ # These are informational events, NOT actual rate limit errors.
50
+ # The word "rate_limit" in the type name would false-positive the regex.
51
+ if type(exc).__name__ == "MessageParseError":
52
+ return False, None
53
+
54
+ # For all other exceptions: match error text against known rate-limit patterns
55
+ exc_str = str(exc)
56
+ if is_rate_limit_error(exc_str):
57
+ retry = parse_retry_after(exc_str)
58
+ return True, retry
59
+
60
+ return False, None
61
+
62
+
63
+ async def safe_receive_response(client: Any, log: logging.Logger) -> AsyncGenerator:
64
+ """Wrap ``client.receive_response()`` to skip ``MessageParseError``.
65
+
66
+ The Claude Code CLI may emit message types (e.g. ``rate_limit_event``)
67
+ that the installed Python SDK does not recognise, causing
68
+ ``MessageParseError`` which kills the async generator. The CLI
69
+ subprocess is still alive and the SDK uses a buffered memory channel,
70
+ so we restart ``receive_response()`` to continue reading remaining
71
+ messages without losing data.
72
+ """
73
+ max_retries = 50
74
+ retries = 0
75
+ while True:
76
+ try:
77
+ async for msg in client.receive_response():
78
+ yield msg
79
+ return # Normal completion
80
+ except Exception as exc:
81
+ if type(exc).__name__ == "MessageParseError":
82
+ retries += 1
83
+ if retries > max_retries:
84
+ log.error(f"Too many unrecognized CLI messages ({retries}), stopping")
85
+ return
86
+ log.warning(f"Ignoring unrecognized message from Claude CLI: {exc}")
87
+ continue
88
+ raise
35
89
 
36
90
 
37
91
  async def make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator[dict, None]:
@@ -22,7 +22,12 @@ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
22
22
  from dotenv import load_dotenv
23
23
 
24
24
  from ..schemas import ImageAttachment
25
- from .chat_constants import ROOT_DIR, make_multimodal_message
25
+ from .chat_constants import (
26
+ ROOT_DIR,
27
+ check_rate_limit_error,
28
+ make_multimodal_message,
29
+ safe_receive_response,
30
+ )
26
31
 
27
32
  # Load environment variables from .env file if present
28
33
  load_dotenv()
@@ -299,23 +304,31 @@ class ExpandChatSession:
299
304
  await self.client.query(message)
300
305
 
301
306
  # Stream the response
302
- async for msg in self.client.receive_response():
303
- msg_type = type(msg).__name__
304
-
305
- if msg_type == "AssistantMessage" and hasattr(msg, "content"):
306
- for block in msg.content:
307
- block_type = type(block).__name__
308
-
309
- if block_type == "TextBlock" and hasattr(block, "text"):
310
- text = block.text
311
- if text:
312
- yield {"type": "text", "content": text}
313
-
314
- self.messages.append({
315
- "role": "assistant",
316
- "content": text,
317
- "timestamp": datetime.now().isoformat()
318
- })
307
+ try:
308
+ async for msg in safe_receive_response(self.client, logger):
309
+ msg_type = type(msg).__name__
310
+
311
+ if msg_type == "AssistantMessage" and hasattr(msg, "content"):
312
+ for block in msg.content:
313
+ block_type = type(block).__name__
314
+
315
+ if block_type == "TextBlock" and hasattr(block, "text"):
316
+ text = block.text
317
+ if text:
318
+ yield {"type": "text", "content": text}
319
+
320
+ self.messages.append({
321
+ "role": "assistant",
322
+ "content": text,
323
+ "timestamp": datetime.now().isoformat()
324
+ })
325
+ except Exception as exc:
326
+ is_rate_limit, _ = check_rate_limit_error(exc)
327
+ if is_rate_limit:
328
+ logger.warning(f"Rate limited: {exc}")
329
+ yield {"type": "error", "content": "Rate limited. Please try again later."}
330
+ return
331
+ raise
319
332
 
320
333
  def get_features_created(self) -> int:
321
334
  """Get the total number of features created in this session."""
@@ -19,7 +19,12 @@ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
19
19
  from dotenv import load_dotenv
20
20
 
21
21
  from ..schemas import ImageAttachment
22
- from .chat_constants import ROOT_DIR, make_multimodal_message
22
+ from .chat_constants import (
23
+ ROOT_DIR,
24
+ check_rate_limit_error,
25
+ make_multimodal_message,
26
+ safe_receive_response,
27
+ )
23
28
 
24
29
  # Load environment variables from .env file if present
25
30
  load_dotenv()
@@ -304,117 +309,125 @@ class SpecChatSession:
304
309
  # Store paths for the completion message
305
310
  spec_path = None
306
311
 
307
- # Stream the response using receive_response
308
- async for msg in self.client.receive_response():
309
- msg_type = type(msg).__name__
310
-
311
- if msg_type == "AssistantMessage" and hasattr(msg, "content"):
312
- # Process content blocks in the assistant message
313
- for block in msg.content:
314
- block_type = type(block).__name__
315
-
316
- if block_type == "TextBlock" and hasattr(block, "text"):
317
- # Accumulate text and yield it
318
- text = block.text
319
- if text:
320
- current_text += text
321
- yield {"type": "text", "content": text}
322
-
323
- # Store in message history
324
- self.messages.append({
325
- "role": "assistant",
326
- "content": text,
327
- "timestamp": datetime.now().isoformat()
328
- })
329
-
330
- elif block_type == "ToolUseBlock" and hasattr(block, "name"):
331
- tool_name = block.name
332
- tool_input = getattr(block, "input", {})
333
- tool_id = getattr(block, "id", "")
334
-
335
- if tool_name in ("Write", "Edit"):
336
- # File being written or edited - track for verification
337
- file_path = tool_input.get("file_path", "")
338
-
339
- # Track app_spec.txt
340
- if "app_spec.txt" in str(file_path):
341
- pending_writes["app_spec"] = {
342
- "tool_id": tool_id,
343
- "path": file_path
344
- }
345
- logger.info(f"{tool_name} tool called for app_spec.txt: {file_path}")
346
-
347
- # Track initializer_prompt.md
348
- elif "initializer_prompt.md" in str(file_path):
349
- pending_writes["initializer"] = {
350
- "tool_id": tool_id,
351
- "path": file_path
352
- }
353
- logger.info(f"{tool_name} tool called for initializer_prompt.md: {file_path}")
354
-
355
- elif msg_type == "UserMessage" and hasattr(msg, "content"):
356
- # Tool results - check for write confirmations and errors
357
- for block in msg.content:
358
- block_type = type(block).__name__
359
- if block_type == "ToolResultBlock":
360
- is_error = getattr(block, "is_error", False)
361
- tool_use_id = getattr(block, "tool_use_id", "")
362
-
363
- if is_error:
364
- content = getattr(block, "content", "Unknown error")
365
- logger.warning(f"Tool error: {content}")
366
- # Clear any pending writes that failed
367
- for key in pending_writes:
368
- pending_write = pending_writes[key]
369
- if pending_write is not None and tool_use_id == pending_write.get("tool_id"):
370
- logger.error(f"{key} write failed: {content}")
371
- pending_writes[key] = None
372
- else:
373
- # Tool succeeded - check which file was written
374
-
375
- # Check app_spec.txt
376
- if pending_writes["app_spec"] and tool_use_id == pending_writes["app_spec"].get("tool_id"):
377
- file_path = pending_writes["app_spec"]["path"]
378
- full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path
379
- if full_path.exists():
380
- logger.info(f"app_spec.txt verified at: {full_path}")
381
- files_written["app_spec"] = True
382
- spec_path = file_path
383
-
384
- # Notify about file write (but NOT completion yet)
385
- yield {
386
- "type": "file_written",
387
- "path": str(file_path)
312
+ # Stream the response
313
+ try:
314
+ async for msg in safe_receive_response(self.client, logger):
315
+ msg_type = type(msg).__name__
316
+
317
+ if msg_type == "AssistantMessage" and hasattr(msg, "content"):
318
+ # Process content blocks in the assistant message
319
+ for block in msg.content:
320
+ block_type = type(block).__name__
321
+
322
+ if block_type == "TextBlock" and hasattr(block, "text"):
323
+ # Accumulate text and yield it
324
+ text = block.text
325
+ if text:
326
+ current_text += text
327
+ yield {"type": "text", "content": text}
328
+
329
+ # Store in message history
330
+ self.messages.append({
331
+ "role": "assistant",
332
+ "content": text,
333
+ "timestamp": datetime.now().isoformat()
334
+ })
335
+
336
+ elif block_type == "ToolUseBlock" and hasattr(block, "name"):
337
+ tool_name = block.name
338
+ tool_input = getattr(block, "input", {})
339
+ tool_id = getattr(block, "id", "")
340
+
341
+ if tool_name in ("Write", "Edit"):
342
+ # File being written or edited - track for verification
343
+ file_path = tool_input.get("file_path", "")
344
+
345
+ # Track app_spec.txt
346
+ if "app_spec.txt" in str(file_path):
347
+ pending_writes["app_spec"] = {
348
+ "tool_id": tool_id,
349
+ "path": file_path
350
+ }
351
+ logger.info(f"{tool_name} tool called for app_spec.txt: {file_path}")
352
+
353
+ # Track initializer_prompt.md
354
+ elif "initializer_prompt.md" in str(file_path):
355
+ pending_writes["initializer"] = {
356
+ "tool_id": tool_id,
357
+ "path": file_path
388
358
  }
389
- else:
390
- logger.error(f"app_spec.txt not found after write: {full_path}")
391
- pending_writes["app_spec"] = None
392
-
393
- # Check initializer_prompt.md
394
- if pending_writes["initializer"] and tool_use_id == pending_writes["initializer"].get("tool_id"):
395
- file_path = pending_writes["initializer"]["path"]
396
- full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path
397
- if full_path.exists():
398
- logger.info(f"initializer_prompt.md verified at: {full_path}")
399
- files_written["initializer"] = True
400
-
401
- # Notify about file write
359
+ logger.info(f"{tool_name} tool called for initializer_prompt.md: {file_path}")
360
+
361
+ elif msg_type == "UserMessage" and hasattr(msg, "content"):
362
+ # Tool results - check for write confirmations and errors
363
+ for block in msg.content:
364
+ block_type = type(block).__name__
365
+ if block_type == "ToolResultBlock":
366
+ is_error = getattr(block, "is_error", False)
367
+ tool_use_id = getattr(block, "tool_use_id", "")
368
+
369
+ if is_error:
370
+ content = getattr(block, "content", "Unknown error")
371
+ logger.warning(f"Tool error: {content}")
372
+ # Clear any pending writes that failed
373
+ for key in pending_writes:
374
+ pending_write = pending_writes[key]
375
+ if pending_write is not None and tool_use_id == pending_write.get("tool_id"):
376
+ logger.error(f"{key} write failed: {content}")
377
+ pending_writes[key] = None
378
+ else:
379
+ # Tool succeeded - check which file was written
380
+
381
+ # Check app_spec.txt
382
+ if pending_writes["app_spec"] and tool_use_id == pending_writes["app_spec"].get("tool_id"):
383
+ file_path = pending_writes["app_spec"]["path"]
384
+ full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path
385
+ if full_path.exists():
386
+ logger.info(f"app_spec.txt verified at: {full_path}")
387
+ files_written["app_spec"] = True
388
+ spec_path = file_path
389
+
390
+ # Notify about file write (but NOT completion yet)
391
+ yield {
392
+ "type": "file_written",
393
+ "path": str(file_path)
394
+ }
395
+ else:
396
+ logger.error(f"app_spec.txt not found after write: {full_path}")
397
+ pending_writes["app_spec"] = None
398
+
399
+ # Check initializer_prompt.md
400
+ if pending_writes["initializer"] and tool_use_id == pending_writes["initializer"].get("tool_id"):
401
+ file_path = pending_writes["initializer"]["path"]
402
+ full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path
403
+ if full_path.exists():
404
+ logger.info(f"initializer_prompt.md verified at: {full_path}")
405
+ files_written["initializer"] = True
406
+
407
+ # Notify about file write
408
+ yield {
409
+ "type": "file_written",
410
+ "path": str(file_path)
411
+ }
412
+ else:
413
+ logger.error(f"initializer_prompt.md not found after write: {full_path}")
414
+ pending_writes["initializer"] = None
415
+
416
+ # Check if BOTH files are now written - only then signal completion
417
+ if files_written["app_spec"] and files_written["initializer"]:
418
+ logger.info("Both app_spec.txt and initializer_prompt.md verified - signaling completion")
419
+ self.complete = True
402
420
  yield {
403
- "type": "file_written",
404
- "path": str(file_path)
421
+ "type": "spec_complete",
422
+ "path": str(spec_path)
405
423
  }
406
- else:
407
- logger.error(f"initializer_prompt.md not found after write: {full_path}")
408
- pending_writes["initializer"] = None
409
-
410
- # Check if BOTH files are now written - only then signal completion
411
- if files_written["app_spec"] and files_written["initializer"]:
412
- logger.info("Both app_spec.txt and initializer_prompt.md verified - signaling completion")
413
- self.complete = True
414
- yield {
415
- "type": "spec_complete",
416
- "path": str(spec_path)
417
- }
424
+ except Exception as exc:
425
+ is_rate_limit, _ = check_rate_limit_error(exc)
426
+ if is_rate_limit:
427
+ logger.warning(f"Rate limited: {exc}")
428
+ yield {"type": "error", "content": "Rate limited. Please try again later."}
429
+ return
430
+ raise
418
431
 
419
432
  def is_complete(self) -> bool:
420
433
  """Check if spec creation is complete."""