@arbiterforge/ca-pi 0.10.12 → 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.
Files changed (58) hide show
  1. package/README.md +6 -5
  2. package/package.json +2 -1
  3. package/plugins/ca-pi/CHANGELOG.md +165 -0
  4. package/plugins/ca-pi/SKILLS.md +1 -1
  5. package/plugins/ca-pi/SPRINT.md +40 -10
  6. package/plugins/ca-pi/agents/backend-author.md +1 -1
  7. package/plugins/ca-pi/agents/frontend-author.md +1 -1
  8. package/plugins/ca-pi/generated/command-catalog.json +1 -1
  9. package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-amd64 +0 -0
  10. package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-arm64 +0 -0
  11. package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-amd64 +0 -0
  12. package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-arm64 +0 -0
  13. package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-amd64.exe +0 -0
  14. package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-arm64.exe +0 -0
  15. package/plugins/ca-pi/helpers/artifacts/release.json +38 -0
  16. package/plugins/ca-pi/hooks/_approvallib.py +355 -0
  17. package/plugins/ca-pi/hooks/_artifactlib.py +855 -0
  18. package/plugins/ca-pi/hooks/_cleanuplib.py +1553 -0
  19. package/plugins/ca-pi/hooks/_hooklib.py +37 -13
  20. package/plugins/ca-pi/hooks/_host.py +1 -1
  21. package/plugins/ca-pi/hooks/_intentlib.py +31 -0
  22. package/plugins/ca-pi/hooks/_planfilelib.py +34 -1
  23. package/plugins/ca-pi/hooks/_protectedstatelib.py +11 -1
  24. package/plugins/ca-pi/hooks/_readinjectlib.py +120 -0
  25. package/plugins/ca-pi/hooks/_releaselib.py +933 -85
  26. package/plugins/ca-pi/hooks/_standuplib.py +38 -10
  27. package/plugins/ca-pi/hooks/doctor.py +103 -0
  28. package/plugins/ca-pi/hooks/hostapi.py +1 -1
  29. package/plugins/ca-pi/hooks/pi-bridge.py +5 -1
  30. package/plugins/ca-pi/hooks/prompt-submit.py +33 -10
  31. package/plugins/ca-pi/hooks/releasehash.py +46 -32
  32. package/plugins/ca-pi/hooks/session-start.py +5 -4
  33. package/plugins/ca-pi/includes/artifacts.md +166 -0
  34. package/plugins/ca-pi/includes/author-tdd-workflow.md +2 -1
  35. package/plugins/ca-pi/includes/farm.md +27 -4
  36. package/plugins/ca-pi/includes/routing-table.md +2 -2
  37. package/plugins/ca-pi/includes/verification-boundary.md +27 -0
  38. package/plugins/ca-pi/package.json +18 -0
  39. package/plugins/ca-pi/routines/INDEX.md +4 -4
  40. package/plugins/ca-pi/routines/brainstorming/SKILL.md +23 -4
  41. package/plugins/ca-pi/routines/commit-gate/SKILL.md +30 -5
  42. package/plugins/ca-pi/routines/executing-plans/SKILL.md +52 -7
  43. package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +25 -3
  44. package/plugins/ca-pi/routines/refactor/SKILL.md +3 -2
  45. package/plugins/ca-pi/routines/release/SKILL.md +150 -44
  46. package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +53 -9
  47. package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +20 -3
  48. package/plugins/ca-pi/routines/tdd/SKILL.md +27 -5
  49. package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +11 -2
  50. package/plugins/ca-pi/routines/writing-plans/SKILL.md +39 -10
  51. package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +34 -13
  52. package/plugins/ca-pi/skills/ca-chore/SKILL.md +7 -3
  53. package/plugins/ca-pi/skills/ca-commit/SKILL.md +3 -2
  54. package/plugins/ca-pi/skills/ca-doctor/SKILL.md +14 -0
  55. package/plugins/ca-pi/skills/ca-feature/SKILL.md +42 -8
  56. package/plugins/ca-pi/skills/ca-refactor/SKILL.md +4 -2
  57. package/plugins/ca-pi/skills/ca-release/SKILL.md +7 -6
  58. 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}. Records
