arkaos 2.31.0 → 2.40.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 (32) hide show
  1. package/README.md +1 -1
  2. package/VERSION +1 -1
  3. package/arka/SKILL.md +76 -0
  4. package/arka/skills/checkpoint/SKILL.md +79 -0
  5. package/config/constitution.yaml +193 -0
  6. package/config/hooks/pre-tool-use.sh +18 -2
  7. package/config/hooks/session-start.sh +7 -0
  8. package/config/hooks/stop.sh +72 -0
  9. package/config/hooks/user-prompt-submit.sh +25 -0
  10. package/core/cognition/__pycache__/dreaming.cpython-313.pyc +0 -0
  11. package/core/cognition/__pycache__/dreams_reader.cpython-313.pyc +0 -0
  12. package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
  13. package/core/governance/__init__.py +7 -1
  14. package/core/governance/__pycache__/__init__.cpython-313.pyc +0 -0
  15. package/core/governance/__pycache__/dod_gate.cpython-313.pyc +0 -0
  16. package/core/governance/__pycache__/kb_cite_check.cpython-313.pyc +0 -0
  17. package/core/governance/__pycache__/learning_detector.cpython-313.pyc +0 -0
  18. package/core/governance/__pycache__/sycophancy_detector.cpython-313.pyc +0 -0
  19. package/core/governance/dod_gate.py +192 -0
  20. package/core/governance/kb_cite_check.py +129 -0
  21. package/core/governance/learning_detector.py +221 -0
  22. package/core/governance/sycophancy_detector.py +169 -0
  23. package/core/orchestration/__pycache__/checkpoint.cpython-313.pyc +0 -0
  24. package/core/orchestration/checkpoint.py +157 -0
  25. package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
  26. package/core/runtime/__pycache__/ollama_provider.cpython-313.pyc +0 -0
  27. package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
  28. package/core/workflow/flow_enforcer.py +144 -3
  29. package/knowledge/commands-registry.json +1 -1
  30. package/package.json +2 -2
  31. package/pyproject.toml +1 -1
  32. package/knowledge/commands-registry.json.bak +0 -2791
