Skip to content

Pipeline Design 444

ezigus edited this page Apr 27, 2026 · 1 revision

Now I have a complete picture of all three root causes. Here is the ADR:


Design: fix(harness): goal/original_goal in loop-state.md accumulate synthesized content — unbounded prompt inflation

Context

The Shipwright harness stores pipeline state in .claude/loop-state.md (frontmatter YAML). Two fields track the task objective:

  • goal: — mutable; receives appended synthesis (test failures, review notes, human feedback) each iteration
  • original_goal: — intended to be immutable; the unchanged user-supplied task description

The harness uses original_goal to anchor the ## Your Goal section in Claude's prompt during stuckness recovery, and as the baseline when resetting after a restart. When both fields accumulate synthesized content, every subsequent iteration's prompt contains a larger "Your Goal" block — unbounded prompt inflation.

Three independent root causes combine to corrupt original_goal:

RC1 — Lazy bootstrap in write_state() (pipeline-state.sh:610-615)

local _write_goal="${ORIGINAL_GOAL:-$GOAL}"
if [[ -z "${ORIGINAL_GOAL:-}" && -n "${_write_goal}" ]]; then
    ORIGINAL_GOAL="$_write_goal"
fi

In --issue pipeline runs, GOAL is populated by the intake stage after pipeline_start() has already called write_state(). The first call to write_state() with a non-empty GOAL bootstraps ORIGINAL_GOAL from whatever $GOAL contains at that moment — which may already include synthesized content appended by intake processing.

RC2 — Identical escaping for both fields (pipeline-state.sh:616-622)

local _goal_esc="${_write_goal//\\/\\\\}"  # _write_goal = ORIGINAL_GOAL:-$GOAL
...
printf 'goal: "%s"\n' "$_goal_esc"
printf 'original_goal: "%s"\n' "$_goal_esc"

Both fields are written using the same _goal_esc derived from the same source variable. original_goal: is never written from a separate, protected source. If ORIGINAL_GOAL is contaminated (RC1), both fields permanently store contaminated content on every subsequent write_state() call.

RC3 — compose_prompt() uses $GOAL in the non-stuck branch (loop-iteration.sh:369)

local prompt_goal="$GOAL"   # mutable, accumulated
if [[ "$stuckness_detected" == "true" ]]; then
    local _base_goal="${ORIGINAL_GOAL:-$GOAL}"   # only stuck path uses ORIGINAL_GOAL
    ...
fi

In normal (non-stuck) iterations, prompt_goal is the full accumulated $GOAL, so even a correct ORIGINAL_GOAL provides no relief — the prompt still grows.

Constraint: All scripts must be Bash 3.2 compatible (set -euo pipefail, no associative arrays, no ${var,,}).


Decision

Three surgical changes, no new state fields, no new files.

Change 1 — Remove the lazy bootstrap from write_state() (pipeline-state.sh:613-615)

Delete lines 613-615 entirely:

# DELETE THIS BLOCK:
if [[ -z "${ORIGINAL_GOAL:-}" && -n "${_write_goal}" ]]; then
    ORIGINAL_GOAL="$_write_goal"
fi

ORIGINAL_GOAL must be set before write_state() is first called. The lazy bootstrap is the contamination vector — it silently promotes a possibly-mutated $GOAL into the immutable slot. The two legitimate callers that need this are sw-loop.sh:419 (already correct) and the --issue pipeline path (fixed by Change 2).

Change 2 — Split goal: and original_goal: writes (pipeline-state.sh:616-622)

Write the two fields from independent sources:

local _goal_esc="${GOAL//\\/\\\\}"
_goal_esc="${_goal_esc//$'\n'/\\n}"
local _orig_esc="${ORIGINAL_GOAL//\\/\\\\}"
_orig_esc="${_orig_esc//$'\n'/\\n}"
...
printf 'goal: "%s"\n'          "$_goal_esc"
printf 'original_goal: "%s"\n' "$_orig_esc"

This makes original_goal: a true snapshot of $ORIGINAL_GOAL in memory. If ORIGINAL_GOAL is empty (e.g., a caller forgot to set it), the written value is an empty string — visible and debuggable rather than silently contaminated.

Change 3 — Use ORIGINAL_GOAL in compose_prompt() for ## Your Goal (loop-iteration.sh:369)

Change the normal-path assignment:

# Before:
local prompt_goal="$GOAL"
# After:
local prompt_goal="${ORIGINAL_GOAL:-$GOAL}"

