arkaos 4.14.4 → 4.16.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **The Operating System for AI Agent Teams.**
4
4
 
5
- 86 agents. 17 departments. 274 skills. Enterprise frameworks. Multi-runtime. One install.
5
+ 86 agents. 17 departments. 275 skills. Enterprise frameworks. Multi-runtime. One install.
6
6
 
7
7
  ```bash
8
8
  npx arkaos install
@@ -95,7 +95,7 @@ npx arkaos doctor # Health check
95
95
 
96
96
  ### Skill packs, à la carte
97
97
 
98
- The default install ships a curated core so your context window stays lean. Everything else lives in the ArkaOS plugin marketplace: 16 department packs with 205 skills, generated straight from the same sources the core uses. Inside Claude Code:
98
+ The default install ships a curated core so your context window stays lean. Everything else lives in the ArkaOS plugin marketplace: 16 department packs with 206 skills, generated straight from the same sources the core uses. Inside Claude Code:
99
99
 
100
100
  ```
101
101
  /plugin marketplace add andreagroferreira/arka-os
package/VERSION CHANGED
@@ -1 +1 @@
1
- 4.14.4
1
+ 4.16.0
package/arka/SKILL.md CHANGED
@@ -25,7 +25,7 @@ measures citation compliance per turn.
25
25
  # ArkaOS — Main Orchestrator
26
26
 
27
27
  > **The Operating System for AI Agent Teams**
28
- > 86 agents. 17 departments. 274 skills. Multi-runtime. Dashboard. Knowledge RAG.
28
+ > 86 agents. 17 departments. 275 skills. Multi-runtime. Dashboard. Knowledge RAG.
29
29
 
30
30
  ## ⛔ Evidence flow — 4 gates (NON-NEGOTIABLE)
31
31
 
@@ -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
@@ -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,13 +13,25 @@ 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
  """
@@ -36,7 +48,7 @@ from pathlib import Path
36
48
  import yaml
37
49
 
38
50
  from core.shared import safe_session_id as _safe_session_id_module
39
- from core.workflow.flow_enforcer import _load_last_assistant_messages
51
+ from core.workflow import specialist_authorization, transcript_scope
40
52
 
41
53
  try:
42
54
  import fcntl # POSIX only
@@ -120,6 +132,12 @@ class Decision:
120
132
  # "frontend-dev" — record both so telemetry can prove the fix.
121
133
  persona_raw: str | None = None
122
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
123
141
 
124
142
  def to_stderr_message(self) -> str:
125
143
  """The message a blocked session reads.
@@ -148,8 +166,10 @@ class Decision:
148
166
  f" [arka:dispatch] {persona} -> {owner}\n"
149
167
  f" Task(subagent_type=\"{owner}\", prompt=\"<what you were "
150
168
  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"
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"
153
173
  f" [arka:specialist-bypass owner={owner} reason=<24+ chars "
154
174
  f"explaining why a specialist cannot>]"
155
175
  )
@@ -172,6 +192,11 @@ class _Ctx:
172
192
  alias_resolved: bool = False
173
193
  config: dict = field(default_factory=dict)
174
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
175
200
 
176
201
 
177
202
  # ─── Config + Ownership loaders ────────────────────────────────────────
@@ -392,6 +417,15 @@ def _is_lead_allowed(file_path: str, patterns: list[str]) -> bool:
392
417
  # ─── Pipeline stages (B1 refactor) ─────────────────────────────────────
393
418
 
394
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
+
395
429
  def _check_tool_gated(ctx: _Ctx) -> Decision | None:
396
430
  if ctx.tool_name not in GATED_TOOLS:
397
431
  return Decision(allow=True, reason="tool-not-gated")
@@ -404,8 +438,15 @@ def _check_feature_flag(ctx: _Ctx) -> Decision | None:
404
438
  return None
405
439
 
406
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
+
407
448
  def _populate_context(ctx: _Ctx) -> None:
408
- """Extract file_path, load ownership config, load + resolve transcript."""
449
+ """Extract file_path, load ownership, resolve persona (P0.2 fail-open)."""
409
450
  if ctx.tool_input and isinstance(ctx.tool_input, dict):
