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.
Files changed (77) hide show
  1. package/README.md +1 -1
  2. package/VERSION +1 -1
  3. package/arka/SKILL.md +2 -1
  4. package/config/claude-agents/marta-cqo.md +9 -0
  5. package/config/standards/{claude-md-overlays → stack-rules}/laravel.md +11 -0
  6. package/config/standards/{claude-md-overlays → stack-rules}/node.md +10 -1
  7. package/config/standards/{claude-md-overlays → stack-rules}/nuxt.md +12 -1
  8. package/config/standards/stack-rules/php.md +13 -0
  9. package/config/standards/{claude-md-overlays → stack-rules}/python.md +5 -0
  10. package/config/standards/stack-rules/react.md +14 -0
  11. package/config/standards/stack-rules/vue.md +13 -0
  12. package/core/cognition/evolve.py +299 -0
  13. package/core/cognition/evolve_cli.py +55 -0
  14. package/core/governance/stop_lint.py +266 -0
  15. package/core/governance/stop_lint_telemetry.py +132 -0
  16. package/core/governance/tool_loop_check.py +111 -0
  17. package/core/hooks/stop.py +73 -22
  18. package/core/runtime/pricing.py +53 -19
  19. package/core/synapse/layers.py +49 -476
  20. package/core/synapse/layers_base.py +99 -0
  21. package/core/synapse/layers_kb.py +395 -0
  22. package/core/sync/content_syncer.py +79 -10
  23. package/dashboard/app/components/AgentDependencyGraph.vue +5 -5
  24. package/dashboard/app/components/AgentEditDrawer.vue +120 -52
  25. package/dashboard/app/components/AgentSuggestionsCard.vue +4 -2
  26. package/dashboard/app/components/ConfirmDialog.vue +2 -2
  27. package/dashboard/app/components/DashboardState.vue +5 -3
  28. package/dashboard/app/components/GlobalSearch.vue +11 -7
  29. package/dashboard/app/components/KeyboardShortcutsHelp.vue +8 -6
  30. package/dashboard/app/components/KnowledgeSourcesList.vue +13 -9
  31. package/dashboard/app/components/MarkdownEditor.vue +1 -1
  32. package/dashboard/app/components/NotificationsBell.vue +8 -4
  33. package/dashboard/app/components/OnboardingTour.vue +24 -11
  34. package/dashboard/app/components/PersonaCloneDialog.vue +15 -8
  35. package/dashboard/app/components/PersonaWizard.vue +105 -72
  36. package/dashboard/app/components/SidebarFavoritesWidget.vue +5 -5
  37. package/dashboard/app/components/SidebarStatsWidget.vue +3 -1
  38. package/dashboard/app/components/Terminal.vue +9 -8
  39. package/dashboard/app/components/TextDiff.vue +27 -15
  40. package/dashboard/app/composables/useActivityFeed.ts +3 -3
  41. package/dashboard/app/composables/useApi.ts +4 -2
  42. package/dashboard/app/composables/useConfirmDialog.ts +1 -1
  43. package/dashboard/app/composables/useFavorites.ts +8 -8
  44. package/dashboard/app/composables/useTerminalThemes.ts +13 -13
  45. package/dashboard/app/composables/useThemeColor.ts +7 -4
  46. package/dashboard/app/pages/agents/[id].vue +319 -159
  47. package/dashboard/app/pages/agents/compare.vue +152 -52
  48. package/dashboard/app/pages/agents/index.vue +68 -55
  49. package/dashboard/app/pages/agents/new.vue +125 -58
  50. package/dashboard/app/pages/audit.vue +13 -6
  51. package/dashboard/app/pages/budget.vue +20 -18
  52. package/dashboard/app/pages/commands.vue +26 -22
  53. package/dashboard/app/pages/departments/[dept].vue +67 -31
  54. package/dashboard/app/pages/departments/compare.vue +85 -27
  55. package/dashboard/app/pages/departments/index.vue +31 -7
  56. package/dashboard/app/pages/health.vue +19 -12
  57. package/dashboard/app/pages/knowledge/index.vue +190 -78
  58. package/dashboard/app/pages/models.vue +74 -28
  59. package/dashboard/app/pages/personas/[id].vue +322 -219
  60. package/dashboard/app/pages/personas/archetypes.vue +20 -6
  61. package/dashboard/app/pages/personas/compare-with-agent.vue +109 -39
  62. package/dashboard/app/pages/personas/compare.vue +196 -124
  63. package/dashboard/app/pages/personas/index.vue +67 -60
  64. package/dashboard/app/pages/personas/new.vue +1 -1
  65. package/dashboard/app/pages/settings.vue +1 -0
  66. package/dashboard/app/pages/tasks.vue +80 -38
  67. package/dashboard/app/pages/trash.vue +11 -9
  68. package/dashboard/app/pages/workflows.vue +29 -19
  69. package/dashboard/nuxt.config.ts +2 -1
  70. package/dashboard/package.json +5 -0
  71. package/departments/kb/skills/doc-extraction/SKILL.md +99 -0
  72. package/departments/kb/skills/doc-redaction/SKILL.md +103 -0
  73. package/installer/index.js +1 -1
  74. package/knowledge/commands-registry.json +16 -3
  75. package/knowledge/skills-manifest.json +27 -1
  76. package/package.json +1 -1
  77. 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))