arkaos 4.14.3 → 4.15.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
- 4.14.3
1
+ 4.15.0
@@ -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
 
@@ -26,6 +26,7 @@ the specialist and flow gates via their ``messages=`` params.
26
26
 
27
27
  from __future__ import annotations
28
28
 
29
+ import contextlib
29
30
  import sys
30
31
  from pathlib import Path
31
32
 
@@ -65,19 +66,26 @@ class _MessagesOnce:
65
66
  def __init__(self, transcript_path: str):
66
67
  self._path = transcript_path
67
68
  self._messages: list[str] | None = None
69
+ self._sidechain: bool | None = None
68
70
 
69
71
  def peek(self) -> list[str] | None:
70
72
  return self._messages
71
73
 
74
+ def sidechain_active(self) -> bool | None:
75
+ """Scope of the most recent assistant record; None before load()."""
76
+ return self._sidechain
77
+
72
78
  def load(self) -> list[str] | None:
73
79
  if self._messages is None:
74
80
  try:
75
- from core.workflow.flow_enforcer import (
76
- _load_last_assistant_messages,
77
- )
78
- self._messages = _load_last_assistant_messages(
79
- self._path, _ASSISTANT_WINDOW
80
- )
81
+ # Scope-aware since P0.2: the window counts MAIN-scope
82
+ # messages only, so interleaved subagent records cannot
83
+ # evict the routing marker; sidechain-ness feeds the
84
+ # specialist gate's persistence guard.
85
+ from core.workflow.transcript_scope import split_from_path
86
+ split = split_from_path(self._path)
87
+ self._messages = split.main[-_ASSISTANT_WINDOW:]
88
+ self._sidechain = split.active_sidechain
81
89
  except Exception:
82
90
  return None
83
91
  return self._messages
@@ -104,10 +112,8 @@ def _kb_gate(root: str, tool_name: str, session_id: str, query: str) -> int | No
104
112
  decision = evaluate_research_gate(
105
113
  tool_name=tool_name, session_id=session_id, query=query
106
114
  )
107
- try:
115
+ with contextlib.suppress(Exception):
108
116
  record_telemetry(session_id=session_id, tool=tool_name, decision=decision)
109
- except Exception:
110
- pass
111
117
  if not decision.allow:
112
118
  return _deny(decision.to_stderr_message())
113
119
  if decision.nudge and decision.to_stderr_message():
@@ -150,8 +156,9 @@ def _specialist_gate(
150
156
  cwd=cwd,
151
157
  tool_input=tool_input,
152
158
  messages=shared,
159
+ is_sidechain=messages.sidechain_active(),
153
160
  )
