sbxloop 0.7.2__tar.gz → 0.7.3__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 (66) hide show
  1. {sbxloop-0.7.2 → sbxloop-0.7.3}/PKG-INFO +1 -1
  2. sbxloop-0.7.2/src/sbxloop/_vendor/sbxloop_worker-0.7.2-py3-none-any.whl → sbxloop-0.7.3/src/sbxloop/_vendor/sbxloop_worker-0.7.3-py3-none-any.whl +0 -0
  3. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/_version.py +2 -2
  4. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/cli/app.py +18 -0
  5. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/config.py +29 -0
  6. sbxloop-0.7.3/src/sbxloop/daemon/audits.py +131 -0
  7. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/daemon/backlog.py +79 -0
  8. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/daemon/discord_format.py +4 -1
  9. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/daemon/loop.py +147 -8
  10. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/daemon/model.py +11 -0
  11. sbxloop-0.7.3/src/sbxloop/daemon/postmortem.py +157 -0
  12. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/daemon/sources.py +150 -32
  13. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/daemon/store.py +114 -2
  14. {sbxloop-0.7.2 → sbxloop-0.7.3}/.gitignore +0 -0
  15. {sbxloop-0.7.2 → sbxloop-0.7.3}/README.md +0 -0
  16. {sbxloop-0.7.2 → sbxloop-0.7.3}/hatch_build.py +0 -0
  17. {sbxloop-0.7.2 → sbxloop-0.7.3}/pyproject.toml +0 -0
  18. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/__init__.py +0 -0
  19. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/cli/__init__.py +0 -0
  20. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/cli/doctor.py +0 -0
  21. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/cli/models.py +0 -0
  22. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/cli/tui.py +0 -0
  23. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/daemon/__init__.py +0 -0
  24. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/daemon/control.py +0 -0
  25. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/daemon/discord.py +0 -0
  26. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/daemon/github.py +0 -0
  27. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/daemon/paths.py +0 -0
  28. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/deliver.py +0 -0
  29. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/engine/__init__.py +0 -0
  30. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/engine/engine.py +0 -0
  31. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/engine/model.py +0 -0
  32. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/engine/phases.py +0 -0
  33. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/engine/prompts/__init__.py +0 -0
  34. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/engine/prompts/decompose.md +0 -0
  35. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/engine/prompts/execute.md +0 -0
  36. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/engine/prompts/plan.md +0 -0
  37. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/engine/prompts/scrutinize.md +0 -0
  38. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/engine/prompts/steer.md +0 -0
  39. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/engine/prompts/validate.md +0 -0
  40. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/engine/store.py +0 -0
  41. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/errors.py +0 -0
  42. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/events.py +0 -0
  43. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/gc.py +0 -0
  44. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/gh/__init__.py +0 -0
  45. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/gh/ops.py +0 -0
  46. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/gh/reporter.py +0 -0
  47. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/hostgit.py +0 -0
  48. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/ids.py +0 -0
  49. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/policy.py +0 -0
  50. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/py.typed +0 -0
  51. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/sbx/__init__.py +0 -0
  52. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/sbx/bake.py +0 -0
  53. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/sbx/cli.py +0 -0
  54. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/sbx/conformance.py +0 -0
  55. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/sbx/models.py +0 -0
  56. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/sbx/pair.py +0 -0
  57. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/sbx/parse.py +0 -0
  58. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/sbx/provision.py +0 -0
  59. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/sbx/prune.py +0 -0
  60. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/sbx/sandbox.py +0 -0
  61. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/sbx/secretstate.py +0 -0
  62. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/toolchains.py +0 -0
  63. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/verifylint.py +0 -0
  64. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/worker/__init__.py +0 -0
  65. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/worker/client.py +0 -0
  66. {sbxloop-0.7.2 → sbxloop-0.7.3}/src/sbxloop/worker/wheel.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sbxloop
3
- Version: 0.7.2
3
+ Version: 0.7.3
4
4
  Summary: Agentic loop orchestration on Docker Sandboxes (sbx) with isolated credential domains
5
5
  Project-URL: Homepage, https://github.com/brettbergin/sbxloop
