autoforge-ai 0.1.13 → 0.1.14

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
@@ -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.14",
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
@@ -7,6 +7,7 @@ The assistant can answer questions about the codebase and features
7
7
  but cannot modify any files.
8
8
  """
9
9
 
10
+ import asyncio
10
11
  import json
11
12
  import logging
12
13
  import os
@@ -25,7 +26,12 @@ from .assistant_database import (
25
26
  create_conversation,
26
27
  get_messages,
27
28
  )
28
- from .chat_constants import ROOT_DIR
29
+ from .chat_constants import (
30
+ MAX_CHAT_RATE_LIMIT_RETRIES,
31
+ ROOT_DIR,
32
+ calculate_rate_limit_backoff,
33
+ check_rate_limit_error,
34
+ )
29
35
 
30
36
  # Load environment variables from .env file if present
31
37
  load_dotenv()
@@ -393,39 +399,66 @@ class AssistantChatSession:
393
399
 
394
400
  full_response = ""
395
401
 
396
- # 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", {})
402
+ # Stream the response (with rate-limit retry)
403
+ for _attempt in range(MAX_CHAT_RATE_LIMIT_RETRIES + 1):
404
+ try:
405
+ async for msg in self.client.receive_response():
406
+ msg_type = type(msg).__name__
407
+
408
+ if msg_type == "AssistantMessage" and hasattr(msg, "content"):
409
+ for block in msg.content:
410
+ block_type = type(block).__name__
411
+
412
+ if block_type == "TextBlock" and hasattr(block, "text"):
413
+ text = block.text
414
+ if text:
415
+ full_response += text
416
+ yield {"type": "text", "content": text}
417
+
418
+ elif block_type == "ToolUseBlock" and hasattr(block, "name"):
419
+ tool_name = block.name
420
+ tool_input = getattr(block, "input", {})
421
+
422
+ # Intercept ask_user tool calls -> yield as question message
423
+ if tool_name == "mcp__features__ask_user":
424
+ questions = tool_input.get("questions", [])
425
+ if questions:
426
+ yield {
427
+ "type": "question",
428
+ "questions": questions,
429
+ }
430
+ continue
413
431
 
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
432
  yield {
419
- "type": "question",
420
- "questions": questions,
433
+ "type": "tool_call",
434
+ "tool": tool_name,
435
+ "input": tool_input,
421
436
  }
422
- continue
423
-
424
- yield {
425
- "type": "tool_call",
426
- "tool": tool_name,
427
- "input": tool_input,
428
- }
437
+ # Completed successfully — break out of retry loop
438
+ break
439
+ except Exception as exc:
440
+ is_rate_limit, retry_secs = check_rate_limit_error(exc)
441
+ if is_rate_limit and _attempt < MAX_CHAT_RATE_LIMIT_RETRIES:
442
+ delay = retry_secs if retry_secs else calculate_rate_limit_backoff(_attempt)
443
+ logger.warning(f"Rate limited (attempt {_attempt + 1}/{MAX_CHAT_RATE_LIMIT_RETRIES}), retrying in {delay}s")
444
+ yield {
445
+ "type": "rate_limited",
446
+ "retry_in": delay,
447
+ "attempt": _attempt + 1,
448
+ "max_attempts": MAX_CHAT_RATE_LIMIT_RETRIES,
449
+ }
450
+ await asyncio.sleep(delay)
451
+ await self.client.query(message)
452
+ continue
453
+ if is_rate_limit:
454
+ logger.error("Rate limit retries exhausted for assistant chat")
455
+ yield {"type": "error", "content": "Rate limited. Please try again later."}
456
+ return
457
+ # Non-rate-limit MessageParseError: log and break (don't crash)
458
+ if type(exc).__name__ == "MessageParseError":
459
+ logger.warning(f"Ignoring unrecognized message from Claude CLI: {exc}")
460
+ break
461
+ raise
429
462
 
430
463
  # Store the complete response in the database
431
464
  if full_response and self.conversation_id:
@@ -9,6 +9,7 @@ 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
15
  from typing import AsyncGenerator
@@ -32,6 +33,45 @@ 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 calculate_rate_limit_backoff, is_rate_limit_error, parse_retry_after # noqa: E402, F401
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ # -------------------------------------------------------------------
41
+ # Rate-limit handling for chat sessions
42
+ # -------------------------------------------------------------------
43
+ MAX_CHAT_RATE_LIMIT_RETRIES = 3
44
+
45
+
46
+ def check_rate_limit_error(exc: Exception) -> tuple[bool, int | None]:
47
+ """Inspect an exception and determine if it represents a rate-limit.
48
+
49
+ Returns ``(is_rate_limit, retry_seconds)``. ``retry_seconds`` is the
50
+ parsed Retry-After value when available, otherwise ``None`` (caller
51
+ should use exponential backoff).
52
+
53
+ Handles:
54
+ - ``MessageParseError`` whose raw *data* dict has
55
+ ``type == "rate_limit_event"`` (Claude CLI sends this).
56
+ - Any exception whose string representation matches known rate-limit
57
+ patterns (via ``rate_limit_utils.is_rate_limit_error``).
58
+ """
59
+ exc_str = str(exc)
60
+
61
+ # Check for MessageParseError with a rate_limit_event payload
62
+ cls_name = type(exc).__name__
63
+ if cls_name == "MessageParseError":
64
+ raw_data = getattr(exc, "data", None)
65
+ if isinstance(raw_data, dict) and raw_data.get("type") == "rate_limit_event":
66
+ retry = parse_retry_after(str(raw_data)) if raw_data else None
67
+ return True, retry
68
+
69
+ # Fallback: match error text against known rate-limit patterns
70
+ if is_rate_limit_error(exc_str):
71
+ retry = parse_retry_after(exc_str)
72
+ return True, retry
73
+
74
+ return False, None
35
75
 
36
76
 
37
77
  async def make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator[dict, None]:
@@ -22,7 +22,13 @@ 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
+ MAX_CHAT_RATE_LIMIT_RETRIES,
27
+ ROOT_DIR,
28
+ calculate_rate_limit_backoff,
29
+ check_rate_limit_error,
30
+ make_multimodal_message,
31
+ )
26
32
 
27
33
  # Load environment variables from .env file if present
28
34
  load_dotenv()
@@ -298,24 +304,67 @@ class ExpandChatSession:
298
304
  else:
299
305
  await self.client.query(message)
300
306
 
301
- # 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()
307
+ # Stream the response (with rate-limit retry)
308
+ for _attempt in range(MAX_CHAT_RATE_LIMIT_RETRIES + 1):
309
+ try:
310
+ async for msg in self.client.receive_response():
311
+ msg_type = type(msg).__name__
312
+
313
+ if msg_type == "AssistantMessage" and hasattr(msg, "content"):
314
+ for block in msg.content:
315
+ block_type = type(block).__name__
316
+
317
+ if block_type == "TextBlock" and hasattr(block, "text"):
318
+ text = block.text
319
+ if text:
320
+ yield {"type": "text", "content": text}
321
+
322
+ self.messages.append({
323
+ "role": "assistant",
324
+ "content": text,
325
+ "timestamp": datetime.now().isoformat()
326
+ })
327
+ # Completed successfully — break out of retry loop
328
+ break
329
+ except Exception as exc:
330
+ is_rate_limit, retry_secs = check_rate_limit_error(exc)
331
+ if is_rate_limit and _attempt < MAX_CHAT_RATE_LIMIT_RETRIES:
332
+ delay = retry_secs if retry_secs else calculate_rate_limit_backoff(_attempt)
333
+ logger.warning(f"Rate limited (attempt {_attempt + 1}/{MAX_CHAT_RATE_LIMIT_RETRIES}), retrying in {delay}s")
334
+ yield {
335
+ "type": "rate_limited",
336
+ "retry_in": delay,
337
+ "attempt": _attempt + 1,
338
+ "max_attempts": MAX_CHAT_RATE_LIMIT_RETRIES,
339
+ }
340
+ await asyncio.sleep(delay)
341
+ # Re-send the query before retrying receive_response
342
+ if attachments and len(attachments) > 0:
343
+ content_blocks_retry: list[dict[str, Any]] = []
344
+ if message:
345
+ content_blocks_retry.append({"type": "text", "text": message})
346
+ for att in attachments:
347
+ content_blocks_retry.append({
348
+ "type": "image",
349
+ "source": {
350
+ "type": "base64",
351
+ "media_type": att.mimeType,
352
+ "data": att.base64Data,
353
+ }
318
354
  })
355
+ await self.client.query(make_multimodal_message(content_blocks_retry))
356
+ else:
357
+ await self.client.query(message)
358
+ continue
359
+ if is_rate_limit:
360
+ logger.error("Rate limit retries exhausted for expand chat")
361
+ yield {"type": "error", "content": "Rate limited. Please try again later."}
362
+ return
363
+ # Non-rate-limit MessageParseError: log and break (don't crash)
364
+ if type(exc).__name__ == "MessageParseError":
365
+ logger.warning(f"Ignoring unrecognized message from Claude CLI: {exc}")
366
+ break
367
+ raise
319
368
 
320
369
  def get_features_created(self) -> int:
321
370
  """Get the total number of features created in this session."""
@@ -6,6 +6,7 @@ Manages interactive spec creation conversation with Claude.
6
6
  Uses the create-spec.md skill to guide users through app spec creation.
7
7
  """
