@muggleai/works 5.27.0-staging.246 → 5.27.0-staging.247
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/.claude-plugin/marketplace.json +1 -1
- package/dist/{chunk-PT5EGMBA.js → chunk-B5BWBZZ5.js} +2 -5
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/plugin/.codex-mcp.json +1 -1
- package/dist/plugin/.codex-plugin/plugin.json +1 -1
- package/dist/plugin/.cursor-plugin/plugin.json +1 -1
- package/dist/plugin/.mcp.json +1 -1
- package/dist/plugin/hooks/README.md +2 -2
- package/dist/plugin/scripts/pr-watch-arm.sh +27 -10
- package/dist/plugin/scripts/pr-watch-floors.sh +62 -0
- package/dist/plugin/scripts/pr-watch-guards.sh +11 -11
- package/dist/plugin/scripts/pr-watch-loop.sh +63 -50
- package/dist/plugin/scripts/reconcile-stale-watchers.sh +7 -30
- package/dist/plugin/skills/_shared/host-tool-equivalents.md +1 -1
- package/dist/plugin/skills/do/next-step.md +1 -1
- package/dist/plugin/skills/do/open-prs/forward.md +1 -1
- package/dist/plugin/skills/muggle-pr-followup/SKILL.md +5 -5
- package/dist/plugin/skills/muggle-pr-followup/adopt.md +2 -2
- package/dist/plugin/skills/muggle-pr-followup/arm-watcher.md +21 -16
- package/dist/plugin/skills/muggle-pr-followup/blocked-tick.md +3 -3
- package/dist/plugin/skills/muggle-pr-followup/bootstrap.md +1 -1
- package/dist/plugin/skills/muggle-pr-followup/contract.md +9 -7
- package/dist/plugin/skills/muggle-pr-followup/evals/evals.json +16 -16
- package/dist/plugin/skills/muggle-pr-followup/reconcile.md +4 -4
- package/dist/plugin/skills/muggle-pr-followup/state-schemas.md +17 -6
- package/dist/plugin/skills/muggle-pr-followup/stop.md +3 -3
- package/dist/plugin/skills/muggle-preferences/preference-gates/watcherLifetime.md +2 -2
- package/dist/release-manifest.json +3 -3
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-mcp.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.cursor-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +1 -1
- package/plugin/hooks/README.md +2 -2
- package/plugin/scripts/pr-watch-arm.sh +27 -10
- package/plugin/scripts/pr-watch-floors.sh +62 -0
- package/plugin/scripts/pr-watch-guards.sh +11 -11
- package/plugin/scripts/pr-watch-loop.sh +63 -50
- package/plugin/scripts/reconcile-stale-watchers.sh +7 -30
- package/plugin/skills/_shared/host-tool-equivalents.md +1 -1
- package/plugin/skills/do/next-step.md +1 -1
- package/plugin/skills/do/open-prs/forward.md +1 -1
- package/plugin/skills/muggle-pr-followup/SKILL.md +5 -5
- package/plugin/skills/muggle-pr-followup/adopt.md +2 -2
- package/plugin/skills/muggle-pr-followup/arm-watcher.md +21 -16
- package/plugin/skills/muggle-pr-followup/blocked-tick.md +3 -3
- package/plugin/skills/muggle-pr-followup/bootstrap.md +1 -1
- package/plugin/skills/muggle-pr-followup/contract.md +9 -7
- package/plugin/skills/muggle-pr-followup/evals/evals.json +16 -16
- package/plugin/skills/muggle-pr-followup/reconcile.md +4 -4
- package/plugin/skills/muggle-pr-followup/state-schemas.md +17 -6
- package/plugin/skills/muggle-pr-followup/stop.md +3 -3
- package/plugin/skills/muggle-preferences/preference-gates/watcherLifetime.md +2 -2
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"name": "muggleai",
|
|
10
10
|
"source": "./plugin",
|
|
11
11
|
"description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
|
|
12
|
-
"version": "5.27.0-staging.
|
|
12
|
+
"version": "5.27.0-staging.247"
|
|
13
13
|
}
|
|
14
14
|
]
|
|
15
15
|
}
|
|
@@ -2384,7 +2384,6 @@ var WATCH_STATUS_FILE = "status.json";
|
|
|
2384
2384
|
var WATCH_STATE_FILE = "state.json";
|
|
2385
2385
|
var WATCH_EVENTS_FILE = "events.jsonl";
|
|
2386
2386
|
var WATCH_EVENTS_ROTATE_BYTES = 5 * 1024 * 1024;
|
|
2387
|
-
var MONITOR_STDOUT_CAP_MS = 30 * 6e4;
|
|
2388
2387
|
var WATCHER_REARM_GRACE_MS = 5 * 6e4;
|
|
2389
2388
|
var CODEX_ACTIVE_WINDOW_MS = 10 * 6e4;
|
|
2390
2389
|
var CODEX_ROLLOUT_DAY_DIRS_SCANNED = 7;
|
|
@@ -4990,8 +4989,7 @@ function resolveWatcherLiveness(loop, heartbeatAgeMs, isOwnerSessionActive, isPr
|
|
|
4990
4989
|
return isHeartbeatWithin(WATCH_HEARTBEAT_STALE_MS) ? "polling" /* Polling */ : "dead" /* Dead */;
|
|
4991
4990
|
}
|
|
4992
4991
|
if (loop) {
|
|
4993
|
-
|
|
4994
|
-
return hasOutlivedMonitor || isOwnerSessionActive === false ? "deaf" /* Deaf */ : "polling" /* Polling */;
|
|
4992
|
+
return isOwnerSessionActive === false ? "deaf" /* Deaf */ : "polling" /* Polling */;
|
|
4995
4993
|
}
|
|
4996
4994
|
return isHeartbeatWithin(WATCHER_REARM_GRACE_MS) ? "rearming" /* Rearming */ : "dead" /* Dead */;
|
|
4997
4995
|
}
|
|
@@ -5058,12 +5056,11 @@ function watcherFinding(assessment, subject) {
|
|
|
5058
5056
|
);
|
|
5059
5057
|
}
|
|
5060
5058
|
if (assessment.watcher === "deaf" /* Deaf */) {
|
|
5061
|
-
const reason = assessment.isOwnerSessionActive === false ? `its owner session ${shortSessionId(assessment.ownerSessionId ?? "")} has ended` : `it has run ${formatDuration(assessment.loopAgeMs ?? 0)}, past the 30-minute Monitor cap`;
|
|
5062
5059
|
return finding(
|
|
5063
5060
|
"WATCHER_DEAF" /* WatcherDeaf */,
|
|
5064
5061
|
[assessment.pr.url, assessment.loopPid],
|
|
5065
5062
|
subject,
|
|
5066
|
-
`watch loop pid ${assessment.loopPid} reports to no session: ${
|
|
5063
|
+
`watch loop pid ${assessment.loopPid} reports to no session: its owner session ${shortSessionId(assessment.ownerSessionId ?? "")} has ended`
|
|
5067
5064
|
);
|
|
5068
5065
|
}
|
|
5069
5066
|
return void 0;
|
package/dist/cli.js
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { src_exports as commands, createUnifiedMcpServer, server_exports as server } from './chunk-
|
|
1
|
+
export { src_exports as commands, createUnifiedMcpServer, server_exports as server } from './chunk-B5BWBZZ5.js';
|
|
2
2
|
export { createChildLogger, e2e_exports as e2e, getConfig, getLocalQaTools, getLogger, getQaTools, local_exports as localQa, mcp_exports as mcp, e2e_exports as qa, src_exports as shared } from './chunk-6BAB7IVG.js';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muggle",
|
|
3
3
|
"description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
|
|
4
|
-
"version": "5.27.0-staging.
|
|
4
|
+
"version": "5.27.0-staging.247",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Muggle AI",
|
|
7
7
|
"email": "support@muggle-ai.com"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muggle",
|
|
3
|
-
"version": "5.27.0-staging.
|
|
3
|
+
"version": "5.27.0-staging.247",
|
|
4
4
|
"description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Muggle AI",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "muggle",
|
|
3
3
|
"displayName": "Muggle AI",
|
|
4
4
|
"description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
|
|
5
|
-
"version": "5.27.0-staging.
|
|
5
|
+
"version": "5.27.0-staging.247",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Muggle AI",
|
|
8
8
|
"email": "support@muggle-ai.com"
|
package/dist/plugin/.mcp.json
CHANGED
|
@@ -77,9 +77,9 @@ Codex runs a plugin's hooks only once the user trusts them: it asks at startup (
|
|
|
77
77
|
|
|
78
78
|
`SessionStart` (`scripts/reconcile-stale-watchers.sh`) — a standalone advisory, not part of the `guardrails.mjs` decision tree above.
|
|
79
79
|
|
|
80
|
-
`muggle-pr-followup` watchers are session-only (a
|
|
80
|
+
`muggle-pr-followup` watchers are session-only (a background watch loop or `/loop` cron); they die with their session, leaving open PRs with no live poller. This is by design — a review is addressed only inside a session that carries the context to address it. The skill's [`reconcile`](../skills/muggle-pr-followup/reconcile.md) procedure recovers them at the next session start — finalizes slots whose PR went terminal, sweeps orphan crons, re-arms silently-stopped open watchers — but re-arming needs Claude tools a shell hook can't call. So this hook nudges rather than acts.
|
|
81
81
|
|
|
82
|
-
The nudge counts only slots **this session owns
|
|
82
|
+
The nudge counts only slots **this session owns** — ownership and the recovery rules it gates are defined in [`reconcile`](../skills/muggle-pr-followup/reconcile.md). A session that owns none hears nothing: the hook speaks into every session start, and slots owned elsewhere are no work of this one's — `muggle watch status` reports them. A pure directory scan (no `gh`, no writes), so it's cheap enough for every session start.
|
|
83
83
|
|
|
84
84
|
## Session-start upkeep
|
|
85
85
|
|
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
# and never reaching its terminal check. Every health signal said fine. Doing
|
|
9
9
|
# the three steps here, in one command, is what makes that unskippable.
|
|
10
10
|
#
|
|
11
|
-
#
|
|
12
|
-
# afterwards: anything that lands between the two would otherwise be marked
|
|
13
|
-
# without ever being reported.
|
|
11
|
+
# A first arm takes its floors from the drain's own read rather than from a fresh
|
|
12
|
+
# fetch afterwards: anything that lands between the two would otherwise be marked
|
|
13
|
+
# seen without ever being reported. A re-arm keeps the review, comment and thread
|
|
14
|
+
# floors the session already holds, for the same reason.
|
|
14
15
|
#
|
|
15
16
|
# A stale branch or a red head is floored only when the slot proves a cycle ran
|
|
16
17
|
# for it. Flooring whatever was outstanding at arm time let a session that never
|
|
@@ -38,7 +39,7 @@ if [ -z "$slot" ] || [ -z "$repo" ] || [ -z "$pr_number" ] || [ -z "$base_branch
|
|
|
38
39
|
exit 2
|
|
39
40
|
fi
|
|
40
41
|
|
|
41
|
-
for lib in pr-watch-guards.sh pr-watch-events.sh pr-watch-fetch.sh pr-watch-due.sh; do
|
|
42
|
+
for lib in pr-watch-guards.sh pr-watch-events.sh pr-watch-fetch.sh pr-watch-due.sh pr-watch-floors.sh; do
|
|
42
43
|
if [ ! -f "${script_dir}/${lib}" ]; then
|
|
43
44
|
echo "pr-watch-arm: missing ${lib} beside this script" >&2
|
|
44
45
|
exit 2
|
|
@@ -135,9 +136,10 @@ recorded_rebase_due_key="$rebase_due_key"
|
|
|
135
136
|
thread_count=0
|
|
136
137
|
[ -n "$unresolved_threads" ] && thread_count=$(printf '%s' "$unresolved_threads" | tr ';' '\n' | grep -c .)
|
|
137
138
|
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
# loop's first
|
|
139
|
+
# A record of the PR as armed, read from the watch's output when its loop ends.
|
|
140
|
+
# Printing wakes nobody: the session hears the watch only when the loop exits.
|
|
141
|
+
# Unattempted work is left unfloored, so the loop's first poll reports it and
|
|
142
|
+
# exits — that report is the wake.
|
|
141
143
|
echo "DRAIN pr=$pr_number state=$pr_state head=${head_sha:0:8}"
|
|
142
144
|
echo "DRAIN unresolved-threads=$thread_count latest-review=${latest_review:-0} latest-comment=${latest_comment:-0}"
|
|
143
145
|
echo "DRAIN checks pending=${pending_checks:-0} failed=${failed_checks:-0} behind-base=${behind_count:-unknown} mergeable=${mergeable:-UNKNOWN}"
|
|
@@ -158,10 +160,25 @@ if [ -n "$rebase_due_key" ]; then
|
|
|
158
160
|
fi
|
|
159
161
|
fi
|
|
160
162
|
|
|
163
|
+
# A re-arm carries the review, comment and thread floors forward. The session
|
|
164
|
+
# advanced them past what it handled, so anything newer — a review landing
|
|
165
|
+
# between its last tick's read and this one — is above them, and the loop's
|
|
166
|
+
# first poll reports it. Seeding them from this read would floor that review
|
|
167
|
+
# unseen. Only a first arm, with nothing handled yet, takes them from its read.
|
|
168
|
+
review_floor="${latest_review:-0}"
|
|
169
|
+
comment_floor="${latest_comment:-0}"
|
|
170
|
+
threads_floor="$unresolved_threads"
|
|
171
|
+
if [ -f "${slot}/watch-watermark.env" ]; then
|
|
172
|
+
review_floor="$(watch_read_slot_value "$slot" watch-watermark.env REV)"
|
|
173
|
+
comment_floor="$(watch_read_slot_value "$slot" watch-watermark.env COM)"
|
|
174
|
+
threads_floor="$(watch_threads_still_unresolved \
|
|
175
|
+
"$(watch_read_slot_value "$slot" watch-watermark.env THREADS)" "$unresolved_threads")"
|
|
176
|
+
fi
|
|
177
|
+
|
|
161
178
|
{
|
|
162
|
-
printf 'REV=%s\n' "${
|
|
163
|
-
printf 'COM=%s\n' "${
|
|
164
|
-
printf 'THREADS="%s"\n' "$
|
|
179
|
+
printf 'REV=%s\n' "${review_floor:-0}"
|
|
180
|
+
printf 'COM=%s\n' "${comment_floor:-0}"
|
|
181
|
+
printf 'THREADS="%s"\n' "$threads_floor"
|
|
165
182
|
printf 'CIRED="%s"\n' "$ci_red_floor"
|
|
166
183
|
printf 'REBASED="%s"\n' "$rebase_floor"
|
|
167
184
|
printf 'BLOCKED_CIDIGEST="%s"\n' "$blocked_digest_floor"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
# The floors a watch compares each poll against, as they persist between loops.
|
|
4
|
+
# Sourced by pr-watch-arm.sh and pr-watch-loop.sh.
|
|
5
|
+
#
|
|
6
|
+
# A loop exits on every report, because the session that owns it hears it only
|
|
7
|
+
# when it exits. So no floor can live in a loop's memory: whatever stops one
|
|
8
|
+
# occurrence firing twice has to be on disk for the next loop to read. Two files
|
|
9
|
+
# hold them — watch-watermark.env, which the session advances past what it
|
|
10
|
+
# handled, and watch-reported.env, which the loop advances past what it told
|
|
11
|
+
# the session — and a poll fires only on what is above both.
|
|
12
|
+
|
|
13
|
+
MUGGLE_PR_WATCH_REPORTED_FILE="watch-reported.env"
|
|
14
|
+
|
|
15
|
+
# Prints the value of `key` in a slot file of `KEY="value"` lines, quotes
|
|
16
|
+
# stripped, the last match winning; nothing when the file or key is absent.
|
|
17
|
+
# Read with builtins, never sourced: THREADS holds bare semicolons, which a
|
|
18
|
+
# sourced unquoted line would split, and a CI digest carries check names an
|
|
19
|
+
# author controls.
|
|
20
|
+
watch_read_slot_value() {
|
|
21
|
+
local slot="$1" file_name="$2" key="$3" line value=""
|
|
22
|
+
[ -f "${slot}/${file_name}" ] || return 0
|
|
23
|
+
while IFS= read -r line || [ -n "$line" ]; do
|
|
24
|
+
line="${line%$'\r'}"
|
|
25
|
+
case "$line" in
|
|
26
|
+
"${key}="*)
|
|
27
|
+
value="${line#"${key}"=}"
|
|
28
|
+
value="${value%\"}"
|
|
29
|
+
value="${value#\"}"
|
|
30
|
+
;;
|
|
31
|
+
esac
|
|
32
|
+
done < "${slot}/${file_name}"
|
|
33
|
+
printf '%s' "$value"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# Writes <slot>/watch-reported.env: the newest review and comment ids and the
|
|
37
|
+
# unresolved threads a loop's last poll saw — every one of them either reported
|
|
38
|
+
# or already floored — and the red head, rebase key and blocked digest it
|
|
39
|
+
# reported, with the block (`blocked_for`, that block's watermark digest) the
|
|
40
|
+
# digest was reported under.
|
|
41
|
+
watch_record_reported() {
|
|
42
|
+
local slot="$1" review="$2" comment="$3" threads="$4" ci_red="$5" rebase="$6" blocked_digest="$7" blocked_for="$8"
|
|
43
|
+
printf 'REV=%s\nCOM=%s\nTHREADS="%s"\nCIRED="%s"\nREBASED="%s"\nBLOCKED_CIDIGEST="%s"\nBLOCKED_FOR="%s"\n' \
|
|
44
|
+
"$review" "$comment" "$threads" "$ci_red" "$rebase" "$blocked_digest" "$blocked_for" \
|
|
45
|
+
> "${slot}/${MUGGLE_PR_WATCH_REPORTED_FILE}"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# Prints the threads of `known` (semicolon-joined) that are still in
|
|
49
|
+
# `unresolved`, semicolon-joined. A thread resolved since it was floored drops
|
|
50
|
+
# out, so its unresolving again is news.
|
|
51
|
+
watch_threads_still_unresolved() {
|
|
52
|
+
local known="$1" unresolved="$2" thread_id kept=""
|
|
53
|
+
local -a thread_ids
|
|
54
|
+
IFS=';' read -ra thread_ids <<< "$known"
|
|
55
|
+
for thread_id in "${thread_ids[@]}"; do
|
|
56
|
+
[ -n "$thread_id" ] || continue
|
|
57
|
+
case ";${unresolved};" in
|
|
58
|
+
*";${thread_id};"*) kept="${kept:+${kept};}${thread_id}" ;;
|
|
59
|
+
esac
|
|
60
|
+
done
|
|
61
|
+
printf '%s' "$kept"
|
|
62
|
+
}
|
|
@@ -13,26 +13,26 @@
|
|
|
13
13
|
# watcher_superseded — <slot>/watch.pid holds the PID of the watcher that
|
|
14
14
|
# owns the slot. A loop whose PID no longer matches has
|
|
15
15
|
# been replaced by a newer arm and must exit.
|
|
16
|
-
# watcher_window_elapsed — a loop exits once it has run for
|
|
17
|
-
# window, before the Monitor that owns its stdout
|
|
18
|
-
# It announces the exit so the owning session
|
|
16
|
+
# watcher_window_elapsed — a loop run under a Monitor exits once it has run for
|
|
17
|
+
# its window, before the Monitor that owns its stdout
|
|
18
|
+
# ends. It announces the exit so the owning session
|
|
19
|
+
# re-arms. Off unless MUGGLE_PR_WATCH_MONITOR_WINDOW is set.
|
|
19
20
|
# watcher_lifetime_exceeded — a loop exits after MUGGLE_PR_WATCH_MAX_LIFETIME
|
|
20
21
|
# regardless, so an orphan nothing supersedes still dies
|
|
21
22
|
# on its own; reconcile re-arms an open PR inside a live
|
|
22
23
|
# session.
|
|
23
24
|
|
|
24
25
|
# Seconds a watch loop may live. 0 means unbounded — the `never` setting of the
|
|
25
|
-
# watcherLifetime preference.
|
|
26
|
-
# before this does; this bounds a loop run with the window disabled.
|
|
26
|
+
# watcherLifetime preference.
|
|
27
27
|
MUGGLE_PR_WATCH_MAX_LIFETIME="${MUGGLE_PR_WATCH_MAX_LIFETIME:-604800}"
|
|
28
28
|
MUGGLE_PR_WATCH_POLL_INTERVAL="${MUGGLE_PR_WATCH_POLL_INTERVAL:-60}"
|
|
29
29
|
# Seconds one loop process runs before handing the watch back for a fresh arm.
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
MUGGLE_PR_WATCH_MONITOR_WINDOW="${MUGGLE_PR_WATCH_MONITOR_WINDOW:-
|
|
30
|
+
# Only a loop run under a Monitor needs one: the Monitor tool stops reading after
|
|
31
|
+
# 1800s without stopping the process, so such a loop must end itself first, and
|
|
32
|
+
# every hand-back costs the session a model turn. The watch runs as a background
|
|
33
|
+
# shell, which has no cap and wakes the session only when the loop exits, so the
|
|
34
|
+
# default is 0, unbounded.
|
|
35
|
+
MUGGLE_PR_WATCH_MONITOR_WINDOW="${MUGGLE_PR_WATCH_MONITOR_WINDOW:-0}"
|
|
36
36
|
# Consecutive failed fetches before a loop gives up. A watcher must ride through
|
|
37
37
|
# a GitHub / network outage — an observed drop lasted ~8 minutes — not die and
|
|
38
38
|
# leave the PR unwatched until its owning session next starts. With the backoff, 60
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
|
|
3
3
|
# The muggle-pr-followup watch loop. One process per PR slot: polls provider
|
|
4
|
-
# state,
|
|
4
|
+
# state silently, and exits as soon as a poll finds something — printing one line
|
|
5
|
+
# per new event — or the PR goes terminal.
|
|
6
|
+
#
|
|
7
|
+
# It runs as a background shell, which wakes the owning session only when the
|
|
8
|
+
# process exits. So a quiet PR costs the session nothing, and each report costs
|
|
9
|
+
# one turn, after which the session handles it and arms a fresh loop.
|
|
5
10
|
#
|
|
6
11
|
# Ships as a file so arming runs it rather than rewrites it. A loop re-derived
|
|
7
12
|
# from prose on every arm drifts — the behind-base wake went missing from half
|
|
@@ -39,7 +44,7 @@ fi
|
|
|
39
44
|
# gone. Guards missing means the plugin moved or upgraded underneath this loop —
|
|
40
45
|
# a newer version's watcher owns the slot now, so step down rather than run on
|
|
41
46
|
# without the supersede check.
|
|
42
|
-
for lib in pr-watch-guards.sh pr-watch-events.sh pr-watch-fetch.sh pr-watch-due.sh; do
|
|
47
|
+
for lib in pr-watch-guards.sh pr-watch-events.sh pr-watch-fetch.sh pr-watch-due.sh pr-watch-floors.sh; do
|
|
43
48
|
[ -f "${script_dir}/${lib}" ] || exit 0
|
|
44
49
|
# shellcheck source=/dev/null
|
|
45
50
|
. "${script_dir}/${lib}"
|
|
@@ -60,16 +65,6 @@ unseeded=0
|
|
|
60
65
|
# look at the PR rather than handing back a watch that never polled.
|
|
61
66
|
has_iterated=""
|
|
62
67
|
|
|
63
|
-
# In-memory floors, above the on-disk watermark. The watermark is advanced by
|
|
64
|
-
# the session after it handles a wave; these stop the loop re-reporting an event
|
|
65
|
-
# in the window before that write lands.
|
|
66
|
-
floor_review=0
|
|
67
|
-
floor_comment=0
|
|
68
|
-
floor_threads=";"
|
|
69
|
-
floor_ci_red=""
|
|
70
|
-
floor_rebase=""
|
|
71
|
-
floor_blocked_digest=""
|
|
72
|
-
|
|
73
68
|
# What watch-due.env last recorded, so the file is rewritten only when the due
|
|
74
69
|
# work changes rather than every poll.
|
|
75
70
|
due_rebase_key=""
|
|
@@ -81,19 +76,28 @@ pinned_token="$(gh auth token 2>/dev/null)"
|
|
|
81
76
|
[ -n "$pinned_token" ] && export GH_TOKEN="$pinned_token"
|
|
82
77
|
|
|
83
78
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
79
|
+
# What earlier loops already reported. Without these, work the session saw and
|
|
80
|
+
# chose not to act on — a HOLD WATCH, a comment needing no reply — would end
|
|
81
|
+
# every fresh loop on its first poll. A blocked-resume floor holds only for the
|
|
82
|
+
# block it was reported under (BLOCKED_FOR), so the same digest can resume a
|
|
83
|
+
# later block.
|
|
84
|
+
read_reported() {
|
|
85
|
+
watch_read_slot_value "$slot" "$MUGGLE_PR_WATCH_REPORTED_FILE" "$1"
|
|
86
|
+
}
|
|
87
|
+
reported_review=$(read_reported REV)
|
|
88
|
+
reported_comment=$(read_reported COM)
|
|
89
|
+
reported_threads=$(read_reported THREADS)
|
|
90
|
+
floor_ci_red=$(read_reported CIRED)
|
|
91
|
+
floor_rebase=$(read_reported REBASED)
|
|
92
|
+
floor_blocked_digest=$(read_reported BLOCKED_CIDIGEST)
|
|
93
|
+
floor_blocked_for=$(read_reported BLOCKED_FOR)
|
|
94
|
+
|
|
95
|
+
read_watermark() {
|
|
96
|
+
watch_read_slot_value "$slot" watch-watermark.env "$1"
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
higher_id() {
|
|
100
|
+
if [ "${1:-0}" -gt "${2:-0}" ] 2>/dev/null; then printf '%s' "${1:-0}"; else printf '%s' "${2:-0}"; fi
|
|
97
101
|
}
|
|
98
102
|
|
|
99
103
|
# Every wait goes through here so that none, however long, can carry the loop
|
|
@@ -104,7 +108,10 @@ pause() {
|
|
|
104
108
|
|
|
105
109
|
while :; do
|
|
106
110
|
watcher_superseded "$slot" "$$" && exit 0
|
|
107
|
-
watcher_lifetime_exceeded "$started" "$(date +%s)"
|
|
111
|
+
if watcher_lifetime_exceeded "$started" "$(date +%s)"; then
|
|
112
|
+
echo "EXPIRED pr=$pr_number watch lifetime of ${MUGGLE_PR_WATCH_MAX_LIFETIME}s reached — re-arm to keep watching"
|
|
113
|
+
exit 0
|
|
114
|
+
fi
|
|
108
115
|
# Exit while the Monitor that owns this stdout is still reading, and say so:
|
|
109
116
|
# the line is the owning session's cue to re-arm, and ending here is what
|
|
110
117
|
# frees the lease that re-arm needs. A loop that ran on past its monitor would
|
|
@@ -136,13 +143,13 @@ while :; do
|
|
|
136
143
|
fi
|
|
137
144
|
unseeded=0
|
|
138
145
|
|
|
139
|
-
watermark_review=$(
|
|
140
|
-
watermark_comment=$(
|
|
141
|
-
watermark_threads=$(
|
|
142
|
-
watermark_ci_red=$(
|
|
143
|
-
watermark_rebase=$(
|
|
144
|
-
watermark_blocked_digest=$(
|
|
145
|
-
watermark_rebase_held=$(
|
|
146
|
+
watermark_review=$(read_watermark REV)
|
|
147
|
+
watermark_comment=$(read_watermark COM)
|
|
148
|
+
watermark_threads=$(read_watermark THREADS)
|
|
149
|
+
watermark_ci_red=$(read_watermark CIRED)
|
|
150
|
+
watermark_rebase=$(read_watermark REBASED)
|
|
151
|
+
watermark_blocked_digest=$(read_watermark BLOCKED_CIDIGEST)
|
|
152
|
+
watermark_rebase_held=$(read_watermark REBASE_HELD)
|
|
146
153
|
|
|
147
154
|
state_line=$(watch_fetch_state "$repo" "$pr_number" "$slot" "$state_projection")
|
|
148
155
|
# One quick retry before counting a strike: a single flaky call should not
|
|
@@ -187,29 +194,25 @@ while :; do
|
|
|
187
194
|
continue
|
|
188
195
|
fi
|
|
189
196
|
fails=0
|
|
197
|
+
has_reported=""
|
|
190
198
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
floor_review="$latest_review"
|
|
194
|
-
fi
|
|
195
|
-
|
|
196
|
-
if watch_wake_comment "$pr_number" "$latest_comment" \
|
|
197
|
-
"$(( watermark_comment > floor_comment ? watermark_comment : floor_comment ))"; then
|
|
198
|
-
floor_comment="$latest_comment"
|
|
199
|
-
fi
|
|
199
|
+
watch_wake_review "$pr_number" "$latest_review" "$(higher_id "$watermark_review" "$reported_review")" && has_reported=1
|
|
200
|
+
watch_wake_comment "$pr_number" "$latest_comment" "$(higher_id "$watermark_comment" "$reported_comment")" && has_reported=1
|
|
200
201
|
|
|
201
202
|
if [ -n "$unresolved_threads" ]; then
|
|
202
203
|
IFS=';' read -ra thread_ids <<< "$unresolved_threads"
|
|
203
204
|
for thread_id in "${thread_ids[@]}"; do
|
|
204
|
-
|
|
205
|
-
floor_threads="${floor_threads}${thread_id};"
|
|
206
|
-
fi
|
|
205
|
+
watch_wake_thread "$pr_number" "$thread_id" "${watermark_threads};${reported_threads}" && has_reported=1
|
|
207
206
|
done
|
|
208
207
|
fi
|
|
209
208
|
|
|
210
|
-
|
|
211
|
-
|
|
209
|
+
# Either floor quiets the head: the watermark's when a fix already ran for
|
|
210
|
+
# it, the reported one when a loop already told the session it went red.
|
|
211
|
+
ci_red_floor="$watermark_ci_red"
|
|
212
|
+
[ "$floor_ci_red" = "$head_sha" ] && ci_red_floor="$floor_ci_red"
|
|
213
|
+
if watch_wake_ci_red "$pr_number" "$pending_checks" "$failed_checks" "$head_sha" "$ci_red_floor"; then
|
|
212
214
|
floor_ci_red="$head_sha"
|
|
215
|
+
has_reported=1
|
|
213
216
|
fi
|
|
214
217
|
|
|
215
218
|
rebase_key="${head_sha}..${base_tip_sha}"
|
|
@@ -227,6 +230,7 @@ while :; do
|
|
|
227
230
|
if watch_wake_rebase "$pr_number" "$mergeable" "$behind_count" "$rebase_key" ""; then
|
|
228
231
|
floor_rebase="$rebase_key"
|
|
229
232
|
due_rebase_key="$rebase_key"
|
|
233
|
+
has_reported=1
|
|
230
234
|
fi
|
|
231
235
|
fi
|
|
232
236
|
|
|
@@ -239,12 +243,21 @@ while :; do
|
|
|
239
243
|
fi
|
|
240
244
|
|
|
241
245
|
# The tick owns clearing the block, and it may not run for a while. Holding
|
|
242
|
-
# the
|
|
243
|
-
#
|
|
244
|
-
|
|
246
|
+
# the reported digest stops each fresh loop repeating it in the meantime — the
|
|
247
|
+
# repeat nagging the design rules out — while a further move still wakes it.
|
|
248
|
+
[ "$floor_blocked_for" = "$watermark_blocked_digest" ] || floor_blocked_digest=""
|
|
245
249
|
if [ "$ci_digest" != "$floor_blocked_digest" ] \
|
|
246
250
|
&& watch_wake_blocked_resume "$pr_number" "$ci_digest" "$watermark_blocked_digest"; then
|
|
247
251
|
floor_blocked_digest="$ci_digest"
|
|
252
|
+
floor_blocked_for="$watermark_blocked_digest"
|
|
253
|
+
has_reported=1
|
|
254
|
+
fi
|
|
255
|
+
|
|
256
|
+
if [ -n "$has_reported" ]; then
|
|
257
|
+
watch_record_reported "$slot" "$(higher_id "$latest_review" "$reported_review")" \
|
|
258
|
+
"$(higher_id "$latest_comment" "$reported_comment")" "$unresolved_threads" \
|
|
259
|
+
"$floor_ci_red" "$floor_rebase" "$floor_blocked_digest" "$floor_blocked_for"
|
|
260
|
+
exit 0
|
|
248
261
|
fi
|
|
249
262
|
|
|
250
263
|
pause "$MUGGLE_PR_WATCH_POLL_INTERVAL"
|
|
@@ -9,10 +9,11 @@ set -euo pipefail
|
|
|
9
9
|
# The nudge is owner-scoped. Each slot records the session that armed it in
|
|
10
10
|
# owner.json; only slots owned by THIS session are a recovery the agent should
|
|
11
11
|
# run, because re-arming a stranger's PR would hand review work to a session with
|
|
12
|
-
# no context for it.
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
12
|
+
# no context for it. A session that owns none hears nothing: the hook speaks into
|
|
13
|
+
# every session start, and slots owned elsewhere are no work of this one's —
|
|
14
|
+
# `muggle watch status` reports them. A pure directory scan — no gh, no writes,
|
|
15
|
+
# bash builtins only — so it stays cheap enough for every session start however
|
|
16
|
+
# many slots have piled up.
|
|
16
17
|
|
|
17
18
|
hook_dir="${BASH_SOURCE[0]%/*}"
|
|
18
19
|
[ "$hook_dir" = "${BASH_SOURCE[0]}" ] && hook_dir=.
|
|
@@ -56,8 +57,6 @@ fi
|
|
|
56
57
|
# stopped watchers back as work.
|
|
57
58
|
sessions_dir="${HOME}/.muggle-ai/muggle-do/sessions"
|
|
58
59
|
owned_count=0
|
|
59
|
-
orphan_count=0
|
|
60
|
-
first_orphan_slug=""
|
|
61
60
|
|
|
62
61
|
if [ -d "$sessions_dir" ]; then
|
|
63
62
|
for slot in "$sessions_dir"/*/; do
|
|
@@ -76,20 +75,11 @@ if [ -d "$sessions_dir" ]; then
|
|
|
76
75
|
# (legacy slot) is likewise foreign — never adopted by default.
|
|
77
76
|
if [ -n "$current_session_id" ] && [ "$slot_owner" = "$current_session_id" ]; then
|
|
78
77
|
owned_count=$((owned_count + 1))
|
|
79
|
-
else
|
|
80
|
-
orphan_count=$((orphan_count + 1))
|
|
81
|
-
if [ -z "$first_orphan_slug" ]; then
|
|
82
|
-
first_orphan_slug="${slot%/}"
|
|
83
|
-
first_orphan_slug="${first_orphan_slug##*/}"
|
|
84
|
-
fi
|
|
85
78
|
fi
|
|
86
79
|
done
|
|
87
80
|
fi
|
|
88
81
|
|
|
89
|
-
|
|
90
|
-
if [ "$owned_count" -eq 0 ] && [ "$orphan_count" -eq 0 ]; then
|
|
91
|
-
exit 0
|
|
92
|
-
fi
|
|
82
|
+
[ "$owned_count" -gt 0 ] || exit 0
|
|
93
83
|
|
|
94
84
|
if [ "$owned_count" -eq 1 ]; then
|
|
95
85
|
owned_word="watcher"
|
|
@@ -97,20 +87,7 @@ else
|
|
|
97
87
|
owned_word="watchers"
|
|
98
88
|
fi
|
|
99
89
|
|
|
100
|
-
|
|
101
|
-
context="muggle-pr-followup: ${owned_count} open ${owned_word} owned by this session may have lost its poller. Run \`/muggle:muggle-pr-followup reconcile\` to finalize any whose PR went terminal and re-arm the silently-stopped ones. Reconcile is idempotent and re-arms only watchers this session armed itself."
|
|
102
|
-
else
|
|
103
|
-
context="muggle-pr-followup: no watchers belong to this session — nothing to re-arm."
|
|
104
|
-
fi
|
|
105
|
-
|
|
106
|
-
if [ "$orphan_count" -gt 0 ]; then
|
|
107
|
-
if [ "$orphan_count" -eq 1 ]; then
|
|
108
|
-
orphan_word="slot is"
|
|
109
|
-
else
|
|
110
|
-
orphan_word="slots are"
|
|
111
|
-
fi
|
|
112
|
-
context="${context} ${orphan_count} open ${orphan_word} owned by other sessions; reconcile will finalize them if their PR already merged or closed, but will never re-arm them. Adopt one deliberately with \`/muggle:muggle-pr-followup adopt <slug>\` (e.g. ${first_orphan_slug}) only if the user asks — do not offer or adopt on your own."
|
|
113
|
-
fi
|
|
90
|
+
context="muggle-pr-followup: ${owned_count} open ${owned_word} owned by this session may have lost its poller. Run \`/muggle:muggle-pr-followup reconcile\` to finalize any whose PR went terminal and re-arm the silently-stopped ones. Reconcile is idempotent and re-arms only watchers this session armed itself."
|
|
114
91
|
|
|
115
92
|
escaped_context=$(escape_for_json "$context")
|
|
116
93
|
|
|
@@ -11,7 +11,7 @@ These skills are written against Claude Code's tools. In another agent host —
|
|
|
11
11
|
| `$CLAUDE_CODE_SESSION_ID` | This session's id | `$CODEX_SESSION_ID` — the same id the hooks receive as `session_id`. |
|
|
12
12
|
| Dispatch a plugin agent (`agents/*.md`, subagent type `muggle:<agent>`) | The Agent tool | `spawn_agent` with that agent file's instructions as the task, when multi-agent is enabled; otherwise run the agent file inline, as each skill already allows for a host with no subagents. |
|
|
13
13
|
| `TodoWrite` | The session task list | `update_plan` |
|
|
14
|
-
| `
|
|
14
|
+
| A background `Bash` command (`run_in_background`), `/loop`, `CronCreate`/`CronDelete` (the PR watcher's background watch) | Wake the session when the watch reports an event | No equivalent: nothing in Codex wakes a session from a background process. Run the drain tick, seed the slot without a loop (`pr-watch-arm.sh … --no-exec`), and tell the user that following the PR up means invoking `$muggle:muggle-pr-followup` again — or scheduling it with `codex exec`. Never leave a loop polling that no session will hear. |
|
|
15
15
|
| `model:` in a skill's frontmatter | Runs the skill on that model | Ignored — the session's model runs every skill. |
|
|
16
16
|
|
|
17
17
|
## Codex's sandbox
|
|
@@ -55,4 +55,4 @@ At most four options, in this order:
|
|
|
55
55
|
|
|
56
56
|
A new task goes through the selector's free-text answer, which starts a fresh `/muggle-do` forward run.
|
|
57
57
|
|
|
58
|
-
**Text while a watch runs.** When the status block holds an `open` or `blocked` line, print the selector as text — one numbered option per line — and end the turn on the question; the user's next message is the pick. A pending `AskUserQuestion` holds the turn, and every watcher this chat runs with it: a
|
|
58
|
+
**Text while a watch runs.** When the status block holds an `open` or `blocked` line, print the selector as text — one numbered option per line — and end the turn on the question; the user's next message is the pick. A pending `AskUserQuestion` holds the turn, and every watcher this chat runs with it: a loop that ends on a report goes unanswered and the watcher stays dead until the user replies. With no such line, ask with one `AskUserQuestion`. The adopt selector under **Other chats' work** follows the same rule.
|
|
@@ -86,7 +86,7 @@ If `prs.json` is empty, **do not arm** — record the reason in `result.md` and
|
|
|
86
86
|
**Skipped:** repo → reason (when `autoCreatePR` short-circuited)
|
|
87
87
|
**Overflow comments posted:** repo → PR #
|
|
88
88
|
**Stage 7.5:** `green` | `waived — <reason>` | `iteration-cap`
|
|
89
|
-
**Stage 8:** `Watching <N> PR(s) — one
|
|
89
|
+
**Stage 8:** `Watching <N> PR(s) — one watch loop per PR, armed per arm-watcher.md` | `No PRs to watch — stage 8 not armed`
|
|
90
90
|
**Errors:** repo → message
|
|
91
91
|
|
|
92
92
|
## Post-merge cleanup
|
|
@@ -16,13 +16,13 @@ A watcher that babysits one open PR toward **merge-ready** — review threads ad
|
|
|
16
16
|
|
|
17
17
|
**Per-PR isolation.** One watcher per PR. Multi-PR work runs N independent watchers.
|
|
18
18
|
|
|
19
|
-
**Arming.** Bootstrap, auto-track, and the executor's post-cycle respawn all arm the watch the same way — one drain tick, then one
|
|
19
|
+
**Arming.** Bootstrap, auto-track, and the executor's post-cycle respawn all arm the watch the same way — one drain tick, then one background watch loop per PR — visible as a running task for as long as the PR is polled, gone at terminal ([`arm-watcher.md`](arm-watcher.md)). The loop polls token-free and reaches the session only by exiting, on the poll that finds something to act on, so a quiet PR costs no turns; the tick that answers it re-arms the loop ([`contract.md`](contract.md) Step 7.5). Crons only ever deliver a recovery tick, never the ongoing cadence: a cron-delivered tick re-arms the loop and cancels its own cron, because every cron fire is a model turn.
|
|
20
20
|
|
|
21
21
|
**One reminder when blocked pending a human.** When a PR can't progress without the user — an escalated rebase/CI budget spent, or an ambiguous review awaiting direction — the tick that flags the block emits **one** one-line reminder: the pending act plus a reference back to the decision context ([`contract.md`](contract.md) Steps 2.5, 7). After that the watch stays visible and silent — no repeat nagging — and the block clears the instant a wake finds a push, review, or CI/deploy state moved.
|
|
22
22
|
|
|
23
|
-
**Cron lifecycle.** Each tick records its `/loop` cron id to `cron.json` while `CronList` can still see it ([`record-cron-id.md`](record-cron-id.md)), so teardown can delete the cron by id after a session continue / compaction blinds `CronList` to it. Reconcile ([`reconcile.md`](reconcile.md)) sweeps crons whose PR is terminal or whose slot is gone, and re-arms an open slot whose watcher stopped silently —
|
|
23
|
+
**Cron lifecycle.** Each tick records its `/loop` cron id to `cron.json` while `CronList` can still see it ([`record-cron-id.md`](record-cron-id.md)), so teardown can delete the cron by id after a session continue / compaction blinds `CronList` to it. Reconcile ([`reconcile.md`](reconcile.md)) sweeps crons whose PR is terminal or whose slot is gone, and re-arms an open slot whose watcher stopped silently — loop-first, never with a recurring cron.
|
|
24
24
|
|
|
25
|
-
**Session death.**
|
|
25
|
+
**Session death.** Watch loops and crons are both session-bound — a session that ends or hits its usage limit takes every watch with it. That is by design: nothing polls out of session, because a review is addressed only inside a session that carries the context to address it, never by a headless process replying context-blind.
|
|
26
26
|
|
|
27
27
|
**A watcher belongs to the session that armed it.** Each slot records its owning session in `owner.json` ([`state-schemas.md`](state-schemas.md#ownerjson)), and recovery is owner-scoped: [`reconcile`](reconcile.md#triggers) re-arms a dead watcher only inside the session that armed it, and reports the rest as orphans it declined to touch. Starting a session therefore picks up nothing new — the same context argument that rules out a headless daemon rules out inheriting a stranger's PR, which would hand review work to a session that never saw the design. A PR whose owning session is gone stays unwatched until the user adopts it by name ([`adopt.md`](adopt.md)).
|
|
28
28
|
|
|
@@ -34,10 +34,10 @@ The skill recognizes its mode by inspecting `$ARGUMENTS` and falling back to on-
|
|
|
34
34
|
| :---- | :------------ | :--- |
|
|
35
35
|
| First arg matches `https?://github\.com/[^/]+/[^/]+/pull/\d+` | — | **bootstrap** → [`bootstrap.md`](bootstrap.md) |
|
|
36
36
|
| `<slug> <pr-number>` | session dir for `<slug>` exists; slot's `watch.pid` is dead, or `--wake` passed | **tick** → [`contract.md`](contract.md) |
|
|
37
|
-
| `<slug> <pr-number>` | session dir for `<slug>` exists; `watch.pid` names a live process and no `--wake` | **watch-status** — report the slot from on-disk state (`prs.json` state + head, newest `followup.log` tick line,
|
|
37
|
+
| `<slug> <pr-number>` | session dir for `<slug>` exists; `watch.pid` names a live process and no `--wake` | **watch-status** — report the slot from on-disk state (`prs.json` state + head, newest `followup.log` tick line, loop liveness, watermark floors). **Zero provider calls:** a live loop owns the cadence, and the session never polls unwoken. |
|
|
38
38
|
| `<slug> <pr-number>` | session dir missing, `<slug>.stopped` exists (or the global kill file `~/.muggle-ai/muggle-do/polling.disabled`) | **absorb** — one line, nothing else ([`contract.md`](contract.md) Step 0) |
|
|
39
39
|
| `<slug> <pr-number>` | session dir missing | **error:** "no session at `<path>`; pass a PR URL to start one" |
|
|
40
|
-
| `stop` (optional `<slug>`) | — | **stop** → [`stop.md`](stop.md) — tear down
|
|
40
|
+
| `stop` (optional `<slug>`) | — | **stop** → [`stop.md`](stop.md) — tear down loop + cron, mark slot(s) `.stopped`; no slug stops everything and writes the kill file |
|
|
41
41
|
| `<pr-number>` alone | exactly one existing session contains it | **tick** or **watch-status** for that PR, by the same `watch.pid` gate |
|
|
42
42
|
| `<pr-number>` alone | zero or multiple matches | **error:** ambiguous; list candidates and exit |
|
|
43
43
|
| empty | — | **auto-track** → [`auto-track.md`](auto-track.md) |
|