arkaos 2.22.0 → 2.25.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 (66) hide show
  1. package/README.md +105 -9
  2. package/VERSION +1 -1
  3. package/arka/SKILL.md +1 -0
  4. package/arka/skills/comfyui/SKILL.md +2 -2
  5. package/arka/skills/comfyui/references/workflows.md +6 -6
  6. package/arka/skills/costs/SKILL.md +11 -0
  7. package/config/cognition/prompts/dreaming.md +3 -3
  8. package/config/cognition/prompts/research.md +4 -4
  9. package/config/hooks/user-prompt-submit.sh +6 -1
  10. package/core/cognition/__pycache__/auto_documentor.cpython-313.pyc +0 -0
  11. package/core/cognition/auto_documentor.py +127 -12
  12. package/core/cognition/capture/__pycache__/collector.cpython-313.pyc +0 -0
  13. package/core/cognition/capture/collector.py +10 -3
  14. package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
  15. package/core/cognition/scheduler/daemon.py +5 -0
  16. package/core/jobs/__pycache__/auto_doc_worker.cpython-313.pyc +0 -0
  17. package/core/jobs/auto_doc_worker.py +5 -3
  18. package/core/obsidian/__pycache__/writer.cpython-313.pyc +0 -0
  19. package/core/obsidian/writer.py +5 -4
  20. package/core/runtime/__pycache__/__init__.cpython-313.pyc +0 -0
  21. package/core/runtime/__pycache__/base.cpython-313.pyc +0 -0
  22. package/core/runtime/__pycache__/claude_code.cpython-313.pyc +0 -0
  23. package/core/runtime/__pycache__/codex_cli.cpython-313.pyc +0 -0
  24. package/core/runtime/__pycache__/cursor.cpython-313.pyc +0 -0
  25. package/core/runtime/__pycache__/gemini_cli.cpython-313.pyc +0 -0
  26. package/core/runtime/__pycache__/llm_cost_telemetry.cpython-313.pyc +0 -0
  27. package/core/runtime/__pycache__/llm_cost_telemetry_cli.cpython-313.pyc +0 -0
  28. package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
  29. package/core/runtime/__pycache__/path_resolver.cpython-313.pyc +0 -0
  30. package/core/runtime/__pycache__/pricing.cpython-313.pyc +0 -0
  31. package/core/runtime/claude_code.py +22 -16
  32. package/core/runtime/codex_cli.py +23 -6
  33. package/core/runtime/gemini_cli.py +135 -11
  34. package/core/runtime/llm_provider.py +14 -9
  35. package/core/runtime/path_resolver.py +202 -0
  36. package/core/shared/__init__.py +6 -0
  37. package/core/shared/__pycache__/__init__.cpython-313.pyc +0 -0
  38. package/core/shared/__pycache__/safe_session_id.cpython-313.pyc +0 -0
  39. package/core/shared/safe_session_id.py +41 -0
  40. package/core/specs/SPEC-installer-cross-os.md +227 -0
  41. package/core/specs/SPEC-paths-portability.md +209 -0
  42. package/core/synapse/__pycache__/kb_cache.cpython-313.pyc +0 -0
  43. package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
  44. package/core/synapse/kb_cache.py +7 -6
  45. package/core/synapse/layers.py +7 -0
  46. package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
  47. package/core/workflow/__pycache__/marker_cache.cpython-313.pyc +0 -0
  48. package/core/workflow/__pycache__/research_gate.cpython-313.pyc +0 -0
  49. package/core/workflow/flow_enforcer.py +6 -14
  50. package/core/workflow/marker_cache.py +6 -8
  51. package/core/workflow/research_gate.py +11 -9
  52. package/departments/dev/skills/scaffold/SKILL.md +4 -4
  53. package/departments/kb/skills/knowledge/SKILL.md +1 -1
  54. package/departments/ops/skills/operations/SKILL.md +1 -1
  55. package/installer/cli.js +2 -1
  56. package/installer/doctor.js +48 -0
  57. package/installer/index.js +26 -1
  58. package/installer/migrate-user-data.js +17 -1
  59. package/installer/migrations/v3_path_schema.js +109 -0
  60. package/installer/package-manager.js +191 -0
  61. package/installer/path-resolver.js +124 -0
  62. package/installer/system-tools.js +243 -0
  63. package/installer/update.js +24 -3
  64. package/knowledge/obsidian-config.json +1 -1
  65. package/package.json +1 -1
  66. package/pyproject.toml +1 -1
