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.
- package/THE-ARKAOS-GUIDE.md +1 -1
- package/VERSION +1 -1
- package/config/hooks/agent-provision.sh +35 -14
- package/config/output-styles/arkaos.md +1 -1
- package/core/governance/aggregate_guard.py +32 -6
- package/core/governance/quality_api.py +13 -17
- package/core/governance/review_workflow.py +26 -12
- package/core/hooks/session_start.py +17 -0
- package/core/hooks/stop.py +67 -21
- package/core/runtime/opencode_hooks.py +13 -10
- package/core/synapse/layers.py +85 -12
- package/harness/codex/AGENTS.md +1 -1
- package/harness/copilot/copilot-instructions.md +1 -1
- package/harness/cursor/rules/arkaos.mdc +2 -2
- package/harness/gemini/GEMINI.md +1 -1
- package/harness/opencode/AGENTS.md +1 -1
- package/harness/opencode/agents/arka-architect-gabriel.md +1 -1
- package/harness/opencode/agents/arka-brand-director-valentina.md +1 -1
- package/harness/opencode/agents/arka-cfo-helena.md +1 -1
- package/harness/opencode/agents/arka-chief-of-staff-afonso.md +1 -1
- package/harness/opencode/agents/arka-community-strategist-beatriz.md +1 -1
- package/harness/opencode/agents/arka-content-strategist-rafael.md +1 -1
- package/harness/opencode/agents/arka-conversion-strategist-ines.md +1 -1
- package/harness/opencode/agents/arka-coo-sofia.md +1 -1
- package/harness/opencode/agents/arka-copy-director-eduardo.md +1 -1
- package/harness/opencode/agents/arka-cqo-marta.md +1 -1
- package/harness/opencode/agents/arka-cto-marco.md +1 -1
- package/harness/opencode/agents/arka-design-ops-lead-iris.md +1 -1
- package/harness/opencode/agents/arka-ecom-director-ricardo.md +1 -1
- package/harness/opencode/agents/arka-knowledge-director-clara.md +1 -1
- package/harness/opencode/agents/arka-leadership-director-rodrigo.md +1 -1
- package/harness/opencode/agents/arka-marketing-director-luna.md +1 -1
- package/harness/opencode/agents/arka-ops-lead-daniel.md +1 -1
- package/harness/opencode/agents/arka-pm-director-carolina.md +1 -1
- package/harness/opencode/agents/arka-revops-lead-vicente.md +1 -1
- package/harness/opencode/agents/arka-saas-strategist-tiago.md +1 -1
- package/harness/opencode/agents/arka-sales-director-miguel.md +1 -1
- package/harness/opencode/agents/arka-strategy-director-tomas.md +1 -1
- package/harness/opencode/agents/arka-tech-director-francisca.md +1 -1
- package/harness/opencode/agents/arka-tech-lead-paulo.md +1 -1
- package/harness/opencode/agents/arka-video-producer-simao.md +1 -1
- package/harness/zed/.rules +1 -1
- package/installer/adapters/claude-code.js +88 -3
- package/installer/doctor.js +121 -3
- package/installer/index.js +17 -0
- package/installer/skill-deploy.js +43 -2
- package/installer/update.js +12 -0
- package/knowledge/skills-manifest.json +1 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/THE-ARKAOS-GUIDE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# The ArkaOS Guide
|
|
2
2
|
|
|
3
|
-
> v4.
|
|
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.
|
|
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
|
|
6
|
-
#
|
|
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
|
-
#
|
|
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
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
|
215
|
+
from datetime import datetime, timedelta
|
|
221
216
|
|
|
222
|
-
cutoff = datetime.now(
|
|
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
|
|
31
|
+
from datetime import UTC, datetime
|
|
21
32
|
from enum import Enum
|
|
22
|
-
from typing import Callable
|
|
23
33
|
|
|
24
34
|
|
|
25
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(),
|
package/core/hooks/stop.py
CHANGED
|
@@ -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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
|
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
|
|
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:
|
|
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(
|
|
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:
|
|
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(
|
|
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(
|
|
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:
|
|
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:
|
|
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:
|
|
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
|