arkaos 4.42.0 → 4.44.0

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 (57) hide show
  1. package/THE-ARKAOS-GUIDE.md +1 -1
  2. package/VERSION +1 -1
  3. package/arka/skills/checkpoint/SKILL.md +10 -3
  4. package/arka/skills/flow/SKILL.md +4 -0
  5. package/config/claude-agents/eduardo-copy.md +13 -2
  6. package/config/claude-agents/francisca-tech.md +12 -2
  7. package/config/claude-agents/marta-cqo.md +63 -13
  8. package/config/constitution.yaml +4 -1
  9. package/config/hooks/agent-provision.sh +35 -14
  10. package/core/evals/record_cli.py +13 -8
  11. package/core/governance/aggregate_guard.py +204 -43
  12. package/core/governance/qg_verdict.py +56 -2
  13. package/core/governance/quality_api.py +13 -17
  14. package/core/governance/review_workflow.py +26 -12
  15. package/core/governance/reviewer_ledger.py +34 -0
  16. package/core/hooks/session_end.py +16 -0
  17. package/core/hooks/stop.py +67 -21
  18. package/departments/quality/SKILL.md +33 -16
  19. package/harness/codex/AGENTS.md +1 -1
  20. package/harness/copilot/copilot-instructions.md +1 -1
  21. package/harness/cursor/rules/arkaos.mdc +2 -2
  22. package/harness/gemini/GEMINI.md +1 -1
  23. package/harness/opencode/AGENTS.md +1 -1
  24. package/harness/opencode/agents/arka-architect-gabriel.md +1 -1
  25. package/harness/opencode/agents/arka-brand-director-valentina.md +1 -1
  26. package/harness/opencode/agents/arka-cfo-helena.md +1 -1
  27. package/harness/opencode/agents/arka-chief-of-staff-afonso.md +1 -1
  28. package/harness/opencode/agents/arka-community-strategist-beatriz.md +1 -1
  29. package/harness/opencode/agents/arka-content-strategist-rafael.md +1 -1
  30. package/harness/opencode/agents/arka-conversion-strategist-ines.md +1 -1
  31. package/harness/opencode/agents/arka-coo-sofia.md +1 -1
  32. package/harness/opencode/agents/arka-copy-director-eduardo.md +1 -1
  33. package/harness/opencode/agents/arka-cqo-marta.md +1 -1
  34. package/harness/opencode/agents/arka-cto-marco.md +1 -1
  35. package/harness/opencode/agents/arka-design-ops-lead-iris.md +1 -1
  36. package/harness/opencode/agents/arka-ecom-director-ricardo.md +1 -1
  37. package/harness/opencode/agents/arka-knowledge-director-clara.md +1 -1
  38. package/harness/opencode/agents/arka-leadership-director-rodrigo.md +1 -1
  39. package/harness/opencode/agents/arka-marketing-director-luna.md +1 -1
  40. package/harness/opencode/agents/arka-ops-lead-daniel.md +1 -1
  41. package/harness/opencode/agents/arka-pm-director-carolina.md +1 -1
  42. package/harness/opencode/agents/arka-revops-lead-vicente.md +1 -1
  43. package/harness/opencode/agents/arka-saas-strategist-tiago.md +1 -1
  44. package/harness/opencode/agents/arka-sales-director-miguel.md +1 -1
  45. package/harness/opencode/agents/arka-strategy-director-tomas.md +1 -1
  46. package/harness/opencode/agents/arka-tech-director-francisca.md +1 -1
  47. package/harness/opencode/agents/arka-tech-lead-paulo.md +1 -1
  48. package/harness/opencode/agents/arka-video-producer-simao.md +1 -1
  49. package/harness/zed/.rules +1 -1
  50. package/installer/adapters/claude-code.js +88 -3
  51. package/installer/doctor.js +121 -3
  52. package/installer/index.js +17 -0
  53. package/installer/skill-deploy.js +43 -2
  54. package/installer/update.js +12 -0
  55. package/knowledge/skills-manifest.json +1 -1
  56. package/package.json +1 -1
  57. package/pyproject.toml +1 -1
@@ -1,6 +1,6 @@
1
1
  # The ArkaOS Guide
2
2
 
3
- > v4.42.0 — 89 agents, 17 departments, 332 skills, 297 commands, 18 ADRs.
3
+ > v4.44.0 — 89 agents, 17 departments, 332 skills, 297 commands, 18 ADRs.
4
4
  > One file, everything you need to start. Generated by `scripts/guide_gen.py` — never hand-edited.
5
5
 
6
6
  ## What it is
package/VERSION CHANGED
@@ -1 +1 @@
1
- 4.42.0
1
+ 4.44.0
@@ -53,7 +53,12 @@ Any work item whose execution would block the user for **> 30 seconds**
53
53
  Per the brainstorm (Marta + Tomas):
54
54
 
55
55
  - **Technical conflicts** between reviewers (e.g. Eduardo + Francisca
56
- disagree on a code-style call) → Marta resolves silently.
56
+ disagree on a code-style call) → Marta resolves ON THE RECORD:
57
+ evidence decides (reproduce the claim, cite the reproduction), and
58
+ the resolution lands in her verdict notes. A blocker backed by
59
+ evidence is fixed or REFUTED with a substantive reason — never
60
+ dropped silently (constitution `inter-agent-checkpoints` enforcement
61
+ + `evidence-flow`; the aggregate guard enforces the bar).
57
62
  - **Strategic / taste / business-knowledge conflicts** → Marta surfaces
