@arbiterforge/ca-pi 0.10.8 → 0.10.11
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.
- package/README.md +2 -2
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +18 -0
- package/plugins/ca-pi/hooks/_appendonlyconflictlib.py +461 -0
- package/plugins/ca-pi/hooks/_hooklib.py +85 -9
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/_modelib.py +12 -3
- package/plugins/ca-pi/hooks/_overrideappendlib.py +154 -0
- package/plugins/ca-pi/hooks/_taskboardlib.py +18 -1
- package/plugins/ca-pi/hooks/_taskexcludelib.py +223 -0
- package/plugins/ca-pi/hooks/append-override.py +8 -0
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- package/plugins/ca-pi/hooks/init-codearbiter.py +34 -19
- package/plugins/ca-pi/hooks/resolve-append-only-conflict.py +8 -0
- package/plugins/ca-pi/skills/ca-override/SKILL.md +40 -6
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ project context. You decide. codeArbiter enforces.
|
|
|
10
10
|
<img alt="Claude Code plugin" src="https://img.shields.io/badge/Claude_Code-plugin-d97757">
|
|
11
11
|
<img alt="Codex plugin" src="https://img.shields.io/badge/OpenAI_Codex-plugin-10a37f">
|
|
12
12
|
<img alt="Pi Feature Forge preview" src="https://img.shields.io/badge/ca--pi-Feature_Forge_preview-d97757">
|
|
13
|
-
<img alt="version 2.17.
|
|
13
|
+
<img alt="version 2.17.10" src="https://img.shields.io/badge/version-2.17.10-2b7489">
|
|
14
14
|
<img alt="core lanes" src="https://img.shields.io/badge/core_lanes-18-555">
|
|
15
15
|
<img alt="skills" src="https://img.shields.io/badge/skills-23-555">
|
|
16
16
|
<img alt="agents" src="https://img.shields.io/badge/agents-19-555">
|
|
@@ -117,7 +117,7 @@ Approve the normal plugin trust prompt, open the target repository, and continue
|
|
|
117
117
|
|
|
118
118
|
### Codex CLI
|
|
119
119
|
|
|
120
|
-
The public GitHub-slug flow is **available now**. The repository currently ships `ca-codex 0.9.
|
|
120
|
+
The public GitHub-slug flow is **available now**. The repository currently ships `ca-codex 0.9.10`;
|
|
121
121
|
the dated end-to-end public-install record discovered `ca-codex 0.2.4` from release `v2.8.13`.
|
|
122
122
|
Current packaging and shared-core parity are continuously verified, while that dated live-install
|
|
123
123
|
record stays labeled rather than being silently promoted to evidence for a newer adapter:
|
package/package.json
CHANGED
|
@@ -4,6 +4,24 @@ All notable changes to `ca-pi` are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.10.11] - 2026-09-08
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Resolve genuine append-only audit-log conflicts through a fail-closed helper that preserves both branch histories and records the immediate H-05 authorization, with shared audit-path locks and a locked mandatory-override writer preventing concurrent row loss.
|
|
12
|
+
|
|
13
|
+
## [0.10.10] - 2026-09-07
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Use the same documented done-tasks template for initialization and first-use archiving, with byte-identical UTF-8 headers on Windows and POSIX.
|
|
18
|
+
|
|
19
|
+
## [0.10.9] - 2026-09-06
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Keep task-board lifecycles Git-clean by locally excluding the retained OS-lock sidecar during initialization, with an explicit repair option for existing repositories.
|
|
24
|
+
|
|
7
25
|
## [0.10.8] - 2026-09-06
|
|
8
26
|
|
|
9
27
|
### Fixed
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter - fail-closed resolution of genuine H-05 append-only conflicts.
|
|
3
|
+
#
|
|
4
|
+
# The public entry point is resolve-append-only-conflict.py. This module keeps
|
|
5
|
+
# Git inspection, byte validation, deterministic union construction, atomic
|
|
6
|
+
# replacement, and explicit staging testable and host-neutral.
|
|
7
|
+
#
|
|
8
|
+
# Public API:
|
|
9
|
+
# resolve(target, reason, cwd=None) -> str resolve and return repo-relative path
|
|
10
|
+
# main(argv=None) -> int CLI contract
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
import os
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
import stat
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
|
|
23
|
+
from _hooklib import acquire_lock, audit_lock_key, release_lock
|
|
24
|
+
from _gitexec import git_executable
|
|
25
|
+
from _protectedlib import classify_protected
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
BOM = b"\xef\xbb\xbf"
|
|
29
|
+
MAX_REASON = 500
|
|
30
|
+
SUPPORTED_AUDIT_PATHS = frozenset(
|
|
31
|
+
{".codearbiter/overrides.log", ".codearbiter/gate-events.log"}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ResolutionError(RuntimeError):
|
|
36
|
+
"""A surfaced, non-mutating refusal of the conflict-resolution contract."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ConcurrentAppendError(ResolutionError):
|
|
40
|
+
"""A displaced original inode changed and was restored before refusal."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, path, restored, observed=None):
|
|
43
|
+
super().__init__(f"concurrent append detected while replacing '{Path(path).name}'")
|
|
44
|
+
self.path = Path(path)
|
|
45
|
+
self.restored = restored
|
|
46
|
+
self.observed = observed
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _git(cwd, *args, input_bytes=None, check=True):
|
|
50
|
+
try:
|
|
51
|
+
result = subprocess.run(
|
|
52
|
+
[git_executable(), *args],
|
|
53
|
+
cwd=cwd,
|
|
54
|
+
input=input_bytes,
|
|
55
|
+
stdout=subprocess.PIPE,
|
|
56
|
+
stderr=subprocess.PIPE,
|
|
57
|
+
check=False,
|
|
58
|
+
)
|
|
59
|
+
except OSError as error:
|
|
60
|
+
raise ResolutionError(f"git unavailable: {error}") from error
|
|
61
|
+
if check and result.returncode != 0:
|
|
62
|
+
detail = result.stderr.decode("utf-8", errors="replace").strip()
|
|
63
|
+
raise ResolutionError(f"git {' '.join(args)} failed: {detail or 'unknown error'}")
|
|
64
|
+
return result
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _repository_root(cwd):
|
|
68
|
+
result = _git(cwd, "rev-parse", "--show-toplevel")
|
|
69
|
+
try:
|
|
70
|
+
raw = result.stdout.decode("utf-8", errors="strict").strip()
|
|
71
|
+
except UnicodeDecodeError as error:
|
|
72
|
+
raise ResolutionError("git returned an invalid UTF-8 repository root") from error
|
|
73
|
+
if not raw:
|
|
74
|
+
raise ResolutionError("git returned an empty repository root")
|
|
75
|
+
return Path(raw).resolve()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _relative_target(root, target):
|
|
79
|
+
candidate = Path(target)
|
|
80
|
+
if candidate.is_absolute():
|
|
81
|
+
raise ResolutionError("target must be a repository-relative supported append-only path")
|
|
82
|
+
normalized = Path(os.path.normpath(str(candidate)))
|
|
83
|
+
if normalized == Path(".") or ".." in normalized.parts:
|
|
84
|
+
raise ResolutionError("target must be a contained supported append-only path")
|
|
85
|
+
path = root / normalized
|
|
86
|
+
try:
|
|
87
|
+
resolved_root = os.path.realpath(root)
|
|
88
|
+
resolved_path = os.path.realpath(path)
|
|
89
|
+
common = os.path.commonpath((resolved_root, resolved_path))
|
|
90
|
+
except ValueError as error:
|
|
91
|
+
raise ResolutionError("target escapes the repository root") from error
|
|
92
|
+
if os.path.normcase(common) != os.path.normcase(resolved_root):
|
|
93
|
+
raise ResolutionError("target escapes the repository root")
|
|
94
|
+
relative = normalized.as_posix()
|
|
95
|
+
if relative not in SUPPORTED_AUDIT_PATHS or "audit" not in classify_protected(
|
|
96
|
+
str(path), str(root)
|
|
97
|
+
):
|
|
98
|
+
raise ResolutionError(f"'{relative}' is not a supported append-only H-05 path")
|
|
99
|
+
try:
|
|
100
|
+
mode = path.lstat().st_mode
|
|
101
|
+
except OSError as error:
|
|
102
|
+
raise ResolutionError(f"cannot inspect '{relative}': {error}") from error
|
|
103
|
+
if not stat.S_ISREG(mode) or path.is_symlink():
|
|
104
|
+
raise ResolutionError(f"'{relative}' is not a regular non-link file")
|
|
105
|
+
return relative, path
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _validate_bytes(label, raw):
|
|
109
|
+
if BOM in raw:
|
|
110
|
+
raise ResolutionError(f"{label} contains a UTF-8 BOM")
|
|
111
|
+
if b"\x00" in raw:
|
|
112
|
+
raise ResolutionError(f"{label} contains a NUL byte")
|
|
113
|
+
if b"\r" in raw:
|
|
114
|
+
raise ResolutionError(f"{label} is not canonical LF text")
|
|
115
|
+
try:
|
|
116
|
+
raw.decode("utf-8", errors="strict")
|
|
117
|
+
except UnicodeDecodeError as error:
|
|
118
|
+
raise ResolutionError(f"{label} is not valid UTF-8") from error
|
|
119
|
+
if raw and not raw.endswith(b"\n"):
|
|
120
|
+
raise ResolutionError(f"{label} does not end with LF")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _marker_run(line, marker):
|
|
124
|
+
if not line.endswith(b"\n"):
|
|
125
|
+
return 0
|
|
126
|
+
body = line[:-1]
|
|
127
|
+
size = 0
|
|
128
|
+
while size < len(body) and body[size : size + 1] == marker:
|
|
129
|
+
size += 1
|
|
130
|
+
if size < 7 or (len(body) > size and body[size : size + 1] != b" "):
|
|
131
|
+
return 0
|
|
132
|
+
return size
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _validate_conflicted_worktree(raw, base, ours_suffix, theirs_suffix):
|
|
136
|
+
"""Prove the worktree is only Git's rendering of the three index stages."""
|
|
137
|
+
# Git for Windows may render conflict markers and checked-out blob lines as
|
|
138
|
+
# CRLF even though every index blob is canonical LF. Normalize only complete
|
|
139
|
+
# CRLF pairs for comparison; lone CR remains invalid and the original bytes
|
|
140
|
+
# stay untouched for rollback.
|
|
141
|
+
normalized = raw.replace(b"\r\n", b"\n")
|
|
142
|
+
_validate_bytes("conflicted worktree", normalized)
|
|
143
|
+
if not normalized.startswith(base):
|
|
144
|
+
raise ResolutionError("conflicted worktree does not preserve the merge-base prefix")
|
|
145
|
+
lines = normalized[len(base) :].splitlines(keepends=True)
|
|
146
|
+
starts = [(index, _marker_run(line, b"<")) for index, line in enumerate(lines)]
|
|
147
|
+
starts = [(index, size) for index, size in starts if size]
|
|
148
|
+
if len(starts) != 1:
|
|
149
|
+
raise ResolutionError("conflicted worktree is not one recognized Git conflict rendering")
|
|
150
|
+
start, marker_size = starts[0]
|
|
151
|
+
separator_line = (b"=" * marker_size) + b"\n"
|
|
152
|
+
separators = [index for index, line in enumerate(lines) if line == separator_line]
|
|
153
|
+
ends = [
|
|
154
|
+
index
|
|
155
|
+
for index, line in enumerate(lines)
|
|
156
|
+
if _marker_run(line, b">") == marker_size
|
|
157
|
+
]
|
|
158
|
+
base_markers = [
|
|
159
|
+
index
|
|
160
|
+
for index, line in enumerate(lines)
|
|
161
|
+
if _marker_run(line, b"|") == marker_size
|
|
162
|
+
]
|
|
163
|
+
if len(separators) != 1 or len(ends) != 1 or len(base_markers) > 1:
|
|
164
|
+
raise ResolutionError("conflicted worktree has ambiguous Git conflict markers")
|
|
165
|
+
separator = separators[0]
|
|
166
|
+
end = ends[0]
|
|
167
|
+
base_marker = base_markers[0] if base_markers else None
|
|
168
|
+
ours_end = base_marker if base_marker is not None else separator
|
|
169
|
+
if not (start < ours_end <= separator < end):
|
|
170
|
+
raise ResolutionError("conflicted worktree has out-of-order Git conflict markers")
|
|
171
|
+
if base_marker is not None and b"".join(lines[base_marker + 1 : separator]):
|
|
172
|
+
raise ResolutionError("conflicted worktree has unexpected merge-base conflict bytes")
|
|
173
|
+
|
|
174
|
+
common_prefix = b"".join(lines[:start])
|
|
175
|
+
ours_body = b"".join(lines[start + 1 : ours_end])
|
|
176
|
+
theirs_body = b"".join(lines[separator + 1 : end])
|
|
177
|
+
common_suffix = b"".join(lines[end + 1 :])
|
|
178
|
+
if ours_suffix != common_prefix + ours_body + common_suffix:
|
|
179
|
+
raise ResolutionError("conflicted worktree differs from the current-branch index stage")
|
|
180
|
+
if theirs_suffix != common_prefix + theirs_body + common_suffix:
|
|
181
|
+
raise ResolutionError("conflicted worktree differs from the incoming-branch index stage")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _unmerged(root, relative, *, allow_resolved=False):
|
|
185
|
+
result = _git(root, "ls-files", "-u", "-z", "--", relative)
|
|
186
|
+
records = []
|
|
187
|
+
for item in result.stdout.split(b"\0"):
|
|
188
|
+
if not item:
|
|
189
|
+
continue
|
|
190
|
+
try:
|
|
191
|
+
metadata, encoded_path = item.split(b"\t", 1)
|
|
192
|
+
mode, oid, stage = metadata.decode("ascii").split(" ")
|
|
193
|
+
path = encoded_path.decode("utf-8", errors="strict")
|
|
194
|
+
records.append((mode, oid, int(stage), path))
|
|
195
|
+
except (ValueError, UnicodeDecodeError) as error:
|
|
196
|
+
raise ResolutionError("malformed unmerged index record") from error
|
|
197
|
+
if allow_resolved and not records:
|
|
198
|
+
return []
|
|
199
|
+
if len(records) != 3 or [record[2] for record in records] != [1, 2, 3]:
|
|
200
|
+
raise ResolutionError("target is not in a complete three-stage conflict")
|
|
201
|
+
if any(record[3] != relative for record in records):
|
|
202
|
+
raise ResolutionError("unmerged index path does not match the requested target")
|
|
203
|
+
if any(record[0] != "100644" for record in records):
|
|
204
|
+
raise ResolutionError("append-only conflict stages must all be regular 100644 blobs")
|
|
205
|
+
return records
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _tracked_index(root, relative):
|
|
209
|
+
result = _git(root, "ls-files", "--stage", "-z", "--", relative)
|
|
210
|
+
records = []
|
|
211
|
+
for item in result.stdout.split(b"\0"):
|
|
212
|
+
if not item:
|
|
213
|
+
continue
|
|
214
|
+
try:
|
|
215
|
+
metadata, encoded_path = item.split(b"\t", 1)
|
|
216
|
+
mode, oid, stage = metadata.decode("ascii").split(" ")
|
|
217
|
+
path = encoded_path.decode("utf-8", errors="strict")
|
|
218
|
+
records.append((mode, oid, int(stage), path))
|
|
219
|
+
except (ValueError, UnicodeDecodeError) as error:
|
|
220
|
+
raise ResolutionError("malformed tracked index record") from error
|
|
221
|
+
return records
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _blob(root, oid, label):
|
|
225
|
+
result = _git(root, "cat-file", "blob", oid)
|
|
226
|
+
raw = result.stdout
|
|
227
|
+
_validate_bytes(label, raw)
|
|
228
|
+
return raw
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _authorization(root, reason):
|
|
232
|
+
reason = reason.strip()
|
|
233
|
+
if not reason:
|
|
234
|
+
raise ResolutionError("--reason must contain a non-empty reason")
|
|
235
|
+
if len(reason) > MAX_REASON or "|" in reason or "\n" in reason or "\r" in reason:
|
|
236
|
+
raise ResolutionError("--reason must be at most 500 characters with no pipes or newlines")
|
|
237
|
+
result = _git(root, "config", "--get", "user.email", check=False)
|
|
238
|
+
if result.returncode != 0:
|
|
239
|
+
raise ResolutionError("git config user.email is required for the attributed override")
|
|
240
|
+
try:
|
|
241
|
+
identity = result.stdout.decode("utf-8", errors="strict").strip()
|
|
242
|
+
except UnicodeDecodeError as error:
|
|
243
|
+
raise ResolutionError("git config user.email is not valid UTF-8") from error
|
|
244
|
+
if not identity or len(identity) > 254 or "|" in identity or "\n" in identity or "\r" in identity:
|
|
245
|
+
raise ResolutionError("git config user.email is not a valid override identity")
|
|
246
|
+
timestamp = datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
247
|
+
return f"[{timestamp}] | BY: {identity} | GATE: H-05 | REASON: {reason}\n".encode("utf-8")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _append_parts(root, records):
|
|
251
|
+
base = _blob(root, records[0][1], "merge base")
|
|
252
|
+
ours = _blob(root, records[1][1], "current branch")
|
|
253
|
+
theirs = _blob(root, records[2][1], "incoming branch")
|
|
254
|
+
if not ours.startswith(base):
|
|
255
|
+
raise ResolutionError("current branch is not append-only relative to the merge base")
|
|
256
|
+
if not theirs.startswith(base):
|
|
257
|
+
raise ResolutionError("incoming branch is not append-only relative to the merge base")
|
|
258
|
+
ours_suffix = ours[len(base) :]
|
|
259
|
+
theirs_suffix = theirs[len(base) :]
|
|
260
|
+
if not ours_suffix or not theirs_suffix:
|
|
261
|
+
raise ResolutionError("both conflict sides must append non-empty history")
|
|
262
|
+
return base, ours_suffix, theirs_suffix
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _build_union(root, records):
|
|
266
|
+
base, ours_suffix, theirs_suffix = _append_parts(root, records)
|
|
267
|
+
merged = base + ours_suffix + theirs_suffix
|
|
268
|
+
_validate_bytes("resolved output", merged)
|
|
269
|
+
return merged
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _sync_parent(path):
|
|
273
|
+
if os.name == "nt":
|
|
274
|
+
return False
|
|
275
|
+
flags = os.O_RDONLY
|
|
276
|
+
if hasattr(os, "O_DIRECTORY"):
|
|
277
|
+
flags |= os.O_DIRECTORY
|
|
278
|
+
parent_fd = os.open(path.parent, flags)
|
|
279
|
+
try:
|
|
280
|
+
os.fsync(parent_fd)
|
|
281
|
+
finally:
|
|
282
|
+
os.close(parent_fd)
|
|
283
|
+
return True
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _write_bytes_atomic(path, raw, *, expected=None):
|
|
287
|
+
original_mode = stat.S_IMODE(path.stat().st_mode)
|
|
288
|
+
fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".", suffix=".tmp")
|
|
289
|
+
witness = None
|
|
290
|
+
try:
|
|
291
|
+
with os.fdopen(fd, "wb") as handle:
|
|
292
|
+
handle.write(raw)
|
|
293
|
+
handle.flush()
|
|
294
|
+
os.fsync(handle.fileno())
|
|
295
|
+
os.chmod(temporary, original_mode)
|
|
296
|
+
if expected is not None:
|
|
297
|
+
witness_fd, witness = tempfile.mkstemp(
|
|
298
|
+
dir=path.parent, prefix=path.name + ".", suffix=".witness"
|
|
299
|
+
)
|
|
300
|
+
os.close(witness_fd)
|
|
301
|
+
os.remove(witness)
|
|
302
|
+
os.link(path, witness)
|
|
303
|
+
observed = Path(witness).read_bytes()
|
|
304
|
+
if observed != expected:
|
|
305
|
+
raise ConcurrentAppendError(path, restored=True, observed=observed)
|
|
306
|
+
os.replace(temporary, path)
|
|
307
|
+
temporary = None
|
|
308
|
+
_sync_parent(path)
|
|
309
|
+
if witness is not None:
|
|
310
|
+
displaced = Path(witness).read_bytes()
|
|
311
|
+
if displaced != expected:
|
|
312
|
+
restored = False
|
|
313
|
+
try:
|
|
314
|
+
os.replace(witness, path)
|
|
315
|
+
witness = None
|
|
316
|
+
_sync_parent(path)
|
|
317
|
+
restored = True
|
|
318
|
+
finally:
|
|
319
|
+
raise ConcurrentAppendError(path, restored=restored, observed=displaced)
|
|
320
|
+
os.remove(witness)
|
|
321
|
+
witness = None
|
|
322
|
+
except Exception:
|
|
323
|
+
if temporary is not None:
|
|
324
|
+
try:
|
|
325
|
+
os.remove(temporary)
|
|
326
|
+
except OSError:
|
|
327
|
+
pass
|
|
328
|
+
if witness is not None:
|
|
329
|
+
try:
|
|
330
|
+
os.remove(witness)
|
|
331
|
+
except OSError:
|
|
332
|
+
pass
|
|
333
|
+
raise
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _restore_index(root, relative, records):
|
|
337
|
+
removed = _git(root, "update-index", "--force-remove", "--", relative, check=False)
|
|
338
|
+
if removed.returncode != 0:
|
|
339
|
+
detail = removed.stderr.decode("utf-8", errors="replace").strip()
|
|
340
|
+
raise ResolutionError(f"could not clear staged resolution during rollback: {detail}")
|
|
341
|
+
index_info = b"".join(
|
|
342
|
+
f"{mode} {oid} {stage}\t{path}\n".encode("utf-8")
|
|
343
|
+
for mode, oid, stage, path in records
|
|
344
|
+
)
|
|
345
|
+
_git(root, "update-index", "--index-info", input_bytes=index_info)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def resolve(target, reason, cwd=None):
|
|
349
|
+
root = _repository_root(cwd or os.getcwd())
|
|
350
|
+
relative, path = _relative_target(root, target)
|
|
351
|
+
records = _unmerged(root, relative)
|
|
352
|
+
authorization = _authorization(root, reason)
|
|
353
|
+
base, ours_suffix, theirs_suffix = _append_parts(root, records)
|
|
354
|
+
proposed = base + ours_suffix + theirs_suffix
|
|
355
|
+
_validate_bytes("resolved output", proposed)
|
|
356
|
+
changes = {relative: proposed}
|
|
357
|
+
paths = {relative: path}
|
|
358
|
+
originals = {relative: path.read_bytes()}
|
|
359
|
+
_validate_conflicted_worktree(originals[relative], base, ours_suffix, theirs_suffix)
|
|
360
|
+
index_records = {relative: records}
|
|
361
|
+
override_relative = ".codearbiter/overrides.log"
|
|
362
|
+
if relative == override_relative:
|
|
363
|
+
changes[relative] += authorization
|
|
364
|
+
else:
|
|
365
|
+
override_relative, override_path = _relative_target(root, override_relative)
|
|
366
|
+
if _unmerged(root, override_relative, allow_resolved=True):
|
|
367
|
+
raise ResolutionError("the override audit sink is itself unmerged")
|
|
368
|
+
for diff_args in (("diff", "--quiet"), ("diff", "--cached", "--quiet")):
|
|
369
|
+
clean = _git(root, *diff_args, "--", override_relative, check=False)
|
|
370
|
+
if clean.returncode != 0:
|
|
371
|
+
raise ResolutionError("the override audit sink must be clean before resolution")
|
|
372
|
+
override_raw = override_path.read_bytes()
|
|
373
|
+
_validate_bytes("override audit sink", override_raw)
|
|
374
|
+
changes[override_relative] = override_raw + authorization
|
|
375
|
+
paths[override_relative] = override_path
|
|
376
|
+
originals[override_relative] = override_raw
|
|
377
|
+
index_records[override_relative] = _tracked_index(root, override_relative)
|
|
378
|
+
try:
|
|
379
|
+
common_raw = _git(root, "rev-parse", "--git-common-dir").stdout.decode(
|
|
380
|
+
"utf-8", errors="strict"
|
|
381
|
+
).strip()
|
|
382
|
+
except UnicodeDecodeError as error:
|
|
383
|
+
raise ResolutionError("git returned an invalid UTF-8 common directory") from error
|
|
384
|
+
if not common_raw:
|
|
385
|
+
raise ResolutionError("git returned an empty common directory")
|
|
386
|
+
common = Path(common_raw)
|
|
387
|
+
if not common.is_absolute():
|
|
388
|
+
common = root / common
|
|
389
|
+
lock = acquire_lock(str(common.resolve() / "codearbiter-append-only-conflict"))
|
|
390
|
+
if lock is None:
|
|
391
|
+
raise ResolutionError("another append-only conflict resolution holds the repository lock")
|
|
392
|
+
path_locks = []
|
|
393
|
+
try:
|
|
394
|
+
for changed_relative in sorted(changes):
|
|
395
|
+
path_lock = acquire_lock(audit_lock_key(root, paths[changed_relative]))
|
|
396
|
+
if path_lock is None:
|
|
397
|
+
raise ResolutionError(
|
|
398
|
+
f"another writer holds the audit path lock for '{changed_relative}'"
|
|
399
|
+
)
|
|
400
|
+
path_locks.append(path_lock)
|
|
401
|
+
if _unmerged(root, relative) != records or path.read_bytes() != originals[relative]:
|
|
402
|
+
raise ResolutionError("conflict state changed during validation; retry from fresh evidence")
|
|
403
|
+
try:
|
|
404
|
+
for changed_relative, raw in changes.items():
|
|
405
|
+
if paths[changed_relative].read_bytes() != originals[changed_relative]:
|
|
406
|
+
raise ResolutionError("protected state changed during validation; retry")
|
|
407
|
+
_write_bytes_atomic(
|
|
408
|
+
paths[changed_relative], raw, expected=originals[changed_relative]
|
|
409
|
+
)
|
|
410
|
+
staged = _git(root, "add", "--", *changes.keys(), check=False)
|
|
411
|
+
if staged.returncode != 0:
|
|
412
|
+
detail = staged.stderr.decode("utf-8", errors="replace").strip()
|
|
413
|
+
raise ResolutionError(f"git add failed: {detail or 'unknown error'}")
|
|
414
|
+
if _unmerged(root, relative, allow_resolved=True):
|
|
415
|
+
raise ResolutionError("git add did not clear the three-stage conflict")
|
|
416
|
+
for changed_relative, raw in changes.items():
|
|
417
|
+
index_blob = _git(root, "show", f":0:{changed_relative}").stdout
|
|
418
|
+
if index_blob != raw:
|
|
419
|
+
raise ResolutionError(
|
|
420
|
+
f"staged blob does not match validated output for {changed_relative}"
|
|
421
|
+
)
|
|
422
|
+
except Exception as error:
|
|
423
|
+
# The replacement is atomic; if staging or verification fails, put
|
|
424
|
+
# the exact conflict-marker worktree bytes and three index stages
|
|
425
|
+
# back. This also covers a successful `git add` followed by a
|
|
426
|
+
# failed staged-blob verification.
|
|
427
|
+
for changed_relative, raw in originals.items():
|
|
428
|
+
if isinstance(error, ConcurrentAppendError) and paths[changed_relative] == error.path:
|
|
429
|
+
if error.restored:
|
|
430
|
+
continue
|
|
431
|
+
if error.observed is not None:
|
|
432
|
+
raw = error.observed
|
|
433
|
+
_write_bytes_atomic(paths[changed_relative], raw)
|
|
434
|
+
for changed_relative, prior_records in index_records.items():
|
|
435
|
+
_restore_index(root, changed_relative, prior_records)
|
|
436
|
+
raise
|
|
437
|
+
finally:
|
|
438
|
+
for path_lock in reversed(path_locks):
|
|
439
|
+
release_lock(path_lock)
|
|
440
|
+
release_lock(lock)
|
|
441
|
+
return relative
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def main(argv=None):
|
|
445
|
+
parser = argparse.ArgumentParser(
|
|
446
|
+
description="Resolve one genuine H-05 append-only Git conflict without weakening the guard."
|
|
447
|
+
)
|
|
448
|
+
parser.add_argument("target", help="repository-relative protected append-only path")
|
|
449
|
+
parser.add_argument("--reason", required=True, help="specific immediate H-05 override reason")
|
|
450
|
+
args = parser.parse_args(argv)
|
|
451
|
+
try:
|
|
452
|
+
relative = resolve(args.target, args.reason)
|
|
453
|
+
except (OSError, ResolutionError) as error:
|
|
454
|
+
print(f"append-only conflict resolution refused: {error}", file=sys.stderr)
|
|
455
|
+
return 1
|
|
456
|
+
print(f"resolved and staged append-only conflict: {relative}")
|
|
457
|
+
return 0
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
if __name__ == "__main__":
|
|
461
|
+
raise SystemExit(main())
|
|
@@ -96,6 +96,7 @@
|
|
|
96
96
|
# non-blocking + bounded LOCK_WAIT retry spin,
|
|
97
97
|
# fail-soft None on contention/timeout/OSError
|
|
98
98
|
# release_lock(handle) -> None release + close; None handle is a no-op
|
|
99
|
+
# audit_lock_key(root, path) -> str trusted Git-owned key for an audit path
|
|
99
100
|
# block(tag, msg) -> None BLOCK tool call: print to stderr and exit 2
|
|
100
101
|
# remind(tag, msg) -> None non-blocking nudge to stderr
|
|
101
102
|
# warn(msg) -> None loud degradation breadcrumb to stderr
|
|
@@ -105,6 +106,7 @@
|
|
|
105
106
|
|
|
106
107
|
import datetime
|
|
107
108
|
import errno
|
|
109
|
+
import hashlib
|
|
108
110
|
import json
|
|
109
111
|
import os
|
|
110
112
|
import re
|
|
@@ -216,8 +218,10 @@ from _sensitivelib import ( # noqa: F401
|
|
|
216
218
|
)
|
|
217
219
|
|
|
218
220
|
|
|
219
|
-
# Serialize same-process
|
|
220
|
-
|
|
221
|
+
# Serialize same-process writers before taking the bounded cross-process lock.
|
|
222
|
+
# POSIX flock and Windows byte-range locks are process-scoped, so sibling
|
|
223
|
+
# threads can otherwise burn the shared wait budget contending with each other.
|
|
224
|
+
_GATE_EVENTS_PROCESS_LOCK = threading.Lock()
|
|
221
225
|
_WINDOWS_LOCK_TIMEOUT_SECONDS = 5.0
|
|
222
226
|
_WINDOWS_LOCK_RETRY_SECONDS = 0.01
|
|
223
227
|
_WINDOWS_CRT_EDEADLK = 36
|
|
@@ -409,6 +413,63 @@ def release_lock(handle):
|
|
|
409
413
|
pass
|
|
410
414
|
|
|
411
415
|
|
|
416
|
+
def audit_lock_key(root, path):
|
|
417
|
+
"""Return a deterministic audit lock key beneath Git-owned metadata.
|
|
418
|
+
|
|
419
|
+
Audit paths and their siblings are repository-controlled, so an adjacent
|
|
420
|
+
lock can be a checked-out symlink that redirects acquire_lock's seed byte
|
|
421
|
+
outside the checkout. Linked worktrees resolve to their common Git
|
|
422
|
+
directory and therefore share one trusted key per repository-relative path.
|
|
423
|
+
"""
|
|
424
|
+
root = os.path.abspath(os.fspath(root))
|
|
425
|
+
path = os.path.abspath(os.fspath(path))
|
|
426
|
+
try:
|
|
427
|
+
if os.path.normcase(os.path.commonpath((root, path))) != os.path.normcase(root):
|
|
428
|
+
raise ValueError
|
|
429
|
+
except (OSError, ValueError) as error:
|
|
430
|
+
raise OSError("audit lock target is outside the repository root") from error
|
|
431
|
+
|
|
432
|
+
dotgit = os.path.join(root, ".git")
|
|
433
|
+
if os.path.isdir(dotgit):
|
|
434
|
+
common = dotgit
|
|
435
|
+
elif os.path.isfile(dotgit):
|
|
436
|
+
try:
|
|
437
|
+
with open(dotgit, encoding="utf-8") as handle:
|
|
438
|
+
pointer = handle.read(4097)
|
|
439
|
+
except OSError as error:
|
|
440
|
+
raise OSError("cannot read linked-worktree Git metadata") from error
|
|
441
|
+
if len(pointer) > 4096 or not pointer.startswith("gitdir:"):
|
|
442
|
+
raise OSError("linked-worktree Git metadata is malformed")
|
|
443
|
+
gitdir = pointer[7:].strip()
|
|
444
|
+
if not gitdir:
|
|
445
|
+
raise OSError("linked-worktree Git directory is empty")
|
|
446
|
+
if not os.path.isabs(gitdir):
|
|
447
|
+
gitdir = os.path.join(root, gitdir)
|
|
448
|
+
commondir_file = os.path.join(gitdir, "commondir")
|
|
449
|
+
if os.path.isfile(commondir_file):
|
|
450
|
+
try:
|
|
451
|
+
with open(commondir_file, encoding="utf-8") as handle:
|
|
452
|
+
relative_common = handle.read(4097).strip()
|
|
453
|
+
except OSError as error:
|
|
454
|
+
raise OSError("cannot read Git common-directory metadata") from error
|
|
455
|
+
if not relative_common or len(relative_common) > 4096:
|
|
456
|
+
raise OSError("Git common-directory metadata is malformed")
|
|
457
|
+
common = relative_common if os.path.isabs(relative_common) else os.path.join(
|
|
458
|
+
gitdir, relative_common
|
|
459
|
+
)
|
|
460
|
+
else:
|
|
461
|
+
common = gitdir
|
|
462
|
+
else:
|
|
463
|
+
raise OSError("repository Git metadata is unavailable for audit locking")
|
|
464
|
+
|
|
465
|
+
common = os.path.realpath(common)
|
|
466
|
+
if not os.path.isdir(common):
|
|
467
|
+
raise OSError("Git common directory is unavailable for audit locking")
|
|
468
|
+
relative = os.path.relpath(path, root).replace("\\", "/")
|
|
469
|
+
digest = hashlib.sha256(relative.encode("utf-8")).hexdigest()
|
|
470
|
+
return os.path.join(common, "codearbiter-audit-locks", digest)
|
|
471
|
+
|
|
472
|
+
|
|
412
473
|
# The H-11 authoring-marker freshness window (issue #567) — the single
|
|
413
474
|
# declaration every `marker_fresh(marker, minutes)` call site resolves by
|
|
414
475
|
# import. Previously five independent `30` literals (pre-write.py,
|
|
@@ -469,17 +530,27 @@ def _log_gate_event(kind, tag, msg):
|
|
|
469
530
|
host = "unknown"
|
|
470
531
|
tag_part = f"[{tag}] " if tag else ""
|
|
471
532
|
line = f"[{ts}] {kind} {tag_part}host={host} hook={hook} | {msg}\n"
|
|
533
|
+
log_path = os.path.join(cad, "gate-events.log")
|
|
472
534
|
flags = os.O_APPEND | os.O_CREAT | os.O_WRONLY
|
|
473
535
|
if hasattr(os, "O_BINARY"):
|
|
474
536
|
flags |= os.O_BINARY
|
|
475
537
|
process_lock_acquired = False
|
|
476
|
-
|
|
477
|
-
|
|
538
|
+
try:
|
|
539
|
+
_GATE_EVENTS_PROCESS_LOCK.acquire()
|
|
478
540
|
process_lock_acquired = True
|
|
541
|
+
audit_lock = acquire_lock(audit_lock_key(root, log_path))
|
|
542
|
+
except Exception:
|
|
543
|
+
if process_lock_acquired:
|
|
544
|
+
_GATE_EVENTS_PROCESS_LOCK.release()
|
|
545
|
+
raise
|
|
546
|
+
if audit_lock is None:
|
|
547
|
+
if process_lock_acquired:
|
|
548
|
+
_GATE_EVENTS_PROCESS_LOCK.release()
|
|
549
|
+
return
|
|
479
550
|
fd = None
|
|
480
551
|
os_lock_acquired = False
|
|
481
552
|
try:
|
|
482
|
-
fd = os.open(
|
|
553
|
+
fd = os.open(log_path, flags, 0o600)
|
|
483
554
|
if os.name == "nt":
|
|
484
555
|
import msvcrt
|
|
485
556
|
os.lseek(fd, 0, os.SEEK_SET)
|
|
@@ -504,10 +575,15 @@ def _log_gate_event(kind, tag, msg):
|
|
|
504
575
|
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
505
576
|
except Exception: # noqa: BLE001 — outer sink remains fail-open
|
|
506
577
|
pass
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
578
|
+
try:
|
|
579
|
+
if fd is not None:
|
|
580
|
+
os.close(fd)
|
|
581
|
+
finally:
|
|
582
|
+
try:
|
|
583
|
+
release_lock(audit_lock)
|
|
584
|
+
finally:
|
|
585
|
+
if process_lock_acquired:
|
|
586
|
+
_GATE_EVENTS_PROCESS_LOCK.release()
|
|
511
587
|
except Exception: # noqa: BLE001 — fail-open: the sink must never affect the gate
|
|
512
588
|
pass
|
|
513
589
|
|
|
@@ -11,7 +11,7 @@ import hostapi # noqa: E402
|
|
|
11
11
|
class PiHost(hostapi.Host):
|
|
12
12
|
name = "pi"
|
|
13
13
|
adapter_name = "@arbiterforge/ca-pi"
|
|
14
|
-
adapter_version = "0.10.
|
|
14
|
+
adapter_version = "0.10.11"
|
|
15
15
|
update_target = "ca-pi"
|
|
16
16
|
update_tag_prefix = "ca-pi-v"
|
|
17
17
|
update_command = "pi update npm:@arbiterforge/ca-pi"
|
|
@@ -25,7 +25,7 @@ import os
|
|
|
25
25
|
import re
|
|
26
26
|
|
|
27
27
|
from _activationlib import marker_root
|
|
28
|
-
from _hooklib import write_text_atomic
|
|
28
|
+
from _hooklib import acquire_lock, audit_lock_key, release_lock, write_text_atomic
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
# ---------------------------------------------------------------------------
|
|
@@ -645,12 +645,21 @@ def _overrides_has_line(root, line):
|
|
|
645
645
|
|
|
646
646
|
def _append_override_line(root, line):
|
|
647
647
|
"""Append one audit line to overrides.log. True on a confirmed write."""
|
|
648
|
+
lock = None
|
|
648
649
|
try:
|
|
649
|
-
|
|
650
|
+
path = _overrides_log_path(root)
|
|
651
|
+
lock = acquire_lock(audit_lock_key(root, path))
|
|
652
|
+
if lock is None:
|
|
653
|
+
return False
|
|
654
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
650
655
|
f.write(line)
|
|
656
|
+
f.flush()
|
|
657
|
+
os.fsync(f.fileno())
|
|
651
658
|
return True
|
|
652
|
-
except
|
|
659
|
+
except Exception: # noqa: BLE001 — mode audit settlement remains fail-soft
|
|
653
660
|
return False
|
|
661
|
+
finally:
|
|
662
|
+
release_lock(lock)
|
|
654
663
|
|
|
655
664
|
|
|
656
665
|
def _dev_dropped_close_note(count, host_name=None):
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fail-closed, cooperatively locked append for mandatory override records."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
import stat
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from _gitexec import git_executable
|
|
16
|
+
from _hooklib import acquire_lock, audit_lock_key, release_lock
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
MAX_FIELD = 500
|
|
20
|
+
GATE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OverrideAppendError(RuntimeError):
|
|
24
|
+
"""The mandatory audit row could not be safely and durably appended."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _git(cwd, *args):
|
|
28
|
+
try:
|
|
29
|
+
result = subprocess.run(
|
|
30
|
+
[git_executable(), *args], cwd=cwd, stdout=subprocess.PIPE,
|
|
31
|
+
stderr=subprocess.PIPE, check=False,
|
|
32
|
+
)
|
|
33
|
+
except OSError as error:
|
|
34
|
+
raise OverrideAppendError(f"git unavailable: {error}") from error
|
|
35
|
+
if result.returncode != 0:
|
|
36
|
+
detail = result.stderr.decode("utf-8", errors="replace").strip()
|
|
37
|
+
raise OverrideAppendError(f"git {' '.join(args)} failed: {detail or 'unknown error'}")
|
|
38
|
+
return result.stdout
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _root(cwd):
|
|
42
|
+
try:
|
|
43
|
+
raw = _git(cwd, "rev-parse", "--show-toplevel").decode("utf-8", errors="strict").strip()
|
|
44
|
+
except UnicodeDecodeError as error:
|
|
45
|
+
raise OverrideAppendError("git returned an invalid UTF-8 repository root") from error
|
|
46
|
+
if not raw:
|
|
47
|
+
raise OverrideAppendError("git returned an empty repository root")
|
|
48
|
+
return Path(raw).resolve()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _field(label, value):
|
|
52
|
+
if not value or len(value) > MAX_FIELD or any(char in value for char in "|\r\n"):
|
|
53
|
+
raise OverrideAppendError(
|
|
54
|
+
f"{label} must be non-empty, at most {MAX_FIELD} characters, and contain no pipe or newline"
|
|
55
|
+
)
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _identity(root):
|
|
60
|
+
try:
|
|
61
|
+
value = _git(root, "config", "user.email").decode("utf-8", errors="strict").strip()
|
|
62
|
+
except UnicodeDecodeError as error:
|
|
63
|
+
raise OverrideAppendError("git config user.email is not valid UTF-8") from error
|
|
64
|
+
_field("git config user.email", value)
|
|
65
|
+
if len(value) > 254 or any(char.isspace() for char in value):
|
|
66
|
+
raise OverrideAppendError("git config user.email is not a valid override identity")
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _validate_file_type(path):
|
|
71
|
+
try:
|
|
72
|
+
mode = path.lstat().st_mode
|
|
73
|
+
except OSError as error:
|
|
74
|
+
raise OverrideAppendError(f"cannot inspect overrides.log: {error}") from error
|
|
75
|
+
if not stat.S_ISREG(mode) or path.is_symlink():
|
|
76
|
+
raise OverrideAppendError("overrides.log is not a regular non-link file")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _validate_existing(path, raw):
|
|
80
|
+
_validate_file_type(path)
|
|
81
|
+
if raw.startswith(b"\xef\xbb\xbf") or b"\x00" in raw or b"\r" in raw:
|
|
82
|
+
raise OverrideAppendError("overrides.log is not canonical UTF-8 LF text")
|
|
83
|
+
try:
|
|
84
|
+
raw.decode("utf-8", errors="strict")
|
|
85
|
+
except UnicodeDecodeError as error:
|
|
86
|
+
raise OverrideAppendError("overrides.log is not valid UTF-8") from error
|
|
87
|
+
if raw and not raw.endswith(b"\n"):
|
|
88
|
+
raise OverrideAppendError("overrides.log does not end with LF")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def append_override(*, gate=None, security_finding=None, reason, cwd=None):
|
|
92
|
+
root = _root(cwd or os.getcwd())
|
|
93
|
+
path = root / ".codearbiter" / "overrides.log"
|
|
94
|
+
_validate_file_type(path)
|
|
95
|
+
before = path.read_bytes()
|
|
96
|
+
_validate_existing(path, before)
|
|
97
|
+
identity = _identity(root)
|
|
98
|
+
reason = _field("reason", reason)
|
|
99
|
+
timestamp = datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
100
|
+
if gate is not None:
|
|
101
|
+
if not GATE_RE.fullmatch(gate):
|
|
102
|
+
raise OverrideAppendError("gate must be a compact gate identifier")
|
|
103
|
+
body = f"GATE: {gate}"
|
|
104
|
+
else:
|
|
105
|
+
body = f"SECURITY-OVERRIDE | FINDING: {_field('security finding', security_finding)}"
|
|
106
|
+
line = f"[{timestamp}] | BY: {identity} | {body} | REASON: {reason}\n".encode("utf-8")
|
|
107
|
+
|
|
108
|
+
lock = acquire_lock(audit_lock_key(root, path))
|
|
109
|
+
if lock is None:
|
|
110
|
+
raise OverrideAppendError("another writer holds the overrides.log audit path lock")
|
|
111
|
+
fd = None
|
|
112
|
+
try:
|
|
113
|
+
if path.read_bytes() != before:
|
|
114
|
+
raise OverrideAppendError("overrides.log changed during validation; retry")
|
|
115
|
+
flags = os.O_APPEND | os.O_WRONLY
|
|
116
|
+
if hasattr(os, "O_BINARY"):
|
|
117
|
+
flags |= os.O_BINARY
|
|
118
|
+
fd = os.open(path, flags)
|
|
119
|
+
offset = 0
|
|
120
|
+
while offset < len(line):
|
|
121
|
+
written = os.write(fd, line[offset:])
|
|
122
|
+
if written <= 0:
|
|
123
|
+
raise OverrideAppendError("override audit append made no progress")
|
|
124
|
+
offset += written
|
|
125
|
+
os.fsync(fd)
|
|
126
|
+
except OSError as error:
|
|
127
|
+
raise OverrideAppendError(f"override audit append failed: {error}") from error
|
|
128
|
+
finally:
|
|
129
|
+
try:
|
|
130
|
+
if fd is not None:
|
|
131
|
+
os.close(fd)
|
|
132
|
+
finally:
|
|
133
|
+
release_lock(lock)
|
|
134
|
+
return path
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def main(argv=None):
|
|
138
|
+
parser = argparse.ArgumentParser(description="Append one mandatory override audit row safely.")
|
|
139
|
+
kind = parser.add_mutually_exclusive_group(required=True)
|
|
140
|
+
kind.add_argument("--gate", help="gate identifier for an ordinary override")
|
|
141
|
+
kind.add_argument("--security-finding", help="specific acknowledged security finding")
|
|
142
|
+
parser.add_argument("--reason", required=True, help="specific justification")
|
|
143
|
+
args = parser.parse_args(argv)
|
|
144
|
+
try:
|
|
145
|
+
path = append_override(
|
|
146
|
+
gate=args.gate,
|
|
147
|
+
security_finding=args.security_finding,
|
|
148
|
+
reason=args.reason,
|
|
149
|
+
)
|
|
150
|
+
except (OSError, OverrideAppendError) as error:
|
|
151
|
+
print(f"override audit append refused: {error}", file=sys.stderr)
|
|
152
|
+
return 1
|
|
153
|
+
print(f"appended mandatory override audit row: {path}")
|
|
154
|
+
return 0
|
|
@@ -704,6 +704,23 @@ ARCHIVE_CUTOFF_DAYS = 14
|
|
|
704
704
|
|
|
705
705
|
DONE_TASKS_HEADING = "# Done tasks"
|
|
706
706
|
|
|
707
|
+
# The canonical documented archive template, shared by init and first use.
|
|
708
|
+
DONE_TASKS = """\
|
|
709
|
+
# Done tasks
|
|
710
|
+
|
|
711
|
+
Completed work swept off the board by `taskwrite archive`, newest last.
|
|
712
|
+
APPEND-ONLY: entries are added here and never edited or removed, so a
|
|
713
|
+
finished task has exactly one permanent record.
|
|
714
|
+
|
|
715
|
+
Each archived task preserves its original lifecycle line and indented task block verbatim
|
|
716
|
+
from `open-tasks.md`. The lifecycle line keeps its `(done YYYY-MM-DD)` stamp
|
|
717
|
+
intact — the stamp is what makes an entry ageable, and what `archive` refuses
|
|
718
|
+
to invent.
|
|
719
|
+
|
|
720
|
+
Written only by `taskwrite archive`. `/ca:standup` proposes the sweep with
|
|
721
|
+
per-item confirmation; nothing sweeps automatically.
|
|
722
|
+
"""
|
|
723
|
+
|
|
707
724
|
|
|
708
725
|
def archive_candidates(text, *, today, cutoff_days=ARCHIVE_CUTOFF_DAYS):
|
|
709
726
|
"""`(aged, undated)` — done tasks eligible for the archival sweep.
|
|
@@ -862,7 +879,7 @@ def archive_transform(open_text, done_text, task):
|
|
|
862
879
|
if already_archived(done_text, task):
|
|
863
880
|
new_done = done_text
|
|
864
881
|
else:
|
|
865
|
-
body = done_text if done_text.strip() else
|
|
882
|
+
body = done_text if done_text.strip() else DONE_TASKS
|
|
866
883
|
if not body.endswith("\n"):
|
|
867
884
|
body += "\n"
|
|
868
885
|
new_done = body + "\n".join(block) + "\n"
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter — locally exclude the retained task-board OS-lock sidecar.
|
|
3
|
+
#
|
|
4
|
+
# The board lock is intentionally never removed: unlinking a locked inode lets
|
|
5
|
+
# another process acquire a different lock. Only Git's common info/exclude is
|
|
6
|
+
# changed, through its own exclusive create/replace lockfile protocol. Existing
|
|
7
|
+
# bytes and modes survive; no tracked file or Git index is written.
|
|
8
|
+
# Static Git-metadata link/reparse aliases and observed concurrent edits fail closed. This
|
|
9
|
+
# cooperates with writers honoring exclude.lock; it is not a sandbox against a
|
|
10
|
+
# hostile same-user process swapping paths between filesystem operations.
|
|
11
|
+
#
|
|
12
|
+
# Public API:
|
|
13
|
+
# ensure_task_lock_excluded(root, required=False) -> str | None
|
|
14
|
+
# "added" / "already present", or None for optional non-Git scaffolding.
|
|
15
|
+
# Raises TaskExclusionError with a surfaced diagnostic on unsafe/failing IO.
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
import stat
|
|
20
|
+
import subprocess
|
|
21
|
+
|
|
22
|
+
from _gitexec import git_executable, root_bound_git_env
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
LOCK_PATH = ".codearbiter/open-tasks.md.lock"
|
|
26
|
+
EXCLUDE_RULE = b"/" + LOCK_PATH.encode("ascii")
|
|
27
|
+
MAX_EXCLUDE_BYTES = 2 * 1024 * 1024
|
|
28
|
+
_REPARSE = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TaskExclusionError(RuntimeError):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _version(info):
|
|
36
|
+
# Windows Python can expose creation time via lstat().st_ctime but change
|
|
37
|
+
# time via fstat().st_ctime. Compare the common birth-time field there;
|
|
38
|
+
# identity, size, mtime, mode/link count and byte rereads still bind edits.
|
|
39
|
+
change = getattr(info, "st_birthtime_ns", 0) if os.name == "nt" else info.st_ctime_ns
|
|
40
|
+
return (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns,
|
|
41
|
+
change, info.st_mode, info.st_nlink)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _regular(info):
|
|
45
|
+
return (stat.S_ISREG(info.st_mode) and info.st_nlink == 1
|
|
46
|
+
and not getattr(info, "st_file_attributes", 0) & _REPARSE)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _directories(path):
|
|
50
|
+
for directory in (*reversed(path.parents), path):
|
|
51
|
+
info = directory.lstat()
|
|
52
|
+
if not stat.S_ISDIR(info.st_mode) or getattr(info, "st_file_attributes", 0) & _REPARSE:
|
|
53
|
+
raise TaskExclusionError("unsafe directory in task lock exclusion path")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _git(root, *args, input_bytes=None):
|
|
57
|
+
return subprocess.run([git_executable(), "-C", str(root), *args],
|
|
58
|
+
env=root_bound_git_env(), capture_output=True, timeout=5,
|
|
59
|
+
input=input_bytes)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _git_path(root, *args):
|
|
63
|
+
result = _git(root, "rev-parse", *args)
|
|
64
|
+
value = os.fsdecode(result.stdout).rstrip("\r\n")
|
|
65
|
+
if result.returncode or not value or not os.path.isabs(value) or "\n" in value:
|
|
66
|
+
raise TaskExclusionError("cannot bind task lock exclusion to selected Git")
|
|
67
|
+
return Path(value)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _same_path(left, right):
|
|
71
|
+
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(os.path.abspath(right))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _binding(root, required):
|
|
75
|
+
# A failed Git probe in an actual checkout is never mistaken for non-Git.
|
|
76
|
+
markers = [item / ".git" for item in (root, *root.parents)]
|
|
77
|
+
has_marker = any(os.path.lexists(path) for path in markers)
|
|
78
|
+
try:
|
|
79
|
+
top = _git_path(root, "--show-toplevel")
|
|
80
|
+
except (OSError, RuntimeError, subprocess.SubprocessError):
|
|
81
|
+
if not required and not has_marker:
|
|
82
|
+
return None
|
|
83
|
+
raise TaskExclusionError("cannot resolve repository root for task lock exclusion") from None
|
|
84
|
+
# Git reports physical roots: /var aliases and Windows 8.3 names can differ
|
|
85
|
+
# from the caller. Resolve only this identity, never metadata paths whose
|
|
86
|
+
# unresolved link/reparse attributes the checks below must inspect.
|
|
87
|
+
root = Path(os.path.realpath(root))
|
|
88
|
+
if not _same_path(os.path.realpath(top), root):
|
|
89
|
+
raise TaskExclusionError("task lock exclusion requires the exact repository root")
|
|
90
|
+
_directories(root)
|
|
91
|
+
marker = root / ".git"
|
|
92
|
+
info = marker.lstat()
|
|
93
|
+
if (not (stat.S_ISDIR(info.st_mode) or _regular(info))
|
|
94
|
+
or getattr(info, "st_file_attributes", 0) & _REPARSE):
|
|
95
|
+
raise TaskExclusionError("unsafe Git marker for task lock exclusion")
|
|
96
|
+
common = _git_path(root, "--path-format=absolute", "--git-common-dir")
|
|
97
|
+
exclude = _git_path(root, "--path-format=absolute", "--git-path", "info/exclude")
|
|
98
|
+
if not _same_path(exclude, common / "info/exclude"):
|
|
99
|
+
raise TaskExclusionError("task lock exclusion common-directory mismatch")
|
|
100
|
+
_directories(common)
|
|
101
|
+
return exclude
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _read_exclude(path):
|
|
105
|
+
try:
|
|
106
|
+
before = path.lstat()
|
|
107
|
+
except FileNotFoundError:
|
|
108
|
+
return b"", None
|
|
109
|
+
if not _regular(before):
|
|
110
|
+
raise TaskExclusionError("task lock exclusion is not a regular single-link file")
|
|
111
|
+
fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
|
112
|
+
with os.fdopen(fd, "rb") as handle:
|
|
113
|
+
opened = os.fstat(handle.fileno())
|
|
114
|
+
if not _regular(opened) or _version(opened) != _version(before):
|
|
115
|
+
raise TaskExclusionError("task lock exclusion changed while opening")
|
|
116
|
+
data = handle.read(MAX_EXCLUDE_BYTES + 1)
|
|
117
|
+
if len(data) > MAX_EXCLUDE_BYTES:
|
|
118
|
+
raise TaskExclusionError("task lock exclusion exceeds size limit")
|
|
119
|
+
if _version(os.fstat(handle.fileno())) != _version(before):
|
|
120
|
+
raise TaskExclusionError("task lock exclusion changed while reading")
|
|
121
|
+
if _version(path.lstat()) != _version(before):
|
|
122
|
+
raise TaskExclusionError("task lock exclusion path changed while reading")
|
|
123
|
+
return data, before
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _check_conflicts(root):
|
|
127
|
+
tracked = _git(root, "ls-files", "--error-unmatch", "--", LOCK_PATH)
|
|
128
|
+
if tracked.returncode == 0:
|
|
129
|
+
raise TaskExclusionError("task lock sidecar is tracked; local exclusion cannot untrack it")
|
|
130
|
+
if tracked.returncode != 1:
|
|
131
|
+
raise TaskExclusionError("cannot check tracked task lock sidecar")
|
|
132
|
+
# A file can serve both global and per-directory roles. For this conflict
|
|
133
|
+
# probe only, disable the lower-priority global source without changing
|
|
134
|
+
# repository configuration. Effectiveness checks below use actual config.
|
|
135
|
+
ignored = _git(root, "-c", "core.excludesFile=", "check-ignore", "--no-index", "-z", "-v", "--stdin",
|
|
136
|
+
input_bytes=LOCK_PATH.encode("ascii") + b"\0")
|
|
137
|
+
if ignored.returncode not in (0, 1):
|
|
138
|
+
raise TaskExclusionError("cannot check task lock exclusion precedence")
|
|
139
|
+
fields = ignored.stdout.split(b"\0")
|
|
140
|
+
# Git reports the target's per-directory sources relative to the -C root.
|
|
141
|
+
if len(fields) == 5 and fields[0].replace(b"\\", b"/") in (
|
|
142
|
+
b".gitignore", b".codearbiter/.gitignore") \
|
|
143
|
+
and fields[2].startswith(b"!"):
|
|
144
|
+
raise TaskExclusionError("a .gitignore negation overrides local task lock exclusion")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _ensure(root, required):
|
|
148
|
+
# Retain the caller spelling so publication-time binding detects retargeting.
|
|
149
|
+
root = Path(os.path.abspath(root))
|
|
150
|
+
exclude = _binding(root, required)
|
|
151
|
+
if exclude is None:
|
|
152
|
+
return None
|
|
153
|
+
# mkdir is exclusive and never follows a preexisting info alias.
|
|
154
|
+
try:
|
|
155
|
+
exclude.parent.mkdir()
|
|
156
|
+
except FileExistsError:
|
|
157
|
+
pass
|
|
158
|
+
_directories(exclude.parent)
|
|
159
|
+
lock = exclude.with_name("exclude.lock")
|
|
160
|
+
fd = None
|
|
161
|
+
owned = None
|
|
162
|
+
try:
|
|
163
|
+
try:
|
|
164
|
+
fd = os.open(lock, os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
165
|
+
| getattr(os, "O_NOFOLLOW", 0), 0o666)
|
|
166
|
+
except FileExistsError:
|
|
167
|
+
raise TaskExclusionError("exclude.lock already exists; another writer may own it") from None
|
|
168
|
+
info = os.fstat(fd)
|
|
169
|
+
owned = info.st_dev, info.st_ino
|
|
170
|
+
before, snapshot = _read_exclude(exclude)
|
|
171
|
+
_check_conflicts(root)
|
|
172
|
+
if EXCLUDE_RULE in before.splitlines():
|
|
173
|
+
ignored = _git(root, "check-ignore", "-q", "--", LOCK_PATH)
|
|
174
|
+
if ignored.returncode == 0:
|
|
175
|
+
return "already present"
|
|
176
|
+
if ignored.returncode != 1:
|
|
177
|
+
raise TaskExclusionError("cannot verify task lock exclusion")
|
|
178
|
+
newline = b"\r\n" if b"\r\n" in before else b"\n"
|
|
179
|
+
data = before + (newline if before and not before.endswith(b"\n") else b"")
|
|
180
|
+
data += EXCLUDE_RULE + newline
|
|
181
|
+
if len(data) > MAX_EXCLUDE_BYTES:
|
|
182
|
+
raise TaskExclusionError("task lock exclusion output exceeds size limit")
|
|
183
|
+
with os.fdopen(fd, "wb") as handle:
|
|
184
|
+
fd = None
|
|
185
|
+
handle.write(data)
|
|
186
|
+
handle.flush()
|
|
187
|
+
os.fsync(handle.fileno())
|
|
188
|
+
if snapshot is not None:
|
|
189
|
+
os.chmod(lock, stat.S_IMODE(snapshot.st_mode))
|
|
190
|
+
latest, current = _read_exclude(exclude)
|
|
191
|
+
if latest != before or ((snapshot is None) != (current is None)) \
|
|
192
|
+
or snapshot is not None and _version(snapshot) != _version(current):
|
|
193
|
+
raise TaskExclusionError("task lock exclusion changed before publication")
|
|
194
|
+
if _binding(root, True) != exclude:
|
|
195
|
+
raise TaskExclusionError("task lock exclusion binding changed before publication")
|
|
196
|
+
_directories(exclude.parent)
|
|
197
|
+
info = lock.lstat()
|
|
198
|
+
if not _regular(info) or (info.st_dev, info.st_ino) != owned:
|
|
199
|
+
raise TaskExclusionError("exclude.lock ownership changed before publication")
|
|
200
|
+
os.replace(lock, exclude)
|
|
201
|
+
owned = None
|
|
202
|
+
if _git(root, "check-ignore", "-q", "--", LOCK_PATH).returncode != 0:
|
|
203
|
+
raise TaskExclusionError("local rule added but task lock exclusion is not effective")
|
|
204
|
+
return "added"
|
|
205
|
+
finally:
|
|
206
|
+
if fd is not None:
|
|
207
|
+
os.close(fd)
|
|
208
|
+
if owned is not None:
|
|
209
|
+
try:
|
|
210
|
+
info = lock.lstat()
|
|
211
|
+
if (info.st_dev, info.st_ino) == owned:
|
|
212
|
+
lock.unlink() # Only OUR Git transaction file, NEVER the board OS lock.
|
|
213
|
+
except FileNotFoundError:
|
|
214
|
+
pass
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def ensure_task_lock_excluded(root, required=False):
|
|
218
|
+
try:
|
|
219
|
+
return _ensure(root, required)
|
|
220
|
+
except TaskExclusionError:
|
|
221
|
+
raise
|
|
222
|
+
except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
|
|
223
|
+
raise TaskExclusionError(f"task lock exclusion failed ({type(exc).__name__})") from None
|
|
@@ -313,7 +313,7 @@ class Host:
|
|
|
313
313
|
|
|
314
314
|
name = "claude"
|
|
315
315
|
adapter_name = "ca"
|
|
316
|
-
adapter_version = "2.17.
|
|
316
|
+
adapter_version = "2.17.10"
|
|
317
317
|
|
|
318
318
|
# Update-notifier descriptor. Each independently versioned host overrides
|
|
319
319
|
# these three values in its per-plugin _host.py. Keeping the target,
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
# Usage:
|
|
13
13
|
# python init-codearbiter.py [--root PATH] [--stage N]
|
|
14
14
|
# python init-codearbiter.py --check # report state, create nothing
|
|
15
|
+
# python init-codearbiter.py --repair-lock-exclusion [--root PATH]
|
|
15
16
|
|
|
16
17
|
import argparse
|
|
17
18
|
import subprocess
|
|
@@ -19,7 +20,8 @@ import os
|
|
|
19
20
|
import sys
|
|
20
21
|
|
|
21
22
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
22
|
-
from _gitexec import git_executable # noqa: E402
|
|
23
|
+
from _gitexec import git_executable, root_bound_git_env # noqa: E402
|
|
24
|
+
from _taskboardlib import DONE_TASKS # noqa: E402 — shared archive template (#625)
|
|
23
25
|
import hostapi # noqa: E402 — host seam (ADR-0011)
|
|
24
26
|
import _hooklib # noqa: E402 — set_host DI seam (#257)
|
|
25
27
|
import _entrylib # noqa: E402 — shared run() dispatch (jscpd dedup)
|
|
@@ -92,21 +94,6 @@ OVERRIDES = """\
|
|
|
92
94
|
# last-checkpoint marker as "overrides since last checkpoint."
|
|
93
95
|
"""
|
|
94
96
|
|
|
95
|
-
DONE_TASKS = """\
|
|
96
|
-
# Done tasks
|
|
97
|
-
|
|
98
|
-
Completed work swept off the board by `taskwrite archive`, newest last.
|
|
99
|
-
APPEND-ONLY: entries are added here and never edited or removed, so a
|
|
100
|
-
finished task has exactly one permanent record.
|
|
101
|
-
|
|
102
|
-
Each line is the task's original lifecycle line, moved verbatim from
|
|
103
|
-
`open-tasks.md` with its `(done YYYY-MM-DD)` stamp intact — the stamp is
|
|
104
|
-
what makes an entry ageable, and what `archive` refuses to invent.
|
|
105
|
-
|
|
106
|
-
Written only by `taskwrite archive`. `/ca:standup` proposes the sweep with
|
|
107
|
-
per-item confirmation; nothing sweeps automatically.
|
|
108
|
-
"""
|
|
109
|
-
|
|
110
97
|
FILES = {
|
|
111
98
|
"open-tasks.md": OPEN_TASKS,
|
|
112
99
|
# B-23: scaffolded at init rather than created on first archive. An
|
|
@@ -126,7 +113,7 @@ def project_root(opt):
|
|
|
126
113
|
# prefer git toplevel; fall back to cwd
|
|
127
114
|
try:
|
|
128
115
|
out = subprocess.run([git_executable(), "rev-parse", "--show-toplevel"],
|
|
129
|
-
capture_output=True, text=True, timeout=2)
|
|
116
|
+
env=root_bound_git_env(), capture_output=True, text=True, timeout=2)
|
|
130
117
|
top = out.stdout.strip()
|
|
131
118
|
if out.returncode == 0 and top:
|
|
132
119
|
return os.path.abspath(top)
|
|
@@ -138,9 +125,16 @@ def project_root(opt):
|
|
|
138
125
|
def main(argv=None):
|
|
139
126
|
ap = argparse.ArgumentParser(add_help=True)
|
|
140
127
|
ap.add_argument("--root")
|
|
141
|
-
ap.add_argument("--stage", type=int
|
|
128
|
+
ap.add_argument("--stage", type=int)
|
|
142
129
|
ap.add_argument("--check", action="store_true")
|
|
130
|
+
ap.add_argument("--repair-lock-exclusion", action="store_true", help=(
|
|
131
|
+
"repair only Git-local info/exclude for an existing scaffold; retain the "
|
|
132
|
+
"task-board OS lock, never rewrite project state (cannot combine with --check/--stage)"))
|
|
143
133
|
args = ap.parse_args(argv)
|
|
134
|
+
if args.repair_lock_exclusion and (args.check or args.stage is not None):
|
|
135
|
+
ap.error("--repair-lock-exclusion cannot combine with --check or --stage")
|
|
136
|
+
if args.stage is None:
|
|
137
|
+
args.stage = 1
|
|
144
138
|
|
|
145
139
|
root = project_root(args.root)
|
|
146
140
|
cad = os.path.join(root, ".codearbiter")
|
|
@@ -168,11 +162,29 @@ def main(argv=None):
|
|
|
168
162
|
print(f"NOT SCAFFOLDED: {cad} would be created. Run without --check to scaffold.")
|
|
169
163
|
return
|
|
170
164
|
|
|
165
|
+
from _taskexcludelib import ensure_task_lock_excluded, TaskExclusionError
|
|
166
|
+
if args.repair_lock_exclusion:
|
|
167
|
+
if not os.path.isfile(ctx) or os.path.islink(ctx):
|
|
168
|
+
raise SystemExit("REFUSING: task lock exclusion repair requires an existing scaffold.")
|
|
169
|
+
try:
|
|
170
|
+
result = ensure_task_lock_excluded(root, required=True)
|
|
171
|
+
except TaskExclusionError as exc:
|
|
172
|
+
raise SystemExit(f"REFUSING: {exc}") from None
|
|
173
|
+
print(f"task lock exclusion: {result}")
|
|
174
|
+
return
|
|
175
|
+
|
|
171
176
|
if os.path.exists(ctx):
|
|
172
177
|
raise SystemExit(
|
|
173
178
|
f"REFUSING: {ctx} already exists. .codearbiter/ is already scaffolded here. "
|
|
174
179
|
f"To populate it, run {_cc} or {_dc}; to repair, edit by hand.")
|
|
175
180
|
|
|
181
|
+
try:
|
|
182
|
+
result = ensure_task_lock_excluded(root)
|
|
183
|
+
except TaskExclusionError as exc:
|
|
184
|
+
raise SystemExit(f"REFUSING: {exc}") from None
|
|
185
|
+
if result:
|
|
186
|
+
print(f"task lock exclusion: {result}")
|
|
187
|
+
|
|
176
188
|
os.makedirs(cad, exist_ok=True)
|
|
177
189
|
created = []
|
|
178
190
|
if not os.path.exists(ctx):
|
|
@@ -183,7 +195,10 @@ def main(argv=None):
|
|
|
183
195
|
for fname, content in FILES.items():
|
|
184
196
|
fp = os.path.join(cad, fname)
|
|
185
197
|
if not os.path.exists(fp):
|
|
186
|
-
|
|
198
|
+
# Match taskwrite's LF output for this shared append-only template
|
|
199
|
+
# on Windows too; other scaffold files retain their existing EOLs.
|
|
200
|
+
newline = "\n" if fname == "done-tasks.md" else None
|
|
201
|
+
with open(fp, "w", encoding="utf-8", newline=newline) as f:
|
|
187
202
|
f.write(content)
|
|
188
203
|
created.append(fname)
|
|
189
204
|
|
|
@@ -15,14 +15,40 @@ logged, always visible, never silent. Single identity, single confirm.
|
|
|
15
15
|
vague reason ("just skip it") and ask for a specific one.
|
|
16
16
|
2. Detect the operator identity from `git config user.email` only. If it is unset, ask the user once
|
|
17
17
|
to state their identity for the log. (No platform ladder, no second confirmation.)
|
|
18
|
-
3.
|
|
18
|
+
3. For an ordinary override, use the private locked audit helper from the repository root:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
python3 "<plugin-root>/hooks/append-override.py" --gate "<gate bypassed>" --reason "<specific reason>"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
It appends one line to `<project-root>/.codearbiter/overrides.log`:
|
|
19
25
|
|
|
20
26
|
```
|
|
21
27
|
[ISO-8601 timestamp] | BY: <email> | GATE: <gate bypassed> | REASON: <reason>
|
|
22
28
|
```
|
|
23
29
|
|
|
24
|
-
The log is append-only — never edited or deleted, committed as a permanent audit artifact.
|
|
25
|
-
|
|
30
|
+
The log is append-only — never edited or deleted, committed as a permanent audit artifact. The
|
|
31
|
+
helper fails closed if another official writer or conflict resolver owns the audit-path lock; the
|
|
32
|
+
overridden action must not proceed unless the row lands. A guard-permitted raw append remains a
|
|
33
|
+
cooperative operator escape hatch, but it must not run concurrently with conflict resolution.
|
|
34
|
+
4. A genuine three-stage Git conflict on an H-05 append-only path uses the dedicated helper instead
|
|
35
|
+
of the ordinary step 3 append. Run it once from the conflicted repository, with the specific
|
|
36
|
+
repo-relative path and the same reason:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
python3 "<plugin-root>/hooks/resolve-append-only-conflict.py" ".codearbiter/overrides.log" --reason "<specific reason>"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The helper derives the same `git config user.email` identity, validates the merge-base and both
|
|
43
|
+
append suffixes, writes the attributed H-05 record, and explicitly stages only the validated
|
|
44
|
+
deterministic union. It refuses a missing or malformed three-stage conflict, any edit to prior
|
|
45
|
+
bytes, unsupported paths, invalid encoding, dirty audit sink, or replay after resolution. This is
|
|
46
|
+
scoped to the immediate action only; it creates no marker, standing exception, or general H-05
|
|
47
|
+
bypass. It takes canonical per-audit-path locks and supports only `overrides.log` and the
|
|
48
|
+
repository-owned `gate-events.log` sink; official writers share those locks. Conflict resolution
|
|
49
|
+
is an exclusive cooperative operation: do not start a raw append before or during it. Never
|
|
50
|
+
hand-resolve the protected log with Write, patch, checkout, or restore.
|
|
51
|
+
5. Proceed with the overridden action. Note in the response that the override is logged.
|
|
26
52
|
|
|
27
53
|
## Security ceiling — heavier path for security-critical stops
|
|
28
54
|
|
|
@@ -40,8 +66,14 @@ Heavier path (all required, in order):
|
|
|
40
66
|
2. **Explicit per-finding acknowledgement** — the user must acknowledge *that specific finding* in
|
|
41
67
|
their own words (a bare "yes"/"go ahead"/"I trust you" is declined — this mirrors `decision-variance`).
|
|
42
68
|
Detect identity from `git config user.email`; if unset, ask once.
|
|
43
|
-
3. **Heavier log entry** —
|
|
44
|
-
|
|
69
|
+
3. **Heavier log entry** — use the same locked helper so the mandatory row cannot race conflict
|
|
70
|
+
resolution:
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
python3 "<plugin-root>/hooks/append-override.py" --security-finding "<specific finding>" --reason "<reason>"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
It appends a line tagged `SECURITY-OVERRIDE` that records the specific finding, not just the gate name:
|
|
45
77
|
|
|
46
78
|
```
|
|
47
79
|
[ISO-8601] | BY: <email> | SECURITY-OVERRIDE | FINDING: <specific finding> | REASON: <reason>
|
|
@@ -62,7 +94,9 @@ Under `/ca-sprint`, a security-critical override is a hard-gate STOP: it surface
|
|
|
62
94
|
MUST write the log line before proceeding — it is not optional. MUST capture an operator identity —
|
|
63
95
|
"codeArbiter" or "automated" are not valid. MUST include a justification. The override is scoped to
|
|
64
96
|
the immediate action only; it creates no standing exception. MUST NOT edit or delete an existing
|
|
65
|
-
`overrides.log` entry.
|
|
97
|
+
`overrides.log` entry. For an H-05 three-stage conflict, the dedicated helper's validated union and
|
|
98
|
+
audit write are the single immediate action; do not pre-edit the conflicted file. MUST route a
|
|
99
|
+
security-critical / crypto-secret / irreversible stop through the
|
|
66
100
|
**Security ceiling** path — never the single-confirm flow — and MUST NOT auto-decide such an override
|
|
67
101
|
under `/ca-sprint`.
|
|
68
102
|
|