devops-bot-sdk 1.4.81__tar.gz → 1.4.85__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.
Files changed (38) hide show
  1. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/pyproject.toml +1 -1
  4. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/sdk/client.py +1 -1
  6. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/sdk/ipc/handlers.py +213 -91
  7. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/README.md +0 -0
  8. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/devops_bot_sdk.egg-info/SOURCES.txt +0 -0
  9. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  10. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  11. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/devops_bot_sdk.egg-info/requires.txt +0 -0
  12. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  13. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/sdk/collectors/__init__.py +0 -0
  14. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/sdk/collectors/files.py +0 -0
  15. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/sdk/collectors/process.py +0 -0
  16. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/sdk/collectors/screenshot.py +0 -0
  17. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/sdk/config.py +0 -0
  18. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/sdk/crucial.py +0 -0
  19. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/sdk/exceptions.py +0 -0
  20. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/sdk/git_ops.py +0 -0
  21. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/sdk/graphify.py +0 -0
  22. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/sdk/hooks/__init__.py +0 -0
  23. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/sdk/hooks/crucial_guard.py +0 -0
  24. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/sdk/ipc/__init__.py +0 -0
  25. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/sdk/ipc/electron_bridge.py +0 -0
  26. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/sdk/local_exec.py +0 -0
  27. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/sdk/models/__init__.py +0 -0
  28. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/sdk/models/envelope.py +0 -0
  29. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/sdk/models/requests.py +0 -0
  30. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/sdk/models/responses.py +0 -0
  31. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/sdk/models/snapshots.py +0 -0
  32. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/sdk/py.typed +0 -0
  33. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/sdk/run_auto.py +0 -0
  34. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/sdk/sse.py +0 -0
  35. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/sdk/test.py +0 -0
  36. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/sdk/test_pipeline.py +0 -0
  37. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/sdk/updater.py +0 -0
  38. {devops_bot_sdk-1.4.81 → devops_bot_sdk-1.4.85}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.81
3
+ Version: 1.4.85
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.81
3
+ Version: 1.4.85
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devops-bot-sdk"
7
- version = "1.4.81"
7
+ version = "1.4.85"
8
8
  description = "DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app"
9
9
  readme = "README.md"
10
10
  license = "LicenseRef-Proprietary"
@@ -1,6 +1,6 @@
1
1
  """AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
2
2
 
3
- Version: 1.4.81
3
+ Version: 1.4.85
4
4
 
5
5
  Public surface:
6
6
  BackendClient.from_config() — create client from ~/.agentos/config.toml
@@ -30,7 +30,7 @@ Rules:
30
30
  - All data egress through submit_webhook only
31
31
  """
32
32
 
33
- __version__ = "1.4.81"
33
+ __version__ = "1.4.85"
34
34
  __author__ = "AgentOS"
35
35
 
36
36
  from sdk.client import BackendClient
@@ -58,7 +58,7 @@ def _notify_status(task_id: str, status: str) -> None:
58
58
  pass
59
59
 
60
60
 
61
- SDK_VERSION = "1.4.81"
61
+ SDK_VERSION = "1.4.85"
62
62
  _POLL_INTERVAL = 3.0
63
63
  _POLL_TIMEOUT = 600.0
64
64
  _ORCHESTRATE_TIMEOUT = 2700.0 # 45 min — covers approval wait + VPS execution time
@@ -128,6 +128,11 @@ _PLAN_DEADLINE_S = float(os.getenv("AGENTOS_PLAN_DEADLINE_S", "") or 180.0)
128
128
  # plan (the "follows my instructions sometimes, not others" failure mode). ~6k
129
129
  # tokens at ~4 chars/token.
130
130
  _DESC_MAX_CHARS = 24_000
131
+ # Analysis/generation prompts (epic decomposition, PRD, validation) must see the
132
+ # WHOLE epic/story, not a middle-elided slice — dropping requirements is what makes
133
+ # the model hallucinate to fill the gap on large tickets. Claude has a large context
134
+ # window, so give these a generous budget (~30k tokens). Env-tunable.
135
+ _GEN_MAX_CHARS = int(os.getenv("AGENTOS_GEN_MAX_CHARS", "") or 120_000)
131
136
 