@@ -0,0 +1,169 @@
1
+ """Sycophancy detector for ArkaOS responses (Conclave Phase 5, PR13 v2.35.0).
2
+
3
+ Implements the *arkaos-not-yes-man* NON-NEGOTIABLE rule from PR10 constitution.
4
+
5
+ The detector is a heuristic — it scans an assistant response for known
6
+ sycophancy signals (agreement-without-critique, pure-agreement-standalone,
7
+ missing reference-companies citation when a recommendation is made,
8
+ missing critic step). Returns a structured verdict the Stop hook records
9
+ to telemetry (warn-only mode in v2.35.0) before promotion to hard
10
+ enforcement in a later PR.
11
+
12
+ This is NOT an LLM-based judgement. It is regex / heuristic. False
13
+ positives are acceptable when they nudge the assistant toward more
14
+ pushback; false negatives are the failure mode we measure and improve.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from dataclasses import asdict, dataclass, field
21
+
22
+ # ─── Patterns ───────────────────────────────────────────────────────────
23
+
24
+ # Sycophantic openers — agreement WITHOUT immediate critique follow-up.
25
+ # These trigger inspection of the rest of the response.
26
+ _AGREEMENT_OPENERS: tuple[re.Pattern, ...] = tuple(
27
+ re.compile(p, re.IGNORECASE | re.MULTILINE) for p in [
28
+ r"^\s*tens\s+raz[aã]o\b",
29
+ r"^\s*est[aá]s?\s+certo\b",
30
+ r"^\s*you[' ]re\s+right\b",
31
+ r"^\s*you\s+are\s+correct\b",
32
+ r"^\s*absolutely(?:[.,!]|\s)",
33
+ r"^\s*boa\s+ideia\b",
34
+ r"^\s*great\s+idea\b",
35
+ r"^\s*excelente\s+ideia\b",
36
+ r"^\s*perfeito[.,!]",
37
+ r"^\s*claro\s*[.,!]",
38
+ r"^\s*ok,?\s+vou\s+fazer\b",
39
+ r"^\s*ok,?\s+fazendo\b",
40
+ r"^\s*ok,?\s+a\s+fazer\b",
41
+ r"^\s*ok,?\s+seguindo\b",
42
+ r"^\s*sure[,.\s]",
43
+ r"^\s*of\s+course[,.\s]",
44
+ r"^\s*understood[,.\s]",
45
+ r"^\s*entendido[,.\s]",
46
+ ]
47
+ )
48
+
49
+ # Critique connectors — if present after an agreement opener, the response
50
+ # isn't sycophantic (it's "yes, AND but here's the issue").
51
+ _CRITIQUE_CONNECTORS: tuple[re.Pattern, ...] = tuple(
52
+ re.compile(p, re.IGNORECASE) for p in [
53
+ r"\bmas\b", r"\bpor[eé]m\b", r"\bcontudo\b", r"\btodavia\b",
54
+ r"\bantes\s+de\s+(avan[cç]ar|fazer|continuar)\b",
55
+ r"\bbut\b", r"\bhowever\b", r"\balthough\b",
56
+ r"\bone\s+concern\b", r"\bone\s+issue\b", r"\bone\s+problem\b",
57
+ r"\bn[aã]o\s+vai\s+funcionar\b", r"\bnot\s+gonna\s+work\b",
58
+ r"\bbefore\s+(we|you|proceed|continue)\b",
59
+ r"\bantes\s+disso\b", r"\bo\s+problema\s+(é|aqui)\b",
60
+ r"\bthere[' ]s\s+a\s+problem\b",
61
+ r"\bissue\s+with\s+(this|your|the)\b",
62
+ r"\bestructur[ae]lly\s+(flawed|wrong)\b",
63
+ r"\bestruturalmente\s+(errad[oa]|com\s+falha)\b",
64
+ # Numerical / evidence cues
65
+ r"\b(?:Google|Stripe|SpaceX|Tesla|Anthropic|OpenAI|Linear|Notion|Vercel|Figma|Supabase|Apple|a16z|Stratechery)\b",
66
+ # Pushback verbs / phrases
67
+ r"\bdiscordo\b", r"\bdiscord[oa]\s+com\b",
68
+ r"\bI\s+disagree\b", r"\bdon[' ]t\s+agree\b",
69
+ # ArkaOS-internal tagged dissent or critic
70
+ r"\[arka:dissent\]", r"\[arka:critic\]",
71
+ ]
72
+ )
73
+
74
+ # Pure-agreement standalone — short response that's just agreement.
75
+ _PURE_AGREEMENT_SHORT_RE = re.compile(
76
+ r"^\s*(?:sim|yes|claro|perfeito|certo|ok|tudo\s+certo)[.!]?\s*$",
77
+ re.IGNORECASE,
78
+ )
79
+
80
+ # Recommendation markers — verbs/phrases that signal "here's what to do".
81
+ _RECOMMENDATION_RE = re.compile(
82
+ r"\b(?:propon?h?o|recomendo|sugiro|deves|devias|implementa|faz|usa|escolhe|"
83
+ r"recommend|suggest|propose|should|build|implement|pick|choose|use)\b",
84
+ re.IGNORECASE,
85
+ )
86
+
87
+ # Reference companies — required citation when recommendation is made.
88
+ _REFERENCE_COMPANIES = (
89
+ "Google", "Stripe", "SpaceX", "Tesla", "Anthropic", "OpenAI",
90
+ "Linear", "Vercel", "Supabase", "Notion", "Figma", "Apple",
91
+ "Raycast", "Arc", "Basecamp", "37signals", "a16z", "Stratechery",
92
+ "Damodaran", "ProfitWell", "Patrick Campbell",
93
+ )
94
+ _REFERENCE_RE = re.compile(
95
+ r"\b(?:" + "|".join(re.escape(c) for c in _REFERENCE_COMPANIES) + r")\b",
96
+ )
97
+
98
+ # Critic verdict marker — emitted by self-critic step.
99
+ _CRITIC_RE = re.compile(r"critic\s*=\s*(passed|failed|skipped)", re.IGNORECASE)
100
+
101
+
102
+ @dataclass
103
+ class SycophancyVerdict:
104
+ """Structured detector output."""
105
+
106
+ is_sycophantic: bool
107
+ signals: list[str] = field(default_factory=list)
108
+ confidence: float = 0.0
109
+ response_length: int = 0
110
+
111
+ def to_dict(self) -> dict:
112
+ return asdict(self)
113
+
114
+
115
+ def detect_sycophancy(text: str) -> SycophancyVerdict:
116
+ """Inspect *text* and return a SycophancyVerdict.
117
+
118
+ Confidence scale:
119
+ 0.0 — no signal at all (clean response)
120
+ 0.3 — weak signal (agreement opener but with later critique)
121
+ 0.6 — medium (recommendation without reference-company citation)
122
+ 0.9 — strong (agreement opener + no critique connectors anywhere)
123
+ 1.0 — pure-agreement standalone short reply to substantive input
124
+ """
125
+ text_clean = (text or "").strip()
126
+ if not text_clean:
127
+ return SycophancyVerdict(is_sycophantic=False)
128
+
129
+ signals: list[str] = []
130
+ confidence = 0.0
131
+
132
+ # Signal 1 — pure-agreement-standalone short reply.
133
+ if len(text_clean) <= 40 and _PURE_AGREEMENT_SHORT_RE.match(text_clean):
134
+ return SycophancyVerdict(
135
+ is_sycophantic=True,
136
+ signals=["pure-agreement-standalone"],
137
+ confidence=1.0,
138
+ response_length=len(text_clean),
139
+ )
140
+
141
+ # Signal 2 — agreement opener.
142
+ has_agreement_opener = any(p.search(text_clean) for p in _AGREEMENT_OPENERS)
143
+ if has_agreement_opener:
144
+ signals.append("agreement-opener")
145
+ # Look for critique connector elsewhere in the text.
146
+ has_critique = any(p.search(text_clean) for p in _CRITIQUE_CONNECTORS)
147
+ if not has_critique:
148
+ signals.append("missing-critique-connector")
149
+ confidence = max(confidence, 0.9)
150
+ else:
151
+ confidence = max(confidence, 0.3)
152
+
153
+ # Signal 3 — recommendation without reference-company citation.
154
+ if _RECOMMENDATION_RE.search(text_clean):
155
+ if not _REFERENCE_RE.search(text_clean):
156
+ signals.append("recommendation-without-reference-company")
157
+ confidence = max(confidence, 0.6)
158
+
159
+ # Signal 4 — missing critic verdict in substantive response (> 200 chars).
160
+ if len(text_clean) > 200 and not _CRITIC_RE.search(text_clean):
161
+ signals.append("missing-critic-verdict")
162
+ confidence = max(confidence, 0.4)
163
+
164
+ return SycophancyVerdict(
165
+ is_sycophantic=confidence >= 0.6,
166
+ signals=signals,
167
+ confidence=confidence,
168
+ response_length=len(text_clean),
169
+ )
@@ -0,0 +1,157 @@
1
+ """Inter-agent checkpoint primitives (PR15 v2.37.0).
2
+
3
+ Implements the `inter-agent-checkpoints` NON-NEGOTIABLE rule from PR10:
4
+ long-running multi-agent dispatches are fragmented into sub-dispatches
5
+ of 2-3min each, and the orchestrator emits a proactive checkpoint
6
+ prompt between each sub-dispatch announcing the next step and inviting
7
+ user context injection.
8
+
9
+ This module ships PRIMITIVES — message builders, an injection parser,
10
+ and a planner that splits a task list into checkpoint-sized chunks.
11
+ The actual orchestration (calling Agent tools in sequence, pausing
12
+ between them) is a pattern the orchestrator follows, not code this
13
+ module runs.
14
+
15
+ Conclave Phase 5 brainstorm (2026-05-13) decided:
16
+ * Live interruption capability — user can add context mid-task
17
+ * Proactive checkpoint prompts between sub-dispatches
18
+ * Sub-dispatch budget: 2-3 minutes each
19
+ * Reviewer conflicts: strategic -> escalate, technical -> Marta decides
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ from dataclasses import asdict, dataclass, field
26
+ from typing import Literal
27
+
28
+ # Recommended sub-dispatch duration window (seconds).
29
+ SUB_DISPATCH_MIN_S = 60
30
+ SUB_DISPATCH_TARGET_S = 180 # 3 min sweet spot
31
+ SUB_DISPATCH_MAX_S = 300 # 5 min hard ceiling
32
+
33
+ # Threshold above which a task MUST be checkpointed.
34
+ CHECKPOINT_TRIGGER_S = 30
35
+
36
+ # Recognised user-injection cues (added context, not new turn / abort).
37
+ _INJECTION_CUES: tuple[re.Pattern, ...] = tuple(
38
+ re.compile(p, re.IGNORECASE) for p in [
39
+ r"\b(adiciona|considera tamb[eé]m|esquecia(-me)?|antes de|t[eê]m que|ainda|t[aâ]mbem)\b",
40
+ r"\b(also|btw|by the way|forgot to mention|one more thing|consider also)\b",
41
+ r"^[+]\s", # leading + prefix common shorthand for "add"
42
+ r"^\s*FYI[:\s]",
43
+ ]
44
+ )
45
+
46
+ # Recognised abort / hard-stop cues (user wants to stop or redirect).
47
+ _ABORT_CUES: tuple[re.Pattern, ...] = tuple(
48
+ re.compile(p, re.IGNORECASE) for p in [
49
+ r"\b(stop|para|parar|abort|cancela|cancel|n[aã]o continues|wait)\b",
50
+ r"\b(redirect|muda|altera|change direction)\b",
51
+ ]
52
+ )
53
+
54
+ InjectionKind = Literal["new-turn", "context-injection", "abort"]
55
+
56
+
57
+ @dataclass
58
+ class CheckpointPlan:
59
+ """A fragmented multi-step work plan."""
60
+
61
+ task_name: str
62
+ sub_dispatches: list[str] = field(default_factory=list)
63
+ estimated_total_seconds: int = 0
64
+
65
+ def to_dict(self) -> dict:
66
+ return asdict(self)
67
+
68
+
69
+ @dataclass
70
+ class UserInjection:
71
+ """Parsed verdict on a mid-checkpoint user message."""
72
+
73
+ kind: InjectionKind
74
+ matched_cues: list[str] = field(default_factory=list)
75
+ raw_text: str = ""
76
+
77
+ def to_dict(self) -> dict:
78
+ return asdict(self)
79
+
80
+
81
+ def build_checkpoint_message(
82
+ next_dispatch_name: str,
83
+ estimated_seconds: int,
84
+ step: int,
85
+ total_steps: int,
86
+ context_carried_forward: str = "",
87
+ ) -> str:
88
+ """Return the canonical [arka:checkpoint] message string.
89
+
90
+ Format::
91
+
92
+ [arka:checkpoint] Step S/T: next dispatch "<name>" — ~Xs.
93
+ Tens contexto a acrescentar antes de eu arrancar? (Silêncio = procedo.)
94
+ """
95
+ s = max(1, int(step))
96
+ t = max(s, int(total_steps))
97
+ name = next_dispatch_name.strip() or "next sub-task"
98
+ seconds = max(0, int(estimated_seconds))
99
+ body = (
100
+ f"[arka:checkpoint] Step {s}/{t}: next dispatch \"{name}\" — ~{seconds}s.\n"
101
+ "Tens contexto a acrescentar antes de eu arrancar? (Silêncio = procedo.)"
102
+ )
103
+ if context_carried_forward:
104
+ body += f"\n (Carry-forward: {context_carried_forward.strip()})"
105
+ return body
106
+
107
+
108
+ def parse_user_injection(text: str) -> UserInjection:
109
+ """Classify a user message arriving between checkpoints.
110
+
111
+ Returns an :class:`UserInjection` with kind in:
112
+ * ``"abort"`` — user wants to stop or redirect
113
+ * ``"context-injection"`` — user adds context to the current plan
114
+ * ``"new-turn"`` — anything else (treat as fresh request)
115
+
116
+ Abort detection wins over injection (cue order: abort first).
117
+ """
118
+ stripped = (text or "").strip()
119
+ if not stripped:
120
+ return UserInjection(kind="new-turn", raw_text=stripped)
121
+
122
+ abort_hits = [p.pattern for p in _ABORT_CUES if p.search(stripped)]
123
+ if abort_hits:
124
+ return UserInjection(
125
+ kind="abort", matched_cues=abort_hits, raw_text=stripped
126
+ )
127
+
128
+ injection_hits = [p.pattern for p in _INJECTION_CUES if p.search(stripped)]
129
+ if injection_hits:
130
+ return UserInjection(
131
+ kind="context-injection",
132
+ matched_cues=injection_hits,
133
+ raw_text=stripped,
134
+ )
135
+ return UserInjection(kind="new-turn", raw_text=stripped)
136
+
137
+
138
+ def plan_fragmented_dispatches(
139
+ task_name: str, sub_tasks: list[str], per_task_seconds: int = SUB_DISPATCH_TARGET_S
140
+ ) -> CheckpointPlan:
141
+ """Build a CheckpointPlan from a list of sub-task names.
142
+
143
+ Each sub-task is one dispatch separated by a checkpoint prompt.
144
+ Caller is responsible for executing the sub-dispatches in order.
145
+ """
146
+ cleaned: list[str] = [s.strip() for s in sub_tasks if s and s.strip()]
147
+ per = max(SUB_DISPATCH_MIN_S, min(int(per_task_seconds), SUB_DISPATCH_MAX_S))
148
+ return CheckpointPlan(
149
+ task_name=task_name.strip() or "unnamed-task",
150
+ sub_dispatches=cleaned,
151
+ estimated_total_seconds=len(cleaned) * per,
152
+ )
153
+
154
+
155
+ def should_checkpoint(estimated_seconds: int) -> bool:
156
+ """Return True if the work is long enough to require a checkpoint."""
157
+ return int(estimated_seconds) > CHECKPOINT_TRIGGER_S
@@ -54,7 +54,135 @@ def _locked_append(path: Path):
54
54
  pass
