devops-bot-sdk 1.4.83__tar.gz → 1.4.86__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.86}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/pyproject.toml +1 -1
  4. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/sdk/client.py +1 -1
  6. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/sdk/ipc/handlers.py +162 -89
  7. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/README.md +0 -0
  8. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/devops_bot_sdk.egg-info/SOURCES.txt +0 -0
  9. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  10. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  11. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/devops_bot_sdk.egg-info/requires.txt +0 -0
  12. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  13. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/sdk/collectors/__init__.py +0 -0
  14. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/sdk/collectors/files.py +0 -0
  15. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/sdk/collectors/process.py +0 -0
  16. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/sdk/collectors/screenshot.py +0 -0
  17. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/sdk/config.py +0 -0
  18. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/sdk/crucial.py +0 -0
  19. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/sdk/exceptions.py +0 -0
  20. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/sdk/git_ops.py +0 -0
  21. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/sdk/graphify.py +0 -0
  22. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/sdk/hooks/__init__.py +0 -0
  23. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/sdk/hooks/crucial_guard.py +0 -0
  24. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/sdk/ipc/__init__.py +0 -0
  25. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/sdk/ipc/electron_bridge.py +0 -0
  26. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/sdk/local_exec.py +0 -0
  27. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/sdk/models/__init__.py +0 -0
  28. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/sdk/models/envelope.py +0 -0
  29. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/sdk/models/requests.py +0 -0
  30. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/sdk/models/responses.py +0 -0
  31. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/sdk/models/snapshots.py +0 -0
  32. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/sdk/py.typed +0 -0
  33. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/sdk/run_auto.py +0 -0
  34. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/sdk/sse.py +0 -0
  35. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/sdk/test.py +0 -0
  36. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/sdk/test_pipeline.py +0 -0
  37. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/sdk/updater.py +0 -0
  38. {devops_bot_sdk-1.4.83 → devops_bot_sdk-1.4.86}/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.86
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.86
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.86"
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.86
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.86"
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.86"
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,67 @@ 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 (critical): derive the stories STRICTLY from the epic's own "
923
+ "Scope / Requirements / User-Stories content. Do NOT invent, add, or split "
924
+ "stories based on assumptions or on what the codebase happens to contain. Cover "
925
+ "the epic's stated scope and NOTHING beyond it. If the epic is too vague to split "
926
+ "faithfully, produce FEWER stories and note the ambiguity in the plan — never "
927
+ "guess to look complete.",
928
+ f"=== EPIC {jira_key} ===\nSummary: {summary}",
929
+ ]
930
+ if description and description.strip():
931
+ parts.append("Description:\n" + _clip_text(description.strip(), _GEN_MAX_CHARS))
932
+ if human_instructions and human_instructions.strip():
933
+ parts.append("Operator instructions:\n" + human_instructions.strip())
934
+ parts.append(
935
+ "You MAY glance at the codebase (CLAUDE.md, graphify, Read/Glob/Grep) ONLY to "
936
+ "phrase a title accurately or confirm a module name — NEVER to introduce scope "
937
+ "the epic does not state. The stories come from the EPIC, not the code.\n\n"
938
+ + (("The epic already lists its stories — use EXACTLY these titles, VERBATIM, in "
939
+ "order; do NOT invent, rename, merge, add, drop, or reorder:\n"
940
+ + "\n".join(f" {i}. {t}" for i, t in enumerate(story_titles, 1)) + "\n")
941
+ if story_titles else
942
+ (f"Produce EXACTLY {story_count} story titles.\n" if story_count else
943
+ "Produce as many story titles as the epic genuinely needs (no padding).\n"))
944
+ + "\nOutput ONLY a single fenced ```json block, nothing else:\n"
945
+ "{\n"
946
+ ' "plan": "an ordered, numbered high-level implementation plan for the epic",\n'
947
+ ' "stories": [ {"summary": "short imperative story title", '
948
+ '"assigneeEmail": "only if the epic assigns it to a named person", "points": 5} ]\n'
949
+ "}"
950
+ )
951
+ return "\n\n".join(parts)
952
+
953
+
954
+ def _build_one_story_prompt(jira_key: str, title: str, index: int, total: int) -> str:
955
+ """Phase-2 prompt: expand ONE story's full description, resuming the grounding
956
+ session from the titles pass so the codebase context is retained."""
957
+ return (
958
+ f"Now write user story {index} of {total} for epic {jira_key}.\n"
959
+ f"Story title (use it VERBATIM as the summary): {title}\n\n"
960
+ "Using the epic and the codebase you already explored, write ONLY this ONE "
961
+ "story's description in the USER STORY FORMAT below — grounded and specific, with "
962
+ "no invented scope and nothing about other stories. Output ONLY the Markdown "
963
+ "(no JSON, no preamble, no closing commentary).\n\n"
964
+ "=== USER STORY FORMAT ===\n" + _USER_STORY_FORMAT
965
+ )
966
+
967
+
902
968
  async def _handle_epic(
903
969
  client: BackendClient, task: dict, payload: dict, send: Callable, thread_id: str,
904
970
  ) -> 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.
