@arbiterforge/ca-pi 0.10.13 → 0.14.7
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 +1 -1
- package/package.json +2 -1
- package/plugins/ca-pi/CHANGELOG.md +167 -0
- package/plugins/ca-pi/SKILLS.md +1 -1
- package/plugins/ca-pi/SPRINT.md +40 -10
- package/plugins/ca-pi/agents/backend-author.md +1 -1
- package/plugins/ca-pi/agents/frontend-author.md +1 -1
- package/plugins/ca-pi/generated/command-catalog.json +1 -1
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-amd64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-arm64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-amd64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-arm64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-amd64.exe +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-arm64.exe +0 -0
- package/plugins/ca-pi/helpers/artifacts/release.json +38 -0
- package/plugins/ca-pi/hooks/_approvallib.py +355 -0
- package/plugins/ca-pi/hooks/_artifactlib.py +855 -0
- package/plugins/ca-pi/hooks/_cleanuplib.py +1553 -0
- package/plugins/ca-pi/hooks/_hooklib.py +37 -13
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/_intentlib.py +31 -0
- package/plugins/ca-pi/hooks/_planfilelib.py +34 -1
- package/plugins/ca-pi/hooks/_prerequisitelib.py +971 -0
- package/plugins/ca-pi/hooks/_protectedstatelib.py +11 -1
- package/plugins/ca-pi/hooks/_readinjectlib.py +120 -0
- package/plugins/ca-pi/hooks/_releaselib.py +933 -85
- package/plugins/ca-pi/hooks/_standuplib.py +38 -10
- package/plugins/ca-pi/hooks/doctor.py +103 -0
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- package/plugins/ca-pi/hooks/pi-bridge.py +5 -1
- package/plugins/ca-pi/hooks/prompt-submit.py +47 -10
- package/plugins/ca-pi/hooks/releasehash.py +46 -32
- package/plugins/ca-pi/includes/artifacts.md +210 -0
- package/plugins/ca-pi/includes/author-tdd-workflow.md +2 -1
- package/plugins/ca-pi/includes/farm.md +27 -4
- package/plugins/ca-pi/includes/routing-table.md +2 -2
- package/plugins/ca-pi/includes/verification-boundary.md +27 -0
- package/plugins/ca-pi/package.json +18 -0
- package/plugins/ca-pi/routines/INDEX.md +4 -4
- package/plugins/ca-pi/routines/brainstorming/SKILL.md +23 -4
- package/plugins/ca-pi/routines/commit-gate/SKILL.md +30 -5
- package/plugins/ca-pi/routines/executing-plans/SKILL.md +52 -7
- package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +25 -3
- package/plugins/ca-pi/routines/refactor/SKILL.md +3 -2
- package/plugins/ca-pi/routines/release/SKILL.md +150 -44
- package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +53 -9
- package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +20 -3
- package/plugins/ca-pi/routines/tdd/SKILL.md +27 -5
- package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +11 -2
- package/plugins/ca-pi/routines/writing-plans/SKILL.md +39 -10
- package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +34 -13
- package/plugins/ca-pi/skills/ca-chore/SKILL.md +7 -3
- package/plugins/ca-pi/skills/ca-commit/SKILL.md +3 -2
- package/plugins/ca-pi/skills/ca-doctor/SKILL.md +14 -0
- package/plugins/ca-pi/skills/ca-feature/SKILL.md +42 -8
- package/plugins/ca-pi/skills/ca-refactor/SKILL.md +4 -2
- package/plugins/ca-pi/skills/ca-release/SKILL.md +7 -6
- package/plugins/ca-pi/skills/ca-standup/SKILL.md +36 -8
|
@@ -116,12 +116,17 @@ def _strip_trailing_sep(path):
|
|
|
116
116
|
def parse_worktrees(porcelain_text, repo_root):
|
|
117
117
|
"""Parse `git worktree list --porcelain` output.
|
|
118
118
|
|
|
119
|
-
Returns a list of {"path": str, "branch": str|None, "is_main": bool
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
119
|
+
Returns a list of {"path": str, "branch": str|None, "is_main": bool,
|
|
120
|
+
"head": str|None, "detached": bool, "locked": bool, "locked_reason": str|None}.
|
|
121
|
+
Records are separated by blank lines; each starts with a `worktree <path>`
|
|
122
|
+
line and may carry a `HEAD <oid>` line, a `branch refs/heads/<name>` line
|
|
123
|
+
(absent / `detached` => branch stays None), and a `locked` line (bare
|
|
124
|
+
`locked` or `locked <reason>`). The main worktree is the record whose path
|
|
125
|
+
equals `repo_root` (trailing separator tolerated). Parses faithfully;
|
|
126
|
+
merge-proof classification is a later task's job — this only surfaces what
|
|
127
|
+
Git itself reports, including the occupancy/protection state (locked,
|
|
128
|
+
detached, exact OID) that a caller needs to exclude a worktree honestly
|
|
129
|
+
rather than silently dropping it.
|
|
125
130
|
"""
|
|
126
131
|
root = _strip_trailing_sep(repo_root or "")
|
|
127
132
|
out = []
|
|
@@ -137,11 +142,25 @@ def parse_worktrees(porcelain_text, repo_root):
|
|
|
137
142
|
if cur is not None:
|
|
138
143
|
out.append(cur)
|
|
139
144
|
path = line[len("worktree "):].strip()
|
|
140
|
-
cur = {"path": path, "branch": None, "is_main": path == root
|
|
141
|
-
|
|
145
|
+
cur = {"path": path, "branch": None, "is_main": path == root,
|
|
146
|
+
"head": None, "detached": False, "locked": False,
|
|
147
|
+
"locked_reason": None}
|
|
148
|
+
continue
|
|
149
|
+
if cur is None:
|
|
150
|
+
continue
|
|
151
|
+
if line.startswith("HEAD "):
|
|
152
|
+
cur["head"] = line[len("HEAD "):].strip() or None
|
|
153
|
+
elif line == "detached":
|
|
154
|
+
cur["detached"] = True
|
|
155
|
+
elif line.startswith("branch "):
|
|
142
156
|
ref = line[len("branch "):].strip()
|
|
143
157
|
cur["branch"] = ref[len("refs/heads/"):] if ref.startswith("refs/heads/") else ref
|
|
144
|
-
|
|
158
|
+
elif line == "locked":
|
|
159
|
+
cur["locked"] = True
|
|
160
|
+
elif line.startswith("locked "):
|
|
161
|
+
cur["locked"] = True
|
|
162
|
+
cur["locked_reason"] = line[len("locked "):].strip() or None
|
|
163
|
+
# bare / other lines carry no fields we surface here.
|
|
145
164
|
if cur is not None:
|
|
146
165
|
out.append(cur)
|
|
147
166
|
return out
|
|
@@ -175,12 +194,21 @@ def stale_worktree_candidates(worktrees, gone_or_merged_branches, path_exists=os
|
|
|
175
194
|
tests (pass a fake predicate); production defaults to os.path.exists. This is a
|
|
176
195
|
PURE classifier: it only IDENTIFIES candidates and removes/mutates nothing —
|
|
177
196
|
the /ca:standup command removes a worktree only on explicit per-item user
|
|
178
|
-
confirmation, which is why the broad (gone OR missing) candidate rule is safe.
|
|
197
|
+
confirmation, which is why the broad (gone OR missing) candidate rule is safe.
|
|
198
|
+
|
|
199
|
+
A LOCKED worktree (per `parse_worktrees`'s `locked` field) is NEVER a
|
|
200
|
+
candidate, regardless of its branch or path state — a lock is an explicit
|
|
201
|
+
Git-level protection signal (and `git worktree remove` itself refuses a
|
|
202
|
+
locked worktree), so surfacing one as a hygiene candidate would misrepresent
|
|
203
|
+
it as ordinary stale output rather than something the user deliberately
|
|
204
|
+
protected."""
|
|
179
205
|
gone = gone_or_merged_branches or set()
|
|
180
206
|
out = []
|
|
181
207
|
for wt in worktrees or []:
|
|
182
208
|
if wt.get("is_main"):
|
|
183
209
|
continue
|
|
210
|
+
if wt.get("locked"):
|
|
211
|
+
continue
|
|
184
212
|
branch = wt.get("branch")
|
|
185
213
|
path = wt.get("path")
|
|
186
214
|
is_stale = (branch in gone) or (not path_exists(path))
|
|
@@ -42,6 +42,14 @@ MCP_SCAN_MAX_DEPTH = 6
|
|
|
42
42
|
results = [] # (level, line)
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
class ArtifactDiagnosticError(RuntimeError):
|
|
46
|
+
"""Bounded testable wrapper for an installation or engine diagnostic."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, code):
|
|
49
|
+
self.code = str(code)[:80]
|
|
50
|
+
super().__init__(self.code)
|
|
51
|
+
|
|
52
|
+
|
|
45
53
|
def ok(line):
|
|
46
54
|
results.append(("OK", line))
|
|
47
55
|
|
|
@@ -170,6 +178,100 @@ def check_payload(root, host=None):
|
|
|
170
178
|
pass
|
|
171
179
|
|
|
172
180
|
|
|
181
|
+
def _artifact_client(plugin_root_path, repository_root):
|
|
182
|
+
"""Construct the installation-pinned client without PATH or repo fallback."""
|
|
183
|
+
try:
|
|
184
|
+
from _artifactlib import ArtifactClient, helper_installation
|
|
185
|
+
return ArtifactClient(
|
|
186
|
+
repository_root or plugin_root_path,
|
|
187
|
+
helper_installation(__file__),
|
|
188
|
+
)
|
|
189
|
+
except Exception as error: # noqa: BLE001 - doctor reports, never crashes
|
|
190
|
+
code = getattr(error, "code", "CAPABILITY_MISSING")
|
|
191
|
+
raise ArtifactDiagnosticError(code) from error
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _artifact_contract():
|
|
195
|
+
"""Read adapter-owned protocol constants without loading any schema."""
|
|
196
|
+
from _artifactlib import PROTOCOL, SCHEMA_VERSION
|
|
197
|
+
return PROTOCOL, SCHEMA_VERSION
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _repository_has_html(root):
|
|
201
|
+
if not root:
|
|
202
|
+
return False
|
|
203
|
+
try:
|
|
204
|
+
for directory in ("specs", "plans"):
|
|
205
|
+
base = os.path.join(root, ".codearbiter", directory)
|
|
206
|
+
if os.path.isdir(base) and any(
|
|
207
|
+
name.endswith(".html") for name in os.listdir(base)
|
|
208
|
+
):
|
|
209
|
+
return True
|
|
210
|
+
except OSError:
|
|
211
|
+
return True # force the engine to produce the bounded path diagnostic
|
|
212
|
+
return False
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def check_artifact_capability(plugin_root_path, repository_root=None):
|
|
216
|
+
"""Probe only capabilities, then validate existing HTML only when present.
|
|
217
|
+
|
|
218
|
+
This is an explicit doctor invocation, not startup loading. It never requests
|
|
219
|
+
the schema operation and never falls back to PATH, repository binaries, or
|
|
220
|
+
Markdown when an HTML authority exists.
|
|
221
|
+
"""
|
|
222
|
+
try:
|
|
223
|
+
client = _artifact_client(plugin_root_path, repository_root)
|
|
224
|
+
capabilities = client.call("capabilities")
|
|
225
|
+
protocol, schema_version = _artifact_contract()
|
|
226
|
+
except Exception as error: # noqa: BLE001 - bounded unhealthy result
|
|
227
|
+
code = str(getattr(error, "code", "CAPABILITY_MISSING"))[:80]
|
|
228
|
+
fail(
|
|
229
|
+
f"artifact capability unavailable ({code}) — repair or reinstall the "
|
|
230
|
+
"pinned artifact payload; new/existing HTML work is blocked, while "
|
|
231
|
+
"unrelated legacy Markdown remains usable"
|
|
232
|
+
)
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
required = {
|
|
236
|
+
"binary": "ca-artifact",
|
|
237
|
+
"protocol": protocol,
|
|
238
|
+
"schema_version": schema_version,
|
|
239
|
+
}
|
|
240
|
+
if (not isinstance(capabilities, dict)
|
|
241
|
+
or any(capabilities.get(key) != value for key, value in required.items())
|
|
242
|
+
or not isinstance(capabilities.get("platform"), str)):
|
|
243
|
+
fail(
|
|
244
|
+
"artifact capability unavailable (INVALID_RESPONSE) — repair or reinstall "
|
|
245
|
+
"the pinned artifact payload; new/existing HTML work is blocked, while "
|
|
246
|
+
"unrelated legacy Markdown remains usable"
|
|
247
|
+
)
|
|
248
|
+
return
|
|
249
|
+
if capabilities.get("repository_operations_available") is not True:
|
|
250
|
+
fail(
|
|
251
|
+
"artifact capability unavailable (UNSUPPORTED_PLATFORM) — repair or reinstall "
|
|
252
|
+
"a native-qualified pinned artifact payload; new/existing HTML work is blocked, "
|
|
253
|
+
"while unrelated legacy Markdown remains usable"
|
|
254
|
+
)
|
|
255
|
+
return
|
|
256
|
+
ok(
|
|
257
|
+
"artifact capability available: "
|
|
258
|
+
f"{capabilities['platform']}, schema {capabilities['schema_version']}"
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
if not _repository_has_html(repository_root):
|
|
262
|
+
return
|
|
263
|
+
try:
|
|
264
|
+
count = sum(1 for _ in client.index())
|
|
265
|
+
except Exception as error: # noqa: BLE001 - bounded invalid-artifact result
|
|
266
|
+
code = str(getattr(error, "code", "INVALID_ARTIFACT"))[:80]
|
|
267
|
+
fail(
|
|
268
|
+
f"HTML artifact validation failed ({code}) — inspect the engine diagnostic "
|
|
269
|
+
"and run repair-preview before an explicit reviewed repair; HTML work is blocked"
|
|
270
|
+
)
|
|
271
|
+
return
|
|
272
|
+
ok(f"HTML artifact catalog validates: {count} artifact{'s' if count != 1 else ''}")
|
|
273
|
+
|
|
274
|
+
|
|
173
275
|
def check_repo():
|
|
174
276
|
"""Returns the resolved repo root (for check_git_hook_freshness below), or
|
|
175
277
|
None when this process isn't inside a git repository at all."""
|
|
@@ -423,6 +525,7 @@ def main():
|
|
|
423
525
|
check_interpreters()
|
|
424
526
|
check_payload(root, host)
|
|
425
527
|
repo_root = check_repo()
|
|
528
|
+
check_artifact_capability(root, repo_root)
|
|
426
529
|
check_git_hook_freshness(repo_root)
|
|
427
530
|
check_mcp(host)
|
|
428
531
|
if getattr(host, "has_statusline", True):
|
|
@@ -313,7 +313,7 @@ class Host:
|
|
|
313
313
|
|
|
314
314
|
name = "claude"
|
|
315
315
|
adapter_name = "ca"
|
|
316
|
-
adapter_version = "2.
|
|
316
|
+
adapter_version = "2.21.7"
|
|
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,
|
|
@@ -476,7 +476,11 @@ def _plan_file_response(result):
|
|
|
476
476
|
|
|
477
477
|
def dispatch(request):
|
|
478
478
|
if request["event"] == "plan_file":
|
|
479
|
-
result = _planfilelib.plan_file_operation(
|
|
479
|
+
result = _planfilelib.plan_file_operation(
|
|
480
|
+
request["cwd"],
|
|
481
|
+
_plan_file_request(request["input"]),
|
|
482
|
+
require_legacy_pair=True,
|
|
483
|
+
)
|
|
480
484
|
return {
|
|
481
485
|
"version": 1,
|
|
482
486
|
"outcome": "notice",
|
|
@@ -64,8 +64,10 @@ import sys
|
|
|
64
64
|
|
|
65
65
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
66
66
|
import hostapi # noqa: E402 — host seam (ADR-0011)
|
|
67
|
+
import _approvallib # noqa: E402 — host-owned artifact approval capture
|
|
67
68
|
import _hooklib # noqa: E402
|
|
68
69
|
import _modelib # noqa: E402 — mode plane core (T-06..T-16, Lane A)
|
|
70
|
+
import _prerequisitelib # noqa: E402 — host-owned prerequisite capture
|
|
69
71
|
import _readinjectlib # noqa: E402 — marker_path(prefix=) (T-30)
|
|
70
72
|
|
|
71
73
|
|
|
@@ -366,38 +368,47 @@ def _codex_allow_envelope(additional_context):
|
|
|
366
368
|
# Injection (job 2), host-neutral core + per-host output
|
|
367
369
|
# ---------------------------------------------------------------------------
|
|
368
370
|
|
|
369
|
-
def _inject_claude(payload, host, root, session_id):
|
|
371
|
+
def _inject_claude(payload, host, root, session_id, approval_context=""):
|
|
370
372
|
mode, diag = _effective_mode(root, session_id, payload)
|
|
371
373
|
if diag:
|
|
372
374
|
sys.stderr.write("codeArbiter: " + diag + "\n")
|
|
373
375
|
generation = _read_compaction_generation(root, session_id)
|
|
374
376
|
if _already_injected(root, session_id, mode, generation):
|
|
377
|
+
if approval_context:
|
|
378
|
+
print(approval_context)
|
|
375
379
|
return
|
|
376
380
|
safety, body = _persona_for_mode(host.plugin_root(), mode)
|
|
377
381
|
if _persona_unavailable(safety, body):
|
|
378
382
|
sys.stderr.write("codeArbiter: persona sources incomplete for mode '{}' — "
|
|
379
383
|
"injecting nothing this turn rather than a body without its "
|
|
380
384
|
"safety floor.\n".format(mode))
|
|
385
|
+
if approval_context:
|
|
386
|
+
print(approval_context)
|
|
381
387
|
return
|
|
382
388
|
composed, _truncated = _compose_persona(safety, body)
|
|
383
|
-
print(composed)
|
|
389
|
+
print("\n\n".join(value for value in (approval_context, composed) if value))
|
|
384
390
|
_record_injected(root, session_id, mode, generation)
|
|
385
391
|
|
|
386
392
|
|
|
387
|
-
def _inject_codex(payload, host, root, session_id):
|
|
393
|
+
def _inject_codex(payload, host, root, session_id, approval_context=""):
|
|
388
394
|
mode, diag = _effective_mode(root, session_id, payload)
|
|
389
395
|
generation = _read_compaction_generation(root, session_id)
|
|
390
396
|
if _already_injected(root, session_id, mode, generation):
|
|
397
|
+
if approval_context:
|
|
398
|
+
print(json.dumps(_codex_allow_envelope(approval_context)))
|
|
391
399
|
return
|
|
392
400
|
safety, body = _persona_for_mode(host.plugin_root(), mode)
|
|
393
401
|
if _persona_unavailable(safety, body):
|
|
394
402
|
sys.stderr.write("codeArbiter: persona sources incomplete for mode '{}' — "
|
|
395
403
|
"injecting nothing this turn rather than a body without its "
|
|
396
404
|
"safety floor.\n".format(mode))
|
|
405
|
+
if approval_context:
|
|
406
|
+
print(json.dumps(_codex_allow_envelope(approval_context)))
|
|
397
407
|
return
|
|
398
408
|
composed, _truncated = _compose_persona(
|
|
399
409
|
safety, body, limit_tokens=CODEX_ADDITIONAL_CONTEXT_LIMIT)
|
|
400
|
-
|
|
410
|
+
context = "\n\n".join(value for value in (approval_context, composed) if value)
|
|
411
|
+
print(json.dumps(_codex_allow_envelope(context)))
|
|
401
412
|
_record_injected(root, session_id, mode, generation)
|
|
402
413
|
|
|
403
414
|
|
|
@@ -405,7 +416,7 @@ def _inject_codex(payload, host, root, session_id):
|
|
|
405
416
|
# UserPromptSubmit dispatch (jobs 1 + 2)
|
|
406
417
|
# ---------------------------------------------------------------------------
|
|
407
418
|
|
|
408
|
-
def _handle_claude(payload, host, root, session_id, token):
|
|
419
|
+
def _handle_claude(payload, host, root, session_id, token, approval_context=""):
|
|
409
420
|
if token == _modelib.MODE_TOKEN_REPORT:
|
|
410
421
|
mode, diag = _modelib.current_mode(session_id, root=root, payload=payload)
|
|
411
422
|
sys.stderr.write(_report_stderr_line(mode, diag) + "\n")
|
|
@@ -415,11 +426,11 @@ def _handle_claude(payload, host, root, session_id, token):
|
|
|
415
426
|
host_name=host.name)
|
|
416
427
|
sys.stderr.write(_flip_stderr_line(token, result) + "\n")
|
|
417
428
|
return 2
|
|
418
|
-
_inject_claude(payload, host, root, session_id)
|
|
429
|
+
_inject_claude(payload, host, root, session_id, approval_context)
|
|
419
430
|
return 0
|
|
420
431
|
|
|
421
432
|
|
|
422
|
-
def _handle_codex(payload, host, root, session_id, token):
|
|
433
|
+
def _handle_codex(payload, host, root, session_id, token, approval_context=""):
|
|
423
434
|
if token == _modelib.MODE_TOKEN_REPORT:
|
|
424
435
|
mode, diag = _modelib.current_mode(session_id, root=root, payload=payload)
|
|
425
436
|
msg = _report_stderr_line(mode, diag)
|
|
@@ -431,7 +442,7 @@ def _handle_codex(payload, host, root, session_id, token):
|
|
|
431
442
|
msg = _flip_stderr_line(token, result)
|
|
432
443
|
print(json.dumps(_codex_block_envelope(msg, msg)))
|
|
433
444
|
return 0
|
|
434
|
-
_inject_codex(payload, host, root, session_id)
|
|
445
|
+
_inject_codex(payload, host, root, session_id, approval_context)
|
|
435
446
|
return 0
|
|
436
447
|
|
|
437
448
|
|
|
@@ -444,9 +455,35 @@ def _handle_user_prompt_submit(payload, host):
|
|
|
444
455
|
prompt = payload.get("prompt")
|
|
445
456
|
prompt = prompt if isinstance(prompt, str) else ""
|
|
446
457
|
token = _modelib.match_mode_token(prompt)
|
|
458
|
+
approval_context = ""
|
|
459
|
+
if token is None:
|
|
460
|
+
contexts = []
|
|
461
|
+
approval = _approvallib.consume_from_hook(
|
|
462
|
+
root=root,
|
|
463
|
+
plugin_root=host.plugin_root(),
|
|
464
|
+
prompt=prompt,
|
|
465
|
+
host=host.name,
|
|
466
|
+
session_id=session_id,
|
|
467
|
+
)
|
|
468
|
+
if approval:
|
|
469
|
+
contexts.append(approval)
|
|
470
|
+
prerequisite = _prerequisitelib.consume_from_hook(
|
|
471
|
+
root=root,
|
|
472
|
+
plugin_root=host.plugin_root(),
|
|
473
|
+
prompt=prompt,
|
|
474
|
+
host=host.name,
|
|
475
|
+
session_id=session_id,
|
|
476
|
+
)
|
|
477
|
+
if prerequisite:
|
|
478
|
+
contexts.append(prerequisite)
|
|
479
|
+
approval_context = "\n".join(contexts)
|
|
447
480
|
if host.name == "codex":
|
|
448
|
-
return _handle_codex(
|
|
449
|
-
|
|
481
|
+
return _handle_codex(
|
|
482
|
+
payload, host, state_root, session_id, token, approval_context
|
|
483
|
+
)
|
|
484
|
+
return _handle_claude(
|
|
485
|
+
payload, host, state_root, session_id, token, approval_context
|
|
486
|
+
)
|
|
450
487
|
|
|
451
488
|
|
|
452
489
|
def _handle_precompact(payload, host):
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
# codeArbiter —
|
|
2
|
+
# codeArbiter — executable release-command confirmation (A-2.10).
|
|
3
3
|
#
|
|
4
|
-
# A row's `pre-tag` commands are operator-authored
|
|
5
|
-
# EXECUTES before composing a tag.
|
|
4
|
+
# A row's `pre-tag` commands and optional `release-build` are operator-authored
|
|
5
|
+
# shell that `/ca:release` EXECUTES before composing a tag.
|
|
6
|
+
# `.codearbiter/release-targets.md` is
|
|
6
7
|
# marker-gated under H-22, so changing those commands already costs a fresh
|
|
7
8
|
# authoring marker — but that gate is about WRITING the file. Nothing made the
|
|
8
9
|
# release lane notice that the commands it is about to run are not the ones
|
|
9
10
|
# anybody last looked at.
|
|
10
11
|
#
|
|
11
|
-
# This closes that: the lane records a hash of a row's
|
|
12
|
-
# operator confirms
|
|
12
|
+
# This closes that: the lane records a hash of a row's executable release
|
|
13
|
+
# commands when an operator confirms them, and refuses to run silently once
|
|
14
|
+
# that hash changes. A
|
|
13
15
|
# changed hash is not a block — it is a re-confirmation prompt. Per ADR-0010
|
|
14
16
|
# this is COOPERATIVE-grade: an agent that skips the check is not stopped by
|
|
15
17
|
# the operating system, only by the lane's own discipline. Stated plainly here
|
|
@@ -29,15 +31,15 @@
|
|
|
29
31
|
# file.
|
|
30
32
|
#
|
|
31
33
|
# Public API:
|
|
32
|
-
# pre_tag_digest(commands) -> str
|
|
34
|
+
# pre_tag_digest(commands, release_build=None) -> str
|
|
33
35
|
# confirmation_path(root, target) -> str
|
|
34
36
|
# read_confirmation(root, target) -> str | None
|
|
35
37
|
# record_confirmation(root, target, digest) -> str
|
|
36
|
-
# confirmation_state(root, target, commands) -> str
|
|
38
|
+
# confirmation_state(root, target, commands, release_build=None) -> str
|
|
37
39
|
# main(argv) -> int
|
|
38
40
|
#
|
|
39
41
|
# CLI:
|
|
40
|
-
# releasehash.py digest <target> print the
|
|
42
|
+
# releasehash.py digest <target> print the executable-command digest
|
|
41
43
|
# releasehash.py check <target> exit 0 confirmed, 1 changed, 2 never
|
|
42
44
|
# confirmed, 3/4 declared-file states
|
|
43
45
|
# releasehash.py record <target> mint the confirmation (the sanctioned
|
|
@@ -63,8 +65,8 @@ NEVER = "never-confirmed"
|
|
|
63
65
|
NO_COMMANDS = "no-commands"
|
|
64
66
|
|
|
65
67
|
|
|
66
|
-
def pre_tag_digest(commands):
|
|
67
|
-
"""A stable digest of a row's
|
|
68
|
+
def pre_tag_digest(commands, release_build=None):
|
|
69
|
+
"""A stable digest of a row's executable release command inputs.
|
|
68
70
|
|
|
69
71
|
ORDER-SENSITIVE on purpose. The commands run in declared order and a
|
|
70
72
|
reordering can change what a release checks — a check that ran after a
|
|
@@ -76,9 +78,13 @@ def pre_tag_digest(commands):
|
|
|
76
78
|
change to this construction cannot be mistaken for a content change
|
|
77
79
|
by an old recorded value.
|
|
78
80
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
Rows without `release-build` deliberately retain the exact v1 digest and
|
|
82
|
+
marker identity, so existing confirmations do not churn. A present build
|
|
83
|
+
command is appended with a typed, length-prefixed record, so changing only
|
|
84
|
+
that command invalidates the same per-target confirmation marker.
|
|
85
|
+
|
|
86
|
+
Pure and non-raising: malformed values degrade conservatively to their
|
|
87
|
+
absent forms rather than raising inside a release lane.
|
|
82
88
|
"""
|
|
83
89
|
if not isinstance(commands, (list, tuple)):
|
|
84
90
|
commands = []
|
|
@@ -87,6 +93,8 @@ def pre_tag_digest(commands):
|
|
|
87
93
|
if not isinstance(command, str):
|
|
88
94
|
continue
|
|
89
95
|
parts.append(f"{len(command)}:{command}")
|
|
96
|
+
if isinstance(release_build, str) and release_build:
|
|
97
|
+
parts.append(f"release-build:{len(release_build)}:{release_build}")
|
|
90
98
|
joined = "\n".join(parts)
|
|
91
99
|
return hashlib.sha256(joined.encode("utf-8")).hexdigest()
|
|
92
100
|
|
|
@@ -141,30 +149,32 @@ def record_confirmation(root, target, digest):
|
|
|
141
149
|
return path
|
|
142
150
|
|
|
143
151
|
|
|
144
|
-
def confirmation_state(root, target, commands):
|
|
152
|
+
def confirmation_state(root, target, commands, release_build=None):
|
|
145
153
|
"""One of `confirmed` / `changed` / `never-confirmed` / `no-commands`.
|
|
146
154
|
|
|
147
155
|
`no-commands` is deliberately distinct from `confirmed`: a row that
|
|
148
|
-
declares
|
|
149
|
-
as confirmed would claim an operator approved
|
|
150
|
-
|
|
151
|
-
|
|
156
|
+
declares neither pre-tag commands nor release-build has nothing to
|
|
157
|
+
confirm, and reporting it as confirmed would claim an operator approved
|
|
158
|
+
something that does not exist. A release-build-only row still requires
|
|
159
|
+
confirmation. The lane treats `no-commands` and `confirmed` as
|
|
160
|
+
"proceed", but only one of them is a statement about human review.
|
|
152
161
|
"""
|
|
153
|
-
if not commands:
|
|
162
|
+
if not commands and not release_build:
|
|
154
163
|
return NO_COMMANDS
|
|
155
164
|
recorded = read_confirmation(root, target)
|
|
156
165
|
if recorded is None:
|
|
157
166
|
return NEVER
|
|
158
|
-
return CONFIRMED if recorded == pre_tag_digest(commands)
|
|
167
|
+
return (CONFIRMED if recorded == pre_tag_digest(commands, release_build)
|
|
168
|
+
else CHANGED)
|
|
159
169
|
|
|
160
170
|
|
|
161
|
-
def
|
|
171
|
+
def _row_executable_inputs(target, targets_path=None):
|
|
162
172
|
path = targets_path or _releaselib.default_targets_path()
|
|
163
173
|
rows = _releaselib.load_targets(path)
|
|
164
174
|
row = next((r for r in rows if r["target"] == target), None)
|
|
165
175
|
if row is None:
|
|
166
176
|
return None
|
|
167
|
-
return row.get("pre_tag") or []
|
|
177
|
+
return row.get("pre_tag") or [], row.get("release_build")
|
|
168
178
|
|
|
169
179
|
|
|
170
180
|
def main(argv=None):
|
|
@@ -177,38 +187,42 @@ def main(argv=None):
|
|
|
177
187
|
root = os.path.dirname(os.path.dirname(_releaselib.default_targets_path()))
|
|
178
188
|
|
|
179
189
|
try:
|
|
180
|
-
|
|
190
|
+
executable_inputs = _row_executable_inputs(target)
|
|
181
191
|
except _releaselib.ReleaseTargetsError as error:
|
|
182
192
|
sys.stderr.write(f"{type(error).__name__}: {error}\n")
|
|
183
193
|
return _releaselib._targets_error_exit_code(error)
|
|
184
|
-
if
|
|
194
|
+
if executable_inputs is None:
|
|
185
195
|
sys.stderr.write(f"unknown release target: {target}\n")
|
|
186
196
|
return 2
|
|
197
|
+
commands, release_build = executable_inputs
|
|
187
198
|
|
|
188
199
|
if command == "digest":
|
|
189
|
-
print(pre_tag_digest(commands))
|
|
200
|
+
print(pre_tag_digest(commands, release_build))
|
|
190
201
|
return 0
|
|
191
202
|
|
|
192
203
|
if command == "record":
|
|
193
|
-
path = record_confirmation(
|
|
194
|
-
|
|
204
|
+
path = record_confirmation(
|
|
205
|
+
root, target, pre_tag_digest(commands, release_build))
|
|
206
|
+
print(f"recorded executable-command confirmation for {target}: {path}")
|
|
195
207
|
return 0
|
|
196
208
|
|
|
197
|
-
state = confirmation_state(root, target, commands)
|
|
209
|
+
state = confirmation_state(root, target, commands, release_build)
|
|
198
210
|
print(state)
|
|
199
211
|
if state in (CONFIRMED, NO_COMMANDS):
|
|
200
212
|
return 0
|
|
201
213
|
if state == CHANGED:
|
|
202
214
|
sys.stderr.write(
|
|
203
|
-
f"releasehash: {target}'s
|
|
204
|
-
"were last confirmed. Read
|
|
215
|
+
f"releasehash: {target}'s executable release commands have CHANGED "
|
|
216
|
+
"since they were last confirmed. Read every pre-tag and release-build "
|
|
217
|
+
"command, and re-confirm with "
|
|
205
218
|
f"`releasehash.py record {target}` before releasing. These commands "
|
|
206
219
|
"are executed by the release lane, so a change nobody has read is "
|
|
207
220
|
"the case this check exists for.\n")
|
|
208
221
|
return 1
|
|
209
222
|
sys.stderr.write(
|
|
210
|
-
f"releasehash: {target}'s
|
|
211
|
-
f"Read
|
|
223
|
+
f"releasehash: {target}'s executable release commands have never been "
|
|
224
|
+
f"confirmed. Read every pre-tag and release-build command, then run "
|
|
225
|
+
f"`releasehash.py record {target}`.\n")
|
|
212
226
|
return 2
|
|
213
227
|
|
|
214
228
|
|