devops-bot-sdk 1.4.33__tar.gz → 1.4.35__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.33 → devops_bot_sdk-1.4.35}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/pyproject.toml +1 -1
  4. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/sdk/client.py +1 -1
  6. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/sdk/config.py +16 -0
  7. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/sdk/git_ops.py +27 -7
  8. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/sdk/ipc/handlers.py +413 -15
  9. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/README.md +0 -0
  10. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/devops_bot_sdk.egg-info/SOURCES.txt +0 -0
  11. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  12. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  13. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/devops_bot_sdk.egg-info/requires.txt +0 -0
  14. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  15. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/sdk/collectors/__init__.py +0 -0
  16. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/sdk/collectors/files.py +0 -0
  17. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/sdk/collectors/process.py +0 -0
  18. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/sdk/collectors/screenshot.py +0 -0
  19. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/sdk/crucial.py +0 -0
  20. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/sdk/exceptions.py +0 -0
  21. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/sdk/graphify.py +0 -0
  22. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/sdk/hooks/__init__.py +0 -0
  23. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/sdk/hooks/crucial_guard.py +0 -0
  24. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/sdk/ipc/__init__.py +0 -0
  25. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/sdk/ipc/electron_bridge.py +0 -0
  26. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/sdk/local_exec.py +0 -0
  27. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/sdk/models/__init__.py +0 -0
  28. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/sdk/models/envelope.py +0 -0
  29. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/sdk/models/requests.py +0 -0
  30. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/sdk/models/responses.py +0 -0
  31. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/sdk/models/snapshots.py +0 -0
  32. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/sdk/py.typed +0 -0
  33. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/sdk/run_auto.py +0 -0
  34. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/sdk/sse.py +0 -0
  35. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/sdk/test.py +0 -0
  36. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/sdk/test_pipeline.py +0 -0
  37. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/sdk/updater.py +0 -0
  38. {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.35}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.33
3
+ Version: 1.4.35
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.33
3
+ Version: 1.4.35
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.33"
7
+ version = "1.4.35"
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.33
3
+ Version: 1.4.35
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.33"
33
+ __version__ = "1.4.35"
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.33"
61
+ SDK_VERSION = "1.4.35"
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
@@ -122,6 +122,22 @@ def get_base_url() -> str:
122
122
  return load().get("base_url", AI_DEFAULT_URL)
123
123
 
124
124
 
125
+ def get_branch_name_template() -> str | None:
126
+ """Org-wide branch-naming convention, if configured in config.toml.
127
+
128
+ Lets teams enforce a house convention without repeating it in every ticket's
129
+ humanInstructions. Supports the ``{jira_key}`` placeholder, e.g.::
130
+
131
+ branch_name_template = "{jira_key}" # branch named exactly the ticket id
132
+ branch_name_template = "feature/{jira_key}"
133
+
134
+ A per-ticket directive parsed from humanInstructions always wins over this.
135
+ Returns None when unset (SDK falls back to feature/<key>-<slug>).
136
+ """
137
+ val = _load_raw().get("branch_name_template")
138
+ return val.strip() if val and val.strip() else None
139
+
140
+
125
141
  def get_token() -> str:
126
142
  """Return the stored apiTokenHash. Raises TokenNotConfigured if missing."""
127
143
  cfg = load()
@@ -148,8 +148,17 @@ async def start_branch(
148
148
  summary: str | None,
149
149
  github_token: str | None,
150
150
  base: str | None = None,
151
+ branch_name: str | None = None,
151
152
  ) -> dict | None:
152
- """Create + check out `feature/<jira_key>-<slug>` off the resolved base.
153
+ """Create + check out a feature branch off the resolved base.
154
+
155
+ Branch name:
156
+ - `branch_name` given (an explicit name parsed from the Jira
157
+ humanInstructions or a configured template, e.g. the exact ticket id
158
+ "CD-72") → used VERBATIM. This is how "name the branch exactly
159
+ <TICKET-ID>" is honoured — the SDK owns branching for the managed repo,
160
+ so the convention has to be applied here, not by the agent.
161
+ - omitted → the historical default `feature/<jira_key>-<slug>`.
153
162
 
154
163
  Base resolution (per repo):
155
164
  - `base` given (named in the Jira humanInstructions, possibly per-repo) →
