arkaos 4.13.2 → 4.14.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 (130) hide show
  1. package/VERSION +1 -1
  2. package/arka/SKILL.md +16 -0
  3. package/bin/arka-doctor +20 -1
  4. package/config/cognition/schedules.yaml +16 -0
  5. package/config/hooks/_lib/fastpath/engine.cjs +337 -0
  6. package/config/hooks/gate-manifest.json +741 -0
  7. package/config/hooks/post-tool-use.cjs +113 -0
  8. package/config/hooks/pre-tool-use.cjs +109 -0
  9. package/config/hooks/session-end.ps1 +40 -0
  10. package/config/hooks/session-end.sh +55 -0
  11. package/config/hooks/session-start.sh +25 -189
  12. package/config/hooks/subagent-stop.ps1 +40 -0
  13. package/config/hooks/subagent-stop.sh +56 -0
  14. package/config/mcp-policy.yaml +8 -0
  15. package/config/skills-curated.yaml +39 -0
  16. package/config/statusline.sh +57 -1
  17. package/core/cognition/capture/__pycache__/store.cpython-313.pyc +0 -0
  18. package/core/cognition/capture/store.py +7 -2
  19. package/core/evals/__pycache__/__init__.cpython-314.pyc +0 -0
  20. package/core/evals/__pycache__/sanitizer.cpython-314.pyc +0 -0
  21. package/core/evals/__pycache__/schema.cpython-314.pyc +0 -0
  22. package/core/evals/__pycache__/verdict_labels.cpython-314.pyc +0 -0
  23. package/core/forge/__pycache__/complexity.cpython-314.pyc +0 -0
  24. package/core/governance/__pycache__/evidence_checks.cpython-313.pyc +0 -0
  25. package/core/governance/__pycache__/judge.cpython-314.pyc +0 -0
  26. package/core/governance/__pycache__/leak_scanner.cpython-314.pyc +0 -0
  27. package/core/governance/__pycache__/qg_verdict.cpython-314.pyc +0 -0
  28. package/core/governance/__pycache__/routing_feedback.cpython-313.pyc +0 -0
  29. package/core/governance/__pycache__/routing_feedback.cpython-314.pyc +0 -0
  30. package/core/governance/__pycache__/routing_feedback_cli.cpython-313.pyc +0 -0
  31. package/core/governance/evidence_checks.py +42 -7
  32. package/core/governance/routing_feedback.py +229 -0
  33. package/core/governance/routing_feedback_cli.py +49 -0
  34. package/core/hooks/__pycache__/_shared.cpython-312.pyc +0 -0
  35. package/core/hooks/__pycache__/gate_manifest.cpython-313.pyc +0 -0
  36. package/core/hooks/__pycache__/post_tool_use.cpython-312.pyc +0 -0
  37. package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
  38. package/core/hooks/__pycache__/pre_tool_use.cpython-312.pyc +0 -0
  39. package/core/hooks/__pycache__/session_end.cpython-313.pyc +0 -0
  40. package/core/hooks/__pycache__/session_start.cpython-312.pyc +0 -0
  41. package/core/hooks/__pycache__/session_start.cpython-313.pyc +0 -0
  42. package/core/hooks/__pycache__/stop.cpython-313.pyc +0 -0
  43. package/core/hooks/__pycache__/stop.cpython-314.pyc +0 -0
  44. package/core/hooks/__pycache__/subagent_stop.cpython-313.pyc +0 -0
  45. package/core/hooks/__pycache__/subagent_stop.cpython-314.pyc +0 -0
  46. package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  47. package/core/hooks/__pycache__/user_prompt_submit.cpython-314.pyc +0 -0
  48. package/core/hooks/gate_manifest.py +366 -0
  49. package/core/hooks/post_tool_use.py +5 -2
  50. package/core/hooks/session_end.py +127 -0
  51. package/core/hooks/session_start.py +322 -0
  52. package/core/hooks/stop.py +69 -0
  53. package/core/hooks/subagent_stop.py +186 -0
  54. package/core/hooks/user_prompt_submit.py +10 -6
  55. package/core/knowledge/__pycache__/embedding_backends.cpython-313.pyc +0 -0
  56. package/core/knowledge/__pycache__/embedding_backends.cpython-314.pyc +0 -0
  57. package/core/knowledge/__pycache__/pattern_cards.cpython-313.pyc +0 -0
  58. package/core/knowledge/__pycache__/pattern_cards.cpython-314.pyc +0 -0
  59. package/core/knowledge/__pycache__/recipes.cpython-314.pyc +0 -0
  60. package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
  61. package/core/knowledge/__pycache__/vector_store.cpython-314.pyc +0 -0
  62. package/core/knowledge/embedding_backends.py +278 -0
  63. package/core/knowledge/pattern_cards.py +49 -1
  64. package/core/knowledge/vector_store.py +14 -2
  65. package/core/memory/__pycache__/semantic_store.cpython-312.pyc +0 -0
  66. package/core/memory/__pycache__/semantic_store.cpython-313.pyc +0 -0
  67. package/core/memory/__pycache__/semantic_store.cpython-314.pyc +0 -0
  68. package/core/memory/__pycache__/turn_capture.cpython-313.pyc +0 -0
  69. package/core/memory/__pycache__/turn_capture.cpython-314.pyc +0 -0
  70. package/core/memory/semantic_store.py +301 -0
  71. package/core/memory/turn_capture.py +277 -0
  72. package/core/registry/__pycache__/generator.cpython-312.pyc +0 -0
  73. package/core/runtime/__pycache__/claude_code.cpython-312.pyc +0 -0
  74. package/core/runtime/__pycache__/codex_cli.cpython-312.pyc +0 -0
  75. package/core/runtime/__pycache__/cost_governor.cpython-314.pyc +0 -0
  76. package/core/runtime/__pycache__/gemini_cli.cpython-312.pyc +0 -0
  77. package/core/runtime/__pycache__/llm_cost_telemetry.cpython-312.pyc +0 -0
  78. package/core/runtime/__pycache__/mcp_telemetry.cpython-312.pyc +0 -0
  79. package/core/shared/__pycache__/decay.cpython-313.pyc +0 -0
  80. package/core/shared/__pycache__/decay.cpython-314.pyc +0 -0
  81. package/core/shared/__pycache__/sqlite_recovery.cpython-312.pyc +0 -0
  82. package/core/shared/__pycache__/sqlite_recovery.cpython-313.pyc +0 -0
  83. package/core/shared/__pycache__/sqlite_recovery.cpython-314.pyc +0 -0
  84. package/core/shared/decay.py +94 -0
  85. package/core/shared/sqlite_recovery.py +266 -0
  86. package/core/synapse/__pycache__/agent_experiences_layer.cpython-313.pyc +0 -0
  87. package/core/synapse/__pycache__/agent_experiences_layer.cpython-314.pyc +0 -0
  88. package/core/synapse/__pycache__/engine.cpython-312.pyc +0 -0
  89. package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
  90. package/core/synapse/__pycache__/engine.cpython-314.pyc +0 -0
  91. package/core/synapse/__pycache__/layers.cpython-312.pyc +0 -0
  92. package/core/synapse/__pycache__/pattern_library_layer.cpython-314.pyc +0 -0
  93. package/core/synapse/__pycache__/recipe_layer.cpython-313.pyc +0 -0
  94. package/core/synapse/__pycache__/recipe_layer.cpython-314.pyc +0 -0
  95. package/core/synapse/__pycache__/routing_feedback_layer.cpython-313.pyc +0 -0
  96. package/core/synapse/__pycache__/routing_feedback_layer.cpython-314.pyc +0 -0
  97. package/core/synapse/__pycache__/session_memory_layer.cpython-312.pyc +0 -0
  98. package/core/synapse/__pycache__/session_memory_layer.cpython-313.pyc +0 -0
  99. package/core/synapse/__pycache__/session_memory_layer.cpython-314.pyc +0 -0
  100. package/core/synapse/agent_experiences_layer.py +42 -3
  101. package/core/synapse/engine.py +13 -0
  102. package/core/synapse/recipe_layer.py +27 -4
  103. package/core/synapse/routing_feedback_layer.py +132 -0
  104. package/core/synapse/session_memory_layer.py +152 -0
  105. package/core/workflow/__pycache__/__init__.cpython-312.pyc +0 -0
  106. package/core/workflow/__pycache__/design_authorization.cpython-314.pyc +0 -0
  107. package/core/workflow/__pycache__/frontend_gate.cpython-314.pyc +0 -0
  108. package/core/workflow/__pycache__/specialist_enforcer.cpython-314.pyc +0 -0
  109. package/installer/adapters/claude-code.js +37 -0
  110. package/installer/claude-plugins.js +6 -0
  111. package/installer/cli.js +23 -2
  112. package/installer/config-seed.js +25 -16
  113. package/installer/doctor.js +116 -1
  114. package/installer/hook-lib.js +28 -0
  115. package/installer/index.js +48 -153
  116. package/installer/mcp-runner.js +108 -0
  117. package/installer/skill-deploy.js +189 -0
  118. package/installer/skills-mode.js +64 -0
  119. package/installer/update.js +55 -79
  120. package/knowledge/commands-registry.json.bak +171 -4
  121. package/knowledge/skills-manifest.json +2337 -0
  122. package/package.json +1 -1
  123. package/pyproject.toml +9 -1
  124. package/scripts/__pycache__/marketplace_gen.cpython-313.pyc +0 -0
  125. package/scripts/__pycache__/synapse-bridge.cpython-314.pyc +0 -0
  126. package/scripts/marketplace_gen.py +310 -0
  127. package/scripts/tools/__pycache__/docs_stats.cpython-313.pyc +0 -0
  128. package/scripts/tools/__pycache__/skill_budget.cpython-313.pyc +0 -0
  129. package/scripts/tools/docs_stats.py +6 -4
  130. package/scripts/tools/skill_budget.py +209 -0