154
- try:
161
+ with contextlib.suppress(Exception):
155
162
  record_telemetry(
156
163
  session_id=session_id,
157
164
  tool=tool_name,
@@ -160,8 +167,6 @@ def _specialist_gate(
160
167
  target_file=str(tool_input.get("file_path", "")),
161
168
  model_requested=str(tool_input.get("model", "")),
162
169
  )
163
- except Exception:
164
- pass
165
170
  if not decision.allow:
166
171
  return _deny(decision.to_stderr_message())
167
172
  return None
@@ -206,10 +211,8 @@ def _frontend_gate(
206
211
  tool_input=tool_input,
207
212
  messages=messages.load(),
208
213
  )
209
- try:
214
+ with contextlib.suppress(Exception):
210
215
  record_telemetry(session_id=session_id, tool=tool_name, decision=decision)
211
- except Exception:
212
- pass
213
216
  if not decision.allow:
214
217
  return _deny(decision.to_stderr_message())
215
218
  if decision.to_stderr_message():
@@ -259,12 +262,10 @@ def _flow_gate(
259
262
  # env bypass, classifier flag, marker cache) before reading.
260
263
  messages=messages.peek(),
261
264
  )
262
- try:
265
+ with contextlib.suppress(Exception):
263
266
  record_telemetry(
264
267
  session_id=session_id, tool=tool_name, decision=decision, cwd=cwd
265
268
  )
266
- except Exception:
267
- pass
268
269
  if decision.allow:
269
270
  # Grace path: allow, but surface the non-blocking warning so the
270
271
  # operator sees the routing nudge instead of a silent pass.
@@ -326,4 +327,4 @@ if __name__ == "__main__":
326
327
  except Exception:
327
328
  # Fail open — the bash version piped every heredoc through
328
329
  # `2>/dev/null` and exited 0 on internal errors.
329
- raise SystemExit(0)
330
+ raise SystemExit(0) from None
@@ -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)
@@ -0,0 +1,140 @@
1
+ """Persistent per-session persona — closes the specialist gate fail-open.
2
+
3
+ The specialist gate reads the persona from the last ``[arka:routing]`` /
4
+ ``[arka:dispatch]`` marker in a 20-message window. When the marker rolls
5
+ out of the window the gate returned ALLOW (``no-routing-tag``) — 4,093
6
+ of 5,683 telemetry records (72%). Routing once and hammering the gate
7
+ with 20 messages of noise was a winning strategy.
8
+
9
+ Fix mirrors ``design_authorization`` (#297, frontend gate): **persist on
10
+ observe, consult before allow**. Whenever ``evaluate`` sees a marker in
11
+ the window it confirms the resolved persona here; when the window shows
12
+ nothing, a valid persisted persona for the session decides exactly as if
13
+ the marker were still visible — including deciding BLOCK.
14
+
15
+ Two deliberate properties:
16
+
17
+ - **The record is keyed by session_id AND the transcript file's name.**
18
+ A dispatched subagent runs on its own transcript (ADR
19
+ 2026-05-28-specialist-dispatch-subagent-blindspot), so it can never
20
+ inherit the parent's persisted persona and be blocked from the very
21
+ files it was dispatched to write — even if it shares the session_id.
22
+ - **A session that never routed stays un-restored** (``never-routed``
23
+ keeps today's ALLOW): this module only closes the eviction hole; the
24
+ hard flip for never-routed sessions is a separate, telemetry-gated
25
+ decision.
26
+
27
+ State dir: ``/tmp/arkaos-specialist-auth`` (override via
28
+ ``ARKA_SPECIALIST_AUTH_DIR``).
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import contextlib
34
+ import json
35
+ import os
36
+ import time
37
+ from pathlib import Path
38
+
39
+ from core.shared import safe_session_id as _safe_session_id_module
40
+ from core.shared.temp_paths import arkaos_temp_dir
41
+
42
+ _safe_session_id = _safe_session_id_module.safe_session_id
43
+
44
+ # A working session; a stale /tmp record expires on its own.
45
+ DEFAULT_TTL_SECONDS = 12 * 60 * 60
46
+
47
+
48
+ def _base_dir() -> Path:
49
+ override = os.environ.get("ARKA_SPECIALIST_AUTH_DIR", "").strip()
50
+ return Path(override) if override else arkaos_temp_dir(
51
+ "arkaos-specialist-auth"
52
+ )
53
+
54
+
55
+ def _auth_path(session_id: str) -> Path | None:
56
+ safe = _safe_session_id(session_id)
57
+ return (_base_dir() / f"{safe}.json") if safe else None
58
+
59
+
60
+ def _read(path: Path | None) -> dict:
61
+ if path is None or not path.exists():
62
+ return {}
63
+ try:
64
+ # errors="replace", like transcript_scope: a non-UTF8 byte in an
65
+ # operator-writable /tmp file must degrade to "no record", never
66
+ # raise through evaluate() into the hook's exit-0 (QG redo 3 —
67
+ # an uncaught UnicodeDecodeError WAS a silent gate bypass).
68
+ raw = path.read_text(encoding="utf-8", errors="replace")
69
+ data = json.loads(raw)
70
+ except (ValueError, OSError): # JSONDecodeError/UnicodeDecodeError ⊂ ValueError
71
+ return {}
72
+ return data if isinstance(data, dict) else {}
73
+
74
+
75
+ def confirm(
76
+ session_id: str,
77
+ transcript_path: str,
78
+ persona: str,
79
+ marker: str,
80
+ persona_raw: str | None = None,
81
+ alias_resolved: bool = False,
82
+ ) -> None:
83
+ """Persist the persona observed in the window (marker seen)."""
84
+ if not persona or not transcript_path:
85
+ return
86
+ path = _auth_path(session_id)
87
+ if path is None:
88
+ return
89
+ try:
90
+ path.parent.mkdir(parents=True, exist_ok=True)
91
+ path.write_text(
92
+ json.dumps({
93
+ "persona": persona,
94
+ "marker": marker,
95
+ "persona_raw": persona_raw,
96
+ "alias_resolved": bool(alias_resolved),
97
+ "transcript": Path(transcript_path).name,
98
+ "confirmed_ts": time.time(),
99
+ }),
100
+ encoding="utf-8",
101
+ )
102
+ except OSError:
103
+ pass # authorization state must never block the hook
104
+
105
+
106
+ def confirmed(
107
+ session_id: str,
108
+ transcript_path: str,
109
+ ttl_seconds: int = DEFAULT_TTL_SECONDS,
110
+ ) -> dict | None:
111
+ """Return the persisted persona record, or None.
112
+
113
+ None unless: record exists, TTL holds, the persona is non-empty AND
114
+ the record was written for THIS transcript file — the property that
115
+ keeps a parent's persona out of a subagent's evaluation.
116
+ """
117
+ if not transcript_path:
118
+ return None
119
+ data = _read(_auth_path(session_id))
120
+ ts = data.get("confirmed_ts")
121
+ # isinstance, not float(): a string in a corrupt-but-valid JSON would
122
+ # raise straight through evaluate() — the same silent-bypass class as
123
+ # the non-UTF8 hole (QG redo 3).
124
+ if not isinstance(ts, (int, float)) or time.time() - ts > ttl_seconds:
125
+ return None
126
+ if data.get("transcript") != Path(transcript_path).name:
127
+ return None
128
+ persona = data.get("persona")
129
+ if not isinstance(persona, str) or not persona:
130
+ return None
131
+ return data
132
+
133
+
134
+ def clear(session_id: str) -> None:
135
+ """Remove authorization state for a session (tests / session end)."""
136
+ path = _auth_path(session_id)
137
+ if path is None:
138
+ return
139
+ with contextlib.suppress(FileNotFoundError, OSError):
140
+ path.unlink()
@@ -13,29 +13,42 @@ Used bypasses are logged to telemetry for accountability.
13
13
  Feature flag: `hooks.specialistEnforcement` in ~/.arkaos/config.json.
14
14
 
15
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
16
+ blindspot, amended by P0.2): the enforcer is a NEGATIVE gate on the
17
+ parent transcript only. Subagent writes pass through because Claude
18
18
  Code isolates subagent transcripts from the parent. The positive
19
19
  `owner-match` path is exercised when the parent emits `[arka:dispatch]`
20
20
  inline (e.g., the orchestrator impersonating a specialist) and remains
21
21
  for forward compatibility if parent-transcript visibility ever ships.
22
22
 
23
+ Fail-open closed (P0.2): a marker that rolls out of the 20-message
24
+ window no longer reopens the gate — the persona observed in the window
25
+ is persisted per session+transcript (specialist_authorization) and
26
+ restored when the window shows nothing, deciding exactly as if the
27
+ marker were visible. The old blanket `no-routing-tag` (72% of all
28
+ telemetry) splits into `never-routed` (session never emitted a marker;
29
+ still ALLOW — hardening THAT is a separate, telemetry-gated decision)
30
+ and `subagent-scope` (sidechain evaluation; ADR behavior, now
31
+ measurable). The window itself counts main-scope messages only
32
+ (transcript_scope), so interleaved subagent records cannot evict the
33
+ marker.
34
+
23
35
  Read by: config/hooks/pre-tool-use.sh between the KB-gate and the
24
36
  flow-gate. Same Decision JSON contract as core.workflow.flow_enforcer.
25
37
  """
