create-merlin-brain 4.2.0 → 5.0.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/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 +6 -5
- 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
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# Duo Mode — Routing Rules
|
|
2
|
+
|
|
3
|
+
This document is the single source of truth for duo mode. Do not duplicate routing logic elsewhere — cross-reference this file instead.
|
|
4
|
+
|
|
5
|
+
## What duo is
|
|
6
|
+
|
|
7
|
+
Duo mode runs two brains on the same task: Claude and Codex. Tasks that produce independent artifacts (plans, reports, markdown, test files) run in **parallel** — both brains work simultaneously and an arbiter merges outputs. Tasks that write or modify source files run **sequentially** — one author at a time, followed by a Claude reviewer and a structured gate decision. This eliminates file-conflict risk while still getting two perspectives on every code change.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Activation
|
|
12
|
+
|
|
13
|
+
**State file:** `~/.claude/merlin-state/duo-mode.json`
|
|
14
|
+
|
|
15
|
+
**Schema:**
|
|
16
|
+
```json
|
|
17
|
+
{"enabled": bool, "sinceISO": "ISO8601 | null", "lastToggleReason": "string | null"}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
**Default (first install):**
|
|
21
|
+
```json
|
|
22
|
+
{"enabled": false, "sinceISO": null, "lastToggleReason": null}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Auto-expire:** If `sinceISO` is more than 24 hours old at read time, treat as disabled. The file is NOT rewritten on read — expiry is a read-time interpretation. Approximately session-sticky.
|
|
26
|
+
|
|
27
|
+
**Toggle:** `Skill("merlin:duo", args="on|off|status|unsuppress")` or natural language (see `merlin-routing.md` for intent triggers).
|
|
28
|
+
|
|
29
|
+
**Install gate:** Run `~/.claude/scripts/duo-installed.sh` before any duo activation. If gate returns non-zero:
|
|
30
|
+
- Silently fall back to solo routing for this task
|
|
31
|
+
- Do NOT write `enabled: true` to the state file
|
|
32
|
+
- Do NOT mention duo, Codex, or the gate failure to the user
|
|
33
|
+
- Proceed as if duo was never considered
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Precedence
|
|
38
|
+
|
|
39
|
+
When multiple modes are enabled simultaneously:
|
|
40
|
+
|
|
41
|
+
1. **Duo active** (enabled=true, within 24h, gate passes) → duo rules in this file win
|
|
42
|
+
2. **Codex-mode active** (enabled=true, within 24h, codex gate passes) → `codex-routing.md` rules apply
|
|
43
|
+
3. **Neither** → solo routing (existing `merlin-routing.md` + `codex-routing.md` rules)
|
|
44
|
+
|
|
45
|
+
Note: duo does NOT require `codex-mode.json` to be enabled. Duo manages its own state independently.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Badge contract (UNMISSABLE)
|
|
50
|
+
|
|
51
|
+
Compute the badge via `~/.claude/scripts/duo-badge.sh` before every action prefix.
|
|
52
|
+
|
|
53
|
+
| Condition | Badge |
|
|
54
|
+
|---|---|
|
|
55
|
+
| Duo enabled + within 24h + gate passes | `⟡🔮↔🔮 MERLIN·DUO ›` |
|
|
56
|
+
| Any other state | `⟡🔮 MERLIN ›` |
|
|
57
|
+
|
|
58
|
+
**Text-only fallback:** If env `MERLIN_BADGE_TEXTONLY=1`, `duo-badge.sh` returns `[DUO] MERLIN ›` or `MERLIN ›` for terminals that mangle `↔` or emoji.
|
|
59
|
+
|
|
60
|
+
Every Merlin action MUST prefix with the computed badge. If the badge is missing, the action is non-compliant. See badge audit: `.planning/duo/BADGE-AUDIT.md`.
|
|
61
|
+
|
|
62
|
+
Special case — `duo off` when codex-mode is still active:
|
|
63
|
+
```
|
|
64
|
+
⟡🔮 MERLIN › Duo off. (codex-mode is still active.)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Routing Matrix — PARALLEL execution
|
|
70
|
+
|
|
71
|
+
Both brains run independently on the same task. Results are routed to the arbiter for merging or deduplication. **Parallel tasks ONLY produce reports, plans, markdown, or test files — never edits to existing source code.** Source edits are always sequential (see below).
|
|
72
|
+
|
|
73
|
+
| Task | Brain A | Brain B | Decider |
|
|
74
|
+
|---|---|---|---|
|
|
75
|
+
| Planning (feature-dev / refactor / product-dev) | `merlin-planner` | `codex-planner` | `challenger-arbiter` |
|
|
76
|
+
| Documentation | `docs-keeper` (Claude) | `docs-keeper` via `codex-as.sh` | `challenger-arbiter` |
|
|
77
|
+
| Code review | `code-review` | `codex-code-review` | merge + dedupe (in arbiter) |
|
|
78
|
+
| Testing | `tests-qa` (Claude) | `tests-qa` via `codex-as.sh` | merge + dedupe (in arbiter) |
|
|
79
|
+
|
|
80
|
+
**UX during parallel runs:**
|
|
81
|
+
```
|
|
82
|
+
⟡🔮↔🔮 MERLIN·DUO › Planning ×2 (claude + codex)…
|
|
83
|
+
⟡🔮↔🔮 MERLIN·DUO › Arbiter merging plans…
|
|
84
|
+
⟡🔮↔🔮 MERLIN·DUO › Plan ready.
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Routing Matrix — SEQUENTIAL execution
|
|
90
|
+
|
|
91
|
+
One author at a time to prevent file conflicts. Sequential only for tasks that write or modify source files.
|
|
92
|
+
|
|
93
|
+
| Task | Author | Reviewer | Decider |
|
|
94
|
+
|---|---|---|---|
|
|
95
|
+
| Code write (new files) | `codex-implementer` | `code-review` (Claude) | `reviewer-decider` (Claude) |
|
|
96
|
+
| Code modification | `codex-implementer` | `code-review` (Claude) | `reviewer-decider` (Claude) |
|
|
97
|
+
|
|
98
|
+
**Sequential decision flow:**
|
|
99
|
+
|
|
100
|
+
1. Author (`codex-implementer`) writes diff
|
|
101
|
+
2. Reviewer (`code-review`, Claude) reviews diff
|
|
102
|
+
3. `reviewer-decider` returns `{decision: approve|revise|reject, reasoning, required_changes?}`
|
|
103
|
+
4. **On `approve`:** proceed to verification
|
|
104
|
+
5. **On `revise`:** author iterates ONCE with `required_changes`. Re-review. If revise again → escalate to reject.
|
|
105
|
+
6. **On `reject`** OR second revise without resolution: Claude reviewer takes over and writes the fix directly
|
|
106
|
+
|
|
107
|
+
Maximum iterations per task: 2 (one initial pass + one revise loop). `reviewer-decider` enforces `iteration_count` ≤ 2.
|
|
108
|
+
|
|
109
|
+
**UX during sequential runs:**
|
|
110
|
+
```
|
|
111
|
+
⟡🔮↔🔮 MERLIN·DUO › [1/3] Codex writing diff…
|
|
112
|
+
⟡🔮↔🔮 MERLIN·DUO › [2/3] Claude reviewing…
|
|
113
|
+
⟡🔮↔🔮 MERLIN·DUO › [3/3] Decision: approve · proceeding to verify
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
If decider returns `revise`:
|
|
117
|
+
```
|
|
118
|
+
⟡🔮↔🔮 MERLIN·DUO › Iteration 2/2: codex revising…
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## reviewer-decider is Claude-only (P0 safety)
|
|
124
|
+
|
|
125
|
+
`reviewer-decider` MUST NOT be invoked via `codex-as.sh`. Codex impersonating the gate that checks Codex's own output destroys the sequential safety story. The curated specialists list in `codex-routing.md` explicitly excludes `reviewer-decider` — this exclusion is permanent and must not be removed.
|
|
126
|
+
|
|
127
|
+
See also: `codex-routing.md` § "Curated specialists exclusion".
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Auto-offer for risky tasks
|
|
132
|
+
|
|
133
|
+
When duo is OFF, Merlin runs a pre-route hook at the start of every routing decision:
|
|
134
|
+
|
|
135
|
+
1. Call `~/.claude/scripts/duo-pre-route.sh --task "..." [--workflow X] [--files a,b] [--loc N]`
|
|
136
|
+
2. Hook reads `duo-mode.json`, calls `duo-installed.sh`, calls `duo-risk-detect.sh`, checks `duo-suppress.json`
|
|
137
|
+
3. Hook outputs exactly one of:
|
|
138
|
+
- `mode=duo` → proceed with duo routing (duo was already on)
|
|
139
|
+
- `mode=offer` → invoke `Skill("merlin:duo", args="offer")` first; user response determines mode
|
|
140
|
+
- `mode=solo` → fall through to existing solo/codex-mode rules
|
|
141
|
+
|
|
142
|
+
**Risk threshold:** `duo-risk-detect.sh` scores the task 0–100. `suggest_duo: true` when score ≥ 50. Threshold tunable via env `MERLIN_DUO_OFFER_THRESHOLD`.
|
|
143
|
+
|
|
144
|
+
**Offer fires only when ALL of the following are true:**
|
|
145
|
+
- `duo-installed.sh` exits 0 (Codex installed)
|
|
146
|
+
- Duo is currently OFF (or auto-expired)
|
|
147
|
+
- Risk score ≥ threshold
|
|
148
|
+
- Not suppressed (no `session_skip`, task hash not in `task_hashes_declined`, intent not in `never_for_intents`)
|
|
149
|
+
|
|
150
|
+
**Offer is one-shot per task.** Never offer mid-flight. If `duo-pre-route.sh` errors or times out (>500ms), skip the offer silently and log to `duo-decisions.log`.
|
|
151
|
+
|
|
152
|
+
**Suppression memory:** `~/.claude/merlin-state/duo-suppress.json`. Reset via `Skill("merlin:duo", args="unsuppress")`. `never_for_intents` entries auto-expire after 7 days. `session_skip` clears when file mtime is > 12h old.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Codex-broken-runtime fallback (P0)
|
|
157
|
+
|
|
158
|
+
`duo-installed.sh` only checks PATH presence. If Codex is on PATH but fails at runtime:
|
|
159
|
+
|
|
160
|
+
1. Wrap all Codex invocations with a 60s timeout:
|
|
161
|
+
- With coreutils: `gtimeout 60s codex …`
|
|
162
|
+
- Without coreutils: `perl -e 'alarm 60; exec @ARGV' codex …`
|
|
163
|
+
2. On Codex error or timeout: log to `~/.claude/merlin-state/duo-decisions.log` with severity `codex_runtime_failure`. Fall back to Claude for that step:
|
|
164
|
+
- Parallel branch: drop the codex result; arbiter receives one input
|
|
165
|
+
- Sequential branch: Claude takes the author role
|
|
166
|
+
3. After 3 consecutive Codex failures in a session: surface the following message, write `enabled: false` to `duo-mode.json` with `lastToggleReason: "codex runtime failures"`, and revert to solo:
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
⟡🔮 MERLIN › Codex appears unhealthy. Reverting to solo for this session. Run 'codex doctor' to diagnose.
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Verification authority (UNCHANGED)
|
|
175
|
+
|
|
176
|
+
Claude ALWAYS runs `merlin_run_verification()` after a duo flow completes, regardless of who wrote the code. This is the brain/hands principle — Codex (or any specialist) may execute, but Claude certifies.
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Workflow integration
|
|
181
|
+
|
|
182
|
+
| Workflow | When duo ON | When duo OFF |
|
|
183
|
+
|---|---|---|
|
|
184
|
+
| `feature-dev` | Parallel planning + sequential coding | Existing codex-mode dual-plan or solo |
|
|
185
|
+
| `refactor` | Parallel planning + sequential coding | Existing codex-mode dual-plan or solo |
|
|
186
|
+
| `product-dev` | Parallel planning + sequential coding | Solo |
|
|
187
|
+
| `bug-fix` | Solo by default; opt-in via "duo this" | Solo |
|
|
188
|
+
| `quick` | Solo by default; opt-in via "duo this" | Solo |
|
|
189
|
+
| Code review intent | Parallel review pair | `code-review` or `codex-code-review` (codex-routing rules) |
|
|
190
|
+
| Tests intent | Parallel test generation pair | Solo |
|
|
191
|
+
| Docs intent | Parallel docs pair | Solo |
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Cross-references
|
|
196
|
+
|
|
197
|
+
- Codex execution layer: `~/.claude/rules/codex-routing.md`
|
|
198
|
+
- Intent triggers (toggle phrases, workflow routing): `~/.claude/rules/merlin-routing.md`
|
|
199
|
+
- Skill prompts: `~/.claude/skills/merlin/duo/` (SKILL.md, on.md, off.md, status.md, offer.md)
|
|
200
|
+
- Reviewer-decider agent: `~/.claude/agents/reviewer-decider.md`
|
|
201
|
+
- Badge audit: `.planning/duo/BADGE-AUDIT.md`
|
|
202
|
+
- State files: `~/.claude/merlin-state/duo-mode.json`, `~/.claude/merlin-state/duo-suppress.json`
|
|
203
|
+
- Decision audit log: `~/.claude/merlin-state/duo-decisions.log` (JSONL, append-only)
|
|
@@ -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
|