[object Object]

← back to Cli Printing Press

fix(cli): gate ship plans on agent readiness (#1638)

ed17d302df9dafd7b3bfc3ded8099ce1525ec77d · 2026-05-18 17:13:34 -0700 · Trevin Chow

* fix(cli): gate ship plans on agent readiness

* docs(cli): document readiness ship gate learning

* fix(cli): clean readiness finding rendering

* fix(cli): normalize readiness table findings

* fix(cli): clarify readiness verdict holds

---------

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Files touched

Diff

commit ed17d302df9dafd7b3bfc3ded8099ce1525ec77d
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Mon May 18 17:13:34 2026 -0700

    fix(cli): gate ship plans on agent readiness (#1638)
    
    * fix(cli): gate ship plans on agent readiness
    
    * docs(cli): document readiness ship gate learning
    
    * fix(cli): clean readiness finding rendering
    
    * fix(cli): normalize readiness table findings
    
    * fix(cli): clarify readiness verdict holds
    
    ---------
    
    Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
---
 docs/PIPELINE.md                                   |   5 +-
 .../ship-plan-agent-readiness-gate-2026-05-18.md   |  68 ++++++
 internal/pipeline/planner.go                       | 230 +++++++++++++++++++--
 internal/pipeline/planner_artifacts_test.go        | 217 +++++++++++++++++++
 internal/pipeline/seeds.go                         |   6 +-
 5 files changed, 509 insertions(+), 17 deletions(-)

diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md
index e96e8813..a4ed1150 100644
--- a/docs/PIPELINE.md
+++ b/docs/PIPELINE.md
@@ -192,6 +192,7 @@ Outputs:
 - Reviewer scorecard across seven principles and three severities
 - Fix implementation log (which fixes were applied, which were skipped or reverted)
 - Phase verdict: `Pass`, `Warn`, or `Degrade`
+- `pipeline/agent-readiness.md` with a parseable `Phase verdict: Pass|Warn|Degrade` line and remaining Blocker/Friction findings as bullets or table rows
 
 Gates:
 - `Pass` - zero Blockers and zero Frictions
@@ -199,7 +200,7 @@ Gates:
 - `Degrade` - Blockers remain; phase fails
 
 Artifacts:
-- Reviewer scorecard document in the pipeline directory
+- `pipeline/agent-readiness.md`
 - Fix log in the pipeline directory
 
 ### 8. comparative
@@ -241,6 +242,7 @@ Purpose: package the generated CLI output and produce the final handoff report.
 
 Inputs:
 - Review score and `review.md` from the review phase
+- Agent-readiness verdict from `agent-readiness.md`
 - Working CLI binary ready for handoff
 
 Outputs:
@@ -250,6 +252,7 @@ Outputs:
 
 Gates:
 - All prior phases are `completed`
+- `agent-readiness.md` reports `Pass`, unless a maintainer records an explicit override in the handoff
 - Output directory contains a valid CLI source tree and compiled binary
 
 Artifacts:
diff --git a/docs/solutions/logic-errors/ship-plan-agent-readiness-gate-2026-05-18.md b/docs/solutions/logic-errors/ship-plan-agent-readiness-gate-2026-05-18.md
new file mode 100644
index 00000000..d53f935f
--- /dev/null
+++ b/docs/solutions/logic-errors/ship-plan-agent-readiness-gate-2026-05-18.md
@@ -0,0 +1,68 @@
+---
+title: "Ship plan agent-readiness gate"
+date: 2026-05-18
+category: logic-errors
+module: internal/pipeline
+problem_type: logic_error
+component: tooling
+symptoms:
+  - "PhaseShip could emit SHIP when the scorecard passed even though agent-readiness reported Degrade"
+  - "Agent-readiness findings appeared in pipeline artifacts but did not affect the generated ship decision"
+root_cause: logic_error
+resolution_type: code_fix
+severity: high
+tags:
+  - pipeline
+  - agent-readiness
+  - ship-gate
+  - artifact-parsing
+  - scorecard
+---
+
+# Ship plan agent-readiness gate
+
+## Problem
+
+The managed pipeline's ship plan treated the scorecard threshold as the only load-bearing ship gate. A run could produce `pipeline/agent-readiness.md` with a non-Pass verdict and still generate a ship plan that said `SHIP` when the scorecard cleared 65%.
+
+## Symptoms
+
+- PhaseShip emitted `SHIP` for a high-scoring CLI even when agent-readiness reported `Degrade`.
+- Readiness Blocker and Friction findings were visible in artifacts, but the ship decision ignored them.
+- Missing readiness evidence was effectively neutral instead of blocking ship.
+
+## What Didn't Work
+
+- Reading only the scorecard in `GenerateNextPlan` kept quality and readiness as independent reports, but only quality became a gate.
+- Loose markdown parsing would have created false positives from prose mentioning "non-Pass" or "Degrade" without declaring a verdict.
+- Falling back to `proofs/agent-readiness.md` would have let stale or non-canonical proof data satisfy the gate when the canonical `pipeline/agent-readiness.md` was missing.
+
+## Solution
+
+Make PhaseShip compose quality and agent-readiness as independent gates:
+
+- Load readiness only for `PhaseShip`, and only from `state.PipelineDir()/agent-readiness.md`.
+- Parse a narrow verdict contract: `Phase verdict: Pass|Warn|Degrade` or `Verdict: Pass|Warn|Degrade`.
+- Treat missing, malformed, `Warn`, and `Degrade` readiness reports as `HOLD`.
+- Keep scorecard and readiness independent in the rendered plan so operators can see which axis failed.
+- Update the agent-readiness seed and `docs/PIPELINE.md` to require the exact artifact the ship gate consumes.
+
+## Why This Works
+
+The root cause was not the readiness reviewer itself. The failure was at the artifact contract boundary: the ship plan did not consume the readiness phase's canonical output. Loading the canonical artifact and parsing only its explicit verdict line makes readiness load-bearing without turning plan generation into a hard error path.
+
+Keeping missing or malformed readiness as a rendered `HOLD` also preserves the reusable planning contract. `GenerateNextPlan` can still produce a useful handoff document, but the document cannot silently recommend shipping without the required evidence.
+
+## Prevention
+
+- When a later phase gates on an earlier phase's artifact, update the producer seed, consumer parser, docs, and regression tests in the same change.
+- Do not treat absent evidence as neutral in ship gates. Missing scorecard or readiness data should render an explicit `HOLD`.
+- Add false-positive parser tests for prose that mentions verdict words without declaring the verdict.
+- Add stale-location tests when a canonical runstate path has tempting fallback locations.
+
+## Related Issues
+
+- GitHub issue #1354
+- `docs/solutions/logic-errors/scorecard-accuracy-broadened-pattern-matching-2026-03-27.md`
+- `docs/solutions/logic-errors/scorer-dogfood-composed-header-auth-and-example-continuations-2026-05-05.md`
+- `docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md`
diff --git a/internal/pipeline/planner.go b/internal/pipeline/planner.go
index e0b49f93..a776237f 100644
--- a/internal/pipeline/planner.go
+++ b/internal/pipeline/planner.go
@@ -2,17 +2,35 @@ package pipeline
 
 import (
 	"fmt"
+	"os"
+	"path/filepath"
 	"strings"
 	"time"
 )
 
 // PlanContext aggregates outputs from completed phases for dynamic plan generation.
 type PlanContext struct {
-	SeedData  SeedData
-	Research  *ResearchResult
-	Dogfood   *DogfoodReport
-	Scorecard *Scorecard
-	Learnings *LearningsDB
+	SeedData     SeedData
+	Research     *ResearchResult
+	Dogfood      *DogfoodReport
+	Scorecard    *Scorecard
+	Learnings    *LearningsDB
+	Readiness    *AgentReadinessReport
+	ReadinessErr error
+}
+
+type AgentReadinessVerdict string
+
+const (
+	AgentReadinessPass    AgentReadinessVerdict = "Pass"
+	AgentReadinessWarn    AgentReadinessVerdict = "Warn"
+	AgentReadinessDegrade AgentReadinessVerdict = "Degrade"
+)
+
+type AgentReadinessReport struct {
+	Path     string
+	Verdict  AgentReadinessVerdict
+	Findings []string
 }
 
 // GenerateNextPlan writes a dynamic plan for the next phase, informed by
@@ -47,6 +65,7 @@ func GenerateNextPlan(state *PipelineState, nextPhase string) (string, error) {
 	case PhaseComparative:
 		return generateComparativePlan(ctx)
 	case PhaseShip:
+		ctx.Readiness, ctx.ReadinessErr = loadAgentReadinessForPlanState(state)
 		return generateShipPlan(ctx)
 	default:
 		// Preflight, Research, AgentReadiness use static seeds
@@ -212,16 +231,7 @@ func generateShipPlan(ctx PlanContext) (string, error) {
 
 	writePipelineContext(&b, ctx.SeedData)
 
-	// Dynamic: ship/hold decision based on scores
-	if ctx.Scorecard != nil {
-		b.WriteString("## Ship Decision\n\n")
-		if ctx.Scorecard.Steinberger.Percentage >= 65 {
-			fmt.Fprintf(&b, "**SHIP** - Quality score %d%% (grade %s) meets threshold.\n\n", ctx.Scorecard.Steinberger.Percentage, ctx.Scorecard.OverallGrade)
-		} else {
-			fmt.Fprintf(&b, "**HOLD** - Quality score %d%% (grade %s) is below 65%% threshold.\n", ctx.Scorecard.Steinberger.Percentage, ctx.Scorecard.OverallGrade)
-			b.WriteString("Fix the gaps identified in the scorecard before shipping.\n\n")
-		}
-	}
+	writeShipDecision(&b, ctx)
 
 	b.WriteString("## What This Phase Must Produce\n\n")
 	fmt.Fprintf(&b, "- Git repository initialized in %s\n", ctx.SeedData.OutputDir)
@@ -231,6 +241,47 @@ func generateShipPlan(ctx PlanContext) (string, error) {
 	return b.String(), nil
 }
 
+func writeShipDecision(b *strings.Builder, ctx PlanContext) {
+	b.WriteString("## Ship Decision\n\n")
+
+	ship := true
+	switch {
+	case ctx.Scorecard == nil:
+		b.WriteString("- Quality: HOLD - missing scorecard.json; run the review phase before shipping.\n")
+		ship = false
+	case ctx.Scorecard.Steinberger.Percentage >= 65:
+		fmt.Fprintf(b, "- Quality: PASS - score %d%% (grade %s) meets threshold.\n", ctx.Scorecard.Steinberger.Percentage, ctx.Scorecard.OverallGrade)
+	default:
+		fmt.Fprintf(b, "- Quality: HOLD - score %d%% (grade %s) is below 65%% threshold.\n", ctx.Scorecard.Steinberger.Percentage, ctx.Scorecard.OverallGrade)
+		ship = false
+	}
+
+	switch {
+	case ctx.Readiness == nil:
+		if ctx.ReadinessErr != nil && !os.IsNotExist(ctx.ReadinessErr) {
+			fmt.Fprintf(b, "- Agent readiness: HOLD - %s.\n", ctx.ReadinessErr)
+		} else {
+			b.WriteString("- Agent readiness: HOLD - missing pipeline/agent-readiness.md; run the agent-readiness phase before shipping.\n")
+		}
+		ship = false
+	case ctx.Readiness.Verdict == AgentReadinessPass:
+		fmt.Fprintf(b, "- Agent readiness: PASS - %s reports Pass.\n", ctx.Readiness.Path)
+	default:
+		fmt.Fprintf(b, "- Agent readiness: HOLD - %s reports %s.\n", ctx.Readiness.Path, ctx.Readiness.Verdict)
+		for _, finding := range ctx.Readiness.Findings {
+			fmt.Fprintf(b, "  - %s\n", finding)
+		}
+		b.WriteString("  - To ship anyway, record an explicit maintainer override and cite the readiness findings in the handoff report.\n")
+		ship = false
+	}
+
+	if ship {
+		b.WriteString("\n**SHIP** - Quality and agent-readiness gates both pass.\n\n")
+		return
+	}
+	b.WriteString("\n**HOLD** - Do not package or promote the CLI until the failed gate is resolved or explicitly overridden.\n\n")
+}
+
 // Helper functions
 
 func writePlanHeader(b *strings.Builder, apiName, phase, title string) {
@@ -280,3 +331,152 @@ func loadScorecardForPlanState(state *PipelineState) (*Scorecard, error) {
 	}
 	return nil, fmt.Errorf("scorecard not found")
 }
+
+func loadAgentReadinessForPlanState(state *PipelineState) (*AgentReadinessReport, error) {
+	return LoadAgentReadinessReport(state.PipelineDir())
+}
+
+func LoadAgentReadinessReport(dir string) (*AgentReadinessReport, error) {
+	path := filepath.Join(dir, "agent-readiness.md")
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return nil, err
+	}
+
+	report := &AgentReadinessReport{Path: path}
+	for line := range strings.SplitSeq(string(data), "\n") {
+		trimmed := strings.TrimSpace(strings.Trim(line, "|"))
+		if trimmed == "" {
+			continue
+		}
+		if report.Verdict == "" {
+			report.Verdict = parseAgentReadinessVerdict(trimmed)
+		}
+		if finding, ok := normalizeAgentReadinessFinding(line); ok {
+			report.Findings = append(report.Findings, finding)
+		}
+	}
+	if report.Verdict == "" {
+		return nil, fmt.Errorf("agent-readiness verdict not found in %s", path)
+	}
+	return report, nil
+}
+
+func parseAgentReadinessVerdict(line string) AgentReadinessVerdict {
+	clean := strings.Trim(strings.TrimSpace(line), "*` ")
+	clean = strings.ReplaceAll(clean, "**", "")
+	lower := strings.ToLower(clean)
+	for _, prefix := range []string{"phase verdict:", "verdict:", "phase verdict -", "verdict -"} {
+		if !strings.HasPrefix(lower, prefix) {
+			continue
+		}
+		rest := strings.TrimSpace(clean[len(prefix):])
+		if rest == "" {
+			return ""
+		}
+		token := strings.Trim(strings.Fields(rest)[0], "*`.,;:()[]{} ")
+		switch strings.ToLower(token) {
+		case "pass":
+			return AgentReadinessPass
+		case "warn":
+			return AgentReadinessWarn
+		case "degrade":
+			return AgentReadinessDegrade
+		default:
+			return ""
+		}
+	}
+	return ""
+}
+
+func normalizeAgentReadinessFinding(line string) (string, bool) {
+	trimmed := strings.TrimSpace(line)
+	lower := strings.ToLower(trimmed)
+	switch {
+	case strings.HasPrefix(lower, "- ") || strings.HasPrefix(lower, "* "):
+		finding := strings.TrimSpace(trimmed[2:])
+		return finding, finding != ""
+	case strings.HasPrefix(trimmed, "|"):
+		return normalizeAgentReadinessTableFinding(readinessTableCells(trimmed))
+	default:
+		return "", false
+	}
+}
+
+func readinessTableCells(line string) []string {
+	parts := strings.Split(strings.Trim(line, "|"), "|")
+	cells := make([]string, 0, len(parts))
+	for _, part := range parts {
+		cell := strings.TrimSpace(part)
+		if cell != "" {
+			cells = append(cells, cell)
+		}
+	}
+	return cells
+}
+
+func normalizeAgentReadinessTableFinding(cells []string) (string, bool) {
+	if len(cells) < 2 || isAgentReadinessTableSeparator(cells) || isAgentReadinessTableHeader(cells) {
+		return "", false
+	}
+	for i, cell := range cells {
+		lower := strings.ToLower(cell)
+		for _, severity := range []string{"blocker", "friction"} {
+			if strings.HasPrefix(lower, severity+":") {
+				return cell, true
+			}
+			if lower == severity {
+				rest := nonHeaderTableCells(cells[i+1:])
+				if len(rest) == 0 {
+					return "", false
+				}
+				return capitalizeASCII(severity) + ": " + strings.Join(rest, " - "), true
+			}
+		}
+	}
+	return "", false
+}
+
+func isAgentReadinessTableSeparator(cells []string) bool {
+	for _, cell := range cells {
+		if strings.Trim(cell, "-: ") != "" {
+			return false
+		}
+	}
+	return true
+}
+
+func isAgentReadinessTableHeader(cells []string) bool {
+	for _, cell := range cells {
+		if !isAgentReadinessHeaderCell(cell) {
+			return false
+		}
+	}
+	return true
+}
+
+func isAgentReadinessHeaderCell(cell string) bool {
+	switch strings.ToLower(strings.TrimSpace(cell)) {
+	case "severity", "finding", "description", "tool", "evidence", "impact", "command", "notes", "blocker", "friction":
+		return true
+	default:
+		return false
+	}
+}
+
+func nonHeaderTableCells(cells []string) []string {
+	var out []string
+	for _, cell := range cells {
+		if !isAgentReadinessHeaderCell(cell) {
+			out = append(out, cell)
+		}
+	}
+	return out
+}
+
+func capitalizeASCII(s string) string {
+	if s == "" {
+		return ""
+	}
+	return strings.ToUpper(s[:1]) + s[1:]
+}
diff --git a/internal/pipeline/planner_artifacts_test.go b/internal/pipeline/planner_artifacts_test.go
index 2ba727b2..9253cd47 100644
--- a/internal/pipeline/planner_artifacts_test.go
+++ b/internal/pipeline/planner_artifacts_test.go
@@ -50,3 +50,220 @@ func TestGenerateNextPlanLoadsArtifactsFromRunstateDirs(t *testing.T) {
 	assert.Contains(t, comparativePlan, "Overall: 72% (B)")
 	assert.Contains(t, comparativePlan, "example gap")
 }
+
+func TestGenerateShipPlanHoldsOnAgentReadinessDegrade(t *testing.T) {
+	setPressTestEnv(t)
+
+	state := NewStateWithRun("sample", filepath.Join(t.TempDir(), "sample-pp-cli"), "run-123", "test-scope")
+	require.NoError(t, state.Save())
+	scorecard := &Scorecard{
+		APIName: "sample",
+		Steinberger: SteinerScore{
+			Percentage: 80,
+		},
+		OverallGrade: "A",
+	}
+	require.NoError(t, writeScorecardJSON(scorecard, state.ProofsDir()))
+	require.NoError(t, os.WriteFile(filepath.Join(state.PipelineDir(), "agent-readiness.md"), []byte(`## Agent Readiness
+
+Phase verdict: Degrade
+
+- Blocker: mutating commands can run under verify mode
+- Friction: help omits automation examples
+`), 0o644))
+
+	shipPlan, err := GenerateNextPlan(state, PhaseShip)
+	require.NoError(t, err)
+	assert.Contains(t, shipPlan, "Quality: PASS")
+	assert.Contains(t, shipPlan, "Agent readiness: HOLD")
+	assert.Contains(t, shipPlan, "reports Degrade")
+	assert.Contains(t, shipPlan, "Blocker: mutating commands can run under verify mode")
+	assert.Contains(t, shipPlan, "  - Blocker: mutating commands can run under verify mode")
+	assert.NotContains(t, shipPlan, "  - - Blocker:")
+	assert.Contains(t, shipPlan, "**HOLD**")
+	assert.NotContains(t, shipPlan, "**SHIP** - Quality and agent-readiness gates both pass.")
+}
+
+func TestGenerateShipPlanRendersTableAgentReadinessFindings(t *testing.T) {
+	setPressTestEnv(t)
+
+	state := NewStateWithRun("sample", filepath.Join(t.TempDir(), "sample-pp-cli"), "run-123", "test-scope")
+	require.NoError(t, state.Save())
+	scorecard := &Scorecard{
+		APIName: "sample",
+		Steinberger: SteinerScore{
+			Percentage: 80,
+		},
+		OverallGrade: "A",
+	}
+	require.NoError(t, writeScorecardJSON(scorecard, state.ProofsDir()))
+	require.NoError(t, os.WriteFile(filepath.Join(state.PipelineDir(), "agent-readiness.md"), []byte(`## Agent Readiness
+
+Phase verdict: Degrade
+
+| Blocker | Description |
+|---|---|
+| Blocker | mutating commands can run under verify mode |
+`), 0o644))
+
+	shipPlan, err := GenerateNextPlan(state, PhaseShip)
+	require.NoError(t, err)
+	assert.Contains(t, shipPlan, "  - Blocker: mutating commands can run under verify mode")
+	assert.NotContains(t, shipPlan, "| Blocker | Description |")
+	assert.NotContains(t, shipPlan, "  - |")
+	assert.Contains(t, shipPlan, "**HOLD**")
+}
+
+func TestGenerateShipPlanShipsWhenScorecardAndReadinessPass(t *testing.T) {
+	setPressTestEnv(t)
+
+	state := NewStateWithRun("sample", filepath.Join(t.TempDir(), "sample-pp-cli"), "run-123", "test-scope")
+	require.NoError(t, state.Save())
+	scorecard := &Scorecard{
+		APIName: "sample",
+		Steinberger: SteinerScore{
+			Percentage: 80,
+		},
+		OverallGrade: "A",
+	}
+	require.NoError(t, writeScorecardJSON(scorecard, state.ProofsDir()))
+	require.NoError(t, os.WriteFile(filepath.Join(state.PipelineDir(), "agent-readiness.md"), []byte(`## Agent Readiness
+
+Phase verdict: Pass
+`), 0o644))
+
+	shipPlan, err := GenerateNextPlan(state, PhaseShip)
+	require.NoError(t, err)
+	assert.Contains(t, shipPlan, "Quality: PASS")
+	assert.Contains(t, shipPlan, "Agent readiness: PASS")
+	assert.Contains(t, shipPlan, "**SHIP** - Quality and agent-readiness gates both pass.")
+	assert.NotContains(t, shipPlan, "**HOLD**")
+}
+
+func TestGenerateShipPlanHoldsWhenAgentReadinessMissing(t *testing.T) {
+	setPressTestEnv(t)
+
+	state := NewStateWithRun("sample", filepath.Join(t.TempDir(), "sample-pp-cli"), "run-123", "test-scope")
+	require.NoError(t, state.Save())
+	scorecard := &Scorecard{
+		APIName: "sample",
+		Steinberger: SteinerScore{
+			Percentage: 80,
+		},
+		OverallGrade: "A",
+	}
+	require.NoError(t, writeScorecardJSON(scorecard, state.ProofsDir()))
+
+	shipPlan, err := GenerateNextPlan(state, PhaseShip)
+	require.NoError(t, err)
+	assert.Contains(t, shipPlan, "Quality: PASS")
+	assert.Contains(t, shipPlan, "Agent readiness: HOLD - missing pipeline/agent-readiness.md")
+	assert.Contains(t, shipPlan, "**HOLD**")
+	assert.NotContains(t, shipPlan, "**SHIP** - Quality and agent-readiness gates both pass.")
+}
+
+func TestGenerateShipPlanIgnoresReadinessOutsidePipelineDir(t *testing.T) {
+	setPressTestEnv(t)
+
+	state := NewStateWithRun("sample", filepath.Join(t.TempDir(), "sample-pp-cli"), "run-123", "test-scope")
+	require.NoError(t, state.Save())
+	scorecard := &Scorecard{
+		APIName: "sample",
+		Steinberger: SteinerScore{
+			Percentage: 80,
+		},
+		OverallGrade: "A",
+	}
+	require.NoError(t, writeScorecardJSON(scorecard, state.ProofsDir()))
+	require.NoError(t, os.WriteFile(filepath.Join(state.ProofsDir(), "agent-readiness.md"), []byte(`## Agent Readiness
+
+Phase verdict: Pass
+`), 0o644))
+
+	shipPlan, err := GenerateNextPlan(state, PhaseShip)
+	require.NoError(t, err)
+	assert.Contains(t, shipPlan, "Agent readiness: HOLD - missing pipeline/agent-readiness.md")
+	assert.Contains(t, shipPlan, "**HOLD**")
+	assert.NotContains(t, shipPlan, "**SHIP** - Quality and agent-readiness gates both pass.")
+}
+
+func TestGenerateShipPlanHoldsWhenScorecardMissing(t *testing.T) {
+	setPressTestEnv(t)
+
+	state := NewStateWithRun("sample", filepath.Join(t.TempDir(), "sample-pp-cli"), "run-123", "test-scope")
+	require.NoError(t, state.Save())
+	require.NoError(t, os.WriteFile(filepath.Join(state.PipelineDir(), "agent-readiness.md"), []byte(`## Agent Readiness
+
+Phase verdict: Pass
+`), 0o644))
+
+	shipPlan, err := GenerateNextPlan(state, PhaseShip)
+	require.NoError(t, err)
+	assert.Contains(t, shipPlan, "Quality: HOLD - missing scorecard.json")
+	assert.Contains(t, shipPlan, "Agent readiness: PASS")
+	assert.Contains(t, shipPlan, "**HOLD**")
+	assert.NotContains(t, shipPlan, "**SHIP** - Quality and agent-readiness gates both pass.")
+}
+
+func TestGenerateShipPlanHoldsWhenAgentReadinessVerdictMissing(t *testing.T) {
+	setPressTestEnv(t)
+
+	state := NewStateWithRun("sample", filepath.Join(t.TempDir(), "sample-pp-cli"), "run-123", "test-scope")
+	require.NoError(t, state.Save())
+	scorecard := &Scorecard{
+		APIName: "sample",
+		Steinberger: SteinerScore{
+			Percentage: 80,
+		},
+		OverallGrade: "A",
+	}
+	require.NoError(t, writeScorecardJSON(scorecard, state.ProofsDir()))
+	require.NoError(t, os.WriteFile(filepath.Join(state.PipelineDir(), "agent-readiness.md"), []byte(`## Agent Readiness
+
+A non-Pass verdict such as Degrade must block ship, but this prose is not the verdict line.
+`), 0o644))
+
+	shipPlan, err := GenerateNextPlan(state, PhaseShip)
+	require.NoError(t, err)
+	assert.Contains(t, shipPlan, "Agent readiness: HOLD - agent-readiness verdict not found")
+	assert.Contains(t, shipPlan, "**HOLD**")
+	assert.NotContains(t, shipPlan, "**SHIP** - Quality and agent-readiness gates both pass.")
+}
+
+func TestParseAgentReadinessVerdictRequiresExplicitVerdictLine(t *testing.T) {
+	assert.Equal(t, AgentReadinessDegrade, parseAgentReadinessVerdict("Phase verdict: **Degrade**"))
+	assert.Equal(t, AgentReadinessWarn, parseAgentReadinessVerdict("**Verdict:** Warn"))
+	assert.Empty(t, parseAgentReadinessVerdict("A non-Pass verdict such as Degrade must block ship."))
+}
+
+func TestLoadAgentReadinessReportCollectsTableFindings(t *testing.T) {
+	dir := t.TempDir()
+	require.NoError(t, os.WriteFile(filepath.Join(dir, "agent-readiness.md"), []byte(`## Agent Readiness
+
+Phase verdict: Degrade
+
+| Severity | Finding |
+|---|---|
+| Blocker | mutating commands can run under verify mode |
+`), 0o644))
+
+	report, err := LoadAgentReadinessReport(dir)
+	require.NoError(t, err)
+	assert.Equal(t, AgentReadinessDegrade, report.Verdict)
+	assert.Equal(t, []string{"Blocker: mutating commands can run under verify mode"}, report.Findings)
+}
+
+func TestLoadAgentReadinessReportCollectsNonKeywordBulletFindings(t *testing.T) {
+	dir := t.TempDir()
+	require.NoError(t, os.WriteFile(filepath.Join(dir, "agent-readiness.md"), []byte(`## Agent Readiness
+
+Phase verdict: Degrade
+
+- Fail: command crashes with --json flag
+`), 0o644))
+
+	report, err := LoadAgentReadinessReport(dir)
+	require.NoError(t, err)
+	assert.Equal(t, AgentReadinessDegrade, report.Verdict)
+	assert.Equal(t, []string{"Fail: command crashes with --json flag"}, report.Findings)
+}
diff --git a/internal/pipeline/seeds.go b/internal/pipeline/seeds.go
index 905705b3..853c061c 100644
--- a/internal/pipeline/seeds.go
+++ b/internal/pipeline/seeds.go
@@ -330,7 +330,8 @@ and implement its fixes in a severity-gated loop (max 2 passes) until no Blocker
 
 - Agent readiness reviewer scorecard (7 principles x severity)
 - Fix implementation log (which fixes were applied, which were skipped/reverted)
-- Phase verdict: Pass (zero Blockers and Frictions), Warn (Frictions remain), or Degrade (Blockers remain)
+- {{.PipelineDir}}/agent-readiness.md with a parseable Phase verdict: Pass|Warn|Degrade line
+- Remaining Blocker and Friction findings listed as bullets or table rows in agent-readiness.md
 
 ## Prior Phase Outputs
 
@@ -342,6 +343,7 @@ and implement its fixes in a severity-gated loop (max 2 passes) until no Blocker
 - Reviewer agent: compound-engineering:cli-agent-readiness-reviewer (external plugin)
 - Plugin dependency declared in .claude/settings.json
 - Phase 4.8 analog: SKILL.md Phase 4.8 (Runtime Verification)
+- Ship gate parser: internal/pipeline/planner.go LoadAgentReadinessReport
 - If the run started in codex mode, preserve that mode here: reviewer runs in Claude, but each accepted fix patch is delegated to Codex and then verified in Claude
 `,
 	PhaseShip: `---
@@ -372,12 +374,14 @@ Package the generated CLI output and produce the final handoff report for humans
 ## Prior Phase Outputs
 
 - Review score and review.md from the review phase
+- Agent-readiness verdict from agent-readiness.md
 - Working CLI binary ready for packaging and handoff
 
 ## Codebase Pointers
 
 - Output CLI tree in {{.OutputDir}}
 - Review artifacts in {{.PipelineDir}}
+- Agent-readiness report in {{.PipelineDir}}/agent-readiness.md
 - Morning report format from SKILL.md Workflow 4 Step 6
 `,
 }

← 7eee7dfc fix(cli): harden browser-sniff PII placeholders (#1639)  ·  back to Cli Printing Press  ·  feat(cli): add cliutil.LooksLikeJWT shared validator with le b5b1dd59 →