sid-harness 0.3.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 (45) hide show
  1. sid_harness/__init__.py +13 -0
  2. sid_harness/__main__.py +4 -0
  3. sid_harness/_native.py +1639 -0
  4. sid_harness/brave_proof.py +246 -0
  5. sid_harness/bridge.py +851 -0
  6. sid_harness/cli.py +937 -0
  7. sid_harness/client.py +161 -0
  8. sid_harness/companion.py +151 -0
  9. sid_harness/config.py +68 -0
  10. sid_harness/context/__init__.py +40 -0
  11. sid_harness/context/index.py +267 -0
  12. sid_harness/context/retrieval.py +74 -0
  13. sid_harness/editor.py +231 -0
  14. sid_harness/gateway.py +106 -0
  15. sid_harness/git_write.py +598 -0
  16. sid_harness/local_workspace.py +421 -0
  17. sid_harness/loop.py +394 -0
  18. sid_harness/lsp.py +598 -0
  19. sid_harness/policy/__init__.py +44 -0
  20. sid_harness/policy/repo_policy.py +626 -0
  21. sid_harness/receipt.py +142 -0
  22. sid_harness/runner.py +468 -0
  23. sid_harness/selftest.py +149 -0
  24. sid_harness/session/__init__.py +33 -0
  25. sid_harness/session/bounds.py +140 -0
  26. sid_harness/session/diff_gate.py +93 -0
  27. sid_harness/session/session.py +427 -0
  28. sid_harness/session/store.py +88 -0
  29. sid_harness/tools/__init__.py +52 -0
  30. sid_harness/tools/mcp_client.py +285 -0
  31. sid_harness/tools/plan.py +138 -0
  32. sid_harness/tools/registry.py +159 -0
  33. sid_harness/tools/skills.py +185 -0
  34. sid_harness/tools/subagents.py +112 -0
  35. sid_harness/tui.py +96 -0
  36. sid_harness/workspace.py +107 -0
  37. sid_harness-0.3.0.data/data/share/sid-harness/editors/README.md +46 -0
  38. sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/README.md +32 -0
  39. sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/extension.js +395 -0
  40. sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/package.json +120 -0
  41. sid_harness-0.3.0.dist-info/METADATA +229 -0
  42. sid_harness-0.3.0.dist-info/RECORD +45 -0
  43. sid_harness-0.3.0.dist-info/WHEEL +5 -0
  44. sid_harness-0.3.0.dist-info/entry_points.txt +2 -0
  45. sid_harness-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,626 @@
