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.
@@ -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
- await doctor();
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");
@@ -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
- description: "ArkaOS virtual environment exists",
58
- severity: "warn",
59
- check: () => existsSync(getVenvPython()),
60
- fix: () => "Run: npx arkaos@latest update (creates venv automatically)",
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
- console.log("\n ArkaOS Doctor Health Checks\n");
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.