arkaos 4.3.6 → 4.5.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 (110) hide show
  1. package/VERSION +1 -1
  2. package/bin/arka-py +22 -7
  3. package/config/hooks/_lib/arka_python.ps1 +31 -0
  4. package/config/hooks/_lib/arka_python.sh +38 -0
  5. package/config/hooks/post-tool-use.ps1 +8 -0
  6. package/config/hooks/post-tool-use.sh +11 -5
  7. package/config/hooks/pre-tool-use.ps1 +11 -6
  8. package/config/hooks/pre-tool-use.sh +11 -6
  9. package/config/hooks/session-start.ps1 +10 -0
  10. package/config/hooks/session-start.sh +5 -0
  11. package/config/hooks/stop.ps1 +9 -1
  12. package/config/hooks/stop.sh +11 -5
  13. package/config/hooks/user-prompt-submit.ps1 +18 -0
  14. package/config/hooks/user-prompt-submit.sh +11 -5
  15. package/config/mcp-policy.yaml +8 -8
  16. package/core/agents/__pycache__/__init__.cpython-312.pyc +0 -0
  17. package/core/agents/__pycache__/loader.cpython-312.pyc +0 -0
  18. package/core/agents/__pycache__/registry_gen.cpython-313.pyc +0 -0
  19. package/core/agents/__pycache__/schema.cpython-312.pyc +0 -0
  20. package/core/agents/registry_gen.py +3 -2
  21. package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
  22. package/core/cognition/scheduler/daemon.py +7 -3
  23. package/core/forge/__pycache__/orchestrator.cpython-313.pyc +0 -0
  24. package/core/forge/orchestrator.py +2 -1
  25. package/core/hooks/__pycache__/_shared.cpython-313.pyc +0 -0
  26. package/core/hooks/__pycache__/_shared.cpython-314.pyc +0 -0
  27. package/core/hooks/__pycache__/stop.cpython-313.pyc +0 -0
  28. package/core/hooks/__pycache__/stop.cpython-314.pyc +0 -0
  29. package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  30. package/core/hooks/_shared.py +29 -9
  31. package/core/hooks/stop.py +4 -3
  32. package/core/hooks/user_prompt_submit.py +5 -4
  33. package/core/knowledge/__pycache__/indexer.cpython-313.pyc +0 -0
  34. package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
  35. package/core/knowledge/indexer.py +1 -1
  36. package/core/knowledge/vector_store.py +10 -3
  37. package/core/runtime/__pycache__/model_routing_check.cpython-313.pyc +0 -0
  38. package/core/runtime/__pycache__/model_routing_context.cpython-313.pyc +0 -0
  39. package/core/runtime/__pycache__/native_usage.cpython-313.pyc +0 -0
  40. package/core/runtime/__pycache__/native_usage.cpython-314.pyc +0 -0
  41. package/core/runtime/gateway/__pycache__/__init__.cpython-313.pyc +0 -0
  42. package/core/runtime/gateway/__pycache__/__main__.cpython-313.pyc +0 -0
  43. package/core/runtime/gateway/__pycache__/litellm_config.cpython-313.pyc +0 -0
  44. package/core/runtime/native_usage.py +2 -1
  45. package/core/shared/__pycache__/temp_paths.cpython-313.pyc +0 -0
  46. package/core/shared/__pycache__/temp_paths.cpython-314.pyc +0 -0
  47. package/core/shared/temp_paths.py +40 -0
  48. package/core/synapse/__pycache__/kb_cache.cpython-313.pyc +0 -0
  49. package/core/synapse/__pycache__/kb_cache.cpython-314.pyc +0 -0
  50. package/core/synapse/kb_cache.py +3 -2
  51. package/core/sync/__pycache__/agent_provisioner.cpython-313.pyc +0 -0
  52. package/core/sync/__pycache__/ai_mcp_decider.cpython-313.pyc +0 -0
  53. package/core/sync/__pycache__/content_syncer.cpython-313.pyc +0 -0
  54. package/core/sync/__pycache__/descriptor_syncer.cpython-313.pyc +0 -0
  55. package/core/sync/__pycache__/discovery.cpython-313.pyc +0 -0
  56. package/core/sync/__pycache__/engine.cpython-313.pyc +0 -0
  57. package/core/sync/__pycache__/manifest.cpython-313.pyc +0 -0
  58. package/core/sync/__pycache__/mcp_optimizer.cpython-313.pyc +0 -0
  59. package/core/sync/__pycache__/mcp_syncer.cpython-313.pyc +0 -0
  60. package/core/sync/__pycache__/policy_loader.cpython-313.pyc +0 -0
  61. package/core/sync/__pycache__/reporter.cpython-313.pyc +0 -0
  62. package/core/sync/__pycache__/settings_syncer.cpython-313.pyc +0 -0
  63. package/core/sync/agent_provisioner.py +5 -5
  64. package/core/sync/ai_mcp_decider.py +2 -2
  65. package/core/sync/content_syncer.py +14 -14
  66. package/core/sync/descriptor_syncer.py +2 -2
  67. package/core/sync/discovery.py +4 -4
  68. package/core/sync/engine.py +4 -4
  69. package/core/sync/manifest.py +1 -1
  70. package/core/sync/mcp_optimizer.py +5 -5
  71. package/core/sync/mcp_syncer.py +7 -3
  72. package/core/sync/policy_loader.py +1 -1
  73. package/core/sync/reporter.py +1 -1
  74. package/core/sync/settings_syncer.py +2 -2
  75. package/core/terminal/__pycache__/session.cpython-313.pyc +0 -0
  76. package/core/terminal/__pycache__/session_windows.cpython-313.pyc +0 -0
  77. package/core/terminal/session.py +66 -11
  78. package/core/terminal/session_windows.py +211 -0
  79. package/core/workflow/__pycache__/flow_authorization.cpython-313.pyc +0 -0
  80. package/core/workflow/__pycache__/flow_authorization.cpython-314.pyc +0 -0
  81. package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
  82. package/core/workflow/__pycache__/flow_enforcer.cpython-314.pyc +0 -0
  83. package/core/workflow/__pycache__/marker_cache.cpython-313.pyc +0 -0
  84. package/core/workflow/__pycache__/marker_cache.cpython-314.pyc +0 -0
  85. package/core/workflow/__pycache__/research_gate.cpython-313.pyc +0 -0
  86. package/core/workflow/__pycache__/research_gate.cpython-314.pyc +0 -0
  87. package/core/workflow/flow_authorization.py +2 -1
  88. package/core/workflow/flow_enforcer.py +2 -1
  89. package/core/workflow/marker_cache.py +2 -1
  90. package/core/workflow/research_gate.py +2 -1
  91. package/dashboard/app/pages/index.vue +6 -1
  92. package/departments/dev/skills/mcp/SKILL.md +3 -1
  93. package/departments/ops/skills/update/references/sync-engine.md +2 -1
  94. package/departments/quality/agents/copy-director.yaml +2 -0
  95. package/departments/quality/agents/tech-director.yaml +2 -0
  96. package/installer/cli.js +5 -4
  97. package/installer/core-snapshot.js +53 -0
  98. package/installer/doctor.js +8 -3
  99. package/installer/index.js +29 -6
  100. package/installer/migrate.js +7 -4
  101. package/installer/update.js +13 -0
  102. package/knowledge/agents-registry-v2.json +487 -120
  103. package/package.json +1 -1
  104. package/pyproject.toml +2 -1
  105. package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
  106. package/scripts/__pycache__/synapse-bridge.cpython-313.pyc +0 -0
  107. package/scripts/dashboard-api.py +69 -33
  108. package/scripts/knowledge-index.py +35 -2
  109. package/scripts/start-dashboard.ps1 +34 -14
  110. package/scripts/synapse-bridge.py +3 -1
