arkaos 4.11.0 → 4.12.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 (71) hide show
  1. package/README.md +1 -1
  2. package/VERSION +1 -1
  3. package/arka/SKILL.md +10 -1
  4. package/arka/skills/flow/SKILL.md +23 -1
  5. package/arka/skills/recipes/SKILL.md +69 -0
  6. package/arka/skills/refine/SKILL.md +81 -0
  7. package/config/constitution.yaml +8 -0
  8. package/config/mcp-policy.yaml +8 -0
  9. package/config/output-styles/arkaos.md +52 -0
  10. package/core/evals/__init__.py +4 -0
  11. package/core/evals/__pycache__/__init__.cpython-313.pyc +0 -0
  12. package/core/evals/__pycache__/record_cli.cpython-313.pyc +0 -0
  13. package/core/evals/__pycache__/verdict_labels.cpython-313.pyc +0 -0
  14. package/core/evals/record_cli.py +41 -8
  15. package/core/evals/verdict_labels.py +53 -13
  16. package/core/forge/__pycache__/complexity.cpython-313.pyc +0 -0
  17. package/core/forge/complexity.py +9 -0
  18. package/core/governance/__pycache__/evidence_checks.cpython-313.pyc +0 -0
  19. package/core/governance/__pycache__/judge.cpython-313.pyc +0 -0
  20. package/core/governance/__pycache__/phantom_action_check.cpython-313.pyc +0 -0
  21. package/core/governance/__pycache__/phantom_action_check.cpython-314.pyc +0 -0
  22. package/core/governance/evidence_checks.py +60 -4
  23. package/core/governance/judge.py +95 -0
  24. package/core/governance/phantom_action_check.py +41 -0
  25. package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
  26. package/core/hooks/__pycache__/stop.cpython-313.pyc +0 -0
  27. package/core/hooks/__pycache__/stop.cpython-314.pyc +0 -0
  28. package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  29. package/core/hooks/post_tool_use.py +13 -0
  30. package/core/hooks/stop.py +23 -0
  31. package/core/hooks/user_prompt_submit.py +69 -0
  32. package/core/knowledge/__pycache__/recipes.cpython-313.pyc +0 -0
  33. package/core/knowledge/__pycache__/recipes_cli.cpython-313.pyc +0 -0
  34. package/core/knowledge/recipes.py +237 -0
  35. package/core/knowledge/recipes_cli.py +99 -0
  36. package/core/shared/__pycache__/safe_session_id.cpython-313.pyc +0 -0
  37. package/core/shared/__pycache__/safe_session_id.cpython-314.pyc +0 -0
  38. package/core/shared/safe_session_id.py +11 -3
  39. package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
  40. package/core/synapse/__pycache__/engine.cpython-314.pyc +0 -0
  41. package/core/synapse/__pycache__/pattern_library_layer.cpython-313.pyc +0 -0
  42. package/core/synapse/__pycache__/recipe_layer.cpython-313.pyc +0 -0
  43. package/core/synapse/engine.py +6 -0
  44. package/core/synapse/pattern_library_layer.py +10 -2
  45. package/core/synapse/recipe_layer.py +114 -0
  46. package/core/sync/__pycache__/mcp_optimizer.cpython-313.pyc +0 -0
  47. package/core/sync/__pycache__/mcp_syncer.cpython-313.pyc +0 -0
  48. package/core/sync/__pycache__/settings_syncer.cpython-313.pyc +0 -0
  49. package/core/sync/mcp_optimizer.py +4 -1
  50. package/core/sync/mcp_syncer.py +50 -6
  51. package/core/sync/settings_syncer.py +9 -2
  52. package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
  53. package/core/workflow/__pycache__/flow_enforcer.cpython-314.pyc +0 -0
  54. package/core/workflow/__pycache__/plan_approval.cpython-313.pyc +0 -0
  55. package/core/workflow/__pycache__/plan_approval.cpython-314.pyc +0 -0
  56. package/core/workflow/flow_enforcer.py +63 -0
  57. package/core/workflow/plan_approval.py +185 -0
  58. package/departments/dev/SKILL.md +10 -0
  59. package/departments/dev/skills/onboard/SKILL.md +6 -2
  60. package/departments/dev/skills/refactor-plan/SKILL.md +7 -0
  61. package/departments/dev/skills/scaffold/SKILL.md +6 -0
  62. package/departments/quality/SKILL.md +16 -0
  63. package/installer/doctor.js +7 -0
  64. package/installer/index.js +20 -0
  65. package/installer/output-style.js +92 -0
  66. package/installer/update.js +19 -0
  67. package/installer/worktree-baseref.js +8 -2
  68. package/knowledge/commands-keywords.json +4 -0
  69. package/knowledge/commands-registry.json +41 -3
  70. package/package.json +1 -1
  71. package/pyproject.toml +1 -1
