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.
- sid_harness/__init__.py +13 -0
- sid_harness/__main__.py +4 -0
- sid_harness/_native.py +1639 -0
- sid_harness/brave_proof.py +246 -0
- sid_harness/bridge.py +851 -0
- sid_harness/cli.py +937 -0
- sid_harness/client.py +161 -0
- sid_harness/companion.py +151 -0
- sid_harness/config.py +68 -0
- sid_harness/context/__init__.py +40 -0
- sid_harness/context/index.py +267 -0
- sid_harness/context/retrieval.py +74 -0
- sid_harness/editor.py +231 -0
- sid_harness/gateway.py +106 -0
- sid_harness/git_write.py +598 -0
- sid_harness/local_workspace.py +421 -0
- sid_harness/loop.py +394 -0
- sid_harness/lsp.py +598 -0
- sid_harness/policy/__init__.py +44 -0
- sid_harness/policy/repo_policy.py +626 -0
- sid_harness/receipt.py +142 -0
- sid_harness/runner.py +468 -0
- sid_harness/selftest.py +149 -0
- sid_harness/session/__init__.py +33 -0
- sid_harness/session/bounds.py +140 -0
- sid_harness/session/diff_gate.py +93 -0
- sid_harness/session/session.py +427 -0
- sid_harness/session/store.py +88 -0
- sid_harness/tools/__init__.py +52 -0
- sid_harness/tools/mcp_client.py +285 -0
- sid_harness/tools/plan.py +138 -0
- sid_harness/tools/registry.py +159 -0
- sid_harness/tools/skills.py +185 -0
- sid_harness/tools/subagents.py +112 -0
- sid_harness/tui.py +96 -0
- sid_harness/workspace.py +107 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/README.md +46 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/README.md +32 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/extension.js +395 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/package.json +120 -0
- sid_harness-0.3.0.dist-info/METADATA +229 -0
- sid_harness-0.3.0.dist-info/RECORD +45 -0
- sid_harness-0.3.0.dist-info/WHEEL +5 -0
- sid_harness-0.3.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|