devops-bot-sdk 1.4.47__tar.gz → 1.4.58__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.47 → devops_bot_sdk-1.4.58}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/pyproject.toml +1 -1
  4. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/sdk/client.py +29 -1
  6. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/sdk/ipc/handlers.py +311 -24
  7. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/README.md +0 -0
  8. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/devops_bot_sdk.egg-info/SOURCES.txt +0 -0
  9. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  10. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  11. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/devops_bot_sdk.egg-info/requires.txt +0 -0
  12. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  13. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/sdk/collectors/__init__.py +0 -0
  14. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/sdk/collectors/files.py +0 -0
  15. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/sdk/collectors/process.py +0 -0
  16. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/sdk/collectors/screenshot.py +0 -0
  17. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/sdk/config.py +0 -0
  18. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/sdk/crucial.py +0 -0
  19. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/sdk/exceptions.py +0 -0
  20. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/sdk/git_ops.py +0 -0
  21. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/sdk/graphify.py +0 -0
  22. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/sdk/hooks/__init__.py +0 -0
  23. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/sdk/hooks/crucial_guard.py +0 -0
  24. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/sdk/ipc/__init__.py +0 -0
  25. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/sdk/ipc/electron_bridge.py +0 -0
  26. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/sdk/local_exec.py +0 -0
  27. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/sdk/models/__init__.py +0 -0
  28. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/sdk/models/envelope.py +0 -0
  29. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/sdk/models/requests.py +0 -0
  30. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/sdk/models/responses.py +0 -0
  31. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/sdk/models/snapshots.py +0 -0
  32. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/sdk/py.typed +0 -0
  33. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/sdk/run_auto.py +0 -0
  34. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/sdk/sse.py +0 -0
  35. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/sdk/test.py +0 -0
  36. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/sdk/test_pipeline.py +0 -0
  37. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/sdk/updater.py +0 -0
  38. {devops_bot_sdk-1.4.47 → devops_bot_sdk-1.4.58}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.47
3
+ Version: 1.4.58
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.47
3
+ Version: 1.4.58
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.47"
7
+ version = "1.4.58"
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.47
3
+ Version: 1.4.58
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.47"
33
+ __version__ = "1.4.58"
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.47"
61
+ SDK_VERSION = "1.4.58"
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
@@ -868,6 +868,34 @@ class BackendClient:
868
868
  logger.warning("client.create_task_ml error err=%s", exc)
869
869
  return None
870
870
 
871
+ async def add_comment(self, task_id: str, body: str) -> bool:
872
+ """POST /tasks-ml/{id}/comments — add a comment to a ticket (best-effort).
873
+
874
+ Used by the validation gate to post AI feedback (prefixed with an
875
+ [AI Comments] label) when an Epic/Story is rejected. JSON body: {"body": ...}.
876
+ Returns True on 2xx.
877
+ """
878
+ from sdk.config import BE_DEFAULT_URL
879
+
880
+ url = f"{BE_DEFAULT_URL.rstrip('/')}/tasks-ml/{task_id}/comments"
881
+ headers = {
882
+ **self._headers, # includes Content-Type: application/json
883
+ "accept": "*/*",
884
+ "developer-name": "null",
885
+ "product": "",
886
+ }
887
+ try:
888
+ async with httpx.AsyncClient(timeout=20.0) as client:
889
+ resp = await client.post(url, json={"body": str(body)}, headers=headers)
890
+ if resp.status_code >= 400:
891
+ logger.warning("client.add_comment failed task=%s status=%s body=%s",
892
+ task_id, resp.status_code, resp.text[:200])
893
+ return False
894
+ return True
895
+ except Exception as exc: # noqa: BLE001
896
+ logger.warning("client.add_comment error task=%s err=%s", task_id, exc)
897
+ return False
898
+
871
899
  async def log_activity(self, activity: str) -> bool:
