devops-bot-sdk 1.4.83__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.83 → devops_bot_sdk-1.4.85}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/pyproject.toml +1 -1
  4. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/sdk/client.py +1 -1
  6. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/sdk/ipc/handlers.py +157 -89
  7. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/README.md +0 -0
  8. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/devops_bot_sdk.egg-info/SOURCES.txt +0 -0
  9. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  10. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  11. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/devops_bot_sdk.egg-info/requires.txt +0 -0
  12. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  13. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/sdk/collectors/__init__.py +0 -0
  14. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/sdk/collectors/files.py +0 -0
  15. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/sdk/collectors/process.py +0 -0
  16. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/sdk/collectors/screenshot.py +0 -0
  17. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/sdk/config.py +0 -0
  18. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/sdk/crucial.py +0 -0
  19. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/sdk/exceptions.py +0 -0
  20. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/sdk/git_ops.py +0 -0
  21. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/sdk/graphify.py +0 -0
  22. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/sdk/hooks/__init__.py +0 -0
  23. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/sdk/hooks/crucial_guard.py +0 -0
  24. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/sdk/ipc/__init__.py +0 -0
  25. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/sdk/ipc/electron_bridge.py +0 -0
  26. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/sdk/local_exec.py +0 -0
  27. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/sdk/models/__init__.py +0 -0
  28. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/sdk/models/envelope.py +0 -0
  29. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/sdk/models/requests.py +0 -0
  30. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/sdk/models/responses.py +0 -0
  31. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/sdk/models/snapshots.py +0 -0
  32. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/sdk/py.typed +0 -0
  33. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/sdk/run_auto.py +0 -0
  34. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/sdk/sse.py +0 -0
  35. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/sdk/test.py +0 -0
  36. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/sdk/test_pipeline.py +0 -0
  37. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.85}/sdk/updater.py +0 -0
  38. {devops_bot_sdk-1.4.83 → 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.83
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.83
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.83"
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.83
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.83"
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.83"
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:
@@ -545,12 +550,18 @@ def _build_epic_prompt(jira_key: str, summary: str, description: str | None,
545
550
  "in that story's Notes — do NOT fabricate a solution.\n"
546
551
  "- Prefer fewer, well-grounded stories over many speculative ones. Every story "
547
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"
548
559
  "- This epic may be for ANY domain (software, Finance, HR, operations, …). Match "
549
560
  "the stories to the epic's actual domain; do not assume it is software.",
550
561
  f"=== EPIC {jira_key} ===\nSummary: {summary}",
551
562
  ]
552
563
  if description and description.strip():
553
- parts.append("Description:\n" + _clip_text(description.strip()))
564
+ parts.append("Description:\n" + _clip_text(description.strip(), _GEN_MAX_CHARS))
554
565
  if human_instructions and human_instructions.strip():
555
566
  parts.append(
556
567
  "Operator instructions (authoritative — carry any project location / "
@@ -770,7 +781,7 @@ def _build_validation_prompt(kind: str, jira_key: str, summary: str,
770
781
  "the required structure or information is not strictly present, REJECT it — a "
771
782
  "vague or under-specified ticket must NOT pass.",
772
783
  f"=== {k} {jira_key} ===\nSummary: {summary or '(none)'}",
773
- "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)"),
774
785
  "Human Instructions:\n" + (human.strip() if human and human.strip() else "(none provided)"),
775
786
  ]
776
787
  is_epic = kind == "epic"
@@ -899,11 +910,62 @@ async def _validate_ticket_or_reject(
899
910
  return False
900
911
 
901
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
+
902
963
  async def _handle_epic(
903
964
  client: BackendClient, task: dict, payload: dict, send: Callable, thread_id: str,
904
965
  ) -> None:
905
- """Decompose an Epic: generate user stor(y|ies), mark the Epic Done with the
906
- 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.
907
969
 
908
970
  Child tasks are created in "To Do"; a later `agentos-auto` run picks them up and
909
971
  implements them through the normal grounded pipeline. Best-effort throughout —
@@ -925,47 +987,51 @@ async def _handle_epic(
925
987
  "current_step": f"Epic {jira_key}: analysing & generating user stories"},
926
988
  ).model_dump())
927
989
 
928
- # 1. Generate the decomposition, grounded in the codebase where one exists.
929
- # Priority for how many stories + their titles:
930
- # (a) the epic ENUMERATES its stories (a "User Stories" list) use those
931
- # exact titles and count, verbatim;
932
- # (b) else an explicit "build N stories" count → exactly N;
933
- # (c) else the model decides (bounded by _MAX_EPIC_STORIES).
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" countexactly N;
993
+ # (c) else the model decides (bounded by _MAX_EPIC_STORIES).
934
994
  story_titles = _extract_story_titles(description) or _extract_story_titles(human)
935
995
  story_count = (len(story_titles) if story_titles
936
996
  else (_extract_story_count(description) or _extract_story_count(human)))
937
997
  limit = story_count or _MAX_EPIC_STORIES
998
+ gh_token = await client.github_token()
938
999
  try:
939
1000
  await graphify.prepare(project_path)
940
1001
  except Exception: # noqa: BLE001
941
1002
  pass
942
- gen = await _run_generation(
943
- _build_epic_prompt(jira_key, summary, description, human,
944
- story_count=story_count, story_titles=story_titles or None),
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),
945
1008
  project_path, send=send, thread_id=thread_id,
946
- label=f"Epic {jira_key}: generating user stories",
947
- github_token=await client.github_token(),
1009
+ label=f"Epic {jira_key}: planning & listing stories",
1010
+ github_token=gh_token,
948
1011
  )
949
- # A usage-limit hit must stop and retry later. For ANY other outcome, still try
950
- # to parse the JSON the model often produces a valid decomposition even when the
951
- # run is flagged is_error (e.g. a stray read-only tool denial during exploration),
952
- # and the result then lands in `error`. Don't discard that work over an is_error flag.
953
- if gen.get("limit_exceeded"):
954
- 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")
955
1014
  await client.move_to_backlog(tid)
956
1015
  await send(Envelope(
957
1016
  type=Envelope.TYPE_ERROR, thread_id=thread_id,
958
1017
  data={"code": "EPIC_DECOMPOSE_LIMIT", "task_id": tid,
959
- "message": "Claude usage limit reached during epic decomposition"},
1018
+ "message": "Claude usage limit reached while planning the epic"},
960
1019
  ).model_dump())
961
1020
  return
962
- doc = _extract_json_block(gen.get("result") or gen.get("error") or "")
963
- stories = (doc or {}).get("stories") or []
964
- if not doc or not stories:
965
- had_output = bool(gen.get("result") or gen.get("error"))
966
- reason = ("model output did not contain a parseable decomposition JSON"
967
- if had_output else
968
- (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"
969
1035
  await client.log_activity(f"Epic {jira_key}: decomposition failed — {reason}")
970
1036
  await client.move_to_backlog(tid)
971
1037
  await send(Envelope(
@@ -974,98 +1040,100 @@ async def _handle_epic(
974
1040
  ).model_dump())
975
1041
  return
976
1042
 
977
- # 2. Mark the Epic Done. Do NOT touch the Epic's description the parent Epic's
978
- # description stays exactly as the human wrote it; only the status and the AI
979
- # plan are recorded.
980
- 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]
981
1045
  await client.update_task_ml(
982
1046
  tid, {"status": "Done", "initialAiPlan": plan}, model_used=_EPIC_MODELS,
983
1047
  )
1048
+ total = len(specs)
984
1049
  await send(Envelope(
985
1050
  type="step_update", thread_id=thread_id,
986
- data={"jira_task_id": tid, "current_step": f"Epic {jira_key}: marked Done — "
987
- 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"},
988
1054
  ).model_dump())
989
1055
 
990
- # 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) ──
991
1068
  inherited = _epic_inherited_fields(task)
992
- # An assignee email named in the EPIC's description/instructions overrides the
993
- # inherited assignee — but ONLY on the CHILD task, never on the parent Epic.
994
1069
  desc_email = _extract_email(description) or _extract_email(human)
995
1070
  created: list[str] = []
996
- for st in stories[:limit]:
997
- if not isinstance(st, dict) or not (st.get("summary") or "").strip():
998
- continue
999
- # Precedence: a per-story email the model extracted → an email in the
1000
- # epic description/instructions → the inherited parent assignee.
1001
- 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 ""))
1002
1095
  child_email = story_email or desc_email or inherited.get("assigneeEmail")
1003
1096
  fields = {
1004
1097
  **inherited,
1005
- "type": "Story", # Epic decomposes into user Stories; a Story later
1006
- # becomes a Task (see _handle_story) when it is run.
1007
- "drivenBy": "SYSTEM", # bot-created → trusted, skips the validation gate
1098
+ "type": "Story",
1099
+ "drivenBy": "SYSTEM", # bot-created trusted, skips the validation gate
1008
1100
  "status": "To Do",
1009
- "summary": str(st.get("summary", "")).strip()[:250],
1010
- "description": str(st.get("description", "") or "")[:20000],
1011
- # NO humanInstructions on AI-created stories — they are clean user-story
1012
- # artifacts; implementation instructions (repos/branch/etc.) are added
1013
- # 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.
1014
1104
  "parentTaskId": tid,
1015
1105
  }
1016
1106
  if child_email:
1017
1107
  fields["assigneeEmail"] = child_email
1018
- if st.get("points"):
1019
- fields["points"] = st["points"]
1108
+ if spec.get("points"):
1109
+ fields["points"] = spec["points"]
1020
1110
  new_id = await client.create_task_ml(fields, model_used=_EPIC_MODELS)
1021
1111
  if new_id:
1022
1112
  created.append(new_id)
1023
1113
  await send(Envelope(
1024
1114
  type="step_update", thread_id=thread_id,
1025
- data={"current_step": f" ↳ created To Do task: {fields['summary']}"},
1115
+ data={"current_step": f" ↳ created To Do story {i}/{total}: {title}"},
1026
1116
  ).model_dump())
1027
1117
 
1028
- # 3b. Optionally ALSO write each story as a Markdown file to a directory named in
1029
- # the instructions (e.g. "create /home/me/new_tasks and write the user stories
1030
- # there"). This is IN ADDITION to the Story tickets. Best-effort — a file-write
1031
- # failure never fails the Epic.
1032
- # Folder may be named in the humanInstructions OR the description — check both.
1033
- stories_dir = _extract_stories_dir(human) or _extract_stories_dir(description)
1034
- if stories_dir:
1035
- from pathlib import Path
1036
- try:
1037
- d = Path(stories_dir).expanduser()
1038
- d.mkdir(parents=True, exist_ok=True) # create from scratch
1039
- written = 0
1040
- for i, st in enumerate(stories[:limit], 1):
1041
- if not isinstance(st, dict) or not (st.get("summary") or "").strip():
1042
- continue
1043
- 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:
1044
1121
  slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")[:50] or f"story-{i}"
1045
- content = f"# {title}\n\n{str(st.get('description', '') or '').strip()}\n"
1046
- (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")
1047
1124
  written += 1
1048
- await client.log_activity(f"Epic {jira_key}: wrote {written} user-story file(s) to {stories_dir}")
1049
- await send(Envelope(
1050
- type="step_update", thread_id=thread_id,
1051
- data={"jira_task_id": tid,
1052
- "current_step": f"Epic {jira_key}: wrote {written} user-story file(s) to {stories_dir}"},
1053
- ).model_dump())
1054
- except Exception as exc: # noqa: BLE001
1055
- await send(Envelope(
1056
- type="step_update", thread_id=thread_id,
1057
- data={"jira_task_id": tid,
1058
- "current_step": f"Epic {jira_key}: could not write story files to {stories_dir} — {str(exc)[:120]}"},
1059
- ).model_dump())
1125
+ except Exception: # noqa: BLE001
1126
+ pass
1060
1127
 
1128
+ tail = f"; wrote {written} .md file(s) to {story_dir}" if dir_path is not None else ""
1061
1129
  await client.log_activity(
1062
- 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}"
1063
1131
  )
1064
1132
  await send(Envelope(
1065
1133
  type="step_update", thread_id=thread_id,
1066
1134
  data={"jira_task_id": tid,
1067
- "current_step": f"Epic {jira_key} complete — {len(created)} Story(ies) queued "
1068
- "(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 "")},
1069
1137
  ).model_dump())
1070
1138
 
1071
1139
 
@@ -1116,7 +1184,7 @@ def _build_prd_prompt(jira_key: str, summary: str, user_story: str | None,
1116
1184
  f"=== USER STORY {jira_key} ===\nSummary: {summary}",
1117
1185
  ]
1118
1186
  if user_story and user_story.strip():
1119
- 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))
1120
1188
  if human_instructions and human_instructions.strip():
1121
1189
  parts.append("Operator instructions:\n" + human_instructions.strip())
1122
1190
  parts.append(