971
+ """Decompose an Epic ONE STORY AT A TIME: a small plan+titles pass, then expand
972
+ each story individually (resuming the grounding session), creating each To Do
973
+ child Story (and optional .md file) as it is produced.
907
974
 
908
975
  Child tasks are created in "To Do"; a later `agentos-auto` run picks them up and
909
976
  implements them through the normal grounded pipeline. Best-effort throughout —
@@ -925,47 +992,51 @@ async def _handle_epic(
925
992
  "current_step": f"Epic {jira_key}: analysing & generating user stories"},
926
993
  ).model_dump())
927
994
 
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).
995
+ # How many stories + their titles:
996
+ # (a) the epic ENUMERATES its stories use those exact titles/count, verbatim;
997
+ # (b) else an explicit "build N stories" countexactly N;
998
+ # (c) else the model decides (bounded by _MAX_EPIC_STORIES).
934
999
  story_titles = _extract_story_titles(description) or _extract_story_titles(human)
935
1000
  story_count = (len(story_titles) if story_titles
936
1001
  else (_extract_story_count(description) or _extract_story_count(human)))
937
1002
  limit = story_count or _MAX_EPIC_STORIES
1003
+ gh_token = await client.github_token()
938
1004
  try:
939
1005
  await graphify.prepare(project_path)
940
1006
  except Exception: # noqa: BLE001
941
1007
  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),
1008
+
1009
+ # ── Phase 1: PLAN + story TITLES (one small, grounded pass) ──────────────
1010
+ plan_gen = await _run_generation(
1011
+ _build_epic_titles_prompt(jira_key, summary, description, human,
1012
+ story_count=story_count, story_titles=story_titles or None),
945
1013
  project_path, send=send, thread_id=thread_id,
946
- label=f"Epic {jira_key}: generating user stories",
947
- github_token=await client.github_token(),
1014
+ label=f"Epic {jira_key}: planning & listing stories",
1015
+ github_token=gh_token,
948
1016
  )
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")
1017
+ if plan_gen.get("limit_exceeded"):
1018
+ await client.log_activity(f"Epic {jira_key}: usage limit while planning will retry after reset")
955
1019
  await client.move_to_backlog(tid)
956
1020
  await send(Envelope(
957
1021
  type=Envelope.TYPE_ERROR, thread_id=thread_id,
958
1022
  data={"code": "EPIC_DECOMPOSE_LIMIT", "task_id": tid,
959
- "message": "Claude usage limit reached during epic decomposition"},
1023
+ "message": "Claude usage limit reached while planning the epic"},
960
1024
  ).model_dump())
961
1025
  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]
1026
+ recon_session = plan_gen.get("session_id")
1027
+ doc = _extract_json_block(plan_gen.get("result") or plan_gen.get("error") or "") or {}
1028
+ specs = doc.get("stories") or []
1029
+ # Enumerated titles are authoritative — align to them (keep any per-story email/points).
1030
+ if story_titles:
1031
+ specs = [
1032
+ {"summary": t,
1033
+ "assigneeEmail": (specs[i].get("assigneeEmail") if i < len(specs) and isinstance(specs[i], dict) else None),
1034
+ "points": (specs[i].get("points") if i < len(specs) and isinstance(specs[i], dict) else None)}
1035
+ for i, t in enumerate(story_titles)
1036
+ ]
1037
+ specs = [s for s in specs[:limit] if isinstance(s, dict) and str(s.get("summary", "")).strip()]
1038
+ if not specs:
1039
+ reason = "could not produce a valid story list from the epic"
969
1040
  await client.log_activity(f"Epic {jira_key}: decomposition failed — {reason}")
970
1041
  await client.move_to_backlog(tid)
971
1042
  await send(Envelope(
@@ -974,98 +1045,100 @@ async def _handle_epic(
974
1045
  ).model_dump())
975
1046
  return
976
1047
 
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]
1048
+ # ── Mark the Epic Done (description untouched; only status + AI plan recorded) ──
1049
+ plan = str(doc.get("plan") or "")[:8000]
981
1050
  await client.update_task_ml(
982
1051
  tid, {"status": "Done", "initialAiPlan": plan}, model_used=_EPIC_MODELS,
983
1052
  )
1053
+ total = len(specs)
984
1054
  await send(Envelope(
985
1055
  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)"},
1056
+ data={"jira_task_id": tid,
1057
+ "current_step": f"Epic {jira_key}: marked Done — building {total} user "
1058
+ f"stor{'y' if total == 1 else 'ies'} one by one"},
988
1059
  ).model_dump())
989
1060
 
990
- # 3. Create a To Do child Task per story, inheriting the Epic's fields.
1061
+ # Optional deliverables folder (from humanInstructions OR description).
1062
+ from pathlib import Path
1063
+ story_dir = _extract_stories_dir(human) or _extract_stories_dir(description)
1064
+ dir_path = None
1065
+ if story_dir:
1066
+ try:
1067
+ dir_path = Path(story_dir).expanduser()
1068
+ dir_path.mkdir(parents=True, exist_ok=True) # create from scratch
1069
+ except Exception: # noqa: BLE001
1070
+ dir_path = None
1071
+
1072
+ # ── Phase 2: expand + create EACH story ONE BY ONE (resume the grounding session) ──
991
1073
  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
1074
  desc_email = _extract_email(description) or _extract_email(human)
995
1075
  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 ""))
1076
+ written = 0
1077
+ for i, spec in enumerate(specs, 1):
1078
+ title = str(spec.get("summary", "")).strip()[:250]
1079
+ await send(Envelope(
1080
+ type="step_update", thread_id=thread_id,
1081
+ data={"jira_task_id": tid,
1082
+ "current_step": f"Epic {jira_key}: story {i}/{total} — {title}"},
1083
+ ).model_dump())
1084
+ one = await _run_generation(
1085
+ _build_one_story_prompt(jira_key, title, i, total),
1086
+ project_path, send=send, thread_id=thread_id,
1087
+ label=f"Epic {jira_key}: story {i}/{total}",
1088
+ github_token=gh_token, resume_session_id=recon_session,
1089
+ )
1090
+ recon_session = one.get("session_id") or recon_session
1091
+ if one.get("limit_exceeded"):
1092
+ await client.log_activity(
1093
+ f"Epic {jira_key}: usage limit after {len(created)}/{total} stories — "
1094
+ "run again to continue the rest"
1095
+ )
1096
+ break
1097
+ story_desc = (one.get("result") or one.get("error") or "").strip() or f"## User Story\n{title}"
1098
+
1099
+ story_email = _extract_email(str(spec.get("assigneeEmail") or ""))
1002
1100
  child_email = story_email or desc_email or inherited.get("assigneeEmail")
1003
1101
  fields = {
1004
1102
  **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
1103
+ "type": "Story",
1104
+ "drivenBy": "SYSTEM", # bot-created trusted, skips the validation gate
1008
1105
  "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.
1106
+ "summary": title,
1107
+ "description": story_desc[:20000],
1108
+ # No humanInstructions on AI-created stories — added later at the task stage.
1014
1109
  "parentTaskId": tid,
1015
1110
  }
1016
1111
  if child_email:
1017
1112
  fields["assigneeEmail"] = child_email
1018
- if st.get("points"):
1019
- fields["points"] = st["points"]
1113
+ if spec.get("points"):
1114
+ fields["points"] = spec["points"]
1020
1115
  new_id = await client.create_task_ml(fields, model_used=_EPIC_MODELS)
1021
1116
  if new_id:
1022
1117
  created.append(new_id)
1023
1118
  await send(Envelope(
1024
1119
  type="step_update", thread_id=thread_id,
1025
- data={"current_step": f" ↳ created To Do task: {fields['summary']}"},
1120
+ data={"current_step": f" ↳ created To Do story {i}/{total}: {title}"},
1026
1121
  ).model_dump())
1027
1122
 
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()
1123
+ # Write this story's .md immediately (incremental), if a folder was given.
1124
+ if dir_path is not None:
1125
+ try:
1044
1126
  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")
1127
+ (dir_path / f"{i:02d}-{slug}.md").write_text(
1128
+ f"# {title}\n\n{story_desc}\n", encoding="utf-8")
1047
1129
  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())
1130
+ except Exception: # noqa: BLE001
1131
+ pass
1060
1132
 
1133
+ tail = f"; wrote {written} .md file(s) to {story_dir}" if dir_path is not None else ""
1061
1134
  await client.log_activity(
1062
- f"Epic {jira_key}: Done — created {len(created)}/{len(stories)} To Do user Story(ies)"
1135
+ f"Epic {jira_key}: Done — created {len(created)}/{total} To Do user Story(ies){tail}"
1063
1136
  )
1064
1137
  await send(Envelope(
1065
1138
  type="step_update", thread_id=thread_id,
1066
1139
  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)"},
1140
+ "current_step": f"Epic {jira_key} complete — {len(created)} Story(ies) queued"
1141
+ + (f" · {written} file(s) in {story_dir}" if dir_path is not None else "")},
1069
1142
  ).model_dump())
1070
1143
 
1071
1144
 
@@ -1116,7 +1189,7 @@ def _build_prd_prompt(jira_key: str, summary: str, user_story: str | None,
1116
1189
  f"=== USER STORY {jira_key} ===\nSummary: {summary}",
1117
1190
  ]
1118
1191
  if user_story and user_story.strip():
1119
- parts.append("User story / description:\n" + _clip_text(user_story.strip()))
1192
+ parts.append("User story / description:\n" + _clip_text(user_story.strip(), _GEN_MAX_CHARS))
1120
1193
  if human_instructions and human_instructions.strip():
1121
1194
  parts.append("Operator instructions:\n" + human_instructions.strip())
1122
1195
  parts.append(