@arbiterforge/ca-pi 0.10.13 → 0.14.6
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 +159 -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/_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 +33 -10
- package/plugins/ca-pi/hooks/releasehash.py +46 -32
- package/plugins/ca-pi/includes/artifacts.md +166 -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.6"
|
|
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,6 +64,7 @@ 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)
|
|
69
70
|
import _readinjectlib # noqa: E402 — marker_path(prefix=) (T-30)
|
|
@@ -366,38 +367,47 @@ def _codex_allow_envelope(additional_context):
|
|
|
366
367
|
# Injection (job 2), host-neutral core + per-host output
|
|
367
368
|
# ---------------------------------------------------------------------------
|
|
368
369
|
|
|
369
|
-
def _inject_claude(payload, host, root, session_id):
|
|
370
|
+
def _inject_claude(payload, host, root, session_id, approval_context=""):
|
|
370
371
|
mode, diag = _effective_mode(root, session_id, payload)
|
|
371
372
|
if diag:
|
|
372
373
|
sys.stderr.write("codeArbiter: " + diag + "\n")
|
|
373
374
|
generation = _read_compaction_generation(root, session_id)
|
|
374
375
|
if _already_injected(root, session_id, mode, generation):
|
|
376
|
+
if approval_context:
|
|
377
|
+
print(approval_context)
|
|
375
378
|
return
|
|
376
379
|
safety, body = _persona_for_mode(host.plugin_root(), mode)
|
|
377
380
|
if _persona_unavailable(safety, body):
|
|
378
381
|
sys.stderr.write("codeArbiter: persona sources incomplete for mode '{}' — "
|
|
379
382
|
"injecting nothing this turn rather than a body without its "
|
|
380
383
|
"safety floor.\n".format(mode))
|
|
384
|
+
if approval_context:
|
|
385
|
+
print(approval_context)
|
|
381
386
|
return
|
|
382
387
|
composed, _truncated = _compose_persona(safety, body)
|
|
383
|
-
print(composed)
|
|
388
|
+
print("\n\n".join(value for value in (approval_context, composed) if value))
|
|
384
389
|
_record_injected(root, session_id, mode, generation)
|
|
385
390
|
|
|
386
391
|
|
|
387
|
-
def _inject_codex(payload, host, root, session_id):
|
|
392
|
+
def _inject_codex(payload, host, root, session_id, approval_context=""):
|
|
388
393
|
mode, diag = _effective_mode(root, session_id, payload)
|
|
389
394
|
generation = _read_compaction_generation(root, session_id)
|
|
390
395
|
if _already_injected(root, session_id, mode, generation):
|
|
396
|
+
if approval_context:
|
|
397
|
+
print(json.dumps(_codex_allow_envelope(approval_context)))
|
|
391
398
|
return
|
|
392
399
|
safety, body = _persona_for_mode(host.plugin_root(), mode)
|
|
393
400
|
if _persona_unavailable(safety, body):
|
|
394
401
|
sys.stderr.write("codeArbiter: persona sources incomplete for mode '{}' — "
|
|
395
402
|
"injecting nothing this turn rather than a body without its "
|
|
396
403
|
"safety floor.\n".format(mode))
|
|
404
|
+
if approval_context:
|
|
405
|
+
print(json.dumps(_codex_allow_envelope(approval_context)))
|
|
397
406
|
return
|
|
398
407
|
composed, _truncated = _compose_persona(
|
|
399
408
|
safety, body, limit_tokens=CODEX_ADDITIONAL_CONTEXT_LIMIT)
|
|
400
|
-
|
|
409
|
+
context = "\n\n".join(value for value in (approval_context, composed) if value)
|
|
410
|
+
print(json.dumps(_codex_allow_envelope(context)))
|
|
401
411
|
_record_injected(root, session_id, mode, generation)
|
|
402
412
|
|
|
403
413
|
|
|
@@ -405,7 +415,7 @@ def _inject_codex(payload, host, root, session_id):
|
|
|
405
415
|
# UserPromptSubmit dispatch (jobs 1 + 2)
|
|
406
416
|
# ---------------------------------------------------------------------------
|
|
407
417
|
|
|
408
|
-
def _handle_claude(payload, host, root, session_id, token):
|
|
418
|
+
def _handle_claude(payload, host, root, session_id, token, approval_context=""):
|
|
409
419
|
if token == _modelib.MODE_TOKEN_REPORT:
|
|
410
420
|
mode, diag = _modelib.current_mode(session_id, root=root, payload=payload)
|
|
411
421
|
sys.stderr.write(_report_stderr_line(mode, diag) + "\n")
|
|
@@ -415,11 +425,11 @@ def _handle_claude(payload, host, root, session_id, token):
|
|
|
415
425
|
host_name=host.name)
|
|
416
426
|
sys.stderr.write(_flip_stderr_line(token, result) + "\n")
|
|
417
427
|
return 2
|
|
418
|
-
_inject_claude(payload, host, root, session_id)
|
|
428
|
+
_inject_claude(payload, host, root, session_id, approval_context)
|
|
419
429
|
return 0
|
|
420
430
|
|
|
421
431
|
|
|
422
|
-
def _handle_codex(payload, host, root, session_id, token):
|
|
432
|
+
def _handle_codex(payload, host, root, session_id, token, approval_context=""):
|
|
423
433
|
if token == _modelib.MODE_TOKEN_REPORT:
|
|
424
434
|
mode, diag = _modelib.current_mode(session_id, root=root, payload=payload)
|
|
425
435
|
msg = _report_stderr_line(mode, diag)
|
|
@@ -431,7 +441,7 @@ def _handle_codex(payload, host, root, session_id, token):
|
|
|
431
441
|
msg = _flip_stderr_line(token, result)
|
|
432
442
|
print(json.dumps(_codex_block_envelope(msg, msg)))
|
|
433
443
|
return 0
|
|
434
|
-
_inject_codex(payload, host, root, session_id)
|
|
444
|
+
_inject_codex(payload, host, root, session_id, approval_context)
|
|
435
445
|
return 0
|
|
436
446
|
|
|
437
447
|
|
|
@@ -444,9 +454,22 @@ def _handle_user_prompt_submit(payload, host):
|
|
|
444
454
|
prompt = payload.get("prompt")
|
|
445
455
|
prompt = prompt if isinstance(prompt, str) else ""
|
|
446
456
|
token = _modelib.match_mode_token(prompt)
|
|
457
|
+
approval_context = ""
|
|
458
|
+
if token is None:
|
|
459
|
+
approval_context = _approvallib.consume_from_hook(
|
|
460
|
+
root=root,
|
|
461
|
+
plugin_root=host.plugin_root(),
|
|
462
|
+
prompt=prompt,
|
|
463
|
+
host=host.name,
|
|
464
|
+
session_id=session_id,
|
|
465
|
+
)
|
|
447
466
|
if host.name == "codex":
|
|
448
|
-
return _handle_codex(
|
|
449
|
-
|
|
467
|
+
return _handle_codex(
|
|
468
|
+
payload, host, state_root, session_id, token, approval_context
|
|
469
|
+
)
|
|
470
|
+
return _handle_claude(
|
|
471
|
+
payload, host, state_root, session_id, token, approval_context
|
|
472
|
+
)
|
|
450
473
|
|
|
451
474
|
|
|
452
475
|
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
|
|