6
6
  Project-URL: Repository, https://github.com/brettbergin/sbxloop
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.7.2'
22
- __version_tuple__ = version_tuple = (0, 7, 2)
21
+ __version__ = version = '0.7.3'
22
+ __version_tuple__ = version_tuple = (0, 7, 3)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -1477,6 +1477,14 @@ def daemon(
1477
1477
  if config.daemon.backlog == "github" and not github_active:
1478
1478
  console.print("[bold red]--backlog github needs a GitHub repository[/] (--repo).")
1479
1479
  raise typer.Exit(2)
1480
+ if github_active and config.daemon.backlog != "github":
1481
+ # The audit lane is always on when issues are a source; its
1482
+ # findings only have somewhere to go with backlog = "github".
1483
+ console.print(
1484
+ f"[yellow]note:[/] audit issues (`{config.daemon.audit_label}`) file their "
1485
+ "findings as backlog issues, which needs [cyan]--backlog github[/]; with "
1486
+ f"backlog={config.daemon.backlog!r} an audit run completes but files nothing."
1487
+ )
1480
1488
 
1481
1489
  # Say where the state went: with the anchored default, `sbxloop status`
1482
1490
  # in the runner dir shows nothing unless SBXLOOP_STATE_DIR points here.
@@ -1500,6 +1508,7 @@ def daemon(
1500
1508
  config.daemon.failed_label,
1501
1509
  config.daemon.backlog_label,
1502
1510
  config.daemon.delivered_label,
1511
+ config.daemon.audit_label,
1503
1512
  )
1504
1513
  sources.append(
1505
1514
  GitHubIssueSource(
@@ -1508,6 +1517,7 @@ def daemon(
1508
1517
  labels,
1509
1518
  on_failure=github.note_failure,
1510
1519
  close_on_success=config.daemon.close_on_success,
1520
+ tool_repo=config.daemon.tool_repo,
1511
1521
  )
1512
1522
  )
1513
1523
 
@@ -1998,6 +2008,14 @@ mem_abort = 0.0
1998
2008
  # failed_label = "sbxloop:failed"
1999
2009
  # backlog_label = "sbxloop:backlog"
2000
2010
  # delivered_label = "sbxloop:delivered"
2011
+ # audit_label = "sbxloop:audit" # discovery lane: investigate, file findings, no PR
2012
+ # postmortems = true # file a post-mortem audit when a patch item fails
2013
+ # postmortems_per_day = 3
2014
+ # audits = false # open .github/sbxloop/audits/*.md charters on schedule
2015
+ # review_deliveries = true # audit each PR the loop delivers (defects → backlog)
2016
+ # reviews_per_day = 5
2017
+ # tool_repo = "brettbergin/sbxloop" # findings ABOUT sbxloop go here, never to the project
2018
+ # audit_dir = ".github/sbxloop/audits"
2001
2019
  # What a delivered run does to the tracker. Defaults are task-queue
2002
2020
  # semantics: close the source issue (the PR is the reviewable object) and
2003
2021
  # open a per-run tracking issue. For a design/discussion tracker set both
@@ -361,6 +361,30 @@ class DaemonConfig(_ConfigModel):
361
361
  failed_label: str = "sbxloop:failed"
362
362
  backlog_label: str = "sbxloop:backlog"
363
363
  delivered_label: str = "sbxloop:delivered"
364
+ # Discovery lane: an issue carrying this label is a charter — the run
365
+ # investigates and files findings as backlog issues, never a PR.
366
+ audit_label: str = "sbxloop:audit"
367
+ # When a patch item is abandoned (or delivers nothing), file a post-mortem
368
+ # charter carrying the plan, verify transcripts and failure events, so the
369
+ # discovery lane turns the daemon's own failures into evidenced findings.
370
+ # Never for audit items (no recursion); capped per rolling day.
371
+ postmortems: bool = True
372
+ postmortems_per_day: int = 3
373
+ # Scheduled area audits: charters versioned in the target repo under
374
+ # ``audit_dir`` (front-matter `every: 7d`), opened as audit issues when
375
+ # due. Off by default — a repo opts in by carrying charters AND the
376
+ # operator flipping this on.
377
+ audits: bool = False
378
+ audit_dir: Path = Path(".github/sbxloop/audits")
379
+ # After a run delivers a PR, open a review audit of that PR — the loop
380
+ # evaluating the code it just wrote (defects, missing edge cases, scope
381
+ # drift) and filing findings for a human to promote.
382
+ review_deliveries: bool = True
383
+ reviews_per_day: int = 5
384
+ # Where findings ABOUT THE TOOL (sbxloop's planner, prompts, lint,
385
+ # delivery) go — the tool's own tracker, never the project's. Unset:
386
+ # such findings are only noted in the closing comment.
387
+ tool_repo: str | None = None
364
388
  close_on_success: bool = True
365
389
  tracking_issue: bool = True
366
390
  max_runs_per_day: int = 12
@@ -394,6 +418,7 @@ class DaemonConfig(_ConfigModel):
394
418
  self.failed_label,
395
419
  self.backlog_label,
396
420
  self.delivered_label,
421
+ self.audit_label,
397
422
  ]
398
423
  if any(not label.strip() for label in labels):
399
424
  raise ValueError("daemon labels must be non-empty")
@@ -406,9 +431,13 @@ class DaemonConfig(_ConfigModel):
406
431
  "max_attempts_per_item",
407
432
  "max_consecutive_failures",
408
433
  "backlog_max_per_run",
434
+ "postmortems_per_day",
435
+ "reviews_per_day",
409
436
  ):
