arkaos 4.11.0 → 4.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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__/verdict_labels.cpython-313.pyc +0 -0
- package/core/evals/record_cli.py +41 -8
- package/core/evals/verdict_labels.py +53 -13
- package/core/forge/__pycache__/complexity.cpython-313.pyc +0 -0
- package/core/forge/complexity.py +9 -0
- package/core/governance/__pycache__/evidence_checks.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/judge.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/phantom_action_check.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/phantom_action_check.cpython-314.pyc +0 -0
- package/core/governance/evidence_checks.py +60 -4
- package/core/governance/judge.py +95 -0
- package/core/governance/phantom_action_check.py +41 -0
- package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/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/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 +16 -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
|
@@ -278,32 +278,88 @@ def _check_coverage(
|
|
|
278
278
|
return _skip("coverage", "no coverage.xml or junit.xml on disk")
|
|
279
279
|
|
|
280
280
|
|
|
281
|
+
def _grep_lines(path: Path, lines: list[str]) -> list[str]:
|
|
282
|
+
hits = []
|
|
283
|
+
for lineno_or_text in lines:
|
|
284
|
+
for name, pattern in _SECURITY_PATTERNS:
|
|
285
|
+
if pattern.search(lineno_or_text):
|
|
286
|
+
hits.append(f"{path} [{name}]: {lineno_or_text.strip()[:120]}")
|
|
287
|
+
return hits
|
|
288
|
+
|
|
289
|
+
|
|
281
290
|
def _grep_file(path: Path) -> list[str]:
|
|
282
291
|
try:
|
|
283
|
-
|
|
292
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
284
293
|
except OSError:
|
|
285
294
|
return []
|
|
286
295
|
hits = []
|
|
287
|
-
for lineno, line in enumerate(
|
|
296
|
+
for lineno, line in enumerate(text.splitlines(), start=1):
|
|
288
297
|
for name, pattern in _SECURITY_PATTERNS:
|
|
289
298
|
if pattern.search(line):
|
|
290
299
|
hits.append(f"{path}:{lineno} [{name}]")
|
|
291
300
|
return hits
|
|
292
301
|
|
|
293
302
|
|
|
303
|
+
def _diff_base(project_dir: Path) -> str | None:
|
|
304
|
+
"""Merge-base with the default branch, or None outside a usable repo."""
|
|
305
|
+
for ref in ("origin/master", "master", "origin/main", "main"):
|
|
306
|
+
proc = subprocess.run(
|
|
307
|
+
["git", "merge-base", "HEAD", ref],
|
|
308
|
+
cwd=project_dir, capture_output=True, text=True, timeout=10,
|
|
309
|
+
)
|
|
310
|
+
if proc.returncode == 0 and proc.stdout.strip():
|
|
311
|
+
return proc.stdout.strip()
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _added_lines(project_dir: Path, base: str, name: str) -> list[str] | None:
|
|
316
|
+
"""Lines ADDED by this change (committed + working tree) vs base.
|
|
317
|
+
|
|
318
|
+
Returns None when git cannot answer — callers fall back to the
|
|
319
|
+
whole-file scan rather than silently passing.
|
|
320
|
+
"""
|
|
321
|
+
proc = subprocess.run(
|
|
322
|
+
["git", "diff", "-U0", base, "--", name],
|
|
323
|
+
cwd=project_dir, capture_output=True, text=True, timeout=30,
|
|
324
|
+
)
|
|
325
|
+
if proc.returncode != 0:
|
|
326
|
+
return None
|
|
327
|
+
return [
|
|
328
|
+
line[1:]
|
|
329
|
+
for line in proc.stdout.splitlines()
|
|
330
|
+
if line.startswith("+") and not line.startswith("+++")
|
|
331
|
+
]
|
|
332
|
+
|
|
333
|
+
|
|
294
334
|
def _check_security_grep(
|
|
295
335
|
project_dir: Path, changed: list[str] | None,
|
|
296
336
|
test_command: str | None, timeout: int,
|
|
297
337
|
) -> CheckResult:
|
|
338
|
+
"""Diff-aware security sweep over the changed files.
|
|
339
|
+
|
|
340
|
+
Scans only lines ADDED relative to the default-branch merge-base —
|
|
341
|
+
a pre-existing pattern elsewhere in a touched file is master's
|
|
342
|
+
debt, not this change's (QG blocker, PR1 Interaction Reform:
|
|
343
|
+
whole-file scans failed changed files on benign pre-existing
|
|
344
|
+
lines). Falls back to the whole-file scan when git cannot provide
|
|
345
|
+
a diff (outside a repo, new file, missing base).
|
|
346
|
+
"""
|
|
298
347
|
if not changed:
|
|
299
348
|
return _skip("security-grep", "no changed files provided")
|
|
349
|
+
base = _diff_base(project_dir)
|
|
300
350
|
hits: list[str] = []
|
|
351
|
+
mode = "added-lines" if base else "whole-file"
|
|
301
352
|
for name in changed:
|
|
302
353
|
path = Path(name)
|
|
303
354
|
if not path.is_absolute():
|
|
304
355
|
path = project_dir / name
|
|
305
|
-
if path.is_file():
|
|
356
|
+
if not path.is_file():
|
|
357
|
+
continue
|
|
358
|
+
added = _added_lines(project_dir, base, name) if base else None
|
|
359
|
+
if added is None:
|
|
306
360
|
hits.extend(_grep_file(path))
|
|
361
|
+
else:
|
|
362
|
+
hits.extend(_grep_lines(path, added))
|
|
307
363
|
summary = (
|
|
308
364
|
"no security patterns matched"
|
|
309
365
|
if not hits
|
|
@@ -311,7 +367,7 @@ def _check_security_grep(
|
|
|
311
367
|
)
|
|
312
368
|
return CheckResult(
|
|
313
369
|
check="security-grep", ran=True, passed=not hits,
|
|
314
|
-
command=f"security-grep over {len(changed)} changed file(s)",
|
|
370
|
+
command=f"security-grep ({mode}) over {len(changed)} changed file(s)",
|
|
315
371
|
exit_code=None, summary=_tail(summary),
|
|
316
372
|
)
|
|
317
373
|
|
|
@@ -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:
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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":
|
package/core/hooks/stop.py
CHANGED
|
@@ -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}"
|
|
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
|