8
8
 
9
+ import asyncio
9
10
  import json
10
11
  import logging
11
12
  import os
@@ -19,7 +20,13 @@ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
19
20
  from dotenv import load_dotenv
20
21
 
21
22
  from ..schemas import ImageAttachment
22
- from .chat_constants import ROOT_DIR, make_multimodal_message
23
+ from .chat_constants import (
24
+ MAX_CHAT_RATE_LIMIT_RETRIES,
25
+ ROOT_DIR,
26
+ calculate_rate_limit_backoff,
27
+ check_rate_limit_error,
28
+ make_multimodal_message,
29
+ )
23
30
 
24
31
  # Load environment variables from .env file if present
25
32
  load_dotenv()
@@ -304,117 +311,145 @@ class SpecChatSession:
304
311
  # Store paths for the completion message
305
312
  spec_path = None
306
313
 
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)
388
- }
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
402
- yield {
403
- "type": "file_written",
404
- "path": str(file_path)
405
- }
314
+ # Stream the response using receive_response (with rate-limit retry)
315
+ for _attempt in range(MAX_CHAT_RATE_LIMIT_RETRIES + 1):
316
+ try:
317
+ async for msg in self.client.receive_response():
318
+ msg_type = type(msg).__name__
319
+
320
+ if msg_type == "AssistantMessage" and hasattr(msg, "content"):
321
+ # Process content blocks in the assistant message
322
+ for block in msg.content:
323
+ block_type = type(block).__name__
324
+
325
+ if block_type == "TextBlock" and hasattr(block, "text"):
326
+ # Accumulate text and yield it
327
+ text = block.text
328
+ if text:
329
+ current_text += text
330
+ yield {"type": "text", "content": text}
331
+
332
+ # Store in message history
333
+ self.messages.append({
334
+ "role": "assistant",
335
+ "content": text,
336
+ "timestamp": datetime.now().isoformat()
337
+ })
338
+
339
+ elif block_type == "ToolUseBlock" and hasattr(block, "name"):
340
+ tool_name = block.name
341
+ tool_input = getattr(block, "input", {})
342
+ tool_id = getattr(block, "id", "")
343
+
344
+ if tool_name in ("Write", "Edit"):
345
+ # File being written or edited - track for verification
346
+ file_path = tool_input.get("file_path", "")
347
+
348
+ # Track app_spec.txt
349
+ if "app_spec.txt" in str(file_path):
350
+ pending_writes["app_spec"] = {
351
+ "tool_id": tool_id,
352
+ "path": file_path
353
+ }
354
+ logger.info(f"{tool_name} tool called for app_spec.txt: {file_path}")
355
+
356
+ # Track initializer_prompt.md
357
+ elif "initializer_prompt.md" in str(file_path):
358
+ pending_writes["initializer"] = {
359
+ "tool_id": tool_id,
360
+ "path": file_path
361
+ }
362
+ logger.info(f"{tool_name} tool called for initializer_prompt.md: {file_path}")
363
+
364
+ elif msg_type == "UserMessage" and hasattr(msg, "content"):
365
+ # Tool results - check for write confirmations and errors
366
+ for block in msg.content:
367
+ block_type = type(block).__name__
368
+ if block_type == "ToolResultBlock":
369
+ is_error = getattr(block, "is_error", False)
370
+ tool_use_id = getattr(block, "tool_use_id", "")
371
+
372
+ if is_error:
373
+ content = getattr(block, "content", "Unknown error")
374
+ logger.warning(f"Tool error: {content}")
375
+ # Clear any pending writes that failed
376
+ for key in pending_writes:
377
+ pending_write = pending_writes[key]
378
+ if pending_write is not None and tool_use_id == pending_write.get("tool_id"):
379
+ logger.error(f"{key} write failed: {content}")
380
+ pending_writes[key] = None
406
381
  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
