arkaos 4.43.0 → 4.45.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 (50) hide show
  1. package/THE-ARKAOS-GUIDE.md +1 -1
  2. package/VERSION +1 -1
  3. package/config/hooks/agent-provision.sh +35 -14
  4. package/config/output-styles/arkaos.md +1 -1
  5. package/core/governance/aggregate_guard.py +32 -6
  6. package/core/governance/quality_api.py +13 -17
  7. package/core/governance/review_workflow.py +26 -12
  8. package/core/hooks/session_start.py +17 -0
  9. package/core/hooks/stop.py +67 -21
  10. package/core/runtime/opencode_hooks.py +13 -10
  11. package/core/synapse/layers.py +85 -12
  12. package/harness/codex/AGENTS.md +1 -1
  13. package/harness/copilot/copilot-instructions.md +1 -1
  14. package/harness/cursor/rules/arkaos.mdc +2 -2
  15. package/harness/gemini/GEMINI.md +1 -1
  16. package/harness/opencode/AGENTS.md +1 -1
  17. package/harness/opencode/agents/arka-architect-gabriel.md +1 -1
  18. package/harness/opencode/agents/arka-brand-director-valentina.md +1 -1
  19. package/harness/opencode/agents/arka-cfo-helena.md +1 -1
  20. package/harness/opencode/agents/arka-chief-of-staff-afonso.md +1 -1
  21. package/harness/opencode/agents/arka-community-strategist-beatriz.md +1 -1
  22. package/harness/opencode/agents/arka-content-strategist-rafael.md +1 -1
  23. package/harness/opencode/agents/arka-conversion-strategist-ines.md +1 -1
  24. package/harness/opencode/agents/arka-coo-sofia.md +1 -1
  25. package/harness/opencode/agents/arka-copy-director-eduardo.md +1 -1
  26. package/harness/opencode/agents/arka-cqo-marta.md +1 -1
  27. package/harness/opencode/agents/arka-cto-marco.md +1 -1
  28. package/harness/opencode/agents/arka-design-ops-lead-iris.md +1 -1
  29. package/harness/opencode/agents/arka-ecom-director-ricardo.md +1 -1
  30. package/harness/opencode/agents/arka-knowledge-director-clara.md +1 -1
  31. package/harness/opencode/agents/arka-leadership-director-rodrigo.md +1 -1
  32. package/harness/opencode/agents/arka-marketing-director-luna.md +1 -1
  33. package/harness/opencode/agents/arka-ops-lead-daniel.md +1 -1
  34. package/harness/opencode/agents/arka-pm-director-carolina.md +1 -1
  35. package/harness/opencode/agents/arka-revops-lead-vicente.md +1 -1
  36. package/harness/opencode/agents/arka-saas-strategist-tiago.md +1 -1
  37. package/harness/opencode/agents/arka-sales-director-miguel.md +1 -1
  38. package/harness/opencode/agents/arka-strategy-director-tomas.md +1 -1
  39. package/harness/opencode/agents/arka-tech-director-francisca.md +1 -1
  40. package/harness/opencode/agents/arka-tech-lead-paulo.md +1 -1
  41. package/harness/opencode/agents/arka-video-producer-simao.md +1 -1
  42. package/harness/zed/.rules +1 -1
  43. package/installer/adapters/claude-code.js +88 -3
  44. package/installer/doctor.js +121 -3
  45. package/installer/index.js +17 -0
  46. package/installer/skill-deploy.js +43 -2
  47. package/installer/update.js +12 -0
  48. package/knowledge/skills-manifest.json +1 -1
  49. package/package.json +1 -1
  50. package/pyproject.toml +1 -1
@@ -1,6 +1,6 @@
1
1
  # The ArkaOS Guide
2
2
 
