The ready stage is the merge-readiness gate that runs after review and before merge. Review answers "is the change correct?"; ready answers "is it safe to merge right now?"
This stage is available in two forms:
wavemill ready <pr>for an on-demand check of a PR number or PR URL- the
readyphase insidewavemill millfor continuous monitor-based gating
The ready stage now includes live GitHub mergeability detection for open PRs. Merge-conflict state is reported separately from the readiness verdict so operators and the monitor can distinguish conflict remediation from other readiness failures.
When autonomous integration mode is enabled, the ready stage also becomes the policy gate that tend rechecks immediately before merge.
The ready stage sits between PR creation and merge:
review -> ready -> merge
Responsibilities of each phase:
review: judge correctness, requirements coverage, and major code-quality blockersready: judge merge-readiness, release-readiness, and operator follow-up stepsmerge: complete the PR only after the ready gate is satisfied
The target check categories for the ready stage are:
- CI status
- required approvals
- merge conflicts
- branch freshness
- release and manual-step requirements
- migration safety checks for risky Alembic DDL
- migration reversibility
Current implementation details:
- mergeability comes from
gh pr view --json mergeable,mergeStateStatus - GitHub
UNKNOWNmergeability is retried up to 3 times with 5-second delays - merge conflict state is surfaced in a dedicated
mergeConflictfield - the readiness verdict still reflects the configured
checksarray
wavemill ready <pr>Direct tool invocation during development also works:
npx tsx tools/ready.ts <pr>| Argument | Meaning |
|---|---|
<pr> |
GitHub PR number like 42 or PR URL like https://github.com/org/repo/pull/42 |
| Flag | Meaning |
|---|---|
--repo-dir <path> |
Repository directory to inspect. Defaults to the current directory. |
Check a PR by number:
cd ~/src/my-repo
wavemill ready 42Check a PR by URL:
wavemill ready https://github.com/acme/widgets/pull/42Check a PR from another checkout:
wavemill ready 42 --repo-dir ~/src/my-repoThe command writes human-readable mergeability status to stderr and JSON to stdout so it can still be consumed by scripts or the monitor:
{
"prNumber": 42,
"verdict": "warn",
"checks": [
{
"name": "release-requirements",
"status": "warn",
"message": "No task packet found - skipping release requirements check",
"details": {}
}
],
"mergeConflict": {
"status": "CLEAN",
"message": "No merge conflicts detected",
"mergeable": "MERGEABLE",
"mergeStateStatus": "CLEAN",
"attempts": 1
},
"timestamp": "2026-04-08T12:00:00.000Z",
"summary": "Checks passed with warnings - review before merge"
}The shared result schema is:
| Field | Type | Meaning |
|---|---|---|
prNumber |
number |
PR that was checked |
verdict |
`"pass" | "fail" |
checks |
ReadyCheck[] |
Individual check results |
mergeConflict |
MergeConflictResult | undefined |
GitHub mergeability state for the PR |
timestamp |
string |
ISO 8601 UTC timestamp |
summary |
string |
Human-readable overall summary |
Each item in checks has:
| Field | Type | Meaning |
|---|---|---|
name |
string |
Stable check identifier such as ci-status |
status |
`"pass" | "fail" |
message |
string |
Human-readable explanation |
details |
Record<string, unknown> |
Optional structured context |
| Code | Meaning |
|---|---|
0 |
Ready verdict is pass or warn |
1 |
Ready verdict is fail |
Operational note: mergeConflict.status is independent from the main verdict. A PR can have clean readiness checks but still be blocked because GitHub reports merge conflicts.
Example clean mergeability output:
{
"prNumber": 42,
"verdict": "pass",
"checks": [
{
"name": "ci-status",
"status": "pass",
"message": "All CI checks passing"
}
],
"mergeConflict": {
"status": "CLEAN",
"message": "No merge conflicts detected",
"mergeable": "MERGEABLE",
"mergeStateStatus": "CLEAN",
"attempts": 1
},
"timestamp": "2026-04-08T12:00:00.000Z",
"summary": "All checks passed - safe to merge"
}The fail shape below shows the contract operators and scripts should be prepared to handle once real checks are implemented:
{
"prNumber": 42,
"verdict": "fail",
"checks": [
{
"name": "merge-conflicts",
"status": "fail",
"message": "PR has merge conflicts with main",
"details": {
"state": "conflicted"
}
},
{
"name": "approvals",
"status": "pass",
"message": "Required approvals are present"
}
],
"mergeConflict": {
"status": "CONFLICTED",
"message": "PR has merge conflicts with base branch",
"mergeable": "CONFLICTING",
"mergeStateStatus": "DIRTY",
"attempts": 1
},
"timestamp": "2026-04-08T12:05:00.000Z",
"summary": "Merge is blocked until conflicts are resolved"
}The ready engine checks mergeability with:
gh pr view <pr> --json mergeable,mergeStateStatusGitHub computes mergeability lazily, so the first response can be UNKNOWN. The ready engine retries up to 3 times with 5-second delays before surfacing UNKNOWN to the caller.
Reported merge states:
CLEAN: GitHub reports the PR can merge cleanlyCONFLICTED: GitHub reports merge conflictsUNKNOWN: GitHub is still computing mergeability after retriesERROR: GitHub CLI failed or returned an unexpected state
The migration-reversibility check inspects every migration file added or
modified by the PR and confirms each one has an executable downgrade()
function. Real incidents need rollback, so a def downgrade(): pass quietly
removes that option without anyone deciding it is OK; this check forces the
choice to be made deliberately rather than discovered at 3 AM.
For each migration file matched by ready.migrationPatterns and present in
the PR's changed files, the check parses the file with Python's ast module
(via shared/lib/migration_ast.py) and inspects the body of def downgrade().
Hard failures:
| Body shape | Reason key |
|---|---|
| Function not defined | missing-downgrade |
Only pass |
empty-pass |
| Only a docstring | empty-docstring |
Docstring + pass |
empty-pass |
Only raise NotImplementedError (bare or called) |
not-implemented |
Docstring + raise NotImplementedError |
not-implemented |
| Python syntax error / parse failure | parse-error |
A non-trivial body — anything that contains executable statements other
than the patterns above — passes.
Repositories that genuinely cannot reverse a specific migration can apply the
migration:irreversible label to the PR. When the label is present, hard
failures are recorded under details.overriddenFailures and the check returns
pass so the choice is documented rather than hidden. Label matching is
case-insensitive.
Even when downgrade() is non-trivial, the check emits an advisory entry in
details.warnings whenever upgrade() contains op.drop_column or
op.drop_table. Recreating a column or table in downgrade() will not
restore data that was already dropped, so reviewers should confirm the data
loss is acceptable. The warning does not change the check status (pass) and
does not flip the overall ready verdict to warn.
If no changed files match the configured migration patterns, the check
returns skip and does not invoke Python at all.
ready.checkshonors"migration-reversibility"as a stable check name. Listing it alongsideci-status, etc. restricts the ready stage to that subset.ready.migrationPatternscontrols which paths are treated as migration files for both this check andmigration-chain-integrity.
{
"name": "migration-reversibility",
"status": "fail",
"message": "Migration file has no executable downgrade() — apply the migration:irreversible label to document this decision",
"details": {
"overrideLabel": "migration:irreversible",
"overrideApplied": false,
"failures": [
{
"file": "migrations/versions/2026_05_03_001_add_column.py",
"reason": "empty-pass",
"classification": "empty-pass",
"message": "Migration downgrade() body is only \"pass\""
}
],
"warnings": [],
"migrationFiles": ["migrations/versions/2026_05_03_001_add_column.py"]
}
}{
"name": "migration-reversibility",
"status": "pass",
"message": "Migration reversibility failures overridden by migration:irreversible label",
"details": {
"overrideLabel": "migration:irreversible",
"overrideApplied": true,
"overriddenFailures": [
{
"file": "migrations/versions/2026_05_03_001_drop_legacy.py",
"reason": "not-implemented",
"classification": "not-implemented",
"message": "Migration downgrade() raises NotImplementedError"
}
],
"warnings": [],
"migrationFiles": ["migrations/versions/2026_05_03_001_drop_legacy.py"]
}
}{
"name": "migration-reversibility",
"status": "pass",
"message": "Migration downgrade() bodies are non-trivial; upgrade() contains destructive operations that downgrade cannot restore data for",
"details": {
"warnings": [
{
"file": "migrations/versions/2026_05_03_002_remove_field.py",
"destructiveOperations": ["op.drop_column"],
"classification": "non-trivial"
}
],
"migrationFiles": ["migrations/versions/2026_05_03_002_remove_field.py"]
}
}When integration.readyPolicy.enabled = true, autonomous merge uses ready-engine.ts to evaluate a focused set of policy guards before tend merges a PR into auto/integration.
The guards are:
checkBaseBranch(): PR must target the configured integration branch.checkMetadata(): PR must contain a validwavemill-metablock.checkDependencies():depends_onanddepends_on_linearreferences must already be satisfied.checkMigrationCoupling():db:migrationmust be paired withwm:migrationunless coupling is disabled.checkRiskPolicy(): high-risk PRs follow the configured risk policy.checkChallengePairs(): challenge PRs need a resolved comparison record before autonomous merge.
| Signal | Ready | Blocked / Pending / Warn |
|---|---|---|
| Base branch | PR targets integration.readyPolicy.integrationBranch or integration.integrationBranch |
fail if the PR still targets another branch such as main |
| Metadata | Valid wavemill-meta block |
fail if missing or malformed |
| PR dependency | Referenced depends_on: ["PR#123"] PR is merged |
pending if still open, fail if missing or closed without merge |
| Linear dependency | Referenced issue is completed | pending if still incomplete, fail if missing or canceled |
| Migration coupling | no migration labels, or both db:migration and wm:migration are present |
fail if db:migration is missing wm:migration; warn if wm:migration exists alone |
| High risk | no high-risk signal, or policy requirements are satisfied | fail for riskPolicy=block; pending for require-label without wm-risk-acknowledged; warn for riskPolicy=auto |
| Challenge pair | PR is not in challenge mode, or a comparison record resolves the pair | fail if comparison data is missing or unreadable |
Ready policy is necessary but not sufficient. tend also checks the current auto/integration branch health before selecting any candidate. If the integration branch CI is red, tend reports health=degraded, no PR is eligible, and autonomous merging halts until the branch is green again.
That means a PR can be individually ready and still remain unmerged because the shared integration branch is failing.
When wavemill mill runs the ready stage and sees mergeConflict.status = "CONFLICTED":
- It writes
features/<slug>/.conflict-detectedin the existing worktree. - It relaunches the task agent in that same worktree with a conflict-resolution-only prompt.
- The agent fetches the base branch, merges it, resolves conflicts, validates, commits, and pushes.
- After the agent exits, the monitor reruns
wavemill ready <pr>.
If conflicts persist, or mergeability comes back UNKNOWN or ERROR, the monitor writes features/<slug>/.needs-attention and marks the task for operator follow-up.
wavemill mill uses the same ready-stage contract for automatic merge gating after review completes and a PR exists.
The intended workflow is:
coding -> review -> PR open -> ready -> merge
Monitor responsibilities in the ready phase:
- move a task from
reviewintoreadyonce the PR is open - run the same shared readiness engine used by
wavemill ready <pr> - persist the result in workflow state
- keep the task blocked from merge completion until the ready gate passes
- rerun readiness after operator action or automated remediation
- surface merge conflicts separately from other readiness verdict failures
Expected state transitions:
| From | Condition | To |
|---|---|---|
review |
PR created successfully | ready |
ready |
all required checks pass | merge |
ready |
required check fails | blocked or stay in ready pending remediation |
ready |
warnings only | merge with operator awareness |
Practical operator interpretation:
- a passing ready result means merge may proceed
- a warning result means merge may proceed, but the warning must be consciously handled
- a failing result means do not merge until the blocking condition is cleared
For conflicted PRs, the monitor attempts a narrow in-place remediation before escalating to the operator.
Policy for repositories adopting the ready stage:
- Do not merge a PR until the ready check has run.
- Do not allow human override for a
failverdict unless there is a documented incident-level reason. - Treat
warnas mergeable only when the operator has completed or explicitly accepted the listed manual steps. - Keep the merge decision tied to the latest ready result, not an older successful run.
Manual operator workflow:
wavemill ready 42Use the result to answer three questions before merge:
- Are blocking checks failing?
- Are there manual release steps that must happen before or after merge?
- Is the result current for the latest PR head?
Recommended merge rule once real checks are live:
pass: merge allowedwarn: merge allowed only with explicit operator handling of the warningfail: merge blocked
Use the migration dry-run workflow when label-based or static migration checks are not enough. Running every migration end to end against a clean Postgres instance catches semantic failures that chain checks miss, including missing create_index calls, object-name collisions, Postgres-specific ENUM behavior, and hidden ordering dependencies between revisions.
Scaffold the workflow into an adopting repository with either entrypoint:
npx tsx tools/scaffold-migrate-dryrun.ts <target-repo>
wavemill scaffold migrate-dryrun <target-repo>The scaffold writes two files into the target repo:
.github/workflows/_migrate-dryrun.yml- the reusable workflow copied from wavemill.github/workflows/migrate-dryrun.yml- a thin wrapper triggered onpull_request
The wrapper defaults to alembic upgrade head, Python 3.11, database app_test, and requirements.txt. Pass --verify-reversibility during scaffolding if the repo should also run alembic downgrade base followed by a second upgrade head.
Tradeoffs:
- Upgrade-only runs are typically about 30 seconds on
ubuntu-latest - Reversibility roughly doubles that to about 60 seconds
- Budget roughly 2 GitHub Actions minutes per PR when the workflow is enabled
Integration with checkCIStatus is automatic. This workflow appears like any other GitHub status check, so the ready stage will pick it up without new ready-specific code.
The ready stage always runs for mill-managed repositories. The ready config section controls which checks run and which checks are required; it does not disable the ready phase itself.
Configuration cases:
readymissing from.wavemill-config.json: only universal checks run (pr-exists,merge-conflict,ci-status)ready.checksmissing or[]: same conservative universal defaultsready.checks: explicit allowlist of checks to runready.requiredChecks: subset ofready.checksthat is merge-blockingready.migrationPatterns: overrides the regex patterns used to discover migration files for migration-related checks (includingmigration-reversibility)ready.migrationKind: declares migration format (alembic,sql,none) for migration-specific checks
Workflow expectations:
- the ready contract remains stable even as checks are added
- existing review and merge workflows continue after the ready gate reports
passorwarn
forbidden-ddl inspects changed migration files with Python AST parsing and evaluates these rules inside upgrade():
add_column_non_nullable_no_default:faildrop_column:failunless the PR hasmigration:destructivedrop_table:failunless the PR hasmigration:destructivealter_column_type:warnunless the PR hasmigration:long-runningexecute_dml:warnwith guidance to run the backfill as a separate online job
Analyzer scope:
- only
op.*calls insideupgrade()are inspected op.execute(...)only warns on literal SQL strings containingUPDATE,INSERT, orDELETE- string literals that merely mention dangerous operations do not trigger findings
Minimal explicit configuration:
{
"ready": {
"checks": ["pr-exists", "merge-conflict", "ci-status"],
"requiredChecks": ["pr-exists", "merge-conflict", "ci-status"]
}
}The ready stage exists to make blocking conditions explicit. The scenarios below describe the operator response expected once the real checks are enabled.
Symptoms:
- ready result shows a failing
ci-statuscheck - the PR is not safe to merge yet
Typical response:
gh pr checks 42
gh run view <run-id> --log-failedRecovery:
- fix the failing code or flaky test in the worktree
- push a new commit
- rerun
wavemill ready 42 - merge only after the verdict returns to
passor acceptablewarn
Symptoms:
- ready result shows
mergeConflict.status = "CONFLICTED" - GitHub reports the PR is not mergeable
Typical response:
- let
wavemill millattempt automatic remediation first - if it writes
.needs-attention, open the existing worktree and resolve manually
Recovery goal:
- get the PR back to a clean merge state
- rerun ready after the push
Symptoms:
- ready result shows the approvals check failed
- required reviewers have not approved or approval was dismissed
Typical response:
gh pr view 42 --json reviews,reviewDecisionRecovery:
- request the missing reviewer
- address review feedback
- rerun
wavemill ready 42after approvals land
Symptoms:
- ready result returns
warn - the result references a manual deployment, config, or migration action
Typical response:
- confirm who owns the manual step
- decide whether the step must happen before merge or immediately after merge
- record the handoff in the PR or release notes
Example operator checklist:
- apply production config change
- run migration playbook
- invalidate caches
- confirm post-merge smoke check owner
Symptoms:
- CLI exits non-zero before producing a usable result
- GitHub or local repo inspection fails transiently
Typical response:
wavemill ready 42 --repo-dir ~/src/my-repoRecovery:
- rerun the command
- verify repository access and GitHub authentication
- avoid merging while readiness status is unknown
Ready-stage settings live in .wavemill-config.json under ready.
{
"ready": {
"checks": [],
"requiredChecks": []
}
}| Setting | Type | Default | Meaning |
|---|---|---|---|
ready.checks |
string[] |
[] |
Checks to run. Missing/empty uses universal defaults (pr-exists, merge-conflict, ci-status). |
ready.requiredChecks |
string[] |
[] |
Subset of ready.checks that must pass. Required skip is non-blocking and reported as warning. |
ready.migrationKind |
"alembic" | "sql" | "none" |
unset | Migration format hint for migration-specific checks; unsupported kinds are skipped instead of failed parsing. |
{
"ready": {
"checks": ["pr-exists", "merge-conflict", "ci-status"],
"requiredChecks": ["pr-exists", "merge-conflict", "ci-status"]
}
}{
"ready": {
"checks": [
"pr-exists",
"merge-conflict",
"ci-status",
"schema-migrations",
"migration-chain-integrity",
"forbidden-ddl",
"migration-reversibility"
],
"requiredChecks": [
"pr-exists",
"merge-conflict",
"ci-status",
"schema-migrations",
"migration-chain-integrity"
],
"migrationKind": "alembic",
"migrationPatterns": ["alembic/versions/.*\\.py$"]
}
}Guidance:
- use
checksto narrow which checks are evaluated - use
requiredChecksto distinguish blockers from advisory checks - keep
requiredChecksaligned with branch-protection and release policy
Use the ready stage as the final pre-merge decision point.
- Review passing is necessary but not sufficient.
- Ready passing is the merge gate.
- Failures block merge.
- Warnings require deliberate operator handling.
- Missing ready configuration keeps onboarding conservative and non-opinionated.
The ready contract, monitor behavior, and operator policy should stay stable as additional checks are added.