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
@@ -12,6 +12,7 @@ policies:
12
12
  - match:
13
13
  stack_includes: [laravel, php]
14
14
  active:
15
+ - codebase-memory
15
16
  - arka-prompts
16
17
  - obsidian
17
18
  - context7
@@ -36,12 +37,14 @@ policies:
36
37
  - teams
37
38
  - shopify-dev
38
39
  - mirakl
40
+ - higgsfield
39
41
  ambiguous: []
40
42
 
41
43
  # --- Frontend: Nuxt / Vue ---
42
44
  - match:
43
45
  stack_includes: [nuxt, vue]
44
46
  active:
47
+ - codebase-memory
45
48
  - arka-prompts
46
49
  - obsidian
47
50
  - context7
@@ -66,12 +69,14 @@ policies:
66
69
  - teams
67
70
  - shopify-dev
68
71
  - mirakl
72
+ - higgsfield
69
73
  ambiguous: []
70
74
 
71
75
  # --- Frontend: React / Next ---
72
76
  - match:
73
77
  stack_includes: [next, react]
74
78
  active:
79
+ - codebase-memory
75
80
  - arka-prompts
76
81
  - obsidian
77
82
  - context7
@@ -96,6 +101,7 @@ policies:
96
101
  - teams
97
102
  - shopify-dev
98
103
  - mirakl
104
+ - higgsfield
99
105
  ambiguous: []
100
106
 
101
107
  # --- E-commerce (Shopify / Mirakl) ---
@@ -111,6 +117,7 @@ policies:
111
117
  - mirakl
112
118
  - sentry
113
119
  deferred:
120
+ - codebase-memory
114
121
  - memory-bank
115
122
  - postgres
116
123
  - supabase
@@ -126,12 +133,14 @@ policies:
126
133
  - discord
127
134
  - whatsapp
128
135
  - teams
136
+ - higgsfield
129
137
  ambiguous: []
130
138
 
131
139
  # --- Python projects ---
132
140
  - match:
133
141
  stack_includes: [python]
134
142
  active:
143
+ - codebase-memory
135
144
  - arka-prompts
136
145
  - obsidian
137
146
  - context7
@@ -156,6 +165,7 @@ policies:
156
165
  - teams
157
166
  - shopify-dev
158
167
  - mirakl
168
+ - higgsfield
159
169
  ambiguous: []
160
170
 
161
171
  # --- Ecosystem: marketing ---
@@ -169,6 +179,7 @@ policies:
169
179
  - firecrawl
170
180
  - clickup
171
181
  deferred:
182
+ - codebase-memory
172
183
  - memory-bank
173
184
  - gh-grep
174
185
  - postgres
@@ -186,6 +197,7 @@ policies:
186
197
  - teams
187
198
  - shopify-dev
188
199
  - mirakl
200
+ - higgsfield
189
201
  ambiguous: []
190
202
 
191
203
  # --- Ecosystem: content ---
@@ -197,7 +209,9 @@ policies:
197
209
  - context7
198
210
  - canva
199
211
  - firecrawl
212
+ - higgsfield
200
213
  deferred:
214
+ - codebase-memory
201
215
  - memory-bank
202
216
  - gh-grep
203
217
  - postgres
@@ -227,6 +241,7 @@ policies:
227
241
  - context7
228
242
  - gh-grep
229
243
  deferred:
244
+ - codebase-memory
230
245
  - memory-bank
231
246
  - postgres
232
247
  - supabase
@@ -246,4 +261,5 @@ policies:
246
261
  - teams
247
262
  - shopify-dev
248
263
  - mirakl
264
+ - higgsfield
249
265
  ambiguous: []
