@arbiterforge/ca-pi 0.10.10 → 0.10.12

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.9" src="https://img.shields.io/badge/version-2.17.9-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.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:
@@ -161,7 +161,7 @@ pi config
161
161
  Replace `<version>` with the numeric suffix from the chosen tag while retaining the full
162
162
  `ca-pi-v...` tag in the install source.
163
163
 
164
- Pi 0.80.5 and Pi 0.84.1 are the supported hosts for this release line. Generated aliases use `/ca-*`;
164
+ Pi 0.84.1 is the supported host for this release line. Generated aliases use `/ca-*`;
165
165
  `/skill:ca-*` is the host-native fallback. Every `ca-pi-v*` tag is also published to npm as
166
166
  `npm:@arbiterforge/ca-pi` with provenance (ADR-0029); the pinned Git tag remains the reproducible
167
167
  install. The [Pi runbook](./docs/pi-parity-testing.md) covers isolated install, trust, verification,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arbiterforge/ca-pi",
3
- "version": "0.10.10",
3
+ "version": "0.10.12",
4
4
  "license": "AGPL-3.0-only",
5
5
  "repository": {
6
6
  "type": "git",
@@ -4,6 +4,19 @@ All notable changes to `ca-pi` are documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.10.12] - 2026-09-09
8
+
9
+ ### Fixed
10
+
11
+ - Bind the reviewed Pi 0.84.1 npm configuration and lock bytes across Windows and POSIX checkouts, require CI to execute the integrity-checked install step, and reject the retired Pi 0.80.5 host explicitly.
12
+ - Correct singleton Pi support wording and reconcile authenticated publication receipts for the already-published 0.10.11 release line.
13
+
14
+ ## [0.10.11] - 2026-09-08
15
+
16
+ ### Fixed
17
+
18
+ - 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.
19
+
7
20
  ## [0.10.10] - 2026-09-07
8
21
 
9
22
  ### Fixed
@@ -629,7 +629,7 @@ function resolvePythonCommand(platform = process.platform, probe = systemPythonP
629
629
  }
630
630
 
631
631
  // src/compatibility.ts
632
- var SUPPORTED_PI_VERSIONS = /* @__PURE__ */ new Set(["0.80.5", "0.84.1"]);
632
+ var SUPPORTED_PI_VERSIONS = /* @__PURE__ */ new Set(["0.84.1"]);
633
633
  var MINIMUM_NODE = [22, 19, 0];
634
634
  var SEMVER_PREFIX = /^(\d+)\.(\d+)\.(\d+)(?:$|[-+])/u;
