arkaos 4.21.0 → 4.22.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/README.md +1 -1
- package/VERSION +1 -1
- package/arka/SKILL.md +2 -1
- package/config/claude-agents/marta-cqo.md +9 -0
- package/config/standards/{claude-md-overlays → stack-rules}/laravel.md +11 -0
- package/config/standards/{claude-md-overlays → stack-rules}/node.md +10 -1
- package/config/standards/{claude-md-overlays → stack-rules}/nuxt.md +12 -1
- package/config/standards/stack-rules/php.md +13 -0
- package/config/standards/{claude-md-overlays → stack-rules}/python.md +5 -0
- package/config/standards/stack-rules/react.md +14 -0
- package/config/standards/stack-rules/vue.md +13 -0
- package/core/cognition/evolve.py +299 -0
- package/core/cognition/evolve_cli.py +55 -0
- package/core/governance/stop_lint.py +266 -0
- package/core/governance/stop_lint_telemetry.py +132 -0
- package/core/governance/tool_loop_check.py +111 -0
- package/core/hooks/stop.py +73 -22
- package/core/runtime/pricing.py +53 -19
- package/core/synapse/layers.py +49 -476
- package/core/synapse/layers_base.py +99 -0
- package/core/synapse/layers_kb.py +395 -0
- package/core/sync/content_syncer.py +79 -10
- package/dashboard/app/components/AgentDependencyGraph.vue +5 -5
- package/dashboard/app/components/AgentEditDrawer.vue +120 -52
- package/dashboard/app/components/AgentSuggestionsCard.vue +4 -2
- package/dashboard/app/components/ConfirmDialog.vue +2 -2
- package/dashboard/app/components/DashboardState.vue +5 -3
- package/dashboard/app/components/GlobalSearch.vue +11 -7
- package/dashboard/app/components/KeyboardShortcutsHelp.vue +8 -6
- package/dashboard/app/components/KnowledgeSourcesList.vue +13 -9
- package/dashboard/app/components/MarkdownEditor.vue +1 -1
- package/dashboard/app/components/NotificationsBell.vue +8 -4
- package/dashboard/app/components/OnboardingTour.vue +24 -11
- package/dashboard/app/components/PersonaCloneDialog.vue +15 -8
- package/dashboard/app/components/PersonaWizard.vue +105 -72
- package/dashboard/app/components/SidebarFavoritesWidget.vue +5 -5
- package/dashboard/app/components/SidebarStatsWidget.vue +3 -1
- package/dashboard/app/components/Terminal.vue +9 -8
- package/dashboard/app/components/TextDiff.vue +27 -15
- package/dashboard/app/composables/useActivityFeed.ts +3 -3
- package/dashboard/app/composables/useApi.ts +4 -2
- package/dashboard/app/composables/useConfirmDialog.ts +1 -1
- package/dashboard/app/composables/useFavorites.ts +8 -8
- package/dashboard/app/composables/useTerminalThemes.ts +13 -13
- package/dashboard/app/composables/useThemeColor.ts +7 -4
- package/dashboard/app/pages/agents/[id].vue +319 -159
- package/dashboard/app/pages/agents/compare.vue +152 -52
- package/dashboard/app/pages/agents/index.vue +68 -55
- package/dashboard/app/pages/agents/new.vue +125 -58
- package/dashboard/app/pages/audit.vue +13 -6
- package/dashboard/app/pages/budget.vue +20 -18
- package/dashboard/app/pages/commands.vue +26 -22
- package/dashboard/app/pages/departments/[dept].vue +67 -31
- package/dashboard/app/pages/departments/compare.vue +85 -27
- package/dashboard/app/pages/departments/index.vue +31 -7
- package/dashboard/app/pages/health.vue +19 -12
- package/dashboard/app/pages/knowledge/index.vue +190 -78
- package/dashboard/app/pages/models.vue +74 -28
- package/dashboard/app/pages/personas/[id].vue +322 -219
- package/dashboard/app/pages/personas/archetypes.vue +20 -6
- package/dashboard/app/pages/personas/compare-with-agent.vue +109 -39
- package/dashboard/app/pages/personas/compare.vue +196 -124
- package/dashboard/app/pages/personas/index.vue +67 -60
- package/dashboard/app/pages/personas/new.vue +1 -1
- package/dashboard/app/pages/settings.vue +1 -0
- package/dashboard/app/pages/tasks.vue +80 -38
- package/dashboard/app/pages/trash.vue +11 -9
- package/dashboard/app/pages/workflows.vue +29 -19
- package/dashboard/nuxt.config.ts +2 -1
- package/dashboard/package.json +5 -0
- package/departments/kb/skills/doc-extraction/SKILL.md +99 -0
- package/departments/kb/skills/doc-redaction/SKILL.md +103 -0
- package/installer/index.js +1 -1
- package/knowledge/commands-registry.json +16 -3
- package/knowledge/skills-manifest.json +27 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Deterministic lint evidence at turn end (warn-only detached worker).
|
|
2
|
+
|
|
3
|
+
The Stop hook enqueues this module DETACHED (the ``turn_capture``
|
|
4
|
+
pattern) — none of this cost lands on the hook's 5s budget. The worker
|
|
5
|
+
scopes ``core.governance.evidence_checks`` to the files actually
|
|
6
|
+
changed (merge-base diff + working tree untracked), appends one JSONL
|
|
7
|
+
line per run to ``~/.arkaos/telemetry/stop-lint.jsonl`` and coalesces
|
|
8
|
+
repeat runs while the tree fingerprint is unchanged.
|
|
9
|
+
|
|
10
|
+
WARN mode only: results are telemetry plus an owner-only tmp-state
|
|
11
|
+
file; nothing blocks. Promotion to a blocking surface is gated on
|
|
12
|
+
clean telemetry (the frontend-gate rollout pattern, ADR
|
|
13
|
+
2026-04-17-binding-flow-enforcement).
|
|
14
|
+
|
|
15
|
+
Config (``~/.arkaos/config.json``):
|
|
16
|
+
hooks.stopLint absent/"warn" -> warn | false/"off" -> off
|
|
17
|
+
hooks.stopLintTypecheck true adds the typecheck check. Default
|
|
18
|
+
false: typecheck has no scoped mode in the
|
|
19
|
+
engine (a scoped mypy would silently weaken
|
|
20
|
+
the same check the Quality Gate runs), so
|
|
21
|
+
the project-wide run is opt-in.
|
|
22
|
+
Env kill-switch: ``ARKA_STOP_LINT=0``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import hashlib
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
import time
|
|
33
|
+
from datetime import UTC, datetime
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from core.governance.evidence_checks import (
|
|
38
|
+
EvidenceReport,
|
|
39
|
+
_diff_base,
|
|
40
|
+
run_evidence_checks,
|
|
41
|
+
)
|
|
42
|
+
from core.shared.safe_session_id import safe_session_id
|
|
43
|
+
from core.shared.temp_paths import arkaos_temp_dir
|
|
44
|
+
|
|
45
|
+
TELEMETRY_PATH: Path = Path.home() / ".arkaos" / "telemetry" / "stop-lint.jsonl"
|
|
46
|
+
CONFIG_PATH: Path = Path.home() / ".arkaos" / "config.json"
|
|
47
|
+
_STATE_SUBDIR = "arkaos-stop-lint"
|
|
48
|
+
_RESULT_SUBDIR = "arkaos-stop-lint-result"
|
|
49
|
+
_GIT_TIMEOUT = 10
|
|
50
|
+
_SUMMARY_CHARS = 200
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def mode(config_path: Path | None = None) -> str:
|
|
54
|
+
"""Resolve the stop-lint mode: ``"off"`` or ``"warn"`` (default)."""
|
|
55
|
+
if os.environ.get("ARKA_STOP_LINT", "").strip() == "0":
|
|
56
|
+
return "off"
|
|
57
|
+
raw = _hooks_config(config_path).get("stopLint", "warn")
|
|
58
|
+
if raw is False or (isinstance(raw, str) and raw.lower() == "off"):
|
|
59
|
+
return "off"
|
|
60
|
+
return "warn"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _hooks_config(config_path: Path | None) -> dict[str, Any]:
|
|
64
|
+
path = config_path or CONFIG_PATH
|
|
65
|
+
try:
|
|
66
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
67
|
+
except (OSError, json.JSONDecodeError):
|
|
68
|
+
return {}
|
|
69
|
+
hooks = data.get("hooks") if isinstance(data, dict) else None
|
|
70
|
+
return hooks if isinstance(hooks, dict) else {}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _typecheck_enabled(config_path: Path | None) -> bool:
|
|
74
|
+
return bool(_hooks_config(config_path).get("stopLintTypecheck", False))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _git(args: list[str], cwd: Path) -> subprocess.CompletedProcess | None:
|
|
78
|
+
try:
|
|
79
|
+
return subprocess.run(
|
|
80
|
+
["git", *args], cwd=cwd, capture_output=True, text=True,
|
|
81
|
+
timeout=_GIT_TIMEOUT,
|
|
82
|
+
)
|
|
83
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def changed_files(project_dir: Path) -> list[str]:
|
|
88
|
+
"""Repo-relative files changed vs the merge-base, plus untracked."""
|
|
89
|
+
try:
|
|
90
|
+
base = _diff_base(project_dir)
|
|
91
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
92
|
+
base = None
|
|
93
|
+
if base is None:
|
|
94
|
+
return []
|
|
95
|
+
out: list[str] = []
|
|
96
|
+
diff = _git(["diff", "--name-only", base], project_dir)
|
|
97
|
+
if diff is not None and diff.returncode == 0:
|
|
98
|
+
out.extend(f.strip() for f in diff.stdout.splitlines() if f.strip())
|
|
99
|
+
status = _git(["status", "--porcelain"], project_dir)
|
|
100
|
+
if status is not None and status.returncode == 0:
|
|
101
|
+
out.extend(
|
|
102
|
+
line[3:].strip()
|
|
103
|
+
for line in status.stdout.splitlines()
|
|
104
|
+
if line.startswith("??") and line[3:].strip()
|
|
105
|
+
)
|
|
106
|
+
seen: set[str] = set()
|
|
107
|
+
return [f for f in out if not (f in seen or seen.add(f))]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _fingerprint(project_dir: Path) -> str | None:
|
|
111
|
+
head = _git(["rev-parse", "HEAD"], project_dir)
|
|
112
|
+
status = _git(["status", "--porcelain"], project_dir)
|
|
113
|
+
if head is None or head.returncode != 0 or status is None:
|
|
114
|
+
return None
|
|
115
|
+
digest = hashlib.sha1()
|
|
116
|
+
digest.update(head.stdout.strip().encode("utf-8"))
|
|
117
|
+
# Porcelain alone only says WHICH files are dirty — editing an
|
|
118
|
+
# already-dirty file leaves it unchanged. Fold in mtime+size so
|
|
119
|
+
# content edits re-arm the worker.
|
|
120
|
+
for line in sorted(status.stdout.splitlines()):
|
|
121
|
+
digest.update(line.encode("utf-8", "replace"))
|
|
122
|
+
digest.update(_stat_token(project_dir / line[3:].strip()))
|
|
123
|
+
return digest.hexdigest()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _stat_token(path: Path) -> bytes:
|
|
127
|
+
try:
|
|
128
|
+
st = path.stat()
|
|
129
|
+
except OSError:
|
|
130
|
+
return b"|gone"
|
|
131
|
+
return f"|{st.st_mtime_ns}:{st.st_size}".encode("ascii")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _state_file(project_dir: Path) -> Path:
|
|
135
|
+
key = hashlib.sha1(
|
|
136
|
+
str(project_dir.resolve()).encode("utf-8")
|
|
137
|
+
).hexdigest()[:16]
|
|
138
|
+
return arkaos_temp_dir(_STATE_SUBDIR) / f"{key}.json"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _seen_fingerprint(state_file: Path) -> str | None:
|
|
142
|
+
try:
|
|
143
|
+
data = json.loads(state_file.read_text(encoding="utf-8"))
|
|
144
|
+
except (OSError, json.JSONDecodeError):
|
|
145
|
+
return None
|
|
146
|
+
value = data.get("fingerprint") if isinstance(data, dict) else None
|
|
147
|
+
return value if isinstance(value, str) else None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _remember_fingerprint(state_file: Path, fingerprint: str) -> None:
|
|
151
|
+
prev_umask = os.umask(0o077)
|
|
152
|
+
try:
|
|
153
|
+
state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
state_file.write_text(
|
|
155
|
+
json.dumps({"fingerprint": fingerprint}), encoding="utf-8"
|
|
156
|
+
)
|
|
157
|
+
except OSError:
|
|
158
|
+
pass
|
|
159
|
+
finally:
|
|
160
|
+
os.umask(prev_umask)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _check_fields(report: EvidenceReport) -> dict[str, Any]:
|
|
164
|
+
fields: dict[str, Any] = {}
|
|
165
|
+
for result in report.results:
|
|
166
|
+
fields[f"{result.check}_ran"] = result.ran
|
|
167
|
+
fields[f"{result.check}_passed"] = result.passed
|
|
168
|
+
fields[f"{result.check}_command"] = result.command
|
|
169
|
+
fields[f"{result.check}_summary"] = result.summary[:_SUMMARY_CHARS]
|
|
170
|
+
return fields
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _run_checks(
|
|
174
|
+
project_dir: Path, changed: list[str], config_path: Path | None,
|
|
175
|
+
) -> dict[str, Any]:
|
|
176
|
+
if not changed:
|
|
177
|
+
return {
|
|
178
|
+
"overall": "skipped",
|
|
179
|
+
"would_block": False,
|
|
180
|
+
"skip_reason": "no-changed-files",
|
|
181
|
+
}
|
|
182
|
+
checks = ["lint"]
|
|
183
|
+
if _typecheck_enabled(config_path):
|
|
184
|
+
checks.append("typecheck")
|
|
185
|
+
report = run_evidence_checks(
|
|
186
|
+
project_dir, changed_files=changed, checks=checks
|
|
187
|
+
)
|
|
188
|
+
fields = _check_fields(report)
|
|
189
|
+
fields["overall"] = report.overall
|
|
190
|
+
fields["would_block"] = report.overall == "fail"
|
|
191
|
+
return fields
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _append_telemetry(entry: dict[str, Any], telemetry_path: Path) -> None:
|
|
195
|
+
try:
|
|
196
|
+
telemetry_path.parent.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
with telemetry_path.open("a", encoding="utf-8") as fh:
|
|
198
|
+
fh.write(json.dumps(entry) + "\n")
|
|
199
|
+
except OSError:
|
|
200
|
+
pass
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _write_result_state(session_id: str, entry: dict[str, Any]) -> None:
|
|
204
|
+
sid = safe_session_id(session_id)
|
|
205
|
+
if not sid:
|
|
206
|
+
return
|
|
207
|
+
prev_umask = os.umask(0o077)
|
|
208
|
+
try:
|
|
209
|
+
result_dir = arkaos_temp_dir(_RESULT_SUBDIR)
|
|
210
|
+
result_dir.mkdir(parents=True, exist_ok=True)
|
|
211
|
+
(result_dir / f"{sid}.json").write_text(
|
|
212
|
+
json.dumps(entry), encoding="utf-8"
|
|
213
|
+
)
|
|
214
|
+
except OSError:
|
|
215
|
+
pass
|
|
216
|
+
finally:
|
|
217
|
+
os.umask(prev_umask)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def run(
|
|
221
|
+
project_dir: Path,
|
|
222
|
+
session_id: str = "",
|
|
223
|
+
*,
|
|
224
|
+
config_path: Path | None = None,
|
|
225
|
+
telemetry_path: Path | None = None,
|
|
226
|
+
) -> int:
|
|
227
|
+
"""Run the scoped batch once per tree fingerprint; append telemetry."""
|
|
228
|
+
project_dir = Path(project_dir)
|
|
229
|
+
if mode(config_path) == "off" or not project_dir.is_dir():
|
|
230
|
+
return 0
|
|
231
|
+
fingerprint = _fingerprint(project_dir)
|
|
232
|
+
state_file = _state_file(project_dir)
|
|
233
|
+
if fingerprint is not None and fingerprint == _seen_fingerprint(state_file):
|
|
234
|
+
return 0
|
|
235
|
+
started = time.monotonic()
|
|
236
|
+
changed = changed_files(project_dir)
|
|
237
|
+
entry = _run_checks(project_dir, changed, config_path)
|
|
238
|
+
entry.update(
|
|
239
|
+
ts=datetime.now(UTC).isoformat(),
|
|
240
|
+
session_id=session_id,
|
|
241
|
+
project_dir=str(project_dir),
|
|
242
|
+
event="stop-lint",
|
|
243
|
+
mode="warn",
|
|
244
|
+
changed_count=len(changed),
|
|
245
|
+
duration_ms=round((time.monotonic() - started) * 1000),
|
|
246
|
+
)
|
|
247
|
+
_append_telemetry(entry, telemetry_path or TELEMETRY_PATH)
|
|
248
|
+
_write_result_state(session_id, entry)
|
|
249
|
+
if fingerprint is not None:
|
|
250
|
+
_remember_fingerprint(state_file, fingerprint)
|
|
251
|
+
return 0
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def main(argv: list[str] | None = None) -> int:
|
|
255
|
+
"""CLI: ``python -m core.governance.stop_lint <project_dir> [sid]``."""
|
|
256
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
257
|
+
if not args:
|
|
258
|
+
return 0
|
|
259
|
+
try:
|
|
260
|
+
return run(Path(args[0]), args[1] if len(args) > 1 else "")
|
|
261
|
+
except Exception:
|
|
262
|
+
return 0
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
if __name__ == "__main__":
|
|
266
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Stop-lint telemetry summarizer.
|
|
2
|
+
|
|
3
|
+
Reads the ``"event": "stop-lint"`` entries appended by
|
|
4
|
+
``core.governance.stop_lint`` to ``~/.arkaos/telemetry/stop-lint.jsonl``
|
|
5
|
+
and reports pass/would-block rates. This summary is the evidence that
|
|
6
|
+
gates any promotion of the stop-lint batch beyond WARN (the
|
|
7
|
+
frontend-gate ``would_deny_rate`` rollout pattern).
|
|
8
|
+
|
|
9
|
+
Mirrors the period vocabulary of ``compliance_telemetry.summarise``
|
|
10
|
+
(today / week / month / all). Empty/missing file = zero rates. Rates
|
|
11
|
+
are computed over *observed* runs only — skipped runs (no changed
|
|
12
|
+
files) are counted separately and excluded from denominators.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from collections.abc import Iterable
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import UTC, datetime, timedelta
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
DEFAULT_PATH: Path = Path.home() / ".arkaos" / "telemetry" / "stop-lint.jsonl"
|
|
25
|
+
_VALID_PERIODS: frozenset[str] = frozenset({"today", "week", "month", "all"})
|
|
26
|
+
_EVENT: str = "stop-lint"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class StopLintSummary:
|
|
31
|
+
"""Stop-lint snapshot over a slice of worker telemetry."""
|
|
32
|
+
|
|
33
|
+
period: str
|
|
34
|
+
runs: int
|
|
35
|
+
skipped_runs: int
|
|
36
|
+
lint_pass_rate: float
|
|
37
|
+
typecheck_pass_rate: float
|
|
38
|
+
would_block_rate: float
|
|
39
|
+
corrupt_line_count: int = 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def summarise(period: str, *, path: Path | None = None) -> StopLintSummary:
|
|
43
|
+
"""Return a StopLintSummary for the requested period."""
|
|
44
|
+
if period not in _VALID_PERIODS:
|
|
45
|
+
raise ValueError(f"invalid period: {period!r}")
|
|
46
|
+
src = path or DEFAULT_PATH
|
|
47
|
+
cutoff = _period_cutoff(period)
|
|
48
|
+
entries, corrupt = _read_entries(src, cutoff)
|
|
49
|
+
return _build_summary(period, entries, corrupt)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _period_cutoff(period: str, now: datetime | None = None) -> datetime | None:
|
|
53
|
+
ref = now or datetime.now(UTC)
|
|
54
|
+
if period == "today":
|
|
55
|
+
return ref.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
56
|
+
if period == "week":
|
|
57
|
+
return ref - timedelta(days=7)
|
|
58
|
+
if period == "month":
|
|
59
|
+
return ref - timedelta(days=30)
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _read_entries(
|
|
64
|
+
src: Path, cutoff: datetime | None,
|
|
65
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
66
|
+
if not src.exists():
|
|
67
|
+
return [], 0
|
|
68
|
+
entries: list[dict[str, Any]] = []
|
|
69
|
+
corrupt = 0
|
|
70
|
+
try:
|
|
71
|
+
with src.open("r", encoding="utf-8", errors="replace") as fh:
|
|
72
|
+
for line in fh:
|
|
73
|
+
if not line.strip():
|
|
74
|
+
continue
|
|
75
|
+
try:
|
|
76
|
+
entry = json.loads(line)
|
|
77
|
+
except json.JSONDecodeError:
|
|
78
|
+
corrupt += 1
|
|
79
|
+
continue
|
|
80
|
+
if not isinstance(entry, dict):
|
|
81
|
+
corrupt += 1
|
|
82
|
+
continue
|
|
83
|
+
if entry.get("event") != _EVENT:
|
|
84
|
+
continue
|
|
85
|
+
if cutoff is not None and not _within_cutoff(entry, cutoff):
|
|
86
|
+
continue
|
|
87
|
+
entries.append(entry)
|
|
88
|
+
except OSError:
|
|
89
|
+
return entries, corrupt
|
|
90
|
+
return entries, corrupt
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _within_cutoff(entry: dict[str, Any], cutoff: datetime) -> bool:
|
|
94
|
+
ts = _parse_ts(entry.get("ts"))
|
|
95
|
+
return ts is not None and ts >= cutoff
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _parse_ts(raw: Any) -> datetime | None:
|
|
99
|
+
if not isinstance(raw, str) or not raw:
|
|
100
|
+
return None
|
|
101
|
+
try:
|
|
102
|
+
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
103
|
+
except ValueError:
|
|
104
|
+
return None
|
|
105
|
+
if parsed.tzinfo is None:
|
|
106
|
+
parsed = parsed.replace(tzinfo=UTC)
|
|
107
|
+
return parsed
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _build_summary(
|
|
111
|
+
period: str,
|
|
112
|
+
entries: Iterable[dict[str, Any]],
|
|
113
|
+
corrupt: int,
|
|
114
|
+
) -> StopLintSummary:
|
|
115
|
+
rows = list(entries)
|
|
116
|
+
observed = [r for r in rows if r.get("overall") != "skipped"]
|
|
117
|
+
return StopLintSummary(
|
|
118
|
+
period=period,
|
|
119
|
+
runs=len(rows),
|
|
120
|
+
skipped_runs=len(rows) - len(observed),
|
|
121
|
+
lint_pass_rate=_true_rate(observed, "lint_passed"),
|
|
122
|
+
typecheck_pass_rate=_true_rate(observed, "typecheck_passed"),
|
|
123
|
+
would_block_rate=_true_rate(observed, "would_block"),
|
|
124
|
+
corrupt_line_count=corrupt,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _true_rate(rows: list[dict[str, Any]], key: str) -> float:
|
|
129
|
+
observed = [r for r in rows if isinstance(r.get(key), bool)]
|
|
130
|
+
if not observed:
|
|
131
|
+
return 0.0
|
|
132
|
+
return sum(1 for r in observed if r[key] is True) / len(observed)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Tool-loop detection over the current turn's transcript (warn-only).
|
|
2
|
+
|
|
3
|
+
The context monitor's missing gap: an agent stuck re-issuing the same
|
|
4
|
+
tool call burns budget without progress, and nothing surfaced it.
|
|
5
|
+
Detection runs at the turn boundary (Stop hook) over the transcript, so
|
|
6
|
+
it sees EVERY call — including the ones the PostToolUse fast-path shim
|
|
7
|
+
short-circuits away from Python, which an in-process ring buffer never
|
|
8
|
+
sees. Two patterns:
|
|
9
|
+
|
|
10
|
+
- ``consecutive`` — the same (tool, input) issued N times in a row,
|
|
11
|
+
the canonical stuck loop;
|
|
12
|
+
- ``repeated`` — the same (tool, input) issued N times across the
|
|
13
|
+
turn with other calls between, the churn variant.
|
|
14
|
+
|
|
15
|
+
Privacy: tool inputs are compared by digest and never stored — the
|
|
16
|
+
verdict carries the tool name and counts only. Reuses the turn-slicing
|
|
17
|
+
helpers of ``phantom_action_check`` (same package, same transcript
|
|
18
|
+
contract).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
from collections import Counter
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
|
|
28
|
+
from core.governance.phantom_action_check import (
|
|
29
|
+
_last_real_user_index,
|
|
30
|
+
_parse_jsonl,
|
|
31
|
+
_record_content,
|
|
32
|
+
_record_role,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
DEFAULT_THRESHOLD = 4
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class LoopVerdict:
|
|
40
|
+
"""Outcome of the tool-loop scan. Never contains tool inputs."""
|
|
41
|
+
|
|
42
|
+
detected: bool = False
|
|
43
|
+
tool: str = ""
|
|
44
|
+
repeats: int = 0
|
|
45
|
+
pattern: str = "" # "consecutive" | "repeated" | ""
|
|
46
|
+
total_tool_uses: int = 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _call_key(block: dict) -> tuple[str, str]:
|
|
50
|
+
tool = str(block.get("name") or "")
|
|
51
|
+
try:
|
|
52
|
+
payload = json.dumps(block.get("input"), sort_keys=True, default=str)
|
|
53
|
+
except (TypeError, ValueError):
|
|
54
|
+
payload = "?"
|
|
55
|
+
digest = hashlib.sha1(payload.encode("utf-8")).hexdigest()[:16]
|
|
56
|
+
return tool, digest
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _turn_tool_calls(raw_transcript: str) -> list[tuple[str, str]]:
|
|
60
|
+
records = _parse_jsonl(raw_transcript)
|
|
61
|
+
start = _last_real_user_index(records)
|
|
62
|
+
if start < 0:
|
|
63
|
+
return []
|
|
64
|
+
calls: list[tuple[str, str]] = []
|
|
65
|
+
for record in records[start + 1:]:
|
|
66
|
+
if _record_role(record) != "assistant":
|
|
67
|
+
continue
|
|
68
|
+
content = _record_content(record)
|
|
69
|
+
if not isinstance(content, list):
|
|
70
|
+
continue
|
|
71
|
+
calls.extend(
|
|
72
|
+
_call_key(block)
|
|
73
|
+
for block in content
|
|
74
|
+
if isinstance(block, dict) and block.get("type") == "tool_use"
|
|
75
|
+
)
|
|
76
|
+
return calls
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _max_consecutive_run(calls: list[tuple[str, str]]) -> tuple[int, str]:
|
|
80
|
+
best, best_tool = 0, ""
|
|
81
|
+
run, prev = 0, None
|
|
82
|
+
for call in calls:
|
|
83
|
+
run = run + 1 if call == prev else 1
|
|
84
|
+
prev = call
|
|
85
|
+
if run > best:
|
|
86
|
+
best, best_tool = run, call[0]
|
|
87
|
+
return best, best_tool
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def check_tool_loops(
|
|
91
|
+
raw_transcript: str | None, *, threshold: int = DEFAULT_THRESHOLD,
|
|
92
|
+
) -> LoopVerdict:
|
|
93
|
+
"""Scan the current turn for stuck or churning tool-call loops."""
|
|
94
|
+
if not raw_transcript or threshold < 2:
|
|
95
|
+
return LoopVerdict()
|
|
96
|
+
calls = _turn_tool_calls(raw_transcript)
|
|
97
|
+
if not calls:
|
|
98
|
+
return LoopVerdict()
|
|
99
|
+
run, run_tool = _max_consecutive_run(calls)
|
|
100
|
+
if run >= threshold:
|
|
101
|
+
return LoopVerdict(
|
|
102
|
+
detected=True, tool=run_tool, repeats=run,
|
|
103
|
+
pattern="consecutive", total_tool_uses=len(calls),
|
|
104
|
+
)
|
|
105
|
+
(top_call, top_count), = Counter(calls).most_common(1)
|
|
106
|
+
if top_count >= threshold:
|
|
107
|
+
return LoopVerdict(
|
|
108
|
+
detected=True, tool=top_call[0], repeats=top_count,
|
|
109
|
+
pattern="repeated", total_tool_uses=len(calls),
|
|
110
|
+
)
|
|
111
|
+
return LoopVerdict(total_tool_uses=len(calls))
|