@@ -0,0 +1,52 @@
1
+ ---
2
+ name: ArkaOS
3
+ description: ArkaOS Operating System personality — squad-driven, framework-backed, branded responses
4
+ keep-coding-instructions: true
5
+ ---
6
+
7
+ # ArkaOS Output Style
8
+
9
+ You are operating as ArkaOS — the Operating System for AI Agent Teams. You orchestrate the ArkaOS specialized agent departments.
10
+
11
+ ## Core Behavior
12
+
13
+ - Every request routes through the appropriate department squad. Never respond as a generic assistant.
14
+ - Use framework-backed reasoning: cite the framework behind your recommendations (SOLID, StoryBrand, Porter's Five Forces, AARRR, etc.)
15
+ - Apply critical thinking before executing. Ask clarifying questions when intent is ambiguous.
16
+ - Quality Gate mindset: everything you produce should be production-ready, reviewed, and validated.
17
+
18
+ ## Challenge the User
19
+
20
+ - When the user's request is technically wrong, mathematically impossible,
21
+ or would ship a worse product, challenge it BEFORE executing — pushback
22
+ protocol, constitution rule `arkaos-not-yes-man`.
23
+ - Ground the challenge in evidence or a reference company (Stripe, Linear,
24
+ Vercel, Notion…), never in opinion alone.
25
+ - Insistence is not new evidence: never grow more agreeable under pressure.
26
+ If the user directs execution anyway, execute under explicit objection,
27
+ on record.
28
+ - Apply the same standard to agents and subagents: judge their plans and
29
+ outputs adversarially — delivering fast is worthless when the solution
30
+ is wrong.
31
+
32
+ ## Response Style
33
+
34
+ - Be direct and concise. Lead with the action or answer, not preamble.
35
+ - When routing to a department, announce it: "Routing to [Department] — [Lead Agent] coordinating."
36
+ - Use structured output: headers, tables, bullet points. No walls of text.
37
+ - For code: follow SOLID principles, clean code, conventional commits.
38
+ - For content: human-quality writing, no AI cliches, culturally appropriate for the target market.
39
+
40
+ ## Squad Awareness
41
+
42
+ When handling requests, think in squads:
43
+ - Who is the department lead for this domain?
44
+ - Which specialists should execute?
45
+ - Does this need cross-department collaboration?
46
+ - Does this need Quality Gate review?
47
+
48
+ ## Language
49
+
50
+ - Match the user's language (Portuguese or English).
51
+ - When writing in Portuguese, use European Portuguese (pt-PT), never Brazilian.
52
+ - Technical terms can stay in English when that's the industry standard.
@@ -7,13 +7,17 @@ or local-model distillation consumes.
7
7
 
8
8
  from core.evals.schema import EvalTask, load_eval_tasks
9
9
  from core.evals.verdict_labels import (
10
+ load_judge_labels,
10
11
  load_verdict_labels,
12
+ record_judge_label,
11
13
  record_verdict_label,
12
14
  )
13
15
 
14
16
  __all__ = [
15
17
  "EvalTask",
16
18
  "load_eval_tasks",
19
+ "load_judge_labels",
17
20
  "load_verdict_labels",
21
+ "record_judge_label",
18
22
  "record_verdict_label",
19
23
  ]
@@ -1,10 +1,11 @@
1
- """Record a QGVerdict as an eval label — the QG-skill wiring point.
1
+ """Record a QGVerdict or gate JudgeVerdict as an eval label.
2
2
 
3
- Reads the reviewer's QGVerdict JSON from stdin (or --file) and appends
4
- it to the label corpus via ``record_verdict_label``. Invoked by the
5
- orchestrator right after a Quality Gate verdict lands (see the Quality
6
- Gate skill instructions), closing the "labels gratuitos" loop from the
7
- evals ADR.
3
+ Reads the reviewer's verdict JSON from stdin (or --file) and appends it
4
+ to the matching label corpus (``--kind qg`` qg-verdicts.jsonl,
5
+ ``--kind judge`` judge-verdicts.jsonl). Invoked by the orchestrator
6
+ right after a Quality Gate verdict or a gate-judge verdict lands (see
7
+ the Quality Gate and flow skill instructions), closing the "labels
8
+ gratuitos" loop from the evals ADR.
8
9
 
9
10
  Unlike the underlying writer (telemetry contract: never raises), this
10
11
  explicit CLI fails LOUDLY on invalid verdict JSON — a malformed label
@@ -20,13 +21,20 @@ from pathlib import Path
20
21
 
21
22
  from pydantic import ValidationError
22
23
 
23
- from core.evals.verdict_labels import record_verdict_label
24
+ from core.evals.verdict_labels import record_judge_label, record_verdict_label
25
+ from core.governance.judge import JudgeVerdict
24
26
  from core.governance.qg_verdict import QGVerdict
25
27
 
26
28
 
27
29
  def main(argv: list[str] | None = None) -> int:
28
30
  parser = argparse.ArgumentParser(description=__doc__)
29
31
  parser.add_argument("--file", help="verdict JSON file (default: stdin)")
32
+ parser.add_argument(
33
+ "--kind",
34
+ choices=("qg", "judge"),
35
+ default="qg",
36
+ help="qg = QGVerdict (default); judge = gate JudgeVerdict (PR2)",
37
+ )
30
38
  parser.add_argument("--deliverable", default="")
31
39
  parser.add_argument("--department", default="")
32
40
  parser.add_argument("--eval-task-id", default="")
@@ -38,12 +46,17 @@ def main(argv: list[str] | None = None) -> int:
38
46
  if args.file
39
47
  else sys.stdin.read()
40
48
  )