26
38
 
39
+ import contextlib
27
40
  import json
28
41
  import re
29
42
  from contextlib import contextmanager
30
43
  from dataclasses import asdict, dataclass, field
31
- from datetime import datetime, timezone
44
+ from datetime import UTC, datetime
32
45
  from functools import lru_cache
33
46
  from pathlib import Path
34
47
 
35
48
  import yaml
36
49
 
37
50
  from core.shared import safe_session_id as _safe_session_id_module
38
- from core.workflow.flow_enforcer import _load_last_assistant_messages
51
+ from core.workflow import specialist_authorization, transcript_scope
39
52
 
40
53
  try:
41
54
  import fcntl # POSIX only
@@ -55,10 +68,8 @@ def _locked_append(path: Path):
55
68
  yield fh
56
69
  finally:
57
70
  if _HAS_FLOCK:
58
- try:
71
+ with contextlib.suppress(OSError):
59
72
  fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
60
- except OSError:
61
- pass
62
73
  fh.close()
63
74
 
64
75
 
@@ -73,6 +84,11 @@ OWNERSHIP_YAML_PATH = (
73
84
  / "config"
74
85
  / "agent-ownership.yaml"
75
86
  )
87
+ ROSTER_PATH = (
88
+ Path(__file__).resolve().parent.parent.parent
89
+ / "config"
90
+ / "agent-roster.json"
91
+ )
76
92
 