132
137
 
133
138
  def _clip_text(text: str, max_chars: int = _DESC_MAX_CHARS) -> str:
@@ -531,7 +536,8 @@ _PRD_FORMAT = (
531
536
 
532
537
 
533
538
  def _build_epic_prompt(jira_key: str, summary: str, description: str | None,
534
- human_instructions: str | None, story_count: int | None = None) -> str:
539
+ human_instructions: str | None, story_count: int | None = None,
540
+ story_titles: list[str] | None = None) -> str:
535
541
  """Read-only prompt that turns an Epic into a decomposition JSON."""
536
542
  parts: list[str] = [
537
543
  "You are decomposing a JIRA EPIC into implementable user stories. This is "
@@ -544,12 +550,18 @@ def _build_epic_prompt(jira_key: str, summary: str, description: str | None,
544
550
  "in that story's Notes — do NOT fabricate a solution.\n"
545
551
  "- Prefer fewer, well-grounded stories over many speculative ones. Every story "
546
552
  "must trace back to something in the epic.\n"
553
+ "- The epic may be LONG: read ALL of it and cover every stated requirement across "
554
+ "the stories — do not drop, summarise away, or skip parts, and do not pad with "
555
+ "invented scope to look complete. If you cannot ground a piece, note it as an open "
556
+ "question instead of fabricating.\n"
557
+ "- Before finalising, self-check: does every story trace to the epic, and do the "
558
+ "stories together cover the epic's scope? Drop any story you cannot ground.\n"
547
559
  "- This epic may be for ANY domain (software, Finance, HR, operations, …). Match "
548
560
  "the stories to the epic's actual domain; do not assume it is software.",
549
561
  f"=== EPIC {jira_key} ===\nSummary: {summary}",
550
562
  ]
551
563
  if description and description.strip():
552
- parts.append("Description:\n" + _clip_text(description.strip()))
564
+ parts.append("Description:\n" + _clip_text(description.strip(), _GEN_MAX_CHARS))
553
565
  if human_instructions and human_instructions.strip():
554
566
  parts.append(
555
567
  "Operator instructions (authoritative — carry any project location / "
@@ -578,11 +590,21 @@ def _build_epic_prompt(jira_key: str, summary: str, description: str | None,
578
590
  "}\n\n"
579
591
  "Do NOT produce humanInstructions for the stories — created stories are clean "
580
592
  "user-story artifacts; implementation instructions are added later.\n"
581
- + (f"Generate EXACTLY {story_count} user stor{'y' if story_count == 1 else 'ies'} — "
582
- "the epic explicitly asks for this many; do not produce more or fewer.\n"
583
- if story_count else
584
- "Create as many stories as the epic genuinely needs (do not pad or truncate "
585
- "artificially); each story must be independently implementable.\n")
593
+ + (
594
+ # The epic ENUMERATES its stories reproduce them EXACTLY (verbatim titles).
595
+ ("The epic already lists its user stories. Produce EXACTLY these "
596
+ f"{len(story_titles)} stor{'y' if len(story_titles) == 1 else 'ies'}, in this "
597
+ "order, using each title VERBATIM as the story's summary do NOT invent, "
598
+ "rename, merge, add, drop, or reorder any. Only expand each into the USER "
599
+ "STORY FORMAT for its description:\n"
600
+ + "\n".join(f" {i}. {t}" for i, t in enumerate(story_titles, 1)) + "\n")
601
+ if story_titles else
602
+ (f"Generate EXACTLY {story_count} user stor{'y' if story_count == 1 else 'ies'} — "
603
+ "the epic explicitly asks for this many; do not produce more or fewer.\n"
604
+ if story_count else
605
+ "Create as many stories as the epic genuinely needs (do not pad or truncate "
606
+ "artificially); each story must be independently implementable.\n")
607
+ )
586
608
  + "assigneeEmail is OPTIONAL per story: set it ONLY when the epic explicitly "
587
609
  "assigns that specific work to a named person/email (e.g. 'assign the API to "
588
610
  "alice@x.com'); otherwise omit it.\n\n"
@@ -684,6 +706,42 @@ def _extract_story_count(text: str | None) -> int | None:
684
706
  return min(n, 50) if n and n >= 1 else None
685
707
 
686
708
 
709
+ _STORY_LIST_ITEM_RE = re.compile(r"^\s*(?:\d+[.)]|[-*•])\s+(.+?)\s*$")
710
+
711
+
712
+ def _extract_story_titles(text: str | None) -> list[str]:
713
+ """Titles of the user stories the epic ENUMERATES under a 'User Stories' heading.
714
+
715
+ When the epic already lists its stories ("1. Login — …", "- Logout"), those are
716
+ the authoritative stories — the bot must reproduce EXACTLY them (same count, same
717
+ titles), not invent its own. Returns the list of titles (the part before an
718
+ em/en-dash or colon separator), or [] when the epic gives no explicit list.
719
+ """
720
+ if not text:
721
+ return []
722
+ lines = text.splitlines()
723
+ start = None
724
+ for i, ln in enumerate(lines):
725
+ if re.match(r"\s*#{0,6}\s*user\s+stor(?:y|ies)\b\s*:?\s*$", ln, re.I):
726
+ start = i + 1
727
+ break
728
+ if start is None:
729
+ return []
730
+ titles: list[str] = []
731
+ for ln in lines[start:]:
732
+ if re.match(r"\s*#{1,6}\s+\S", ln): # next heading → section ends
733
+ break
734
+ m = _STORY_LIST_ITEM_RE.match(ln)
735
+ if not m:
736
+ continue
737
+ item = m.group(1).strip()
738
+ title = re.split(r"\s+[—–-]\s+|:\s+", item, maxsplit=1)[0]
739
+ title = title.strip(" *`_").strip()
740
+ if title:
741
+ titles.append(title[:250])
742
+ return titles[:50]
743
+
744
+
687
745
  def _extract_stories_dir(text: str | None) -> str | None:
688
746
  """A directory the instructions ask the user stories to be WRITTEN into, or None.
689
747
 
@@ -723,7 +781,7 @@ def _build_validation_prompt(kind: str, jira_key: str, summary: str,
723
781
  "the required structure or information is not strictly present, REJECT it — a "
724
782
  "vague or under-specified ticket must NOT pass.",
725
783
  f"=== {k} {jira_key} ===\nSummary: {summary or '(none)'}",
726
- "Description:\n" + (_clip_text(description.strip()) if description and description.strip() else "(none provided)"),
784
+ "Description:\n" + (_clip_text(description.strip(), _GEN_MAX_CHARS) if description and description.strip() else "(none provided)"),
727
785
  "Human Instructions:\n" + (human.strip() if human and human.strip() else "(none provided)"),
728
786
  ]
729
787
  is_epic = kind == "epic"
@@ -852,11 +910,62 @@ async def _validate_ticket_or_reject(
852
910
  return False
853
911
 
854
912
 
913
+ def _build_epic_titles_prompt(jira_key: str, summary: str, description: str | None,
914
+ human_instructions: str | None, story_count: int | None = None,
915
+ story_titles: list[str] | None = None) -> str:
916
+ """Phase-1 prompt: produce the epic PLAN + a small list of story TITLES only (no
917
+ full descriptions). Kept small so the output can't overflow; each story is
918
+ expanded individually afterwards to avoid context pressure / hallucination."""
919
+ parts: list[str] = [
920
+ "You are decomposing a JIRA EPIC into a LIST of user-story TITLES — titles only, "
921
+ "NOT full descriptions yet. This is ANALYSIS ONLY — do NOT create/edit any file.",
922
+ "Faithfulness: derive titles ONLY from the epic; do NOT invent scope. Cover the "
923
+ "epic's WHOLE scope; a few well-grounded titles beat many speculative ones.",
924
+ f"=== EPIC {jira_key} ===\nSummary: {summary}",
925
+ ]
926
+ if description and description.strip():
927
+ parts.append("Description:\n" + _clip_text(description.strip(), _GEN_MAX_CHARS))
928
+ if human_instructions and human_instructions.strip():
929
+ parts.append("Operator instructions:\n" + human_instructions.strip())
930
+ parts.append(
931
+ "If this is a software project, briefly inspect the codebase (CLAUDE.md, "
932
+ "graphify, Read/Glob/Grep) so the split fits the real architecture.\n\n"
933
+ + (("The epic already lists its stories — use EXACTLY these titles, VERBATIM, in "
934
+ "order; do NOT invent, rename, merge, add, drop, or reorder:\n"
935
+ + "\n".join(f" {i}. {t}" for i, t in enumerate(story_titles, 1)) + "\n")
936
+ if story_titles else
937
+ (f"Produce EXACTLY {story_count} story titles.\n" if story_count else
938
+ "Produce as many story titles as the epic genuinely needs (no padding).\n"))
939
+ + "\nOutput ONLY a single fenced ```json block, nothing else:\n"
940
+ "{\n"
941
+ ' "plan": "an ordered, numbered high-level implementation plan for the epic",\n'
942
+ ' "stories": [ {"summary": "short imperative story title", '
943
+ '"assigneeEmail": "only if the epic assigns it to a named person", "points": 5} ]\n'
944
+ "}"
945
+ )
946
+ return "\n\n".join(parts)
947
+
948
+
949
+ def _build_one_story_prompt(jira_key: str, title: str, index: int, total: int) -> str:
950
+ """Phase-2 prompt: expand ONE story's full description, resuming the grounding
951
+ session from the titles pass so the codebase context is retained."""
952
+ return (
953
+ f"Now write user story {index} of {total} for epic {jira_key}.\n"
954
+ f"Story title (use it VERBATIM as the summary): {title}\n\n"
955
+ "Using the epic and the codebase you already explored, write ONLY this ONE "
956
+ "story's description in the USER STORY FORMAT below — grounded and specific, with "
957
+ "no invented scope and nothing about other stories. Output ONLY the Markdown "
958
+ "(no JSON, no preamble, no closing commentary).\n\n"
959
+ "=== USER STORY FORMAT ===\n" + _USER_STORY_FORMAT
960
+ )
961
+
962
+
855
963
  async def _handle_epic(
856
964
  client: BackendClient, task: dict, payload: dict, send: Callable, thread_id: str,
857
965
  ) -> None:
858
- """Decompose an Epic: generate user stor(y|ies), mark the Epic Done with the
859
- detailed description + plan, then create a To Do child Task per story.
966
+ """Decompose an Epic ONE STORY AT A TIME: a small plan+titles pass, then expand
967
+ each story individually (resuming the grounding session), creating each To Do
968
+ child Story (and optional .md file) as it is produced.
860
969
 
861
970
  Child tasks are created in "To Do"; a later `agentos-auto` run picks them up and
862
971
  implements them through the normal grounded pipeline. Best-effort throughout —
@@ -878,41 +987,51 @@ async def _handle_epic(
878
987
  "current_step": f"Epic {jira_key}: analysing & generating user stories"},
879
988
  ).model_dump())
880
989
 
881
- # 1. Generate the decomposition, grounded in the codebase where one exists.
882
- # If the description/instructions ask for a specific number of stories, honor
883
- # it exactly; otherwise the model decides (bounded by _MAX_EPIC_STORIES).
884
- story_count = _extract_story_count(description) or _extract_story_count(human)
990
+ # How many stories + their titles:
991
+ # (a) the epic ENUMERATES its stories use those exact titles/count, verbatim;
992
+ # (b) else an explicit "build N stories" count → exactly N;
993
+ # (c) else the model decides (bounded by _MAX_EPIC_STORIES).
994
+ story_titles = _extract_story_titles(description) or _extract_story_titles(human)
995
+ story_count = (len(story_titles) if story_titles
996
+ else (_extract_story_count(description) or _extract_story_count(human)))
885
997
  limit = story_count or _MAX_EPIC_STORIES
998
+ gh_token = await client.github_token()
886
999
  try:
887
1000
  await graphify.prepare(project_path)
888
1001
  except Exception: # noqa: BLE001
889
1002
  pass
890
- gen = await _run_generation(
891
- _build_epic_prompt(jira_key, summary, description, human, story_count=story_count),
1003
+
1004
+ # ── Phase 1: PLAN + story TITLES (one small, grounded pass) ──────────────
1005
+ plan_gen = await _run_generation(
1006
+ _build_epic_titles_prompt(jira_key, summary, description, human,
1007
+ story_count=story_count, story_titles=story_titles or None),
892
1008
  project_path, send=send, thread_id=thread_id,
893
- label=f"Epic {jira_key}: generating user stories",
894
- github_token=await client.github_token(),
1009
+ label=f"Epic {jira_key}: planning & listing stories",
1010
+ github_token=gh_token,
895
1011
  )
896
- # A usage-limit hit must stop and retry later. For ANY other outcome, still try
897
- # to parse the JSON the model often produces a valid decomposition even when the
898
- # run is flagged is_error (e.g. a stray read-only tool denial during exploration),
899
- # and the result then lands in `error`. Don't discard that work over an is_error flag.
900
- if gen.get("limit_exceeded"):
901
- await client.log_activity(f"Epic {jira_key}: usage limit during decomposition — will retry after reset")
1012
+ if plan_gen.get("limit_exceeded"):
1013
+ await client.log_activity(f"Epic {jira_key}: usage limit while planning will retry after reset")
902
1014
  await client.move_to_backlog(tid)
903
1015
  await send(Envelope(
904
1016
  type=Envelope.TYPE_ERROR, thread_id=thread_id,
905
1017
  data={"code": "EPIC_DECOMPOSE_LIMIT", "task_id": tid,
906
- "message": "Claude usage limit reached during epic decomposition"},
1018
+ "message": "Claude usage limit reached while planning the epic"},
907
1019
  ).model_dump())
908
1020
  return
909
- doc = _extract_json_block(gen.get("result") or gen.get("error") or "")
910
- stories = (doc or {}).get("stories") or []
911
- if not doc or not stories:
912
- had_output = bool(gen.get("result") or gen.get("error"))
913
- reason = ("model output did not contain a parseable decomposition JSON"
914
- if had_output else
915
- (gen.get("error") or "no output from the decomposition run"))[:300]
1021
+ recon_session = plan_gen.get("session_id")
1022
+ doc = _extract_json_block(plan_gen.get("result") or plan_gen.get("error") or "") or {}
1023
+ specs = doc.get("stories") or []
1024
+ # Enumerated titles are authoritative — align to them (keep any per-story email/points).
1025
+ if story_titles:
1026
+ specs = [
1027
+ {"summary": t,
1028
+ "assigneeEmail": (specs[i].get("assigneeEmail") if i < len(specs) and isinstance(specs[i], dict) else None),
1029
+ "points": (specs[i].get("points") if i < len(specs) and isinstance(specs[i], dict) else None)}
1030
+ for i, t in enumerate(story_titles)
1031
+ ]
1032
+ specs = [s for s in specs[:limit] if isinstance(s, dict) and str(s.get("summary", "")).strip()]
1033
+ if not specs:
1034
+ reason = "could not produce a valid story list from the epic"
916
1035
  await client.log_activity(f"Epic {jira_key}: decomposition failed — {reason}")
917
1036
  await client.move_to_backlog(tid)
918
1037
  await send(Envelope(
@@ -921,97 +1040,100 @@ async def _handle_epic(
921
1040
  ).model_dump())
922
1041
  return
923
1042
 
924
- # 2. Mark the Epic Done. Do NOT touch the Epic's description the parent Epic's
925
- # description stays exactly as the human wrote it; only the status and the AI
926
- # plan are recorded.
927
- plan = (doc.get("plan") or "")[:8000]
1043
+ # ── Mark the Epic Done (description untouched; only status + AI plan recorded) ──
1044
+ plan = str(doc.get("plan") or "")[:8000]
928
1045
  await client.update_task_ml(
929
1046
  tid, {"status": "Done", "initialAiPlan": plan}, model_used=_EPIC_MODELS,
930
1047
  )
1048
+ total = len(specs)
931
1049
  await send(Envelope(
932
1050
  type="step_update", thread_id=thread_id,
933
- data={"jira_task_id": tid, "current_step": f"Epic {jira_key}: marked Done — "
934
- f"creating {min(len(stories), limit)} user Story(ies)"},
1051
+ data={"jira_task_id": tid,
1052
+ "current_step": f"Epic {jira_key}: marked Done — building {total} user "
1053
+ f"stor{'y' if total == 1 else 'ies'} one by one"},
935
1054
  ).model_dump())
936
1055
 
937
- # 3. Create a To Do child Task per story, inheriting the Epic's fields.
1056
+ # Optional deliverables folder (from humanInstructions OR description).
1057
+ from pathlib import Path
1058
+ story_dir = _extract_stories_dir(human) or _extract_stories_dir(description)
1059
+ dir_path = None
1060
+ if story_dir:
1061
+ try:
1062
+ dir_path = Path(story_dir).expanduser()
1063
+ dir_path.mkdir(parents=True, exist_ok=True) # create from scratch
1064
+ except Exception: # noqa: BLE001
1065
+ dir_path = None
1066
+
1067
+ # ── Phase 2: expand + create EACH story ONE BY ONE (resume the grounding session) ──
938
1068
  inherited = _epic_inherited_fields(task)
939
- # An assignee email named in the EPIC's description/instructions overrides the
940
- # inherited assignee — but ONLY on the CHILD task, never on the parent Epic.
941
1069
  desc_email = _extract_email(description) or _extract_email(human)
942
1070
  created: list[str] = []
943
- for st in stories[:limit]:
944
- if not isinstance(st, dict) or not (st.get("summary") or "").strip():
945
- continue
946
- # Precedence: a per-story email the model extracted → an email in the
947
- # epic description/instructions → the inherited parent assignee.
948
- story_email = _extract_email(str(st.get("assigneeEmail") or ""))
1071
+ written = 0
1072
+ for i, spec in enumerate(specs, 1):
1073
+ title = str(spec.get("summary", "")).strip()[:250]
1074
+ await send(Envelope(
1075
+ type="step_update", thread_id=thread_id,
1076
+ data={"jira_task_id": tid,
1077
+ "current_step": f"Epic {jira_key}: story {i}/{total} — {title}"},
1078
+ ).model_dump())
1079
+ one = await _run_generation(
1080
+ _build_one_story_prompt(jira_key, title, i, total),
1081
+ project_path, send=send, thread_id=thread_id,
1082
+ label=f"Epic {jira_key}: story {i}/{total}",
1083
+ github_token=gh_token, resume_session_id=recon_session,
1084
+ )
1085
+ recon_session = one.get("session_id") or recon_session
1086
+ if one.get("limit_exceeded"):
1087
+ await client.log_activity(
1088
+ f"Epic {jira_key}: usage limit after {len(created)}/{total} stories — "
1089
+ "run again to continue the rest"
1090
+ )
1091
+ break
1092
+ story_desc = (one.get("result") or one.get("error") or "").strip() or f"## User Story\n{title}"
1093
+
1094
+ story_email = _extract_email(str(spec.get("assigneeEmail") or ""))
949
1095
  child_email = story_email or desc_email or inherited.get("assigneeEmail")
950
1096
  fields = {
951
1097
  **inherited,
952
- "type": "Story", # Epic decomposes into user Stories; a Story later
953
- # becomes a Task (see _handle_story) when it is run.
954
- "drivenBy": "SYSTEM", # bot-created → trusted, skips the validation gate
1098
+ "type": "Story",
1099
+ "drivenBy": "SYSTEM", # bot-created trusted, skips the validation gate
955
1100
  "status": "To Do",
956
- "summary": str(st.get("summary", "")).strip()[:250],
957
- "description": str(st.get("description", "") or "")[:20000],
958
- # NO humanInstructions on AI-created stories — they are clean user-story
959
- # artifacts; implementation instructions (repos/branch/etc.) are added
960
- # later at the story/task stage, not baked in by the Epic.
1101
+ "summary": title,
1102
+ "description": story_desc[:20000],
1103
+ # No humanInstructions on AI-created stories — added later at the task stage.
961
1104
  "parentTaskId": tid,
962
1105
  }
963
1106
  if child_email:
964
1107
  fields["assigneeEmail"] = child_email
965
- if st.get("points"):
966
- fields["points"] = st["points"]
1108
+ if spec.get("points"):
1109
+ fields["points"] = spec["points"]
967
1110
  new_id = await client.create_task_ml(fields, model_used=_EPIC_MODELS)
968
1111
  if new_id:
969
1112
  created.append(new_id)
970
1113
  await send(Envelope(
971
1114
  type="step_update", thread_id=thread_id,
972
- data={"current_step": f" ↳ created To Do task: {fields['summary']}"},
1115
+ data={"current_step": f" ↳ created To Do story {i}/{total}: {title}"},
973
1116
  ).model_dump())
974
1117
 
975
- # 3b. Optionally ALSO write each story as a Markdown file to a directory named in
976
- # the instructions (e.g. "create /home/me/new_tasks and write the user stories
977
- # there"). This is IN ADDITION to the Story tickets. Best-effort — a file-write
978
- # failure never fails the Epic.
979
- stories_dir = _extract_stories_dir(human)
980
- if stories_dir:
981
- from pathlib import Path
982
- try:
983
- d = Path(stories_dir).expanduser()
984
- d.mkdir(parents=True, exist_ok=True) # create from scratch
985
- written = 0
986
- for i, st in enumerate(stories[:limit], 1):
987
- if not isinstance(st, dict) or not (st.get("summary") or "").strip():
988
- continue
989
- title = str(st.get("summary", "")).strip()
1118
+ # Write this story's .md immediately (incremental), if a folder was given.
1119
+ if dir_path is not None:
1120
+ try:
990
1121
  slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")[:50] or f"story-{i}"
991
- content = f"# {title}\n\n{str(st.get('description', '') or '').strip()}\n"
992
- (d / f"{i:02d}-{slug}.md").write_text(content, encoding="utf-8")
1122
+ (dir_path / f"{i:02d}-{slug}.md").write_text(
1123
+ f"# {title}\n\n{story_desc}\n", encoding="utf-8")
993
1124
  written += 1
994
- await client.log_activity(f"Epic {jira_key}: wrote {written} user-story file(s) to {stories_dir}")
995
- await send(Envelope(
996
- type="step_update", thread_id=thread_id,
997
- data={"jira_task_id": tid,
998
- "current_step": f"Epic {jira_key}: wrote {written} user-story file(s) to {stories_dir}"},
999
- ).model_dump())
1000
- except Exception as exc: # noqa: BLE001
1001
- await send(Envelope(
1002
- type="step_update", thread_id=thread_id,
1003
- data={"jira_task_id": tid,
1004
- "current_step": f"Epic {jira_key}: could not write story files to {stories_dir} — {str(exc)[:120]}"},
1005
- ).model_dump())
1125
+ except Exception: # noqa: BLE001
1126
+ pass
1006
1127
 
1128
+ tail = f"; wrote {written} .md file(s) to {story_dir}" if dir_path is not None else ""
1007
1129
  await client.log_activity(
1008
- f"Epic {jira_key}: Done — created {len(created)}/{len(stories)} To Do user Story(ies)"
1130
+ f"Epic {jira_key}: Done — created {len(created)}/{total} To Do user Story(ies){tail}"
1009
1131
  )
1010
1132
  await send(Envelope(
1011
1133
  type="step_update", thread_id=thread_id,
1012
1134
  data={"jira_task_id": tid,
1013
- "current_step": f"Epic {jira_key} complete — {len(created)} Story(ies) queued "
1014
- "(run again: each Story PRD + Task)"},
1135
+ "current_step": f"Epic {jira_key} complete — {len(created)} Story(ies) queued"
1136
+ + (f" · {written} file(s) in {story_dir}" if dir_path is not None else "")},
1015
1137
  ).model_dump())
1016
1138
 
1017
1139
 
@@ -1062,7 +1184,7 @@ def _build_prd_prompt(jira_key: str, summary: str, user_story: str | None,
1062
1184
  f"=== USER STORY {jira_key} ===\nSummary: {summary}",
1063
1185
  ]
1064
1186
  if user_story and user_story.strip():
1065
- parts.append("User story / description:\n" + _clip_text(user_story.strip()))
1187
+ parts.append("User story / description:\n" + _clip_text(user_story.strip(), _GEN_MAX_CHARS))
1066
1188
  if human_instructions and human_instructions.strip():
1067
1189
  parts.append("Operator instructions:\n" + human_instructions.strip())
1068
1190
  parts.append(