49
+ if args.kind == "judge":
50
+ return _record_judge(raw, args)
51
+ return _record_qg(raw, args)
52
+
53
+
54
+ def _record_qg(raw: str, args: argparse.Namespace) -> int:
41
55
  try:
42
56
  verdict = QGVerdict.model_validate(json.loads(raw))
43
57
  except (json.JSONDecodeError, ValidationError) as exc:
44
58
  print(f"error: invalid QGVerdict JSON — {exc}", file=sys.stderr)
45
59
  return 1
46
-
47
60
  record_verdict_label(
48
61
  verdict,
49
62
  deliverable=args.deliverable,
@@ -59,5 +72,25 @@ def main(argv: list[str] | None = None) -> int:
59
72
  return 0
60
73
 
61
74
 
75
+ def _record_judge(raw: str, args: argparse.Namespace) -> int:
76
+ try:
77
+ verdict = JudgeVerdict.model_validate(json.loads(raw))
78
+ except (json.JSONDecodeError, ValidationError) as exc:
79
+ print(f"error: invalid JudgeVerdict JSON — {exc}", file=sys.stderr)
80
+ return 1
81
+ record_judge_label(
82
+ verdict,
83
+ deliverable=args.deliverable,
84
+ department=args.department,
85
+ session_id=args.session_id,
86
+ )
87
+ print(json.dumps({
88
+ "recorded": True,
89
+ "verdict": verdict.verdict,
90
+ "gate": verdict.gate,
91
+ }))
92
+ return 0
93
+
94
+
62
95
  if __name__ == "__main__":
63
96
  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))
@@ -28,6 +28,7 @@ import re
28
28
  import shlex
29
29
  import shutil
30
30
  import subprocess
31
+ import time
31
32
  from dataclasses import asdict, dataclass, field
32
33
  from pathlib import Path
33
34
 
@@ -37,8 +38,16 @@ TIMEOUT_SECONDS = 300
37
38
  COVERAGE_THRESHOLD = 80.0
38
39
  ALL_CHECKS: tuple[str, ...] = (
39
40
  "lint", "typecheck", "tests", "coverage", "security-grep", "spellcheck",
41
+ "ui-screenshot",
40
42
  )
41
43
 
44
+ # ui-screenshot artifact contract (Excellence Reform PR-D3): captures land
45
+ # in <project>/.arka/evidence/ui/ per brand/design-review; the check only
46
+ # stats files (read-only), it never runs a browser.
47
+ UI_EVIDENCE_DIR = Path(".arka") / "evidence" / "ui"
48
+ UI_SCREENSHOT_WINDOW_HOURS = 24
49
+ UI_SCREENSHOT_MIN_BYTES = 10 * 1024
50
+
42
51
  _MAX_SUMMARY_CHARS = 800
43
52
  _MAX_GREP_HITS = 20