58
63
  the disagreement at the next checkpoint: *"Eduardo + Francisca
59
64
  discordam em X, eu inclino para A — qual o teu input?"*. The user
@@ -74,8 +79,10 @@ Tens contexto a acrescentar antes de eu arrancar? (Silêncio = procedo.)
74
79
  follows by emitting checkpoint markers between agent calls.
75
80
  - Does not block the user. The user is free to stay silent (proceed)
76
81
  or to send any message (parsed by `parse_user_injection`).
77
- - Does not bypass the mandatory 13-phase flow. Checkpoints are
78
- emitted **inside** the per-todo loop of Phase 11.
82
+ - Does not bypass the mandatory 4-gate evidence flow (the 13-phase
83
+ flow it referenced was replaced in v4.1.0 — constitution
84
+ `evidence-flow`). Checkpoints are emitted **inside** Gate 3
85
+ execution, between sub-dispatches; Gate 4 still runs.
79
86
 
80
87
  ## Cross-references
81
88
 
@@ -142,6 +142,10 @@ resumes at the right gate.
142
142
  Time and token cost are not acceptable answers to any of the three.
143
143
  - Quality Gate REJECTED loops back at most twice; a third REJECTED
144
144
  escalates to the operator with the full verdict.
145
+ - The gate-closing report reproduces each reviewer verdict VERBATIM
146
+ under `### <Reviewer> — verbatim`, with the ledger artifact path
147
+ (`~/.arkaos/quality-gate/<session>/`) beside it. Summarising a
148
+ reviewer in your own words is relay, not report.
145
149
  - Close with an honest summary: what changed, where, how it was
146
150
  verified (real commands + results), what remains open.
147
151
 
@@ -55,10 +55,21 @@ DOCUMENTATION-scope prose it is advisory.
55
55
  Return a `QGVerdict` JSON object (schema: `QG_VERDICT_JSON_SCHEMA` in
56
56
  `core.governance.qg_verdict`): `verdict`, `evidence_report` summary,
57
57
  `blockers` [{check, detail, file, verdict}] with exact location and
