arkaos 2.10.0 → 2.11.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.
Files changed (46) hide show
  1. package/README.md +318 -107
  2. package/VERSION +1 -1
  3. package/config/hooks/cwd-changed.ps1 +144 -0
  4. package/config/hooks/post-tool-use.ps1 +347 -0
  5. package/config/hooks/post-tool-use.sh +6 -6
  6. package/config/hooks/pre-compact.ps1 +238 -0
  7. package/config/hooks/pre-compact.sh +10 -6
  8. package/config/hooks/session-start.ps1 +109 -0
  9. package/config/hooks/session-start.sh +1 -1
  10. package/config/hooks/user-prompt-submit.ps1 +287 -0
  11. package/config/hooks/user-prompt-submit.sh +5 -2
  12. package/config/statusline.ps1 +160 -0
  13. package/core/cognition/__pycache__/__init__.cpython-313.pyc +0 -0
  14. package/core/cognition/capture/__pycache__/__init__.cpython-313.pyc +0 -0
  15. package/core/cognition/capture/__pycache__/collector.cpython-313.pyc +0 -0
  16. package/core/cognition/capture/__pycache__/store.cpython-313.pyc +0 -0
  17. package/core/cognition/insights/__pycache__/__init__.cpython-313.pyc +0 -0
  18. package/core/cognition/insights/__pycache__/store.cpython-313.pyc +0 -0
  19. package/core/cognition/memory/__pycache__/__init__.cpython-313.pyc +0 -0
  20. package/core/cognition/memory/__pycache__/obsidian.cpython-313.pyc +0 -0
  21. package/core/cognition/memory/__pycache__/schemas.cpython-313.pyc +0 -0
  22. package/core/cognition/memory/__pycache__/vector.cpython-313.pyc +0 -0
  23. package/core/cognition/memory/__pycache__/writer.cpython-313.pyc +0 -0
  24. package/core/cognition/research/__pycache__/__init__.cpython-313.pyc +0 -0
  25. package/core/cognition/research/__pycache__/profiler.cpython-313.pyc +0 -0
  26. package/core/cognition/scheduler/__pycache__/__init__.cpython-313.pyc +0 -0
  27. package/core/cognition/scheduler/__pycache__/cli.cpython-313.pyc +0 -0
  28. package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
  29. package/core/cognition/scheduler/__pycache__/platform.cpython-313.pyc +0 -0
  30. package/core/cognition/scheduler/daemon.py +77 -21
  31. package/core/cognition/scheduler/platform.py +43 -12
  32. package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
  33. package/core/knowledge/vector_store.py +50 -25
  34. package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
  35. package/core/synapse/layers.py +2 -2
  36. package/installer/adapters/claude-code.js +72 -45
  37. package/installer/cli.js +19 -6
  38. package/installer/doctor.js +130 -18
  39. package/installer/index.js +592 -149
  40. package/installer/platform.js +20 -0
  41. package/installer/prompts.js +109 -5
  42. package/installer/python-resolver.js +251 -0
  43. package/installer/update.js +497 -62
  44. package/package.json +1 -1
  45. package/pyproject.toml +2 -2
  46. package/scripts/start-dashboard.ps1 +271 -0
@@ -8,6 +8,7 @@ import os
8
8
  import shutil
9
9
  import subprocess
10
10
  import sys
11
+ import time as time_mod
11
12
  from dataclasses import dataclass, field
12
13
  from datetime import datetime, time
13
14
  from pathlib import Path
@@ -113,9 +114,34 @@ class ArkaScheduler:
113
114
  and current_time.minute == schedule.run_time.minute
114
115
  )
115
116
 
