@muggleai/works 5.5.0 → 5.5.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/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/plugin/.cursor-plugin/plugin.json +1 -1
- package/dist/plugin/hooks/README.md +6 -0
- package/dist/plugin/hooks/hooks.json +6 -0
- package/dist/plugin/scripts/reconcile-stale-watchers.sh +59 -0
- package/dist/plugin/skills/_shared/post-merge-cleanup.md +2 -3
- package/dist/plugin/skills/_shared/session-state-writes.md +1 -1
- package/dist/plugin/skills/_shared/telemetry-events/muggle-do-cycle.md +1 -1
- package/dist/plugin/skills/do/resolve-conflicts.md +5 -5
- package/dist/plugin/skills/muggle-pr-followup/CLAUDE.md +1 -1
- package/dist/plugin/skills/muggle-pr-followup/SKILL.md +2 -0
- package/dist/plugin/skills/muggle-pr-followup/contract.md +8 -4
- package/dist/plugin/skills/muggle-pr-followup/evals/evals.json +89 -0
- package/dist/plugin/skills/muggle-pr-followup/reconcile.md +10 -0
- package/dist/plugin/skills/muggle-pr-followup/state-schemas.md +9 -5
- package/dist/release-manifest.json +4 -4
- package/package.json +6 -6
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.cursor-plugin/plugin.json +1 -1
- package/plugin/hooks/README.md +6 -0
- package/plugin/hooks/hooks.json +6 -0
- package/plugin/scripts/reconcile-stale-watchers.sh +59 -0
- package/plugin/skills/_shared/post-merge-cleanup.md +2 -3
- package/plugin/skills/_shared/session-state-writes.md +1 -1
- package/plugin/skills/_shared/telemetry-events/muggle-do-cycle.md +1 -1
- package/plugin/skills/do/resolve-conflicts.md +5 -5
- package/plugin/skills/muggle-pr-followup/CLAUDE.md +1 -1
- package/plugin/skills/muggle-pr-followup/SKILL.md +2 -0
- package/plugin/skills/muggle-pr-followup/contract.md +8 -4
- package/plugin/skills/muggle-pr-followup/evals/evals.json +89 -0
- package/plugin/skills/muggle-pr-followup/reconcile.md +10 -0
- package/plugin/skills/muggle-pr-followup/state-schemas.md +9 -5
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muggle",
|
|
3
3
|
"description": "Run real-browser end-to-end (E2E) acceptance tests on your web app from any AI coding agent. Generate test scripts from plain English, replay them on localhost, capture screenshots, and validate user flows like signup, checkout, and dashboards. Works across Claude Code, Cursor, Codex, and Windsurf.",
|
|
4
|
-
"version": "5.5.
|
|
4
|
+
"version": "5.5.1",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Muggle AI",
|
|
7
7
|
"email": "support@muggle-ai.com"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "muggle",
|
|
3
3
|
"displayName": "Muggle AI",
|
|
4
4
|
"description": "Ship quality products with AI-powered end-to-end (E2E) acceptance testing that validates your web app like a real user — from Claude Code and Cursor to PR.",
|
|
5
|
-
"version": "5.5.
|
|
5
|
+
"version": "5.5.1",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Muggle AI",
|
|
8
8
|
"email": "support@muggle-ai.com"
|
|
@@ -35,3 +35,9 @@ Each guardrail is a thin bash wrapper in `../scripts/` registered in `hooks.json
|
|
|
35
35
|
| `PreToolUse` (Bash) | `guardrail-report-format.sh` | **enforce** | a `gh pr comment\|create\|edit` body reads like an E2E report but lacks the `build-pr-section` sentinel | — | **deny** — render via `muggle build-pr-section` instead |
|
|
36
36
|
| `Stop` | `guardrail-e2e-gate.sh` | **enforce** | unit tests passed this session and no E2E ran yet | `autoE2ETest` | **block** the turn until E2E runs via `muggle-test` (releases after 3 blocks) |
|
|
37
37
|
| `UserPromptSubmit` | `guardrail-build-router.sh` | advise | a build/implement/fix request (first one this session) | `autoRouteBuildToMuggleDo` | route the work through `muggle-do` (build delegated to superpowers) |
|
|
38
|
+
|
|
39
|
+
## Session-start reconcile nudge
|
|
40
|
+
|
|
41
|
+
`SessionStart` (`scripts/reconcile-stale-watchers.sh`) — a standalone advisory, not part of the `guardrails.mjs` decision tree above.
|
|
42
|
+
|
|
43
|
+
`muggle-pr-followup` watchers are session-only `/loop` crons; they die on session end and the 7-day `/loop` expiry, leaving open PRs with no live poller. The skill's [`reconcile`](../skills/muggle-pr-followup/reconcile.md) procedure recovers them — finalizes slots whose PR went terminal, sweeps orphan crons, re-arms silently-stopped open watchers — but re-arming needs the `CronCreate` tool, which a shell hook can't call. So this hook nudges rather than acts: it scans `~/.muggle-ai/muggle-do/sessions/*/` for open slots (a `prs.json` with no `result.md`) and, **only when one or more exist**, injects `additionalContext` telling the agent to run `/muggle:muggle-pr-followup reconcile`. Zero open slots → it emits nothing. A pure directory scan (no `gh`, no writes), so it's cheap enough for every session start.
|
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/ensure-electron-app.sh\"",
|
|
10
10
|
"async": false,
|
|
11
11
|
"timeout": 120
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"type": "command",
|
|
15
|
+
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/reconcile-stale-watchers.sh\"",
|
|
16
|
+
"async": false,
|
|
17
|
+
"timeout": 10
|
|
12
18
|
}
|
|
13
19
|
]
|
|
14
20
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
# muggle-pr-followup watchers are session-only crons — they die on session end
|
|
6
|
+
# and on the 7-day /loop expiry, leaving open PRs with no live poller. Re-arming
|
|
7
|
+
# needs CronCreate, a Claude tool a shell hook can't call, so this hook can't
|
|
8
|
+
# recover a watcher itself. It nudges instead: on session start, if any open slot
|
|
9
|
+
# exists, it tells the agent to run reconcile (which finalizes terminal slots and
|
|
10
|
+
# re-arms silently-dead open watchers). A pure directory scan — no gh, no writes —
|
|
11
|
+
# so it stays cheap enough to run on every session start.
|
|
12
|
+
|
|
13
|
+
escape_for_json() {
|
|
14
|
+
local s="$1"
|
|
15
|
+
s="${s//\\/\\\\}"
|
|
16
|
+
s="${s//\"/\\\"}"
|
|
17
|
+
s="${s//$'\n'/\\n}"
|
|
18
|
+
s="${s//$'\r'/\\r}"
|
|
19
|
+
s="${s//$'\t'/\\t}"
|
|
20
|
+
printf '%s' "$s"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# A slot is a live-or-dead open watcher iff it has prs.json (a tracked PR) but no
|
|
24
|
+
# result.md (not yet finalized). result.md is the terminal marker.
|
|
25
|
+
sessions_dir="${HOME}/.muggle-ai/muggle-do/sessions"
|
|
26
|
+
stale_count=0
|
|
27
|
+
if [ -d "$sessions_dir" ]; then
|
|
28
|
+
for slot in "$sessions_dir"/*/; do
|
|
29
|
+
[ -d "$slot" ] || continue
|
|
30
|
+
if [ -f "${slot}prs.json" ] && [ ! -f "${slot}result.md" ]; then
|
|
31
|
+
stale_count=$((stale_count + 1))
|
|
32
|
+
fi
|
|
33
|
+
done
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
# Clean state: no open slots to reconcile → stay completely silent, never nudge.
|
|
37
|
+
if [ "$stale_count" -eq 0 ]; then
|
|
38
|
+
exit 0
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
if [ "$stale_count" -eq 1 ]; then
|
|
42
|
+
slot_word="slot"
|
|
43
|
+
else
|
|
44
|
+
slot_word="slots"
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
context="muggle-pr-followup: ${stale_count} open watcher ${slot_word} found (a tracked PR with no result.md). Session-only watcher crons die on session end and after the 7-day /loop expiry, so these may have no live poller. Run \`/muggle:muggle-pr-followup reconcile\` to finalize any whose PR went terminal and re-arm silently-stopped open watchers. Reconcile is idempotent — it re-arms only genuinely dead watchers and stays quiet on the rest."
|
|
48
|
+
|
|
49
|
+
escaped_context=$(escape_for_json "$context")
|
|
50
|
+
|
|
51
|
+
if [ -n "${CURSOR_PLUGIN_ROOT:-}" ]; then
|
|
52
|
+
printf '{\n "additional_context": "%s"\n}\n' "$escaped_context"
|
|
53
|
+
elif [ -n "${CLAUDE_PLUGIN_ROOT:-}" ]; then
|
|
54
|
+
printf '{\n "hookSpecificOutput": {\n "hookEventName": "SessionStart",\n "additionalContext": "%s"\n }\n}\n' "$escaped_context"
|
|
55
|
+
else
|
|
56
|
+
printf '{\n "additional_context": "%s"\n}\n' "$escaped_context"
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
exit 0
|
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Gated by [`autoCleanup`](../muggle-preferences/preference-gates/autoCleanup.md). Follow the standard procedure in [`preference-gates/README.md`](../muggle-preferences/preference-gates/README.md). Fire only after the PR is **merged** — never while it's still open.
|
|
4
4
|
|
|
5
|
-
On `always`, the
|
|
5
|
+
On `always`, the steps below run as one pre-authorized sequence (no per-step prompts). Stop on the first failure; do not force.
|
|
6
6
|
|
|
7
|
-
1. `git worktree remove {worktreePath}
|
|
7
|
+
1. **Remove the worktree — link-safe.** `git worktree remove {worktreePath}`, only if a worktree was used. A worktree's dependency dir (e.g. `node_modules`) is often a **link** (symlink, or a Windows junction) to a shared tree, not a real copy — and a forced/recursive delete follows the link and wipes that shared target, breaking every worktree. So never `--force`: unlink the dependency link first (remove the link only, using the host OS's unlink), then a plain `git worktree remove {worktreePath}`.
|
|
8
8
|
2. `git branch -d {branch}` — **skip when no worktree was used**: the branch is the user's current live checkout (a bootstrap/auto-track watcher), and the checked-out branch must never be deleted. Then `git push origin --delete {branch}`.
|
|
9
9
|
3. Clear `.muggle-ai/` session folders for this branch's runs and stale `/tmp/muggle-prepare-*.log` files. Cloud results stay.
|
|
10
|
-
4. Invoke `commit-commands:clean_gone` via the `Skill` tool.
|
|
@@ -18,5 +18,5 @@ If you script the rewrite instead of using the Write tool, any tool that replace
|
|
|
18
18
|
|
|
19
19
|
## Field map
|
|
20
20
|
|
|
21
|
-
- `last_seen.json` — one object keyed by `"<owner>/<repo>#<n>"`. Mutate fields under that key: `idle_tick_count`, `cycles_completed` (counters), `last_pushed_sha`, `lastBodyReviewId` (scalars), `pushed_shas`, `escalated_review_ids`, `ci_escalated_shas`, `
|
|
21
|
+
- `last_seen.json` — one object keyed by `"<owner>/<repo>#<n>"`. Mutate fields under that key: `idle_tick_count`, `cycles_completed` (counters), `last_pushed_sha`, `lastBodyReviewId` (scalars), `pushed_shas`, `escalated_review_ids`, `ci_escalated_shas`, `conflict_escalated_keys` (arrays), `ci_fix_attempts[<sha>]` (keyed by head SHA), `conflict_resolve_attempts[<head-sha>..<base-tip-sha>]` (keyed by the head/base-tip pair).
|
|
22
22
|
- `prs.json` — a one-element array. Mutate `[0]`: `head_sha`, `state`.
|
|
@@ -30,6 +30,6 @@ One per address-reviews invocation, regardless of outcome.
|
|
|
30
30
|
- `"ci-fixed"` — a watcher-dispatched fix-ci cycle pushed a fix for one or more red checks.
|
|
31
31
|
- `"ci-escalated"` — fix-ci exhausted its 3 attempts for the SHA or the failing checks were out of scope; the SHA was added to `ci_escalated_shas`. No further auto-fix on it.
|
|
32
32
|
- `"rebased"` — a watcher-dispatched rebase cycle rebased the branch onto its base (behind-only or conflicts resolved), verified, and force-pushed.
|
|
33
|
-
- `"rebase-escalated"` — the rebase couldn't be completed (a conflict under `autoResolveConflicts=never`, verification failed, or the 2-attempt budget for the
|
|
33
|
+
- `"rebase-escalated"` — the rebase couldn't be completed (a conflict under `autoResolveConflicts=never`, verification failed, or the 2-attempt budget for the head/base-tip pair was spent); the pair was added to `conflict_escalated_keys`. No further auto-rebase until either side moves.
|
|
34
34
|
|
|
35
35
|
For fix-ci cycles (`ci-fixed` / `ci-escalated`) the `review_ids_*` arrays are empty and the `ci_checks_*` arrays carry the data: `ci_checks_in` (red checks dispatched), `ci_checks_fixed` (made green and pushed), `ci_checks_escalated` (out-of-scope or unresolved). For rebase cycles (`rebased` / `rebase-escalated`) all the `review_ids_*` and `ci_checks_*` arrays are empty; the SHA fields carry the before/after of the rebase.
|
|
@@ -14,13 +14,13 @@ Rebase a PR's branch onto its base — whether it's merely **behind** (out of da
|
|
|
14
14
|
|
|
15
15
|
## Inputs from disk
|
|
16
16
|
|
|
17
|
-
From `~/.muggle-ai/muggle-do/sessions/<slug>/`: `prs.json` (PR + branch + `head_sha`), `last_seen.json` (`conflict_resolve_attempts`, `
|
|
17
|
+
From `~/.muggle-ai/muggle-do/sessions/<slug>/`: `prs.json` (PR + branch + `head_sha`), `last_seen.json` (`conflict_resolve_attempts`, `conflict_escalated_keys`, `pushed_shas`), `state.md` (worktree path, validation strategy, base branch).
|
|
18
18
|
|
|
19
19
|
## Procedure
|
|
20
20
|
|
|
21
21
|
### Step 1 — Re-attach
|
|
22
22
|
|
|
23
|
-
Materialize the PR branch in its worktree per [`../_shared/pr-branch-worktree.md`](../_shared/pr-branch-worktree.md) (or use `state.md`'s `worktreePath`). Capture `rebase_sha = prs.json[0].head_sha` and the base branch (`baseRefName` from [`../_shared/vcs/github/pr-metadata.md`](../_shared/vcs/github/pr-metadata.md)).
|
|
23
|
+
Materialize the PR branch in its worktree per [`../_shared/pr-branch-worktree.md`](../_shared/pr-branch-worktree.md) (or use `state.md`'s `worktreePath`). Capture `rebase_sha = prs.json[0].head_sha` and the base branch (`baseRefName` from [`../_shared/vcs/github/pr-metadata.md`](../_shared/vcs/github/pr-metadata.md)). Capture the base tip too — `.base_commit.sha` from that recipe's `compare` call — and form `rebase_key = "<rebase_sha>..<base_tip_sha>"`. Both budget fields below are keyed on that pair, matching the watcher's Step 5; writing a bare SHA instead leaves an entry the watcher ignores, and the rebase re-dispatches forever.
|
|
24
24
|
|
|
25
25
|
### Step 2 — Rebase onto base (resolve conflicts if any)
|
|
26
26
|
|
|
@@ -37,13 +37,13 @@ Build (typecheck + lint on the changed surface) + unit suite must pass. Run E2E
|
|
|
37
37
|
|
|
38
38
|
### Step 4 — Force-push + respawn
|
|
39
39
|
|
|
40
|
-
Push with `--force-with-lease` (the rebase rewrote history). Append the new SHA to `last_seen.pushed_shas`; increment `last_seen.conflict_resolve_attempts[
|
|
40
|
+
Push with `--force-with-lease` (the rebase rewrote history). Append the new SHA to `last_seen.pushed_shas`; increment `last_seen.conflict_resolve_attempts[rebase_key]` — both whole-file rewrites (Read → change field → Write) per [`../_shared/session-state-writes.md`](../_shared/session-state-writes.md), never the Edit tool. Respawn the watcher per [`respawn-watcher.md`](respawn-watcher.md). Its next tick re-checks the branch against its base on the new head — the rebase is its own verify loop, bounded by the per-SHA attempt budget.
|
|
41
41
|
|
|
42
42
|
### Step 5 — Escalate (can't resolve / budget spent)
|
|
43
43
|
|
|
44
|
-
When `autoResolveConflicts=never`, the resolution failed verification, or `conflict_resolve_attempts[
|
|
44
|
+
When `autoResolveConflicts=never`, the resolution failed verification, or `conflict_resolve_attempts[rebase_key]` has reached 2:
|
|
45
45
|
|
|
46
|
-
1. Add `
|
|
46
|
+
1. Add `rebase_key` to `last_seen.conflict_escalated_keys` so the watcher does not re-dispatch this head against this base. If the base later moves, the key changes and the branch re-arms on its own — a conflict the user resolved upstream stops being this watcher's dead end.
|
|
47
47
|
2. Emit one terminal escalation naming the PR and the conflicting files (or the failing verification, for a behind-only rebase that didn't verify).
|
|
48
48
|
3. Respawn the watcher per [`respawn-watcher.md`](respawn-watcher.md) — it keeps polling for the user's manual resolution or any new reviews.
|
|
49
49
|
|
|
@@ -12,7 +12,7 @@ This folder holds the watcher loop that drives one PR toward merge-ready. The wa
|
|
|
12
12
|
- [`finalize.md`](finalize.md) — shared termination sequence for a terminal PR (mark terminal, `result.md`, log/telemetry, unschedule cron, post-merge cleanup handoff). Called by `contract.md` and `reconcile.md`.
|
|
13
13
|
- [`cancel-cron.md`](cancel-cron.md) — stops this watcher's cron, recorded-id-first (survives `CronList` going blind) with a `CronList`-match fallback, plus the tool-call-not-shell guard. Referenced by `contract.md` and `finalize.md`.
|
|
14
14
|
- [`record-cron-id.md`](record-cron-id.md) — the per-tick self-record that keeps this slot's cron id in `cron.json` deletable after a compaction blinds `CronList`. Referenced by `contract.md` Step 0.
|
|
15
|
-
- [`reconcile.md`](reconcile.md) — sweep that finalizes slots whose PR went terminal while polling lapsed, deletes orphaned crons, and re-arms open slots whose watcher stopped silently (dropped respawn); runs at the top of auto-track and
|
|
15
|
+
- [`reconcile.md`](reconcile.md) — sweep that finalizes slots whose PR went terminal while polling lapsed, deletes orphaned crons, and re-arms open slots whose watcher stopped silently (dropped respawn); runs on demand, at the top of auto-track, and nudged by a session-start hook.
|
|
16
16
|
- [`state-schemas.md`](state-schemas.md) — canonical JSON shapes of session state files.
|
|
17
17
|
- [`output-templates.md`](output-templates.md) — TOC of message templates; per-group files in `output-templates/`.
|
|
18
18
|
|
|
@@ -35,6 +35,8 @@ The skill recognizes its mode by inspecting `$ARGUMENTS` and falling back to on-
|
|
|
35
35
|
|
|
36
36
|
Auto-track runs **reconcile** first, so a no-arg invocation also finalizes any slot whose PR merged or closed while its watcher was down (expired cron, ended session) and re-arms any open slot whose watcher stopped silently (a dropped respawn). Reconcile recovers a watcher that was already running; it never seeds a first watcher for a PR — that is auto-track's / bootstrap's job.
|
|
37
37
|
|
|
38
|
+
**Reconcile also runs at session start** — a `SessionStart` hook ([`../../hooks/README.md`](../../hooks/README.md)) surfaces the sweep when open slots exist, catching a watcher that died with its session (end, or 7-day `/loop` expiry) before a manual sweep would. See [`reconcile.md`](reconcile.md#triggers).
|
|
39
|
+
|
|
38
40
|
Bootstrap accepts three optional trailing flags:
|
|
39
41
|
|
|
40
42
|
- `--slug=<name>` — override the default `<repo>-pr<n>` slug
|
|
@@ -37,7 +37,7 @@ Otherwise, self-record this watcher's cron id per [`record-cron-id.md`](record-c
|
|
|
37
37
|
|
|
38
38
|
### Step 1 — Refresh PR state
|
|
39
39
|
|
|
40
|
-
Per [`../_shared/vcs/github/pr-metadata.md`](../_shared/vcs/github/pr-metadata.md). Update `prs.json[0].head_sha` and `prs.json[0].state` from the response; keep `mergeable` (conflict signal) for Step 5, and run the recipe's `compare` call to capture `behind_by` (out-of-date signal)
|
|
40
|
+
Per [`../_shared/vcs/github/pr-metadata.md`](../_shared/vcs/github/pr-metadata.md). Update `prs.json[0].head_sha` and `prs.json[0].state` from the response; keep `mergeable` (conflict signal) for Step 5, and run the recipe's `compare` call to capture both `behind_by` (out-of-date signal) and `.base_commit.sha` — the base branch tip, Step 5's `base_tip_sha`.
|
|
41
41
|
|
|
42
42
|
### Step 2 — Termination check
|
|
43
43
|
|
|
@@ -102,7 +102,11 @@ A merge-ready branch is **current with its base** — neither conflicting nor be
|
|
|
102
102
|
|
|
103
103
|
This trigger is **independent of approval and CI state**: an out-of-date branch is rebased whether or not it has been reviewed, approved, or has green checks. The watcher acts on staleness directly — it never waits for an approval to surface it.
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
Rebase dedup is keyed on the **pair** `rebase_key = "<head_sha>..<base_tip_sha>"`, not on the head alone. Whether a branch conflicts is a function of both sides, so a head-only key wedges a PR permanently the first time the base moves: the head cannot change while nobody pushes, so one stale entry suppresses every genuinely new conflict that base movement introduces, forever. Take `base_tip_sha` from the Step 1 compare's `.base_commit.sha` (the base branch tip, which advances when the base does) — **never** `.merge_base_commit.sha`, which does not move when only the base advances and so would never re-arm.
|
|
106
|
+
|
|
107
|
+
Entries written by an older watcher are bare head SHAs with no `..` — ignore them when reading `conflict_escalated_keys`, which re-arms any slot a head-only key had wedged.
|
|
108
|
+
|
|
109
|
+
If a rebase is due **and** `conflict_resolve_attempts[rebase_key] < 2` **and** `rebase_key` ∉ `conflict_escalated_keys` → dispatch and exit:
|
|
106
110
|
|
|
107
111
|
1. Reset `last_seen.idle_tick_count` to 0.
|
|
108
112
|
2. **Stop this watcher (single-thread):** cancel its cron exactly as in Step 4 — `/muggle-do`'s rebase respawns it when the cycle is done.
|
|
@@ -114,7 +118,7 @@ If a rebase is due **and** `conflict_resolve_attempts[head_sha] < 2` **and** `he
|
|
|
114
118
|
4. Append a dispatching line to `followup.log`; emit a `tick` event with `rebase_needed: true`, `dispatched_rebase: true`.
|
|
115
119
|
5. Exit. The dev cycle owns the PR; its respawn restarts the watcher, whose next tick re-checks the branch against its base on the new head — the rebase is its own verify loop, bounded by the per-SHA attempt budget.
|
|
116
120
|
|
|
117
|
-
Otherwise — `behind_by == 0` and not conflicting (`mergeable == UNKNOWN` is fine here: `behind_by` is exact while GitHub is still computing conflict state, so a stale branch still triggers), or budget spent (`conflict_resolve_attempts[
|
|
121
|
+
Otherwise — `behind_by == 0` and not conflicting (`mergeable == UNKNOWN` is fine here: `behind_by` is exact while GitHub is still computing conflict state, so a stale branch still triggers), or budget spent (`conflict_resolve_attempts[rebase_key] >= 2` or `rebase_key` ∈ `conflict_escalated_keys`) → fall through to CI.
|
|
118
122
|
|
|
119
123
|
### Step 6 — No actionable feedback, branch current → poll CI for the head SHA
|
|
120
124
|
|
|
@@ -137,7 +141,7 @@ Fetch the CI rollup for `prs.json[0].head_sha`, provider resolved as in Step 3
|
|
|
137
141
|
|
|
138
142
|
Any idle branch (Steps 4–6 that did not dispatch). First classify **why** this tick idled. It is **blocked pending a human** when the head is under a durable block that only the user can clear:
|
|
139
143
|
|
|
140
|
-
- `
|
|
144
|
+
- `rebase_key` ∈ `conflict_escalated_keys` — a rebase `/muggle-do` gave up on (a semantic conflict, or `autoResolveConflicts=never`), reason `conflict_escalated`. This block clears on its own when the base moves: the new `base_tip_sha` yields a key the set does not contain, and the branch re-arms for a fresh rebase attempt; or
|
|
141
145
|
- `head_sha` ∈ `ci_escalated_shas` — CI the fix-ci stage gave up on, reason `ci_escalated`; or
|
|
142
146
|
- `last_seen.escalated_review_ids` is non-empty with the actionable set empty — an ambiguous review awaiting the user's direction, reason `reviews_escalated`.
|
|
143
147
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
{
|
|
2
|
+
"skill_name": "muggle-pr-followup",
|
|
3
|
+
"notes": "These evals test the TICK DECISION behavior. A real tick needs live GitHub state and the Cron tools, so each prompt hands the model a concrete slot + provider state and asks for the step-by-step plan it would execute; assertions check the plan takes the branch the contract mandates. Run via skill-creator's eval runner — CI does not execute these (ci.yml gates on vitest/check-skill-deps; skill-eval.yml runs gate scenarios sourced from muggle-ai-brain and the routing eval).",
|
|
4
|
+
"evals": [
|
|
5
|
+
{
|
|
6
|
+
"id": 0,
|
|
7
|
+
"eval_name": "actionable-threads-dispatch-address-reviews",
|
|
8
|
+
"prompt": "Tick muggle-ai-works-pr500 500. The slot is open. The PR has two unresolved, not-outdated review threads whose newest comments are from a human (no muggle-do:bot marker), CI is fully green, and the branch is level with master. Walk me through the plan for this tick.",
|
|
9
|
+
"files": [],
|
|
10
|
+
"assertions": [
|
|
11
|
+
{ "name": "computes_actionable_from_live_thread_state", "text": "Plan derives the actionable set from current unresolved-thread state, not from a stored review-id cursor." },
|
|
12
|
+
{ "name": "classifies_by_loop_marker", "text": "Plan decides a thread is actionable by the absence of the muggle-do:bot marker on the newest comment, never by author login." },
|
|
13
|
+
{ "name": "cancels_own_cron_before_dispatch", "text": "Plan cancels this watcher's own cron before dispatching, so no tick overlaps the running cycle." },
|
|
14
|
+
{ "name": "dispatches_address_reviews", "text": "Plan dispatches /muggle-do with an address-reviews directive carrying the PR URL, slug, and owning review ids, then exits." },
|
|
15
|
+
{ "name": "stays_a_dumb_pipe", "text": "Plan does NOT classify reviews, post replies, resolve threads, or escalate — those belong to /muggle-do." }
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"id": 1,
|
|
20
|
+
"eval_name": "reviews-preempt-ci",
|
|
21
|
+
"prompt": "Tick muggle-ai-works-pr501 501. The slot is open. There is one unresolved human review thread AND two red required checks on the head SHA. Walk me through the plan for this tick.",
|
|
22
|
+
"files": [],
|
|
23
|
+
"assertions": [
|
|
24
|
+
{ "name": "dispatches_address_reviews_only", "text": "Plan dispatches address-reviews for the review thread." },
|
|
25
|
+
{ "name": "does_not_poll_or_dispatch_ci", "text": "Plan does NOT dispatch fix-ci on this tick — actionable feedback preempts the CI branch entirely." },
|
|
26
|
+
{ "name": "single_dispatch_then_exit", "text": "Plan performs exactly one dispatch and exits rather than handling both concerns in one tick." }
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"id": 2,
|
|
31
|
+
"eval_name": "behind-branch-dispatches-rebase",
|
|
32
|
+
"prompt": "Tick muggle-ai-works-pr502 502. The slot is open, there are no unresolved review threads, CI is green. GitHub reports mergeStateStatus BLOCKED and mergeable MERGEABLE, and the compare call shows behind_by 4. Walk me through the plan for this tick.",
|
|
33
|
+
"files": [],
|
|
34
|
+
"assertions": [
|
|
35
|
+
{ "name": "reads_staleness_from_compare", "text": "Plan determines the branch is out of date from behind_by on the compare call (commit ancestry)." },
|
|
36
|
+
{ "name": "does_not_trust_mergeStateStatus", "text": "Plan does NOT conclude the branch is current just because mergeStateStatus reads BLOCKED rather than BEHIND." },
|
|
37
|
+
{ "name": "dispatches_rebase", "text": "Plan dispatches /muggle-do with a rebase directive (PR URL + slug, no review ids, no check names)." },
|
|
38
|
+
{ "name": "keys_dedup_on_head_and_base_pair", "text": "Plan keys the rebase attempt/escalation lookup on the head..base_tip pair, not the head SHA alone." }
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"id": 3,
|
|
43
|
+
"eval_name": "red-ci-within-budget-dispatches-fix-ci",
|
|
44
|
+
"prompt": "Tick muggle-ai-works-pr503 503. The slot is open, no unresolved review threads, branch is level with its base. Two checks are red on the head SHA. ci_fix_attempts for that SHA is 1 and the SHA is not in ci_escalated_shas. Walk me through the plan for this tick.",
|
|
45
|
+
"files": [],
|
|
46
|
+
"assertions": [
|
|
47
|
+
{ "name": "checks_attempt_budget", "text": "Plan verifies ci_fix_attempts for the head SHA is under the cap of 3 before dispatching." },
|
|
48
|
+
{ "name": "checks_escalated_set", "text": "Plan verifies the head SHA is not in ci_escalated_shas." },
|
|
49
|
+
{ "name": "dispatches_fix_ci_with_check_names", "text": "Plan dispatches /muggle-do with a fix-ci directive carrying the red check names." },
|
|
50
|
+
{ "name": "cancels_own_cron_before_dispatch", "text": "Plan cancels this watcher's cron before dispatching." }
|
|
51
|
+
]
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"id": 4,
|
|
55
|
+
"eval_name": "red-ci-over-budget-idles",
|
|
56
|
+
"prompt": "Tick muggle-ai-works-pr504 504. The slot is open, no unresolved review threads, branch is level with its base. Three checks are red on the head SHA, ci_fix_attempts for that SHA is 3, and the SHA is in ci_escalated_shas. Walk me through the plan for this tick.",
|
|
57
|
+
"files": [],
|
|
58
|
+
"assertions": [
|
|
59
|
+
{ "name": "does_not_dispatch", "text": "Plan does NOT dispatch fix-ci — the budget is spent and the SHA is already escalated." },
|
|
60
|
+
{ "name": "treats_as_durable_block", "text": "Plan classifies this idle as blocked pending a human (reason ci_escalated), not as a transient idle." },
|
|
61
|
+
{ "name": "reminds_owner_at_1m", "text": "Plan emits a one-line owner reminder and keeps the normal 1m cadence — it does not back off, slow down, or stop polling." },
|
|
62
|
+
{ "name": "no_pr_side_post", "text": "Plan does NOT post anything to the PR for a blocked tick; the reminder goes to the loop owner in-session." }
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"id": 5,
|
|
67
|
+
"eval_name": "merged-pr-finalizes-without-respawn",
|
|
68
|
+
"prompt": "Tick muggle-ai-works-pr505 505. Refreshing the PR shows state MERGED. The slot on disk still says open. Walk me through the plan for this tick.",
|
|
69
|
+
"files": [],
|
|
70
|
+
"assertions": [
|
|
71
|
+
{ "name": "finalizes_the_slot", "text": "Plan finalizes the slot: marks it terminal, writes result.md, logs, and unschedules this watcher's cron." },
|
|
72
|
+
{ "name": "does_not_respawn", "text": "Plan does NOT respawn or re-arm a watcher — a terminal PR needs none." },
|
|
73
|
+
{ "name": "hands_off_terminal_wrapup", "text": "Plan hands the terminal wrap-up to /muggle-do post-merge cleanup as the turn's last action." },
|
|
74
|
+
{ "name": "unschedules_recorded_id_first", "text": "Plan cancels the cron by the id recorded in cron.json first, falling back to a CronList match — so teardown works even after CronList has gone blind." }
|
|
75
|
+
]
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"id": 6,
|
|
79
|
+
"eval_name": "stale-queued-fire-does-not-refinalize",
|
|
80
|
+
"prompt": "Tick muggle-ai-works-pr506 506. Reading the slot, prs.json already records state merged and result.md exists — this fire was queued before the cron was cancelled. Walk me through the plan for this tick.",
|
|
81
|
+
"files": [],
|
|
82
|
+
"assertions": [
|
|
83
|
+
{ "name": "detects_stale_fire", "text": "Plan recognises this as a stale queued fire from the on-disk terminal state before fetching anything." },
|
|
84
|
+
{ "name": "does_not_refetch_or_refinalize", "text": "Plan does NOT re-fetch the PR, re-write result.md, or re-run the terminal handoff." },
|
|
85
|
+
{ "name": "defensively_cancels_and_logs", "text": "Plan defensively cancels any lingering cron for the slug and appends a stale-tick line to followup.log, then exits." }
|
|
86
|
+
]
|
|
87
|
+
}
|
|
88
|
+
]
|
|
89
|
+
}
|
|
@@ -4,6 +4,16 @@ The procedure for the **reconcile mode** of `muggle-pr-followup` — a sweep tha
|
|
|
4
4
|
|
|
5
5
|
Termination is otherwise tick-driven ([`contract.md`](contract.md) Step 2): a slot finalizes only when a tick fires and observes `MERGED` / `CLOSED`. If the tick stream stops first — the recurring `/loop` cron auto-expires after 7 days, the session ends, or the machine is off when the PR merges — no tick catches the transition, and the slot is left un-finalized: no `result.md`, no post-merge cleanup, and a surviving cron would keep polling a dead PR. Reconcile is the catch-up.
|
|
6
6
|
|
|
7
|
+
## Triggers
|
|
8
|
+
|
|
9
|
+
Three ways in, all running the same procedure:
|
|
10
|
+
|
|
11
|
+
- **Manual** — `/muggle:muggle-pr-followup reconcile` (or `sweep`).
|
|
12
|
+
- **Auto-track** — the top of a no-arg invocation ([`auto-track.md`](auto-track.md)).
|
|
13
|
+
- **Session start** — the `reconcile-stale-watchers.sh` hook ([`../../hooks/README.md`](../../hooks/README.md)) runs this sweep, catching a watcher that died with its session (end, or 7-day `/loop` cron expiry) before its PR's merge was observed.
|
|
14
|
+
|
|
15
|
+
Recover-don't-seed holds on every trigger: a session-start run still never seeds a first watcher (see Invariants).
|
|
16
|
+
|
|
7
17
|
## Input
|
|
8
18
|
|
|
9
19
|
`$ARGUMENTS` is `reconcile` (or `sweep`), optionally followed by a `<slug>` to scope the sweep to one slot.
|
|
@@ -62,8 +62,8 @@ Keyed by `"<owner>/<repo>#<n>"`. One key per PR in the slot.
|
|
|
62
62
|
"pushed_shas": ["<sha>", ...],
|
|
63
63
|
"ci_fix_attempts": { "<sha>": <int> },
|
|
64
64
|
"ci_escalated_shas": ["<sha>", ...],
|
|
65
|
-
"conflict_resolve_attempts": { "<sha>": <int> },
|
|
66
|
-
"
|
|
65
|
+
"conflict_resolve_attempts": { "<head-sha>..<base-tip-sha>": <int> },
|
|
66
|
+
"conflict_escalated_keys": ["<head-sha>..<base-tip-sha>", ...],
|
|
67
67
|
"blocked": {
|
|
68
68
|
"reason": "conflict_escalated" | "ci_escalated" | "reviews_escalated",
|
|
69
69
|
"since": "<ISO-8601>",
|
|
@@ -85,10 +85,14 @@ Keyed by `"<owner>/<repo>#<n>"`. One key per PR in the slot.
|
|
|
85
85
|
- `pushed_shas`: every SHA `/muggle-do` has pushed for this PR. Append-only. Used by the resolve-reminder stage to recognize threads addressed by the loop.
|
|
86
86
|
- `ci_fix_attempts`: per-SHA count of fix-ci cycles `/muggle-do` has run. The watcher stops dispatching fix-ci for a SHA once its count reaches 3. Keyed by head SHA.
|
|
87
87
|
- `ci_escalated_shas`: head SHAs whose CI the fix-ci stage gave up on (attempts exhausted or only out-of-scope checks). The watcher excludes these from CI dispatch so a hopeless SHA is never re-fixed.
|
|
88
|
-
- `conflict_resolve_attempts`:
|
|
89
|
-
- `
|
|
88
|
+
- `conflict_resolve_attempts`: count of rebase cycles `/muggle-do` has run (behind-only or conflicting — both rebase onto the base). The watcher stops dispatching once a key's count reaches 2. Keyed by `rebase_key` — `"<head_sha>..<base_tip_sha>"`, the head paired with the base branch tip it was measured against.
|
|
89
|
+
- `conflict_escalated_keys`: `rebase_key`s whose rebase `/muggle-do` gave up on (attempts exhausted, or a conflict under `autoResolveConflicts=never`). The watcher excludes these from rebase dispatch so a hopeless pairing is never re-attempted.
|
|
90
|
+
|
|
91
|
+
Both are keyed on the pair, not the head alone, because whether a branch conflicts depends on both sides. Under a head-only key, a base that moves produces a genuinely new conflict against an unchanged head — and the stale entry suppresses it permanently, because nothing can change the head while the branch sits blocked. Pairing re-arms the budget whenever either side moves. Legacy entries written before this change are bare SHAs with no `..`; readers ignore them, which un-wedges any slot they had blocked.
|
|
92
|
+
|
|
93
|
+
Unlike these, `ci_fix_attempts` / `ci_escalated_shas` stay keyed on the head SHA alone — a CI result is a function of the head only, so base movement must not re-arm them.
|
|
90
94
|
- `blocked`: present only while the watcher is **awaiting the owner** on a PR that cannot progress without a human ([`contract.md`](contract.md) Step 7). Absent ⇒ the watcher is in its normal dispatch flow. When present, the watcher **keeps the normal `1m` cadence** and each tick is a reminder-or-resume check ([`contract.md`](contract.md) Step 2.5): it re-emits a one-line reminder to the owner, recomputes the `fingerprint`, and clears the block the moment any component moves. Its value is the reason-specific reminder plus fingerprint auto-resume.
|
|
91
|
-
- `reason`: which durable block is being awaited — `conflict_escalated` (`
|
|
95
|
+
- `reason`: which durable block is being awaited — `conflict_escalated` (`rebase_key` ∈ `conflict_escalated_keys`), `ci_escalated` (`head_sha` ∈ `ci_escalated_shas`), or `reviews_escalated` (a review sits in `escalated_review_ids` awaiting the user, actionable set empty). Selects the reminder wording; the resume decision is fingerprint-driven, not reason-driven.
|
|
92
96
|
- `since`: when the block was first flagged — lets the reminder state how long the owner has been the blocker.
|
|
93
97
|
- `fingerprint`: the external state the block is waiting on. `head_sha` moves on a new push (which also clears the per-SHA escalation sets, keyed by SHA); `latest_review_id` is `max(id)` over submitted reviews and moves when a reviewer submits anything new; `ci_digest` is a stable digest of the head SHA's CI rollup (bucket + each check's name/conclusion, sorted) and moves when a check flips, a rerun lands, or an external check such as a staging deploy posts. Any change clears the block and resumes evaluation.
|
|
94
98
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release": "5.5.
|
|
3
|
-
"buildId": "run-
|
|
4
|
-
"commitSha": "
|
|
5
|
-
"buildTime": "2026-07-
|
|
2
|
+
"release": "5.5.1",
|
|
3
|
+
"buildId": "run-64-1",
|
|
4
|
+
"commitSha": "60e0213974d01fae642c7b480589d2e6b512f2c7",
|
|
5
|
+
"buildTime": "2026-07-20T23:29:20Z",
|
|
6
6
|
"serviceName": "muggle-ai-works-mcp"
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@muggleai/works",
|
|
3
3
|
"mcpName": "io.github.multiplex-ai/muggle",
|
|
4
|
-
"version": "5.5.
|
|
4
|
+
"version": "5.5.1",
|
|
5
5
|
"description": "Ship quality products with AI-powered E2E acceptance testing that validates your web app like a real user — from Claude Code and Cursor to PR.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "dist/index.js",
|
|
@@ -46,14 +46,14 @@
|
|
|
46
46
|
"eval:studio-gen": "tsx internal/studio-gen-eval/src/run.ts"
|
|
47
47
|
},
|
|
48
48
|
"muggleConfig": {
|
|
49
|
-
"electronAppVersion": "1.6.
|
|
49
|
+
"electronAppVersion": "1.6.10",
|
|
50
50
|
"downloadBaseUrl": "https://github.com/multiplex-ai/muggle-ai-works/releases/download",
|
|
51
51
|
"runtimeTargetDefault": "production",
|
|
52
52
|
"checksums": {
|
|
53
|
-
"darwin-arm64": "
|
|
54
|
-
"darwin-x64": "
|
|
55
|
-
"linux-x64": "
|
|
56
|
-
"win32-x64": "
|
|
53
|
+
"darwin-arm64": "11672d444a151125a4d2f3413a7f0f4f8cb2de64ecbd2a43eddca4f364743b13",
|
|
54
|
+
"darwin-x64": "6277246853cdcc3aa3fbbaae703d428cfbf85c53fda6720a71a0cadbe8f5a5f6",
|
|
55
|
+
"linux-x64": "9485e0f9f4a0fb90666e63e87bd83da3c4b865a8cff8da40dc4d1b0ad2aba242",
|
|
56
|
+
"win32-x64": "e68471bcbf4f00d501067a282e835d00bb2f4941dc42878a8f041a5dab06acdf"
|
|
57
57
|
}
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muggle",
|
|
3
3
|
"description": "Run real-browser end-to-end (E2E) acceptance tests on your web app from any AI coding agent. Generate test scripts from plain English, replay them on localhost, capture screenshots, and validate user flows like signup, checkout, and dashboards. Works across Claude Code, Cursor, Codex, and Windsurf.",
|
|
4
|
-
"version": "5.5.
|
|
4
|
+
"version": "5.5.1",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Muggle AI",
|
|
7
7
|
"email": "support@muggle-ai.com"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "muggle",
|
|
3
3
|
"displayName": "Muggle AI",
|
|
4
4
|
"description": "Ship quality products with AI-powered end-to-end (E2E) acceptance testing that validates your web app like a real user — from Claude Code and Cursor to PR.",
|
|
5
|
-
"version": "5.5.
|
|
5
|
+
"version": "5.5.1",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Muggle AI",
|
|
8
8
|
"email": "support@muggle-ai.com"
|
package/plugin/hooks/README.md
CHANGED
|
@@ -35,3 +35,9 @@ Each guardrail is a thin bash wrapper in `../scripts/` registered in `hooks.json
|
|
|
35
35
|
| `PreToolUse` (Bash) | `guardrail-report-format.sh` | **enforce** | a `gh pr comment\|create\|edit` body reads like an E2E report but lacks the `build-pr-section` sentinel | — | **deny** — render via `muggle build-pr-section` instead |
|
|
36
36
|
| `Stop` | `guardrail-e2e-gate.sh` | **enforce** | unit tests passed this session and no E2E ran yet | `autoE2ETest` | **block** the turn until E2E runs via `muggle-test` (releases after 3 blocks) |
|
|
37
37
|
| `UserPromptSubmit` | `guardrail-build-router.sh` | advise | a build/implement/fix request (first one this session) | `autoRouteBuildToMuggleDo` | route the work through `muggle-do` (build delegated to superpowers) |
|
|
38
|
+
|
|
39
|
+
## Session-start reconcile nudge
|
|
40
|
+
|
|
41
|
+
`SessionStart` (`scripts/reconcile-stale-watchers.sh`) — a standalone advisory, not part of the `guardrails.mjs` decision tree above.
|
|
42
|
+
|
|
43
|
+
`muggle-pr-followup` watchers are session-only `/loop` crons; they die on session end and the 7-day `/loop` expiry, leaving open PRs with no live poller. The skill's [`reconcile`](../skills/muggle-pr-followup/reconcile.md) procedure recovers them — finalizes slots whose PR went terminal, sweeps orphan crons, re-arms silently-stopped open watchers — but re-arming needs the `CronCreate` tool, which a shell hook can't call. So this hook nudges rather than acts: it scans `~/.muggle-ai/muggle-do/sessions/*/` for open slots (a `prs.json` with no `result.md`) and, **only when one or more exist**, injects `additionalContext` telling the agent to run `/muggle:muggle-pr-followup reconcile`. Zero open slots → it emits nothing. A pure directory scan (no `gh`, no writes), so it's cheap enough for every session start.
|
package/plugin/hooks/hooks.json
CHANGED
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/ensure-electron-app.sh\"",
|
|
10
10
|
"async": false,
|
|
11
11
|
"timeout": 120
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"type": "command",
|
|
15
|
+
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/reconcile-stale-watchers.sh\"",
|
|
16
|
+
"async": false,
|
|
17
|
+
"timeout": 10
|
|
12
18
|
}
|
|
13
19
|
]
|
|
14
20
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
# muggle-pr-followup watchers are session-only crons — they die on session end
|
|
6
|
+
# and on the 7-day /loop expiry, leaving open PRs with no live poller. Re-arming
|
|
7
|
+
# needs CronCreate, a Claude tool a shell hook can't call, so this hook can't
|
|
8
|
+
# recover a watcher itself. It nudges instead: on session start, if any open slot
|
|
9
|
+
# exists, it tells the agent to run reconcile (which finalizes terminal slots and
|
|
10
|
+
# re-arms silently-dead open watchers). A pure directory scan — no gh, no writes —
|
|
11
|
+
# so it stays cheap enough to run on every session start.
|
|
12
|
+
|
|
13
|
+
escape_for_json() {
|
|
14
|
+
local s="$1"
|
|
15
|
+
s="${s//\\/\\\\}"
|
|
16
|
+
s="${s//\"/\\\"}"
|
|
17
|
+
s="${s//$'\n'/\\n}"
|
|
18
|
+
s="${s//$'\r'/\\r}"
|
|
19
|
+
s="${s//$'\t'/\\t}"
|
|
20
|
+
printf '%s' "$s"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# A slot is a live-or-dead open watcher iff it has prs.json (a tracked PR) but no
|
|
24
|
+
# result.md (not yet finalized). result.md is the terminal marker.
|
|
25
|
+
sessions_dir="${HOME}/.muggle-ai/muggle-do/sessions"
|
|
26
|
+
stale_count=0
|
|
27
|
+
if [ -d "$sessions_dir" ]; then
|
|
28
|
+
for slot in "$sessions_dir"/*/; do
|
|
29
|
+
[ -d "$slot" ] || continue
|
|
30
|
+
if [ -f "${slot}prs.json" ] && [ ! -f "${slot}result.md" ]; then
|
|
31
|
+
stale_count=$((stale_count + 1))
|
|
32
|
+
fi
|
|
33
|
+
done
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
# Clean state: no open slots to reconcile → stay completely silent, never nudge.
|
|
37
|
+
if [ "$stale_count" -eq 0 ]; then
|
|
38
|
+
exit 0
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
if [ "$stale_count" -eq 1 ]; then
|
|
42
|
+
slot_word="slot"
|
|
43
|
+
else
|
|
44
|
+
slot_word="slots"
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
context="muggle-pr-followup: ${stale_count} open watcher ${slot_word} found (a tracked PR with no result.md). Session-only watcher crons die on session end and after the 7-day /loop expiry, so these may have no live poller. Run \`/muggle:muggle-pr-followup reconcile\` to finalize any whose PR went terminal and re-arm silently-stopped open watchers. Reconcile is idempotent — it re-arms only genuinely dead watchers and stays quiet on the rest."
|
|
48
|
+
|
|
49
|
+
escaped_context=$(escape_for_json "$context")
|
|
50
|
+
|
|
51
|
+
if [ -n "${CURSOR_PLUGIN_ROOT:-}" ]; then
|
|
52
|
+
printf '{\n "additional_context": "%s"\n}\n' "$escaped_context"
|
|
53
|
+
elif [ -n "${CLAUDE_PLUGIN_ROOT:-}" ]; then
|
|
54
|
+
printf '{\n "hookSpecificOutput": {\n "hookEventName": "SessionStart",\n "additionalContext": "%s"\n }\n}\n' "$escaped_context"
|
|
55
|
+
else
|
|
56
|
+
printf '{\n "additional_context": "%s"\n}\n' "$escaped_context"
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
exit 0
|
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Gated by [`autoCleanup`](../muggle-preferences/preference-gates/autoCleanup.md). Follow the standard procedure in [`preference-gates/README.md`](../muggle-preferences/preference-gates/README.md). Fire only after the PR is **merged** — never while it's still open.
|
|
4
4
|
|
|
5
|
-
On `always`, the
|
|
5
|
+
On `always`, the steps below run as one pre-authorized sequence (no per-step prompts). Stop on the first failure; do not force.
|
|
6
6
|
|
|
7
|
-
1. `git worktree remove {worktreePath}
|
|
7
|
+
1. **Remove the worktree — link-safe.** `git worktree remove {worktreePath}`, only if a worktree was used. A worktree's dependency dir (e.g. `node_modules`) is often a **link** (symlink, or a Windows junction) to a shared tree, not a real copy — and a forced/recursive delete follows the link and wipes that shared target, breaking every worktree. So never `--force`: unlink the dependency link first (remove the link only, using the host OS's unlink), then a plain `git worktree remove {worktreePath}`.
|
|
8
8
|
2. `git branch -d {branch}` — **skip when no worktree was used**: the branch is the user's current live checkout (a bootstrap/auto-track watcher), and the checked-out branch must never be deleted. Then `git push origin --delete {branch}`.
|
|
9
9
|
3. Clear `.muggle-ai/` session folders for this branch's runs and stale `/tmp/muggle-prepare-*.log` files. Cloud results stay.
|
|
10
|
-
4. Invoke `commit-commands:clean_gone` via the `Skill` tool.
|
|
@@ -18,5 +18,5 @@ If you script the rewrite instead of using the Write tool, any tool that replace
|
|
|
18
18
|
|
|
19
19
|
## Field map
|
|
20
20
|
|
|
21
|
-
- `last_seen.json` — one object keyed by `"<owner>/<repo>#<n>"`. Mutate fields under that key: `idle_tick_count`, `cycles_completed` (counters), `last_pushed_sha`, `lastBodyReviewId` (scalars), `pushed_shas`, `escalated_review_ids`, `ci_escalated_shas`, `
|
|
21
|
+
- `last_seen.json` — one object keyed by `"<owner>/<repo>#<n>"`. Mutate fields under that key: `idle_tick_count`, `cycles_completed` (counters), `last_pushed_sha`, `lastBodyReviewId` (scalars), `pushed_shas`, `escalated_review_ids`, `ci_escalated_shas`, `conflict_escalated_keys` (arrays), `ci_fix_attempts[<sha>]` (keyed by head SHA), `conflict_resolve_attempts[<head-sha>..<base-tip-sha>]` (keyed by the head/base-tip pair).
|
|
22
22
|
- `prs.json` — a one-element array. Mutate `[0]`: `head_sha`, `state`.
|
|
@@ -30,6 +30,6 @@ One per address-reviews invocation, regardless of outcome.
|
|
|
30
30
|
- `"ci-fixed"` — a watcher-dispatched fix-ci cycle pushed a fix for one or more red checks.
|
|
31
31
|
- `"ci-escalated"` — fix-ci exhausted its 3 attempts for the SHA or the failing checks were out of scope; the SHA was added to `ci_escalated_shas`. No further auto-fix on it.
|
|
32
32
|
- `"rebased"` — a watcher-dispatched rebase cycle rebased the branch onto its base (behind-only or conflicts resolved), verified, and force-pushed.
|
|
33
|
-
- `"rebase-escalated"` — the rebase couldn't be completed (a conflict under `autoResolveConflicts=never`, verification failed, or the 2-attempt budget for the
|
|
33
|
+
- `"rebase-escalated"` — the rebase couldn't be completed (a conflict under `autoResolveConflicts=never`, verification failed, or the 2-attempt budget for the head/base-tip pair was spent); the pair was added to `conflict_escalated_keys`. No further auto-rebase until either side moves.
|
|
34
34
|
|
|
35
35
|
For fix-ci cycles (`ci-fixed` / `ci-escalated`) the `review_ids_*` arrays are empty and the `ci_checks_*` arrays carry the data: `ci_checks_in` (red checks dispatched), `ci_checks_fixed` (made green and pushed), `ci_checks_escalated` (out-of-scope or unresolved). For rebase cycles (`rebased` / `rebase-escalated`) all the `review_ids_*` and `ci_checks_*` arrays are empty; the SHA fields carry the before/after of the rebase.
|
|
@@ -14,13 +14,13 @@ Rebase a PR's branch onto its base — whether it's merely **behind** (out of da
|
|
|
14
14
|
|
|
15
15
|
## Inputs from disk
|
|
16
16
|
|
|
17
|
-
From `~/.muggle-ai/muggle-do/sessions/<slug>/`: `prs.json` (PR + branch + `head_sha`), `last_seen.json` (`conflict_resolve_attempts`, `
|
|
17
|
+
From `~/.muggle-ai/muggle-do/sessions/<slug>/`: `prs.json` (PR + branch + `head_sha`), `last_seen.json` (`conflict_resolve_attempts`, `conflict_escalated_keys`, `pushed_shas`), `state.md` (worktree path, validation strategy, base branch).
|
|
18
18
|
|
|
19
19
|
## Procedure
|
|
20
20
|
|
|
21
21
|
### Step 1 — Re-attach
|
|
22
22
|
|
|
23
|
-
Materialize the PR branch in its worktree per [`../_shared/pr-branch-worktree.md`](../_shared/pr-branch-worktree.md) (or use `state.md`'s `worktreePath`). Capture `rebase_sha = prs.json[0].head_sha` and the base branch (`baseRefName` from [`../_shared/vcs/github/pr-metadata.md`](../_shared/vcs/github/pr-metadata.md)).
|
|
23
|
+
Materialize the PR branch in its worktree per [`../_shared/pr-branch-worktree.md`](../_shared/pr-branch-worktree.md) (or use `state.md`'s `worktreePath`). Capture `rebase_sha = prs.json[0].head_sha` and the base branch (`baseRefName` from [`../_shared/vcs/github/pr-metadata.md`](../_shared/vcs/github/pr-metadata.md)). Capture the base tip too — `.base_commit.sha` from that recipe's `compare` call — and form `rebase_key = "<rebase_sha>..<base_tip_sha>"`. Both budget fields below are keyed on that pair, matching the watcher's Step 5; writing a bare SHA instead leaves an entry the watcher ignores, and the rebase re-dispatches forever.
|
|
24
24
|
|
|
25
25
|
### Step 2 — Rebase onto base (resolve conflicts if any)
|
|
26
26
|
|
|
@@ -37,13 +37,13 @@ Build (typecheck + lint on the changed surface) + unit suite must pass. Run E2E
|
|
|
37
37
|
|
|
38
38
|
### Step 4 — Force-push + respawn
|
|
39
39
|
|
|
40
|
-
Push with `--force-with-lease` (the rebase rewrote history). Append the new SHA to `last_seen.pushed_shas`; increment `last_seen.conflict_resolve_attempts[
|
|
40
|
+
Push with `--force-with-lease` (the rebase rewrote history). Append the new SHA to `last_seen.pushed_shas`; increment `last_seen.conflict_resolve_attempts[rebase_key]` — both whole-file rewrites (Read → change field → Write) per [`../_shared/session-state-writes.md`](../_shared/session-state-writes.md), never the Edit tool. Respawn the watcher per [`respawn-watcher.md`](respawn-watcher.md). Its next tick re-checks the branch against its base on the new head — the rebase is its own verify loop, bounded by the per-SHA attempt budget.
|
|
41
41
|
|
|
42
42
|
### Step 5 — Escalate (can't resolve / budget spent)
|
|
43
43
|
|
|
44
|
-
When `autoResolveConflicts=never`, the resolution failed verification, or `conflict_resolve_attempts[
|
|
44
|
+
When `autoResolveConflicts=never`, the resolution failed verification, or `conflict_resolve_attempts[rebase_key]` has reached 2:
|
|
45
45
|
|
|
46
|
-
1. Add `
|
|
46
|
+
1. Add `rebase_key` to `last_seen.conflict_escalated_keys` so the watcher does not re-dispatch this head against this base. If the base later moves, the key changes and the branch re-arms on its own — a conflict the user resolved upstream stops being this watcher's dead end.
|
|
47
47
|
2. Emit one terminal escalation naming the PR and the conflicting files (or the failing verification, for a behind-only rebase that didn't verify).
|
|
48
48
|
3. Respawn the watcher per [`respawn-watcher.md`](respawn-watcher.md) — it keeps polling for the user's manual resolution or any new reviews.
|
|
49
49
|
|
|
@@ -12,7 +12,7 @@ This folder holds the watcher loop that drives one PR toward merge-ready. The wa
|
|
|
12
12
|
- [`finalize.md`](finalize.md) — shared termination sequence for a terminal PR (mark terminal, `result.md`, log/telemetry, unschedule cron, post-merge cleanup handoff). Called by `contract.md` and `reconcile.md`.
|
|
13
13
|
- [`cancel-cron.md`](cancel-cron.md) — stops this watcher's cron, recorded-id-first (survives `CronList` going blind) with a `CronList`-match fallback, plus the tool-call-not-shell guard. Referenced by `contract.md` and `finalize.md`.
|
|
14
14
|
- [`record-cron-id.md`](record-cron-id.md) — the per-tick self-record that keeps this slot's cron id in `cron.json` deletable after a compaction blinds `CronList`. Referenced by `contract.md` Step 0.
|
|
15
|
-
- [`reconcile.md`](reconcile.md) — sweep that finalizes slots whose PR went terminal while polling lapsed, deletes orphaned crons, and re-arms open slots whose watcher stopped silently (dropped respawn); runs at the top of auto-track and
|
|
15
|
+
- [`reconcile.md`](reconcile.md) — sweep that finalizes slots whose PR went terminal while polling lapsed, deletes orphaned crons, and re-arms open slots whose watcher stopped silently (dropped respawn); runs on demand, at the top of auto-track, and nudged by a session-start hook.
|
|
16
16
|
- [`state-schemas.md`](state-schemas.md) — canonical JSON shapes of session state files.
|
|
17
17
|
- [`output-templates.md`](output-templates.md) — TOC of message templates; per-group files in `output-templates/`.
|
|
18
18
|
|
|
@@ -35,6 +35,8 @@ The skill recognizes its mode by inspecting `$ARGUMENTS` and falling back to on-
|
|
|
35
35
|
|
|
36
36
|
Auto-track runs **reconcile** first, so a no-arg invocation also finalizes any slot whose PR merged or closed while its watcher was down (expired cron, ended session) and re-arms any open slot whose watcher stopped silently (a dropped respawn). Reconcile recovers a watcher that was already running; it never seeds a first watcher for a PR — that is auto-track's / bootstrap's job.
|
|
37
37
|
|
|
38
|
+
**Reconcile also runs at session start** — a `SessionStart` hook ([`../../hooks/README.md`](../../hooks/README.md)) surfaces the sweep when open slots exist, catching a watcher that died with its session (end, or 7-day `/loop` expiry) before a manual sweep would. See [`reconcile.md`](reconcile.md#triggers).
|
|
39
|
+
|
|
38
40
|
Bootstrap accepts three optional trailing flags:
|
|
39
41
|
|
|
40
42
|
- `--slug=<name>` — override the default `<repo>-pr<n>` slug
|
|
@@ -37,7 +37,7 @@ Otherwise, self-record this watcher's cron id per [`record-cron-id.md`](record-c
|
|
|
37
37
|
|
|
38
38
|
### Step 1 — Refresh PR state
|
|
39
39
|
|
|
40
|
-
Per [`../_shared/vcs/github/pr-metadata.md`](../_shared/vcs/github/pr-metadata.md). Update `prs.json[0].head_sha` and `prs.json[0].state` from the response; keep `mergeable` (conflict signal) for Step 5, and run the recipe's `compare` call to capture `behind_by` (out-of-date signal)
|
|
40
|
+
Per [`../_shared/vcs/github/pr-metadata.md`](../_shared/vcs/github/pr-metadata.md). Update `prs.json[0].head_sha` and `prs.json[0].state` from the response; keep `mergeable` (conflict signal) for Step 5, and run the recipe's `compare` call to capture both `behind_by` (out-of-date signal) and `.base_commit.sha` — the base branch tip, Step 5's `base_tip_sha`.
|
|
41
41
|
|
|
42
42
|
### Step 2 — Termination check
|
|
43
43
|
|
|
@@ -102,7 +102,11 @@ A merge-ready branch is **current with its base** — neither conflicting nor be
|
|
|
102
102
|
|
|
103
103
|
This trigger is **independent of approval and CI state**: an out-of-date branch is rebased whether or not it has been reviewed, approved, or has green checks. The watcher acts on staleness directly — it never waits for an approval to surface it.
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
Rebase dedup is keyed on the **pair** `rebase_key = "<head_sha>..<base_tip_sha>"`, not on the head alone. Whether a branch conflicts is a function of both sides, so a head-only key wedges a PR permanently the first time the base moves: the head cannot change while nobody pushes, so one stale entry suppresses every genuinely new conflict that base movement introduces, forever. Take `base_tip_sha` from the Step 1 compare's `.base_commit.sha` (the base branch tip, which advances when the base does) — **never** `.merge_base_commit.sha`, which does not move when only the base advances and so would never re-arm.
|
|
106
|
+
|
|
107
|
+
Entries written by an older watcher are bare head SHAs with no `..` — ignore them when reading `conflict_escalated_keys`, which re-arms any slot a head-only key had wedged.
|
|
108
|
+
|
|
109
|
+
If a rebase is due **and** `conflict_resolve_attempts[rebase_key] < 2` **and** `rebase_key` ∉ `conflict_escalated_keys` → dispatch and exit:
|
|
106
110
|
|
|
107
111
|
1. Reset `last_seen.idle_tick_count` to 0.
|
|
108
112
|
2. **Stop this watcher (single-thread):** cancel its cron exactly as in Step 4 — `/muggle-do`'s rebase respawns it when the cycle is done.
|
|
@@ -114,7 +118,7 @@ If a rebase is due **and** `conflict_resolve_attempts[head_sha] < 2` **and** `he
|
|
|
114
118
|
4. Append a dispatching line to `followup.log`; emit a `tick` event with `rebase_needed: true`, `dispatched_rebase: true`.
|
|
115
119
|
5. Exit. The dev cycle owns the PR; its respawn restarts the watcher, whose next tick re-checks the branch against its base on the new head — the rebase is its own verify loop, bounded by the per-SHA attempt budget.
|
|
116
120
|
|
|
117
|
-
Otherwise — `behind_by == 0` and not conflicting (`mergeable == UNKNOWN` is fine here: `behind_by` is exact while GitHub is still computing conflict state, so a stale branch still triggers), or budget spent (`conflict_resolve_attempts[
|
|
121
|
+
Otherwise — `behind_by == 0` and not conflicting (`mergeable == UNKNOWN` is fine here: `behind_by` is exact while GitHub is still computing conflict state, so a stale branch still triggers), or budget spent (`conflict_resolve_attempts[rebase_key] >= 2` or `rebase_key` ∈ `conflict_escalated_keys`) → fall through to CI.
|
|
118
122
|
|
|
119
123
|
### Step 6 — No actionable feedback, branch current → poll CI for the head SHA
|
|
120
124
|
|
|
@@ -137,7 +141,7 @@ Fetch the CI rollup for `prs.json[0].head_sha`, provider resolved as in Step 3
|
|
|
137
141
|
|
|
138
142
|
Any idle branch (Steps 4–6 that did not dispatch). First classify **why** this tick idled. It is **blocked pending a human** when the head is under a durable block that only the user can clear:
|
|
139
143
|
|
|
140
|
-
- `
|
|
144
|
+
- `rebase_key` ∈ `conflict_escalated_keys` — a rebase `/muggle-do` gave up on (a semantic conflict, or `autoResolveConflicts=never`), reason `conflict_escalated`. This block clears on its own when the base moves: the new `base_tip_sha` yields a key the set does not contain, and the branch re-arms for a fresh rebase attempt; or
|
|
141
145
|
- `head_sha` ∈ `ci_escalated_shas` — CI the fix-ci stage gave up on, reason `ci_escalated`; or
|
|
142
146
|
- `last_seen.escalated_review_ids` is non-empty with the actionable set empty — an ambiguous review awaiting the user's direction, reason `reviews_escalated`.
|
|
143
147
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
{
|
|
2
|
+
"skill_name": "muggle-pr-followup",
|
|
3
|
+
"notes": "These evals test the TICK DECISION behavior. A real tick needs live GitHub state and the Cron tools, so each prompt hands the model a concrete slot + provider state and asks for the step-by-step plan it would execute; assertions check the plan takes the branch the contract mandates. Run via skill-creator's eval runner — CI does not execute these (ci.yml gates on vitest/check-skill-deps; skill-eval.yml runs gate scenarios sourced from muggle-ai-brain and the routing eval).",
|
|
4
|
+
"evals": [
|
|
5
|
+
{
|
|
6
|
+
"id": 0,
|
|
7
|
+
"eval_name": "actionable-threads-dispatch-address-reviews",
|
|
8
|
+
"prompt": "Tick muggle-ai-works-pr500 500. The slot is open. The PR has two unresolved, not-outdated review threads whose newest comments are from a human (no muggle-do:bot marker), CI is fully green, and the branch is level with master. Walk me through the plan for this tick.",
|
|
9
|
+
"files": [],
|
|
10
|
+
"assertions": [
|
|
11
|
+
{ "name": "computes_actionable_from_live_thread_state", "text": "Plan derives the actionable set from current unresolved-thread state, not from a stored review-id cursor." },
|
|
12
|
+
{ "name": "classifies_by_loop_marker", "text": "Plan decides a thread is actionable by the absence of the muggle-do:bot marker on the newest comment, never by author login." },
|
|
13
|
+
{ "name": "cancels_own_cron_before_dispatch", "text": "Plan cancels this watcher's own cron before dispatching, so no tick overlaps the running cycle." },
|
|
14
|
+
{ "name": "dispatches_address_reviews", "text": "Plan dispatches /muggle-do with an address-reviews directive carrying the PR URL, slug, and owning review ids, then exits." },
|
|
15
|
+
{ "name": "stays_a_dumb_pipe", "text": "Plan does NOT classify reviews, post replies, resolve threads, or escalate — those belong to /muggle-do." }
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"id": 1,
|
|
20
|
+
"eval_name": "reviews-preempt-ci",
|
|
21
|
+
"prompt": "Tick muggle-ai-works-pr501 501. The slot is open. There is one unresolved human review thread AND two red required checks on the head SHA. Walk me through the plan for this tick.",
|
|
22
|
+
"files": [],
|
|
23
|
+
"assertions": [
|
|
24
|
+
{ "name": "dispatches_address_reviews_only", "text": "Plan dispatches address-reviews for the review thread." },
|
|
25
|
+
{ "name": "does_not_poll_or_dispatch_ci", "text": "Plan does NOT dispatch fix-ci on this tick — actionable feedback preempts the CI branch entirely." },
|
|
26
|
+
{ "name": "single_dispatch_then_exit", "text": "Plan performs exactly one dispatch and exits rather than handling both concerns in one tick." }
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"id": 2,
|
|
31
|
+
"eval_name": "behind-branch-dispatches-rebase",
|
|
32
|
+
"prompt": "Tick muggle-ai-works-pr502 502. The slot is open, there are no unresolved review threads, CI is green. GitHub reports mergeStateStatus BLOCKED and mergeable MERGEABLE, and the compare call shows behind_by 4. Walk me through the plan for this tick.",
|
|
33
|
+
"files": [],
|
|
34
|
+
"assertions": [
|
|
35
|
+
{ "name": "reads_staleness_from_compare", "text": "Plan determines the branch is out of date from behind_by on the compare call (commit ancestry)." },
|
|
36
|
+
{ "name": "does_not_trust_mergeStateStatus", "text": "Plan does NOT conclude the branch is current just because mergeStateStatus reads BLOCKED rather than BEHIND." },
|
|
37
|
+
{ "name": "dispatches_rebase", "text": "Plan dispatches /muggle-do with a rebase directive (PR URL + slug, no review ids, no check names)." },
|
|
38
|
+
{ "name": "keys_dedup_on_head_and_base_pair", "text": "Plan keys the rebase attempt/escalation lookup on the head..base_tip pair, not the head SHA alone." }
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"id": 3,
|
|
43
|
+
"eval_name": "red-ci-within-budget-dispatches-fix-ci",
|
|
44
|
+
"prompt": "Tick muggle-ai-works-pr503 503. The slot is open, no unresolved review threads, branch is level with its base. Two checks are red on the head SHA. ci_fix_attempts for that SHA is 1 and the SHA is not in ci_escalated_shas. Walk me through the plan for this tick.",
|
|
45
|
+
"files": [],
|
|
46
|
+
"assertions": [
|
|
47
|
+
{ "name": "checks_attempt_budget", "text": "Plan verifies ci_fix_attempts for the head SHA is under the cap of 3 before dispatching." },
|
|
48
|
+
{ "name": "checks_escalated_set", "text": "Plan verifies the head SHA is not in ci_escalated_shas." },
|
|
49
|
+
{ "name": "dispatches_fix_ci_with_check_names", "text": "Plan dispatches /muggle-do with a fix-ci directive carrying the red check names." },
|
|
50
|
+
{ "name": "cancels_own_cron_before_dispatch", "text": "Plan cancels this watcher's cron before dispatching." }
|
|
51
|
+
]
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"id": 4,
|
|
55
|
+
"eval_name": "red-ci-over-budget-idles",
|
|
56
|
+
"prompt": "Tick muggle-ai-works-pr504 504. The slot is open, no unresolved review threads, branch is level with its base. Three checks are red on the head SHA, ci_fix_attempts for that SHA is 3, and the SHA is in ci_escalated_shas. Walk me through the plan for this tick.",
|
|
57
|
+
"files": [],
|
|
58
|
+
"assertions": [
|
|
59
|
+
{ "name": "does_not_dispatch", "text": "Plan does NOT dispatch fix-ci — the budget is spent and the SHA is already escalated." },
|
|
60
|
+
{ "name": "treats_as_durable_block", "text": "Plan classifies this idle as blocked pending a human (reason ci_escalated), not as a transient idle." },
|
|
61
|
+
{ "name": "reminds_owner_at_1m", "text": "Plan emits a one-line owner reminder and keeps the normal 1m cadence — it does not back off, slow down, or stop polling." },
|
|
62
|
+
{ "name": "no_pr_side_post", "text": "Plan does NOT post anything to the PR for a blocked tick; the reminder goes to the loop owner in-session." }
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"id": 5,
|
|
67
|
+
"eval_name": "merged-pr-finalizes-without-respawn",
|
|
68
|
+
"prompt": "Tick muggle-ai-works-pr505 505. Refreshing the PR shows state MERGED. The slot on disk still says open. Walk me through the plan for this tick.",
|
|
69
|
+
"files": [],
|
|
70
|
+
"assertions": [
|
|
71
|
+
{ "name": "finalizes_the_slot", "text": "Plan finalizes the slot: marks it terminal, writes result.md, logs, and unschedules this watcher's cron." },
|
|
72
|
+
{ "name": "does_not_respawn", "text": "Plan does NOT respawn or re-arm a watcher — a terminal PR needs none." },
|
|
73
|
+
{ "name": "hands_off_terminal_wrapup", "text": "Plan hands the terminal wrap-up to /muggle-do post-merge cleanup as the turn's last action." },
|
|
74
|
+
{ "name": "unschedules_recorded_id_first", "text": "Plan cancels the cron by the id recorded in cron.json first, falling back to a CronList match — so teardown works even after CronList has gone blind." }
|
|
75
|
+
]
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"id": 6,
|
|
79
|
+
"eval_name": "stale-queued-fire-does-not-refinalize",
|
|
80
|
+
"prompt": "Tick muggle-ai-works-pr506 506. Reading the slot, prs.json already records state merged and result.md exists — this fire was queued before the cron was cancelled. Walk me through the plan for this tick.",
|
|
81
|
+
"files": [],
|
|
82
|
+
"assertions": [
|
|
83
|
+
{ "name": "detects_stale_fire", "text": "Plan recognises this as a stale queued fire from the on-disk terminal state before fetching anything." },
|
|
84
|
+
{ "name": "does_not_refetch_or_refinalize", "text": "Plan does NOT re-fetch the PR, re-write result.md, or re-run the terminal handoff." },
|
|
85
|
+
{ "name": "defensively_cancels_and_logs", "text": "Plan defensively cancels any lingering cron for the slug and appends a stale-tick line to followup.log, then exits." }
|
|
86
|
+
]
|
|
87
|
+
}
|
|
88
|
+
]
|
|
89
|
+
}
|
|
@@ -4,6 +4,16 @@ The procedure for the **reconcile mode** of `muggle-pr-followup` — a sweep tha
|
|
|
4
4
|
|
|
5
5
|
Termination is otherwise tick-driven ([`contract.md`](contract.md) Step 2): a slot finalizes only when a tick fires and observes `MERGED` / `CLOSED`. If the tick stream stops first — the recurring `/loop` cron auto-expires after 7 days, the session ends, or the machine is off when the PR merges — no tick catches the transition, and the slot is left un-finalized: no `result.md`, no post-merge cleanup, and a surviving cron would keep polling a dead PR. Reconcile is the catch-up.
|
|
6
6
|
|
|
7
|
+
## Triggers
|
|
8
|
+
|
|
9
|
+
Three ways in, all running the same procedure:
|
|
10
|
+
|
|
11
|
+
- **Manual** — `/muggle:muggle-pr-followup reconcile` (or `sweep`).
|
|
12
|
+
- **Auto-track** — the top of a no-arg invocation ([`auto-track.md`](auto-track.md)).
|
|
13
|
+
- **Session start** — the `reconcile-stale-watchers.sh` hook ([`../../hooks/README.md`](../../hooks/README.md)) runs this sweep, catching a watcher that died with its session (end, or 7-day `/loop` cron expiry) before its PR's merge was observed.
|
|
14
|
+
|
|
15
|
+
Recover-don't-seed holds on every trigger: a session-start run still never seeds a first watcher (see Invariants).
|
|
16
|
+
|
|
7
17
|
## Input
|
|
8
18
|
|
|
9
19
|
`$ARGUMENTS` is `reconcile` (or `sweep`), optionally followed by a `<slug>` to scope the sweep to one slot.
|
|
@@ -62,8 +62,8 @@ Keyed by `"<owner>/<repo>#<n>"`. One key per PR in the slot.
|
|
|
62
62
|
"pushed_shas": ["<sha>", ...],
|
|
63
63
|
"ci_fix_attempts": { "<sha>": <int> },
|
|
64
64
|
"ci_escalated_shas": ["<sha>", ...],
|
|
65
|
-
"conflict_resolve_attempts": { "<sha>": <int> },
|
|
66
|
-
"
|
|
65
|
+
"conflict_resolve_attempts": { "<head-sha>..<base-tip-sha>": <int> },
|
|
66
|
+
"conflict_escalated_keys": ["<head-sha>..<base-tip-sha>", ...],
|
|
67
67
|
"blocked": {
|
|
68
68
|
"reason": "conflict_escalated" | "ci_escalated" | "reviews_escalated",
|
|
69
69
|
"since": "<ISO-8601>",
|
|
@@ -85,10 +85,14 @@ Keyed by `"<owner>/<repo>#<n>"`. One key per PR in the slot.
|
|
|
85
85
|
- `pushed_shas`: every SHA `/muggle-do` has pushed for this PR. Append-only. Used by the resolve-reminder stage to recognize threads addressed by the loop.
|
|
86
86
|
- `ci_fix_attempts`: per-SHA count of fix-ci cycles `/muggle-do` has run. The watcher stops dispatching fix-ci for a SHA once its count reaches 3. Keyed by head SHA.
|
|
87
87
|
- `ci_escalated_shas`: head SHAs whose CI the fix-ci stage gave up on (attempts exhausted or only out-of-scope checks). The watcher excludes these from CI dispatch so a hopeless SHA is never re-fixed.
|
|
88
|
-
- `conflict_resolve_attempts`:
|
|
89
|
-
- `
|
|
88
|
+
- `conflict_resolve_attempts`: count of rebase cycles `/muggle-do` has run (behind-only or conflicting — both rebase onto the base). The watcher stops dispatching once a key's count reaches 2. Keyed by `rebase_key` — `"<head_sha>..<base_tip_sha>"`, the head paired with the base branch tip it was measured against.
|
|
89
|
+
- `conflict_escalated_keys`: `rebase_key`s whose rebase `/muggle-do` gave up on (attempts exhausted, or a conflict under `autoResolveConflicts=never`). The watcher excludes these from rebase dispatch so a hopeless pairing is never re-attempted.
|
|
90
|
+
|
|
91
|
+
Both are keyed on the pair, not the head alone, because whether a branch conflicts depends on both sides. Under a head-only key, a base that moves produces a genuinely new conflict against an unchanged head — and the stale entry suppresses it permanently, because nothing can change the head while the branch sits blocked. Pairing re-arms the budget whenever either side moves. Legacy entries written before this change are bare SHAs with no `..`; readers ignore them, which un-wedges any slot they had blocked.
|
|
92
|
+
|
|
93
|
+
Unlike these, `ci_fix_attempts` / `ci_escalated_shas` stay keyed on the head SHA alone — a CI result is a function of the head only, so base movement must not re-arm them.
|
|
90
94
|
- `blocked`: present only while the watcher is **awaiting the owner** on a PR that cannot progress without a human ([`contract.md`](contract.md) Step 7). Absent ⇒ the watcher is in its normal dispatch flow. When present, the watcher **keeps the normal `1m` cadence** and each tick is a reminder-or-resume check ([`contract.md`](contract.md) Step 2.5): it re-emits a one-line reminder to the owner, recomputes the `fingerprint`, and clears the block the moment any component moves. Its value is the reason-specific reminder plus fingerprint auto-resume.
|
|
91
|
-
- `reason`: which durable block is being awaited — `conflict_escalated` (`
|
|
95
|
+
- `reason`: which durable block is being awaited — `conflict_escalated` (`rebase_key` ∈ `conflict_escalated_keys`), `ci_escalated` (`head_sha` ∈ `ci_escalated_shas`), or `reviews_escalated` (a review sits in `escalated_review_ids` awaiting the user, actionable set empty). Selects the reminder wording; the resume decision is fingerprint-driven, not reason-driven.
|
|
92
96
|
- `since`: when the block was first flagged — lets the reminder state how long the owner has been the blocker.
|
|
93
97
|
- `fingerprint`: the external state the block is waiting on. `head_sha` moves on a new push (which also clears the per-SHA escalation sets, keyed by SHA); `latest_review_id` is `max(id)` over submitted reviews and moves when a reviewer submits anything new; `ci_digest` is a stable digest of the head SHA's CI rollup (bucket + each check's name/conclusion, sorted) and moves when a check flips, a rerun lands, or an external check such as a staging deploy posts. Any change clears the block and resumes evaluation.
|
|
94
98
|
|