arkaos 4.0.2 → 4.1.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 (167) hide show
  1. package/README.md +2 -2
  2. package/VERSION +1 -1
  3. package/arka/SKILL.md +16 -22
  4. package/arka/skills/flow/SKILL.md +108 -234
  5. package/config/claude-agents/eduardo-copy.md +53 -0
  6. package/config/claude-agents/francisca-tech.md +52 -0
  7. package/config/claude-agents/marta-cqo.md +52 -0
  8. package/config/claude-agents/paulo-tech-lead.md +50 -0
  9. package/config/cognition/schedules.yaml +15 -0
  10. package/config/constitution.yaml +17 -21
  11. package/config/hooks/_lib/workflow-classifier.sh +1 -1
  12. package/config/hooks/post-tool-use.sh +40 -539
  13. package/config/hooks/pre-tool-use.sh +32 -281
  14. package/config/hooks/session-start.sh +8 -10
  15. package/config/hooks/stop.sh +30 -425
  16. package/config/hooks/user-prompt-submit.sh +42 -459
  17. package/core/cognition/__pycache__/dreaming.cpython-313.pyc +0 -0
  18. package/core/cognition/dreaming.py +5 -0
  19. package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
  20. package/core/cognition/scheduler/daemon.py +10 -0
  21. package/core/forge/__init__.py +2 -0
  22. package/core/forge/__pycache__/__init__.cpython-313.pyc +0 -0
  23. package/core/forge/__pycache__/orchestrator.cpython-313.pyc +0 -0
  24. package/core/forge/__pycache__/persistence.cpython-313.pyc +0 -0
  25. package/core/forge/__pycache__/renderer.cpython-313.pyc +0 -0
  26. package/core/forge/__pycache__/runtime_dispatcher.cpython-313.pyc +0 -0
  27. package/core/forge/__pycache__/schema.cpython-313.pyc +0 -0
  28. package/core/forge/orchestrator.py +50 -27
  29. package/core/forge/persistence.py +16 -0
  30. package/core/forge/renderer.py +16 -0
  31. package/core/forge/runtime_dispatcher.py +34 -19
  32. package/core/forge/schema.py +12 -0
  33. package/core/governance/__pycache__/__init__.cpython-312.pyc +0 -0
  34. package/core/governance/__pycache__/closing_marker_check.cpython-313.pyc +0 -0
  35. package/core/governance/__pycache__/compliance_telemetry.cpython-313.pyc +0 -0
  36. package/core/governance/__pycache__/constitution.cpython-312.pyc +0 -0
  37. package/core/governance/__pycache__/evidence_checks.cpython-313.pyc +0 -0
  38. package/core/governance/__pycache__/meta_tag_check.cpython-313.pyc +0 -0
  39. package/core/governance/__pycache__/qg_verdict.cpython-313.pyc +0 -0
  40. package/core/governance/__pycache__/review_workflow.cpython-313.pyc +0 -0
  41. package/core/governance/__pycache__/skill_proposer.cpython-313.pyc +0 -0
  42. package/core/governance/closing_marker_check.py +9 -5
  43. package/core/governance/compliance_telemetry.py +1 -1
  44. package/core/governance/compliance_telemetry_cli.py +1 -1
  45. package/core/governance/evidence_checks.py +421 -0
  46. package/core/governance/meta_tag_check.py +36 -0
  47. package/core/governance/qg_verdict.py +78 -0
  48. package/core/governance/review_workflow.py +34 -1
  49. package/core/governance/skill_proposer.py +2 -1
  50. package/core/hooks/__init__.py +12 -0
  51. package/core/hooks/__pycache__/__init__.cpython-312.pyc +0 -0
  52. package/core/hooks/__pycache__/__init__.cpython-313.pyc +0 -0
  53. package/core/hooks/__pycache__/_shared.cpython-312.pyc +0 -0
  54. package/core/hooks/__pycache__/_shared.cpython-313.pyc +0 -0
  55. package/core/hooks/__pycache__/post_tool_use.cpython-312.pyc +0 -0
  56. package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
  57. package/core/hooks/__pycache__/pre_tool_use.cpython-312.pyc +0 -0
  58. package/core/hooks/__pycache__/pre_tool_use.cpython-313.pyc +0 -0
  59. package/core/hooks/__pycache__/stop.cpython-312.pyc +0 -0
  60. package/core/hooks/__pycache__/stop.cpython-313.pyc +0 -0
  61. package/core/hooks/__pycache__/user_prompt_submit.cpython-312.pyc +0 -0
  62. package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  63. package/core/hooks/_shared.py +110 -0
  64. package/core/hooks/post_tool_use.py +689 -0
  65. package/core/hooks/pre_tool_use.py +267 -0
  66. package/core/hooks/stop.py +395 -0
  67. package/core/hooks/user_prompt_submit.py +600 -0
  68. package/core/jobs/__pycache__/__init__.cpython-312.pyc +0 -0
  69. package/core/jobs/__pycache__/auto_doc_worker.cpython-312.pyc +0 -0
  70. package/core/jobs/__pycache__/manager.cpython-312.pyc +0 -0
  71. package/core/knowledge/__pycache__/embedder.cpython-312.pyc +0 -0
  72. package/core/knowledge/__pycache__/embedder.cpython-313.pyc +0 -0
  73. package/core/knowledge/__pycache__/vector_store.cpython-312.pyc +0 -0
  74. package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
  75. package/core/knowledge/embedder.py +118 -11
  76. package/core/knowledge/vector_store.py +111 -14
  77. package/core/runtime/__pycache__/__init__.cpython-312.pyc +0 -0
  78. package/core/runtime/__pycache__/__init__.cpython-313.pyc +0 -0
  79. package/core/runtime/__pycache__/base.cpython-312.pyc +0 -0
  80. package/core/runtime/__pycache__/base.cpython-313.pyc +0 -0
  81. package/core/runtime/__pycache__/capabilities_cli.cpython-312.pyc +0 -0
  82. package/core/runtime/__pycache__/capabilities_cli.cpython-313.pyc +0 -0
  83. package/core/runtime/__pycache__/claude_code.cpython-312.pyc +0 -0
  84. package/core/runtime/__pycache__/claude_code.cpython-313.pyc +0 -0
  85. package/core/runtime/__pycache__/codex_cli.cpython-312.pyc +0 -0
  86. package/core/runtime/__pycache__/codex_cli.cpython-313.pyc +0 -0
  87. package/core/runtime/__pycache__/cost_governor.cpython-312.pyc +0 -0
  88. package/core/runtime/__pycache__/cost_governor.cpython-313.pyc +0 -0
  89. package/core/runtime/__pycache__/cursor.cpython-312.pyc +0 -0
  90. package/core/runtime/__pycache__/cursor.cpython-313.pyc +0 -0
  91. package/core/runtime/__pycache__/gemini_cli.cpython-312.pyc +0 -0
  92. package/core/runtime/__pycache__/gemini_cli.cpython-313.pyc +0 -0
  93. package/core/runtime/__pycache__/llm_provider.cpython-312.pyc +0 -0
  94. package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
  95. package/core/runtime/__pycache__/llm_retry.cpython-312.pyc +0 -0
  96. package/core/runtime/__pycache__/llm_retry.cpython-313.pyc +0 -0
  97. package/core/runtime/__pycache__/native_usage.cpython-312.pyc +0 -0
  98. package/core/runtime/__pycache__/native_usage.cpython-313.pyc +0 -0
  99. package/core/runtime/__pycache__/path_resolver.cpython-312.pyc +0 -0
  100. package/core/runtime/__pycache__/pricing.cpython-312.pyc +0 -0
  101. package/core/runtime/__pycache__/pricing.cpython-313.pyc +0 -0
  102. package/core/runtime/__pycache__/registry.cpython-312.pyc +0 -0
  103. package/core/runtime/__pycache__/registry.cpython-313.pyc +0 -0
  104. package/core/runtime/__pycache__/squad_orchestrator.cpython-312.pyc +0 -0
  105. package/core/runtime/base.py +29 -0
  106. package/core/runtime/capabilities_cli.py +74 -0
  107. package/core/runtime/claude_code.py +8 -0
  108. package/core/runtime/codex_cli.py +205 -37
  109. package/core/runtime/cost_governor.py +184 -0
  110. package/core/runtime/cursor.py +8 -0
  111. package/core/runtime/gemini_cli.py +8 -0
  112. package/core/runtime/llm_provider.py +32 -13
  113. package/core/runtime/llm_retry.py +233 -0
  114. package/core/runtime/native_usage.py +186 -0
  115. package/core/runtime/pricing.py +9 -0
  116. package/core/runtime/registry.py +3 -0
  117. package/core/shared/__pycache__/test_evidence.cpython-313-pytest-9.1.1.pyc +0 -0
  118. package/core/shared/__pycache__/test_evidence.cpython-313.pyc +0 -0
  119. package/core/shared/test_evidence.py +34 -0
  120. package/core/synapse/__pycache__/agent_experiences_layer.cpython-312.pyc +0 -0
  121. package/core/synapse/__pycache__/engine.cpython-312.pyc +0 -0
  122. package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
  123. package/core/synapse/__pycache__/graph_context_layer.cpython-313.pyc +0 -0
  124. package/core/synapse/__pycache__/kb_cache.cpython-312.pyc +0 -0
  125. package/core/synapse/__pycache__/kb_cache.cpython-313.pyc +0 -0
  126. package/core/synapse/__pycache__/layers.cpython-312.pyc +0 -0
  127. package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
  128. package/core/synapse/engine.py +7 -0
  129. package/core/synapse/graph_context_layer.py +212 -0
  130. package/core/synapse/kb_cache.py +3 -0
  131. package/core/synapse/layers.py +104 -20
  132. package/core/workflow/__pycache__/enforcer.cpython-313.pyc +0 -0
  133. package/core/workflow/__pycache__/flow_enforcer.cpython-312.pyc +0 -0
  134. package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
  135. package/core/workflow/__pycache__/gate_checkpoint.cpython-312.pyc +0 -0
  136. package/core/workflow/__pycache__/gate_checkpoint.cpython-313.pyc +0 -0
  137. package/core/workflow/__pycache__/research_gate.cpython-313.pyc +0 -0
  138. package/core/workflow/__pycache__/rules_registry.cpython-313.pyc +0 -0
  139. package/core/workflow/__pycache__/specialist_enforcer.cpython-312.pyc +0 -0
  140. package/core/workflow/__pycache__/specialist_enforcer.cpython-313.pyc +0 -0
  141. package/core/workflow/enforcer.py +5 -16
  142. package/core/workflow/flow_enforcer.py +36 -11
  143. package/core/workflow/gate_checkpoint.py +149 -0
  144. package/core/workflow/research_gate.py +15 -3
  145. package/core/workflow/rules_registry.py +137 -389
  146. package/core/workflow/specialist_enforcer.py +21 -4
  147. package/dashboard/app/pages/knowledge/index.vue +4 -1
  148. package/departments/ops/skills/n8n-flow/SKILL.md +13 -0
  149. package/departments/ops/skills/workflow-automate/SKILL.md +16 -0
  150. package/departments/quality/SKILL.md +45 -5
  151. package/departments/quality/agents/copy-director.yaml +3 -1
  152. package/departments/quality/agents/tech-director.yaml +3 -1
  153. package/installer/adapters/claude-code.js +46 -2
  154. package/installer/config-seed.js +39 -14
  155. package/installer/doctor.js +8 -0
  156. package/installer/graphify.js +153 -0
  157. package/installer/index.js +20 -0
  158. package/installer/init.js +11 -0
  159. package/installer/update.js +19 -0
  160. package/package.json +1 -1
  161. package/pyproject.toml +1 -1
  162. package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
  163. package/scripts/__pycache__/knowledge-index.cpython-313.pyc +0 -0
  164. package/scripts/__pycache__/synapse-bridge.cpython-312.pyc +0 -0
  165. package/scripts/__pycache__/synapse-bridge.cpython-313.pyc +0 -0
  166. package/scripts/knowledge-index.py +8 -2
  167. package/scripts/synapse-bridge.py +102 -26
