arkaos 5.9.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.
Files changed (74) hide show
  1. package/THE-ARKAOS-GUIDE.md +1 -1
  2. package/VERSION +1 -1
  3. package/bin/arka-menubar.py +12 -5
  4. package/core/agents/loader.py +1 -1
  5. package/core/agents/registry_gen.py +1 -1
  6. package/core/budget/manager.py +2 -2
  7. package/core/cognition/scheduler/daemon.py +2 -2
  8. package/core/conclave/persistence.py +2 -2
  9. package/core/forge/orchestrator.py +5 -1
  10. package/core/governance/constitution.py +1 -1
  11. package/core/governance/evidence_checks.py +166 -22
  12. package/core/governance/quality_api.py +4 -4
  13. package/core/governance/skill_proposer.py +82 -5
  14. package/core/hooks/session_start.py +78 -3
  15. package/core/keys.py +2 -2
  16. package/core/obsidian/writer.py +1 -1
  17. package/core/personas/manager.py +3 -3
  18. package/core/specs/manager.py +2 -2
  19. package/core/squads/loader.py +1 -1
  20. package/core/synapse/kb_cache.py +2 -2
  21. package/core/sync/content_merger.py +77 -26
  22. package/core/sync/content_syncer.py +67 -29
  23. package/core/sync/engine.py +28 -20
  24. package/core/sync/manifest.py +48 -8
  25. package/core/sync/reporter.py +49 -29
  26. package/core/sync/schema.py +2 -0
  27. package/core/tasks/manager.py +2 -2
  28. package/core/workflow/loader.py +1 -1
  29. package/dashboard/app/assets/css/main.css +0 -29
  30. package/dashboard/app/composables/useApi.ts +33 -2
  31. package/dashboard/nuxt.config.ts +0 -7
  32. package/departments/dev/skills/animated-website/scripts/extract_frames.py +1 -1
  33. package/departments/dev/skills/onboard/scripts/detect-stack.py +5 -5
  34. package/departments/ops/skills/update/references/sync-engine.md +1 -1
  35. package/departments/ops/skills/update/references/workflows.md +4 -2
  36. package/harness/codex/AGENTS.md +1 -1
  37. package/harness/copilot/copilot-instructions.md +1 -1
  38. package/harness/cursor/rules/arkaos.mdc +2 -2
  39. package/harness/gemini/GEMINI.md +1 -1
  40. package/harness/opencode/AGENTS.md +1 -1
  41. package/harness/opencode/agents/arka-architect-gabriel.md +1 -1
  42. package/harness/opencode/agents/arka-brand-director-valentina.md +1 -1
  43. package/harness/opencode/agents/arka-cfo-helena.md +1 -1
  44. package/harness/opencode/agents/arka-chief-of-staff-afonso.md +1 -1
  45. package/harness/opencode/agents/arka-community-strategist-beatriz.md +1 -1
  46. package/harness/opencode/agents/arka-content-strategist-rafael.md +1 -1
  47. package/harness/opencode/agents/arka-conversion-strategist-ines.md +1 -1
  48. package/harness/opencode/agents/arka-coo-sofia.md +1 -1
  49. package/harness/opencode/agents/arka-copy-director-eduardo.md +1 -1
  50. package/harness/opencode/agents/arka-cqo-marta.md +1 -1
  51. package/harness/opencode/agents/arka-cto-marco.md +1 -1
  52. package/harness/opencode/agents/arka-design-ops-lead-iris.md +1 -1
  53. package/harness/opencode/agents/arka-ecom-director-ricardo.md +1 -1
  54. package/harness/opencode/agents/arka-knowledge-director-clara.md +1 -1
  55. package/harness/opencode/agents/arka-leadership-director-rodrigo.md +1 -1
  56. package/harness/opencode/agents/arka-marketing-director-luna.md +1 -1
  57. package/harness/opencode/agents/arka-ops-lead-daniel.md +1 -1
  58. package/harness/opencode/agents/arka-pm-director-carolina.md +1 -1
  59. package/harness/opencode/agents/arka-revops-lead-vicente.md +1 -1
  60. package/harness/opencode/agents/arka-saas-strategist-tiago.md +1 -1
  61. package/harness/opencode/agents/arka-sales-director-miguel.md +1 -1
  62. package/harness/opencode/agents/arka-strategy-director-tomas.md +1 -1
  63. package/harness/opencode/agents/arka-tech-director-francisca.md +1 -1
  64. package/harness/opencode/agents/arka-tech-lead-paulo.md +1 -1
  65. package/harness/opencode/agents/arka-video-producer-simao.md +1 -1
  66. package/harness/zed/.rules +1 -1
  67. package/knowledge/commands-registry.json +1 -1
  68. package/knowledge/skills-manifest.json +1 -1
  69. package/mcps/arka-prompts/server.py +1 -1
  70. package/package.json +1 -1
  71. package/pyproject.toml +1 -1
  72. package/scripts/dashboard-api.py +4 -4
  73. package/scripts/harness_gen.py +4 -4
  74. package/scripts/synapse-bridge.py +2 -2