635
635
  function atLeast(version, minimum) {
@@ -644,7 +644,7 @@ function atLeast(version, minimum) {
644
644
  }
645
645
  function compatibilityDirection(input) {
646
646
  if (!SUPPORTED_PI_VERSIONS.has(input.piVersion)) {
647
- return "codeArbiter requires Pi 0.80.5 or 0.84.1; install a supported Pi version and run /ca-doctor.";
647
+ return "codeArbiter requires Pi 0.84.1; install a supported Pi version and run /ca-doctor.";
648
648
  }
649
649
  if (!atLeast(input.nodeVersion, MINIMUM_NODE)) {
650
650
  return "codeArbiter requires Node >=22.19.0 for Pi; upgrade Node and run /ca-doctor.";
@@ -2,7 +2,7 @@
2
2
  var define_CODEARBITER_PI_PERMISSION_POLICY_SURFACES_default = { "ca-plan": "planning-write", codearbiter_background_bash: "background-launch" };
3
3
 
4
4
  // <define:__CODEARBITER_PI_SKILL_EXPANSION_FINGERPRINTS__>
5
- var define_CODEARBITER_PI_SKILL_EXPANSION_FINGERPRINTS_default = { "0.80.5": "12632f365440b07d5183cff871d889b796a3c711b6b49df20f95d9bc198d6c51", "0.84.1": "12632f365440b07d5183cff871d889b796a3c711b6b49df20f95d9bc198d6c51" };
5
+ var define_CODEARBITER_PI_SKILL_EXPANSION_FINGERPRINTS_default = { "0.84.1": "12632f365440b07d5183cff871d889b796a3c711b6b49df20f95d9bc198d6c51" };
6
6
 
7
7
  // <define:__CODEARBITER_PI_TOOL_CLASSES__>
8
8
  var define_CODEARBITER_PI_TOOL_CLASSES_default = { bash: "EXEC", codearbiter_background_bash: "EXEC", codearbiter_dispatch: "EXEC", codearbiter_farm_preview: "EXEC", write: "WRITE", edit: "EDIT", read: "READ" };
@@ -16,7 +16,7 @@ import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL2 } from
16
16
  import { types as utilTypes9 } from "node:util";
17
17
 
18
18
  // src/compatibility.ts
19
- var SUPPORTED_PI_VERSIONS = /* @__PURE__ */ new Set(["0.80.5", "0.84.1"]);
19
+ var SUPPORTED_PI_VERSIONS = /* @__PURE__ */ new Set(["0.84.1"]);
20
20
  var MINIMUM_NODE = [22, 19, 0];
21
21
  var SEMVER_PREFIX = /^(\d+)\.(\d+)\.(\d+)(?:$|[-+])/u;
22
22
  function atLeast(version, minimum) {
@@ -31,7 +31,7 @@ function atLeast(version, minimum) {
31
31
  }
32
32
  function compatibilityDirection(input) {
33
33
  if (!SUPPORTED_PI_VERSIONS.has(input.piVersion)) {
34
- return "codeArbiter requires Pi 0.80.5 or 0.84.1; install a supported Pi version and run /ca-doctor.";
34
+ return "codeArbiter requires Pi 0.84.1; install a supported Pi version and run /ca-doctor.";
35
35
  }
36
36
  if (!atLeast(input.nodeVersion, MINIMUM_NODE)) {
37
37
  return "codeArbiter requires Node >=22.19.0 for Pi; upgrade Node and run /ca-doctor.";
@@ -6881,10 +6881,10 @@ async function collectPiDoctorInput(dependencies) {
6881
6881
  var REMEDIATION = {
6882
6882
  package: "Reinstall ca-pi from the approved pinned Git tag, then restart Pi.",
6883
6883
  trust: "Run /trust in Pi, inspect the project, grant trust only if you accept it, then start a new session.",
6884
- version: "Upgrade Pi to 0.80.5 or 0.84.1 and Node to >=22.19.0, then restart Pi.",
6884
+ version: "Upgrade Pi to 0.84.1 and Node to >=22.19.0, then restart Pi.",
6885
6885
  python: "Upgrade or install Python 3, then run /ca-doctor again.",
6886
6886
  core: "Reinstall ca-pi to restore the generated shared core, then run /ca-doctor again.",
6887
- commands: "Remove conflicting command owners or run Pi 0.80.5/0.84.1, then restart Pi and run /ca-doctor.",
6887
+ commands: "Remove conflicting command owners or run Pi 0.84.1, then restart Pi and run /ca-doctor.",
6888
6888
  bridge: "Reinstall ca-pi and Python 3, then run /ca-doctor again.",
6889
6889
  child: "Reinstall ca-pi if the hardened child artifact is missing or tampered, then run /ca-doctor again.",
6890
6890
  "ambient-marker": "Remove CODEARBITER_SUBAGENT from the parent environment and restart Pi.",
@@ -6913,7 +6913,7 @@ function diagnosePi(input) {
6913
6913
  const packageHealthy = input.package.declared && input.package.name === "@arbiterforge/ca-pi" && existsSync(input.package.root) && existsSync(input.package.extensionPath) && samePath2(input.package.extensionPath, expectedExtension) && canonicallyInside(input.package.extensionPath, input.package.root);
6914
6914
  const trustHealthy = input.trust.inspected && (!input.trust.required || input.trust.projectTrusted);
6915
6915
  const waitingForTrust = input.trust.required && !input.trust.projectTrusted;
6916
- const versionHealthy = ["0.80.5", "0.84.1"].includes(input.runtime.piVersion) && atLeast(input.runtime.nodeVersion, [22, 19, 0]);
6916
+ const versionHealthy = input.runtime.piVersion === "0.84.1" && atLeast(input.runtime.nodeVersion, [22, 19, 0]);
6917
6917
  const piBelowMinimum = !atLeast(input.runtime.piVersion, [0, 80, 5]);
6918
6918
  const supportedExpansion = input.commands.expansionVerifiedVersions.includes(input.runtime.piVersion);
6919
6919
  const expectedDoctorSkill = resolve9(input.package.root, "skills", "ca-doctor", "SKILL.md");
@@ -7034,7 +7034,7 @@ function diagnosePi(input) {
7034
7034
  {
7035
7035
  id: "active-dispatch",
7036
7036
  state: "degraded",
7037
- message: "Supported Pi 0.80.5/0.84.1 public extension APIs cannot submit this deterministic self-test through the active dispatcher; the wrapper self-test does not exercise active dispatch.",
7037
+ message: "Supported Pi 0.84.1 public extension APIs cannot submit this deterministic self-test through the active dispatcher; the wrapper self-test does not exercise active dispatch.",
7038
7038
  remediation: REMEDIATION["active-dispatch"]
7039
7039
  }
7040
7040
  ];
@@ -10588,7 +10588,7 @@ async function codeArbiterPi(pi) {
10588
10588
  activeTools: pi.getActiveTools(),
10589
10589
  allTools: pi.getAllTools(),
10590
10590
  expansionFingerprints,
10591
- childFingerprint: "c9573777b4c59abfb4adbe6879fc03de7f7962327d97c378c2fb71d300a4e22f"
10591
+ childFingerprint: "22e5231b36a0af4a4202ced48a20e7dc9de435b6c6af985c6007bff96c5e04d1"
10592
10592
  });
10593
10593
  const wrapperSelfTest = await runPiWrapperSelfTest({
10594
10594
  enabled: enabledForDoctor,
@@ -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.10"
14
+ adapter_version = "0.10.12"
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
@@ -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.9"
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,
@@ -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())
@@ -74,7 +74,7 @@ file maps those actions to Pi's extension API.
74
74
  operator-launched Pi CLI, imported module, package root, and reported version.
75
75
  It does not prove publisher authenticity. Verify the source separately with
76
76
  `pi list` and `pi config`.
77
- - Supported promotion targets are Pi 0.80.5 and Pi 0.84.1. The package is
77
+ - The supported promotion target is exact Pi 0.84.1. The package is
78
78
  published as `npm:@arbiterforge/ca-pi` on every `ca-pi-v*` tag (ADR-0029);
79
79
  the pinned Git tag remains the reproducible install. A Pi-native embedded
80
80
  farm worker is a future spike, not an installed dependency.
@@ -21,7 +21,7 @@ remediation.
21
21
  Pi bash wrapper. The exact shared-core `[H-03]` block is healthy and cannot stage files; execution
22
22
  or a different block is unhealthy. This self-test does not traverse Pi's active dispatcher. Do not
23
23
  rerun or respell it.
24
- 4. Its `active-dispatch` row remains degraded because supported Pi 0.80.5/0.84.1 public extension
24
+ 4. Its `active-dispatch` row remains degraded because supported Pi 0.84.1 public extension
25
25
  APIs cannot submit that deterministic call through the active dispatcher. PI-AC-28 remains blocked
26
26
  until supported-version real-host promotion/CI evidence closes the gap.
27
27
 
@@ -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