@@ -0,0 +1,99 @@
1
+ """Recipe capture/list/show CLI (Interaction Reform PR7).
2
+
3
+ The QG skill (step 7) proposes promoting an APPROVED reusable feature
4
+ to a recipe; on operator confirmation it calls `capture` here. Capture
5
+ is fail-closed: no redaction config → refused (SanitizerConfigMissing),
6
+ because a client identifier reaching the recipe corpus is the exact
7
+ confidentiality breach the sanitizer exists to prevent.
8
+
9
+ Usage:
10
+ arka-py -m core.knowledge.recipes_cli capture --spec <recipe.json>
11
+ arka-py -m core.knowledge.recipes_cli list
12
+ arka-py -m core.knowledge.recipes_cli show <slug>
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from pydantic import ValidationError
23
+
24
+ from core.evals.sanitizer import SanitizerConfigMissing
25
+ from core.knowledge.recipes import (
26
+ Recipe,
27
+ RecipeCaptureRefused,
28
+ capture_recipe,
29
+ list_recipes,
30
+ load_recipe,
31
+ )
32
+
33
+
34
+ def _cmd_capture(args: argparse.Namespace) -> int:
35
+ """Capture from a spec JSON: {recipe, narrative, files:{path:content}}."""
36
+ raw = (
37
+ Path(args.spec).read_text(encoding="utf-8")
38
+ if args.spec
39
+ else sys.stdin.read()
40
+ )
41
+ try:
42
+ payload = json.loads(raw)
43
+ recipe = Recipe.model_validate(payload["recipe"])
44
+ except (json.JSONDecodeError, KeyError, ValidationError) as exc:
45
+ print(f"error: invalid recipe spec — {exc}", file=sys.stderr)
46
+ return 1
47
+ try:
48
+ target = capture_recipe(
49
+ recipe,
50
+ narrative=payload.get("narrative", ""),
51
+ reference_files=payload.get("files", {}),
52
+ )
53
+ except SanitizerConfigMissing as exc:
54
+ print(f"error: capture refused — {exc}", file=sys.stderr)
55
+ return 2
56
+ except RecipeCaptureRefused as exc:
57
+ print(f"error: capture refused — {exc}", file=sys.stderr)
58
+ return 3
59
+ print(json.dumps({"captured": True, "slug": recipe.slug,
60
+ "path": str(target)}))
61
+ return 0
62
+
63
+
64
+ def _cmd_list(_args: argparse.Namespace) -> int:
65
+ recipes = list_recipes()
66
+ for recipe in recipes:
67
+ print(f"{recipe.slug:30s} {', '.join(recipe.stack):20s} {recipe.name}")
68
+ print(f"\n{len(recipes)} recipe(s)")
69
+ return 0
70
+
71
+
72
+ def _cmd_show(args: argparse.Namespace) -> int:
73
+ recipe = load_recipe(args.slug)
74
+ if recipe is None:
75
+ print(f"error: no recipe {args.slug!r}", file=sys.stderr)
76
+ return 1
77
+ print(recipe.model_dump_json(indent=2))
78
+ return 0
79
+
80
+
81
+ def main(argv: list[str] | None = None) -> int:
82
+ parser = argparse.ArgumentParser(description=__doc__)
83
+ sub = parser.add_subparsers(dest="cmd", required=True)
84
+ p_capture = sub.add_parser("capture")
85
+ p_capture.add_argument("--spec", help="spec JSON file (default: stdin)")
86
+ sub.add_parser("list")
87
+ p_show = sub.add_parser("show")
88
+ p_show.add_argument("slug")
89
+ args = parser.parse_args(argv)
90
+
91
+ return {
92
+ "capture": _cmd_capture,
93
+ "list": _cmd_list,
94
+ "show": _cmd_show,
95
+ }[args.cmd](args)
96
+
97
+
98
+ if __name__ == "__main__":
99
+ raise SystemExit(main())
@@ -21,15 +21,21 @@ import re
21
21
 
22
22
 
23
23
  SAFE_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
24
+ # A token made ONLY of dots ("." / ".." / "...") passes the char
25
+ # allowlist but is a filesystem traversal/self-reference — it must
26
+ # reject (QG 2026-07-09: `safe_session_id('..') == '..'` would let a
27
+ # future `recipes delete ..` rmtree ~/.arkaos).
28
+ _DOT_ONLY_RE = re.compile(r"^\.+$")
24
29
 
25
30
 
26
31
  def safe_session_id(session_id: str) -> str | None:
27
32
  """Validate ``session_id`` against the strict allowlist.
