arkaos 3.72.0 → 3.73.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/VERSION CHANGED
@@ -1 +1 @@
1
- 3.72.0
1
+ 3.73.0
@@ -73,6 +73,26 @@ state the gap explicitly and propose filling it.
73
73
  Dispatch specialists via the `Agent` tool. The squad lead from Phase 3
74
74
  names them. Specialists run in parallel when work is independent.
75
75
 
76
+ **Dispatch must be announced (NON-NEGOTIABLE `dispatch-must-be-announced`).**
77
+ Immediately before each `Agent` tool call, emit on its own line:
78
+
79
+ ```
80
+ [arka:dispatch] <calling-persona> -> <specialist-id>
81
+ ```
82
+
83
+ Example before dispatching a frontend specialist from Paulo's seat:
84
+
85
+ ```
86
+ [arka:dispatch] paulo -> frontend-dev
87
+ ```
88
+
89
+ The PreToolUse specialist-enforcer (`core/workflow/specialist_enforcer.py`)
90
+ reads this marker to identify which specialist holds the floor. Without
91
+ it, the specialist's subsequent `Write`/`Edit` will block when
92
+ `hooks.specialistEnforcement=true`. The marker format mirrors
93
+ `[arka:routing]` exactly but uses the verb `dispatch` and points from
94
+ the caller to the receiver.
95
+
76
96
  ### Phase 7 — Plan and make the spec
77
97
  Run six parallel reviewers on the plan:
78
98
 