@@ -120,7 +120,7 @@ class ClaudeCodeAdapter(RuntimeAdapter):
120
120
  max_tokens: int = 2000,
121
121
  system: str = "",
122
122
  ) -> "LLMResponse":
123
- from core.runtime.llm_provider import LLMResponse, LLMUnavailable
123
+ from core.runtime.llm_provider import LLMUnavailable
124
124
 
125
125
  binary = shutil.which("claude")
126
126
  if binary is None:
@@ -131,27 +131,28 @@ class ClaudeCodeAdapter(RuntimeAdapter):
131
131
  cmd = [binary, "-p", prompt, "--output-format", "json"]
132
132
  if system:
133
133
  cmd.extend(["--append-system-prompt", system])
134
- try:
135
- proc = subprocess.run(
136
- cmd,
137
- capture_output=True,
138
- text=True,
139
- timeout=60,
140
- check=False,
141
- )
142
- except subprocess.TimeoutExpired as exc:
143
- raise LLMUnavailable("claude CLI timed out after 60s") from exc
144
- except OSError as exc:
145
- raise LLMUnavailable(f"claude CLI subprocess failed: {exc}") from exc
146
-
134
+ proc = _run_claude_cli(cmd)
147
135
  if proc.returncode != 0:
148
136
  raise LLMUnavailable(
149
137
  f"claude CLI exited {proc.returncode}: {proc.stderr.strip()[:200]}"
150
138
  )
151
- return _parse_claude_json(proc.stdout)
139
+ return _parse_claude_cli_output(proc.stdout)
140
+
141
+
142
+ def _run_claude_cli(cmd: list[str]) -> subprocess.CompletedProcess:
143
+ from core.runtime.llm_provider import LLMUnavailable
144
+
145
+ try:
146
+ return subprocess.run(
147
+ cmd, capture_output=True, text=True, timeout=60, check=False
148
+ )
149
+ except subprocess.TimeoutExpired as exc:
150
+ raise LLMUnavailable("claude CLI timed out after 60s") from exc
151
+ except OSError as exc:
152
+ raise LLMUnavailable(f"claude CLI subprocess failed: {exc}") from exc
152
153
 
153
154
 
154
- def _parse_claude_json(stdout: str) -> "LLMResponse":
155
+ def _parse_claude_cli_output(stdout: str) -> "LLMResponse":
155
156
  from core.runtime.llm_provider import LLMResponse
156
157
 
157
158
  payload = json.loads(stdout) if stdout.strip() else {}
@@ -170,3 +171,8 @@ def _parse_claude_json(stdout: str) -> "LLMResponse":
170
171
  cached_tokens=cache_read,
171
172
  model=model,
172
173
  )
174
+
175
+
176
+ # Backward compatibility alias — tests and external importers that used
177
+ # the old helper name continue to work without modification.
178
+ _parse_claude_json = _parse_claude_cli_output
@@ -94,11 +94,28 @@ class CodexCliAdapter(RuntimeAdapter):
94
94
  "codex CLI not found on PATH — install Codex CLI to "
95
95
  "enable headless completion."
96
96
  )
97
- # TODO(llm-agnostic): Verify Codex CLI headless invocation
98
- # syntax (`codex exec "<prompt>"` was the working hypothesis
99
- # but has not been confirmed for the current release). Until
100
- # then, refuse rather than guess. Tracked in Task #12 report.
97
+ # TODO(llm-agnostic): Implement real headless completion.
98
+ #
99
+ # Status as of 2026-04-20: Codex CLI is NOT installed on the
100
+ # development machine, so actual invocation syntax could not
101
+ # be verified. Until a local install is available, refuse
102
+ # rather than ship guessed arguments.
103
+ #
104
+ # Verification checklist for whoever picks this up:
105
+ # 1. Install: npm install -g @openai/codex-cli
106
+ # 2. Discover: codex --help (confirm non-interactive flag)
107
+ # 3. Pattern: likely `codex exec "<prompt>"` or
108
+ # `codex --prompt "<prompt>" --format json`
109
+ # 4. Wire the subprocess call (mirror the Gemini adapter —
110
+ # list-form args, 60s timeout, stderr clipped, JSON parse
111
+ # with plain-text fallback, token estimate on miss).
112
+ #
113
+ # SubagentProvider cleanly falls back to anthropic-direct or
114
+ # stub when this raises, so the chain keeps working.
101
115
  raise NotImplementedError(
102
- "Codex CLI headless completion not yet wired — verify CLI "
103
- "syntax before enabling. See core/runtime/codex_cli.py TODO."
116
+ "Codex CLI headless mode requires local `codex` CLI. "
117
+ "Install: `npm install -g @openai/codex-cli` (verified 2026-04-20). "
118
+ "Verify syntax: `codex --help`. "
119
+ "See TODO(llm-agnostic) in this file. "
120
+ "SubagentProvider will cleanly fall back to anthropic-direct or stub."
104
121
  )
