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 +2 -2
- package/VERSION +1 -1
- package/arka/SKILL.md +1 -1
- package/core/hooks/pre_tool_use.py +20 -19
- package/core/workflow/specialist_authorization.py +140 -0
- package/core/workflow/specialist_enforcer.py +103 -19
- package/core/workflow/transcript_scope.py +74 -0
- package/departments/dev/skills/animated-website/SKILL.md +449 -0
- package/departments/dev/skills/animated-website/scripts/extract_frames.py +407 -0
- package/knowledge/skills-manifest.json +11 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/marketplace_gen.py +4 -1
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
17
|
-
only. Subagent writes pass through
|
|
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
|
|
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"
|
|
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
|
|
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
|
-
|
|
420
|
-
|
|
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=
|
|
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,
|
|
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,
|
|
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,
|
|
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)
|