@@ -1,6 +1,6 @@
1
1
  # The ArkaOS Guide
2
2
 
3
- > v5.9.0 — 89 agents, 17 departments, 340 skills, 306 commands, 20 ADRs.
3
+ > v5.10.0 — 89 agents, 17 departments, 340 skills, 306 commands, 20 ADRs.
4
4
  > One file, everything you need to start. Generated by `scripts/guide_gen.py` — never hand-edited.
5
5
 
6
6
  ## What it is
package/VERSION CHANGED
@@ -1 +1 @@
1
- 5.9.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)
@@ -35,7 +35,8 @@ import subprocess
35
35
  import sys
36
36
  import time
37
37
  from dataclasses import asdict, dataclass, field, replace
38
- from pathlib import Path
38
+ from pathlib import Path, PurePosixPath
39
+ from xml.etree import ElementTree
39
40
 
40
41
  from core.governance.qg_digest import evidence_digest
41
42
  from core.shared.test_evidence import coverage_percent_from_xml
@@ -553,30 +554,172 @@ def _junit_result(junit: Path) -> CheckResult:
553
554
  )
554
555
 
555
556
 
557
+ def _coverage_from_xml(
558
+ coverage_xml: Path, project_dir: Path, changed: list[str] | None,
559
+ ) -> CheckResult:
560
+ """Coverage verdict from an artefact, refusing one that cannot describe the diff."""
561
+ stale = _stale_coverage_reason(coverage_xml, project_dir, changed)
562
+ if stale is not None:
563
+ return CheckResult(
564
+ check="coverage", ran=True, passed=False,
565
+ command="parse:coverage.xml", exit_code=None,
566
+ summary=stale, details_path=str(coverage_xml),
567
+ )
568
+ percent = coverage_percent_from_xml(coverage_xml)
569
+ if percent is None:
570
+ return CheckResult(
571
+ check="coverage", ran=True, passed=None,
572
+ command="parse:coverage.xml", exit_code=None,
573
+ summary="coverage.xml present but unparseable",
574
+ details_path=str(coverage_xml),
575
+ )
576
+ return CheckResult(
577
+ check="coverage", ran=True,
578
+ passed=percent >= COVERAGE_THRESHOLD,
579
+ command="parse:coverage.xml", exit_code=None,
580
+ summary=f"coverage {percent:.1f}% (threshold {COVERAGE_THRESHOLD:.0f}%)",
581
+ details_path=str(coverage_xml),
582
+ )
583
+
584
+
585
+ def _stale_coverage_reason(
586
+ coverage_xml: Path, project_dir: Path, changed: list[str] | None,
587
+ ) -> str | None:
588
+ """Reason the artefact cannot describe this diff, or None if it can.
589
+
590
+ A coverage.xml older than the newest changed source measured a different
591
+ codebase, and a green number then vouches for code it never executed.
592
+ """
593
+ try:
594
+ artefact_mtime = coverage_xml.stat().st_mtime
595
+ except OSError:
596
+ return "coverage.xml unreadable"
597
+
598
+ newest_name = _newest_changed_after(project_dir, changed, artefact_mtime)
599
+ if newest_name is not None:
600
+ return (
601
+ f"coverage.xml predates changed source ({newest_name}) — "
602
+ "regenerate it; it cannot describe this diff"
603
+ )
604
+ return _missing_module_reason(coverage_xml, changed, project_dir)
605
+
606
+
607
+ def _missing_module_reason(
608
+ coverage_xml: Path, changed: list[str] | None, project_dir: Path,
609
+ ) -> str | None:
610
+ """Reason a changed module is absent from the artefact, or None."""
611
+ covered = _covered_paths(coverage_xml, project_dir)
612
+ missing = [
613
+ rel for rel in (changed or [])
614
+ if rel.endswith(".py")
615
+ and not rel.startswith("tests/")
616
+ and not _is_covered(rel, covered)
617
+ ]
618
+ if not missing:
619
+ return None
620
+ return (
621
+ f"coverage.xml has no entry for {len(missing)} changed module(s), "
622
+ f"first: {missing[0]}"
623
+ )
624
+
625
+
626
+ def _newest_changed_after(
627
+ project_dir: Path, changed: list[str] | None, cutoff: float,
628
+ ) -> str | None:
629
+ """Name of the newest changed .py file modified after cutoff, else None.
630
+
631
+ Only executable source can invalidate a coverage artefact — treating
632
+ CHANGELOG.md as "changed source" forced a full regeneration for edits no
633
+ test could ever execute (same filter the module-presence check applies).
634
+ """
635
+ newest = cutoff
636
+ newest_name: str | None = None
637
+ for rel in changed or []:
638
+ if not rel.endswith(".py"):
639
+ continue
640
+ try:
641
+ mtime = (project_dir / rel).stat().st_mtime
642
+ except OSError:
643
+ continue
644
+ if mtime > newest:
645
+ newest, newest_name = mtime, rel
646
+ return newest_name
647
+
648
+
649
+ def _covered_paths(coverage_xml: Path, project_dir: Path) -> set[str]:
650
+ """Covered files as paths relative to project_dir, exactly.
651
+
652
+ A ``class/@filename`` is relative to one of the ``sources/source``
653
+ roots, which are usually absolute and which the changed-file list never
654
+ carries. Each candidate is therefore rebuilt against every source and
655
+ re-expressed relative to the project, so comparison can be equality.
656
+
657
+ Suffix or stem matching is not good enough here: `core/` alone carries
658
+ 14 colliding stems, this repo's own artefact yields five bare basenames,
659
+ and a suffix rule cannot tell `core/sync/engine.py` from
660
+ `vendor/core/sync/engine.py`. Equality can.
661
+ """
662
+ try:
663
+ root = ElementTree.parse(coverage_xml).getroot()
664
+ except (ElementTree.ParseError, OSError):
665
+ return set()
666
+ sources = [(el.text or "").strip() for el in root.iterfind("sources/source")]
667
+ try:
668
+ base = project_dir.resolve()
669
+ except OSError:
670
+ base = project_dir
671
+ return {
672
+ candidate
673
+ for cls in root.iter("class")
674
+ if cls.get("filename")
675
+ for candidate in _source_candidates(cls.get("filename", ""), sources, base)
676
+ }
677
+
678
+
679
+ def _source_candidates(
680
+ filename: str, sources: list[str], base: Path,
681
+ ) -> set[str]:
682
+ """Project-relative spellings of one covered file, anchored.
683
+
684
+ With ``<source>`` roots declared, every candidate must resolve through
685
+ one of them into the project — the unanchored raw filename let another
686
+ checkout's artefact vouch for this project's files. The raw spelling is
687
+ a fallback only when no sources exist; a relative source resolves
688
+ against the project, never the CWD.
689
+ """
690
+ if not sources:
691
+ raw = PurePosixPath(filename)
692
+ return set() if raw.is_absolute() else {raw.as_posix()}
693
+
694
+ # Candidates must exist on disk; existence under more than one source
695
+ # is ambiguous — vouch for neither (fail closed).
696
+ found: set[str] = set()
697
+ for source in sources:
698
+ src = Path(source)
699
+ if not src.is_absolute():
700
+ src = base / src
701
+ try:
702
+ resolved = (src / filename).resolve()
703
+ if not resolved.is_file():
704
+ continue
705
+ found.add(PurePosixPath(resolved.relative_to(base)).as_posix())
706
+ except (OSError, ValueError):
707
+ continue
708
+ return found if len(found) == 1 else set()
709
+
710
+
711
+ def _is_covered(rel: str, covered: set[str]) -> bool:
712
+ """True only when the artefact names exactly this project-relative path."""
713
+ return PurePosixPath(rel).as_posix() in covered
714
+
715
+
556
716
  def _check_coverage(
557
717
  project_dir: Path, changed: list[str] | None,
558
718
  test_command: str | None, timeout: int,
559
719
  ) -> CheckResult:
560
720
  coverage_xml = project_dir / "coverage.xml"
561
721
  if coverage_xml.is_file():
562
- percent = coverage_percent_from_xml(coverage_xml)
563
- if percent is None:
564
- return CheckResult(
565
- check="coverage", ran=True, passed=None,
566
- command="parse:coverage.xml", exit_code=None,
567
- summary="coverage.xml present but unparseable",
568
- details_path=str(coverage_xml),
569
- )
570
- return CheckResult(
571
- check="coverage", ran=True,
572
- passed=percent >= COVERAGE_THRESHOLD,
573
- command="parse:coverage.xml", exit_code=None,
574
- summary=(
575
- f"coverage {percent:.1f}% "
576
- f"(threshold {COVERAGE_THRESHOLD:.0f}%)"
577
- ),
578
- details_path=str(coverage_xml),
579
- )
722
+ return _coverage_from_xml(coverage_xml, project_dir, changed)
580
723
  junit = project_dir / "junit.xml"
581
724
  if junit.is_file():
582
725
  return _junit_result(junit)
@@ -767,9 +910,10 @@ def _check_spellcheck(
767
910
  def _spellcheck_inspected_count(project_dir: Path, md_files: list[str]) -> int:
768
911
  """How many of ``md_files`` codespell actually reads after `skip`.
769
912
 
770
- Asks codespell itself (``--count`` on a per-file basis is too slow; a
771
- skipped file simply produces no output for any planted probe), so instead
772
- we replay its own glob semantics from the config.
913
+ Replays the config's skip globs with fnmatch rather than asking codespell
914
+ itself — per-file ``--count`` probing is too slow, and a skipped file
915
+ simply produces no output, so codespell cannot be asked which files it
916
+ ignored.
773
917
  """
774
918
  patterns = _codespell_skip_globs(project_dir)
775
919
  if not patterns:
@@ -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)