@@ -171,8 +180,13 @@ async def start_branch(
171
180
  if not await _is_git_repo(path, env):
172
181
  return None
173
182
 
174
- key = slugify(jira_key or "task", max_len=24)
175
- branch = f"feature/{key}-{slugify(summary)}"
183
+ explicit_name = (branch_name or "").strip().lstrip("/")
184
+ if explicit_name:
185
+ # Honour the exact name the human/convention asked for (e.g. "CD-72").
186
+ branch = explicit_name
187
+ else:
188
+ key = slugify(jira_key or "task", max_len=24)
189
+ branch = f"feature/{key}-{slugify(summary)}"
176
190
  # Fetch so any remote base ref (origin/HEAD, origin/<base>) is present/fresh.
177
191
  await _run(["git", "fetch", "origin"], path, env, timeout=180.0)
178
192
 
@@ -292,8 +306,13 @@ async def finish_pr(
292
306
  env = _git_env(github_token)
293
307
 
294
308
  await _run(["git", "add", "-A"], path, env)
295
- # commit — no-op (non-zero) when there's nothing staged; ignore that case.
296
- await _run(["git", "commit", "-m", title], path, env)
309
+ # commit — returns non-zero when there's nothing staged. Capture that: a
310
+ # zero exit means a real commit was created (the agent actually changed
311
+ # something), which the caller uses as the authoritative "did work happen?"
312
+ # signal — far more reliable than files_changed, which also counts files the
313
+ # agent merely READ.
314
+ commit_rc, _, _ = await _run(["git", "commit", "-m", title], path, env)
315
+ committed = commit_rc == 0
297
316
 
298
317
  rc, _, push_err = await _run(
299
318
  ["git", "push", "-u", "origin", branch, "--force-with-lease"],
@@ -302,7 +321,8 @@ async def finish_pr(
302
321
  pushed = rc == 0
303
322
  if not pushed:
304
323
  logger.warning("git_ops.push_failed branch=%s error=%s", branch, push_err[:300])
305
- return {"pushed": False, "pr_url": None, "pr_error": f"push failed: {push_err[:200]}"}
324
+ return {"pushed": False, "pr_url": None, "committed": committed,
325
+ "pr_error": f"push failed: {push_err[:200]}"}
306
326
 
307
327
  pr_url: str | None = None
308
328
  pr_error: str | None = None
@@ -347,4 +367,4 @@ async def finish_pr(
347
367
  pr_error = "no PR created"
348
368
  logger.warning("git_ops.no_pr branch=%s reason=%s", branch, pr_error)
349
369
 
350
- return {"pushed": pushed, "pr_url": pr_url, "pr_error": pr_error}
370
+ return {"pushed": pushed, "pr_url": pr_url, "pr_error": pr_error, "committed": committed}
@@ -154,6 +154,9 @@ def _build_local_prompt(
154
154
  human_instructions: str | None = None,
155
155
  agent_mode: bool = True,
156
156
  git_managed: bool = True,
157
+ repo_name: str | None = None,
158
+ grounded: bool = False,
159
+ branch_convention: str | None = None,
157
160
  ) -> str:
158
161
  """Compose the coding prompt for the local claude CLI from the server plan.
159
162
 
@@ -173,6 +176,25 @@ def _build_local_prompt(
173
176
  locally itself.
174
177
  """
175
178
  parts: list[str] = []
179
+ # 0. Per-repo scope + grounding framing — keeps a multi-repo ticket from
180
+ # bleeding one repo's work into another, and points the agent at the
181
+ # grounded plan it just produced during the read-only recon pass.
182
+ if repo_name:
183
+ scope_line = (
184
+ f"You are working ONLY in the `{repo_name}` repository. Make changes "
185
+ f"here that belong to THIS repo; do not attempt work that belongs to a "
186
+ f"different repository."
187
+ )
188
+ if grounded:
189
+ scope_line += (
190
+ " You have just completed a READ-ONLY exploration of this repo in "
191
+ "this same session and produced a SCOPE / FILES / PATTERNS / PLAN "
192
+ "report. Implement THAT grounded plan now — edit the real files you "
193
+ "identified and follow the existing patterns you found. The server "
194
+ "plan below is secondary reference; if it conflicts with what the "
195
+ "code actually is, trust the code and the operator instructions."
196
+ )
197
+ parts.append(scope_line)
176
198
  # 1. Operator instructions — FIRST, verbatim, authoritative.
177
199
  if human_instructions and human_instructions.strip():
178
200
  parts.append(
@@ -219,6 +241,12 @@ def _build_local_prompt(
219
241
  "out, and the system will commit, push, and open the PR after you finish — "
220
242
  "so do NOT create branches, commit, push, or open a PR for the current repo. "
221
243
  "(You may still clone and open PRs for OTHER repositories the instructions name.)"
244
+ + (
245
+ f"\nFor any OTHER repository you clone yourself, follow the branch-naming "
246
+ f"convention exactly: name the branch `{branch_convention}` "
247
+ f"(or the equivalent per that repo's ticket id)."
248
+ if branch_convention else ""
249
+ )
222
250
  )
223
251
  else:
224
252
  # Plain local working folder (no GitHub repo / not under our git control) —
@@ -239,6 +267,90 @@ def _build_local_prompt(
239
267
  return "\n\n".join(p for p in parts if p)
240
268
 
241
269
 
270
+ # Read-only tool set for the recon pass — no Write/Edit/Bash, so it physically
271
+ # cannot modify the repo or run git while it explores and plans.
272
+ _RECON_ALLOWED_TOOLS = ["Read", "Glob", "Grep"]
273
+
274
+
275
+ def _build_recon_prompt(
276
+ repo_name: str,
277
+ user_input: str | None,
278
+ structured_plan: dict | None,
279
+ human_instructions: str | None,
280
+ ) -> str:
281
+ """Prompt for the read-only recon pass: explore THIS repo, decide scope, and
282
+ produce a codebase-grounded plan. The server plan is passed as HINTS only —
283
+ the whole point is to replace guessed files/contracts with real ones.
284
+ """
285
+ parts: list[str] = [
286
+ f"READ-ONLY RECON PASS for the `{repo_name}` repository. Do NOT create, "
287
+ "edit, or delete ANY file, and do NOT run git — this pass ONLY explores "
288
+ "and produces a grounded plan. Implementation happens in the next step.",
289
+ ]
290
+ if human_instructions and human_instructions.strip():
291
+ parts.append(
292
+ "=== Operator instructions (Jira humanInstructions) — AUTHORITATIVE ===\n"
293
+ + human_instructions.strip()
294
+ )
295
+ sp = structured_plan or {}
296
+ criteria = sp.get("acceptance_criteria") or []
297
+ steps = sp.get("task_steps") or []
298
+ if criteria:
299
+ parts.append(
300
+ "Acceptance criteria (server plan — HINTS to verify against the real "
301
+ "code, not ground truth):\n" + "\n".join(f"- {c}" for c in criteria)
302
+ )
303
+ if steps:
304
+ lines = [
305
+ f"- {s.get('title', '')}: {s.get('description', '')}".rstrip(": ")
306
+ for s in steps if isinstance(s, dict)
307
+ ]
308
+ if lines:
309
+ parts.append("Proposed steps (hints):\n" + "\n".join(lines))
310
+ if user_input and user_input.strip():
311
+ parts.append(
312
+ "=== Task description (reference) ===\n" + _clip_text(user_input.strip())
313
+ )
314
+ parts.append(
315
+ "Explore THIS repository to ground the work in what actually exists:\n"
316
+ "- If a CLAUDE.md is present, read it FIRST and follow its documented "
317
+ "structure, conventions and commands.\n"
318
+ "- Prefer graphify (the /graphify skill / graph queries) over reading whole "
319
+ "files, so this stays fast and cheap on large repos.\n"
320
+ "- Use Read/Glob/Grep to confirm the REAL files, modules, routes/endpoints, "
321
+ "request/response shapes, naming and error/test patterns involved.\n\n"
322
+ "Then output a report with these EXACT sections (and nothing that edits code):\n"
323
+ "SCOPE: IN — if this repo genuinely needs changes for this ticket\n"
324
+ "SCOPE: OUT — if NOTHING in this repo is in scope (the work belongs to a "
325
+ "different repo). Be honest — guessing IN and then changing nothing is a failure.\n"
326
+ "FILES: the actual files you will change (existing paths), or new files with "
327
+ "their correct location following this repo's real layout\n"
328
+ "PATTERNS: the existing conventions/contracts to follow (API shape, naming, "
329
+ "error handling, tests) — cite real examples you found\n"
330
+ "PLAN: a concrete, ordered implementation plan for THIS repo that references "
331
+ "the real files and patterns above"
332
+ )
333
+ return "\n\n".join(p for p in parts if p)
334
+
335
+
336
+ def _parse_scope(recon_text: str | None) -> str:
337
+ """'out' when recon explicitly declared the repo out of scope, else 'in'.
338
+
339
+ Defaults to 'in' on any ambiguity or recon failure so we never REGRESS to the
340
+ old silent-skip behaviour — a wrongly-included repo is still caught later by
341
+ the 'in-scope but 0 changes' compliance check.
342
+ """
343
+ if not recon_text:
344
+ return "in"
345
+ low = recon_text.lower()
346
+ verdicts = re.findall(r"scope\s*[:=]\s*(in|out)\b", low)
347
+ if verdicts:
348
+ return "out" if verdicts[-1] == "out" else "in"
349
+ if "out-of-scope" in low or "out of scope" in low:
350
+ return "out"
351
+ return "in"
352
+
353
+
242
354
  async def _run_local_pipeline(
243
355
  client: BackendClient, payload: dict, send: Callable
244
356
  ) -> None:
@@ -448,7 +560,10 @@ async def _code_one_repo(
448
560
  name = Path(repo_path).name
449
561
 
450
562
  # 0. Make sure the repo is present (clone from repo_url only if missing/empty).
451
- await git_ops.ensure_repo(repo_path, repo_url, gh_token)
563
+ # `is_git` tells coding targets (real git repos → recon + branch + PR) apart
564
+ # from plain working folders (documentation / BA deliverables → no recon-
565
+ # scope gate, no branch/PR), so non-coding tasks aren't wrongly skipped.
566
+ is_git = await git_ops.ensure_repo(repo_path, repo_url, gh_token)
452
567
 
453
568
  _write_count = 0
454
569
 
@@ -490,13 +605,80 @@ async def _code_one_repo(
490
605
  except Exception: # noqa: BLE001
491
606
  pass
492
607
 
493
- # 2. feature branch off the resolved base (explicit base from instructions,
494
- # else the repo's current checked-out branch see git_ops.start_branch).
608
+ # 2. RECON (read-only): before touching anything, explore THIS repo and
609
+ # decide scope + a grounded plan. Skipped on a resume (rate-limit/context
610
+ # retry) — the session already holds that context, so we go straight back
611
+ # to implementing instead of re-exploring and re-spending tokens.
612
+ # Recon is for CODE repos (there's a codebase to ground against + a scope
613
+ # decision to make). A plain working folder (documentation / analyst
614
+ # deliverables) has no codebase to explore and is never "out of scope", so
615
+ # it skips recon and goes straight to producing its deliverables.
616
+ recon_session: str | None = None
617
+ scope = "in"
618
+ recon: dict = {}
619
+ if not resume_session_id and is_git:
620
+ await send(Envelope(
621
+ type="step_update", thread_id=thread_id,
622
+ data={"status": "running",
623
+ "current_step": f"[{name}] Recon — exploring the repo to ground the plan"},
624
+ ).model_dump())
625
+ recon = await local_exec.run_claude_local(
626
+ _build_recon_prompt(
627
+ name, pending["user_input"], pending.get("structured_plan"),
628
+ pending.get("human_instructions"),
629
+ ),
630
+ repo_path, on_event=_on_event, github_token=gh_token,
631
+ allowed_tools=_RECON_ALLOWED_TOOLS, # read-only: no Write/Edit/Bash
632
+ )
633
+ recon_session = recon.get("session_id")
634
+ # A usage-limit hit during recon must be surfaced so the loop waits/retries
635
+ # rather than treating the repo as done — return it straight up.
636
+ if recon.get("limit_exceeded"):
637
+ return {"repo": repo_path, "result": recon, "pr": None,
638
+ "branch": None, "scope": "in", "compliance": {"issues": []}}
639
+ if recon.get("ok"):
640
+ scope = _parse_scope(recon.get("result"))
641
+ else:
642
+ # Recon errored (not a limit) → don't sink the ticket: fall back to the
643
+ # (ungrounded) server plan and let implementation proceed.
644
+ await send(Envelope(
645
+ type="step_update", thread_id=thread_id,
646
+ data={"status": "running",
647
+ "current_step": f"[{name}] Recon unavailable "
648
+ f"({(recon.get('error') or '')[:80]}) — proceeding on the server plan"},
649
+ ).model_dump())
650
+
651
+ if scope == "out":
652
+ # Honest "nothing here" — no branch, no PR, no empty commit. Reported as a
653
+ # deliberate no-op (NOT a silent skip, NOT a failure).
654
+ await send(Envelope(
655
+ type="step_update", thread_id=thread_id,
656
+ data={"status": "running",
657
+ "current_step": f"[{name}] Out of scope — recon found no work for this ticket here"},
658
+ ).model_dump())
659
+ return {
660
+ "repo": repo_path,
661
+ "result": {
662
+ "ok": True,
663
+ "result": f"OUT_OF_SCOPE — recon found no work for this ticket in {name}.",
664
+ "files_changed": [],
665
+ "input_tokens": recon.get("input_tokens", 0),
666
+ "output_tokens": recon.get("output_tokens", 0),
667
+ "total_tokens": recon.get("total_tokens", 0),
668
+ "cost_usd": recon.get("cost_usd", 0.0),
669
+ "session_id": recon_session,
670
+ },
671
+ "pr": None, "branch": None, "scope": "out", "compliance": {"issues": []},
672
+ }
673
+
674
+ # 3. feature branch off the resolved base — named per the human/convention
675
+ # directive when given (e.g. exactly "CD-72"), else feature/<key>-<slug>.
676
+ branch_name = _resolve_branch_name(pending.get("human_instructions"), pending.get("jira_key"))
495
677
  branch_info = None
496
678
  try:
497
679
  branch_info = await git_ops.start_branch(
498
680
  repo_path, pending.get("jira_key"), pending.get("summary"), gh_token,
499
- base=base,
681
+ base=base, branch_name=branch_name,
500
682
  )
501
683
  if branch_info:
502
684
  await send(Envelope(
@@ -514,18 +696,22 @@ async def _code_one_repo(
514
696
 
515
697
  # Build the prompt with framing that matches the target: a git repo we manage
516
698
  # (branch + PR) vs a plain local folder (BA/analyst deliverables, no git/PR).
699
+ # When recon ran, the implementation resumes that session so its grounded plan
700
+ # stays in context; the prompt tells the agent to implement THAT plan.
517
701
  prompt = _build_local_prompt(
518
702
  pending["user_input"], pending.get("structured_plan"),
519
703
  pending.get("human_instructions"),
520
704
  agent_mode=agent_mode, git_managed=bool(branch_info),
705
+ repo_name=name, grounded=bool(recon_session), branch_convention=branch_name,
521
706
  )
522
707
 
523
- # 3. initial claude run (resume_session_id is set on rate-limit retries so
524
- # the model picks up exactly where it was interrupted, skipping re-work).
708
+ # 3b. initial claude run. resume_session_id (rate-limit/context retry) wins;
709
+ # otherwise resume the recon session so implementation builds on the recon
710
+ # exploration instead of starting cold.
525
711
  result = await local_exec.run_claude_local(
526
712
  prompt, repo_path, on_event=_on_event,
527
713
  github_token=gh_token, approval_dir=approval_dir,
528
- resume_session_id=resume_session_id or None,
714
+ resume_session_id=resume_session_id or recon_session or None,
529
715
  )
530
716
 
531
717
  # 4. context-window recovery
@@ -645,6 +831,71 @@ async def _code_one_repo(
645
831
  )
646
832
  session_id = result.get("session_id") or session_id
647
833
 
834
+ # 5b. SELF-REPAIR loop (diagnose → resume → retry). The recovery loops above
835
+ # handle the KNOWN, self-describing failures (context overflow, output
836
+ # cap, usage limit). This loop handles everything else — a build that
837
+ # won't compile, a failing test, a tool error, or a run that finished OK
838
+ # but produced NOTHING. Instead of dropping the ticket to Backlog, we
839
+ # resume the SAME session and ask the agent to first DIAGNOSE the root
840
+ # cause, then fix and finish. Bounded by _MAX_REPAIR_ATTEMPTS so it can
841
+ # never loop forever / burn tokens indefinitely; limit/context/cap errors
842
+ # are excluded so their dedicated handlers own them.
843
+ repair = 0
844
+ while session_id and repair < _MAX_REPAIR_ATTEMPTS \
845
+ and not result.get("limit_exceeded") \
846
+ and not result.get("context_exceeded") \
847
+ and not result.get("output_cap_exceeded"):
848
+ errored = not result.get("ok")
849
+ # "did real work happen?" — actual Write/Edit count, not files merely read.
850
+ empty = result.get("ok") and _write_count == 0
851
+ if not (errored or empty):
852
+ break
853
+ repair += 1
854
+ if errored:
855
+ await send(Envelope(
856
+ type="step_update", thread_id=thread_id,
857
+ data={"status": "running",
858
+ "current_step": f"[{name}] Error — diagnosing & repairing "
859
+ f"(attempt {repair}/{_MAX_REPAIR_ATTEMPTS})"},
860
+ ).model_dump())
861
+ try:
862
+ await client.log_activity(
863
+ f"Task {task_id} [{name}]: run errored — auto-diagnosing "
864
+ f"(attempt {repair}): {(result.get('error') or '')[:160]}"
865
+ )
866
+ except Exception: # noqa: BLE001
867
+ pass
868
+ repair_prompt = (
869
+ "Your previous attempt ended with an ERROR:\n\n"
870
+ f"{(result.get('error') or result.get('result') or 'unknown error')[:1500]}\n\n"
871
+ "Do NOT start over. First DIAGNOSE the root cause: re-read the relevant "
872
+ "files, re-run the failing command to see its ACTUAL output, and check "
873
+ "any logs. State the cause in one line, then FIX it and continue until "
874
+ "the task is complete and verified. If the same command keeps failing, "
875
+ "try a different approach rather than repeating it."
876
+ )
877
+ else: # empty — ran clean but wrote nothing
878
+ await send(Envelope(
879
+ type="step_update", thread_id=thread_id,
880
+ data={"status": "running",
881
+ "current_step": f"[{name}] Finished with no changes — re-checking scope "
882
+ f"(attempt {repair}/{_MAX_REPAIR_ATTEMPTS})"},
883
+ ).model_dump())
884
+ repair_prompt = (
885
+ "You finished WITHOUT creating or editing any files, but this task is "
886
+ "expected to produce concrete changes/deliverables. Re-read the operator "
887
+ "instructions and the plan you produced, and implement the actual work now "
888
+ "(edit or create the real files). If — and only if — you are certain "
889
+ "nothing belongs in this location, reply with a single line explaining "
890
+ "precisely why, and make no changes."
891
+ )
892
+ result = await local_exec.run_claude_local(
893
+ repair_prompt, repo_path, on_event=_on_event,
894
+ github_token=gh_token, resume_session_id=session_id,
895
+ approval_dir=approval_dir,
896
+ )
897
+ session_id = result.get("session_id") or session_id
898
+
648
899
  # 6. commit + push + PR for THIS repo
649
900
  pr = None
650
901
  if branch_info and result.get("ok"):
@@ -671,8 +922,39 @@ async def _code_one_repo(
671
922
  except Exception as exc: # noqa: BLE001
672
923
  logger.warning("local_run.finish_pr_failed", error=str(exc))
673
924
 
925
+ # 7. Compliance verification (enforce + verify). An in-scope repo that ran OK
926
+ # but changed nothing, or that failed to push / open a PR, is NOT "done" —
927
+ # these become issues the caller turns into a loud failure, never a silent
928
+ # success. (Branch NAME is compliant by construction: the SDK created it
929
+ # from the resolved convention above.)
930
+ issues: list[str] = []
931
+ if result.get("ok"):
932
+ if branch_info:
933
+ # CODE repo: `committed` from finish_pr is authoritative (true only when
934
+ # a real commit was created); files_changed is a weak fallback.
935
+ made_output = pr.get("committed") if pr else bool(result.get("files_changed"))
936
+ if not made_output:
937
+ issues.append("in scope but produced 0 committed changes")
938
+ elif not pr.get("pushed"):
939
+ issues.append("branch was not pushed")
940
+ elif not pr.get("pr_url"):
941
+ issues.append(f"no PR opened ({(pr or {}).get('pr_error') or 'unknown'})")
942
+ else:
943
+ # Plain working folder (documentation / analyst deliverables, non-git):
944
+ # no branch/PR to verify, but it must still have PRODUCED something.
945
+ if _write_count == 0:
946
+ issues.append("task produced no files/deliverables")
947
+ if issues:
948
+ await send(Envelope(
949
+ type="step_update", thread_id=thread_id,
950
+ data={"status": "running",
951
+ "current_step": f"[{name}] Compliance issue(s): " + "; ".join(issues)},
952
+ ).model_dump())
953
+
674
954
  return {"repo": repo_path, "result": result, "pr": pr,
675
- "branch": (branch_info or {}).get("branch")}
955
+ "branch": (branch_info or {}).get("branch"), "scope": scope,
956
+ "compliance": {"issues": issues, "expected_branch": branch_name,
957
+ "branch": (branch_info or {}).get("branch")}}
676
958
 
677
959
 
678
960
  async def handle_pipeline_approve(
@@ -776,7 +1058,17 @@ async def handle_pipeline_approve(
776
1058
  _shutil.rmtree(approval_dir, ignore_errors=True)
777
1059
 
778
1060
  # ── Aggregate the per-repo runs for the server finalize + done payload ──
779
- ok_any = any(r["result"].get("ok") for r in runs)
1061
+ # Scope-aware: OUT-OF-SCOPE repos (recon found no work there) are deliberate
1062
+ # no-ops, not successes to brag about nor failures to punish. Only IN-SCOPE
1063
+ # repos decide whether the ticket is actually done, and each must be both
1064
+ # ok AND compliant (pushed, PR opened, non-empty) — enforce + verify.
1065
+ scoped = [r for r in runs if r.get("scope", "in") != "out"]
1066
+ out_of_scope = [r for r in runs if r.get("scope") == "out"]
1067
+ noncompliant = [
1068
+ r for r in scoped
1069
+ if (not r["result"].get("ok")) or r.get("compliance", {}).get("issues")
1070
+ ]
1071
+ ok_any = any(r["result"].get("ok") for r in scoped)
780
1072
  parts: list[str] = []
781
1073
  pr_urls: list[str] = []
782
1074
  files_changed: list[str] = []
@@ -828,24 +1120,48 @@ async def handle_pipeline_approve(
828
1120
  "repo_resume_sessions": repo_resume_sessions,
829
1121
  }
830
1122
 
831
- # ── Notify on WhatsApp if nothing succeeded ──────────────────────────
832
- if not ok_any:
833
- err = "; ".join(str(r["result"].get("error") or "unknown") for r in runs) or "no repos processed"
1123
+ # ── Fail loud unless EVERY in-scope repo succeeded AND is compliant ──────
1124
+ # This is the enforce-side of the fix: a repo that ran but changed nothing,
1125
+ # didn't push, or skipped its PR no longer slips through as "done"; and a
1126
+ # ticket whose named repos ALL came back out of scope is escalated for human
1127
+ # review rather than silently closed.
1128
+ if not scoped or noncompliant:
1129
+ if not scoped:
1130
+ named = ", ".join(Path(r["repo"]).name for r in runs) or "the named repo(s)"
1131
+ err = (f"recon found no in-scope work in {named} — nothing was implemented; "
1132
+ "needs human review (wrong repo/path, or the work belongs elsewhere)")
1133
+ else:
1134
+ def _why(r: dict) -> str:
1135
+ res = r["result"]
1136
+ reason = (res.get("error")
1137
+ or "; ".join(r.get("compliance", {}).get("issues", []))
1138
+ or "failed")
1139
+ return f"{Path(r['repo']).name}: {reason}"
1140
+ err = "incomplete / non-compliant — " + " | ".join(_why(r) for r in noncompliant)
834
1141
  note = (f"⚠ Task {task_id}: context window exceeded — split the ticket or narrow scope."
835
1142
  if _is_context_window_error(err)
836
- else f"⚠ Task {task_id}: local execution failed — {err[:200]}")
1143
+ else f"⚠ Task {task_id}: {err[:220]}")
837
1144
  try:
838
1145
  await client.notify_whatsapp(task_id, note, summary="AgentOS local run alert")
839
1146
  except Exception: # noqa: BLE001
840
1147
  pass
841
- await client.log_activity(f"Task {task_id}: local execution failed — {err[:300]}")
1148
+ await client.log_activity(f"Task {task_id}: {err[:300]}")
842
1149
  await client.move_to_backlog(task_id)
843
1150
  await send(Envelope(
844
1151
  type=Envelope.TYPE_ERROR, thread_id=thread_id,
845
- data={"code": "LOCAL_EXEC_FAILED", "message": err[:300], "task_id": task_id},
1152
+ data={"code": "LOCAL_EXEC_INCOMPLETE", "message": err[:300], "task_id": task_id},
846
1153
  ).model_dump())
847
1154
  return
848
1155
 
1156
+ # Surface deliberate no-ops so a skipped repo is transparent, not invisible.
1157
+ if out_of_scope:
1158
+ skipped = ", ".join(Path(r["repo"]).name for r in out_of_scope)
1159
+ await send(Envelope(
1160
+ type="step_update", thread_id=thread_id,
1161
+ data={"status": "running",
1162
+ "current_step": f"Out of scope (no changes): {skipped}"},
1163
+ ).model_dump())
1164
+
849
1165
  # ── Finalize on the server once per task (validation + Jira/cost) ─────
850
1166
  await send(Envelope(
851
1167
  type="step_update", thread_id=thread_id,
@@ -1499,6 +1815,84 @@ def _resolve_base_for(name: str, url: str | None,
1499
1815
  return global_base
1500
1816
 
1501
1817
 
1818
+ # Explicit branch-NAME directives (distinct from the base branch you branch
1819
+ # FROM). The SDK — not the agent — owns branching for the managed repo, so a
1820
+ # convention like "name the branch exactly the ticket id" MUST be applied here.
1821
+ _BRANCH_TICKET_RE = _re.compile(
1822
+ r"branch\b[^.\n]{0,60}?\b(?:jira[\s\-]?)?(?:ticket|jira|story|issue)"
1823
+ r"[\s\-]?(?:id|key|number|name|no\.?)\b",
1824
+ _re.I,
1825
+ )
1826
+ _BRANCH_LITERAL_RES = [
1827
+ _re.compile(
1828
+ r"(?:name|call|create|use)\s+(?:the\s+)?branch\s+"
1829
+ r"(?:named\s+|called\s+|exactly\s+|as\s+)?[\"'`]?([A-Za-z0-9][\w.\-/]*)[\"'`]?",
1830
+ _re.I,
1831
+ ),
1832
+ _re.compile(
1833
+ r"branch\s*(?:name)?\s*(?:should\s+be|must\s+be|has\s+to\s+be|is|=|:)\s*"
1834
+ r"[\"'`]?([A-Za-z0-9][\w.\-/]*)[\"'`]?",
1835
+ _re.I,
1836
+ ),
1837
+ ]
1838
+ # Never a real branch name — filters false positives from the loose patterns.
1839
+ _BRANCH_NAME_NOISE = {
1840
+ "the", "a", "an", "exactly", "named", "called", "from", "off", "of",
1841
+ "your", "this", "that", "new", "same", "current", "default", "please",
1842
+ "name", "id", "key", "number", "ticket", "jira", "feature", "should", "must",
1843
+ }
1844
+
1845
+
1846
+ def _extract_branch_name(text: str | None, jira_key: str | None) -> str | None:
1847
+ """An explicit branch NAME directive from humanInstructions, or None.
1848
+
1849
+ Two shapes are recognised:
1850
+ - a convention referring to the ticket id ("name the branch exactly the
1851
+ JIRA ticket id") → resolves to ``jira_key``.
1852
+ - a literal name ("branch: CD-72", "name the branch release-1").
1853
+ Base-branch directives ("branch from main") are deliberately NOT matched —
1854
+ _extract_base_branches owns those.
1855
+ """
1856
+ if not text:
1857
+ return None
1858
+ if jira_key and _BRANCH_TICKET_RE.search(text):
1859
+ return jira_key
1860
+ for rx in _BRANCH_LITERAL_RES:
1861
+ for m in rx.finditer(text):
1862
+ cand = (m.group(1) or "").strip().strip("./")
1863
+ low = cand.lower()
1864
+ if not cand or low in _BRANCH_NAME_NOISE:
1865
+ continue
1866
+ # "branch: main" alongside "branch from main" is a base, not a name.
1867
+ if low in ("main", "master", "develop", "dev") and "branch from" in text.lower():
1868
+ continue
1869
+ return cand
1870
+ return None
1871
+
1872
+
1873
+ def _resolve_branch_name(text: str | None, jira_key: str | None) -> str | None:
1874
+ """Final branch name: per-ticket directive → configured template → None.
1875
+
1876
+ None means "use the SDK default" (feature/<key>-<slug>). A per-ticket
1877
+ directive always beats the org-wide ``branch_name_template`` in config.
1878
+ """
1879
+ explicit = _extract_branch_name(text, jira_key)
1880
+ if explicit:
1881
+ return explicit
1882
+ try:
1883
+ from sdk import config
1884
+ tmpl = config.get_branch_name_template()
1885
+ except Exception: # noqa: BLE001
1886
+ tmpl = None
1887
+ if tmpl:
1888
+ try:
1889
+ resolved = tmpl.format(jira_key=jira_key or "", key=jira_key or "").strip()
1890
+ return resolved or None
1891
+ except Exception: # noqa: BLE001
1892
+ return tmpl
1893
+ return None
1894
+
1895
+
1502
1896
  def _extract_repo_targets(text: str | None) -> list[dict]:
1503
1897
  """All repo work-targets named in `text`: ``[{"path", "url", "base"}]``.
1504
1898
 
@@ -1607,6 +2001,10 @@ _MAX_CONTEXT_RESUMES = 3
1607
2001
  _MAX_OUTPUT_CAP_RESUMES = 3
1608
2002
  # Max mid-run crucial-decision approval gates per ticket (safety bound).
1609
2003
  _MAX_CRUCIAL_GATES = 10
2004
+ # Max diagnose-and-retry (self-repair) passes per repo for a generic error or an
2005
+ # empty (ran-clean-but-changed-nothing) result — distinct from context/cap/limit,
2006
+ # which have their own handlers. Bounded so a stuck task can't burn tokens forever.
2007
+ _MAX_REPAIR_ATTEMPTS = int(os.getenv("AGENTOS_MAX_REPAIR_ATTEMPTS", "") or 2)
1610
2008
  # Max times a ticket is retried after hitting the Claude usage limit.
1611
2009
  _MAX_LIMIT_RETRIES = 3
1612
2010
  # Cap on how long we'll sleep waiting for a usage-limit window to reset.