872
900
  """POST /ml-api/agent-activity — emit an agent activity event (best-effort).
873
901
 
@@ -210,6 +210,17 @@ def _build_local_prompt(
210
210
  "before changing it.\n\n"
211
211
  + human_instructions.strip()
212
212
  )
213
+ # 1b. If a PRD is present (a dev Story was converted to this Task with its PRD
214
+ # folded into the description), it is the AUTHORITATIVE spec — build the
215
+ # whole feature to satisfy it end-to-end.
216
+ if user_input and _PRD_MARKER in user_input:
217
+ parts.append(
218
+ "This task includes a PRD (Product Requirements Document) in its description "
219
+ "below. Treat the PRD as the AUTHORITATIVE specification: implement the WHOLE "
220
+ "feature to satisfy its Functional Requirements, Non-Functional Requirements, "
221
+ "and Acceptance Criteria, following the PRD end-to-end. Do not narrow or skip "
222
+ "any part of the PRD's scope."
223
+ )
213
224
  # 2. Distilled plan (acceptance criteria + steps) — the compact, actionable
214
225
  # content the backend planner derived from the full description.
215
226
  sp = structured_plan or {}
@@ -231,6 +242,15 @@ def _build_local_prompt(
231
242
  "above are authoritative if anything conflicts) ===\n"
232
243
  + _clip_text(user_input.strip())
233
244
  )
245
+ # House standards — if this task produces a PRD document, hold it to the
246
+ # same standard format the bot uses everywhere else (a Task converted from a
247
+ # Story keeps its PRD directive in the instructions).
248
+ if _wants_prd(human_instructions) or _wants_prd(user_input):
249
+ parts.append(
250
+ "If you create a PRD / product-requirements document (e.g. a PRD.md), "
251
+ "write it in this STANDARD PRD FORMAT (keep the numbered headings):\n"
252
+ + _PRD_FORMAT
253
+ )
234
254
  if git_managed:
235
255
  # The target is a git repo the SDK manages — it branches before and
236
256
  # commits/pushes/opens the PR after, so the agent must not touch git here.
@@ -360,13 +380,78 @@ _MAX_EPIC_STORIES = int(os.getenv("AGENTOS_MAX_EPIC_STORIES", "") or 8)
360
380
  # Label recorded in the ticket's modelUsed on Epic completion.
361
381
  _EPIC_MODELS = [m.strip() for m in (os.getenv("AGENTOS_EPIC_MODELS", "") or "claude-code").split(",") if m.strip()]
362
382
 
383
+ # ── Standard output formats ─────────────────────────────────────────────────
384
+ # One house style for every generated artifact so Epics, Stories and PRDs read
385
+ # consistently across tickets. Referenced by the generation prompts below.
386
+ _EPIC_FORMAT = (
387
+ "## Epic\n<concise epic title>\n\n"
388
+ "## Overview\n<1-2 paragraph summary of the goal and context>\n\n"
389
+ "## Business Value / Goal\n<why this matters; the outcome it drives>\n\n"
390
+ "## Scope\n**In scope:** <what is included>\n**Out of scope:** <what is not>\n\n"
391
+ "## User Stories\n1. <story title> — <one-line summary>\n2. <story title> — <one-line summary>\n\n"
392
+ "## Epic-level Acceptance Criteria\n- <observable, high-level pass/fail criteria>\n\n"
393
+ "## Dependencies & Risks\n- <key dependencies, assumptions, risks>"
394
+ )
395
+ _USER_STORY_FORMAT = (
396
+ "## User Story\n"
397
+ "As a <role>, I want <capability>, so that <benefit>.\n\n"
398
+ "## Acceptance Criteria\n"
399
+ "- Given <context>, when <action>, then <expected outcome>.\n"
400
+ "- <list every testable criterion, Gherkin Given/When/Then style>\n\n"
401
+ "## Notes & Constraints\n"
402
+ "- <context, dependencies, constraints; for software work: affected files/modules & patterns to follow>\n\n"
403
+ "## Definition of Done\n"
404
+ "- Delivered against the acceptance criteria above\n"
405
+ "- Reviewed / validated (for software: tests added & passing)\n"
406
+ "- Documentation updated where relevant"
407
+ )
408
+ _PRD_FORMAT = (
409
+ "# PRD: <title>\n\n"
410
+ "## 1. Overview\n<what the feature is and why, in brief>\n\n"
411
+ "## 2. Goals & Non-Goals\n<what it must achieve; what is explicitly out of scope>\n\n"
412
+ "## 3. Current Behaviour & Context\n<the existing system/modules this touches — "
413
+ "grounded in the REAL codebase: files, components, current flow>\n\n"
414
+ "## 4. How It Works — Mechanism\n<the core mechanism end-to-end: the exact flow of "
415
+ "control and data, step by step, from trigger/input to output/persistence. Describe "
416
+ "how the feature actually functions, not just what it does>\n\n"
417
+ "## 5. Architecture & Components\n<components/modules/services involved and their "
418
+ "responsibilities; where new code lives; how they interact>\n\n"
419
+ "## 6. Data Model & Schema\n<entities, fields + types, relationships, indexes, and "
420
+ "any migrations / schema changes>\n\n"
421
+ "## 7. API / Interface Contracts\n<each endpoint or interface: HTTP method + path, "
422
+ "request shape, response shape, status codes, auth. Include example payloads>\n\n"
423
+ "## 8. Detailed Logic & Business Rules\n<algorithms, validation rules, state "
424
+ "transitions, sequence of operations, ordering/idempotency, concurrency>\n\n"
425
+ "## 9. Data Flow & Integrations\n<how data moves between components/services; "
426
+ "external systems, events, queues, third-party APIs>\n\n"
427
+ "## 10. Error Handling & Edge Cases\n<failure modes, validation errors, retries, "
428
+ "timeouts, boundary conditions, empty/duplicate/large inputs>\n\n"
429
+ "## 11. Security & Performance (Non-Functional)\n<authn/authz, input hardening, "
430
+ "secrets, rate limits, performance/scale targets, accessibility where relevant>\n\n"
431
+ "## 12. Testing Strategy\n<unit / integration / e2e coverage and the key test cases "
432
+ "that prove each requirement>\n\n"
433
+ "## 13. Acceptance Criteria\n<observable, testable pass/fail tied to the mechanism>\n\n"
434
+ "## 14. Dependencies, Risks & Rollout\n<dependencies, assumptions, risks, migration "
435
+ "and rollout/phasing>"
436
+ )
437
+
363
438
 
364
439
  def _build_epic_prompt(jira_key: str, summary: str, description: str | None,
365
440
  human_instructions: str | None) -> str:
366
441
  """Read-only prompt that turns an Epic into a decomposition JSON."""
367
442
  parts: list[str] = [
368
- "You are decomposing a JIRA EPIC into implementable work. This is ANALYSIS "
369
- "ONLY — do NOT create, edit, or delete any file.",
443
+ "You are decomposing a JIRA EPIC into implementable user stories. This is "
444
+ "ANALYSIS ONLY — do NOT create, edit, or delete any file.",
445
+ "Faithfulness (read first — this prevents hallucinated stories):\n"
446
+ "- Derive stories ONLY from what the epic actually states or clearly implies. "
447
+ "Do NOT invent features, scope, integrations, tools, or requirements that are "
448
+ "not grounded in the epic (or, for software, in the REAL codebase you inspect).\n"
449
+ "- If something needed is missing or ambiguous, capture it as an open question "
450
+ "in that story's Notes — do NOT fabricate a solution.\n"
451
+ "- Prefer fewer, well-grounded stories over many speculative ones. Every story "
452
+ "must trace back to something in the epic.\n"
453
+ "- This epic may be for ANY domain (software, Finance, HR, operations, …). Match "
454
+ "the stories to the epic's actual domain; do not assume it is software.",
370
455
  f"=== EPIC {jira_key} ===\nSummary: {summary}",
371
456
  ]
372
457
  if description and description.strip():
@@ -378,18 +463,20 @@ def _build_epic_prompt(jira_key: str, summary: str, description: str | None,
378
463
  + human_instructions.strip()
379
464
  )
380
465
  parts.append(
381
- "First understand the existing codebase in this directory (read CLAUDE.md, "
382
- "use graphify / Read / Glob / Grep) so the stories fit the REAL architecture. "
383
- "Then produce the decomposition.\n\n"
466
+ "Ground your work first: if this is a SOFTWARE project, inspect the existing "
467
+ "codebase in this directory (read CLAUDE.md, use graphify / Read / Glob / Grep) "
468
+ "so the stories fit the REAL architecture. For a NON-software epic (Finance, HR, "
469
+ "operations, …), ground the stories in the epic's stated details and any "
470
+ "reference material present — never in invented facts. Then produce the "
471
+ "decomposition.\n\n"
384
472
  "Output ONLY a single fenced ```json block and nothing after it, with shape:\n"
385
473
  "{\n"
386
- ' "epic_description": "A detailed description for the epic: a full user-story '
387
- "write-up covering goal, the user stories, acceptance criteria and scope.\",\n"
474
+ ' "epic_description": "The epic write-up in the EPIC FORMAT below (Markdown).",\n'
388
475
  ' "plan": "An ordered, numbered high-level implementation plan.",\n'
389
476
  ' "stories": [\n'
390
477
  ' {\n'
391
478
  ' "summary": "Short imperative story title (a JIRA task summary)",\n'
392
- ' "description": "Detailed description: what to build + acceptance criteria.",\n'
479
+ ' "description": "The story in the USER STORY FORMAT below (Markdown).",\n'
393
480
  ' "humanInstructions": "Operator instructions to implement THIS story. MUST '
394
481
  "repeat the same project location/path and constraints from the epic so the "
395
482
  'coding run works in the right place.",\n'
@@ -397,12 +484,16 @@ def _build_epic_prompt(jira_key: str, summary: str, description: str | None,
397
484
  ' "points": 5\n'
398
485
  " }\n"
399
486
  " ]\n"
400
- "}\n"
487
+ "}\n\n"
401
488
  "Create as many stories as the epic genuinely needs (do not pad or truncate "
402
489
  "artificially); each story must be independently implementable.\n"
403
490
  "assigneeEmail is OPTIONAL per story: set it ONLY when the epic explicitly "
404
491
  "assigns that specific work to a named person/email (e.g. 'assign the API to "
405
- "alice@x.com'); otherwise omit it."
492
+ "alice@x.com'); otherwise omit it.\n\n"
493
+ "=== EPIC FORMAT (epic_description MUST follow this exactly) ===\n"
494
+ + _EPIC_FORMAT
495
+ + "\n\n=== USER STORY FORMAT (every story.description MUST follow this exactly) ===\n"
496
+ + _USER_STORY_FORMAT
406
497
  )
407
498
  return "\n\n".join(parts)
408
499
 
@@ -459,6 +550,117 @@ def _epic_inherited_fields(task: dict) -> dict:
459
550
  return {k: v for k, v in fields.items() if v}
460
551
 
461
552
 
553
+ # ── Ticket validation gate (Epics + USER-driven Stories) ────────────────────
554
+ # A strict quality gate: before the bot actions an Epic or a USER-created Story it
555
+ # checks the ticket meets the required standard. On failure it posts a harsh,
556
+ # technical comment (prefixed with the label below) and moves the ticket to
557
+ # Backlog. SYSTEM-created Stories (spun off an Epic by the bot) are trusted and
558
+ # skip validation. A later run re-validates; once the gaps are addressed in the
559
+ # description / Human Instructions, it passes and the normal flow proceeds.
560
+ _AI_COMMENT_LABEL = "[AI Comments]"
561
+
562
+
563
+ def _build_validation_prompt(kind: str, jira_key: str, summary: str,
564
+ description: str | None, human: str | None) -> str:
565
+ k = kind.upper()
566
+ parts: list[str] = [
567
+ f"You are a STRICT ticket-quality gate for a JIRA {k}. Decide whether this "
568
+ "ticket meets the required standard to be actioned autonomously. Be HARSH: if "
569
+ "the required structure or information is not strictly present, REJECT it — a "
570
+ "vague or under-specified ticket must NOT pass.",
571
+ f"=== {k} {jira_key} ===\nSummary: {summary or '(none)'}",
572
+ "Description:\n" + (_clip_text(description.strip()) if description and description.strip() else "(none provided)"),
573
+ "Human Instructions:\n" + (human.strip() if human and human.strip() else "(none provided)"),
574
+ ]
575
+ parts.append(
576
+ "A VALID ticket MUST have:\n"
577
+ "- A clear, unambiguous goal and scope (no hand-waving).\n"
578
+ "- Explicit Human Instructions: for codebase work, the repository link(s) "
579
+ "and/or file path(s), the branch/PR convention, and build/run/test steps.\n"
580
+ "- Observable, testable acceptance criteria.\n"
581
+ + ("- Enough concrete detail to decompose into independent user stories.\n"
582
+ if kind == "epic" else
583
+ "- A clear 'As a <role>, I want <capability>, so that <benefit>' intent plus "
584
+ "testable acceptance criteria.\n")
585
+ + "- Any THIRD-PARTY INTEGRATION or human-effort dependency (API keys, OAuth "
586
+ "credentials, external accounts, secrets, manual provisioning, DNS, billing) "
587
+ "MUST be named explicitly WITH who provides it. If such a dependency is implied "
588
+ "but undefined/missing, REJECT.\n\n"
589
+ "Output ONLY a fenced ```json block, nothing else:\n"
590
+ "{\n"
591
+ ' "valid": true or false,\n'
592
+ ' "comment": "When invalid: a concise, TECHNICAL rejection that lists EXACTLY '
593
+ "what is missing or ambiguous and must be added. Use precise engineering "
594
+ "terminology (API contract, data model/schema, auth/OAuth flow, env/config, "
595
+ "acceptance criteria, target repository/branch, integration credentials & "
596
+ 'ownership). When valid: empty string."\n'
597
+ "}"
598
+ )
599
+ return "\n\n".join(parts)
600
+
601
+
602
+ def _parse_validation(text: str | None) -> tuple[bool, str]:
603
+ """(valid, comment) from the validator's JSON output. Defaults to invalid."""
604
+ obj = _extract_json_block(text) or {}
605
+ return bool(obj.get("valid")), str(obj.get("comment") or "").strip()
606
+
607
+
608
+ async def _validate_ticket_or_reject(
609
+ client: BackendClient, task: dict, kind: str, payload: dict,
610
+ send: Callable, thread_id: str,
611
+ ) -> bool:
612
+ """Strict gate. Returns True to proceed, False if the ticket was rejected
613
+ (comment posted + moved to Backlog). Fails OPEN if the validator can't run —
614
+ we never block a ticket on our own infrastructure error."""
615
+ from sdk import local_exec
616
+
617
+ tid = str(task.get("id", ""))
618
+ jira_key = task.get("jiraIssueKey") or task.get("jiraKey", "") or kind.upper()
619
+ project_path = _resolve_local_project_path(task, payload)
620
+
621
+ await send(Envelope(
622
+ type="step_update", thread_id=thread_id,
623
+ data={"jira_task_id": tid, "task_id": tid,
624
+ "current_step": f"{kind.title()} {jira_key}: validating ticket against the standard"},
625
+ ).model_dump())
626
+
627
+ res = await local_exec.run_claude_local(
628
+ _build_validation_prompt(kind, jira_key, task.get("summary", "") or "",
629
+ task.get("description"), task.get("humanInstructions")),
630
+ project_path, allowed_tools=_RECON_ALLOWED_TOOLS, # read-only judgment
631
+ github_token=await client.github_token(),
632
+ )
633
+ if not res.get("ok"):
634
+ await client.log_activity(
635
+ f"{kind} {jira_key}: validation could not run ({(res.get('error') or '')[:120]}) — proceeding"
636
+ )
637
+ return True # fail-open on validator infra error
638
+
639
+ valid, comment = _parse_validation(res.get("result"))
640
+ if valid:
641
+ await send(Envelope(
642
+ type="step_update", thread_id=thread_id,
643
+ data={"jira_task_id": tid, "current_step": f"{kind.title()} {jira_key}: validation passed"},
644
+ ).model_dump())
645
+ return True
646
+
647
+ # Rejected — post a labelled, technical comment and move to Backlog.
648
+ fallback = ("This ticket does not meet the required standard. Provide: a clear goal "
649
+ "and scope; explicit Human Instructions (target repository/branch and/or "
650
+ "file paths, PR convention, build/run/test steps); testable acceptance "
651
+ "criteria; and the credentials/ownership for any third-party integration.")
652
+ body = f"{_AI_COMMENT_LABEL}\n\n{comment or fallback}"
653
+ await client.add_comment(tid, body)
654
+ await client.move_to_backlog(tid)
655
+ await client.log_activity(f"{kind} {jira_key}: REJECTED by validation gate — commented + moved to Backlog")
656
+ await send(Envelope(
657
+ type=Envelope.TYPE_ERROR, thread_id=thread_id,
658
+ data={"code": f"{kind.upper()}_VALIDATION_REJECTED", "task_id": tid,
659
+ "message": (comment or fallback)[:300]},
660
+ ).model_dump())
661
+ return False
662
+
663
+
462
664
  async def _handle_epic(
463
665
  client: BackendClient, task: dict, payload: dict, send: Callable, thread_id: str,
464
666
  ) -> None:
