arkaos 5.10.0 → 5.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.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 5.10.0
1
+ 5.11.0
@@ -79,17 +79,23 @@ def read_state(home: Path | None = None) -> dict:
79
79
  "autoupdate_on": True,
80
80
  }
81
81
  try:
82
- manifest = json.loads((home / "install-manifest.json").read_text())
82
+ manifest = json.loads(
83
+ (home / "install-manifest.json").read_text(encoding="utf-8")
84
+ )
83
85
  state["version"] = manifest.get("version") or None
84
86
  except Exception:
85
87
  pass
86
88
  try:
87
- sync = json.loads((home / "sync-state.json").read_text())
89
+ sync = json.loads(
90
+ (home / "sync-state.json").read_text(encoding="utf-8")
91
+ )
88
92
  state["sync_pending"] = sync.get("version") == "pending-sync"
89
93
  except Exception:
90
94
  pass
91
95
  try:
92
- profile = json.loads((home / "profile.json").read_text())
96
+ profile = json.loads(
97
+ (home / "profile.json").read_text(encoding="utf-8")
98
+ )
93
99
  value = str(profile.get("installProfile", "essential")).strip().lower()
94
100
  state["profile"] = value if value in VALID_PROFILES else "essential"
95
101
  except Exception:
@@ -154,7 +160,7 @@ def stable_script(name: str) -> Path | None:
154
160
  if lib.exists():
155
161
  return lib
156
162
  try:
157
- repo = Path((home / ".repo-path").read_text().strip())
163
+ repo = Path((home / ".repo-path").read_text(encoding="utf-8").strip())
158
164
  candidate = repo / "scripts" / name
159
165
  if candidate.exists():
160
166
  return candidate
@@ -190,7 +196,8 @@ def action_open_dashboard() -> None:
190
196
  log_line(f"open_dashboard: ensure failed ({err})")
191
197
  ui_port = ""
192
198
  try:
193
- for line in (arka_home() / "dashboard.ports").read_text().splitlines():
199
+ ports = (arka_home() / "dashboard.ports").read_text(encoding="utf-8")
200
+ for line in ports.splitlines():
194
201
  if line.startswith("UI_PORT="):
195
202
  ui_port = line.split("=", 1)[1].strip()
196
203
  except Exception:
@@ -24,7 +24,7 @@ def load_agent(path: str | Path) -> Agent:
24
24
  if not path.exists():
25
25
  raise FileNotFoundError(f"Agent file not found: {path}")
26
26
 
27
- with open(path) as f:
27
+ with open(path, encoding="utf-8") as f:
28
28
  data = yaml.safe_load(f)
29
29
 
30
30
  if data is None:
@@ -126,7 +126,7 @@ def generate_registry(departments_dir: str | Path, output_path: str | Path) -> d
126
126
  registry["_meta"]["errors"] = errors
127
127
 
128
128
  output_path.parent.mkdir(parents=True, exist_ok=True)
129
- with open(output_path, "w") as f:
129
+ with open(output_path, "w", encoding="utf-8") as f:
130
130
  json.dump(registry, f, indent=2, ensure_ascii=False)
131
131
 
132
132
  return registry
@@ -178,13 +178,13 @@ class BudgetManager:
178
178
  "counter": self._counter,
179
179
  "usages": [u.model_dump(mode="json") for u in self._usages],
180
180
  }
181
- with open(self._storage_path, "w") as f:
181
+ with open(self._storage_path, "w", encoding="utf-8") as f:
182
182
  json.dump(data, f, indent=2)
183
183
 
184
184
  def _load(self) -> None:
185
185
  if self._storage_path is None or not self._storage_path.exists():
186
186
  return
187
- content = self._storage_path.read_text().strip()
187
+ content = self._storage_path.read_text(encoding="utf-8").strip()
188
188
  if not content:
189
189
  return
190
190
  data = json.loads(content)
@@ -61,7 +61,7 @@ class ScheduleConfig:
61
61
  @classmethod
62
62
  def load(cls, config_path: str) -> "list[ScheduleConfig]":
63
63
  """Load schedules from YAML, returning only enabled entries."""
64
- with open(config_path) as fh:
64
+ with open(config_path, encoding="utf-8") as fh:
65
65
  data = yaml.safe_load(fh)
66
66
 
67
67
  schedules = []
@@ -106,7 +106,7 @@ class ArkaScheduler:
106
106
  """Acquire an exclusive file lock. Returns False if already locked."""
107
107
  Path(self._lock_path).parent.mkdir(parents=True, exist_ok=True)
108
108
  try:
109
- fd = open(self._lock_path, "w") # noqa: WPS515
109
+ fd = open(self._lock_path, "w", encoding="utf-8") # noqa: WPS515
110
110
  if sys.platform == "win32":
111
111
  import msvcrt # type: ignore[import]
112
112
 
