arkaos 4.10.0 → 4.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +1 -1
  2. package/VERSION +1 -1
  3. package/arka/SKILL.md +10 -1
  4. package/arka/skills/flow/SKILL.md +23 -1
  5. package/arka/skills/recipes/SKILL.md +69 -0
  6. package/arka/skills/refine/SKILL.md +81 -0
  7. package/config/constitution.yaml +8 -0
  8. package/config/mcp-policy.yaml +8 -0
  9. package/config/output-styles/arkaos.md +52 -0
  10. package/core/evals/__init__.py +4 -0
  11. package/core/evals/__pycache__/__init__.cpython-313.pyc +0 -0
  12. package/core/evals/__pycache__/record_cli.cpython-313.pyc +0 -0
  13. package/core/evals/__pycache__/runner_cli.cpython-313.pyc +0 -0
  14. package/core/evals/__pycache__/sanitizer.cpython-313.pyc +0 -0
  15. package/core/evals/__pycache__/verdict_labels.cpython-313.pyc +0 -0
  16. package/core/evals/record_cli.py +96 -0
  17. package/core/evals/runner_cli.py +102 -0
  18. package/core/evals/sanitizer.py +97 -0
  19. package/core/evals/verdict_labels.py +53 -13
  20. package/core/forge/__pycache__/complexity.cpython-313.pyc +0 -0
  21. package/core/forge/complexity.py +9 -0
  22. package/core/governance/__pycache__/evidence_checks.cpython-313.pyc +0 -0
  23. package/core/governance/__pycache__/judge.cpython-313.pyc +0 -0
  24. package/core/governance/__pycache__/leak_scanner.cpython-313.pyc +0 -0
  25. package/core/governance/__pycache__/phantom_action_check.cpython-313.pyc +0 -0
  26. package/core/governance/__pycache__/phantom_action_check.cpython-314.pyc +0 -0
  27. package/core/governance/evidence_checks.py +60 -4
  28. package/core/governance/judge.py +95 -0
  29. package/core/governance/leak_scanner.py +10 -0
  30. package/core/governance/phantom_action_check.py +58 -2
  31. package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
  32. package/core/hooks/__pycache__/stop.cpython-313.pyc +0 -0
  33. package/core/hooks/__pycache__/stop.cpython-314.pyc +0 -0
  34. package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  35. package/core/hooks/post_tool_use.py +13 -0
  36. package/core/hooks/stop.py +23 -0
  37. package/core/hooks/user_prompt_submit.py +69 -0
  38. package/core/knowledge/__pycache__/recipes.cpython-313.pyc +0 -0
  39. package/core/knowledge/__pycache__/recipes_cli.cpython-313.pyc +0 -0
  40. package/core/knowledge/recipes.py +237 -0
  41. package/core/knowledge/recipes_cli.py +99 -0
  42. package/core/registry/__pycache__/generator.cpython-313.pyc +0 -0
  43. package/core/shared/__pycache__/safe_session_id.cpython-313.pyc +0 -0
  44. package/core/shared/__pycache__/safe_session_id.cpython-314.pyc +0 -0
  45. package/core/shared/safe_session_id.py +11 -3
  46. package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
  47. package/core/synapse/__pycache__/engine.cpython-314.pyc +0 -0
  48. package/core/synapse/__pycache__/pattern_library_layer.cpython-313.pyc +0 -0
  49. package/core/synapse/__pycache__/recipe_layer.cpython-313.pyc +0 -0
  50. package/core/synapse/engine.py +6 -0
  51. package/core/synapse/pattern_library_layer.py +10 -2
  52. package/core/synapse/recipe_layer.py +114 -0
  53. package/core/sync/__pycache__/mcp_optimizer.cpython-313.pyc +0 -0
  54. package/core/sync/__pycache__/mcp_syncer.cpython-313.pyc +0 -0
  55. package/core/sync/__pycache__/settings_syncer.cpython-313.pyc +0 -0
  56. package/core/sync/mcp_optimizer.py +4 -1
  57. package/core/sync/mcp_syncer.py +50 -6
  58. package/core/sync/settings_syncer.py +9 -2
  59. package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
  60. package/core/workflow/__pycache__/flow_enforcer.cpython-314.pyc +0 -0
  61. package/core/workflow/__pycache__/plan_approval.cpython-313.pyc +0 -0
  62. package/core/workflow/__pycache__/plan_approval.cpython-314.pyc +0 -0
  63. package/core/workflow/flow_enforcer.py +63 -0
  64. package/core/workflow/plan_approval.py +185 -0
  65. package/departments/dev/SKILL.md +10 -0
  66. package/departments/dev/skills/onboard/SKILL.md +6 -2
  67. package/departments/dev/skills/refactor-plan/SKILL.md +7 -0
  68. package/departments/dev/skills/scaffold/SKILL.md +6 -0
  69. package/departments/quality/SKILL.md +24 -0
  70. package/installer/doctor.js +7 -0
  71. package/installer/index.js +20 -0
  72. package/installer/output-style.js +92 -0
  73. package/installer/update.js +19 -0
  74. package/installer/worktree-baseref.js +8 -2
  75. package/knowledge/commands-keywords.json +4 -0
  76. package/knowledge/commands-registry.json +41 -3
  77. package/package.json +1 -1
  78. package/pyproject.toml +1 -1
  79. package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
  80. package/scripts/__pycache__/synapse-bridge.cpython-313.pyc +0 -0
@@ -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 already produces a structured ``QGVerdict``
4
- (APPROVED/REJECTED + blockers + evidence summary). Persisting them turns
5
- each review into a labeled example — the training/eval signal the E2E
6
- audit called "labels gratuitos". Append-only JSONL, same conventions as
7
- the other ``~/.arkaos/telemetry`` writers: never raises, fcntl advisory
8
- lock via ``llm_cost_telemetry._locked_append`` (QG reviews run as
9
- parallel subagents and verdict lines far exceed atomic-write sizes, so
10
- unlocked appends could interleave and corrupt the label corpus),
11
- ``ARKA_QG_LABELS_PATH`` override for tests.
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
- target = path or _labels_path()
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]] = []
@@ -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))
@@ -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
- lines = path.read_text(encoding="utf-8", errors="ignore").splitlines()
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(lines, start=1):
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)\b"
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
- r"\bI(?:'ve| have)?\s+(?:just\s+)?(created|wrote|updated|deleted"
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:
@@ -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":
@@ -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 = []