117
+ @staticmethod
118
+ def _resolve_claude_binary() -> str:
119
+ """Resolve the Claude CLI binary by checking known install locations.
120
+
121
+ In daemon context (launchd/systemd/schtasks), PATH is minimal and shell
122
+ aliases don't exist, so we check absolute paths first.
123
+ """
124
+ home = Path.home()
125
+ candidates = [
126
+ home / ".local" / "bin" / "claude",
127
+ home / ".arkaos" / "bin" / "arka-claude",
128
+ ]
129
+ for candidate in candidates:
130
+ if candidate.is_file() and os.access(candidate, os.X_OK):
131
+ return str(candidate)
132
+ # Fallback to PATH lookup (works in interactive shells)
133
+ found = shutil.which("claude") or shutil.which("arka-claude")
134
+ if found:
135
+ return found
136
+ raise FileNotFoundError(
137
+ "Claude CLI not found. Checked: "
138
+ + ", ".join(str(c) for c in candidates)
139
+ + " and PATH lookup."
140
+ )
141
+
116
142
  def _build_command(self, schedule: ScheduleConfig) -> list[str]:
117
143
  """Build the Claude CLI invocation for a schedule."""
118
- claude_bin = shutil.which("claude") or "claude"
144
+ claude_bin = self._resolve_claude_binary()
119
145
  prompt_path = os.path.expanduser(schedule.prompt_file)
120
146
  prompt_content = Path(prompt_path).read_text(encoding="utf-8")
121
147
  return [claude_bin, "-p", prompt_content, "--dangerously-skip-permissions"]
@@ -131,33 +157,63 @@ class ArkaScheduler:
131
157
  log_dir.mkdir(parents=True, exist_ok=True)
132
158
  return log_dir / f"{today}.log"
133
159
 
160
+ @staticmethod
161
+ def _daemon_env() -> dict[str, str]:
162
+ """Build an environment with PATH that includes known Claude locations.
163
+
164
+ Daemons (launchd/systemd) inherit a minimal PATH. We extend it so that
165
+ any child processes spawned by Claude can also find common tools.
166
+ """
167
+ home = str(Path.home())
168
+ extra_paths = [
169
+ os.path.join(home, ".local", "bin"),
170
+ os.path.join(home, ".arkaos", "bin"),
171
+ "/usr/local/bin",
172
+ ]
173
+ env = os.environ.copy()
174
+ existing = env.get("PATH", "/usr/bin:/bin")
175
+ env["PATH"] = ":".join(extra_paths) + ":" + existing
176
+ return env
177
+
178
+ def _run_attempt(
179
+ self, cmd: list[str], log_file: Path, attempt: int, timeout: int,
180
+ ) -> bool:
181
+ """Run a single attempt of a scheduled command. Returns True on success."""
182
+ env = self._daemon_env()
183
+ with open(log_file, "a", encoding="utf-8") as lf:
184
+ lf.write(f"\n--- attempt {attempt} at {datetime.now().isoformat()} ---\n")
185
+ lf.write(f"cmd: {cmd[0]}\n")
186
+ try:
187
+ result = subprocess.run(
188
+ cmd, stdout=lf, stderr=lf, timeout=timeout, env=env,
189
+ )
190
+ if result.returncode == 0:
191
+ return True
192
+ lf.write(f"exit code: {result.returncode}\n")
193
+ except subprocess.TimeoutExpired:
194
+ lf.write("TIMEOUT\n")
195
+ except Exception as exc: # noqa: BLE001
196
+ lf.write(f"ERROR: {exc}\n")
197
+ return False
198
+
134
199
  def execute(self, schedule: ScheduleConfig) -> bool:
135
- """Run the scheduled command, writing output to a dated log file."""
200
+ """Run the scheduled command with retries and backoff."""
136
201
  log_file = self._log_path(schedule.command)
137
- timeout_seconds = schedule.timeout_minutes * 60
138
- attempts = 0
202
+ timeout = schedule.timeout_minutes * 60
139
203
  max_attempts = schedule.max_retries + 1 if schedule.retry_on_fail else 1
140
204
 
141
- while attempts < max_attempts:
142
- attempts += 1
205
+ try:
143
206
  cmd = self._build_command(schedule)
207
+ except FileNotFoundError as exc:
144
208
  with open(log_file, "a", encoding="utf-8") as lf:
145
- lf.write(f"\n--- attempt {attempts} at {datetime.now().isoformat()} ---\n")
146
- try:
147
- result = subprocess.run(
148
- cmd,
149
- stdout=lf,
150
- stderr=lf,
151
- timeout=timeout_seconds,
152
- )
153
- if result.returncode == 0:
154
- return True
155
- lf.write(f"exit code: {result.returncode}\n")
156
- except subprocess.TimeoutExpired:
157
- lf.write("TIMEOUT\n")
158
- except Exception as exc: # noqa: BLE001
159
- lf.write(f"ERROR: {exc}\n")
209
+ lf.write(f"\n--- at {datetime.now().isoformat()} ---\nFATAL: {exc}\n")
210
+ return False
160
211
 