@@ -22,7 +22,7 @@ def save_profile(board: ConclaveBoard, path: str | Path = "") -> None:
22
22
  "contrarian": [a.model_dump(mode="json") for a in board.contrarian],
23
23
  }
24
24
 
25
- with open(path, "w") as f:
25
+ with open(path, "w", encoding="utf-8") as f:
26
26
  json.dump(data, f, indent=2, ensure_ascii=False)
27
27
 
28
28
 
@@ -33,7 +33,7 @@ def load_profile(path: str | Path = "") -> Optional[ConclaveBoard]:
33
33
  if not path.exists():
34
34
  return None
35
35
 
36
- content = path.read_text().strip()
36
+ content = path.read_text(encoding="utf-8").strip()
37
37
  if not content:
38
38
  return None
39
39
 
@@ -474,7 +474,11 @@ class ForgeOrchestrator:
474
474
  repo = Path.cwd().name
475
475
 
476
476
  version_file = Path(__file__).parent.parent.parent / "VERSION"
477
- version = version_file.read_text().strip() if version_file.exists() else "unknown"
477
+ version = (
478
+ version_file.read_text(encoding="utf-8").strip()
479
+ if version_file.exists()
480
+ else "unknown"
481
+ )
478
482
 
479
483
  self._forge_context = ForgeContext(
480
484
  repo=repo,
@@ -146,7 +146,7 @@ def load_constitution(path: str | Path) -> Constitution:
146
146
  if not path.exists():
147
147
  raise FileNotFoundError(f"Constitution file not found: {path}")
148
148
 
149
- with open(path) as f:
149
+ with open(path, encoding="utf-8") as f:
150
150
  data = yaml.safe_load(f)
151
151
 
152
152
  return Constitution.model_validate(data)
@@ -31,14 +31,14 @@ def _load_queue() -> list[dict]:
31
31
  if not _QUEUE_FILE.exists():
32
32
  return []
33
33
  try:
34
- return json.loads(_QUEUE_FILE.read_text())
34
+ return json.loads(_QUEUE_FILE.read_text(encoding="utf-8"))
35
35
  except (json.JSONDecodeError, OSError):
36
36
  return []
37
37
 
38
38
 
39
39
  def _save_queue(queue: list[dict]) -> None:
40
40
  _ensure_state_dir()
41
- _QUEUE_FILE.write_text(json.dumps(queue, indent=2))
41
+ _QUEUE_FILE.write_text(json.dumps(queue, indent=2), encoding="utf-8")
42
42
 
43
43
 
44
44
  def _load_workflows() -> list[dict]:
@@ -46,14 +46,14 @@ def _load_workflows() -> list[dict]:
46
46
  if not _WORKFLOWS_FILE.exists():
47
47
  return []
48
48
  try:
49
- return json.loads(_WORKFLOWS_FILE.read_text())
49
+ return json.loads(_WORKFLOWS_FILE.read_text(encoding="utf-8"))
50
50
  except (json.JSONDecodeError, OSError):
51
51
  return []
52
52
 
53
53
 
54
54
  def _save_workflows(workflows: list[dict]) -> None:
55
55
  _ensure_state_dir()
56
- _WORKFLOWS_FILE.write_text(json.dumps(workflows, indent=2))
56
+ _WORKFLOWS_FILE.write_text(json.dumps(workflows, indent=2), encoding="utf-8")
57
57
 
58
58
 
59
59
  def submit(
@@ -9,9 +9,10 @@ Mirror of the PR20 reorganizer pattern but focused on capability-capture.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import hashlib
12
13
  import re
13
14
  from dataclasses import dataclass
14
- from datetime import datetime, timezone
15
+ from datetime import UTC, datetime
15
16
  from pathlib import Path
16
17
 
17
18
  _COMPLETION_PATTERNS: tuple[re.Pattern[str], ...] = (
@@ -41,7 +42,14 @@ _DEFAULT_OUTPUT_DIR: Path = Path.home() / ".arkaos" / "skill-proposals"
41
42
 
42
43
  @dataclass(frozen=True)
43
44
  class SkillProposal:
44
- """Outcome of a skill-evaluation pass."""
45
+ """Outcome of a skill-evaluation pass.
46
+
47
+ ``proposal_path`` is set only when a file was actually written. It is
48
+ ``None`` both when no proposal was warranted and when one was
49
+ rendered but had nowhere safe to go (reason ``no-safe-filename``) —
50
+ in that second case ``proposal_markdown`` still carries the capture,
51
+ so a caller can route it somewhere else.
52
+ """
45
53
  should_propose: bool
46
54
  reason: str
47
55
  suggested_slug: str | None
@@ -75,12 +83,81 @@ def evaluate(
75
83
  markdown = _render_proposal(text, slug, today=today)
76
84
  out_dir = output_dir or _DEFAULT_OUTPUT_DIR
77
85
  out_dir.mkdir(parents=True, exist_ok=True)
78
- iso_today = today or datetime.now(timezone.utc).strftime("%Y-%m-%d")
79
- path = out_dir / f"{iso_today}-{slug}.md"
86
+ iso_today = today or datetime.now(UTC).strftime("%Y-%m-%d")
87
+ path = _collision_free_path(out_dir, iso_today, slug, markdown)
88
+ if path is None:
89
+ return SkillProposal(False, "no-safe-filename", slug, None, markdown)
80
90
  path.write_text(markdown, encoding="utf-8")
81
91
  return SkillProposal(True, "proposed", slug, path, markdown)
82
92
 
83
93
 
94
+ def _collision_free_path(
95
+ out_dir: Path, iso_today: str, slug: str, markdown: str
96
+ ) -> Path | None:
97
+ """Return a path for today's proposal, or ``None`` if none is safe.
98
+
99
+ ``_suggest_slug`` anchors on the first matching skill-worthy hint, so
100
+ same-day slug collisions are the norm: the six word hints plus the
101
+ fallback yield seven fixed names, and the numeric ``N-phase`` hint
102
+ mints a fresh one per number it matches (until ``_slugify``'s 60-char
103
+ cap truncates the extremes back together). The space is small in
104
+ practice, but it is not the fixed handful this docstring once
105
+ claimed. Every proposal after the first with the same slug used to
106
+ overwrite its predecessor, silently: distinct captured capabilities
107
+ were lost with no error and no trace.
108
+
109
+ Disambiguates by content digest rather than a counter, so re-running
110
+ the hook over the same closing message stays idempotent (same content
111
+ -> same path -> one file) while genuinely different proposals get
112
+ their own. One invariant holds every branch honest: never return a
113
+ path unless it is free or provably holds this exact proposal.
114
+
115
+ A name built from our digest proves nothing about the bytes inside
116
+ the file — any file can carry any name, no hash collision required.
117
+ So when the plain name and both digest names are all occupied by
118
+ content we cannot account for, this returns ``None`` and the caller
119
+ writes nothing: losing one capture is honest, overwriting somebody
120
+ else's is not.
121
+
122
+ The digest names are checked before the plain one, so a re-fire after
123
+ the operator deleted the plain twin lands back on the file it already
124
+ wrote instead of duplicating it.
125
+ """
126
+ digest = hashlib.sha256(markdown.encode("utf-8")).hexdigest()
127
+ plain = out_dir / f"{iso_today}-{slug}.md"
128
+ # 8 hex chars keep the filename readable; the full digest is the
129
+ # tie-breaker for the day those 32 bits meet a different proposal.
130
+ digest_paths = (
131
+ out_dir / f"{iso_today}-{slug}-{digest[:8]}.md",
132
+ out_dir / f"{iso_today}-{slug}-{digest}.md",
133
+ )
134
+ for candidate in digest_paths:
135
+ if _already_holds(candidate, markdown):
136
+ return candidate
137
+ if not plain.exists() or _already_holds(plain, markdown):
138
+ return plain
139
+ for candidate in digest_paths:
140
+ if not candidate.exists():
141
+ return candidate
142
+ return None
143
+
144
+
145
+ def _already_holds(path: Path, markdown: str) -> bool:
146
+ """True only when ``path`` provably contains exactly ``markdown``.
147
+
148
+ Content we cannot read back is not "equal": a missing, unreadable, or
149
+ non-UTF-8 file is unknown content, and the caller must treat unknown
150
+ as another proposal rather than write over it. ``UnicodeDecodeError``
151
+ is a ``ValueError``, not an ``OSError`` — both belong in the same
152
+ branch, or the exception escapes ``evaluate`` into the Stop hook's
153
+ blanket ``except Exception: pass`` and the proposal is lost silently.
154
+ """
155
+ try:
156
+ return path.read_text(encoding="utf-8") == markdown
157
+ except (OSError, ValueError):
158
+ return False
159
+
160
+
84
161
  def _has_completion_signal(text: str) -> bool:
85
162
  return any(p.search(text) for p in _COMPLETION_PATTERNS)
86
163
 
@@ -108,7 +185,7 @@ def _slugify(value: str) -> str:
108
185
 
109
186
 
110
187
  def _render_proposal(text: str, slug: str, *, today: str | None) -> str:
111
- iso = today or datetime.now(timezone.utc).strftime("%Y-%m-%d")
188
+ iso = today or datetime.now(UTC).strftime("%Y-%m-%d")
112
189
  excerpt = text.strip()
113
190
  if len(excerpt) > 1000:
114
191
  excerpt = excerpt[:1000].rstrip() + "..."
@@ -24,6 +24,7 @@ module; with no usable venv it emits a static banner (fail-open).
24
24
 
25
25
  from __future__ import annotations
26
26
 
27
+ import contextlib
27
28
  import json
28
29
  import os
29
30
  import re
@@ -247,13 +248,46 @@ def _marker_safe(value: str) -> str:
247
248
  return _CONTROL_RE.sub("", value.splitlines()[0]) if value else value
248
249
 
249
250
 
251
+ def _spawning_suppressed() -> bool:
252
+ """Whether this process may launch the operator's background daemons.
253
+
254
+ SessionStart spawns two long-lived side processes: the dashboard
255
+ (`_ensure_dashboard`) and the reorganizer. Both take the *resolved repo
256
+ root* as their working tree, and `start-dashboard` kills the PIDs
257
+ recorded in `~/.arkaos/dashboard.pid` — one shared file for every
258
+ checkout on the machine — before registering its own. (`ensure` leaves
259
+ a healthy instance alone; `find_port` steps past occupied ports.)
260
+
261
+ Run the hook from a test and that is a live-fire action on the machine
262
+ running the tests: the operator's dashboard is killed through that
263
+ global PID file and replaced by one served out of the test tree — a
264
+ checkout that legitimately lacks whatever local state the real install
265
+ has. It looks like the dashboard broke, not like a test ran.
266
+ (Observed 2026-07-27: two separate pytest invocations silently took
267
+ over ports 3333/3334 and served a blank UI.)
268
+
269
+ ARKA_HOOK_NO_SPAWN=1 is the explicit switch. PYTEST_CURRENT_TEST is
270
+ honored as well, deliberately: a test author who forgets the switch
271
+ should lose a background process they never wanted, not the dashboard
272
+ they were using. pytest exports it into the environment every child
273
+ process inherits, so the safety net costs nothing in production, where
274
+ the variable is simply absent.
275
+ """
276
+ return bool(
277
+ os.environ.get("ARKA_HOOK_NO_SPAWN") == "1"
278
+ or os.environ.get("PYTEST_CURRENT_TEST")
279
+ )
280
+
281
+
250
282
  def _spawn_detached(cmd: list[str], repo: str, log_path: Path | None = None) -> None:
283
+ if _spawning_suppressed():
284
+ return
251
285
  stdout = subprocess.DEVNULL
252
286
  handle = None
253
287
  try:
254
288
  if log_path is not None:
255
289
  log_path.parent.mkdir(parents=True, exist_ok=True)
256
- handle = log_path.open("a")
290
+ handle = log_path.open("a", encoding="utf-8")
257
291
  stdout = handle
258
292
  subprocess.Popen(
259
293
  cmd,
@@ -436,6 +470,47 @@ def build_recap(cwd: str, budget_ms: int = _BUDGET_MS) -> str:
436
470
  return ""
437
471
 
438
472
 
473
+ _CONTRACT_FAILURE_LOG = (
474
+ Path.home() / ".arkaos" / "telemetry" / "session-start-failures.jsonl"
475
+ )
476
+
477
+
478
+ def _contracts_unavailable_notice(exc: BaseException) -> str:
479
+ """Say the contracts are missing instead of returning an empty string.
480
+
481
+ build_context() produces [ARKA:EVIDENCE-FLOW], [ARKA:SKILL-CONTRACT],
482
+ [ARKA:META-TAG], [ARKA:AUTHORITY] and [ARKA:MODEL-FABRIC], plus the
483
+ resume and root lines and, when present, the [SESSION-MEMORY] recap —
484
+ the rules the session is supposed to run under. Swallowing a failure here returns the exact
485
+ state those blocks exist to prevent: a session with no idea the rules
486
+ exist, behaving like a generic assistant, with nothing anywhere saying
487
+ why. That is indistinguishable from the hook never having run, which
488
+ is precisely how the Windows delivery gap survived unnoticed for
489
+ months (PR #408).
490
+
491
+ Same principle `_authority_brief` already applies: one honest line
492
+ beats a silent void. The greeting still never breaks
493
+ and the hook still exits 0.
494
+ """
495
+ with contextlib.suppress(Exception): # telemetry must never be the thing that breaks
496
+ _CONTRACT_FAILURE_LOG.parent.mkdir(parents=True, exist_ok=True)
497
+ with _CONTRACT_FAILURE_LOG.open("a", encoding="utf-8") as fh:
498
+ fh.write(json.dumps({
499
+ "ts": datetime.now(UTC).isoformat(),
500
+ "event": "build_context_failed",
501
+ "error_type": type(exc).__name__,
502
+ "error": str(exc)[:500],
503
+ }) + "\n")
504
+ return (
505
+ f"\n[ARKA:CONTRACTS] unavailable ({type(exc).__name__}) — the evidence "
506
+ f"flow, skill-routing, meta-tag, authority and model-routing contracts "
507
+ f"could not be built for this session. Treat their absence as a fault, not as "
508
+ f"permission: keep routing and gating as if they were present, and "
509
+ f"report the failure. Detail in "
510
+ f"~/.arkaos/telemetry/session-start-failures.jsonl"
511
+ )
512
+
513
+
439
514
  def main(stdin_json: dict | None = None) -> int:
440
515
  if stdin_json is None:
441
516
  stdin_json, _ = read_stdin_json()
@@ -450,8 +525,8 @@ def main(stdin_json: dict | None = None) -> int:
450
525
  visible = _FALLBACK_BANNER + "\n Olá, founder\n"
451
526
  try:
452
527
  context = build_context(cwd)
453
- except Exception: # contracts are best-effort; greeting never breaks
454
- context = ""
528
+ except Exception as exc: # greeting never breaks but never lie either
529
+ context = _contracts_unavailable_notice(exc)
455
530
  payload: dict = {"systemMessage": visible}
456
531
  if context:
457
532
  # Assign the sub-dict key explicitly — SessionStart's wrapper also
package/core/keys.py CHANGED
@@ -34,12 +34,12 @@ PROVIDERS = {
34
34
  def _load() -> dict[str, str]:
35
35
  if not KEYS_PATH.exists():
36
36
  return {}
37
- return json.loads(KEYS_PATH.read_text())
37
+ return json.loads(KEYS_PATH.read_text(encoding="utf-8"))
38
38
 
39
39
 
40
40
  def _save(keys: dict[str, str]) -> None:
41
41
  KEYS_PATH.parent.mkdir(parents=True, exist_ok=True)
42
- KEYS_PATH.write_text(json.dumps(keys, indent=2))
42
+ KEYS_PATH.write_text(json.dumps(keys, indent=2), encoding="utf-8")
43
43
  os.chmod(KEYS_PATH, stat.S_IRUSR | stat.S_IWUSR) # 600
44
44
 
45
45
 
@@ -131,7 +131,7 @@ class ObsidianWriter:
131
131
  if config_path.exists():
132
132
  try:
133
133
  from core.runtime.path_resolver import resolve
134
- config = json.loads(config_path.read_text())
134
+ config = json.loads(config_path.read_text(encoding="utf-8"))
135
135
  vault = resolve(config.get("vault_path", ""))
136
136
  if vault and not vault.startswith("${") and Path(vault).exists():
137
137
  return Path(vault)
@@ -73,7 +73,7 @@ class PersonaManager:
73
73
  output_dir = Path(agents_dir)
74
74
  output_dir.mkdir(parents=True, exist_ok=True)
75
75
  output_path = output_dir / f"{agent_id}.yaml"
76
- with open(output_path, "w") as f:
76
+ with open(output_path, "w", encoding="utf-8") as f:
77
77
  yaml.dump(agent_data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
78
78
 
79
79
  # Track the clone
@@ -88,13 +88,13 @@ class PersonaManager:
88
88
  return
89
89
  self._storage_path.parent.mkdir(parents=True, exist_ok=True)
90
90
  data = {pid: p.model_dump(mode="json") for pid, p in self._personas.items()}
91
- with open(self._storage_path, "w") as f:
91
+ with open(self._storage_path, "w", encoding="utf-8") as f:
92
92
  json.dump(data, f, indent=2)
93
93
 
94
94
  def _load(self) -> None:
95
95
  if self._storage_path is None or not self._storage_path.exists():
96
96
  return
97
- content = self._storage_path.read_text().strip()
97
+ content = self._storage_path.read_text(encoding="utf-8").strip()
98
98
  if not content:
99
99
  return
100
100
  data = json.loads(content)
@@ -108,7 +108,7 @@ class SpecManager:
108
108
  path = Path(path)
109
109
  path.parent.mkdir(parents=True, exist_ok=True)
110
110
  data = spec.model_dump(mode="json")
111
- with open(path, "w") as f:
111
+ with open(path, "w", encoding="utf-8") as f:
112
112
  yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
113
113
  return True
114
114
 
@@ -117,7 +117,7 @@ class SpecManager:
117
117
  path = Path(path)
118
118
  if not path.exists():
119
119
  raise FileNotFoundError(f"Spec file not found: {path}")
120
- with open(path) as f:
120
+ with open(path, encoding="utf-8") as f:
121
121
  data = yaml.safe_load(f)
122
122
  spec = Spec.model_validate(data)
123
123
  self._specs[spec.id] = spec
@@ -12,7 +12,7 @@ def load_squad(path: str | Path) -> Squad:
12
12
  if not path.exists():
13
13
  raise FileNotFoundError(f"Squad file not found: {path}")
14
14
 
15
- with open(path) as f:
15
+ with open(path, encoding="utf-8") as f:
16
16
  data = yaml.safe_load(f)
17
17
 
18
18
  if data is None:
@@ -179,12 +179,12 @@ class KBSessionCache:
179
179
  if not self._cache_file.exists():
180
180
  return {}
181
181
  try:
182
- return json.loads(self._cache_file.read_text())
182
+ return json.loads(self._cache_file.read_text(encoding="utf-8"))
183
183
  except (json.JSONDecodeError, OSError):
184
184
  return {}
185
185
 
186
186
  def _save(self, data: dict[str, Any]) -> None:
187
- self._cache_file.write_text(json.dumps(data, indent=2))
187
+ self._cache_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
188
188
 
189
189
  def extract_topics(self, query: str) -> set[str]:
190
190
  """Extract key topics from a query string.
@@ -135,13 +135,13 @@ class TaskManager:
135
135
  "counter": self._counter,
136
136
  "tasks": {tid: t.model_dump(mode="json") for tid, t in self._tasks.items()},
137
137
  }
138
- with open(self._storage_path, "w") as f:
138
+ with open(self._storage_path, "w", encoding="utf-8") as f:
139
139
  json.dump(data, f, indent=2)
140
140
 
141
141
  def _load(self) -> None:
142
142
  if self._storage_path is None or not self._storage_path.exists():
143
143
  return
144
- content = self._storage_path.read_text().strip()
144
+ content = self._storage_path.read_text(encoding="utf-8").strip()
145
145
  if not content:
146
146
  return
147
147
  data = json.loads(content)
@@ -19,7 +19,7 @@ def load_workflow(path: str | Path) -> Workflow:
19
19
  if not path.exists():
20
20
  raise FileNotFoundError(f"Workflow file not found: {path}")
21
21
 
22
- with open(path) as f:
22
+ with open(path, encoding="utf-8") as f:
23
23
  data = yaml.safe_load(f)
24
24
 
25
25
  if data is None:
@@ -251,27 +251,6 @@
251
251
  to { transform: translate3d(-2.5%, -1.5%, 0); }
252
252
  }
253
253
 
254
- /* Route transitions: one rhythm, upward continuity (enter rises, exit
255
- recedes). Wired via app.pageTransition in nuxt.config. */
256
- .arka-page-enter-active {
257
- transition:
258
- opacity var(--arka-motion-base) var(--arka-ease-out),
259
- transform var(--arka-motion-base) var(--arka-ease-out);
260
- }
261
- .arka-page-leave-active {
262
- transition:
263
- opacity var(--arka-motion-fast) var(--arka-ease-in),
264
- transform var(--arka-motion-fast) var(--arka-ease-in);
265
- }
266
- .arka-page-enter-from {
267
- opacity: 0;
268
- transform: translateY(8px);
269
- }
270
- .arka-page-leave-to {
271
- opacity: 0;
272
- transform: translateY(-4px);
273
- }
274
-
275
254
  @media (prefers-reduced-motion: reduce) {
276
255
  .arka-live-dot,
277
256
  .arka-pulse-line,
@@ -279,12 +258,4 @@
279
258
  .arka-starfield {
280
259
  animation: none;
281
260
  }
282
- .arka-page-enter-active,
283
- .arka-page-leave-active {
284
- transition: none;
285
- }
286
- .arka-page-enter-from,
287
- .arka-page-leave-to {
288
- transform: none;
289
- }
290
261
  }
@@ -3,8 +3,39 @@ export const useApi = () => {
3
3
 
4
4
  // path may be a plain string or a getter — useFetch accepts a getter URL
5
5
  // and refetches when its reactive deps change (compare pages rely on it).
6
- const fetchApi = <T>(path: MaybeRefOrGetter<string>, opts?: Record<string, unknown>) =>
7
- useFetch<T>(() => `${apiBase}${toValue(path)}`, { ...opts })
6
+ //
7
+ // An empty path means "nothing to fetch yet". Every empty-until-selected
8
+ // caller has that shape: the four compare pages return '' until their ids
9
+ // land (agents/compare.vue:49-54, departments/compare.vue:36-41,
10
+ // personas/compare.vue:75-80, personas/compare-with-agent.vue:54-59) and so
11
+ // does plan-canvas.vue:73-76 until a plan row is picked. useFetch has no
12
+ // skip flag, so an empty path collapses the URL to apiBase itself and 404s
13
+ // on mount. The guard therefore sits in the transport, never in
14
+ // `watch`/`immediate`: `watch: false` freezes the cache key at setup
15
+ // (fetch.js:106 passes `key.value`, not `key`), which merges two parallel
16
+ // fetches that start empty into one asyncData entry — both compare columns
17
+ // would then render the same record — and also kills the refetch of the
18
+ // pages that pass a reactive `query`.
19
+ //
20
+ // The app is client-only (`ssr: false`), so overriding `$fetch` costs
21
+ // nothing: useFetch's `useRequestFetch()` branch is server-side only, and
22
+ // apiBase is absolute, which disqualifies that branch anyway.
23
+ type Transport = typeof globalThis.$fetch
24
+
25
+ const fetchApi = <T>(path: MaybeRefOrGetter<string>, opts?: Record<string, unknown>) => {
26
+ const relative = computed(() => toValue(path))
27
+ const caller = opts?.$fetch as Transport | undefined
28
+
29
+ const skipWhenPathEmpty = ((...args: Parameters<Transport>) =>
30
+ relative.value
31
+ ? (caller ?? globalThis.$fetch)(...args)
32
+ : Promise.resolve(undefined)) as Transport
33
+
34
+ return useFetch<T>(() => `${apiBase}${relative.value}`, {
35
+ ...opts,
36
+ $fetch: skipWhenPathEmpty
37
+ })
38
+ }
8
39
 
9
40
  return { fetchApi, apiBase }
10
41
  }
@@ -14,13 +14,6 @@ export default defineNuxtConfig({
14
14
  enabled: true
15
15
  },
16
16
 
17
- app: {
18
- pageTransition: {
19
- name: 'arka-page',
20
- mode: 'out-in'
21
- }
22
- },
23
-
24
17
  css: ['~/assets/css/main.css'],
25
18
 
26
19
  colorMode: {
@@ -314,7 +314,7 @@ def generate_manifest(output_dir, video_info, frame_count, scroll_height,
314
314
  manifest["mobile"] = _variant_summary(mobile_info)
315
315
 
316
316
  manifest_path = Path(output_dir) / "manifest.json"
317
- with open(manifest_path, "w") as f:
317
+ with open(manifest_path, "w", encoding="utf-8") as f:
318
318
  json.dump(manifest, f, indent=2)
319
319
  print(f"\n Manifest saved to: {manifest_path}", file=sys.stderr)
320
320
  return manifest
@@ -109,7 +109,7 @@ def detect_stack(project_path: str) -> dict:
109
109
  composer_path = p / "composer.json"
110
110
  if composer_path.exists():
111
111
  try:
112
- composer = json.loads(composer_path.read_text())
112
+ composer = json.loads(composer_path.read_text(encoding="utf-8"))
113
113
  require = {**composer.get("require", {}), **composer.get("require-dev", {})}
114
114
 
115
115
  if "laravel/framework" in require:
@@ -162,7 +162,7 @@ def detect_stack(project_path: str) -> dict:
162
162
  php_files = list(app_path.rglob("*.php"))[:5]
163
163
  for pf in php_files:
164
164
  try:
165
- content = pf.read_text(errors="ignore")[:200]
165
+ content = pf.read_text(encoding="utf-8", errors="ignore")[:200]
166
166
  if "declare(strict_types=1)" in content:
167
167
  result["conventions"]["strict_types"] = True
168
168
  break
@@ -176,7 +176,7 @@ def detect_stack(project_path: str) -> dict:
176
176
  package_path = p / "package.json"
177
177
  if package_path.exists():
178
178
  try:
179
- pkg = json.loads(package_path.read_text())
179
+ pkg = json.loads(package_path.read_text(encoding="utf-8"))
180
180
  deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
181
181
 
182
182
  # Nuxt
@@ -261,7 +261,7 @@ def detect_stack(project_path: str) -> dict:
261
261
  fp = p / f
262
262
  if fp.exists():
263
263
  try:
264
- content = fp.read_text(errors="ignore")[:2000]
264
+ content = fp.read_text(encoding="utf-8", errors="ignore")[:2000]
265
265
  if "fastapi" in content.lower() or "FastAPI" in content:
266
266
  result["framework"] = "FastAPI"
267
267
  result["stack"].append("FastAPI")
@@ -275,7 +275,7 @@ def detect_stack(project_path: str) -> dict:
275
275
  env_file = p / ".env.example" if (p / ".env.example").exists() else p / ".env"
276
276
  if env_file.exists():
277
277
  try:
278
- env_content = env_file.read_text(errors="ignore")
278
+ env_content = env_file.read_text(encoding="utf-8", errors="ignore")
279
279
  if "DB_CONNECTION=pgsql" in env_content or "DATABASE_URL=postgres" in env_content:
280
280
  result["database"].append("PostgreSQL")
281
281
  elif "DB_CONNECTION=mysql" in env_content:
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "_meta": {
3
3
  "generator": "scripts/marketplace_gen.py",
4
- "version": "5.10.0",
4
+ "version": "5.11.0",
5
5
  "marketplace": "arkaos"
6
6
  },
7
7
  "structural": {
@@ -39,7 +39,7 @@ def _find_skills_dir() -> Path:
39
39
  # Dev mode: .repo-path points to the repo checkout
40
40
  repo_path_file = Path.home() / ".claude" / "skills" / "arka" / ".repo-path"
41
41
  if repo_path_file.exists():
42
- repo_path = Path(repo_path_file.read_text().strip())
42
+ repo_path = Path(repo_path_file.read_text(encoding="utf-8").strip())
43
43
  if repo_path.is_dir():
44
44
  return repo_path # Return repo root for dev mode resolution
45
45
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "5.10.0",
3
+ "version": "5.11.0",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arkaos-core"
3
- version = "5.10.0"
3
+ version = "5.11.0"
4
4
  description = "Core engine for ArkaOS — The Operating System for AI Agent Teams"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -88,7 +88,7 @@ def _load_agents() -> list[dict]:
88
88
  if _agents_cache is None:
89
89
  path = ARKAOS_ROOT / "knowledge" / "agents-registry-v2.json"
90
90
  if path.exists():
91
- data = json.loads(path.read_text())
91
+ data = json.loads(path.read_text(encoding="utf-8"))
92
92
  _agents_cache = data.get("agents", [])
93
93
  else:
94
94
  _agents_cache = []
@@ -100,7 +100,7 @@ def _load_commands() -> list[dict]:
100
100
  if _commands_cache is None:
101
101
  path = ARKAOS_ROOT / "knowledge" / "commands-registry.json"
102
102
  if path.exists():
103
- data = json.loads(path.read_text())
103
+ data = json.loads(path.read_text(encoding="utf-8"))
104
104
  _commands_cache = data.get("commands", [])
105
105
  else:
106
106
  _commands_cache = []
@@ -739,7 +739,7 @@ def agent_detail(agent_id: str):
739
739
  if yaml_file.exists():
740
740
  try:
741
741
  import yaml
742
- raw = yaml.safe_load(yaml_file.read_text())
742
+ raw = yaml.safe_load(yaml_file.read_text(encoding="utf-8"))
743
743
  dna = raw.get("behavioral_dna", {})
744
744
  disc = dna.get("disc", {})
745
745
  ennea = dna.get("enneagram", {})
@@ -4486,7 +4486,7 @@ def metrics():
4486
4486
  if not metrics_file.exists():
4487
4487
  return {"entries": [], "avg_ms": 0}
4488
4488
  entries = []
4489
- for line in metrics_file.read_text().strip().split("\n"):
4489
+ for line in metrics_file.read_text(encoding="utf-8").strip().split("\n"):
4490
4490
  try:
4491
4491
  entries.append(json.loads(line))
4492
4492
  except Exception:
@@ -88,13 +88,13 @@ STACK_DISPLAY = {
88
88
 
89
89
  def _load_agents() -> list[dict]:
90
90
  data = json.loads(
91
- (ROOT / "knowledge" / "agents-registry-v2.json").read_text())
91
+ (ROOT / "knowledge" / "agents-registry-v2.json").read_text(encoding="utf-8"))
92
92
  return data["agents"]
93
93
 
94
94
 
95
95
  def _load_command_counts() -> dict[str, int]:
96
96
  data = json.loads(
97
- (ROOT / "knowledge" / "commands-registry.json").read_text())
97
+ (ROOT / "knowledge" / "commands-registry.json").read_text(encoding="utf-8"))
98
98
  return data["_meta"]["departments"]
99
99
 
100
100
 
@@ -114,7 +114,7 @@ def _split_frontmatter(text: str) -> tuple[list[str], str]:
114
114
  def _stack_rules() -> list[tuple[str, list[str], str]]:
115
115
  rules = []
116
116
  for path in sorted(STACK_RULES_DIR.glob("*.md")):
117
- globs, body = _split_frontmatter(path.read_text())
117
+ globs, body = _split_frontmatter(path.read_text(encoding="utf-8"))
118
118
  rules.append((path.stem, globs, body))
119
119
  return rules
120
120
 
@@ -402,7 +402,7 @@ def write_bundle(files: dict[str, str], harness_dir: Path) -> None:
402
402
  for rel, content in files.items():
403
403
  target = harness_dir / rel
404
404
  target.parent.mkdir(parents=True, exist_ok=True)
405
- target.write_text(content)
405
+ target.write_text(content, encoding="utf-8")
406
406
 
407
407
 
408
408
  def main() -> int:
@@ -99,7 +99,7 @@ def load_agents_registry(root: Path) -> dict:
99
99
  if not path.exists():
100
100
  return {}
101
101
  try:
102
- data = json.loads(path.read_text())
102
+ data = json.loads(path.read_text(encoding="utf-8"))
103
103
  agents = {}
104
104
  for agent in data.get("agents", []):
105
105
  agent_id = agent.get("id", "")
@@ -116,7 +116,7 @@ def load_commands_registry(root: Path) -> list:
116
116
  if not path.exists():
117
117
  return []
118
118
  try:
119
- data = json.loads(path.read_text())
119
+ data = json.loads(path.read_text(encoding="utf-8"))
120
120
  return data.get("commands", [])
121
121
  except Exception:
122
122
  return []