28
33
 
29
34
  Returns the id unchanged when safe, or ``None`` when it contains
30
- path separators, ``..`` traversal fragments, whitespace, unicode,
31
- NUL bytes, or any character outside ``[A-Za-z0-9._-]``. Length is
32
- capped at 128 characters to prevent pathological filesystem paths.
35
+ path separators, ``..`` traversal fragments, a dot-only token,
36
+ whitespace, unicode, NUL bytes, or any character outside
37
+ ``[A-Za-z0-9._-]``. Length is capped at 128 characters to prevent
38
+ pathological filesystem paths.
33
39
 
34
40
  Callers MUST treat ``None`` as reject — never construct a path or
35
41
  shell argument from the raw input when this returns ``None``.
@@ -38,4 +44,6 @@ def safe_session_id(session_id: str) -> str | None:
38
44
  return None
39
45
  if not SAFE_SESSION_ID_RE.match(session_id):
40
46
  return None
47
+ if _DOT_ONLY_RE.match(session_id):
48
+ return None
41
49
  return session_id
@@ -204,6 +204,7 @@ def create_default_engine(
204
204
  from core.synapse.agent_experiences_layer import AgentExperiencesLayer
205
205
  from core.synapse.graph_context_layer import GraphContextLayer
206
206
  from core.synapse.pattern_library_layer import PatternLibraryLayer
207
+ from core.synapse.recipe_layer import RecipeLayer
207
208
 
208
209
  engine = SynapseEngine()
209
210
 
@@ -245,6 +246,11 @@ def create_default_engine(
245
246
  # specs and dispatched specialists start from prior art rather than
246
247
  # reinventing.
247
248
  engine.register_layer(PatternLibraryLayer())
249
+ # L7.6 (Interaction Reform PR8) — validated Recipe injection.
250
+ # Sibling of L7.5: surfaces QG-approved prior BUILDS (reference files
251
+ # on disk) when the prompt matches, so the build loop reuses proven
252
+ # work. Inert when no recipes are captured.
253
+ engine.register_layer(RecipeLayer())
248
254
  engine.register_layer(SessionContextLayer())
249
255
 
250
256
  return engine
@@ -42,8 +42,12 @@ _STOPWORDS: frozenset[str] = frozenset({
42
42
  })
43
43
 
44
44
 
45
- def _extract_keywords(text: str, max_n: int = 10) -> list[str]:
46
- """Return the first `max_n` distinct meaningful words from `text`."""
45
+ def extract_keywords(text: str, max_n: int = 10) -> list[str]:
46
+ """Return the first `max_n` distinct meaningful words from `text`.
47
+
48
+ Public: shared by L7.5 (patterns) and L7.6 (recipes) — a single
49
+ extractor keeps the two prior-art layers matching on the same tokens.
50
+ """
47
51
  seen: list[str] = []
48
52
  seen_set: set[str] = set()
49
53
  for word in _WORD_RE.findall(text or ""):
@@ -57,6 +61,10 @@ def _extract_keywords(text: str, max_n: int = 10) -> list[str]:
57
61
  return seen
58
62
 
59
63
 
64
+ # Back-compat alias (was private before Interaction Reform PR8).
65
+ _extract_keywords = extract_keywords
66
+
67
+
60
68
  class PatternLibraryLayer(Layer):
61
69
  """L7.5 — surface prior implementations matching the user prompt."""
62
70
 
@@ -0,0 +1,114 @@
1
+ """Synapse layer L7.6 — validated Recipe injection (Interaction Reform PR8).
2
+
3
+ Sibling of L7.5 (Pattern Library): where a PatternCard is a short text
4
+ hint, a Recipe points at QG-approved reference files on disk that can be
5
+ adapted. When the prompt's keywords overlap a captured recipe, this
6
+ layer surfaces the top matches as `[recipes:N]` so the orchestrator
7
+ starts from "we have a validated build of this" — the `/arka refine` →
8
+ build loop reuses proven work instead of re-deriving it.
9
+
10
+ Keyword extraction is shared with L7.5 (imported, not duplicated).
11
+ Matching is keyword-overlap on the recipe's feature_keywords + stack for
12
+ v1; semantic ranking is a follow-up.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import time
18
+
19
+ from core.knowledge.recipes import Recipe, list_recipes
20
+ from core.synapse.layers import Layer, LayerResult, PromptContext
21
+ from core.synapse.pattern_library_layer import extract_keywords
22
+
23
+
24
+ class RecipeLayer(Layer):
25
+ """L7.6 — surface validated recipes matching the user prompt."""
26
+
27
+ def __init__(self, limit: int = 3) -> None:
28
+ self._limit = limit
29
+
30
+ @property
31
+ def id(self) -> str:
32
+ return "L7.6"
33
+
34
+ @property
35
+ def name(self) -> str:
36
+ return "Recipes"
37
+
38
+ @property
39
+ def input_sensitive(self) -> bool:
40
+ return True
41
+
42
+ @property
43
+ def cache_ttl(self) -> int:
44
+ return 60
45
+
46
+ @property
47
+ def priority(self) -> int:
48
+ # 76 — right after L7.5 Pattern Library (75), before L8 Forge (80),
49
+ # so both prior-art sources reach Forge's plan synthesis.
50
+ return 76
51
+
52
+ def compute(self, ctx: PromptContext) -> LayerResult:
53
+ start = time.time()
54
+ keywords = extract_keywords(ctx.user_input)
55
+ if not keywords:
56
+ return self._empty(start)
57
+ matches = _match_recipes(keywords, self._limit)
58
+ if not matches:
59
+ return self._empty(start, tag="[recipes:none]")
60
+ content = format_recipes(matches)
61
+ return LayerResult(
62
+ layer_id=self.id,
63
+ tag=f"[recipes:{len(matches)}]",
64
+ content=content,
65
+ tokens_est=max(1, len(content) // 4),
66
+ compute_ms=int((time.time() - start) * 1000),
67
+ cached=False,
68
+ )
69
+
70
+ def _empty(self, start: float, tag: str = "") -> LayerResult:
71
+ return LayerResult(
72
+ layer_id=self.id,
73
+ tag=tag,
74
+ content="",
75
+ tokens_est=0,
76
+ compute_ms=int((time.time() - start) * 1000),
77
+ cached=False,
78
+ )
79
+
80
+
81
+ def _match_recipes(keywords: list[str], limit: int) -> list[Recipe]:
82
+ """Rank recipes by keyword overlap with feature_keywords + stack."""
83
+ kw = set(keywords)
84
+ scored: list[tuple[int, Recipe]] = []
85
+ for recipe in list_recipes():
86
+ haystack = {
87
+ w.lower()
88
+ for w in (recipe.feature_keywords + recipe.stack)
89
+ }
90
+ overlap = len(kw & haystack)
91
+ if overlap:
92
+ scored.append((overlap, recipe))
93
+ scored.sort(key=lambda pair: (-pair[0], pair[1].slug))
94
+ return [recipe for _, recipe in scored[:limit]]
95
+
96
+
97
+ def format_recipes(recipes: list[Recipe]) -> str:
98
+ """Compact, model-readable summary pointing at the on-disk files."""
99
+ lines = [
100
+ "Validated recipes found (QG-approved prior builds — read "
101
+ "RECIPE.md + files/ and adapt via the normal flow, never "
102
+ "copy-paste blind):"
103
+ ]
104
+ for i, r in enumerate(recipes, start=1):
105
+ head = f" {i}. {r.name} (~/.arkaos/recipes/{r.slug}/)"
106
+ if r.stack:
107
+ head += f" — stack: {', '.join(r.stack[:4])}"
108
+ lines.append(head)
109
+ lines.append(f" {r.problem}")
110
+ if r.files:
111
+ lines.append(f" files/: {', '.join(r.files[:4])}")
112
+ if r.apply_notes:
113
+ lines.append(f" adapt: {r.apply_notes}")
114
+ return "\n".join(lines)
@@ -81,7 +81,10 @@ def _do_optimize(
81
81
 
82
82
  return mcp_result.model_copy(update={
83
83
  "final_mcp_list": sorted(active),
84
- "mcps_deferred": sorted(deferred),
84
+ # Merge, never replace: the syncer defers entries whose
85
+ # requires_binary is absent (never written to .mcp.json) and
86
+ # that skip must survive into the report (PR6).
87
+ "mcps_deferred": sorted(deferred | set(mcp_result.mcps_deferred)),
85
88
  "optimizer_warnings": warnings,
86
89
  })
87
90
 
@@ -8,22 +8,26 @@ from __future__ import annotations
8
8
 
9
9
  import copy
10
10
  import json
11
+ import shutil
11
12
  from pathlib import Path
12
13
 
13
14
  from core.sync.schema import McpSyncResult, Project
14
15
 
15
16
  # Maps stack item names to registry categories
16
17
  _STACK_TO_CATEGORIES: dict[str, list[str]] = {
17
- "laravel": ["laravel"],
18
- "nuxt": ["nuxt"],
19
- "vue": ["nuxt"],
20
- "next": ["react"],
21
- "react": ["react"],
18
+ "laravel": ["laravel", "grounding-code"],
19
+ "nuxt": ["nuxt", "grounding-code"],
20
+ "vue": ["nuxt", "grounding-code"],
21
+ "next": ["react", "grounding-code"],
22
+ "react": ["react", "grounding-code"],
23
+ "python": ["grounding-code"],
22
24
  "shopify": ["ecommerce"],
23
25
  }
24
26
 
25
27
  # Keys that are registry metadata — stripped when writing .mcp.json
26
- _REGISTRY_META_KEYS = {"category", "description", "required_env"}
28
+ _REGISTRY_META_KEYS = {
29
+ "category", "description", "required_env", "requires_binary",
30
+ }
27
31
 
28
32
 
29
33
  # ---------------------------------------------------------------------------
@@ -69,6 +73,39 @@ def resolve_mcps_for_stack(
69
73
  return result
70
74
 
71
75
 
76
+ def _split_missing_binaries(
77
+ target: list[tuple[str, dict]],
78
+ ) -> tuple[list[tuple[str, dict]], list[str]]:
79
+ """Drop entries whose `requires_binary` is not on PATH.
80
+
81
+ An MCP entry pointing at an absent binary is a dead server that
82
+ errors on every session start — the sync never writes one. The
83
+ skipped names are reported (mcps_deferred) so the operator knows
84
+ installing the binary + re-sync activates it (Interaction Reform
85
+ PR6, codebase-memory-mcp).
86
+ """
87
+ available: list[tuple[str, dict]] = []
88
+ missing: list[str] = []
89
+ for name, config in target:
90
+ binary = config.get("requires_binary", "")
91
+ if binary and shutil.which(binary) is None:
92
+ missing.append(name)
93
+ continue
94
+ available.append((name, config))
95
+ return available, missing
96
+
97
+
98
+ def _missing_binary_warnings(registry: dict, missing: list[str]) -> list[str]:
99
+ return [
100
+ (
101
+ f"{name}: requires binary "
102
+ f"'{registry.get(name, {}).get('requires_binary', '?')}' not on "
103
+ f"PATH — install it and re-run /arka update to activate"
104
+ )
105
+ for name in missing
106
+ ]
107
+
108
+
72
109
  def sync_project_mcp(
73
110
  project: Project, registry: dict, home_path: str
74
111
  ) -> McpSyncResult:
@@ -97,6 +134,7 @@ def _do_sync_mcp(
97
134
 
98
135
  current = _read_current_mcps(mcp_file)
99
136
  target_raw = resolve_mcps_for_stack(registry, project.stack)
137
+ target_raw, missing_binary = _split_missing_binaries(target_raw)
100
138
  target = _resolve_placeholders(target_raw, home_path, project.path)
101
139
  registry_names = set(registry.keys())
102
140
 
@@ -104,11 +142,15 @@ def _do_sync_mcp(
104
142
  current, target, registry_names, registry
105
143
  )
106
144
 
145
+ binary_warnings = _missing_binary_warnings(registry, missing_binary)
146
+
107
147
  if not added and not removed and not updated:
108
148
  return McpSyncResult(
109
149
  path=project.path,
110
150
  status="unchanged",
111
151
  final_mcp_list=sorted(current.keys()),
152
+ mcps_deferred=sorted(missing_binary),
153
+ optimizer_warnings=binary_warnings,
112
154
  )
113
155
 
114
156
  merged = _build_merged(current, target, registry_names, registry)
@@ -122,6 +164,8 @@ def _do_sync_mcp(
122
164
  mcps_removed=sorted(removed),
123
165
  mcps_updated=sorted(updated),
124
166
  mcps_preserved=sorted(preserved),
167
+ mcps_deferred=sorted(missing_binary),
168
+ optimizer_warnings=binary_warnings,
125
169
  final_mcp_list=sorted(merged.keys()),
126
170
  )
127
171
 
@@ -88,10 +88,14 @@ def _read_current_settings(settings_file: Path) -> dict:
88
88
 
89
89
 
90
90
  def _is_already_correct(current: dict, target_servers: list[str]) -> bool:
91
- """Return True when servers and flag already match the target."""
91
+ """Return True when servers, flag and outputStyle already match."""
92
92
  current_servers = sorted(current.get("enabledMcpjsonServers", []))
93
93
  flag = current.get("enableAllProjectMcpServers", False)
94
- return current_servers == target_servers and flag is True
94
+ return (
95
+ current_servers == target_servers
96
+ and flag is True
97
+ and bool(current.get("outputStyle"))
98
+ )
95
99
 
96
100
 
97
101
  def _compute_diff(
@@ -112,6 +116,9 @@ def _build_merged_settings(current: dict, target_servers: list[str]) -> dict:
112
116
  merged["permissions"] = _DEFAULT_PERMISSIONS
113
117
  merged["enabledMcpjsonServers"] = target_servers
114
118
  merged["enableAllProjectMcpServers"] = True
119
+ # Interaction Reform PR1: seed-if-absent — an explicit operator
120
+ # choice (any value, including "default") is never overridden.
121
+ merged.setdefault("outputStyle", "ArkaOS")
115
122
  return merged
116
123
 
117
124
 
@@ -224,6 +224,9 @@ class Decision:
224
224
  phase_observed: str | None = None
225
225
  bypass_used: bool = False
226
226
  warning: str = ""
227
+ # Interaction Reform PR3 — plan-approval telemetry (WARN phase):
228
+ # "approved" | "missing" | "no-plan" | "unknown" | "" (not gated).
229
+ approval_state: str = ""
227
230
 
228
231
  def to_stderr_message(self) -> str:
229
232
  if self.allow:
@@ -369,6 +372,46 @@ def _scan_markers(messages: list[str]) -> tuple[str | None, str | None]:
369
372
  return marker_found, phase_observed
370
373
 
371
374
 
375
+ _APPROVAL_EXEMPT_REASONS: frozenset[str] = frozenset({
376
+ "tool-not-gated", "feature-flag-off", "env-bypass",
377
+ "classifier-did-not-match",
378
+ })
379
+
380
+
381
+ def _annotate_plan_approval(decision: Decision, session_id: str) -> Decision:
382
+ """PR3 WARN phase: annotate gated allows with plan-approval state.
383
+
384
+ Telemetry only — no deny path exists here (hard enforcement is PR4,
385
+ gated on this telemetry's false-positive rate). The stderr warning
386
+ fires only for the interesting case: a plan was PRESENTED and the
387
+ effect tool ran without the approval landing.
388
+ """
389
+ if not decision.allow or decision.reason in _APPROVAL_EXEMPT_REASONS:
390
+ return decision
391
+ if decision.marker_found == "trivial" or "trivial" in decision.reason:
392
+ return decision
393
+ try:
394
+ from core.workflow import plan_approval
395
+ if plan_approval.is_approved(session_id):
396
+ decision.approval_state = "approved"
397
+ elif plan_approval.is_presented(session_id):
398
+ decision.approval_state = "missing"
399
+ warn = (
400
+ "[arka:warn] no-plan-approval-warn — effect tool ran with a "
401
+ "presented Gate-2 plan and no user approval on record "
402
+ "(telemetry only in this release)."
403
+ )
404
+ decision.warning = (
405
+ f"{decision.warning}\n{warn}".strip()
406
+ if decision.warning else warn
407
+ )
408
+ else:
409
+ decision.approval_state = "no-plan"
410
+ except Exception: # noqa: BLE001 — annotation must never break a hook
411
+ decision.approval_state = "unknown"
412
+ return decision
413
+
414
+
372
415
  def evaluate(
373
416
  tool_name: str,
374
417
  transcript_path: str,
@@ -389,7 +432,27 @@ def evaluate(
389
432
  ``messages`` (PR-6 hook consolidation): pre-parsed assistant messages.
390
433
  When None (default) the transcript is read from ``transcript_path`` —
391
434
  backward compatible with all existing callers.
435
+
436
+ Interaction Reform PR3: every gated allow is annotated with the
437
+ plan-approval state (``approval_state``) for the WARN-phase
438
+ telemetry that gates PR4's hard G2 enforcement.
392
439
  """
440
+ return _annotate_plan_approval(
441
+ _evaluate_flow(
442
+ tool_name, transcript_path, session_id, cwd, tool_input, messages
443
+ ),
444
+ session_id,
445
+ )
446
+
447
+
448
+ def _evaluate_flow(
449
+ tool_name: str,
450
+ transcript_path: str,
451
+ session_id: str = "",
452
+ cwd: str = "",
453
+ tool_input: dict | None = None,
454
+ messages: list[str] | None = None,
455
+ ) -> Decision:
393
456
  is_gated = tool_name in EFFECT_TOOLS_ALWAYS
394
457
  if not is_gated and tool_name == "Bash":
395
458
  bash_cmd = ""