arkaos 4.11.0 → 4.13.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.
- package/README.md +2 -2
- package/VERSION +1 -1
- package/arka/SKILL.md +10 -1
- package/arka/skills/flow/SKILL.md +34 -5
- package/arka/skills/recipes/SKILL.md +69 -0
- package/arka/skills/refine/SKILL.md +81 -0
- package/config/claude-agents/content-researcher.md +51 -0
- package/config/claude-agents/frontend-dev.md +1 -1
- package/config/claude-agents/info-compiler.md +51 -0
- package/config/claude-agents/scriptwriter.md +9 -7
- package/config/claude-agents/trends-analyst.md +51 -0
- package/config/claude-agents/ux-designer.md +1 -1
- package/config/claude-agents/video-producer.md +51 -0
- package/config/claude-agents/visual-designer.md +1 -1
- package/config/constitution.yaml +9 -0
- package/config/mcp-policy.yaml +16 -0
- package/config/output-styles/arkaos.md +52 -0
- package/core/evals/__init__.py +4 -0
- package/core/evals/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/evals/__pycache__/record_cli.cpython-313.pyc +0 -0
- package/core/evals/__pycache__/verdict_labels.cpython-313.pyc +0 -0
- package/core/evals/record_cli.py +41 -8
- package/core/evals/verdict_labels.py +53 -13
- package/core/forge/__pycache__/complexity.cpython-313.pyc +0 -0
- package/core/forge/complexity.py +9 -0
- package/core/governance/__pycache__/evidence_checks.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/judge.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/phantom_action_check.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/phantom_action_check.cpython-314.pyc +0 -0
- package/core/governance/evidence_checks.py +130 -4
- package/core/governance/judge.py +95 -0
- package/core/governance/phantom_action_check.py +41 -0
- package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/pre_tool_use.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/pre_tool_use.cpython-314.pyc +0 -0
- package/core/hooks/__pycache__/stop.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/stop.cpython-314.pyc +0 -0
- package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
- package/core/hooks/post_tool_use.py +13 -0
- package/core/hooks/pre_tool_use.py +8 -2
- package/core/hooks/stop.py +23 -0
- package/core/hooks/user_prompt_submit.py +69 -0
- package/core/knowledge/__pycache__/recipes.cpython-313.pyc +0 -0
- package/core/knowledge/__pycache__/recipes_cli.cpython-313.pyc +0 -0
- package/core/knowledge/recipes.py +237 -0
- package/core/knowledge/recipes_cli.py +99 -0
- package/core/runtime/__pycache__/model_routing_context.cpython-313.pyc +0 -0
- package/core/runtime/model_routing_context.py +4 -0
- package/core/shared/__pycache__/safe_session_id.cpython-313.pyc +0 -0
- package/core/shared/__pycache__/safe_session_id.cpython-314.pyc +0 -0
- package/core/shared/safe_session_id.py +11 -3
- package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/engine.cpython-314.pyc +0 -0
- package/core/synapse/__pycache__/pattern_library_layer.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/recipe_layer.cpython-313.pyc +0 -0
- package/core/synapse/engine.py +6 -0
- package/core/synapse/pattern_library_layer.py +10 -2
- package/core/synapse/recipe_layer.py +114 -0
- package/core/sync/__pycache__/mcp_optimizer.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/mcp_syncer.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/settings_syncer.cpython-313.pyc +0 -0
- package/core/sync/mcp_optimizer.py +4 -1
- package/core/sync/mcp_syncer.py +50 -6
- package/core/sync/settings_syncer.py +9 -2
- package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/flow_enforcer.cpython-314.pyc +0 -0
- package/core/workflow/__pycache__/frontend_gate.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/frontend_gate_telemetry.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/frontend_gate_telemetry_cli.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/plan_approval.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/plan_approval.cpython-314.pyc +0 -0
- package/core/workflow/flow_enforcer.py +63 -0
- package/core/workflow/frontend_gate.py +118 -25
- package/core/workflow/frontend_gate_telemetry.py +129 -0
- package/core/workflow/frontend_gate_telemetry_cli.py +53 -0
- package/core/workflow/plan_approval.py +185 -0
- package/departments/brand/agents/ux-designer.yaml +1 -1
- package/departments/brand/agents/visual-designer.yaml +1 -1
- package/departments/brand/references/uiux-knowledge-and-tools.md +73 -0
- package/departments/brand/skills/colors/SKILL.md +76 -3
- package/departments/brand/skills/design-review/SKILL.md +81 -31
- package/departments/brand/skills/design-system/SKILL.md +32 -0
- package/departments/brand/skills/identity-system/SKILL.md +65 -4
- package/departments/brand/skills/logo-brief/SKILL.md +81 -3
- package/departments/brand/skills/mockup-generate/SKILL.md +82 -3
- package/departments/brand/skills/ux-audit/SKILL.md +74 -5
- package/departments/brand/skills/wireframe/SKILL.md +75 -3
- package/departments/content/SKILL.md +16 -2
- package/departments/content/agents/production/content-researcher.yaml +68 -0
- package/departments/content/agents/production/info-compiler.yaml +68 -0
- package/departments/content/agents/production/trends-analyst.yaml +69 -0
- package/departments/content/agents/production/video-producer.yaml +77 -0
- package/departments/content/agents/scriptwriter.yaml +29 -10
- package/departments/content/skills/research-compile/SKILL.md +75 -0
- package/departments/content/skills/short-form/SKILL.md +3 -1
- package/departments/content/skills/trend-hunt/SKILL.md +74 -0
- package/departments/content/skills/video-produce/SKILL.md +98 -0
- package/departments/content/skills/video-setup/SKILL.md +91 -0
- package/departments/content/squad.yaml +6 -0
- package/departments/content/workflows/shorts-social.yaml +83 -0
- package/departments/content/workflows/video-production.yaml +142 -0
- package/departments/content/workflows/youtube.yaml +3 -0
- package/departments/dev/SKILL.md +10 -0
- package/departments/dev/agents/frontend-dev.yaml +1 -1
- package/departments/dev/skills/onboard/SKILL.md +6 -2
- package/departments/dev/skills/refactor-plan/SKILL.md +7 -0
- package/departments/dev/skills/scaffold/SKILL.md +6 -0
- package/departments/quality/SKILL.md +16 -0
- package/installer/doctor.js +63 -1
- package/installer/index.js +20 -0
- package/installer/output-style.js +92 -0
- package/installer/update.js +19 -0
- package/installer/worktree-baseref.js +8 -2
- package/knowledge/agents-registry-v2.json +209 -14
- package/knowledge/commands-keywords.json +1390 -232
- package/knowledge/commands-registry.json +171 -4
- package/package.json +1 -1
- package/pyproject.toml +1 -1
|
Binary file
|
|
Binary file
|
|
@@ -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,
|
|
31
|
-
NUL bytes, or any character outside
|
|
32
|
-
capped at 128 characters to prevent
|
|
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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/core/synapse/engine.py
CHANGED
|
@@ -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
|
|
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)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
-
|
|
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
|
|
package/core/sync/mcp_syncer.py
CHANGED
|
@@ -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 = {
|
|
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
|
|
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
|
|
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
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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 = ""
|