55
55
  fh.close()
56
56
 
57
- GATED_TOOLS: frozenset[str] = frozenset({"Write", "Edit", "MultiEdit"})
57
+ # PR11 v2.33.0 — Discovery vs Effect tool taxonomy (Conclave Phase 5).
58
+ #
59
+ # DISCOVERY tools (no routing required): Read, Grep, Glob, ToolSearch,
60
+ # the various read-only MCP tools (Obsidian search, claude-mem search,
61
+ # Context7 query), AskUserQuestion. These never mutate user state.
62
+ #
63
+ # EFFECT tools (routing required): tools below produce visible state
64
+ # changes — write to filesystem, dispatch agents, invoke skills,
65
+ # mutate the notebook. Each requires a flow marker
66
+ # ([arka:routing] or [arka:trivial]) in the recent assistant messages.
67
+ #
68
+ # Bash is special: command-by-command classification via bash_is_effect().
69
+ # Pure read commands (cat, ls, grep, git status, etc.) are DISCOVERY.
70
+ # Mutating commands (rm, mv, git commit/push, npm install, etc.) are
71
+ # EFFECT. Unknown commands default to EFFECT (safer).
72
+
73
+ EFFECT_TOOLS_ALWAYS: frozenset[str] = frozenset({
74
+ "Write", "Edit", "MultiEdit", "NotebookEdit",
75
+ "Task", "Skill", # Agent dispatch + skill invocation cascade to effect
76
+ })
77
+
78
+ # Backwards-compatible alias for callers that import GATED_TOOLS.
79
+ GATED_TOOLS: frozenset[str] = EFFECT_TOOLS_ALWAYS
80
+
81
+ # Bash classifier — whitelist of safe DISCOVERY first-tokens.
82
+ _BASH_DISCOVERY_FIRST: frozenset[str] = frozenset({
83
+ # File reading
84
+ "cat", "head", "tail", "less", "more", "tee",
85
+ # Directory + search
86
+ "ls", "find", "locate", "tree", "stat", "file",
87
+ # Text search / processing (read-only)
88
+ "grep", "ag", "rg", "wc", "sort", "uniq", "tr", "cut", "awk", "fmt",
89
+ # System info
90
+ "pwd", "whoami", "id", "hostname", "uname", "date", "df", "du",
91
+ "free", "uptime", "echo", "printf", "true", "false",
92
+ # Process info
93
+ "ps", "pgrep", "jobs",
94
+ # Tool version queries (no side-effect)
95
+ "which", "type", "command", "where",
96
+ # Common toolchain read-only entry points (subcommand checked below)
97
+ "git", "npm", "yarn", "pnpm", "pip", "pip3", "uv", "poetry",
98
+ "brew", "apt", "snap", "winget", "choco", "ollama", "docker",
99
+ "python", "python3", "node", "ruby", "php", "go", "rustc",
100
+ "curl", "wget", # default to read; mutation via flags below
101
+ # Shell builtins / control
102
+ "test", "[", "if", "while", "for", "case", "function", "return",
103
+ "exit", "source", ".", "set", "export", "alias", "unalias",
104
+ "shopt", "trap", "wait", "eval", "exec",
105
+ # Test runners (read state but don't mutate canonical files)
106
+ "pytest", "jest", "vitest", "phpunit", "pest", "rspec", "mocha",
107
+ "go", "cargo",
108
+ })
109
+
110
+ # Bash classifier — patterns that indicate mutation (anywhere in command).
111
+ _BASH_EFFECT_PATTERNS: tuple[re.Pattern, ...] = tuple(
112
+ re.compile(p) for p in [
113
+ # File-system mutation
114
+ r"(^|[\s|;&])rm\s", r"(^|[\s|;&])mv\s", r"(^|[\s|;&])cp\s+-[rRf]",
115
+ r"(^|[\s|;&])dd\s", r"(^|[\s|;&])truncate\s",
116
+ r"(^|[\s|;&])touch\s", r"(^|[\s|;&])mkdir\s", r"(^|[\s|;&])rmdir\s",
117
+ r"(^|[\s|;&])ln\s+-s", r"(^|[\s|;&])chmod\s", r"(^|[\s|;&])chown\s",
118
+ # In-place edit
119
+ r"sed\s+-i", r"perl\s+-i", r"awk\s+-i\s",
120
+ # Process control
121
+ r"(^|[\s|;&])kill\s", r"(^|[\s|;&])killall\s", r"(^|[\s|;&])pkill\s",
122
+ # Elevation
123
+ r"(^|[\s|;&])sudo\s", r"(^|[\s|;&])su\s+-",
124
+ # Git mutation
125
+ r"git\s+(commit|push|merge|rebase|reset\s+--hard|checkout\s+-[Bb]|tag\s|stash|cherry-pick|revert|branch\s+-[dD])",
126
+ # Package mutation
127
+ r"npm\s+(install|i|publish|uninstall|update|run\s+publish)",
128
+ r"yarn\s+(add|remove|install|publish|upgrade)",
129
+ r"pnpm\s+(add|remove|install|publish|update)",
130
+ r"pip3?\s+install", r"pip3?\s+uninstall",
131
+ r"uv\s+pip\s+install", r"poetry\s+(add|remove|install|publish)",
132
+ r"brew\s+(install|uninstall|upgrade|cleanup)",
133
+ r"apt(-get)?\s+(install|remove|purge|upgrade)",
134
+ r"snap\s+(install|remove|refresh)",
135
+ r"winget\s+(install|uninstall|upgrade)",
136
+ r"choco\s+(install|uninstall|upgrade)",
137
+ # GitHub mutation
138
+ r"gh\s+(pr\s+create|release\s+create|issue\s+create|repo\s+create|secret\s+set)",
139
+ r"gh\s+pr\s+merge", r"gh\s+pr\s+close", r"gh\s+repo\s+delete",
140
+ # Docker mutation
141
+ r"docker\s+(build|push|run|create|rm|kill|stop|start|restart|exec)",
142
+ # Network transfer (mutates remote)
143
+ r"(^|[\s|;&])scp\s",
144
+ # rsync is intentionally not in the blacklist nor in the discovery
145
+ # whitelist — default-deny path classifies it as EFFECT. Users
146
+ # who genuinely need rsync (including --dry-run) emit a routing
147
+ # marker; safer than guessing intent from flags.
148
+ # Redirects to file (overwrite or append)
149
+ r">\s*[^&\s]", r">>\s*[^&\s]",
150
+ ]
151
+ )
152
+
153
+
154
+ def bash_is_effect(command: str) -> bool:
155
+ """Classify a Bash command as EFFECT (requires routing) or DISCOVERY (free).
156
+
157
+ Algorithm (default-deny for unknowns):
158
+ 1. Empty command → False (no effect).
159
+ 2. Any blacklist pattern matches anywhere in the command → True.
160
+ 3. First non-pipe token is in the discovery whitelist → False.
161
+ 4. Otherwise → True (unknown commands default to requiring routing).
162
+
163
+ Pipes and command chaining are scanned as a whole — if any segment
164
+ has an effect verb, the entire chain is classified EFFECT.
165
+ """
166
+ if not command or not command.strip():
167
+ return False
168
+ stripped = command.strip()
169
+ for pattern in _BASH_EFFECT_PATTERNS:
170
+ if pattern.search(stripped):
171
+ return True
172
+ first_tokens = stripped.split(None, 1)
173
+ if not first_tokens:
174
+ return False
175
+ first = first_tokens[0]
176
+ # Strip leading env-var assignments like FOO=bar baz qux
177
+ while "=" in first and first_tokens:
178
+ first_tokens = first_tokens[1].split(None, 1) if len(first_tokens) > 1 else []
179
+ first = first_tokens[0] if first_tokens else ""
180
+ if not first:
181
+ return False
182
+ if first in _BASH_DISCOVERY_FIRST:
183
+ return False
184
+ # Unknown command — default to requiring routing.
185
+ return True
58
186
 
