arkaos 4.10.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.
- package/README.md +1 -1
- package/VERSION +1 -1
- package/arka/SKILL.md +10 -1
- package/arka/skills/flow/SKILL.md +23 -1
- package/arka/skills/recipes/SKILL.md +69 -0
- package/arka/skills/refine/SKILL.md +81 -0
- package/config/constitution.yaml +8 -0
- package/config/mcp-policy.yaml +8 -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__/runner_cli.cpython-313.pyc +0 -0
- package/core/evals/__pycache__/sanitizer.cpython-313.pyc +0 -0
- package/core/evals/__pycache__/verdict_labels.cpython-313.pyc +0 -0
- package/core/evals/record_cli.py +96 -0
- package/core/evals/runner_cli.py +102 -0
- package/core/evals/sanitizer.py +97 -0
- 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__/leak_scanner.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 +60 -4
- package/core/governance/judge.py +95 -0
- package/core/governance/leak_scanner.py +10 -0
- package/core/governance/phantom_action_check.py +58 -2
- package/core/hooks/__pycache__/post_tool_use.cpython-313.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/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/registry/__pycache__/generator.cpython-313.pyc +0 -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__/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/plan_approval.py +185 -0
- package/departments/dev/SKILL.md +10 -0
- 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 +24 -0
- package/installer/doctor.js +7 -0
- 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/commands-keywords.json +4 -0
- package/knowledge/commands-registry.json +41 -3
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
- package/scripts/__pycache__/synapse-bridge.cpython-313.pyc +0 -0
|
@@ -102,6 +102,26 @@ _VAGUE_PHRASES = (
|
|
|
102
102
|
"esse ficheiro", "esse erro", "aquele bug",
|
|
103
103
|
)
|
|
104
104
|
|
|
105
|
+
# A prompt is CONCRETE (never refine-worthy) when it names a real
|
|
106
|
+
# target: a file (foo.py, README.md, path/to/x), a CamelCase or
|
|
107
|
+
# snake_case code identifier (AuthService, user_repo), or a backticked
|
|
108
|
+
# term. Kills the "no files → +20 constant" false positives the scorer
|
|
109
|
+
# alone can't distinguish (QG 2026-07-09).
|
|
110
|
+
# NOTE: NOT case-insensitive — the CamelCase alternation depends on real
|
|
111
|
+
# case (IGNORECASE would match any two-syllable lowercase word like
|
|
112
|
+
# "melhor" and defeat the whole carve-out).
|
|
113
|
+
_CONCRETE_TARGET_RE = re.compile(
|
|
114
|
+
r"[\w./-]+\.[A-Za-z]{1,5}\b" # file with a real extension
|
|
115
|
+
r"|[\w-]+/[\w./-]+" # path segments (a/b/c)
|
|
116
|
+
r"|\b[A-Z][a-z0-9]+[A-Z]\w+\b" # CamelCase identifier
|
|
117
|
+
r"|\b[a-z]+_[a-z0-9_]+\b" # snake_case identifier
|
|
118
|
+
r"|`[^`]+`" # backticked term
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _names_concrete_target(text: str) -> bool:
|
|
123
|
+
return bool(_CONCRETE_TARGET_RE.search(text or ""))
|
|
124
|
+
|
|
105
125
|
|
|
106
126
|
# ─── Sections 1-2: migration + sync detection ────────────────────────────
|
|
107
127
|
|
|
@@ -573,9 +593,56 @@ def main(stdin_json: dict | None = None, raw: str = "") -> int:
|
|
|
573
593
|
)
|
|
574
594
|
|
|
575
595
|
workflow_directive = ""
|
|
596
|
+
refine_hint = ""
|
|
576
597
|
if user_input and _wf_classify(user_input):
|
|
577
598
|
_wf_mark_required(session_id)
|
|
578
599
|
workflow_directive = _WORKFLOW_DIRECTIVE
|
|
600
|
+
# Interaction Reform PR5 — a vague code-modifying request is a
|
|
601
|
+
# signal to refine the prompt (ask about the topic) BEFORE the
|
|
602
|
+
# workflow. SUGGESTION only; /do decides. Not a slash command.
|
|
603
|
+
# Calibration (QG 2026-07-09): the scorer's +20 "no files" branch
|
|
604
|
+
# is a constant in the hook context (the hook never has files), so
|
|
605
|
+
# 70 is the floor for ANY short prompt. Two guards keep clear asks
|
|
606
|
+
# out — a higher threshold (85, above that constant) AND a
|
|
607
|
+
# concrete-target carve-out: a prompt that names a file or a code
|
|
608
|
+
# identifier ("fix the typo in README.md", "add a test to
|
|
609
|
+
# AuthService") is specific, never refine-worthy.
|
|
610
|
+
if (
|
|
611
|
+
not user_input.strip().startswith("/")
|
|
612
|
+
and not _names_concrete_target(user_input)
|
|
613
|
+
):
|
|
614
|
+
try:
|
|
615
|
+
from core.forge.complexity import score_prompt_ambiguity
|
|
616
|
+
score = score_prompt_ambiguity(user_input)
|
|
617
|
+
if score >= 85:
|
|
618
|
+
refine_hint = (
|
|
619
|
+
f"[arka:refine-suggested] score={score}/100 — the "
|
|
620
|
+
f"request may be vague; consider /arka refine to ask "
|
|
621
|
+
f"about the topic and compile a precise prompt "
|
|
622
|
+
f"before building."
|
|
623
|
+
)
|
|
624
|
+
except Exception:
|
|
625
|
+
pass
|
|
626
|
+
|
|
627
|
+
# Interaction Reform PR3 — when the previous turn ended at Gate 2
|
|
628
|
+
# (plan on the table), classify THIS message as approval/rejection.
|
|
629
|
+
# UserPromptSubmit runs before the turn's tool calls, so the token
|
|
630
|
+
# is visible to the PreToolUse enforcer with no marker-invisibility
|
|
631
|
+
# problem. Never blocks the hook.
|
|
632
|
+
try:
|
|
633
|
+
from core.workflow import plan_approval
|
|
634
|
+
if session_id and user_input and plan_approval.is_presented(session_id):
|
|
635
|
+
verdict = plan_approval.classify_reply(
|
|
636
|
+
user_input, has_creation_verb=_wf_classify(user_input)
|
|
637
|
+
)
|
|
638
|
+
if verdict == "approve":
|
|
639
|
+
plan_approval.mark_approved(
|
|
640
|
+
session_id, source="text", excerpt=user_input
|
|
641
|
+
)
|
|
642
|
+
elif verdict == "reject":
|
|
643
|
+
plan_approval.mark_rejected(session_id)
|
|
644
|
+
except Exception:
|
|
645
|
+
pass
|
|
579
646
|
|
|
580
647
|
context_hits = _cognitive_hits(session_id)
|
|
581
648
|
|
|
@@ -591,6 +658,8 @@ def main(stdin_json: dict | None = None, raw: str = "") -> int:
|
|
|
591
658
|
)
|
|
592
659
|
if hygiene:
|
|
593
660
|
out = f"{out} {hygiene}"
|
|
661
|
+
if refine_hint:
|
|
662
|
+
out = f"{out}\n{refine_hint}"
|
|
594
663
|
for nudge in (kb_cite_nudge, meta_tag_nudge, closing_marker_nudge):
|
|
595
664
|
if nudge:
|
|
596
665
|
out = f"{out}\n{nudge}"
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Validated feature recipes (Interaction Reform PR7).
|
|
2
|
+
|
|
3
|
+
A Recipe is the missing artifact between the Pattern Library (short text
|
|
4
|
+
hints) and scaffold (whole generic starter repos): a QG-APPROVED feature
|
|
5
|
+
implementation captured with its reference files so the same feature —
|
|
6
|
+
"Laravel login the ArkaOS-approved way" — is reused across projects
|
|
7
|
+
instead of re-derived from documentation every time.
|
|
8
|
+
|
|
9
|
+
Layout on disk (``~/.arkaos/recipes/<slug>/``):
|
|
10
|
+
recipe.json # structured metadata (source of truth for retrieval)
|
|
11
|
+
RECIPE.md # narrative: problem, approach, decisions, how to apply
|
|
12
|
+
files/ # SANITIZED reference files (exemplary feature code)
|
|
13
|
+
|
|
14
|
+
Confidentiality is NON-NEGOTIABLE (v2.18.0 npm leak precedent): capture
|
|
15
|
+
runs every text field and every reference file through
|
|
16
|
+
``core.evals.sanitizer.sanitize_text`` BEFORE writing. No redaction
|
|
17
|
+
config → ``SanitizerConfigMissing`` → capture REFUSED. There is no write
|
|
18
|
+
path that skips sanitization, and ``sanitized`` must be True to persist.
|
|
19
|
+
|
|
20
|
+
Admission: only a verdict of APPROVED with a timestamp that ties back to
|
|
21
|
+
``~/.arkaos/telemetry/qg-verdicts.jsonl`` promotes a deliverable to a
|
|
22
|
+
recipe, and capture is always operator-confirmed (the QG skill proposes
|
|
23
|
+
it; it never fires silently).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
import shutil
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Literal
|
|
32
|
+
|
|
33
|
+
from pydantic import BaseModel, Field, field_validator
|
|
34
|
+
|
|
35
|
+
from core.evals.sanitizer import sanitize_text
|
|
36
|
+
from core.governance.leak_scanner import load_redaction_patterns
|
|
37
|
+
from core.shared.safe_session_id import safe_session_id
|
|
38
|
+
|
|
39
|
+
MAX_FILES = 20
|
|
40
|
+
MAX_TOTAL_BYTES = 200 * 1024
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _recipes_root() -> Path:
|
|
44
|
+
override = os.environ.get("ARKA_RECIPES_DIR", "").strip()
|
|
45
|
+
return Path(override) if override else Path.home() / ".arkaos" / "recipes"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RecipeProvenance(BaseModel):
|
|
49
|
+
"""Where a recipe came from — all fields already sanitized."""
|
|
50
|
+
|
|
51
|
+
source_project: str = Field(description="Origin project ([CLIENT-N] if client)")
|
|
52
|
+
qg_verdict: Literal["APPROVED"]
|
|
53
|
+
qg_verdict_ts: str = Field(description="Ties to qg-verdicts.jsonl")
|
|
54
|
+
captured_at: str
|
|
55
|
+
department: str = ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Recipe(BaseModel):
|
|
59
|
+
"""A reusable, QG-approved feature implementation."""
|
|
60
|
+
|
|
61
|
+
slug: str
|
|
62
|
+
name: str
|
|
63
|
+
problem: str = Field(min_length=10)
|
|
64
|
+
stack: list[str] = Field(min_length=1)
|
|
65
|
+
feature_keywords: list[str] = Field(min_length=1)
|
|
66
|
+
files: list[str] = Field(default_factory=list)
|
|
67
|
+
acceptance_criteria: list[str] = Field(default_factory=list)
|
|
68
|
+
apply_notes: str = ""
|
|
69
|
+
provenance: RecipeProvenance
|
|
70
|
+
sanitized: bool = False
|
|
71
|
+
|
|
72
|
+
@field_validator("slug")
|
|
73
|
+
@classmethod
|
|
74
|
+
def _slug_is_path_safe(cls, value: str) -> str:
|
|
75
|
+
if safe_session_id(value) != value:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"unsafe recipe slug {value!r} — must be a path-safe token"
|
|
78
|
+
)
|
|
79
|
+
return value
|
|
80
|
+
|
|
81
|
+
@field_validator("files")
|
|
82
|
+
@classmethod
|
|
83
|
+
def _within_file_cap(cls, value: list[str]) -> list[str]:
|
|
84
|
+
if len(value) > MAX_FILES:
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"recipe has {len(value)} files, cap is {MAX_FILES}"
|
|
87
|
+
)
|
|
88
|
+
return value
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class RecipeCaptureRefused(RuntimeError):
|
|
92
|
+
"""Capture cannot proceed safely (unsanitized, oversize, bad verdict)."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _recipe_dir(slug: str) -> Path:
|
|
96
|
+
return _recipes_root() / slug
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _redaction_patterns(config_path: Path | None) -> tuple[str, ...]:
|
|
100
|
+
"""Lowercased client patterns for substring filename screening."""
|
|
101
|
+
return tuple(p.lower() for p in load_redaction_patterns(config_path))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _is_safe_relpath(rel_path: str) -> bool:
|
|
105
|
+
"""Reject absolute paths, '..' escapes, backslashes, and leading dots.
|
|
106
|
+
|
|
107
|
+
Reference files live strictly under ``files/`` (CWE-22 guard). They
|
|
108
|
+
cannot collide with the recipe's own metadata because they are
|
|
109
|
+
written into the ``files/`` subdirectory, never the recipe root.
|
|
110
|
+
"""
|
|
111
|
+
if not rel_path or rel_path.startswith(("/", ".")) or "\\" in rel_path:
|
|
112
|
+
return False
|
|
113
|
+
if Path(rel_path).is_absolute():
|
|
114
|
+
return False
|
|
115
|
+
return all(part not in ("..", "") for part in Path(rel_path).parts)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def capture_recipe(
|
|
119
|
+
recipe: Recipe,
|
|
120
|
+
narrative: str,
|
|
121
|
+
reference_files: dict[str, str],
|
|
122
|
+
config_path: Path | None = None,
|
|
123
|
+
) -> Path:
|
|
124
|
+
"""Sanitize everything, then persist a recipe. Fail-closed.
|
|
125
|
+
|
|
126
|
+
``reference_files`` maps a relative path under ``files/`` to its raw
|
|
127
|
+
content. Every text field, the narrative, and each file are run
|
|
128
|
+
through the sanitizer first; ``SanitizerConfigMissing`` propagates
|
|
129
|
+
(capture refused). Raises ``RecipeCaptureRefused`` on oversize input
|
|
130
|
+
or a non-APPROVED verdict.
|
|
131
|
+
"""
|
|
132
|
+
if recipe.provenance.qg_verdict != "APPROVED":
|
|
133
|
+
raise RecipeCaptureRefused("only APPROVED deliverables become recipes")
|
|
134
|
+
if len(reference_files) > MAX_FILES:
|
|
135
|
+
raise RecipeCaptureRefused(
|
|
136
|
+
f"{len(reference_files)} files exceeds the {MAX_FILES} cap"
|
|
137
|
+
)
|
|
138
|
+
total = sum(len(c.encode("utf-8")) for c in reference_files.values())
|
|
139
|
+
if total > MAX_TOTAL_BYTES:
|
|
140
|
+
raise RecipeCaptureRefused(
|
|
141
|
+
f"reference files total {total} B exceeds {MAX_TOTAL_BYTES} B"
|
|
142
|
+
)
|
|
143
|
+
def _clean(text: str) -> str:
|
|
144
|
+
return sanitize_text(text, config_path=config_path)[0]
|
|
145
|
+
|
|
146
|
+
patterns = _redaction_patterns(config_path)
|
|
147
|
+
for rel_path in reference_files:
|
|
148
|
+
if not _is_safe_relpath(rel_path):
|
|
149
|
+
raise RecipeCaptureRefused(
|
|
150
|
+
f"unsafe reference path {rel_path!r} — must stay under files/"
|
|
151
|
+
)
|
|
152
|
+
# A filename can carry a client identifier that reaches both
|
|
153
|
+
# recipe.json and the on-disk path. Filenames are not free text
|
|
154
|
+
# to rewrite, so REFUSE rather than mangle. The word-bounded
|
|
155
|
+
# sanitizer misses a name GLUED into a CamelCase filename
|
|
156
|
+
# (GlobexAuthService.php), so check the redaction patterns as
|
|
157
|
+
# plain case-insensitive substrings here (confidentiality is
|
|
158
|
+
# NON-NEGOTIABLE, QG 2026-07-09).
|
|
159
|
+
lowered = rel_path.lower()
|
|
160
|
+
if any(p in lowered for p in patterns):
|
|
161
|
+
raise RecipeCaptureRefused(
|
|
162
|
+
f"reference filename {rel_path!r} carries a client "
|
|
163
|
+
f"identifier — rename it before capture"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
clean_narrative = _clean(narrative)
|
|
167
|
+
clean_files = {
|
|
168
|
+
rel_path: _clean(content)
|
|
169
|
+
for rel_path, content in reference_files.items()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
# EVERY free-text field that reaches recipe.json must be sanitized.
|
|
173
|
+
# The model_copy update dict is the single sanitize gate — if a new
|
|
174
|
+
# free-text field is added to Recipe, add it here or it leaks
|
|
175
|
+
# (confidentiality is NON-NEGOTIABLE).
|
|
176
|
+
clean_provenance = recipe.provenance.model_copy(update={
|
|
177
|
+
"source_project": _clean(recipe.provenance.source_project),
|
|
178
|
+
"department": _clean(recipe.provenance.department),
|
|
179
|
+
})
|
|
180
|
+
stored = recipe.model_copy(update={
|
|
181
|
+
"name": _clean(recipe.name),
|
|
182
|
+
"problem": _clean(recipe.problem),
|
|
183
|
+
"stack": [_clean(s) for s in recipe.stack],
|
|
184
|
+
"feature_keywords": [_clean(k) for k in recipe.feature_keywords],
|
|
185
|
+
"acceptance_criteria": [_clean(c) for c in recipe.acceptance_criteria],
|
|
186
|
+
"apply_notes": _clean(recipe.apply_notes),
|
|
187
|
+
"files": sorted(clean_files.keys()),
|
|
188
|
+
"provenance": clean_provenance,
|
|
189
|
+
"sanitized": True,
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
target = _recipe_dir(stored.slug)
|
|
193
|
+
(target / "files").mkdir(parents=True, exist_ok=True)
|
|
194
|
+
(target / "recipe.json").write_text(
|
|
195
|
+
stored.model_dump_json(indent=2) + "\n", encoding="utf-8"
|
|
196
|
+
)
|
|
197
|
+
(target / "RECIPE.md").write_text(clean_narrative + "\n", encoding="utf-8")
|
|
198
|
+
for rel_path, content in clean_files.items():
|
|
199
|
+
dest = target / "files" / rel_path
|
|
200
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
201
|
+
dest.write_text(content, encoding="utf-8")
|
|
202
|
+
return target
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def load_recipe(slug: str) -> Recipe | None:
|
|
206
|
+
if safe_session_id(slug) != slug:
|
|
207
|
+
return None
|
|
208
|
+
path = _recipe_dir(slug) / "recipe.json"
|
|
209
|
+
if not path.exists():
|
|
210
|
+
return None
|
|
211
|
+
try:
|
|
212
|
+
return Recipe.model_validate_json(path.read_text(encoding="utf-8"))
|
|
213
|
+
except Exception: # noqa: BLE001 — corrupt recipe is simply skipped
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def list_recipes() -> list[Recipe]:
|
|
218
|
+
root = _recipes_root()
|
|
219
|
+
if not root.exists():
|
|
220
|
+
return []
|
|
221
|
+
out: list[Recipe] = []
|
|
222
|
+
for entry in sorted(root.iterdir()):
|
|
223
|
+
if entry.is_dir():
|
|
224
|
+
recipe = load_recipe(entry.name)
|
|
225
|
+
if recipe is not None:
|
|
226
|
+
out.append(recipe)
|
|
227
|
+
return out
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def delete_recipe(slug: str) -> bool:
|
|
231
|
+
if safe_session_id(slug) != slug:
|
|
232
|
+
return False
|
|
233
|
+
target = _recipe_dir(slug)
|
|
234
|
+
if not target.exists():
|
|
235
|
+
return False
|
|
236
|
+
shutil.rmtree(target)
|
|
237
|
+
return True
|
|
@@ -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())
|
|
Binary file
|
|
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
|
|