The stuckness branch already truncates _base_goal to the first paragraph; the normal branch can use the full ORIGINAL_GOAL since it is the clean user-supplied text. The :-$GOAL fallback preserves backward compatibility when ORIGINAL_GOAL is empty (legacy state files without the field).

Consequence on the --issue pipeline path

The intake stage appends build context to $GOAL and is the primary source of contamination. The pipeline startup (pipeline_start() in pipeline-state.sh) must set ORIGINAL_GOAL before calling write_state(). The call sequence is:

pipeline_start() → sets PIPELINE_NAME, GOAL (from --goal arg or empty) → write_state()
intake stage → appends to GOAL → write_state()

At the pipeline_start() call, GOAL is the clean user-supplied string. Adding ORIGINAL_GOAL="${ORIGINAL_GOAL:-$GOAL}" immediately before the first write_state() call inside pipeline_start() (or at the top of each pipeline-stage entry point when ORIGINAL_GOAL is unset) ensures the immutable baseline is captured from the clean value.


Alternatives Considered

  1. Store original_goal only once (write-once sentinel) — Guard write_state() so original_goal: is only written on the first call, never overwritten.

    • Pros: Simple, zero drift.
    • Cons: Complicates write_state() with file-read-before-write; breaks atomicity guarantee; fails when state file is deleted for a fresh start.
  2. Strip synthesized content from $GOAL before writing original_goal: — Apply the existing sentinel-stripping logic (legacy backward-compat block in resume_state(), lines 720-728) inside write_state() on every call.

    • Pros: Self-healing — eventually converges even from a contaminated state.
    • Cons: Sentinel patterns must be kept in sync across two sites; stripping is fragile (regex-free bash string matching can miss variants); does not address RC3 (prompt still uses $GOAL).
  3. Make $GOAL immutable; use a separate $ENRICHED_GOAL for synthesis — Full rename of the mutable accumulator.

    • Pros: Clean separation at the variable level.
    • Cons: High blast radius — $GOAL is referenced in >50 locations across 8 scripts; invasive rename increases merge-conflict risk and regression surface.
  4. Persist original_goal as a separate file — Write $ORIGINAL_GOAL to .claude/original-goal.txt once at pipeline start; read it back on resume.

    • Pros: Completely isolated from state-file corruption.
    • Cons: Adds a new artifact to track, clean up, and document; splits state across files when the state-file format already supports the field.

Implementation Plan

Files to modify

File Line(s) Change
scripts/lib/pipeline-state.sh 610-617 Remove lazy bootstrap block; split _goal_esc / _orig_esc variables
scripts/lib/pipeline-state.sh 621-622 Write goal: from _goal_esc, original_goal: from _orig_esc
scripts/lib/pipeline-state.sh ~185-200 Add ORIGINAL_GOAL="${ORIGINAL_GOAL:-$GOAL}" before first write_state() inside pipeline_start()
scripts/lib/loop-iteration.sh 369 Change local prompt_goal="$GOAL"local prompt_goal="${ORIGINAL_GOAL:-$GOAL}"

Files to create

None.

Dependencies

None — no new packages or external dependencies.

Risk areas

  • pipeline_start() call ordering: The new ORIGINAL_GOAL="${ORIGINAL_GOAL:-$GOAL}" line in pipeline_start() must execute before the first write_state() call within that function. Read pipeline-state.sh:185-201 carefully; there are two early write_state() calls (intake gate and initial-state write).
  • Resume path (resume_state()) backward compatibility: The _has_original_goal=false path (lines 717-730) correctly handles legacy state files with no original_goal: field. After this fix, original_goal: will always be written, so the legacy path becomes dead code — leave it for now; removing it is a separate cleanup.
  • Empty ORIGINAL_GOAL at compose_prompt() call time: The :-$GOAL fallback in Change 3 handles this. Test that the fallback does not re-introduce inflation by verifying $GOAL is the uncontaminated value when ORIGINAL_GOAL is genuinely absent (loop mode, first iteration before any state file exists).
  • Concurrent writers: write_state() already uses mv -f tmp → STATE_FILE atomically. The split-variable change does not break atomicity.

Component Diagram

┌─────────────────────────────────────────────────────────────────┐
│  pipeline_start() / sw-loop.sh:419                              │
│  Sets ORIGINAL_GOAL="${ORIGINAL_GOAL:-$GOAL}" (immutable seed)  │
└───────────────────────────┬─────────────────────────────────────┘
                            │ ORIGINAL_GOAL (read-only after this point)
          ┌─────────────────┼───────────────────────┐
          ▼                 ▼                       ▼