77
93
  GATED_TOOLS: frozenset[str] = frozenset(
78
94
  {"Write", "Edit", "MultiEdit", "NotebookEdit"}
@@ -85,8 +101,13 @@ ROUTING_RE = re.compile(
85
101
  DISPATCH_RE = re.compile(
86
102
  r"\[arka:dispatch\]\s*[\w-]+\s*->\s*([\w-]+)", re.IGNORECASE
87
103
  )
104
+ # Single-line, length-bounded body: the lazy `([^\]]+?)\s*` form
105
+ # backtracked catastrophically on an unclosed marker (4000 trailing
106
+ # spaces = 22s) — inside a BLOCKING PreToolUse hook, on model-emitted
107
+ # text. 400 chars is far above any substantive reason; beyond it the
108
+ # marker simply does not parse and the gate stays closed.
88
109
  BYPASS_RE = re.compile(
89
- r"\[arka:specialist-bypass\s+([^\]]+?)\s*\]", re.IGNORECASE
110
+ r"\[arka:specialist-bypass\s+([^\]\n]{1,400})\]", re.IGNORECASE
90
111
  )
91
112
 
92
113
  ASSISTANT_WINDOW = 20
@@ -107,22 +128,50 @@ class Decision:
107
128
  bypass_used: bool = False
108
129
  bypass_reason: str | None = None
109
130
  target_file: str | None = None
131
+ # Alias resolution (PR-1): the marker said "diana", the rules say
132
+ # "frontend-dev" — record both so telemetry can prove the fix.
133
+ persona_raw: str | None = None
134
+ alias_resolved: bool = False
135
+ # Fail-open taxonomy (P0.2): where the persona came from ("window" |
136
+ # "persisted" | ""), whether the ACTIVE scope is a sidechain, and how
137
+ # far the marker sits from the window's end (None = not in window).
138
+ persona_source: str = ""
139
+ is_sidechain: bool = False
140
+ msgs_since_marker: int | None = None
110
141
 
111
142
  def to_stderr_message(self) -> str:
143
+ """The message a blocked session reads.
144
+
145
+ Rewritten after the 2026-07-12 incident, where a session read the
146
+ old text and went looking for a bug to justify a bypass. Three
147
+ changes carry the weight: it refuses the false diagnosis up front,
148
+ it hands over the correct path already filled in (two copyable
149
+ lines), and it demotes the bypass to what it is — an audited
150
+ exception the operator sees. The old text also called EVERY
151
+ persona "(lead)", so a blocked specialist was told she was a lead:
152
+ that actively fed the "the gate is buggy" story.
153
+ """
112
154
  if self.allow:
113
155
  return ""
114
- persona = self.current_persona or "lead"
115
- owners = ", ".join(self.required_owners) or "specialist"
156
+ persona = self.current_persona or "unrouted"
157
+ owner = (self.required_owners or ["the owning specialist"])[0]
158
+ owners = ", ".join(self.required_owners) or "the owning specialist"
116
159
  target = self.target_file or "this file"
117
160
  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)."
161
+ f"[ARKA:SPECIALIST] BLOCKED. {target} is owned by: {owners} "
162
+ f" dispatch any ONE of them. You are {persona}.\n"
163
+ f"This is NOT a bug. The gate is working as designed. Retrying "
164
+ f"this Write will fail again, every time.\n"
165
+ f"DO THIS — two lines, and the specialist writes with no block:\n"
166
+ f" [arka:dispatch] {persona} -> {owner}\n"
167
+ f" Task(subagent_type=\"{owner}\", prompt=\"<what you were "
168
+ f"about to write>\")\n"
169
+ f"Audited exception (visible to the operator in the "
170
+ f"specialist-dispatch telemetry: `arka-py -m "
171
+ f"core.governance.specialist_telemetry_cli today`) — ONLY if "
172
+ f"no specialist can do this:\n"
173
+ f" [arka:specialist-bypass owner={owner} reason=<24+ chars "
174
+ f"explaining why a specialist cannot>]"
126
175
  )
127
176
 
128
177
 
@@ -139,8 +188,15 @@ class _Ctx:
139
188
  messages: list[str] = field(default_factory=list)
140
189
  persona: str | None = None
141
190
  marker: str | None = None
191
+ persona_raw: str | None = None
192
+ alias_resolved: bool = False
142
193
  config: dict = field(default_factory=dict)
143
194
  preloaded_messages: list[str] | None = None
195
+ persona_source: str = ""
196
+ # None = self-detect from the transcript; the consolidated hook passes
197
+ # the value it computed while parsing (parse-once contract, PR-6).
198
+ is_sidechain: bool | None = None
199
+ msgs_since_marker: int | None = None
144
200
 
145
201
 
146
202
  # ─── Config + Ownership loaders ────────────────────────────────────────
@@ -233,34 +289,110 @@ def _glob_match(pattern: str, path: str) -> bool:
233
289
  # ─── Persona, bypass, ownership resolution ─────────────────────────────
234
290
 
235
291
 
