autoforge-cli 0.1.0a0__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 (81) hide show
  1. autoforge/__init__.py +5 -0
  2. autoforge/agent_worktree.py +653 -0
  3. autoforge/certificate.py +495 -0
  4. autoforge/child_env.py +871 -0
  5. autoforge/claims.py +726 -0
  6. autoforge/claude_stream.py +501 -0
  7. autoforge/cli.py +2884 -0
  8. autoforge/config.py +2178 -0
  9. autoforge/config_fingerprint.py +374 -0
  10. autoforge/doctor.py +1551 -0
  11. autoforge/doctor_sandbox.py +691 -0
  12. autoforge/effect_ops.py +1272 -0
  13. autoforge/effects.py +3210 -0
  14. autoforge/engine.py +16637 -0
  15. autoforge/epic_commands.py +1486 -0
  16. autoforge/epic_graph.py +852 -0
  17. autoforge/errors.py +536 -0
  18. autoforge/evidence.py +1722 -0
  19. autoforge/executor.py +1601 -0
  20. autoforge/executor_duplex.py +985 -0
  21. autoforge/executor_supervised.py +144 -0
  22. autoforge/git_transport.py +970 -0
  23. autoforge/github.py +3402 -0
  24. autoforge/interaction.py +1027 -0
  25. autoforge/local_workspace.py +1253 -0
  26. autoforge/locking.py +1050 -0
  27. autoforge/loop_detect.py +660 -0
  28. autoforge/loop_guard.py +404 -0
  29. autoforge/materialize.py +722 -0
  30. autoforge/ownership.py +240 -0
  31. autoforge/pi_rpc.py +713 -0
  32. autoforge/premerge.py +181 -0
  33. autoforge/profiles.py +106 -0
  34. autoforge/progress.py +392 -0
  35. autoforge/prompts/__init__.py +173 -0
  36. autoforge/prompts/acceptance_actions.md +39 -0
  37. autoforge/prompts/analyze_execute.md +128 -0
  38. autoforge/prompts/common.md +132 -0
  39. autoforge/prompts/correction.md +12 -0
  40. autoforge/prompts/fix.md +252 -0
  41. autoforge/prompts/local_analyze_execute.md +69 -0
  42. autoforge/prompts/local_common.md +149 -0
  43. autoforge/prompts/local_fix.md +96 -0
  44. autoforge/prompts/local_review.md +103 -0
  45. autoforge/prompts/operator_acceptance.md +61 -0
  46. autoforge/prompts/replan_reexecute.md +991 -0
  47. autoforge/prompts/review.md +312 -0
  48. autoforge/prompts/update_epic.md +120 -0
  49. autoforge/prompts/update_epic_rerequest.md +85 -0
  50. autoforge/prompts/validation_evidence.md +26 -0
  51. autoforge/prompts/validation_failure.md +22 -0
  52. autoforge/prompts/worktree_state.md +45 -0
  53. autoforge/providers.py +2893 -0
  54. autoforge/read_context.py +389 -0
  55. autoforge/redaction.py +408 -0
  56. autoforge/replan.py +208 -0
  57. autoforge/replan_txn.py +1930 -0
  58. autoforge/result_parser.py +2234 -0
  59. autoforge/roadmap.py +151 -0
  60. autoforge/run_contract.py +408 -0
  61. autoforge/run_namespace.py +839 -0
  62. autoforge/runlog.py +764 -0
  63. autoforge/safefs.py +1717 -0
  64. autoforge/sandbox.py +1313 -0
  65. autoforge/sandbox_bwrap.py +815 -0
  66. autoforge/sandbox_pin.py +170 -0
  67. autoforge/sandbox_policy.py +1107 -0
  68. autoforge/scheduler.py +1143 -0
  69. autoforge/scheduler_state.py +2316 -0
  70. autoforge/scheduler_stop.py +421 -0
  71. autoforge/sdk_policy.py +988 -0
  72. autoforge/state.py +2417 -0
  73. autoforge/transitions.py +388 -0
  74. autoforge/validation.py +324 -0
  75. autoforge/worker.py +568 -0
  76. autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
  77. autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
  78. autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
  79. autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
  80. autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
  81. autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1253 @@
