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.
- ergane_cli-0.1.0.dist-info/METADATA +148 -0
- ergane_cli-0.1.0.dist-info/RECORD +121 -0
- ergane_cli-0.1.0.dist-info/WHEEL +4 -0
- ergane_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ergane_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- ergane_cli-0.1.0.dist-info/licenses/NOTICE +27 -0
- factory/__init__.py +0 -0
- factory/activities/__init__.py +0 -0
- factory/activities/agent_activities.py +745 -0
- factory/activities/merge_activities.py +654 -0
- factory/activities/notify_activities.py +1078 -0
- factory/activities/roadmap_activities.py +577 -0
- factory/activities/usage_activities.py +593 -0
- factory/activities/verify_activities.py +617 -0
- factory/cli/__init__.py +0 -0
- factory/cli/completion.py +150 -0
- factory/cli/doctor.py +484 -0
- factory/cli/env.py +247 -0
- factory/cli/errors.py +77 -0
- factory/cli/init.py +1057 -0
- factory/cli/install.py +679 -0
- factory/cli/main.py +212 -0
- factory/cli/nouns/__init__.py +59 -0
- factory/cli/nouns/answer.py +134 -0
- factory/cli/nouns/build.py +1222 -0
- factory/cli/nouns/completion.py +13 -0
- factory/cli/nouns/doctor.py +13 -0
- factory/cli/nouns/env.py +13 -0
- factory/cli/nouns/escalations.py +93 -0
- factory/cli/nouns/findings.py +13 -0
- factory/cli/nouns/init.py +13 -0
- factory/cli/nouns/install.py +99 -0
- factory/cli/nouns/repo.py +13 -0
- factory/cli/nouns/roadmap.py +13 -0
- factory/cli/nouns/spec.py +511 -0
- factory/cli/nouns/status.py +18 -0
- factory/cli/nouns/usage.py +13 -0
- factory/cli/nouns/worker.py +69 -0
- factory/cli/repo.py +569 -0
- factory/cli/repo_export.py +282 -0
- factory/cli/roadmap.py +434 -0
- factory/cli/status.py +844 -0
- factory/cli/usage.py +129 -0
- factory/config.py +262 -0
- factory/controlplane/__init__.py +0 -0
- factory/controlplane/config.py +863 -0
- factory/controlplane/resolve.py +434 -0
- factory/controlplane/verify.py +827 -0
- factory/discovery/__init__.py +3 -0
- factory/discovery/llm_scanner.py +176 -0
- factory/doctor/__init__.py +0 -0
- factory/doctor/cli.py +392 -0
- factory/doctor/models.py +165 -0
- factory/doctor/probes.py +522 -0
- factory/doctor/scaffold.py +184 -0
- factory/doctor/store.py +449 -0
- factory/env.py +131 -0
- factory/escalation/__init__.py +0 -0
- factory/escalation/client.py +168 -0
- factory/escalation/question.py +161 -0
- factory/escalation/workflow.py +470 -0
- factory/locking.py +85 -0
- factory/mergequeue/__init__.py +21 -0
- factory/mergequeue/classify.py +133 -0
- factory/mergequeue/forge.py +401 -0
- factory/mergequeue/gh.py +484 -0
- factory/mergequeue/github_forge.py +349 -0
- factory/mergequeue/merge_queue_ruleset.json +32 -0
- factory/mergequeue/messages.py +141 -0
- factory/mergequeue/models.py +324 -0
- factory/mergequeue/onboard.py +537 -0
- factory/mergequeue/wiring.py +466 -0
- factory/notify/__init__.py +0 -0
- factory/notify/adapter.py +263 -0
- factory/notify/messages.py +474 -0
- factory/notify/service.py +809 -0
- factory/notify/webhook.py +151 -0
- factory/personas.yaml +161 -0
- factory/registry.py +583 -0
- factory/roadmap/__init__.py +8 -0
- factory/roadmap/cli.py +125 -0
- factory/roadmap/discovery.py +286 -0
- factory/roadmap/models.py +583 -0
- factory/roadmap/schedule.py +377 -0
- factory/roadmap/workflow.py +1364 -0
- factory/supervision/__init__.py +0 -0
- factory/supervision/alert.py +239 -0
- factory/supervision/probe.py +467 -0
- factory/supervision/temporal_server.py +121 -0
- factory/supervision/units.py +649 -0
- factory/usage/__init__.py +0 -0
- factory/usage/aggregate.py +113 -0
- factory/usage/cli.py +223 -0
- factory/usage/ledger.py +295 -0
- factory/usage/litellm_client.py +486 -0
- factory/usage/models.py +149 -0
- factory/verify/__init__.py +0 -0
- factory/verify/criteria.py +488 -0
- factory/verify/diffbounds.py +159 -0
- factory/verify/diffcheck.py +506 -0
- factory/verify/factory_yaml.py +842 -0
- factory/verify/gates.py +1341 -0
- factory/verify/judge.py +775 -0
- factory/verify/ladder.py +145 -0
- factory/verify/models.py +745 -0
- factory/verify/question.py +167 -0
- factory/verify/store.py +1366 -0
- factory/verify/toolchain.py +370 -0
- factory/worker.py +296 -0
- factory/workgraph/__init__.py +0 -0
- factory/workgraph/adapter.py +1333 -0
- factory/workgraph/cli.py +801 -0
- factory/workgraph/delta.py +278 -0
- factory/workgraph/derive.py +607 -0
- factory/workgraph/detector.py +509 -0
- factory/workgraph/landed.py +496 -0
- factory/workgraph/models.py +494 -0
- factory/workgraph/preflight.py +651 -0
- factory/workgraph/prompt.py +796 -0
- factory/workgraph/workflow.py +2733 -0
- 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
|