3
- > v4.43.0 — 89 agents, 17 departments, 332 skills, 297 commands, 18 ADRs.
3
+ > v4.45.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.43.0
1
+ 4.45.0
@@ -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
@@ -32,7 +32,7 @@ You are operating as ArkaOS — the Operating System for AI Agent Teams. You orc
32
32
  ## Response Style
33
33
 
34
34
  - Be direct and concise. Lead with the action or answer, not preamble.
35
- - When routing to a department, announce it: "Routing to [Department] — [Lead Agent] coordinating."
35
+ - When routing to a department, INVOKE its skill first `Skill(arka-dev)`, `Skill(arka-brand)`, `Skill(arka-content)`, … then announce `[arka:routing] <dept> -> <lead>`. Announcing without invoking is not routing: the department's workflows do not enter context until the tool call.
36
36
  - Use structured output: headers, tables, bullet points. No walls of text.
37
37
  - For code: follow SOLID principles, clean code, conventional commits.
38
38
  - For content: human-quality writing, no AI cliches, culturally appropriate for the target market.
@@ -391,12 +391,33 @@ def _digest_issues(
391
391
  return issues, notes
392
392
 
393
393
 
394
- def _ended_issues(session_id: str) -> list[str]:
395
- """A stamped session is not a live quorum (PR-B4 session binding)."""
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
+ """
396
407
  from core.governance.reviewer_ledger import ENDED_NAME
397
408
 
398
- if not (ledger_root() / session_id / ENDED_NAME).is_file():
409
+ session_dir = ledger_root() / session_id
410
+ stamp = session_dir / ENDED_NAME
411
+ if not stamp.is_file():
399
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
400
421
  return [
401
422
  f"session {session_id} is marked ended (SessionEnd stamped its "
402
423
  "ledger) — a past session's reviewer records are not a reusable "
@@ -568,7 +589,9 @@ def check_aggregate(aggregate: dict, session_id: str) -> GuardResult:
568
589
  reviewers, [name for name, _ in counted]
569
590
  )
570
591
  verdicts = [(str(r.get("reviewer_id")), r["verdict"]) for _, r in counted]
571
- reasons, warnings = _reasons(aggregate, verdicts, session_id)
592
+ reasons, warnings = _reasons(
593
+ aggregate, verdicts, session_id, [name for name, _ in counted]
594
+ )
572
595
  return GuardResult(
573
596
  ok=not reasons,
574
597
  reasons=reasons,
@@ -579,7 +602,10 @@ def check_aggregate(aggregate: dict, session_id: str) -> GuardResult:
579
602
 
580
603
 
581
604
  def _reasons(
582
- aggregate: dict, verdicts: list[tuple[str, dict]], session_id: str
605
+ aggregate: dict,
606
+ verdicts: list[tuple[str, dict]],
607
+ session_id: str,
608
+ artifact_names: list[str],
583
609
  ) -> tuple[list[str], list[str]]:
584
610
  """Every refusal reason and warning for one aggregate.
585
611
 
@@ -594,7 +620,7 @@ def _reasons(
594
620
  """
595
621
  approved = _norm(aggregate.get("verdict")) == "approved"
596
622
  issues, notes = _digest_issues(aggregate, verdicts)
597
- issues += _ended_issues(session_id)
623
+ issues += _ended_issues(session_id, artifact_names)
598
624
  reasons = (
599
625
  _verdict_reasons(aggregate, verdicts)
600
626
  + _blocker_reasons(aggregate, verdicts, approved)
@@ -8,26 +8,15 @@ Provides a simple programmatic interface for the Quality Gate system:
8
8
  """
9
9
 
10
10
  import json
11
+ from datetime import UTC
11
12
  from pathlib import Path
12
13
  from typing import Any
13
14
 
14
15
  from core.governance.quality_router import (
15
- QualityRouter,
16
- ReviewAssignment,
17
- ReviewerType,
18
16
  DeliverableType,
19
- ReviewPriority,
20
- route_deliverable,
21
- )
22
- from core.governance.review_workflow import (
23
- ReviewWorkflowEngine,
24
- ReviewWorkflow,
25
- ReviewPhase,
26
- Verdict,
27
- ReviewerOpinion,
17
+ QualityRouter,
28
18
  )
29
19
 
30
-
31
20
  _STATE_DIR = Path.home() / ".arkaos" / "quality-gate"
32
21
  _QUEUE_FILE = _STATE_DIR / "queue.json"
33
22
  _WORKFLOWS_FILE = _STATE_DIR / "workflows.json"
@@ -147,6 +136,14 @@ def record_verdict(
147
136
  ) -> bool:
148
137
  """Record a reviewer's verdict for a submission.
149
138
 
139
+ .. deprecated:: 4.44.0 (PR-B5)
140
+ Writes an unguarded label: no ledger artifact, no digest, no
141
+ aggregate guard. Use ``core.evals.record_cli``: ``--kind qg``
142
+ passes ``aggregate_guard.check_aggregate``, which requires at
143
+ least two hook-captured reviewer artifacts in the session
144
+ ledger; ``--kind reviewer`` remains an unguarded label path —
145
+ its ledger cross-reference is provenance, not admission.
146
+
150
147
  Args:
151
148
  submission_id: ID of the submission
152
149
  reviewer: Agent ID of reviewer
@@ -157,8 +154,6 @@ def record_verdict(
157
154
  Returns:
158
155
  True if verdict recorded successfully
159
156
  """
160
- reviewer_type = ReviewerType(reviewer) if reviewer in [r.value for r in ReviewerType] else None
161
-
162
157
  queue = _load_queue()
163
158
  for item in queue:
164
159
  if item["id"] == submission_id:
@@ -217,9 +212,9 @@ def clear_resolved(older_than_days: int = 7) -> int:
217
212
  Returns:
218
213
  Number of items removed
219
214
  """
220
- from datetime import datetime, timedelta, timezone
215
+ from datetime import datetime, timedelta
221
216
 
222
- cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
217
+ cutoff = datetime.now(UTC) - timedelta(days=older_than_days)
223
218
  queue = _load_queue()
224
219
  original_count = len(queue)
225
220
 
@@ -268,6 +263,7 @@ class QualityGateAPI:
268
263
  feedback: str = "",
269
264
  flagged_rules: list[str] | None = None,
270
265
  ) -> bool:
266
+ """Deprecated with the module function — see ``record_verdict``."""
271
267
  return record_verdict(submission_id, reviewer, verdict, feedback, flagged_rules)
272
268
 
273
269
  def list_pending(self, reviewer: str | None = None) -> list[dict]:
@@ -1,5 +1,15 @@
1
1
  """Review Workflow Engine — structured review phases for Quality Gate.
2
2
 
3
+ .. deprecated:: 4.44.0 (PR-B5)
4
+ This engine models review phases in memory and records verdicts
5
+ WITHOUT the reviewer ledger or the aggregate guard behind them —
6
+ nothing here proves a verdict was authored at the hook boundary.
7
+ The guarded path is ``core.evals.record_cli``: ``--kind qg`` passes
8
+ ``aggregate_guard.check_aggregate``, which requires hook-captured
9
+ reviewer artifacts in the session ledger. The module stays
10
+ importable because live tests pin its phase mechanics, but no new
11
+ caller should route verdicts through it.
12
+
3
13
  Phases:
4
14
  1. Submission — deliverable submitted for review
5
15
  2. Triage — initial assessment, assign reviewers
@@ -16,13 +26,17 @@ Verdicts:
16
26
  """
17
27
 
18
28
  import uuid
29
+ from collections.abc import Callable
19
30
  from dataclasses import dataclass, field
20
- from datetime import datetime, timezone
31
+ from datetime import UTC, datetime
21
32
  from enum import Enum
22
- from typing import Callable
23
33
 
24
34
 
25
- class ReviewPhase(str, Enum):
35
+ # UP042 suppressed on the three enums below: switching to StrEnum changes
36
+ # str(member) from "Verdict.APPROVED" to "APPROVED" — behaviour, not
37
+ # style — and this module is deprecated (see the module docstring), not
38
+ # being modernised.
39
+ class ReviewPhase(str, Enum): # noqa: UP042
26
40
  SUBMISSION = "submission"
27
41
  TRIAGE = "triage"
28
42
  REVIEW = "review"
@@ -31,14 +45,14 @@ class ReviewPhase(str, Enum):
31
45
  DELIVERY = "delivery"
32
46
 
33
47
 
34
- class Verdict(str, Enum):
48
+ class Verdict(str, Enum): # noqa: UP042
35
49
  APPROVED = "APPROVED"
36
50
  REJECTED = "REJECTED"
37
51
  REQUEST_CHANGES = "REQUEST_CHANGES"
38
52
  ESCALATE = "ESCALATE"
39
53
 
40
54
 
41
- class ReviewerOpinion(str, Enum):
55
+ class ReviewerOpinion(str, Enum): # noqa: UP042
42
56
  APPROVE = "APPROVE"
43
57
  REJECT = "REJECT"
44
58
  CHANGES = "REQUEST_CHANGES"
@@ -79,7 +93,7 @@ class ReviewWorkflow:
79
93
 
80
94
  def __post_init__(self):
81
95
  if not self.submitted_at:
82
- self.submitted_at = datetime.now(timezone.utc).isoformat()
96
+ self.submitted_at = datetime.now(UTC).isoformat()
83
97
 
84
98
 
85
99
  @dataclass
@@ -156,7 +170,7 @@ class ReviewWorkflowEngine:
156
170
  self._announce_phase_start(ReviewPhase.TRIAGE, workflow_id)
157
171
 
158
172
  workflow.phase = ReviewPhase.TRIAGE
159
- workflow.triage_at = datetime.now(timezone.utc).isoformat()
173
+ workflow.triage_at = datetime.now(UTC).isoformat()
160
174
 
161
175
  result = PhaseResult(
162
176
  phase=ReviewPhase.TRIAGE,
@@ -191,7 +205,7 @@ class ReviewWorkflowEngine:
191
205
  self._announce_phase_start(ReviewPhase.REVIEW, workflow_id)
192
206
 
193
207
  workflow.phase = ReviewPhase.REVIEW
194
- workflow.review_started_at = datetime.now(timezone.utc).isoformat()
208
+ workflow.review_started_at = datetime.now(UTC).isoformat()
195
209
 
196
210
  result = PhaseResult(
197
211
  phase=ReviewPhase.REVIEW,
@@ -248,7 +262,7 @@ class ReviewWorkflowEngine:
248
262
  opinion=opinion,
249
263
  comments=comments,
250
264
  flagged_rules=flagged_rules or [],
251
- voted_at=datetime.now(timezone.utc).isoformat(),
265
+ voted_at=datetime.now(UTC).isoformat(),
252
266
  evidence_overall=evidence_overall,
253
267
  )
254
268
  workflow.votes.append(vote)
@@ -279,7 +293,7 @@ class ReviewWorkflowEngine:
279
293
  self._announce_phase_start(ReviewPhase.DELIBERATION, workflow_id)
280
294
 
281
295
  workflow.phase = ReviewPhase.DELIBERATION
282
- workflow.deliberation_at = datetime.now(timezone.utc).isoformat()
296
+ workflow.deliberation_at = datetime.now(UTC).isoformat()
283
297
 
284
298
  result = PhaseResult(
285
299
  phase=ReviewPhase.DELIBERATION,
@@ -331,7 +345,7 @@ class ReviewWorkflowEngine:
331
345
  workflow.phase = ReviewPhase.VERDICT
332
346
  workflow.verdict = verdict
333
347
  workflow.feedback = feedback
334
- workflow.verdict_at = datetime.now(timezone.utc).isoformat()
348
+ workflow.verdict_at = datetime.now(UTC).isoformat()
335
349
 
336
350
  self._announce_verdict(verdict, workflow_id)
337
351
 
@@ -370,7 +384,7 @@ class ReviewWorkflowEngine:
370
384
  )
371
385
 
372
386
  workflow.phase = ReviewPhase.DELIVERY
373
- workflow.delivered_at = datetime.now(timezone.utc).isoformat()
387
+ workflow.delivered_at = datetime.now(UTC).isoformat()
374
388
 
375
389
  result = PhaseResult(
376
390
  phase=ReviewPhase.DELIVERY,
@@ -76,6 +76,22 @@ _EVIDENCE_CONTRACT = (
76
76
  " 10 lines."
77
77
  )
78
78
 
79
+ # PR-A4: routing was fulfilled in PROSE — the model announced the squad
80
+ # and never invoked the Skill tool, so the department's workflows and
81
+ # contracts never entered context (the label without the payload). This
82
+ # contract defines fulfilment; enforcement stays with A5a/A5b,
83
+ # telemetry-gated.
84
+ _SKILL_ROUTING_CONTRACT = (
85
+ "\n\n[ARKA:SKILL-CONTRACT] Routing IS tool invocation. A department"
86
+ " route is fulfilled by calling the department's hub skill — e.g."
87
+ " Skill(arka-dev), Skill(arka-brand), Skill(arka-content) — BEFORE"
88
+ " substantive work; announcing the squad in prose without invoking"
89
+ " the skill is not routing; it is the label without the payload."
90
+ "\nNever derive a hub name by concatenation — [arka:skill-hint] tags"
91
+ " name the exact call to make. The [arka:trivial] bypass is"
92
+ " unchanged."
93
+ )
94
+
79
95
  _META_TAG_CONTRACT = (
80
96
  "\n\n[ARKA:META-TAG] Every substantive response ends with a single line:"
81
97
  "\n [arka:meta] kb=N research=X persona=Y gap=Z critic=W"
@@ -348,6 +364,7 @@ def build_context(cwd: str) -> str:
348
364
  """
349
365
  parts = [
350
366
  _EVIDENCE_CONTRACT,
367
+ _SKILL_ROUTING_CONTRACT,
351
368
  _META_TAG_CONTRACT,
352
369
  _authority_brief(cwd),
353
370
  _model_fabric(),
@@ -371,6 +371,60 @@ def _flow_checks(
371
371
  clear_flow_required(session_id)
372
372
 
373
373
 
374
+ def _qg_aggregate_verdict(session_id: str) -> bool | None:
375
+ """Verdict from the session's guard-accepted ``AGGREGATE.json``.
376
+
377
+ True/False when a readable aggregate for THIS session exists — the
378
+ aggregate only lands on disk through aggregate_guard (PR-B3), so it
379
+ is authoritative in both directions. None when absent, foreign or
380
+ unreadable: only then may the caller fall back to the telemetry log.
381
+ """
382
+ try:
383
+ from core.governance.reviewer_ledger import (
384
+ AGGREGATE_NAME,
385
+ _safe_id,
386
+ ledger_root,
387
+ )
388
+
389
+ if not _safe_id(session_id):
390
+ return None
391
+ rec = json.loads(
392
+ (ledger_root() / session_id / AGGREGATE_NAME).read_text(
393
+ encoding="utf-8"
394
+ )
395
+ )
396
+ except Exception:
397
+ return None
398
+ if not isinstance(rec, dict) or rec.get("session_id") != session_id:
399
+ return None
400
+ verdict = (rec.get("aggregate") or {}).get("verdict", "")
401
+ return str(verdict).upper() == "APPROVED"
402
+
403
+
404
+ def _qg_telemetry_approved(session_id: str) -> bool:
405
+ """Legacy fallback: newest ``qg.jsonl`` record for this session.
406
+
407
+ Pre-B3 sessions have no aggregate, so its absence proves nothing;
408
+ the unguarded telemetry log then keeps its historical role.
409
+ """
410
+ qg_log = Path.home() / ".arkaos" / "telemetry" / "qg.jsonl"
411
+ if not qg_log.exists():
412
+ return False
413
+ try:
414
+ for line in reversed(qg_log.read_text(encoding="utf-8").splitlines()):
415
+ if not line.strip():
416
+ continue
417
+ try:
418
+ rec = json.loads(line)
419
+ except Exception:
420
+ continue
421
+ if rec.get("session_id") == session_id:
422
+ return rec.get("verdict", "").upper() == "APPROVED"
423
+ except Exception:
424
+ pass
425
+ return False
426
+
427
+
374
428
  def _auto_doc_enqueue(
375
429
  session_id: str, transcript_path: str, raw: str | None
376
430
  ) -> None:
@@ -389,27 +443,19 @@ def _auto_doc_enqueue(
389
443
  except Exception:
390
444
  last = ""
391
445
 
392
- qg_approved = bool(re.search(r"\[arka:qg:approved\]", last, re.IGNORECASE))
393
- if not qg_approved:
394
- qg_log = Path.home() / ".arkaos" / "telemetry" / "qg.jsonl"
395
- if qg_log.exists():
396
- try:
397
- for line in reversed(
398
- qg_log.read_text(encoding="utf-8").splitlines()
399
- ):
400
- if not line.strip():
401
- continue
402
- try:
403
- rec = json.loads(line)
404
- except Exception:
405
- continue
406
- if rec.get("session_id") == session_id:
407
- qg_approved = (
408
- rec.get("verdict", "").upper() == "APPROVED"
409
- )
410
- break
411
- except Exception:
412
- pass
446
+ # The guard-accepted aggregate is authoritative either way (PR-B5,
447
+ # G7) — over the telemetry log AND over the assistant's own
448
+ # [arka:qg:approved] marker: narration must not outrank the record
449
+ # it narrates (the same laundering shape G7 closes one layer down).
450
+ # Marker and telemetry keep their historical roles only when no
451
+ # aggregate exists for the session.
452
+ from_aggregate = _qg_aggregate_verdict(session_id)
453
+ if from_aggregate is not None:
454
+ qg_approved = from_aggregate
455
+ else:
456
+ qg_approved = bool(
457
+ re.search(r"\[arka:qg:approved\]", last, re.IGNORECASE)
458
+ ) or _qg_telemetry_approved(session_id)
413
459
  if not qg_approved:
414
460
  return
415
461
 
@@ -31,7 +31,7 @@ import os
31
31
  import re
32
32
  import subprocess
33
33
  import sys
34
- from datetime import datetime, timezone
34
+ from datetime import UTC, datetime
35
35
  from pathlib import Path
36
36
 
37
37
  _TELEMETRY_DIR = Path.home() / ".arkaos" / "telemetry"
@@ -135,7 +135,10 @@ def _load_commands_registry() -> list[dict]:
135
135
 
136
136
  def _routing_lines(prompt: str, session_id: str, cwd: str) -> list[str]:
137
137
  """UserPromptSubmit routing parity (core/hooks/user_prompt_submit.py):
138
- [ARKA:ROUTE] on every prompt + L1 [dept:X] + L5 [hint:/cmd] +
138
+ [ARKA:ROUTE] on every prompt + L1 [dept:X] + L5 [arka:skill-hint]
139
+ Skill(<hub>) -> /cmd (PR-A4 imperative form; hub resolved by
140
+ layers._hub_skill, never by slug concatenation — a /mkt hint
141
+ renders Skill(arka-marketing)) +
139
142
  [ARKA:WORKFLOW-REQUIRED] on creation/implementation prompts. The
140
143
  directive strings and the verb regex are IMPORTED from the claude
141
144
  hook — one source of truth, zero drift."""
@@ -170,7 +173,7 @@ def _routing_lines(prompt: str, session_id: str, cwd: str) -> list[str]:
170
173
  hints = CommandHintsLayer(commands=commands).compute(ctx)
171
174
  if hints.tag:
172
175
  lines.append(hints.tag)
173
- except Exception: # noqa: BLE001 — routing hints are a bonus, never a blocker
176
+ except Exception:
174
177
  pass
175
178
  if prompt[0] not in ("/", "!") and _WF_VERB_RE.search(prompt):
176
179
  lines.append(_WORKFLOW_DIRECTIVE.strip())
@@ -285,7 +288,7 @@ def _action_post_tool(payload: dict) -> dict:
285
288
  _append_jsonl(
286
289
  _TELEMETRY_DIR / "opencode-tools.jsonl",
287
290
  {
288
- "ts": datetime.now(timezone.utc).isoformat(),
291
+ "ts": datetime.now(UTC).isoformat(),
289
292
  "session": session_id,
290
293
  "tool": mapped,
291
294
  "ok": bool(payload.get("ok", True)),
@@ -319,7 +322,7 @@ def _enqueue_turn_capture(session_id: str, text: str, cwd: str) -> None:
319
322
  proc.stdin.close()
320
323
  except (BrokenPipeError, OSError):
321
324
  pass
322
- except Exception: # noqa: BLE001 — a hook bridge never breaks a turn
325
+ except Exception:
323
326
  pass
324
327
 
325
328
 
@@ -347,7 +350,7 @@ def _action_idle(payload: dict) -> dict:
347
350
  _append_jsonl(
348
351
  _TELEMETRY_DIR / "opencode-compliance.jsonl",
349
352
  {
350
- "ts": datetime.now(timezone.utc).isoformat(),
353
+ "ts": datetime.now(UTC).isoformat(),
351
354
  "session": session_id,
352
355
  "kb_cite": result.reason,
353
356
  "meta_tag": "[arka:meta]" in text,
@@ -388,7 +391,7 @@ def _handoff_line(session_id: str, project: str) -> str:
388
391
  try:
389
392
  _handoff_marker(session_id).parent.mkdir(parents=True, exist_ok=True)
390
393
  _handoff_marker(session_id).write_text(
391
- json.dumps({"shown_at": datetime.now(timezone.utc).isoformat()}),
394
+ json.dumps({"shown_at": datetime.now(UTC).isoformat()}),
392
395
  encoding="utf-8",
393
396
  )
394
397
  except OSError:
@@ -440,7 +443,7 @@ def _action_memory(payload: dict) -> dict:
440
443
  handoff = _handoff_line(session_id, project)
441
444
  if handoff:
442
445
  lines.insert(0, handoff)
443
- except Exception: # noqa: BLE001 — memory is a bonus, never a blocker
446
+ except Exception:
444
447
  return {"context": []}
445
448
  return {"context": lines[:6]}
446
449
 
@@ -460,7 +463,7 @@ def _action_compact(payload: dict) -> dict:
460
463
  current = state.get_state()
461
464
  if current:
462
465
  context.append(f"Workflow activo: {json.dumps(current)[:400]}")
463
- except Exception: # noqa: BLE001 — fail-open
466
+ except Exception:
464
467
  pass
465
468
  return {"context": context}
466
469
 
@@ -488,7 +491,7 @@ def main() -> int:
488
491
  else:
489
492
  try:
490
493
  result = handler(payload)
491
- except Exception as exc: # noqa: BLE001 — fail-open, never block
494
+ except Exception as exc:
492
495
  result = {"error": f"{type(exc).__name__}: {exc}"}
493
496
  sys.stdout.write(json.dumps(result, ensure_ascii=False) + "\n")
494
497
  return 0