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.
- package/THE-ARKAOS-GUIDE.md +1 -1
- package/VERSION +1 -1
- package/bin/arka-menubar.py +12 -5
- package/core/agents/loader.py +1 -1
- package/core/agents/registry_gen.py +1 -1
- package/core/budget/manager.py +2 -2
- package/core/cognition/scheduler/daemon.py +2 -2
- package/core/conclave/persistence.py +2 -2
- package/core/forge/orchestrator.py +5 -1
- package/core/governance/constitution.py +1 -1
- package/core/governance/evidence_checks.py +166 -22
- package/core/governance/quality_api.py +4 -4
- package/core/governance/skill_proposer.py +82 -5
- package/core/hooks/session_start.py +78 -3
- package/core/keys.py +2 -2
- package/core/obsidian/writer.py +1 -1
- package/core/personas/manager.py +3 -3
- package/core/specs/manager.py +2 -2
- package/core/squads/loader.py +1 -1
- package/core/synapse/kb_cache.py +2 -2
- package/core/sync/content_merger.py +77 -26
- package/core/sync/content_syncer.py +67 -29
- package/core/sync/engine.py +28 -20
- package/core/sync/manifest.py +48 -8
- package/core/sync/reporter.py +49 -29
- package/core/sync/schema.py +2 -0
- package/core/tasks/manager.py +2 -2
- package/core/workflow/loader.py +1 -1
- package/dashboard/app/assets/css/main.css +0 -29
- package/dashboard/app/composables/useApi.ts +33 -2
- package/dashboard/nuxt.config.ts +0 -7
- package/departments/dev/skills/animated-website/scripts/extract_frames.py +1 -1
- package/departments/dev/skills/onboard/scripts/detect-stack.py +5 -5
- package/departments/ops/skills/update/references/sync-engine.md +1 -1
- package/departments/ops/skills/update/references/workflows.md +4 -2
- package/harness/codex/AGENTS.md +1 -1
- package/harness/copilot/copilot-instructions.md +1 -1
- package/harness/cursor/rules/arkaos.mdc +2 -2
- package/harness/gemini/GEMINI.md +1 -1
- package/harness/opencode/AGENTS.md +1 -1
- package/harness/opencode/agents/arka-architect-gabriel.md +1 -1
- package/harness/opencode/agents/arka-brand-director-valentina.md +1 -1
- package/harness/opencode/agents/arka-cfo-helena.md +1 -1
- package/harness/opencode/agents/arka-chief-of-staff-afonso.md +1 -1
- package/harness/opencode/agents/arka-community-strategist-beatriz.md +1 -1
- package/harness/opencode/agents/arka-content-strategist-rafael.md +1 -1
- package/harness/opencode/agents/arka-conversion-strategist-ines.md +1 -1
- package/harness/opencode/agents/arka-coo-sofia.md +1 -1
- package/harness/opencode/agents/arka-copy-director-eduardo.md +1 -1
- package/harness/opencode/agents/arka-cqo-marta.md +1 -1
- package/harness/opencode/agents/arka-cto-marco.md +1 -1
- package/harness/opencode/agents/arka-design-ops-lead-iris.md +1 -1
- package/harness/opencode/agents/arka-ecom-director-ricardo.md +1 -1
- package/harness/opencode/agents/arka-knowledge-director-clara.md +1 -1
- package/harness/opencode/agents/arka-leadership-director-rodrigo.md +1 -1
- package/harness/opencode/agents/arka-marketing-director-luna.md +1 -1
- package/harness/opencode/agents/arka-ops-lead-daniel.md +1 -1
- package/harness/opencode/agents/arka-pm-director-carolina.md +1 -1
- package/harness/opencode/agents/arka-revops-lead-vicente.md +1 -1
- package/harness/opencode/agents/arka-saas-strategist-tiago.md +1 -1
- package/harness/opencode/agents/arka-sales-director-miguel.md +1 -1
- package/harness/opencode/agents/arka-strategy-director-tomas.md +1 -1
- package/harness/opencode/agents/arka-tech-director-francisca.md +1 -1
- package/harness/opencode/agents/arka-tech-lead-paulo.md +1 -1
- package/harness/opencode/agents/arka-video-producer-simao.md +1 -1
- package/harness/zed/.rules +1 -1
- package/knowledge/commands-registry.json +1 -1
- package/knowledge/skills-manifest.json +1 -1
- package/mcps/arka-prompts/server.py +1 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/dashboard-api.py +4 -4
- package/scripts/harness_gen.py +4 -4
- package/scripts/synapse-bridge.py +2 -2
package/THE-ARKAOS-GUIDE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# The ArkaOS Guide
|
|
2
2
|
|
|
3
|
-
> v5.
|
|
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.
|
|
1
|
+
5.11.0
|
package/bin/arka-menubar.py
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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:
|
package/core/agents/loader.py
CHANGED
|
@@ -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
|
package/core/budget/manager.py
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
|
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(
|
|
79
|
-
path = out_dir
|
|
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(
|
|
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: #
|
|
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
|
|
package/core/obsidian/writer.py
CHANGED
|
@@ -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)
|