ergane-cli 0.1.0__py3-none-any.whl

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 (121) hide show
  1. ergane_cli-0.1.0.dist-info/METADATA +148 -0
  2. ergane_cli-0.1.0.dist-info/RECORD +121 -0
  3. ergane_cli-0.1.0.dist-info/WHEEL +4 -0
  4. ergane_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. ergane_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
  6. ergane_cli-0.1.0.dist-info/licenses/NOTICE +27 -0
  7. factory/__init__.py +0 -0
  8. factory/activities/__init__.py +0 -0
  9. factory/activities/agent_activities.py +745 -0
  10. factory/activities/merge_activities.py +654 -0
  11. factory/activities/notify_activities.py +1078 -0
  12. factory/activities/roadmap_activities.py +577 -0
  13. factory/activities/usage_activities.py +593 -0
  14. factory/activities/verify_activities.py +617 -0
  15. factory/cli/__init__.py +0 -0
  16. factory/cli/completion.py +150 -0
  17. factory/cli/doctor.py +484 -0
  18. factory/cli/env.py +247 -0
  19. factory/cli/errors.py +77 -0
  20. factory/cli/init.py +1057 -0
  21. factory/cli/install.py +679 -0
  22. factory/cli/main.py +212 -0
  23. factory/cli/nouns/__init__.py +59 -0
  24. factory/cli/nouns/answer.py +134 -0
  25. factory/cli/nouns/build.py +1222 -0
  26. factory/cli/nouns/completion.py +13 -0
  27. factory/cli/nouns/doctor.py +13 -0
  28. factory/cli/nouns/env.py +13 -0
  29. factory/cli/nouns/escalations.py +93 -0
  30. factory/cli/nouns/findings.py +13 -0
  31. factory/cli/nouns/init.py +13 -0
  32. factory/cli/nouns/install.py +99 -0
  33. factory/cli/nouns/repo.py +13 -0
  34. factory/cli/nouns/roadmap.py +13 -0
  35. factory/cli/nouns/spec.py +511 -0
  36. factory/cli/nouns/status.py +18 -0
  37. factory/cli/nouns/usage.py +13 -0
  38. factory/cli/nouns/worker.py +69 -0
  39. factory/cli/repo.py +569 -0
  40. factory/cli/repo_export.py +282 -0
  41. factory/cli/roadmap.py +434 -0
  42. factory/cli/status.py +844 -0
  43. factory/cli/usage.py +129 -0
  44. factory/config.py +262 -0
  45. factory/controlplane/__init__.py +0 -0
  46. factory/controlplane/config.py +863 -0
  47. factory/controlplane/resolve.py +434 -0
  48. factory/controlplane/verify.py +827 -0
  49. factory/discovery/__init__.py +3 -0
  50. factory/discovery/llm_scanner.py +176 -0
  51. factory/doctor/__init__.py +0 -0
  52. factory/doctor/cli.py +392 -0
  53. factory/doctor/models.py +165 -0
  54. factory/doctor/probes.py +522 -0
  55. factory/doctor/scaffold.py +184 -0
  56. factory/doctor/store.py +449 -0
  57. factory/env.py +131 -0
  58. factory/escalation/__init__.py +0 -0
  59. factory/escalation/client.py +168 -0
  60. factory/escalation/question.py +161 -0
  61. factory/escalation/workflow.py +470 -0
  62. factory/locking.py +85 -0
  63. factory/mergequeue/__init__.py +21 -0
  64. factory/mergequeue/classify.py +133 -0
  65. factory/mergequeue/forge.py +401 -0
  66. factory/mergequeue/gh.py +484 -0
  67. factory/mergequeue/github_forge.py +349 -0
  68. factory/mergequeue/merge_queue_ruleset.json +32 -0
  69. factory/mergequeue/messages.py +141 -0
  70. factory/mergequeue/models.py +324 -0
  71. factory/mergequeue/onboard.py +537 -0
  72. factory/mergequeue/wiring.py +466 -0
  73. factory/notify/__init__.py +0 -0
  74. factory/notify/adapter.py +263 -0
  75. factory/notify/messages.py +474 -0
  76. factory/notify/service.py +809 -0
  77. factory/notify/webhook.py +151 -0
  78. factory/personas.yaml +161 -0
  79. factory/registry.py +583 -0
  80. factory/roadmap/__init__.py +8 -0
  81. factory/roadmap/cli.py +125 -0
  82. factory/roadmap/discovery.py +286 -0
  83. factory/roadmap/models.py +583 -0
  84. factory/roadmap/schedule.py +377 -0
  85. factory/roadmap/workflow.py +1364 -0
  86. factory/supervision/__init__.py +0 -0
  87. factory/supervision/alert.py +239 -0
  88. factory/supervision/probe.py +467 -0
  89. factory/supervision/temporal_server.py +121 -0
  90. factory/supervision/units.py +649 -0
  91. factory/usage/__init__.py +0 -0
  92. factory/usage/aggregate.py +113 -0
  93. factory/usage/cli.py +223 -0
  94. factory/usage/ledger.py +295 -0
  95. factory/usage/litellm_client.py +486 -0
  96. factory/usage/models.py +149 -0
  97. factory/verify/__init__.py +0 -0
  98. factory/verify/criteria.py +488 -0
  99. factory/verify/diffbounds.py +159 -0
  100. factory/verify/diffcheck.py +506 -0
  101. factory/verify/factory_yaml.py +842 -0
  102. factory/verify/gates.py +1341 -0
  103. factory/verify/judge.py +775 -0
  104. factory/verify/ladder.py +145 -0
  105. factory/verify/models.py +745 -0
  106. factory/verify/question.py +167 -0
  107. factory/verify/store.py +1366 -0
  108. factory/verify/toolchain.py +370 -0
  109. factory/worker.py +296 -0
  110. factory/workgraph/__init__.py +0 -0
  111. factory/workgraph/adapter.py +1333 -0
  112. factory/workgraph/cli.py +801 -0
  113. factory/workgraph/delta.py +278 -0
  114. factory/workgraph/derive.py +607 -0
  115. factory/workgraph/detector.py +509 -0
  116. factory/workgraph/landed.py +496 -0
  117. factory/workgraph/models.py +494 -0
  118. factory/workgraph/preflight.py +651 -0
  119. factory/workgraph/prompt.py +796 -0
  120. factory/workgraph/workflow.py +2733 -0
  121. factory/workgraph/worktree.py +1322 -0
