create-merlin-brain 4.2.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/bin/install.cjs +71 -16
- package/files/CLAUDE.md +25 -3
- package/files/agents/merlin.md +3 -2
- package/files/agents/reviewer-decider.md +124 -0
- package/files/commands/merlin/challenge.md +2 -0
- package/files/hooks/config-change.sh +3 -2
- package/files/hooks/notify-desktop.sh +1 -1
- package/files/hooks/notify-webhook.sh +2 -1
- package/files/hooks/orchestrator-guard.sh +3 -2
- package/files/hooks/pre-edit-sights-check.sh +3 -2
- package/files/hooks/task-completed-verify.sh +2 -2
- package/files/hooks/user-prompt-router.sh +2 -1
- package/files/hooks/worktree-create.sh +1 -1
- package/files/hooks/worktree-remove.sh +1 -1
- package/files/merlin/skills/duo/SKILL.md +48 -0
- package/files/merlin/skills/duo/off.md +32 -0
- package/files/merlin/skills/duo/offer.md +158 -0
- package/files/merlin/skills/duo/on.md +50 -0
- package/files/merlin/skills/duo/status.md +95 -0
- package/files/merlin/skills/duo/unsuppress.md +122 -0
- package/files/merlin-state/duo-mode.json +5 -0
- package/files/merlin-state/duo-suppress.json +5 -0
- package/files/merlin-system-prompt.txt +1 -1
- package/files/rules/codex-routing.md +15 -0
- package/files/rules/duo-routing.md +203 -0
- package/files/rules/merlin-routing.md +6 -0
- package/files/scripts/duo-badge.sh +39 -0
- package/files/scripts/duo-codex-call.sh +83 -0
- package/files/scripts/duo-installed.sh +8 -0
- package/files/scripts/duo-mode-read.sh +51 -0
- package/files/scripts/duo-mode-write.sh +66 -0
- package/files/scripts/duo-pre-route.sh +124 -0
- package/files/scripts/duo-risk-detect.sh +157 -0
- package/package.json +1 -1
|
@@ -58,6 +58,10 @@ Call `merlin_smart_route(task="...")` FIRST (searches 500+ community agents). Th
|
|
|
58
58
|
| "remind me" / "add a todo" | `Skill("merlin:add-todo")` |
|
|
59
59
|
| "check todos" / "pending items" | `Skill("merlin:check-todos")` |
|
|
60
60
|
| New project, no PROJECT.md | `Skill("merlin:map-codebase")` then `Skill("merlin:new-project")` |
|
|
61
|
+
| "duo on" / "enable duo" / "go duo" | `Skill("merlin:duo", args="on")` |
|
|
62
|
+
| "duo off" / "disable duo" / "back to solo" | `Skill("merlin:duo", args="off")` |
|
|
63
|
+
| "duo status" / "am I in duo" / "is duo on" | `Skill("merlin:duo", args="status")` |
|
|
64
|
+
| "stop duo for this kind of task" / "never duo for X" | `Skill("merlin:duo", args="unsuppress")` |
|
|
61
65
|
|
|
62
66
|
## Planning Intents
|
|
63
67
|
|
|
@@ -124,3 +128,5 @@ See `~/.claude/rules/codex-routing.md` for full details.
|
|
|
124
128
|
|
|
125
129
|
- `feature-dev` and `refactor` workflows: If Codex installed, use dual-plan flow (merlin-planner + codex-planner → challenger-arbiter → codex-implementer execution)
|
|
126
130
|
- `bug-fix` and `quick`: No dual-plan — normal flow, but failed-fix escalation to codex-escalator is available
|
|
131
|
+
|
|
132
|
+
> When duo mode is active, `feature-dev`, `refactor`, and `product-dev` workflows automatically use parallel planning + sequential coding. See `duo-routing.md`.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# duo-badge.sh — outputs the Merlin badge string to stdout
|
|
3
|
+
# Usage: duo-badge.sh [step-phrase]
|
|
4
|
+
# Env: MERLIN_BADGE_TEXTONLY=1 for ASCII-only output
|
|
5
|
+
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
|
|
8
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
9
|
+
STEP_PHRASE="${1:-}"
|
|
10
|
+
|
|
11
|
+
# Determine if duo is active: gate passes AND mode is enabled
|
|
12
|
+
DUO_ACTIVE=false
|
|
13
|
+
if "${SCRIPT_DIR}/duo-installed.sh" 2>/dev/null; then
|
|
14
|
+
if [[ "$("${SCRIPT_DIR}/duo-mode-read.sh" 2>/dev/null)" == "enabled" ]]; then
|
|
15
|
+
DUO_ACTIVE=true
|
|
16
|
+
fi
|
|
17
|
+
fi
|
|
18
|
+
|
|
19
|
+
# Build badge
|
|
20
|
+
if [[ "${MERLIN_BADGE_TEXTONLY:-0}" == "1" ]]; then
|
|
21
|
+
if [[ "$DUO_ACTIVE" == "true" ]]; then
|
|
22
|
+
BADGE="[DUO] MERLIN ›"
|
|
23
|
+
else
|
|
24
|
+
BADGE="MERLIN ›"
|
|
25
|
+
fi
|
|
26
|
+
else
|
|
27
|
+
if [[ "$DUO_ACTIVE" == "true" ]]; then
|
|
28
|
+
BADGE="⟡🔮↔🔮 MERLIN·DUO ›"
|
|
29
|
+
else
|
|
30
|
+
BADGE="⟡🔮 MERLIN ›"
|
|
31
|
+
fi
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
# Append optional step phrase
|
|
35
|
+
if [[ -n "$STEP_PHRASE" ]]; then
|
|
36
|
+
printf '%s %s\n' "$BADGE" "$STEP_PHRASE"
|
|
37
|
+
else
|
|
38
|
+
printf '%s\n' "$BADGE"
|
|
39
|
+
fi
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# duo-codex-call.sh — timeout-wrapped codex invocation with 3-strike fallback
|
|
3
|
+
# Usage: duo-codex-call.sh <codex-command> [args...]
|
|
4
|
+
# Exit 0: success (stdout/stderr forwarded)
|
|
5
|
+
# Exit 75 (TEMPFAIL): codex failed or timed out — caller should fall back to Claude
|
|
6
|
+
# Always exits — never hangs beyond 60s
|
|
7
|
+
|
|
8
|
+
set -euo pipefail
|
|
9
|
+
|
|
10
|
+
FAILURES_FILE="${HOME}/.claude/merlin-state/.duo-codex-failures"
|
|
11
|
+
DECISIONS_LOG="${HOME}/.claude/merlin-state/duo-decisions.log"
|
|
12
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
13
|
+
|
|
14
|
+
# --- Counter helpers ---
|
|
15
|
+
_read_counter() {
|
|
16
|
+
# Reset counter if file is > 6h old (session boundary)
|
|
17
|
+
if [[ -f "$FAILURES_FILE" ]]; then
|
|
18
|
+
AGE=$(python3 -c "import os,time; print(int(time.time() - os.path.getmtime('$FAILURES_FILE')))" 2>/dev/null || echo "99999")
|
|
19
|
+
if [[ "$AGE" -gt 21600 ]]; then
|
|
20
|
+
rm -f "$FAILURES_FILE"
|
|
21
|
+
echo 0; return
|
|
22
|
+
fi
|
|
23
|
+
cat "$FAILURES_FILE" 2>/dev/null || echo 0
|
|
24
|
+
else
|
|
25
|
+
echo 0
|
|
26
|
+
fi
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_write_counter() {
|
|
30
|
+
echo "$1" > "$FAILURES_FILE"
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_log_failure() {
|
|
34
|
+
local exit_code="$1"
|
|
35
|
+
local ts
|
|
36
|
+
ts=$(python3 -c "from datetime import datetime,timezone; print(datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'))" 2>/dev/null || date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
37
|
+
# Mask command to avoid leaking sensitive args (show only first token)
|
|
38
|
+
local masked_cmd
|
|
39
|
+
masked_cmd=$(echo "${*:2}" | awk '{print $1}')
|
|
40
|
+
printf '{"ts":"%s","event":"codex_runtime_failure","exit_code":%d,"command":"%s"}\n' \
|
|
41
|
+
"$ts" "$exit_code" "$masked_cmd" >> "$DECISIONS_LOG" 2>/dev/null || true
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_auto_disable_duo() {
|
|
45
|
+
"${SCRIPT_DIR}/duo-mode-write.sh" off "codex runtime failures (3 in session)" 2>/dev/null || true
|
|
46
|
+
echo "⟡🔮 MERLIN › Codex appears unhealthy. Reverting to solo for this session. Run 'codex doctor' to diagnose." >&2
|
|
47
|
+
_write_counter 0
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# --- Build timeout command ---
|
|
51
|
+
_TIMEOUT=""
|
|
52
|
+
if command -v gtimeout >/dev/null 2>&1; then
|
|
53
|
+
_TIMEOUT="gtimeout 60"
|
|
54
|
+
elif timeout --version >/dev/null 2>&1; then
|
|
55
|
+
_TIMEOUT="timeout 60"
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
# --- Execute (capture exit code without triggering set -e) ---
|
|
59
|
+
EXIT_CODE=0
|
|
60
|
+
if [[ -n "$_TIMEOUT" ]]; then
|
|
61
|
+
$_TIMEOUT "$@" || EXIT_CODE=$?
|
|
62
|
+
else
|
|
63
|
+
# perl alarm fallback for macOS without coreutils
|
|
64
|
+
perl -e 'alarm 60; exec @ARGV or exit 127' -- "$@" || EXIT_CODE=$?
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
if [[ $EXIT_CODE -eq 0 ]]; then
|
|
68
|
+
# Success — reset failure counter
|
|
69
|
+
_write_counter 0
|
|
70
|
+
exit 0
|
|
71
|
+
fi
|
|
72
|
+
|
|
73
|
+
# Failure path
|
|
74
|
+
_log_failure "$EXIT_CODE" "$@"
|
|
75
|
+
|
|
76
|
+
COUNT=$(( $(_read_counter) + 1 ))
|
|
77
|
+
_write_counter "$COUNT"
|
|
78
|
+
|
|
79
|
+
if [[ $COUNT -ge 3 ]]; then
|
|
80
|
+
_auto_disable_duo
|
|
81
|
+
fi
|
|
82
|
+
|
|
83
|
+
exit 75
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# duo-mode-read.sh — reads duo-mode.json, applies 24h auto-expire (read-time only, never modifies file)
|
|
3
|
+
# Prints exactly "enabled" or "disabled" to stdout
|
|
4
|
+
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
STATE_FILE="${HOME}/.claude/merlin-state/duo-mode.json"
|
|
8
|
+
|
|
9
|
+
# If state file missing, default to disabled
|
|
10
|
+
if [[ ! -f "$STATE_FILE" ]]; then
|
|
11
|
+
echo "disabled"
|
|
12
|
+
exit 0
|
|
13
|
+
fi
|
|
14
|
+
|
|
15
|
+
python3 - "$STATE_FILE" <<'PYEOF'
|
|
16
|
+
import sys
|
|
17
|
+
import json
|
|
18
|
+
from datetime import datetime, timezone, timedelta
|
|
19
|
+
|
|
20
|
+
state_path = sys.argv[1]
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
with open(state_path, "r") as f:
|
|
24
|
+
data = json.load(f)
|
|
25
|
+
except (json.JSONDecodeError, OSError):
|
|
26
|
+
print("disabled")
|
|
27
|
+
sys.exit(0)
|
|
28
|
+
|
|
29
|
+
enabled = data.get("enabled", False)
|
|
30
|
+
since_iso = data.get("sinceISO")
|
|
31
|
+
|
|
32
|
+
if not enabled or since_iso is None:
|
|
33
|
+
print("disabled")
|
|
34
|
+
sys.exit(0)
|
|
35
|
+
|
|
36
|
+
# Parse sinceISO and apply 24h auto-expire (read-time interpretation, no file write)
|
|
37
|
+
try:
|
|
38
|
+
# Handle both Z suffix and +00:00 format
|
|
39
|
+
since_str = since_iso.replace("Z", "+00:00")
|
|
40
|
+
since_dt = datetime.fromisoformat(since_str)
|
|
41
|
+
now_dt = datetime.now(timezone.utc)
|
|
42
|
+
if (now_dt - since_dt) > timedelta(hours=24):
|
|
43
|
+
print("disabled")
|
|
44
|
+
sys.exit(0)
|
|
45
|
+
except (ValueError, TypeError):
|
|
46
|
+
# Unparseable timestamp — treat as expired
|
|
47
|
+
print("disabled")
|
|
48
|
+
sys.exit(0)
|
|
49
|
+
|
|
50
|
+
print("enabled")
|
|
51
|
+
PYEOF
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# duo-mode-write.sh — atomic write to duo-mode.json
|
|
3
|
+
# Usage: duo-mode-write.sh on "<reason>" | off "<reason>"
|
|
4
|
+
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
if [[ $# -lt 2 ]]; then
|
|
8
|
+
echo "Usage: duo-mode-write.sh on|off \"<reason>\"" >&2
|
|
9
|
+
exit 1
|
|
10
|
+
fi
|
|
11
|
+
|
|
12
|
+
ACTION="$1"
|
|
13
|
+
REASON="$2"
|
|
14
|
+
STATE_FILE="${HOME}/.claude/merlin-state/duo-mode.json"
|
|
15
|
+
STATE_DIR="$(dirname "$STATE_FILE")"
|
|
16
|
+
|
|
17
|
+
# Ensure state directory exists
|
|
18
|
+
mkdir -p "$STATE_DIR"
|
|
19
|
+
|
|
20
|
+
case "$ACTION" in
|
|
21
|
+
on)
|
|
22
|
+
ENABLED="true"
|
|
23
|
+
;;
|
|
24
|
+
off)
|
|
25
|
+
ENABLED="false"
|
|
26
|
+
;;
|
|
27
|
+
*)
|
|
28
|
+
echo "Error: first argument must be 'on' or 'off', got: $ACTION" >&2
|
|
29
|
+
exit 1
|
|
30
|
+
;;
|
|
31
|
+
esac
|
|
32
|
+
|
|
33
|
+
# Use python3 for JSON serialization and atomic write via mktemp + mv (same FS = atomic)
|
|
34
|
+
python3 - "$STATE_FILE" "$ENABLED" "$REASON" <<'PYEOF'
|
|
35
|
+
import sys
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
import tempfile
|
|
39
|
+
from datetime import datetime, timezone
|
|
40
|
+
|
|
41
|
+
state_path = sys.argv[1]
|
|
42
|
+
enabled = sys.argv[2] == "true"
|
|
43
|
+
reason = sys.argv[3]
|
|
44
|
+
|
|
45
|
+
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") if enabled else None
|
|
46
|
+
|
|
47
|
+
data = {
|
|
48
|
+
"enabled": enabled,
|
|
49
|
+
"sinceISO": now_iso,
|
|
50
|
+
"lastToggleReason": reason,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
state_dir = os.path.dirname(state_path)
|
|
54
|
+
fd, tmp_path = tempfile.mkstemp(dir=state_dir, suffix=".tmp")
|
|
55
|
+
try:
|
|
56
|
+
with os.fdopen(fd, "w") as f:
|
|
57
|
+
json.dump(data, f, indent=2)
|
|
58
|
+
f.write("\n")
|
|
59
|
+
os.replace(tmp_path, state_path)
|
|
60
|
+
except Exception:
|
|
61
|
+
try:
|
|
62
|
+
os.unlink(tmp_path)
|
|
63
|
+
except OSError:
|
|
64
|
+
pass
|
|
65
|
+
raise
|
|
66
|
+
PYEOF
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# duo-pre-route.sh — pre-routing hook for workflow dispatch
|
|
3
|
+
# Usage: duo-pre-route.sh --task "<text>" [--workflow <name>] [--files <comma-list>] [--loc <int>]
|
|
4
|
+
# Outputs one of: mode=duo | mode=offer | mode=solo
|
|
5
|
+
# Always exits 0 — never blocks routing
|
|
6
|
+
|
|
7
|
+
set -euo pipefail
|
|
8
|
+
|
|
9
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
10
|
+
|
|
11
|
+
TASK=""
|
|
12
|
+
WORKFLOW=""
|
|
13
|
+
FILES=""
|
|
14
|
+
LOC=""
|
|
15
|
+
|
|
16
|
+
while [[ $# -gt 0 ]]; do
|
|
17
|
+
case "$1" in
|
|
18
|
+
--task) TASK="${2:-}"; shift 2 ;;
|
|
19
|
+
--workflow) WORKFLOW="${2:-}"; shift 2 ;;
|
|
20
|
+
--files) FILES="${2:-}"; shift 2 ;;
|
|
21
|
+
--loc) LOC="${2:-}"; shift 2 ;;
|
|
22
|
+
*) shift ;;
|
|
23
|
+
esac
|
|
24
|
+
done
|
|
25
|
+
|
|
26
|
+
# Branch 1: duo explicitly enabled by user (and not expired)
|
|
27
|
+
DUO_STATE=$("${SCRIPT_DIR}/duo-mode-read.sh" 2>/dev/null || echo "disabled")
|
|
28
|
+
if [[ "$DUO_STATE" == "enabled" ]]; then
|
|
29
|
+
echo "mode=duo"
|
|
30
|
+
exit 0
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
# Branch 2: Codex absent — stay solo silently (never mention duo)
|
|
34
|
+
if ! "${SCRIPT_DIR}/duo-installed.sh" 2>/dev/null; then
|
|
35
|
+
echo "mode=solo"
|
|
36
|
+
exit 0
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
# Branch 3: risk-detect suggestion
|
|
40
|
+
RISK_DETECT="${SCRIPT_DIR}/duo-risk-detect.sh"
|
|
41
|
+
if [[ ! -x "$RISK_DETECT" ]]; then
|
|
42
|
+
echo "mode=solo"
|
|
43
|
+
exit 0
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
# Run risk detector — it handles its own 500ms timeout internally
|
|
47
|
+
RISK_JSON=$("$RISK_DETECT" --task "$TASK" --workflow "$WORKFLOW" --files "$FILES" --loc "$LOC" 2>/dev/null \
|
|
48
|
+
|| echo '{"score":0,"reasons":[],"suggest_duo":false}')
|
|
49
|
+
|
|
50
|
+
# Parse suggest_duo from JSON (python3, no jq dep)
|
|
51
|
+
SUGGEST_DUO=$(python3 -c "
|
|
52
|
+
import json, sys
|
|
53
|
+
try:
|
|
54
|
+
d = json.loads(sys.argv[1])
|
|
55
|
+
print('true' if d.get('suggest_duo', False) else 'false')
|
|
56
|
+
except Exception:
|
|
57
|
+
print('false')
|
|
58
|
+
" "$RISK_JSON" 2>/dev/null || echo "false")
|
|
59
|
+
|
|
60
|
+
if [[ "$SUGGEST_DUO" != "true" ]]; then
|
|
61
|
+
echo "mode=solo"
|
|
62
|
+
exit 0
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
# Branch 3a: suppression check — use a temp python script to avoid heredoc-in-subshell issues
|
|
66
|
+
REASONS_JSON=$(python3 -c "
|
|
67
|
+
import json, sys
|
|
68
|
+
try:
|
|
69
|
+
d = json.loads(sys.argv[1])
|
|
70
|
+
print(json.dumps(d.get('reasons', [])))
|
|
71
|
+
except Exception:
|
|
72
|
+
print('[]')
|
|
73
|
+
" "$RISK_JSON" 2>/dev/null || echo "[]")
|
|
74
|
+
|
|
75
|
+
SUPPRESS_SCRIPT=$(mktemp /tmp/duo-suppress-check.XXXXXX.py)
|
|
76
|
+
trap 'rm -f "$SUPPRESS_SCRIPT"' EXIT
|
|
77
|
+
|
|
78
|
+
cat > "$SUPPRESS_SCRIPT" << 'PYEOF'
|
|
79
|
+
import sys, json, os, time, hashlib, re
|
|
80
|
+
|
|
81
|
+
task = os.environ.get("_DUO_TASK", "")
|
|
82
|
+
workflow = os.environ.get("_DUO_WORKFLOW", "")
|
|
83
|
+
reasons = json.loads(os.environ.get("_DUO_REASONS", "[]"))
|
|
84
|
+
|
|
85
|
+
path = os.path.expanduser("~/.claude/merlin-state/duo-suppress.json")
|
|
86
|
+
try:
|
|
87
|
+
d = json.load(open(path))
|
|
88
|
+
except Exception:
|
|
89
|
+
d = {}
|
|
90
|
+
|
|
91
|
+
# session_skip: check file mtime < 12h
|
|
92
|
+
mtime = os.path.getmtime(path) if os.path.exists(path) else 0
|
|
93
|
+
if d.get("session_skip") and (time.time() - mtime) < 43200:
|
|
94
|
+
print("true"); sys.exit(0)
|
|
95
|
+
|
|
96
|
+
# task_hash check
|
|
97
|
+
normalized = re.sub(r'[\s\'"`]+', ' ', task.lower()).strip()[:120]
|
|
98
|
+
task_hash = hashlib.sha1(f"{workflow}:{normalized}".encode()).hexdigest()
|
|
99
|
+
if task_hash in d.get("task_hashes_declined", []):
|
|
100
|
+
print("true"); sys.exit(0)
|
|
101
|
+
|
|
102
|
+
# intent fingerprint check (7d expiry)
|
|
103
|
+
top3 = sorted(reasons[:3])
|
|
104
|
+
intent_fp = hashlib.sha1(f"{workflow}:{':'.join(top3)}".encode()).hexdigest()
|
|
105
|
+
now = time.time()
|
|
106
|
+
for entry in d.get("never_for_intents", []):
|
|
107
|
+
if isinstance(entry, dict):
|
|
108
|
+
if entry.get("fp") == intent_fp and (now - entry.get("ts", 0)) < 604800:
|
|
109
|
+
print("true"); sys.exit(0)
|
|
110
|
+
|
|
111
|
+
print("false")
|
|
112
|
+
PYEOF
|
|
113
|
+
|
|
114
|
+
IS_SUPPRESSED=$(export _DUO_TASK="$TASK" _DUO_WORKFLOW="$WORKFLOW" _DUO_REASONS="$REASONS_JSON"; \
|
|
115
|
+
python3 "$SUPPRESS_SCRIPT" 2>/dev/null || echo "false")
|
|
116
|
+
|
|
117
|
+
if [[ "$IS_SUPPRESSED" == "true" ]]; then
|
|
118
|
+
echo "mode=solo"
|
|
119
|
+
exit 0
|
|
120
|
+
fi
|
|
121
|
+
|
|
122
|
+
# Risk fires, not suppressed — ask user
|
|
123
|
+
echo "mode=offer"
|
|
124
|
+
exit 0
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# duo-risk-detect.sh — heuristic risk scorer for duo auto-offer
|
|
3
|
+
# Usage: duo-risk-detect.sh --task "<text>" [--workflow <name>] [--files <comma,sep>] [--loc <int>]
|
|
4
|
+
# Output: single JSON line {"score":<0-100>,"reasons":[...],"suggest_duo":<bool>}
|
|
5
|
+
# Always exits 0 — never blocks routing. Times out at 500ms.
|
|
6
|
+
|
|
7
|
+
set -euo pipefail
|
|
8
|
+
|
|
9
|
+
TASK=""
|
|
10
|
+
WORKFLOW=""
|
|
11
|
+
FILES=""
|
|
12
|
+
LOC=""
|
|
13
|
+
|
|
14
|
+
while [[ $# -gt 0 ]]; do
|
|
15
|
+
case "$1" in
|
|
16
|
+
--task) TASK="${2:-}"; shift 2 ;;
|
|
17
|
+
--workflow) WORKFLOW="${2:-}"; shift 2 ;;
|
|
18
|
+
--files) FILES="${2:-}"; shift 2 ;;
|
|
19
|
+
--loc) LOC="${2:-}"; shift 2 ;;
|
|
20
|
+
*) shift ;;
|
|
21
|
+
esac
|
|
22
|
+
done
|
|
23
|
+
|
|
24
|
+
THRESHOLD="${MERLIN_DUO_OFFER_THRESHOLD:-50}"
|
|
25
|
+
|
|
26
|
+
# Write the scorer to a temp file so timeout can exec it cleanly (no eval of user input)
|
|
27
|
+
TMPSCRIPT=$(mktemp /tmp/duo-risk-score.XXXXXX.py)
|
|
28
|
+
trap 'rm -f "$TMPSCRIPT"' EXIT
|
|
29
|
+
|
|
30
|
+
cat > "$TMPSCRIPT" << 'PYEOF'
|
|
31
|
+
import os, sys, json, re
|
|
32
|
+
|
|
33
|
+
task = os.environ.get("_DUO_TASK", "")
|
|
34
|
+
workflow = os.environ.get("_DUO_WORKFLOW", "")
|
|
35
|
+
files = os.environ.get("_DUO_FILES", "")
|
|
36
|
+
loc_raw = os.environ.get("_DUO_LOC", "")
|
|
37
|
+
threshold = int(os.environ.get("_DUO_THRESHOLD", "50"))
|
|
38
|
+
|
|
39
|
+
score = 0
|
|
40
|
+
reasons = []
|
|
41
|
+
|
|
42
|
+
# Keyword scoring: +20 each, cap +40
|
|
43
|
+
KEYWORDS = [
|
|
44
|
+
"auth", "password", "payment", "billing", "migration", "schema",
|
|
45
|
+
"production", "prod", "security", "crypto", "token", "secret",
|
|
46
|
+
"permission", "role", "admin", "delete", "drop", "force"
|
|
47
|
+
]
|
|
48
|
+
task_lower = task.lower()
|
|
49
|
+
kw_score = 0
|
|
50
|
+
for kw in KEYWORDS:
|
|
51
|
+
if re.search(r"\b" + re.escape(kw) + r"\b", task_lower):
|
|
52
|
+
if kw_score < 40:
|
|
53
|
+
add = min(20, 40 - kw_score)
|
|
54
|
+
kw_score += add
|
|
55
|
+
reasons.append(f"keyword:{kw}")
|
|
56
|
+
score += kw_score
|
|
57
|
+
|
|
58
|
+
# File path scoring: +25 each, cap +50
|
|
59
|
+
PATH_PATTERNS = [
|
|
60
|
+
("migrations/", "path:migrations/"),
|
|
61
|
+
("auth/", "path:auth/"),
|
|
62
|
+
("payment/", "path:payment/"),
|
|
63
|
+
("billing/", "path:billing/"),
|
|
64
|
+
("security/", "path:security/"),
|
|
65
|
+
("database/migrations/", "path:database/migrations/"),
|
|
66
|
+
("infra/", "path:infra/"),
|
|
67
|
+
(".github/workflows/", "path:.github/workflows/"),
|
|
68
|
+
("Dockerfile", "path:Dockerfile"),
|
|
69
|
+
(".sql", "path:*.sql"),
|
|
70
|
+
]
|
|
71
|
+
path_score = 0
|
|
72
|
+
for pattern, tag in PATH_PATTERNS:
|
|
73
|
+
if pattern in files:
|
|
74
|
+
if path_score < 50:
|
|
75
|
+
add = min(25, 50 - path_score)
|
|
76
|
+
path_score += add
|
|
77
|
+
reasons.append(tag)
|
|
78
|
+
score += path_score
|
|
79
|
+
|
|
80
|
+
# Workflow scoring: +30
|
|
81
|
+
HIGH_RISK_WORKFLOWS = {"security-audit", "refactor", "migration"}
|
|
82
|
+
if workflow.lower() in HIGH_RISK_WORKFLOWS:
|
|
83
|
+
score += 30
|
|
84
|
+
reasons.append(f"workflow:{workflow.lower()}")
|
|
85
|
+
|
|
86
|
+
# LOC delta scoring (>500 replaces >200)
|
|
87
|
+
try:
|
|
88
|
+
loc = int(loc_raw.strip()) if loc_raw.strip() else 0
|
|
89
|
+
except ValueError:
|
|
90
|
+
loc = 0
|
|
91
|
+
|
|
92
|
+
if loc > 500:
|
|
93
|
+
score += 35
|
|
94
|
+
reasons.append("loc:>500")
|
|
95
|
+
elif loc > 200:
|
|
96
|
+
score += 20
|
|
97
|
+
reasons.append("loc:>200")
|
|
98
|
+
|
|
99
|
+
# Files count: +15
|
|
100
|
+
if files.strip():
|
|
101
|
+
file_list = [f for f in files.split(",") if f.strip()]
|
|
102
|
+
if len(file_list) > 10:
|
|
103
|
+
score += 15
|
|
104
|
+
reasons.append("files:>10")
|
|
105
|
+
|
|
106
|
+
# Production/critical keywords: +25 (first match only)
|
|
107
|
+
PROD_WORDS = ["production", "ship", "release", "critical"]
|
|
108
|
+
for word in PROD_WORDS:
|
|
109
|
+
if re.search(r"\b" + re.escape(word) + r"\b", task_lower):
|
|
110
|
+
score += 25
|
|
111
|
+
reasons.append(f"keyword:{word}")
|
|
112
|
+
break
|
|
113
|
+
|
|
114
|
+
# Dep-manifest files: +15 (first match only)
|
|
115
|
+
DEP_FILES = ["package.json", "go.mod", "Cargo.toml", "requirements.txt"]
|
|
116
|
+
for dep in DEP_FILES:
|
|
117
|
+
if dep in files:
|
|
118
|
+
score += 15
|
|
119
|
+
reasons.append(f"dep:{dep}")
|
|
120
|
+
break
|
|
121
|
+
|
|
122
|
+
# Cap at 100, keep top 5 reasons
|
|
123
|
+
score = min(score, 100)
|
|
124
|
+
reasons = reasons[:5]
|
|
125
|
+
|
|
126
|
+
print(json.dumps({"score": score, "reasons": reasons, "suggest_duo": score >= threshold}))
|
|
127
|
+
PYEOF
|
|
128
|
+
|
|
129
|
+
# Export all inputs via env — never interpolated into code
|
|
130
|
+
export _DUO_TASK="$TASK"
|
|
131
|
+
export _DUO_WORKFLOW="$WORKFLOW"
|
|
132
|
+
export _DUO_FILES="$FILES"
|
|
133
|
+
export _DUO_LOC="$LOC"
|
|
134
|
+
export _DUO_THRESHOLD="$THRESHOLD"
|
|
135
|
+
|
|
136
|
+
# Run with 500ms timeout — try gtimeout (macOS Homebrew coreutils), then timeout, then perl alarm
|
|
137
|
+
_TIMEOUT_CMD=""
|
|
138
|
+
if command -v gtimeout >/dev/null 2>&1; then
|
|
139
|
+
_TIMEOUT_CMD="gtimeout 0.5"
|
|
140
|
+
elif timeout --version >/dev/null 2>&1; then
|
|
141
|
+
_TIMEOUT_CMD="timeout 0.5"
|
|
142
|
+
fi
|
|
143
|
+
|
|
144
|
+
if [[ -n "$_TIMEOUT_CMD" ]]; then
|
|
145
|
+
OUT=$($_TIMEOUT_CMD python3 "$TMPSCRIPT" 2>/dev/null) || OUT=""
|
|
146
|
+
else
|
|
147
|
+
OUT=$(perl -e 'alarm 1; exec @ARGV' -- python3 "$TMPSCRIPT" 2>/dev/null) || OUT=""
|
|
148
|
+
fi
|
|
149
|
+
|
|
150
|
+
# Validate JSON; fall back to safe default
|
|
151
|
+
if python3 -c "import json,sys; json.loads(sys.stdin.read())" <<< "$OUT" 2>/dev/null; then
|
|
152
|
+
echo "$OUT"
|
|
153
|
+
else
|
|
154
|
+
echo '{"score":0,"reasons":["timeout"],"suggest_duo":false}'
|
|
155
|
+
fi
|
|
156
|
+
|
|
157
|
+
exit 0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-merlin-brain",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0",
|
|
4
4
|
"description": "Merlin - The Ultimate AI Brain for Claude Code, Codex, and other AI CLIs. One install: workflows, agents, loop, and Sights MCP server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/server/index.js",
|