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.
Files changed (118) hide show
  1. package/README.md +2 -2
  2. package/VERSION +1 -1
  3. package/arka/SKILL.md +10 -1
  4. package/arka/skills/flow/SKILL.md +34 -5
  5. package/arka/skills/recipes/SKILL.md +69 -0
  6. package/arka/skills/refine/SKILL.md +81 -0
  7. package/config/claude-agents/content-researcher.md +51 -0
  8. package/config/claude-agents/frontend-dev.md +1 -1
  9. package/config/claude-agents/info-compiler.md +51 -0
  10. package/config/claude-agents/scriptwriter.md +9 -7
  11. package/config/claude-agents/trends-analyst.md +51 -0
  12. package/config/claude-agents/ux-designer.md +1 -1
  13. package/config/claude-agents/video-producer.md +51 -0
  14. package/config/claude-agents/visual-designer.md +1 -1
  15. package/config/constitution.yaml +9 -0
  16. package/config/mcp-policy.yaml +16 -0
  17. package/config/output-styles/arkaos.md +52 -0
  18. package/core/evals/__init__.py +4 -0
  19. package/core/evals/__pycache__/__init__.cpython-313.pyc +0 -0
  20. package/core/evals/__pycache__/record_cli.cpython-313.pyc +0 -0
  21. package/core/evals/__pycache__/verdict_labels.cpython-313.pyc +0 -0
  22. package/core/evals/record_cli.py +41 -8
  23. package/core/evals/verdict_labels.py +53 -13
  24. package/core/forge/__pycache__/complexity.cpython-313.pyc +0 -0
  25. package/core/forge/complexity.py +9 -0
  26. package/core/governance/__pycache__/evidence_checks.cpython-313.pyc +0 -0
  27. package/core/governance/__pycache__/judge.cpython-313.pyc +0 -0
  28. package/core/governance/__pycache__/phantom_action_check.cpython-313.pyc +0 -0
  29. package/core/governance/__pycache__/phantom_action_check.cpython-314.pyc +0 -0
  30. package/core/governance/evidence_checks.py +130 -4
  31. package/core/governance/judge.py +95 -0
  32. package/core/governance/phantom_action_check.py +41 -0
  33. package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
  34. package/core/hooks/__pycache__/pre_tool_use.cpython-313.pyc +0 -0
  35. package/core/hooks/__pycache__/pre_tool_use.cpython-314.pyc +0 -0
  36. package/core/hooks/__pycache__/stop.cpython-313.pyc +0 -0
  37. package/core/hooks/__pycache__/stop.cpython-314.pyc +0 -0
  38. package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  39. package/core/hooks/post_tool_use.py +13 -0
  40. package/core/hooks/pre_tool_use.py +8 -2
  41. package/core/hooks/stop.py +23 -0
  42. package/core/hooks/user_prompt_submit.py +69 -0
  43. package/core/knowledge/__pycache__/recipes.cpython-313.pyc +0 -0
  44. package/core/knowledge/__pycache__/recipes_cli.cpython-313.pyc +0 -0
  45. package/core/knowledge/recipes.py +237 -0
  46. package/core/knowledge/recipes_cli.py +99 -0
  47. package/core/runtime/__pycache__/model_routing_context.cpython-313.pyc +0 -0
  48. package/core/runtime/model_routing_context.py +4 -0
  49. package/core/shared/__pycache__/safe_session_id.cpython-313.pyc +0 -0
  50. package/core/shared/__pycache__/safe_session_id.cpython-314.pyc +0 -0
  51. package/core/shared/safe_session_id.py +11 -3
  52. package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
  53. package/core/synapse/__pycache__/engine.cpython-314.pyc +0 -0
  54. package/core/synapse/__pycache__/pattern_library_layer.cpython-313.pyc +0 -0
  55. package/core/synapse/__pycache__/recipe_layer.cpython-313.pyc +0 -0
  56. package/core/synapse/engine.py +6 -0
  57. package/core/synapse/pattern_library_layer.py +10 -2
  58. package/core/synapse/recipe_layer.py +114 -0
  59. package/core/sync/__pycache__/mcp_optimizer.cpython-313.pyc +0 -0
  60. package/core/sync/__pycache__/mcp_syncer.cpython-313.pyc +0 -0
  61. package/core/sync/__pycache__/settings_syncer.cpython-313.pyc +0 -0
  62. package/core/sync/mcp_optimizer.py +4 -1
  63. package/core/sync/mcp_syncer.py +50 -6
  64. package/core/sync/settings_syncer.py +9 -2
  65. package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
  66. package/core/workflow/__pycache__/flow_enforcer.cpython-314.pyc +0 -0
  67. package/core/workflow/__pycache__/frontend_gate.cpython-313.pyc +0 -0
  68. package/core/workflow/__pycache__/frontend_gate_telemetry.cpython-313.pyc +0 -0
  69. package/core/workflow/__pycache__/frontend_gate_telemetry_cli.cpython-313.pyc +0 -0
  70. package/core/workflow/__pycache__/plan_approval.cpython-313.pyc +0 -0
  71. package/core/workflow/__pycache__/plan_approval.cpython-314.pyc +0 -0
  72. package/core/workflow/flow_enforcer.py +63 -0
  73. package/core/workflow/frontend_gate.py +118 -25
  74. package/core/workflow/frontend_gate_telemetry.py +129 -0
  75. package/core/workflow/frontend_gate_telemetry_cli.py +53 -0
  76. package/core/workflow/plan_approval.py +185 -0
  77. package/departments/brand/agents/ux-designer.yaml +1 -1
  78. package/departments/brand/agents/visual-designer.yaml +1 -1
  79. package/departments/brand/references/uiux-knowledge-and-tools.md +73 -0
  80. package/departments/brand/skills/colors/SKILL.md +76 -3
  81. package/departments/brand/skills/design-review/SKILL.md +81 -31
  82. package/departments/brand/skills/design-system/SKILL.md +32 -0
  83. package/departments/brand/skills/identity-system/SKILL.md +65 -4
  84. package/departments/brand/skills/logo-brief/SKILL.md +81 -3
  85. package/departments/brand/skills/mockup-generate/SKILL.md +82 -3
  86. package/departments/brand/skills/ux-audit/SKILL.md +74 -5
  87. package/departments/brand/skills/wireframe/SKILL.md +75 -3
  88. package/departments/content/SKILL.md +16 -2
  89. package/departments/content/agents/production/content-researcher.yaml +68 -0
  90. package/departments/content/agents/production/info-compiler.yaml +68 -0
  91. package/departments/content/agents/production/trends-analyst.yaml +69 -0
  92. package/departments/content/agents/production/video-producer.yaml +77 -0
  93. package/departments/content/agents/scriptwriter.yaml +29 -10
  94. package/departments/content/skills/research-compile/SKILL.md +75 -0
  95. package/departments/content/skills/short-form/SKILL.md +3 -1
  96. package/departments/content/skills/trend-hunt/SKILL.md +74 -0
  97. package/departments/content/skills/video-produce/SKILL.md +98 -0
  98. package/departments/content/skills/video-setup/SKILL.md +91 -0
  99. package/departments/content/squad.yaml +6 -0
  100. package/departments/content/workflows/shorts-social.yaml +83 -0
  101. package/departments/content/workflows/video-production.yaml +142 -0
  102. package/departments/content/workflows/youtube.yaml +3 -0
  103. package/departments/dev/SKILL.md +10 -0
  104. package/departments/dev/agents/frontend-dev.yaml +1 -1
  105. package/departments/dev/skills/onboard/SKILL.md +6 -2
  106. package/departments/dev/skills/refactor-plan/SKILL.md +7 -0
  107. package/departments/dev/skills/scaffold/SKILL.md +6 -0
  108. package/departments/quality/SKILL.md +16 -0
  109. package/installer/doctor.js +63 -1
  110. package/installer/index.js +20 -0
  111. package/installer/output-style.js +92 -0
  112. package/installer/update.js +19 -0
  113. package/installer/worktree-baseref.js +8 -2
  114. package/knowledge/agents-registry-v2.json +209 -14
  115. package/knowledge/commands-keywords.json +1390 -232
  116. package/knowledge/commands-registry.json +171 -4
  117. package/package.json +1 -1
  118. package/pyproject.toml +1 -1