┌──────────────────┐  ┌─────────────┐  ┌───────────────────────┐
│  write_state()   │  │ resume_     │  │  compose_prompt()     │
│  pipeline-       │  │ state()     │  │  loop-iteration.sh    │
│  state.sh        │  │ pipeline-   │  │                       │
│                  │  │ state.sh    │  │  prompt_goal =        │
│  goal: ← $GOAL   │  │             │  │  ORIGINAL_GOAL:-$GOAL │
│  original_goal:  │  │  restores   │  │  (not $GOAL)          │
│  ← $ORIGINAL_    │  │  both vars  │  └───────────────────────┘
│    GOAL (fixed)  │  │  from file  │
└──────────────────┘  └─────────────┘
          │
          ▼
  .claude/loop-state.md
  goal: "<mutable, may grow>"
  original_goal: "<immutable user text>"

Interface Contracts

# write_state() — no args, reads globals
# Precondition:  ORIGINAL_GOAL must be set before first call (non-empty user goal)
# Postcondition: loop-state.md contains two distinct fields:
#                goal: encodes current $GOAL (mutable)
#                original_goal: encodes $ORIGINAL_GOAL (must not change across calls)
# Error contract: returns 1 if disk space < 100 MB; returns 0 on success
write_state()

# compose_prompt() — no args, reads globals, returns prompt string on stdout
# Precondition:  ORIGINAL_GOAL set (or empty — fallback to $GOAL via :-)
# Postcondition: ## Your Goal section contains ORIGINAL_GOAL, not accumulated GOAL
# Error contract: never exits; empty output triggers caller-side error
compose_prompt()

# pipeline_start() — call site must set ORIGINAL_GOAL before first write_state() call
# Precondition:  GOAL set from user input (clean, pre-synthesis)
# Postcondition: ORIGINAL_GOAL == GOAL at moment of first write_state() call
pipeline_start()

Data Flow

User supplies --goal "fix auth bug"
        │
        ▼
GOAL="fix auth bug"
ORIGINAL_GOAL="fix auth bug"   ← set once here (pipeline_start / sw-loop.sh:419)
        │
        ▼
write_state() ──► loop-state.md:
                    goal: "fix auth bug"
                    original_goal: "fix auth bug"
        │
intake appends test failure context to GOAL
        │
        ▼
GOAL="fix auth bug\n\nBLOCKING ISSUES: ..."
ORIGINAL_GOAL="fix auth bug"   ← unchanged
        │
        ▼
write_state() ──► loop-state.md:
                    goal: "fix auth bug\n\nBLOCKING ISSUES: ..."
                    original_goal: "fix auth bug"   ← protected
        │
        ▼
compose_prompt() uses ORIGINAL_GOAL → ## Your Goal: "fix auth bug"

Error Boundaries

Component Errors it handles Propagation
write_state() Disk-full → returns 1; mv failure → logs and returns 1 Caller logs; pipeline continues (non-fatal)
compose_prompt() Empty ORIGINAL_GOAL → falls back to $GOAL via :- Silent fallback; no exit
pipeline_start() GOAL empty at call time → ORIGINAL_GOAL set to ""original_goal: written as empty string Visible in state file; downstream :-$GOAL fallbacks activate
resume_state() Missing original_goal: field (legacy file) → sets ORIGINAL_GOAL via sentinel-stripping compat path Safe; existing behavior preserved

Validation Criteria

  • After write_state() is called twice with $GOAL mutated between calls, original_goal: in loop-state.md equals the value of $GOAL at the time of the first call.
  • compose_prompt() output for the ## Your Goal section matches $ORIGINAL_GOAL, not $GOAL, on both stuck and non-stuck iterations.
  • A pipeline started with --issue <N> (intake path) produces a state file where original_goal: does not contain any appended synthesis content (no "BLOCKING ISSUES", "Previous build", "KNOWN FIX" prefixes).
  • resume_state() round-trips a multi-line ORIGINAL_GOAL without corruption (existing test in sw-lib-pipeline-state-test.sh:486 covers this; confirm it passes unchanged).
  • npm test passes with no new failures.
  • Manual inspection: run cat .claude/loop-state.md after two iterations of a loop with a test failure injected — original_goal: must be identical in both snapshots.

Clone this wiki locally