59
187
  ROUTING_RE = re.compile(r"\[arka:routing\]\s*[\w-]+\s*->\s*\w+", re.IGNORECASE)
60
188
  TRIVIAL_RE = re.compile(r"\[arka:trivial\]\s*\S+", re.IGNORECASE)
@@ -88,7 +216,9 @@ class Decision:
88
216
  return (
89
217
  f"[ARKA:ENFORCEMENT] Flow marker missing. "
90
218
  f"Emit `[arka:routing] <dept> -> <lead>` or `[arka:trivial] <reason>` "
91
- f"before any `Write`/`Edit`/`MultiEdit`. Reason: {self.reason}"
219
+ f"before any tool that mutates state "
220
+ f"(Write/Edit/MultiEdit/NotebookEdit/Task/Skill, or Bash with effect commands like rm/mv/git commit/npm install). "
221
+ f"Reason: {self.reason}"
92
222
  )
93
223
 
94
224
 
@@ -209,13 +339,24 @@ def evaluate(
209
339
  transcript_path: str,
210
340
  session_id: str = "",
211
341
  cwd: str = "",
342
+ tool_input: dict | None = None,
212
343
  ) -> Decision:
213
344
  """Decide whether a tool call may proceed.
214
345
 
215
346
  Returns a Decision. Caller is responsible for translating `allow=False`
216
347
  into the appropriate hook exit code or permissionDecision output.
348
+
349
+ PR11 v2.33.0 expanded the gated set beyond Write/Edit/MultiEdit to
350
+ cover all EFFECT tools (NotebookEdit, Task, Skill) and to classify
351
+ Bash commands per-command via ``bash_is_effect``.
217
352
  """
