arkaos 4.14.3 → 4.14.4
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/config/agent-roster.json +16 -0
- package/core/agents/authority_brief.py +205 -0
- package/core/agents/roster_manifest.py +60 -0
- package/core/hooks/session_start.py +25 -0
- package/core/workflow/specialist_enforcer.py +143 -31
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
4.14.
|
|
1
|
+
4.14.4
|
package/config/agent-roster.json
CHANGED
|
@@ -3,33 +3,49 @@
|
|
|
3
3
|
"generator": "core/agents/roster_manifest.py",
|
|
4
4
|
"purpose": "every specialist-gate owner resolves to a dispatchable agent source; installer/skill-deploy.js deploys these"
|
|
5
5
|
},
|
|
6
|
+
"aliases": {
|
|
7
|
+
"andre": "backend-dev",
|
|
8
|
+
"bruno": "security-eng",
|
|
9
|
+
"carlos": "devops-eng",
|
|
10
|
+
"diana": "frontend-dev",
|
|
11
|
+
"gabriel": "architect",
|
|
12
|
+
"vasco": "dba"
|
|
13
|
+
},
|
|
14
|
+
"ambiguous_first_names": [],
|
|
6
15
|
"gate_owners": {
|
|
7
16
|
"architect": {
|
|
8
17
|
"compiled": false,
|
|
18
|
+
"human_name": "Gabriel",
|
|
9
19
|
"source": "departments/dev/agents/architect.md"
|
|
10
20
|
},
|
|
11
21
|
"backend-dev": {
|
|
12
22
|
"compiled": true,
|
|
23
|
+
"human_name": "Andre",
|
|
13
24
|
"source": "config/claude-agents/backend-dev.md"
|
|
14
25
|
},
|
|
15
26
|
"dba": {
|
|
16
27
|
"compiled": true,
|
|
28
|
+
"human_name": "Vasco",
|
|
17
29
|
"source": "config/claude-agents/dba.md"
|
|
18
30
|
},
|
|
19
31
|
"devops-eng": {
|
|
20
32
|
"compiled": true,
|
|
33
|
+
"human_name": "Carlos",
|
|
21
34
|
"source": "config/claude-agents/devops-eng.md"
|
|
22
35
|
},
|
|
23
36
|
"frontend-dev": {
|
|
24
37
|
"compiled": false,
|
|
38
|
+
"human_name": "Diana",
|
|
25
39
|
"source": "departments/dev/agents/frontend-dev.md"
|
|
26
40
|
},
|
|
27
41
|
"security-eng": {
|
|
28
42
|
"compiled": true,
|
|
43
|
+
"human_name": "Bruno",
|
|
29
44
|
"source": "config/claude-agents/security-eng.md"
|
|
30
45
|
},
|
|
31
46
|
"senior-dev": {
|
|
32
47
|
"compiled": false,
|
|
48
|
+
"human_name": "",
|
|
33
49
|
"source": "departments/dev/agents/senior-dev.md"
|
|
34
50
|
}
|
|
35
51
|
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Authority brief — the write rules a session must know BEFORE it writes.
|
|
2
|
+
|
|
3
|
+
The 2026-07-12 incident: a session was blocked writing a Laravel
|
|
4
|
+
controller and, never having been told the rule existed, invented a
|
|
5
|
+
"documented bug" to justify a bypass. Grep confirms the gap: ownership
|
|
6
|
+
and ``specialist-bypass`` appear NOWHERE in CLAUDE.md, arka/SKILL.md,
|
|
7
|
+
flow/SKILL.md or constitution.yaml. The deny message was the first and
|
|
8
|
+
only place a session ever learned any of it.
|
|
9
|
+
|
|
10
|
+
This module renders the ``[ARKA:AUTHORITY]`` block the SessionStart hook
|
|
11
|
+
injects. Everything is GENERATED from ``config/agent-ownership.yaml`` +
|
|
12
|
+
``config/agent-roster.json`` + the agents actually deployed on THIS
|
|
13
|
+
machine — never hand-typed.
|
|
14
|
+
|
|
15
|
+
Two rules this file learned the hard way (QG, redo 1):
|
|
16
|
+
|
|
17
|
+
1. **Relevance is decided by the FULL literal spine, never its first
|
|
18
|
+
segment.** The first cut matched ``**/app/Http/Controllers/**`` in
|
|
19
|
+
this repo because a directory called ``dashboard/app`` exists — so it
|
|
20
|
+
taught a session five Laravel rules that govern nothing here.
|
|
21
|
+
2. **Never truncate in silence.** The same cut capped the list at 8 and
|
|
22
|
+
dropped ``core/workflow/**/*.py`` and ``core/agents/**/*.py`` — the
|
|
23
|
+
rules that own the very files that PR was editing — under a heading
|
|
24
|
+
that claims "Owned paths in THIS project". A brief that hides the
|
|
25
|
+
rules that apply is the incident, re-delivered as its own fix.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import re
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
import yaml
|
|
35
|
+
|
|
36
|
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
37
|
+
OWNERSHIP_YAML = REPO_ROOT / "config" / "agent-ownership.yaml"
|
|
38
|
+
ROSTER_JSON = REPO_ROOT / "config" / "agent-roster.json"
|
|
39
|
+
|
|
40
|
+
# Enough to state every rule that really applies to a project this size
|
|
41
|
+
# (arka-os itself renders 14). Anything beyond is COUNTED, never dropped.
|
|
42
|
+
_MAX_RULES = 16
|
|
43
|
+
_MAX_FIELD = 120
|
|
44
|
+
# `{` is a glob char too: the enforcer expands `{core,dashboard}` braces,
|
|
45
|
+
# so treating that segment as a literal spine would make the brief DROP a
|
|
46
|
+
# rule the gate enforces — the "brief hides an applicable rule" incident.
|
|
47
|
+
_GLOB_CHARS = ("*", "?", "[", "{")
|
|
48
|
+
# The brief lands in a system prompt. A pattern is operator-editable YAML
|
|
49
|
+
# (agent-ownership.yaml is `lead_allowed`), so a newline in it could forge
|
|
50
|
+
# an authority line — OWASP LLM01. Control bytes never reach the prompt.
|
|
51
|
+
_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _load(path: Path) -> dict:
|
|
55
|
+
try:
|
|
56
|
+
text = path.read_text(encoding="utf-8")
|
|
57
|
+
except OSError:
|
|
58
|
+
return {}
|
|
59
|
+
try:
|
|
60
|
+
data = (
|
|
61
|
+
json.loads(text) if path.suffix == ".json" else yaml.safe_load(text)
|
|
62
|
+
)
|
|
63
|
+
except (json.JSONDecodeError, yaml.YAMLError):
|
|
64
|
+
return {}
|
|
65
|
+
return data if isinstance(data, dict) else {}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _clean(value: str) -> str:
|
|
69
|
+
"""Strip control bytes and bound the length — prompt-injection guard."""
|
|
70
|
+
return _CONTROL_RE.sub("", str(value))[:_MAX_FIELD]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def deployed_agents(agents_dirs: list[Path] | None = None) -> set[str]:
|
|
74
|
+
"""Slugs the Task tool can actually dispatch on this machine."""
|
|
75
|
+
dirs = agents_dirs or [
|
|
76
|
+
Path.home() / ".claude" / "agents",
|
|
77
|
+
Path.cwd() / ".claude" / "agents",
|
|
78
|
+
]
|
|
79
|
+
found: set[str] = set()
|
|
80
|
+
for base in dirs:
|
|
81
|
+
try:
|
|
82
|
+
for md in base.glob("*.md"):
|
|
83
|
+
found.add(md.stem.removeprefix("arka-"))
|
|
84
|
+
except OSError:
|
|
85
|
+
continue
|
|
86
|
+
return found
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _spine(pattern: str) -> list[str]:
|
|
90
|
+
"""The literal path segments of a glob: `**/app/Http/Controllers/**`
|
|
91
|
+
-> ['app', 'Http', 'Controllers']; `**/*.vue` -> []."""
|
|
92
|
+
return [
|
|
93
|
+
part for part in pattern.split("/")
|
|
94
|
+
if part and not any(ch in part for ch in _GLOB_CHARS)
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def rule_applies(pattern: str, root: Path) -> bool:
|
|
99
|
+
"""Does this rule govern anything in `root`?
|
|
100
|
+
|
|
101
|
+
The FULL spine must exist — checking only its first segment is what
|
|
102
|
+
made `**/app/Http/Controllers/**` look relevant here (a `dashboard/app`
|
|
103
|
+
directory exists) while the rules that actually govern the repo were
|
|
104
|
+
pushed out of the list. Extension-only rules always apply: they cost
|
|
105
|
+
one line and are honest ("write a .vue and frontend-dev owns it").
|
|
106
|
+
"""
|
|
107
|
+
spine = _spine(pattern)
|
|
108
|
+
if not spine:
|
|
109
|
+
return True
|
|
110
|
+
joined = Path(*spine)
|
|
111
|
+
try:
|
|
112
|
+
if (root / joined).exists():
|
|
113
|
+
return True
|
|
114
|
+
# One level down covers monorepo layouts (dashboard/app/…).
|
|
115
|
+
return any(
|
|
116
|
+
(child / joined).exists()
|
|
117
|
+
for child in root.iterdir()
|
|
118
|
+
if child.is_dir() and not child.name.startswith(".")
|
|
119
|
+
)
|
|
120
|
+
except OSError:
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def applicable_rules(rules: list[dict], root: Path) -> list[dict]:
|
|
125
|
+
return [
|
|
126
|
+
rule for rule in rules
|
|
127
|
+
if isinstance(rule, dict)
|
|
128
|
+
and rule.get("pattern")
|
|
129
|
+
and rule.get("owners")
|
|
130
|
+
and "*" not in rule["owners"]
|
|
131
|
+
and rule_applies(str(rule["pattern"]), root)
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _dispatch_status(named: set[str], owners_meta: dict,
|
|
136
|
+
dispatchable: set[str]) -> list[str]:
|
|
137
|
+
status: list[str] = []
|
|
138
|
+
for slug in sorted(named):
|
|
139
|
+
# agent-roster.json is operator-editable and this line lands in a
|
|
140
|
+
# system prompt — human_name walks through _clean like every other
|
|
141
|
+
# field (QG redo 2: it was the one field that did not).
|
|
142
|
+
tokens = _clean(owners_meta.get(slug, {}).get("human_name", "")).split()
|
|
143
|
+
label = f"{slug} ({tokens[0]})" if tokens else slug
|
|
144
|
+
status.append(
|
|
145
|
+
f"{label} ok" if slug in dispatchable else f"{label} MISSING"
|
|
146
|
+
)
|
|
147
|
+
return status
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def render(cwd: str | Path | None = None,
|
|
151
|
+
agents_dirs: list[Path] | None = None) -> str:
|
|
152
|
+
"""The [ARKA:AUTHORITY] block, or "" when there is nothing to say."""
|
|
153
|
+
ownership = _load(OWNERSHIP_YAML)
|
|
154
|
+
roster = _load(ROSTER_JSON)
|
|
155
|
+
rules = ownership.get("ownership") or []
|
|
156
|
+
owners_meta = roster.get("gate_owners") or {}
|
|
157
|
+
if not isinstance(rules, list) or not owners_meta:
|
|
158
|
+
return ""
|
|
159
|
+
|
|
160
|
+
root = Path(cwd) if cwd else Path.cwd()
|
|
161
|
+
relevant = applicable_rules(rules, root)
|
|
162
|
+
if not relevant:
|
|
163
|
+
return ""
|
|
164
|
+
shown, overflow = relevant[:_MAX_RULES], len(relevant) - _MAX_RULES
|
|
165
|
+
|
|
166
|
+
lines = [
|
|
167
|
+
"[ARKA:AUTHORITY] Write permissions (enforced by a PreToolUse gate)",
|
|
168
|
+
"1. Your last [arka:routing] / [arka:dispatch] marker is an "
|
|
169
|
+
"AUTHORIZATION TOKEN a gate reads before every Write/Edit. It is "
|
|
170
|
+
"not narration.",
|
|
171
|
+
"2. Owned paths in THIS project (first match wins):",
|
|
172
|
+
]
|
|
173
|
+
named: set[str] = set()
|
|
174
|
+
for rule in shown:
|
|
175
|
+
owners = [_clean(o) for o in rule["owners"]]
|
|
176
|
+
named.update(owners)
|
|
177
|
+
lines.append(
|
|
178
|
+
f" {_clean(rule['pattern']):<26s} -> {', '.join(owners)}"
|
|
179
|
+
)
|
|
180
|
+
if overflow > 0:
|
|
181
|
+
lines.append(
|
|
182
|
+
f" (+{overflow} more — see config/agent-ownership.yaml)"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
status = _dispatch_status(named, owners_meta, deployed_agents(agents_dirs))
|
|
186
|
+
lines.append(
|
|
187
|
+
f"3. Dispatchable here (Task subagent_type): {', '.join(status)}"
|
|
188
|
+
)
|
|
189
|
+
if any(entry.endswith("MISSING") for entry in status):
|
|
190
|
+
lines.append(" MISSING -> run `npx arkaos update` (never bypass).")
|
|
191
|
+
lines.append(
|
|
192
|
+
"4. The dispatched specialist writes with NO block. Retrying a "
|
|
193
|
+
"blocked Write never works — dispatch instead."
|
|
194
|
+
)
|
|
195
|
+
return "\n".join(lines)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def main() -> int:
|
|
199
|
+
brief = render()
|
|
200
|
+
print(brief or "(no ownership rules apply to this directory)")
|
|
201
|
+
return 0
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
if __name__ == "__main__":
|
|
205
|
+
raise SystemExit(main())
|
|
@@ -68,6 +68,55 @@ def _resolve_source(slug: str) -> Path | None:
|
|
|
68
68
|
return None
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
def _agent_yaml(slug: str) -> Path | None:
|
|
72
|
+
matches = sorted(DEPARTMENTS_DIR.glob(f"*/agents/**/{slug}.yaml"))
|
|
73
|
+
return matches[0] if matches else None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _human_name(slug: str) -> str:
|
|
77
|
+
source = _agent_yaml(slug)
|
|
78
|
+
if source is None:
|
|
79
|
+
return ""
|
|
80
|
+
try:
|
|
81
|
+
data = yaml.safe_load(source.read_text(encoding="utf-8"))
|
|
82
|
+
except yaml.YAMLError:
|
|
83
|
+
return ""
|
|
84
|
+
return str(data.get("name") or "") if isinstance(data, dict) else ""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _first_name(human: str) -> str:
|
|
88
|
+
"""'Andre — Backend Core Lead' -> 'andre'."""
|
|
89
|
+
return human.split()[0].split("—")[0].strip().lower() if human else ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build_aliases(entries: dict[str, dict]) -> dict[str, str]:
|
|
93
|
+
"""First-name -> owner slug, resolved WITHIN the gate-owner set.
|
|
94
|
+
|
|
95
|
+
Sessions route by human name (``[arka:dispatch] paulo -> diana``) but
|
|
96
|
+
ownership rules name slugs (``frontend-dev``). Unresolved, that
|
|
97
|
+
mismatch blocked the RIGHT specialist from her own files: 22 of 189
|
|
98
|
+
measured blocks. (The 22 `senior-dev` blocks are a separate, still
|
|
99
|
+
open class: there the slug IS the persona, so no alias helps.)
|
|
100
|
+
|
|
101
|
+
Scoping to gate owners is what makes this safe. Globally, ``diana``
|
|
102
|
+
is ambiguous (frontend-dev AND hr-specialist) and so is ``andre``
|
|
103
|
+
(backend-dev AND growth-engineer) — a naive first-name alias would
|
|
104
|
+
trade one bug for a worse one. Among the owners the gate can
|
|
105
|
+
actually demand, both are unique. Anything still ambiguous here is
|
|
106
|
+
REFUSED, never guessed.
|
|
107
|
+
"""
|
|
108
|
+
by_first: dict[str, set[str]] = {}
|
|
109
|
+
for slug in entries:
|
|
110
|
+
first = _first_name(_human_name(slug))
|
|
111
|
+
if first:
|
|
112
|
+
by_first.setdefault(first, set()).add(slug)
|
|
113
|
+
return {
|
|
114
|
+
first: next(iter(slugs))
|
|
115
|
+
for first, slugs in sorted(by_first.items())
|
|
116
|
+
if len(slugs) == 1
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
71
120
|
def build_roster() -> dict:
|
|
72
121
|
owners = gate_owners()
|
|
73
122
|
entries: dict[str, dict] = {}
|
|
@@ -80,6 +129,7 @@ def build_roster() -> dict:
|
|
|
80
129
|
entries[slug] = {
|
|
81
130
|
"source": str(source.relative_to(REPO_ROOT)),
|
|
82
131
|
"compiled": source.parent == COMPILED_DIR,
|
|
132
|
+
"human_name": _human_name(slug),
|
|
83
133
|
}
|
|
84
134
|
if ghosts:
|
|
85
135
|
raise ValueError(
|
|
@@ -88,6 +138,14 @@ def build_roster() -> dict:
|
|
|
88
138
|
f"config/agent-ownership.yaml; a gate must never demand an "
|
|
89
139
|
f"owner nobody can invoke"
|
|
90
140
|
)
|
|
141
|
+
aliases = build_aliases(entries)
|
|
142
|
+
ambiguous = sorted(
|
|
143
|
+
first
|
|
144
|
+
for first in {
|
|
145
|
+
_first_name(e["human_name"]) for e in entries.values()
|
|
146
|
+
}
|
|
147
|
+
if first and first not in aliases
|
|
148
|
+
)
|
|
91
149
|
return {
|
|
92
150
|
"_meta": {
|
|
93
151
|
"generator": "core/agents/roster_manifest.py",
|
|
@@ -97,6 +155,8 @@ def build_roster() -> dict:
|
|
|
97
155
|
),
|
|
98
156
|
},
|
|
99
157
|
"gate_owners": entries,
|
|
158
|
+
"aliases": aliases,
|
|
159
|
+
"ambiguous_first_names": ambiguous,
|
|
100
160
|
}
|
|
101
161
|
|
|
102
162
|
|
|
@@ -242,6 +242,30 @@ def _ensure_dashboard(repo: str, config: dict) -> None:
|
|
|
242
242
|
)
|
|
243
243
|
|
|
244
244
|
|
|
245
|
+
def _authority_brief(cwd: str) -> str:
|
|
246
|
+
"""[ARKA:AUTHORITY] — who may write what, BEFORE the first Write.
|
|
247
|
+
|
|
248
|
+
The 2026-07-12 incident happened because this information existed
|
|
249
|
+
nowhere in the prompt surface: a session met the ownership rule for
|
|
250
|
+
the first time in a deny message, and rationalized a bypass instead
|
|
251
|
+
of dispatching. Generated per project, never hand-typed.
|
|
252
|
+
"""
|
|
253
|
+
try:
|
|
254
|
+
from core.agents.authority_brief import render
|
|
255
|
+
brief = render(cwd)
|
|
256
|
+
except Exception as exc:
|
|
257
|
+
# But it must not fail SILENTLY either: a swallowed error here
|
|
258
|
+
# restores the exact pre-incident state (a session writing with no
|
|
259
|
+
# idea the rules exist). One honest line beats a silent void.
|
|
260
|
+
return (
|
|
261
|
+
f"\n\n[ARKA:AUTHORITY] unavailable ({type(exc).__name__}) — "
|
|
262
|
+
f"write rules could not be rendered; check "
|
|
263
|
+
f"config/agent-ownership.yaml. Dispatch the owning specialist "
|
|
264
|
+
f"rather than assuming you may write."
|
|
265
|
+
)
|
|
266
|
+
return f"\n\n{brief}" if brief else ""
|
|
267
|
+
|
|
268
|
+
|
|
245
269
|
def build_message(cwd: str) -> str:
|
|
246
270
|
repo = repo_path()
|
|
247
271
|
config = _config()
|
|
@@ -254,6 +278,7 @@ def build_message(cwd: str) -> str:
|
|
|
254
278
|
msg += _drift(version)
|
|
255
279
|
msg += _EVIDENCE_CONTRACT
|
|
256
280
|
msg += _META_TAG_CONTRACT
|
|
281
|
+
msg += _authority_brief(cwd)
|
|
257
282
|
msg += _model_fabric()
|
|
258
283
|
_trigger_reorganizer(repo, config)
|
|
259
284
|
_ensure_dashboard(repo, config)
|
|
@@ -24,11 +24,12 @@ Read by: config/hooks/pre-tool-use.sh between the KB-gate and the
|
|
|
24
24
|
flow-gate. Same Decision JSON contract as core.workflow.flow_enforcer.
|
|
25
25
|
"""
|
|
26
26
|
|
|
27
|
+
import contextlib
|
|
27
28
|
import json
|
|
28
29
|
import re
|
|
29
30
|
from contextlib import contextmanager
|
|
30
31
|
from dataclasses import asdict, dataclass, field
|
|
31
|
-
from datetime import
|
|
32
|
+
from datetime import UTC, datetime
|
|
32
33
|
from functools import lru_cache
|
|
33
34
|
from pathlib import Path
|
|
34
35
|
|
|
@@ -55,10 +56,8 @@ def _locked_append(path: Path):
|
|
|
55
56
|
yield fh
|
|
56
57
|
finally:
|
|
57
58
|
if _HAS_FLOCK:
|
|
58
|
-
|
|
59
|
+
with contextlib.suppress(OSError):
|
|
59
60
|
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
|
|
60
|
-
except OSError:
|
|
61
|
-
pass
|
|
62
61
|
fh.close()
|
|
63
62
|
|
|
64
63
|
|
|
@@ -73,6 +72,11 @@ OWNERSHIP_YAML_PATH = (
|
|
|
73
72
|
/ "config"
|
|
74
73
|
/ "agent-ownership.yaml"
|
|
75
74
|
)
|
|
75
|
+
ROSTER_PATH = (
|
|
76
|
+
Path(__file__).resolve().parent.parent.parent
|
|
77
|
+
/ "config"
|
|
78
|
+
/ "agent-roster.json"
|
|
79
|
+
)
|
|
76
80
|
|
|
77
81
|
GATED_TOOLS: frozenset[str] = frozenset(
|
|
78
82
|
{"Write", "Edit", "MultiEdit", "NotebookEdit"}
|
|
@@ -85,8 +89,13 @@ ROUTING_RE = re.compile(
|
|
|
85
89
|
DISPATCH_RE = re.compile(
|
|
86
90
|
r"\[arka:dispatch\]\s*[\w-]+\s*->\s*([\w-]+)", re.IGNORECASE
|
|
87
91
|
)
|
|
92
|
+
# Single-line, length-bounded body: the lazy `([^\]]+?)\s*` form
|
|
93
|
+
# backtracked catastrophically on an unclosed marker (4000 trailing
|
|
94
|
+
# spaces = 22s) — inside a BLOCKING PreToolUse hook, on model-emitted
|
|
95
|
+
# text. 400 chars is far above any substantive reason; beyond it the
|
|
96
|
+
# marker simply does not parse and the gate stays closed.
|
|
88
97
|
BYPASS_RE = re.compile(
|
|
89
|
-
r"\[arka:specialist-bypass\s+([^\]]
|
|
98
|
+
r"\[arka:specialist-bypass\s+([^\]\n]{1,400})\]", re.IGNORECASE
|
|
90
99
|
)
|
|
91
100
|
|
|
92
101
|
ASSISTANT_WINDOW = 20
|
|
@@ -107,22 +116,42 @@ class Decision:
|
|
|
107
116
|
bypass_used: bool = False
|
|
108
117
|
bypass_reason: str | None = None
|
|
109
118
|
target_file: str | None = None
|
|
119
|
+
# Alias resolution (PR-1): the marker said "diana", the rules say
|
|
120
|
+
# "frontend-dev" — record both so telemetry can prove the fix.
|
|
121
|
+
persona_raw: str | None = None
|
|
122
|
+
alias_resolved: bool = False
|
|
110
123
|
|
|
111
124
|
def to_stderr_message(self) -> str:
|
|
125
|
+
"""The message a blocked session reads.
|
|
126
|
+
|
|
127
|
+
Rewritten after the 2026-07-12 incident, where a session read the
|
|
128
|
+
old text and went looking for a bug to justify a bypass. Three
|
|
129
|
+
changes carry the weight: it refuses the false diagnosis up front,
|
|
130
|
+
it hands over the correct path already filled in (two copyable
|
|
131
|
+
lines), and it demotes the bypass to what it is — an audited
|
|
132
|
+
exception the operator sees. The old text also called EVERY
|
|
133
|
+
persona "(lead)", so a blocked specialist was told she was a lead:
|
|
134
|
+
that actively fed the "the gate is buggy" story.
|
|
135
|
+
"""
|
|
112
136
|
if self.allow:
|
|
113
137
|
return ""
|
|
114
|
-
persona = self.current_persona or "
|
|
115
|
-
|
|
138
|
+
persona = self.current_persona or "unrouted"
|
|
139
|
+
owner = (self.required_owners or ["the owning specialist"])[0]
|
|
140
|
+
owners = ", ".join(self.required_owners) or "the owning specialist"
|
|
116
141
|
target = self.target_file or "this file"
|
|
117
142
|
return (
|
|
118
|
-
f"[ARKA:SPECIALIST] {
|
|
119
|
-
f"
|
|
120
|
-
f"
|
|
121
|
-
f"
|
|
122
|
-
f"the
|
|
123
|
-
f"
|
|
124
|
-
f"
|
|
125
|
-
f"
|
|
143
|
+
f"[ARKA:SPECIALIST] BLOCKED. {target} is owned by: {owners} "
|
|
144
|
+
f"— dispatch any ONE of them. You are {persona}.\n"
|
|
145
|
+
f"This is NOT a bug. The gate is working as designed. Retrying "
|
|
146
|
+
f"this Write will fail again, every time.\n"
|
|
147
|
+
f"DO THIS — two lines, and the specialist writes with no block:\n"
|
|
148
|
+
f" [arka:dispatch] {persona} -> {owner}\n"
|
|
149
|
+
f" Task(subagent_type=\"{owner}\", prompt=\"<what you were "
|
|
150
|
+
f"about to write>\")\n"
|
|
151
|
+
f"Audited exception (visible to the operator in "
|
|
152
|
+
f"`/arka enforcement`) — ONLY if no specialist can do this:\n"
|
|
153
|
+
f" [arka:specialist-bypass owner={owner} reason=<24+ chars "
|
|
154
|
+
f"explaining why a specialist cannot>]"
|
|
126
155
|
)
|
|
127
156
|
|
|
128
157
|
|
|
@@ -139,6 +168,8 @@ class _Ctx:
|
|
|
139
168
|
messages: list[str] = field(default_factory=list)
|
|
140
169
|
persona: str | None = None
|
|
141
170
|
marker: str | None = None
|
|
171
|
+
persona_raw: str | None = None
|
|
172
|
+
alias_resolved: bool = False
|
|
142
173
|
config: dict = field(default_factory=dict)
|
|
143
174
|
preloaded_messages: list[str] | None = None
|
|
144
175
|
|
|
@@ -233,34 +264,110 @@ def _glob_match(pattern: str, path: str) -> bool:
|
|
|
233
264
|
# ─── Persona, bypass, ownership resolution ─────────────────────────────
|
|
234
265
|
|
|
235
266
|
|
|
236
|
-
def
|
|
237
|
-
"""
|
|
267
|
+
def _load_aliases() -> dict[str, str]:
|
|
268
|
+
"""First-name -> owner slug, from the generated roster.
|
|
269
|
+
|
|
270
|
+
Sessions dispatch by human name (``-> diana``); ownership rules name
|
|
271
|
+
slugs (``frontend-dev``). Without this, the RIGHT specialist was
|
|
272
|
+
blocked from her own files — 22 of 189 measured blocks. The roster
|
|
273
|
+
only emits aliases that are unambiguous among gate owners, and never
|
|
274
|
+
guesses (core/agents/roster_manifest.py::build_aliases).
|
|
275
|
+
"""
|
|
276
|
+
try:
|
|
277
|
+
data = json.loads(ROSTER_PATH.read_text(encoding="utf-8"))
|
|
278
|
+
except (OSError, json.JSONDecodeError):
|
|
279
|
+
return {}
|
|
280
|
+
aliases = data.get("aliases")
|
|
281
|
+
return aliases if isinstance(aliases, dict) else {}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _normalize_persona(raw: str) -> tuple[str, bool]:
|
|
285
|
+
"""Return (slug, alias_resolved). Unknown names pass through."""
|
|
286
|
+
slug = _load_aliases().get(raw)
|
|
287
|
+
return (slug, True) if slug else (raw, False)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _resolve_persona(
|
|
291
|
+
messages: list[str],
|
|
292
|
+
) -> tuple[str | None, str | None, str | None, bool]:
|
|
293
|
+
"""Find the current persona: (slug, marker, raw, alias_resolved).
|
|
238
294
|
|
|
239
295
|
Dispatch tag wins over routing because dispatching is more specific.
|
|
296
|
+
The name is normalized through the roster aliases, so a marker that
|
|
297
|
+
names the human (``-> diana``) matches an ownership rule that names
|
|
298
|
+
the slug (``frontend-dev``).
|
|
240
299
|
"""
|
|
241
300
|
for text in reversed(messages):
|
|
242
301
|
dispatch = DISPATCH_RE.search(text)
|
|
243
302
|
if dispatch:
|
|
244
|
-
|
|
303
|
+
raw = dispatch.group(1).lower()
|
|
304
|
+
slug, resolved = _normalize_persona(raw)
|
|
305
|
+
return slug, "dispatch", raw, resolved
|
|
245
306
|
routing = ROUTING_RE.search(text)
|
|
246
307
|
if routing:
|
|
247
|
-
|
|
248
|
-
|
|
308
|
+
raw = routing.group(1).lower()
|
|
309
|
+
slug, resolved = _normalize_persona(raw)
|
|
310
|
+
return slug, "routing", raw, resolved
|
|
311
|
+
return None, None, None, False
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# A bypass must cost more than doing the right thing. Before the
|
|
315
|
+
# 2026-07-12 incident the only barrier was a non-empty string, and the
|
|
316
|
+
# deny message advertised the bypass beside the dispatch as an equal
|
|
317
|
+
# option — the single place in the whole prompt surface that taught it.
|
|
318
|
+
_MIN_BYPASS_REASON = 24
|
|
319
|
+
_EMPTY_REASONS = (
|
|
320
|
+
"typo", "quick fix", "quickfix", "trivial", "urgent", "just this once",
|
|
321
|
+
"small change", "minor", "one char", "fast", "quick typo fix",
|
|
322
|
+
"just a quick typo fix", "just a quick typo fix honestly",
|
|
323
|
+
"nothing important", "it is faster this way",
|
|
324
|
+
)
|
|
325
|
+
# Structured form the deny message teaches: `owner=<slug> reason=<text>`.
|
|
326
|
+
_BYPASS_STRUCTURED_RE = re.compile(
|
|
327
|
+
r"^owner=(?P<owner>[\w-]+)\s+reason=(?P<reason>.*)$",
|
|
328
|
+
re.IGNORECASE | re.DOTALL,
|
|
329
|
+
)
|
|
249
330
|
|
|
250
331
|
|
|
251
|
-
def
|
|
252
|
-
"""
|
|
332
|
+
def _reason_is_substantive(reason: str) -> bool:
|
|
333
|
+
"""A reason must say something. QG redo 1 caught the hole: the floor
|
|
334
|
+
was applied to the WHOLE marker body, so
|
|
335
|
+
``owner=senior-dev reason=`` (24 chars of boilerplate, empty reason)
|
|
336
|
+
opened the gate — with the deny message teaching that very template.
|
|
337
|
+
The floor now applies to the reason ALONE."""
|
|
338
|
+
reason = reason.strip()
|
|
339
|
+
if len(reason) < _MIN_BYPASS_REASON:
|
|
340
|
+
return False
|
|
341
|
+
normalized = reason.lower().strip(" .!,;")
|
|
342
|
+
return normalized not in _EMPTY_REASONS
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _find_bypass(messages: list[str], owners: list[str] | None = None) -> str | None:
|
|
346
|
+
"""Return the bypass reason from the LAST assistant message, or None.
|
|
253
347
|
|
|
254
348
|
Scope is strict: only the immediately preceding assistant message can
|
|
255
|
-
grant a bypass.
|
|
349
|
+
grant a bypass. The structured form is required in spirit — a bare
|
|
350
|
+
legacy reason is still accepted for one release, but it must clear the
|
|
351
|
+
same substance floor. When ``owners`` is given, a structured
|
|
352
|
+
``owner=`` that names someone other than an actual owner is rejected:
|
|
353
|
+
a bypass may not invent its own justification target.
|
|
256
354
|
"""
|
|
257
355
|
if not messages:
|
|
258
356
|
return None
|
|
259
357
|
match = BYPASS_RE.search(messages[-1])
|
|
260
358
|
if not match:
|
|
261
359
|
return None
|
|
262
|
-
|
|
263
|
-
|
|
360
|
+
body = match.group(1).strip()
|
|
361
|
+
structured = _BYPASS_STRUCTURED_RE.match(body)
|
|
362
|
+
if structured:
|
|
363
|
+
if owners:
|
|
364
|
+
claimed = structured.group("owner").lower()
|
|
365
|
+
if claimed not in {o.lower() for o in owners}:
|
|
366
|
+
return None
|
|
367
|
+
reason = structured.group("reason")
|
|
368
|
+
else:
|
|
369
|
+
reason = body
|
|
370
|
+
return reason.strip() if _reason_is_substantive(reason) else None
|
|
264
371
|
|
|
265
372
|
|
|
266
373
|
def _match_ownership(
|
|
@@ -312,7 +419,9 @@ def _populate_context(ctx: _Ctx) -> None:
|
|
|
312
419
|
ctx.messages = _load_last_assistant_messages(
|
|
313
420
|
ctx.transcript_path, ASSISTANT_WINDOW
|
|
314
421
|
)
|
|
315
|
-
ctx.persona, ctx.marker =
|
|
422
|
+
ctx.persona, ctx.marker, ctx.persona_raw, ctx.alias_resolved = (
|
|
423
|
+
_resolve_persona(ctx.messages)
|
|
424
|
+
)
|
|
316
425
|
|
|
317
426
|
|
|
318
427
|
def _check_no_persona(ctx: _Ctx) -> Decision | None:
|
|
@@ -360,7 +469,8 @@ def _decide_owner_match(ctx: _Ctx, owners: list[str]) -> Decision:
|
|
|
360
469
|
return Decision(
|
|
361
470
|
allow=True, reason=f"owner-match:{ctx.persona}",
|
|
362
471
|
current_persona=ctx.persona, marker_found=ctx.marker,
|
|
363
|
-
target_file=ctx.file_path,
|
|
472
|
+
target_file=ctx.file_path,
|
|
473
|
+
persona_raw=ctx.persona_raw, alias_resolved=ctx.alias_resolved, required_owners=owners,
|
|
364
474
|
)
|
|
365
475
|
|
|
366
476
|
|
|
@@ -368,7 +478,8 @@ def _decide_bypass(ctx: _Ctx, owners: list[str], reason: str) -> Decision:
|
|
|
368
478
|
return Decision(
|
|
369
479
|
allow=True, reason="bypass-with-reason",
|
|
370
480
|
current_persona=ctx.persona, marker_found=ctx.marker,
|
|
371
|
-
target_file=ctx.file_path,
|
|
481
|
+
target_file=ctx.file_path,
|
|
482
|
+
persona_raw=ctx.persona_raw, alias_resolved=ctx.alias_resolved, required_owners=owners,
|
|
372
483
|
bypass_used=True, bypass_reason=reason,
|
|
373
484
|
)
|
|
374
485
|
|
|
@@ -379,7 +490,8 @@ def _decide_block(ctx: _Ctx, owners: list[str]) -> Decision:
|
|
|
379
490
|
allow=False,
|
|
380
491
|
reason=f"lead-blocked:{ctx.persona}-not-in-[{','.join(owners_lower)}]",
|
|
381
492
|
current_persona=ctx.persona, marker_found=ctx.marker,
|
|
382
|
-
target_file=ctx.file_path,
|
|
493
|
+
target_file=ctx.file_path,
|
|
494
|
+
persona_raw=ctx.persona_raw, alias_resolved=ctx.alias_resolved, required_owners=owners,
|
|
383
495
|
)
|
|
384
496
|
|
|
385
497
|
|
|
@@ -389,7 +501,7 @@ def _resolve_with_owners(ctx: _Ctx, owners: list[str], rule_reason: str | None)
|
|
|
389
501
|
return _decide_open_access(ctx, owners, rule_reason)
|
|
390
502
|
if ctx.persona in {o.lower() for o in owners}:
|
|
391
503
|
return _decide_owner_match(ctx, owners)
|
|
392
|
-
bypass = _find_bypass(ctx.messages)
|
|
504
|
+
bypass = _find_bypass(ctx.messages, owners)
|
|
393
505
|
if bypass:
|
|
394
506
|
return _decide_bypass(ctx, owners, bypass)
|
|
395
507
|
return _decide_block(ctx, owners)
|
|
@@ -464,7 +576,7 @@ def record_telemetry(
|
|
|
464
576
|
if safe is None:
|
|
465
577
|
return
|
|
466
578
|
entry = {
|
|
467
|
-
"ts": datetime.now(
|
|
579
|
+
"ts": datetime.now(UTC).isoformat(),
|
|
468
580
|
"session_id": safe,
|
|
469
581
|
"tool": tool,
|
|
470
582
|
"cwd": cwd,
|
package/package.json
CHANGED