@@ -36,6 +36,7 @@ import subprocess
36
36
  import time
37
37
  from pathlib import Path
38
38
 
39
+ from core.shared.temp_paths import arkaos_temp_dir
39
40
  from core.hooks._shared import (
40
41
  ensure_root_on_path,
41
42
  get_str,
@@ -45,7 +46,7 @@ from core.hooks._shared import (
45
46
  safe_session_id,
46
47
  )
47
48
 
48
- _CACHE_DIR = Path("/tmp/arkaos-context-cache")
49
+ _CACHE_DIR = arkaos_temp_dir("arkaos-context-cache")
49
50
  _CACHE_TTL = 300 # Constitution cache: 5 minutes
50
51
 
51
52
  _L0_FALLBACK = (
@@ -282,7 +283,7 @@ def _kb_auto_inject(root: str, user_input: str) -> str:
282
283
  project_hash = hashlib.md5(
283
284
  root.encode(), usedforsecurity=False
284
285
  ).hexdigest()[:12]
285
- cache_dir = Path("/tmp") / f"arkaos-kb-{project_hash}"
286
+ cache_dir = arkaos_temp_dir(f"arkaos-kb-{project_hash}")
286
287
  if not cache_dir.is_dir():
287
288
  return ""
288
289
  try:
@@ -439,7 +440,7 @@ def _wf_mark_required(session_id: str) -> None:
439
440
  if safe_session_id(session_id) is None:
440
441
  return
441
442
  marker_dir = Path(
442
- os.environ.get("ARKA_WF_REQUIRED_DIR", "/tmp/arkaos-wf-required")
443
+ os.environ.get("ARKA_WF_REQUIRED_DIR", str(arkaos_temp_dir("arkaos-wf-required")))
443
444
  )
444
445
  try:
445
446
  marker_dir.mkdir(parents=True, exist_ok=True)
@@ -469,7 +470,7 @@ def _cognitive_hits(session_id: str) -> str:
469
470
 
470
471
  def _one_shot_nudge(subdir: str, session_id: str) -> str:
471
472
  """Read + delete a /tmp/<subdir>/<session>.json nudge state file."""
472
- nudge_file = Path("/tmp") / subdir / f"{session_id}.json"
473
+ nudge_file = arkaos_temp_dir(subdir) / f"{session_id}.json"
473
474
  if not nudge_file.is_file():
474
475
  return ""
475
476
  nudge = ""
@@ -56,7 +56,7 @@ def index_directory(
56
56
 
57
57
  fhash = file_hash(filepath)
58
58
 
59
- if skip_indexed and store.is_file_indexed(fhash):
59
+ if skip_indexed and store.is_file_indexed(str(filepath), fhash):
60
60
  skipped += 1
61
61
  continue
62
62
 
@@ -347,10 +347,17 @@ class VectorStore:
347
347
  for r in rows
348
348
  ]
349
349
 
350
- def is_file_indexed(self, file_hash: str) -> bool:
351
- """Check if a file has already been indexed."""
350
+ def is_file_indexed(self, source: str, file_hash: str) -> bool:
351
+ """Check if a file (identified by source path + content hash) is indexed.
352
+
353
+ The source path is part of the key so identical content in two
354
+ different locations (e.g. two npx cache directories) each gets
355
+ indexed — a hash-only check would skip every subsequent cache
356
+ and leave the new location unsearchable.
357
+ """
352
358
  row = self._db.execute(
353
- "SELECT COUNT(*) as cnt FROM chunks WHERE file_hash = ?", (file_hash,)
359
+ "SELECT COUNT(*) as cnt FROM chunks WHERE source = ? AND file_hash = ?",
360
+ (source, file_hash),
354
361
  ).fetchone()
355
362
  return row["cnt"] > 0
356
363
 
@@ -33,9 +33,10 @@ from typing import Any
33
33
  from core.runtime.llm_cost_telemetry import record_cost
34
34
  from core.runtime.pricing import estimate_cost_usd
35
35
  from core.shared.safe_session_id import safe_session_id
36
+ from core.shared.temp_paths import arkaos_temp_dir
36
37
 
37
38
 
38
- DEFAULT_CURSOR_DIR = Path("/tmp/arkaos-native-cost")
39
+ DEFAULT_CURSOR_DIR = arkaos_temp_dir("arkaos-native-cost")
39
40
 
40
41
  _USAGE_KEYS = (
41
42
  "input_tokens",
@@ -0,0 +1,40 @@
1
+ """Cross-platform base directory for ArkaOS inter-process coordination files.
2
+
3
+ Several core modules and the bash hooks coordinate through small marker
4
+ and cache files: workflow-required markers (``stop.sh`` ->
5
+ ``flow_enforcer``), the turn-scoped KB query cache
6
+ (``user-prompt-submit.sh`` -> ``kb_cache``), hook metrics
7
+ (``user-prompt-submit.sh`` -> ``dashboard-api``). The hooks write them
8
+ under ``/tmp/arkaos-*``; the Python side MUST resolve to the *same*
9
+ directory or the coordination silently breaks.
10
+
11
+ Why this lives in ``core.shared``: every module that coordinates through
12
+ these files (flow_enforcer, marker_cache, research_gate, kb_cache,
13
+ forge.orchestrator, the consolidated hooks, native_usage, the synapse
14
+ bridge, the dashboard API, …) used to hardcode the literal string
15
+ ``/tmp``. ``/tmp`` does not exist on Windows, so every one of those
16
+ features failed there. A single helper keeps the resolution in one place.
17
+
18
+ POSIX keeps the literal ``/tmp`` the bash hooks use (avoiding a
19
+ ``$TMPDIR`` divergence on macOS where ``tempfile.gettempdir()`` returns
20
+ ``/var/folders/...`` but the hooks still write ``/tmp``). On Windows
21
+ there is no ``/tmp``: Git-for-Windows maps a hook's ``/tmp`` to
22
+ ``%TEMP%``, which is exactly what ``tempfile.gettempdir()`` returns, so
23
+ both sides still meet.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import os
29
+ import tempfile
30
+ from pathlib import Path
31
+
32
+
33
+ def arkaos_temp_dir(*parts: str) -> Path:
34
+ """Return ``<coordination base>/<parts...>`` (the path is not created).
35
+
36
+ POSIX -> ``/tmp/<parts>`` (matches the bash hooks verbatim).
37
+ Windows -> ``<%TEMP%>/<parts>`` (matches Git-for-Windows ``/tmp``).
38
+ """
39
+ base = Path(tempfile.gettempdir()) if os.name == "nt" else Path("/tmp")
40
+ return base.joinpath(*parts)
@@ -30,11 +30,12 @@ from pathlib import Path
30
30
  from typing import Any, Optional
31
31
 
32
32
  from core.shared import safe_session_id as _safe_session_id_module
33
+ from core.shared.temp_paths import arkaos_temp_dir
33
34
 
34
35
 
35
36
  # Re-export for backward compatibility with any external importers.
36
37
  SAFE_SESSION_ID_RE = _safe_session_id_module.SAFE_SESSION_ID_RE
37
- KB_QUERY_MARKER_DIR = Path("/tmp/arkaos-kb-query")
38
+ KB_QUERY_MARKER_DIR = arkaos_temp_dir("arkaos-kb-query")
38
39
  _MAX_QUERIES_PER_TURN = 32
39
40
  _MAX_QUERY_LEN = 512
40
41
 
@@ -159,7 +160,7 @@ class KBSessionCache:
159
160
  self._cache_dir = Path(cache_dir)
160
161
  else:
161
162
  project_hash = self._hash_project(project_path or "")
162
- self._cache_dir = Path("/tmp") / f"arkaos-kb-{project_hash}"
163
+ self._cache_dir = arkaos_temp_dir(f"arkaos-kb-{project_hash}")
163
164
 
164
165
  self._cache_file = self._cache_dir / f"{session_id}.json"
165
166
  self._ensure_dir()
@@ -55,7 +55,7 @@ def _extend_from_file(path: Path, agents: set[str]) -> None:
55
55
  if not path.exists():
56
56
  return
57
57
  try:
58
- data = yaml.safe_load(path.read_text()) or {}
58
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
59
59
  except yaml.YAMLError:
60
60
  return
61
61
  for name in data.get("baseline", []) or []:
@@ -95,10 +95,10 @@ def _apply_allowlist(
95
95
  continue
96
96
 
97
97
  target = agents_dir / f"{name}.md"
98
- if target.exists() and target.read_text() == rendered:
98
+ if target.exists() and target.read_text(encoding="utf-8") == rendered:
99
99
  unchanged.append(name)
100
100
  continue
101
- target.write_text(rendered)
101
+ target.write_text(rendered, encoding="utf-8")
102
102
  added.append(name)
103
103
 
104
104
  return added, unchanged, errored
@@ -124,10 +124,10 @@ def _render_agent(core: Path, name: str) -> str | None:
124
124
  parts: list[str] = []
125
125
  if yaml_path is not None:
126
126
  parts.append("---")
127
- parts.append(yaml_path.read_text().strip())
127
+ parts.append(yaml_path.read_text(encoding="utf-8").strip())
128
128
  parts.append("---")
129
129
  if md_path is not None:
130
- parts.append(md_path.read_text().rstrip())
130
+ parts.append(md_path.read_text(encoding="utf-8").rstrip())
131
131
 
132
132
  return "\n".join(parts) + "\n"
133
133
 
@@ -76,11 +76,11 @@ def _load_cache(path: Path) -> dict[str, str]:
76
76
  if not path.exists():
77
77
  return {}
78
78
  try:
79
- return json.loads(path.read_text())
79
+ return json.loads(path.read_text(encoding="utf-8"))
80
80
  except (json.JSONDecodeError, OSError):
81
81
  return {}
82
82
 
83
83
 
84
84
  def _save_cache(path: Path, data: dict[str, str]) -> None:
85
85
  path.parent.mkdir(parents=True, exist_ok=True)
86
- path.write_text(json.dumps(data, indent=2, sort_keys=True))
86
+ path.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8")
@@ -36,7 +36,7 @@ def sync_project_content(project: Project) -> ContentSyncResult:
36
36
 
37
37
  def _do_sync(project: Project) -> ContentSyncResult:
38
38
  core = _core_root()
39
- version = (core / "VERSION").read_text().strip()
39
+ version = (core / "VERSION").read_text(encoding="utf-8").strip()
40
40
  project_claude = Path(project.path) / ".claude"
41
41
  project_claude.mkdir(parents=True, exist_ok=True)
42
42
 
@@ -73,28 +73,28 @@ def _sync_claude_md(
73
73
  unchanged: list[str],
74
74
  errored: list[str],
75
75
  ) -> None:
76
- base = (core / "config" / "user-claude.md").read_text()
76
+ base = (core / "config" / "user-claude.md").read_text(encoding="utf-8")
77
77
  overlays_dir = core / "config" / "standards" / "claude-md-overlays"
78
78
  overlays: list[str] = []
79
79
  for stack in project.stack:
80
80
  overlay = overlays_dir / f"{stack}.md"
81
81
  if overlay.exists():
82
- overlays.append(overlay.read_text())
82
+ overlays.append(overlay.read_text(encoding="utf-8"))
83
83
 
84
84
  managed_content = "\n\n".join([base, *overlays]).strip()
85
85
  target_file = project_claude / "CLAUDE.md"
86
- target_text = target_file.read_text() if target_file.exists() else ""
86
+ target_text = target_file.read_text(encoding="utf-8") if target_file.exists() else ""
87
87
 
88
88
  result = merge_managed_content(target_text, managed_content, version)
89
89
  if result.status == "error":
90
90
  errored.append(f"CLAUDE.md: {result.error}")
91
91
  sidecar = target_file.with_suffix(".md.arkaos-new")
92
- sidecar.write_text(managed_content)
92
+ sidecar.write_text(managed_content, encoding="utf-8")
93
93
  return
94
94
  if result.status == "unchanged":
95
95
  unchanged.append("CLAUDE.md")
96
96
  return
97
- target_file.write_text(result.new_text)
97
+ target_file.write_text(result.new_text, encoding="utf-8")
98
98
  updated.append("CLAUDE.md")
99
99
 
100
100
 
@@ -111,11 +111,11 @@ def _sync_rules(
111
111
  dst.mkdir(parents=True, exist_ok=True)
112
112
  for rule in src.glob("*.md"):
113
113
  target = dst / rule.name
114
- src_text = rule.read_text()
115
- if target.exists() and target.read_text() == src_text:
114
+ src_text = rule.read_text(encoding="utf-8")
115
+ if target.exists() and target.read_text(encoding="utf-8") == src_text:
116
116
  unchanged.append(f"rules/{rule.name}")
117
117
  continue
118
- target.write_text(src_text)
118
+ target.write_text(src_text, encoding="utf-8")
119
119
  updated.append(f"rules/{rule.name}")
120
120
 
121
121
 
@@ -131,8 +131,8 @@ def _sync_hooks(
131
131
  dst.mkdir(parents=True, exist_ok=True)
132
132
  for hook in src.glob("*.sh"):
133
133
  target = dst / hook.name
134
- src_text = hook.read_text()
135
- if target.exists() and target.read_text() == src_text:
134
+ src_text = hook.read_text(encoding="utf-8")
135
+ if target.exists() and target.read_text(encoding="utf-8") == src_text:
136
136
  unchanged.append(f"hooks/{hook.name}")
137
137
  continue
138
138
  shutil.copy2(hook, target)
@@ -149,16 +149,16 @@ def _sync_constitution(
149
149
  ) -> None:
150
150
  src = core / "config" / "constitution.yaml"
151
151
  target = project_claude / "constitution-applicable.md"
152
- data = yaml.safe_load(src.read_text()) or {}
152
+ data = yaml.safe_load(src.read_text(encoding="utf-8")) or {}
153
153
  rules = data.get("rules", [])
154
154
  lines = ["# ArkaOS Constitution — Applicable Rules", ""]
155
155
  for rule in rules:
156
156
  lines.append(f"- **{rule.get('name', '?')}** — {rule.get('level', '?')}")
157
157
  body = "\n".join(lines) + "\n"
158
- if target.exists() and target.read_text() == body:
158
+ if target.exists() and target.read_text(encoding="utf-8") == body:
159
159
  unchanged.append("constitution-applicable.md")
160
160
  return
161
- target.write_text(body)
161
+ target.write_text(body, encoding="utf-8")
162
162
  updated.append("constitution-applicable.md")
163
163
 
164
164
 
@@ -47,7 +47,7 @@ def sync_descriptor(project: Project) -> DescriptorSyncResult:
47
47
  def _do_sync(project: Project) -> DescriptorSyncResult:
48
48
  """Execute the descriptor sync logic for a single project."""
49
49
  desc_path = Path(project.descriptor_path) # type: ignore[arg-type]
50
- text = desc_path.read_text()
50
+ text = desc_path.read_text(encoding="utf-8")
51
51
  frontmatter, body = _split_frontmatter(text)
52
52
  changes: list[str] = []
53
53
 
@@ -193,4 +193,4 @@ def _get_last_commit_days(project_path: str) -> int | None:
193
193
  def _write_descriptor(desc_path: Path, frontmatter: dict, body: str) -> None:
194
194
  """Write updated frontmatter and preserved body back to the descriptor file."""
195
195
  fm_text = yaml.dump(frontmatter, default_flow_style=False, allow_unicode=True)
196
- desc_path.write_text(f"---\n{fm_text}---{body}")
196
+ desc_path.write_text(f"---\n{fm_text}---{body}", encoding="utf-8")
@@ -21,7 +21,7 @@ def _detect_from_composer(project_path: Path) -> list[str]:
21
21
  if not composer.exists():
22
22
  return []
23
23
  try:
24
- data = json.loads(composer.read_text())
24
+ data = json.loads(composer.read_text(encoding="utf-8"))
25
25
  require = data.get("require", {})
26
26
  if "laravel/framework" in require:
27
27
  return ["php", "laravel"]
@@ -35,7 +35,7 @@ def _detect_from_package_json(project_path: Path) -> list[str]:
35
35
  if not pkg.exists():
36
36
  return []
37
37
  try:
38
- data = json.loads(pkg.read_text())
38
+ data = json.loads(pkg.read_text(encoding="utf-8"))
39
39
  deps = {
40
40
  **data.get("dependencies", {}),
41
41
  **data.get("devDependencies", {}),
@@ -100,7 +100,7 @@ def _parse_descriptor_frontmatter(text: str) -> dict:
100
100
  def _read_descriptor_item(item: Path) -> dict:
101
101
  """Read a descriptor file and return its frontmatter as a dict."""
102
102
  try:
103
- return _parse_descriptor_frontmatter(item.read_text())
103
+ return _parse_descriptor_frontmatter(item.read_text(encoding="utf-8"))
104
104
  except OSError:
105
105
  return {}
106
106
 
@@ -187,7 +187,7 @@ def discover_from_ecosystems(ecosystems_file: Path) -> list[Project]:
187
187
  if not ecosystems_file.exists():
188
188
  return []
189
189
  try:
190
- data = json.loads(ecosystems_file.read_text())
190
+ data = json.loads(ecosystems_file.read_text(encoding="utf-8"))
191
191
  except (json.JSONDecodeError, OSError):
192
192
  return []
193
193
 
@@ -117,7 +117,7 @@ def _read_previous_version(arkaos_home: Path) -> str:
117
117
  if not state_file.exists():
118
118
  return "pending-sync"
119
119
  try:
120
- data = json.loads(state_file.read_text())
120
+ data = json.loads(state_file.read_text(encoding="utf-8"))
121
121
  return data.get("version", "pending-sync") or "pending-sync"
122
122
  except (json.JSONDecodeError, OSError):
123
123
  return "pending-sync"
@@ -132,7 +132,7 @@ def _read_current_version(arkaos_home: Path) -> str:
132
132
  if not version_file.exists():
133
133
  return "unknown"
134
134
  try:
135
- return version_file.read_text().strip()
135
+ return version_file.read_text(encoding="utf-8").strip()
136
136
  except OSError:
137
137
  return "unknown"
138
138
 
@@ -143,7 +143,7 @@ def _read_repo_path(arkaos_home: Path) -> Path | None:
143
143
  if not repo_path_file.exists():
144
144
  return None
145
145
  try:
146
- raw = repo_path_file.read_text().strip()
146
+ raw = repo_path_file.read_text(encoding="utf-8").strip()
147
147
  return Path(raw) if raw else None
148
148
  except OSError:
149
149
  return None
@@ -204,7 +204,7 @@ def _load_scan_dirs_from_profile(arkaos_home: Path) -> list[Path]:
204
204
  if not profile_file.exists():
205
205
  return []
206
206
  try:
207
- data = json.loads(profile_file.read_text())
207
+ data = json.loads(profile_file.read_text(encoding="utf-8"))
208
208
  projects_dir_str = data.get("projectsDir", "")
209
209
  if not projects_dir_str:
210
210
  return []
@@ -18,7 +18,7 @@ def load_features(features_dir: Path) -> list[FeatureSpec]:
18
18
  for path in sorted(features_dir.iterdir()):
19
19
  if path.suffix != ".yaml":
20
20
  continue
21
- data = yaml.safe_load(path.read_text())
21
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
22
22
  features.append(FeatureSpec(**data))
23
23
 
24
24
  return features
@@ -92,7 +92,7 @@ def _load_override(project_path: Path) -> tuple[dict, list[str]]:
92
92
  if not override.exists():
93
93
  return {}, []
94
94
  try:
95
- return yaml.safe_load(override.read_text()) or {}, []
95
+ return yaml.safe_load(override.read_text(encoding="utf-8")) or {}, []
96
96
  except (yaml.YAMLError, OSError) as exc:
97
97
  return {}, [f"override YAML parse error: {exc}"]
98
98
 
@@ -123,7 +123,7 @@ def _inject_env_vars(project_path: Path, vault_path: Path | None, project_name:
123
123
  return []
124
124
 
125
125
  try:
126
- data = json.loads(mcp_file.read_text())
126
+ data = json.loads(mcp_file.read_text(encoding="utf-8"))
127
127
  except (json.JSONDecodeError, OSError):
128
128
  return [".mcp.json malformed; skipped env injection"]
129
129
 
@@ -137,7 +137,7 @@ def _inject_env_vars(project_path: Path, vault_path: Path | None, project_name:
137
137
  changed, missing = _merge_env(servers, merged_env)
138
138
 
139
139
  if changed:
140
- mcp_file.write_text(json.dumps(data, indent=2) + "\n")
140
+ mcp_file.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
141
141
 
142
142
  if missing:
143
143
  _write_env_example(project_path, missing)
@@ -157,7 +157,7 @@ def _load_vault(path: Path) -> tuple[dict, list[str]]:
157
157
  if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
158
158
  return {}, ["vault permissions too permissive (group/world readable); secrets not injected"]
159
159
  try:
160
- return json.loads(path.read_text()), []
160
+ return json.loads(path.read_text(encoding="utf-8")), []
161
161
  except (json.JSONDecodeError, OSError):
162
162
  return {}, ["vault JSON parse error; secrets not injected"]
163
163
 
@@ -167,7 +167,7 @@ def _write_env_example(project_path: Path, missing: dict[str, str]) -> None:
167
167
  for var, server in sorted(missing.items()):
168
168
  lines.append(f"# required by {server}")
169
169
  lines.append(f"{var}=")
170
- (project_path / ".env.arkaos.example").write_text("\n".join(lines) + "\n")
170
+ (project_path / ".env.arkaos.example").write_text("\n".join(lines) + "\n", encoding="utf-8")
171
171
 
172
172
 
173
173
  def optimize_all_mcps(
@@ -39,7 +39,7 @@ def load_registry(registry_path: Path) -> dict:
39
39
  if not registry_path.exists():
40
40
  return {}
41
41
  try:
42
- data = json.loads(registry_path.read_text())
42
+ data = json.loads(registry_path.read_text(encoding="utf-8"))
43
43
  return data.get("mcpServers", {})
44
44
  except (json.JSONDecodeError, OSError):
45
45
  return {}
@@ -60,6 +60,10 @@ def resolve_mcps_for_stack(
60
60
 
61
61
  result: list[tuple[str, dict]] = []
62
62
  for name, config in registry.items():
63
+ if "managed" in config:
64
+ # Runtime-managed servers (extension/plugin) are registered for
65
+ # governance and telemetry — they have no launchable command to write.
66
+ continue
63
67
  if config.get("category") in allowed:
64
68
  result.append((name, config))
65
69
  return result
@@ -142,7 +146,7 @@ def _read_current_mcps(mcp_file: Path) -> dict:
142
146
  if not mcp_file.exists():
143
147
  return {}
144
148
  try:
145
- data = json.loads(mcp_file.read_text())
149
+ data = json.loads(mcp_file.read_text(encoding="utf-8"))
146
150
  return data.get("mcpServers", {})
147
151
  except (json.JSONDecodeError, OSError):
148
152
  return {}
@@ -252,4 +256,4 @@ def _build_merged(
252
256
  def _write_mcp_json(mcp_file: Path, servers: dict) -> None:
253
257
  """Write the mcpServers dict to .mcp.json with 2-space indentation."""
254
258
  payload = {"mcpServers": servers}
255
- mcp_file.write_text(json.dumps(payload, indent=2) + "\n")
259
+ mcp_file.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
@@ -30,7 +30,7 @@ class PolicyDecision:
30
30
 
31
31
  def load_policy(path: Path) -> Policy:
32
32
  """Load and parse an mcp-policy.yaml file."""
33
- data = yaml.safe_load(path.read_text()) or {}
33
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
34
34
  rules = [
35
35
  PolicyRule(
36
36
  match=r.get("match", {}),
@@ -74,7 +74,7 @@ def write_sync_state(state_file: Path, report: SyncReport) -> None:
74
74
  "skills_synced": len(report.skill_results),
75
75
  "errors": report.errors,
76
76
  }
77
- state_file.write_text(json.dumps(state, indent=2))
77
+ state_file.write_text(json.dumps(state, indent=2), encoding="utf-8")
78
78
 
79
79
 
80
80
  def format_report(report: SyncReport) -> str:
@@ -82,7 +82,7 @@ def _read_current_settings(settings_file: Path) -> dict:
82
82
  if not settings_file.exists():
83
83
  return {}
84
84
  try:
85
- return json.loads(settings_file.read_text())
85
+ return json.loads(settings_file.read_text(encoding="utf-8"))
86
86
  except (json.JSONDecodeError, OSError):
87
87
  return {}
88
88
 
@@ -118,4 +118,4 @@ def _build_merged_settings(current: dict, target_servers: list[str]) -> dict:
118
118
  def _write_settings(settings_file: Path, data: dict) -> None:
119
119
  """Create parent dirs if needed and write settings as 2-space JSON."""
120
120
  settings_file.parent.mkdir(parents=True, exist_ok=True)
121
- settings_file.write_text(json.dumps(data, indent=2) + "\n")
121
+ settings_file.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")