arkaos 4.10.0 → 4.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/VERSION +1 -1
- package/arka/SKILL.md +10 -1
- package/arka/skills/flow/SKILL.md +23 -1
- package/arka/skills/recipes/SKILL.md +69 -0
- package/arka/skills/refine/SKILL.md +81 -0
- package/config/constitution.yaml +8 -0
- package/config/mcp-policy.yaml +8 -0
- package/config/output-styles/arkaos.md +52 -0
- package/core/evals/__init__.py +4 -0
- package/core/evals/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/evals/__pycache__/record_cli.cpython-313.pyc +0 -0
- package/core/evals/__pycache__/runner_cli.cpython-313.pyc +0 -0
- package/core/evals/__pycache__/sanitizer.cpython-313.pyc +0 -0
- package/core/evals/__pycache__/verdict_labels.cpython-313.pyc +0 -0
- package/core/evals/record_cli.py +96 -0
- package/core/evals/runner_cli.py +102 -0
- package/core/evals/sanitizer.py +97 -0
- package/core/evals/verdict_labels.py +53 -13
- package/core/forge/__pycache__/complexity.cpython-313.pyc +0 -0
- package/core/forge/complexity.py +9 -0
- package/core/governance/__pycache__/evidence_checks.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/judge.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/leak_scanner.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/phantom_action_check.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/phantom_action_check.cpython-314.pyc +0 -0
- package/core/governance/evidence_checks.py +60 -4
- package/core/governance/judge.py +95 -0
- package/core/governance/leak_scanner.py +10 -0
- package/core/governance/phantom_action_check.py +58 -2
- package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/stop.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/stop.cpython-314.pyc +0 -0
- package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
- package/core/hooks/post_tool_use.py +13 -0
- package/core/hooks/stop.py +23 -0
- package/core/hooks/user_prompt_submit.py +69 -0
- package/core/knowledge/__pycache__/recipes.cpython-313.pyc +0 -0
- package/core/knowledge/__pycache__/recipes_cli.cpython-313.pyc +0 -0
- package/core/knowledge/recipes.py +237 -0
- package/core/knowledge/recipes_cli.py +99 -0
- package/core/registry/__pycache__/generator.cpython-313.pyc +0 -0
- package/core/shared/__pycache__/safe_session_id.cpython-313.pyc +0 -0
- package/core/shared/__pycache__/safe_session_id.cpython-314.pyc +0 -0
- package/core/shared/safe_session_id.py +11 -3
- package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/engine.cpython-314.pyc +0 -0
- package/core/synapse/__pycache__/pattern_library_layer.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/recipe_layer.cpython-313.pyc +0 -0
- package/core/synapse/engine.py +6 -0
- package/core/synapse/pattern_library_layer.py +10 -2
- package/core/synapse/recipe_layer.py +114 -0
- package/core/sync/__pycache__/mcp_optimizer.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/mcp_syncer.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/settings_syncer.cpython-313.pyc +0 -0
- package/core/sync/mcp_optimizer.py +4 -1
- package/core/sync/mcp_syncer.py +50 -6
- package/core/sync/settings_syncer.py +9 -2
- package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/flow_enforcer.cpython-314.pyc +0 -0
- package/core/workflow/__pycache__/plan_approval.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/plan_approval.cpython-314.pyc +0 -0
- package/core/workflow/flow_enforcer.py +63 -0
- package/core/workflow/plan_approval.py +185 -0
- package/departments/dev/SKILL.md +10 -0
- package/departments/dev/skills/onboard/SKILL.md +6 -2
- package/departments/dev/skills/refactor-plan/SKILL.md +7 -0
- package/departments/dev/skills/scaffold/SKILL.md +6 -0
- package/departments/quality/SKILL.md +24 -0
- package/installer/doctor.js +7 -0
- package/installer/index.js +20 -0
- package/installer/output-style.js +92 -0
- package/installer/update.js +19 -0
- package/installer/worktree-baseref.js +8 -2
- package/knowledge/commands-keywords.json +4 -0
- package/knowledge/commands-registry.json +41 -3
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
- package/scripts/__pycache__/synapse-bridge.cpython-313.pyc +0 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Client-identifier sanitizer — distillation prerequisite (evals ADR).
|
|
2
|
+
|
|
3
|
+
Redacts every client identifier from transcript text BEFORE it can feed
|
|
4
|
+
a LoRA training run. Confidentiality is NON-NEGOTIABLE (v2.18.0 npm
|
|
5
|
+
leak precedent), so this module fails CLOSED: with no redaction config
|
|
6
|
+
on disk there is nothing to prove the text is clean, and sanitize()
|
|
7
|
+
refuses to run rather than pass text through unredacted.
|
|
8
|
+
|
|
9
|
+
The identifier list is the same one the release leak-scanner uses
|
|
10
|
+
(``~/.arkaos/redaction-clients.json`` via
|
|
11
|
+
``core.governance.leak_scanner.load_redaction_patterns``) — one source
|
|
12
|
+
of truth, never hardcoded in the repo.
|
|
13
|
+
|
|
14
|
+
Replacement is deterministic: the Nth pattern in the config maps to
|
|
15
|
+
``[CLIENT-N]`` in every run. Corpora stay diffable under the APPEND-ONLY
|
|
16
|
+
invariant — new clients go at the END of the config list; reordering or
|
|
17
|
+
deleting entries remaps the placeholders of everything sanitized before.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from core.governance.leak_scanner import load_redaction_patterns
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SanitizerConfigMissing(RuntimeError):
|
|
30
|
+
"""No redaction config — sanitization cannot be proven, so it refuses."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def sanitize_text(
|
|
34
|
+
text: str, config_path: Path | None = None
|
|
35
|
+
) -> tuple[str, dict[str, int]]:
|
|
36
|
+
"""Redact client identifiers; return (clean_text, per-placeholder counts).
|
|
37
|
+
|
|
38
|
+
Raises SanitizerConfigMissing when the redaction config is absent or
|
|
39
|
+
empty — fail closed, never pass text through unproven.
|
|
40
|
+
"""
|
|
41
|
+
patterns = load_redaction_patterns(config_path)
|
|
42
|
+
if not patterns:
|
|
43
|
+
raise SanitizerConfigMissing(
|
|
44
|
+
"redaction config missing or empty (~/.arkaos/redaction-"
|
|
45
|
+
"clients.json) — refusing to sanitize without an identifier "
|
|
46
|
+
"list; a silent pass-through would poison the training corpus"
|
|
47
|
+
)
|
|
48
|
+
# SINGLE pass over ONE longest-first alternation (QG blocker,
|
|
49
|
+
# 2026-07-09): a positional per-pattern loop leaked the suffix of a
|
|
50
|
+
# longer identifier whenever a shorter prefix pattern came first in
|
|
51
|
+
# the config ("data" before "data dynamics corp"), and replacement
|
|
52
|
+
# text could itself be re-matched. Placeholder numbers stay keyed to
|
|
53
|
+
# CONFIG position, so appends never remap an existing corpus.
|
|
54
|
+
index_by_pattern = {p: i for i, p in enumerate(patterns, start=1)}
|
|
55
|
+
alternation = "|".join(
|
|
56
|
+
re.escape(p) for p in sorted(patterns, key=len, reverse=True)
|
|
57
|
+
)
|
|
58
|
+
regex = re.compile(
|
|
59
|
+
r"(?<![a-z0-9])(" + alternation + r")(?![a-z0-9])", re.IGNORECASE
|
|
60
|
+
)
|
|
61
|
+
counts: dict[str, int] = {}
|
|
62
|
+
|
|
63
|
+
def _redact(match: re.Match[str]) -> str:
|
|
64
|
+
placeholder = f"[CLIENT-{index_by_pattern[match.group(1).lower()]}]"
|
|
65
|
+
counts[placeholder] = counts.get(placeholder, 0) + 1
|
|
66
|
+
return placeholder
|
|
67
|
+
|
|
68
|
+
return regex.sub(_redact, text), counts
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def sanitize_file(
|
|
72
|
+
path: Path, config_path: Path | None = None
|
|
73
|
+
) -> tuple[str, dict[str, int]]:
|
|
74
|
+
return sanitize_text(
|
|
75
|
+
path.read_text(encoding="utf-8"), config_path=config_path
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main(argv: list[str] | None = None) -> int:
|
|
80
|
+
"""CLI: sanitize a file (or stdin) to stdout; counts on stderr."""
|
|
81
|
+
args = argv if argv is not None else sys.argv[1:]
|
|
82
|
+
try:
|
|
83
|
+
if args:
|
|
84
|
+
clean, counts = sanitize_file(Path(args[0]))
|
|
85
|
+
else:
|
|
86
|
+
clean, counts = sanitize_text(sys.stdin.read())
|
|
87
|
+
except SanitizerConfigMissing as exc:
|
|
88
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
89
|
+
return 2
|
|
90
|
+
sys.stdout.write(clean)
|
|
91
|
+
total = sum(counts.values())
|
|
92
|
+
print(f"sanitized: {total} redaction(s) {counts}", file=sys.stderr)
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
raise SystemExit(main())
|
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
"""QG verdicts as free eval labels.
|
|
2
|
-
|
|
3
|
-
Every Quality Gate review
|
|
4
|
-
(
|
|
5
|
-
each review into a labeled example — the training/eval signal the
|
|
6
|
-
audit called "labels gratuitos".
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
1
|
+
"""QG and gate-judge verdicts as free eval labels.
|
|
2
|
+
|
|
3
|
+
Every Quality Gate review produces a structured ``QGVerdict`` and every
|
|
4
|
+
gate judge (Interaction Reform PR2) a ``JudgeVerdict``. Persisting them
|
|
5
|
+
turns each review into a labeled example — the training/eval signal the
|
|
6
|
+
E2E audit called "labels gratuitos". Two corpora, one module:
|
|
7
|
+
``qg-verdicts.jsonl`` and ``judge-verdicts.jsonl``. Append-only JSONL,
|
|
8
|
+
same conventions as the other ``~/.arkaos/telemetry`` writers: never
|
|
9
|
+
raises, fcntl advisory lock via ``llm_cost_telemetry._locked_append``
|
|
10
|
+
(reviews run as parallel subagents and verdict lines far exceed
|
|
11
|
+
atomic-write sizes, so unlocked appends could interleave and corrupt
|
|
12
|
+
the corpus), ``ARKA_QG_LABELS_PATH`` / ``ARKA_JUDGE_LABELS_PATH``
|
|
13
|
+
overrides for tests.
|
|
12
14
|
"""
|
|
13
15
|
|
|
14
16
|
from __future__ import annotations
|
|
@@ -19,12 +21,16 @@ from datetime import datetime, timezone
|
|
|
19
21
|
from pathlib import Path
|
|
20
22
|
from typing import Any
|
|
21
23
|
|
|
24
|
+
from core.governance.judge import JudgeVerdict
|
|
22
25
|
from core.governance.qg_verdict import QGVerdict
|
|
23
26
|
from core.runtime.llm_cost_telemetry import _locked_append
|
|
24
27
|
|
|
25
28
|
DEFAULT_LABELS_PATH = (
|
|
26
29
|
Path.home() / ".arkaos" / "telemetry" / "qg-verdicts.jsonl"
|
|
27
30
|
)
|
|
31
|
+
DEFAULT_JUDGE_LABELS_PATH = (
|
|
32
|
+
Path.home() / ".arkaos" / "telemetry" / "judge-verdicts.jsonl"
|
|
33
|
+
)
|
|
28
34
|
|
|
29
35
|
|
|
30
36
|
def _labels_path() -> Path:
|
|
@@ -32,6 +38,11 @@ def _labels_path() -> Path:
|
|
|
32
38
|
return Path(override) if override else DEFAULT_LABELS_PATH
|
|
33
39
|
|
|
34
40
|
|
|
41
|
+
def _judge_labels_path() -> Path:
|
|
42
|
+
override = os.environ.get("ARKA_JUDGE_LABELS_PATH", "").strip()
|
|
43
|
+
return Path(override) if override else DEFAULT_JUDGE_LABELS_PATH
|
|
44
|
+
|
|
45
|
+
|
|
35
46
|
def record_verdict_label(
|
|
36
47
|
verdict: QGVerdict,
|
|
37
48
|
deliverable: str = "",
|
|
@@ -55,9 +66,38 @@ def record_verdict_label(
|
|
|
55
66
|
return
|
|
56
67
|
|
|
57
68
|
|
|
69
|
+
def record_judge_label(
|
|
70
|
+
verdict: JudgeVerdict,
|
|
71
|
+
deliverable: str = "",
|
|
72
|
+
department: str = "",
|
|
73
|
+
session_id: str = "",
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Append one labeled gate-judge example. Never raises."""
|
|
76
|
+
try:
|
|
77
|
+
entry: dict[str, Any] = {
|
|
78
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
79
|
+
"deliverable": str(deliverable or ""),
|
|
80
|
+
"department": str(department or ""),
|
|
81
|
+
"session_id": str(session_id or ""),
|
|
82
|
+
**verdict.model_dump(),
|
|
83
|
+
}
|
|
84
|
+
with _locked_append(_judge_labels_path()) as fh:
|
|
85
|
+
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
86
|
+
except Exception: # noqa: BLE001 — telemetry must never raise
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
|
|
58
90
|
def load_verdict_labels(path: Path | None = None) -> list[dict[str, Any]]:
|
|
59
|
-
"""Read all labeled examples; malformed lines are skipped."""
|
|
60
|
-
|
|
91
|
+
"""Read all labeled QG examples; malformed lines are skipped."""
|
|
92
|
+
return _load_jsonl(path or _labels_path())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def load_judge_labels(path: Path | None = None) -> list[dict[str, Any]]:
|
|
96
|
+
"""Read all labeled gate-judge examples; malformed lines are skipped."""
|
|
97
|
+
return _load_jsonl(path or _judge_labels_path())
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _load_jsonl(target: Path) -> list[dict[str, Any]]:
|
|
61
101
|
if not target.exists():
|
|
62
102
|
return []
|
|
63
103
|
out: list[dict[str, Any]] = []
|
|
Binary file
|
package/core/forge/complexity.py
CHANGED
|
@@ -107,6 +107,15 @@ def _score_ambiguity(prompt: str, files: list[str]) -> int:
|
|
|
107
107
|
return max(0, min(100, score))
|
|
108
108
|
|
|
109
109
|
|
|
110
|
+
def score_prompt_ambiguity(prompt: str, files: list[str] | None = None) -> int:
|
|
111
|
+
"""Public 0-100 vagueness score for a user prompt.
|
|
112
|
+
|
|
113
|
+
Consumed by the UserPromptSubmit hook to suggest `/arka refine`
|
|
114
|
+
(Interaction Reform PR5) — regex-only, no I/O, hook-budget safe.
|
|
115
|
+
"""
|
|
116
|
+
return _score_ambiguity(prompt or "", files or [])
|
|
117
|
+
|
|
118
|
+
|
|
110
119
|
def _score_risk(prompt: str, files: list[str]) -> int:
|
|
111
120
|
score = 20
|
|
112
121
|
risk_matches = len(_RISK_KEYWORDS.findall(prompt))
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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()
|
|
@@ -93,6 +93,16 @@ def scan_text(
|
|
|
93
93
|
return [m.group(1).lower() for m in regex.finditer(text)]
|
|
94
94
|
|
|
95
95
|
|
|
96
|
+
def load_redaction_patterns(config_path: Path | None = None) -> tuple[str, ...]:
|
|
97
|
+
"""Public accessor for the redaction client list.
|
|
98
|
+
|
|
99
|
+
Consumed by core.evals.sanitizer (distillation prerequisite) so the
|
|
100
|
+
scanner and the sanitizer can never disagree on what a client
|
|
101
|
+
identifier is. Returns () when the config is missing or empty.
|
|
102
|
+
"""
|
|
103
|
+
return _load_patterns(config_path or _DEFAULT_CONFIG_PATH)
|
|
104
|
+
|
|
105
|
+
|
|
96
106
|
def _load_patterns(config_path: Path) -> tuple[str, ...]:
|
|
97
107
|
try:
|
|
98
108
|
data = json.loads(config_path.read_text(encoding="utf-8"))
|
|
@@ -45,7 +45,12 @@ _ANALYTIC_NOUN = (
|
|
|
45
45
|
r"|takeaways?|impressions?|sense|grasp|view|idea|picture"
|
|
46
46
|
r"|mental\s+model|contexto|resumo|notas?|entendimento"
|
|
47
47
|
r"|perce[çc][ãa]o|an[áa]lise|analysis|ideia|vis[ãa]o|imagem"
|
|
48
|
-
r"|no[çc][ãa]o|perspe[ct]?tiva|opini[ãa]o|conhecimento|knowledge
|
|
48
|
+
r"|no[çc][ãa]o|perspe[ct]?tiva|opini[ãa]o|conhecimento|knowledge"
|
|
49
|
+
# Synonym tail (ADR 2026-07-09-phantom M3): noun-reads anchored by
|
|
50
|
+
# the preposition — plurals included — so verb uses ("read the
|
|
51
|
+
# file") stay untouched.
|
|
52
|
+
r"|read(?:ing)?s?(?=\s+(?:on|of)\b)|takes?(?=\s+on\b)"
|
|
53
|
+
r"|interpretations?|assessments?|interpreta[çc][õã][eo]s?|leituras?)\b"
|
|
49
54
|
)
|
|
50
55
|
_GAP = r"(?:(?!" + _ANALYTIC_NOUN + r")[^.\n!?]){0,60}?"
|
|
51
56
|
|
|
@@ -67,7 +72,17 @@ _BOUND_PT = (
|
|
|
67
72
|
r"|movi|apliquei|corri|executei)\b" + _GAP + _EFFECT_OBJECT
|
|
68
73
|
)
|
|
69
74
|
_BOUND_EN = (
|
|
70
|
-
|
|
75
|
+
# Optional coordinated clause after the I-subject (ADR 2026-07-09-
|
|
76
|
+
# phantom M2): "I ran through the plan, then ran the tests" — the
|
|
77
|
+
# second bare verb shares the subject. The chunk must START with the
|
|
78
|
+
# I-clause's own idiom verb (ran into/through/over/across), so an
|
|
79
|
+
# embedded clause with a third-party subject ("I confirmed the hook
|
|
80
|
+
# ran and created…") can never bridge the I anchor to a foreign
|
|
81
|
+
# effect verb (QG blocker, 2026-07-09 re-review).
|
|
82
|
+
r"\bI(?:'ve| have)?\s+(?:just\s+)?"
|
|
83
|
+
r"(?:ran\s+(?:into|through|over|across)\b"
|
|
84
|
+
r"[^.\n!?]{0,40}?[,;]?\s*\b(?:then|and)\s+)?"
|
|
85
|
+
r"(created|wrote|updated|deleted"
|
|
71
86
|
r"|added|moved|applied|renamed|ran(?!\s+(?:into|through|over|across))"
|
|
72
87
|
r"|executed)\b" + _GAP + _EFFECT_OBJECT
|
|
73
88
|
)
|
|
@@ -135,6 +150,47 @@ def count_turn_tool_uses(raw_transcript: str | None) -> int | None:
|
|
|
135
150
|
return count
|
|
136
151
|
|
|
137
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
|
+
|
|
138
194
|
def check_phantom_actions(
|
|
139
195
|
response_text: str, raw_transcript: str | None
|
|
140
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 = []
|