arkaos 3.72.0 → 3.73.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/VERSION +1 -1
- package/arka/skills/flow/SKILL.md +20 -0
- package/config/agent-ownership.yaml +169 -0
- package/config/constitution.yaml +5 -0
- package/config/hooks/pre-tool-use.ps1 +77 -0
- package/config/hooks/pre-tool-use.sh +80 -0
- package/core/governance/__pycache__/specialist_telemetry.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/specialist_telemetry_cli.cpython-313.pyc +0 -0
- package/core/governance/specialist_telemetry.py +117 -0
- package/core/governance/specialist_telemetry_cli.py +51 -0
- package/core/workflow/__pycache__/specialist_enforcer.cpython-313.pyc +0 -0
- package/core/workflow/specialist_enforcer.py +462 -0
- package/installer/cli.js +4 -2
- package/installer/doctor.js +43 -7
- package/installer/python-resolver.js +129 -1
- package/installer/update.js +10 -5
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
- package/scripts/start-dashboard.ps1 +19 -11
- package/scripts/start-dashboard.sh +23 -1
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"""Force Specialist Dispatch — PreToolUse enforcement for write tools.
|
|
2
|
+
|
|
3
|
+
Blocks Tier-1 squad leads (Paulo, Ines, Daniel, etc.) from writing to
|
|
4
|
+
specialist-owned files (e.g., *.vue, **/app/Services/**) without first
|
|
5
|
+
dispatching the specialist via the Agent tool. The current persona is
|
|
6
|
+
read from the most recent `[arka:routing]` or `[arka:dispatch]` marker
|
|
7
|
+
in the session transcript.
|
|
8
|
+
|
|
9
|
+
Bypass: emit `[arka:specialist-bypass <reason>]` in the same assistant
|
|
10
|
+
message immediately before the Write/Edit. Empty reason is rejected.
|
|
11
|
+
Used bypasses are logged to telemetry for accountability.
|
|
12
|
+
|
|
13
|
+
Feature flag: `hooks.specialistEnforcement` in ~/.arkaos/config.json.
|
|
14
|
+
|
|
15
|
+
Architectural note (per ADR 2026-05-28-specialist-dispatch-subagent-
|
|
16
|
+
blindspot): the enforcer is a NEGATIVE gate on the parent transcript
|
|
17
|
+
only. Subagent writes pass through as `no-routing-tag` because Claude
|
|
18
|
+
Code isolates subagent transcripts from the parent. The positive
|
|
19
|
+
`owner-match` path is exercised when the parent emits `[arka:dispatch]`
|
|
20
|
+
inline (e.g., the orchestrator impersonating a specialist) and remains
|
|
21
|
+
for forward compatibility if parent-transcript visibility ever ships.
|
|
22
|
+
|
|
23
|
+
Read by: config/hooks/pre-tool-use.sh between the KB-gate and the
|
|
24
|
+
flow-gate. Same Decision JSON contract as core.workflow.flow_enforcer.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import re
|
|
29
|
+
from contextlib import contextmanager
|
|
30
|
+
from dataclasses import asdict, dataclass, field
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
from functools import lru_cache
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
import yaml
|
|
36
|
+
|
|
37
|
+
from core.shared import safe_session_id as _safe_session_id_module
|
|
38
|
+
from core.workflow.flow_enforcer import _load_last_assistant_messages
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
import fcntl # POSIX only
|
|
42
|
+
_HAS_FLOCK = True
|
|
43
|
+
except ImportError:
|
|
44
|
+
_HAS_FLOCK = False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@contextmanager
|
|
48
|
+
def _locked_append(path: Path):
|
|
49
|
+
"""Append to `path` under an exclusive advisory lock (POSIX flock)."""
|
|
50
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
fh = path.open("a", encoding="utf-8")
|
|
52
|
+
try:
|
|
53
|
+
if _HAS_FLOCK:
|
|
54
|
+
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
|
|
55
|
+
yield fh
|
|
56
|
+
finally:
|
|
57
|
+
if _HAS_FLOCK:
|
|
58
|
+
try:
|
|
59
|
+
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
|
|
60
|
+
except OSError:
|
|
61
|
+
pass
|
|
62
|
+
fh.close()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ─── Constants ──────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
CONFIG_PATH = Path.home() / ".arkaos" / "config.json"
|
|
68
|
+
TELEMETRY_PATH = (
|
|
69
|
+
Path.home() / ".arkaos" / "telemetry" / "specialist-dispatch.jsonl"
|
|
70
|
+
)
|
|
71
|
+
OWNERSHIP_YAML_PATH = (
|
|
72
|
+
Path(__file__).resolve().parent.parent.parent
|
|
73
|
+
/ "config"
|
|
74
|
+
/ "agent-ownership.yaml"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
GATED_TOOLS: frozenset[str] = frozenset(
|
|
78
|
+
{"Write", "Edit", "MultiEdit", "NotebookEdit"}
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Marker regexes — see docs/adr/2026-05-28-specialist-dispatch-...md
|
|
82
|
+
ROUTING_RE = re.compile(
|
|
83
|
+
r"\[arka:routing\]\s*[\w-]+\s*->\s*(\w+)", re.IGNORECASE
|
|
84
|
+
)
|
|
85
|
+
DISPATCH_RE = re.compile(
|
|
86
|
+
r"\[arka:dispatch\]\s*[\w-]+\s*->\s*([\w-]+)", re.IGNORECASE
|
|
87
|
+
)
|
|
88
|
+
BYPASS_RE = re.compile(
|
|
89
|
+
r"\[arka:specialist-bypass\s+([^\]]+?)\s*\]", re.IGNORECASE
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
ASSISTANT_WINDOW = 20
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ─── Data ───────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class Decision:
|
|
100
|
+
"""Outcome of specialist-enforcement evaluation."""
|
|
101
|
+
|
|
102
|
+
allow: bool
|
|
103
|
+
reason: str
|
|
104
|
+
current_persona: str | None = None
|
|
105
|
+
required_owners: list[str] = field(default_factory=list)
|
|
106
|
+
marker_found: str | None = None
|
|
107
|
+
bypass_used: bool = False
|
|
108
|
+
bypass_reason: str | None = None
|
|
109
|
+
target_file: str | None = None
|
|
110
|
+
|
|
111
|
+
def to_stderr_message(self) -> str:
|
|
112
|
+
if self.allow:
|
|
113
|
+
return ""
|
|
114
|
+
persona = self.current_persona or "lead"
|
|
115
|
+
owners = ", ".join(self.required_owners) or "specialist"
|
|
116
|
+
target = self.target_file or "this file"
|
|
117
|
+
return (
|
|
118
|
+
f"[ARKA:SPECIALIST] {persona} (lead) is not authorised to write "
|
|
119
|
+
f"{target}. Required owners: {owners}. Choose one: (1) dispatch "
|
|
120
|
+
f"the specialist via the Agent tool AND emit "
|
|
121
|
+
f"`[arka:dispatch] {persona} -> <specialist>` immediately before "
|
|
122
|
+
f"the dispatch call (NON-NEGOTIABLE constitution rule "
|
|
123
|
+
f"`dispatch-must-be-announced`), OR (2) add "
|
|
124
|
+
f"`[arka:specialist-bypass <reason>]` to the same assistant "
|
|
125
|
+
f"message to override (logged for accountability)."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class _Ctx:
|
|
131
|
+
"""Mutable evaluation context passed through pipeline stages."""
|
|
132
|
+
|
|
133
|
+
tool_name: str
|
|
134
|
+
transcript_path: str
|
|
135
|
+
session_id: str
|
|
136
|
+
cwd: str
|
|
137
|
+
tool_input: dict
|
|
138
|
+
file_path: str = ""
|
|
139
|
+
messages: list[str] = field(default_factory=list)
|
|
140
|
+
persona: str | None = None
|
|
141
|
+
marker: str | None = None
|
|
142
|
+
config: dict = field(default_factory=dict)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ─── Config + Ownership loaders ────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _feature_flag_on() -> bool:
|
|
149
|
+
"""Check `hooks.specialistEnforcement` in ~/.arkaos/config.json."""
|
|
150
|
+
if not CONFIG_PATH.exists():
|
|
151
|
+
return False
|
|
152
|
+
try:
|
|
153
|
+
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
154
|
+
except (json.JSONDecodeError, OSError):
|
|
155
|
+
return False
|
|
156
|
+
return bool(data.get("hooks", {}).get("specialistEnforcement", False))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _empty_ownership() -> dict:
|
|
160
|
+
return {
|
|
161
|
+
"version": 1,
|
|
162
|
+
"leads": [],
|
|
163
|
+
"c_suite": [],
|
|
164
|
+
"ownership": [],
|
|
165
|
+
"lead_allowed": [],
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@lru_cache(maxsize=1)
|
|
170
|
+
def _load_ownership() -> dict:
|
|
171
|
+
"""Load ownership rules from YAML. Cached per-process.
|
|
172
|
+
|
|
173
|
+
Each `python3 -` heredoc invocation from the bash hook is a fresh
|
|
174
|
+
interpreter, so the cache scope is one tool call — no TTL needed.
|
|
175
|
+
Tests call `_load_ownership.cache_clear()` when monkeypatching.
|
|
176
|
+
"""
|
|
177
|
+
if not OWNERSHIP_YAML_PATH.exists():
|
|
178
|
+
return _empty_ownership()
|
|
179
|
+
try:
|
|
180
|
+
with OWNERSHIP_YAML_PATH.open(encoding="utf-8") as fh:
|
|
181
|
+
data = yaml.safe_load(fh) or {}
|
|
182
|
+
except (yaml.YAMLError, OSError):
|
|
183
|
+
return _empty_ownership()
|
|
184
|
+
return data
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ─── Glob matching (B2 refactor: split tokenizer from matcher) ─────────
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _glob_token(pattern: str, i: int) -> tuple[str, int]:
|
|
191
|
+
"""Translate the glob character at `pattern[i]` to a regex fragment.
|
|
192
|
+
|
|
193
|
+
Returns (fragment, next_index). Handles `**/`, `**`, `*`, `?`, brace
|
|
194
|
+
expansion `{a,b,c}`, and escapes regex meta-characters.
|
|
195
|
+
"""
|
|
196
|
+
c = pattern[i]
|
|
197
|
+
if c == "*" and i + 1 < len(pattern) and pattern[i + 1] == "*":
|
|
198
|
+
if i + 2 < len(pattern) and pattern[i + 2] == "/":
|
|
199
|
+
return r"(?:.*/)?", i + 3
|
|
200
|
+
return r".*", i + 2
|
|
201
|
+
if c == "*":
|
|
202
|
+
return r"[^/]*", i + 1
|
|
203
|
+
if c == "?":
|
|
204
|
+
return r"[^/]", i + 1
|
|
205
|
+
if c in r".()[]+\|^$":
|
|
206
|
+
return "\\" + c, i + 1
|
|
207
|
+
if c == "{":
|
|
208
|
+
close = pattern.find("}", i + 1)
|
|
209
|
+
if close == -1:
|
|
210
|
+
return re.escape(c), i + 1
|
|
211
|
+
options = pattern[i + 1:close].split(",")
|
|
212
|
+
return "(?:" + "|".join(re.escape(o) for o in options) + ")", close + 1
|
|
213
|
+
return c, i + 1
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@lru_cache(maxsize=256)
|
|
217
|
+
def _glob_to_regex(pattern: str) -> re.Pattern[str]:
|
|
218
|
+
"""Compile a glob pattern (with ** support) into an anchored regex."""
|
|
219
|
+
parts: list[str] = []
|
|
220
|
+
i = 0
|
|
221
|
+
while i < len(pattern):
|
|
222
|
+
fragment, i = _glob_token(pattern, i)
|
|
223
|
+
parts.append(fragment)
|
|
224
|
+
return re.compile("^" + "".join(parts) + "$")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _glob_match(pattern: str, path: str) -> bool:
|
|
228
|
+
"""Match `path` against `pattern` with `**` recursive-glob support."""
|
|
229
|
+
return bool(_glob_to_regex(pattern).match(path.replace("\\", "/")))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ─── Persona, bypass, ownership resolution ─────────────────────────────
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _resolve_persona(messages: list[str]) -> tuple[str | None, str | None]:
|
|
236
|
+
"""Find the current persona, scanning newest-to-oldest assistant turns.
|
|
237
|
+
|
|
238
|
+
Dispatch tag wins over routing because dispatching is more specific.
|
|
239
|
+
"""
|
|
240
|
+
for text in reversed(messages):
|
|
241
|
+
dispatch = DISPATCH_RE.search(text)
|
|
242
|
+
if dispatch:
|
|
243
|
+
return dispatch.group(1).lower(), "dispatch"
|
|
244
|
+
routing = ROUTING_RE.search(text)
|
|
245
|
+
if routing:
|
|
246
|
+
return routing.group(1).lower(), "routing"
|
|
247
|
+
return None, None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _find_bypass(messages: list[str]) -> str | None:
|
|
251
|
+
"""Return bypass reason from LAST assistant message, or None.
|
|
252
|
+
|
|
253
|
+
Scope is strict: only the immediately preceding assistant message can
|
|
254
|
+
grant a bypass. Empty / whitespace reasons are rejected.
|
|
255
|
+
"""
|
|
256
|
+
if not messages:
|
|
257
|
+
return None
|
|
258
|
+
match = BYPASS_RE.search(messages[-1])
|
|
259
|
+
if not match:
|
|
260
|
+
return None
|
|
261
|
+
reason = match.group(1).strip()
|
|
262
|
+
return reason if reason else None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _match_ownership(
|
|
266
|
+
file_path: str, rules: list[dict]
|
|
267
|
+
) -> tuple[list[str] | None, str | None]:
|
|
268
|
+
"""Return (owners, rule_reason) of FIRST matching rule, or (None, None)."""
|
|
269
|
+
for rule in rules:
|
|
270
|
+
pattern = rule.get("pattern", "")
|
|
271
|
+
if pattern and _glob_match(pattern, file_path):
|
|
272
|
+
return list(rule.get("owners", []) or []), rule.get("reason")
|
|
273
|
+
return None, None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _is_lead_allowed(file_path: str, patterns: list[str]) -> bool:
|
|
277
|
+
"""Check lead_allowed against full path AND basename for convenience."""
|
|
278
|
+
base = file_path.split("/")[-1]
|
|
279
|
+
return any(
|
|
280
|
+
_glob_match(p, file_path) or _glob_match(p, base) for p in patterns
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ─── Pipeline stages (B1 refactor) ─────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _check_tool_gated(ctx: _Ctx) -> Decision | None:
|
|
288
|
+
if ctx.tool_name not in GATED_TOOLS:
|
|
289
|
+
return Decision(allow=True, reason="tool-not-gated")
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _check_feature_flag(ctx: _Ctx) -> Decision | None:
|
|
294
|
+
if not _feature_flag_on():
|
|
295
|
+
return Decision(allow=True, reason="feature-flag-off")
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _populate_context(ctx: _Ctx) -> None:
|
|
300
|
+
"""Extract file_path, load ownership config, load + resolve transcript."""
|
|
301
|
+
if ctx.tool_input and isinstance(ctx.tool_input, dict):
|
|
302
|
+
ctx.file_path = str(
|
|
303
|
+
ctx.tool_input.get("file_path")
|
|
304
|
+
or ctx.tool_input.get("notebook_path")
|
|
305
|
+
or ""
|
|
306
|
+
)
|
|
307
|
+
ctx.config = _load_ownership()
|
|
308
|
+
ctx.messages = _load_last_assistant_messages(
|
|
309
|
+
ctx.transcript_path, ASSISTANT_WINDOW
|
|
310
|
+
)
|
|
311
|
+
ctx.persona, ctx.marker = _resolve_persona(ctx.messages)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _check_no_persona(ctx: _Ctx) -> Decision | None:
|
|
315
|
+
if ctx.persona is None:
|
|
316
|
+
return Decision(
|
|
317
|
+
allow=True, reason="no-routing-tag", target_file=ctx.file_path,
|
|
318
|
+
)
|
|
319
|
+
return None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _check_c_suite(ctx: _Ctx) -> Decision | None:
|
|
323
|
+
c_suite = {x.lower() for x in ctx.config.get("c_suite", [])}
|
|
324
|
+
if ctx.persona in c_suite:
|
|
325
|
+
return Decision(
|
|
326
|
+
allow=True, reason="c-suite-override",
|
|
327
|
+
current_persona=ctx.persona, marker_found=ctx.marker,
|
|
328
|
+
target_file=ctx.file_path,
|
|
329
|
+
)
|
|
330
|
+
return None
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _check_lead_allowed_file(ctx: _Ctx) -> Decision | None:
|
|
334
|
+
allowed = ctx.config.get("lead_allowed", []) or []
|
|
335
|
+
if _is_lead_allowed(ctx.file_path, allowed):
|
|
336
|
+
return Decision(
|
|
337
|
+
allow=True, reason="lead-allowed-file",
|
|
338
|
+
current_persona=ctx.persona, marker_found=ctx.marker,
|
|
339
|
+
target_file=ctx.file_path,
|
|
340
|
+
)
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _decide_open_access(
|
|
345
|
+
ctx: _Ctx, owners: list[str], rule_reason: str | None
|
|
346
|
+
) -> Decision:
|
|
347
|
+
reason = f"open-access:{rule_reason}" if rule_reason else "open-access"
|
|
348
|
+
return Decision(
|
|
349
|
+
allow=True, reason=reason, current_persona=ctx.persona,
|
|
350
|
+
marker_found=ctx.marker, target_file=ctx.file_path,
|
|
351
|
+
required_owners=owners,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _decide_owner_match(ctx: _Ctx, owners: list[str]) -> Decision:
|
|
356
|
+
return Decision(
|
|
357
|
+
allow=True, reason=f"owner-match:{ctx.persona}",
|
|
358
|
+
current_persona=ctx.persona, marker_found=ctx.marker,
|
|
359
|
+
target_file=ctx.file_path, required_owners=owners,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _decide_bypass(ctx: _Ctx, owners: list[str], reason: str) -> Decision:
|
|
364
|
+
return Decision(
|
|
365
|
+
allow=True, reason="bypass-with-reason",
|
|
366
|
+
current_persona=ctx.persona, marker_found=ctx.marker,
|
|
367
|
+
target_file=ctx.file_path, required_owners=owners,
|
|
368
|
+
bypass_used=True, bypass_reason=reason,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _decide_block(ctx: _Ctx, owners: list[str]) -> Decision:
|
|
373
|
+
owners_lower = sorted({o.lower() for o in owners})
|
|
374
|
+
return Decision(
|
|
375
|
+
allow=False,
|
|
376
|
+
reason=f"lead-blocked:{ctx.persona}-not-in-[{','.join(owners_lower)}]",
|
|
377
|
+
current_persona=ctx.persona, marker_found=ctx.marker,
|
|
378
|
+
target_file=ctx.file_path, required_owners=owners,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _resolve_with_owners(ctx: _Ctx, owners: list[str], rule_reason: str | None) -> Decision:
|
|
383
|
+
"""Pick the right Decision when ownership rule matched."""
|
|
384
|
+
if "*" in owners:
|
|
385
|
+
return _decide_open_access(ctx, owners, rule_reason)
|
|
386
|
+
if ctx.persona in {o.lower() for o in owners}:
|
|
387
|
+
return _decide_owner_match(ctx, owners)
|
|
388
|
+
bypass = _find_bypass(ctx.messages)
|
|
389
|
+
if bypass:
|
|
390
|
+
return _decide_bypass(ctx, owners, bypass)
|
|
391
|
+
return _decide_block(ctx, owners)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _resolve_ownership_outcome(ctx: _Ctx) -> Decision:
|
|
395
|
+
"""Final branch: match ownership rules and resolve to a Decision."""
|
|
396
|
+
owners, rule_reason = _match_ownership(
|
|
397
|
+
ctx.file_path, ctx.config.get("ownership", []) or []
|
|
398
|
+
)
|
|
399
|
+
if owners is None:
|
|
400
|
+
return Decision(
|
|
401
|
+
allow=True, reason="no-ownership-rule",
|
|
402
|
+
current_persona=ctx.persona, marker_found=ctx.marker,
|
|
403
|
+
target_file=ctx.file_path,
|
|
404
|
+
)
|
|
405
|
+
return _resolve_with_owners(ctx, owners, rule_reason)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# ─── Public API ─────────────────────────────────────────────────────────
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def evaluate(
|
|
412
|
+
tool_name: str,
|
|
413
|
+
transcript_path: str,
|
|
414
|
+
session_id: str = "",
|
|
415
|
+
cwd: str = "",
|
|
416
|
+
tool_input: dict | None = None,
|
|
417
|
+
) -> Decision:
|
|
418
|
+
"""Decide whether a Write/Edit/MultiEdit/NotebookEdit may proceed."""
|
|
419
|
+
ctx = _Ctx(
|
|
420
|
+
tool_name=tool_name, transcript_path=transcript_path,
|
|
421
|
+
session_id=session_id, cwd=cwd, tool_input=tool_input or {},
|
|
422
|
+
)
|
|
423
|
+
for early_check in (_check_tool_gated, _check_feature_flag):
|
|
424
|
+
decision = early_check(ctx)
|
|
425
|
+
if decision is not None:
|
|
426
|
+
return decision
|
|
427
|
+
_populate_context(ctx)
|
|
428
|
+
for late_check in (_check_no_persona, _check_c_suite, _check_lead_allowed_file):
|
|
429
|
+
decision = late_check(ctx)
|
|
430
|
+
if decision is not None:
|
|
431
|
+
return decision
|
|
432
|
+
return _resolve_ownership_outcome(ctx)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def record_telemetry(
|
|
436
|
+
session_id: str,
|
|
437
|
+
tool: str,
|
|
438
|
+
decision: Decision,
|
|
439
|
+
cwd: str = "",
|
|
440
|
+
target_file: str = "",
|
|
441
|
+
) -> None:
|
|
442
|
+
"""Append a structured record to the specialist-dispatch telemetry log.
|
|
443
|
+
|
|
444
|
+
Drops the record silently when session_id fails the safe-id check
|
|
445
|
+
(path-traversal mitigation, CWE-22).
|
|
446
|
+
"""
|
|
447
|
+
safe = _safe_session_id_module.safe_session_id(session_id)
|
|
448
|
+
if safe is None:
|
|
449
|
+
return
|
|
450
|
+
entry = {
|
|
451
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
452
|
+
"session_id": safe,
|
|
453
|
+
"tool": tool,
|
|
454
|
+
"cwd": cwd,
|
|
455
|
+
"target_file": target_file or decision.target_file or "",
|
|
456
|
+
**asdict(decision),
|
|
457
|
+
}
|
|
458
|
+
try:
|
|
459
|
+
with _locked_append(TELEMETRY_PATH) as fh:
|
|
460
|
+
fh.write(json.dumps(entry) + "\n")
|
|
461
|
+
except OSError:
|
|
462
|
+
pass # Telemetry write failure must never block the hook.
|
package/installer/cli.js
CHANGED
|
@@ -92,10 +92,12 @@ async function main() {
|
|
|
92
92
|
break;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
case "doctor":
|
|
95
|
+
case "doctor": {
|
|
96
96
|
const { doctor } = await import("./doctor.js");
|
|
97
|
-
|
|
97
|
+
const fixMode = positionals.slice(1).includes("--fix") || values.fix === true;
|
|
98
|
+
await doctor({ fix: fixMode });
|
|
98
99
|
break;
|
|
100
|
+
}
|
|
99
101
|
|
|
100
102
|
case "update":
|
|
101
103
|
const { update } = await import("./update.js");
|
package/installer/doctor.js
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { execSync } from "node:child_process";
|
|
5
|
-
import { getArkaosPython, getVenvPython, canImportCore, getRepoRoot } from "./python-resolver.js";
|
|
5
|
+
import { getArkaosPython, getVenvPython, canImportCore, getRepoRoot, diagnoseVenv, ensureVenvHealthy } from "./python-resolver.js";
|
|
6
6
|
import { IS_WINDOWS, HOOK_EXT, CMD_FINDER } from "./platform.js";
|
|
7
7
|
import { checkNode, checkObsidian, checkOllama } from "./system-tools.js";
|
|
8
8
|
|
|
@@ -54,10 +54,21 @@ const checks = [
|
|
|
54
54
|
},
|
|
55
55
|
{
|
|
56
56
|
name: "venv",
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
// PR2 v3.73.1: promoted from "warn" to "fail" — without the venv, the
|
|
58
|
+
// dashboard cannot start at all (start-dashboard.{sh,ps1} now fail fast
|
|
59
|
+
// instead of falling back to ambient python3 with missing deps).
|
|
60
|
+
description: "ArkaOS virtual environment exists and is runnable",
|
|
61
|
+
severity: "fail",
|
|
62
|
+
check: () => {
|
|
63
|
+
const venvDir = join(INSTALL_DIR, "venv");
|
|
64
|
+
const d = diagnoseVenv(venvDir);
|
|
65
|
+
return d.healthy;
|
|
66
|
+
},
|
|
67
|
+
fix: () => {
|
|
68
|
+
const venvDir = join(INSTALL_DIR, "venv");
|
|
69
|
+
const d = diagnoseVenv(venvDir);
|
|
70
|
+
return `Run: npx arkaos doctor --fix (current state: ${d.reason})`;
|
|
71
|
+
},
|
|
61
72
|
},
|
|
62
73
|
{
|
|
63
74
|
name: "hooks-dir",
|
|
@@ -241,8 +252,33 @@ if (IS_WINDOWS) {
|
|
|
241
252
|
);
|
|
242
253
|
}
|
|
243
254
|
|
|
244
|
-
export async function doctor() {
|
|
245
|
-
|
|
255
|
+
export async function doctor(options = {}) {
|
|
256
|
+
const fixMode = !!options.fix;
|
|
257
|
+
console.log(`\n ArkaOS Doctor — Health Checks${fixMode ? " (--fix)" : ""}\n`);
|
|
258
|
+
|
|
259
|
+
// ─── --fix: repair the venv before reporting checks (PR2 v3.73.1) ────
|
|
260
|
+
// Targeted, idempotent self-heal: detects broken symlinks / version
|
|
261
|
+
// drift / missing bin/python and recreates the venv with --clear so
|
|
262
|
+
// the subsequent venv check has a chance of passing.
|
|
263
|
+
if (fixMode) {
|
|
264
|
+
const venvDir = join(INSTALL_DIR, "venv");
|
|
265
|
+
const before = diagnoseVenv(venvDir);
|
|
266
|
+
if (before.healthy) {
|
|
267
|
+
console.log(" ℹ Venv already healthy — no repair needed");
|
|
268
|
+
} else {
|
|
269
|
+
console.log(` → Repairing venv (current state: ${before.reason})`);
|
|
270
|
+
const result = ensureVenvHealthy({
|
|
271
|
+
venvDir,
|
|
272
|
+
log: (msg) => console.log(" " + msg.trim()),
|
|
273
|
+
});
|
|
274
|
+
if (result.healthy && result.repaired) {
|
|
275
|
+
console.log(" ✓ Venv repaired");
|
|
276
|
+
} else if (!result.healthy) {
|
|
277
|
+
console.log(` ✗ Venv repair failed (${result.reason})`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
console.log("");
|
|
281
|
+
}
|
|
246
282
|
|
|
247
283
|
let passed = 0;
|
|
248
284
|
let warned = 0;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* and guarantees the doctor checks the same interpreter the installer uses.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { homedir, platform } from "node:os";
|
|
12
12
|
import { execSync } from "node:child_process";
|
|
@@ -94,6 +94,134 @@ export function findSystemPython() {
|
|
|
94
94
|
return null;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Diagnose a venv directory. Pure read-only — does not modify anything.
|
|
99
|
+
* Returns { healthy: bool, reason: string, pythonPath?: string }.
|
|
100
|
+
*
|
|
101
|
+
* Reasons:
|
|
102
|
+
* - "missing" — venv dir absent OR bin/python absent (no symlink)
|
|
103
|
+
* - "broken-symlink" — bin/python is a symlink to a missing target
|
|
104
|
+
* (typical after Homebrew rotates Python patch versions)
|
|
105
|
+
* - "version-failed" — python --version exec failed (corrupt binary)
|
|
106
|
+
* - "ok" — venv healthy, python runs
|
|
107
|
+
*/
|
|
108
|
+
export function diagnoseVenv(venvDir) {
|
|
109
|
+
const isWin = platform() === "win32";
|
|
110
|
+
const pythonPath = isWin
|
|
111
|
+
? join(venvDir, "Scripts", "python.exe")
|
|
112
|
+
: join(venvDir, "bin", "python");
|
|
113
|
+
|
|
114
|
+
// existsSync FOLLOWS symlinks, so a broken symlink returns false here
|
|
115
|
+
// even when the symlink itself is present on disk. Distinguish via lstat.
|
|
116
|
+
if (!existsSync(pythonPath)) {
|
|
117
|
+
let isBroken = false;
|
|
118
|
+
try {
|
|
119
|
+
const stat = lstatSync(pythonPath);
|
|
120
|
+
if (stat.isSymbolicLink()) isBroken = true;
|
|
121
|
+
} catch {
|
|
122
|
+
// pythonPath doesn't exist at all — fall through as "missing"
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
healthy: false,
|
|
126
|
+
reason: isBroken ? "broken-symlink" : "missing",
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// pythonPath exists. Try to run it — guards against corrupt-but-present
|
|
131
|
+
// binaries (e.g., a non-executable file placed at bin/python by accident).
|
|
132
|
+
try {
|
|
133
|
+
const out = execSync(`"${pythonPath}" --version 2>&1`, {
|
|
134
|
+
stdio: "pipe",
|
|
135
|
+
timeout: 5000,
|
|
136
|
+
}).toString();
|
|
137
|
+
if (!/Python 3/.test(out)) {
|
|
138
|
+
return { healthy: false, reason: "version-failed", pythonPath };
|
|
139
|
+
}
|
|
140
|
+
return { healthy: true, reason: "ok", pythonPath };
|
|
141
|
+
} catch {
|
|
142
|
+
return { healthy: false, reason: "version-failed", pythonPath };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Ensure the venv is healthy, repairing if needed.
|
|
149
|
+
* Returns { healthy: bool, repaired: bool, reason: string }.
|
|
150
|
+
*
|
|
151
|
+
* Repair strategy: `python -m venv --clear` removes the stale bin/ Scripts
|
|
152
|
+
* directories (closing the broken-symlink and version-failed cases) and
|
|
153
|
+
* recreates them against the currently resolvable system Python. The
|
|
154
|
+
* post-repair venv is re-diagnosed to confirm health before returning.
|
|
155
|
+
*
|
|
156
|
+
* Options:
|
|
157
|
+
* - venvDir (default: ~/.arkaos/venv)
|
|
158
|
+
* - log (default: console.log)
|
|
159
|
+
* - skipDeps (default: false) — when true, do not attempt pip upgrades
|
|
160
|
+
* after repair. Used by tests to keep them fast/offline.
|
|
161
|
+
*/
|
|
162
|
+
export function ensureVenvHealthy(options = {}) {
|
|
163
|
+
const venvDir = options.venvDir || join(INSTALL_DIR, "venv");
|
|
164
|
+
const log = options.log || console.log;
|
|
165
|
+
const skipDeps = !!options.skipDeps;
|
|
166
|
+
|
|
167
|
+
const diagnosis = diagnoseVenv(venvDir);
|
|
168
|
+
if (diagnosis.healthy) {
|
|
169
|
+
log(` ✓ Venv healthy at ${venvDir}`);
|
|
170
|
+
return { healthy: true, repaired: false, reason: "already-healthy" };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
log(` ⚠ Venv ${diagnosis.reason} at ${venvDir} — repairing`);
|
|
174
|
+
|
|
175
|
+
const systemPython = findSystemPython();
|
|
176
|
+
if (!systemPython) {
|
|
177
|
+
return {
|
|
178
|
+
healthy: false,
|
|
179
|
+
repaired: false,
|
|
180
|
+
reason: `${diagnosis.reason}-and-no-system-python`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
execSync(`"${systemPython}" -m venv --clear "${venvDir}"`, {
|
|
186
|
+
stdio: "pipe",
|
|
187
|
+
timeout: 60000,
|
|
188
|
+
});
|
|
189
|
+
log(` ✓ Venv recreated at ${venvDir}`);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
const msg = (err && err.message ? err.message : String(err)).slice(0, 100);
|
|
192
|
+
return {
|
|
193
|
+
healthy: false,
|
|
194
|
+
repaired: false,
|
|
195
|
+
reason: `recreate-failed: ${msg}`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const post = diagnoseVenv(venvDir);
|
|
200
|
+
if (!post.healthy) {
|
|
201
|
+
return {
|
|
202
|
+
healthy: false,
|
|
203
|
+
repaired: true,
|
|
204
|
+
reason: `repaired-but-still-unhealthy: ${post.reason}`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!skipDeps) {
|
|
209
|
+
try {
|
|
210
|
+
execSync(`"${post.pythonPath}" -m pip install --upgrade pip --quiet`, {
|
|
211
|
+
stdio: "pipe",
|
|
212
|
+
timeout: 60000,
|
|
213
|
+
});
|
|
214
|
+
} catch { /* pip upgrade is non-critical */ }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
healthy: true,
|
|
219
|
+
repaired: true,
|
|
220
|
+
reason: `repaired-from-${diagnosis.reason}`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
97
225
|
/**
|
|
98
226
|
* Create the ArkaOS venv if it doesn't exist.
|
|
99
227
|
* Returns true on success, false on failure.
|