arkaos 2.20.0 → 2.20.1

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/VERSION CHANGED
@@ -1 +1 @@
1
- 2.20.0
1
+ 2.20.1
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env bash
2
+ # ============================================================================
3
+ # ArkaOS v2 — Shared Workflow Classifier
4
+ #
5
+ # Decides whether a user prompt triggers the mandatory 13-phase flow.
6
+ # Used by: user-prompt-submit.sh, pre-tool-use.sh, stop.sh.
7
+ #
8
+ # Contract:
9
+ # arka_wf_classify "<prompt text>" → echoes "true" or "false", exits 0.
10
+ # arka_wf_mark_required "<session_id>" → writes marker file.
11
+ # arka_wf_is_required "<session_id>" → exits 0 if required, 1 otherwise.
12
+ # arka_wf_clear_required "<session_id>" → removes marker file.
13
+ #
14
+ # Markers live under /tmp/arkaos-wf-required/<session_id>.
15
+ # Python path for mark/clear: delegates to flow_enforcer.py when available,
16
+ # otherwise falls back to touching the marker file directly.
17
+ # ============================================================================
18
+
19
+ ARKA_WF_REQUIRED_DIR="${ARKA_WF_REQUIRED_DIR:-/tmp/arkaos-wf-required}"
20
+
21
+ # Reject any session_id outside [A-Za-z0-9._-]{1,128}. Protects the marker
22
+ # directory from path-traversal writes (CWE-22). Must stay in sync with
23
+ # core/workflow/flow_enforcer.py::_safe_session_id.
24
+ arka_wf_safe_session_id() {
25
+ local session_id="${1:-}"
26
+ [ -z "$session_id" ] && return 1
27
+ [ ${#session_id} -gt 128 ] && return 1
28
+ case "$session_id" in
29
+ *[!A-Za-z0-9._-]*) return 1 ;;
30
+ esac
31
+ return 0
32
+ }
33
+
34
+ # Verb + noun patterns shared with the original inline classifier in
35
+ # user-prompt-submit.sh. Keep in sync when adding new intent verbs.
36
+ ARKA_WF_VERB_PATTERN='(criar?|crie[ms]?|cria[mr]?|adicionar?|adiciona[mr]?|implementar?|implementa[mr]?|desenvolver?|desenvolve[mr]?|construir?|constru[ií]a?[mr]?|fazer?|faz[ae][mr]?|refactor(izar?)?|corrigir?|corrige[mr]?|consertar?|conserta[mr]?|create[sd]?|creating|build(s|ing)?|add(s|ed|ing)?|implement(s|ed|ing)?|develop(s|ed|ing)?|fix(es|ed|ing)?|refactor(s|ed|ing)?|make[sd]?|making)'
37
+
38
+ # Classify: returns "true" if the prompt looks like a creation/
39
+ # implementation/modification request, "false" otherwise.
40
+ # Skips: explicit slash commands (already routed) and bang shells.
41
+ arka_wf_classify() {
42
+ local text="${1:-}"
43
+ [ -z "$text" ] && { echo "false"; return 0; }
44
+
45
+ local first_char
46
+ first_char=$(printf '%s' "$text" | head -c 1)
47
+ if [ "$first_char" = "/" ] || [ "$first_char" = "!" ]; then
48
+ echo "false"
49
+ return 0
50
+ fi
51
+
52
+ if echo "$text" | grep -qiE "\b${ARKA_WF_VERB_PATTERN}\b"; then
53
+ echo "true"
54
+ else
55
+ echo "false"
56
+ fi
57
+ }
58
+
59
+ # Mark that the flow is required for this session. Safe no-op if session_id
60
+ # is empty or fails the allowlist check.
61
+ arka_wf_mark_required() {
62
+ local session_id="${1:-}"
63
+ arka_wf_safe_session_id "$session_id" || return 0
64
+ mkdir -p "$ARKA_WF_REQUIRED_DIR" 2>/dev/null
65
+ date -u +"%Y-%m-%dT%H:%M:%SZ" > "$ARKA_WF_REQUIRED_DIR/$session_id" 2>/dev/null
66
+ }
67
+
68
+ # Test whether flow is required. Exit code 0 = required, 1 = not required.
69
+ arka_wf_is_required() {
70
+ local session_id="${1:-}"
71
+ arka_wf_safe_session_id "$session_id" || return 1
72
+ [ -f "$ARKA_WF_REQUIRED_DIR/$session_id" ]
73
+ }
74
+
75
+ # Clear the requirement marker. Safe no-op if absent or unsafe.
76
+ arka_wf_clear_required() {
77
+ local session_id="${1:-}"
78
+ arka_wf_safe_session_id "$session_id" || return 0
79
+ rm -f "$ARKA_WF_REQUIRED_DIR/$session_id" 2>/dev/null
80
+ return 0
81
+ }
82
+
83
+ # When invoked directly (not sourced), expose a simple CLI for ad-hoc use.
84
+ if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
85
+ case "${1:-}" in
86
+ classify) arka_wf_classify "${2:-}" ;;
87
+ mark) arka_wf_mark_required "${2:-}" ;;
88
+ is-required) arka_wf_is_required "${2:-}" && echo "true" || echo "false" ;;
89
+ clear) arka_wf_clear_required "${2:-}" ;;
90
+ *)
91
+ echo "Usage: $0 {classify <text>|mark <session_id>|is-required <session_id>|clear <session_id>}" >&2
92
+ exit 64
93
+ ;;
94
+ esac
95
+ fi
@@ -0,0 +1,117 @@
1
+ # ============================================================================
2
+ # ArkaOS v2 — PreToolUse Hook (Windows PowerShell)
3
+ #
4
+ # Parity with config/hooks/pre-tool-use.sh. Blocks Write/Edit/MultiEdit when
5
+ # the mandatory 13-phase flow is required for the session AND the assistant
6
+ # has not emitted a flow marker in its last 3 messages of the transcript.
7
+ #
8
+ # Delegates the decision to core/workflow/flow_enforcer.py.
9
+ #
10
+ # Exit 0 = allow (silent). Exit 2 = deny + structured hookSpecificOutput JSON.
11
+ # ============================================================================
12
+
13
+ $ErrorActionPreference = "SilentlyContinue"
14
+
15
+ # --- Read stdin JSON ---
16
+ $inputJson = [Console]::In.ReadToEnd()
17
+ if ([string]::IsNullOrWhiteSpace($inputJson)) { exit 0 }
18
+
19
+ try {
20
+ $inp = $inputJson | ConvertFrom-Json
21
+ } catch {
22
+ exit 0
23
+ }
24
+
25
+ $toolName = [string]$inp.tool_name
26
+ $transcriptPath = [string]$inp.transcript_path
27
+ $sessionId = [string]$inp.session_id
28
+ $cwd = [string]$inp.cwd
29
+
30
+ # --- Fast allow: not a gated tool ---
31
+ if ($toolName -ne "Write" -and $toolName -ne "Edit" -and $toolName -ne "MultiEdit") {
32
+ exit 0
33
+ }
34
+
35
+ # --- Resolve ARKAOS_ROOT ---
36
+ if ([string]::IsNullOrWhiteSpace($env:ARKAOS_ROOT)) {
37
+ $repoPathFile = Join-Path $HOME ".arkaos/.repo-path"
38
+ if (Test-Path $repoPathFile) {
39
+ $env:ARKAOS_ROOT = (Get-Content $repoPathFile -Raw).Trim()
40
+ } elseif (Test-Path (Join-Path $HOME ".arkaos")) {
41
+ $env:ARKAOS_ROOT = (Join-Path $HOME ".arkaos")
42
+ } else {
43
+ $env:ARKAOS_ROOT = if ($env:ARKA_OS) { $env:ARKA_OS } else { Join-Path $HOME ".claude/skills/arkaos" }
44
+ }
45
+ }
46
+
47
+ $enforcerPy = Join-Path $env:ARKAOS_ROOT "core/workflow/flow_enforcer.py"
48
+ if (-not (Test-Path $enforcerPy)) { exit 0 }
49
+
50
+ $python = Get-Command python3 -ErrorAction SilentlyContinue
51
+ if (-not $python) { $python = Get-Command python -ErrorAction SilentlyContinue }
52
+ if (-not $python) { exit 0 }
53
+
54
+ # --- Delegate to Python enforcer ---
55
+ $env:TOOL_NAME = $toolName
56
+ $env:TRANSCRIPT_PATH = $transcriptPath
57
+ $env:SESSION_ID = $sessionId
58
+ $env:CWD = $cwd
59
+
60
+ $pyScript = @'
61
+ import json
62
+ import os
63
+ import sys
64
+
65
+ sys.path.insert(0, os.environ["ARKAOS_ROOT"])
66
+ try:
67
+ from core.workflow.flow_enforcer import evaluate, record_telemetry
68
+ except Exception:
69
+ print(json.dumps({"allow": True, "reason": "enforcer-import-failed"}))
70
+ sys.exit(0)
71
+
72
+ decision = evaluate(
73
+ tool_name=os.environ.get("TOOL_NAME", ""),
74
+ transcript_path=os.environ.get("TRANSCRIPT_PATH", ""),
75
+ session_id=os.environ.get("SESSION_ID", ""),
76
+ cwd=os.environ.get("CWD", ""),
77
+ )
78
+ try:
79
+ record_telemetry(
80
+ session_id=os.environ.get("SESSION_ID", ""),
81
+ tool=os.environ.get("TOOL_NAME", ""),
82
+ decision=decision,
83
+ cwd=os.environ.get("CWD", ""),
84
+ )
85
+ except Exception:
86
+ pass
87
+ print(json.dumps({
88
+ "allow": decision.allow,
89
+ "reason": decision.reason,
90
+ "stderr_msg": decision.to_stderr_message(),
91
+ }))
92
+ '@
93
+
94
+ $decisionJson = $pyScript | & $python.Source -
95
+ if ([string]::IsNullOrWhiteSpace($decisionJson)) { exit 0 }
96
+
97
+ try {
98
+ $decision = $decisionJson | ConvertFrom-Json
99
+ } catch {
100
+ exit 0
101
+ }
102
+
103
+ if ($decision.allow) { exit 0 }
104
+
105
+ # --- Deny path ---
106
+ [Console]::Error.WriteLine($decision.stderr_msg)
107
+
108
+ $denyOut = @{
109
+ hookSpecificOutput = @{
110
+ hookEventName = "PreToolUse"
111
+ permissionDecision = "deny"
112
+ permissionDecisionReason = $decision.stderr_msg
113
+ }
114
+ } | ConvertTo-Json -Compress -Depth 5
115
+
116
+ Write-Output $denyOut
117
+ exit 2
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env bash
2
+ # ============================================================================
3
+ # ArkaOS v2 — PreToolUse Hook (Flow Enforcement Gate)
4
+ #
5
+ # Blocks Write/Edit/MultiEdit when the mandatory 13-phase flow is required
6
+ # for the session AND the assistant has not emitted a flow marker
7
+ # (`[arka:routing]`, `[arka:trivial]`, or `[arka:phase:`) in its last
8
+ # 3 messages of the transcript.
9
+ #
10
+ # Delegates the decision to core/workflow/flow_enforcer.py (single source
11
+ # of truth, pytest-covered). This shell script is a thin wrapper — anti
12
+ # pattern `duplicated-security-logic` compliance.
13
+ #
14
+ # Timeout: 10s.
15
+ # Allow semantics: no stdout, exit 0.
16
+ # Deny semantics: hookSpecificOutput.permissionDecision=deny JSON on stdout,
17
+ # `[ARKA:ENFORCEMENT] ...` on stderr, exit 2.
18
+ # ============================================================================
19
+
20
+ input=$(cat)
21
+
22
+ # ─── Extract fields (docs: session_id, transcript_path, cwd, tool_name)
23
+ TOOL_NAME=""
24
+ TRANSCRIPT_PATH=""
25
+ SESSION_ID=""
26
+ CWD=""
27
+ if command -v jq &>/dev/null; then
28
+ TOOL_NAME=$(echo "$input" | jq -r '.tool_name // ""' 2>/dev/null)
29
+ TRANSCRIPT_PATH=$(echo "$input" | jq -r '.transcript_path // ""' 2>/dev/null)
30
+ SESSION_ID=$(echo "$input" | jq -r '.session_id // ""' 2>/dev/null)
31
+ CWD=$(echo "$input" | jq -r '.cwd // ""' 2>/dev/null)
32
+ fi
33
+
34
+ # ─── Fast allow: not a gated tool
35
+ case "$TOOL_NAME" in
36
+ Write|Edit|MultiEdit) ;;
37
+ *) exit 0 ;;
38
+ esac
39
+
40
+ # ─── Resolve ARKAOS_ROOT (same rules as user-prompt-submit.sh) ──────────
41
+ if [ -z "${ARKAOS_ROOT:-}" ]; then
42
+ if [ -f "$HOME/.arkaos/.repo-path" ]; then
43
+ ARKAOS_ROOT=$(cat "$HOME/.arkaos/.repo-path")
44
+ elif [ -d "$HOME/.arkaos" ]; then
45
+ ARKAOS_ROOT="$HOME/.arkaos"
46
+ else
47
+ ARKAOS_ROOT="${ARKA_OS:-$HOME/.claude/skills/arkaos}"
48
+ fi
49
+ fi
50
+
51
+ # ─── Degrade gracefully if Python or module is unavailable ──────────────
52
+ if ! command -v python3 &>/dev/null; then
53
+ exit 0
54
+ fi
55
+ if [ ! -f "$ARKAOS_ROOT/core/workflow/flow_enforcer.py" ]; then
56
+ exit 0
57
+ fi
58
+
59
+ # ─── Delegate to Python enforcer ────────────────────────────────────────
60
+ DECISION_JSON=$(TOOL_NAME="$TOOL_NAME" \
61
+ TRANSCRIPT_PATH="$TRANSCRIPT_PATH" \
62
+ SESSION_ID="$SESSION_ID" \
63
+ CWD="$CWD" \
64
+ ARKAOS_ROOT="$ARKAOS_ROOT" \
65
+ python3 - <<'PY' 2>/dev/null
66
+ import json
67
+ import os
68
+ import sys
69
+
70
+ sys.path.insert(0, os.environ["ARKAOS_ROOT"])
71
+ try:
72
+ from core.workflow.flow_enforcer import evaluate, record_telemetry
73
+ except Exception:
74
+ print(json.dumps({"allow": True, "reason": "enforcer-import-failed"}))
75
+ sys.exit(0)
76
+
77
+ decision = evaluate(
78
+ tool_name=os.environ.get("TOOL_NAME", ""),
79
+ transcript_path=os.environ.get("TRANSCRIPT_PATH", ""),
80
+ session_id=os.environ.get("SESSION_ID", ""),
81
+ cwd=os.environ.get("CWD", ""),
82
+ )
83
+ try:
84
+ record_telemetry(
85
+ session_id=os.environ.get("SESSION_ID", ""),
86
+ tool=os.environ.get("TOOL_NAME", ""),
87
+ decision=decision,
88
+ cwd=os.environ.get("CWD", ""),
89
+ )
90
+ except Exception:
91
+ pass
92
+ print(json.dumps({
93
+ "allow": decision.allow,
94
+ "reason": decision.reason,
95
+ "stderr_msg": decision.to_stderr_message(),
96
+ }))
97
+ PY
98
+ )
99
+
100
+ if [ -z "$DECISION_JSON" ]; then
101
+ exit 0
102
+ fi
103
+
104
+ ALLOW=$(echo "$DECISION_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin).get('allow', True))" 2>/dev/null)
105
+
106
+ if [ "$ALLOW" = "True" ] || [ "$ALLOW" = "true" ]; then
107
+ exit 0
108
+ fi
109
+
110
+ # ─── Deny path: structured hookSpecificOutput + stderr fallback ─────────
111
+ REASON=$(echo "$DECISION_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin).get('reason',''))" 2>/dev/null)
112
+ STDERR_MSG=$(echo "$DECISION_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin).get('stderr_msg',''))" 2>/dev/null)
113
+
114
+ # Emit stderr (visible to the model per Claude Code hook spec) ─────────
115
+ echo "$STDERR_MSG" >&2
116
+
117
+ # Emit structured deny JSON (preferred path when runtime understands it).
118
+ # STDERR_MSG is passed via env var and read inside a single-quoted heredoc
119
+ # so no shell interpolation occurs inside the Python source — this closes
120
+ # the command-injection surface flagged by Francisca's tech review.
121
+ STDERR_MSG="$STDERR_MSG" python3 - <<'PY'
122
+ import json
123
+ import os
124
+
125
+ out = {
126
+ "hookSpecificOutput": {
127
+ "hookEventName": "PreToolUse",
128
+ "permissionDecision": "deny",
129
+ "permissionDecisionReason": os.environ.get("STDERR_MSG", ""),
130
+ }
131
+ }
132
+ print(json.dumps(out))
133
+ PY
134
+
135
+ exit 2
@@ -0,0 +1,112 @@
1
+ # ============================================================================
2
+ # ArkaOS v2 — Stop Hook (Windows PowerShell, WARN mode v1)
3
+ #
4
+ # Parity with config/hooks/stop.sh. Observes whether a flow-required session
5
+ # closed with [arka:phase:13] or [arka:trivial]. Logs to telemetry; never
6
+ # blocks in v1.
7
+ # ============================================================================
8
+
9
+ $ErrorActionPreference = "SilentlyContinue"
10
+
11
+ $inputJson = [Console]::In.ReadToEnd()
12
+ if ([string]::IsNullOrWhiteSpace($inputJson)) { exit 0 }
13
+
14
+ try {
15
+ $inp = $inputJson | ConvertFrom-Json
16
+ } catch {
17
+ exit 0
18
+ }
19
+
20
+ $sessionId = [string]$inp.session_id
21
+ $transcriptPath = [string]$inp.transcript_path
22
+ $stopHookActive = [string]$inp.stop_hook_active
23
+ $cwd = [string]$inp.cwd
24
+
25
+ if ($stopHookActive -eq "true") { exit 0 }
26
+
27
+ $wfMarker = Join-Path "/tmp/arkaos-wf-required" $sessionId
28
+ if ([string]::IsNullOrWhiteSpace($sessionId) -or -not (Test-Path $wfMarker)) {
29
+ exit 0
30
+ }
31
+
32
+ if ([string]::IsNullOrWhiteSpace($env:ARKAOS_ROOT)) {
33
+ $repoPathFile = Join-Path $HOME ".arkaos/.repo-path"
34
+ if (Test-Path $repoPathFile) {
35
+ $env:ARKAOS_ROOT = (Get-Content $repoPathFile -Raw).Trim()
36
+ } elseif (Test-Path (Join-Path $HOME ".arkaos")) {
37
+ $env:ARKAOS_ROOT = (Join-Path $HOME ".arkaos")
38
+ } else {
39
+ $env:ARKAOS_ROOT = if ($env:ARKA_OS) { $env:ARKA_OS } else { Join-Path $HOME ".claude/skills/arkaos" }
40
+ }
41
+ }
42
+
43
+ $enforcerPy = Join-Path $env:ARKAOS_ROOT "core/workflow/flow_enforcer.py"
44
+ if (-not (Test-Path $enforcerPy)) { exit 0 }
45
+
46
+ $python = Get-Command python3 -ErrorAction SilentlyContinue
47
+ if (-not $python) { $python = Get-Command python -ErrorAction SilentlyContinue }
48
+ if (-not $python) { exit 0 }
49
+
50
+ $env:SESSION_ID_VAL = $sessionId
51
+ $env:TRANSCRIPT_PATH_VAL = $transcriptPath
52
+ $env:CWD_VAL = $cwd
53
+
54
+ $pyScript = @'
55
+ import json
56
+ import os
57
+ import re
58
+ import sys
59
+ from datetime import datetime, timezone
60
+
61
+ sys.path.insert(0, os.environ["ARKAOS_ROOT"])
62
+ try:
63
+ from core.workflow.flow_enforcer import (
64
+ _load_last_assistant_messages,
65
+ TELEMETRY_PATH,
66
+ clear_flow_required,
67
+ )
68
+ except Exception:
69
+ sys.exit(0)
70
+
71
+ session_id = os.environ.get("SESSION_ID_VAL", "")
72
+ transcript_path = os.environ.get("TRANSCRIPT_PATH_VAL", "")
73
+ cwd = os.environ.get("CWD_VAL", "")
74
+
75
+ messages = _load_last_assistant_messages(transcript_path, n=1)
76
+ last = messages[-1] if messages else ""
77
+
78
+ phase13 = bool(re.search(r"\[arka:phase:13\]", last, re.IGNORECASE))
79
+ trivial = bool(re.search(r"\[arka:trivial\]", last, re.IGNORECASE))
80
+
81
+ entry = {
82
+ "ts": datetime.now(timezone.utc).isoformat(),
83
+ "session_id": session_id,
84
+ "cwd": cwd,
85
+ "event": "stop-hook-flow-check",
86
+ "closing_marker_found": phase13 or trivial,
87
+ "phase13": phase13,
88
+ "trivial": trivial,
89
+ "mode": "warn",
90
+ }
91
+
92
+ try:
93
+ TELEMETRY_PATH.parent.mkdir(parents=True, exist_ok=True)
94
+ with TELEMETRY_PATH.open("a", encoding="utf-8") as fh:
95
+ fh.write(json.dumps(entry) + "\n")
96
+ except Exception:
97
+ pass
98
+
99
+ try:
100
+ clear_flow_required(session_id)
101
+ except Exception:
102
+ pass
103
+ '@
104
+
105
+ $pyScript | & $python.Source - | Out-Null
106
+
107
+ # Belt-and-braces marker cleanup (safe even if the Python block crashed).
108
+ if ($sessionId -match '^[A-Za-z0-9._-]{1,128}$') {
109
+ Remove-Item -LiteralPath $wfMarker -ErrorAction SilentlyContinue
110
+ }
111
+
112
+ exit 0
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env bash
2
+ # ============================================================================
3
+ # ArkaOS v2 — Stop Hook (Flow Completion Validator, WARN mode v1)
4
+ #
5
+ # When the classifier marked the session as flow-required, this hook checks
6
+ # whether the final assistant message contains [arka:phase:13] or
7
+ # [arka:trivial]. If absent, a structured warning is appended to
8
+ # ~/.arkaos/telemetry/enforcement.jsonl. The hook NEVER blocks in v1.
9
+ #
10
+ # Promotion to strict mode is planned for v2.21.0 after ≥ 2 weeks of clean
11
+ # telemetry. Until then, this hook is observation only.
12
+ #
13
+ # Timeout: 5s | Always exit 0.
14
+ # ============================================================================
15
+
16
+ input=$(cat)
17
+
18
+ SESSION_ID=""
19
+ TRANSCRIPT_PATH=""
20
+ STOP_HOOK_ACTIVE=""
21
+ CWD=""
22
+ if command -v jq &>/dev/null; then
23
+ SESSION_ID=$(echo "$input" | jq -r '.session_id // ""' 2>/dev/null)
24
+ TRANSCRIPT_PATH=$(echo "$input" | jq -r '.transcript_path // ""' 2>/dev/null)
25
+ STOP_HOOK_ACTIVE=$(echo "$input" | jq -r '.stop_hook_active // ""' 2>/dev/null)
26
+ CWD=$(echo "$input" | jq -r '.cwd // ""' 2>/dev/null)
27
+ fi
28
+
29
+ # Prevent infinite loops when Stop hook was triggered by its own decision.
30
+ if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
31
+ exit 0
32
+ fi
33
+
34
+ # Only evaluate sessions where the classifier flagged a creation intent.
35
+ WF_MARKER="/tmp/arkaos-wf-required/$SESSION_ID"
36
+ if [ -z "$SESSION_ID" ] || [ ! -f "$WF_MARKER" ]; then
37
+ exit 0
38
+ fi
39
+
40
+ # Resolve ARKAOS_ROOT
41
+ if [ -z "${ARKAOS_ROOT:-}" ]; then
42
+ if [ -f "$HOME/.arkaos/.repo-path" ]; then
43
+ ARKAOS_ROOT=$(cat "$HOME/.arkaos/.repo-path")
44
+ elif [ -d "$HOME/.arkaos" ]; then
45
+ ARKAOS_ROOT="$HOME/.arkaos"
46
+ else
47
+ ARKAOS_ROOT="${ARKA_OS:-$HOME/.claude/skills/arkaos}"
48
+ fi
49
+ fi
50
+
51
+ if ! command -v python3 &>/dev/null; then
52
+ exit 0
53
+ fi
54
+ if [ ! -f "$ARKAOS_ROOT/core/workflow/flow_enforcer.py" ]; then
55
+ exit 0
56
+ fi
57
+
58
+ # Reuse the last-messages reader to check for a closing phase marker.
59
+ SESSION_ID_VAL="$SESSION_ID" \
60
+ TRANSCRIPT_PATH_VAL="$TRANSCRIPT_PATH" \
61
+ CWD_VAL="$CWD" \
62
+ ARKAOS_ROOT_VAL="$ARKAOS_ROOT" \
63
+ python3 - <<'PY' 2>/dev/null
64
+ import json
65
+ import os
66
+ import re
67
+ import sys
68
+ from datetime import datetime, timezone
69
+ from pathlib import Path
70
+
71
+ sys.path.insert(0, os.environ["ARKAOS_ROOT_VAL"])
72
+ try:
73
+ from core.workflow.flow_enforcer import (
74
+ _load_last_assistant_messages,
75
+ TELEMETRY_PATH,
76
+ clear_flow_required,
77
+ )
78
+ except Exception:
79
+ sys.exit(0)
80
+
81
+ session_id = os.environ.get("SESSION_ID_VAL", "")
82
+ transcript_path = os.environ.get("TRANSCRIPT_PATH_VAL", "")
83
+ cwd = os.environ.get("CWD_VAL", "")
84
+
85
+ # Only inspect the very last assistant message for closing markers.
86
+ messages = _load_last_assistant_messages(transcript_path, n=1)
87
+ last = messages[-1] if messages else ""
88
+
89
+ phase13 = bool(re.search(r"\[arka:phase:13\]", last, re.IGNORECASE))
90
+ trivial = bool(re.search(r"\[arka:trivial\]", last, re.IGNORECASE))
91
+ closing_ok = phase13 or trivial
92
+
93
+ entry = {
94
+ "ts": datetime.now(timezone.utc).isoformat(),
95
+ "session_id": session_id,
96
+ "cwd": cwd,
97
+ "event": "stop-hook-flow-check",
98
+ "closing_marker_found": closing_ok,
99
+ "phase13": phase13,
100
+ "trivial": trivial,
101
+ "mode": "warn",
102
+ }
103
+
104
+ try:
105
+ TELEMETRY_PATH.parent.mkdir(parents=True, exist_ok=True)
106
+ with TELEMETRY_PATH.open("a", encoding="utf-8") as fh:
107
+ fh.write(json.dumps(entry) + "\n")
108
+ except Exception:
109
+ pass
110
+
111
+ # Clean up the session marker once Stop has evaluated.
112
+ try:
113
+ clear_flow_required(session_id)
114
+ except Exception:
115
+ pass
116
+ PY
117
+
118
+ # Belt-and-braces: remove the marker at shell level in case the Python
119
+ # block above crashed before reaching clear_flow_required(). Session_id
120
+ # is already validated by the Python helper; this shell remove is scoped
121
+ # to the exact marker path and is idempotent.
122
+ case "$SESSION_ID" in
123
+ *[!A-Za-z0-9._-]*|"") ;; # reject unsafe/empty session ids
124
+ *) rm -f "$WF_MARKER" 2>/dev/null ;;
125
+ esac
126
+
127
+ exit 0
@@ -83,14 +83,23 @@ mkdir -p "$CACHE_DIR" 2>/dev/null
83
83
 