236
- def _resolve_persona(messages: list[str]) -> tuple[str | None, str | None]:
237
- """Find the current persona, scanning newest-to-oldest assistant turns.
292
+ def _load_aliases() -> dict[str, str]:
293
+ """First-name -> owner slug, from the generated roster.
294
+
295
+ Sessions dispatch by human name (``-> diana``); ownership rules name
296
+ slugs (``frontend-dev``). Without this, the RIGHT specialist was
297
+ blocked from her own files — 22 of 189 measured blocks. The roster
298
+ only emits aliases that are unambiguous among gate owners, and never
299
+ guesses (core/agents/roster_manifest.py::build_aliases).
300
+ """
301
+ try:
302
+ data = json.loads(ROSTER_PATH.read_text(encoding="utf-8"))
303
+ except (OSError, json.JSONDecodeError):
304
+ return {}
305
+ aliases = data.get("aliases")
306
+ return aliases if isinstance(aliases, dict) else {}
307
+
308
+
309
+ def _normalize_persona(raw: str) -> tuple[str, bool]:
310
+ """Return (slug, alias_resolved). Unknown names pass through."""
311
+ slug = _load_aliases().get(raw)
312
+ return (slug, True) if slug else (raw, False)
313
+
314
+
315
+ def _resolve_persona(
316
+ messages: list[str],
317
+ ) -> tuple[str | None, str | None, str | None, bool]:
318
+ """Find the current persona: (slug, marker, raw, alias_resolved).
238
319
 
239
320
  Dispatch tag wins over routing because dispatching is more specific.
321
+ The name is normalized through the roster aliases, so a marker that
322
+ names the human (``-> diana``) matches an ownership rule that names
323
+ the slug (``frontend-dev``).
240
324
  """
241
325
  for text in reversed(messages):
242
326
  dispatch = DISPATCH_RE.search(text)
243
327
  if dispatch:
244
- return dispatch.group(1).lower(), "dispatch"
328
+ raw = dispatch.group(1).lower()
329
+ slug, resolved = _normalize_persona(raw)
330
+ return slug, "dispatch", raw, resolved
245
331
  routing = ROUTING_RE.search(text)
246
332
  if routing:
247
- return routing.group(1).lower(), "routing"
248
- return None, None
333
+ raw = routing.group(1).lower()
334
+ slug, resolved = _normalize_persona(raw)
335
+ return slug, "routing", raw, resolved
336
+ return None, None, None, False
337
+
338
+
339
+ # A bypass must cost more than doing the right thing. Before the
340
+ # 2026-07-12 incident the only barrier was a non-empty string, and the
341
+ # deny message advertised the bypass beside the dispatch as an equal
342
+ # option — the single place in the whole prompt surface that taught it.
343
+ _MIN_BYPASS_REASON = 24
344
+ _EMPTY_REASONS = (
345
+ "typo", "quick fix", "quickfix", "trivial", "urgent", "just this once",
346
+ "small change", "minor", "one char", "fast", "quick typo fix",
347
+ "just a quick typo fix", "just a quick typo fix honestly",
348
+ "nothing important", "it is faster this way",
349
+ )
350
+ # Structured form the deny message teaches: `owner=<slug> reason=<text>`.
351
+ _BYPASS_STRUCTURED_RE = re.compile(
352
+ r"^owner=(?P<owner>[\w-]+)\s+reason=(?P<reason>.*)$",
353
+ re.IGNORECASE | re.DOTALL,
354
+ )
249
355
 
250
356
 
251
- def _find_bypass(messages: list[str]) -> str | None:
252
- """Return bypass reason from LAST assistant message, or None.
357
+ def _reason_is_substantive(reason: str) -> bool:
358
+ """A reason must say something. QG redo 1 caught the hole: the floor
359
+ was applied to the WHOLE marker body, so
360
+ ``owner=senior-dev reason=`` (24 chars of boilerplate, empty reason)
361
+ opened the gate — with the deny message teaching that very template.
362
+ The floor now applies to the reason ALONE."""
363
+ reason = reason.strip()
364
+ if len(reason) < _MIN_BYPASS_REASON:
365
+ return False
366
+ normalized = reason.lower().strip(" .!,;")
367
+ return normalized not in _EMPTY_REASONS
368
+
369
+
370
+ def _find_bypass(messages: list[str], owners: list[str] | None = None) -> str | None:
371
+ """Return the bypass reason from the LAST assistant message, or None.
253
372
 
254
373
  Scope is strict: only the immediately preceding assistant message can
255
- grant a bypass. Empty / whitespace reasons are rejected.
374
+ grant a bypass. The structured form is required in spirit — a bare
375
+ legacy reason is still accepted for one release, but it must clear the
376
+ same substance floor. When ``owners`` is given, a structured
377
+ ``owner=`` that names someone other than an actual owner is rejected:
378
+ a bypass may not invent its own justification target.
256
379
  """
257
380
  if not messages:
258
381
  return None
259
382
  match = BYPASS_RE.search(messages[-1])
260
383
  if not match:
261
384
  return None
262
- reason = match.group(1).strip()
263
- return reason if reason else None
385
+ body = match.group(1).strip()
386
+ structured = _BYPASS_STRUCTURED_RE.match(body)
387
+ if structured:
388
+ if owners:
389
+ claimed = structured.group("owner").lower()
390
+ if claimed not in {o.lower() for o in owners}:
391
+ return None
392
+ reason = structured.group("reason")
393
+ else:
394
+ reason = body
395
+ return reason.strip() if _reason_is_substantive(reason) else None
264
396
 