@@ -0,0 +1,169 @@
1
+ # ============================================================================
2
+ # ArkaOS — Agent File Ownership
3
+ #
4
+ # Maps file path patterns to the specialist agents (Tier 2) who own writes
5
+ # to those files. Squad leads (Tier 1) MUST dispatch the specialist via
6
+ # the Agent tool before writing to owned files — otherwise the PreToolUse
7
+ # hook blocks the write.
8
+ #
9
+ # Read by: core/workflow/specialist_enforcer.py
10
+ # Hook: config/hooks/pre-tool-use.sh (between KB-gate and flow-gate)
11
+ #
12
+ # Bypass: emit `[arka:specialist-bypass <reason>]` in the same assistant
13
+ # message immediately before the Write/Edit. Bypass is logged to
14
+ # ~/.arkaos/telemetry/specialist-dispatch.jsonl for accountability.
15
+ #
16
+ # Feature flag: hooks.specialistEnforcement in ~/.arkaos/config.json
17
+ # (defaults to false during rollout — promote to true after telemetry
18
+ # shows the rule set is stable).
19
+ # ============================================================================
20
+
21
+ version: 1
22
+
23
+ # Squad leads (Tier 1) — orchestrate, dispatch specialists, do not implement.
24
+ # The routing tag `[arka:routing] <dept> -> <name>` identifies which lead
25
+ # currently holds the floor. Lead names are normalized to lowercase by
26
+ # the enforcer before comparison.
27
+ leads:
28
+ - paulo # dev — Tech Lead
29
+ - luna # mkt — Marketing Lead
30
+ - valentina # brand — Brand Lead
31
+ - helena # fin — CFO (also c_suite)
32
+ - tomas # strat — Strategy Lead
33
+ - ricardo # ecom — E-commerce Lead
34
+ - clara # kb — Knowledge Lead
35
+ - daniel # ops — Operations Lead
36
+ - carolina # pm — Project Management Lead
37
+ - tiago # saas — SaaS Lead
38
+ - ines # landing — Landing Lead
39
+ - rafael # content — Content Lead
40
+ - beatriz # community — Community Lead
41
+ - miguel # sales — Sales Lead
42
+ - rodrigo # leadership — People Lead
43
+ - sofia # org — COO (also c_suite)
44
+
45
+ # C-Suite (Tier 0) — veto power, may write anywhere without dispatch.
46
+ # Listed here so the enforcer treats them as always-authorized.
47
+ c_suite:
48
+ - marco # CTO
49
+ - marta # CQO
50
+ - eduardo # Copy Director
51
+ - francisca # Tech Director
52
+ - sofia # COO
53
+ - helena # CFO
54
+
55
+ # Ownership rules — evaluated top-to-bottom, FIRST match wins.
56
+ # Patterns use glob syntax (fnmatch + ** for recursive directory).
57
+ # `owners` lists the specialist agent IDs (kebab-case) authorized to write.
58
+ # A persona currently holding the routing floor must match one of `owners`
59
+ # (case-insensitive). Leads always fail unless they appear in `owners`.
60
+ ownership:
61
+ # ─── Frontend ────────────────────────────────────────────────────────
62
+ - pattern: "**/*.vue"
63
+ owners: [frontend-dev]
64
+ reason: "Vue templates require frontend specialist"
65
+
66
+ - pattern: "**/*.tsx"
67
+ owners: [frontend-dev]
68
+ reason: "React TSX requires frontend specialist"
69
+
70
+ - pattern: "**/*.jsx"
71
+ owners: [frontend-dev]
72
+ reason: "React JSX requires frontend specialist"
73
+
74
+ - pattern: "**/components/**"
75
+ owners: [frontend-dev]
76
+ reason: "Component layer is frontend specialist territory"
77
+
78
+ # ─── Backend (PHP/Laravel) ───────────────────────────────────────────
79
+ - pattern: "**/app/Services/**"
80
+ owners: [senior-dev, backend-dev]
81
+ reason: "Service layer requires backend specialist"
82
+
83
+ - pattern: "**/app/Repositories/**"
84
+ owners: [senior-dev, backend-dev]
85
+ reason: "Repository layer requires backend specialist"
86
+
87
+ - pattern: "**/app/Http/Controllers/**"
88
+ owners: [senior-dev, backend-dev]
89
+ reason: "Controllers require backend specialist"
90
+
91
+ - pattern: "**/database/migrations/**"
92
+ owners: [dba, backend-dev]
93
+ reason: "Migrations require DBA review"
94
+
95
+ # ─── Security-sensitive ──────────────────────────────────────────────
96
+ - pattern: "**/.env*"
97
+ owners: [security-eng]
98
+ reason: "Environment files require security specialist"
99
+
100
+ - pattern: "**/auth/**"
101
+ owners: [security-eng, backend-dev]
102
+ reason: "Auth code requires security review"
103
+
104
+ - pattern: "core/security/**"
105
+ owners: [security-eng]
106
+ reason: "Core security module is security specialist territory"
107
+
108
+ - pattern: "config/hooks/**"
109
+ owners: [security-eng, devops-eng]
110
+ reason: "Hooks affect runtime behavior — security + devops review required"
111
+
112
+ # ─── DevOps / Infrastructure ─────────────────────────────────────────
113
+ - pattern: ".github/workflows/**"
114
+ owners: [devops-eng]
115
+ reason: "CI/CD workflows are devops specialist territory"
116
+
117
+ - pattern: "**/Dockerfile*"
118
+ owners: [devops-eng]
119
+ reason: "Container builds require devops specialist"
120
+
121
+ - pattern: "infrastructure/**"
122
+ owners: [devops-eng]
123
+ reason: "Infrastructure-as-code requires devops specialist"
124
+
125
+ # ─── Core architecture ──────────────────────────────────────────────
126
+ - pattern: "core/workflow/**/*.py"
127
+ owners: [architect, senior-dev]
128
+ reason: "Workflow engine requires architecture review"
129
+
130
+ - pattern: "core/agents/**/*.py"
131
+ owners: [architect, senior-dev]
132
+ reason: "Agent schema/loader requires architecture review"
133
+
134
+ - pattern: "core/governance/**/*.py"
135
+ owners: [architect, security-eng]
136
+ reason: "Governance code requires architecture + security review"
137
+
138
+ - pattern: "docs/adr/**"
139
+ owners: [architect]
140
+ reason: "ADRs are architect's responsibility"
141
+
142
+ # ─── Open-access (any persona may write) ─────────────────────────────
143
+ # Tests are written by the specialist who owns the code under test.
144
+ # qa-eng owns test STRATEGY, not individual test files.
145
+ - pattern: "**/tests/**"
146
+ owners: ["*"]
147
+ reason: "Tests written by specialist who owns code under test"
148
+
149
+ - pattern: "**/*.md"
150
+ owners: ["*"]
151
+ reason: "Markdown docs are open to any persona"
152
+
153
+ # Lead-allowed files — leads + c-suite ALWAYS allowed without dispatch.
154
+ # Used for cross-cutting files that touch many specialists.
155
+ lead_allowed:
156
+ - "CHANGELOG.md"
157
+ - "VERSION"
158
+ - "package.json"
159
+ - "package-lock.json"
160
+ - "pyproject.toml"
161
+ - "uv.lock"
162
+ - "README.md"
163
+ - "CLAUDE.md"
164
+ - "CONSTITUTION.md"
165
+ - "config/agent-ownership.yaml" # bootstrap — this file
166
+ - "config/constitution.yaml"
167
+ - "**/*.yaml" # squad/workflow configs
168
+ - "**/*.yml"
169
+ - "knowledge/**" # KB curation
@@ -121,6 +121,11 @@ enforcement_levels:
121
121
  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 (NON-NEGOTIABLE 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."
122
122
  enforcement: "Correction signals detected via absolute-language keywords ('sempre', 'nunca', 'no exceptions') and correction magnitude; [arka:learned-rule confidence=X] tag emitted when rule auto-saved; explicit Marta confirmation question fired when rule is high-leverage or conflicts with existing memory."
123
123
 
124
+ # ─── Rule added in PR1 Squad Intelligence Upgrade (2026-05-28) ───────
125
+ - id: dispatch-must-be-announced
126
+ rule: "When a squad lead dispatches a specialist via the Agent tool, the lead MUST emit `[arka:dispatch] <from> -> <to>` on a line of its own immediately before the dispatch call. The marker identifies the specialist to the PreToolUse specialist-enforcer so writes from the specialist pass `owner-match` instead of falling through as `no-routing-tag` or blocking the lead. The format is identical to `[arka:routing]` but uses the verb `dispatch` and points from the calling persona to the receiving specialist (e.g., `[arka:dispatch] paulo -> frontend-dev`)."
127
+ enforcement: "PreToolUse hook (config/hooks/pre-tool-use.sh) reads the dispatch marker via core.workflow.specialist_enforcer._resolve_persona. Dispatch tag overrides routing tag because it is more specific. Without it, lead writes to specialist-owned files are blocked when hooks.specialistEnforcement=true. See ADR docs/adr/2026-05-28-specialist-dispatch-subagent-blindspot.md for the architectural constraint this rule mitigates."
128
+
124
129
  quality_gate:
125
130
  description: "Mandatory pre-delivery review. Nothing ships without APPROVED verdict."
126
131
  trigger: "After the last execution phase, before delivery to user"
@@ -116,6 +116,83 @@ print(json.dumps({
116
116
  }
117
117
  }
118
118
 
119
+ # --- Specialist-dispatch gate (between KB-gate and flow-gate) ---
120
+ # Blocks Tier-1 leads from writing to specialist-owned files without
121
+ # dispatching first. Only fires for file-mutation tools.
122
+ $specialistPy = Join-Path $env:ARKAOS_ROOT "core/workflow/specialist_enforcer.py"
123
+ if ((Test-Path $specialistPy) -and ($toolName -in @("Write","Edit","MultiEdit","NotebookEdit"))) {
124
+ $toolInputJson = "{}"
125
+ if ($inp.tool_input) {
126
+ $toolInputJson = ($inp.tool_input | ConvertTo-Json -Compress -Depth 10)
127
+ }
128
+ $env:TOOL_NAME = $toolName
129
+ $env:TRANSCRIPT_PATH = $transcriptPath
130
+ $env:SESSION_ID = $sessionId
131
+ $env:CWD = $cwd
132
+ $env:TOOL_INPUT_JSON = $toolInputJson
133
+
134
+ $spScript = @'
135
+ import json
136
+ import os
137
+ import sys
138
+
139
+ sys.path.insert(0, os.environ["ARKAOS_ROOT"])
140
+ try:
141
+ from core.workflow.specialist_enforcer import evaluate, record_telemetry
142
+ except Exception:
143
+ print(json.dumps({"allow": True, "reason": "specialist-import-failed"}))
144
+ sys.exit(0)
145
+
146
+ try:
147
+ tool_input = json.loads(os.environ.get("TOOL_INPUT_JSON", "{}"))
148
+ except json.JSONDecodeError:
149
+ tool_input = {}
150
+
151
+ decision = evaluate(
152
+ tool_name=os.environ.get("TOOL_NAME", ""),
153
+ transcript_path=os.environ.get("TRANSCRIPT_PATH", ""),
154
+ session_id=os.environ.get("SESSION_ID", ""),
155
+ cwd=os.environ.get("CWD", ""),
156
+ tool_input=tool_input,
157
+ )
158
+ try:
159
+ record_telemetry(
160
+ session_id=os.environ.get("SESSION_ID", ""),
161
+ tool=os.environ.get("TOOL_NAME", ""),
162
+ decision=decision,
163
+ cwd=os.environ.get("CWD", ""),
164
+ target_file=str(tool_input.get("file_path", "")),
165
+ )
166
+ except Exception:
167
+ pass
168
+ print(json.dumps({
169
+ "allow": decision.allow,
170
+ "reason": decision.reason,
171
+ "stderr_msg": decision.to_stderr_message(),
172
+ }))
173
+ '@
174
+
175
+ $spDecisionJson = $spScript | & $python.Source -
176
+ if (-not [string]::IsNullOrWhiteSpace($spDecisionJson)) {
177
+ try {
178
+ $spDecision = $spDecisionJson | ConvertFrom-Json
179
+ } catch { $spDecision = $null }
180
+
181
+ if ($spDecision -and -not $spDecision.allow) {
182
+ [Console]::Error.WriteLine($spDecision.stderr_msg)
183
+ $denyOut = @{
184
+ hookSpecificOutput = @{
185
+ hookEventName = "PreToolUse"
186
+ permissionDecision = "deny"
187
+ permissionDecisionReason = $spDecision.stderr_msg
188
+ }
189
+ } | ConvertTo-Json -Compress -Depth 5
190
+ Write-Output $denyOut
191
+ exit 2
192
+ }
193
+ }
194
+ }
195
+
119
196
  # --- Fast allow: not a flow-gated tool ---
120
197
  if ($toolName -ne "Write" -and $toolName -ne "Edit" -and $toolName -ne "MultiEdit") {
121
198
  exit 0
@@ -120,6 +120,86 @@ PY
120
120
  fi
121
121
  fi
122
122
 
123
+ # ─── Specialist-dispatch gate (between KB-gate and flow-gate) ──────────
124
+ # Blocks Tier-1 leads from writing to specialist-owned files without
125
+ # dispatching first. Only fires for file-mutation tools. Independent
126
+ # feature flag (`hooks.specialistEnforcement`) — fails open if the
127
+ # Python module is absent or raises.
128
+ if [ -f "$ARKAOS_ROOT/core/workflow/specialist_enforcer.py" ]; then
129
+ case "$TOOL_NAME" in
130
+ Write|Edit|MultiEdit|NotebookEdit)
131
+ TOOL_INPUT_JSON_SP="{}"
132
+ if command -v jq &>/dev/null; then
133
+ TOOL_INPUT_JSON_SP=$(echo "$input" | jq -c '.tool_input // {}' 2>/dev/null)
134
+ fi
135
+ SP_DECISION_JSON=$(TOOL_NAME="$TOOL_NAME" \
136
+ TRANSCRIPT_PATH="$TRANSCRIPT_PATH" \
137
+ SESSION_ID="$SESSION_ID" \
138
+ CWD="$CWD" \
139
+ TOOL_INPUT_JSON="$TOOL_INPUT_JSON_SP" \
140
+ ARKAOS_ROOT="$ARKAOS_ROOT" \
141
+ python3 - <<'PY' 2>/dev/null
142
+ import json
143
+ import os
144
+ import sys
145
+
146
+ sys.path.insert(0, os.environ["ARKAOS_ROOT"])
147
+ try:
148
+ from core.workflow.specialist_enforcer import evaluate, record_telemetry
149
+ except Exception:
150
+ print(json.dumps({"allow": True, "reason": "specialist-import-failed"}))
151
+ sys.exit(0)
152
+
153
+ try:
154
+ tool_input = json.loads(os.environ.get("TOOL_INPUT_JSON", "{}"))
155
+ except json.JSONDecodeError:
156
+ tool_input = {}
157
+
158
+ decision = evaluate(
159
+ tool_name=os.environ.get("TOOL_NAME", ""),
160
+ transcript_path=os.environ.get("TRANSCRIPT_PATH", ""),
161
+ session_id=os.environ.get("SESSION_ID", ""),
162
+ cwd=os.environ.get("CWD", ""),
163
+ tool_input=tool_input,
164
+ )
165
+ try:
166
+ record_telemetry(
167
+ session_id=os.environ.get("SESSION_ID", ""),
168
+ tool=os.environ.get("TOOL_NAME", ""),
169
+ decision=decision,
170
+ cwd=os.environ.get("CWD", ""),
171
+ target_file=str(tool_input.get("file_path", "")),
172
+ )
173
+ except Exception:
174
+ pass
175
+ print(json.dumps({
176
+ "allow": decision.allow,
177
+ "reason": decision.reason,
178
+ "stderr_msg": decision.to_stderr_message(),
179
+ }))
180
+ PY
181
+ )
182
+
183
+ if [ -n "$SP_DECISION_JSON" ]; then
184
+ SP_ALLOW=$(echo "$SP_DECISION_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin).get('allow', True))" 2>/dev/null)
185
+ if [ "$SP_ALLOW" != "True" ] && [ "$SP_ALLOW" != "true" ]; then
186
+ SP_STDERR=$(echo "$SP_DECISION_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin).get('stderr_msg',''))" 2>/dev/null)
187
+ echo "$SP_STDERR" >&2
188
+ STDERR_MSG="$SP_STDERR" python3 - <<'PY'
189
+ import json, os
190
+ print(json.dumps({"hookSpecificOutput": {
191
+ "hookEventName": "PreToolUse",
192
+ "permissionDecision": "deny",
193
+ "permissionDecisionReason": os.environ.get("STDERR_MSG", ""),
194
+ }}))
195
+ PY
196
+ exit 2
197
+ fi
198
+ fi
199
+ ;;
200
+ esac
201
+ fi
202
+
123
203
  # ─── Fast allow: not a flow-gated tool ──────────────────────────────────
124
204
  # PR11 v2.33.0 expanded the gated set to include all EFFECT tools.
125
205
  # Bash is special — handled per-command by the Python enforcer via
@@ -0,0 +1,117 @@
1
+ """Specialist-dispatch telemetry summarizer (PR1 — Squad Intelligence Upgrade).
2
+
3
+ Reads ``~/.arkaos/telemetry/specialist-dispatch.jsonl`` (the JSONL stream
4
+ the PreToolUse specialist-enforcer appends to on every gated decision)
5
+ and produces compact summaries for ``/arka status`` and tuning.
6
+
7
+ Mirrors the pattern of ``core.governance.enforcement_telemetry`` so
8
+ periods, malformed-line tolerance, and zero-division safety behave the
9
+ same way across telemetry surfaces. Read-only.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from collections import Counter
16
+ from dataclasses import dataclass, field
17
+ from datetime import datetime, timedelta, timezone
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ DEFAULT_PATH: Path = (
22
+ Path.home() / ".arkaos" / "telemetry" / "specialist-dispatch.jsonl"
23
+ )
24
+ _VALID_PERIODS: frozenset[str] = frozenset({"today", "week", "month", "all"})
25
+ _TOP_N: int = 5
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class SpecialistSummary:
30
+ """Aggregated specialist-dispatch telemetry over a time slice."""
31
+ period: str
32
+ total_calls: int
33
+ blocked_calls: int
34
+ block_rate: float
35
+ bypass_used: int
36
+ top_blocked_personas: list[tuple[str, int]] = field(default_factory=list)
37
+ top_owners_required: list[tuple[str, int]] = field(default_factory=list)
38
+ corrupt_line_count: int = 0
39
+
40
+
41
+ def summarise(period: str, *, path: Path | None = None) -> SpecialistSummary:
42
+ if period not in _VALID_PERIODS:
43
+ raise ValueError(f"invalid period: {period!r}")
44
+ src = path or DEFAULT_PATH
45
+ cutoff = _period_cutoff(period)
46
+ entries, corrupt = _read_jsonl(src, cutoff)
47
+ return _build_summary(period, entries, corrupt)
48
+
49
+
50
+ def _period_cutoff(period: str, now: datetime | None = None) -> datetime | None:
51
+ ref = now or datetime.now(timezone.utc)
52
+ if period == "today":
53
+ return ref.replace(hour=0, minute=0, second=0, microsecond=0)
54
+ if period == "week":
55
+ return ref - timedelta(days=7)
56
+ if period == "month":
57
+ return ref - timedelta(days=30)
58
+ return None
59
+
60
+
61
+ def _read_jsonl(
62
+ src: Path, cutoff: datetime | None
63
+ ) -> tuple[list[dict[str, Any]], int]:
64
+ if not src.exists():
65
+ return [], 0
66
+ entries: list[dict[str, Any]] = []
67
+ corrupt = 0
68
+ try:
69
+ with src.open("r", encoding="utf-8", errors="replace") as fh:
70
+ for line in fh:
71
+ if not line.strip():
72
+ continue
73
+ try:
74
+ entry = json.loads(line)
75
+ except json.JSONDecodeError:
76
+ corrupt += 1
77
+ continue
78
+ if cutoff is not None:
79
+ ts_raw = entry.get("ts", "")
80
+ try:
81
+ ts = datetime.fromisoformat(ts_raw)
82
+ except (TypeError, ValueError):
83
+ corrupt += 1
84
+ continue
85
+ if ts < cutoff:
86
+ continue
87
+ entries.append(entry)
88
+ except OSError:
89
+ return entries, corrupt
90
+ return entries, corrupt
91
+
92
+
93
+ def _build_summary(
94
+ period: str, entries: list[dict[str, Any]], corrupt: int
95
+ ) -> SpecialistSummary:
96
+ total = len(entries)
97
+ blocked = sum(1 for e in entries if e.get("allow") is False)
98
+ bypass = sum(1 for e in entries if e.get("bypass_used") is True)
99
+ rate = (blocked / total) if total else 0.0
100
+ persona_counter: Counter[str] = Counter()
101
+ owner_counter: Counter[str] = Counter()
102
+ for e in entries:
103
+ if e.get("allow") is False:
104
+ persona = e.get("current_persona") or "unknown"
105
+ persona_counter[persona] += 1
106
+ for owner in e.get("required_owners", []) or []:
107
+ owner_counter[owner] += 1
108
+ return SpecialistSummary(
109
+ period=period,
110
+ total_calls=total,
111
+ blocked_calls=blocked,
112
+ block_rate=rate,
113
+ bypass_used=bypass,
114
+ top_blocked_personas=persona_counter.most_common(_TOP_N),
115
+ top_owners_required=owner_counter.most_common(_TOP_N),
116
+ corrupt_line_count=corrupt,
117
+ )
@@ -0,0 +1,51 @@
1
+ """CLI front-end for specialist-dispatch telemetry.
2
+
3
+ Usage:
4
+ python -m core.governance.specialist_telemetry_cli today
5
+ python -m core.governance.specialist_telemetry_cli week
6
+ python -m core.governance.specialist_telemetry_cli month
7
+ python -m core.governance.specialist_telemetry_cli all
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+
14
+ from core.governance.specialist_telemetry import summarise
15
+
16
+
17
+ def _format_human(summary) -> str:
18
+ pct = f"{summary.block_rate * 100:.1f}%"
19
+ lines = [
20
+ f"Specialist Dispatch Telemetry — {summary.period}",
21
+ f" Total calls: {summary.total_calls}",
22
+ f" Blocked: {summary.blocked_calls} ({pct})",
23
+ f" Bypasses used: {summary.bypass_used}",
24
+ ]
25
+ if summary.top_blocked_personas:
26
+ lines.append(" Top blocked personas:")
27
+ for persona, count in summary.top_blocked_personas:
28
+ lines.append(f" - {persona}: {count}")
29
+ if summary.top_owners_required:
30
+ lines.append(" Top owners required:")
31
+ for owner, count in summary.top_owners_required:
32
+ lines.append(f" - {owner}: {count}")
33
+ if summary.corrupt_line_count:
34
+ lines.append(f" Corrupt lines: {summary.corrupt_line_count}")
35
+ return "\n".join(lines)
36
+
37
+
38
+ def main(argv: list[str] | None = None) -> int:
39
+ args = argv if argv is not None else sys.argv[1:]
40
+ period = args[0] if args else "today"
41
+ try:
42
+ summary = summarise(period)
43
+ except ValueError as exc:
44
+ print(f"error: {exc}", file=sys.stderr)
45
+ return 2
46
+ print(_format_human(summary))
47
+ return 0
48
+
49
+
50
+ if __name__ == "__main__": # pragma: no cover
51
+ sys.exit(main())
@@ -0,0 +1,462 @@
1
+ """Force Specialist Dispatch — PreToolUse enforcement for write tools.
2
+
3
+ Blocks Tier-1 squad leads (Paulo, Ines, Daniel, etc.) from writing to
4
+ specialist-owned files (e.g., *.vue, **/app/Services/**) without first
5
+ dispatching the specialist via the Agent tool. The current persona is
6
+ read from the most recent `[arka:routing]` or `[arka:dispatch]` marker
7
+ in the session transcript.
8
+
9
+ Bypass: emit `[arka:specialist-bypass <reason>]` in the same assistant
10
+ message immediately before the Write/Edit. Empty reason is rejected.
11
+ Used bypasses are logged to telemetry for accountability.
12
+
13
+ Feature flag: `hooks.specialistEnforcement` in ~/.arkaos/config.json.
14
+
15
+ Architectural note (per ADR 2026-05-28-specialist-dispatch-subagent-
16
+ blindspot): the enforcer is a NEGATIVE gate on the parent transcript
17
+ only. Subagent writes pass through as `no-routing-tag` because Claude
18
+ Code isolates subagent transcripts from the parent. The positive
19
+ `owner-match` path is exercised when the parent emits `[arka:dispatch]`
20
+ inline (e.g., the orchestrator impersonating a specialist) and remains
21
+ for forward compatibility if parent-transcript visibility ever ships.
22
+
23
+ Read by: config/hooks/pre-tool-use.sh between the KB-gate and the
24
+ flow-gate. Same Decision JSON contract as core.workflow.flow_enforcer.
25
+ """
26
+
27
+ import json
28
+ import re
29
+ from contextlib import contextmanager
30
+ from dataclasses import asdict, dataclass, field
31
+ from datetime import datetime, timezone
32
+ from functools import lru_cache
33
+ from pathlib import Path
34
+
35
+ import yaml
36
+
37
+ from core.shared import safe_session_id as _safe_session_id_module
38
+ from core.workflow.flow_enforcer import _load_last_assistant_messages
39
+
40
+ try:
41
+ import fcntl # POSIX only
42
+ _HAS_FLOCK = True
43
+ except ImportError:
44
+ _HAS_FLOCK = False
45
+
46
+
47
+ @contextmanager
48
+ def _locked_append(path: Path):
49
+ """Append to `path` under an exclusive advisory lock (POSIX flock)."""
50
+ path.parent.mkdir(parents=True, exist_ok=True)
51
+ fh = path.open("a", encoding="utf-8")
52
+ try:
53
+ if _HAS_FLOCK:
54
+ fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
55
+ yield fh
56
+ finally:
57
+ if _HAS_FLOCK:
58
+ try:
59
+ fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
60
+ except OSError:
61
+ pass
62
+ fh.close()
63
+
64
+
65
+ # ─── Constants ──────────────────────────────────────────────────────────
66
+
67
+ CONFIG_PATH = Path.home() / ".arkaos" / "config.json"
68
+ TELEMETRY_PATH = (
69
+ Path.home() / ".arkaos" / "telemetry" / "specialist-dispatch.jsonl"
70
+ )
71
+ OWNERSHIP_YAML_PATH = (
72
+ Path(__file__).resolve().parent.parent.parent
73
+ / "config"
74
+ / "agent-ownership.yaml"
75
+ )
76
+
77
+ GATED_TOOLS: frozenset[str] = frozenset(
78
+ {"Write", "Edit", "MultiEdit", "NotebookEdit"}
79
+ )
80
+
81
+ # Marker regexes — see docs/adr/2026-05-28-specialist-dispatch-...md
82
+ ROUTING_RE = re.compile(
83
+ r"\[arka:routing\]\s*[\w-]+\s*->\s*(\w+)", re.IGNORECASE
84
+ )
85
+ DISPATCH_RE = re.compile(
86
+ r"\[arka:dispatch\]\s*[\w-]+\s*->\s*([\w-]+)", re.IGNORECASE
87
+ )
88
+ BYPASS_RE = re.compile(
89
+ r"\[arka:specialist-bypass\s+([^\]]+?)\s*\]", re.IGNORECASE
90
+ )
91
+
92
+ ASSISTANT_WINDOW = 20
93
+
94
+
95
+ # ─── Data ───────────────────────────────────────────────────────────────
96
+
97
+
98
+ @dataclass
99
+ class Decision:
100
+ """Outcome of specialist-enforcement evaluation."""
101
+
102
+ allow: bool
103
+ reason: str
104
+ current_persona: str | None = None
105
+ required_owners: list[str] = field(default_factory=list)
106
+ marker_found: str | None = None
107
+ bypass_used: bool = False
108
+ bypass_reason: str | None = None
109
+ target_file: str | None = None
110
+
111
+ def to_stderr_message(self) -> str:
112
+ if self.allow:
113
+ return ""
114
+ persona = self.current_persona or "lead"
115
+ owners = ", ".join(self.required_owners) or "specialist"
116
+ target = self.target_file or "this file"
117
+ return (
118
+ f"[ARKA:SPECIALIST] {persona} (lead) is not authorised to write "
119
+ f"{target}. Required owners: {owners}. Choose one: (1) dispatch "
120
+ f"the specialist via the Agent tool AND emit "
121
+ f"`[arka:dispatch] {persona} -> <specialist>` immediately before "
122
+ f"the dispatch call (NON-NEGOTIABLE constitution rule "
123
+ f"`dispatch-must-be-announced`), OR (2) add "
124
+ f"`[arka:specialist-bypass <reason>]` to the same assistant "
125
+ f"message to override (logged for accountability)."
126
+ )
127
+
128
+
129
+ @dataclass
130
+ class _Ctx:
131
+ """Mutable evaluation context passed through pipeline stages."""
132
+
133
+ tool_name: str
134
+ transcript_path: str
135
+ session_id: str
136
+ cwd: str
137
+ tool_input: dict
138
+ file_path: str = ""
139
+ messages: list[str] = field(default_factory=list)
140
+ persona: str | None = None
141
+ marker: str | None = None
142
+ config: dict = field(default_factory=dict)
143
+
144
+
145
+ # ─── Config + Ownership loaders ────────────────────────────────────────
146
+
147
+
148
+ def _feature_flag_on() -> bool:
149
+ """Check `hooks.specialistEnforcement` in ~/.arkaos/config.json."""
150
+ if not CONFIG_PATH.exists():
151
+ return False
152
+ try:
153
+ data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
154
+ except (json.JSONDecodeError, OSError):
155
+ return False
156
+ return bool(data.get("hooks", {}).get("specialistEnforcement", False))
157
+
158
+
159
+ def _empty_ownership() -> dict:
160
+ return {
161
+ "version": 1,
162
+ "leads": [],
163
+ "c_suite": [],
164
+ "ownership": [],
165
+ "lead_allowed": [],
166
+ }
167
+
168
+
169
+ @lru_cache(maxsize=1)
170
+ def _load_ownership() -> dict:
171
+ """Load ownership rules from YAML. Cached per-process.
172
+
173
+ Each `python3 -` heredoc invocation from the bash hook is a fresh
174
+ interpreter, so the cache scope is one tool call — no TTL needed.
175
+ Tests call `_load_ownership.cache_clear()` when monkeypatching.
176
+ """
177
+ if not OWNERSHIP_YAML_PATH.exists():
178
+ return _empty_ownership()
179
+ try:
180
+ with OWNERSHIP_YAML_PATH.open(encoding="utf-8") as fh:
181
+ data = yaml.safe_load(fh) or {}
182
+ except (yaml.YAMLError, OSError):
183
+ return _empty_ownership()
184
+ return data
185
+
186
+
187
+ # ─── Glob matching (B2 refactor: split tokenizer from matcher) ─────────
188
+
189
+
190
+ def _glob_token(pattern: str, i: int) -> tuple[str, int]:
191
+ """Translate the glob character at `pattern[i]` to a regex fragment.
192
+
193
+ Returns (fragment, next_index). Handles `**/`, `**`, `*`, `?`, brace
194
+ expansion `{a,b,c}`, and escapes regex meta-characters.
195
+ """
196
+ c = pattern[i]
197
+ if c == "*" and i + 1 < len(pattern) and pattern[i + 1] == "*":
198
+ if i + 2 < len(pattern) and pattern[i + 2] == "/":
199
+ return r"(?:.*/)?", i + 3
200
+ return r".*", i + 2
201
+ if c == "*":
202
+ return r"[^/]*", i + 1
203
+ if c == "?":
204
+ return r"[^/]", i + 1
205
+ if c in r".()[]+\|^$":
206
+ return "\\" + c, i + 1
207
+ if c == "{":
208
+ close = pattern.find("}", i + 1)
209
+ if close == -1:
210
+ return re.escape(c), i + 1
211
+ options = pattern[i + 1:close].split(",")
212
+ return "(?:" + "|".join(re.escape(o) for o in options) + ")", close + 1
213
+ return c, i + 1
214
+
215
+
216
+ @lru_cache(maxsize=256)
217
+ def _glob_to_regex(pattern: str) -> re.Pattern[str]:
218
+ """Compile a glob pattern (with ** support) into an anchored regex."""
219
+ parts: list[str] = []
220
+ i = 0
221
+ while i < len(pattern):
222
+ fragment, i = _glob_token(pattern, i)
223
+ parts.append(fragment)
224
+ return re.compile("^" + "".join(parts) + "$")
225
+
226
+
227
+ def _glob_match(pattern: str, path: str) -> bool:
228
+ """Match `path` against `pattern` with `**` recursive-glob support."""
229
+ return bool(_glob_to_regex(pattern).match(path.replace("\\", "/")))
230
+
231
+
232
+ # ─── Persona, bypass, ownership resolution ─────────────────────────────
233
+
234
+
235
+ def _resolve_persona(messages: list[str]) -> tuple[str | None, str | None]:
236
+ """Find the current persona, scanning newest-to-oldest assistant turns.
237
+
238
+ Dispatch tag wins over routing because dispatching is more specific.
239
+ """
240
+ for text in reversed(messages):
241
+ dispatch = DISPATCH_RE.search(text)
242
+ if dispatch:
243
+ return dispatch.group(1).lower(), "dispatch"
244
+ routing = ROUTING_RE.search(text)
245
+ if routing:
246
+ return routing.group(1).lower(), "routing"
247
+ return None, None
248
+
249
+
250
+ def _find_bypass(messages: list[str]) -> str | None:
251
+ """Return bypass reason from LAST assistant message, or None.
252
+
253
+ Scope is strict: only the immediately preceding assistant message can
254
+ grant a bypass. Empty / whitespace reasons are rejected.
255
+ """
256
+ if not messages:
257
+ return None
258
+ match = BYPASS_RE.search(messages[-1])
259
+ if not match:
260
+ return None
261
+ reason = match.group(1).strip()
262
+ return reason if reason else None
263
+
264
+
265
+ def _match_ownership(
266
+ file_path: str, rules: list[dict]
267
+ ) -> tuple[list[str] | None, str | None]:
268
+ """Return (owners, rule_reason) of FIRST matching rule, or (None, None)."""
269
+ for rule in rules:
270
+ pattern = rule.get("pattern", "")
271
+ if pattern and _glob_match(pattern, file_path):
272
+ return list(rule.get("owners", []) or []), rule.get("reason")
273
+ return None, None
274
+
275
+
276
+ def _is_lead_allowed(file_path: str, patterns: list[str]) -> bool:
277
+ """Check lead_allowed against full path AND basename for convenience."""
278
+ base = file_path.split("/")[-1]
279
+ return any(
280
+ _glob_match(p, file_path) or _glob_match(p, base) for p in patterns
281
+ )
282
+
283
+
284
+ # ─── Pipeline stages (B1 refactor) ─────────────────────────────────────
285
+
286
+
287
+ def _check_tool_gated(ctx: _Ctx) -> Decision | None:
288
+ if ctx.tool_name not in GATED_TOOLS:
289
+ return Decision(allow=True, reason="tool-not-gated")
290
+ return None
291
+
292
+
293
+ def _check_feature_flag(ctx: _Ctx) -> Decision | None:
294
+ if not _feature_flag_on():
295
+ return Decision(allow=True, reason="feature-flag-off")
296
+ return None
297
+
298
+
299
+ def _populate_context(ctx: _Ctx) -> None:
300
+ """Extract file_path, load ownership config, load + resolve transcript."""
301
+ if ctx.tool_input and isinstance(ctx.tool_input, dict):
302
+ ctx.file_path = str(
303
+ ctx.tool_input.get("file_path")
304
+ or ctx.tool_input.get("notebook_path")
305
+ or ""
306
+ )
307
+ ctx.config = _load_ownership()
308
+ ctx.messages = _load_last_assistant_messages(
309
+ ctx.transcript_path, ASSISTANT_WINDOW
310
+ )
311
+ ctx.persona, ctx.marker = _resolve_persona(ctx.messages)
312
+
313
+
314
+ def _check_no_persona(ctx: _Ctx) -> Decision | None:
315
+ if ctx.persona is None:
316
+ return Decision(
317
+ allow=True, reason="no-routing-tag", target_file=ctx.file_path,
318
+ )
319
+ return None
320
+
321
+
322
+ def _check_c_suite(ctx: _Ctx) -> Decision | None:
323
+ c_suite = {x.lower() for x in ctx.config.get("c_suite", [])}
324
+ if ctx.persona in c_suite:
325
+ return Decision(
326
+ allow=True, reason="c-suite-override",
327
+ current_persona=ctx.persona, marker_found=ctx.marker,
328
+ target_file=ctx.file_path,
329
+ )
330
+ return None
331
+
332
+
333
+ def _check_lead_allowed_file(ctx: _Ctx) -> Decision | None:
334
+ allowed = ctx.config.get("lead_allowed", []) or []
335
+ if _is_lead_allowed(ctx.file_path, allowed):
336
+ return Decision(
337
+ allow=True, reason="lead-allowed-file",
338
+ current_persona=ctx.persona, marker_found=ctx.marker,
339
+ target_file=ctx.file_path,
340
+ )
341
+ return None
342
+
343
+
344
+ def _decide_open_access(
345
+ ctx: _Ctx, owners: list[str], rule_reason: str | None
346
+ ) -> Decision:
347
+ reason = f"open-access:{rule_reason}" if rule_reason else "open-access"
348
+ return Decision(
349
+ allow=True, reason=reason, current_persona=ctx.persona,
350
+ marker_found=ctx.marker, target_file=ctx.file_path,
351
+ required_owners=owners,
352
+ )
353
+
354
+
355
+ def _decide_owner_match(ctx: _Ctx, owners: list[str]) -> Decision:
356
+ return Decision(
357
+ allow=True, reason=f"owner-match:{ctx.persona}",
358
+ current_persona=ctx.persona, marker_found=ctx.marker,
359
+ target_file=ctx.file_path, required_owners=owners,
360
+ )
361
+
362
+
363
+ def _decide_bypass(ctx: _Ctx, owners: list[str], reason: str) -> Decision:
364
+ return Decision(
365
+ allow=True, reason="bypass-with-reason",
366
+ current_persona=ctx.persona, marker_found=ctx.marker,
367
+ target_file=ctx.file_path, required_owners=owners,
368
+ bypass_used=True, bypass_reason=reason,
369
+ )
370
+
371
+
372
+ def _decide_block(ctx: _Ctx, owners: list[str]) -> Decision:
373
+ owners_lower = sorted({o.lower() for o in owners})
374
+ return Decision(
375
+ allow=False,
376
+ reason=f"lead-blocked:{ctx.persona}-not-in-[{','.join(owners_lower)}]",
377
+ current_persona=ctx.persona, marker_found=ctx.marker,
378
+ target_file=ctx.file_path, required_owners=owners,
379
+ )
380
+
381
+
382
+ def _resolve_with_owners(ctx: _Ctx, owners: list[str], rule_reason: str | None) -> Decision:
383
+ """Pick the right Decision when ownership rule matched."""
384
+ if "*" in owners:
385
+ return _decide_open_access(ctx, owners, rule_reason)
386
+ if ctx.persona in {o.lower() for o in owners}:
387
+ return _decide_owner_match(ctx, owners)
388
+ bypass = _find_bypass(ctx.messages)
389
+ if bypass:
390
+ return _decide_bypass(ctx, owners, bypass)
391
+ return _decide_block(ctx, owners)
392
+
393
+
394
+ def _resolve_ownership_outcome(ctx: _Ctx) -> Decision:
395
+ """Final branch: match ownership rules and resolve to a Decision."""
396
+ owners, rule_reason = _match_ownership(
397
+ ctx.file_path, ctx.config.get("ownership", []) or []
398
+ )
399
+ if owners is None:
400
+ return Decision(
401
+ allow=True, reason="no-ownership-rule",
402
+ current_persona=ctx.persona, marker_found=ctx.marker,
403
+ target_file=ctx.file_path,
404
+ )
405
+ return _resolve_with_owners(ctx, owners, rule_reason)
406
+
407
+
408
+ # ─── Public API ─────────────────────────────────────────────────────────
409
+
410
+
411
+ def evaluate(
412
+ tool_name: str,
413
+ transcript_path: str,
414
+ session_id: str = "",
415
+ cwd: str = "",
416
+ tool_input: dict | None = None,
417
+ ) -> Decision:
418
+ """Decide whether a Write/Edit/MultiEdit/NotebookEdit may proceed."""
419
+ ctx = _Ctx(
420
+ tool_name=tool_name, transcript_path=transcript_path,
421
+ session_id=session_id, cwd=cwd, tool_input=tool_input or {},
422
+ )
423
+ for early_check in (_check_tool_gated, _check_feature_flag):
424
+ decision = early_check(ctx)
425
+ if decision is not None:
426
+ return decision
427
+ _populate_context(ctx)
428
+ for late_check in (_check_no_persona, _check_c_suite, _check_lead_allowed_file):
429
+ decision = late_check(ctx)
430
+ if decision is not None:
431
+ return decision
432
+ return _resolve_ownership_outcome(ctx)
433
+
434
+
435
+ def record_telemetry(
436
+ session_id: str,
437
+ tool: str,
438
+ decision: Decision,
439
+ cwd: str = "",
440
+ target_file: str = "",
441
+ ) -> None:
442
+ """Append a structured record to the specialist-dispatch telemetry log.
443
+
444
+ Drops the record silently when session_id fails the safe-id check
445
+ (path-traversal mitigation, CWE-22).
446
+ """
447
+ safe = _safe_session_id_module.safe_session_id(session_id)
448
+ if safe is None:
449
+ return
450
+ entry = {
451
+ "ts": datetime.now(timezone.utc).isoformat(),
452
+ "session_id": safe,
453
+ "tool": tool,
454
+ "cwd": cwd,
455
+ "target_file": target_file or decision.target_file or "",
456
+ **asdict(decision),
457
+ }
458
+ try:
459
+ with _locked_append(TELEMETRY_PATH) as fh:
460
+ fh.write(json.dumps(entry) + "\n")
461
+ except OSError:
462
+ pass # Telemetry write failure must never block the hook.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "3.72.0",
3
+ "version": "3.73.0",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arkaos-core"
3
- version = "3.72.0"
3
+ version = "3.73.0"
4
4
  description = "Core engine for ArkaOS — The Operating System for AI Agent Teams"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}