410
437
  if getattr(self, name) < 1:
411
438
  raise ValueError(f"daemon.{name} must be >= 1")
439
+ if self.tool_repo is not None and not re.fullmatch(r"[\w.-]+/[\w.-]+", self.tool_repo):
440
+ raise ValueError(f"daemon.tool_repo must be owner/name, got {self.tool_repo!r}")
412
441
  return self
413
442
 
414
443
 
@@ -0,0 +1,131 @@
1
+ """Scheduled area audits: charters versioned in the target repository.
2
+
3
+ The discovery lane needs a steady supply of *good* charters, and Brett
4
+ wants every moving part visible on GitHub. So the charters live in the
5
+ repo itself — ``.github/sbxloop/audits/<name>.md`` with a small front-matter block —
6
+ where they are reviewed like code, and the daemon opens each one as a
7
+ ``sbxloop:audit`` issue when it is due. What is due is decided from the
8
+ issues that already exist (title search on GitHub, plus the store as a
9
+ cache), so a wiped state dir cannot double-file and an audit that is still
10
+ open is never re-opened on top of itself.
11
+
12
+ Pure parsing/scheduling here; the loop does the filing.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ AUDIT_DIR = Path(".github") / "sbxloop" / "audits"
22
+ AUDIT_TITLE_PREFIX = "audit: "
23
+ _FRONT_MATTER = re.compile(r"\A---\s*\n(.*?)\n---\s*\n?", re.S)
24
+ _EVERY = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([dhm])\s*$", re.I)
25
+ _UNIT_S = {"d": 86_400.0, "h": 3_600.0, "m": 60.0}
26
+ _NAME_OK = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Charter:
31
+ name: str
32
+ every_s: float
33
+ enabled: bool
34
+ body: str
35
+ rel: str # path relative to the checkout, for the issue's provenance line
36
+
37
+ @property
38
+ def title(self) -> str:
39
+ return f"{AUDIT_TITLE_PREFIX}{self.name}"
40
+
41
+
42
+ def parse_every(text: str) -> float:
43
+ """``7d`` / ``12h`` / ``30m`` → seconds. ``0d`` means every tick (tests,
44
+ or "run once now")."""
45
+ m = _EVERY.match(text)
46
+ if not m:
47
+ raise ValueError(f"unrecognised interval {text!r} (use e.g. 7d, 12h, 30m)")
48
+ return float(m.group(1)) * _UNIT_S[m.group(2).lower()]
49
+
50
+
51
+ def parse_charter(path: Path, rel: str | None = None) -> Charter:
52
+ """One charter file. Front-matter keys: ``every`` (required), ``enabled``
53
+ (default true). The name is the file stem, which also keys the schedule
54
+ and the issue title — keep it stable."""
55
+ name = path.stem
56
+ if not _NAME_OK.match(name):
57
+ raise ValueError(f"charter file name {name!r} must be lowercase [a-z0-9._-]")
58
+ text = path.read_text(encoding="utf-8")
59
+ m = _FRONT_MATTER.match(text)
60
+ if not m:
61
+ raise ValueError(f"{path}: missing front-matter (--- every: 7d ---)")
62
+ meta: dict[str, str] = {}
63
+ for line in m.group(1).splitlines():
64
+ if ":" in line:
65
+ key, _, value = line.partition(":")
66
+ meta[key.strip().lower()] = value.strip().strip("'\"")
67
+ if "every" not in meta:
68
+ raise ValueError(f"{path}: front-matter needs `every: <interval>`")
69
+ body = text[m.end() :].strip()
70
+ if not body:
71
+ raise ValueError(f"{path}: charter body is empty")
72
+ enabled = meta.get("enabled", "true").lower() not in ("false", "no", "0", "off")
73
+ return Charter(
74
+ name=name,
75
+ every_s=parse_every(meta["every"]),
76
+ enabled=enabled,
77
+ body=body,
78
+ rel=rel or path.name,
79
+ )
80
+
81
+
82
+ def load_charters(checkout: Path, audit_dir: Path = AUDIT_DIR) -> tuple[list[Charter], list[str]]:
83
+ """(charters, problems) under ``checkout/audit_dir``; a bad file is a
84
+ problem string, never a crash — one broken charter must not stop the
85
+ others."""
86
+ folder = checkout / audit_dir
87
+ if not folder.is_dir():
88
+ return [], []
89
+ charters: list[Charter] = []
90
+ problems: list[str] = []
91
+ for path in sorted(folder.glob("*.md")):
92
+ try:
93
+ charters.append(parse_charter(path, path.relative_to(checkout).as_posix()))
94
+ except (ValueError, OSError) as exc:
95
+ problems.append(str(exc))
96
+ return charters, problems
97
+
98
+
99
+ def due_charters(
100
+ charters: list[Charter], last_filed: dict[str, float], now: float
101
+ ) -> list[Charter]:
102
+ """Enabled charters whose interval has elapsed since they were last
103
+ filed (never filed → due)."""
104
+ out = []
105
+ for c in charters:
106
+ if not c.enabled:
107
+ continue
108
+ last = last_filed.get(c.name)
109
+ if last is None or now - last >= c.every_s:
110
+ out.append(c)
111
+ return out
112
+
113
+
114
+ def issue_body(charter: Charter, marker: str) -> str:
115
+ return (
116
+ f"{charter.body}\n\n---\n"
117
+ f"Scheduled audit `{charter.name}` (every {describe_every(charter.every_s)}); "
118
+ f"charter: `{charter.rel}`.\n"
119
+ f"{marker}"
120
+ )
121
+
122
+
123
+ def audit_marker(name: str) -> str:
124
+ return f"<!-- sbxloop-audit {name} -->"
125
+
126
+
127
+ def describe_every(seconds: float) -> str:
128
+ for unit, size in (("d", 86_400.0), ("h", 3_600.0), ("m", 60.0)):
129
+ if seconds >= size and seconds % size == 0:
130
+ return f"{int(seconds // size)}{unit}"
131
+ return f"{seconds:g}s"
@@ -20,6 +20,7 @@ from __future__ import annotations
20
20
  import hashlib
21
21
  import logging
22
22
  from pathlib import Path
23
+ from typing import NamedTuple
23
24
 
24
25
  from sbxloop.daemon.sources import WorkSource, parse_markdown_item
25
26
  from sbxloop.daemon.store import DaemonStore
@@ -29,6 +30,84 @@ logger = logging.getLogger(__name__)
29
30
 
30
31
  BACKLOG_SUBDIR = Path(".sbxloop") / "backlog"
31
32
 
33
+ # The audit lane's contract (discovery on GitHub): the deliverable is issues.
34
+ AUDIT_INSTRUCTIONS = (
35
+ "This is an AUDIT: your deliverable is a set of well-evidenced findings, not "
36
+ "code. Do not modify the tree except to write findings under "
37
+ "`.sbxloop/backlog/` in the workspace — one markdown file per finding, first "
38
+ "line `# <short, specific title>`, then these sections: **Evidence** (file:line "
39
+ "references and quoted lines that show the problem), **Repro / how to observe** "
40
+ "(a command, a test, or the exact scenario), **Proposal** (a one-line remedy), "
41
+ "**Size** (small | medium | large), **Kind** (bug | test | docs | refactor). "
42
+ "No finding without evidence. At most 5 findings — pick the ones that matter. "
43
+ "If the charter turns up nothing real, write no files and say so in your "
44
+ "summary: an empty result is a valid, honest outcome. Each file becomes a "
45
+ "GitHub issue for a human to triage. ROUTING: findings about THIS PROJECT's "
46
+ "code go directly under `.sbxloop/backlog/`. Findings about sbxloop itself — "
47
+ "the tool that ran you: its planner, prompts, verify lint, delivery, daemon — "
48
+ "go under `.sbxloop/backlog/tool/` instead; they are filed to the tool's own "
49
+ "tracker when the operator configured one, otherwise only noted in the closing "
50
+ "comment, never filed as issues of this project."
51
+ )
52
+
53
+ TOOL_SUBDIR = BACKLOG_SUBDIR / "tool"
54
+
55
+
56
+ class ToolFindings(NamedTuple):
57
+ filed: list[str] # refs filed upstream (tool_repo configured)
58
+ unfiled: list[str] # titles noted only (no tool_repo)
59
+
60
+
61
+ def collect_tool_findings(
62
+ run: RunRecord,
63
+ *,
64
+ dstore: DaemonStore,
65
+ source: WorkSource,
66
+ max_items: int,
67
+ now: float,
68
+ ) -> ToolFindings:
69
+ """Findings the run addressed to the TOOL (``.sbxloop/backlog/tool/``):
70
+ filed to the tool's tracker via ``source.file_tool_backlog`` when the
71
+ operator configured ``[daemon] tool_repo``, otherwise returned as
72
+ titles for the closing comment — never as issues of the project."""
73
+ if not run.mounted or run.workspace is None:
74
+ return ToolFindings([], [])
75
+ folder = run.workspace / TOOL_SUBDIR
76
+ if not folder.is_dir():
77
+ return ToolFindings([], [])
78
+ filer = getattr(source, "file_tool_backlog", None)
79
+ filed: list[str] = []
80
+ unfiled: list[str] = []
81
+ for path in sorted(folder.glob("*.md")):
82
+ try:
83
+ title, body = parse_markdown_item(path.read_text(), path.stem)
84
+ except OSError:
85
+ continue
86
+ if filer is None:
87
+ unfiled.append(title)
88
+ continue
89
+ fp = fingerprint("tool:" + title, body)
90
+ if dstore.backlog_seen(fp):
91
+ continue
92
+ if len(filed) >= max_items:
93
+ unfiled.append(title)
94
+ continue
95
+ try:
96
+ ref = filer(title, body, run.run_id)
97
+ except Exception:
98
+ logger.warning(
99
+ "tool finding %r from run %s could not be filed", title, run.run_id, exc_info=True
100
+ )
101
+ unfiled.append(title)
102
+ continue
103
+ if ref is None:
104
+ unfiled.append(title)
105
+ continue
106
+ dstore.backlog_record(fp, run.run_id, ref, now)
107
+ filed.append(ref)
108
+ return ToolFindings(filed, unfiled)
109
+
110
+
32
111
  BACKLOG_INSTRUCTIONS = (
33
112
  "If you identify follow-up work that is OUT OF SCOPE for this outcome, do "
34
113
  "not expand scope. Write each such item as its own markdown file under "
@@ -891,7 +891,8 @@ def format_for_discord(
891
891
  def _origin(item: WorkItem) -> tuple[str, str]:
892
892
  """(label, kind) for where a work item came from."""
893
893
  if item.source == "github":
894
- return f"issue #{item.source_key}", "github"
894
+ prefix = "audit" if item.kind == "audit" else "issue"
895
+ return f"{prefix} #{item.source_key}", "github"
895
896
  return f"inbox {code(item.source_key)}", "inbox"
896
897
 
897
898
 
@@ -1037,6 +1038,8 @@ def items_lines(items: list[WorkItem], limit: int = 20) -> str:
1037
1038
  rows = []
1038
1039
  for i in items[:limit]:
1039
1040
  row = f"{ITEM_STATE_MARKER.get(i.state, '•')} {code(i.item_id)} {i.state} · "
1041
+ if i.kind == "audit":
1042
+ row += "🔎 audit · "
1040
1043
  row += link(_one_line(i.title, 60), i.url) if i.url else _one_line(i.title, 60)
1041
1044
  row += f" · attempts {i.attempts}"
1042
1045
  if i.run_id:
@@ -26,8 +26,16 @@ from typing import Any, NamedTuple, Protocol
26
26
 
27
27
  from sbxloop import hostgit
28
28
  from sbxloop.config import Config, GithubConfig, SandboxConfig
29
- from sbxloop.daemon.backlog import BACKLOG_INSTRUCTIONS, collect_backlog
29
+ from sbxloop.daemon.audits import audit_marker, due_charters, issue_body, load_charters
30
+ from sbxloop.daemon.backlog import (
31
+ AUDIT_INSTRUCTIONS,
32
+ BACKLOG_INSTRUCTIONS,
33
+ ToolFindings,
34
+ collect_backlog,
35
+ collect_tool_findings,
36
+ )
30
37
  from sbxloop.daemon.model import RunReport, TickOutcome, TickResult, WorkItem
38
+ from sbxloop.daemon.postmortem import build_dossier
31
39
  from sbxloop.daemon.sources import WorkSource
32
40
  from sbxloop.daemon.store import DaemonStore
33
41
  from sbxloop.engine.engine import LoopEngine
@@ -118,6 +126,7 @@ class DaemonLoop:
118
126
  self._runner = runner or self._default_runner
119
127
  self._stop = threading.Event()
120
128
  self._paused = False
129
+ self._audit_problems_seen: set[str] = set()
121
130
  self._current: RunHandle | None = None
122
131
  self._current_lock = threading.Lock()
123
132
  self._cancel_request: CancelRequest | None = None
@@ -346,6 +355,7 @@ class DaemonLoop:
346
355
  f"daily run cap reached ({started_today}/{cap}); idling until the window rolls"
347
356
  )
348
357
  return TickResult(idle_kind="daily_cap")
358
+ self._schedule_audits(now)
349
359
  discovered = self._discover(now)
350
360
  item = self.dstore.next_queued(now, self.config.daemon.retry_backoff_s)
351
361
  if item is None:
@@ -638,7 +648,11 @@ class DaemonLoop:
638
648
  now = self.clock()
639
649
  report = self._report(run_id, result)
640
650
  if result is not None and report.succeeded:
641
- self._collect_backlog(run_id, source)
651
+ filed = self._collect_backlog(run_id, source)
652
+ tool = self._collect_tool_findings(run_id, source)
653
+ report = report._replace(
654
+ filed=tuple(filed), tool_filed=tuple(tool.filed), tool_noted=tuple(tool.unfiled)
655
+ )
642
656
  self.dstore.mark_done(item.item_id, now)
643
657
  self.dstore.finish_ledger(run_id, "done", now)
644
658
  self._set_breaker(None, 0)
@@ -647,7 +661,9 @@ class DaemonLoop:
647
661
  self._notify(
648
662
  f"✅ {item.item_id} done ({report.task_summary})"
649
663
  + (f" · PR {report.delivery[1]}" if report.delivery else "")
664
+ + (f" · filed {len(report.filed)} finding(s)" if item.kind == "audit" else "")
650
665
  )
666
+ self._file_review(item, run_id, report)
651
667
  return "done"
652
668
  if result is not None and result.state == "completed" and report.delivery_error:
653
669
  # Work done, PR failed: a human must look; retrying would redo the work.
@@ -659,6 +675,7 @@ class DaemonLoop:
659
675
  source.report_delivery_failed(item, report)
660
676
  self._frontend_finished(item, report)
661
677
  self._notify(f"⚠ {item.item_id} completed but delivery failed: {report.delivery_error}")
678
+ self._file_postmortem(item, run_id, f"delivery failed: {report.delivery_error}")
662
679
  return "delivery_failed"
663
680
  reason = str(error) if error is not None else f"run ended {report.state}"
664
681
  attempts_left = self.config.daemon.max_attempts_per_item - item.attempts
@@ -673,6 +690,7 @@ class DaemonLoop:
673
690
  self.dstore.mark_failed(item.item_id, reason, now, requeue=False)
674
691
  source.report_abandoned(item, reason)
675
692
  self._notify(f"❌ {item.item_id} abandoned after {item.attempts} attempt(s): {reason}")
693
+ self._file_postmortem(item, run_id, f"abandoned: {reason}")
676
694
  outcome = "abandoned"
677
695
  self._frontend_finished(item, report)
678
696
  if self._consecutive_failures >= self.config.daemon.max_consecutive_failures:
@@ -738,7 +756,10 @@ class DaemonLoop:
738
756
  # issue already is one (#251); the summary comment there
739
757
  # carries the same information.
740
758
  "report": self.config.daemon.tracking_issue,
741
- "deliver": True,
759
+ # An audit's output is the issues it files; delivering
760
+ # its (deliberately unchanged) tree would only raise
761
+ # "nothing to deliver" and mis-settle it as failed.
762
+ "deliver": item.kind != "audit",
742
763
  "deliver_draft": self.config.daemon.deliver_draft,
743
764
  "create_repo": False,
744
765
  }
@@ -807,7 +828,9 @@ class DaemonLoop:
807
828
  else:
808
829
  origin = f"inbox file `{item.source_key}`"
809
830
  parts.append(f"---\nThis work item came from: {origin}.")
810
- if self.config.daemon.backlog != "off":
831
+ if item.kind == "audit":
832
+ parts.append(AUDIT_INSTRUCTIONS)
833
+ elif self.config.daemon.backlog != "off":
811
834
  parts.append(BACKLOG_INSTRUCTIONS)
812
835
  return "\n\n".join(parts)
813
836
 
@@ -853,14 +876,129 @@ class DaemonLoop:
853
876
  workspace = str(result.workspace) if result is not None and result.workspace else None
854
877
  return RunReport(run_id, state, summary, tracking, delivery, delivery_error, workspace)
855
878
 
856
- def _collect_backlog(self, run_id: str, source: WorkSource) -> None:
879
+ def _schedule_audits(self, now: float) -> None:
880
+ """Open due charters from the checkout's ``audit_dir`` as audit issues.
881
+
882
+ Runs after the pause/breaker/cap gates (a stressed daemon does not
883
+ add work for itself) and before discovery, so a freshly filed audit
884
+ is picked up in the same tick. GitHub is the schedule's source of
885
+ truth (a still-open audit is never re-filed; one created within the
886
+ interval counts as filed) with the store as a cache; a GitHub
887
+ hiccup skips this tick, never raises."""
888
+ daemon = self.config.daemon
889
+ if not daemon.audits:
890
+ return
891
+ github: Any = next((s for s in self.sources if s.name == "github"), None)
892
+ if github is None or not hasattr(github, "file_audit"):
893
+ return
894
+ checkout = self._workspace_checkout()
895
+ if checkout is None:
896
+ return
897
+ charters, problems = load_charters(checkout, daemon.audit_dir)
898
+ for problem in problems:
899
+ if problem not in self._audit_problems_seen:
900
+ self._audit_problems_seen.add(problem)
901
+ self._notify(f"⚠ audit charter skipped: {problem}")
902
+ for charter in due_charters(charters, self.dstore.audit_last_filed(), now):
903
+ try:
904
+ since = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now - charter.every_s))
905
+ is_open, filed_recently = github.audit_issue_state(charter.title, since)
906
+ if is_open or filed_recently:
907
+ # Someone (or a previous process) already filed it: sync
908
+ # the cache so we stop asking GitHub every tick.
909
+ self.dstore.record_audit(charter.name, "gh:existing", now)
910
+ continue
911
+ ref = github.file_audit(
912
+ charter.title, issue_body(charter, audit_marker(charter.name))
913
+ )
914
+ self.dstore.record_audit(charter.name, ref, now)
915
+ self._notify(f"🔎 scheduled audit filed: {charter.title} → {ref}")
916
+ except Exception:
917
+ logger.warning("scheduled audit %s could not be filed", charter.name, exc_info=True)
918
+
919
+ def _collect_tool_findings(self, run_id: str, source: WorkSource) -> ToolFindings:
920
+ """Findings addressed to the tool: filed upstream when tool_repo is
921
+ set, otherwise noted for the closing comment. Never the project's."""
922
+ if self.config.daemon.backlog == "off":
923
+ return ToolFindings([], [])
924
+ target = next((s for s in self.sources if s.name == self.config.daemon.backlog), None)
925
+ if target is None:
926
+ return ToolFindings([], [])
927
+ try:
928
+ return collect_tool_findings(
929
+ self.store.get_run(run_id),
930
+ dstore=self.dstore,
931
+ source=target,
932
+ max_items=self.config.daemon.backlog_max_per_run,
933
+ now=self.clock(),
934
+ )
935
+ except SbxloopError:
936
+ logger.warning("tool-finding collection failed for %s", run_id, exc_info=True)
937
+ return ToolFindings([], [])
938
+
939
+ def _file_review(self, item: WorkItem, run_id: str, report: RunReport) -> None:
940
+ """The loop evaluating the code it just wrote: after a patch item
941
+ delivers a PR, open a review audit of that PR. Once per run, patch
942
+ items only (an audit has no PR), capped per rolling day, best-effort."""
943
+ daemon = self.config.daemon
944
+ if not daemon.review_deliveries or item.kind != "patch" or report.delivery is None:
945
+ return
946
+ github: Any = next((s for s in self.sources if s.name == "github"), None)
947
+ if github is None or not hasattr(github, "file_review"):
948
+ return
949
+ now = self.clock()
950
+ try:
951
+ if self.dstore.review_filed(run_id):
952
+ return
953
+ if self.dstore.reviews_since(now - DAY_S) >= daemon.reviews_per_day:
954
+ logger.info("delivery review for %s skipped: daily cap reached", run_id)
955
+ return
956
+ number, url = report.delivery
957
+ ref = github.file_review(item, number, url, run_id)
958
+ self.dstore.record_review(run_id, number, ref, now)
959
+ self._notify(f"🔎 review audit filed for PR #{number}: {ref}")
960
+ except Exception:
961
+ logger.warning("delivery review for %s could not be filed", run_id, exc_info=True)
962
+
963
+ def _file_postmortem(self, item: WorkItem, run_id: str, reason: str) -> None:
964
+ """Turn the daemon's own failure into a discovery-lane charter.
965
+
966
+ Only for patch items — a failed audit filing a post-mortem that is
967
+ itself an audit would recurse — only once per run, and capped per
968
+ rolling day so a bad night cannot flood the tracker. Best-effort:
969
+ the item is already settled; this must never change that."""
970
+ daemon = self.config.daemon
971
+ if not daemon.postmortems or item.kind != "patch":
972
+ return
973
+ github: Any = next((s for s in self.sources if s.name == "github"), None)
974
+ if github is None or not hasattr(github, "file_postmortem"):
975
+ return
976
+ now = self.clock()
977
+ try:
978
+ if self.dstore.postmortem_filed(run_id):
979
+ return
980
+ if self.dstore.postmortems_since(now - DAY_S) >= daemon.postmortems_per_day:
981
+ logger.info("post-mortem for %s skipped: daily cap reached", run_id)
982
+ return
983
+ run_ids = self.dstore.runs_for_item(item.item_id) or [run_id]
984
+ dossier = build_dossier(
985
+ self.store, item, run_ids, reason, state_dir=str(self.config.state_dir)
986
+ )
987
+ ref = github.file_postmortem(item, dossier, run_id)
988
+ self.dstore.record_postmortem(run_id, item.item_id, ref, now)
989
+ self._notify(f"🔎 post-mortem filed for {item.item_id}: {ref}")
990
+ except Exception:
991
+ logger.warning("post-mortem for %s could not be filed", run_id, exc_info=True)
992
+
993
+ def _collect_backlog(self, run_id: str, source: WorkSource) -> list[str]:
994
+ """File the run's backlog items; returns their refs (``gh:<n>``)."""
857
995
  mode = self.config.daemon.backlog