84
84
  # ─── Extract user input from hook JSON ───────────────────────────────────
85
85
  user_input=""
86
+ SESSION_ID=""
86
87
  if command -v jq &>/dev/null; then
87
88
  user_input=$(echo "$input" | jq -r '.userInput // .message // ""' 2>/dev/null)
89
+ SESSION_ID=$(echo "$input" | jq -r '.session_id // ""' 2>/dev/null)
88
90
  fi
89
91
  # Fallback: try to get the raw text
90
92
  if [ -z "$user_input" ]; then
91
93
  user_input=$(echo "$input" | head -c 2000)
92
94
  fi
93
95
 
96
+ # ─── Load shared workflow classifier ─────────────────────────────────────
97
+ _CLASSIFIER_LIB="$(dirname "$0")/_lib/workflow-classifier.sh"
98
+ if [ -f "$_CLASSIFIER_LIB" ]; then
99
+ # shellcheck disable=SC1090
100
+ . "$_CLASSIFIER_LIB"
101
+ fi
102
+
94
103
  # ─── Try Python Synapse bridge first ────────────────────────────────────
95
104
  python_result=""
96
105
  BRIDGE_SCRIPT="${ARKAOS_ROOT}/scripts/synapse-bridge.py"
@@ -276,21 +285,18 @@ When [knowledge:N chunks] is present, cite at least one source.
276
285
  If [knowledge:N chunks] is absent on a non-trivial ArkaOS topic, query Obsidian first."