212
+ for attempt in range(1, max_attempts + 1):
213
+ if self._run_attempt(cmd, log_file, attempt, timeout):
214
+ return True
215
+ if attempt < max_attempts:
216
+ time_mod.sleep(30 * attempt)
161
217
  return False
162
218
 
163
219
  # ------------------------------------------------------------------
@@ -15,9 +15,30 @@ def _default_daemon_script() -> str:
15
15
 
16
16
 
17
17
  def _python_executable() -> str:
18
+ """Resolve Python, preferring the ArkaOS venv over random system pythons.
19
+
20
+ shutil.which("python3") in a daemon context may pick up a venv from an
21
+ unrelated project (e.g., ai-jarvis). We check the ArkaOS venv first.
22
+ """
23
+ home = Path.home()
24
+ # Check both common venv directory names
25
+ for venv_dir in ("venv", ".venv"):
26
+ arkaos_venv = home / ".arkaos" / venv_dir / "bin" / "python3"
27
+ if arkaos_venv.is_file():
28
+ return str(arkaos_venv)
29
+ # Windows venv layout
30
+ arkaos_venv_win = home / ".arkaos" / venv_dir / "Scripts" / "python.exe"
31
+ if arkaos_venv_win.is_file():
32
+ return str(arkaos_venv_win)
18
33
  return shutil.which("python3") or sys.executable
19
34
 
20
35
 
36
+ def _daemon_path_value() -> str:
37
+ """Return a PATH string with known Claude CLI locations for daemon contexts."""
38
+ home = str(Path.home())
39
+ return f"{home}/.local/bin:{home}/.arkaos/bin:/usr/local/bin:/usr/bin:/bin"
40
+
41
+
21
42
  class PlatformAdapter(ABC):
22
43
  """Abstract base for OS-level service management."""
23
44
 
@@ -55,27 +76,35 @@ class MacOSAdapter(PlatformAdapter):
55
76
  def _plist_path(self) -> str:
56
77
  return str(Path(self._plist_dir) / f"{self._LABEL}.plist")
57
78
 
79
+ def _plist_env_block(self) -> str:
80
+ """Generate the EnvironmentVariables section of the plist."""
81
+ home = str(Path.home())
82
+ return (
83
+ "\t<key>EnvironmentVariables</key>\n\t<dict>\n"
84
+ f"\t\t<key>PATH</key>\n\t\t<string>{_daemon_path_value()}</string>\n"
85
+ f"\t\t<key>HOME</key>\n\t\t<string>{home}</string>\n"
86
+ "\t</dict>\n"
87
+ )
88
+
58
89
  def _generate_plist(self) -> str:
90
+ """Generate the launchd plist XML for the scheduler daemon."""
59
91
  python = _python_executable()
60
92
  log_dir = Path.home() / ".arkaos" / "logs"
61
- stdout = str(log_dir / "scheduler-stdout.log")
62
- stderr = str(log_dir / "scheduler-stderr.log")
63
93
  return (
64
94
  '<?xml version="1.0" encoding="UTF-8"?>\n'
65
95
  '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"'
66
96
  ' "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
67
- '<plist version="1.0">\n'
68
- "<dict>\n"
97
+ '<plist version="1.0">\n<dict>\n'
69
98
  f"\t<key>Label</key>\n\t<string>{self._LABEL}</string>\n"
70
- f"\t<key>ProgramArguments</key>\n"
71
- f"\t<array>\n\t\t<string>{python}</string>"
72
- f"\n\t\t<string>{self._daemon_script}</string>\n\t</array>\n"
73
- "\t<key>RunAtLoad</key>\n\t<true/>\n"
99
+ f"\t<key>ProgramArguments</key>\n\t<array>\n"
100
+ f"\t\t<string>{python}</string>\n"
101
+ f"\t\t<string>{self._daemon_script}</string>\n\t</array>\n"
102
+ + self._plist_env_block()
103
+ + "\t<key>RunAtLoad</key>\n\t<true/>\n"
74
104
  "\t<key>KeepAlive</key>\n\t<true/>\n"
75
- f"\t<key>StandardOutPath</key>\n\t<string>{stdout}</string>\n"
76
- f"\t<key>StandardErrorPath</key>\n\t<string>{stderr}</string>\n"
77
- "</dict>\n"
78
- "</plist>\n"
105
+ f"\t<key>StandardOutPath</key>\n\t<string>{log_dir / 'scheduler-stdout.log'}</string>\n"
106
+ f"\t<key>StandardErrorPath</key>\n\t<string>{log_dir / 'scheduler-stderr.log'}</string>\n"
107
+ "</dict>\n</plist>\n"
79
108
  )