120
- are separated by blank lines; each starts with a `worktree <path>` line and may
121
- carry a `branch refs/heads/<name>` line (absent / `detached` => branch None).
122
- The main worktree is the record whose path equals `repo_root` (trailing
123
- separator tolerated). Parses faithfully; staleness/merge classification is a
124
- later task's job.
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
- elif line.startswith("branch ") and cur is not None:
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
- # HEAD / detached / bare / locked lines carry no fields we surface here.
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.17.10"
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(request["cwd"], _plan_file_request(request["input"]))
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
- print(json.dumps(_codex_allow_envelope(composed)))
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(payload, host, state_root, session_id, token)
449
- return _handle_claude(payload, host, state_root, session_id, token)
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 — pre-tag content-hash confirmation (A-2.10).
2
+ # codeArbiter — executable release-command confirmation (A-2.10).
3
3
  #
4
- # A row's `pre-tag` commands are operator-authored shell that `/ca:release`
5
- # EXECUTES before composing a tag. `.codearbiter/release-targets.md` is
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 pre-tag content when an
12
- # operator confirms it, and refuses to run silently once that hash changes. A
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 current pre-tag digest
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 `pre-tag` command list.
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
- Pure and non-raising: a non-list, or a list with non-string members,
80
- degrades to the digest of an empty list rather than raising inside a
81
- release lane.
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 no pre-tag commands has nothing to confirm, and reporting it
149
- as confirmed would claim an operator approved something that does not
150
- exist. The lane treats both as "proceed", but only one of them is a
151
- statement about human review.
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) else CHANGED
167
+ return (CONFIRMED if recorded == pre_tag_digest(commands, release_build)
168
+ else CHANGED)
159
169
 
160
170
 
161
- def _row_commands(target, targets_path=None):
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
- commands = _row_commands(target)
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 commands is None:
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(root, target, pre_tag_digest(commands))
194
- print(f"recorded pre-tag confirmation for {target}: {path}")
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 pre-tag commands have CHANGED since they "
204
- "were last confirmed. Read them, and re-confirm with "
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 pre-tag commands have never been confirmed. "
211
- f"Read them, then run `releasehash.py record {target}`.\n")
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
 
@@ -1080,10 +1080,11 @@ def emit_update_notice(update_line):
1080
1080
 
1081
1081
 
1082
1082
  def emit_trailer(host):
1083
- """AC-32: the await-a-command trailer + catalog reference. ARBITER-ONLY —
1083
+ """AC-32: the continue-or-await trailer + catalog reference. ARBITER-ONLY —
1084
1084
  the caller omits this emitter entirely for a non-arbiter startup (a mode
1085
- with no commands has nothing to 'await')."""
1086
- print(f"Present this state, then await a {host.command_noun}. "
1085
+ with no commands has no command catalog to reference)."""
1086
+ print("Present this state. Continue any active authorized work or accompanying "
1087
+ f"user request; otherwise await a {host.command_noun}. "
1087
1088
  f"Type {host.cmd_ref('commands')} for the catalog.")
1088
1089
 
1089
1090
 
@@ -1221,7 +1222,7 @@ def main():
1221
1222
  # --- Startup-state block: per-mode composable emitters (T-44) ---------
1222
1223
  # AC-30: each emitter below is individually callable with only its own
1223
1224
  # explicit inputs. AC-32: host/stage/active-mode are unconditional in
1224
- # every mode; the await-a-command trailer and the daily briefing (which
1225
+ # every mode; the continue-or-await trailer and the daily briefing (which
1225
1226
  # references {standup}) are ARBITER-ONLY.
1226
1227
  print("=== codeArbiter startup state ===")
1227
1228
  # observability-004 (#268): name the RESOLVED host so a dormant/broken