1
+ """Independent verification of the local working tree (LOCAL mode).
2
+
3
+ In REMOTE mode GitHub is the source of truth: a `CONTROL_RESULT` claiming a
4
+ PR exists is checked against `gh`. LOCAL mode has no GitHub, so the working
5
+ tree itself is the source of truth and this module is the only place that
6
+ reads it. The engine never shells out to `git` directly.
7
+
8
+ Everything here is a *read*. The controller never commits, stages, resets,
9
+ checks out, stashes or discards anything: a local run leaves the operator's
10
+ work exactly where it found it, and an implementation may legitimately
11
+ consist of uncommitted (even untracked) files.
12
+
13
+ Workspace identity
14
+ ------------------
15
+ A review is only meaningful if the controller can say *which bytes* were
16
+ reviewed and prove they did not change afterwards. That is the workspace
17
+ snapshot, and it is defined by one rule:
18
+
19
+ The snapshot covers **every entry in the working tree**, found by the
20
+ controller walking the tree itself, and the only entries it does not
21
+ cover are the ones an explicitly enumerated, fingerprinted exclusion rule
22
+ removes. There is no third category.
23
+
24
+ This is deliberately *not* built on ``git status``. Git reports what is
25
+ interesting to git, which is a moving target: an ignored file, a file marked
26
+ ``assume-unchanged`` or ``skip-worktree``, a mode change under
27
+ ``core.fileMode=false``, a clean tracked file, the contents of a submodule —
28
+ each is invisible to some git invocation while being perfectly able to change
29
+ what a reviewer concluded. Asking git "what changed?" and fingerprinting the
30
+ answer makes the snapshot's completeness a property of git's configuration,
31
+ which the agent under review can edit. The controller walks the tree instead,
32
+ so no git setting, index flag or ignore rule can subtract from the snapshot.
33
+
34
+ Git keeps exactly one job in LOCAL mode: telling the controller where the
35
+ repository is (its working tree and its git directory) and what its *anchor*
36
+ is (HEAD and the checked-out branch). Those are separate facts, checked
37
+ separately; no decision about which bytes are bound depends on git.
38
+
39
+ Classification is total. Every entry the walk meets is exactly one of:
40
+
41
+ =================== =========================================================
42
+ regular file included: permission bits + SHA-256 of its bytes
43
+ directory included: permission bits; the walk descends into it
44
+ symbolic link included: SHA-256 of its *link text*, and the target must
45
+ resolve inside the working tree and must neither lie in
46
+ an excluded region nor *contain* one — a link to bytes
47
+ the snapshot does not cover, or to a directory through
48
+ which such bytes can be reached at an unexcluded path,
49
+ would let those bytes change without the fingerprint
50
+ moving
51
+ excluded an enumerated rule matched: the rule and the path are
52
+ recorded *in* the fingerprint, so what is not covered is
53
+ itself part of the workspace's identity
54
+ anything else refused (fail closed): FIFOs, sockets, devices,
55
+ unreadable entries, and nested git repositories
56
+ =================== =========================================================
57
+
58
+ There is no "git did not report it, so the controller did not see it" state.
59
+
60
+ Exclusion rules
61
+ ---------------
62
+ Exactly two kinds of rule exist, and both are recorded in the fingerprint:
63
+
64
+ ``gitdir``
65
+ This repository's own git directory, matched by ``(st_dev, st_ino)``
66
+ rather than by the name ``.git`` — a name is not evidence. (A linked
67
+ worktree's ``.git`` *file* at the top level is matched too: it is the
68
+ pointer git itself put there.) Any **other** entry named ``.git`` is
69
+ refused: a submodule or a nested repository holds a whole second tree
70
+ whose contents git will not show through the outer one, and LOCAL v1
71
+ does not support binding it. Fail closed, not silently unbound.
72
+
73
+ ``exclude:<pattern>``
74
+ An operator-declared pattern from ``local.exclude``. This exists because
75
+ a complete walk really is complete: ``.venv/``, ``node_modules/`` and
76
+ ``__pycache__/`` are in the working tree, the test suite the controller
77
+ itself runs rewrites them, and no amount of cleverness makes "the
78
+ reviewer changed nothing" true over bytes that a build tool rewrites.
79
+ The honest answer is an explicit, operator-visible list that is hashed
80
+ into the fingerprint and named to the reviewer in its prompt — not a
81
+ built-in default that quietly decides which bytes do not count.
82
+
83
+ A LOCAL state directory is **not** an exclusion rule, because it is not
84
+ inside the reviewed tree: see :meth:`LocalWorkspace.check_state_dir_location`.
85
+
86
+ Cost
87
+ ----
88
+ A complete walk is bounded by ``local.max_workspace_entries`` and
89
+ ``local.max_workspace_bytes``. Exceeding either is a *refusal* naming the
90
+ largest subtrees seen so far and the exact YAML to paste — never a silent
91
+ downgrade to metadata-only hashing, which would accept an equal-sized
92
+ replacement with a restored mtime.
93
+ """
94
+
95
+ from __future__ import annotations
96
+
97
+ import hashlib
98
+ import os
99
+ import posixpath
100
+ import re
101
+ import stat
102
+ from collections.abc import Callable, Iterable
103
+ from dataclasses import dataclass
104
+ from fnmatch import fnmatchcase
105
+ from pathlib import Path
106
+
107
+ from .errors import ConfigurationError, VerificationError
108
+ from .executor import ExecutionRequest, ExecutionResult, execute
109
+ from .git_transport import local_git_request
110
+ from .prompts import escape_inline
111
+ from .run_contract import WorkspacePolicy
112
+ from .safefs import (
113
+ ReadLimitExceeded,
114
+ SafeRoot,
115
+ UnreadableEntryError,
116
+ WalkBudgetExceeded,
117
+ entry_kind,
118
+ open_regular_at,
119
+ readlink_at,
120
+ )
121
+
122
+ Runner = Callable[[ExecutionRequest], ExecutionResult]
123
+
124
+ GIT_TIMEOUT_SECONDS = 60
125
+
126
+ # Bytes read per file when hashing working-tree content.
127
+ _CHUNK = 1024 * 1024
128
+
129
+ FEATURE_SPEC_SUFFIXES = (".md", ".markdown")
130
+
131
+ #: Bumped whenever the *meaning* of a fingerprint changes, so a fingerprint
132
+ #: persisted by an older controller can never compare equal to a new one.
133
+ SNAPSHOT_TAG = "autoforge-workspace-v4"
134
+
135
+ DEFAULT_MAX_ENTRIES = 50_000
136
+ DEFAULT_MAX_BYTES = 512 * 1024 * 1024
137
+
138
+ # A full object name as `git rev-parse` prints it (40 hex for SHA-1, 64 for a
139
+ # SHA-256 repository). Anything else from a successful read is not an anchor.
140
+ _OBJECT_NAME_RE = re.compile(r"^[0-9a-f]{40}(?:[0-9a-f]{24})?$")
141
+
142
+
143
+ def hash_bytes(data: bytes) -> str:
144
+ return hashlib.sha256(data).hexdigest()
145
+
146
+
147
+ # -- exclusion rules ---------------------------------------------------------
148
+ def normalize_exclude_pattern(pattern: str) -> str:
149
+ """Canonical text of one ``local.exclude`` pattern (ConfigurationError if unusable).
150
+
151
+ The canonical form is what gets hashed into the fingerprint, so two
152
+ spellings of the same rule must not produce two different workspace
153
+ identities.
154
+ """
155
+ if not isinstance(pattern, str):
156
+ raise ConfigurationError(f"local.exclude entry must be a string, got {pattern!r}")
157
+ text = pattern.strip().replace(os.sep, "/").strip("/")
158
+ if not text:
159
+ raise ConfigurationError("local.exclude entry must not be empty")
160
+ if "\0" in text:
161
+ raise ConfigurationError(f"local.exclude entry {pattern!r} contains a NUL byte")
162
+ parts = [p for p in text.split("/") if p != ""]
163
+ if any(p in (".", "..") for p in parts):
164
+ raise ConfigurationError(
165
+ f"local.exclude entry {pattern!r} may not contain '.' or '..': patterns are "
166
+ "matched against paths relative to the repository root"
167
+ )
168
+ return "/".join(parts)
169
+
170
+
171
+ def _match_components(pattern: tuple[str, ...], path: tuple[str, ...]) -> bool:
172
+ """Component-wise glob match with ``**`` spanning zero or more components."""
173
+ if not pattern:
174
+ return not path
175
+ head, rest = pattern[0], pattern[1:]
176
+ if head == "**":
177
+ # Zero components, or one component consumed and try again.
178
+ if _match_components(rest, path):
179
+ return True
180
+ return bool(path) and _match_components(pattern, path[1:])
181
+ if not path:
182
+ return False
183
+ if not fnmatchcase(path[0], head):
184
+ return False
185
+ return _match_components(rest, path[1:])
186
+
187
+
188
+ @dataclass(frozen=True)
189
+ class Exclusion:
190
+ """One reason a path is not covered by the snapshot."""
191
+
192
+ path: str # repository-relative, "/"-separated
193
+ rule: str # canonical rule text, e.g. "gitdir" or "exclude:.venv"
194
+
195
+
196
+ # -- snapshot ----------------------------------------------------------------
197
+ KIND_FILE = "file"
198
+ KIND_DIR = "dir"
199
+ KIND_LINK = "link"
200
+ KIND_EXCLUDED = "excluded"
201
+
202
+
203
+ @dataclass(frozen=True)
204
+ class WorkspaceEntry:
205
+ """One classified entry of the working tree.
206
+
207
+ ``digest`` is the SHA-256 of a regular file's bytes, or of a symbolic
208
+ link's target text; it is empty for a directory and for an excluded path.
209
+ ``detail`` carries a directory's permission bits and, for an excluded
210
+ path, the rule that excluded it.
211
+ """
212
+
213
+ path: str
214
+ kind: str
215
+ mode: str
216
+ digest: str
217
+ detail: str = ""
218
+
219
+
220
+ @dataclass(frozen=True)
221
+ class WorkspaceSnapshot:
222
+ """Exactly which bytes the working tree held when this was taken."""
223
+
224
+ root: Path
225
+ head_sha: str
226
+ branch: str
227
+ entries: tuple[WorkspaceEntry, ...]
228
+ rules: tuple[str, ...]
229
+ fingerprint: str
230
+ total_bytes: int
231
+
232
+ @property
233
+ def anchor(self) -> str:
234
+ """HEAD + branch, the git fact bound independently of the tree bytes."""
235
+ head = self.head_sha or "(unborn)"
236
+ branch = self.branch or "(detached)"
237
+ return f"{head}@{branch}"
238
+
239
+ @property
240
+ def file_count(self) -> int:
241
+ return sum(1 for e in self.entries if e.kind == KIND_FILE)
242
+
243
+ @property
244
+ def exclusions(self) -> tuple[Exclusion, ...]:
245
+ return tuple(
246
+ Exclusion(path=e.path, rule=e.detail) for e in self.entries if e.kind == KIND_EXCLUDED
247
+ )
248
+
249
+ def describe(self, limit: int = 12) -> str:
250
+ """One line for a prompt or a block reason."""
251
+ files = self.file_count
252
+ dirs = sum(1 for e in self.entries if e.kind == KIND_DIR)
253
+ links = sum(1 for e in self.entries if e.kind == KIND_LINK)
254
+ excluded = self.exclusions
255
+ parts = [
256
+ f"{files} file(s), {dirs} director(ies), {links} symlink(s), "
257
+ f"{self.total_bytes} byte(s) hashed"
258
+ ]
259
+ if excluded:
260
+ shown = ", ".join(
261
+ f"{escape_inline(x.path)} [{escape_inline(x.rule)}]" for x in excluded[:limit]
262
+ )
263
+ more = "" if len(excluded) <= limit else f" and {len(excluded) - limit} more"
264
+ parts.append(f"excluded: {shown}{more}")
265
+ return " | ".join(parts)
266
+
267
+ def describe_exclusions(self) -> str:
268
+ """What the reviewer must be told is *not* bound by the fingerprint."""
269
+ excluded = self.exclusions
270
+ if not excluded:
271
+ return "(none — every entry in the working tree is covered by the fingerprint)"
272
+ return "; ".join(f"{escape_inline(x.path)} [{escape_inline(x.rule)}]" for x in excluded)
273
+
274
+
275
+ def _fingerprint(
276
+ entries: Iterable[WorkspaceEntry],
277
+ rules: Iterable[str],
278
+ ) -> str:
279
+ """SHA-256 over the classified tree and the rules that shaped it.
280
+
281
+ Every field is length-prefixed before it is fed to the hash, because a
282
+ path may contain any byte except NUL and ``/`` — including a newline —
283
+ and a separator-delimited encoding would let two different trees produce
284
+ one digest.
285
+
286
+ The git anchor is deliberately *not* part of this: "which bytes are in the
287
+ tree" and "which commit is checked out" are two facts with two different
288
+ failure modes, and mixing them would report a plain ``git commit`` (which
289
+ changes HEAD and nothing else) as "the reviewer modified the working
290
+ tree".
291
+ """
292
+ h = hashlib.sha256()
293
+
294
+ def feed(value: str) -> None:
295
+ raw = value.encode("utf-8", errors="surrogateescape")
296
+ h.update(str(len(raw)).encode("ascii"))
297
+ h.update(b"\0")
298
+ h.update(raw)
299
+ h.update(b"\0")
300
+
301
+ feed(SNAPSHOT_TAG)
302
+ ordered_rules = sorted(rules)
303
+ feed(str(len(ordered_rules)))
304
+ for rule in ordered_rules:
305
+ feed(rule)
306
+ ordered = sorted(entries, key=lambda e: e.path.encode("utf-8", errors="surrogateescape"))
307
+ feed(str(len(ordered)))
308
+ for entry in ordered:
309
+ feed(entry.kind)
310
+ feed(entry.mode)
311
+ feed(entry.digest)
312
+ feed(entry.detail)
313
+ feed(entry.path)
314
+ return h.hexdigest()
315
+
316
+
317
+ def _mode_text(st: os.stat_result) -> str:
318
+ return f"{stat.S_IMODE(st.st_mode):04o}"
319
+
320
+
321
+ def _refuse(path: str, what: str, remedy: str) -> VerificationError:
322
+ return VerificationError(
323
+ f"cannot bind the working tree: {path} is {what}. A LOCAL run must be able to say "
324
+ f"exactly which bytes the reviewer reviewed, and it cannot for this entry. {remedy}"
325
+ )
326
+
327
+
328
+ @dataclass(frozen=True)
329
+ class StatusEntry:
330
+ """One record of ``git status --porcelain=v1 -z``.
331
+
332
+ ``code`` is git's two-letter status (``" M"``, ``"A "``, ``"??"``, ...),
333
+ ``path`` the path the record names and ``orig_path`` the path a rename or
334
+ copy came from (``""`` for every other record). Paths are as git prints
335
+ them, relative to the working tree's root, and are not verified to exist.
336
+ """
337
+
338
+ code: str
339
+ path: str
340
+ orig_path: str = ""
341
+
342
+
343
+ class LocalWorkspace:
344
+ """Read-only view of the git working tree a LOCAL run operates on."""
345
+
346
+ def __init__(
347
+ self,
348
+ workdir: str | Path = ".",
349
+ runner: Runner | None = None,
350
+ timeout_seconds: int = GIT_TIMEOUT_SECONDS,
351
+ exclude: Iterable[str] = (),
352
+ max_entries: int = DEFAULT_MAX_ENTRIES,
353
+ max_bytes: int = DEFAULT_MAX_BYTES,
354
+ ) -> None:
355
+ self.workdir = Path(workdir)
356
+ self._runner: Runner = runner or execute
357
+ self.timeout = timeout_seconds
358
+ self.exclude: tuple[str, ...] = tuple(
359
+ sorted({normalize_exclude_pattern(p) for p in exclude})
360
+ )
361
+ self.max_entries = max_entries
362
+ self.max_bytes = max_bytes
363
+ self._root: Path | None = None
364
+ self._git_dirs: tuple[Path, ...] | None = None
365
+ # Content-digest memo, keyed by identity *and* change indicators. The
366
+ # key is a cache key only: it never becomes part of the fingerprint,
367
+ # so a forged mtime can at worst cost a re-hash, never bind the wrong
368
+ # bytes... and ctime cannot be set backwards without root at all.
369
+ self._digests: dict[tuple[int, int, int, int, int, int], str] = {}
370
+
371
+ # -- reader policy ----------------------------------------------------
372
+ def policy(self) -> WorkspacePolicy:
373
+ """Everything about this reader that shapes a snapshot, as contract data.
374
+
375
+ A fingerprint binds the working tree *as this reader classifies it*,
376
+ so the reader's configuration is part of what a review means (see
377
+ :class:`autoforge.run_contract.WorkspacePolicy`). The snapshot tag
378
+ is included because the walk's classification rules are part of the
379
+ reader too.
380
+ """
381
+ return WorkspacePolicy(
382
+ exclude=self.exclude,
383
+ max_entries=self.max_entries,
384
+ max_bytes=self.max_bytes,
385
+ snapshot_tag=SNAPSHOT_TAG,
386
+ )
387
+
388
+ # -- git plumbing -----------------------------------------------------
389
+ def _git(self, argv: list[str], *, allow_failure: bool = False) -> ExecutionResult:
390
+ res = self._runner(
391
+ # --no-optional-locks: every call here is a read, and a
392
+ # `git status` that refreshes the index would write
393
+ # `.git/index` — a side effect a `--dry-run` must not have.
394
+ # Hardened like every controller git process (ADR 0004 D7.3):
395
+ # the repository's hooks and file-system monitor are the agent's
396
+ # to write, so neither runs, and nothing git starts holds a
397
+ # credential. None of this changes what a read reports.
398
+ local_git_request(
399
+ ["--no-optional-locks", *argv],
400
+ cwd=str(self.workdir),
401
+ timeout_seconds=self.timeout,
402
+ )
403
+ )
404
+ if res.timed_out:
405
+ raise VerificationError(f"`git {' '.join(argv)}` timed out in {self.workdir}")
406
+ if res.truncated:
407
+ # Head + marker + tail of a listing is not the working tree.
408
+ raise VerificationError(
409
+ f"`git {' '.join(argv)}` output in {self.workdir} was truncated at the "
410
+ "executor's capture bound"
411
+ )
412
+ if res.exit_code != 0 and not allow_failure:
413
+ detail = (res.stderr or res.stdout).strip().splitlines()
414
+ raise VerificationError(
415
+ f"`git {' '.join(argv)}` failed in {self.workdir}: "
416
+ f"{detail[-1] if detail else f'exit {res.exit_code}'}"
417
+ )
418
+ return res
419
+
420
+ def root(self) -> Path:
421
+ """Absolute path of the repository working tree containing ``workdir``."""
422
+ if self._root is None:
423
+ res = self._git(["rev-parse", "--show-toplevel"], allow_failure=True)
424
+ if res.exit_code != 0:
425
+ detail = (res.stderr or res.stdout).strip().splitlines()
426
+ raise VerificationError(
427
+ f"{Path(self.workdir).resolve()} is not inside a git repository "
428
+ f"({detail[-1] if detail else 'git rev-parse failed'}); "
429
+ "local mode operates on a git working tree"
430
+ )
431
+ top = (res.stdout or "").strip()
432
+ if not top:
433
+ raise VerificationError(
434
+ f"git did not report a working tree for {Path(self.workdir).resolve()}"
435
+ )
436
+ self._root = Path(os.path.realpath(top))
437
+ return self._root
438
+
439
+ def git_dirs(self) -> tuple[Path, ...]:
440
+ """This repository's git directories (per-worktree and common), resolved.
441
+
442
+ Both are needed: in a linked worktree ``--git-dir`` is
443
+ ``<main>/.git/worktrees/<name>`` while ``--git-common-dir`` is
444
+ ``<main>/.git``, and either can be the directory that physically sits
445
+ inside the tree being walked.
446
+ """
447
+ if self._git_dirs is None:
448
+ found: list[Path] = []
449
+ for flag in ("--git-dir", "--git-common-dir"):
450
+ res = self._git(["rev-parse", flag], allow_failure=True)
451
+ if res.exit_code != 0:
452
+ raise VerificationError(
453
+ f"cannot locate the git directory of {self.workdir} "
454
+ f"(`git rev-parse {flag}` exited {res.exit_code}); the controller "
455
+ "will not walk a working tree whose git directory it cannot identify, "
456
+ "because it could not then tell the repository's own metadata apart "
457
+ "from reviewable content"
458
+ )
459
+ text = (res.stdout or "").strip()
460
+ if not text:
461
+ raise VerificationError(
462
+ f"`git rev-parse {flag}` returned nothing for {self.workdir}"
463
+ )
464
+ found.append(Path(os.path.realpath(Path(self.workdir) / text)))
465
+ self._git_dirs = tuple(dict.fromkeys(found))
466
+ return self._git_dirs
467
+
468
+ def local_state_dir(self) -> Path:
469
+ """Where a LOCAL run keeps its runtime state by default.
470
+
471
+ Inside the git directory, which is *outside* the reviewed tree by
472
+ construction. That is the whole point: a state directory in the
473
+ working tree would have to be carved out of the workspace snapshot by
474
+ name, and "these particular file names do not count" is exactly the
475
+ kind of rule an agent writing into the tree can exploit.
476
+ """
477
+ return self.git_dirs()[0] / "autoforge" / "state"
478
+
479
+ def head_sha(self) -> str:
480
+ """Current HEAD, or "" when the repository has no commit yet.
481
+
482
+ ``rev-parse --verify --quiet`` rather than plain ``rev-parse``: it
483
+ separates "this ref does not resolve" (exit 1, the unborn HEAD of a
484
+ repository with no commit) from "the read failed" (exit 128: a damaged
485
+ repository, a permissions change, a missing object store). Both used
486
+ to collapse into ``""``, which is also the legitimate value for an
487
+ unborn HEAD — so a failing read looked exactly like "HEAD has not
488
+ moved", and a run whose git identity could no longer be established
489
+ continued instead of failing closed.
490
+ """
491
+ res = self._git(["rev-parse", "--verify", "--quiet", "HEAD"], allow_failure=True)
492
+ if res.exit_code == 1:
493
+ return "" # unborn HEAD: the ref does not resolve, and that is a fact
494
+ if res.exit_code != 0:
495
+ raise VerificationError(self._anchor_read_failure("HEAD", res))
496
+ sha = (res.stdout or "").strip().lower()
497
+ if not _OBJECT_NAME_RE.match(sha):
498
+ raise VerificationError(
499
+ f"`git rev-parse --verify HEAD` succeeded in {self.workdir} but returned "
500
+ f"{sha!r}, which is not an object name; the run's git anchor cannot be "
501
+ "established and the controller will not continue without it"
502
+ )
503
+ return sha
504
+
505
+ def branch(self) -> str:
506
+ """Checked-out branch name, or "" when HEAD is detached.
507
+
508
+ ``symbolic-ref`` rather than ``rev-parse --abbrev-ref HEAD``: the
509
+ latter reports the literal string "HEAD" for a detached HEAD, which is
510
+ indistinguishable from a branch actually named ``HEAD``. An unborn
511
+ HEAD still has a symbolic ref, so a repository with no commit reports
512
+ its branch normally.
513
+
514
+ ``--quiet`` makes a detached HEAD exit 1 silently, which is the one
515
+ non-zero status that means "not a symbolic ref" rather than "the read
516
+ failed"; every other status fails closed, for the reason given in
517
+ :meth:`head_sha`.
518
+ """
519
+ res = self._git(["symbolic-ref", "--quiet", "--short", "HEAD"], allow_failure=True)
520
+ if res.exit_code == 1:
521
+ return "" # detached HEAD: not a symbolic ref, and that is a fact
522
+ if res.exit_code != 0:
523
+ raise VerificationError(self._anchor_read_failure("the checked-out branch", res))
524
+ name = (res.stdout or "").strip()
525
+ if not name:
526
+ raise VerificationError(
527
+ f"`git symbolic-ref HEAD` succeeded in {self.workdir} but named no branch; "
528
+ "the run's git anchor cannot be established"
529
+ )
530
+ return name
531
+
532
+ def _anchor_read_failure(self, what: str, res: ExecutionResult) -> str:
533
+ detail = (res.stderr or res.stdout or "").strip().splitlines()
534
+ return (
535
+ f"cannot read {what} in {self.workdir} (git exited {res.exit_code}: "
536
+ f"{detail[-1] if detail else 'no output'}). A local run is pinned to the HEAD "
537
+ "and branch it started on, and a failed read is not evidence that they are "
538
+ "unchanged — treating it as one would let the run continue over a repository "
539
+ "whose identity the controller can no longer establish."
540
+ )
541
+
542
+ def dirty_paths(self) -> list[str]:
543
+ """Paths git considers changed, for the *start-up dirty-tree policy* only.
544
+
545
+ This is a policy question ("is the operator's work in the way?"), not
546
+ an identity question: nothing derived from it enters the fingerprint.
547
+ Git's own notion of "changed" is exactly right here — it is the
548
+ operator's mental model of their uncommitted work — and it is exactly
549
+ wrong for identity, which is why the two are separate methods.
550
+ """
551
+ paths: list[str] = []
552
+ for entry in self.status_entries():
553
+ if entry.orig_path:
554
+ paths.append(entry.orig_path)
555
+ paths.append(entry.path)
556
+ return sorted(dict.fromkeys(paths))
557
+
558
+ def status_entries(self) -> list[StatusEntry]:
559
+ """The records behind :meth:`dirty_paths`, in git's order, with their codes.
560
+
561
+ The same single ``git status`` read, kept as git reports it rather
562
+ than reduced to a set of paths, for a caller that has to say *how*
563
+ each path is uncommitted (#187). It is the operator's notion of
564
+ uncommitted work, never an identity.
565
+ """
566
+ res = self._git(["status", "--porcelain=v1", "-z", "--untracked-files=all"])
567
+ out = res.stdout or ""
568
+ fields = out.split("\0")
569
+ entries: list[StatusEntry] = []
570
+ index = 0
571
+ while index < len(fields):
572
+ record = fields[index]
573
+ index += 1
574
+ if not record:
575
+ continue
576
+ if len(record) < 4:
577
+ raise VerificationError(f"cannot parse `git status` record {record!r}")
578
+ code, path, old = record[:2], record[3:], ""
579
+ if code[0] in "RC" or code[1] in "RC":
580
+ # A rename or a copy (`status.renames=copies`) is two
581
+ # NUL-separated fields: "<code> <new>\0<old>". Read as a
582
+ # record of its own, the source path would be cut into a
583
+ # made-up code and path.
584
+ if index < len(fields):
585
+ old = fields[index]
586
+ index += 1
587
+ entries.append(StatusEntry(code=code, path=path, orig_path=old))
588
+ return entries
589
+
590
+ # -- state directory --------------------------------------------------
591
+ def check_state_dir_location(self, state_dir: str | Path) -> None:
592
+ """Require the state directory to sit outside the reviewed tree.
593
+
594
+ LOCAL mode's workspace snapshot covers the *whole* working tree. A
595
+ state directory inside it would be rewritten by the controller on
596
+ every step, so it would have to be excluded — and an exclusion carved
597
+ out by path is a region of the tree an agent can write into knowing
598
+ the fingerprint will not move. Putting runtime state in the git
599
+ directory removes the problem instead of managing it: the git
600
+ directory is already excluded, by inode identity, for reasons that
601
+ have nothing to do with AutoForge.
602
+ """
603
+ root = self.root()
604
+ resolved = Path(os.path.realpath(state_dir))
605
+ if not _is_within(resolved, root):
606
+ return
607
+ for git_dir in self.git_dirs():
608
+ if resolved == git_dir or _is_within(resolved, git_dir):
609
+ return
610
+ raise ConfigurationError(
611
+ f"state directory {resolved} is inside the LOCAL working tree {root}. A LOCAL "
612
+ "run fingerprints every byte of the working tree, so runtime state kept there "
613
+ "would either invalidate its own fingerprint on every step or have to be carved "
614
+ "out of it by path — a region an agent could then write into unnoticed. Leave "
615
+ f"--state-dir unset (the default is {self.local_state_dir()}) or point it "
616
+ "outside the repository."
617
+ )
618
+
619
+ # -- the snapshot -----------------------------------------------------
620
+ def snapshot(self) -> WorkspaceSnapshot:
621
+ """Classify every entry of the working tree and fingerprint the result."""
622
+ root = self.root()
623
+ head = self.head_sha()
624
+ branch = self.branch()
625
+ git_ids = self._git_dir_identities()
626
+ entries: list[WorkspaceEntry] = []
627
+ # Every link's resolved target (repository-relative, "." for the
628
+ # root), checked against the excluded entries once the walk is
629
+ # complete: see :meth:`_check_link_reach`.
630
+ link_targets: list[tuple[str, str]] = []
631
+ total_bytes = 0
632
+ by_top: dict[str, int] = {}
633
+
634
+ # One translation point for "the controller may not read this".
635
+ # Whether it is a file it cannot open or a directory it cannot list,
636
+ # unreadable means unbindable, and the answer is the same refusal.
637
+ # The entry budget is the walk's own: it is charged as directory
638
+ # entries are *listed*, before they are sorted, stat'ed or handed
639
+ # here, so a directory of a million names or a nest ten thousand deep
640
+ # costs at most the budget in work and never a RecursionError.
641
+ try:
642
+ with SafeRoot.open(root) as tree:
643
+ for entry in tree.walk(max_entries=self.max_entries):
644
+ top = entry.relpath.split("/", 1)[0]
645
+ by_top[top] = by_top.get(top, 0) + 1
646
+ rule = self._exclusion_rule(entry.relpath, entry.st, git_ids)
647
+ if rule is not None:
648
+ entries.append(
649
+ WorkspaceEntry(
650
+ path=entry.relpath,
651
+ kind=KIND_EXCLUDED,
652
+ mode="",
653
+ digest="",
654
+ detail=rule,
655
+ )
656
+ )
657
+ entry.skip = True
658
+ continue
659
+ if entry.name == ".git":
660
+ raise _refuse(
661
+ entry.relpath,
662
+ "a nested git repository or submodule",
663
+ "LOCAL mode binds one working tree and cannot see inside a second one, "
664
+ "so it refuses rather than reviewing code it cannot pin. Remove it, or "
665
+ f"declare it unreviewed by adding '{posixpath.dirname(entry.relpath)}' "
666
+ "to local.exclude.",
667
+ )
668
+ if entry.is_dir:
669
+ entries.append(
670
+ WorkspaceEntry(
671
+ path=entry.relpath,
672
+ kind=KIND_DIR,
673
+ mode=_mode_text(entry.st),
674
+ digest="",
675
+ )
676
+ )
677
+ elif entry.is_symlink:
678
+ target = readlink_at(entry.dir_fd, entry.name)
679
+ link_targets.append(
680
+ (
681
+ entry.relpath,
682
+ self._check_link_target(entry.relpath, target, root, git_ids),
683
+ )
684
+ )
685
+ entries.append(
686
+ WorkspaceEntry(
687
+ path=entry.relpath,
688
+ kind=KIND_LINK,
689
+ mode="",
690
+ digest=hash_bytes(target.encode("utf-8", errors="surrogateescape")),
691
+ )
692
+ )
693
+ elif entry.is_regular:
694
+ total_bytes += entry.st.st_size
695
+ if total_bytes > self.max_bytes:
696
+ raise self._too_big(
697
+ f"the working tree holds more than {self.max_bytes} bytes of "
698
+ "regular-file content",
699
+ "local.max_workspace_bytes",
700
+ by_top,
701
+ )
702
+ entries.append(
703
+ WorkspaceEntry(
704
+ path=entry.relpath,
705
+ kind=KIND_FILE,
706
+ mode=_mode_text(entry.st),
707
+ digest=self._digest(
708
+ entry.dir_fd, entry.name, entry.relpath, entry.st
709
+ ),
710
+ )
711
+ )
712
+ else:
713
+ raise _refuse(
714
+ entry.relpath,
715
+ f"a {entry_kind(entry.st.st_mode)}",
716
+ "Its contents are not a sequence of bytes the controller can hash, so "
717
+ "it cannot be part of a reviewed snapshot. Move it out of the working "
718
+ "tree, or add it to local.exclude to declare it unreviewed.",
719
+ )
720
+ except WalkBudgetExceeded as exc:
721
+ raise self._too_big(
722
+ f"the working tree has more than {self.max_entries} entries "
723
+ f"(the budget ran out while listing {exc.where})",
724
+ "local.max_workspace_entries",
725
+ by_top,
726
+ ) from exc
727
+ except UnreadableEntryError as exc:
728
+ raise _refuse(
729
+ exc.path,
730
+ "not readable by the controller",
731
+ "An entry the controller cannot read is an entry it cannot prove "
732
+ "unchanged. Fix its permissions, or add it to local.exclude to declare "
733
+ "it unreviewed.",
734
+ ) from exc
735
+
736
+ self._check_link_reach(link_targets, entries)
737
+
738
+ rules = ("gitdir",) + tuple(f"exclude:{p}" for p in self.exclude)
739
+ return WorkspaceSnapshot(
740
+ root=root,
741
+ head_sha=head,
742
+ branch=branch,
743
+ entries=tuple(entries),
744
+ rules=rules,
745
+ fingerprint=_fingerprint(entries, rules),
746
+ total_bytes=total_bytes,
747
+ )
748
+
749
+ def _git_dir_identities(self) -> set[tuple[int, int]]:
750
+ ids: set[tuple[int, int]] = set()
751
+ for git_dir in self.git_dirs():
752
+ try:
753
+ st = os.lstat(git_dir)
754
+ except OSError as exc:
755
+ raise VerificationError(
756
+ f"cannot inspect the git directory {git_dir}: {exc}. The controller "
757
+ "identifies it by inode, not by name, and will not walk the working tree "
758
+ "without that identity."
759
+ ) from exc
760
+ ids.add((st.st_dev, st.st_ino))
761
+ return ids
762
+
763
+ def _exclusion_rule(
764
+ self, relpath: str, st: os.stat_result | None, git_ids: set[tuple[int, int]]
765
+ ) -> str | None:
766
+ """The rule excluding ``relpath``, or ``None`` when it is covered.
767
+
768
+ This is the *only* definition of "excluded from the snapshot": the
769
+ walk asks it about every entry, and the link-target check asks it
770
+ about every ancestor of a resolved target, so the two can never
771
+ disagree about what a reviewer may read unbound. ``st`` is the
772
+ entry's ``lstat``, or ``None`` for a path that does not exist (a
773
+ dangling link's target), for which only the path rules apply.
774
+
775
+ Identity first: the git directory is recognised by ``(st_dev,
776
+ st_ino)``, so a decoy named ``.git`` is not excluded and a git
777
+ directory under any other name still is.
778
+ """
779
+ if st is not None:
780
+ if stat.S_ISDIR(st.st_mode) and (st.st_dev, st.st_ino) in git_ids:
781
+ return "gitdir"
782
+ if relpath == ".git" and stat.S_ISREG(st.st_mode):
783
+ # A linked worktree's pointer file. It is git's own metadata
784
+ # and it names a directory the walk never enters.
785
+ return "gitdir"
786
+ return self._config_rule(relpath)
787
+
788
+ def _config_rule(self, relpath: str) -> str | None:
789
+ """The ``local.exclude`` rule covering ``relpath`` or any ancestor of it."""
790
+ parts = tuple(p for p in relpath.split("/") if p)
791
+ for pattern in self.exclude:
792
+ pat = tuple(pattern.split("/"))
793
+ for depth in range(1, len(parts) + 1):
794
+ if _match_components(pat, parts[:depth]):
795
+ return f"exclude:{pattern}"
796
+ return None
797
+
798
+ def _check_link_target(
799
+ self, relpath: str, target: str, root: Path, git_ids: set[tuple[int, int]]
800
+ ) -> str:
801
+ """A symbolic link may only point at bytes this snapshot also covers.
802
+
803
+ The link *text* is what the snapshot binds, so the link itself cannot
804
+ change without moving the fingerprint. What the link resolves to is a
805
+ different question: if it leaves the working tree, or lands in an
806
+ excluded region, the reviewer read bytes that can be replaced with the
807
+ fingerprint standing still.
808
+
809
+ The resolution uses ``realpath``, which follows other links — and that
810
+ is sound here rather than a hole, because *every* link in the tree
811
+ gets this check: if any link on the resolved path escaped, that link
812
+ is itself refused. The conjunction over the whole walk is the
813
+ invariant, not each check in isolation.
814
+
815
+ "Excluded" is decided by :meth:`_exclusion_rule`, asked about every
816
+ ancestor of the resolved target exactly as the walk asks it about
817
+ every entry: a target the walk would have skipped (a git directory
818
+ by inode, a linked worktree's ``.git`` pointer file, a
819
+ ``local.exclude`` region) is a target whose bytes are unbound,
820
+ whichever of those rules would have skipped it.
821
+
822
+ Returns the resolved target, repository-relative (``"."`` for the
823
+ root itself). This checks the target and its *ancestors*; what the
824
+ target *contains* is checked by :meth:`_check_link_reach` once the
825
+ walk has listed every excluded entry. The root is not special: it
826
+ has no ancestors, and it contains everything.
827
+ """
828
+ parent = root / posixpath.dirname(relpath) if posixpath.dirname(relpath) else root
829
+ resolved = Path(os.path.realpath(os.path.join(str(parent), target)))
830
+ if resolved != root and not _is_within(resolved, root):
831
+ raise _refuse(
832
+ relpath,
833
+ f"a symbolic link to {resolved}, which is outside the working tree",
834
+ "The reviewer would read bytes the snapshot cannot bind, so they could be "
835
+ "replaced without the fingerprint changing. Point it inside the repository, "
836
+ "or add it to local.exclude to declare it unreviewed.",
837
+ )
838
+ rel = os.path.relpath(resolved, root).replace(os.sep, "/")
839
+ if rel in (".", ""):
840
+ return "."
841
+ parts = rel.split("/")
842
+ for depth in range(1, len(parts) + 1):
843
+ prefix = "/".join(parts[:depth])
844
+ try:
845
+ st: os.stat_result | None = os.lstat(root.joinpath(*parts[:depth]))
846
+ except OSError:
847
+ # Nothing exists there (yet): only the path rules can apply.
848
+ st = None
849
+ rule = self._exclusion_rule(prefix, st, git_ids)
850
+ if rule is not None:
851
+ raise _refuse(
852
+ relpath,
853
+ f"a symbolic link to {prefix}, which '{rule}' excludes from the snapshot",
854
+ "Its bytes are not bound, so they could change without the fingerprint "
855
+ "moving. Remove the link, or exclude the link itself as well.",
856
+ )
857
+ return rel
858
+
859
+ @staticmethod
860
+ def _check_link_reach(
861
+ link_targets: Iterable[tuple[str, str]], entries: Iterable[WorkspaceEntry]
862
+ ) -> None:
863
+ """No symbolic link may *contain* an excluded entry.
864
+
865
+ :meth:`_check_link_target` refuses a link whose target is excluded or
866
+ lies under an exclusion. The converse escape is a link to a directory
867
+ *above* one: with ``local.exclude: [build]`` and ``src/escape -> ..``,
868
+ the reviewer reads ``src/escape/build/out`` — a path no rule matches
869
+ — and gets the bytes of ``build/out``, which no entry binds. A link
870
+ to the root reaches every exclusion at once (the git directory
871
+ included), so it is refused by the same rule rather than by a
872
+ special case.
873
+
874
+ The excluded entries are exactly the ones the walk recorded as
875
+ :data:`KIND_EXCLUDED`, so "reachable through the link" and "skipped by
876
+ the walk" cannot disagree; the check runs after the walk because an
877
+ exclusion may be listed after the link that reaches it. A link to a
878
+ regular file contains nothing and passes. An exclusion that does not
879
+ exist yet cannot be reached; when it appears, the excluded entry's
880
+ *path* enters the fingerprint (see the module docstring) and the next
881
+ snapshot refuses the link.
882
+ """
883
+ excluded = [e.path for e in entries if e.kind == KIND_EXCLUDED]
884
+ if not excluded:
885
+ return
886
+ for relpath, rel in link_targets:
887
+ for path in excluded:
888
+ if rel == "." or path == rel or path.startswith(rel + "/"):
889
+ where = "the working tree root" if rel == "." else rel
890
+ raise _refuse(
891
+ relpath,
892
+ f"a symbolic link to {where}, through which {path} (excluded from "
893
+ "the snapshot) can be reached at an unexcluded path",
894
+ "Its bytes are not bound, so they could change without the "
895
+ "fingerprint moving. Remove the link, point it below every "
896
+ "exclusion, or exclude the link itself as well.",
897
+ )
898
+
899
+ def _digest(self, dir_fd: int, name: str, relpath: str, st: os.stat_result) -> str:
900
+ key = (
901
+ st.st_dev,
902
+ st.st_ino,
903
+ st.st_size,
904
+ st.st_mtime_ns,
905
+ st.st_ctime_ns,
906
+ stat.S_IMODE(st.st_mode),
907
+ )
908
+ cached = self._digests.get(key)
909
+ if cached is not None:
910
+ return cached
911
+ try:
912
+ fd = open_regular_at(dir_fd, name, os.O_RDONLY, where=relpath)
913
+ except FileNotFoundError as exc:
914
+ raise _refuse(
915
+ relpath,
916
+ "gone between being listed and being read",
917
+ "Something is writing the working tree while the controller is binding it; "
918
+ "stop it and re-run.",
919
+ ) from exc
920
+ h = hashlib.sha256()
921
+ try:
922
+ with os.fdopen(fd, "rb") as fh:
923
+ remaining = st.st_size
924
+ while remaining:
925
+ chunk = fh.read(min(_CHUNK, remaining))
926
+ if not chunk:
927
+ break
928
+ h.update(chunk)
929
+ remaining -= len(chunk)
930
+ if remaining or fh.read(1):
931
+ raise _refuse(
932
+ relpath,
933
+ "grew or shrank while it was being read",
934
+ "Something is writing the working tree while the controller is binding it; "
935
+ "stop it and re-run.",
936
+ )
937
+ final = os.fstat(fh.fileno())
938
+ final_key = (
939
+ final.st_dev,
940
+ final.st_ino,
941
+ final.st_size,
942
+ final.st_mtime_ns,
943
+ final.st_ctime_ns,
944
+ stat.S_IMODE(final.st_mode),
945
+ )
946
+ if final_key != key:
947
+ raise _refuse(
948
+ relpath,
949
+ "changed while it was being read",
950
+ "Something is writing the working tree while the controller is binding it; "
951
+ "stop it and re-run.",
952
+ )
953
+ except OSError as exc:
954
+ raise _refuse(
955
+ relpath, f"unreadable ({exc})", "Fix it, or add it to local.exclude."
956
+ ) from exc
957
+ digest = h.hexdigest()
958
+ self._digests[key] = digest
959
+ return digest
960
+
961
+ def _too_big(self, what: str, setting: str, by_top: dict[str, int]) -> VerificationError:
962
+ biggest = sorted(by_top.items(), key=lambda kv: -kv[1])[:8]
963
+ listing = "\n".join(f" - {name} ({n} entries seen so far)" for name, n in biggest)
964
+ return VerificationError(
965
+ f"refusing to fingerprint the working tree: {what}.\n"
966
+ "A LOCAL run binds the review to every byte of the tree, and it will not fall "
967
+ "back to hashing metadata instead — an equal-sized replacement with a restored "
968
+ "mtime would then pass. Either exclude what does not need reviewing:\n"
969
+ " local:\n"
970
+ " exclude:\n"
971
+ " - .venv\n"
972
+ " - '**/__pycache__'\n"
973
+ f"or raise {setting} deliberately.\n"
974
+ f"Largest top-level entries seen so far:\n{listing}"
975
+ )
976
+
977
+ # -- writes into the working tree -------------------------------------
978
+ def tree_root(self) -> SafeRoot:
979
+ """A write capability on the working tree (caller closes it).
980
+
981
+ The only controller writes into the working tree are the ones
982
+ ``autoforge local init`` makes, and they go through the same boundary
983
+ as every other controller write.
984
+ """
985
+ return SafeRoot.open(self.root())
986
+
987
+
988
+ def _is_within(path: Path, base: Path) -> bool:
989
+ """True when ``path`` is ``base`` or lies beneath it (both already resolved).
990
+
991
+ Compared component-wise: a textual prefix test would say ``/repo-backup``
992
+ is inside ``/repo``.
993
+ """
994
+ try:
995
+ path.relative_to(base)
996
+ except ValueError:
997
+ return False
998
+ return True
999
+
1000
+
1001
+ def uncommitted_paths(
1002
+ workdir: str | Path,
1003
+ runner: Runner | None = None,
1004
+ *,
1005
+ timeout_seconds: int = GIT_TIMEOUT_SECONDS,
1006
+ ) -> list[str]:
1007
+ """What ``git status`` reports as changed or untracked in ``workdir``, sorted.
1008
+
1009
+ The one read of "what uncommitted work is here" outside the LOCAL
1010
+ dirty-tree policy (:meth:`LocalWorkspace.dirty_paths`, which it is):
1011
+ a single ``git status --porcelain=v1 -z --untracked-files=all``, with no
1012
+ optional locks, so the index is not refreshed, and with hooks and the
1013
+ file-system monitor off. An agent failure that leaves the state
1014
+ unchanged reports its count for the REMOTE agent worktree (#189), and a
1015
+ write phase's prompt lists the same read's records, codes included
1016
+ (:meth:`LocalWorkspace.status_entries`, #187). Like the policy, it is
1017
+ the operator's notion of uncommitted work, never an identity. A read
1018
+ that fails, times out or is truncated raises VerificationError; a
1019
+ caller that cannot use the answer omits it rather than guessing.
1020
+ """
1021
+ return LocalWorkspace(workdir, runner=runner, timeout_seconds=timeout_seconds).dirty_paths()
1022
+
1023
+
1024
+ # -- feature specifications ---------------------------------------------------
1025
+ @dataclass(frozen=True)
1026
+ class FeatureSpec:
1027
+ """A resolved, frozen feature specification."""
1028
+
1029
+ path: Path # absolute, real path
1030
+ relative_path: str # repository-relative, "/"-separated
1031
+ content: str
1032
+ sha256: str
1033
+
1034
+
1035
+ def resolve_feature_spec(workspace: LocalWorkspace, spec_path: str | Path) -> Path:
1036
+ """Resolve ``spec_path`` to a regular Markdown file inside the repository.
1037
+
1038
+ Rejects (with ConfigurationError): a path that escapes the repository via
1039
+ ``..`` or an absolute path elsewhere, a symbolic link, a directory, a
1040
+ FIFO/socket/device, a non-Markdown suffix, and anything unreadable. The
1041
+ feature specification is *untrusted project data*; the controller only
1042
+ guarantees where it came from, never what it says.
1043
+ """
1044
+ root = workspace.root()
1045
+ raw = Path(spec_path)
1046
+ candidate = raw if raw.is_absolute() else Path(workspace.workdir) / raw
1047
+ # realpath on the *parent* only: the file itself must not be a symlink,
1048
+ # and resolving it would silently accept one pointing outside the repo.
1049
+ parent = Path(os.path.realpath(candidate.parent))
1050
+ resolved = parent / candidate.name
1051
+ try:
1052
+ rel = os.path.relpath(resolved, root)
1053
+ except ValueError:
1054
+ raise ConfigurationError(
1055
+ f"feature specification {spec_path!r} is not inside the repository {root}"
1056
+ ) from None
1057
+ if rel.startswith("..") or os.path.isabs(rel):
1058
+ raise ConfigurationError(
1059
+ f"feature specification {spec_path!r} resolves to {resolved}, which is outside "
1060
+ f"the repository {root}; local runs only accept a specification inside the "
1061
+ "working repository"
1062
+ )
1063
+ if resolved.suffix.lower() not in FEATURE_SPEC_SUFFIXES:
1064
+ raise ConfigurationError(
1065
+ f"feature specification {resolved} must be a Markdown file "
1066
+ f"({' or '.join(FEATURE_SPEC_SUFFIXES)})"
1067
+ )
1068
+ try:
1069
+ st = os.lstat(resolved)
1070
+ except FileNotFoundError:
1071
+ raise ConfigurationError(f"feature specification not found: {resolved}") from None
1072
+ except OSError as exc:
1073
+ raise ConfigurationError(f"cannot read feature specification {resolved}: {exc}") from exc
1074
+ if stat.S_ISLNK(st.st_mode):
1075
+ raise ConfigurationError(
1076
+ f"feature specification {resolved} is a symbolic link; local runs require a "
1077
+ "regular file inside the repository so the frozen specification cannot be "
1078
+ "redirected during the run"
1079
+ )
1080
+ if not stat.S_ISREG(st.st_mode):
1081
+ raise ConfigurationError(
1082
+ f"feature specification {resolved} is not a regular file "
1083
+ f"(mode {st.st_mode:o}); local runs require a regular Markdown file"
1084
+ )
1085
+ return resolved
1086
+
1087
+
1088
+ def read_feature_spec(workspace: LocalWorkspace, spec_path: str | Path) -> FeatureSpec:
1089
+ """Resolve + read + hash a feature specification (ConfigurationError on any problem)."""
1090
+ resolved = resolve_feature_spec(workspace, spec_path)
1091
+ rel = os.path.relpath(resolved, workspace.root()).replace(os.sep, "/")
1092
+ # The specification is bounded by the same budget as the tree it lives
1093
+ # in: a run's prompts carry it verbatim, and a spec the snapshot would
1094
+ # refuse to fingerprint is not one the controller should read whole.
1095
+ with workspace.tree_root() as tree:
1096
+ try:
1097
+ data = tree.read_bytes(rel, limit=workspace.max_bytes)
1098
+ except ReadLimitExceeded as exc:
1099
+ raise ConfigurationError(
1100
+ f"feature specification {resolved} is larger than {exc.limit} bytes "
1101
+ "(local.max_workspace_bytes); a specification is a short Markdown "
1102
+ "document, not a corpus"
1103
+ ) from exc
1104
+ except OSError as exc:
1105
+ raise ConfigurationError(
1106
+ f"cannot read feature specification {resolved}: {exc}"
1107
+ ) from exc
1108
+ if data is None:
1109
+ raise ConfigurationError(f"feature specification not found: {resolved}")
1110
+ try:
1111
+ content = data.decode("utf-8")
1112
+ except UnicodeDecodeError as exc:
1113
+ raise ConfigurationError(
1114
+ f"feature specification {resolved} is not valid UTF-8 ({exc})"
1115
+ ) from exc
1116
+ if not content.strip():
1117
+ raise ConfigurationError(
1118
+ f"feature specification {resolved} is empty; describe the feature first "
1119
+ "(see 'autoforge local init')"
1120
+ )
1121
+ return FeatureSpec(
1122
+ path=resolved,
1123
+ relative_path=rel,
1124
+ content=content,
1125
+ sha256=hash_bytes(data),
1126
+ )
1127
+
1128
+
1129
+ def verify_feature_spec_unchanged(
1130
+ workspace: LocalWorkspace, relative_path: str, expected_sha256: str, *, when: str
1131
+ ) -> FeatureSpec:
1132
+ """Re-read the frozen specification and require its hash to be unchanged.
1133
+
1134
+ ``when`` names the moment for the error message ("before REVIEW", "after
1135
+ ANALYZE_EXECUTE", ...). Raises :class:`VerificationError` — an agent that
1136
+ rewrote its own acceptance criteria is a controller-invariant violation,
1137
+ not a configuration mistake.
1138
+ """
1139
+ target = workspace.root() / relative_path
1140
+ try:
1141
+ spec = read_feature_spec(workspace, target)
1142
+ except ConfigurationError as exc:
1143
+ raise VerificationError(
1144
+ f"feature specification {relative_path} is no longer usable {when}: {exc}"
1145
+ ) from exc
1146
+ if spec.sha256 != expected_sha256:
1147
+ raise VerificationError(
1148
+ f"feature specification {relative_path} changed {when}: expected SHA-256 "
1149
+ f"{expected_sha256[:16]}..., found {spec.sha256[:16]}.... The specification is "
1150
+ "frozen for the whole run so an agent cannot rewrite its own acceptance "
1151
+ "criteria. Restore the file, or start a new run for the changed requirements."
1152
+ )
1153
+ return spec
1154
+
1155
+
1156
+ # -- `autoforge local init` ---------------------------------------------------
1157
+ SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
1158
+
1159
+ FEATURE_TEMPLATE = """# Feature: {title}
1160
+
1161
+ ## Problem
1162
+
1163
+ <!-- What is wrong or missing today, and for whom? One short paragraph. -->
1164
+
1165
+ ## Requirements
1166
+
1167
+ - [ ] <!-- Concrete, checkable behaviour the implementation must provide. -->
1168
+
1169
+ ## Acceptance Criteria
1170
+
1171
+ - [ ] <!-- How a reviewer decides this is done. One line per criterion. -->
1172
+
1173
+ ## Non-goals
1174
+
1175
+ - <!-- Explicitly out of scope, so the agent does not invent work. -->
1176
+
1177
+ ## Notes / Decisions
1178
+
1179
+ <!-- Constraints, prior art, files worth reading first, decisions already made. -->
1180
+ """
1181
+
1182
+
1183
+ def slug_to_title(slug: str) -> str:
1184
+ """``add-transaction-filter`` -> ``Add Transaction Filter``."""
1185
+ words = [w for w in re.split(r"[-_.]+", slug) if w]
1186
+ return " ".join(w[:1].upper() + w[1:] for w in words) or slug
1187
+
1188
+
1189
+ def feature_template(slug: str) -> str:
1190
+ """The starting Markdown for ``autoforge local init <slug>``."""
1191
+ return FEATURE_TEMPLATE.format(title=slug_to_title(slug))
1192
+
1193
+
1194
+ def init_feature_file(
1195
+ workspace: LocalWorkspace,
1196
+ slug: str,
1197
+ feature_dir: str = "features",
1198
+ overwrite: bool = False,
1199
+ ) -> Path:
1200
+ """Create ``<feature_dir>/<slug>.md`` from the template and return its path.
1201
+
1202
+ Refuses to overwrite an existing file unless ``overwrite`` is set: the
1203
+ feature specification is the operator's own work, and clobbering it on a
1204
+ mistyped slug would destroy exactly the input a run depends on.
1205
+
1206
+ The write goes through the working tree's :class:`~autoforge.safefs
1207
+ .SafeRoot`, which is what makes this safe rather than a fourth hand-rolled
1208
+ set of checks: no component of ``features/<slug>.md`` can be a symbolic
1209
+ link, ``--force`` *replaces the name* instead of truncating an inode (so a
1210
+ hard link planted at the target keeps its contents), and without
1211
+ ``--force`` the create is ``O_EXCL``, which is atomic rather than a
1212
+ check a racing writer can slip past.
1213
+ """
1214
+ if not SLUG_RE.match(slug):
1215
+ raise ConfigurationError(
1216
+ f"invalid feature slug {slug!r}: use lowercase letters, digits, '-', '_' or '.' "
1217
+ "(no path separators), e.g. 'add-transaction-filter'"
1218
+ )
1219
+ root = workspace.root()
1220
+ directory = Path(feature_dir)
1221
+ if directory.is_absolute():
1222
+ try:
1223
+ rel_dir = directory.relative_to(root)
1224
+ except ValueError:
1225
+ raise ConfigurationError(
1226
+ f"feature directory {feature_dir!r} is outside the repository {root}"
1227
+ ) from None
1228
+ else:
1229
+ rel_dir = directory
1230
+ rel = f"{rel_dir.as_posix()}/{slug}.md".lstrip("/")
1231
+ if rel.startswith("..") or "/../" in f"/{rel}":
1232
+ raise ConfigurationError(f"feature directory {feature_dir!r} escapes the repository {root}")
1233
+ content = feature_template(slug).encode("utf-8")
1234
+ with workspace.tree_root() as tree:
1235
+ if overwrite:
1236
+ existing = tree.lstat(rel)
1237
+ if existing is not None and not stat.S_ISREG(existing.st_mode):
1238
+ raise ConfigurationError(
1239
+ f"refusing to overwrite {root / rel}: it is a "
1240
+ f"{entry_kind(existing.st_mode)}, not a regular file. --force replaces "
1241
+ "a specification AutoForge could have written; it does not write through "
1242
+ "an entry the operator put there. Move it aside, or choose another slug."
1243
+ )
1244
+ tree.write_bytes(rel, content, mode=0o644)
1245
+ else:
1246
+ try:
1247
+ tree.create_exclusive(rel, content, mode=0o644)
1248
+ except FileExistsError:
1249
+ raise ConfigurationError(
1250
+ f"feature specification already exists: {root / rel} — edit it, choose "
1251
+ "another slug, or pass --force to overwrite it"
1252
+ ) from None
1253
+ return root / rel