@@ -0,0 +1,509 @@
1
+ """US1 detector: an attempt that writes outside its worktree is caught and named.
2
+
3
+ The detector captures the state of the *target repository* and the factory's own
4
+ runtime root at attempt start, and compares again at teardown. Any tracked path
5
+ that changed in the target repo, or any evidence store / ledger / sibling worktree
6
+ that was removed or truncated under the runtime root, becomes a critical finding.
7
+
8
+ Key design points:
9
+
10
+ - Read-only with respect to the target repository. We never stash, checkout,
11
+ clean or reset it (FR-002).
12
+ - The start-state snapshot is kept *outside* the runtime root, so deleting the
13
+ runtime root does not destroy the thing we compare against (FR-013).
14
+ - The finding key is keyed by ``epic_id`` and ``node_id`` so recurrence is
15
+ countable per node.
16
+ - Findings are filed by writing a JSON batch to a path the operator can inspect,
17
+ and by upserting into ``doctor.db`` when that store is still reachable.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import sqlite3
25
+ import subprocess
26
+ import tempfile
27
+ from dataclasses import dataclass
28
+ from datetime import datetime, timezone
29
+ from pathlib import Path
30
+ from typing import Any, Mapping
31
+
32
+ from factory.doctor.models import Finding, Severity, Status, parse_findings_batch
33
+ from factory.doctor.store import connect, report
34
+ from factory.workgraph.models import AttemptContext
35
+
36
+
37
+ class DetectorError(RuntimeError):
38
+ """The detector could not read state it needed; the finding may still be filed."""
39
+
40
+
41
+ #: The category used for all detector findings.
42
+ CATEGORY = "hardening"
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class TrackedState:
47
+ """A snapshot of tracked paths in a git repository.
48
+
49
+ Maps path (relative to repo root) to the blob sha at start, so a rename or
50
+ mode change still shows up as a difference.
51
+ """
52
+
53
+ repo: Path
54
+ blobs: dict[str, str]
55
+
56
+ def changed(self, other: "TrackedState") -> set[str]:
57
+ """Paths that differ between two snapshots."""
58
+ return set(self.blobs.keys()).symmetric_difference(set(other.blobs.keys())) | {
59
+ path
60
+ for path in self.blobs.keys() & other.blobs.keys()
61
+ if self.blobs[path] != other.blobs[path]
62
+ }
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class RuntimeRootState:
67
+ """A snapshot of critical runtime-root files and directories.
68
+
69
+ Records size and existence for stores, ledgers and every sibling worktree,
70
+ so removal or truncation is detectable even when the runtime root itself is
71
+ deleted at teardown.
72
+ """
73
+
74
+ root: Path | None
75
+ entries: dict[str, dict[str, Any]]
76
+
77
+ def changes_since(self, previous: "RuntimeRootState") -> dict[str, dict[str, Any]]:
78
+ """Map of relative path -> {before, after} for any change."""
79
+ changes: dict[str, dict[str, Any]] = {}
80
+ all_paths = set(self.entries.keys()) | set(previous.entries.keys())
81
+ for path in all_paths:
82
+ before = previous.entries.get(path)
83
+ after = self.entries.get(path)
84
+ if before != after:
85
+ changes[path] = {"before": before, "after": after}
86
+ return changes
87
+
88
+
89
+ def _now_iso() -> str:
90
+ return (
91
+ datetime.now(timezone.utc).isoformat(timespec="seconds")
92
+ .replace("+00:00", "Z")
93
+ )
94
+
95
+
96
+ def _git_env() -> dict[str, str]:
97
+ return {
98
+ "PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin"),
99
+ "HOME": os.devnull,
100
+ "GIT_CONFIG_GLOBAL": os.devnull,
101
+ "GIT_CONFIG_SYSTEM": os.devnull,
102
+ "GIT_TERMINAL_PROMPT": "0",
103
+ }
104
+
105
+
106
+ def _git(cwd: Path, *args: str) -> str:
107
+ completed = subprocess.run(
108
+ ["git", "-C", str(cwd), *args],
109
+ capture_output=True,
110
+ text=True,
111
+ env=_git_env(),
112
+ check=False,
113
+ )
114
+ if completed.stdout is None:
115
+ stdout = ""
116
+ else:
117
+ stdout = completed.stdout
118
+ if completed.stderr is None:
119
+ stderr = ""
120
+ else:
121
+ stderr = completed.stderr
122
+ if not completed.stdout:
123
+ raise DetectorError(f"git {' '.join(args)} failed in {cwd}: {(stderr or stdout).strip()}")
124
+ return stdout
125
+
126
+
127
+ def _tracked_state(repo: Path) -> TrackedState:
128
+ """Read-only snapshot of the repository's working tree (FR-002).
129
+
130
+ Tracks every committed path, plus any unstaged or staged changes, plus
131
+ untracked files that are not ignored. The detector reports what happened
132
+ during the attempt regardless of whether the change was to a tracked path or
133
+ a new file the operator (or agent) created; it never tries to attribute
134
+ intent.
135
+ """
136
+ try:
137
+ # ``HEAD^{tree}`` is read-only and names the tree we want; we avoid
138
+ # ``git status`` because status mutates the index in some git versions.
139
+ head = _git(repo, "rev-parse", "HEAD").strip()
140
+ if not head:
141
+ return TrackedState(repo=repo, blobs={})
142
+ out = _git(repo, "ls-tree", "-r", "-z", head)
143
+ except DetectorError:
144
+ return TrackedState(repo=repo, blobs={})
145
+
146
+ blobs: dict[str, str] = {}
147
+ if not out:
148
+ return TrackedState(repo=repo, blobs=blobs)
149
+
150
+ # ls-tree -z output: "<mode> <type> <sha>\t<path>\0"
151
+ for entry in out.split("\0"):
152
+ if not entry:
153
+ continue
154
+ meta, _, path = entry.partition("\t")
155
+ parts = meta.split()
156
+ if len(parts) >= 3:
157
+ blobs[path] = parts[2]
158
+
159
+ # Overlay any unstaged or staged changes so tracked files modified in the
160
+ # working tree during the attempt show up as changed. ``diff-index`` is
161
+ # read-only and compares the index against ``HEAD``; ``git diff HEAD`` covers
162
+ # both unstaged and staged changes in one read.
163
+ try:
164
+ working = _git(repo, "diff-index", "--raw", "--no-abbrev", "-z", "HEAD")
165
+ except DetectorError:
166
+ working = ""
167
+ # Raw diff -z lines look like:
168
+ # :<oldmode> <newmode> <oldsha> <newsha> <status>\t<path>\0[<newpath>\0]
169
+ if working:
170
+ pieces = working.split("\0")
171
+ index = 0
172
+ while index < len(pieces):
173
+ piece = pieces[index]
174
+ if not piece.startswith(":"):
175
+ index += 1
176
+ continue
177
+ meta = piece[1:]
178
+ path_piece = pieces[index + 1] if index + 1 < len(pieces) else ""
179
+ meta_parts = meta.split()
180
+ if len(meta_parts) >= 5 and path_piece:
181
+ new_sha = meta_parts[3]
182
+ status = meta_parts[4]
183
+ if status.startswith("D"):
184
+ blobs.pop(path_piece, None)
185
+ else:
186
+ blobs[path_piece] = new_sha
187
+ # Rename pairs carry the new path in the next piece; skip it.
188
+ if status.startswith("R") and index + 2 < len(pieces):
189
+ index += 1
190
+ index += 2
191
+
192
+ # Untracked (but not ignored) files are part of the working-tree state the
193
+ # detector must report on, because operator work often arrives as new files.
194
+ try:
195
+ untracked = _git(repo, "ls-files", "--others", "--exclude-standard", "-z")
196
+ except DetectorError:
197
+ untracked = ""
198
+ for path in untracked.split("\0"):
199
+ if not path:
200
+ continue
201
+ try:
202
+ blobs[path] = _git(repo, "hash-object", path).strip()
203
+ except DetectorError:
204
+ blobs[path] = ""
205
+
206
+ return TrackedState(repo=repo, blobs=blobs)
207
+
208
+
209
+ def _committed_state(repo: Path) -> TrackedState:
210
+ """Read-only snapshot of the committed tracked tree (HEAD).
211
+
212
+ Used for the *start* snapshot so that any working-tree changes present when
213
+ the attempt begins — operator work or lingering agent output — show up as a
214
+ difference at teardown. The teardown snapshot is the full working-tree
215
+ state (``_tracked_state``), so modifications during the attempt are caught
216
+ too, and the detector never attributes a change to a particular actor.
217
+ """
218
+ try:
219
+ head = _git(repo, "rev-parse", "HEAD").strip()
220
+ if not head:
221
+ return TrackedState(repo=repo, blobs={})
222
+ out = _git(repo, "ls-tree", "-r", "-z", head)
223
+ except DetectorError:
224
+ return TrackedState(repo=repo, blobs={})
225
+
226
+ blobs: dict[str, str] = {}
227
+ if not out:
228
+ return TrackedState(repo=repo, blobs=blobs)
229
+
230
+ for entry in out.split("\0"):
231
+ if not entry:
232
+ continue
233
+ meta, _, path = entry.partition("\t")
234
+ parts = meta.split()
235
+ if len(parts) >= 3:
236
+ blobs[path] = parts[2]
237
+
238
+ return TrackedState(repo=repo, blobs=blobs)
239
+
240
+
241
+ def _runtime_root_state(root: Path | None, own_worktree: Path) -> RuntimeRootState:
242
+ """Snapshot the stores and every sibling worktree under ``root``.
243
+
244
+ ``own_worktree`` is excluded: the agent is allowed to write there.
245
+ """
246
+ entries: dict[str, dict[str, Any]] = {}
247
+ if root is None or not root.exists():
248
+ return RuntimeRootState(root=None, entries=entries)
249
+
250
+ own_resolved = own_worktree.resolve()
251
+
252
+ # Evidence stores and ledgers at the root.
253
+ for name in ("doctor.db", "ledger.db", "verification.db"):
254
+ path = root / name
255
+ entries[name] = _describe_path(path)
256
+
257
+ # Every node worktree under root/worktrees/<epic>/<node>.
258
+ worktrees_root = root / "worktrees"
259
+ if worktrees_root.is_dir():
260
+ for epic_dir in worktrees_root.iterdir():
261
+ if not epic_dir.is_dir():
262
+ continue
263
+ for node_dir in epic_dir.iterdir():
264
+ if not node_dir.is_dir():
265
+ continue
266
+ if node_dir.resolve() == own_resolved:
267
+ continue
268
+ rel = str(node_dir.relative_to(root))
269
+ entries[rel] = _describe_path(node_dir)
270
+ # Snapshot the worktree's contents so file changes inside it are
271
+ # detectable, not just the directory's own metadata.
272
+ for path in node_dir.rglob("*"):
273
+ entry_rel = str(path.relative_to(root))
274
+ entries[entry_rel] = _describe_path(path)
275
+
276
+ return RuntimeRootState(root=root, entries=entries)
277
+
278
+
279
+ def _describe_path(path: Path) -> dict[str, Any]:
280
+ """A JSON-serializable description of a path: exists, size, type."""
281
+ if not path.exists():
282
+ return {"exists": False}
283
+ try:
284
+ size = path.stat().st_size
285
+ except OSError:
286
+ size = None
287
+ kind = "dir" if path.is_dir() else "file"
288
+ return {"exists": True, "size": size, "kind": kind}
289
+
290
+
291
+ def _snapshot_dir(factory_root: Path) -> Path:
292
+ """A directory outside the runtime root to hold the start snapshot.
293
+
294
+ Kept beside the runtime root, under the same parent, with a suffix that makes
295
+ it obviously not the runtime root itself. This satisfies FR-013: deleting
296
+ ``.ergane`` does not delete ``.ergane-detector``.
297
+ """
298
+ return factory_root.parent / f"{factory_root.name}-detector"
299
+
300
+
301
+ def _write_snapshot(
302
+ factory_root: Path,
303
+ tracked: TrackedState | None,
304
+ runtime: RuntimeRootState,
305
+ context: AttemptContext,
306
+ ) -> Path:
307
+ """Persist the start snapshot outside the runtime root and return its path."""
308
+ directory = _snapshot_dir(factory_root)
309
+ directory.mkdir(parents=True, exist_ok=True)
310
+ snapshot = directory / f"{context.epic_id}-{context.node_id}-{context.attempt}.json"
311
+ data = {
312
+ "epic_id": context.epic_id,
313
+ "node_id": context.node_id,
314
+ "attempt": context.attempt,
315
+ "target_repo": str(tracked.repo) if tracked else None,
316
+ "tracked": tracked.blobs if tracked else {},
317
+ "runtime_root": str(runtime.root) if runtime.root else None,
318
+ "runtime": runtime.entries,
319
+ "captured_at": _now_iso(),
320
+ }
321
+ snapshot.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8")
322
+ return snapshot
323
+
324
+
325
+ def _read_snapshot(snapshot_path: Path) -> dict[str, Any]:
326
+ return json.loads(snapshot_path.read_text(encoding="utf-8"))
327
+
328
+
329
+ def _finding_key(epic_id: str, node_id: str) -> str:
330
+ return f"hardening/agent-worktree-boundary/{epic_id}/{node_id}"
331
+
332
+
333
+ def _build_finding(
334
+ context: AttemptContext,
335
+ tracked_changes: set[str],
336
+ runtime_changes: dict[str, dict[str, Any]],
337
+ seen_at: str,
338
+ ) -> Finding | None:
339
+ """One critical finding naming every changed path, or None if nothing changed."""
340
+ if not tracked_changes and not runtime_changes:
341
+ return None
342
+
343
+ refs: list[str] = []
344
+ notes_parts: list[str] = []
345
+
346
+ if tracked_changes:
347
+ notes_parts.append("Tracked paths changed in target repository:")
348
+ for path in sorted(tracked_changes):
349
+ refs.append(f"target:{path}")
350
+ notes_parts.append(", ".join(sorted(tracked_changes)))
351
+
352
+ if runtime_changes:
353
+ if notes_parts:
354
+ notes_parts.append("")
355
+ notes_parts.append("Runtime-root paths removed or truncated:")
356
+ for path in sorted(runtime_changes):
357
+ refs.append(f"runtime:{path}")
358
+ before = runtime_changes[path]["before"]
359
+ after = runtime_changes[path]["after"]
360
+ notes_parts.append(f" {path}: {before} -> {after}")
361
+
362
+ all_changed = sorted(tracked_changes) + sorted(runtime_changes)
363
+ summary = (
364
+ f"attempt {context.attempt} of {context.epic_id}/{context.node_id} "
365
+ f"modified {len(tracked_changes)} tracked path(s) and "
366
+ f"{len(runtime_changes)} runtime-root path(s): "
367
+ f"{', '.join(all_changed) if all_changed else 'none'}"
368
+ )
369
+
370
+ return Finding(
371
+ key=_finding_key(context.epic_id, context.node_id),
372
+ category=CATEGORY,
373
+ severity=Severity.CRITICAL,
374
+ status=Status.OPEN,
375
+ summary=summary,
376
+ refs=refs,
377
+ notes="\n".join(notes_parts) if notes_parts else None,
378
+ source="agent-worktree-detector",
379
+ occurrences=1,
380
+ first_seen=seen_at,
381
+ last_seen=seen_at,
382
+ promoted_spec=None,
383
+ resolved_at=None,
384
+ resolution=None,
385
+ )
386
+
387
+
388
+ def capture_start(
389
+ factory_root: Path,
390
+ target_repo: Path,
391
+ context: AttemptContext,
392
+ ) -> Path:
393
+ """Capture and persist the start snapshot.
394
+
395
+ Called once at attempt start, before the agent runs. The target-repo
396
+ snapshot is the *committed* tree only, so any working-tree changes present
397
+ at the beginning of the attempt are reported alongside changes made during
398
+ the attempt.
399
+ """
400
+ tracked = _committed_state(target_repo)
401
+ runtime = _runtime_root_state(factory_root, Path(context.worktree_path))
402
+ return _write_snapshot(factory_root, tracked, runtime, context)
403
+
404
+
405
+ def compare_and_report(
406
+ factory_root: Path,
407
+ target_repo: Path,
408
+ context: AttemptContext,
409
+ ) -> Finding | None:
410
+ """Compare teardown state to the start snapshot and file a finding if needed.
411
+
412
+ Returns the finding that was filed, or None if nothing changed. Writes the
413
+ finding both to ``doctor.db`` (when reachable) and to a JSON batch file under
414
+ the outside-runtime-root snapshot directory, so FR-013 holds even when the
415
+ runtime root was deleted.
416
+ """
417
+ seen_at = _now_iso()
418
+ snapshot_dir = _snapshot_dir(factory_root)
419
+ snapshot_path = snapshot_dir / f"{context.epic_id}-{context.node_id}-{context.attempt}.json"
420
+
421
+ if not snapshot_path.exists():
422
+ # We cannot compare; record that the snapshot is missing as a finding.
423
+ finding = Finding(
424
+ key=_finding_key(context.epic_id, context.node_id),
425
+ category=CATEGORY,
426
+ severity=Severity.CRITICAL,
427
+ status=Status.OPEN,
428
+ summary=(
429
+ f"attempt {context.attempt} of {context.epic_id}/{context.node_id}: "
430
+ "detector start snapshot missing at teardown"
431
+ ),
432
+ refs=["detector:snapshot_missing"],
433
+ notes=f"expected snapshot at {snapshot_path}",
434
+ source="agent-worktree-detector",
435
+ occurrences=1,
436
+ first_seen=seen_at,
437
+ last_seen=seen_at,
438
+ promoted_spec=None,
439
+ resolved_at=None,
440
+ resolution=None,
441
+ )
442
+ _persist_finding(snapshot_dir, finding, factory_root)
443
+ return finding
444
+
445
+ start = _read_snapshot(snapshot_path)
446
+ start_tracked = TrackedState(
447
+ repo=Path(start["target_repo"]) if start.get("target_repo") else target_repo,
448
+ blobs=dict(start.get("tracked", {})),
449
+ )
450
+ start_runtime = RuntimeRootState(
451
+ root=Path(start["runtime_root"]) if start.get("runtime_root") else factory_root,
452
+ entries=dict(start.get("runtime", {})),
453
+ )
454
+
455
+ end_tracked = _tracked_state(target_repo)
456
+ end_runtime = _runtime_root_state(factory_root, Path(context.worktree_path))
457
+
458
+ tracked_changes = start_tracked.changed(end_tracked)
459
+ runtime_changes = end_runtime.changes_since(start_runtime)
460
+
461
+ finding = _build_finding(context, tracked_changes, runtime_changes, seen_at)
462
+ if finding is not None:
463
+ _persist_finding(snapshot_dir, finding, factory_root)
464
+ # Clean up the per-attempt snapshot now that comparison is done.
465
+ with contextlib.suppress(OSError):
466
+ snapshot_path.unlink(missing_ok=True)
467
+ return finding
468
+
469
+
470
+ def _persist_finding(
471
+ snapshot_dir: Path,
472
+ finding: Finding,
473
+ factory_root: Path,
474
+ ) -> None:
475
+ """Write the finding to the out-of-band batch and, if reachable, to doctor.db."""
476
+ batch_path = snapshot_dir / "findings.json"
477
+ batch = {"source": "agent-worktree-detector", "findings": []}
478
+ if batch_path.exists():
479
+ try:
480
+ batch = json.loads(batch_path.read_text(encoding="utf-8"))
481
+ except (json.JSONDecodeError, OSError):
482
+ batch = {"source": "agent-worktree-detector", "findings": []}
483
+
484
+ existing = [entry for entry in batch["findings"] if entry.get("key") != finding.key]
485
+ existing.append({
486
+ "key": finding.key,
487
+ "category": finding.category,
488
+ "severity": finding.severity.value,
489
+ "summary": finding.summary,
490
+ "refs": finding.refs,
491
+ "notes": finding.notes,
492
+ })
493
+ batch["findings"] = existing
494
+ batch_path.write_text(json.dumps(batch, indent=2, sort_keys=True), encoding="utf-8")
495
+
496
+ # Also write to doctor.db when the store path exists and is readable.
497
+ db_path = factory_root / "doctor.db"
498
+ try:
499
+ conn = connect(db_path)
500
+ try:
501
+ report(conn, finding, seen_at=finding.last_seen)
502
+ finally:
503
+ conn.close()
504
+ except (OSError, sqlite3.Error):
505
+ # The runtime root may be gone; the batch file is the surviving record.
506
+ pass
507
+
508
+
509
+ import contextlib # noqa: E402 - imported at the end to avoid disrupting module docstring order