@arbiterforge/ca-pi 0.10.0 → 0.10.4
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 +5 -3
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +24 -0
- package/plugins/ca-pi/extensions/codearbiter.js +51 -14
- package/plugins/ca-pi/hooks/_adrlifecycle.py +496 -0
- package/plugins/ca-pi/hooks/_adrlifecyclegit.py +210 -0
- package/plugins/ca-pi/hooks/_bashguardlib.py +15 -5
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/adr-merge-method.py +34 -0
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- package/plugins/ca-pi/includes/safety-core.md +7 -0
- package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +5 -0
- package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +22 -1
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ project context. You decide. codeArbiter enforces.
|
|
|
10
10
|
<img alt="Claude Code plugin" src="https://img.shields.io/badge/Claude_Code-plugin-d97757">
|
|
11
11
|
<img alt="Codex plugin" src="https://img.shields.io/badge/OpenAI_Codex-plugin-10a37f">
|
|
12
12
|
<img alt="Pi Feature Forge preview" src="https://img.shields.io/badge/ca--pi-Feature_Forge_preview-d97757">
|
|
13
|
-
<img alt="version 2.17.
|
|
13
|
+
<img alt="version 2.17.3" src="https://img.shields.io/badge/version-2.17.3-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">
|
|
@@ -96,7 +96,9 @@ stability, command syntax, trust, and platform differences.
|
|
|
96
96
|
| Codex CLI | `ca-codex` | `$ca-feature` | Stable |
|
|
97
97
|
| Pi | `ca-pi` | `/ca-feature` | Feature Forge `preview` |
|
|
98
98
|
|
|
99
|
-
**Prerequisites:** Python 3 on `PATH` and `git config user.email` set.
|
|
99
|
+
**Prerequisites:** Python 3 on `PATH` and `git config user.email` set. ADR lifecycle proof requires
|
|
100
|
+
Git 2.45.0+ with `--no-lazy-fetch` on all three governance hosts; unavailable flag support blocks
|
|
101
|
+
verification with an upgrade prerequisite, never an implicit fetch fallback. Pi also requires Node.js
|
|
100
102
|
22.19+. If Python is missing, Pi installs its final wrappers but blocks mutating calls and points to
|
|
101
103
|
`/ca-doctor`; Claude Code and Codex surface an interpreter breadcrumb instead of silently claiming
|
|
102
104
|
governance is active. The [compatibility matrix](https://arbiterforge.github.io/codeArbiter/getting-started/compatibility/)
|
|
@@ -115,7 +117,7 @@ Approve the normal plugin trust prompt, open the target repository, and continue
|
|
|
115
117
|
|
|
116
118
|
### Codex CLI
|
|
117
119
|
|
|
118
|
-
The public GitHub-slug flow is **available now**. The repository currently ships `ca-codex 0.9.
|
|
120
|
+
The public GitHub-slug flow is **available now**. The repository currently ships `ca-codex 0.9.3`;
|
|
119
121
|
the dated end-to-end public-install record discovered `ca-codex 0.2.4` from release `v2.8.13`.
|
|
120
122
|
Current packaging and shared-core parity are continuously verified, while that dated live-install
|
|
121
123
|
record stays labeled rather than being silently promoted to evidence for a newer adapter:
|
package/package.json
CHANGED
|
@@ -4,6 +4,30 @@ All notable changes to `ca-pi` are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.10.4] - 2026-09-05
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Select a history-preserving PR merge when ADR lifecycle evidence binds source commits outside the base branch.
|
|
12
|
+
|
|
13
|
+
## [0.10.3] - 2026-09-04
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Synchronize the shared host kernel's adapter identity metadata with the Claude adapter version.
|
|
18
|
+
|
|
19
|
+
## [0.10.2] - 2026-09-03
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Report precise Windows containment startup stages and reject output-overflow admission races.
|
|
24
|
+
|
|
25
|
+
## [0.10.1] - 2026-09-03
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- Prevent read-only filename searches from falsely triggering protected-state interpreter guards while preserving mutation enforcement.
|
|
30
|
+
|
|
7
31
|
## [0.10.0] - 2026-09-02
|
|
8
32
|
|
|
9
33
|
### Added
|
|
@@ -1546,10 +1546,14 @@ public static class CodeArbiterJob {
|
|
|
1546
1546
|
'@
|
|
1547
1547
|
try {
|
|
1548
1548
|
Add-Type -TypeDefinition $source -Language CSharp | Out-Null
|
|
1549
|
+
[Console]::Out.WriteLine('COMPILED')
|
|
1550
|
+
[Console]::Out.Flush()
|
|
1549
1551
|
$line = [Console]::In.ReadLine()
|
|
1550
1552
|
if ($null -eq $line -or $line -notmatch '^([1-9][0-9]*) ([1-9][0-9]*)$') { exit 40 }
|
|
1551
1553
|
[UInt32]$target = $Matches[1]
|
|
1552
1554
|
[UInt32]$parent = $Matches[2]
|
|
1555
|
+
[Console]::Out.WriteLine('PID_ACCEPTED')
|
|
1556
|
+
[Console]::Out.Flush()
|
|
1553
1557
|
$job = [CodeArbiterJob]::CreateAndAssign($target)
|
|
1554
1558
|
if ($job -eq [IntPtr]::Zero) { exit 41 }
|
|
1555
1559
|
try {
|
|
@@ -1642,12 +1646,31 @@ async function awaitProgressTokens(readLine, expected, options) {
|
|
|
1642
1646
|
if (idleMs > ceilingMs) throw new Error("progress idleMs must be bounded by the progress ceiling");
|
|
1643
1647
|
const now = options.now ?? Date.now;
|
|
1644
1648
|
const deadline = now() + ceilingMs;
|
|
1649
|
+
if (options.windowsJobStages && (expected.length !== 2 || expected[0] !== WINDOWS_JOB_STARTING || expected[1] !== WINDOWS_JOB_READY)) {
|
|
1650
|
+
throw new Error("Windows startup diagnostics require the bounded admission sequence");
|
|
1651
|
+
}
|
|
1652
|
+
let lastStage = "WAITING";
|
|
1645
1653
|
for (const token of expected) {
|
|
1646
|
-
const remaining = deadline - now();
|
|
1647
|
-
if (remaining <= 0) return Object.freeze({ state: "stalled", phase: token, waitedMs: 0 });
|
|
1648
1654
|
const started = now();
|
|
1649
|
-
const
|
|
1650
|
-
|
|
1655
|
+
const phaseDeadline = Math.min(deadline, started + idleMs);
|
|
1656
|
+
const stalled = () => Object.freeze({
|
|
1657
|
+
state: "stalled",
|
|
1658
|
+
phase: token,
|
|
1659
|
+
waitedMs: now() - started,
|
|
1660
|
+
...options.windowsJobStages ? { lastStage } : {}
|
|
1661
|
+
});
|
|
1662
|
+
const diagnostics = options.windowsJobStages && token === WINDOWS_JOB_READY ? ["COMPILED", "PID_ACCEPTED"] : [];
|
|
1663
|
+
for (const diagnostic of diagnostics) {
|
|
1664
|
+
const remaining2 = phaseDeadline - now();
|
|
1665
|
+
if (remaining2 <= 0) return stalled();
|
|
1666
|
+
if (await readLine(remaining2) !== diagnostic) return stalled();
|
|
1667
|
+
lastStage = diagnostic;
|
|
1668
|
+
}
|
|
1669
|
+
const remaining = phaseDeadline - now();
|
|
1670
|
+
if (remaining <= 0) return stalled();
|
|
1671
|
+
const line = await readLine(remaining);
|
|
1672
|
+
if (line !== token) return stalled();
|
|
1673
|
+
if (options.windowsJobStages && token === WINDOWS_JOB_STARTING) lastStage = "STARTING";
|
|
1651
1674
|
}
|
|
1652
1675
|
return Object.freeze({ state: "ready" });
|
|
1653
1676
|
}
|
|
@@ -1831,7 +1854,9 @@ function startWindowsJobGuard(pid, timing) {
|
|
|
1831
1854
|
let closePending;
|
|
1832
1855
|
let armed = false;
|
|
1833
1856
|
let outputEnded = false;
|
|
1857
|
+
let protocolFailed = false;
|
|
1834
1858
|
let outputBuffer = "";
|
|
1859
|
+
let queuedOutputBytes = 0;
|
|
1835
1860
|
const outputLines = [];
|
|
1836
1861
|
const outputWaiters = [];
|
|
1837
1862
|
let resolveExitCode;
|
|
@@ -1851,7 +1876,11 @@ function startWindowsJobGuard(pid, timing) {
|
|
|
1851
1876
|
settleExitCode();
|
|
1852
1877
|
};
|
|
1853
1878
|
const readOutputLine = (timeoutMs) => {
|
|
1854
|
-
if (outputLines.length > 0)
|
|
1879
|
+
if (outputLines.length > 0) {
|
|
1880
|
+
const { line, bytes } = outputLines.shift();
|
|
1881
|
+
queuedOutputBytes -= bytes;
|
|
1882
|
+
return Promise.resolve(line);
|
|
1883
|
+
}
|
|
1855
1884
|
if (outputEnded) return Promise.resolve(void 0);
|
|
1856
1885
|
return new Promise((resolveLine) => {
|
|
1857
1886
|
let timer;
|
|
@@ -1870,8 +1899,11 @@ function startWindowsJobGuard(pid, timing) {
|
|
|
1870
1899
|
helper.stdout.setEncoding("utf8");
|
|
1871
1900
|
helper.stdout.on("data", (chunk) => {
|
|
1872
1901
|
if (outputEnded) return;
|
|
1873
|
-
outputBuffer
|
|
1874
|
-
|
|
1902
|
+
if (queuedOutputBytes + Buffer.byteLength(outputBuffer, "utf8") + Buffer.byteLength(chunk, "utf8") > MAX_JOB_PROTOCOL_BYTES) {
|
|
1903
|
+
protocolFailed = true;
|
|
1904
|
+
outputLines.length = 0;
|
|
1905
|
+
queuedOutputBytes = 0;
|
|
1906
|
+
outputBuffer = "";
|
|
1875
1907
|
finishOutput();
|
|
1876
1908
|
try {
|
|
1877
1909
|
helper.stdin.end();
|
|
@@ -1879,13 +1911,17 @@ function startWindowsJobGuard(pid, timing) {
|
|
|
1879
1911
|
}
|
|
1880
1912
|
return;
|
|
1881
1913
|
}
|
|
1914
|
+
outputBuffer += chunk;
|
|
1882
1915
|
let newline = outputBuffer.indexOf("\n");
|
|
1883
1916
|
while (newline >= 0) {
|
|
1917
|
+
const bytes = Buffer.byteLength(outputBuffer.slice(0, newline + 1), "utf8");
|
|
1884
1918
|
const line = outputBuffer.slice(0, newline).replace(/\r$/u, "");
|
|
1885
1919
|
outputBuffer = outputBuffer.slice(newline + 1);
|
|
1886
1920
|
const waiter = outputWaiters.shift();
|
|
1887
|
-
if (waiter === void 0)
|
|
1888
|
-
|
|
1921
|
+
if (waiter === void 0) {
|
|
1922
|
+
outputLines.push({ line, bytes });
|
|
1923
|
+
queuedOutputBytes += bytes;
|
|
1924
|
+
} else waiter(line);
|
|
1889
1925
|
newline = outputBuffer.indexOf("\n");
|
|
1890
1926
|
}
|
|
1891
1927
|
});
|
|
@@ -1912,8 +1948,9 @@ function startWindowsJobGuard(pid, timing) {
|
|
|
1912
1948
|
const finish = (accepted) => {
|
|
1913
1949
|
if (settled) return false;
|
|
1914
1950
|
settled = true;
|
|
1915
|
-
|
|
1916
|
-
|
|
1951
|
+
const admitted = accepted && !protocolFailed;
|
|
1952
|
+
resolveReady(admitted);
|
|
1953
|
+
if (!admitted) {
|
|
1917
1954
|
try {
|
|
1918
1955
|
helper.stdin.end();
|
|
1919
1956
|
} catch {
|
|
@@ -1930,9 +1967,9 @@ function startWindowsJobGuard(pid, timing) {
|
|
|
1930
1967
|
if (!intentional) finish(false);
|
|
1931
1968
|
});
|
|
1932
1969
|
helper.once("error", () => finish(false));
|
|
1933
|
-
void awaitProgressTokens(readOutputLine, WINDOWS_JOB_READY_TOKENS, { ceilingMs, idleMs }).then((outcome) => {
|
|
1970
|
+
void awaitProgressTokens(readOutputLine, WINDOWS_JOB_READY_TOKENS, { ceilingMs, idleMs, windowsJobStages: true }).then((outcome) => {
|
|
1934
1971
|
const refused = finish(outcome.state === "ready");
|
|
1935
|
-
if (refused && outcome.state === "stalled") stallDiagnostic = `stalled at ${outcome.phase} after ${outcome.waitedMs}ms`;
|
|
1972
|
+
if (refused && outcome.state === "stalled") stallDiagnostic = `stalled at ${outcome.phase} after ${outcome.waitedMs}ms; last startup stage ${outcome.lastStage}`;
|
|
1936
1973
|
}, () => finish(false));
|
|
1937
1974
|
try {
|
|
1938
1975
|
helper.stdin.write(`${pid} ${process.pid}
|
|
@@ -1950,7 +1987,7 @@ function startWindowsJobGuard(pid, timing) {
|
|
|
1950
1987
|
return stallDiagnostic;
|
|
1951
1988
|
},
|
|
1952
1989
|
async arm(rootPid) {
|
|
1953
|
-
if (armed || !positivePid(rootPid) || !await ready || closed) return false;
|
|
1990
|
+
if (armed || !positivePid(rootPid) || !await ready || closed || protocolFailed) return false;
|
|
1954
1991
|
armed = true;
|
|
1955
1992
|
const watched = readOutputLine(idleMs);
|
|
1956
1993
|
const written = await new Promise((resolveWrite) => {
|
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""codeArbiter: pure lifecycle validation and verified-only export helpers.
|
|
3
|
+
|
|
4
|
+
Shared API: parse_adr, validate_events, validate_source_blobs,
|
|
5
|
+
validate_evidence_sources, append_only_error, and verified_export.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import datetime as dt
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
ADR_RE = re.compile(r"^(\d{4}-.+)\.md$")
|
|
15
|
+
HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
|
16
|
+
HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
|
17
|
+
FRONTMATTER_LINE_RE = re.compile(r"^([a-z][a-z0-9-]*):(?: (.*))?$")
|
|
18
|
+
H1_RE = re.compile(r"^# ADR-\d{4} (?:—|-) .+$")
|
|
19
|
+
H2_RE = re.compile(r"^## (.+)$", re.MULTILINE)
|
|
20
|
+
STATUS_VALUES = {"draft", "proposed", "accepted", "superseded", "rejected"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _sha(data):
|
|
24
|
+
return hashlib.sha256(data).hexdigest()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _normalized_text(blob):
|
|
28
|
+
return bytes(blob).decode("utf-8").replace("\r\n", "\n").replace("\r", "\n")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_adr(blob):
|
|
32
|
+
"""Strictly parse one ADR and return status, sections, and bound bytes."""
|
|
33
|
+
text = _normalized_text(blob)
|
|
34
|
+
if not text.startswith("---\n"):
|
|
35
|
+
raise ValueError("ADR has no opening frontmatter delimiter")
|
|
36
|
+
end = text.find("\n---\n", 4)
|
|
37
|
+
if end < 0:
|
|
38
|
+
raise ValueError("ADR has no closing frontmatter delimiter")
|
|
39
|
+
front_text = text[4:end]
|
|
40
|
+
body = text[end + 5:]
|
|
41
|
+
fields = {}
|
|
42
|
+
retained_front = []
|
|
43
|
+
for line in front_text.split("\n"):
|
|
44
|
+
if not line:
|
|
45
|
+
raise ValueError("blank or malformed frontmatter line")
|
|
46
|
+
match = FRONTMATTER_LINE_RE.fullmatch(line)
|
|
47
|
+
if not match:
|
|
48
|
+
raise ValueError("malformed frontmatter line %r" % line)
|
|
49
|
+
key, value = match.groups()
|
|
50
|
+
if key in fields:
|
|
51
|
+
raise ValueError("duplicate frontmatter key %s" % key)
|
|
52
|
+
fields[key] = value or ""
|
|
53
|
+
if key == "status":
|
|
54
|
+
retained_front.append("status: <mutable-status>")
|
|
55
|
+
else:
|
|
56
|
+
retained_front.append(line)
|
|
57
|
+
for required in ("status", "date", "title", "decided-by", "supersedes"):
|
|
58
|
+
if not fields.get(required):
|
|
59
|
+
raise ValueError("missing frontmatter key %s" % required)
|
|
60
|
+
if fields["status"].lower() not in STATUS_VALUES:
|
|
61
|
+
raise ValueError("unsupported ADR status %r" % fields["status"])
|
|
62
|
+
|
|
63
|
+
# Both separators are present in accepted history. Bind the exact heading
|
|
64
|
+
# bytes, but fail closed unless there is exactly one recognized ADR H1.
|
|
65
|
+
h1_matches = re.findall(r"(?m)^# ADR-\d{4} (?:—|-) .+$", body)
|
|
66
|
+
if len(h1_matches) != 1 or not H1_RE.fullmatch(h1_matches[0]):
|
|
67
|
+
raise ValueError("ADR has malformed or missing H1 heading")
|
|
68
|
+
heading_matches = list(H2_RE.finditer(body))
|
|
69
|
+
headings = [match.group(1) for match in heading_matches]
|
|
70
|
+
duplicates = sorted({heading for heading in headings if headings.count(heading) > 1})
|
|
71
|
+
if duplicates:
|
|
72
|
+
raise ValueError("duplicate ADR section heading(s): %s" % ", ".join(duplicates))
|
|
73
|
+
for required in ("Status", "Context", "Decision"):
|
|
74
|
+
if required not in headings:
|
|
75
|
+
raise ValueError("ADR has no ## %s section" % required)
|
|
76
|
+
|
|
77
|
+
sections = {}
|
|
78
|
+
status_value_span = None
|
|
79
|
+
for index, match in enumerate(heading_matches):
|
|
80
|
+
content_start = match.end()
|
|
81
|
+
if content_start < len(body) and body[content_start] == "\n":
|
|
82
|
+
content_start += 1
|
|
83
|
+
content_end = heading_matches[index + 1].start() if index + 1 < len(heading_matches) else len(body)
|
|
84
|
+
sections[match.group(1)] = body[content_start:content_end].encode("utf-8")
|
|
85
|
+
if match.group(1) == "Status":
|
|
86
|
+
status_text = body[content_start:content_end]
|
|
87
|
+
status_match = re.match(r"\s*([A-Za-z]+)(?=$|[\s—-])", status_text)
|
|
88
|
+
if status_match is None or status_match.group(1).lower() not in STATUS_VALUES:
|
|
89
|
+
raise ValueError("ADR Status section has no recognized status value")
|
|
90
|
+
if status_match.group(1).lower() != fields["status"].lower():
|
|
91
|
+
raise ValueError("ADR Status section disagrees with frontmatter status")
|
|
92
|
+
status_value_span = (
|
|
93
|
+
content_start + status_match.start(1),
|
|
94
|
+
content_start + status_match.end(1),
|
|
95
|
+
)
|
|
96
|
+
if status_value_span is None or not sections["Status"].strip():
|
|
97
|
+
raise ValueError("ADR Status section is empty")
|
|
98
|
+
|
|
99
|
+
status_start, status_end = status_value_span
|
|
100
|
+
immutable_text = (
|
|
101
|
+
"---\n" + "\n".join(retained_front) + "\n---\n" +
|
|
102
|
+
body[:status_start] + "<mutable-status>" + body[status_end:]
|
|
103
|
+
)
|
|
104
|
+
return {"status": fields["status"].lower(), "fields": fields,
|
|
105
|
+
"sections": sections, "immutable": immutable_text.encode("utf-8")}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def immutable_body(blob):
|
|
109
|
+
"""Complete normalized ADR bytes excluding only its parsed status values."""
|
|
110
|
+
return parse_adr(blob)["immutable"]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def obligation_set_digest(obligations):
|
|
114
|
+
encoded = json.dumps(obligations, sort_keys=True, separators=(",", ":"),
|
|
115
|
+
ensure_ascii=False).encode("utf-8")
|
|
116
|
+
return _sha(encoded)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def read_jsonl(path):
|
|
120
|
+
events = []
|
|
121
|
+
with open(path, encoding="utf-8") as handle:
|
|
122
|
+
for number, line in enumerate(handle, 1):
|
|
123
|
+
if not line.strip():
|
|
124
|
+
continue
|
|
125
|
+
try:
|
|
126
|
+
events.append(json.loads(line))
|
|
127
|
+
except (TypeError, ValueError) as exc:
|
|
128
|
+
events.append({"event": "invalid", "_error": "line %d: %s" % (number, exc)})
|
|
129
|
+
return events
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def read_adrs(root, errors=None):
|
|
133
|
+
directory = os.path.join(root, ".codearbiter", "decisions")
|
|
134
|
+
result = {}
|
|
135
|
+
for name in sorted(os.listdir(directory)):
|
|
136
|
+
match = ADR_RE.match(name)
|
|
137
|
+
if not match:
|
|
138
|
+
continue
|
|
139
|
+
path = os.path.join(directory, name)
|
|
140
|
+
with open(path, "rb") as handle:
|
|
141
|
+
blob = handle.read()
|
|
142
|
+
try:
|
|
143
|
+
parse_adr(blob)
|
|
144
|
+
except (UnicodeError, ValueError) as exc:
|
|
145
|
+
if errors is not None:
|
|
146
|
+
errors.append("%s: %s" % (match.group(1), exc))
|
|
147
|
+
continue
|
|
148
|
+
result[match.group(1)] = blob
|
|
149
|
+
return result
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def read_accepted_adrs(root, errors=None):
|
|
153
|
+
return {adr: blob for adr, blob in read_adrs(root, errors=errors).items()
|
|
154
|
+
if parse_adr(blob)["status"] == "accepted"}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def append_only_error(base, current):
|
|
158
|
+
if bytes(current).startswith(bytes(base)):
|
|
159
|
+
return None
|
|
160
|
+
return "adr-lifecycle ledger rewrites or truncates base history"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _parse_time(value):
|
|
164
|
+
moment = dt.datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
165
|
+
if moment.tzinfo is None or moment.utcoffset() is None:
|
|
166
|
+
raise ValueError("timestamp has no timezone")
|
|
167
|
+
return moment
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _binding_errors(event, blob):
|
|
171
|
+
errors = []
|
|
172
|
+
adr = event.get("adr", "<missing>")
|
|
173
|
+
try:
|
|
174
|
+
parsed = parse_adr(blob)
|
|
175
|
+
body = _sha(parsed["immutable"])
|
|
176
|
+
except (UnicodeError, ValueError) as exc:
|
|
177
|
+
errors.append("%s: immutable content unavailable: %s" % (adr, exc))
|
|
178
|
+
parsed = {"sections": {}}
|
|
179
|
+
else:
|
|
180
|
+
if body != event.get("body_sha256"):
|
|
181
|
+
errors.append("%s: immutable-body digest does not match" % adr)
|
|
182
|
+
try:
|
|
183
|
+
_parse_time(event.get("recorded_at"))
|
|
184
|
+
except (TypeError, ValueError):
|
|
185
|
+
errors.append("%s: binding has invalid recorded_at" % adr)
|
|
186
|
+
obligations = event.get("obligations")
|
|
187
|
+
if not isinstance(obligations, list):
|
|
188
|
+
errors.append("%s: obligations must be a list" % adr)
|
|
189
|
+
obligations = []
|
|
190
|
+
if obligation_set_digest(obligations) != event.get("obligations_sha256"):
|
|
191
|
+
errors.append("%s: obligation-set digest does not match sealed content" % adr)
|
|
192
|
+
seen = set()
|
|
193
|
+
for obligation in obligations:
|
|
194
|
+
oid = obligation.get("id") if isinstance(obligation, dict) else None
|
|
195
|
+
if not isinstance(oid, str) or not oid.startswith(adr + "."):
|
|
196
|
+
errors.append("%s: obligation id %r is not stem-scoped" % (adr, oid))
|
|
197
|
+
continue
|
|
198
|
+
if oid in seen:
|
|
199
|
+
errors.append("%s: duplicate obligation id %s" % (adr, oid))
|
|
200
|
+
seen.add(oid)
|
|
201
|
+
section = obligation.get("section")
|
|
202
|
+
section_bytes = parsed["sections"].get(section) if isinstance(section, str) else None
|
|
203
|
+
if section_bytes is None:
|
|
204
|
+
errors.append("%s: obligation %s section binding is absent" % (adr, oid))
|
|
205
|
+
text = obligation.get("text")
|
|
206
|
+
if not isinstance(text, str) or not text.strip():
|
|
207
|
+
errors.append("%s: obligation %s has no bound text" % (adr, oid))
|
|
208
|
+
continue
|
|
209
|
+
encoded = text.encode("utf-8")
|
|
210
|
+
if _sha(encoded) != obligation.get("text_sha256"):
|
|
211
|
+
errors.append("%s: obligation %s text digest does not match" % (adr, oid))
|
|
212
|
+
if section_bytes is not None and encoded not in section_bytes:
|
|
213
|
+
errors.append("%s: obligation %s text violates its section binding" % (adr, oid))
|
|
214
|
+
return errors
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def validate_source_blobs(events, source_blobs):
|
|
218
|
+
"""Validate acceptance and migration bindings against committed Git bytes."""
|
|
219
|
+
errors = []
|
|
220
|
+
for event in events:
|
|
221
|
+
if not isinstance(event, dict) or event.get("event") not in ("acceptance", "baseline"):
|
|
222
|
+
continue
|
|
223
|
+
adr = event.get("adr")
|
|
224
|
+
kind = event.get("event")
|
|
225
|
+
label = "source-commit" if kind == "acceptance" else "migration-snapshot"
|
|
226
|
+
field = "source_commit" if kind == "acceptance" else "observed_commit"
|
|
227
|
+
commit = event.get(field)
|
|
228
|
+
if not isinstance(commit, str) or not isinstance(adr, str):
|
|
229
|
+
errors.append("%s: %s binding identity is malformed" % (adr, label))
|
|
230
|
+
continue
|
|
231
|
+
blob = source_blobs.get((commit, adr))
|
|
232
|
+
if blob is None:
|
|
233
|
+
errors.append("%s: %s ADR blob is unavailable" % (adr, label))
|
|
234
|
+
continue
|
|
235
|
+
if _sha(blob) != event.get("blob_sha256"):
|
|
236
|
+
errors.append("%s: %s blob digest does not match" % (adr, label))
|
|
237
|
+
try:
|
|
238
|
+
parsed = parse_adr(blob)
|
|
239
|
+
body_digest = _sha(parsed["immutable"])
|
|
240
|
+
except (UnicodeError, ValueError) as exc:
|
|
241
|
+
errors.append("%s: %s immutable content unavailable: %s" % (adr, label, exc))
|
|
242
|
+
else:
|
|
243
|
+
if body_digest != event.get("body_sha256"):
|
|
244
|
+
errors.append("%s: %s immutable-body digest does not match" % (adr, label))
|
|
245
|
+
if parsed["status"] != "accepted":
|
|
246
|
+
errors.append("%s: %s ADR status must be accepted" % (adr, label))
|
|
247
|
+
return errors
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _evidence_errors(event, index):
|
|
251
|
+
errors = []
|
|
252
|
+
if not isinstance(event.get("event_id"), str) or not event["event_id"].strip():
|
|
253
|
+
errors.append("line %d: evidence has no event_id" % index)
|
|
254
|
+
if not HEX40.fullmatch(str(event.get("source_commit", ""))):
|
|
255
|
+
errors.append("line %d: evidence source_commit must be a 40-character Git id" % index)
|
|
256
|
+
digests = event.get("input_digests")
|
|
257
|
+
if not isinstance(digests, dict) or not digests:
|
|
258
|
+
errors.append("line %d: evidence has no input digests" % index)
|
|
259
|
+
elif any(not isinstance(path, str) or not path or not HEX64.fullmatch(str(digest))
|
|
260
|
+
for path, digest in digests.items()):
|
|
261
|
+
errors.append("line %d: evidence input digests must map paths to SHA-256 values" % index)
|
|
262
|
+
kind = event.get("event")
|
|
263
|
+
if kind == "implemented":
|
|
264
|
+
if not isinstance(event.get("evidence"), str) or not event["evidence"].strip():
|
|
265
|
+
errors.append("line %d: implementation evidence is missing" % index)
|
|
266
|
+
if kind == "verified":
|
|
267
|
+
for field in ("proof_contract", "producer", "command", "observed_at",
|
|
268
|
+
"valid_until", "claim"):
|
|
269
|
+
if not isinstance(event.get(field), str) or not event[field].strip():
|
|
270
|
+
errors.append("line %d: verified evidence has no %s" % (index, field))
|
|
271
|
+
if event.get("claim_scope") != "repository":
|
|
272
|
+
errors.append("line %d: verified evidence claim_scope must be repository" % index)
|
|
273
|
+
try:
|
|
274
|
+
observed = _parse_time(event.get("observed_at"))
|
|
275
|
+
valid_until = _parse_time(event.get("valid_until"))
|
|
276
|
+
if observed > valid_until:
|
|
277
|
+
errors.append("line %d: observed_at is after valid_until" % index)
|
|
278
|
+
except (TypeError, ValueError) as exc:
|
|
279
|
+
errors.append("line %d: verified evidence timestamp/timezone is invalid: %s" %
|
|
280
|
+
(index, exc))
|
|
281
|
+
return errors
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def validate_events(events, adr_blobs, require_all_accepted=False):
|
|
285
|
+
errors = []
|
|
286
|
+
bindings = {}
|
|
287
|
+
obligations = {}
|
|
288
|
+
event_ids = {}
|
|
289
|
+
implementations = {}
|
|
290
|
+
for index, event in enumerate(events, 1):
|
|
291
|
+
if not isinstance(event, dict):
|
|
292
|
+
errors.append("invalid lifecycle event at entry %d" % index)
|
|
293
|
+
continue
|
|
294
|
+
if event.get("_error"):
|
|
295
|
+
errors.append("invalid lifecycle event: %s" % event["_error"])
|
|
296
|
+
continue
|
|
297
|
+
if event.get("schema") != "adr-lifecycle/v1":
|
|
298
|
+
errors.append("line %d: unsupported schema" % index)
|
|
299
|
+
kind = event.get("event")
|
|
300
|
+
adr = event.get("adr")
|
|
301
|
+
if kind in ("acceptance", "baseline"):
|
|
302
|
+
if not isinstance(adr, str) or not adr:
|
|
303
|
+
errors.append("line %d: binding has malformed ADR identity" % index)
|
|
304
|
+
continue
|
|
305
|
+
if adr in bindings:
|
|
306
|
+
errors.append("%s: second acceptance/baseline binding is forbidden" % adr)
|
|
307
|
+
continue
|
|
308
|
+
blob = adr_blobs.get(adr)
|
|
309
|
+
if blob is None:
|
|
310
|
+
errors.append("%s: binding names no valid ADR" % adr)
|
|
311
|
+
continue
|
|
312
|
+
bindings[adr] = event
|
|
313
|
+
errors.extend(_binding_errors(event, blob))
|
|
314
|
+
sealed = event.get("obligations_sealed") is True
|
|
315
|
+
if kind == "acceptance":
|
|
316
|
+
if not sealed:
|
|
317
|
+
errors.append("%s: acceptance obligation set is not sealed" % adr)
|
|
318
|
+
if sealed and not (isinstance(event.get("obligations"), list)
|
|
319
|
+
and event["obligations"]):
|
|
320
|
+
errors.append("%s: sealed acceptance obligation set is empty" % adr)
|
|
321
|
+
if not HEX40.fullmatch(str(event.get("source_commit", ""))):
|
|
322
|
+
errors.append("%s: acceptance source_commit must be a 40-character Git id" % adr)
|
|
323
|
+
if "observed_commit" in event:
|
|
324
|
+
errors.append("%s: acceptance must not use observed_commit" % adr)
|
|
325
|
+
else:
|
|
326
|
+
if sealed:
|
|
327
|
+
errors.append("%s: legacy baseline must remain unsealed" % adr)
|
|
328
|
+
if "source_commit" in event:
|
|
329
|
+
errors.append("%s: legacy baseline must not fabricate an acceptance commit" % adr)
|
|
330
|
+
if not HEX40.fullmatch(str(event.get("observed_commit", ""))):
|
|
331
|
+
errors.append("%s: baseline observed_commit must be a 40-character Git id" % adr)
|
|
332
|
+
raw_obligations = event.get("obligations")
|
|
333
|
+
for item in raw_obligations if isinstance(raw_obligations, list) else []:
|
|
334
|
+
if isinstance(item, dict) and isinstance(item.get("id"), str):
|
|
335
|
+
oid = item["id"]
|
|
336
|
+
if oid in obligations:
|
|
337
|
+
errors.append("%s: duplicate obligation id %s" % (adr, oid))
|
|
338
|
+
obligations[oid] = adr
|
|
339
|
+
elif kind in ("implemented", "verified"):
|
|
340
|
+
event_id = event.get("event_id")
|
|
341
|
+
if not isinstance(event_id, str) or not event_id:
|
|
342
|
+
errors.append("line %d: evidence has no event_id" % index)
|
|
343
|
+
elif event_id in event_ids:
|
|
344
|
+
errors.append("line %d: duplicate event_id %r" % (index, event_id))
|
|
345
|
+
else:
|
|
346
|
+
event_ids[event_id] = kind
|
|
347
|
+
oid = event.get("obligation")
|
|
348
|
+
if not isinstance(oid, str):
|
|
349
|
+
errors.append("line %d: evidence has malformed obligation identity" % index)
|
|
350
|
+
continue
|
|
351
|
+
if oid not in obligations:
|
|
352
|
+
errors.append("line %d: evidence names undeclared obligation %r" % (index, oid))
|
|
353
|
+
elif adr != obligations[oid]:
|
|
354
|
+
errors.append("line %d: evidence ADR does not own obligation %r" % (index, oid))
|
|
355
|
+
key = (adr, oid)
|
|
356
|
+
if kind == "implemented":
|
|
357
|
+
implementations.setdefault(key, []).append(event)
|
|
358
|
+
else:
|
|
359
|
+
prior = implementations.get(key, [])
|
|
360
|
+
if not prior:
|
|
361
|
+
errors.append("line %d: verified evidence appears before implementation for %r" %
|
|
362
|
+
(index, oid))
|
|
363
|
+
elif event.get("input_digests") != prior[-1].get("input_digests"):
|
|
364
|
+
errors.append("line %d: verification input set differs from implementation input digest boundary for %r" %
|
|
365
|
+
(index, oid))
|
|
366
|
+
errors.extend(_evidence_errors(event, index))
|
|
367
|
+
elif kind == "invalidated":
|
|
368
|
+
event_id = event.get("event_id")
|
|
369
|
+
if not isinstance(event_id, str) or not event_id:
|
|
370
|
+
errors.append("line %d: invalidation has no event_id" % index)
|
|
371
|
+
elif event_id in event_ids:
|
|
372
|
+
errors.append("line %d: duplicate event_id %r" % (index, event_id))
|
|
373
|
+
else:
|
|
374
|
+
event_ids[event_id] = kind
|
|
375
|
+
target = event.get("target_event_id")
|
|
376
|
+
if (not isinstance(target, str) or target not in event_ids or
|
|
377
|
+
event_ids.get(target) not in ("implemented", "verified")):
|
|
378
|
+
errors.append("line %d: invalidation target is missing or not prior evidence" % index)
|
|
379
|
+
if not isinstance(event.get("reason"), str) or not event["reason"].strip():
|
|
380
|
+
errors.append("line %d: invalidation has no reason" % index)
|
|
381
|
+
try:
|
|
382
|
+
_parse_time(event.get("recorded_at"))
|
|
383
|
+
except (TypeError, ValueError):
|
|
384
|
+
errors.append("line %d: invalidation has invalid recorded_at" % index)
|
|
385
|
+
else:
|
|
386
|
+
errors.append("line %d: unknown event %r" % (index, kind))
|
|
387
|
+
if require_all_accepted:
|
|
388
|
+
for adr in sorted(set(adr_blobs) - set(bindings)):
|
|
389
|
+
errors.append("%s: accepted ADR has no lifecycle binding" % adr)
|
|
390
|
+
return errors
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def validate_evidence_sources(events, source_inputs):
|
|
394
|
+
"""Recompute every evidence path digest from its committed Git bytes."""
|
|
395
|
+
errors = []
|
|
396
|
+
for event in events:
|
|
397
|
+
if not isinstance(event, dict) or event.get("event") not in ("implemented", "verified"):
|
|
398
|
+
continue
|
|
399
|
+
commit = event.get("source_commit")
|
|
400
|
+
event_id = event.get("event_id", "<missing>")
|
|
401
|
+
digests = event.get("input_digests")
|
|
402
|
+
if not isinstance(digests, dict):
|
|
403
|
+
continue
|
|
404
|
+
for path, expected in digests.items():
|
|
405
|
+
if not isinstance(path, str) or not isinstance(commit, str):
|
|
406
|
+
errors.append("%s: Git input binding is malformed" % event_id)
|
|
407
|
+
continue
|
|
408
|
+
blob = source_inputs.get((commit, path))
|
|
409
|
+
if blob is None:
|
|
410
|
+
errors.append("%s: Git input %s is unavailable at %s" % (event_id, path, commit))
|
|
411
|
+
elif _sha(blob) != expected:
|
|
412
|
+
errors.append("%s: Git input digest does not match for %s" % (event_id, path))
|
|
413
|
+
return errors
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _inputs_current(event, current_blobs):
|
|
417
|
+
digests = event.get("input_digests")
|
|
418
|
+
if not isinstance(digests, dict) or not digests:
|
|
419
|
+
return False
|
|
420
|
+
return all(path in current_blobs and current_blobs[path] is not None and
|
|
421
|
+
_sha(current_blobs[path]) == digest
|
|
422
|
+
for path, digest in digests.items())
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def verified_export(events, adr_blobs, current_blobs, now):
|
|
426
|
+
errors = validate_events(events, adr_blobs)
|
|
427
|
+
if errors:
|
|
428
|
+
return [], errors
|
|
429
|
+
try:
|
|
430
|
+
moment = _parse_time(now)
|
|
431
|
+
except (TypeError, ValueError) as exc:
|
|
432
|
+
return [], ["export time/timezone is invalid: %s" % exc]
|
|
433
|
+
bindings = {event.get("adr"): event for event in events
|
|
434
|
+
if event.get("event") in ("acceptance", "baseline")}
|
|
435
|
+
invalidated = {event.get("target_event_id") for event in events
|
|
436
|
+
if event.get("event") == "invalidated"}
|
|
437
|
+
exported = []
|
|
438
|
+
suppressed = set()
|
|
439
|
+
for adr, binding in sorted(bindings.items()):
|
|
440
|
+
if parse_adr(adr_blobs[adr])["status"] != "accepted":
|
|
441
|
+
errors.append("%s: current ADR status is not accepted; no Verified export" % adr)
|
|
442
|
+
suppressed.add(adr)
|
|
443
|
+
continue
|
|
444
|
+
if not binding.get("obligations_sealed"):
|
|
445
|
+
errors.append("%s: obligation set is unsealed; no Verified export" % adr)
|
|
446
|
+
suppressed.add(adr)
|
|
447
|
+
continue
|
|
448
|
+
for obligation in binding.get("obligations", []):
|
|
449
|
+
oid = obligation["id"]
|
|
450
|
+
impls = [event for event in events if event.get("event") == "implemented"
|
|
451
|
+
and event.get("adr") == adr and event.get("obligation") == oid
|
|
452
|
+
and event.get("event_id") not in invalidated]
|
|
453
|
+
proofs = [event for event in events if event.get("event") == "verified"
|
|
454
|
+
and event.get("adr") == adr and event.get("obligation") == oid
|
|
455
|
+
and event.get("event_id") not in invalidated]
|
|
456
|
+
implementation = next(
|
|
457
|
+
(event for event in reversed(impls) if _inputs_current(event, current_blobs)), None)
|
|
458
|
+
proof = next(
|
|
459
|
+
(event for event in reversed(proofs)
|
|
460
|
+
if _inputs_current(event, current_blobs)
|
|
461
|
+
and _parse_time(event["observed_at"]) <= moment <= _parse_time(event["valid_until"])),
|
|
462
|
+
None)
|
|
463
|
+
if implementation is None:
|
|
464
|
+
errors.append("%s: no current implementation evidence; implementation input digest is missing or stale" % oid)
|
|
465
|
+
suppressed.add(adr)
|
|
466
|
+
continue
|
|
467
|
+
if proof is None:
|
|
468
|
+
current_proofs = [event for event in proofs
|
|
469
|
+
if _inputs_current(event, current_blobs)]
|
|
470
|
+
if current_proofs and all(
|
|
471
|
+
moment > _parse_time(event["valid_until"])
|
|
472
|
+
for event in current_proofs):
|
|
473
|
+
errors.append("%s: no current verification evidence; all current-input evidence expired or was invalidated" % oid)
|
|
474
|
+
elif current_proofs and all(
|
|
475
|
+
moment < _parse_time(event["observed_at"])
|
|
476
|
+
for event in current_proofs):
|
|
477
|
+
errors.append("%s: verification evidence is not yet observable" % oid)
|
|
478
|
+
else:
|
|
479
|
+
errors.append("%s: no current verification evidence; verification input digest is missing or stale" % oid)
|
|
480
|
+
suppressed.add(adr)
|
|
481
|
+
continue
|
|
482
|
+
if proof.get("input_digests") != implementation.get("input_digests"):
|
|
483
|
+
errors.append("%s: implementation/verification input boundary differs" % oid)
|
|
484
|
+
suppressed.add(adr)
|
|
485
|
+
continue
|
|
486
|
+
exported.append({
|
|
487
|
+
"adr": adr, "obligation": oid, "claim": proof["claim"],
|
|
488
|
+
"claim_scope": proof["claim_scope"], "source_commit": proof["source_commit"],
|
|
489
|
+
"input_digests": proof["input_digests"],
|
|
490
|
+
"proof_contract": proof["proof_contract"], "producer": proof["producer"],
|
|
491
|
+
"command": proof["command"], "observed_at": proof["observed_at"],
|
|
492
|
+
"valid_until": proof["valid_until"],
|
|
493
|
+
})
|
|
494
|
+
# A verified report is aggregate per ADR over that ADR's complete sealed set.
|
|
495
|
+
# Never leak a partial set for one ADR or let it hide another complete ADR.
|
|
496
|
+
return [row for row in exported if row["adr"] not in suppressed], errors
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""codeArbiter: root-bound, committed lifecycle proof shared by CI and installs.
|
|
3
|
+
|
|
4
|
+
API: read_committed_evidence, validate_committed_sources, source_ancestry,
|
|
5
|
+
merge_method. No working-tree fallback, pending-packet allowance, or network.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import subprocess
|
|
10
|
+
|
|
11
|
+
import _adrlifecycle as al
|
|
12
|
+
from _gitexec import git_executable, root_bound_git_env
|
|
13
|
+
|
|
14
|
+
LEDGER_REL = ".codearbiter/decisions/adr-lifecycle.jsonl"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GitPrerequisiteError(Exception):
|
|
18
|
+
"""The selected Git cannot provide the required offline proof boundary."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _git(root, *args):
|
|
22
|
+
prerequisite = ("ADR lifecycle proof requires Git 2.45.0+ with --no-lazy-fetch; "
|
|
23
|
+
"upgrade the selected Git executable. Proof is never retried without that flag.")
|
|
24
|
+
try:
|
|
25
|
+
executable = git_executable()
|
|
26
|
+
env = root_bound_git_env()
|
|
27
|
+
result = subprocess.run(
|
|
28
|
+
[executable, "--no-replace-objects", "--no-lazy-fetch", "-C", root, *args],
|
|
29
|
+
capture_output=True, check=False, env=env)
|
|
30
|
+
if result.returncode:
|
|
31
|
+
# Diagnose the actual safety capability, not localized error text or
|
|
32
|
+
# a version string. This probe reads no repository and never retries
|
|
33
|
+
# the failed proof command with its network protection removed.
|
|
34
|
+
capability = subprocess.run(
|
|
35
|
+
[executable, "--no-lazy-fetch", "--version"],
|
|
36
|
+
capture_output=True, check=False, env=env)
|
|
37
|
+
if capability.returncode:
|
|
38
|
+
raise GitPrerequisiteError(prerequisite)
|
|
39
|
+
except (OSError, RuntimeError) as exc:
|
|
40
|
+
raise GitPrerequisiteError(prerequisite) from exc
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _git_blob(root, commit, path):
|
|
45
|
+
if not isinstance(commit, str):
|
|
46
|
+
return None
|
|
47
|
+
resolved = _git(root, "rev-parse", "--verify", "--end-of-options", "%s^{commit}" % commit)
|
|
48
|
+
if resolved.returncode != 0:
|
|
49
|
+
return None
|
|
50
|
+
result = _git(root, "show", "%s:%s" % (commit, path))
|
|
51
|
+
return result.stdout if result.returncode == 0 else None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _ledger_at(root, commit):
|
|
55
|
+
entry = _git(root, "ls-tree", "--name-only", commit, LEDGER_REL)
|
|
56
|
+
if entry.returncode != 0:
|
|
57
|
+
raise ValueError("could not inspect lifecycle ledger at %s" % commit)
|
|
58
|
+
if not entry.stdout.strip():
|
|
59
|
+
return None
|
|
60
|
+
blob = _git_blob(root, commit, LEDGER_REL)
|
|
61
|
+
if blob is None:
|
|
62
|
+
raise ValueError("could not read lifecycle ledger at %s" % commit)
|
|
63
|
+
return blob
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def source_ancestry(root, events, current_ref, base_ref=None):
|
|
67
|
+
"""Require retained source identities; select a merge that preserves them."""
|
|
68
|
+
errors = []
|
|
69
|
+
refs = {}
|
|
70
|
+
for label, ref in (("current", current_ref), ("base", base_ref)):
|
|
71
|
+
if ref is None and label == "base":
|
|
72
|
+
continue
|
|
73
|
+
if not isinstance(ref, str) or not ref:
|
|
74
|
+
errors.append("%s ref is not a resolvable commit: %s" % (label, ref))
|
|
75
|
+
continue
|
|
76
|
+
resolved = _git(root, "rev-parse", "--verify", "--end-of-options",
|
|
77
|
+
"%s^{commit}" % ref)
|
|
78
|
+
if resolved.returncode:
|
|
79
|
+
errors.append("%s ref is not a resolvable commit: %s" % (label, ref))
|
|
80
|
+
else:
|
|
81
|
+
refs[label] = resolved.stdout.decode("ascii").strip()
|
|
82
|
+
if errors:
|
|
83
|
+
return errors, None
|
|
84
|
+
method = "squash"
|
|
85
|
+
sources = set()
|
|
86
|
+
for event in events:
|
|
87
|
+
if not isinstance(event, dict):
|
|
88
|
+
continue
|
|
89
|
+
kind = event.get("event")
|
|
90
|
+
if kind in ("acceptance", "implemented", "verified"):
|
|
91
|
+
source = event.get("source_commit")
|
|
92
|
+
elif kind == "baseline":
|
|
93
|
+
source = event.get("observed_commit")
|
|
94
|
+
else:
|
|
95
|
+
continue
|
|
96
|
+
if not isinstance(source, str) or not al.HEX40.fullmatch(source):
|
|
97
|
+
errors.append("lifecycle source commit is malformed")
|
|
98
|
+
continue
|
|
99
|
+
sources.add(source)
|
|
100
|
+
for source in sorted(sources):
|
|
101
|
+
retained = _git(root, "merge-base", "--is-ancestor", source, refs["current"])
|
|
102
|
+
if retained.returncode == 1:
|
|
103
|
+
errors.append("%s: source commit is not an ancestor of current ref" % source)
|
|
104
|
+
elif retained.returncode:
|
|
105
|
+
errors.append("%s: could not verify source commit ancestry" % source)
|
|
106
|
+
if "base" in refs:
|
|
107
|
+
in_base = _git(root, "merge-base", "--is-ancestor", source, refs["base"])
|
|
108
|
+
if in_base.returncode == 1:
|
|
109
|
+
method = "merge"
|
|
110
|
+
elif in_base.returncode:
|
|
111
|
+
errors.append("%s: could not verify source commit base ancestry" % source)
|
|
112
|
+
return errors, None if errors else method
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def read_committed_evidence(root, current_ref):
|
|
117
|
+
"""Read only direct, canonical ADR paths from an exact Git snapshot."""
|
|
118
|
+
committed = _ledger_at(root, current_ref)
|
|
119
|
+
if committed is None:
|
|
120
|
+
raise ValueError("current ref has no lifecycle ledger")
|
|
121
|
+
events = [json.loads(line) for line in committed.decode("utf-8").splitlines()
|
|
122
|
+
if line.strip()]
|
|
123
|
+
tree = _git(root, "ls-tree", "-r", "--name-only", "-z",
|
|
124
|
+
current_ref, ".codearbiter/decisions/")
|
|
125
|
+
if tree.returncode:
|
|
126
|
+
raise ValueError("could not inspect current ADR tree")
|
|
127
|
+
blobs = {}
|
|
128
|
+
for path in tree.stdout.decode("utf-8").split("\0"):
|
|
129
|
+
prefix = ".codearbiter/decisions/"
|
|
130
|
+
if not path.startswith(prefix):
|
|
131
|
+
continue
|
|
132
|
+
name = path[len(prefix):]
|
|
133
|
+
if "/" in name:
|
|
134
|
+
continue
|
|
135
|
+
match = al.ADR_RE.fullmatch(name)
|
|
136
|
+
if match:
|
|
137
|
+
blob = _git_blob(root, current_ref, path)
|
|
138
|
+
if blob is None:
|
|
139
|
+
raise ValueError("could not read current ADR blob: %s" % path)
|
|
140
|
+
al.parse_adr(blob)
|
|
141
|
+
blobs[match.group(1)] = blob
|
|
142
|
+
return events, blobs, committed
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def validate_committed_sources(root, events):
|
|
146
|
+
"""Recompute existing source-blob and evidence digests from exact Git bytes."""
|
|
147
|
+
source_blobs = {}
|
|
148
|
+
source_inputs = {}
|
|
149
|
+
for event in events:
|
|
150
|
+
if not isinstance(event, dict):
|
|
151
|
+
continue
|
|
152
|
+
kind = event.get("event")
|
|
153
|
+
if kind in ("acceptance", "baseline"):
|
|
154
|
+
commit = event.get("source_commit" if kind == "acceptance" else "observed_commit")
|
|
155
|
+
adr = event.get("adr")
|
|
156
|
+
if isinstance(commit, str) and isinstance(adr, str):
|
|
157
|
+
source_blobs[(commit, adr)] = _git_blob(
|
|
158
|
+
root, commit, ".codearbiter/decisions/%s.md" % adr)
|
|
159
|
+
elif kind in ("implemented", "verified"):
|
|
160
|
+
commit = event.get("source_commit")
|
|
161
|
+
digests = event.get("input_digests")
|
|
162
|
+
if isinstance(digests, dict) and isinstance(commit, str):
|
|
163
|
+
for path in digests:
|
|
164
|
+
if isinstance(path, str):
|
|
165
|
+
source_inputs[(commit, path)] = _git_blob(root, commit, path)
|
|
166
|
+
return (al.validate_source_blobs(events, source_blobs) +
|
|
167
|
+
al.validate_evidence_sources(events, source_inputs))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def merge_method(root, base_ref, current_ref):
|
|
171
|
+
"""Strict portable preflight; inherited baselines are not migration authority."""
|
|
172
|
+
errors = []
|
|
173
|
+
refs = []
|
|
174
|
+
for label, ref in (("base", base_ref), ("current", current_ref)):
|
|
175
|
+
if not isinstance(ref, str) or not ref:
|
|
176
|
+
return ["%s ref is not a resolvable commit" % label], None
|
|
177
|
+
result = _git(root, "rev-parse", "--verify", "--end-of-options", ref + "^{commit}")
|
|
178
|
+
if result.returncode:
|
|
179
|
+
return ["%s ref is not a resolvable commit: %s" % (label, ref)], None
|
|
180
|
+
refs.append(result.stdout.decode("ascii").strip())
|
|
181
|
+
base, head = refs
|
|
182
|
+
try:
|
|
183
|
+
events, blobs, committed = read_committed_evidence(root, head)
|
|
184
|
+
base_bytes = _ledger_at(root, base)
|
|
185
|
+
base_events = [json.loads(line) for line in (base_bytes or b"").decode("utf-8").splitlines()
|
|
186
|
+
if line.strip()]
|
|
187
|
+
errors.extend(al.validate_events(events, blobs))
|
|
188
|
+
bindings = {event.get("adr") for event in events if isinstance(event, dict)
|
|
189
|
+
and event.get("event") in ("acceptance", "baseline")
|
|
190
|
+
and isinstance(event.get("adr"), str)}
|
|
191
|
+
errors.extend("%s: accepted ADR has no lifecycle binding" % adr
|
|
192
|
+
for adr, blob in sorted(blobs.items())
|
|
193
|
+
if al.parse_adr(blob)["status"] == "accepted" and adr not in bindings)
|
|
194
|
+
if base_bytes is not None:
|
|
195
|
+
prefix_error = al.append_only_error(base_bytes, committed)
|
|
196
|
+
if prefix_error:
|
|
197
|
+
errors.append(prefix_error)
|
|
198
|
+
# A consumer cannot import this repository's closed migration epoch.
|
|
199
|
+
# Only exact already-committed base events may carry baseline records.
|
|
200
|
+
for event in events:
|
|
201
|
+
if (isinstance(event, dict) and event.get("event") == "baseline"
|
|
202
|
+
and event not in base_events):
|
|
203
|
+
errors.append("%s: new legacy baseline is not permitted by merge preflight"
|
|
204
|
+
% event.get("adr"))
|
|
205
|
+
errors.extend(validate_committed_sources(root, events))
|
|
206
|
+
ancestry_errors, method = source_ancestry(root, events, head, base)
|
|
207
|
+
errors.extend(ancestry_errors)
|
|
208
|
+
except (OSError, RuntimeError, ValueError, UnicodeError) as exc:
|
|
209
|
+
return ["could not validate committed lifecycle evidence: %s" % exc], None
|
|
210
|
+
return errors, None if errors else method
|
|
@@ -449,6 +449,16 @@ GATE_MARKER_WRITE_RE = re.compile(
|
|
|
449
449
|
_INTERP_TOKENS = (r"python3?|python2|py|node|deno|bun|perl|ruby|php"
|
|
450
450
|
r"|sh|bash|zsh|pwsh|powershell")
|
|
451
451
|
|
|
452
|
+
# A word boundary also matches the `py` in `catalog.py`: that is a filename,
|
|
453
|
+
# not an interpreter. Share the corrected left edge across every interpreter
|
|
454
|
+
# leg so read-only filename arguments cannot falsely imply a protected write.
|
|
455
|
+
# Preserve slash/backslash and quote boundaries for executable paths, and the
|
|
456
|
+
# existing right edge for versioned executables and Windows `.exe` spellings.
|
|
457
|
+
# Consume leading dots at that boundary: `./.python` is an executable basename,
|
|
458
|
+
# unlike the interpreter-looking extension in `catalog.python`.
|
|
459
|
+
# This remains conservative lexical detection, not command-position parsing.
|
|
460
|
+
_INTERP_EXECUTABLE = r"(?<![\w.])\.*(" + _INTERP_TOKENS + r")\b"
|
|
461
|
+
|
|
452
462
|
# The inline-code switch that makes an interpreter EXECUTE A STRING rather
|
|
453
463
|
# than run a file. `-c` (python/py/sh/bash/zsh/pwsh), `-e`/`-E` (perl,
|
|
454
464
|
# ruby, node, bun), `-r` (php), `-p`/`--print`/`--eval` (node), and
|
|
@@ -476,7 +486,7 @@ _INTERP_INLINE_CODE = (
|
|
|
476
486
|
# whereas handing a board filename to `taskwrite.py` is the sanctioned
|
|
477
487
|
# call.
|
|
478
488
|
GATE_MARKER_INTERP_RE = re.compile(
|
|
479
|
-
|
|
489
|
+
_INTERP_EXECUTABLE + r"[\s\S]*" + GATE_MARKER, re.I,
|
|
480
490
|
)
|
|
481
491
|
|
|
482
492
|
# #574: H-05/H-11/H-18 carried NO interpreter leg at all — an inline-code
|
|
@@ -496,13 +506,13 @@ GATE_MARKER_INTERP_RE = re.compile(
|
|
|
496
506
|
# target name may sit on different physical lines of the SAME multi-line
|
|
497
507
|
# `-c`/`-e` payload.
|
|
498
508
|
LOG_INTERP_RE = re.compile(
|
|
499
|
-
|
|
509
|
+
_INTERP_EXECUTABLE + r"[\s\S]*" + LOG_NAMES, re.I,
|
|
500
510
|
)
|
|
501
511
|
DECISIONS_INTERP_RE = re.compile(
|
|
502
|
-
|
|
512
|
+
_INTERP_EXECUTABLE + r"[\s\S]*" + DECISIONS, re.I,
|
|
503
513
|
)
|
|
504
514
|
CONTEXT_INTERP_RE = re.compile(
|
|
505
|
-
|
|
515
|
+
_INTERP_EXECUTABLE + r"[\s\S]*" + CONTEXT_MD, re.I,
|
|
506
516
|
)
|
|
507
517
|
|
|
508
518
|
# H-22's shell flank: the protected-state registry (B1/#564) — Write/Edit are
|
|
@@ -731,7 +741,7 @@ def _state_write_res(basename, rel_path=None):
|
|
|
731
741
|
+ _STATE_NAME_RIGHT_EDGE, re.I,
|
|
732
742
|
)
|
|
733
743
|
interp_re = re.compile(
|
|
734
|
-
|
|
744
|
+
_INTERP_EXECUTABLE + r"[^\n]*?" + _INTERP_INLINE_CODE
|
|
735
745
|
+ r"[\s\S]*" + name + _STATE_NAME_RIGHT_EDGE, re.I,
|
|
736
746
|
)
|
|
737
747
|
return redirect_re, write_re, git_restore_re, interp_re
|
|
@@ -11,7 +11,7 @@ import hostapi # noqa: E402
|
|
|
11
11
|
class PiHost(hostapi.Host):
|
|
12
12
|
name = "pi"
|
|
13
13
|
adapter_name = "@arbiterforge/ca-pi"
|
|
14
|
-
adapter_version = "0.10.
|
|
14
|
+
adapter_version = "0.10.4"
|
|
15
15
|
update_target = "ca-pi"
|
|
16
16
|
update_tag_prefix = "ca-pi-v"
|
|
17
17
|
update_command = "pi update npm:@arbiterforge/ca-pi"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""codeArbiter: read-only lifecycle merge selector for an installed plugin.
|
|
3
|
+
|
|
4
|
+
CLI: --root REPO --base-ref COMMIT --current-ref COMMIT --merge-method
|
|
5
|
+
Success emits exactly merge or squash; every unavailable proof fails closed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from _adrlifecyclegit import GitPrerequisiteError, merge_method
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main(argv=None):
|
|
15
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
16
|
+
parser.add_argument("--root", required=True)
|
|
17
|
+
parser.add_argument("--base-ref", required=True)
|
|
18
|
+
parser.add_argument("--current-ref", required=True)
|
|
19
|
+
parser.add_argument("--merge-method", required=True, action="store_true")
|
|
20
|
+
args = parser.parse_args(argv)
|
|
21
|
+
try:
|
|
22
|
+
errors, method = merge_method(args.root, args.base_ref, args.current_ref)
|
|
23
|
+
except (GitPrerequisiteError, OSError, RuntimeError, ValueError) as exc:
|
|
24
|
+
errors, method = ["could not verify lifecycle refs: %s" % exc], None
|
|
25
|
+
if errors:
|
|
26
|
+
for error in errors:
|
|
27
|
+
print("::error::" + error, file=sys.stderr)
|
|
28
|
+
return 1
|
|
29
|
+
print(method)
|
|
30
|
+
return 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
sys.exit(main())
|
|
@@ -313,7 +313,7 @@ class Host:
|
|
|
313
313
|
|
|
314
314
|
name = "claude"
|
|
315
315
|
adapter_name = "ca"
|
|
316
|
-
adapter_version = "2.17.
|
|
316
|
+
adapter_version = "2.17.3"
|
|
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,
|
|
@@ -52,6 +52,13 @@ The irreversible-action set draws a confirmation even when intent is obvious, be
|
|
|
52
52
|
confirmation is the gate, not friction: merge to the default branch, branch or worktree
|
|
53
53
|
deletion, release and tag publication, and the logged bypass itself (`/ca-override`).
|
|
54
54
|
|
|
55
|
+
**Merge-to-default hard gate: ADR source ancestry.** Before composing any merge offer (including
|
|
56
|
+
`/ca-watch`) or performing an authorized merge, load Phase 1 of
|
|
57
|
+
`<plugin-root>/routines/finishing-a-development-branch/SKILL.md` and apply its `--merge-method`
|
|
58
|
+
preflight to the exact fetched base and PR head. Preserve a required true merge and use
|
|
59
|
+
`--match-head-commit`; green CI cannot substitute for source ancestry. Missing evidence blocks the
|
|
60
|
+
offer. This check never authorizes the merge itself.
|
|
61
|
+
|
|
55
62
|
A parameter is yours to decide only when it is reversible, has one sensible answer, and is
|
|
56
63
|
recorded where the user will review it — an uncertain classification is a fork, and forks are
|
|
57
64
|
asked.
|
|
@@ -81,6 +81,11 @@ that any obligation is Implemented or Verified. When the user explicitly authori
|
|
|
81
81
|
`recorded_at`, `source_commit`, `blob_sha256`, `body_sha256`, `obligations`,
|
|
82
82
|
`obligations_sha256`, and `obligations_sealed: true`. A second acceptance or baseline binding for
|
|
83
83
|
the same stem is invalid.
|
|
84
|
+
4. Preserve **ADR source ancestry** through delivery. Before opening the PR and again before
|
|
85
|
+
its merge offer, follow the finishing skill's `--merge-method` preflight on the exact base/head.
|
|
86
|
+
A source not already in base ancestry requires a true merge commit with `--match-head-commit`;
|
|
87
|
+
squash or rebase would orphan its identity. Missing source ancestry blocks delivery. Never
|
|
88
|
+
rewrite the acceptance binding or rely on deleted branch objects remaining remotely fetchable.
|
|
84
89
|
|
|
85
90
|
The lifecycle ledger is append-only. A legacy accepted ADR receives a `baseline` with no fabricated
|
|
86
91
|
acceptance commit, an `observed_commit` whose Git blob is rechecked as the migration snapshot, an
|
|
@@ -26,6 +26,27 @@ Assemble the facts the decision needs. Nothing is presented until all are in han
|
|
|
26
26
|
- **Diff summary** — files changed, insertions/deletions, and the commit list since the base. Read it, do not paraphrase from memory.
|
|
27
27
|
- **Gate results** — `commit-gate` outcome and the `last-checkpoint` record. Surface any open `[NEEDS-TRIAGE]` markers left in the diff as out-of-scope findings.
|
|
28
28
|
- **Plan delta** — when a plan exists, state which plan items the branch satisfied and which remain open. Open items are surfaced, not hidden.
|
|
29
|
+
- **ADR source ancestry** — when `.codearbiter/decisions/adr-lifecycle.jsonl` exists, select
|
|
30
|
+
the merge method from the exact fetched target commit and PR head commit. Run the installed verifier:
|
|
31
|
+
`python "<plugin-root>/hooks/adr-merge-method.py" --root "<project-root>" --base-ref <base-sha> --current-ref <head-sha> --merge-method`.
|
|
32
|
+
It validates committed lifecycle evidence and prints `merge` if any bound source is absent from
|
|
33
|
+
the base ancestry, otherwise `squash`. Every acceptance/evidence `source_commit` and baseline
|
|
34
|
+
`observed_commit` must resolve and be an ancestor of the head; each is checked against the base too.
|
|
35
|
+
The self-contained verifier reads committed ADR paths and source bytes, checks their digests and
|
|
36
|
+
status, and enforces the exact ledger prefix. It admits no baseline newly introduced after the base;
|
|
37
|
+
an inherited baseline is not authorization for another migration. No repository-local verifier,
|
|
38
|
+
dirty working-tree bytes, or network fallback can substitute for this proof.
|
|
39
|
+
ADR lifecycle proof requires Git 2.45.0+ with `--no-lazy-fetch`. The verifier enforces that flag
|
|
40
|
+
capability and reports an upgrade prerequisite when unavailable. Missing objects block verification;
|
|
41
|
+
neither failure triggers a retry that fetches proof implicitly.
|
|
42
|
+
An unavailable verifier, malformed record, missing object, or source outside head ancestry blocks
|
|
43
|
+
the offer. Never infer retention from local object availability or remote branch/PR refs.
|
|
44
|
+
A source absent from base requires a true merge commit; squash and rebase would lose its identity.
|
|
45
|
+
Record the exact base, head, and selected method in the PR body. If merge commits are unavailable,
|
|
46
|
+
STOP and surface the conflict; do not change repository settings or rewrite the ledger.
|
|
47
|
+
Revalidate immediately before a merge offer or authorized merge, and use
|
|
48
|
+
`gh pr merge <PR> --merge --match-head-commit <head-sha>` when `merge` is required.
|
|
49
|
+
If all sources are already in base ancestry, retain the project's usual merge convention.
|
|
29
50
|
|
|
30
51
|
Gate: branch confirmed non-default, diff summary read, gate results and plan delta in hand.
|
|
31
52
|
|
|
@@ -49,7 +70,7 @@ Gate: a single terminal option is chosen — by the user under `/feature`, or au
|
|
|
49
70
|
Carry out the chosen option, and only that one:
|
|
50
71
|
|
|
51
72
|
- **Open a PR** — push the branch and open the PR against the default branch. The reviewer path-matrix, the anti-slop PR-body composition (description citing the plan items satisfied, the gate results, the §2 conflict level of any non-obvious tradeoff), and the babysitter attach are the steps documented in the `/ca-pr` command flow (`<plugin-root>/skills/ca-pr/SKILL.md`) — **execute those steps here; do not re-invoke `/ca-pr`** (under `/sprint` this skill is reached via `commit-gate`, without the `/pr` command ever running, so a route back would loop). Leave the PR open; the merge is not yours to take.
|
|
52
|
-
- **Merge via PR** — open the PR as above, confirm its checks are green, then merge it through the PR
|
|
73
|
+
- **Merge via PR** — open the PR as above, confirm its checks are green, revalidate Phase 1's ADR source ancestry and exact base/head, then merge it through the PR with the selected method and `--match-head-commit`. Never push to the default branch directly, never force-push.
|
|
53
74
|
- **Discard** — requires explicit user confirmation naming the branch. Before discarding, verify the branch is fully pushed; if any commit is un-pushed, STOP and report exactly what would be lost — never delete un-pushed work silently. Discard proceeds only after the user confirms with that loss in view.
|
|
54
75
|
|
|
55
76
|
Gate: the chosen option completed — for open-PR a PR exists against the default branch; for merge the work landed through that PR; for discard the user confirmed against a stated loss summary.
|