1
+ """SUB-57 — repo policy: the platform's own ship discipline, enforced locally.
2
+
3
+ The harness edits the user's real tree (SUB-54). This module is where the
4
+ platform's rules become visible to that loop and are enforced *before* the
5
+ harness offers a mutating action — with the platform's own explanation, not a
6
+ paraphrase:
7
+
8
+ * **integration-only main checkout** — a source commit staged in the MAIN
9
+ worktree is refused (Gate 31 ``main_checkout_integration_only_gate.py``;
10
+ JB_DECISIONS 2026-07-18 integration-only-main-checkout). Source commits
11
+ happen in a session worktree.
12
+ * **main moves only through the landing gate** — ``git push origin HEAD:main``
13
+ is refused (JB_DECISIONS 2026-09-08 main-requires-server-landing-gate); main
14
+ moves only through ``bash scripts/admin/push_main_checked.sh`` (reached via
15
+ ``ship_worktree.sh``).
16
+ * **resolve its own worktree** — the operating worktree is resolved from the
17
+ repo, never the ambient cwd (LESSONS #22).
18
+ * **the pre-push battery runs before a commit is offered** — and the battery is
19
+ the platform's own hook, discovered from the filesystem.
20
+
21
+ Two invariants, both load-bearing:
22
+
23
+ * **this policy module never ships** — it has no push and no commit; it returns
24
+ the sanctioned command for the operator to run. The explicit opt-in
25
+ ``sid run --git-write`` path (``git_write.py``) owns branch/commit/PR under
26
+ its own hard guards: never main/master, never forced, per-step approval.
27
+ * the gate inventory is read from the **filesystem** (``scripts/gates/`` and
28
+ ``.git-hooks/pre-push``), never a hardcoded list, and the commit decision
29
+ delegates to the platform gate module itself — so the harness cannot weaken
30
+ or drift from a gate.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import importlib.util
36
+ import os
37
+ import re
38
+ import subprocess
39
+ from dataclasses import dataclass
40
+ from pathlib import Path
41
+ from types import ModuleType
42
+
43
+ from ..local_workspace import git_info
44
+
45
+ _GIT_TIMEOUT = 30.0
46
+
47
+ # The canonical gate that owns the integration-only-main decision. Named here
48
+ # so the decision is *delegated* to it, never reimplemented; the file itself is
49
+ # discovered on disk (see ``load_gate_module``).
50
+ _MAIN_CHECKOUT_GATE = "main_checkout_integration_only_gate"
51
+
52
+ _MAIN_REF = "refs/heads/main"
53
+
54
+ # Gate scripts are referenced from the hook two ways: the tracked path
55
+ # (``scripts/gates/x.py``) and the ``$GATES_DIR`` variable the hook defines.
56
+ _WIRED_RE = re.compile(r"(?:scripts/gates/|\$\{?GATES_DIR\}?/)([A-Za-z0-9_]+)\.py")
57
+
58
+ # ``git push`` targets that mean "main" — a colon refspec to main, a bare
59
+ # ``origin main``, or a pre-push line whose remote ref is main.
60
+ _MAIN_TARGET_RE = re.compile(r"(?::\s*(?:refs/heads/)?main\b)|(?:\borigin\s+main\b)")
61
+
62
+
63
+ # --------------------------------------------------------------------------- #
64
+ # Decisions
65
+ # --------------------------------------------------------------------------- #
66
+ @dataclass(frozen=True)
67
+ class PolicyDecision:
68
+ """The result of a policy check, with the platform's own explanation."""
69
+
70
+ allowed: bool
71
+ code: str
72
+ explanation: str
73
+ remedy: str = ""
74
+ command: str | None = None
75
+
76
+ def as_dict(self) -> dict[str, object]:
77
+ return {
78
+ "allowed": self.allowed,
79
+ "code": self.code,
80
+ "explanation": self.explanation,
81
+ "remedy": self.remedy,
82
+ "command": self.command,
83
+ }
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class WorktreeResolution:
88
+ """Where the harness may operate: its own worktree, never the ambient cwd."""
89
+
90
+ directory: Path
91
+ is_repo: bool
92
+ root: Path | None
93
+ main_root: Path | None
94
+ is_worktree: bool
95
+ is_main_checkout: bool
96
+ branch: str | None
97
+ head: str | None
98
+ session_worktrees: tuple[str, ...]
99
+ reason: str
100
+
101
+ def as_dict(self) -> dict[str, object]:
102
+ return {
103
+ "directory": str(self.directory),
104
+ "is_repo": self.is_repo,
105
+ "root": str(self.root) if self.root else None,
106
+ "main_root": str(self.main_root) if self.main_root else None,
107
+ "is_worktree": self.is_worktree,
108
+ "is_main_checkout": self.is_main_checkout,
109
+ "branch": self.branch,
110
+ "head": self.head,
111
+ "session_worktrees": list(self.session_worktrees),
112
+ "reason": self.reason,
113
+ }
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class GateInventory:
118
+ """The gate battery, read from the filesystem — never a hardcoded list."""
119
+
120
+ repo_root: Path | None
121
+ gates_dir: Path | None
122
+ gate_files: tuple[str, ...]
123
+ hook_path: Path | None
124
+ wired_gates: tuple[str, ...]
125
+ missing_wired: tuple[str, ...]
126
+ unwired_gates: tuple[str, ...]
127
+
128
+ def as_dict(self) -> dict[str, object]:
129
+ return {
130
+ "repo_root": str(self.repo_root) if self.repo_root else None,
131
+ "gates_dir": str(self.gates_dir) if self.gates_dir else None,
132
+ "gate_files": list(self.gate_files),
133
+ "hook_path": str(self.hook_path) if self.hook_path else None,
134
+ "wired_gates": list(self.wired_gates),
135
+ "missing_wired": list(self.missing_wired),
136
+ "unwired_gates": list(self.unwired_gates),
137
+ }
138
+
139
+
140
+ # --------------------------------------------------------------------------- #
141
+ # Repo / worktree resolution
142
+ # --------------------------------------------------------------------------- #
143
+ def _run_git(root: str | os.PathLike[str], args: list[str]) -> subprocess.CompletedProcess[str]:
144
+ return subprocess.run(
145
+ ["git", "-C", str(root), *args],
146
+ capture_output=True,
147
+ text=True,
148
+ encoding="utf-8",
149
+ errors="replace",
150
+ timeout=_GIT_TIMEOUT,
151
+ )
152
+
153
+
154
+ def _norm(path: object) -> str:
155
+ return os.path.normcase(os.path.normpath(str(path or "")))
156
+
157
+
158
+ def _worktree_entries(root: str | os.PathLike[str]) -> list[dict[str, str]]:
159
+ """Parse ``git worktree list --porcelain`` into ordered entries."""
160
+ proc = _run_git(root, ["worktree", "list", "--porcelain"])
161
+ if proc.returncode != 0:
162
+ return []
163
+ entries: list[dict[str, str]] = []
164
+ current: dict[str, str] | None = None
165
+ for line in proc.stdout.splitlines():
166
+ if line.startswith("worktree "):
167
+ current = {"path": line[len("worktree ") :].strip()}
168
+ entries.append(current)
169
+ elif line.startswith("branch ") and current is not None:
170
+ current["branch"] = line[len("branch ") :].strip()
171
+ elif line.startswith("detached") and current is not None:
172
+ current["detached"] = "1"
173
+ return entries
174
+
175
+
176
+ def find_repo_root(directory: str | os.PathLike[str]) -> Path | None:
177
+ """The repository (or worktree) top level for ``directory``, else ``None``."""
178
+ info = git_info(directory)
179
+ return info.root if info.is_repo else None
180
+
181
+
182
+ def main_worktree_root(directory: str | os.PathLike[str]) -> Path | None:
183
+ """The repo's MAIN worktree — the first entry of ``git worktree list``.
184
+
185
+ Same definition Gate 31 uses, resolved from an explicit directory (never
186
+ the ambient cwd).
187
+ """
188
+ root = find_repo_root(directory)
189
+ if root is None:
190
+ return None
191
+ entries = _worktree_entries(root)
192
+ if not entries:
193
+ return None
194
+ return Path(entries[0]["path"])
195
+
196
+
197
+ def resolve_session_worktree(directory: str | os.PathLike[str]) -> WorktreeResolution:
198
+ """Resolve the worktree the harness should operate in (LESSONS #22).
199
+
200
+ A linked worktree resolves to itself. The MAIN checkout is
201
+ integration-only: it is reported as such (``is_main_checkout``) rather than
202
+ silently accepted as a place to commit source, and its sibling worktrees
203
+ are listed so the caller can pick one.
204
+ """
205
+ base = Path(directory).expanduser().resolve()
206
+ info = git_info(base)
207
+ if not info.is_repo or info.root is None:
208
+ return WorktreeResolution(
209
+ directory=base,
210
+ is_repo=False,
211
+ root=None,
212
+ main_root=None,
213
+ is_worktree=False,
214
+ is_main_checkout=False,
215
+ branch=None,
216
+ head=None,
217
+ session_worktrees=(),
218
+ reason="Not a git repository; no worktree policy applies.",
219
+ )
220
+
221
+ main_root = main_worktree_root(base)
222
+ is_main_checkout = main_root is not None and _norm(info.root) == _norm(main_root)
223
+
224
+ siblings: list[str] = []
225
+ for entry in _worktree_entries(info.root):
226
+ path = entry.get("path")
227
+ if path and _norm(path) != _norm(main_root):
228
+ siblings.append(path)
229
+
230
+ if is_main_checkout:
231
+ reason = (
232
+ "This is the MAIN checkout. It is integration-only: source commits "
233
+ "belong in a session worktree."
234
+ )
235
+ else:
236
+ reason = "This is a linked session worktree; source commits are allowed here."
237
+
238
+ return WorktreeResolution(
239
+ directory=base,
240
+ is_repo=True,
241
+ root=info.root,
242
+ main_root=main_root,
243
+ is_worktree=info.is_worktree,
244
+ is_main_checkout=is_main_checkout,
245
+ branch=info.branch,
246
+ head=info.head,
247
+ session_worktrees=tuple(siblings),
248
+ reason=reason,
249
+ )
250
+
251
+
252
+ # --------------------------------------------------------------------------- #
253
+ # Gate inventory — read from the filesystem
254
+ # --------------------------------------------------------------------------- #
255
+ def _wired_gate_names(hook_text: str) -> tuple[str, ...]:
256
+ names: list[str] = []
257
+ for match in _WIRED_RE.finditer(hook_text):
258
+ name = match.group(1)
259
+ if name not in names:
260
+ names.append(name)
261
+ return tuple(names)
262
+
263
+
264
+ def gate_inventory(directory: str | os.PathLike[str]) -> GateInventory:
265
+ """Enumerate the gate battery from disk: ``scripts/gates/`` + the hook.
266
+
267
+ The hook (``.git-hooks/pre-push``) is the canonical ordering; its gate
268
+ references are parsed in order of first appearance. Nothing here is a
269
+ hardcoded list — add a gate file or wire it into the hook and it appears.
270
+ """
271
+ root = find_repo_root(directory)
272
+ if root is None:
273
+ return GateInventory(None, None, (), None, (), (), ())
274
+
275
+ gates_dir = root / "scripts" / "gates"
276
+ gate_files: tuple[str, ...] = ()
277
+ if gates_dir.is_dir():
278
+ gate_files = tuple(
279
+ sorted(p.stem for p in gates_dir.glob("*.py") if p.name != "__init__.py")
280
+ )
281
+
282
+ hook_candidate = root / ".git-hooks" / "pre-push"
283
+ hook_path = hook_candidate if hook_candidate.is_file() else None
284
+ wired: tuple[str, ...] = ()
285
+ if hook_path is not None:
286
+ try:
287
+ wired = _wired_gate_names(hook_path.read_text(encoding="utf-8", errors="replace"))
288
+ except OSError: # pragma: no cover - defensive
289
+ wired = ()
290
+
291
+ missing_wired = tuple(name for name in wired if name not in gate_files)
292
+ unwired = tuple(name for name in gate_files if name not in wired)
293
+ return GateInventory(
294
+ repo_root=root,
295
+ gates_dir=gates_dir if gates_dir.is_dir() else None,
296
+ gate_files=gate_files,
297
+ hook_path=hook_path,
298
+ wired_gates=wired,
299
+ missing_wired=missing_wired,
300
+ unwired_gates=unwired,
301
+ )
302
+
303
+
304
+ def load_gate_module(repo_root: str | os.PathLike[str], name: str) -> ModuleType | None:
305
+ """Load a platform gate module from ``scripts/gates/`` on disk.
306
+
307
+ Used so the harness's decisions ARE the gate's decisions — the check is not
308
+ reimplemented here, so it cannot drift from or weaken the gate.
309
+ """
310
+ path = Path(repo_root) / "scripts" / "gates" / f"{name}.py"
311
+ if not path.is_file():
312
+ return None
313
+ spec = importlib.util.spec_from_file_location(f"_jb_gate_{name}", path)
314
+ if spec is None or spec.loader is None: # pragma: no cover - defensive
315
+ return None
316
+ module = importlib.util.module_from_spec(spec)
317
+ spec.loader.exec_module(module)
318
+ return module
319
+
320
+
321
+ # --------------------------------------------------------------------------- #
322
+ # Checks
323
+ # --------------------------------------------------------------------------- #
324
+ def check_source_commit(
325
+ directory: str | os.PathLike[str],
326
+ staged_paths: list[str] | tuple[str, ...],
327
+ message: str = "",
328
+ *,
329
+ env_override: bool = False,
330
+ ) -> PolicyDecision:
331
+ """Refuse a source commit staged in the MAIN checkout (Gate 31).
332
+
333
+ The decision is delegated to ``main_checkout_integration_only_gate`` read
334
+ from the filesystem; if that gate cannot be read, the check fails closed.
335
+ """
336
+ resolution = resolve_session_worktree(directory)
337
+ if not resolution.is_repo:
338
+ return PolicyDecision(
339
+ allowed=True,
340
+ code="not-a-repo",
341
+ explanation="Not a git repository; the main-checkout policy does not apply.",
342
+ )
343
+ if not resolution.is_main_checkout:
344
+ return PolicyDecision(
345
+ allowed=True,
346
+ code="session-worktree",
347
+ explanation=(
348
+ f"Source commits are allowed here: {resolution.root} is a session "
349
+ f"worktree (branch {resolution.branch or '(detached)'})."
350
+ ),
351
+ )
352
+
353
+ gate = load_gate_module(resolution.root, _MAIN_CHECKOUT_GATE)
354
+ if gate is None:
355
+ return PolicyDecision(
356
+ allowed=False,
357
+ code="gate-missing",
358
+ explanation=(
359
+ "Refusing: this is the MAIN checkout (integration-only) and the "
360
+ f"canonical gate {_MAIN_CHECKOUT_GATE}.py could not be read from "
361
+ "the filesystem. Failing closed rather than guessing the policy."
362
+ ),
363
+ remedy=(
364
+ "Restore scripts/gates/main_checkout_integration_only_gate.py, or "
365
+ "work in a session worktree: bash scripts/admin/session_worktree.sh"
366
+ ),
367
+ )
368
+
369
+ ok, blocked = gate.evaluate(
370
+ list(staged_paths), message, True, env_override
371
+ )
372
+ if ok:
373
+ return PolicyDecision(
374
+ allowed=True,
375
+ code="coordination-paths",
376
+ explanation=(
377
+ "All staged paths are on the coordination/telemetry allowlist; "
378
+ "this commit may land from the main checkout."
379
+ ),
380
+ )
381
+
382
+ listing = ", ".join(blocked[:15])
383
+ if len(blocked) > 15:
384
+ listing += f", ... and {len(blocked) - 15} more"
385
+ return PolicyDecision(
386
+ allowed=False,
387
+ code="main-checkout-source-commit",
388
+ explanation=(
389
+ "The main checkout is integration-only — source commits happen in a "
390
+ f"session worktree, not here. Staged paths outside the allowlist: {listing}"
391
+ ),
392
+ remedy=(
393
+ "Work in your session worktree (bash scripts/admin/session_worktree.sh "
394
+ "prints its path), commit there, then ship with "
395
+ "bash scripts/admin/ship_worktree.sh. Emergency override: "
396
+ "[override:commit:<reason>] in the subject, or JB_ALLOW_MAIN_COMMIT=1."
397
+ ),
398
+ )
399
+
400
+
401
+ def preflight_write(
402
+ directory: str | os.PathLike[str],
403
+ paths: list[str] | tuple[str, ...],
404
+ ) -> PolicyDecision:
405
+ """Gate a harness write against the platform's main-checkout policy.
406
+
407
+ Only applies inside the platform repo — the one that ships the canonical
408
+ gate. Any other ``--dir`` is a plain project and no local ship policy
409
+ applies, so the harness is a general client, not a platform-only tool.
410
+
411
+ Inside the platform repo the decision is DELEGATED to
412
+ ``check_source_commit`` (Gate 31's own allowlist), so the harness cannot
413
+ drift from or weaken the gate.
414
+ """
415
+ resolution = resolve_session_worktree(directory)
416
+ if not resolution.is_repo or resolution.root is None:
417
+ return PolicyDecision(
418
+ allowed=True,
419
+ code="not-a-repo",
420
+ explanation="Not a git repository; the platform write policy does not apply.",
421
+ )
422
+ gate_path = resolution.root / "scripts" / "gates" / f"{_MAIN_CHECKOUT_GATE}.py"
423
+ if not gate_path.is_file():
424
+ return PolicyDecision(
425
+ allowed=True,
426
+ code="not-platform-repo",
427
+ explanation=(
428
+ "This directory is not the platform repo (no canonical gate on "
429
+ "disk); no local ship policy applies."
430
+ ),
431
+ )
432
+ return check_source_commit(directory, paths)
433
+
434
+
435
+ def pushes_main(refspec: str) -> bool:
436
+ """True when ``refspec`` targets ``main``.
437
+
438
+ Accepts a raw command/refspec (``git push origin HEAD:main``), a bare
439
+ ``origin main``, or pre-push stdin lines (``<local> <sha> <remote-ref>``).
440
+ """
441
+ text = refspec or ""
442
+ for line in text.splitlines():
443
+ parts = line.split()
444
+ if len(parts) >= 3 and parts[2] in (_MAIN_REF, "main"):
445
+ return True
446
+ return bool(_MAIN_TARGET_RE.search(text))
447
+
448
+
449
+ def check_push(directory: str | os.PathLike[str], refspec: str) -> PolicyDecision:
450
+ """Refuse ``git push origin HEAD:main`` — main moves only via the landing gate.
451
+
452
+ This check exists so a loop that proposes a push to main is told why the
453
+ platform refuses it and what to run instead. A non-main branch may be
454
+ pushed only by the explicit ``--git-write`` path.
455
+ """
456
+ if not pushes_main(refspec):
457
+ return PolicyDecision(
458
+ allowed=True,
459
+ code="not-main",
460
+ explanation=(
461
+ "This push does not target main, so the landing gate is not "
462
+ "involved. (Only the explicit --git-write path pushes a branch; "
463
+ "shipping to main stays the platform's path.)"
464
+ ),
465
+ )
466
+ return PolicyDecision(
467
+ allowed=False,
468
+ code="push-to-main",
469
+ explanation=(
470
+ "Refused: main only accepts SHAs that passed the server-side landing "
471
+ "gate (JB_DECISIONS 2026-09-08). A direct 'git push origin HEAD:main' "
472
+ "is rejected by the ruleset."
473
+ ),
474
+ remedy=(
475
+ "Ship the worktree branch instead: bash scripts/admin/ship_worktree.sh "
476
+ "(it rebases onto origin/main, runs the proof battery, and moves main "
477
+ "through bash scripts/admin/push_main_checked.sh)."
478
+ ),
479
+ command="bash scripts/admin/ship_worktree.sh",
480
+ )
481
+
482
+
483
+ def _default_battery_runner(
484
+ hook_path: Path, ref_lines: str, cwd: Path
485
+ ) -> tuple[int, str]:
486
+ bash = os.environ.get("SID_HARNESS_BASH") or "bash"
487
+ proc = subprocess.run(
488
+ [bash, str(hook_path)],
489
+ input=ref_lines + "\n",
490
+ capture_output=True,
491
+ text=True,
492
+ encoding="utf-8",
493
+ errors="replace",
494
+ cwd=str(cwd),
495
+ )
496
+ return proc.returncode, (proc.stdout or "") + (proc.stderr or "")
497
+
498
+
499
+ def run_pre_push_battery(
500
+ directory: str | os.PathLike[str],
501
+ *,
502
+ base: str = "origin/main",
503
+ runner=None,
504
+ ) -> PolicyDecision:
505
+ """Run the platform's own pre-push battery before a commit is offered.
506
+
507
+ The battery is the tracked hook ``.git-hooks/pre-push`` discovered on disk —
508
+ the harness does not carry a second list of checks. ``runner`` is injectable
509
+ for tests; the default shells the hook with the current branch's push line.
510
+ """
511
+ resolution = resolve_session_worktree(directory)
512
+ if not resolution.is_repo or resolution.root is None:
513
+ return PolicyDecision(
514
+ allowed=False,
515
+ code="not-a-repo",
516
+ explanation="Refusing: not a git repository; no pre-push battery to run.",
517
+ )
518
+
519
+ inventory = gate_inventory(resolution.root)
520
+ if inventory.hook_path is None:
521
+ return PolicyDecision(
522
+ allowed=False,
523
+ code="hook-missing",
524
+ explanation=(
525
+ "Refusing: the canonical pre-push battery (.git-hooks/pre-push) "
526
+ "is not present, so the platform's checks cannot be run."
527
+ ),
528
+ remedy="Restore .git-hooks/pre-push (bash scripts/admin/install_hooks.sh).",
529
+ )
530
+
531
+ branch = resolution.branch or "HEAD"
532
+ head = resolution.head or "HEAD"
533
+ ref_lines = f"refs/heads/{branch} {head} refs/heads/{branch}"
534
+ run = runner or _default_battery_runner
535
+ returncode, output = run(inventory.hook_path, ref_lines, resolution.root)
536
+ if returncode == 0:
537
+ return PolicyDecision(
538
+ allowed=True,
539
+ code="battery-passed",
540
+ explanation=(
541
+ f"Pre-push battery passed ({len(inventory.wired_gates)} wired "
542
+ f"gate(s); {len(inventory.gate_files)} on disk)."
543
+ ),
544
+ )
545
+ return PolicyDecision(
546
+ allowed=False,
547
+ code="battery-failed",
548
+ explanation=(output.strip() or "pre-push battery failed."),
549
+ remedy="Fix the reported gate(s) before committing; the platform would block this range.",
550
+ )
551
+
552
+
553
+ def _commit_command(staged_paths: list[str] | tuple[str, ...]) -> str:
554
+ from shlex import quote
555
+
556
+ paths = " ".join(quote(p) for p in sorted(staged_paths))
557
+ return f'git commit -F "$MSG" -- {paths}'.strip()
558
+
559
+
560
+ def offer_commit(
561
+ directory: str | os.PathLike[str],
562
+ staged_paths: list[str] | tuple[str, ...],
563
+ message: str,
564
+ *,
565
+ base: str = "origin/main",
566
+ battery_runner=None,
567
+ ) -> PolicyDecision:
568
+ """Decide whether a source commit may be offered, battery included.
569
+
570
+ Runs the main-checkout refusal first, then the pre-push battery. When both
571
+ pass it returns the sanctioned commit command — the harness never runs it
572
+ and never pushes.
573
+ """
574
+ commit_decision = check_source_commit(directory, staged_paths, message)
575
+ if not commit_decision.allowed:
576
+ return commit_decision
577
+
578
+ battery = run_pre_push_battery(directory, base=base, runner=battery_runner)
579
+ if not battery.allowed:
580
+ return battery
581
+
582
+ resolution = resolve_session_worktree(directory)
583
+ return PolicyDecision(
584
+ allowed=True,
585
+ code="offer-commit",
586
+ explanation=(
587
+ f"Source commit may be offered from {resolution.root} "
588
+ f"(branch {resolution.branch or '(detached)'}); pre-push battery passed."
589
+ ),
590
+ remedy=(
591
+ "This policy check never commits or pushes; the explicit --git-write "
592
+ "path is the only write path (and it refuses main/master). Run the "
593
+ "command yourself, or ship the branch with: "
594
+ "bash scripts/admin/ship_worktree.sh"
595
+ ),
596
+ command=_commit_command(staged_paths),
597
+ )
598
+
599
+
600
+ def policy_report(directory: str | os.PathLike[str]) -> dict[str, object]:
601
+ """A single read-only summary for diagnostics/CLI: worktree + inventory."""
602
+ resolution = resolve_session_worktree(directory)
603
+ inventory = gate_inventory(directory)
604
+ return {
605
+ "worktree": resolution.as_dict(),
606
+ "gates": inventory.as_dict(),
607
+ }
608
+
609
+
610
+ __all__ = [
611
+ "GateInventory",
612
+ "PolicyDecision",
613
+ "WorktreeResolution",
614
+ "check_push",
615
+ "check_source_commit",
616
+ "find_repo_root",
617
+ "gate_inventory",
618
+ "load_gate_module",
619
+ "main_worktree_root",
620
+ "offer_commit",
621
+ "policy_report",
622
+ "preflight_write",
623
+ "pushes_main",
624
+ "resolve_session_worktree",
625
+ "run_pre_push_battery",
626
+ ]