858
996
  if mode == "off":
859
- return
997
+ return []
860
998
  target = next((s for s in self.sources if s.name == mode), None)
861
999
  if target is None:
862
1000
  logger.warning("backlog mode %r but no such source is active", mode)
863
- return
1001
+ return []
864
1002
  try:
865
1003
  record = self.store.get_run(run_id)
866
1004
  filed = collect_backlog(
@@ -873,9 +1011,10 @@ class DaemonLoop:
873
1011
  )
874
1012
  except SbxloopError:
875
1013
  logger.warning("backlog collection failed for %s", run_id, exc_info=True)
876
- return
1014
+ return []
877
1015
  if filed:
878
1016
  self._notify(f"filed {len(filed)} backlog item(s) from {run_id}: {', '.join(filed)}")
1017
+ return list(filed)
879
1018
 
880
1019
  # -- recovery ------------------------------------------------------------------------
881
1020
 
@@ -9,6 +9,10 @@ from pydantic import BaseModel, ConfigDict
9
9
  from sbxloop.engine.model import RunState
10
10
 
11
11
  ItemSource = Literal["github", "inbox"]
12
+ # What a run of the item is FOR. ``patch``: change the tree, deliver a PR.
13
+ # ``audit``: investigate and file findings as backlog issues — no delivery,
14
+ # the tree is disposable, the issues are the output (the discovery lane).
15
+ ItemKind = Literal["patch", "audit"]
12
16
  # ``cancelled`` is an operator's decision (``!sbx cancel``), not a failure:
13
17
  # it is terminal for the daemon (no retry, no breaker count) while the run
14
18
  # itself stays resumable from the CLI.
@@ -32,6 +36,7 @@ class WorkItem(BaseModel):
32
36
  item_id: str
33
37
  source: ItemSource
34
38
  source_key: str
39
+ kind: ItemKind = "patch"
35
40
  title: str
36
41
  body: str = ""
37
42
  url: str = ""
@@ -62,6 +67,12 @@ class RunReport(NamedTuple):
62
67
  cancelled_by: str | None = None
63
68
  # ``!sbx cancel --retry``: the item went straight back to the queue.
64
69
  requeued: bool = False
70
+ # Backlog issues the run filed (``gh:<n>`` refs) — an audit's deliverable.
71
+ filed: tuple[str, ...] = ()
72
+ # Findings addressed to sbxloop itself: filed upstream (``[daemon]
73
+ # tool_repo``) as refs, or merely noted by title in the closing comment.
74
+ tool_filed: tuple[str, ...] = ()
75
+ tool_noted: tuple[str, ...] = ()
65
76
 
66
77
  @property
67
78
  def succeeded(self) -> bool: