arkaos 4.35.0 → 4.37.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 (45) hide show
  1. package/THE-ARKAOS-GUIDE.md +1 -1
  2. package/VERSION +1 -1
  3. package/bin/arka-menubar.py +58 -5
  4. package/core/runtime/opencode.py +168 -18
  5. package/core/runtime/opencode_hooks.py +313 -0
  6. package/harness/codex/AGENTS.md +1 -1
  7. package/harness/copilot/copilot-instructions.md +1 -1
  8. package/harness/cursor/rules/arkaos.mdc +2 -2
  9. package/harness/gemini/GEMINI.md +1 -1
  10. package/harness/opencode/AGENTS.md +1 -1
  11. package/harness/opencode/agents/arka-architect-gabriel.md +1 -1
  12. package/harness/opencode/agents/arka-brand-director-valentina.md +1 -1
  13. package/harness/opencode/agents/arka-cfo-helena.md +1 -1
  14. package/harness/opencode/agents/arka-chief-of-staff-afonso.md +1 -1
  15. package/harness/opencode/agents/arka-community-strategist-beatriz.md +1 -1
  16. package/harness/opencode/agents/arka-content-strategist-rafael.md +1 -1
  17. package/harness/opencode/agents/arka-conversion-strategist-ines.md +1 -1
  18. package/harness/opencode/agents/arka-coo-sofia.md +1 -1
  19. package/harness/opencode/agents/arka-copy-director-eduardo.md +1 -1
  20. package/harness/opencode/agents/arka-cqo-marta.md +1 -1
  21. package/harness/opencode/agents/arka-cto-marco.md +1 -1
  22. package/harness/opencode/agents/arka-design-ops-lead-iris.md +1 -1
  23. package/harness/opencode/agents/arka-ecom-director-ricardo.md +1 -1
  24. package/harness/opencode/agents/arka-knowledge-director-clara.md +1 -1
  25. package/harness/opencode/agents/arka-leadership-director-rodrigo.md +1 -1
  26. package/harness/opencode/agents/arka-marketing-director-luna.md +1 -1
  27. package/harness/opencode/agents/arka-ops-lead-daniel.md +1 -1
  28. package/harness/opencode/agents/arka-pm-director-carolina.md +1 -1
  29. package/harness/opencode/agents/arka-revops-lead-vicente.md +1 -1
  30. package/harness/opencode/agents/arka-saas-strategist-tiago.md +1 -1
  31. package/harness/opencode/agents/arka-sales-director-miguel.md +1 -1
  32. package/harness/opencode/agents/arka-strategy-director-tomas.md +1 -1
  33. package/harness/opencode/agents/arka-tech-director-francisca.md +1 -1
  34. package/harness/opencode/agents/arka-tech-lead-paulo.md +1 -1
  35. package/harness/opencode/agents/arka-video-producer-simao.md +1 -1
  36. package/harness/opencode/agents-meta.json +27 -0
  37. package/harness/opencode/opencode.json +23 -0
  38. package/harness/opencode/plugins/arka.ts +113 -0
  39. package/harness/zed/.rules +1 -1
  40. package/installer/adapters/opencode.js +142 -16
  41. package/installer/assets/opencode/arka.ts +113 -0
  42. package/knowledge/skills-manifest.json +1 -1
  43. package/package.json +1 -1
  44. package/pyproject.toml +1 -1
  45. package/scripts/harness_gen.py +43 -3
@@ -1,6 +1,6 @@
1
1
  # The ArkaOS Guide
2
2
 
3
- > v4.35.0 — 89 agents, 17 departments, 331 skills, 297 commands, 16 ADRs.
3
+ > v4.36.0 — 89 agents, 17 departments, 331 skills, 297 commands, 17 ADRs.
4
4
  > One file, everything you need to start. Generated by `scripts/guide_gen.py` — never hand-edited.
5
5
 
6
6
  ## What it is
package/VERSION CHANGED
@@ -1 +1 @@
1
- 4.35.0
1
+ 4.37.0
@@ -278,10 +278,44 @@ def run_app() -> int:
278
278
  super().__init__(TITLE, quit_button=None)
279
279
  self._rumps = rumps
280
280
  self._pollers = set()
281
+ # QG r2 follow-ups: the ollama probe result is CACHED and
282
+ # refreshed by a worker (the 2s `ollama list` subprocess
283
+ # never runs on the menu runloop); the auto-update toggle
284
+ # is disabled while its worker is in flight.
285
+ self._ollama_cache = "absent"
286
+ self._probe_running = False
287
+ self._toggle_inflight = False
281
288
  self.refresh(None)
282
289
  self.timer = rumps.Timer(self.refresh, REFRESH_SECONDS)
283
290
  self.timer.start()
284
291
 
292
+ def _schedule_ollama_probe(self):
293
+ """Worker-side probe -> cache; redraw only on change."""
294
+ if self._probe_running:
295
+ return
296
+ self._probe_running = True
297
+ before = self._ollama_cache
298
+
299
+ def probe():
300
+ try:
301
+ self._ollama_cache = ollama_status()
302
+ finally:
303
+ self._probe_running = False
304
+
305
+ worker = threading.Thread(target=probe, daemon=True)
306
+ worker.start()
307
+
308
+ def poll(timer):
309
+ if not worker.is_alive():
310
+ timer.stop()
311
+ self._pollers.discard(timer)
312
+ if self._ollama_cache != before:
313
+ self.refresh(None)
314
+
315
+ poller = self._rumps.Timer(poll, 1)
316
+ self._pollers.add(poller)
317
+ poller.start()
318
+
285
319
  def _spawn(self, work, refresh_after=False):
