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 +1 -1
- package/arka/skills/flow/SKILL.md +20 -0
- package/config/agent-ownership.yaml +169 -0
- package/config/constitution.yaml +5 -0
- package/config/hooks/pre-tool-use.ps1 +77 -0
- package/config/hooks/pre-tool-use.sh +80 -0
- package/core/governance/__pycache__/specialist_telemetry.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/specialist_telemetry_cli.cpython-313.pyc +0 -0
- package/core/governance/specialist_telemetry.py +117 -0
- package/core/governance/specialist_telemetry_cli.py +51 -0
- package/core/workflow/__pycache__/specialist_enforcer.cpython-313.pyc +0 -0
- package/core/workflow/specialist_enforcer.py +462 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
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
|
package/config/constitution.yaml
CHANGED
|
@@ -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
|
|
Binary file
|
|
@@ -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())
|
|
Binary file
|
|
@@ -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
package/pyproject.toml
CHANGED
|
Binary file
|