410
451
  ctx.file_path = str(
411
452
  ctx.tool_input.get("file_path")
@@ -416,18 +457,55 @@ def _populate_context(ctx: _Ctx) -> None:
416
457
  if ctx.preloaded_messages is not None:
417
458
  ctx.messages = ctx.preloaded_messages[-ASSISTANT_WINDOW:]
418
459
  else:
419
- ctx.messages = _load_last_assistant_messages(
420
- ctx.transcript_path, ASSISTANT_WINDOW
421
- )
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
+ """
422
478
  ctx.persona, ctx.marker, ctx.persona_raw, ctx.alias_resolved = (
423
479
  _resolve_persona(ctx.messages)
424
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
492
+ )
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"
425
499
 
426
500
 
427
501
  def _check_no_persona(ctx: _Ctx) -> Decision | None:
428
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"
429
506
  return Decision(
430
- 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),
431
509
  )
432
510
  return None
433
511
 
@@ -438,7 +516,7 @@ def _check_c_suite(ctx: _Ctx) -> Decision | None:
438
516
  return Decision(
439
517
  allow=True, reason="c-suite-override",
440
518
  current_persona=ctx.persona, marker_found=ctx.marker,
441
- target_file=ctx.file_path,
519
+ target_file=ctx.file_path, **_scope_fields(ctx),
442
520
  )
443
521
  return None
444
522
 
@@ -449,7 +527,7 @@ def _check_lead_allowed_file(ctx: _Ctx) -> Decision | None:
449
527
  return Decision(
450
528
  allow=True, reason="lead-allowed-file",
451
529
  current_persona=ctx.persona, marker_found=ctx.marker,
452
- target_file=ctx.file_path,
530
+ target_file=ctx.file_path, **_scope_fields(ctx),
453
531
  )
454
532
  return None
455
533
 
@@ -461,7 +539,7 @@ def _decide_open_access(
461
539
  return Decision(
462
540
  allow=True, reason=reason, current_persona=ctx.persona,
463
541
  marker_found=ctx.marker, target_file=ctx.file_path,
464
- required_owners=owners,
542
+ required_owners=owners, **_scope_fields(ctx),
465
543
  )
466
544
 
467
545
 
@@ -470,7 +548,8 @@ def _decide_owner_match(ctx: _Ctx, owners: list[str]) -> Decision:
470
548
  allow=True, reason=f"owner-match:{ctx.persona}",
471
549
  current_persona=ctx.persona, marker_found=ctx.marker,
472
550
  target_file=ctx.file_path,
473
- persona_raw=ctx.persona_raw, alias_resolved=ctx.alias_resolved, required_owners=owners,
551
+ persona_raw=ctx.persona_raw, alias_resolved=ctx.alias_resolved,
552
+ required_owners=owners, **_scope_fields(ctx),
474
553
  )
475
554
 
476
555
 
@@ -479,8 +558,9 @@ def _decide_bypass(ctx: _Ctx, owners: list[str], reason: str) -> Decision:
479
558
  allow=True, reason="bypass-with-reason",
480
559
  current_persona=ctx.persona, marker_found=ctx.marker,
481
560
  target_file=ctx.file_path,
482
- persona_raw=ctx.persona_raw, alias_resolved=ctx.alias_resolved, required_owners=owners,
483
- bypass_used=True, bypass_reason=reason,
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),
484
564
  )
485
565
 
486
566
 
@@ -491,7 +571,8 @@ def _decide_block(ctx: _Ctx, owners: list[str]) -> Decision:
491
571
  reason=f"lead-blocked:{ctx.persona}-not-in-[{','.join(owners_lower)}]",
492
572
  current_persona=ctx.persona, marker_found=ctx.marker,
493
573
  target_file=ctx.file_path,
494
- persona_raw=ctx.persona_raw, alias_resolved=ctx.alias_resolved, required_owners=owners,
574
+ persona_raw=ctx.persona_raw, alias_resolved=ctx.alias_resolved,
575
+ required_owners=owners, **_scope_fields(ctx),
495
576
  )
496
577
 
497
578
 
@@ -516,7 +597,7 @@ def _resolve_ownership_outcome(ctx: _Ctx) -> Decision:
516
597
  return Decision(
517
598
  allow=True, reason="no-ownership-rule",
518
599
  current_persona=ctx.persona, marker_found=ctx.marker,
519
- target_file=ctx.file_path,
600
+ target_file=ctx.file_path, **_scope_fields(ctx),
520
601
  )
521
602
  return _resolve_with_owners(ctx, owners, rule_reason)
522
603
 
@@ -531,16 +612,19 @@ def evaluate(
531
612
  cwd: str = "",
532
613
  tool_input: dict | None = None,
533
614
  messages: list[str] | None = None,
615
+ is_sidechain: bool | None = None,
534
616
  ) -> Decision:
535
617
  """Decide whether a Write/Edit/MultiEdit/NotebookEdit may proceed.
536
618
 
537
619
  ``messages`` (PR-6 hook consolidation): pre-parsed assistant messages;
538
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``).
539
623
  """
540
624
  ctx = _Ctx(
541
625
  tool_name=tool_name, transcript_path=transcript_path,
542
626
  session_id=session_id, cwd=cwd, tool_input=tool_input or {},
543
- preloaded_messages=messages,
627
+ preloaded_messages=messages, is_sidechain=is_sidechain,
544
628
  )
545
629
  for early_check in (_check_tool_gated, _check_feature_flag):
546
630
  decision = early_check(ctx)
@@ -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)