286
320
  """Run side-effect work off the AppKit main thread (a blocking
287
321
  subprocess in a rumps callback freezes the whole menu bar).
@@ -313,7 +347,11 @@ def run_app() -> int:
313
347
 
314
348
  def refresh(self, _sender):
315
349
  state = read_state()
316
- ollama = ollama_status_for(state)
350
+ if state["profile"] == "local-ai":
351
+ ollama = self._ollama_cache
352
+ self._schedule_ollama_probe()
353
+ else:
354
+ ollama = "absent"
317
355
  self.title = title_for(state)
318
356
  self.menu.clear()
319
357
  info = rumps.MenuItem(version_label(state))
@@ -331,8 +369,9 @@ def run_app() -> int:
331
369
  "doctor": ("Run Doctor", self.on_doctor),
332
370
  "start_ollama": ("Start Ollama", self.on_start_ollama),
333
371
  "autoupdate_toggle": (
334
- "Auto-update: on" if state["autoupdate_on"] else "Auto-update: off",
335
- self.on_autoupdate_toggle,
372
+ "Auto-update: switching…" if self._toggle_inflight
373
+ else ("Auto-update: on" if state["autoupdate_on"] else "Auto-update: off"),
374
+ None if self._toggle_inflight else self.on_autoupdate_toggle,
336
375
  ),
337
376
  "disable": ("Disable menu bar (permanent)", self.on_disable),
338
377
  "quit": ("Quit until next login", self.on_quit),
@@ -340,7 +379,7 @@ def run_app() -> int:
340
379
  for item_id in visible:
341
380
  label, callback = labels[item_id]
342
381
  entry = rumps.MenuItem(label, callback=callback)
343
- if item_id == "autoupdate_toggle":
382
+ if item_id == "autoupdate_toggle" and not self._toggle_inflight:
344
383
  entry.state = 1 if state["autoupdate_on"] else 0
345
384
  if item_id == "disable":
346
385
  entries.append(rumps.separator)
@@ -365,8 +404,22 @@ def run_app() -> int:
365
404
  self._spawn(action_start_ollama, refresh_after=True)
366
405
 
367
406
  def on_autoupdate_toggle(self, _):
407
+ # Guard against double-fire while the (up to 180s) npx
408
+ # worker runs — the item is disabled and relabelled until
409
+ # the post-worker refresh (QG r2 minor).
410
+ if self._toggle_inflight:
411
+ return
412
+ self._toggle_inflight = True
368
413
  enable = not read_state()["autoupdate_on"]
369
- self._spawn(lambda: action_autoupdate(enable=enable), refresh_after=True)
414
+
415
+ def work():
416
+ try:
417
+ action_autoupdate(enable=enable)
418
+ finally:
419
+ self._toggle_inflight = False
420
+
421
+ self._spawn(work, refresh_after=True)
422
+ self.refresh(None)
370
423
 
371
424
  def on_disable(self, _):
372
425
  try:
@@ -1,18 +1,40 @@
1
- """OpenCode runtime adapter (Foundation PR-6).
1
+ """OpenCode runtime adapter (Foundation PR-6 + headless follow-up).
2
2
 
3
3
  OpenCode (opencode.ai) — open-source terminal AI coding agent with
4
4
  native agents (markdown, ``~/.config/opencode/agents/``), custom
5
- commands (``commands/``), MCP servers (``opencode.json``), and AGENTS.md
6
- instructions. The installer adapter (installer/adapters/opencode.js)
7
- deploys the generated harness bundle onto those surfaces.
8
-
9
- Honesty note: OpenCode ships a headless ``opencode run`` mode, but the
10
- invocation/event shape has NOT been live-verified against a real binary
11
- the way codex_cli.py documents until that verification lands,
12
- ``headless_supported`` stays False (base-adapter conservatism; the
13
- capabilities matrix must never overclaim). Registered follow-up.
5
+ commands (``commands/``), MCP servers (``opencode.json``), AGENTS.md
6
+ instructions, and a TypeScript plugin system (``plugins/``). The
7
+ installer adapter (installer/adapters/opencode.js) deploys the
8
+ generated harness bundle onto those surfaces, including
9
+ ``plugins/arka.ts``, which bridges OpenCode events into
10
+ ``core.runtime.opencode_hooks`` (kb-first research gate, frontend
11
+ gate, MCP telemetry, stop-hook compliance, compaction context).
12
+
13
+ Headless invocation (live-verified against the installed binary,
14
+ opencode 1.18.4, ``opencode run --help`` + a real probe, 2026-07-23):
15
+
16
+ opencode run --format json "<prompt>"
17
+
18
+ ``--format json`` prints JSONL events to stdout. Live-verified shapes:
19
+
20
+ {"type":"step_start", "sessionID":"...", "part":{...}}
21
+ {"type":"text","part":{"type":"text","text":"ok",...}}
22
+ {"type":"step_finish","part":{"reason":"stop","tokens":
23
+ {"total":N,"input":N,"output":N,"reasoning":N,
24
+ "cache":{"write":N,"read":N}},"cost":X}}
25
+ {"type":"error","error":{"name":"...","data":{"message":"..."}}}
26
+
27
+ The error event fires with exit 1 when the user's DEFAULT model/provider
28
+ is broken (observed live) — headless_complete surfaces that as
29
+ LLMUnavailable with the event message. When no JSONL parses at all,
30
+ stdout is treated as raw text with a ``len(text) // 4`` token estimate
31
+ (same fallback as the Codex/Gemini adapters). stdin is closed explicitly
32
+ (codex precedent — a piped stdin must never leak into the prompt).
14
33
  """
15
34
 
35
+ import json
36
+ import shutil
37
+ import subprocess
16
38
  from pathlib import Path
17
39
  from os.path import expanduser
18
40
  from typing import TYPE_CHECKING
@@ -23,6 +45,13 @@ if TYPE_CHECKING:
23
45
  from core.runtime.llm_provider import LLMResponse
24
46
 
25
47
 
48
+ # `opencode run` is a full agent turn (session bootstrap + provider
49
+ # round-trip) — mirror the Codex budget, not the tighter Gemini one.
50
+ _TIMEOUT_SECONDS = 120
51
+ _TOKEN_ESTIMATE_DIVISOR = 4 # Rough chars-per-token heuristic.
52
+ _STDERR_CLIP = 200
53
+
54
+
26
55
  class OpenCodeAdapter(RuntimeAdapter):
27
56
  """Adapter for OpenCode (terminal AI coding agent)."""
28
57
 
@@ -35,18 +64,25 @@ class OpenCodeAdapter(RuntimeAdapter):
35
64
  config_dir=config_dir,
36
65
  skills_dir=config_dir / "commands",
37
66
  settings_file=config_dir / "opencode.json",