@@ -0,0 +1,366 @@
1
+ """Gate manifest generator — the single source the Node fast-path trusts.
2
+
3
+ F2-6 (hook fast-path): ``config/hooks/pre-tool-use.cjs`` and
4
+ ``post-tool-use.cjs`` short-circuit the trivial hook decisions in ~18ms
5
+ p50 (measured — the Node startup floor is ~10ms on the reference
6
+ machine) instead of spawning the 82-96ms bash->Python chain on every
7
+ tool call.
8
+ The shims decide from ``config/gate-manifest.json`` ONLY — never from
9
+ constants of their own — and this module is the only writer of that
10
+ file: every value is imported from the real gate modules at generation
11
+ time (precedent: ``scripts/tools/docs_stats.py`` + its consistency
12
+ test; hand-typed copies are how the v4.3.2 drift happened).
13
+
14
+ Regenerate after touching any source constant:
15
+
16
+ python -m core.hooks.gate_manifest
17
+
18
+ ``tests/python/test_gate_manifest_parity.py`` fails when the committed
19
+ manifest drifts from these imports, and executes the embedded corpora
20
+ against the REAL Python functions; ``tests/installer/gate-manifest.test.js``
21
+ executes the same corpora against the Node engine. Drift on either side
22
+ breaks the build.
23
+
24
+ Scope contract: the manifest carries ONLY what the shims consume. The
25
+ shims have no deny path — they fast-allow a closed, corpus-proven set
26
+ or delegate to the Python chain — so gate internals that always
27
+ delegate (specialist globs, frontend markers, plan approval) stay out.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import re
34
+ from dataclasses import asdict
35
+ from pathlib import Path
36
+
37
+ _SCHEMA_VERSION = 1
38
+
39
+ # Python-only regex constructs that would compile differently (or not at
40
+ # all) under JavaScript's RegExp. The generator refuses to export any
41
+ # pattern containing them — porting is a conscious decision, not a
42
+ # silent one.
43
+ _JS_INCOMPATIBLE = ("(?P", "(?#", "(?(", "\\A", "\\Z")
44
+
45
+
46
+ def _js_pattern(compiled: re.Pattern) -> dict:
47
+ """Export a compiled Python regex as a {py, js, flags} entry."""
48
+ pattern = compiled.pattern
49
+ for construct in _JS_INCOMPATIBLE:
50
+ if construct in pattern:
51
+ raise ValueError(
52
+ f"regex {pattern!r} uses {construct!r} — not portable to "
53
+ f"JS; port it consciously before exporting"
54
+ )
55
+ flags = "i" if compiled.flags & re.IGNORECASE else ""
56
+ return {"py": pattern, "js": pattern, "flags": flags}
57
+
58
+
59
+ def _relative_to_home(path: Path) -> str:
60
+ return str(path.relative_to(Path.home()))
61
+
62
+
63
+ def _bash_corpus() -> list[dict]:
64
+ """Golden Bash classifications. ``expect`` is asserted against the
65
+ REAL ``bash_is_effect`` by the pytest side; the node:test side
66
+ asserts the engine returns ``engine_expect`` (defaults to
67
+ ``expect``). ``engine_expect`` may only ever be MORE conservative
68
+ ("effect" where Python says "discovery" — the engine delegates and
69
+ Python re-classifies); the node suite enforces that one-sidedness
70
+ structurally. The corpus cannot drift alone."""
71
+ cases: list[tuple] = [
72
+ ("git status", "discovery"),
73
+ ("git log --oneline -5", "discovery"),
74
+ ("git commit -m 'x'", "effect"),
75
+ ("git push origin master", "effect"),
76
+ ("ls -la /tmp", "discovery"),
77
+ ("echo hello", "discovery"),
78
+ ("echo hello > out.txt", "effect"),
79
+ ("cat f.txt | grep needle", "discovery"),
80
+ ("FOO=1 BAR=2 grep -r needle .", "discovery"),
81
+ ("FOO=1 rm -rf /tmp/x", "effect"),
82
+ ("rsync -av --dry-run a/ b/", "effect"),
83
+ ("unknowncmd --version", "effect"),
84
+ ("python3 -m pytest tests/", "discovery"),
85
+ ("npm run build", "discovery"),
86
+ ("npm install lodash", "effect"),
87
+ ("docker ps", "discovery"),
88
+ ("docker build -t x .", "effect"),
89
+ ("kill -9 12345", "effect"),
90
+ ("sed -i '' -e s/a/b/ f", "effect"),
91
+ ("curl -s https://example.com", "discovery"),
92
+ ("grep 'a > b' file.txt", "effect"), # redirect regex sees quotes
93
+ ("", "discovery"),
94
+ (" ", "discovery"),
95
+ ("touch marker", "effect"),
96
+ ("mkdir -p build", "effect"),
97
+ # QG B1 (redo 1) — whitespace-class boundary. U+FEFF is NOT
98
+ # whitespace to Python (isspace()=False, re \s no-match): the
99
+ # token "FOO=1<U+FEFF>grep" stays glued → unknown → EFFECT. JS
100
+ # \s/trim/split disagree, so the engine carries an explicit
101
+ # non-ASCII-whitespace guard that forces EFFECT (delegation).
102
+ ("FOO=1\ufeffgrep needle file", "effect"),
103
+ ("FOO=1\ufeffFOO=2\ufeffgrep x", "effect"),
104
+ ("FOO=1 \ufeff grep x", "effect"),
105
+ # NBSP IS whitespace to both sides, so Python classifies this
106
+ # discovery — but the engine's guard still forces EFFECT
107
+ # (delegate), the allowed conservative direction.
108
+ ("FOO=1\u00a0grep x", "discovery", "effect"),
109
+ ]
110
+ rows = []
111
+ for case in cases:
112
+ row = {"cmd": case[0], "expect": case[1]}
113
+ if len(case) > 2:
114
+ row["engine_expect"] = case[2]
115
+ rows.append(row)
116
+ return rows
117
+
118
+
119
+ def _pre_tool_corpus() -> list[dict]:
120
+ """Golden PreToolUse routing: fast_allow vs delegate per tool name."""
121
+ cases = [
122
+ ("Read", "fast_allow"),
123
+ ("Grep", "fast_allow"),
124
+ ("Glob", "fast_allow"),
125
+ ("TodoWrite", "fast_allow"),
126
+ ("ExitPlanMode", "fast_allow"),
127
+ ("Agent", "fast_allow"),
128
+ ("mcp__obsidian__search_notes", "fast_allow"),
129
+ ("mcp__supabase__list_tables", "fast_allow"),
130
+ ("", "fast_allow"),
131
+ ("WebSearch", "delegate"),
132
+ ("WebFetch", "delegate"),
133
+ ("mcp__context7__query-docs", "delegate"),
134
+ ("mcp__firecrawl__firecrawl_scrape", "delegate"),
135
+ ("Write", "delegate"),
136
+ ("Edit", "delegate"),
137
+ ("MultiEdit", "delegate"),
138
+ ("NotebookEdit", "delegate"),
139
+ ("Task", "delegate"),
140
+ ("Skill", "delegate"),
141
+ ]
142
+ return [{"tool": tool, "expect": expect} for tool, expect in cases]
143
+
144
+
145
+ def _session_id_corpus() -> list[dict]:
146
+ cases = [
147
+ ("abc-123", True),
148
+ ("a.b_c-D9", True),
149
+ ("a" * 128, True),
150
+ ("a" * 129, False),
151
+ (".", False),
152
+ ("..", False),
153
+ ("...", False),
154
+ ("a/../b", False),
155
+ ("a b", False),
156
+ ("", False),
157
+ ("sessão", False),
158
+ ]
159
+ return [{"sid": sid, "expect": expect} for sid, expect in cases]
160
+
161
+
162
+ def _error_trigger_corpus() -> list[dict]:
163
+ cases = [
164
+ ("Error: something broke", True),
165
+ ("fatal: not a git repository", True),
166
+ ("2 tests failed", True),
167
+ ("ENOENT: no such file", True),
168
+ ("panic: runtime error", True),
169
+ ("all 14 tests passed", False),
170
+ ("ok", False),
171
+ ("", False),
172
+ ]
173
+ return [{"output": output, "expect": expect} for output, expect in cases]
174
+
175
+
176
+ def _tool_sets() -> dict:
177
+ from core.hooks import pre_tool_use
178
+ from core.workflow import flow_enforcer, research_gate
179
+
180
+ if pre_tool_use._FLOW_GATED_TOOLS != flow_enforcer.EFFECT_TOOLS_ALWAYS | {"Bash"}:
181
+ raise ValueError(
182
+ "pre_tool_use._FLOW_GATED_TOOLS no longer equals "
183
+ "flow_enforcer.EFFECT_TOOLS_ALWAYS + Bash — re-derive the "
184
+ "shim decision table before regenerating"
185
+ )
186
+ return {
187
+ "flow_gated": sorted(pre_tool_use._FLOW_GATED_TOOLS),
188
+ "effect_always": sorted(flow_enforcer.EFFECT_TOOLS_ALWAYS),
189
+ "research_external": sorted(research_gate.RESEARCH_EXTERNAL_TOOLS),
190
+ "post_delegate_always": ["Agent", "ExitPlanMode", "Task"],
191
+ }
192
+
193
+
194
+ def _bash_classifier() -> dict:
195
+ from core.workflow import flow_enforcer
196
+
197
+ return {
198
+ "discovery_first_tokens": sorted(flow_enforcer._BASH_DISCOVERY_FIRST),
199
+ "effect_patterns": [
200
+ _js_pattern(p) for p in flow_enforcer._BASH_EFFECT_PATTERNS
201
+ ],
202
+ "eval_order": "blacklist-then-whitelist",
203
+ "strip_var_prefix": True,
204
+ "unknown_first_token": "effect",
205
+ }
206
+
207
+
208
+ def _telemetry_contract() -> dict:
209
+ from core.workflow import flow_enforcer, research_gate
210
+
211
+ kb_template = asdict(
212
+ research_gate.Decision(allow=True, reason="tool-not-gated")
213
+ )
214
+ flow_template = asdict(
215
+ flow_enforcer.Decision(allow=True, reason="tool-not-gated")
216
+ )
217
+ return {
218
+ # datetime.now(timezone.utc).isoformat() — microseconds, +00:00
219
+ # offset. The Node side emits the same shape (ms padded to 6
220
+ # digits); parity test asserts datetime.fromisoformat parses it.
221
+ "ts_format": "python-utc-isoformat",
222
+ "kb_first_prefix_keys": ["ts", "session_id", "tool"],
223
+ "kb_first_template": kb_template,
224
+ "enforcement_prefix_keys": ["ts", "session_id", "tool", "cwd"],
225
+ "enforcement_template": flow_template,
226
+ "mcp_keys": ["ts", "server", "tool", "session"],
227
+ "mcp_prefix": "mcp__",
228
+ }
229
+
230
+
231
+ def _flags_and_budget() -> dict:
232
+ from core.workflow import flow_enforcer
233
+
234
+ if not flow_enforcer.CONFIG_PATH.name == "config.json":
235
+ raise ValueError("flow_enforcer.CONFIG_PATH moved — update manifest")
236
+ return {
237
+ "hardEnforcement": {
238
+ "path": ["hooks", "hardEnforcement"],
239
+ # flow_enforcer._feature_flag_on: missing file → False,
240
+ # corrupt JSON → False, value coerced with PYTHON truthiness
241
+ # ("false" string is ON, [] and {} are OFF — JS Boolean()
242
+ # disagrees on the last two; engine.cjs pythonTruthy() pins it).
243
+ "on_missing_file": False,
244
+ "on_corrupt": False,
245
+ "coercion": "python-truthy",
246
+ },
247
+ "budget": {
248
+ "section": "budget",
249
+ "cap_keys": ["hardCapUsd", "dailyCapUsd"],
250
+ # cost_governor semantics (cost_governor.py:68-86,126-131):
251
+ # missing/corrupt config → {} → no-cap → allow with zero
252
+ # reads/writes; a cap is ACTIVE only when float(value) > 0.
253
+ "on_missing_file": "no-cap",
254
+ "on_corrupt": "no-cap",
255
+ "active_when": "float(value) > 0",
256
+ },
257
+ }
258
+
259
+
260
+ def _state_and_paths() -> dict:
261
+ from core.shared.temp_paths import arkaos_temp_dir
262
+ from core.workflow import flow_authorization, flow_enforcer, research_gate
263
+ from core.runtime import mcp_telemetry
264
+
265
+ auth_dir = arkaos_temp_dir("arkaos-flow-auth")
266
+ if str(auth_dir.parent) != "/tmp": # pragma: no cover — POSIX only
267
+ raise ValueError("manifest generation is POSIX-only (tmp base)")
268
+ return {
269
+ "state_dirs": {
270
+ "tmp_base_posix": "/tmp",
271
+ "flow_auth_dirname": auth_dir.name,
272
+ "flow_auth_file": "{sid}.json",
273
+ "flow_auth_env_override": "ARKA_FLOW_AUTH_DIR",
274
+ },
275
+ "home_paths": {
276
+ "config": ".arkaos/config.json",
277
+ "telemetry_kb_first": _relative_to_home(research_gate.TELEMETRY_PATH),
278
+ "telemetry_enforcement": _relative_to_home(
279
+ flow_enforcer.TELEMETRY_PATH
280
+ ),
281
+ "telemetry_mcp": _relative_to_home(mcp_telemetry.DEFAULT_PATH),
282
+ },
283
+ "numbers": {
284
+ "assistant_window": flow_enforcer.ASSISTANT_WINDOW,
285
+ "grace_cap": flow_authorization.GRACE_CAP,
286
+ "auth_ttl_seconds": flow_authorization.DEFAULT_TTL_SECONDS,
287
+ },
288
+ }
289
+
290
+
291
+ def build_manifest() -> dict:
292
+ """Assemble the manifest from the real gate constants (import-time)."""
293
+ from core.hooks import post_tool_use, pre_tool_use # noqa: F401
294
+ from core.shared import safe_session_id as ssid
295
+
296
+ state = _state_and_paths()
297
+ if pre_tool_use._ASSISTANT_WINDOW != state["numbers"]["assistant_window"]:
298
+ raise ValueError("ASSISTANT_WINDOW diverged between hook and enforcer")
299
+ return {
300
+ "schema_version": _SCHEMA_VERSION,
301
+ "platform": "posix",
302
+ "source_modules": [
303
+ "core.hooks.post_tool_use",
304
+ "core.hooks.pre_tool_use",
305
+ "core.runtime.cost_governor",
306
+ "core.runtime.mcp_telemetry",
307
+ "core.shared.safe_session_id",
308
+ "core.shared.temp_paths",
309
+ "core.workflow.flow_authorization",
310
+ "core.workflow.flow_enforcer",
311
+ "core.workflow.research_gate",
312
+ ],
313
+ "tools": _tool_sets(),
314
+ "bash": _bash_classifier(),
315
+ "post": {
316
+ "error_trigger": _js_pattern(post_tool_use._ERROR_TRIGGER_RE),
317
+ "success_exit_codes": ["0", ""],
318
+ },
319
+ "session_id": {
320
+ **_js_pattern(ssid.SAFE_SESSION_ID_RE),
321
+ "reject_dot_only": True,
322
+ },
323
+ "flags": _flags_and_budget(),
324
+ "telemetry": _telemetry_contract(),
325
+ **state,
326
+ "io_contract": {
327
+ "pre": {"allow_stdout": "", "fail_open_exit": 0},
328
+ "post": {"success_stdout": "{}", "fail_open_stdout": "{}",
329
+ "fail_open_exit": 0},
330
+ },
331
+ "env": {
332
+ "kill_switch": "ARKA_HOOK_FASTPATH",
333
+ "kill_switch_off_value": "0",
334
+ },
335
+ "corpora": {
336
+ "bash": _bash_corpus(),
337
+ "pre_tools": _pre_tool_corpus(),
338
+ "session_ids": _session_id_corpus(),
339
+ "error_trigger": _error_trigger_corpus(),
340
+ },
341
+ }
342
+
343
+
344
+ def manifest_path() -> Path:
345
+ # Sibling of the .cjs shims (config/hooks/) so the __dirname
346
+ # resolution works identically in-repo and deployed — the shims
347
+ # never resolve ARKAOS_ROOT (design R7).
348
+ return (
349
+ Path(__file__).resolve().parents[2]
350
+ / "config" / "hooks" / "gate-manifest.json"
351
+ )
352
+
353
+
354
+ def render() -> str:
355
+ return json.dumps(build_manifest(), indent=2, sort_keys=True) + "\n"
356
+
357
+
358
+ def main() -> int:
359
+ path = manifest_path()
360
+ path.write_text(render(), encoding="utf-8")
361
+ print(f"gate-manifest written: {path}")
362
+ return 0
363
+
364
+
365
+ if __name__ == "__main__":
366
+ raise SystemExit(main())
@@ -171,10 +171,13 @@ def _record_pattern_stub(tool_output: str, prompt: str) -> None:
171
171
  try:
172
172
  from core.knowledge.pattern_cards import (
173
173
  PatternCard,
174
- query_patterns,
175
174
  record_pattern,
175
+ reinforce_pattern,
176
176
  )
177
- if any(c.id == pid for c in query_patterns(limit=1000)):
177
+ # An APPROVED verdict on an EXISTING card is real usage evidence —
178
+ # reinforce it AND return: falling through to record_pattern would
179
+ # REPLACE an enriched card with this fresh empty stub (data loss).
180
+ if reinforce_pattern(pid):
178
181
  return
179
182
  ts = datetime.now(timezone.utc).isoformat()
180
183
  record_pattern(PatternCard(
@@ -0,0 +1,127 @@
1
+ """SessionEnd — consolidated entrypoint (F2-4, Claude Code reform).
2
+
3
+ Fires when a Claude Code session ends cleanly. Writes a final session
4
+ digest immediately — the PreCompact hook only fires on context
5
+ compaction, so a short session that never compacts left no record. Also
6
+ marks the session ended in the session store.
7
+
8
+ WARN/best-effort only: never blocks, always exits 0. The digest is
9
+ sanitized (recipes precedent) and content-hashed for dedup, same shape
10
+ as the PreCompact digest.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ from datetime import UTC, datetime
17
+ from pathlib import Path
18
+
19
+ from core.hooks._shared import (
20
+ ensure_root_on_path,
21
+ get_str,
22
+ read_stdin_json,
23
+ resolve_arkaos_root,
24
+ safe_session_id,
25
+ )
26
+
27
+ _DIGEST_DIR = Path.home() / ".arkaos" / "session-digests"
28
+ _TAIL_MSGS = 5
29
+ _MSG_CHARS = 500
30
+
31
+
32
+ def _read_transcript(path: str) -> str | None:
33
+ if not path:
34
+ return None
35
+ try:
36
+ # errors="replace" + ValueError: never raise on an invalid-UTF-8
37
+ # transcript or a null-byte path (this hook promises exit 0 always,
38
+ # and write_digest must reach _end_session).
39
+ return Path(path).read_text(encoding="utf-8", errors="replace")
40
+ except (OSError, ValueError):
41
+ return None
42
+
43
+
44
+ def _last_assistant_texts(transcript_path: str, raw: str | None) -> list[str]:
45
+ try:
46
+ from core.workflow.flow_enforcer import _load_last_assistant_messages
47
+
48
+ return _load_last_assistant_messages(transcript_path, _TAIL_MSGS, raw_text=raw)
49
+ except Exception:
50
+ return []
51
+
52
+
53
+ def _sanitize(text: str) -> str:
54
+ try:
55
+ from core.evals.sanitizer import SanitizerConfigMissing, sanitize_text
56
+
57
+ try:
58
+ clean, _counts = sanitize_text(text)
59
+ return clean
60
+ except SanitizerConfigMissing:
61
+ return "" # no config => omit the excerpt, keep the digest metadata
62
+ except Exception:
63
+ return ""
64
+
65
+
66
+ def _digest_id(session_id: str, seed: str) -> str:
67
+ basis = f"{session_id or 'default'}-{seed[:200]}".encode("utf-8", "replace")
68
+ return hashlib.sha256(basis).hexdigest()[:16]
69
+
70
+
71
+ def write_digest(session_id: str, transcript_path: str) -> Path | None:
72
+ raw = _read_transcript(transcript_path)
73
+ messages = _last_assistant_texts(transcript_path, raw)
74
+ seed = messages[-1] if messages else ""
75
+ digest_id = _digest_id(session_id, seed)
76
+ short = (session_id or "default")[:8]
77
+ sanitized = [_sanitize(m[:_MSG_CHARS]) for m in messages]
78
+ excerpt = "\n\n".join(s for s in sanitized if s) \
79
+ or "(no sanitizable transcript excerpt)"
80
+ body = (
81
+ "---\n"
82
+ "type: session-digest\n"
83
+ f"session_id: {session_id}\n"
84
+ f"digest_id: {digest_id}\n"
85
+ "trigger: session-end\n"
86
+ f"ended_at: {datetime.now(UTC).isoformat()}\n"
87
+ "---\n\n"
88
+ f"# Session Digest — {digest_id}\n\n"
89
+ "## Closing messages (sanitized)\n\n"
90
+ f"{excerpt}\n"
91
+ )
92
+ try:
93
+ _DIGEST_DIR.mkdir(parents=True, exist_ok=True)
94
+ path = _DIGEST_DIR / f"digest-{short}-{digest_id}.md"
95
+ path.write_text(body, encoding="utf-8")
96
+ return path
97
+ except OSError:
98
+ return None
99
+
100
+
101
+ def _end_session(session_id: str) -> None:
102
+ try:
103
+ from core.memory.session_store import SessionStore
104
+
105
+ SessionStore(session_id).end_session()
106
+ except Exception:
107
+ pass
108
+
109
+
110
+ def main(stdin_json: dict | None = None) -> int:
111
+ if stdin_json is None:
112
+ stdin_json, _ = read_stdin_json()
113
+ root = resolve_arkaos_root()
114
+ ensure_root_on_path(root)
115
+
116
+ session_id = get_str(stdin_json, "session_id")
117
+ if session_id and not safe_session_id(session_id):
118
+ return 0
119
+ transcript_path = get_str(stdin_json, "transcript_path")
120
+
121
+ write_digest(session_id, transcript_path)
122
+ _end_session(session_id)
123
+ return 0
124
+
125
+
126
+ if __name__ == "__main__": # pragma: no cover
127
+ raise SystemExit(main())