arkaos 2.39.0 → 2.41.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
- 2.39.0
1
+ 2.41.0
package/arka/SKILL.md CHANGED
@@ -185,8 +185,9 @@ violation (squad-routing, arka-supremacy, spec-driven, mandatory-qa).
185
185
 
186
186
  | Command | Description |
187
187
  |---------|-------------|
188
- | `/arka status` | System status (version, departments, agents, active projects). Includes **LLM costs (24h)** section: top-line cost + cache hit rate + call count from `core.runtime.llm_cost_telemetry.summarise(period="today")`. |
188
+ | `/arka status` | System status (version, departments, agents, active projects). Includes **LLM costs (24h)** section: top-line cost + cache hit rate + call count from `core.runtime.llm_cost_telemetry.summarise(period="today")`. Also includes **Enforcement (24h)** section: total calls, block rate, top blocked tools/reasons from `core.governance.enforcement_telemetry.summarise(period="today")` (PR19 v2.41.0). |
189
189
  | `/arka costs [period]` | LLM cost visibility — aggregates telemetry by day/week/month/all, with top expensive sessions. See `arka/skills/costs/SKILL.md`. Shells out to `python -m core.runtime.llm_cost_telemetry_cli <period>`. |
190
+ | `/arka enforcement [period]` | Enforcement compliance — aggregates flow-marker enforcement telemetry by day/week/month/all. Shows block rate, top blocked tools, top reasons. Shells out to `python -m core.governance.enforcement_telemetry_cli <period>`. |
190
191
  | `/arka standup` | Daily standup (projects, priorities, blockers, updates) |
191
192
  | `/arka monitor` | System health monitoring |
192
193
  | `/arka onboard <path>` | Onboard an existing project into ArkaOS |
@@ -113,6 +113,47 @@ try:
113
113
  except Exception:
114
114
  pass
115
115
 
116
+ # PR18 v2.40.0 — KB citation soft-block. Records whether the closing
117
+ # assistant message cited the vault on an ArkaOS topic. Result is also
118
+ # persisted to /tmp/arkaos-cite/<session>.json so the next UserPromptSubmit
119
+ # can surface a nudge if passed=False. Non-blocking; never raises.
120
+ cite_passed = True
121
+ cite_reason = "trivial"
122
+ cite_count = 0
123
+ cite_topic_score = 0.0
124
+ cite_suggestion: str | None = None
125
+ try:
126
+ from core.governance.kb_cite_check import check_citation
127
+ cr = check_citation(last)
128
+ cite_passed = cr.passed
129
+ cite_reason = cr.reason
130
+ cite_count = cr.citation_count
131
+ cite_topic_score = cr.topic_score
132
+ cite_suggestion = cr.suggestion
133
+ # PR18 security fix: session_id comes from the runtime via stdin JSON
134
+ # and is untrusted. Reuse the shared allowlist before building any
135
+ # filesystem path. Reject (skip write) on anything outside
136
+ # [A-Za-z0-9._-]{1,128} — no `..`, no slashes, no whitespace, no NUL.
137
+ try:
138
+ from core.shared.safe_session_id import safe_session_id as _safe_sid
139
+ safe_sid = _safe_sid(session_id)
140
+ except Exception:
141
+ safe_sid = None
142
+ if safe_sid:
143
+ cite_dir = Path("/tmp/arkaos-cite")
144
+ cite_dir.mkdir(parents=True, exist_ok=True)
145
+ cite_path = cite_dir / f"{safe_sid}.json"
146
+ cite_payload = {
147
+ "passed": cr.passed,
148
+ "reason": cr.reason,
149
+ "suggestion": cr.suggestion,
150
+ "citation_count": cr.citation_count,
151
+ "topic_score": cr.topic_score,
152
+ }
153
+ cite_path.write_text(json.dumps(cite_payload), encoding="utf-8")
154
+ except Exception:
155
+ pass
156
+
116
157
  entry = {
117
158
  "ts": datetime.now(timezone.utc).isoformat(),
118
159
  "session_id": session_id,
@@ -125,6 +166,10 @@ entry = {
125
166
  "sycophancy_is_flagged": is_sycophantic,
126
167
  "sycophancy_signals": sycophancy_signals,
127
168
  "sycophancy_confidence": sycophancy_confidence,
169
+ "kb_cite_passed": cite_passed,
170
+ "kb_cite_reason": cite_reason,
171
+ "kb_cite_count": cite_count,
172
+ "kb_cite_topic_score": cite_topic_score,
128
173
  "mode": "warn",
129
174
  }
130
175
 
@@ -370,9 +370,34 @@ if [ -n "$SESSION_ID" ]; then
370
370
  fi
371
371
  fi
372
372
 
373
+ # ─── KB citation nudge (PR18 v2.40.0) ────────────────────────────────────
374
+ # Read the cite-check result written by the previous Stop hook. If the
375
+ # last assistant turn was on an ArkaOS topic without any citation, surface
376
+ # the suggestion to the model in this turn's additionalContext. One-shot:
377
+ # the file is deleted after read so the nudge does not repeat across turns.
378
+ _KB_CITE_NUDGE=""
379
+ if [ -n "$SESSION_ID" ]; then
380
+ _CITE_FILE="/tmp/arkaos-cite/${SESSION_ID}.json"
381
+ if [ -f "$_CITE_FILE" ]; then
382
+ if command -v jq &>/dev/null; then
383
+ # NOTE: do not use `// true` here — jq's `//` treats false as needing
384
+ # the default, which would suppress the nudge in the exact case we
385
+ # care about. Read .passed raw and compare to the literal "false".
386
+ _CITE_PASSED=$(jq -r '.passed' "$_CITE_FILE" 2>/dev/null)
387
+ _CITE_SUGGEST=$(jq -r '.suggestion // ""' "$_CITE_FILE" 2>/dev/null)
388
+ if [ "$_CITE_PASSED" = "false" ] && [ -n "$_CITE_SUGGEST" ] && [ "$_CITE_SUGGEST" != "null" ]; then
389
+ _KB_CITE_NUDGE="[arka:suggest] ${_CITE_SUGGEST}"
390
+ fi
391
+ fi
392
+ rm -f "$_CITE_FILE" 2>/dev/null
393
+ fi
394
+ fi
395
+
373
396
  # ─── Output ──────────────────────────────────────────────────────────────
374
397
  _OUT_CONTEXT="${_ARKA_GREETING:-}${_SYNC_NOTICE:-}${_ROUTE_REMINDER}${_WORKFLOW_DIRECTIVE} $python_result"
375
398
  [ -n "$_HYGIENE" ] && _OUT_CONTEXT="$_OUT_CONTEXT $_HYGIENE"
399
+ [ -n "$_KB_CITE_NUDGE" ] && _OUT_CONTEXT="$_OUT_CONTEXT
400
+ $_KB_CITE_NUDGE"
376
401
  [ -n "$_ARKA_CONTEXT_HITS" ] && _OUT_CONTEXT="$_OUT_CONTEXT
377
402
  $_ARKA_CONTEXT_HITS"
378
403
  # Escape for JSON
@@ -1,5 +1,11 @@
1
1
  """Governance engine — Constitution, quality gates, audit trails."""
2
2
 
3
3
  from core.governance.constitution import Constitution, load_constitution
4
+ from core.governance.kb_cite_check import CitationResult, check_citation
4
5
 
5
- __all__ = ["Constitution", "load_constitution"]
6
+ __all__ = [
7
+ "CitationResult",
8
+ "Constitution",
9
+ "check_citation",
10
+ "load_constitution",
11
+ ]
@@ -0,0 +1,144 @@
1
+ """Enforcement telemetry summarizer (PR19 v2.41.0).
2
+
3
+ Reads ``~/.arkaos/telemetry/enforcement.jsonl`` (the JSONL stream the
4
+ PreToolUse hook appends to on every gated tool decision) and produces
5
+ compact summaries for ``/arka status`` and downstream tuning.
6
+
7
+ Mirrors the pattern of ``core.runtime.llm_cost_telemetry`` so periods,
8
+ malformed-line tolerance, and zero-division safety behave the same way
9
+ across the two telemetry surfaces. Read-only — never writes to the
10
+ JSONL itself.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from collections import Counter
17
+ from dataclasses import dataclass, field
18
+ from datetime import datetime, timedelta, timezone
19
+ from pathlib import Path
20
+ from typing import Any, Iterable
21
+
22
+ DEFAULT_PATH: Path = Path.home() / ".arkaos" / "telemetry" / "enforcement.jsonl"
23
+ _VALID_PERIODS: frozenset[str] = frozenset({"today", "week", "month", "all"})
24
+ _TOP_N: int = 5
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class EnforcementSummary:
29
+ """Aggregated enforcement telemetry over a time slice."""
30
+ period: str
31
+ total_calls: int
32
+ blocked_calls: int
33
+ block_rate: float
34
+ bypass_used: int
35
+ top_blocked_tools: list[tuple[str, int]] = field(default_factory=list)
36
+ top_block_reasons: list[tuple[str, int]] = field(default_factory=list)
37
+ corrupt_line_count: int = 0
38
+
39
+
40
+ def summarise(period: str, *, path: Path | None = None) -> EnforcementSummary:
41
+ """Return an EnforcementSummary for the requested period.
42
+
43
+ period: one of "today", "week", "month", "all".
44
+ path: override telemetry source (used by tests; defaults to DEFAULT_PATH).
45
+ """
46
+ if period not in _VALID_PERIODS:
47
+ raise ValueError(f"invalid period: {period!r}")
48
+ src = path or DEFAULT_PATH
49
+ cutoff = _period_cutoff(period)
50
+ entries, corrupt = _read_jsonl(src, cutoff)
51
+ return _build_summary(period, entries, corrupt)
52
+
53
+
54
+ def _period_cutoff(period: str, now: datetime | None = None) -> datetime | None:
55
+ ref = now or datetime.now(timezone.utc)
56
+ if period == "today":
57
+ return ref.replace(hour=0, minute=0, second=0, microsecond=0)
58
+ if period == "week":
59
+ return ref - timedelta(days=7)
60
+ if period == "month":
61
+ return ref - timedelta(days=30)
62
+ return None
63
+
64
+
65
+ def _read_jsonl(src: Path, cutoff: datetime | None) -> tuple[list[dict[str, Any]], int]:
66
+ if not src.exists():
67
+ return [], 0
68
+ entries: list[dict[str, Any]] = []
69
+ corrupt = 0
70
+ # Line-stream the file to keep memory O(1) regardless of telemetry size.
71
+ # A runaway hook could grow this file to multiple GB; reading it whole
72
+ # would OOM the /arka status / /arka enforcement caller.
73
+ try:
74
+ with src.open("r", encoding="utf-8", errors="replace") as fh:
75
+ for line in fh:
76
+ if not line.strip():
77
+ continue
78
+ try:
79
+ entry = json.loads(line)
80
+ except json.JSONDecodeError:
81
+ corrupt += 1
82
+ continue
83
+ if not isinstance(entry, dict):
84
+ corrupt += 1
85
+ continue
86
+ if cutoff is not None and not _within_cutoff(entry, cutoff):
87
+ continue
88
+ entries.append(entry)
89
+ except OSError:
90
+ return entries, corrupt
91
+ return entries, corrupt
92
+
93
+
94
+ def _within_cutoff(entry: dict[str, Any], cutoff: datetime) -> bool:
95
+ ts = _parse_ts(entry.get("ts"))
96
+ if ts is None:
97
+ return False
98
+ return ts >= cutoff
99
+
100
+
101
+ def _parse_ts(raw: Any) -> datetime | None:
102
+ if not isinstance(raw, str) or not raw:
103
+ return None
104
+ try:
105
+ parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
106
+ except ValueError:
107
+ return None
108
+ if parsed.tzinfo is None:
109
+ parsed = parsed.replace(tzinfo=timezone.utc)
110
+ return parsed
111
+
112
+
113
+ def _build_summary(
114
+ period: str,
115
+ entries: Iterable[dict[str, Any]],
116
+ corrupt: int,
117
+ ) -> EnforcementSummary:
118
+ total = 0
119
+ blocked = 0
120
+ bypass = 0
121
+ blocked_tools: Counter[str] = Counter()
122
+ block_reasons: Counter[str] = Counter()
123
+ for entry in entries:
124
+ total += 1
125
+ if entry.get("bypass_used"):
126
+ bypass += 1
127
+ if entry.get("allow") is False:
128
+ blocked += 1
129
+ tool = str(entry.get("tool", ""))
130
+ reason = str(entry.get("reason", ""))
131
+ if tool:
132
+ blocked_tools[tool] += 1
133
+ if reason:
134
+ block_reasons[reason] += 1
135
+ return EnforcementSummary(
136
+ period=period,
137
+ total_calls=total,
138
+ blocked_calls=blocked,
139
+ block_rate=(blocked / total) if total else 0.0,
140
+ bypass_used=bypass,
141
+ top_blocked_tools=blocked_tools.most_common(_TOP_N),
142
+ top_block_reasons=block_reasons.most_common(_TOP_N),
143
+ corrupt_line_count=corrupt,
144
+ )
@@ -0,0 +1,67 @@
1
+ """CLI entry for the enforcement telemetry summarizer (PR19 v2.41.0).
2
+
3
+ Invoked as ``python -m core.governance.enforcement_telemetry_cli <period>``
4
+ where ``<period>`` is one of: today, week, month, all.
5
+
6
+ Output is plain markdown so it renders cleanly in both the terminal and
7
+ the ``/arka status`` skill which concatenates it into its report.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+
14
+ from core.governance.enforcement_telemetry import (
15
+ EnforcementSummary,
16
+ summarise,
17
+ )
18
+
19
+
20
+ def _fmt_rate(value: float) -> str:
21
+ return f"{value * 100:.2f}%"
22
+
23
+
24
+ def _sanitize_md(value: str) -> str:
25
+ """Strip markdown-breaking characters before rendering JSONL strings.
26
+
27
+ Telemetry is local-writer-only, but a malformed tool/reason value (newline,
28
+ backtick) can still distort the markdown if /arka status pipes the output
29
+ to a UI. Belt-and-braces against passive corruption, not active attack.
30
+ """
31
+ return value.replace("\n", " ").replace("\r", " ").replace("`", "").strip()
32
+
33
+
34
+ def _render(summary: EnforcementSummary) -> str:
35
+ lines = [
36
+ f"# Enforcement — {summary.period}",
37
+ "",
38
+ f"- Calls: **{summary.total_calls}**",
39
+ f"- Blocked: **{summary.blocked_calls}** ({_fmt_rate(summary.block_rate)})",
40
+ f"- Bypass used (`ARKA_BYPASS_FLOW=1`): **{summary.bypass_used}**",
41
+ ]
42
+ if summary.top_blocked_tools:
43
+ lines += ["", "## Top blocked tools"]
44
+ for tool, count in summary.top_blocked_tools:
45
+ lines.append(f"- `{_sanitize_md(tool)}` — {count}")
46
+ if summary.top_block_reasons:
47
+ lines += ["", "## Top block reasons"]
48
+ for reason, count in summary.top_block_reasons:
49
+ lines.append(f"- {_sanitize_md(reason)} — {count}")
50
+ if summary.corrupt_line_count:
51
+ lines += ["", f"_(skipped {summary.corrupt_line_count} corrupt line(s))_"]
52
+ return "\n".join(lines)
53
+
54
+
55
+ def main(argv: list[str]) -> int:
56
+ period = argv[1] if len(argv) > 1 else "today"
57
+ try:
58
+ summary = summarise(period)
59
+ except ValueError as exc:
60
+ print(f"error: {exc}", file=sys.stderr)
61
+ return 2
62
+ print(_render(summary))
63
+ return 0
64
+
65
+
66
+ if __name__ == "__main__":
67
+ raise SystemExit(main(sys.argv))
@@ -0,0 +1,129 @@
1
+ """KB citation check for ArkaOS responses (PR18 v2.40.0).
2
+
3
+ Soft-block classifier. Inspects an assistant response for KB
4
+ citations (`[[wikilink]]`, `[knowledge:` marker, `file.ext:line`
5
+ code references) and reports whether the response honored the
6
+ KB-first contract on an ArkaOS topic.
7
+
8
+ Non-blocking by design — hooks consume CitationResult and decide
9
+ whether to surface a nudge to the next turn's additionalContext.
10
+ This module never raises; on malformed input it returns a passed
11
+ "trivial" verdict so the caller never crashes the hook pipeline.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from dataclasses import dataclass
18
+
19
+ # ─── Patterns ───────────────────────────────────────────────────────────
20
+
21
+ _WIKILINK_PATTERN: re.Pattern[str] = re.compile(r"\[\[([^\[\]]+)\]\]")
22
+ _KNOWLEDGE_PATTERN: re.Pattern[str] = re.compile(r"\[knowledge:", re.IGNORECASE)
23
+ # File:line — requires at least one path separator. Quantifiers are bounded
24
+ # to prevent catastrophic backtracking on pathological input (PR18 security
25
+ # review: unbounded variant blew past Stop's 5s budget by ~8x on 100KB of
26
+ # `a/a/a/...` with no trailing extension).
27
+ _FILE_LINE_PATTERN: re.Pattern[str] = re.compile(
28
+ r"(?:[\w._-]{1,64}/){1,8}[\w._-]{1,64}\.\w{1,8}:\d{1,6}"
29
+ )
30
+ # Hard upper bound on input scanned for citations. Anything beyond is
31
+ # almost certainly a code dump / log paste and not human prose — skip the
32
+ # more expensive file-line regex entirely above this threshold.
33
+ _MAX_SCAN_CHARS: int = 50_000
34
+
35
+ # ArkaOS topic keywords. Match is case-insensitive substring.
36
+ _ARKAOS_KEYWORDS: frozenset[str] = frozenset({
37
+ "arkaos", "arka", "constitution", "quality gate", "synapse",
38
+ "conclave", "forge", "dreaming", "marta", "eduardo", "francisca",
39
+ "marco", "helena", "sofia", "paulo", "luna", "valentina",
40
+ "tomas", "ricardo", "clara", "daniel", "carolina", "tiago",
41
+ "ines", "rafael", "beatriz", "miguel", "rodrigo",
42
+ "non-negotiable", "tier 0", "squad lead",
43
+ "core/governance", "core/synapse", "core/cognition",
44
+ "core/workflow", "mcp__obsidian", "kb-first",
45
+ "departments/", "agent yaml", "obsidian vault",
46
+ })
47
+
48
+ _BYPASS_DEFAULTS: tuple[str, ...] = ("[arka:trivial]",)
49
+ _TRIVIAL_WORD_THRESHOLD: int = 15
50
+ _TOPIC_THRESHOLD: float = 0.4
51
+ _SUGGESTION_TEXT: str = (
52
+ "KB-first — last response had no citation on ArkaOS topic. "
53
+ "Use @[[note-name]], /kb search, or mcp__obsidian__search_notes "
54
+ "to ground the next answer."
55
+ )
56
+
57
+
58
+ # ─── Result ─────────────────────────────────────────────────────────────
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class CitationResult:
63
+ """Verdict of a citation check. Immutable; safe to log as JSON."""
64
+ passed: bool
65
+ reason: str
66
+ suggestion: str | None
67
+ citation_count: int
68
+ topic_score: float
69
+
70
+
71
+ # ─── Public API ─────────────────────────────────────────────────────────
72
+
73
+
74
+ def check_citation(
75
+ response_text: str,
76
+ *,
77
+ topic_keywords: frozenset[str] | None = None,
78
+ bypass_markers: tuple[str, ...] = _BYPASS_DEFAULTS,
79
+ ) -> CitationResult:
80
+ """Classify an assistant response for KB citation discipline."""
81
+ text = response_text or ""
82
+ citation_count = _count_citations(text)
83
+ topic_score = _compute_topic_score(text, topic_keywords or _ARKAOS_KEYWORDS)
84
+
85
+ if _has_bypass_marker(text, bypass_markers):
86
+ return CitationResult(True, "trivial", None, citation_count, topic_score)
87
+
88
+ if _is_trivial_length(text):
89
+ return CitationResult(True, "trivial", None, citation_count, topic_score)
90
+
91
+ if citation_count > 0:
92
+ return CitationResult(True, "cited", None, citation_count, topic_score)
93
+
94
+ if topic_score < _TOPIC_THRESHOLD:
95
+ return CitationResult(True, "off-topic", None, 0, topic_score)
96
+
97
+ return CitationResult(False, "missing", _SUGGESTION_TEXT, 0, topic_score)
98
+
99
+
100
+ # ─── Helpers (private) ──────────────────────────────────────────────────
101
+
102
+
103
+ def _has_bypass_marker(text: str, markers: tuple[str, ...]) -> bool:
104
+ return any(marker in text for marker in markers)
105
+
106
+
107
+ def _is_trivial_length(text: str) -> bool:
108
+ return len(text.split()) < _TRIVIAL_WORD_THRESHOLD
109
+
110
+
111
+ def _count_citations(text: str) -> int:
112
+ # Truncate to _MAX_SCAN_CHARS first; the file-line regex is the only
113
+ # backtracking-prone pattern and the bounded quantifiers (capped at 8
114
+ # segments × 64 chars each) combined with this slice make all three
115
+ # patterns safe to run unconditionally below.
116
+ scan = text if len(text) <= _MAX_SCAN_CHARS else text[:_MAX_SCAN_CHARS]
117
+ wikilinks = len(_WIKILINK_PATTERN.findall(scan))
118
+ knowledge = 1 if _KNOWLEDGE_PATTERN.search(scan) else 0
119
+ files = len(_FILE_LINE_PATTERN.findall(scan))
120
+ return wikilinks + knowledge + files
121
+
122
+
123
+ def _compute_topic_score(text: str, keywords: frozenset[str]) -> float:
124
+ if not keywords:
125
+ return 0.0
126
+ lower = text.lower()
127
+ hits = sum(1 for k in keywords if k.lower() in lower)
128
+ denom = max(1, len(keywords) // 10)
129
+ return min(1.0, hits / denom)
@@ -214,11 +214,10 @@ class Decision:
214
214
  if self.allow:
215
215
  return ""
216
216
  return (
217
- f"[ARKA:ENFORCEMENT] Flow marker missing. "
218
- f"Emit `[arka:routing] <dept> -> <lead>` or `[arka:trivial] <reason>` "
219
- f"before any tool that mutates state "
220
- f"(Write/Edit/MultiEdit/NotebookEdit/Task/Skill, or Bash with effect commands like rm/mv/git commit/npm install). "
221
- f"Reason: {self.reason}"
217
+ f"[ARKA:ENFORCEMENT] Missing `[arka:routing] <dept> -> <lead>` or "
218
+ f"`[arka:trivial] <reason>` before "
219
+ f"Write/Edit/MultiEdit/NotebookEdit/Task/Skill/Bash-effect. "
220
+ f"Bypass once with `ARKA_BYPASS_FLOW=1`. Reason: {self.reason}."
222
221
  )
223
222
 
224
223
 
@@ -0,0 +1,63 @@
1
+ // ~/.arkaos/config.json seed/migration (PR19 v2.41.0).
2
+ //
3
+ // Run on every `npx arkaos install` and `npx arkaos@latest update`.
4
+ // Idempotent: writes `hooks.hardEnforcement = true` only when the key
5
+ // is unset. Explicit user choice (true OR false) is preserved.
6
+ //
7
+ // Returns a status object:
8
+ // { action: "created" | "added-key" | "noop"
9
+ // | "preserved-user-false" | "rewrote-corrupt" }
10
+ // so the installer caller can log a human-readable line per run.
11
+
12
+ import {
13
+ existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, copyFileSync,
14
+ } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { join, dirname } from "node:path";
17
+
18
+ export function seedArkaosConfig({ home = homedir() } = {}) {
19
+ const cfgPath = join(home, ".arkaos", "config.json");
20
+
21
+ if (!existsSync(cfgPath)) {
22
+ writeConfig(cfgPath, { hooks: { hardEnforcement: true } });
23
+ return { action: "created", path: cfgPath };
24
+ }
25
+
26
+ let config;
27
+ try {
28
+ config = JSON.parse(readFileSync(cfgPath, "utf-8"));
29
+ if (typeof config !== "object" || config === null) {
30
+ throw new Error("config root is not an object");
31
+ }
32
+ } catch {
33
+ // Corrupt JSON — keep the broken copy for recovery, write safe default.
34
+ const backup = `${cfgPath}.broken-${Date.now()}`;
35
+ try { copyFileSync(cfgPath, backup); } catch { /* best effort */ }
36
+ writeConfig(cfgPath, { hooks: { hardEnforcement: true } });
37
+ return { action: "rewrote-corrupt", path: cfgPath, backup };
38
+ }
39
+
40
+ config.hooks = config.hooks && typeof config.hooks === "object" ? config.hooks : {};
41
+ const current = config.hooks.hardEnforcement;
42
+
43
+ if (current === true) {
44
+ return { action: "noop", path: cfgPath };
45
+ }
46
+ if (current === false) {
47
+ return { action: "preserved-user-false", path: cfgPath };
48
+ }
49
+
50
+ // Key unset (undefined, null, or any non-boolean) — set to true.
51
+ config.hooks.hardEnforcement = true;
52
+ writeConfig(cfgPath, config);
53
+ return { action: "added-key", path: cfgPath };
54
+ }
55
+
56
+ function writeConfig(cfgPath, payload) {
57
+ mkdirSync(dirname(cfgPath), { recursive: true });
58
+ // Atomic write: render to a sibling .tmp then rename. Prevents partial-write
59
+ // corruption if the process is interrupted between open and close.
60
+ const tmp = `${cfgPath}.tmp-${process.pid}`;
61
+ writeFileSync(tmp, JSON.stringify(payload, null, 2) + "\n");
62
+ renameSync(tmp, cfgPath);
63
+ }
@@ -275,6 +275,26 @@ export async function install({ runtime, path, force, skipSystem, withOllama })
275
275
 
276
276
  // ═══ Step 14: Finalize ═══
277
277
  step(14, 14, "Finalizing...");
278
+
279
+ // PR19 v2.41.0 — seed/migrate hooks.hardEnforcement so the PreToolUse
280
+ // gate blocks tool calls without [arka:routing] on fresh installs.
281
+ // Idempotent + preserves explicit user `false`.
282
+ try {
283
+ const { seedArkaosConfig } = await import("./config-seed.js");
284
+ const seedResult = seedArkaosConfig({ home: homedir() });
285
+ if (seedResult.action === "created") {
286
+ console.log(` hardEnforcement enabled (default).`);
287
+ } else if (seedResult.action === "added-key") {
288
+ console.log(` hardEnforcement enabled (key was unset).`);
289
+ } else if (seedResult.action === "preserved-user-false") {
290
+ console.log(` hardEnforcement is OFF (user-set, preserved).`);
291
+ } else if (seedResult.action === "rewrote-corrupt") {
292
+ console.log(` config.json was corrupt — rewrote, backup at ${seedResult.backup}`);
293
+ }
294
+ } catch (err) {
295
+ console.log(` Warning: could not seed config.json (${err.message})`);
296
+ }
297
+
278
298
  const manifest = {
279
299
  version: VERSION,
280
300
  runtime,
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "2.39.0",
3
+ "version": "2.41.0",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
7
- "arkaos": "./installer/cli.js"
7
+ "arkaos": "installer/cli.js"
8
8
  },
9
9
  "scripts": {
10
10
  "test": "node --test \"tests/installer/**/*.test.js\"",
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arkaos-core"
3
- version = "2.39.0"
3
+ version = "2.41.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"}