218
- if tool_name not in GATED_TOOLS:
353
+ is_gated = tool_name in EFFECT_TOOLS_ALWAYS
354
+ if not is_gated and tool_name == "Bash":
355
+ bash_cmd = ""
356
+ if tool_input and isinstance(tool_input, dict):
357
+ bash_cmd = str(tool_input.get("command", ""))
358
+ is_gated = bash_is_effect(bash_cmd)
359
+ if not is_gated:
219
360
  return Decision(allow=True, reason="tool-not-gated")
220
361
 
221
362
  if not _feature_flag_on():
@@ -916,7 +916,7 @@
916
916
  "id": "dev-scaffold-laravel",
917
917
  "command": "/dev scaffold laravel <name>",
918
918
  "department": "dev",
919
- "description": "`git@andreagroferreira:andreagroferreira/laravel-starter-kit.git`",
919
+ "description": "Scaffold a Laravel project from the configured starter-kit template.",
920
920
  "lead_agent": "dev-scaffold",
921
921
  "tier": 2,
922
922
  "requires_branch": false,
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "2.31.0",
3
+ "version": "2.40.0",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
7
- "arkaos": "./installer/cli.js"
7
+ "arkaos": "installer/cli.js"
8
8
  },
9
9
  "scripts": {
10
10
  "test": "node --test \"tests/installer/**/*.test.js\"",
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arkaos-core"
3
- version = "2.31.0"
3
+ version = "2.40.0"
4
4
  description = "Core engine for ArkaOS — The Operating System for AI Agent Teams"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}