277
286
 
278
287
  # ─── Workflow Classifier (hard enforcement for creation/implementation) ──
279
- # Classifies the user prompt. If it looks like a creation/implementation/
280
- # modification request that is NOT already routed with an explicit /prefix,
281
- # emits a directive that the agent MUST acknowledge with [arka:routing]
282
- # BEFORE using any write tool. Trivial quick questions pass through
283
- # untouched. Explicit slash commands pass through untouched.
288
+ # Uses the shared _lib/workflow-classifier.sh. When a creation/implementation
289
+ # verb is detected, the session is marked as flow-required so PreToolUse
290
+ # can block Write/Edit/MultiEdit until the agent emits [arka:routing] or
291
+ # [arka:trivial]. Explicit slash commands and bang shells pass through.
284
292
  _WORKFLOW_DIRECTIVE=""
285
- if [ -n "$user_input" ]; then
286
- # Skip: explicit slash command (already routed)
287
- _FIRST_CHAR=$(echo "$user_input" | head -c 1)
288
- if [ "$_FIRST_CHAR" != "/" ] && [ "$_FIRST_CHAR" != "!" ]; then
289
- # Match creation/implementation verbs in EN and PT (case-insensitive).
290
- _VERB_PATTERN='(criar?|crie[ms]?|cria[mr]?|adicionar?|adiciona[mr]?|implementar?|implementa[mr]?|desenvolver?|desenvolve[mr]?|construir?|constru[ií]a?[mr]?|fazer?|faz[ae][mr]?|refactor(izar?)?|corrigir?|corrige[mr]?|consertar?|conserta[mr]?|create[sd]?|creating|build(s|ing)?|add(s|ed|ing)?|implement(s|ed|ing)?|develop(s|ed|ing)?|fix(es|ed|ing)?|refactor(s|ed|ing)?|make[sd]?|making)'
291
- _NOUN_PATTERN='(feature|funcionalidade|skill|squad|agent[e]?|workflow|endpoint|api|component[e]?|module|m[oó]dulo|page|p[aá]gina|hook|pipeline|integration|integra[cç][aã]o|dashboard|report|report[eó]|script|test[es]?)'
292
- if echo "$user_input" | grep -qiE "\b${_VERB_PATTERN}\b"; then
293
- _WORKFLOW_DIRECTIVE="
293
+ if [ -n "$user_input" ] && command -v arka_wf_classify &>/dev/null; then
294
+ if [ "$(arka_wf_classify "$user_input")" = "true" ]; then
295
+ # Mark session as flow-required (consumed by pre-tool-use.sh and stop.sh)
296
+ if command -v arka_wf_mark_required &>/dev/null; then
297
+ arka_wf_mark_required "$SESSION_ID"
298
+ fi
299
+ _WORKFLOW_DIRECTIVE="
294
300
  [ARKA:WORKFLOW-REQUIRED] Your user request matched a CREATION/IMPLEMENTATION pattern.
