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.
Files changed (35) hide show
  1. package/README.md +19 -0
  2. package/bin/install.cjs +71 -16
  3. package/files/CLAUDE.md +25 -3
  4. package/files/agents/merlin.md +3 -2
  5. package/files/agents/reviewer-decider.md +124 -0
  6. package/files/commands/merlin/challenge.md +2 -0
  7. package/files/hooks/config-change.sh +3 -2
  8. package/files/hooks/notify-desktop.sh +1 -1
  9. package/files/hooks/notify-webhook.sh +2 -1
  10. package/files/hooks/orchestrator-guard.sh +3 -2
  11. package/files/hooks/pre-edit-sights-check.sh +3 -2
  12. package/files/hooks/task-completed-verify.sh +2 -2
  13. package/files/hooks/user-prompt-router.sh +2 -1
  14. package/files/hooks/worktree-create.sh +1 -1
  15. package/files/hooks/worktree-remove.sh +1 -1
  16. package/files/merlin/skills/duo/SKILL.md +48 -0
  17. package/files/merlin/skills/duo/off.md +32 -0
  18. package/files/merlin/skills/duo/offer.md +158 -0
  19. package/files/merlin/skills/duo/on.md +50 -0
  20. package/files/merlin/skills/duo/status.md +95 -0
  21. package/files/merlin/skills/duo/unsuppress.md +122 -0
  22. package/files/merlin-state/duo-mode.json +5 -0
  23. package/files/merlin-state/duo-suppress.json +5 -0
  24. package/files/merlin-system-prompt.txt +1 -1
  25. package/files/rules/codex-routing.md +15 -0
  26. package/files/rules/duo-routing.md +203 -0
  27. package/files/rules/merlin-routing.md +6 -0
  28. package/files/scripts/duo-badge.sh +39 -0
  29. package/files/scripts/duo-codex-call.sh +83 -0
  30. package/files/scripts/duo-installed.sh +8 -0
  31. package/files/scripts/duo-mode-read.sh +51 -0
  32. package/files/scripts/duo-mode-write.sh +66 -0
  33. package/files/scripts/duo-pre-route.sh +124 -0
  34. package/files/scripts/duo-risk-detect.sh +157 -0
  35. 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,8 @@
1
+ #!/usr/bin/env bash
2
+ # duo-installed.sh — composite install gate for duo mode
3
+ # Returns 0 only if codex-installed.sh (sibling) returns 0
4
+
5
+ set -euo pipefail
6
+
7
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
+ "${SCRIPT_DIR}/codex-installed.sh"
@@ -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": "4.2.0",
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",