80
109
 
81
110
  def install_service(self) -> bool:
@@ -143,6 +172,7 @@ class LinuxAdapter(PlatformAdapter):
143
172
  return str(Path(self._service_dir) / self._SERVICE_NAME)
144
173
 
145
174
  def _generate_unit(self) -> str:
175
+ """Generate the systemd unit file for the scheduler daemon."""
146
176
  python = _python_executable()
147
177
  return (
148
178
  "[Unit]\n"
@@ -150,6 +180,7 @@ class LinuxAdapter(PlatformAdapter):
150
180
  "After=network.target\n\n"
151
181
  "[Service]\n"
152
182
  "Type=simple\n"
183
+ f"Environment=PATH={_daemon_path_value()}\n"
153
184
  f"ExecStart={python} {self._daemon_script}\n"
154
185
  "Restart=on-failure\n"
155
186
  "RestartSec=60\n\n"
@@ -1,7 +1,7 @@
1
- """Vector store — SQLite-VSS backed semantic search.
1
+ """Vector store — SQLite-vec backed semantic search.
2
2
 
3
3
  Stores document chunks with embeddings for fast similarity search.
4
- Graceful degradation: works without sqlite-vss (brute-force fallback).
4
+ Graceful degradation: works without sqlite-vec (brute-force fallback).
5
5
  """
6
6
 
7
7
  import json
@@ -13,25 +13,25 @@ from typing import Any, Optional
13
13
  from core.knowledge.embedder import embed, embed_batch, EMBEDDING_DIMS
14
14
 
15
15
 
16
- def _load_vss(db: sqlite3.Connection) -> bool:
17
- """Try to load sqlite-vss extension."""
16
+ def _load_vec(db: sqlite3.Connection) -> bool:
17
+ """Try to load sqlite-vec extension."""
18
18
  try:
19
19
  db.enable_load_extension(True)
20
- import sqlite_vss
21
- sqlite_vss.load(db)
20
+ import sqlite_vec
21
+ sqlite_vec.load(db)
22
22
  return True
23
23
  except (ImportError, Exception):
24
24
  return False
25
25
 
26
26
 
27
27
  class VectorStore:
28
- """SQLite-VSS backed vector store for knowledge retrieval."""
28
+ """SQLite-vec backed vector store for knowledge retrieval."""
29
29
 
30
30
  def __init__(self, db_path: str | Path = ":memory:") -> None:
31
31
  self._db_path = str(db_path)
32
32
  self._db = sqlite3.connect(self._db_path)
33
33
  self._db.row_factory = sqlite3.Row
34
- self._vss_available = _load_vss(self._db)
34
+ self._vec_available = _load_vec(self._db)
35
35
  self._init_schema()
36
36
 
37
37
  def _init_schema(self) -> None:
@@ -50,15 +50,39 @@ class VectorStore:
50
50
  CREATE INDEX IF NOT EXISTS idx_chunks_source ON chunks(source);
51
51
  CREATE INDEX IF NOT EXISTS idx_chunks_hash ON chunks(file_hash);
52
52
  """)
53
- if self._vss_available:
53
+ self._migrate_vss_to_vec()
54
+ if self._vec_available:
54
55
  try:
55
56
  self._db.execute(
56
- f"CREATE VIRTUAL TABLE IF NOT EXISTS vss_chunks USING vss0(embedding({EMBEDDING_DIMS}))"
57
+ f"CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0(embedding float[{EMBEDDING_DIMS}])"
57
58
  )