295
301
  The ArkaOS mandatory 13-phase flow applies. It is NON-NEGOTIABLE (constitution rule
296
302
  mandatory-flow). You MUST walk every phase, in order, emitting a [arka:phase:N] tag
@@ -323,7 +329,6 @@ Anything else runs the full 13 phases. Source: arka/skills/flow/SKILL.md.
323
329
  This is enforced by the hook and the session-start systemMessage, not by convention.
324
330
  Skipping violates: mandatory-flow, squad-routing, spec-driven, mandatory-qa,
325
331
  sequential-validation, full-visibility, arka-supremacy."
326
- fi
327
332
  fi
328
333
  fi
329
334
 
@@ -0,0 +1,272 @@
1
+ """Mandatory 13-phase flow enforcement for write-mutation tools.
2
+
3
+ Invoked by the Claude Code `PreToolUse` hook. Decides whether a `Write`,
4
+ `Edit`, or `MultiEdit` tool call may proceed, based on markers observed
5
+ in the last N assistant messages of the session transcript.
6
+
7
+ Design contract:
8
+ - Stateless transcript parse (no /tmp state for decisions).
9
+ - Side effects limited to reading the transcript path supplied by the hook.
10
+ - Signals permission when the assistant has emitted one of the flow markers:
11
+ `[arka:routing]`, `[arka:trivial]`, or `[arka:phase:`.
12
+ - Respects `ARKA_BYPASS_FLOW=1` env var (installer/`/arka update` internal).
13
+ - Honors feature flag `hooks.hardEnforcement` in `~/.arkaos/config.json`.
14
+ - Gated tool list is closed: anything outside it is always allowed.
15
+ """
16
+
17
+ import json
18
+ import os
19
+ import re
20
+ from contextlib import contextmanager
21
+ from dataclasses import asdict, dataclass
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+
25
+ try:
26
+ import fcntl # POSIX only
27
+ _HAS_FLOCK = True
28
+ except ImportError:
29
+ _HAS_FLOCK = False
30
+
31
+
32
+ @contextmanager
33
+ def _locked_append(path: Path):
34
+ """Append to `path` under an exclusive advisory lock (POSIX flock).
35
+
36
+ On Windows or any platform without fcntl, falls back to a plain append
37
+ (single-process writers remain safe; cross-process interleaving is
38
+ mitigated by `O_APPEND` atomicity for writes up to PIPE_BUF).
39
+ """
40
+ path.parent.mkdir(parents=True, exist_ok=True)
41
+ fh = path.open("a", encoding="utf-8")
42
+ try:
43
+ if _HAS_FLOCK:
44
+ fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
45
+ yield fh
46
+ finally:
47
+ if _HAS_FLOCK:
48
+ try:
49
+ fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
50
+ except OSError:
51
+ pass
52
+ fh.close()
53
+
54
+ GATED_TOOLS: frozenset[str] = frozenset({"Write", "Edit", "MultiEdit"})
55
+
56
+ ROUTING_RE = re.compile(r"\[arka:routing\]\s*[\w-]+\s*->\s*\w+", re.IGNORECASE)
57
+ TRIVIAL_RE = re.compile(r"\[arka:trivial\]\s*\S+", re.IGNORECASE)
58
+ PHASE_RE = re.compile(r"\[arka:phase:\d+\]", re.IGNORECASE)
59
+ SAFE_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
60
+
61
+ ASSISTANT_WINDOW = 3
62
+ CONFIG_PATH = Path.home() / ".arkaos" / "config.json"
63
+ BYPASS_AUDIT_PATH = Path.home() / ".arkaos" / "audit" / "bypass.log"
64
+ TELEMETRY_PATH = Path.home() / ".arkaos" / "telemetry" / "enforcement.jsonl"
65
+ FLOW_REQUIRED_DIR = Path("/tmp/arkaos-wf-required")
66
+
67
+
68
+ def _safe_session_id(session_id: str) -> str | None:
69
+ """Validate session_id against a strict allowlist (prevents path traversal).
70
+
71
+ Returns the id if safe, or None if it contains path separators, dots-dots,
72
+ or characters outside `[A-Za-z0-9._-]`. Callers MUST treat None as reject.
73
+ """
74
+ if not session_id or not isinstance(session_id, str):
75
+ return None
76
+ if not SAFE_SESSION_ID_RE.match(session_id):
77
+ return None
78
+ return session_id
79
+
80
+
81
+ @dataclass
82
+ class Decision:
83
+ """Outcome of enforcement evaluation for a single tool call."""
84
+
85
+ allow: bool
86
+ reason: str
87
+ marker_found: str | None = None
88
+ phase_observed: str | None = None
89
+ bypass_used: bool = False
90
+
91
+ def to_stderr_message(self) -> str:
92
+ if self.allow:
93
+ return ""
94
+ return (
95
+ f"[ARKA:ENFORCEMENT] Flow marker missing. "
96
+ f"Emit `[arka:routing] <dept> -> <lead>` or `[arka:trivial] <reason>` "
97
+ f"before any Write/Edit/MultiEdit. Reason: {self.reason}"
98
+ )
99
+
100
+
101
+ def _feature_flag_on() -> bool:
102
+ if not CONFIG_PATH.exists():
103
+ return False
104
+ try:
105
+ data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
106
+ except (json.JSONDecodeError, OSError):
107
+ return False
108
+ return bool(data.get("hooks", {}).get("hardEnforcement", False))
109
+
110
+
111
+ def _bypass_env_active() -> bool:
112
+ return os.environ.get("ARKA_BYPASS_FLOW", "").strip() == "1"
113
+
114
+
115
+ def _audit_bypass(session_id: str, tool: str, cwd: str) -> None:
116
+ entry = {
117
+ "ts": datetime.now(timezone.utc).isoformat(),
118
+ "session_id": session_id,
119
+ "tool": tool,
120
+ "cwd": cwd,
121
+ "reason": os.environ.get("ARKA_BYPASS_REASON", ""),
122
+ }
123
+ with _locked_append(BYPASS_AUDIT_PATH) as fh:
124
+ fh.write(json.dumps(entry) + "\n")
125
+
126
+
127
+ def record_telemetry(
128
+ session_id: str, tool: str, decision: Decision, cwd: str
129
+ ) -> None:
130
+ """Append a structured record to the enforcement telemetry log."""
131
+ entry = {
132
+ "ts": datetime.now(timezone.utc).isoformat(),
133
+ "session_id": session_id,
134
+ "tool": tool,
135
+ "cwd": cwd,
136
+ **asdict(decision),
137
+ }
138
+ with _locked_append(TELEMETRY_PATH) as fh:
139
+ fh.write(json.dumps(entry) + "\n")
140
+
141
+
142
+ def _flow_required_for_session(session_id: str) -> bool:
143
+ """Check whether the UserPromptSubmit classifier flagged this session."""
144
+ safe = _safe_session_id(session_id)
145
+ if safe is None:
146
+ return False
147
+ marker = FLOW_REQUIRED_DIR / safe
148
+ return marker.exists()
149
+
150
+
151
+ def _extract_text(content: object) -> str:
152
+ """Flatten Claude transcript message content into a single string."""
153
+ if isinstance(content, str):
154
+ return content
155
+ if isinstance(content, list):
156
+ parts: list[str] = []
157
+ for item in content:
158
+ if isinstance(item, dict):
159
+ if "text" in item:
160
+ parts.append(str(item["text"]))
161
+ elif item.get("type") == "tool_use":
162
+ parts.append(f"<tool_use:{item.get('name', '')}>")
163
+ elif isinstance(item, str):
164
+ parts.append(item)
165
+ return "\n".join(parts)
166
+ return ""
167
+
168
+
169
+ def _load_last_assistant_messages(transcript_path: str, n: int) -> list[str]:
170
+ """Read the last `n` assistant messages from a JSONL transcript."""
171
+ path = Path(transcript_path) if transcript_path else None
172
+ if path is None or not path.exists():
173
+ return []
174
+ messages: list[str] = []
175
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
176
+ if not line.strip():
177
+ continue
178
+ try:
179
+ record = json.loads(line)
180
+ except json.JSONDecodeError:
181
+ continue
182
+ role = record.get("role") or record.get("message", {}).get("role")
183
+ if role != "assistant":
184
+ continue
185
+ content = record.get("content")
186
+ if content is None:
187
+ content = record.get("message", {}).get("content")
188
+ text = _extract_text(content)
189
+ if text:
190
+ messages.append(text)
191
+ return messages[-n:]
192
+
193
+
194
+ def _scan_markers(messages: list[str]) -> tuple[str | None, str | None]:
195
+ """Return (marker_found, phase_observed) across the given messages."""
196
+ marker_found: str | None = None
197
+ phase_observed: str | None = None
198
+ for text in messages:
199
+ if phase_observed is None:
200
+ phase_match = PHASE_RE.search(text)
201
+ if phase_match:
202
+ phase_observed = phase_match.group(0)
203
+ if marker_found is None:
204
+ if ROUTING_RE.search(text):
205
+ marker_found = "routing"
206
+ elif TRIVIAL_RE.search(text):
207
+ marker_found = "trivial"
208
+ elif PHASE_RE.search(text):
209
+ marker_found = "phase"
210
+ return marker_found, phase_observed
211
+
212
+
213
+ def evaluate(
214
+ tool_name: str,
215
+ transcript_path: str,
216
+ session_id: str = "",
217
+ cwd: str = "",
218
+ ) -> Decision:
219
+ """Decide whether a tool call may proceed.
220
+
221
+ Returns a Decision. Caller is responsible for translating `allow=False`
222
+ into the appropriate hook exit code or permissionDecision output.
223
+ """
224
+ if tool_name not in GATED_TOOLS:
225
+ return Decision(allow=True, reason="tool-not-gated")
226
+
227
+ if not _feature_flag_on():
228
+ return Decision(allow=True, reason="feature-flag-off")
229
+
230
+ if _bypass_env_active():
231
+ _audit_bypass(session_id, tool_name, cwd)
232
+ return Decision(allow=True, reason="env-bypass", bypass_used=True)
233
+
234
+ if not _flow_required_for_session(session_id):
235
+ return Decision(allow=True, reason="classifier-did-not-match")
236
+
237
+ messages = _load_last_assistant_messages(transcript_path, ASSISTANT_WINDOW)
238
+ marker_found, phase_observed = _scan_markers(messages)
239
+
240
+ if marker_found is None:
241
+ return Decision(
242
+ allow=False,
243
+ reason="no-flow-marker-in-last-3-assistant-messages",
244
+ phase_observed=phase_observed,
245
+ )
246
+
247
+ return Decision(
248
+ allow=True,
249
+ reason=f"marker-found:{marker_found}",
250
+ marker_found=marker_found,
251
+ phase_observed=phase_observed,
252
+ )
253
+
254
+
255
+ def mark_flow_required(session_id: str) -> None:
256
+ """Invoked by UserPromptSubmit when classifier matches creation intent."""
257
+ safe = _safe_session_id(session_id)
258
+ if safe is None:
259
+ return
260
+ FLOW_REQUIRED_DIR.mkdir(parents=True, exist_ok=True)
261
+ marker = FLOW_REQUIRED_DIR / safe
262
+ marker.write_text(datetime.now(timezone.utc).isoformat(), encoding="utf-8")
263
+
264
+
265
+ def clear_flow_required(session_id: str) -> None:
266
+ """Clear the flow-required marker (end of session / rollout tooling)."""
267
+ safe = _safe_session_id(session_id)
268
+ if safe is None:
269
+ return
270
+ marker = FLOW_REQUIRED_DIR / safe
271
+ if marker.exists():
272
+ marker.unlink()
@@ -94,6 +94,19 @@ export default {
94
94
  { hooks: [hookEntry(hooksDir, "post-tool-use", 5)] },
95
95
  ];
96
96
 
97
+ // PreToolUse — Flow enforcement gate (gated by hooks.hardEnforcement
98
+ // feature flag in ~/.arkaos/config.json; no-op when flag is false).
99
+ settings.hooks.PreToolUse = [
100
+ { hooks: [hookEntry(hooksDir, "pre-tool-use", 10)] },
101
+ ];
102
+
103
+ // Stop — Flow completion validator (WARN mode in v2.20.0; promotion
104
+ // to STRICT mode is gated on ≥ 2 weeks of clean telemetry per ADR
105
+ // 2026-04-17-binding-flow-enforcement).
106
+ settings.hooks.Stop = [
107
+ { hooks: [hookEntry(hooksDir, "stop", 5)] },
108
+ ];
109
+
97
110
  // PreCompact — Session digest
98
111
  settings.hooks.PreCompact = [
99
112
  { hooks: [hookEntry(hooksDir, "pre-compact", 30)] },
@@ -62,7 +62,19 @@ const checks = [
62
62
  name: "hooks-dir",
63
63
  description: "Hook scripts installed",
64
64
  severity: "fail",
65
- check: () => existsSync(join(INSTALL_DIR, "config", "hooks", `user-prompt-submit${HOOK_EXT}`)),
65
+ check: () => {
66
+ const required = [
67
+ "session-start",
68
+ "user-prompt-submit",
69
+ "post-tool-use",
70
+ "pre-compact",
71
+ "cwd-changed",
72
+ "pre-tool-use",
73
+ "stop",
74
+ ];
75
+ const hooksDir = join(INSTALL_DIR, "config", "hooks");
76
+ return required.every((h) => existsSync(join(hooksDir, `${h}${HOOK_EXT}`)));
77
+ },
66
78
  fix: () => "Run: npx arkaos install --force",
67
79
  },
68
80
  {
@@ -450,6 +450,8 @@ function installHooks(installDir) {
450
450
  "post-tool-use",
451
451
  "pre-compact",
452
452
  "cwd-changed",
453
+ "pre-tool-use",
454
+ "stop",
453
455
  ];
454
456
  const hookExt = HOOK_EXT;
455
457
 
@@ -481,6 +483,19 @@ function installHooks(installDir) {
481
483
  ok(`Hook: ${filename}`);
482
484
  }
483
485
  }
486
+
487
+ const srcLibDir = join(srcHooksDir, "_lib");
488
+ if (existsSync(srcLibDir)) {
489
+ const destLibDir = join(hooksDir, "_lib");
490
+ ensureDir(destLibDir);
491
+ cpSync(srcLibDir, destLibDir, { recursive: true });
492
+ try {
493
+ for (const f of readdirSync(destLibDir)) {
494
+ if (f.endsWith(".sh")) chmodSync(join(destLibDir, f), 0o755);
495
+ }
496
+ } catch {}
497
+ ok("Hook lib: _lib/");
498
+ }
484
499
  }
485
500
 
486
501
  // Safe directory iteration. Returns an empty array instead of throwing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "2.20.0",
3
+ "version": "2.20.1",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arkaos-core"
3
- version = "2.20.0"
3
+ version = "2.20.1"
4
4
  description = "Core engine for ArkaOS — The Operating System for AI Agent Teams"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}