- }
382
+ # Tool succeeded - check which file was written
383
+
384
+ # Check app_spec.txt
385
+ if pending_writes["app_spec"] and tool_use_id == pending_writes["app_spec"].get("tool_id"):
386
+ file_path = pending_writes["app_spec"]["path"]
387
+ full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path
388
+ if full_path.exists():
389
+ logger.info(f"app_spec.txt verified at: {full_path}")
390
+ files_written["app_spec"] = True
391
+ spec_path = file_path
392
+
393
+ # Notify about file write (but NOT completion yet)
394
+ yield {
395
+ "type": "file_written",
396
+ "path": str(file_path)
397
+ }
398
+ else:
399
+ logger.error(f"app_spec.txt not found after write: {full_path}")
400
+ pending_writes["app_spec"] = None
401
+
402
+ # Check initializer_prompt.md
403
+ if pending_writes["initializer"] and tool_use_id == pending_writes["initializer"].get("tool_id"):
404
+ file_path = pending_writes["initializer"]["path"]
405
+ full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path
406
+ if full_path.exists():
407
+ logger.info(f"initializer_prompt.md verified at: {full_path}")
408
+ files_written["initializer"] = True
409
+
410
+ # Notify about file write
411
+ yield {
412
+ "type": "file_written",
413
+ "path": str(file_path)
414
+ }
415
+ else:
416
+ logger.error(f"initializer_prompt.md not found after write: {full_path}")
417
+ pending_writes["initializer"] = None
418
+
419
+ # Check if BOTH files are now written - only then signal completion
420
+ if files_written["app_spec"] and files_written["initializer"]:
421
+ logger.info("Both app_spec.txt and initializer_prompt.md verified - signaling completion")
422
+ self.complete = True
423
+ yield {
424
+ "type": "spec_complete",
425
+ "path": str(spec_path)
426
+ }
427
+ # Completed successfully — break out of retry loop
428
+ break
429
+ except Exception as exc:
430
+ is_rate_limit, retry_secs = check_rate_limit_error(exc)
431
+ if is_rate_limit and _attempt < MAX_CHAT_RATE_LIMIT_RETRIES:
432
+ delay = retry_secs if retry_secs else calculate_rate_limit_backoff(_attempt)
433
+ logger.warning(f"Rate limited (attempt {_attempt + 1}/{MAX_CHAT_RATE_LIMIT_RETRIES}), retrying in {delay}s")
434
+ yield {
435
+ "type": "rate_limited",
436
+ "retry_in": delay,
437
+ "attempt": _attempt + 1,
438
+ "max_attempts": MAX_CHAT_RATE_LIMIT_RETRIES,
439
+ }
440
+ await asyncio.sleep(delay)
441
+ # Re-send the query before retrying receive_response
442
+ await self.client.query(message)
443
+ continue
444
+ if is_rate_limit:
445
+ logger.error("Rate limit retries exhausted for spec chat")
446
+ yield {"type": "error", "content": "Rate limited. Please try again later."}
447
+ return
448
+ # Non-rate-limit MessageParseError: log and break (don't crash)
449
+ if type(exc).__name__ == "MessageParseError":
450
+ logger.warning(f"Ignoring unrecognized message from Claude CLI: {exc}")
451
+ break
452
+ raise
418
453
 
419
454
  def is_complete(self) -> bool:
420
455
  """Check if spec creation is complete."""