moai-workflow-ci-watch)
Quick Reference
Automatically invoked after /moai sync creates a PR. Polls gh pr checks
every 30 seconds, classifies required vs auxiliary failures using the SSoT at
.github/required-checks.yml, and presents a ready-to-merge decision report.
Trigger: After /moai sync Phase 4 PR-create step completes.
Exit paths: all-pass (ready-to-merge) | required-fail (T3 handoff) | 30-min timeout.
One-liner
# Orchestrator invokes via Bash tool after gh pr create returns PR number.
MOAI_CIWATCH_GH=gh sh scripts/ci-watch/run.sh <PR_NUMBER> <BRANCH>
State Machine
[idle] → [arm-watch] → [watching] → ┬─→ [all-required-pass] → emit ready-to-merge
├─→ [required-fail] → JSON handoff to T3
├─→ [auxiliary-fail-only] → emit advisory → continue
└─→ [timeout-30m] → emit blocker
Required vs Auxiliary
- Required checks: defined in
.github/required-checks.ymlbranches.<pattern>.contexts - Auxiliary checks: defined in
.github/required-checks.ymlauxiliarylist - Auxiliary failures emit advisory messages but do NOT block ready-to-merge
Detailed Reference: modules/ci-watch-protocol.md
Implementation Guide
Orchestrator-Side Protocol
When /moai sync Phase 4 returns a PR number, the orchestrator MUST:
-
Start the watch loop via Bash tool:
MOAI_CIWATCH_GH=gh sh scripts/ci-watch/run.sh <PR_NUMBER> <BRANCH> -
Monitor the exit code:
- Exit 0: All required checks passed → present ready-to-merge report via AskUserQuestion
- Exit 2: Required failure detected → parse JSON from stdout → invoke T3 auto-fix loop
- Exit 3: 30-min hard timeout → emit blocker message, return control to user
-
On exit 0, emit ready-to-merge report:
# Go CLI generates the markdown report; orchestrator reads and presents via AskUserQuestion. moai pr watch --report <PR_NUMBER> --branch <BRANCH> -
On exit 2, pipe JSON handoff to Wave 3 manager-quality:
# stdout contains: {"prNumber":N,"branch":"...","failedChecks":[...],"auxiliaryFailCount":N} # Orchestrator injects this JSON into manager-quality spawn prompt.
State File Management
The watch loop maintains .moai/state/ci-watch-active.flag (YAML):
pr_number: 785
started_at: "2026-05-06T08:30:00Z"
heartbeat_at: "2026-05-06T08:35:00Z"
required_checks: [Lint, "Test (ubuntu-latest)", ...]
abort_requested: false
Abort an active watch:
moai pr watch --abort
Staleness detection: If heartbeat_at is older than 90 seconds, the state
is considered stale (crashed watch) and a new invocation may take over.
Go Helpers (internal/ciwatch/)
| Package | File | Purpose |
|---|---|---|
| ciwatch | classifier.go |
IsRequired(check, branch) via SSoT YAML |
| ciwatch | handoff.go |
CIState, CheckResult, Handoff, NewHandoff(), FormatStatusUpdate() |
| ciwatch | state.go |
WatchState, ReadState(), WriteState(), Touch(), SetAbortFlag() |
| cli/pr | watch.go |
EmitReadyToMergeReport(), EmitFailureHandoff() |
Shell Helpers (scripts/ci-watch/)
| File | Purpose |
|---|---|
run.sh |
Main polling loop (mock-injectable via MOAI_CIWATCH_GH) |
lib/_common.sh |
log_step(), abort(), posix_now() |
lib/classify.sh |
is_required(), is_auxiliary() (yq + grep fallback) |
lib/timeout.sh |
ciwatch_start_timer(), ciwatch_check_timeout() |
Detailed Reference: modules/trigger-handoff.md
Advanced Patterns
Abort and Recover
If the watch loop is interrupted (Ctrl-C, session end):
- Heartbeat becomes stale after 90s
- Next invocation auto-reclaims (no explicit cleanup needed)
- For immediate reclaim:
moai pr watch --abort
gh CLI Compatibility
Requires gh >= 2.50 for the workflow JSON field. Version check:
gh --version | grep -E '^gh version ([2-9]|[1-9][0-9])\.([5-9][0-9]|[6-9][0-9])'
On older gh, workflow field is absent; lib/classify.sh falls back to
name-based heuristics using the required: list from the SSoT directly.
Concurrent Invocation Guard
Only one watch-per-repo at a time. If a second moai pr watch invocation
detects a fresh heartbeat (< 90s), it exits with:
[ci-watch][FATAL] watch already active for PR=785; run `moai pr watch --abort` to release
Status Report Format
Every 30-second tick emits to stderr:
[ci-watch] PR #785: required 4/6 pass, 2 pending; advisory 0 fail
- No ANSI escape codes (orchestrator chat transport)
- Max 200 chars per line
- State-change ticks only (no repeat if unchanged)
Wave 3 Handoff Schema
On exit 2, stdout contains stable JSON for manager-quality consumption:
{
"prNumber": 785,
"branch": "feat/my-feature",
"failedChecks": [
{
"name": "Lint",
"runId": "12345678",
"logUrl": "https://github.com/.../actions/runs/12345678",
"conclusionDetail": ""
}
],
"auxiliaryFailCount": 1,
"totalRequired": 6
}
Field stability: name, runId, logUrl are stable for Wave 3. Do not rename.
Works Well With
moai-workflow-ci-watch→manager-quality(Wave 3 auto-fix loop).github/required-checks.yml(Wave 1 SSoT)scripts/ci-mirror/run.sh(Wave 1 local CI mirror).claude/rules/moai/workflow/ci-watch-protocol.md(HARD invocation contract)
Common Rationalizations
| Rationalization | Reality |
|---|---|
| "I'll skip the watch loop for a small PR" | Small PRs fail CI too. The loop costs nothing and saves manual polling. |
| "Auxiliary failures should block merge" | Auxiliary checks are defined as advisory in the SSoT. Changing this requires editing required-checks.yml, not the skill. |
| "30 min is too long" | Extend via CIWATCH_TIMEOUT_SECONDS env if needed. Default covers tail-end CI runs. |
Red Flags
- Orchestrator calls AskUserQuestion inside the watch loop (HARD: orchestrator-only, not CLI)
- State file path hardcoded outside
ciwatch.StateFileconstant - Watch loop polling interval < 30s (GitHub API rate-limit risk)
- required-checks.yml modified to add checks without updating branch protection via
moai github init
Verification
-
bash scripts/ci-watch/test/run_test.shpasses all 9 shell tests -
go test ./internal/ciwatch/... ./internal/cli/pr/... -racepasses -
internal/ciwatch/coverage >= 85% - No ANSI codes in
FormatStatusUpdate()output -
EmitReadyToMergeReportcontains(권장)on first option - CLI does NOT call AskUserQuestion (orchestrator-only)