sbxloop 0.7.2__tar.gz → 0.7.4__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.4}/PKG-INFO +1 -1
  2. sbxloop-0.7.2/src/sbxloop/_vendor/sbxloop_worker-0.7.2-py3-none-any.whl → sbxloop-0.7.4/src/sbxloop/_vendor/sbxloop_worker-0.7.4-py3-none-any.whl +0 -0
  3. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/_version.py +2 -2
  4. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/cli/app.py +18 -0
  5. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/config.py +29 -0
  6. sbxloop-0.7.4/src/sbxloop/daemon/audits.py +150 -0
  7. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/daemon/backlog.py +79 -0
  8. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/daemon/discord_format.py +4 -1
  9. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/daemon/loop.py +158 -8
  10. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/daemon/model.py +11 -0
  11. sbxloop-0.7.4/src/sbxloop/daemon/postmortem.py +157 -0
  12. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/daemon/sources.py +150 -32
  13. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/daemon/store.py +114 -2
  14. {sbxloop-0.7.2 → sbxloop-0.7.4}/.gitignore +0 -0
  15. {sbxloop-0.7.2 → sbxloop-0.7.4}/README.md +0 -0
  16. {sbxloop-0.7.2 → sbxloop-0.7.4}/hatch_build.py +0 -0
  17. {sbxloop-0.7.2 → sbxloop-0.7.4}/pyproject.toml +0 -0
  18. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/__init__.py +0 -0
  19. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/cli/__init__.py +0 -0
  20. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/cli/doctor.py +0 -0
  21. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/cli/models.py +0 -0
  22. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/cli/tui.py +0 -0
  23. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/daemon/__init__.py +0 -0
  24. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/daemon/control.py +0 -0
  25. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/daemon/discord.py +0 -0
  26. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/daemon/github.py +0 -0
  27. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/daemon/paths.py +0 -0
  28. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/deliver.py +0 -0
  29. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/engine/__init__.py +0 -0
  30. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/engine/engine.py +0 -0
  31. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/engine/model.py +0 -0
  32. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/engine/phases.py +0 -0
  33. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/engine/prompts/__init__.py +0 -0
  34. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/engine/prompts/decompose.md +0 -0
  35. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/engine/prompts/execute.md +0 -0
  36. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/engine/prompts/plan.md +0 -0
  37. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/engine/prompts/scrutinize.md +0 -0
  38. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/engine/prompts/steer.md +0 -0
  39. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/engine/prompts/validate.md +0 -0
  40. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/engine/store.py +0 -0
  41. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/errors.py +0 -0
  42. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/events.py +0 -0
  43. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/gc.py +0 -0
  44. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/gh/__init__.py +0 -0
  45. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/gh/ops.py +0 -0
  46. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/gh/reporter.py +0 -0
  47. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/hostgit.py +0 -0
  48. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/ids.py +0 -0
  49. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/policy.py +0 -0
  50. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/py.typed +0 -0
  51. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/sbx/__init__.py +0 -0
  52. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/sbx/bake.py +0 -0
  53. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/sbx/cli.py +0 -0
  54. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/sbx/conformance.py +0 -0
  55. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/sbx/models.py +0 -0
  56. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/sbx/pair.py +0 -0
  57. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/sbx/parse.py +0 -0
  58. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/sbx/provision.py +0 -0
  59. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/sbx/prune.py +0 -0
  60. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/sbx/sandbox.py +0 -0
  61. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/sbx/secretstate.py +0 -0
  62. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/toolchains.py +0 -0
  63. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/verifylint.py +0 -0
  64. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/worker/__init__.py +0 -0
  65. {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/worker/client.py +0 -0
  66. {sbxloop-0.7.2 → sbxloop-0.7.4}/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.4
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.4'
22
+ __version_tuple__ = version_tuple = (0, 7, 4)
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,150 @@
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
+ # Preferred metadata form: an HTML comment. Invisible on GitHub and left
25
+ # alone by every Markdown formatter (mdformat rewrote a `---` YAML block
26
+ # into a thematic break + an H2 the first time the charters shipped, and
27
+ # the daemon then saw no metadata at all).
28
+ _COMMENT_META = re.compile(r"\A\s*<!--\s*sbxloop(?:-audit)?\s*:?\s*(.*?)\s*-->\s*\n?", re.S)
29
+ _EVERY = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([dhm])\s*$", re.I)
30
+ _UNIT_S = {"d": 86_400.0, "h": 3_600.0, "m": 60.0}
31
+ _NAME_OK = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Charter:
36
+ name: str
37
+ every_s: float
38
+ enabled: bool
39
+ body: str
40
+ rel: str # path relative to the checkout, for the issue's provenance line
41
+
42
+ @property
43
+ def title(self) -> str:
44
+ return f"{AUDIT_TITLE_PREFIX}{self.name}"
45
+
46
+
47
+ def parse_every(text: str) -> float:
48
+ """``7d`` / ``12h`` / ``30m`` → seconds. ``0d`` means every tick (tests,
49
+ or "run once now")."""
50
+ m = _EVERY.match(text)
51
+ if not m:
52
+ raise ValueError(f"unrecognised interval {text!r} (use e.g. 7d, 12h, 30m)")
53
+ return float(m.group(1)) * _UNIT_S[m.group(2).lower()]
54
+
55
+
56
+ def parse_charter(path: Path, rel: str | None = None) -> Charter:
57
+ """One charter file. Metadata keys: ``every`` (required), ``enabled``
58
+ (default true), given as ``<!-- sbxloop: every=7d -->`` on the first line
59
+ (preferred) or a ``---`` front-matter block. The name is the file stem,
60
+ which also keys the schedule and the issue title — keep it stable."""
61
+ name = path.stem
62
+ if not _NAME_OK.match(name):
63
+ raise ValueError(f"charter file name {name!r} must be lowercase [a-z0-9._-]")
64
+ text = path.read_text(encoding="utf-8")
65
+ meta: dict[str, str] = {}
66
+ m = _COMMENT_META.match(text)
67
+ if m:
68
+ # `<!-- sbxloop: every=7d enabled=false -->` (also `key: value`)
69
+ for token in re.split(r"[\s,;]+", m.group(1).strip()):
70
+ if not token:
71
+ continue
72
+ key, sep, value = token.partition("=") if "=" in token else token.partition(":")
73
+ if sep:
74
+ meta[key.strip().lower()] = value.strip().strip("'\"")
75
+ else:
76
+ m = _FRONT_MATTER.match(text)
77
+ if not m:
78
+ raise ValueError(
79
+ f"{path}: missing metadata — start the file with "
80
+ "`<!-- sbxloop: every=7d -->` (or a --- every: 7d --- front-matter block)"
81
+ )
82
+ for line in m.group(1).splitlines():
83
+ if ":" in line:
84
+ key, _, value = line.partition(":")
85
+ meta[key.strip().lower()] = value.strip().strip("'\"")
86
+ if "every" not in meta:
87
+ raise ValueError(f"{path}: front-matter needs `every: <interval>`")
88
+ body = text[m.end() :].strip()
89
+ if not body:
90
+ raise ValueError(f"{path}: charter body is empty")
91
+ enabled = meta.get("enabled", "true").lower() not in ("false", "no", "0", "off")
92
+ return Charter(
93
+ name=name,
94
+ every_s=parse_every(meta["every"]),
95
+ enabled=enabled,
96
+ body=body,
97
+ rel=rel or path.name,
98
+ )
99
+
100
+
101
+ def load_charters(checkout: Path, audit_dir: Path = AUDIT_DIR) -> tuple[list[Charter], list[str]]:
102
+ """(charters, problems) under ``checkout/audit_dir``; a bad file is a
103
+ problem string, never a crash — one broken charter must not stop the
104
+ others."""
105
+ folder = checkout / audit_dir
106
+ if not folder.is_dir():
107
+ return [], []
108
+ charters: list[Charter] = []
109
+ problems: list[str] = []
110
+ for path in sorted(folder.glob("*.md")):
111
+ try:
112
+ charters.append(parse_charter(path, path.relative_to(checkout).as_posix()))
113
+ except (ValueError, OSError) as exc:
114
+ problems.append(str(exc))
115
+ return charters, problems
116
+
117
+
118
+ def due_charters(
119
+ charters: list[Charter], last_filed: dict[str, float], now: float
120
+ ) -> list[Charter]:
121
+ """Enabled charters whose interval has elapsed since they were last
122
+ filed (never filed → due)."""
123
+ out = []
124
+ for c in charters:
125
+ if not c.enabled:
126
+ continue
127
+ last = last_filed.get(c.name)
128
+ if last is None or now - last >= c.every_s:
129
+ out.append(c)
130
+ return out
131
+
132
+
133
+ def issue_body(charter: Charter, marker: str) -> str:
134
+ return (
135
+ f"{charter.body}\n\n---\n"
136
+ f"Scheduled audit `{charter.name}` (every {describe_every(charter.every_s)}); "
137
+ f"charter: `{charter.rel}`.\n"
138
+ f"{marker}"
139
+ )
140
+
141
+
142
+ def audit_marker(name: str) -> str:
143
+ return f"<!-- sbxloop-audit {name} -->"
144
+
145
+
146
+ def describe_every(seconds: float) -> str:
147
+ for unit, size in (("d", 86_400.0), ("h", 3_600.0), ("m", 60.0)):
148
+ if seconds >= size and seconds % size == 0:
149
+ return f"{int(seconds // size)}{unit}"
150
+ 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
@@ -49,6 +57,9 @@ from sbxloop.sbx.prune import remove_run_sandbox, remove_run_sandbox_secrets
49
57
 
50
58
  logger = logging.getLogger(__name__)
51
59
 
60
+ # How often the audit scheduler fast-forwards the checkout to see new charters.
61
+ AUDIT_REFRESH_S = 600.0
62
+
52
63
 
53
64
  class Frontend(Protocol):
54
65
  """What a human-facing channel (Discord) sees of the loop's lifecycle.
@@ -118,6 +129,8 @@ class DaemonLoop:
118
129
  self._runner = runner or self._default_runner
119
130
  self._stop = threading.Event()
120
131
  self._paused = False
132
+ self._audit_problems_seen: set[str] = set()
133
+ self._last_audit_refresh = float("-inf")
121
134
  self._current: RunHandle | None = None
122
135
  self._current_lock = threading.Lock()
123
136
  self._cancel_request: CancelRequest | None = None
@@ -346,6 +359,7 @@ class DaemonLoop:
346
359
  f"daily run cap reached ({started_today}/{cap}); idling until the window rolls"
347
360
  )
348
361
  return TickResult(idle_kind="daily_cap")
362
+ self._schedule_audits(now)
349
363
  discovered = self._discover(now)
350
364
  item = self.dstore.next_queued(now, self.config.daemon.retry_backoff_s)
351
365
  if item is None:
@@ -638,7 +652,11 @@ class DaemonLoop:
638
652
  now = self.clock()
639
653
  report = self._report(run_id, result)
640
654
  if result is not None and report.succeeded:
641
- self._collect_backlog(run_id, source)
655
+ filed = self._collect_backlog(run_id, source)
656
+ tool = self._collect_tool_findings(run_id, source)
657
+ report = report._replace(
658
+ filed=tuple(filed), tool_filed=tuple(tool.filed), tool_noted=tuple(tool.unfiled)
659
+ )
642
660
  self.dstore.mark_done(item.item_id, now)
643
661
  self.dstore.finish_ledger(run_id, "done", now)
644
662
  self._set_breaker(None, 0)
@@ -647,7 +665,9 @@ class DaemonLoop:
647
665
  self._notify(
648
666
  f"✅ {item.item_id} done ({report.task_summary})"
649
667
  + (f" · PR {report.delivery[1]}" if report.delivery else "")
668
+ + (f" · filed {len(report.filed)} finding(s)" if item.kind == "audit" else "")
650
669
  )
670
+ self._file_review(item, run_id, report)
651
671
  return "done"
652
672
  if result is not None and result.state == "completed" and report.delivery_error:
653
673
  # Work done, PR failed: a human must look; retrying would redo the work.
@@ -659,6 +679,7 @@ class DaemonLoop:
659
679
  source.report_delivery_failed(item, report)
660
680
  self._frontend_finished(item, report)
661
681
  self._notify(f"⚠ {item.item_id} completed but delivery failed: {report.delivery_error}")
682
+ self._file_postmortem(item, run_id, f"delivery failed: {report.delivery_error}")
662
683
  return "delivery_failed"
663
684
  reason = str(error) if error is not None else f"run ended {report.state}"
664
685
  attempts_left = self.config.daemon.max_attempts_per_item - item.attempts
@@ -673,6 +694,7 @@ class DaemonLoop:
673
694
  self.dstore.mark_failed(item.item_id, reason, now, requeue=False)
674
695
  source.report_abandoned(item, reason)
675
696
  self._notify(f"❌ {item.item_id} abandoned after {item.attempts} attempt(s): {reason}")
697
+ self._file_postmortem(item, run_id, f"abandoned: {reason}")
676
698
  outcome = "abandoned"
677
699
  self._frontend_finished(item, report)
678
700
  if self._consecutive_failures >= self.config.daemon.max_consecutive_failures:
@@ -738,7 +760,10 @@ class DaemonLoop:
738
760
  # issue already is one (#251); the summary comment there
739
761
  # carries the same information.
740
762
  "report": self.config.daemon.tracking_issue,
741
- "deliver": True,
763
+ # An audit's output is the issues it files; delivering
764
+ # its (deliberately unchanged) tree would only raise
765
+ # "nothing to deliver" and mis-settle it as failed.
766
+ "deliver": item.kind != "audit",
742
767
  "deliver_draft": self.config.daemon.deliver_draft,
743
768
  "create_repo": False,
744
769
  }
@@ -807,7 +832,9 @@ class DaemonLoop:
807
832
  else:
808
833
  origin = f"inbox file `{item.source_key}`"
809
834
  parts.append(f"---\nThis work item came from: {origin}.")
810
- if self.config.daemon.backlog != "off":
835
+ if item.kind == "audit":
836
+ parts.append(AUDIT_INSTRUCTIONS)
837
+ elif self.config.daemon.backlog != "off":
811
838
  parts.append(BACKLOG_INSTRUCTIONS)
812
839
  return "\n\n".join(parts)
813
840
 
@@ -853,14 +880,136 @@ class DaemonLoop:
853
880
  workspace = str(result.workspace) if result is not None and result.workspace else None
854
881
  return RunReport(run_id, state, summary, tracking, delivery, delivery_error, workspace)
855
882
 
856
- def _collect_backlog(self, run_id: str, source: WorkSource) -> None:
883
+ def _schedule_audits(self, now: float) -> None:
884
+ """Open due charters from the checkout's ``audit_dir`` as audit issues.
885
+
886
+ Runs after the pause/breaker/cap gates (a stressed daemon does not
887
+ add work for itself) and before discovery, so a freshly filed audit
888
+ is picked up in the same tick. GitHub is the schedule's source of
889
+ truth (a still-open audit is never re-filed; one created within the
890
+ interval counts as filed) with the store as a cache; a GitHub
891
+ hiccup skips this tick, never raises."""
892
+ daemon = self.config.daemon
893
+ if not daemon.audits:
894
+ return
895
+ github: Any = next((s for s in self.sources if s.name == "github"), None)
896
+ if github is None or not hasattr(github, "file_audit"):
897
+ return
898
+ checkout = self._workspace_checkout()
899
+ if checkout is None:
900
+ return
901
+ # The checkout is normally refreshed only when a run starts, so
902
+ # charters merged after the last run would never be seen (field:
903
+ # the first deploy read a clone that predated its own charters).
904
+ # Refresh here too, throttled — a fetch a minute is not the point.
905
+ if now - self._last_audit_refresh >= AUDIT_REFRESH_S:
906
+ self._last_audit_refresh = now
907
+ self._refresh_workspace()
908
+ charters, problems = load_charters(checkout, daemon.audit_dir)
909
+ for problem in problems:
910
+ if problem not in self._audit_problems_seen:
911
+ self._audit_problems_seen.add(problem)
912
+ self._notify(f"⚠ audit charter skipped: {problem}")
913
+ for charter in due_charters(charters, self.dstore.audit_last_filed(), now):
914
+ try:
915
+ since = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now - charter.every_s))
916
+ is_open, filed_recently = github.audit_issue_state(charter.title, since)
917
+ if is_open or filed_recently:
918
+ # Someone (or a previous process) already filed it: sync
919
+ # the cache so we stop asking GitHub every tick.
920
+ self.dstore.record_audit(charter.name, "gh:existing", now)
921
+ continue
922
+ ref = github.file_audit(
923
+ charter.title, issue_body(charter, audit_marker(charter.name))
924
+ )
925
+ self.dstore.record_audit(charter.name, ref, now)
926
+ self._notify(f"🔎 scheduled audit filed: {charter.title} → {ref}")
927
+ except Exception:
928
+ logger.warning("scheduled audit %s could not be filed", charter.name, exc_info=True)
929
+
930
+ def _collect_tool_findings(self, run_id: str, source: WorkSource) -> ToolFindings:
931
+ """Findings addressed to the tool: filed upstream when tool_repo is
932
+ set, otherwise noted for the closing comment. Never the project's."""
933
+ if self.config.daemon.backlog == "off":
934
+ return ToolFindings([], [])
935
+ target = next((s for s in self.sources if s.name == self.config.daemon.backlog), None)
936
+ if target is None:
937
+ return ToolFindings([], [])
938
+ try:
939
+ return collect_tool_findings(
940
+ self.store.get_run(run_id),
941
+ dstore=self.dstore,
942
+ source=target,
943
+ max_items=self.config.daemon.backlog_max_per_run,
944
+ now=self.clock(),
945
+ )
946
+ except SbxloopError:
947
+ logger.warning("tool-finding collection failed for %s", run_id, exc_info=True)
948
+ return ToolFindings([], [])
949
+
950
+ def _file_review(self, item: WorkItem, run_id: str, report: RunReport) -> None:
951
+ """The loop evaluating the code it just wrote: after a patch item
952
+ delivers a PR, open a review audit of that PR. Once per run, patch
953
+ items only (an audit has no PR), capped per rolling day, best-effort."""
954
+ daemon = self.config.daemon
955
+ if not daemon.review_deliveries or item.kind != "patch" or report.delivery is None:
956
+ return
957
+ github: Any = next((s for s in self.sources if s.name == "github"), None)
958
+ if github is None or not hasattr(github, "file_review"):
959
+ return
960
+ now = self.clock()
961
+ try:
962
+ if self.dstore.review_filed(run_id):
963
+ return
964
+ if self.dstore.reviews_since(now - DAY_S) >= daemon.reviews_per_day:
965
+ logger.info("delivery review for %s skipped: daily cap reached", run_id)
966
+ return
967
+ number, url = report.delivery
968
+ ref = github.file_review(item, number, url, run_id)
969
+ self.dstore.record_review(run_id, number, ref, now)
970
+ self._notify(f"🔎 review audit filed for PR #{number}: {ref}")
971
+ except Exception:
972
+ logger.warning("delivery review for %s could not be filed", run_id, exc_info=True)
973
+
974
+ def _file_postmortem(self, item: WorkItem, run_id: str, reason: str) -> None:
975
+ """Turn the daemon's own failure into a discovery-lane charter.
976
+
977
+ Only for patch items — a failed audit filing a post-mortem that is
978
+ itself an audit would recurse — only once per run, and capped per
979
+ rolling day so a bad night cannot flood the tracker. Best-effort:
980
+ the item is already settled; this must never change that."""
981
+ daemon = self.config.daemon
982
+ if not daemon.postmortems or item.kind != "patch":
983
+ return
984
+ github: Any = next((s for s in self.sources if s.name == "github"), None)
985
+ if github is None or not hasattr(github, "file_postmortem"):
986
+ return
987
+ now = self.clock()
988
+ try:
989
+ if self.dstore.postmortem_filed(run_id):
990
+ return
991
+ if self.dstore.postmortems_since(now - DAY_S) >= daemon.postmortems_per_day:
992
+ logger.info("post-mortem for %s skipped: daily cap reached", run_id)
993
+ return
994
+ run_ids = self.dstore.runs_for_item(item.item_id) or [run_id]
995
+ dossier = build_dossier(
996
+ self.store, item, run_ids, reason, state_dir=str(self.config.state_dir)
997
+ )
998
+ ref = github.file_postmortem(item, dossier, run_id)
999
+ self.dstore.record_postmortem(run_id, item.item_id, ref, now)
1000
+ self._notify(f"🔎 post-mortem filed for {item.item_id}: {ref}")
1001
+ except Exception:
1002
+ logger.warning("post-mortem for %s could not be filed", run_id, exc_info=True)
1003
+
1004
+ def _collect_backlog(self, run_id: str, source: WorkSource) -> list[str]:
1005
+ """File the run's backlog items; returns their refs (``gh:<n>``)."""
857
1006
  mode = self.config.daemon.backlog
858
1007
  if mode == "off":
859
- return
1008
+ return []
860
1009
  target = next((s for s in self.sources if s.name == mode), None)
861
1010
  if target is None:
862
1011
  logger.warning("backlog mode %r but no such source is active", mode)
863
- return
1012
+ return []
864
1013
  try:
865
1014
  record = self.store.get_run(run_id)
866
1015
  filed = collect_backlog(
@@ -873,9 +1022,10 @@ class DaemonLoop:
873
1022
  )
874
1023
  except SbxloopError:
875
1024
  logger.warning("backlog collection failed for %s", run_id, exc_info=True)
876
- return
1025
+ return []
877
1026
  if filed:
878
1027
  self._notify(f"filed {len(filed)} backlog item(s) from {run_id}: {', '.join(filed)}")
1028
+ return list(filed)
879
1029
 
880
1030
  # -- recovery ------------------------------------------------------------------------
881
1031
 
@@ -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: