arkaos 2.40.0 → 2.42.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 +1 -1
- package/arka/SKILL.md +3 -1
- package/core/cognition/__pycache__/reorganizer.cpython-313.pyc +0 -0
- package/core/cognition/__pycache__/reorganizer_cli.cpython-313.pyc +0 -0
- package/core/cognition/reorganizer.py +412 -0
- package/core/cognition/reorganizer_cli.py +72 -0
- package/core/governance/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/enforcement_telemetry.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/enforcement_telemetry_cli.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/kb_cite_check.cpython-313.pyc +0 -0
- package/core/governance/enforcement_telemetry.py +144 -0
- package/core/governance/enforcement_telemetry_cli.py +67 -0
- package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
- package/core/workflow/flow_enforcer.py +4 -5
- package/installer/config-seed.js +63 -0
- package/installer/index.js +20 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
1
|
+
2.42.0
|
package/arka/SKILL.md
CHANGED
|
@@ -185,8 +185,10 @@ 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>`. |
|
|
191
|
+
| `/arka reorganize [--since-days N]` | Dreaming → Agent reorganizer. Aggregates recent KB pattern/anti-pattern/lesson artifacts (default last 7 days) into a markdown proposal at `~/.arkaos/reorganize-proposals/<date>.md`. **Propose-only** — never modifies agent YAMLs. Sanitizes client identifiers from titles and body excerpts; drops `tags:` field entirely to prevent project-name leaks. Shells out to `python -m core.cognition.reorganizer_cli [--since-days N] [--dry-run]`. |
|
|
190
192
|
| `/arka standup` | Daily standup (projects, priorities, blockers, updates) |
|
|
191
193
|
| `/arka monitor` | System health monitoring |
|
|
192
194
|
| `/arka onboard <path>` | Onboard an existing project into ArkaOS |
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Dreaming → Agent reorganizer MVP (PR20 v2.42.0).
|
|
2
|
+
|
|
3
|
+
Propose-only. Scans the KB for pattern/anti-pattern/lesson artifacts
|
|
4
|
+
produced by Dreaming, sanitizes client identifiers, and renders a
|
|
5
|
+
markdown proposal report. Never modifies agent YAMLs — that step is
|
|
6
|
+
left for human review (and PR21 will wire optional auto-PR creation).
|
|
7
|
+
|
|
8
|
+
Reads only. The only write is the proposal markdown file under
|
|
9
|
+
``output_dir`` (default ``~/.arkaos/reorganize-proposals/``), and only
|
|
10
|
+
when ``dry_run=False``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import tempfile
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from datetime import datetime, timedelta, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
_FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n(.*)", re.DOTALL)
|
|
24
|
+
_BODY_EXCERPT_LIMIT = 500
|
|
25
|
+
_PROPOSAL_FILENAME_FMT = "%Y-%m-%d.md"
|
|
26
|
+
_DEFAULT_OUTPUT_DIR = Path.home() / ".arkaos" / "reorganize-proposals"
|
|
27
|
+
_REDACT_TOKEN = "<redacted-client>"
|
|
28
|
+
|
|
29
|
+
# Client-name redaction patterns are loaded from a user-local JSON file —
|
|
30
|
+
# NEVER hard-coded in source. v2.18.0 shipped client names in source and
|
|
31
|
+
# leaked them to the npm registry (see feedback_npm_publish_safety in
|
|
32
|
+
# user memory); v2.42.0 fixes that class of mistake by design.
|
|
33
|
+
#
|
|
34
|
+
# File: ~/.arkaos/redaction-clients.json
|
|
35
|
+
# Shape: {"clients": ["client-a", "client-b", ...]}
|
|
36
|
+
# Missing file or malformed JSON → empty list (no redaction, tags-drop
|
|
37
|
+
# safety net at `_render` is the architectural backstop).
|
|
38
|
+
#
|
|
39
|
+
# Word boundary uses negative lookaround that allows hyphens so
|
|
40
|
+
# `client-a-billing-quirk` redacts the `client-a` segment correctly
|
|
41
|
+
# without false-positives on `client-axfoo`.
|
|
42
|
+
_REDACT_CONFIG_PATH = Path.home() / ".arkaos" / "redaction-clients.json"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _load_client_patterns() -> tuple[str, ...]:
|
|
46
|
+
"""Read the user-local redaction list. Empty tuple on any error."""
|
|
47
|
+
try:
|
|
48
|
+
data = json.loads(_REDACT_CONFIG_PATH.read_text(encoding="utf-8"))
|
|
49
|
+
raw = data.get("clients", [])
|
|
50
|
+
return tuple(str(c).strip().lower() for c in raw if c and isinstance(c, str))
|
|
51
|
+
except (OSError, json.JSONDecodeError, AttributeError):
|
|
52
|
+
return ()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _build_redact_re(patterns: tuple[str, ...]) -> re.Pattern[str] | None:
|
|
56
|
+
if not patterns:
|
|
57
|
+
return None
|
|
58
|
+
escaped = [re.escape(p) for p in patterns]
|
|
59
|
+
return re.compile(
|
|
60
|
+
r"(?<![a-z0-9])(" + "|".join(escaped) + r")(?![a-z0-9])",
|
|
61
|
+
re.IGNORECASE,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_CLIENT_PATTERNS: tuple[str, ...] = _load_client_patterns()
|
|
66
|
+
_REDACT_RE: re.Pattern[str] | None = _build_redact_re(_CLIENT_PATTERNS)
|
|
67
|
+
|
|
68
|
+
_KNOWN_CATEGORIES = ("pattern", "anti-pattern", "lesson")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class KbArtifact:
|
|
73
|
+
"""One pattern / anti-pattern / lesson surfaced from the KB."""
|
|
74
|
+
path: Path
|
|
75
|
+
category: str
|
|
76
|
+
title: str
|
|
77
|
+
confidence: str
|
|
78
|
+
tags: list[str] = field(default_factory=list)
|
|
79
|
+
first_seen: str = ""
|
|
80
|
+
last_seen: str = ""
|
|
81
|
+
times_used: int = 0
|
|
82
|
+
body_excerpt: str = ""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class ProposalReport:
|
|
87
|
+
"""Result of build_proposal — never contains client identifiers."""
|
|
88
|
+
generated_at: str
|
|
89
|
+
since_days: int
|
|
90
|
+
kb_dir: str
|
|
91
|
+
artifact_count: int
|
|
92
|
+
by_category: dict[str, int] = field(default_factory=dict)
|
|
93
|
+
report_markdown: str = ""
|
|
94
|
+
report_path: Path | None = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def build_proposal(
|
|
98
|
+
kb_dir: Path,
|
|
99
|
+
*,
|
|
100
|
+
since_days: int = 7,
|
|
101
|
+
output_dir: Path | None = None,
|
|
102
|
+
dry_run: bool = False,
|
|
103
|
+
) -> ProposalReport:
|
|
104
|
+
"""Aggregate recent KB artifacts into a propose-only markdown report."""
|
|
105
|
+
kb_dir = Path(kb_dir)
|
|
106
|
+
cutoff = datetime.now(timezone.utc).date() - timedelta(days=max(since_days - 1, 0))
|
|
107
|
+
artifacts = _scan_kb(kb_dir, cutoff)
|
|
108
|
+
by_category = _aggregate_by_category(artifacts)
|
|
109
|
+
generated_at = datetime.now(timezone.utc).isoformat()
|
|
110
|
+
markdown = _render(artifacts, by_category, since_days, kb_dir, generated_at)
|
|
111
|
+
report_path = None if dry_run else _write_report(markdown, output_dir)
|
|
112
|
+
return ProposalReport(
|
|
113
|
+
generated_at=generated_at,
|
|
114
|
+
since_days=since_days,
|
|
115
|
+
kb_dir=str(kb_dir),
|
|
116
|
+
artifact_count=len(artifacts),
|
|
117
|
+
by_category=by_category,
|
|
118
|
+
report_markdown=markdown,
|
|
119
|
+
report_path=report_path,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _aggregate_by_category(artifacts: list[KbArtifact]) -> dict[str, int]:
|
|
124
|
+
counts: dict[str, int] = {}
|
|
125
|
+
for art in artifacts:
|
|
126
|
+
counts[art.category] = counts.get(art.category, 0) + 1
|
|
127
|
+
return counts
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _write_report(markdown: str, output_dir: Path | None) -> Path:
|
|
131
|
+
"""Atomic markdown write to a validated output directory."""
|
|
132
|
+
out = _validate_output_dir(output_dir)
|
|
133
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
report_path = out / datetime.now(timezone.utc).strftime(_PROPOSAL_FILENAME_FMT)
|
|
135
|
+
tmp_path = report_path.with_suffix(f".tmp-{os.getpid()}.md")
|
|
136
|
+
tmp_path.write_text(markdown, encoding="utf-8")
|
|
137
|
+
os.replace(tmp_path, report_path)
|
|
138
|
+
return report_path
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _validate_output_dir(output_dir: Path | None) -> Path:
|
|
142
|
+
"""Allowlist the output directory to a safe parent.
|
|
143
|
+
|
|
144
|
+
Without this guard, programmatic callers passing an attacker-controlled
|
|
145
|
+
``output_dir`` could write the proposal report anywhere the process has
|
|
146
|
+
write access (e.g., ``~/.ssh/``). Even though the CLI does not expose
|
|
147
|
+
``--output-dir``, the public API does — defence in depth.
|
|
148
|
+
|
|
149
|
+
Allowed roots:
|
|
150
|
+
- ``~/.arkaos`` — the canonical production location
|
|
151
|
+
- the system tempdir — for tests using pytest's ``tmp_path`` and any
|
|
152
|
+
deliberate scratch use; still bounded by OS process privilege
|
|
153
|
+
"""
|
|
154
|
+
if output_dir is None:
|
|
155
|
+
return _DEFAULT_OUTPUT_DIR
|
|
156
|
+
resolved = Path(output_dir).expanduser().resolve()
|
|
157
|
+
allowed_roots = (
|
|
158
|
+
(Path.home() / ".arkaos").resolve(),
|
|
159
|
+
Path(tempfile.gettempdir()).resolve(),
|
|
160
|
+
)
|
|
161
|
+
for root in allowed_roots:
|
|
162
|
+
try:
|
|
163
|
+
resolved.relative_to(root)
|
|
164
|
+
return resolved
|
|
165
|
+
except ValueError:
|
|
166
|
+
continue
|
|
167
|
+
raise ValueError(
|
|
168
|
+
"output_dir must be under one of "
|
|
169
|
+
f"{[str(r) for r in allowed_roots]}; got {resolved}"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _scan_kb(kb_dir: Path, cutoff) -> list[KbArtifact]:
|
|
174
|
+
if not kb_dir.is_dir():
|
|
175
|
+
return []
|
|
176
|
+
out: list[KbArtifact] = []
|
|
177
|
+
for md in sorted(kb_dir.rglob("*.md")):
|
|
178
|
+
category = _category_from_filename(md.name)
|
|
179
|
+
if category is None:
|
|
180
|
+
continue
|
|
181
|
+
try:
|
|
182
|
+
text = md.read_text(encoding="utf-8")
|
|
183
|
+
except OSError:
|
|
184
|
+
continue
|
|
185
|
+
artifact = _parse_artifact(md, text, category)
|
|
186
|
+
if artifact is None:
|
|
187
|
+
continue
|
|
188
|
+
if not _within_window(artifact, cutoff):
|
|
189
|
+
continue
|
|
190
|
+
out.append(artifact)
|
|
191
|
+
return out
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _category_from_filename(name: str) -> str | None:
|
|
195
|
+
lower = name.lower()
|
|
196
|
+
if lower.startswith("anti-pattern-"):
|
|
197
|
+
return "anti-pattern"
|
|
198
|
+
if lower.startswith("pattern-"):
|
|
199
|
+
return "pattern"
|
|
200
|
+
if lower.startswith("lesson-"):
|
|
201
|
+
return "lesson"
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _parse_artifact(path: Path, text: str, default_category: str) -> KbArtifact | None:
|
|
206
|
+
match = _FRONTMATTER_RE.match(text)
|
|
207
|
+
if not match:
|
|
208
|
+
return None
|
|
209
|
+
fm = _parse_frontmatter(match.group(1))
|
|
210
|
+
if not fm:
|
|
211
|
+
return None
|
|
212
|
+
body = match.group(2)
|
|
213
|
+
category = str(fm.get("category", default_category))
|
|
214
|
+
if category not in _KNOWN_CATEGORIES:
|
|
215
|
+
category = default_category
|
|
216
|
+
raw_title = str(fm.get("title", path.stem))
|
|
217
|
+
excerpt = _redact(_body_excerpt(body))
|
|
218
|
+
times_used = _safe_int(fm.get("times_used"))
|
|
219
|
+
return KbArtifact(
|
|
220
|
+
path=path,
|
|
221
|
+
category=category,
|
|
222
|
+
title=_redact(raw_title),
|
|
223
|
+
confidence=str(fm.get("confidence", "")),
|
|
224
|
+
tags=_parse_list(fm.get("tags")),
|
|
225
|
+
first_seen=str(fm.get("first_seen", "")),
|
|
226
|
+
last_seen=str(fm.get("last_seen", "")),
|
|
227
|
+
times_used=times_used,
|
|
228
|
+
body_excerpt=excerpt,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _within_window(artifact: KbArtifact, cutoff) -> bool:
|
|
233
|
+
for raw in (artifact.last_seen, artifact.first_seen):
|
|
234
|
+
try:
|
|
235
|
+
seen = datetime.strptime(raw, "%Y-%m-%d").date()
|
|
236
|
+
except (ValueError, TypeError):
|
|
237
|
+
continue
|
|
238
|
+
if seen >= cutoff:
|
|
239
|
+
return True
|
|
240
|
+
return not artifact.first_seen and not artifact.last_seen
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _parse_frontmatter(block: str) -> dict:
|
|
244
|
+
out: dict[str, object] = {}
|
|
245
|
+
current_key: str | None = None
|
|
246
|
+
current_list: list[str] = []
|
|
247
|
+
for raw_line in block.splitlines():
|
|
248
|
+
if not raw_line.strip():
|
|
249
|
+
continue
|
|
250
|
+
if raw_line.startswith(" - "):
|
|
251
|
+
current_list.append(raw_line[4:].strip())
|
|
252
|
+
continue
|
|
253
|
+
if current_key is not None and current_list:
|
|
254
|
+
out[current_key] = list(current_list)
|
|
255
|
+
current_list = []
|
|
256
|
+
current_key = None
|
|
257
|
+
if ":" not in raw_line:
|
|
258
|
+
continue
|
|
259
|
+
key, _, value = raw_line.partition(":")
|
|
260
|
+
key, value = key.strip(), value.strip()
|
|
261
|
+
if value == "":
|
|
262
|
+
current_key = key
|
|
263
|
+
continue
|
|
264
|
+
out[key] = _parse_inline_value(value)
|
|
265
|
+
if current_key is not None and current_list:
|
|
266
|
+
out[current_key] = list(current_list)
|
|
267
|
+
return out
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _parse_inline_value(value: str) -> object:
|
|
271
|
+
if value.startswith("[") and value.endswith("]"):
|
|
272
|
+
inner = value[1:-1].strip()
|
|
273
|
+
if not inner:
|
|
274
|
+
return []
|
|
275
|
+
return [item.strip() for item in inner.split(",")]
|
|
276
|
+
return value
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _parse_list(value: object) -> list[str]:
|
|
280
|
+
if isinstance(value, list):
|
|
281
|
+
return [str(v) for v in value]
|
|
282
|
+
if isinstance(value, str) and value:
|
|
283
|
+
return [value]
|
|
284
|
+
return []
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _safe_int(value: object) -> int:
|
|
288
|
+
try:
|
|
289
|
+
return int(str(value))
|
|
290
|
+
except (TypeError, ValueError):
|
|
291
|
+
return 0
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _redact(text: str) -> str:
|
|
295
|
+
if _REDACT_RE is None:
|
|
296
|
+
return text
|
|
297
|
+
return _REDACT_RE.sub(_REDACT_TOKEN, text)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _md_escape(text: str) -> str:
|
|
301
|
+
"""Escape markdown control characters that would distort a table row.
|
|
302
|
+
|
|
303
|
+
Titles and excerpts come from frontmatter the operator controls, but
|
|
304
|
+
a `|` in a title silently shifts table columns and corrupts the raw-
|
|
305
|
+
artifact table. Escape pipes, newlines, and stray backticks.
|
|
306
|
+
"""
|
|
307
|
+
return (
|
|
308
|
+
text.replace("\\", "\\\\")
|
|
309
|
+
.replace("|", "\\|")
|
|
310
|
+
.replace("\n", " ")
|
|
311
|
+
.replace("\r", " ")
|
|
312
|
+
.replace("`", "")
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _body_excerpt(body: str) -> str:
|
|
317
|
+
stripped = body.strip()
|
|
318
|
+
if len(stripped) <= _BODY_EXCERPT_LIMIT:
|
|
319
|
+
return stripped
|
|
320
|
+
return stripped[:_BODY_EXCERPT_LIMIT].rstrip() + "..."
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _render(
|
|
324
|
+
artifacts: list[KbArtifact],
|
|
325
|
+
by_category: dict[str, int],
|
|
326
|
+
since_days: int,
|
|
327
|
+
kb_dir: Path,
|
|
328
|
+
generated_at: str,
|
|
329
|
+
) -> str:
|
|
330
|
+
today = generated_at.split("T", 1)[0]
|
|
331
|
+
parts = [
|
|
332
|
+
_render_header(today, len(artifacts), since_days),
|
|
333
|
+
_render_summary(by_category, since_days, len(artifacts)),
|
|
334
|
+
]
|
|
335
|
+
if not artifacts:
|
|
336
|
+
parts.append("\n_(no artifacts in window — nothing to propose)_")
|
|
337
|
+
return "\n".join(parts)
|
|
338
|
+
parts.append(_render_suggested_actions(artifacts))
|
|
339
|
+
parts.append("## Raw artifact list\n\n" + _render_table(artifacts))
|
|
340
|
+
return "\n".join(parts)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _render_header(today: str, count: int, since_days: int) -> str:
|
|
344
|
+
return (
|
|
345
|
+
f"# ArkaOS Reorganization Proposal — {today}\n"
|
|
346
|
+
"\n"
|
|
347
|
+
f"> Generated by `/arka reorganize` from {count} artifact(s) "
|
|
348
|
+
f"learned in the last {since_days} days.\n"
|
|
349
|
+
"> **Propose-only** — no agent YAML changes have been applied."
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _render_summary(by_category: dict[str, int], since_days: int, count: int) -> str:
|
|
354
|
+
lines = [
|
|
355
|
+
"\n## Summary\n",
|
|
356
|
+
f"- Window: last {since_days} days",
|
|
357
|
+
f"- Artifacts: **{count}**",
|
|
358
|
+
]
|
|
359
|
+
for cat in _KNOWN_CATEGORIES:
|
|
360
|
+
if cat in by_category:
|
|
361
|
+
lines.append(f"- {cat.capitalize()}s: {by_category[cat]}")
|
|
362
|
+
return "\n".join(lines)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _render_suggested_actions(artifacts: list[KbArtifact]) -> str:
|
|
366
|
+
lines = ["\n## Suggested actions\n"]
|
|
367
|
+
for cat in _KNOWN_CATEGORIES:
|
|
368
|
+
cat_items = [a for a in artifacts if a.category == cat]
|
|
369
|
+
if not cat_items:
|
|
370
|
+
continue
|
|
371
|
+
lines.append(f"### {cat.capitalize()}s\n")
|
|
372
|
+
for art in cat_items:
|
|
373
|
+
lines.append(_render_artifact_bullet(art, cat))
|
|
374
|
+
return "\n".join(lines)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _render_artifact_bullet(art: KbArtifact, category: str) -> str:
|
|
378
|
+
# Tags intentionally NOT rendered — see _CLIENT_PATTERNS comment.
|
|
379
|
+
line = (
|
|
380
|
+
f"- **{art.title}** (confidence: {art.confidence or 'n/a'}, "
|
|
381
|
+
f"times_used: {art.times_used})\n"
|
|
382
|
+
f" suggested: review for {_suggest_target(category)}"
|
|
383
|
+
)
|
|
384
|
+
if art.body_excerpt:
|
|
385
|
+
line += f"\n > {art.body_excerpt}"
|
|
386
|
+
return line + "\n"
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _suggest_target(category: str) -> str:
|
|
390
|
+
if category == "pattern":
|
|
391
|
+
return "surfacing in the relevant department squad's context"
|
|
392
|
+
if category == "anti-pattern":
|
|
393
|
+
return "candidate detector in `core/governance/learning_detector.py`"
|
|
394
|
+
if category == "lesson":
|
|
395
|
+
return "Tier 0 review — potential constitution amendment"
|
|
396
|
+
return "manual review"
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _render_table(artifacts: list[KbArtifact]) -> str:
|
|
400
|
+
rows = [
|
|
401
|
+
"| Category | Title | Confidence | first_seen | last_seen | times_used |",
|
|
402
|
+
"|---|---|---|---|---|---|",
|
|
403
|
+
]
|
|
404
|
+
for art in artifacts:
|
|
405
|
+
rows.append(
|
|
406
|
+
f"| {_md_escape(art.category)} | {_md_escape(art.title)} "
|
|
407
|
+
f"| {_md_escape(art.confidence) or 'n/a'} "
|
|
408
|
+
f"| {_md_escape(art.first_seen) or 'n/a'} "
|
|
409
|
+
f"| {_md_escape(art.last_seen) or 'n/a'} "
|
|
410
|
+
f"| {art.times_used} |"
|
|
411
|
+
)
|
|
412
|
+
return "\n".join(rows)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""CLI for the propose-only Dreaming reorganizer (PR20 v2.42.0).
|
|
2
|
+
|
|
3
|
+
Invoked as ``python -m core.cognition.reorganizer_cli [options]``.
|
|
4
|
+
Reads recent KB artifacts (pattern / anti-pattern / lesson files),
|
|
5
|
+
sanitizes client identifiers, and writes a proposal markdown report.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from core.cognition.reorganizer import build_proposal
|
|
16
|
+
|
|
17
|
+
# Default KB location — the Obsidian vault subfolder where Dreaming v2
|
|
18
|
+
# writes pattern/anti-pattern/lesson artifacts. Overridable via env var
|
|
19
|
+
# or --kb-dir for tests and unusual installs.
|
|
20
|
+
_DEFAULT_KB_DIR = (
|
|
21
|
+
Path.home()
|
|
22
|
+
/ "Documents" / "Personal" / "Projects"
|
|
23
|
+
/ "WizardingCode Internal" / "ArkaOS" / "Knowledge Base"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
28
|
+
parser = argparse.ArgumentParser(
|
|
29
|
+
prog="arkaos-reorganize",
|
|
30
|
+
description="Aggregate recent KB artifacts into a propose-only "
|
|
31
|
+
"markdown report. Never modifies agent YAMLs.",
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--since-days", type=int, default=7,
|
|
35
|
+
help="Window in days for first_seen/last_seen filter (default: 7).",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--kb-dir", type=Path, default=None,
|
|
39
|
+
help="Override the KB directory to scan (default: ArkaOS vault).",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--dry-run", action="store_true",
|
|
43
|
+
help="Print the report to stdout, do not write to disk.",
|
|
44
|
+
)
|
|
45
|
+
return parser
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main(argv: list[str]) -> int:
|
|
49
|
+
parser = _build_parser()
|
|
50
|
+
args = parser.parse_args(argv[1:])
|
|
51
|
+
kb_dir = args.kb_dir or Path(os.environ.get("ARKAOS_KB_DIR", _DEFAULT_KB_DIR))
|
|
52
|
+
|
|
53
|
+
report = build_proposal(
|
|
54
|
+
kb_dir,
|
|
55
|
+
since_days=args.since_days,
|
|
56
|
+
dry_run=args.dry_run,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if args.dry_run:
|
|
60
|
+
print(report.report_markdown)
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
print(f"Artifacts: {report.artifact_count}")
|
|
64
|
+
for cat, count in sorted(report.by_category.items()):
|
|
65
|
+
print(f" {cat}: {count}")
|
|
66
|
+
if report.report_path is not None:
|
|
67
|
+
print(f"Report: {report.report_path}")
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
raise SystemExit(main(sys.argv))
|
|
Binary file
|
|
Binary file
|
|
@@ -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))
|
|
Binary file
|
|
@@ -214,11 +214,10 @@ class Decision:
|
|
|
214
214
|
if self.allow:
|
|
215
215
|
return ""
|
|
216
216
|
return (
|
|
217
|
-
f"[ARKA:ENFORCEMENT]
|
|
218
|
-
f"
|
|
219
|
-
f"
|
|
220
|
-
f"
|
|
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
|
+
}
|
package/installer/index.js
CHANGED
|
@@ -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