@@ -537,6 +739,7 @@ async def _handle_epic(
537
739
  **inherited,
538
740
  "type": "Story", # Epic decomposes into user Stories; a Story later
539
741
  # becomes a Task (see _handle_story) when it is run.
742
+ "drivenBy": "SYSTEM", # bot-created → trusted, skips the validation gate
540
743
  "status": "To Do",
541
744
  "summary": str(st.get("summary", "")).strip()[:250],
542
745
  "description": str(st.get("description", "") or "")[:20000],
@@ -582,12 +785,34 @@ def _wants_prd(text: str | None) -> bool:
582
785
  return bool(text and _PRD_RE.search(text))
583
786
 
584
787
 
788
+ # Signals that a ticket is DEVELOPMENT work (→ always gets a PRD). Deliberately
789
+ # code-specific so Finance/HR/ops tickets don't false-positive (they get a PRD
790
+ # only when explicitly requested).
791
+ _DEV_RE = re.compile(
792
+ r"github\.com/|gitlab\.com/|bitbucket\.org/|\b(?:repo(?:sitory)?|codebase|"
793
+ r"source\s*code|frontend|front-end|back-?end|pull\s+requests?|merge\s+requests?|"
794
+ r"\bPRs?\b|feature\s+branch|create\s+a?\s*branch|commit|deploy(?:ment)?|compile|"
795
+ r"build\s+pipeline|microservice|endpoint|api\s+contract|database\s+migration|"
796
+ r"unit\s+test)\b",
797
+ re.I,
798
+ )
799
+
800
+
801
+ def _is_dev_task(text: str | None) -> bool:
802
+ """True when the text clearly describes software-development work."""
803
+ return bool(text and _DEV_RE.search(text))
804
+
805
+
585
806
  def _build_prd_prompt(jira_key: str, summary: str, user_story: str | None,
586
807
  human_instructions: str | None) -> str:
587
- """Read-only prompt that produces a PRD (Markdown) for a user story."""
808
+ """Read-only prompt that produces a TECHNICAL PRD (Markdown) for a user story."""
588
809
  parts: list[str] = [
589
- "Write a PRD (Product Requirements Document) for the user story below. This is "
590
- "ANALYSIS ONLY do NOT create, edit, or delete any file.",
810
+ "Write a TECHNICAL PRD (Product Requirements Document) for the user story below. "
811
+ "It must fully specify the MECHANISM how the feature works end-to-end "
812
+ "(architecture, components, data model, API/interface contracts, control & data "
813
+ "flow, business logic, error handling) — in enough detail that a developer can "
814
+ "implement it directly from the PRD without guessing. This is ANALYSIS ONLY — do "
815
+ "NOT create, edit, or delete any file.",
591
816
  f"=== USER STORY {jira_key} ===\nSummary: {summary}",
592
817
  ]
593
818
  if user_story and user_story.strip():
@@ -595,11 +820,17 @@ def _build_prd_prompt(jira_key: str, summary: str, user_story: str | None,
595
820
  if human_instructions and human_instructions.strip():
596
821
  parts.append("Operator instructions:\n" + human_instructions.strip())
597
822
  parts.append(
598
- "Explore the codebase (CLAUDE.md, graphify, Read/Glob/Grep) to ground the PRD in "
599
- "the REAL system. Output ONLY the PRD as Markdown — no preamble, no code edits — "
600
- "with sections: Overview, Goals & Success Metrics, User Stories, Functional "
601
- "Requirements, Non-Functional Requirements, Acceptance Criteria, Out of Scope, "
602
- "Dependencies & Risks."
823
+ "Ground the PRD in reality — for software, FIRST understand the current codebase "
824
+ "STRUCTURE (folder layout, key modules, existing patterns, data models, APIs) via "
825
+ "CLAUDE.md, graphify and Read/Glob/Grep, then specify the mechanism against the "
826
+ "REAL files, modules, data models and APIs that exist; for a non-software task, "
827
+ "ground it in "
828
+ "the story's stated details and mark technical sections that don't apply as "
829
+ "'N/A'. Do NOT invent requirements, systems, endpoints, or facts not supported by "
830
+ "the story or the code you inspect. Output ONLY the PRD as Markdown — no preamble, "
831
+ "no code edits — following this PRD FORMAT exactly (keep the numbered section "
832
+ "headings):\n\n"
833
+ + _PRD_FORMAT
603
834
  )
604
835
  return "\n\n".join(parts)
605
836
 
@@ -621,14 +852,25 @@ async def _handle_story(
621
852
  summary = task.get("summary", "") or ""
622
853
  user_story = task.get("description") or ""
623
854
  human = task.get("humanInstructions")
624
- wants_prd = _wants_prd(human) or _wants_prd(user_story)
625
-
855
+ # STEP 1 — validate whether this is a CODEBASE (development) task: repo links /
856
+ # file paths in the instructions, or dev signals (PRs, branches, endpoints,
857
+ # deploy, …). A codebase task ALWAYS gets a technical PRD built from the current
858
+ # codebase; other domains (Finance, HR, …) get one only when explicitly asked.
859
+ has_repo = bool(_extract_repo_targets(human)) or bool(_extract_repo_targets(user_story))
860
+ is_codebase_task = has_repo or _is_dev_task(human) or _is_dev_task(user_story)
861
+ wants_prd = is_codebase_task or _wants_prd(human) or _wants_prd(user_story)
862
+
863
+ if is_codebase_task:
864
+ step = ("validated as a codebase task — building a technical PRD from the "
865
+ "current codebase, then converting to Task")
866
+ elif wants_prd:
867
+ step = "generating the requested PRD, then converting to Task"
868
+ else:
869
+ step = "converting to Task"
626
870
  await send(Envelope(
627
871
  type="step_update", thread_id=thread_id,
628
872
  data={"jira_task_id": tid, "task_id": tid,
629
- "current_step": f"Story {jira_key}: "
630
- + ("generating PRD, then " if wants_prd else "")
631
- + "converting to Task"},
873
+ "current_step": f"Story {jira_key}: {step}"},
632
874
  ).model_dump())
633
875
 
634
876
  # Idempotent: if a PRD is already in the description, don't regenerate it or
@@ -1039,6 +1281,40 @@ async def _code_one_repo(
1039
1281
  except Exception: # noqa: BLE001
1040
1282
  branch_info = None
1041
1283
 
1284
+ # 3a. Technical PRD for a CODEBASE task that doesn't have one yet. A Task created
1285
+ # directly (not via a Story) may carry only a user-story/feature description —
1286
+ # for codebase work we first turn the recon understanding into a technical PRD
1287
+ # and build to it, exactly like a Story-derived Task. Resumes the recon session
1288
+ # (already understands the codebase) so it's cheap; persists the PRD to the
1289
+ # ticket and to pending (shared → other repos reuse it and skip regeneration).
1290
+ # Skipped when a PRD is already present, on a resume, or for non-git targets.
1291
+ if (is_git and recon_session and not resume_session_id
1292
+ and _PRD_MARKER not in (pending.get("user_input") or "")):
1293
+ await send(Envelope(
1294
+ type="step_update", thread_id=thread_id,
1295
+ data={"status": "running",
1296
+ "current_step": f"[{name}] Codebase task — writing a technical PRD from the codebase"},
1297
+ ).model_dump())
1298
+ prd_res = await local_exec.run_claude_local(
1299
+ "Using your exploration above, write a TECHNICAL PRD for this task that fully "
1300
+ "specifies the mechanism (how it works end-to-end) against the REAL code you "
1301
+ "found. Output ONLY the PRD as Markdown, following this format exactly:\n\n"
1302
+ + _PRD_FORMAT,
1303
+ repo_path, allowed_tools=_RECON_ALLOWED_TOOLS, # read-only
1304
+ github_token=gh_token, resume_session_id=recon_session,
1305
+ )
1306
+ recon_session = prd_res.get("session_id") or recon_session
1307
+ prd = (prd_res.get("result") or "").strip() if prd_res.get("ok") else ""
1308
+ if prd:
1309
+ enriched = (
1310
+ f"{pending.get('user_input') or ''}\n\n---\n\n{_PRD_MARKER}\n\n{prd}"
1311
+ )[:20000]
1312
+ pending["user_input"] = enriched # shared across repos → reused, not regenerated
1313
+ try:
1314
+ await client.update_task_ml(task_id, {"description": enriched}, model_used=_EPIC_MODELS)
1315
+ except Exception: # noqa: BLE001 — persisting the PRD is best-effort
1316
+ pass
1317
+
1042
1318
  await send(Envelope(
1043
1319
  type="step_update", thread_id=thread_id,
1044
1320
  data={"status": "running", "current_step": f"Executing locally in {name} (claude_code)"},
@@ -1047,7 +1323,8 @@ async def _code_one_repo(
1047
1323
  # Build the prompt with framing that matches the target: a git repo we manage
1048
1324
  # (branch + PR) vs a plain local folder (BA/analyst deliverables, no git/PR).
1049
1325
  # When recon ran, the implementation resumes that session so its grounded plan
1050
- # stays in context; the prompt tells the agent to implement THAT plan.
1326
+ # (and the technical PRD, if generated) stays in context; if a PRD is present the
1327
+ # prompt makes it the authoritative spec.
1051
1328
  prompt = _build_local_prompt(
1052
1329
  pending["user_input"], pending.get("structured_plan"),
1053
1330
  pending.get("human_instructions"),
@@ -2555,7 +2832,17 @@ async def handle_orchestrate_auto_local_run(
2555
2832
  if ttype in ("epic", "story"):
2556
2833
  handler = _handle_epic if ttype == "epic" else _handle_story
2557
2834
  code = "EPIC_DECOMPOSE_FAILED" if ttype == "epic" else "STORY_HANDLE_FAILED"
2835
+ # Validation gate: Epics are always validated; a Story only when it
2836
+ # is USER-driven (a SYSTEM/bot-created Story is trusted). A rejected
2837
+ # ticket is commented + moved to Backlog inside the gate — skip it.
2838
+ driven_by = str(task.get("drivenBy") or "").strip().upper()
2839
+ needs_validation = (ttype == "epic") or (ttype == "story" and driven_by != "SYSTEM")
2558
2840
  try:
2841
+ if needs_validation and not await _validate_ticket_or_reject(
2842
+ client, task, ttype, payload, send, thread_id
2843
+ ):
2844
+ processed += 1
2845
+ continue
2559
2846
  await handler(client, task, payload, send, thread_id)
2560
2847
  except Exception as exc: # noqa: BLE001
2561
2848
  logger.warning("auto_local.%s_failed task=%s err=%s", ttype, tid, exc)