44
53
 
@@ -278,32 +287,88 @@ def _check_coverage(
278
287
  return _skip("coverage", "no coverage.xml or junit.xml on disk")
279
288
 
280
289
 
290
+ def _grep_lines(path: Path, lines: list[str]) -> list[str]:
291
+ hits = []
292
+ for lineno_or_text in lines:
293
+ for name, pattern in _SECURITY_PATTERNS:
294
+ if pattern.search(lineno_or_text):
295
+ hits.append(f"{path} [{name}]: {lineno_or_text.strip()[:120]}")
296
+ return hits
297
+
298
+
281
299
  def _grep_file(path: Path) -> list[str]:
282
300
  try:
283
- lines = path.read_text(encoding="utf-8", errors="ignore").splitlines()
301
+ text = path.read_text(encoding="utf-8", errors="ignore")
284
302
  except OSError:
285
303
  return []
286
304
  hits = []
287
- for lineno, line in enumerate(lines, start=1):
305
+ for lineno, line in enumerate(text.splitlines(), start=1):
288
306
  for name, pattern in _SECURITY_PATTERNS:
289
307
  if pattern.search(line):
290
308
  hits.append(f"{path}:{lineno} [{name}]")
291
309
  return hits
292
310
 
293
311
 
312
+ def _diff_base(project_dir: Path) -> str | None:
313
+ """Merge-base with the default branch, or None outside a usable repo."""
314
+ for ref in ("origin/master", "master", "origin/main", "main"):
315
+ proc = subprocess.run(
316
+ ["git", "merge-base", "HEAD", ref],
317
+ cwd=project_dir, capture_output=True, text=True, timeout=10,
318
+ )
319
+ if proc.returncode == 0 and proc.stdout.strip():
320
+ return proc.stdout.strip()
321
+ return None
322
+
323
+
324
+ def _added_lines(project_dir: Path, base: str, name: str) -> list[str] | None:
325
+ """Lines ADDED by this change (committed + working tree) vs base.
326
+
327
+ Returns None when git cannot answer — callers fall back to the
328
+ whole-file scan rather than silently passing.
329
+ """
330
+ proc = subprocess.run(
331
+ ["git", "diff", "-U0", base, "--", name],
332
+ cwd=project_dir, capture_output=True, text=True, timeout=30,
333
+ )
334
+ if proc.returncode != 0:
335
+ return None
336
+ return [
337
+ line[1:]
338
+ for line in proc.stdout.splitlines()
339
+ if line.startswith("+") and not line.startswith("+++")
340
+ ]
341
+
342
+
294
343
  def _check_security_grep(
295
344
  project_dir: Path, changed: list[str] | None,
296
345
  test_command: str | None, timeout: int,
297
346
  ) -> CheckResult:
347
+ """Diff-aware security sweep over the changed files.
348
+
349
+ Scans only lines ADDED relative to the default-branch merge-base —
350
+ a pre-existing pattern elsewhere in a touched file is master's
351
+ debt, not this change's (QG blocker, PR1 Interaction Reform:
352
+ whole-file scans failed changed files on benign pre-existing
353
+ lines). Falls back to the whole-file scan when git cannot provide
354
+ a diff (outside a repo, new file, missing base).
355
+ """
298
356
  if not changed:
299
357
  return _skip("security-grep", "no changed files provided")
358
+ base = _diff_base(project_dir)
300
359
  hits: list[str] = []
360
+ mode = "added-lines" if base else "whole-file"
301
361
  for name in changed:
302
362
  path = Path(name)
303
363
  if not path.is_absolute():
304
364
  path = project_dir / name
305
- if path.is_file():
365
+ if not path.is_file():
366
+ continue
367
+ added = _added_lines(project_dir, base, name) if base else None
368
+ if added is None:
306
369
  hits.extend(_grep_file(path))
370
+ else:
371
+ hits.extend(_grep_lines(path, added))
307
372
  summary = (
308
373
  "no security patterns matched"
309
374
  if not hits
@@ -311,7 +376,7 @@ def _check_security_grep(
311
376
  )
312
377
  return CheckResult(
313
378
  check="security-grep", ran=True, passed=not hits,
314
- command=f"security-grep over {len(changed)} changed file(s)",
379
+ command=f"security-grep ({mode}) over {len(changed)} changed file(s)",
315
380
  exit_code=None, summary=_tail(summary),
316
381
  )
317
382
 
@@ -328,6 +393,66 @@ def _check_spellcheck(
328
393
  return _run("spellcheck", ["codespell", *md_files], project_dir, timeout)
329
394
 
330
395
 
396
+ def _check_ui_screenshot(
397
+ project_dir: Path, changed: list[str] | None,
398
+ test_command: str | None, timeout: int,
399
+ ) -> CheckResult:
400
+ """UI-touching changes require a recent screenshot artifact.
401
+
402
+ Mechanical half of the visual-review loop (excellence-mandate): a
403
+ PNG > 10KB captured within the last 24h under
404
+ ``<project>/.arka/evidence/ui/``. The judgment half — whether the
405
+ pixels hold up against the benchmark named in the ``[arka:design]``
406
+ marker — stays with Francisca, who Reads the artifact this check
407
+ points at. Skips when no UI files changed; read-only (stat only).
408
+ """
409
+ if not changed:
410
+ return _skip("ui-screenshot", "no changed files provided")
411
+ try:
412
+ from core.workflow.frontend_gate import is_ui_file
413
+ except Exception:
414
+ return _skip("ui-screenshot", "frontend_gate unavailable")
415
+ ui_changed = [f for f in changed if is_ui_file(f)]
416
+ if not ui_changed:
417
+ return _skip("ui-screenshot", "no UI files changed")
418
+ command_str = (
419
+ f"scan:{UI_EVIDENCE_DIR} (png >{UI_SCREENSHOT_MIN_BYTES // 1024}KB, "
420
+ f"last {UI_SCREENSHOT_WINDOW_HOURS}h)"
421
+ )
422
+ evidence_dir = project_dir / UI_EVIDENCE_DIR
423
+ cutoff = time.time() - UI_SCREENSHOT_WINDOW_HOURS * 3600
424
+ candidates: list[tuple[float, Path]] = []
425
+ if evidence_dir.is_dir():
426
+ for png in evidence_dir.rglob("*.png"):
427
+ try:
428
+ stat = png.stat()
429
+ except OSError:
430
+ continue
431
+ if stat.st_mtime >= cutoff and stat.st_size > UI_SCREENSHOT_MIN_BYTES:
432
+ candidates.append((stat.st_mtime, png))
433
+ if candidates:
434
+ newest = max(candidates)[1]
435
+ return CheckResult(
436
+ check="ui-screenshot", ran=True, passed=True,
437
+ command=command_str, exit_code=None,
438
+ summary=(
439
+ f"{len(candidates)} recent screenshot(s) for "
440
+ f"{len(ui_changed)} changed UI file(s); newest: {newest.name}"
441
+ ),
442
+ details_path=str(newest),
443
+ )
444
+ return CheckResult(
445
+ check="ui-screenshot", ran=True, passed=False,
446
+ command=command_str, exit_code=None,
447
+ summary=(
448
+ f"{len(ui_changed)} UI file(s) changed but no screenshot "
449
+ f"evidence under {UI_EVIDENCE_DIR}/ — capture per "
450
+ "brand/design-review (Playwright first, 1440+390 widths) or "
451
+ "record an explicit [arka:trivial] justification"
452
+ ),
453
+ )
454
+
455
+
331
456
  _CHECK_DISPATCH = {
332
457
  "lint": _check_lint,
333
458
  "typecheck": _check_typecheck,
@@ -335,6 +460,7 @@ _CHECK_DISPATCH = {
335
460
  "coverage": _check_coverage,
336
461
  "security-grep": _check_security_grep,
337
462
  "spellcheck": _check_spellcheck,
463
+ "ui-screenshot": _check_ui_screenshot,
338
464
  }
339
465
 
340
466