38
- supports_hooks=False,
67
+ supports_hooks=(config_dir / "plugins" / "arka.ts").exists(),
39
68
  supports_subagents=True,
40
69
  supports_mcp=True,
41
70
  max_context_tokens=200_000,
42
71
  )
43
72
 
44
73
  def capabilities(self) -> dict[str, bool]:
74
+ plugin = (
75
+ Path(expanduser("~")) / ".config" / "opencode" / "plugins" / "arka.ts"
76
+ )
45
77
  return {
46
78
  "agent_dispatch": False, # native agents exist; no ArkaOS wiring yet
47
- "headless": False, # `opencode run` not live-verified yet
79
+ "headless": True, # `opencode run` live-verified 2026-07-23
48
80
  "file_ops": True, # terminal agent edits files natively
49
- "hooks": False, # plugins exist; no PreToolUse/Stop parity
81
+ # plugins/arka.ts (deployed by the installer adapter) bridges
82
+ # prompt/pre_tool/post_tool/idle/compact events into
83
+ # core.runtime.opencode_hooks — kb-first, frontend gate,
84
+ # telemetry, compliance. True only when actually deployed.
85
+ "hooks": plugin.exists(),
50
86
  }
51
87
 
52
88
  def inject_context(self, layers: dict[str, str]) -> str:
@@ -97,7 +133,7 @@ class OpenCodeAdapter(RuntimeAdapter):
97
133
  raise NotImplementedError("Use OpenCode's native content search")
98
134
 
99
135
  def headless_supported(self) -> bool:
100
- return False
136
+ return shutil.which("opencode") is not None
101
137
 
102
138
  def headless_complete(
103
139
  self,
@@ -106,8 +142,122 @@ class OpenCodeAdapter(RuntimeAdapter):
106
142
  max_tokens: int = 2000,
107
143
  system: str = "",
108
144
  ) -> "LLMResponse":