@@ -0,0 +1,689 @@
1
+ """PostToolUse — consolidated entrypoint (PR-6 v4.1.0 hook hygiene).
2
+
3
+ Replaces the ~38 python3/jq spawn sites of the old ``post-tool-use.sh``
4
+ with ONE python process (plus one detached child for the cognition
5
+ capture, preserving its fire-and-forget backgrounding, and one optional
6
+ venv fallback for the yaml-needing workflow sections on machines whose
7
+ ambient python3 lacks PyYAML — mirroring the old ARKAOS_PY resolution).
8
+
9
+ Section order is preserved exactly:
10
+ 1. Flow marker cache write ([arka:routing]/[arka:trivial] detection)
11
+ 2. CQO REJECTED experience auto-record + APPROVED pattern stub
12
+ 3. Activation tracking for every Task/Agent dispatch
13
+ 4. Early exit `{}` when the tool output carries no error signal
14
+ 5. Gotchas memory (~/.arkaos/gotchas.json, flock, top-100)
15
+ 6. Workflow violation rules 1-3 (branch-isolation, spec-driven,
16
+ sequential-validation) against ~/.arkaos/workflow-state.json
17
+ 7. Enforcement engine (core/workflow/enforcer.py, all rules)
18
+ 8. Forge scope-creep detection (yaml plan deliverables)
19
+ 9. Cognition capture enqueue (detached background process)
20
+ 10. Hook metrics append + additionalContext output
21
+
22
+ Sections 6-8 need core.workflow / yaml. When the in-process import fails
23
+ (PyYAML-less ambient python3) and the ArkaOS venv exists, they are
24
+ delegated ONCE to ``<venv>/bin/python3 -m core.hooks.post_tool_use
25
+ --workflow-sections`` — the same interpreter the old hook used for them.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import os
32
+ import re
33
+ import subprocess
34
+ import sys
35
+ import time
36
+ from datetime import datetime, timezone
37
+ from pathlib import Path
38
+
39
+ from core.hooks._shared import (
40
+ ensure_root_on_path,
41
+ get_str,
42
+ read_stdin_json,
43
+ repo_path,
44
+ resolve_arkaos_root,
45
+ venv_python,
46
+ )
47
+
48
+ try:
49
+ import fcntl # POSIX only
50
+ _HAS_FLOCK = True
51
+ except ImportError:
52
+ _HAS_FLOCK = False
53
+
54
+
55
+ _ROUTING_RE = re.compile(
56
+ r"\[arka:routing\][ \t]*([A-Za-z_-]+)[ \t]*->[ \t]*([A-Za-z_-]+)",
57
+ re.IGNORECASE,
58
+ )
59
+ _TRIVIAL_RE = re.compile(r"\[arka:trivial\][ \t]*\S+", re.IGNORECASE)
60
+ _REJECTED_RE = re.compile(r"Quality Gate Verdict:[ \t]*REJECTED")
61
+ _APPROVED_RE = re.compile(r"Quality Gate Verdict:[ \t]*APPROVED")
62
+ _REVIEWING_RE = re.compile(r"\[arka:reviewing[ \t]+([A-Za-z0-9_.-]+)\]")
63
+ _PATTERN_SUGGEST_RE = re.compile(
64
+ r"\[arka:pattern-suggest[ \t]+([A-Za-z0-9_.-]+)[ \t]+([^][]+)\]"
65
+ )
66
+ _ERROR_TRIGGER_RE = re.compile(
67
+ r"(error:|fatal:|exception:|failed|ENOENT|EACCES|EPERM|panic:)",
68
+ re.IGNORECASE,
69
+ )
70
+ _ERROR_LINE_RE = re.compile(
71
+ r"(error|fatal|exception|failed|ENOENT|EACCES|EPERM|panic|cannot"
72
+ r"|not found|permission denied)",
73
+ re.IGNORECASE,
74
+ )
75
+ _CODE_FILE_RE = re.compile(r"\.(py|js|ts|vue|php|jsx|tsx)$")
76
+
77
+ _CATEGORY_RES: tuple[tuple[str, re.Pattern], ...] = (
78
+ ("laravel", re.compile(
79
+ r"(artisan|eloquent|laravel|blade|migration|composer|php )", re.I)),
80
+ ("frontend", re.compile(
81
+ r"(npm|node|vue|react|nuxt|next|vite|webpack|typescript|tsx|jsx)",
82
+ re.I)),
83
+ ("git", re.compile(
84
+ r"(git |merge|rebase|checkout|branch|commit|push|pull)", re.I)),
85
+ ("database", re.compile(
86
+ r"(sql|postgres|mysql|database|migration|table|column|constraint)",
87
+ re.I)),
88
+ ("permissions", re.compile(
89
+ r"(permission|denied|EACCES|EPERM|chmod|chown|sudo)", re.I)),
90
+ ("testing", re.compile(
91
+ r"(test|assert|expect|jest|phpunit|bats|coverage)", re.I)),
92
+ )
93
+
94
+
95
+ def _locked(fh) -> None:
96
+ if _HAS_FLOCK:
97
+ try:
98
+ fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
99
+ except OSError:
100
+ pass
101
+
102
+
103
+ def _unlocked(fh) -> None:
104
+ if _HAS_FLOCK:
105
+ try:
106
+ fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
107
+ except OSError:
108
+ pass
109
+
110
+
111
+ # ─── Section 1: flow marker cache ────────────────────────────────────────
112
+
113
+
114
+ def _write_flow_marker(session_id: str, assistant_msg: str) -> None:
115
+ if not session_id or not assistant_msg:
116
+ return
117
+ routing = _ROUTING_RE.search(assistant_msg)
118
+ if routing:
119
+ kind, dept, lead = "routing", routing.group(1), routing.group(2)
120
+ elif _TRIVIAL_RE.search(assistant_msg):
121
+ kind, dept, lead = "trivial", "", ""
122
+ else:
123
+ return
124
+ try:
125
+ from core.workflow.marker_cache import write_marker
126
+ write_marker(session_id, kind, dept, lead)
127
+ except Exception:
128
+ pass
129
+
130
+
131
+ # ─── Section 2+3: CQO verdicts, pattern stubs, activation ───────────────
132
+
133
+
134
+ def _record_cqo_rejected(tool_output: str, prompt: str, session_id: str) -> None:
135
+ if not _REJECTED_RE.search(tool_output):
136
+ return
137
+ match = _REVIEWING_RE.search(prompt)
138
+ if not match:
139
+ return
140
+ try:
141
+ from core.governance.cqo_experience_recorder import record_from_verdict
142
+ record_from_verdict(
143
+ verdict_text=tool_output,
144
+ agent_id=match.group(1),
145
+ session_id=session_id,
146
+ context="auto-recorded via PostToolUse hook (cqo dispatch REJECTED)",
147
+ )
148
+ except Exception:
149
+ pass
150
+
151
+
152
+ def _record_pattern_stub(tool_output: str, prompt: str) -> None:
153
+ if not _APPROVED_RE.search(tool_output):
154
+ return
155
+ match = _PATTERN_SUGGEST_RE.search(prompt)
156
+ if not match:
157
+ return
158
+ pid, pname = match.group(1), match.group(2).strip()
159
+ if not pid or not pname:
160
+ return
161
+ try:
162
+ from core.knowledge.pattern_cards import (
163
+ PatternCard,
164
+ query_patterns,
165
+ record_pattern,
166
+ )
167
+ if any(c.id == pid for c in query_patterns(limit=1000)):
168
+ return
169
+ ts = datetime.now(timezone.utc).isoformat()
170
+ record_pattern(PatternCard(
171
+ id=pid,
172
+ name=pname,
173
+ feature_keywords=[pid.replace("-", " "), pname.lower()],
174
+ description=(
175
+ "Stub auto-created from APPROVED CQO verdict — enrich via "
176
+ "record_pattern() or by editing the JSONL."
177
+ ),
178
+ stack=[], files=[], acceptance_criteria=[], edge_cases=[],
179
+ references=[], projects_using=["arkaos"],
180
+ created_at=ts, last_updated=ts,
181
+ ))
182
+ except Exception:
183
+ pass
184
+
185
+
186
+ def _record_activation(subagent_type: str, session_id: str) -> None:
187
+ if not subagent_type:
188
+ return
189
+ try:
190
+ from core.governance.activation_tracker import record_activation
191
+ record_activation(subagent_type=subagent_type, session_id=session_id)
192
+ except Exception:
193
+ pass
194
+
195
+
196
+ # ─── Section 5: gotchas memory ───────────────────────────────────────────
197
+
198
+
199
+ def _extract_error_line(tool_output: str) -> str:
200
+ lines = tool_output.splitlines()
201
+ for line in lines:
202
+ if _ERROR_LINE_RE.search(line):
203
+ return line
204
+ # Fallback mirrors `head -5 | tail -1`.
205
+ head = lines[:5]
206
+ return head[-1] if head else ""
207
+
208
+
209
+ def _normalize_pattern(error_line: str) -> str:
210
+ pattern = re.sub(
211
+ r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}[^ ]*", "TIMESTAMP",
212
+ error_line,
213
+ )
214
+ pattern = re.sub(r"[0-9a-f]{7,40}", "HASH", pattern)
215
+ pattern = re.sub(r"line \d+", "line N", pattern)
216
+ pattern = re.sub(r":\d+:", ":N:", pattern)
217
+ return pattern[:200]
218
+
219
+
220
+ def _categorize(error_line: str) -> str:
221
+ for category, regex in _CATEGORY_RES:
222
+ if regex.search(error_line):
223
+ return category
224
+ return "general"
225
+
226
+
227
+ def _fixes_file() -> Path:
228
+ arka_os = os.environ.get(
229
+ "ARKA_OS", str(Path.home() / ".claude" / "skills" / "arka")
230
+ )
231
+ fixes = Path(arka_os) / "config" / "gotchas-fixes.json"
232
+ if fixes.is_file():
233
+ return fixes
234
+ try:
235
+ repo = (Path(arka_os) / ".repo-path").read_text(encoding="utf-8").strip()
236
+ except OSError:
237
+ repo = ""
238
+ return Path(repo) / "config" / "gotchas-fixes.json" if repo else fixes
239
+
240
+
241
+ def _match_suggestion(error_line: str) -> str:
242
+ fixes_path = _fixes_file()
243
+ if not fixes_path.is_file():
244
+ return ""
245
+ try:
246
+ fixes = json.loads(fixes_path.read_text(encoding="utf-8"))
247
+ except (OSError, json.JSONDecodeError):
248
+ return ""
249
+ for fix in fixes.get("fixes", []):
250
+ pattern = fix.get("pattern_match", "")
251
+ try:
252
+ if pattern and re.search(pattern, error_line, re.IGNORECASE):
253
+ return str(fix.get("suggestion", ""))
254
+ except re.error:
255
+ continue
256
+ return ""
257
+
258
+
259
+ def _detect_project(cwd: str) -> str:
260
+ if not cwd:
261
+ return ""
262
+ arka_os = os.environ.get(
263
+ "ARKA_OS", str(Path.home() / ".claude" / "skills" / "arka")
264
+ )
265
+ try:
266
+ repo = (Path(arka_os) / ".repo-path").read_text(encoding="utf-8").strip()
267
+ except OSError:
268
+ repo = ""
269
+ if repo and (Path(repo) / "projects").is_dir():
270
+ for proj_dir in sorted((Path(repo) / "projects").iterdir()):
271
+ marker = proj_dir / ".project-path"
272
+ if not marker.is_file():
273
+ continue
274
+ try:
275
+ proj_path = marker.read_text(encoding="utf-8").strip()
276
+ except OSError:
277
+ continue
278
+ if proj_path and cwd.startswith(proj_path):
279
+ return proj_dir.name
280
+ return Path(cwd).name
281
+
282
+
283
+ def _store_gotcha(
284
+ pattern: str, error_line: str, category: str,
285
+ tool_name: str, project: str, suggestion: str,
286
+ ) -> None:
287
+ gotchas_file = Path.home() / ".arkaos" / "gotchas.json"
288
+ lock_file = Path.home() / ".arkaos" / "gotchas.lock"
289
+ gotchas_file.parent.mkdir(parents=True, exist_ok=True)
290
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
291
+ try:
292
+ with lock_file.open("a", encoding="utf-8") as lock_fh:
293
+ _locked(lock_fh)
294
+ try:
295
+ try:
296
+ entries = json.loads(
297
+ gotchas_file.read_text(encoding="utf-8")
298
+ )
299
+ if not isinstance(entries, list):
300
+ entries = []
301
+ except (OSError, json.JSONDecodeError):
302
+ entries = []
303
+ existing = next(
304
+ (e for e in entries if e.get("pattern") == pattern), None
305
+ )
306
+ if existing is not None:
307
+ existing["count"] = int(existing.get("count", 0)) + 1
308
+ existing["last_seen"] = now
309
+ if project and project not in existing.get("projects", []):
310
+ existing.setdefault("projects", []).append(project)
311
+ if suggestion and not existing.get("suggestion"):
312
+ existing["suggestion"] = suggestion
313
+ else:
314
+ entries.append({
315
+ "pattern": pattern,
316
+ "full_pattern": error_line[:500],
317
+ "category": category,
318
+ "tool": tool_name,
319
+ "count": 1,
320
+ "first_seen": now,
321
+ "last_seen": now,
322
+ "projects": [project] if project else [],
323
+ "suggestion": suggestion or None,
324
+ })
325
+ entries.sort(key=lambda e: -int(e.get("count", 0)))
326
+ tmp = gotchas_file.with_suffix(".json.tmp")
327
+ tmp.write_text(
328
+ json.dumps(entries[:100]), encoding="utf-8"
329
+ )
330
+ tmp.replace(gotchas_file)
331
+ finally:
332
+ _unlocked(lock_fh)
333
+ except OSError:
334
+ pass
335
+
336
+
337
+ # ─── Sections 6-8: workflow rules, enforcer, forge (yaml-needing) ───────
338
+
339
+
340
+ def _workflow_state() -> dict | None:
341
+ state_file = Path.home() / ".arkaos" / "workflow-state.json"
342
+ if not state_file.is_file():
343
+ return None
344
+ try:
345
+ state = json.loads(state_file.read_text(encoding="utf-8"))
346
+ except (OSError, json.JSONDecodeError):
347
+ return None
348
+ return state if isinstance(state, dict) else None
349
+
350
+
351
+ def _phase_status(state: dict, phase: str) -> str:
352
+ return str(
353
+ (state.get("phases", {}) or {}).get(phase, {}).get("status", "")
354
+ )
355
+
356
+
357
+ def _detect_rule_violations(input_data: dict) -> tuple[str, list[tuple]]:
358
+ """Rules 1-3 (stdlib detection). Returns (violation_msg, to_persist)."""
359
+ state = _workflow_state()
360
+ if state is None:
361
+ return "", []
362
+ tool_name = get_str(input_data, "tool_name")
363
+ tool_output = get_str(input_data, "tool_output")
364
+ violation_msg = ""
365
+ persist: list[tuple] = []
366
+
367
+ if tool_name == "Bash":
368
+ on_master = any(
369
+ re.match(r"^\[(master|main)", line)
370
+ for line in tool_output.splitlines()
371
+ )
372
+ cmd_text = get_str(input_data, "command")
373
+ if on_master and re.search(r"git commit", cmd_text):
374
+ persist.append((
375
+ "branch-isolation",
376
+ "Commit on master/main while workflow active", "Bash", "",
377
+ ))
378
+ violation_msg = (
379
+ "VIOLATION [branch-isolation]: Commit on master while "
380
+ "workflow active. Use a feature branch."
381
+ )
382
+
383
+ if tool_name in ("Write", "Edit"):
384
+ file_path = get_str(input_data, "file_path")
385
+ if _CODE_FILE_RE.search(file_path):
386
+ if _phase_status(state, "spec") != "completed":
387
+ persist.append((
388
+ "spec-driven", "Code edited without completed spec",
389
+ tool_name, file_path,
390
+ ))
391
+ violation_msg = (
392
+ f"VIOLATION [spec-driven]: Code edited without completed "
393
+ f"spec ({file_path}). Complete the spec phase first."
394
+ )
395
+ if not violation_msg and _phase_status(
396
+ state, "implementation"
397
+ ) == "pending":
398
+ persist.append((
399
+ "sequential-validation",
400
+ "Code written before implementation phase started",
401
+ tool_name, file_path,
402
+ ))
403
+ violation_msg = (
404
+ f"VIOLATION [sequential-validation]: Implementation "
405
+ f"started before planning completed ({file_path})."
406
+ )
407
+
408
+ return violation_msg, persist
409
+
410
+
411
+ def _run_workflow_sections(input_data: dict, persist: list, msg: str) -> str:
412
+ """Persist rule violations + run enforcer + forge check (needs yaml).
413
+
414
+ Raises ImportError when the interpreter lacks the dependencies —
415
+ caller falls back to the venv delegate.
416
+ """
417
+ import yaml # noqa: F401 — probe: forge + core.workflow need it
418
+ from core.workflow.enforcer import enforce_tool
419
+ from core.workflow.state import add_violation
420
+
421
+ for rule_id, message, tool, file_path in persist:
422
+ try:
423
+ add_violation(rule_id, message, tool, file_path)
424
+ except Exception:
425
+ pass
426
+
427
+ msg = _enforcer_messages(input_data, enforce_tool, add_violation, msg)
428
+ if not msg:
429
+ msg = _forge_violation(input_data, yaml)
430
+ return msg
431
+
432
+
433
+ def _enforcer_messages(input_data, enforce_tool, add_violation, msg: str) -> str:
434
+ tool_name = get_str(input_data, "tool_name")
435
+ extra = {}
436
+ if tool_name == "Bash":
437
+ try:
438
+ branch = subprocess.check_output(
439
+ ["git", "branch", "--show-current"],
440
+ text=True, stderr=subprocess.DEVNULL,
441
+ ).strip()
442
+ extra["git_branch"] = branch
443
+ except Exception:
444
+ extra["git_branch"] = ""
445
+ try:
446
+ result = enforce_tool(
447
+ tool_name=tool_name,
448
+ command=get_str(input_data, "command"),
449
+ file_path=get_str(input_data, "file_path"),
450
+ user_input=get_str(input_data, "user_input"),
451
+ **extra,
452
+ )
453
+ except Exception:
454
+ return msg
455
+ if not result.violations:
456
+ return msg
457
+ for v in result.violations:
458
+ try:
459
+ add_violation(v.rule_id, v.message, v.tool, v.file_path, v.severity)
460
+ except Exception:
461
+ pass
462
+ for message in result.messages:
463
+ if message:
464
+ msg = f"{msg}\n{message}" if msg else message
465
+ if result.blocked:
466
+ msg = f"🔴 BLOCK: {msg}"
467
+ return msg
468
+
469
+
470
+ def _forge_violation(input_data: dict, yaml_mod) -> str:
471
+ active = Path.home() / ".arkaos" / "plans" / "active.yaml"
472
+ if not active.is_file():
473
+ return ""
474
+ tool_name = get_str(input_data, "tool_name")
475
+ if tool_name not in ("Edit", "Write"):
476
+ return ""
477
+ try:
478
+ forge_id = active.read_text(encoding="utf-8").strip()
479
+ except OSError:
480
+ return ""
481
+ forge_file = Path.home() / ".arkaos" / "plans" / f"{forge_id}.yaml"
482
+ if not forge_file.is_file():
483
+ return ""
484
+ edited = get_str(input_data, "file_path")
485
+ if not edited:
486
+ return ""
487
+ try:
488
+ plan = yaml_mod.safe_load(forge_file.read_text(encoding="utf-8")) or {}
489
+ except Exception:
490
+ return ""
491
+ if plan.get("status", "") != "executing":
492
+ return ""
493
+ deliverables = [
494
+ d for p in plan.get("plan_phases", []) for d in p.get("deliverables", [])
495
+ ]
496
+ if not deliverables:
497
+ return ""
498
+ if any(d in edited or edited.endswith(d) for d in deliverables):
499
+ return ""
500
+ return (
501
+ f"⚠ Forge scope-creep: editing {edited} which is outside forge "
502
+ f"plan deliverables."
503
+ )
504
+
505
+
506
+ def _workflow_sections_with_fallback(
507
+ input_data: dict, root: str, persist: list, msg: str
508
+ ) -> str:
509
+ """In-process when yaml is importable; else delegate ONCE to the venv."""
510
+ try:
511
+ return _run_workflow_sections(input_data, persist, msg)
512
+ except ImportError:
513
+ pass
514
+ except Exception:
515
+ return msg
516
+ venv_py = venv_python()
517
+ if venv_py is None:
518
+ return msg
519
+ payload = json.dumps({
520
+ "input": input_data,
521
+ "persist": persist,
522
+ "violation_msg": msg,
523
+ })
524
+ try:
525
+ proc = subprocess.run(
526
+ [venv_py, "-m", "core.hooks.post_tool_use", "--workflow-sections"],
527
+ input=payload, capture_output=True, text=True, timeout=8,
528
+ env={**os.environ, "PYTHONPATH": root},
529
+ )
530
+ if proc.returncode == 0 and proc.stdout.strip():
531
+ return str(
532
+ json.loads(proc.stdout).get("violation_msg", msg) or msg
533
+ )
534
+ except Exception:
535
+ pass
536
+ return msg
537
+
538
+
539
+ # ─── Section 9: cognition capture (detached, fire-and-forget) ───────────
540
+
541
+
542
+ def _enqueue_cognition_capture(session_id: str, tool_output: str) -> None:
543
+ if not session_id or not tool_output:
544
+ return
545
+ repo = repo_path()
546
+ if not repo or not Path(repo).is_dir():
547
+ return
548
+ try:
549
+ proc = subprocess.Popen(
550
+ [sys.executable, "-m", "core.cognition.retrieval", "capture",
551
+ session_id],
552
+ stdin=subprocess.PIPE,
553
+ stdout=subprocess.DEVNULL,
554
+ stderr=subprocess.DEVNULL,
555
+ env={**os.environ, "PYTHONPATH": repo},
556
+ start_new_session=True, # replaces `& disown`
557
+ )
558
+ if proc.stdin is not None:
559
+ try:
560
+ proc.stdin.write(tool_output.encode("utf-8", "replace"))
561
+ proc.stdin.close()
562
+ except (BrokenPipeError, OSError):
563
+ pass
564
+ except Exception:
565
+ pass
566
+
567
+
568
+ # ─── Section 10: metrics ─────────────────────────────────────────────────
569
+
570
+
571
+ def _log_metrics(duration_ms: int) -> None:
572
+ metrics_file = Path.home() / ".arkaos" / "hook-metrics.json"
573
+ lock_file = Path.home() / ".arkaos" / "hook-metrics.lock"
574
+ metrics_file.parent.mkdir(parents=True, exist_ok=True)
575
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
576
+ try:
577
+ with lock_file.open("a", encoding="utf-8") as lock_fh:
578
+ _locked(lock_fh)
579
+ try:
580
+ try:
581
+ entries = json.loads(
582
+ metrics_file.read_text(encoding="utf-8")
583
+ )
584
+ if not isinstance(entries, list):
585
+ entries = []
586
+ except (OSError, json.JSONDecodeError):
587
+ entries = []
588
+ entries.append({
589
+ "hook": "post-tool-use",
590
+ "duration_ms": duration_ms,
591
+ "timestamp": now,
592
+ })
593
+ tmp = metrics_file.with_suffix(".json.tmp")
594
+ tmp.write_text(json.dumps(entries[-500:]), encoding="utf-8")
595
+ tmp.replace(metrics_file)
596
+ finally:
597
+ _unlocked(lock_fh)
598
+ except OSError:
599
+ pass
600
+
601
+
602
+ # ─── Entry point ─────────────────────────────────────────────────────────
603
+
604
+
605
+ def main(stdin_json: dict | None = None) -> int:
606
+ start = time.monotonic()
607
+ if stdin_json is None:
608
+ stdin_json, _ = read_stdin_json()
609
+ root = resolve_arkaos_root()
610
+ ensure_root_on_path(root)
611
+
612
+ tool_name = get_str(stdin_json, "tool_name")
613
+ tool_output = get_str(stdin_json, "tool_output")
614
+ exit_code = get_str(stdin_json, "exit_code") or "0"
615
+ cwd = get_str(stdin_json, "cwd")
616
+ session_id = get_str(stdin_json, "session_id")
617
+ assistant_msg = get_str(stdin_json, "assistant_message")
618
+
619
+ _write_flow_marker(session_id, assistant_msg)
620
+
621
+ if tool_name in ("Task", "Agent"):
622
+ subagent_type = get_str(stdin_json, "tool_input", "subagent_type")
623
+ if subagent_type == "cqo":
624
+ prompt = get_str(stdin_json, "tool_input", "prompt")
625
+ _record_cqo_rejected(tool_output, prompt, session_id)
626
+ _record_pattern_stub(tool_output, prompt)
627
+ _record_activation(subagent_type, session_id)
628
+
629
+ # Only process further if there was an error signal (same early exit
630
+ # as the bash version — violations/metrics only run on error turns).
631
+ if exit_code in ("0", "") and not _ERROR_TRIGGER_RE.search(tool_output):
632
+ print("{}")
633
+ return 0
634
+
635
+ error_line = _extract_error_line(tool_output)
636
+ if not error_line:
637
+ print("{}")
638
+ return 0
639
+ pattern = _normalize_pattern(error_line)
640
+ if not pattern:
641
+ print("{}")
642
+ return 0
643
+
644
+ _store_gotcha(
645
+ pattern, error_line, _categorize(error_line), tool_name,
646
+ _detect_project(cwd), _match_suggestion(error_line),
647
+ )
648
+
649
+ violation_msg, persist = _detect_rule_violations(stdin_json)
650
+ violation_msg = _workflow_sections_with_fallback(
651
+ stdin_json, root, persist, violation_msg
652
+ )
653
+
654
+ _enqueue_cognition_capture(session_id, tool_output)
655
+ _log_metrics(int((time.monotonic() - start) * 1000))
656
+
657
+ if violation_msg:
658
+ print(json.dumps({"additionalContext": violation_msg}))
659
+ else:
660
+ print("{}")
661
+ return 0
662
+
663
+
664
+ def _workflow_sections_main() -> int:
665
+ """`--workflow-sections` mode — the venv delegate entry."""
666
+ payload, _ = read_stdin_json()
667
+ root = resolve_arkaos_root()
668
+ ensure_root_on_path(root)
669
+ input_data = payload.get("input", {})
670
+ persist = [tuple(item) for item in payload.get("persist", [])]
671
+ msg = str(payload.get("violation_msg", ""))
672
+ try:
673
+ msg = _run_workflow_sections(input_data, persist, msg)
674
+ except Exception:
675
+ pass
676
+ print(json.dumps({"violation_msg": msg}))
677
+ return 0
678
+
679
+
680
+ if __name__ == "__main__":
681
+ try:
682
+ if "--workflow-sections" in sys.argv[1:]:
683
+ raise SystemExit(_workflow_sections_main())
684
+ raise SystemExit(main())
685
+ except SystemExit:
686
+ raise
687
+ except Exception:
688
+ print("{}")
689
+ raise SystemExit(0)