@@ -1,9 +1,26 @@
1
1
  """Gemini CLI runtime adapter.
2
2
 
3
3
  Google's Gemini CLI. Uses GEMINI.md for instructions and activate_skill for skills.
4
+
5
+ Headless invocation reference (verified against
6
+ https://github.com/google-gemini/gemini-cli docs — Context7 query
7
+ on 2026-04-20):
8
+
9
+ gemini -p "<prompt>" --output-format json
10
+
11
+ The JSON payload contains a ``response`` key (the model's text) and a
12
+ ``stats`` block with ``totalTokenCount`` / token counts. On failure the
13
+ payload includes an ``error`` block with diagnostic details. If JSON
14
+ parsing fails we fall back to treating stdout as raw text and estimate
15
+ tokens via a ``len(text) // 4`` heuristic — better than losing cost
16
+ telemetry entirely.
4
17
  """
5
18
 
19
+ from __future__ import annotations
20
+
21
+ import json
6
22
  import shutil
23
+ import subprocess
7
24
  from pathlib import Path
8
25
  from os.path import expanduser
9
26
  from typing import TYPE_CHECKING
@@ -14,6 +31,11 @@ if TYPE_CHECKING:
14
31
  from core.runtime.llm_provider import LLMResponse
15
32
 
16
33
 
34
+ _TIMEOUT_SECONDS = 60
35
+ _TOKEN_ESTIMATE_DIVISOR = 4 # Rough chars-per-token heuristic.
36
+ _STDERR_CLIP = 200
37
+
38
+
17
39
  class GeminiCliAdapter(RuntimeAdapter):
18
40
  """Adapter for Google's Gemini CLI."""
19
41
 
@@ -73,10 +95,7 @@ class GeminiCliAdapter(RuntimeAdapter):
73
95
  raise NotImplementedError("Use Gemini CLI's native content search")
74
96
 
75
97
  def headless_supported(self) -> bool:
76
- # Gemini CLI headless invocation syntax is not verified for the
77
- # current release. Returning False lets SubagentProvider fall
78
- # back gracefully rather than shell out blindly.
79
- return False
98
+ return shutil.which("gemini") is not None
80
99
 
81
100
  def headless_complete(
82
101
  self,
@@ -85,17 +104,122 @@ class GeminiCliAdapter(RuntimeAdapter):
85
104
  max_tokens: int = 2000,
86
105
  system: str = "",
87
106
  ) -> "LLMResponse":
107
+ from core.runtime.llm_provider import LLMUnavailable
108
+
88
109
  binary = shutil.which("gemini")
89
110
  if binary is None:
90
111
  raise NotImplementedError(
91
112
  "gemini CLI not found on PATH — install Gemini CLI to "
92
113
  "enable headless completion."
93
114
  )