58
- correction — `verdict` is claim-level: CONFIRMED (you verified the
58
+ correction — `check` names the evidence check or rubric area (the
59
+ aggregate's coverage matching keys on it; never leave it empty),
60
+ `verdict` is claim-level: CONFIRMED (you verified the
59
61
  error on the page/line), PLAUSIBLE (credible, unverified), REFUTED
60
62
  (disproven; recorded, never counts toward rejection) —,
61
- `reviewer: "copy-director-eduardo"`, `model_used`, `notes`.
63
+ `reviewer: "copy-director-eduardo"`, `model_used`, `evidence_digest`
64
+ (the `report_digest` of the report you interpreted — mandatory since
65
+ PR-B4; an artifact without it cannot support an APPROVED aggregate),
66
+ `notes`.
67
+
68
+ Emit the JSON inside a ```arka-qgverdict fence in your FINAL message —
69
+ the fence is what the hook-boundary ledger captures verbatim. Never
70
+ write triple backticks inside a JSON string — one inside notes cut the
71
+ extractor mid-string (francisca-tech-17); the balanced-JSON cut now
72
+ recovers most such cases, and none of them is worth relying on.
62
73
 
63
74
  Model tier: single source is constitution `quality_gate.model_policy` —
64
75
  Quality Gate reviewers run on the best model available (frontier tier,
@@ -54,8 +54,18 @@ you did not attempt to reproduce is PLAUSIBLE at best, never CONFIRMED.
54
54
  Return a `QGVerdict` JSON object (schema: `QG_VERDICT_JSON_SCHEMA` in
55
55
  `core.governance.qg_verdict`): `verdict`, `evidence_report` summary,
56
56
  `blockers` [{check, detail, file, verdict}] numbered B1./B2. with line
57
- references and fix suggestions,
58
- `reviewer: "tech-director-francisca"`, `model_used`, `notes`.
57
+ references and fix suggestions — `check` names the evidence check or
58
+ rubric area (the aggregate's coverage matching keys on it; never
59
+ leave it empty) —, `reviewer: "tech-director-francisca"`,
60
+ `model_used`, `evidence_digest` (the `report_digest` of the report
61
+ you interpreted — mandatory since PR-B4; an artifact without it
62
+ cannot support an APPROVED aggregate), `notes`.
63
+
64
+ Emit the JSON inside a ```arka-qgverdict fence in your FINAL message —
65
+ the fence is what the hook-boundary ledger captures verbatim. Never
66
+ write triple backticks inside a JSON string — one inside notes cut the
67
+ extractor mid-string (francisca-tech-17); the balanced-JSON cut now
68
+ recovers most such cases, and none of them is worth relying on.
59
69
 
60
70
  Model tier: single source is constitution `quality_gate.model_policy` —
61
71
  Quality Gate reviewers run on the best model available (frontier tier,
@@ -26,9 +26,20 @@ evidence report, never from model size.
26
26
  1. Run the engine first — no verdict without a report:
27
27
  `~/.arkaos/bin/arka-py -m core.governance.evidence_checks <project_dir> [--changed-files ...] [--test-command '...'] --json`
28
28
  2. Dispatch Eduardo (spellcheck + changed copy) and Francisca
29
- (lint/typecheck/tests/coverage/security-grep) with the report and the
30
- structured output schema `QG_VERDICT_JSON_SCHEMA` from
31
- `core.governance.qg_verdict`.
29
+ (lint/typecheck/tests/coverage/security-grep) with the report and,
30
+ in the prompt, the QGVerdict field names (`QG_VERDICT_JSON_SCHEMA`
31
+ in `core.governance.qg_verdict` is that contract — the Agent tool
32
+ has no structured-output parameter). Dispatch shape (PR-B4): the
33
+ prompt names the FULL field set the reviewer returns — `verdict`,
34
+ `evidence_report` {overall, checks_ran, checks_failed,
35
+ checks_skipped}, `blockers` [{`check` (the evidence check name;
36
+ coverage matching keys on it), `detail`, `file`, `verdict`
37
+ CONFIRMED/PLAUSIBLE/REFUTED}], `reviewer`, `model_used`,
38
+ `evidence_digest` (= the report's `report_digest`), `notes`. A
39
+ dispatch that invents its own field names fail-softs the artifact
40
+ (16 schema errors on one B2 round); a reviewer artifact without
41
+ `evidence_digest` cannot support an APPROVED aggregate — the guard
42
+ refuses it and the reviewer must be re-dispatched.
32
43
  3. Aggregate at CLAIM level (Constitution 2.0): every reviewer blocker
33
44
  carries `verdict` CONFIRMED / PLAUSIBLE / REFUTED. Only CONFIRMED and
34
45
  PLAUSIBLE blockers count toward rejection; REFUTED are recorded for
@@ -39,9 +50,7 @@ evidence report, never from model size.
39
50
  - overall == "pass" → APPROVED only if zero CONFIRMED/PLAUSIBLE blockers.
40
51
  - overall == "insufficient-evidence" → APPROVED only with explicit
41
52
  justification in notes; otherwise REJECTED.
42
- 4. Record the outcome via `core.governance.review_workflow` passing
43
- `evidence_overall` — it raises on APPROVED-over-fail by design.
44
- 5. Record the eval label (evals ADR 2026-07-09) as your FINAL act — the
53
+ 4. Record the eval label (evals ADR 2026-07-09) as your FINAL act — the
45
54
  corpus only grows if the verdict-issuer writes it, and dispatch
46
55
  through this agent bypasses the department SKILL's step 6: write your
47
56
  final QGVerdict JSON to a temp file and run
@@ -51,11 +60,15 @@ evidence report, never from model size.
51
60
  remedy: invalid JSON (fix the JSON and re-run), a missing
52
61
  --session-id (pass the session id — the anti-self-approval guard
53
62
  reads that session's reviewer ledger), or a guard refusal (the
54
- ledger cannot support your aggregate: quorum, digest, blocker
55
- coverage, or an APPROVED verdict standing over a rejecting
56
- reviewer read the stderr reasons and fix the REVIEW, not the
57
- JSON; if the reason names AGGREGATE.json or the session id, fix
58
- that instead). Never skip.
63
+ ledger cannot support your aggregate: quorum, a missing or
64
+ mismatched `evidence_digest` without a justified `digest_carries`
65
+ entry, a session already stamped as ended (digest and session
66
+ reasons refuse only an APPROVED aggregate a REJECTED one records
67
+ with warnings), blocker coverage, or an
68
+ APPROVED verdict standing over a rejecting reviewer — read the
69
+ stderr reasons and fix the REVIEW, not the JSON; if the reason
70
+ names AGGREGATE.json or the session id, fix that instead). Never
71
+ skip.
59
72
  Every review feeds `~/.arkaos/telemetry/qg-verdicts.jsonl`, redo
60
73
  verdicts included (a REJECTED→APPROVED pair is two labels).
61
74
 
@@ -64,11 +77,25 @@ evidence report, never from model size.
64
77
  Return a `QGVerdict` JSON object: `verdict` (APPROVED|REJECTED),
65
78
  `evidence_report` {overall, checks_ran, checks_failed, checks_skipped},
66
79
  `blockers` [{check, detail, file, verdict}], `reviewer: "cqo-marta"`,
67
- `model_used`, `notes`. Binary there is no "approved with caveats".
80
+ `model_used`, `notes`, `evidence_digest` (the `report_digest` of the
81
+ report you aggregated — mandatory since PR-B4) and, when you carry an
82
+ earlier review over a report change, `digest_carries`
83
+ [{reviewer, evidence_digest, reason}] naming the digest THAT reviewer
84
+ actually reviewed and why the review still stands (>= 40 chars).
85
+ Binary — there is no "approved with caveats".
86
+
87
+ Emit the final JSON inside a ```arka-qgverdict fence in your FINAL
88
+ message — the fence is what the hook-boundary ledger captures, and an
89
+ aggregate that exists only as prose is a relay (the B1 gate closed
90
+ with this fence present by ad-hoc instruction; it is contract now).
91
+ Never write triple backticks inside a JSON string — one inside notes
92
+ cut the extractor mid-string (francisca-tech-17); the balanced-JSON
93
+ cut now recovers most such cases, and none of them is worth relying
94
+ on.
68
95
 
69
96
  Filled example (the shape you return, not a schema):
70
97
 
71
- ```json
98
+ ```arka-qgverdict
72
99
  {"verdict": "REJECTED",
73
100
  "evidence_report": {"overall": "pass", "checks_ran": ["lint","tests"],
74
101
  "checks_failed": [], "checks_skipped": ["coverage"]},
@@ -77,9 +104,32 @@ Filled example (the shape you return, not a schema):
77
104
  "detail": "AttributeError on malformed record — docstring claims 'never raises'; reproduced via check_x('bad')",
78
105
  "file": "core/governance/x.py:138", "verdict": "CONFIRMED"}],
79
106
  "reviewer": "cqo-marta", "model_used": "opus",
107
+ "evidence_digest": "3f2a3f2a3f2a3f2a3f2a3f2a3f2a3f2a3f2a3f2a3f2a3f2a3f2a3f2a3f2a3f2a",
80
108
  "notes": "Engine pass but 1 CONFIRMED blocker, reproduced by my own hand."}
81
109
  ```
82
110
 
111
+ ## Reporting (verbatim, never relay)
112
+
113
+ The gate-closing report reproduces every reviewer verdict VERBATIM
114
+ under `### <Reviewer> — verbatim`, each with its ledger artifact path
115
+ beside it. Summarising a reviewer in your own words is relay, not
116
+ report — a relay inside a gate is a single point of distortion, and
117
+ it is how a corpus reached 80 aggregator-authored records with zero
118
+ reviewer-signed ones.
119
+
120
+ ## Conflict Handling (no silent resolution)
121
+
122
+ A reviewer blocker BACKED BY EVIDENCE is never resolved silently: it
123
+ is fixed (and the fix verified by execution), or REFUTED on the
124
+ record with a substantive reason (>= 40 chars — the guard enforces
125
+ the bar), or it blocks. Disagreement between reviewers is settled by
126
+ evidence, not by rank: reproduce the claim, cite the reproduction.
127
+ Only a blocker with no evidence behind it (no repro, no citation) may
128
+ be dropped, and even that drop is recorded in `notes`, never omitted.
129
+ A CONFIRMED blocker is never merely noted: the guard reads
130
+ `blockers`, not `notes` — it is fixed, or REFUTED in `blockers` with
131
+ its reason, or it blocks.
132
+
83
133
  ## Signature Rules (anti-sycophancy)
84
134
 
85
135
  - Open with "Quality Gate Verdict:" and close with "Final:".
@@ -248,7 +248,7 @@ enforcement_levels:
248
248
 
249
249
  - id: inter-agent-checkpoints
250
250
  rule: "Long-running multi-agent dispatches (estimated >30s) are fragmented into sub-dispatches of 2-3min each. Between sub-dispatches, the orchestrator emits a proactive [arka:checkpoint] prompt announcing the next sub-dispatch and inviting user context injection. The user can inject (continue with addition) or stay silent (proceed)."
251
- enforcement: "Orchestrator wraps multi-step work in checkpointed dispatches; reviewer conflicts escalated to user when strategic (taste/business knowledge), resolved silently when purely technical; QG deliberations are visible, not just verdict."
251
+ enforcement: "Orchestrator wraps multi-step work in checkpointed dispatches; reviewer conflicts escalated to user when strategic (taste/business knowledge), resolved on the record when purely technical (evidence cited in the verdict notes — never silently; the earlier self-contradiction was resolved 2026-07-30 in favour of visibility); QG deliberations are visible, not just verdict."
252
252
 
253
253
  - id: hybrid-learning
254
254
  rule: "ArkaOS learns from user corrections via hybrid mechanism: implicit auto-detection with confidence scoring for typical corrections (default), explicit Marta-led confirmation for high-leverage rules (top-level rule candidates) or rules that contradict existing memory. Marta is the owner of the learning loop. Memory rules carry a confidence field that climbs as the rule is applied without correction."
@@ -492,3 +492,6 @@ amendments:
492
492
  - version: "4.12.0"
493
493
  date: "2026-07-09"
494
494
  changes: "Interaction Reform PR2 (operator-approved plan, 2026-07-09): added gate-judges MUST rule — plan-judge at Gate 2 and output-judge at Gate 4 for MEDIUM/HIGH work, structured JudgeVerdict (core.governance.judge), frontier tier, arkaos-not-yes-man standard applied to agents, user_challenge surfacing for technically wrong requests; verdicts recorded to judge-verdicts.jsonl."
495
+ - version: "4.43.0"
496
+ date: "2026-07-30"
497
+ changes: "PR-B4 (operator-approved repair-campaign plan): inter-agent-checkpoints enforcement text amended — technical reviewer conflicts change from 'resolved silently' to 'resolved on the record' (evidence cited in verdict notes), resolving the self-contradiction with the same enforcement's 'QG deliberations are visible' clause in favour of visibility. Rule text, level and scope unchanged."
@@ -2,11 +2,23 @@
2
2
  # ArkaOS PreToolUse hook for dynamic agent provisioning.
3
3
  # Intercepts Task tool calls: if subagent_type is not present in the
4
4
  # project's .claude/agents/, copies it from ArkaOS core when available,
5
- # or blocks with an approval-request message when the agent must be
6
- # created via `/platform-arka agent provision <name>`.
5
+ # or surfaces an approval-request message WITHOUT blocking when the
6
+ # agent is unknown. The set of valid subagent_types is not enumerable
7
+ # here — runtime built-ins (general-purpose, statusline-setup, …)
8
+ # change with Claude Code versions and plugins add their own — so any
9
+ # blocking arm on "unknown" breaks legitimate dispatches (QG PR-B5 r1
10
+ # reproduced exit 2 on general-purpose). Provision when we know the
11
+ # name; stand aside when we don't.
7
12
 
8
13
  set -euo pipefail
9
14
 
15
+ # No jq, no payload parse. .claude/rules/bash-hooks.md:11-12 prescribes
16
+ # a python3 fallback for jq; this gate is OPTIONAL by design, so it
17
+ # stands aside instead of paying a python spawn on every Task call —
18
+ # and per the same rules file ("exit code 2 = block action", last
19
+ # bullet), exiting 0 here can never break a dispatch.
20
+ command -v jq >/dev/null 2>&1 || exit 0
21
+
10
22
  # ─── Shared Python resolver (exports ARKA_PY) ──────────────────────────
11
23
  # The resolver guards its own ARKA_PY assignment with `|| true`, so sourcing
12
24
  # is set -e-safe even on the last-resort fallback. The trailing `|| true`
@@ -49,7 +61,6 @@ if [ -z "$core_root" ]; then
49
61
  fi
50
62
 
51
63
  if [ -d "$core_root/departments" ]; then
52
- mkdir -p "$project_agents_dir"
53
64
  set +e
54
65
  "$ARKA_PY" - "$core_root" "$subagent_type" "$target" <<'PY'
55
66
  import os, re, sys
@@ -105,7 +116,10 @@ if md_path is not None:
105
116
 
106
117
  content = "\n".join(parts) + "\n"
107
118
 
108
- # Atomic write: temp file in same dir, then os.replace.
119
+ # Create the parent only on the arm that actually writes (a dispatch
120
+ # of an unknown name must not scatter empty .claude/agents/ dirs), then
121
+ # atomic write: temp file in same dir, then os.replace.
122
+ target.parent.mkdir(parents=True, exist_ok=True)
109
123
  tmp = target.with_suffix(".md.tmp")
110
124
  tmp.write_text(content)
111
125
  os.replace(tmp, target)
@@ -128,15 +142,22 @@ PY
128
142
  esac
129
143
  fi
130
144
 
131
- # Agent not in project and not in core surface an approval-request.
145
+ # Agent not in project, and the core lookup either found nothing or
146
+ # never ran. Say exactly which, and NEVER block: this name may be a
147
+ # runtime built-in or a plugin agent this script cannot know about —
148
+ # if it truly doesn't exist, the Task dispatch itself fails with the
149
+ # runtime's own error.
150
+ if [ -d "$core_root/departments" ]; then
151
+ core_note="no ArkaOS core agent of that name was found (core searched at: $core_root)"
152
+ else
153
+ core_note="the ArkaOS core lookup did not run (no departments/ under: ${core_root:-<unset>})"
154
+ fi
132
155
  cat >&2 <<MSG
133
- [arka:provision-needed] Agent '$subagent_type' is not installed in this
134
- project and does not exist in ArkaOS core. To create it, run:
135
-
136
- /platform-arka agent provision $subagent_type
137
-
138
- This opens the Skill Architect flow which drafts the agent YAML with
139
- 4-framework DNA, goes through Quality Gate, and commits to core before
140
- propagating to the project. Blocking dispatch until the agent exists.
156
+ [arka:provision-needed] Agent '$subagent_type' is not in this project's
157
+ .claude/agents/, and $core_note.
158
+ If ArkaOS should provide this agent, create it at
159
+ .claude/agents/$subagent_type.md, or run 'npx arkaos update' so ArkaOS
160
+ core ships a definition this gate can copy.
161
+ Runtime built-ins and plugin agents are unaffected dispatch proceeds.
141
162
  MSG
142
- exit 2
163
+ exit 0
@@ -8,14 +8,19 @@ flow skill instructions), closing the "labels gratuitos" loop from the
8
8
  evals ADR.
9
9
 
10
10
  ``--kind qg`` is the AGGREGATE path and runs the anti-self-approval
11
- guard (PR-B3): ``--session-id`` is mandatory and validated, the
12
- session's reviewer ledger must hold at least two hook-captured
13
- reviewer verdicts, no evidence_digest present on both sides may
14
- disagree (an absent digest warns PR-B4 makes dispatch populate it),
15
- no CONFIRMED reviewer blocker may disappear silently, and an APPROVED
16
- aggregate may not stand over a rejecting reviewer. The label records
17
- ONLY once AGGREGATE.json is verifiably on disk an
18
- accepted-but-unwritten aggregate is a refusal, not a success.
11
+ guard (PR-B3, hardened in PR-B4): ``--session-id`` is mandatory and
12
+ validated, the session's reviewer ledger must hold at least two
13
+ hook-captured reviewer verdicts, the digest chain must hold (the
14
+ aggregate and every counted reviewer artifact carry evidence_digest;
15
+ a reviewer's digest that predates the final report needs an explicit
16
+ ``digest_carries`` justification), the session must not be stamped
17
+ ended, no CONFIRMED reviewer blocker may disappear silently, and an
18
+ APPROVED aggregate may not stand over a rejecting reviewer.
19
+ Dispatch-shape refusals are verdict-aware: they refuse an APPROVED
20
+ aggregate and demote to warnings on a REJECTED one, so a rejection
21
+ label survives a bad delta. The label records ONLY once
22
+ AGGREGATE.json is verifiably on disk — an accepted-but-unwritten
23
+ aggregate is a refusal, not a success.
19
24
 
20
25
  ``--kind reviewer`` records an individual reviewer's verdict as a
21
26
  label and cross-references the ledger by ``verdict_digest`` — it never
@@ -5,12 +5,19 @@ reviewer artifacts behind it — the orchestrator's own words, recorded
5
5
  as a completed gate. This guard makes that shape unrecordable AS AN
6
6
  AGGREGATE: an aggregate verdict is only recorded when the session's
7
7
  reviewer ledger holds at least two HOOK-CAPTURED reviewer verdicts,
8
- no evidence_digest present on both sides disagrees (dispatch does not
9
- yet REQUIRE the digest PR-B4 makes it mandatory; an absent digest
10
- warns instead of refusing, and when reviewers do populate it, as this
11
- PR's own r1 artifacts did, the comparison decides), no CONFIRMED
12
- reviewer blocker disappears silently, and an APPROVED aggregate
13
- stands over no rejecting reviewer.
8
+ the digest chain holds (PR-B4 dispatch shape: the aggregate and every
9
+ counted reviewer artifact must carry evidence_digest, and each
10
+ reviewer's digest must match the aggregate's or be excused by an
11
+ explicit, justified ``digest_carries`` entry naming the report that
12
+ reviewer actually reviewed), no CONFIRMED reviewer blocker disappears
13
+ silently, and an APPROVED aggregate stands over no rejecting reviewer.
14
+
15
+ Severity is verdict-aware (PR-B4): fabrication vectors — quorum, an
16
+ APPROVED over a rejecting reviewer, a vanishing CONFIRMED blocker —
17
+ refuse regardless of verdict; dispatch-shape issues (digest chain,
18
+ session binding) refuse only an APPROVED aggregate and demote to
19
+ warnings on a REJECTED one, so a rejection label survives exactly the
20
+ case where the CQO catches a bad delta.
14
21
 
15
22
  Only records whose ``source`` is in ``CAPTURE_SOURCES`` count: a
16
23
  record the orchestrator could write itself (any other source) would
@@ -27,16 +34,21 @@ home directory and this guard runs in the same trust domain as the
27
34
  orchestrator it polices — a determined orchestrator could forge
28
35
  records with a file write, either to FABRICATE a quorum or to
29
36
  SUPPRESS an existing verdict (a forged newer record supersedes it —
30
- same capability ceiling, different mechanics). Session ids are
31
- validated against the ledger's safety rule, but nothing yet binds an
32
- aggregate to the runtime session that produced the reviews — a past
33
- session holding two reviewer records is a reusable quorum token
34
- until PR-B4 binds the dispatch side. And ``--kind reviewer``
35
- (record_cli) remains an unguarded label path for reviewer
36
- identities: its ledger cross-reference is provenance, not
37
- admission. The guard turns ACCIDENTAL self-approval into
38
- deliberate, transcript-visible forgery; it does not and cannot make
39
- forgery impossible from inside the same account.
37
+ same capability ceiling, different mechanics). Session binding
38
+ (PR-B4) closes the COMMON reuse path, not every path: the SessionEnd
39
+ hook stamps a session's ledger directory (``.ended``), and an
40
+ APPROVED aggregate citing a stamped session is refused. A session
41
+ that crashes never fires SessionEnd and stays unstamped — and in
42
+ observed practice SessionEnd fires for a minority of sessions, so an
43
+ unstamped ledger proves nothing about liveness. A session still open
44
+ elsewhere is citable from this one, and the stamp itself is a file
45
+ the same account could delete: the binding is evidence from the
46
+ hook boundary, not a cryptographic seal. ``--kind
47
+ reviewer`` (record_cli) likewise remains an unguarded label path for
48
+ reviewer identities: its ledger cross-reference is provenance, not
49
+ admission. The guard turns ACCIDENTAL self-approval into deliberate,
50
+ transcript-visible forgery; it does not and cannot make forgery
51
+ impossible from inside the same account.
40
52
  """
41
53
 
42
54
  from __future__ import annotations
@@ -286,35 +298,157 @@ def _covered(key: frozenset[str], aggregate: dict, approved: bool) -> str:
286
298
  return "absent"
287
299
 
288
300
 
289
- def _digest_reasons(
301
+ def _carries(aggregate: dict) -> dict[str, dict]:
302
+ """Declared digest carries, keyed by canonical reviewer identity."""
303
+ carries: dict[str, dict] = {}
304
+ for entry in aggregate.get("digest_carries") or []:
305
+ if isinstance(entry, dict):
306
+ carries[_identity(str(entry.get("reviewer") or ""))] = entry
307
+ return carries
308
+
309
+
310
+ def _carry_issue(
311
+ reviewer_id: str, their: str, agg_digest: str, carry: dict | None
312
+ ) -> str | None:
313
+ """Why a digest mismatch is NOT excused by a declared carry.
314
+
315
+ None means the carry stands: it names the digest the reviewer's own
316
+ artifact carries, with a substantive reason. Anything else is the
317
+ plain mismatch — an undeclared carry, a carry pointing at a digest
318
+ the reviewer never reviewed, or a bare justification.
319
+ """
320
+ if carry is None:
321
+ return (
322
+ f"evidence_digest mismatch: {reviewer_id} reviewed "
323
+ f"{their[:12]}…, aggregate cites {agg_digest[:12]}… — not "
324
+ "the same evidence report; re-dispatch the reviewer or "
325
+ "declare a justified digest_carries entry (PR-B4)"
326
+ )
327
+ if _norm(carry.get("evidence_digest")) != their:
328
+ return (
329
+ f"digest carry for {reviewer_id} names "
330
+ f"{_norm(carry.get('evidence_digest'))[:12]}… but the "
331
+ f"reviewer's artifact carries {their[:12]}… — a carry must "
332
+ "cite the report that reviewer actually reviewed"
333
+ )
334
+ if len(_norm(carry.get("reason"))) < _MIN_REFUTE_DETAIL:
335
+ return (
336
+ f"digest carry for {reviewer_id} lacks a substantive reason "
337
+ f"(>= {_MIN_REFUTE_DETAIL} chars — the same bar as a REFUTED "
338
+ "drop): why does the earlier review still stand?"
339
+ )
340
+ return None
341
+
342
+
343
+ _MISSING_AGGREGATE_DIGEST = (
344
+ "aggregate carries no evidence_digest — the dispatch shape (PR-B4) "
345
+ "requires the aggregator to cite the report it aggregated "
346
+ "(report_digest from the evidence --json output)"
347
+ )
348
+ _MISSING_REVIEWER_DIGEST = (
349
+ "{rid} artifact carries no evidence_digest — the dispatch must "
350
+ "populate it (PR-B4 dispatch shape); re-dispatch that reviewer "
351
+ "with the report_digest"
352
+ )
353
+ _CARRY_ACCEPTED = (
354
+ "digest carry accepted: {rid} reviewed {their}… while the "
355
+ "aggregate cites {agg}… — justification on the record"
356
+ )
357
+
358
+
359
+ def _digest_issues(
290
360
  aggregate: dict, verdicts: list[tuple[str, dict]]
291
361
  ) -> tuple[list[str], list[str]]:
292
- """Compare evidence_digest across aggregate and reviewer verdicts."""
293
- reasons: list[str] = []
294
- warnings: list[str] = []
362
+ """Dispatch-shape and integrity issues on the digest chain (PR-B4).
363
+
364
+ Returns (issues, notes). Issues are verdict-aware at the caller:
365
+ they refuse an APPROVED aggregate and are demoted to warnings on a
366
+ REJECTED one. Notes (accepted carries) are always warnings — a
367
+ carry is legitimate, and legitimate is not invisible.
368
+ """
369
+ issues: list[str] = []
370
+ notes: list[str] = []
295
371
  agg_digest = _norm(aggregate.get("evidence_digest"))
296
372
  if not agg_digest:
297
- warnings.append("aggregate carries no evidence_digest")
298
- return reasons, warnings
299
- seen_any = False
373
+ issues.append(_MISSING_AGGREGATE_DIGEST)
374
+ carries = _carries(aggregate)
300
375
  for reviewer_id, verdict in verdicts:
301
376
  their = _norm(verdict.get("evidence_digest"))
302
377
  if not their:
378
+ issues.append(_MISSING_REVIEWER_DIGEST.format(rid=reviewer_id))
303
379
  continue
304
- seen_any = True
305
- if their != agg_digest:
306
- reasons.append(
307
- f"evidence_digest mismatch: {reviewer_id} reviewed "
308
- f"{their[:12]}…, aggregate cites {agg_digest[:12]}… — "
309
- "not the same evidence report"
310
- )
311
- if not seen_any:
312
- warnings.append(
313
- "no reviewer artifact carries evidence_digest (dispatch "
314
- "shape — PR-B4 enforces it); digest comparison had nothing "
315
- "to bite on"
380
+ if not agg_digest or their == agg_digest:
381
+ continue
382
+ issue = _carry_issue(
383
+ reviewer_id, their, agg_digest, carries.get(_identity(reviewer_id))
316
384
  )
317
- return reasons, warnings
385
+ if issue is not None:
386
+ issues.append(issue)
387
+ else:
388
+ notes.append(_CARRY_ACCEPTED.format(
389
+ rid=reviewer_id, their=their[:12], agg=agg_digest[:12]
390
+ ))
391
+ return issues, notes
392
+
393
+
394
+ def _ended_issues(session_id: str, artifact_names: list[str]) -> list[str]:
395
+ """A stamped session is not a live quorum (PR-B4 session binding).
396
+
397
+ M4 hardening (PR-B5): the stamp only outlaws records that PREDATE
398
+ it. A counted record captured strictly after the stamp's mtime
399
+ proves the hook boundary came back to life under this session id —
400
+ never observed on the dev machine, where the stamp shipped one
401
+ release earlier and the ledger held a single stamped session when
402
+ this was written; the refusal must not outlive its own evidence.
403
+ Ties and unreadable mtimes keep the refusal: equality cannot
404
+ distinguish before from after, and fail-closed is the guard's
405
+ resting state.
406
+ """
407
+ from core.governance.reviewer_ledger import ENDED_NAME
408
+
409
+ session_dir = ledger_root() / session_id
410
+ stamp = session_dir / ENDED_NAME
411
+ if not stamp.is_file():
412
+ return []
413
+ try:
414
+ newest = max(
415
+ (session_dir / name).stat().st_mtime for name in artifact_names
416
+ )
417
+ if newest > stamp.stat().st_mtime:
418
+ return []
419
+ except (OSError, ValueError):
420
+ pass # no readable counted record newer than the stamp
421
+ return [
422
+ f"session {session_id} is marked ended (SessionEnd stamped its "
423
+ "ledger) — a past session's reviewer records are not a reusable "
424
+ "quorum token; run the reviews in the live session"
425
+ ]
426
+
427
+
428
+ def _check_key_warnings(
429
+ verdicts: list[tuple[str, dict]]
430
+ ) -> list[str]:
431
+ """CONFIRMED blockers filed without a check key — warning only.
432
+
433
+ Coverage still matches on detail/file tokens (_blocker_key keeps
434
+ liveness), but the dispatch shape asks reviewers to name the
435
+ evidence check so coverage means shared vocabulary, not prose luck.
436
+ """
437
+ warnings: list[str] = []
438
+ for reviewer_id, verdict in verdicts:
439
+ for blocker in verdict.get("blockers") or []:
440
+ if not isinstance(blocker, dict):
441
+ continue
442
+ if _norm(blocker.get("verdict")) != "confirmed":
443
+ continue
444
+ if not _tokens(blocker.get("check")) and _blocker_key(blocker):
445
+ warnings.append(
446
+ f"CONFIRMED blocker ({reviewer_id}) filed without a "
447
+ "check key — matched on detail/file tokens; the "
448
+ "dispatch shape (PR-B4) asks for the evidence check "
449
+ "name in 'check'"
450
+ )
451
+ return warnings
318
452
 
319
453
 
320
454
  _COVERAGE_REASONS = {
@@ -455,7 +589,9 @@ def check_aggregate(aggregate: dict, session_id: str) -> GuardResult:
455
589
  reviewers, [name for name, _ in counted]
456
590
  )
457
591
  verdicts = [(str(r.get("reviewer_id")), r["verdict"]) for _, r in counted]
458
- reasons, warnings = _reasons(aggregate, verdicts)
592
+ reasons, warnings = _reasons(
593
+ aggregate, verdicts, session_id, [name for name, _ in counted]
594
+ )
459
595
  return GuardResult(
460
596
  ok=not reasons,
461
597
  reasons=reasons,
@@ -466,17 +602,42 @@ def check_aggregate(aggregate: dict, session_id: str) -> GuardResult:
466
602
 
467
603
 
468
604
  def _reasons(
469
- aggregate: dict, verdicts: list[tuple[str, dict]]
605
+ aggregate: dict,
606
+ verdicts: list[tuple[str, dict]],
607
+ session_id: str,
608
+ artifact_names: list[str],
470
609
  ) -> tuple[list[str], list[str]]:
471
- """Every refusal reason and warning for one aggregate."""
610
+ """Every refusal reason and warning for one aggregate.
611
+
612
+ Two severities, split on the aggregate's own verdict (PR-B4 item 7).
613
+ Fabrication vectors — an APPROVED standing over a rejecting
614
+ reviewer, a CONFIRMED blocker vanishing — refuse regardless of
615
+ verdict. Dispatch-shape issues (digest chain, session binding)
616
+ refuse only an APPROVED aggregate: refusing a REJECTED one over
617
+ shape would throw away the rejection label in exactly the case
618
+ where the CQO catches a bad delta, and a recorded rejection
619
+ launders nothing — the redo loop continues either way.
620
+ """
472
621
  approved = _norm(aggregate.get("verdict")) == "approved"
473
- digest_reasons, warnings = _digest_reasons(aggregate, verdicts)
622
+ issues, notes = _digest_issues(aggregate, verdicts)
623
+ issues += _ended_issues(session_id, artifact_names)
474
624
  reasons = (
475
- digest_reasons
476
- + _verdict_reasons(aggregate, verdicts)
625
+ _verdict_reasons(aggregate, verdicts)
477
626
  + _blocker_reasons(aggregate, verdicts, approved)
478
627
  )
479
- return reasons, warnings + _own_finding_warnings(aggregate, verdicts)
628
+ warnings = (
629
+ notes
630
+ + _check_key_warnings(verdicts)
631
+ + _own_finding_warnings(aggregate, verdicts)
632
+ )
633
+ if approved:
634
+ return issues + reasons, warnings
635
+ demoted = [
636
+ f"{issue} [recorded anyway: the refusal is verdict-aware "
637
+ "(PR-B4) — a REJECTED label is never lost to dispatch shape]"
638
+ for issue in issues
639
+ ]
640
+ return reasons, demoted + warnings
480
641
 
481
642
 
482
643
  def write_aggregate(