58
59
  except Exception:
59
- self._vss_available = False
60
+ self._vec_available = False
60
61
  self._db.commit()
61
62
 
63
+ def _migrate_vss_to_vec(self) -> None:
64
+ """Drop legacy sqlite-vss tables if present.
65
+
66
+ The vss_chunks virtual table used a different schema and query
67
+ syntax that is incompatible with sqlite-vec. Dropping it forces a
68
+ clean re-index on the next /arka index invocation. The chunks
69
+ table (with raw embeddings) is preserved — only the virtual table
70
+ index is removed.
71
+ """
72
+ try:
73
+ tables = [
74
+ r[0]
75
+ for r in self._db.execute(
76
+ "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'vss_%'"
77
+ ).fetchall()
78
+ ]
79
+ if tables:
80
+ for t in tables:
81
+ self._db.execute(f"DROP TABLE IF EXISTS {t}")
82
+ self._db.commit()
83
+ except Exception:
84
+ pass
85
+
62
86
  def index_chunks(
63
87
  self,
64
88
  texts: list[str],
@@ -90,9 +114,9 @@ class VectorStore:
90
114
  (text, heading, source, file_hash, meta_json, emb_blob),
91
115
  )
92
116
 
93
- if self._vss_available and emb_blob:
117
+ if self._vec_available and emb_blob:
94
118
  self._db.execute(
95
- "INSERT INTO vss_chunks (rowid, embedding) VALUES (?, ?)",
119
+ "INSERT INTO vec_chunks (rowid, embedding) VALUES (?, ?)",
96
120
  (cursor.lastrowid, emb_blob),
97
121
  )
98
122
  count += 1
@@ -112,23 +136,24 @@ class VectorStore:
112
136
 
113
137
  query_emb = embed(query)
114
138
 
115
- if query_emb and self._vss_available:
139
+ if query_emb and self._vec_available:
116
140
  try:
117
- return self._vss_search(query_emb, top_k)
141
+ return self._vec_search(query_emb, top_k)
118
142
  except Exception:
119
143
  return self._keyword_search(query, top_k)
120
144
 
121
145
  # Fallback: keyword search
122
146
  return self._keyword_search(query, top_k)
123
147
 
124
- def _vss_search(self, query_emb: list[float], top_k: int) -> list[dict]:
125
- """Vector similarity search via sqlite-vss."""
148
+ def _vec_search(self, query_emb: list[float], top_k: int) -> list[dict]:
149
+ """Vector similarity search via sqlite-vec."""
126
150
  query_blob = _vec_to_blob(query_emb)
127
151
  rows = self._db.execute("""
128
152
  SELECT c.text, c.heading, c.source, c.metadata, v.distance
129
- FROM vss_chunks v
153
+ FROM vec_chunks v
130
154
  JOIN chunks c ON c.id = v.rowid
131
- WHERE vss_search(v.embedding, vss_search_params(?, ?))
155
+ WHERE v.embedding MATCH ? AND k = ?
156
+ ORDER BY v.distance
132
157
  """, (query_blob, top_k)).fetchall()
133
158
 
134
159
  return [
@@ -136,7 +161,7 @@ class VectorStore:
136
161
  "text": r["text"],
137
162
  "heading": r["heading"],
138
163
  "source": r["source"],
139
- "score": 1.0 - r["distance"], # Convert distance to similarity
164
+ "score": 1.0 / (1.0 + r["distance"]), # Convert distance to similarity
140
165
  "metadata": json.loads(r["metadata"]),
141
166
  }
142
167
  for r in rows
@@ -176,10 +201,10 @@ class VectorStore:
176
201
 
177
202
  def remove_file(self, source: str) -> int:
178
203
  """Remove all chunks from a source file."""
179
- if self._vss_available:
204
+ if self._vec_available:
180
205
  rows = self._db.execute("SELECT id FROM chunks WHERE source = ?", (source,)).fetchall()
181
206
  for r in rows:
