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.
Files changed (80) 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__/runner_cli.cpython-313.pyc +0 -0
  14. package/core/evals/__pycache__/sanitizer.cpython-313.pyc +0 -0
  15. package/core/evals/__pycache__/verdict_labels.cpython-313.pyc +0 -0
  16. package/core/evals/record_cli.py +96 -0
  17. package/core/evals/runner_cli.py +102 -0
  18. package/core/evals/sanitizer.py +97 -0
  19. package/core/evals/verdict_labels.py +53 -13
  20. package/core/forge/__pycache__/complexity.cpython-313.pyc +0 -0
  21. package/core/forge/complexity.py +9 -0
  22. package/core/governance/__pycache__/evidence_checks.cpython-313.pyc +0 -0
  23. package/core/governance/__pycache__/judge.cpython-313.pyc +0 -0
  24. package/core/governance/__pycache__/leak_scanner.cpython-313.pyc +0 -0
  25. package/core/governance/__pycache__/phantom_action_check.cpython-313.pyc +0 -0
  26. package/core/governance/__pycache__/phantom_action_check.cpython-314.pyc +0 -0
  27. package/core/governance/evidence_checks.py +60 -4
  28. package/core/governance/judge.py +95 -0
  29. package/core/governance/leak_scanner.py +10 -0
  30. package/core/governance/phantom_action_check.py +58 -2
  31. package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
  32. package/core/hooks/__pycache__/stop.cpython-313.pyc +0 -0
  33. package/core/hooks/__pycache__/stop.cpython-314.pyc +0 -0
  34. package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  35. package/core/hooks/post_tool_use.py +13 -0
  36. package/core/hooks/stop.py +23 -0
  37. package/core/hooks/user_prompt_submit.py +69 -0
  38. package/core/knowledge/__pycache__/recipes.cpython-313.pyc +0 -0
  39. package/core/knowledge/__pycache__/recipes_cli.cpython-313.pyc +0 -0
  40. package/core/knowledge/recipes.py +237 -0
  41. package/core/knowledge/recipes_cli.py +99 -0
  42. package/core/registry/__pycache__/generator.cpython-313.pyc +0 -0
  43. package/core/shared/__pycache__/safe_session_id.cpython-313.pyc +0 -0
  44. package/core/shared/__pycache__/safe_session_id.cpython-314.pyc +0 -0
  45. package/core/shared/safe_session_id.py +11 -3
  46. package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
  47. package/core/synapse/__pycache__/engine.cpython-314.pyc +0 -0
  48. package/core/synapse/__pycache__/pattern_library_layer.cpython-313.pyc +0 -0
  49. package/core/synapse/__pycache__/recipe_layer.cpython-313.pyc +0 -0
  50. package/core/synapse/engine.py +6 -0
  51. package/core/synapse/pattern_library_layer.py +10 -2
  52. package/core/synapse/recipe_layer.py +114 -0
  53. package/core/sync/__pycache__/mcp_optimizer.cpython-313.pyc +0 -0
  54. package/core/sync/__pycache__/mcp_syncer.cpython-313.pyc +0 -0
  55. package/core/sync/__pycache__/settings_syncer.cpython-313.pyc +0 -0
  56. package/core/sync/mcp_optimizer.py +4 -1
  57. package/core/sync/mcp_syncer.py +50 -6
  58. package/core/sync/settings_syncer.py +9 -2
  59. package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
  60. package/core/workflow/__pycache__/flow_enforcer.cpython-314.pyc +0 -0
  61. package/core/workflow/__pycache__/plan_approval.cpython-313.pyc +0 -0
  62. package/core/workflow/__pycache__/plan_approval.cpython-314.pyc +0 -0
  63. package/core/workflow/flow_enforcer.py +63 -0
  64. package/core/workflow/plan_approval.py +185 -0
  65. package/departments/dev/SKILL.md +10 -0
  66. package/departments/dev/skills/onboard/SKILL.md +6 -2
  67. package/departments/dev/skills/refactor-plan/SKILL.md +7 -0
  68. package/departments/dev/skills/scaffold/SKILL.md +6 -0
  69. package/departments/quality/SKILL.md +24 -0
  70. package/installer/doctor.js +7 -0
  71. package/installer/index.js +20 -0
  72. package/installer/output-style.js +92 -0
  73. package/installer/update.js +19 -0
  74. package/installer/worktree-baseref.js +8 -2
  75. package/knowledge/commands-keywords.json +4 -0
  76. package/knowledge/commands-registry.json +41 -3
  77. package/package.json +1 -1
  78. package/pyproject.toml +1 -1
  79. package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
  80. 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}"
@@ -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())
@@ -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