109
- raise NotImplementedError(
110
- "OpenCode `opencode run` exists but its invocation/output "
111
- "shape is not live-verified yet (codex_cli.py precedent). "
112
- "Fall back to AnthropicDirectProvider or StubProvider."
145
+ """One-shot completion via `opencode run --format json`.
146
+
147
+ Verified against opencode 1.18.4 (live probe, 2026-07-23). The
148
+ model comes from the user's opencode config; a broken default
149
+ provider surfaces as an error event -> LLMUnavailable. Raises
150
+ LLMUnavailable on non-zero exit or timeout.
151
+ """
152
+ from core.runtime.llm_provider import LLMUnavailable
153
+
154
+ binary = shutil.which("opencode")
155
+ if binary is None:
156
+ raise NotImplementedError(
157
+ "opencode CLI not found on PATH — install OpenCode to "
158
+ "enable headless completion."
159
+ )
160
+ effective_prompt = _merge_system_prompt(prompt, system)
161
+ cmd = [binary, "run", "--format", "json", effective_prompt]
162
+ proc = _run_opencode_cli(cmd)
163
+ if proc.returncode != 0:
164
+ # The error event carries the actionable message (observed
165
+ # live with a broken default model); stderr is often empty.
166
+ event_message = _first_error_message(proc.stdout)
167
+ detail = event_message or proc.stderr.strip()[:_STDERR_CLIP]
168
+ raise LLMUnavailable(
169
+ f"opencode run exited {proc.returncode}: {detail}"
170
+ )
171
+ return _parse_opencode_output(proc.stdout)
172
+
173
+
174
+ def _merge_system_prompt(prompt: str, system: str) -> str:
175
+ # `opencode run` takes a single message; prepend the system text so
176
+ # downstream behaviour matches the other adapters.
177
+ if not system:
178
+ return prompt
179
+ return f"{system}\n\n---\n\n{prompt}"
180
+
181
+
182
+ def _run_opencode_cli(cmd: list[str]) -> subprocess.CompletedProcess:
183
+ from core.runtime.llm_provider import LLMUnavailable
184
+
185
+ try:
186
+ return subprocess.run(
187
+ cmd,
188
+ capture_output=True,
189
+ text=True,
190
+ encoding="utf-8",
191
+ errors="replace",
192
+ timeout=_TIMEOUT_SECONDS,
193
+ check=False,
194
+ # codex precedent: piped stdin must never leak into the run.
195
+ stdin=subprocess.DEVNULL,
196
+ )
197
+ except subprocess.TimeoutExpired as err:
198
+ raise LLMUnavailable(
199
+ f"opencode run timed out after {_TIMEOUT_SECONDS}s"
200
+ ) from err
201
+
202
+
203
+ def _iter_events(stdout: str):
204
+ for line in stdout.splitlines():
205
+ line = line.strip()
206
+ if not line.startswith("{"):
207
+ continue
208
+ try:
209
+ yield json.loads(line)
210
+ except (ValueError, TypeError):
211
+ continue
212
+
213
+
214
+ def _first_error_message(stdout: str) -> str:
215
+ for event in _iter_events(stdout):
216
+ if event.get("type") == "error":
217
+ data = (event.get("error") or {}).get("data") or {}
218
+ message = data.get("message") or (event.get("error") or {}).get("name")
219
+ if message:
220
+ return str(message)[:_STDERR_CLIP]
221
+ return ""
222
+
223
+
224
+ def _parse_opencode_output(stdout: str) -> "LLMResponse":
225
+ """Parse the live-verified JSONL stream (see module docstring)."""
226
+ from core.runtime.llm_provider import LLMResponse, LLMUnavailable
227
+
228
+ texts: list[str] = []
229
+ tokens_in = 0
230
+ tokens_out = 0
231
+ cached = 0
232
+ saw_event = False
233
+ for event in _iter_events(stdout):
234
+ saw_event = True
235
+ kind = event.get("type")
236
+ part = event.get("part") or {}
237
+ if kind == "text" and part.get("type") == "text":
238
+ texts.append(str(part.get("text", "")))
239
+ elif kind == "step_finish":
240
+ tokens = part.get("tokens") or {}
241
+ tokens_in += int(tokens.get("input") or 0)
242
+ tokens_out += int(tokens.get("output") or 0)
243
+ cached += int((tokens.get("cache") or {}).get("read") or 0)
244
+ elif kind == "error":
245
+ message = _first_error_message(stdout) or "unknown opencode error"
246
+ raise LLMUnavailable(f"opencode run error event: {message}")
247
+ if saw_event:
248
+ return LLMResponse(
249
+ text="".join(texts),
250
+ tokens_in=tokens_in,
251
+ tokens_out=tokens_out,
252
+ cached_tokens=cached,
253
+ model="", # events carry no model id (verified 1.18.4)
113
254
  )
255
+ # No JSONL parsed — degrade to raw text (codex/gemini fallback).
256
+ raw = stdout.strip()
257
+ return LLMResponse(
258
+ text=raw,
259
+ tokens_in=0,
260
+ tokens_out=max(1, len(raw) // _TOKEN_ESTIMATE_DIVISOR) if raw else 0,
261
+ cached_tokens=0,
262
+ model="",
263
+ )
@@ -0,0 +1,313 @@
1
+ """OpenCode plugin bridge — JSON stdin/stdout hook adapter.
2
+
3
+ OpenCode has no shell-hook chain like Claude Code; its plugin system
4
+ (TypeScript, ``~/.config/opencode/plugins/``) emits events instead. The
5
+ ``arka.ts`` plugin shells out to this module so the OpenCode runtime
6
+ gets the same governance stack as the Claude runtime:
7
+
8
+ prompt token-hygiene checks (UserPromptSubmit parity)
9
+ pre_tool research gate + frontend gate (PreToolUse parity)
10
+ post_tool MCP usage telemetry (PostToolUse parity)
11
+ idle kb-citation + [arka:meta] compliance (Stop-hook parity)
12
+ compact gate state context (PreCompact parity)
13
+
14
+ CLI contract::
15
+
16
+ echo '{"action": "pre_tool", ...}' | arka-py -m core.runtime.opencode_hooks
17
+
18
+ Output is a single JSON object on stdout. The module NEVER raises and
19
+ always exits 0 — a hook bridge must never break a turn (fail-open,
20
+ same posture as the bash hooks it mirrors).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import re
27
+ import sys
28
+ from datetime import datetime, timezone
29
+ from pathlib import Path
30
+
31
+ _TELEMETRY_DIR = Path.home() / ".arkaos" / "telemetry"
32
+ _PROMPT_CACHE_DIR = Path.home() / ".arkaos" / "context-cache"
33
+
34
+ _BUILTIN_MAP = {
35
+ "webfetch": "WebFetch",
36
+ "websearch": "WebSearch",
37
+ "edit": "Edit",
38
+ "write": "Write",
39
+ "read": "Read",
40
+ "bash": "Bash",
41
+ "grep": "Grep",
42
+ "glob": "Glob",
43
+ "task": "Task",
44
+ }
45
+
46
+ _ARG_KEY_MAP = {
47
+ "filePath": "file_path",
48
+ "newString": "new_string",
49
+ "oldString": "old_string",
50
+ }
51
+
52
+ _VAGUE_RE = re.compile(
53
+ r"\b(fix the bug|that file|esse ficheiro|esse bug|o erro|aquilo)\b",
54
+ re.IGNORECASE,
55
+ )
56
+
57
+ _STOPWORDS = frozenset({
58
+ "a", "o", "e", "de", "do", "da", "que", "em", "um", "uma", "para",
59
+ "com", "os", "as", "no", "na", "the", "and", "to", "of", "in", "is",
60
+ })
61
+
62
+
63
+ def _map_tool_name(name: str) -> str:
64
+ """OpenCode ``server_tool`` / builtin -> Claude-style tool name."""
65
+ if name in _BUILTIN_MAP:
66
+ return _BUILTIN_MAP[name]
67
+ if name.startswith("mcp__"):
68
+ return name
69
+ server, sep, tool = name.partition("_")
70
+ if sep and server and tool:
71
+ return f"mcp__{server}__{tool}"
72
+ return name
73
+
74
+
75
+ def _map_args(args: dict) -> dict:
76
+ return {_ARG_KEY_MAP.get(k, k): v for k, v in (args or {}).items()}
77
+
78
+
79
+ def _keywords(text: str) -> set[str]:
80
+ return {
81
+ w
82
+ for w in re.findall(r"[a-zA-ZÀ-ÿ]{4,}", text.lower())
83
+ if w not in _STOPWORDS
84
+ }
85
+
86
+
87
+ def _append_jsonl(path: Path, entry: dict) -> None:
88
+ try:
89
+ path.parent.mkdir(parents=True, exist_ok=True)
90
+ with path.open("a", encoding="utf-8") as fh:
91
+ fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
92
+ except OSError:
93
+ pass
94
+
95
+
96
+ def _recent_prompts(session_id: str) -> list[str]:
97
+ safe = re.sub(r"[^A-Za-z0-9_-]", "", session_id or "default")
98
+ path = _PROMPT_CACHE_DIR / f"opencode-prompts-{safe}.json"
99
+ try:
100
+ return json.loads(path.read_text(encoding="utf-8"))
101
+ except (OSError, json.JSONDecodeError):
102
+ return []
103
+
104
+
105
+ def _store_prompt(session_id: str, prompt: str) -> None:
106
+ safe = re.sub(r"[^A-Za-z0-9_-]", "", session_id or "default")
107
+ path = _PROMPT_CACHE_DIR / f"opencode-prompts-{safe}.json"
108
+ history = _recent_prompts(session_id)
109
+ history.append(prompt[:500])
110
+ _append = history[-3:]
111
+ try:
112
+ path.parent.mkdir(parents=True, exist_ok=True)
113
+ path.write_text(json.dumps(_append), encoding="utf-8")
114
+ except OSError:
115
+ pass
116
+
117
+
118
+ def _action_prompt(payload: dict) -> dict:
119
+ """Token-hygiene parity (config/hooks/token-hygiene.sh)."""
120
+ prompt = str(payload.get("prompt", ""))
121
+ session_id = str(payload.get("session_id", ""))
122
+ suggestions: list[str] = []
123
+
124
+ ctx = payload.get("context_pct")
125
+ if isinstance(ctx, (int, float)):
126
+ if ctx > 80:
127
+ suggestions.append(
128
+ f"[arka:warn] Contexto a {int(ctx)}% — considera /compact."
129
+ )
130
+ elif ctx > 60:
131
+ suggestions.append(
132
+ f"[arka:suggest] Contexto a {int(ctx)}% — vigia o orçamento."
133
+ )
134
+
135
+ history = _recent_prompts(session_id)
136
+ if history and prompt:
137
+ current = _keywords(prompt)
138
+ if current:
139
+ recent = set().union(*(_keywords(p) for p in history[-3:]))
140
+ if recent and len(current & recent) / len(current) < 0.3:
141
+ suggestions.append(
142
+ "[arka:suggest] Mudança de tópico — considera /clear "
143
+ "para libertar contexto."
144
+ )
145
+ if prompt:
146
+ _store_prompt(session_id, prompt)
147
+
148
+ if len(prompt) > 2000 and "```" in prompt:
149
+ suggestions.append(
150
+ "[arka:suggest] Paste grande — usa @filepath em vez de colar "
151
+ "código no prompt."
152
+ )
153
+
154
+ if _VAGUE_RE.search(prompt) and "@" not in prompt:
155
+ suggestions.append(
156
+ "[arka:suggest] Referência vaga — usa @path para apontar o "
157
+ "ficheiro concreto."
158
+ )
159
+
160
+ return {"suggestions": suggestions}
161
+
162
+
163
+ def _action_pre_tool(payload: dict) -> dict:
164
+ tool = str(payload.get("tool", ""))
165
+ mapped = _map_tool_name(tool)
166
+ session_id = str(payload.get("session_id", ""))
167
+ args = _map_args(payload.get("args") or {})
168
+
169
+ from core.workflow import research_gate
170
+
171
+ decision = research_gate.evaluate_research_gate(
172
+ mapped,
173
+ session_id=session_id,
174
+ query=str(args.get("query", "") or args.get("url", "")),
175
+ )
176
+ if not decision.allow:
177
+ research_gate.record_telemetry(session_id, mapped, decision)
178
+ return {
179
+ "allow": False,
180
+ "reason": decision.reason,
181
+ "message": decision.to_stderr_message(),
182
+ }
183
+ if decision.nudge:
184
+ research_gate.record_telemetry(session_id, mapped, decision)
185
+ return {
186
+ "allow": True,
187
+ "reason": decision.reason,
188
+ "message": decision.to_stderr_message(),
189
+ }
190
+
191
+ if tool in ("edit", "write"):
192
+ from core.workflow import frontend_gate
193
+
194
+ fe = frontend_gate.evaluate(
195
+ _map_tool_name(tool),
196
+ "",
197
+ session_id,
198
+ str(payload.get("cwd", "")),
199
+ args,
200
+ messages=payload.get("messages") or [],
201
+ )
202
+ frontend_gate.record_telemetry(session_id, mapped, fe)
203
+ if not fe.allow or fe.reason == "no-design-marker":
204
+ return {
205
+ "allow": fe.allow,
206
+ "reason": fe.reason,
207
+ "message": fe.to_stderr_message(),
208
+ }
209
+
210
+ return {"allow": True, "reason": "ok", "message": ""}
211
+
212
+
213
+ def _action_post_tool(payload: dict) -> dict:
214
+ tool = str(payload.get("tool", ""))
215
+ mapped = _map_tool_name(tool)
216
+ session_id = str(payload.get("session_id", ""))
217
+
218
+ from core.runtime import mcp_telemetry
219
+
220
+ recorded = mcp_telemetry.record(mapped, session_id=session_id)
221
+ _append_jsonl(
222
+ _TELEMETRY_DIR / "opencode-tools.jsonl",
223
+ {
224
+ "ts": datetime.now(timezone.utc).isoformat(),
225
+ "session": session_id,
226
+ "tool": mapped,
227
+ "ok": bool(payload.get("ok", True)),
228
+ },
229
+ )
230
+ return {"recorded_mcp": recorded}
231
+
232
+
233
+ def _action_idle(payload: dict) -> dict:
234
+ """Stop-hook parity: kb-citation + [arka:meta] compliance (soft)."""
235
+ text = str(payload.get("response_text", ""))
236
+ session_id = str(payload.get("session_id", ""))
237
+ nudges: list[str] = []
238
+
239
+ from core.governance import kb_cite_check
240
+
241
+ result = kb_cite_check.check_citation(text)
242
+ if not result.passed and result.suggestion:
243
+ nudges.append(result.suggestion)
244
+
245
+ if len(text) > 400 and "[arka:meta]" not in text and "[arka:trivial]" not in text:
246
+ nudges.append(
247
+ "[arka:suggest] Resposta substantiva sem tag [arka:meta] — "
248
+ "fecha com kb=N research=X persona=Y gap=Z critic=W."
249
+ )
250
+
251
+ _append_jsonl(
252
+ _TELEMETRY_DIR / "opencode-compliance.jsonl",
253
+ {
254
+ "ts": datetime.now(timezone.utc).isoformat(),
255
+ "session": session_id,
256
+ "kb_cite": result.reason,
257
+ "meta_tag": "[arka:meta]" in text,
258
+ "nudges": len(nudges),
259
+ },
260
+ )
261
+ return {"nudges": nudges}
262
+
263
+
264
+ def _action_compact(payload: dict) -> dict:
265
+ """PreCompact parity: carry gate state across compaction."""
266
+ from core.workflow import state
267
+
268
+ context: list[str] = [
269
+ "## ArkaOS gate state (injected by the opencode bridge)",
270
+ "Obrigatório: evidence flow G1 contexto -> G2 plano (aprovação) "
271
+ "-> G3 execução (teste real, exit 0) -> G4 review. Emite "
272
+ "[arka:gate:N] em cada gate; [arka:trivial] <razão> só para "
273
+ "edição de 1 ficheiro < 10 linhas.",
274
+ ]
275
+ try:
276
+ current = state.get_state()
277
+ if current:
278
+ context.append(f"Workflow activo: {json.dumps(current)[:400]}")
279
+ except Exception: # noqa: BLE001 — fail-open
280
+ pass
281
+ return {"context": context}
282
+
283
+
284
+ _ACTIONS = {
285
+ "prompt": _action_prompt,
286
+ "pre_tool": _action_pre_tool,
287
+ "post_tool": _action_post_tool,
288
+ "idle": _action_idle,
289
+ "compact": _action_compact,
290
+ }
291
+
292
+
293
+ def main() -> int:
294
+ try:
295
+ payload = json.loads(sys.stdin.read() or "{}")
296
+ except json.JSONDecodeError:
297
+ payload = {}
298
+ action = str(payload.pop("action", ""))
299
+ handler = _ACTIONS.get(action)
300
+ result: dict
301
+ if handler is None:
302
+ result = {"error": f"unknown action: {action!r}"}
303
+ else:
304
+ try:
305
+ result = handler(payload)
306
+ except Exception as exc: # noqa: BLE001 — fail-open, never block
307
+ result = {"error": f"{type(exc).__name__}: {exc}"}
308
+ sys.stdout.write(json.dumps(result, ensure_ascii=False) + "\n")
309
+ return 0
310
+
311
+
312
+ if __name__ == "__main__":
313
+ raise SystemExit(main())
@@ -1,6 +1,6 @@
1
1
  # ArkaOS — The Operating System for AI Agent Teams
2
2
 
3
- > v4.35.0 — 89 agents, 17 departments, 331 skills. Generated by `scripts/harness_gen.py`; do not edit.
3
+ > v4.37.0 — 89 agents, 17 departments, 331 skills. Generated by `scripts/harness_gen.py`; do not edit.
4
4
 
5
5
  You are operating within ArkaOS. Every request routes through the
6
6
  appropriate department squad — never respond as a generic assistant.
@@ -1,6 +1,6 @@
1
1
  # ArkaOS — The Operating System for AI Agent Teams
2
2
 
3
- > v4.35.0 — 89 agents, 17 departments, 331 skills. Generated by `scripts/harness_gen.py`; do not edit.
3
+ > v4.37.0 — 89 agents, 17 departments, 331 skills. Generated by `scripts/harness_gen.py`; do not edit.
4
4
 
5
5
  You are operating within ArkaOS. Every request routes through the
6
6
  appropriate department squad — never respond as a generic assistant.