@@ -0,0 +1,95 @@
1
+ """Unified gate-judge schema (Interaction Reform PR2).
2
+
3
+ ``JudgeVerdict`` is the contract every gate judge returns: the
4
+ plan-judge at Gate 2 (judges the planning summary BEFORE it is shown to
5
+ the user) and the output-judge at Gate 4 (judges the deliverable BEFORE
6
+ the Quality Gate personas). Judges close the fragmentation gap — the
7
+ Forge critic only judged plans inside Forge runs, adversarial-review
8
+ only judged dev diffs, and Gate 4's excellence check was prose
9
+ instruction with no structured output.
10
+
11
+ Like ``qg_verdict.QGVerdict``, this module is schema + validation only:
12
+ the runtime never invokes models from Python. The orchestrator
13
+ dispatches the judge via the Agent tool with
14
+ ``JUDGE_VERDICT_JSON_SCHEMA`` as the structured-output schema (see
15
+ ``arka/skills/flow/SKILL.md`` Gates 2 and 4) and records the verdict via
16
+ ``core.evals.record_cli --kind judge``.
17
+
18
+ The judge applies the ``arkaos-not-yes-man`` standard in BOTH
19
+ directions: findings judge the AGENT's work adversarially, and
20
+ ``user_challenge`` carries the pushback when the USER's request itself
21
+ is technically wrong — the orchestrator must surface it, never swallow
22
+ it.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import Literal
28
+
29
+ from pydantic import BaseModel, Field, model_validator
30
+
31
+
32
+ class JudgeFinding(BaseModel):
33
+ """One concrete defect or risk the judge found."""
34
+
35
+ area: str = Field(
36
+ description=(
37
+ "Rubric area: scope, verification, assumptions, excellence, "
38
+ "consistency, risk…"
39
+ )
40
+ )
41
+ detail: str = Field(description="What exactly is wrong, with evidence")
42
+ severity: Literal["blocker", "major", "minor"]
43
+ verdict: Literal["CONFIRMED", "PLAUSIBLE", "REFUTED"] | None = Field(
44
+ default=None,
45
+ description=(
46
+ "Claim-level verdict after attempting reproduction; REFUTED is "
47
+ "recorded for telemetry and must NOT count toward REVISE"
48
+ ),
49
+ )
50
+
51
+
52
+ class JudgeVerdict(BaseModel):
53
+ """Binary gate-judge verdict. REVISE loops the work, max 2 times."""
54
+
55
+ gate: Literal["G2", "G4"]
56
+ role: Literal["plan-judge", "output-judge"]
57
+ verdict: Literal["PASS", "REVISE"]
58
+ findings: list[JudgeFinding] = Field(default_factory=list)
59
+ user_challenge: str = Field(
60
+ default="",
61
+ description=(
62
+ "Non-empty when the USER's request is technically wrong or "
63
+ "would ship a worse product — the orchestrator presents this "
64
+ "alongside the plan (pushback protocol, arkaos-not-yes-man)"
65
+ ),
66
+ )
67
+ judged_digest: str = Field(
68
+ default="",
69
+ max_length=120,
70
+ description="Opening excerpt of the judged artifact (auditability)",
71
+ )
72
+ reviewer: str = Field(description="Judge id, e.g. plan-judge-g2")
73
+ model_used: str = Field(description="Model tier the judgment ran on")
74
+ notes: str = ""
75
+
76
+ @model_validator(mode="after")
77
+ def revise_requires_actionable_findings(self) -> "JudgeVerdict":
78
+ """REVISE must cite at least one blocker/major finding —
79
+ a judge cannot loop work back on narrative alone."""
80
+ if self.verdict == "REVISE":
81
+ actionable = [
82
+ f
83
+ for f in self.findings
84
+ if f.severity in ("blocker", "major") and f.verdict != "REFUTED"
85
+ ]
86
+ if not actionable:
87
+ raise ValueError(
88
+ "REVISE verdict without an actionable (blocker/major, "
89
+ "non-REFUTED) finding — judges loop work on evidence, "
90
+ "never on narrative"
91
+ )
92
+ return self
93
+
94
+
95
+ JUDGE_VERDICT_JSON_SCHEMA: dict = JudgeVerdict.model_json_schema()
@@ -150,6 +150,47 @@ def count_turn_tool_uses(raw_transcript: str | None) -> int | None:
150
150
  return count
151
151
 
152
152
 
153
+ def current_turn_assistant_texts(raw_transcript: str | None) -> list[str] | None:
154
+ """Assistant message texts of the CURRENT turn (after the last real
155
+ user message). None when the transcript is unparseable or no real
156
+ user message is found — callers must fail open on None.
157
+
158
+ Shares the turn-boundary logic with ``count_turn_tool_uses`` so
159
+ turn-scoped consumers (plan-approval's mark_presented) do not span
160
+ turns and re-trigger on a prior turn's markers.
161
+ """
162
+ if not raw_transcript:
163
+ return None
164
+ records = _parse_jsonl(raw_transcript)
165
+ if not records:
166
+ return None
167
+ start = _last_real_user_index(records)
168
+ if start < 0:
169
+ return None
170
+ texts: list[str] = []
171
+ for record in records[start + 1 :]:
172
+ if _record_role(record) != "assistant":
173
+ continue
174
+ text = _assistant_text(_record_content(record))
175
+ if text:
176
+ texts.append(text)
177
+ return texts
178
+
179
+
180
+ def _assistant_text(content: object) -> str:
181
+ """Flatten an assistant record's content to its text blocks."""
182
+ if isinstance(content, str):
183
+ return content
184
+ if isinstance(content, list):
185
+ parts = [
186
+ block.get("text", "")
187
+ for block in content
188
+ if isinstance(block, dict) and block.get("type") == "text"
189
+ ]
190
+ return "\n".join(p for p in parts if p)
191
+ return ""
192
+
193
+
153
194
  def check_phantom_actions(
154
195
  response_text: str, raw_transcript: str | None
155
196
  ) -> PhantomActionResult:
@@ -642,6 +642,19 @@ def main(stdin_json: dict | None = None) -> int:
642
642
  except Exception: # noqa: BLE001 — telemetry must never break the hook
643
643
  pass
644
644
 
645
+ # Interaction Reform PR3 — the native plan-mode approve button IS
646
+ # explicit plan approval: a successful ExitPlanMode marks the
647
+ # session approved (stronger, less ambiguous than a text "sim").
648
+ if tool_name == "ExitPlanMode" and exit_code in ("0", ""):
649
+ try:
650
+ from core.workflow import plan_approval
651
+ if session_id:
652
+ plan_approval.mark_approved(
653
+ session_id, source="exit-plan-mode"
654
+ )
655
+ except Exception: # noqa: BLE001 — hooks never break the turn
656
+ pass
657
+
645
658
  if tool_name in ("Task", "Agent"):
646
659
  subagent_type = get_str(stdin_json, "tool_input", "subagent_type")
647
660
  if subagent_type == "cqo":
@@ -184,13 +184,19 @@ def _frontend_gate(
184
184
  try:
185
185
  from core.workflow.frontend_gate import (
186
186
  evaluate,
187
+ is_heuristic_ui_file,
187
188
  is_ui_file,
188
189
  record_telemetry,
189
190
  )
190
191
  except Exception:
191
192
  return None # frontend-gate-import-failed → allow
192
- # Zero-read fast path: only UI files ever need the transcript.
193
- if not is_ui_file(str(tool_input.get("file_path", ""))):
193
+ # Zero-read fast path: only UI files ever need the transcript. The
194
+ # heuristic check is tool_input-only (regex, no I/O), so it keeps the
195
+ # fast path cheap for plain .ts/.js.
196
+ file_path = str(tool_input.get("file_path", ""))
197
+ if not is_ui_file(file_path) and not is_heuristic_ui_file(
198
+ file_path, tool_name, tool_input
199
+ ):
194
200
  return None
195
201
  decision = evaluate(
196
202
  tool_name=tool_name,
@@ -149,6 +149,29 @@ def _flow_checks(
149
149
  except Exception:
150
150
  pass
151
151
 
152
+ # Interaction Reform PR3 — a turn whose furthest gate is Gate 2 means
153
+ # a plan is on the table awaiting the user; record it so the next
154
+ # user message can be classified as approval (plan_approval state).
155
+ # Scope to the CURRENT turn's messages (QG 2026-07-09, PR4
156
+ # prerequisite #1, re-review): scanning the whole 20-message window
157
+ # would re-trigger on a PRIOR turn's [arka:gate:2] still in the
158
+ # window and silently invalidate a live approval. Turn-scoping keeps
159
+ # the mid-turn fix (gate:2 + a separate marker-less summary in the
160
+ # same turn) while never spanning turns. Falls back to the last
161
+ # message only when the transcript can't be turn-parsed.
162
+ try:
163
+ from core.governance.phantom_action_check import (
164
+ current_turn_assistant_texts,
165
+ )
166
+ from core.workflow.gate_checkpoint import extract_latest_gate
167
+ from core.workflow import plan_approval
168
+ turn = current_turn_assistant_texts(raw)
169
+ scan = turn if turn is not None else [last]
170
+ if session_id and extract_latest_gate(scan) == 2:
171
+ plan_approval.mark_presented(session_id)
172
+ except Exception:
173
+ pass
174
+
152
175
  meta_tag_found = bool(re.search(r"\[arka:meta\]", last, re.IGNORECASE))
153
176
 
154
177
  sycophancy_signals: list = []
@@ -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())
@@ -28,6 +28,10 @@ AGENT_ROLE_HINTS: dict[str, str] = {
28
28
  "creative-director": "design",
29
29
  "visual-designer": "design",
30
30
  "frontend-dev": "design",
31
+ "ux-designer": "design",
32
+ "motion-designer": "design",
33
+ "scriptwriter": "design",
34
+ "video-producer": "design",
31
35
  "marta-cqo": "quality_gate",
32
36
  "eduardo-copy": "quality_gate",
33
37
  "francisca-tech": "quality_gate",