182
- self._db.execute("DELETE FROM vss_chunks WHERE rowid = ?", (r["id"],))
207
+ self._db.execute("DELETE FROM vec_chunks WHERE rowid = ?", (r["id"],))
183
208
  deleted = self._db.execute("DELETE FROM chunks WHERE source = ?", (source,)).rowcount
184
209
  self._db.commit()
185
210
  return deleted
@@ -191,14 +216,14 @@ class VectorStore:
191
216
  return {
192
217
  "total_chunks": total,
193
218
  "total_files": sources,
194
- "vss_available": self._vss_available,
219
+ "vec_available": self._vec_available,
195
220
  "db_path": self._db_path,
196
221
  }
197
222
 
198
223
  def clear(self) -> None:
199
224
  """Remove all data."""
200
- if self._vss_available:
201
- self._db.execute("DELETE FROM vss_chunks")
225
+ if self._vec_available:
226
+ self._db.execute("DELETE FROM vec_chunks")
202
227
  self._db.execute("DELETE FROM chunks")
203
228
  self._db.commit()
204
229
 
@@ -133,7 +133,7 @@ DEPARTMENT_PATTERNS: dict[str, str] = {
133
133
  "marketing": r"\b(social|content|campaign|post|instagram|linkedin|twitter|tiktok|seo|marketing|ads|email.?campaign|growth|viral)\b",
134
134
  "finance": r"\b(budget|invoice|revenue|forecast|profit|loss|roi|margin|cash.?flow|financial|invest|valuation|pricing)\b",
135
135
  "ecom": r"\b(store|product|shop|shopify|ecommerce|catalog|inventory|cart|checkout|pricing|marketplace)\b",
136
- "strategy": r"\b(strategy|brainstorm|market|swot|competitor|roadmap|pivot|growth|porter|blue.?ocean|positioning)\b",
136
+ "strategy": r"\b(strategy|brainstorm|market|swot|competitors?|roadmap|pivot|growth|porter|blue.?ocean|positioning)\b",
137
137
  "ops": r"\b(task|automate|meeting|workflow|process|schedule|sop|integration|zapier|n8n)\b",
138
138
  "kb": r"\b(learn|persona|knowledge|youtube|transcribe|article|research|zettelkasten|note)\b",
139
139
  "brand": r"\b(brand|logo|colors|palette|mockup|photoshoot|brand.?identity|brand.?guide|mood.?board|naming|visual.?design|motion|ux|ui|wireframe)\b",
@@ -143,7 +143,7 @@ DEPARTMENT_PATTERNS: dict[str, str] = {
143
143
  "content": r"\b(viral|hook|script|repurpose|youtube|tiktok|reels|shorts|newsletter|creator)\b",
144
144
  "pm": r"\b(sprint|backlog|standup|retro|scrum|kanban|story|estimate|roadmap|agile)\b",
145
145
  "lead": r"\b(leadership|delegation|1on1|feedback|culture|hiring|performance.?review|team.?build)\b",
146
- "sales": r"\b(pipeline|proposal|discovery.?call|objection|negotiate|deal|close|prospect|spin|challenger)\b",
146
+ "sales": r"\b(pipeline|proposal|discovery.?call|objection|negotiate|deal|close|prospect|spin|challenger|cold.?email|outreach)\b",
147
147
  "org": r"\b(org.?design|hiring.?plan|onboarding|remote|meeting.?optimize|compensation|decision.?framework)\b",
148
148
  }
149
149
 
@@ -1,5 +1,55 @@
1
1
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
2
2
  import { join, dirname } from "node:path";
3
+ import { platform } from "node:os";
4
+
5
+ const IS_WINDOWS = platform() === "win32";
6
+
7
+ /**
8
+ * Build a complete inner hook-entry object for Claude Code's settings.json.
9
+ *
10
+ * On Unix we point `command` directly at the `.sh` script and rely on the
11
+ * shebang for interpreter selection; no `shell` field is needed because
12
+ * Claude Code's default is `bash`.
13
+ *
14
+ * On Windows we point `command` at the `.ps1` script and set the
15
+ * documented `shell: "powershell"` hook field so Claude Code spawns
16
+ * PowerShell directly and pipes the hook payload to the script's stdin
17
+ * natively. This is the pattern prescribed by the upstream hooks
18
+ * reference at https://code.claude.com/docs/en/hooks :
19
+ *
20
+ * shell (no, optional): Shell to use for this hook. Accepts
21
+ * "bash" (default) or "powershell". Setting "powershell" runs the
22
+ * command via PowerShell on Windows. Does not require
23
+ * CLAUDE_CODE_USE_POWERSHELL_TOOL since hooks spawn PowerShell
24
+ * directly.
25
+ *
26
+ * Earlier versions of this adapter embedded the full
27
+ * `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ...`
28
+ * command line in `command` without setting `shell`. Claude Code then
29
+ * defaulted to `shell: "bash"`, which on Windows routes through a
30
+ * compatibility layer that drops the stdin pipe before the PowerShell
31
+ * script reads it. `SessionStart` still appeared to work (it only
32
+ * writes stdout), but every other hook received a 0-byte stdin, hit
33
+ * the `IsNullOrWhiteSpace` guard at the top of each `.ps1`, and
34
+ * silently exited. This commit fixes that by emitting the canonical
35
+ * `shell: "powershell"` field and letting Claude Code handle the
36
+ * PowerShell invocation itself.
37
+ */
38
+ function hookEntry(hooksDir, name, timeout) {
39
+ if (IS_WINDOWS) {
40
+ return {
41
+ type: "command",
42
+ command: join(hooksDir, `${name}.ps1`),
43
+ shell: "powershell",
44
+ timeout,
45
+ };
46
+ }
47
+ return {
48
+ type: "command",
49
+ command: join(hooksDir, `${name}.sh`),
50
+ timeout,
51
+ };
52
+ }
3
53
 
4
54
  export default {
5
55
  configureHooks(config, installDir) {
@@ -29,70 +79,47 @@ export default {
29
79
  }
30
80
 
31
81
  // SessionStart — Branded greeting + version drift detection
82
+ // Timeout 15s: PowerShell cold-start on Windows VMs can exceed 5s.
32
83
  settings.hooks.SessionStart = [
33
- {
34
- hooks: [
35
- {
36
- type: "command",
37
- command: join(hooksDir, "session-start.sh"),
38
- timeout: 5,
39
- },
40
- ],
41
- },
84
+ { hooks: [hookEntry(hooksDir, "session-start", 15)] },
42
85
  ];
43
86
 
44
87
  // UserPromptSubmit — Synapse v2 context injection
45
88
  settings.hooks.UserPromptSubmit = [
46
- {
47
- hooks: [
48
- {
49
- type: "command",
50
- command: join(hooksDir, "user-prompt-submit.sh"),
51
- timeout: 10,
52
- },
53
- ],
54
- },
89
+ { hooks: [hookEntry(hooksDir, "user-prompt-submit", 10)] },
55
90
  ];
56
91
 
57
92
  // PostToolUse — Error tracking
58
93
  settings.hooks.PostToolUse = [
59
- {
60
- hooks: [
61
- {
62
- type: "command",
63
- command: join(hooksDir, "post-tool-use.sh"),
64
- timeout: 5,
65
- },
66
- ],
67
- },
94
+ { hooks: [hookEntry(hooksDir, "post-tool-use", 5)] },
68
95
  ];
69
96
 
70
97
  // PreCompact — Session digest
71
98
  settings.hooks.PreCompact = [
72
- {
73
- hooks: [
74
- {
75
- type: "command",
76
- command: join(hooksDir, "pre-compact.sh"),
77
- timeout: 30,
78
- },
79
- ],
80
- },
99
+ { hooks: [hookEntry(hooksDir, "pre-compact", 30)] },
81
100
  ];
82
101
 
83
102
  // CwdChanged — Project/ecosystem auto-detection
84
103
  settings.hooks.CwdChanged = [
85
- {
86
- hooks: [
87
- {
88
- type: "command",
89
- command: join(hooksDir, "cwd-changed.sh"),
90
- timeout: 5,
91
- },
92
- ],
93
- },
104
+ { hooks: [hookEntry(hooksDir, "cwd-changed", 5)] },
94
105
  ];
95
106
 
107
+ // Statusline — ArkaOS branded status bar
108
+ const configDir = join(installDir, "config");
109
+ const statuslineFile = IS_WINDOWS ? "statusline.ps1" : "statusline.sh";
110
+ const statuslinePath = join(configDir, statuslineFile);
111
+ if (existsSync(statuslinePath)) {
112
+ if (IS_WINDOWS) {
113
+ settings.statusline = {
114
+ command: `powershell -NoProfile -ExecutionPolicy Bypass -File "${statuslinePath}"`,
115
+ };
116
+ } else {
117
+ settings.statusline = {
118
+ command: statuslinePath,
119
+ };
120
+ }
121
+ }
122
+
96
123
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
97
124
  console.log(" Claude Code hooks configured.");
98
125
  },
package/installer/cli.js CHANGED
@@ -6,6 +6,8 @@ import { dirname, join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { install } from "./index.js";
8
8
  import { detectRuntime } from "./detect-runtime.js";
9
+ import { IS_WINDOWS } from "./platform.js";
10
+ import { getArkaosPython } from "./python-resolver.js";
9
11
 
10
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
11
13
  const VERSION = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8")).version;
@@ -108,9 +110,16 @@ async function main() {
108
110
 
109
111
  case "dashboard": {
110
112
  const { execSync: execDash } = await import("node:child_process");
111
- const repoRootDash = dirname(fileURLToPath(import.meta.url)).replace(/\/installer$/, "");
113
+ // join(__dirname, "..") is cross-platform; the previous regex
114
+ // `/\/installer$/` used forward slashes and did not match the
115
+ // Windows backslash-separated path, leaving repoRootDash pointing
116
+ // at the installer directory instead of the repo root.
117
+ const repoRootDash = join(__dirname, "..");
118
+ const dashCmd = IS_WINDOWS
119
+ ? `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${join(repoRootDash, "scripts", "start-dashboard.ps1")}"`
120
+ : `bash "${repoRootDash}/scripts/start-dashboard.sh"`;
112
121
  try {
113
- execDash(`bash "${repoRootDash}/scripts/start-dashboard.sh"`, {
122
+ execDash(dashCmd, {
114
123
  stdio: "inherit",
115
124
  env: { ...process.env, ARKAOS_ROOT: repoRootDash },
116
125
  });
@@ -121,9 +130,11 @@ async function main() {
121
130
  case "index": {
122
131
  const { execSync } = await import("node:child_process");
123
132
  const indexArgs = positionals.slice(1).join(" ");
124
- const repoRoot = dirname(fileURLToPath(import.meta.url)).replace(/\/installer$/, "");
133
+ const repoRoot = join(__dirname, "..");
134
+ const pyIndex = getArkaosPython();
135
+ if (!pyIndex) { console.error("No Python found. Run: npx arkaos install"); process.exit(1); }
125
136
  try {
126
- execSync(`python3 "${repoRoot}/scripts/knowledge-index.py" ${indexArgs || ""}`, {
137
+ execSync(`"${pyIndex}" "${join(repoRoot, "scripts", "knowledge-index.py")}" ${indexArgs || ""}`, {
127
138
  stdio: "inherit",
128
139
  env: { ...process.env, ARKAOS_ROOT: repoRoot },
129
140
  });
@@ -135,9 +146,11 @@ async function main() {
135
146
  const { execSync } = await import("node:child_process");
136
147
  const query = positionals.slice(1).join(" ");
137
148
  if (!query) { console.error("Usage: npx arkaos search \"your query\""); process.exit(1); }
138
- const repoRoot2 = dirname(fileURLToPath(import.meta.url)).replace(/\/installer$/, "");
149
+ const repoRoot2 = join(__dirname, "..");
150
+ const pySearch = getArkaosPython();
151
+ if (!pySearch) { console.error("No Python found. Run: npx arkaos install"); process.exit(1); }
139
152
  try {
140
- execSync(`python3 "${repoRoot2}/scripts/knowledge-index.py" --search "${query}"`, {
153
+ execSync(`"${pySearch}" "${join(repoRoot2, "scripts", "knowledge-index.py")}" --search "${query}"`, {
141
154
  stdio: "inherit",
142
155
  env: { ...process.env, ARKAOS_ROOT: repoRoot2 },
143
156
  });