265
397
 
266
398
  def _match_ownership(
@@ -285,6 +417,15 @@ def _is_lead_allowed(file_path: str, patterns: list[str]) -> bool:
285
417
  # ─── Pipeline stages (B1 refactor) ─────────────────────────────────────
286
418
 
287
419
 
420
+ def _scope_fields(ctx: _Ctx) -> dict:
421
+ """The P0.2 taxonomy fields every post-populate Decision carries."""
422
+ return {
423
+ "persona_source": ctx.persona_source,
424
+ "is_sidechain": bool(ctx.is_sidechain),
425
+ "msgs_since_marker": ctx.msgs_since_marker,
426
+ }
427
+
428
+
288
429
  def _check_tool_gated(ctx: _Ctx) -> Decision | None:
289
430
  if ctx.tool_name not in GATED_TOOLS:
290
431
  return Decision(allow=True, reason="tool-not-gated")
@@ -297,8 +438,15 @@ def _check_feature_flag(ctx: _Ctx) -> Decision | None:
297
438
  return None
298
439
 
299
440
 
441
+ def _msgs_since_marker(messages: list[str]) -> int | None:
442
+ for i, text in enumerate(reversed(messages)):
443
+ if DISPATCH_RE.search(text) or ROUTING_RE.search(text):
444
+ return i
445
+ return None
446
+
447
+
300
448
  def _populate_context(ctx: _Ctx) -> None:
301
- """Extract file_path, load ownership config, load + resolve transcript."""
449
+ """Extract file_path, load ownership, resolve persona (P0.2 fail-open)."""
302
450
  if ctx.tool_input and isinstance(ctx.tool_input, dict):
303
451
  ctx.file_path = str(
304
452
  ctx.tool_input.get("file_path")
@@ -309,16 +457,55 @@ def _populate_context(ctx: _Ctx) -> None:
309
457
  if ctx.preloaded_messages is not None:
310
458
  ctx.messages = ctx.preloaded_messages[-ASSISTANT_WINDOW:]
311
459
  else:
312
- ctx.messages = _load_last_assistant_messages(
313
- ctx.transcript_path, ASSISTANT_WINDOW
460
+ split = transcript_scope.split_from_path(ctx.transcript_path)
461
+ ctx.messages = split.main[-ASSISTANT_WINDOW:]
462
+ if ctx.is_sidechain is None:
463
+ ctx.is_sidechain = split.active_sidechain
464
+ ctx.is_sidechain = bool(ctx.is_sidechain)
465
+ _resolve_or_restore_persona(ctx)
466
+
467
+
468
+ def _resolve_or_restore_persona(ctx: _Ctx) -> None:
469
+ """Persist-on-observe + consult-before-allow (P0.2, mirrors #297).
470
+
471
+ Marker in the window -> persist the resolved persona. Window empty of
472
+ markers -> a valid persisted persona for THIS session+transcript
473
+ decides as if the marker were still visible — including deciding
474
+ BLOCK. Sidechain evaluations never consult the parent's persistence
475
+ (the dispatched specialist must keep writing with no block, per the
476
+ ADR).
477
+ """
478
+ ctx.persona, ctx.marker, ctx.persona_raw, ctx.alias_resolved = (
479
+ _resolve_persona(ctx.messages)
480
+ )
481
+ if ctx.persona is not None:
482
+ ctx.persona_source = "window"
483
+ ctx.msgs_since_marker = _msgs_since_marker(ctx.messages)
484
+ specialist_authorization.confirm(
485
+ ctx.session_id, ctx.transcript_path,
486
+ persona=ctx.persona, marker=ctx.marker or "",
487
+ persona_raw=ctx.persona_raw, alias_resolved=ctx.alias_resolved,
488
+ )
489
+ elif not ctx.is_sidechain:
490
+ restored = specialist_authorization.confirmed(
491
+ ctx.session_id, ctx.transcript_path
314
492
  )
315
- ctx.persona, ctx.marker = _resolve_persona(ctx.messages)
493
+ if restored:
494
+ ctx.persona = restored["persona"]
495
+ ctx.marker = restored.get("marker") or None
496
+ ctx.persona_raw = restored.get("persona_raw")
497
+ ctx.alias_resolved = bool(restored.get("alias_resolved"))
498
+ ctx.persona_source = "persisted"
316
499
 
317
500
 
318
501
  def _check_no_persona(ctx: _Ctx) -> Decision | None:
319
502
  if ctx.persona is None:
503
+ # Taxonomy (P0.2): the old blanket "no-routing-tag" (72% of all
504
+ # telemetry) splits into what actually happened.
505
+ reason = "subagent-scope" if ctx.is_sidechain else "never-routed"
320
506
  return Decision(
321
- allow=True, reason="no-routing-tag", target_file=ctx.file_path,
507
+ allow=True, reason=reason, target_file=ctx.file_path,
508
+ **_scope_fields(ctx),
322
509
  )
323
510
  return None
324
511
 
@@ -329,7 +516,7 @@ def _check_c_suite(ctx: _Ctx) -> Decision | None:
329
516
  return Decision(
330
517
  allow=True, reason="c-suite-override",
331
518
  current_persona=ctx.persona, marker_found=ctx.marker,
332
- target_file=ctx.file_path,
519
+ target_file=ctx.file_path, **_scope_fields(ctx),
333
520
  )
334
521
  return None
335
522
 
@@ -340,7 +527,7 @@ def _check_lead_allowed_file(ctx: _Ctx) -> Decision | None:
340
527
  return Decision(
341
528
  allow=True, reason="lead-allowed-file",
342
529
  current_persona=ctx.persona, marker_found=ctx.marker,
343
- target_file=ctx.file_path,
530
+ target_file=ctx.file_path, **_scope_fields(ctx),
344
531
  )
345
532
  return None
346
533
 
@@ -352,7 +539,7 @@ def _decide_open_access(
352
539
  return Decision(
353
540
  allow=True, reason=reason, current_persona=ctx.persona,
354
541
  marker_found=ctx.marker, target_file=ctx.file_path,
355
- required_owners=owners,
542
+ required_owners=owners, **_scope_fields(ctx),
356
543
  )
357
544
 
358
545
 
@@ -360,7 +547,9 @@ def _decide_owner_match(ctx: _Ctx, owners: list[str]) -> Decision:
360
547
  return Decision(
361
548
  allow=True, reason=f"owner-match:{ctx.persona}",
362
549
  current_persona=ctx.persona, marker_found=ctx.marker,
363
- target_file=ctx.file_path, required_owners=owners,
550
+ target_file=ctx.file_path,
551
+ persona_raw=ctx.persona_raw, alias_resolved=ctx.alias_resolved,
552
+ required_owners=owners, **_scope_fields(ctx),
364
553
  )
365
554
 
366
555
 
@@ -368,8 +557,10 @@ def _decide_bypass(ctx: _Ctx, owners: list[str], reason: str) -> Decision:
368
557
  return Decision(
369
558
  allow=True, reason="bypass-with-reason",
370
559
  current_persona=ctx.persona, marker_found=ctx.marker,
371
- target_file=ctx.file_path, required_owners=owners,
372
- bypass_used=True, bypass_reason=reason,
560
+ target_file=ctx.file_path,
561
+ persona_raw=ctx.persona_raw, alias_resolved=ctx.alias_resolved,
562
+ required_owners=owners, bypass_used=True, bypass_reason=reason,
563
+ **_scope_fields(ctx),
373
564
  )
374
565
 
375
566
 
@@ -379,7 +570,9 @@ def _decide_block(ctx: _Ctx, owners: list[str]) -> Decision:
379
570
  allow=False,
380
571
  reason=f"lead-blocked:{ctx.persona}-not-in-[{','.join(owners_lower)}]",
381
572
  current_persona=ctx.persona, marker_found=ctx.marker,
382
- target_file=ctx.file_path, required_owners=owners,
573
+ target_file=ctx.file_path,
574
+ persona_raw=ctx.persona_raw, alias_resolved=ctx.alias_resolved,
575
+ required_owners=owners, **_scope_fields(ctx),
383
576
  )
384
577
 
385
578
 
@@ -389,7 +582,7 @@ def _resolve_with_owners(ctx: _Ctx, owners: list[str], rule_reason: str | None)
389
582
  return _decide_open_access(ctx, owners, rule_reason)
390
583
  if ctx.persona in {o.lower() for o in owners}:
391
584
  return _decide_owner_match(ctx, owners)
392
- bypass = _find_bypass(ctx.messages)
585
+ bypass = _find_bypass(ctx.messages, owners)
393
586
  if bypass:
394
587
  return _decide_bypass(ctx, owners, bypass)
395
588
  return _decide_block(ctx, owners)
@@ -404,7 +597,7 @@ def _resolve_ownership_outcome(ctx: _Ctx) -> Decision:
404
597
  return Decision(
405
598
  allow=True, reason="no-ownership-rule",
406
599
  current_persona=ctx.persona, marker_found=ctx.marker,
407
- target_file=ctx.file_path,
600
+ target_file=ctx.file_path, **_scope_fields(ctx),
408
601
  )
409
602
  return _resolve_with_owners(ctx, owners, rule_reason)
410
603
 
@@ -419,16 +612,19 @@ def evaluate(
419
612
  cwd: str = "",
420
613
  tool_input: dict | None = None,
421
614
  messages: list[str] | None = None,
615
+ is_sidechain: bool | None = None,
422
616
  ) -> Decision:
423
617
  """Decide whether a Write/Edit/MultiEdit/NotebookEdit may proceed.
424
618
 
425
619
  ``messages`` (PR-6 hook consolidation): pre-parsed assistant messages;
426
620
  when None the transcript is read from ``transcript_path``.
621
+ ``is_sidechain`` (P0.2): the active scope, when the caller computed it
622
+ while parsing; None self-detects (only possible without ``messages``).
427
623
  """
428
624
  ctx = _Ctx(
429
625
  tool_name=tool_name, transcript_path=transcript_path,
430
626
  session_id=session_id, cwd=cwd, tool_input=tool_input or {},
431
- preloaded_messages=messages,
627
+ preloaded_messages=messages, is_sidechain=is_sidechain,
432
628
  )
433
629
  for early_check in (_check_tool_gated, _check_feature_flag):
434
630
  decision = early_check(ctx)
@@ -464,7 +660,7 @@ def record_telemetry(
464
660
  if safe is None:
465
661
  return
466
662
  entry = {
467
- "ts": datetime.now(timezone.utc).isoformat(),
663
+ "ts": datetime.now(UTC).isoformat(),
468
664
  "session_id": safe,
469
665
  "tool": tool,
470
666
  "cwd": cwd,
@@ -0,0 +1,74 @@
1
+ """Scope-aware transcript reading — the sidechain guard (specialist P0.2).
2
+
3
+ The specialist gate's 20-message window was counted over EVERY assistant
4
+ record in the JSONL. Two scope leaks follow:
5
+
6
+ - where a harness interleaves subagent (sidechain) records into the same
7
+ transcript, a burst of subagent activity evicts the routing marker and
8
+ reopens the fail-open the persisted-persona fix exists to close;
9
+ - the persisted persona itself must never cross scopes — a dispatched
10
+ specialist evaluated under the PARENT's persisted persona would be
11
+ blocked from the very files it was dispatched to write.
12
+
13
+ This module splits a transcript into main-scope and sidechain-scope
14
+ assistant messages and reports which scope is ACTIVE (the scope of the
15
+ most recent assistant record — at PreToolUse time, the agent whose tool
16
+ call is being judged). Parsing mirrors
17
+ ``flow_enforcer._load_last_assistant_messages`` exactly; records without
18
+ an ``isSidechain`` field are main-scope, so a transcript that predates
19
+ the field behaves as before.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+
28
+ from core.workflow.flow_enforcer import _extract_text
29
+
30
+
31
+ @dataclass
32
+ class ScopeSplit:
33
+ """Assistant messages of a transcript, separated by scope."""
34
+
35
+ main: list[str] = field(default_factory=list)
36
+ sidechain: list[str] = field(default_factory=list)
37
+ active_sidechain: bool = False
38
+
39
+
40
+ def split_by_scope(raw_text: str) -> ScopeSplit:
41
+ """Split raw JSONL transcript text into main/sidechain scopes."""
42
+ split = ScopeSplit()
43
+ for line in raw_text.splitlines():
44
+ if not line.strip():
45
+ continue
46
+ try:
47
+ record = json.loads(line)
48
+ except json.JSONDecodeError:
49
+ continue
50
+ role = record.get("role") or record.get("message", {}).get("role")
51
+ if role != "assistant":
52
+ continue
53
+ content = record.get("content")
54
+ if content is None:
55
+ content = record.get("message", {}).get("content")
56
+ text = _extract_text(content)
57
+ if not text:
58
+ continue
59
+ side = bool(record.get("isSidechain", False))
60
+ (split.sidechain if side else split.main).append(text)
61
+ split.active_sidechain = side
62
+ return split
63
+
64
+
65
+ def split_from_path(transcript_path: str) -> ScopeSplit:
66
+ """Read and split a transcript file; any failure is an empty split."""
67
+ path = Path(transcript_path) if transcript_path else None
68
+ if path is None or not path.exists():
69
+ return ScopeSplit()
70
+ try:
71
+ raw = path.read_text(encoding="utf-8", errors="replace")
72
+ except OSError:
73
+ return ScopeSplit()
74
+ return split_by_scope(raw)
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "_meta": {
3
3
  "generator": "scripts/marketplace_gen.py",
4
- "version": "4.14.3",
4
+ "version": "4.15.0",
5
5
  "marketplace": "arkaos"
6
6
  },
7
7
  "structural": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "4.14.3",
3
+ "version": "4.15.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 = "4.14.3"
3
+ version = "4.15.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"}