@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 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.7" src="https://img.shields.io/badge/version-2.17.7-2b7489">
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.7`;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arbiterforge/ca-pi",
3
- "version": "0.10.8",
3
+ "version": "0.10.11",
4
4
  "license": "AGPL-3.0-only",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 Windows writers before taking the cross-process lock.
220
- _GATE_EVENTS_WINDOWS_LOCK = threading.Lock()
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
- if os.name == "nt":
477
- _GATE_EVENTS_WINDOWS_LOCK.acquire()
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(os.path.join(cad, "gate-events.log"), flags, 0o600)
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
- if process_lock_acquired:
508
- _GATE_EVENTS_WINDOWS_LOCK.release()
509
- if fd is not None:
510
- os.close(fd)
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.8"
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
- with open(_overrides_log_path(root), "a", encoding="utf-8") as f:
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 OSError:
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 DONE_TASKS_HEADING + "\n"
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
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env python3
2
+ """CLI entry point for the locked mandatory override audit append."""
3
+
4
+ from _overrideappendlib import main
5
+
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
@@ -313,7 +313,7 @@ class Host:
313
313
 
314
314
  name = "claude"
315
315
  adapter_name = "ca"
316
- adapter_version = "2.17.7"
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, default=1)
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
- with open(fp, "w", encoding="utf-8") as f:
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
 
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter - sanctioned helper entry point for H-05 append-only conflicts.
3
+
4
+ from _appendonlyconflictlib import main
5
+
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
@@ -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. Append one line to `<project-root>/.codearbiter/overrides.log`:
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
- 4. Proceed with the overridden action. Note in the response that the override is logged.
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** — append a line tagged `SECURITY-OVERRIDE` that records the specific finding,
44
- not just the gate name:
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. MUST route a security-critical / crypto-secret / irreversible stop through the
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