94
- # TODO(llm-agnostic): Verify Gemini CLI's headless invocation
95
- # (`gemini -p "<prompt>"` was the working hypothesis). Until
96
- # confirmed for the shipped CLI version, refuse rather than
97
- # guess. Tracked in Task #12 report.
98
- raise NotImplementedError(
99
- "Gemini CLI headless completion not yet wired — verify CLI "
100
- "syntax before enabling. See core/runtime/gemini_cli.py TODO."
115
+ effective_prompt = _merge_system_prompt(prompt, system)
116
+ cmd = [binary, "-p", effective_prompt, "--output-format", "json"]
117
+ proc = _run_gemini_cli(cmd)
118
+ if proc.returncode != 0:
119
+ stderr_tail = proc.stderr.strip()[:_STDERR_CLIP]
120
+ raise LLMUnavailable(
121
+ f"gemini CLI exited {proc.returncode}: {stderr_tail}"
122
+ )
123
+ return _parse_gemini_cli_output(proc.stdout)
124
+
125
+
126
+ def _merge_system_prompt(prompt: str, system: str) -> str:
127
+ # Gemini CLI's -p flag accepts a single prompt; prepend the system
128
+ # text when provided so downstream behaviour matches Claude Code.
129
+ if not system:
130
+ return prompt
131
+ return f"{system}\n\n---\n\n{prompt}"
132
+
133
+
134
+ def _run_gemini_cli(cmd: list[str]) -> subprocess.CompletedProcess:
135
+ from core.runtime.llm_provider import LLMUnavailable
136
+
137
+ try:
138
+ return subprocess.run(
139
+ cmd,
140
+ capture_output=True,
141
+ text=True,
142
+ timeout=_TIMEOUT_SECONDS,
143
+ check=False,
144
+ )
145
+ except subprocess.TimeoutExpired as exc:
146
+ raise LLMUnavailable(
147
+ f"gemini CLI timed out after {_TIMEOUT_SECONDS}s"
148
+ ) from exc
149
+ except OSError as exc:
150
+ raise LLMUnavailable(f"gemini CLI subprocess failed: {exc}") from exc
151
+
152
+
153
+ def _parse_gemini_cli_output(stdout: str) -> "LLMResponse":
154
+ from core.runtime.llm_provider import LLMResponse
155
+
156
+ stripped = stdout.strip()
157
+ if not stripped:
158
+ return LLMResponse(
159
+ text="", tokens_in=0, tokens_out=0, cached_tokens=0, model=""
160
+ )
161
+ payload = _safe_loads(stripped)
162
+ if payload is None:
163
+ # Non-JSON fallback: treat stdout as raw text, estimate tokens.
164
+ return _response_from_plain_text(stripped)
165
+ return _response_from_json_payload(payload)
166
+
167
+
168
+ def _safe_loads(text: str) -> dict | None:
169
+ try:
170
+ data = json.loads(text)
171
+ except (json.JSONDecodeError, ValueError):
172
+ return None
173
+ return data if isinstance(data, dict) else None
174
+
175
+
176
+ def _response_from_plain_text(text: str) -> "LLMResponse":
177
+ from core.runtime.llm_provider import LLMResponse
178
+
179
+ estimate = max(1, len(text) // _TOKEN_ESTIMATE_DIVISOR)
180
+ return LLMResponse(
181
+ text=text,
182
+ tokens_in=0,
183
+ tokens_out=estimate,
184
+ cached_tokens=0,
185
+ model="",
186
+ )
187
+
188
+
189
+ def _response_from_json_payload(payload: dict) -> "LLMResponse":
190
+ from core.runtime.llm_provider import LLMResponse, LLMUnavailable
191
+
192
+ error = payload.get("error")
193
+ if isinstance(error, dict) and error:
194
+ message = str(error.get("message") or error).strip()[:_STDERR_CLIP]
195
+ raise LLMUnavailable(f"gemini CLI returned error: {message}")
196
+
197
+ text = str(payload.get("response") or payload.get("result") or "")
198
+ tokens_in, tokens_out = _extract_token_counts(payload, text)
199
+ model = str(payload.get("model") or "")
200
+ return LLMResponse(
201
+ text=text,
202
+ tokens_in=tokens_in,
203
+ tokens_out=tokens_out,
204
+ cached_tokens=0,
205
+ model=model,
206
+ )
207
+
208
+
209
+ def _extract_token_counts(payload: dict, text: str) -> tuple[int, int]:
210
+ stats = payload.get("stats") or payload.get("usageMetadata") or {}
211
+ if isinstance(stats, dict):
212
+ tokens_in = int(stats.get("promptTokenCount") or stats.get("input_tokens") or 0)
213
+ tokens_out = int(
214
+ stats.get("candidatesTokenCount")
215
+ or stats.get("output_tokens")
216
+ or 0
101
217
  )
218
+ # Fall back to the rolled-up total when per-side counts are absent.
219
+ if tokens_in == 0 and tokens_out == 0:
220
+ total = int(stats.get("totalTokenCount") or 0)
221
+ if total > 0:
222
+ return 0, total
223
+ return tokens_in, tokens_out
224
+ # No stats block at all — estimate output from text length.
225
+ return 0, max(1, len(text) // _TOKEN_ESTIMATE_DIVISOR)
@@ -199,6 +199,19 @@ class AnthropicDirectProvider:
199
199
  }
200
200
  ]
201
201
 
202
+ def _build_anthropic_payload(
203
+ self, prompt: str, system: str, max_tokens: int, model: str
204
+ ) -> dict[str, object]:
205
+ payload: dict[str, object] = {
206
+ "model": model,
207
+ "max_tokens": max_tokens,
208
+ "messages": [{"role": "user", "content": prompt}],
209
+ }
210
+ system_blocks = self._build_system_blocks(system)
211
+ if system_blocks:
212
+ payload["system"] = system_blocks
213
+ return payload
214
+
202
215
  def complete(
203
216
  self,
204
217
  prompt: str,
@@ -213,15 +226,7 @@ class AnthropicDirectProvider:
213
226
  "cannot select a model."
214
227
  )
215
228
  client = self._build_client()
216
- payload: dict[str, object] = {
217
- "model": model,
218
- "max_tokens": max_tokens,
219
- "messages": [{"role": "user", "content": prompt}],
220
- }
221
- system_blocks = self._build_system_blocks(system)
222
- if system_blocks:
223
- payload["system"] = system_blocks
224
-
229
+ payload = self._build_anthropic_payload(prompt, system, max_tokens, model)
225
230
  try:
226
231
  raw = client.messages.create(**payload) # type: ignore[attr-defined]
227
232
  except Exception as exc: # noqa: BLE001
@@ -0,0 +1,202 @@
1
+ """Template-token path resolver for ArkaOS.
2
+
3
+ Resolves user-specific paths from ``~/.arkaos/profile.json`` and environment
4
+ variables, so source files can use neutral tokens like ``${VAULT_PATH}`` and
5
+ ``${ARKA_OS_REPOS}`` instead of hardcoded absolute paths.
6
+
7
+ Token precedence (highest → lowest):
8
+ 1. Environment variable (e.g. ``ARKAOS_VAULT_PATH``)
9
+ 2. profile.json field (e.g. ``vaultPath``)
10
+ 3. For ``${GIT_HOST}`` only: hardcoded default ``github.com``
11
+
12
+ If ``~/.arkaos/profile.json`` is missing or unparseable and an
13
+ unconfigured token is requested, ``ProfileMissingError`` is raised
14
+ with a clear remediation message. Unknown tokens pass through
15
+ unchanged so that prompt strings containing ``${SOME_BASH_VAR}`` for
16
+ the agent are preserved.
17
+
18
+ See ``core/specs/SPEC-paths-portability.md`` for the full contract.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ import re
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+
29
+ from core.runtime.user_paths import user_data_root
30
+
31
+ _PROFILE_FILENAME = "profile.json"
32
+ _TOKEN_PATTERN = re.compile(r"\$\{([A-Z_]+)\}")
33
+
34
+ _DEFAULT_PROJECT_ROOTS = ["~/Herd", "~/Work", "~/AIProjects"]
35
+ _DEFAULT_REPOS_ROOT = "~/AIProjects"
36
+ _DEFAULT_GIT_HOST = "github.com"
37
+
38
+ _PROFILE_CACHE: "ProfileV3 | None" = None
39
+
40
+
41
+ class ProfileMissingError(RuntimeError):
42
+ """Raised when ``~/.arkaos/profile.json`` cannot be loaded."""
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class ProfileV3:
47
+ """In-memory representation of profile.json v3."""
48
+
49
+ version: str
50
+ vault_path: str
51
+ repos_root: str
52
+ project_roots: list[str] = field(default_factory=list)
53
+ raw: dict = field(default_factory=dict)
54
+
55
+
56
+ def profile_path() -> Path:
57
+ """Absolute path to ``~/.arkaos/profile.json``."""
58
+ return user_data_root() / _PROFILE_FILENAME
59
+
60
+
61
+ def load_profile(*, refresh: bool = False) -> ProfileV3:
62
+ """Load and validate profile.json.
63
+
64
+ Cached per process; pass ``refresh=True`` to force a re-read.
65
+
66
+ Raises:
67
+ ProfileMissingError: file absent, unreadable, or unparseable JSON.
68
+ """
69
+ global _PROFILE_CACHE
70
+ if _PROFILE_CACHE is not None and not refresh:
71
+ return _PROFILE_CACHE
72
+
73
+ path = profile_path()
74
+ if not path.exists():
75
+ raise ProfileMissingError(
76
+ f"~/.arkaos/profile.json not found at {path}. "
77
+ "Run /arka setup to configure ArkaOS paths."
78
+ )
79
+ try:
80
+ raw = json.loads(path.read_text(encoding="utf-8"))
81
+ except (OSError, json.JSONDecodeError) as exc:
82
+ raise ProfileMissingError(
83
+ f"~/.arkaos/profile.json could not be parsed ({exc}). "
84
+ "Run /arka setup to repair, or restore from a .bak file."
85
+ ) from exc
86
+
87
+ _PROFILE_CACHE = _project_from_raw(raw)
88
+ return _PROFILE_CACHE
89
+
90
+
91
+ def reset_cache() -> None:
92
+ """Drop the cached profile (for tests)."""
93
+ global _PROFILE_CACHE
94
+ _PROFILE_CACHE = None
95
+
96
+
97
+ def resolve(template: str) -> str:
98
+ """Substitute known ``${VAR}`` tokens in *template*.
99
+
100
+ Tokens recognised:
101
+ - ``${VAULT_PATH}`` → ``ARKAOS_VAULT_PATH`` env or ``profile.vaultPath``
102
+ - ``${ARKA_OS_REPOS}`` → ``ARKAOS_REPOS_ROOT`` env or ``profile.reposRoot``
103
+ - ``${PROJECT_ROOTS}`` → ``os.pathsep``-joined profile.projectRoots
104
+ - ``${GIT_HOST}`` → ``ARKAOS_GIT_HOST`` env, default ``github.com``
105
+ - ``${HOME}`` → ``os.path.expanduser("~")``
106
+
107
+ Unknown tokens pass through unchanged.
108
+ """
109
+ return _TOKEN_PATTERN.sub(_resolve_token_match, template)
110
+
111
+
112
+ def resolve_dict(obj):
113
+ """Recursively resolve every string value in nested dicts/lists."""
114
+ if isinstance(obj, str):
115
+ return resolve(obj)
116
+ if isinstance(obj, dict):
117
+ return {k: resolve_dict(v) for k, v in obj.items()}
118
+ if isinstance(obj, list):
119
+ return [resolve_dict(item) for item in obj]
120
+ return obj
121
+
122
+
123
+ def project_root_regex() -> re.Pattern[str]:
124
+ """Compile a regex matching paths under any configured project root.
125
+
126
+ Used by ``core/cognition/capture/collector.py`` to detect project paths
127
+ inside captured session digests across macOS, Linux and Windows.
128
+ """
129
+ profile = load_profile()
130
+ roots = [os.path.expanduser(r) for r in profile.project_roots] or [
131
+ os.path.expanduser(r) for r in _DEFAULT_PROJECT_ROOTS
132
+ ]
133
+ alternation = "|".join(re.escape(r.rstrip("/").rstrip("\\")) for r in roots)
134
+ return re.compile(rf"({alternation})[/\\]([^\s/\\]+)")
135
+
136
+
137
+ def _resolve_token_match(match: re.Match[str]) -> str:
138
+ return _resolve_token(match.group(1), match.group(0))
139
+
140
+
141
+ def _resolve_token(name: str, original: str) -> str:
142
+ if name == "HOME":
143
+ return os.path.expanduser("~")
144
+ if name == "GIT_HOST":
145
+ return _env_or("ARKAOS_GIT_HOST", _DEFAULT_GIT_HOST)
146
+ if name == "VAULT_PATH":
147
+ env_val = _env_or("ARKAOS_VAULT_PATH", _env_or("ARKAOS_VAULT", ""))
148
+ return env_val or load_profile().vault_path
149
+ if name == "ARKA_OS_REPOS":
150
+ env_val = _env_or("ARKAOS_REPOS_ROOT", "")
151
+ return env_val or load_profile().repos_root
152
+ if name == "PROJECT_ROOTS":
153
+ env_val = _env_or("ARKAOS_PROJECT_ROOTS", "")
154
+ if env_val:
155
+ return env_val
156
+ return os.pathsep.join(load_profile().project_roots)
157
+ return original
158
+
159
+
160
+ def _env_or(name: str, fallback: str) -> str:
161
+ value = os.environ.get(name, "")
162
+ return value if value else fallback
163
+
164
+
165
+ def _project_from_raw(raw: dict) -> ProfileV3:
166
+ vault_path = raw.get("vaultPath") or raw.get("vault_path") or ""
167
+ if not vault_path:
168
+ raise ProfileMissingError(
169
+ "profile.json has no vaultPath. Run /arka setup to configure it."
170
+ )
171
+ repos_root = (
172
+ raw.get("reposRoot")
173
+ or raw.get("repos_root")
174
+ or _DEFAULT_REPOS_ROOT
175
+ )
176
+ project_roots = list(raw.get("projectRoots") or [])
177
+ if not project_roots:
178
+ project_roots = _derive_project_roots(raw.get("projectsDir", ""))
179
+ return ProfileV3(
180
+ version=str(raw.get("version", "2")),
181
+ vault_path=vault_path,
182
+ repos_root=repos_root,
183
+ project_roots=project_roots,
184
+ raw=raw,
185
+ )
186
+
187
+
188
+ def _derive_project_roots(projects_dir_text: str) -> list[str]:
189
+ """Best-effort parse of the legacy free-text ``projectsDir`` field.
190
+
191
+ Looks for absolute paths on macOS/Linux/Windows. Falls back to
192
+ ``_DEFAULT_PROJECT_ROOTS`` when no path can be extracted. This keeps
193
+ the ~20K legacy users functional until ``npx arkaos update`` rewrites
194
+ their profile.json with the new ``projectRoots`` field.
195
+ """
196
+ if not projects_dir_text:
197
+ return list(_DEFAULT_PROJECT_ROOTS)
198
+ posix = r"(?:/Users|/home)/\S+?/(?:Herd|Work|AIProjects|code|repos)"
199
+ windows = r"[A-Z]:\\Users\\[^\s\\]+\\(?:Herd|Work|AIProjects|code|repos)"
200
+ pattern = re.compile(rf"({posix}|{windows})")
201
+ found = pattern.findall(projects_dir_text)
202
+ return found or list(_DEFAULT_PROJECT_ROOTS)
@@ -0,0 +1,6 @@
1
+ """Cross-cutting primitives shared by multiple ArkaOS core packages.
2
+
3
+ Keep this package lean — only primitives that two or more sibling
4
+ packages already duplicate belong here. It is NOT a dumping ground for
5
+ utilities; each addition must delete a duplicate elsewhere.
6
+ """
@@ -0,0 +1,41 @@
1
+ """Shared session-id allowlist — path-traversal / injection guard.
2
+
3
+ A session id is considered safe iff it matches ``[A-Za-z0-9._-]{1,128}``.
4
+ Any other character (``/``, ``\\``, whitespace, control char, unicode,
5
+ NUL, ``..``) rejects — callers MUST treat ``None`` as "do not use this
6
+ id for any filesystem or shell path".
7
+
8
+ Why this lives here: the exact same regex + helper was duplicated in 6
9
+ modules (flow_enforcer, marker_cache, research_gate, kb_cache,
10
+ auto_documentor, auto_doc_worker). A single source of truth prevents
11
+ drift — if the allowlist ever tightens, it tightens everywhere.
12
+
13
+ Historic aliases remain at each call site as module-level re-exports
14
+ so external importers that did ``from core.workflow.flow_enforcer
15
+ import SAFE_SESSION_ID_RE`` continue to work.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+
22
+
23
+ SAFE_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
24
+
25
+
26
+ def safe_session_id(session_id: str) -> str | None:
27
+ """Validate ``session_id`` against the strict allowlist.
28
+
29
+ Returns the id unchanged when safe, or ``None`` when it contains
30
+ path separators, ``..`` traversal fragments, whitespace, unicode,
31
+ NUL bytes, or any character outside ``[A-Za-z0-9._-]``. Length is
32
+ capped at 128 characters to prevent pathological filesystem paths.
33
+
34
+ Callers MUST treat ``None`` as reject — never construct a path or
35
+ shell argument from the raw input when this returns ``None``.
36
+ """
37
+ if not session_id or not isinstance(session_id, str):
38
+ return None
39
+ if not SAFE_SESSION_ID_RE.match(session_id):
40
+ return None
41
+ return session_id