# Wrap-up pipeline — post-execution wrap-up for one task or a batch (design
# §wrapup-pipeline).
#
# Orchestration is configuration (ADR-022 / §3.2): this is YAML over the
# existing
# dual-workflow engine — zero new engine code. The pipeline NEVER mutates task
# status —
# wrap-up consumes completed tasks and produces learning/metrics/doc artifacts
# only.
# Feature transitions go through `spur feature update` so the feature-lifecycle
# guards
# apply identically. Branch cleanup is an irreversible HITL gate — it always
# pauses,
# even under --auto (Iron Law #6: irreversible action -> surface to human).
#
# Shape: start -> task-resolve -> doc-sync -> learnings-append -> metrics-record
#          -> feature-transition (conditional: if vars.feature set)
#          -> branch-cleanup (conditional: if vars.merge=true)
#          -> done
#        doc-sync routes a contract violation to `repair` (cheap, no
#        re-dispatch — ADR-118 pilot) and an executor failure to `failed`;
#        task-resolve with a VALIDATED empty list short-circuits to `skipped`;
#        invalid wrap input, an unresolved/non-completed task, a failed metrics
#        capture, a failed required sync, or a failed feature gate routes to
#        `failed` (0770) instead of being absorbed by an always-defensive skip.
#
# Vars (passed as a JSON object via `--vars`):
# tasks    — JSON array of WBS strings, passed as a JSON-encoded STRING value
# (the CLI
# rejects non-string --vars values), e.g. --vars '{"tasks":"[\"0167\"]"}'
#   feature  — feature id to advance through legal lifecycle edges (optional)
#   profile  — set --vars '{"profile":"auto"}' to skip objective confirmations
# merge    — set --vars '{"merge":"true"}' to run branch cleanup (irreversible
# HITL)
#   spurBin  — PATH-independent spur invocation (overridden by CLI at run start)
#   agent    — agent for agent.run steps (default: auto → `agent.default` in config)
#
# Reliability (aligned with task-pipeline / ADR-043):
# - Prefer pure slash commands when a command exists; free-form inputs remain
# only
#     where capture artifacts (answerFile) have no dedicated slash surface yet.
#   - doc-sync / metrics-record use expectFile + soft append (empty capture
# does not abort wrap-up after prior steps already landed). The doc-sync hop is
# a
# single model query that both repairs doc drift (sp:doc-evolve) and captures
#     working learnings (task 0607 R2 — one query answers both; count 2 -> 1).
# - feature-transition is a soft shell (always exit 0); a failed required sync
#     or feature gate records FAIL and routes to `failed` instead of claiming
#     success (0770).
# - 0770 truthful outcomes: vars.tasks is parsed and validated ONCE at
#     task-resolve (JSON array of non-empty WBS strings, every member resolved
#     via `task show` to a completed status); siblings consume the normalized
#     run-scoped artifact and never re-parse raw input (malformed input can no
#     longer be silently re-interpreted as an empty list). All captures are
#     run-scoped under .spur/run/<runId>-wrapup-*; a lookup failure is
#     recorded, never silently omitted as success.
#   - 0783 validated consumers (audit 0781 F-04): validation accepts only
#     canonical four-digit WBS strings (whitespace is rejected, not trimmed);
#     the route writer, every guard, model prompt and operator note after
#     resolution read the run-scoped capture, never raw vars.tasks, and a
#     missing/corrupted capture or status refuses progression. Metrics
#     revalidate the capture, require well-shaped task lookups, serialize rows
#     with jq (never interpolated printf), and write PASS only after every
#     required append succeeds. Required feature sync succeeds only for a
#     valid matching proposal with no gateBlocked/requiresConfirm condition
#     whose target status is freshly observed; applied:false is a successful
#     no-op only when from == to and that status is observed — nonzero,
#     malformed, partial, blocked or unreadable outcomes fail explicitly, and
#     an affected-feature check can never convert a failed sync into success.
# - branch-cleanup HITL is exhaustive (yes/no/cancel → done; missing answer
# defense) and consent-only: it records the decision, performs no git op.
#   - Wrap-up never mutates task status (consumes completed work only).

"$schema": "@gobing-ai/spur/schemas/state-machine-workflow.schema.json"
kind: state-machine
name: wrapup-pipeline
version: "4"
description: "Post-execution wrap-up: doc-sync (doc drift + learning capture), metrics, feature-transition, branch-cleanup"
iterationBound: 10
initialState: start
terminalStates:
  - done
  - skipped
  - failed
failureStates:
  - failed
vars:
  tasks: "[]"
  feature: ""
  profile: "standard"
  merge: "false"
  spurBin: "spur"
  agent: "auto"
  __runId: ""
  mode: ""
  stepTimeoutMs: "1800000"
  # Recheck the affected feature after sync (D61 / ADR-108), not the corpus
  # or full code suite. Explicit caller overrides remain supported.
  # TRUSTED CONFIG ONLY — this string is executed via `sh -c` (same surface as
  # task-pipeline's qualityGateCmd). Never interpolate untrusted input into it.
  featureGateCmd: '$spurBin feature check "$feature"'
  __hitlAnswer: ""

states:
  - id: start
    description: >
      Pipeline start. Task statuses are NOT mutated by wrap-up — wrap-up consumes
      completed tasks and produces learning/metrics/doc artifacts only.
    onEnter:
      - kind: note
        options:
          message: "Wrap-up pipeline start for tasks: ${vars.tasks}. Task statuses are NOT mutated."

  - id: task-resolve
    description: >
      Validate the task list ONCE and evaluate closed proportional routing (0758
      R1-R4). Validation (0770 + 0783): vars.tasks must be a JSON array of
      canonical four-digit WBS strings — non-arrays, non-strings, whitespace or
      other shapes are rejected, not trimmed — deduplicated in first-seen
      order, with __runId present, and every member must resolve via `spur task
      show` to a completed status (done/cancelled); failed or malformed
      lookups fail. Invalid input or an unresolved/non-completed task records
      FAIL (reason into the run-scoped reason file) and routes to `failed`;
      the route writer and every guard, prompt and note after resolution read
      the normalized run-scoped artifact
      .spur/run/<runId>-wrapup-tasks.json and never re-parse raw input — a
      missing or corrupted capture refuses progression. A validated empty list
      -> skipped; length > 0 && mode == fast -> fast-path (metrics-record,
      bypassing doc-sync); missing/unknown/conflict -> doc-sync (safety-path).
      Writes bounded machine-readable reason to reasonFile.
    onEnter:
      - kind: note
        options:
          message: "Resolving task list: ${vars.tasks} (proportional mode: ${vars.mode})"
      # (d) wrapup-steps.ts resolve owns task capture and status resolution (0824);
      # the wrapper only locates it (monorepo first, registered twin under node) and
      # fails closed, writing FAIL for the failed defense edge — never PASS.
      - kind: shell
        options:
          command: >-
            mkdir -p .spur/run &&
            if [ -f plugins/sp/scripts/wrapup-steps.ts ]; then
              bun plugins/sp/scripts/wrapup-steps.ts resolve;
            elif W="$(superskill script path sp wrapup-steps.mjs 2>/dev/null)" && [ -f "$W" ]; then
              node "$W" resolve;
            else
              echo "wrapup-steps failed closed — script not found — run 'superskill install sp'" >&2;
              printf 'FAIL\n' > ".spur/run/$__runId-wrapup-resolve.status";
            fi
      # (e) route-reason writer: one jq table lookup over the mode var over the
      # validated capture; workflow-local routing glue (0824). Runs AFTER validation
      # and reads ONLY .spur/run/<runId>-wrapup-tasks.json — never raw vars.tasks
      # (0783 R2/R5). A FAIL resolve exits without writing: the failed reason written
      # by validation stands, and the skip reason can never mask it. A missing or
      # corrupted capture fails the jq lookup (no skipped claim can be invented);
      # the failed defense edge owns the run.
      - kind: shell
        options:
          command: >-
            mkdir -p .spur/run .spur/memory; RUN_ID="$__runId"; case "$RUN_ID" in '') echo "task-resolve: __runId is empty — refusing to write a route reason" >&2; exit 1 ;; esac;
            REASON_FILE=".spur/run/$RUN_ID-route-reason.txt"; case "$(cat ".spur/run/$RUN_ID-wrapup-resolve.status" 2>/dev/null)" in FAIL) exit 0 ;; esac;
            N=$(jq length ".spur/run/$RUN_ID-wrapup-tasks.json" 2>/dev/null);
            jq -rn --arg m "$mode" --arg n "$N" 'if $n == "0" then "skipped:empty task list" else {"fast":"fast:evidence complete+consistent","":"safety:missing evidence (mode empty)","unknown":"safety:unknown evidence quality","conflict":"safety:conflicting evidence"}[$m] // "safety:unrecognized evidence (mode=\($m))" end' > "$REASON_FILE";
            printf '%s %s\n' "$RUN_ID" "$(cat "$REASON_FILE")" >> .spur/memory/wrapup-routes.log

  - id: doc-sync
    description: >
      ONE model query that both repairs project-level doc drift for the batch
      (04_DESIGN, 03_ARCHITECTURE, 00_ADR, docs/design/*) and captures working
      learnings (task 0607 R2: two adjacent agent.run hops merged, count 2 -> 1;
      no operator-decision pause or verdict boundary sits between them). No dedicated
      slash command yet — free-form skill pointer until a /sp:doc-evolve command
      exists (ADR-043 residual). This is the expensive stage; a contract violation
      (a clean exit that missed its declared answerFile/expectFile post-condition)
      routes to the cheap `repair` edge instead of a full re-dispatch (ADR-118
      pilot, 0871) — `onError: continue` lets the transition guards read the
      agent's result instead of halting the run.
    onEnter:
      # (warn) free-form prompt: no /sp:doc-evolve command surface yet (ADR-043
      # residual, 0607 R2 one-query merge); capture is bounded by expectFile.
      - kind: agent.run
        onError: continue
        options:
          agent: ${vars.agent}
          input: >-
            First run Skill(skill="sp:doc-evolve", args="wrapup") to repair drift in
            docs/00_ADR.md, docs/03_ARCHITECTURE.md, docs/04_DESIGN.md, docs/design/* per constitution edit
            rules; do not write task/feature corpus. The batch task list is the normalized, validated
            capture at .spur/run/${vars.__runId}-wrapup-tasks.json — read it for the WBS ids; never
            re-parse raw input. THEN extract working learnings from those tasks —
            conventions, errors fixed, patterns, gotchas, grouped by date and task WBS — as raw markdown
            (no fences) and END your final message with that markdown (it is captured to the
            run-scoped .spur/run/${vars.__runId}-wrapup-learnings.md).
          # Declared Layer-1 role (0538 R2): routing reason beside the agent:
          # pin.
          role: coder
          answerFile: .spur/run/${vars.__runId}-wrapup-learnings.md
          expectFile: .spur/run/${vars.__runId}-wrapup-learnings.md
          timeoutMs: ${vars.stepTimeoutMs}

  - id: learnings-append
    description: >
      Best-effort soft append of the captured learnings to .spur/memory/learnings.md
      (0607 R2): an empty capture skips without aborting wrap-up. Runs only after
      doc-sync's contract held; a contract violation never reaches this state.
    onEnter:
      # (warn) learnings append is intentionally soft (empty capture skips
      # without aborting wrap-up, 0607 R2).
      - kind: shell
        options:
          command: >-
            mkdir -p .spur/run .spur/memory &&
            LEARNINGS_FILE=".spur/run/$__runId-wrapup-learnings.md" &&
            if test -s "$LEARNINGS_FILE"; then
              cat "$LEARNINGS_FILE" >> .spur/memory/learnings.md &&
              printf '\n' >> .spur/memory/learnings.md;
            else
              echo "wrapup: learning capture missing or empty at $PWD/$LEARNINGS_FILE - append skipped" >&2;
            fi &&
            exit 0

  - id: repair
    description: >
      Contract-violation repair (ADR-118 pilot, 0871): doc-sync's agent.run missed
      its declared post-condition after a clean exit, so wrap-up does NOT re-dispatch
      the expensive agent on this attempt. The miss is recorded to a run-scoped
      repair status and wrap-up proceeds to metrics-record — the run log and action
      trace already name the violated contract and the observed value.
    onEnter:
      - kind: shell
        options:
          command: >-
            mkdir -p .spur/run &&
            printf 'contract-violation: doc-sync learnings capture missed its declared contract — repair skipped re-dispatch (ADR-118 pilot)\n' > ".spur/run/$__runId-wrapup-repair.status" &&
            exit 0

  - id: metrics-record
    description: >
      Append one JSONL row per task to .spur/memory/wrapup-metrics.jsonl deterministically.
      Revalidates the normalized run-scoped capture from task-resolve (0770 + 0783):
      a missing, corrupted or non-canonical capture records FAIL; every member must
      produce a successful, well-shaped `task show` lookup (a missing metrics row is
      never silently absorbed as success); rows are serialized with jq — never
      interpolated printf JSON — so escaped fields stay parseable; and PASS is
      written only after every required append succeeds. A missing per-task verdict
      is UNKNOWN telemetry, never proof of completion; previously written valid rows
      survive a failure.
    onEnter:
      # (d) wrapup-steps.ts metrics owns the JSONL row append (0824); the wrapper
      # only locates it and fails closed — FAIL, never a silent skip.
      - kind: shell
        options:
          command: >-
            mkdir -p .spur/run &&
            if [ -f plugins/sp/scripts/wrapup-steps.ts ]; then
              bun plugins/sp/scripts/wrapup-steps.ts metrics;
            elif W="$(superskill script path sp wrapup-steps.mjs 2>/dev/null)" && [ -f "$W" ]; then
              node "$W" metrics;
            else
              echo "wrapup-steps failed closed — script not found — run 'superskill install sp'" >&2;
              printf 'FAIL\n' > ".spur/run/$__runId-wrapup-metrics.status";
            fi

  - id: feature-transition
    description: >
      If vars.feature is set, sync feature status via bounded feature-sync-bounded
      (0411). The shell terminates `exit 0` so artifacts already landed are never
      discarded, but the outcome is truthful (0770 + 0783 R4): sync succeeds only
      with a valid proposal matching vars.feature, no gateBlocked/requiresConfirm
      condition, and a freshly observed feature status equal to proposal.to;
      applied:false is a successful explicit no-change only when from == to and
      that status is observed. A nonzero, malformed, partial, blocked or
      unreadable outcome records FAIL in the run-scoped sync status and routes
      to `failed` — an affected-feature check cannot convert a failed sync into
      success. Empty vars.feature is NOT a blocked sync but a mis-invocation
      (the engine only enters this state when vars.feature is set) and fails
      loud (dogfood 2026-08-15, feature I3).
    onEnter:
      # (d) wrapup-steps.ts feature-transition owns the bounded sync, observation
      # and the gate (0824); the wrapper only locates it and fails closed. The
      # script terminates exit 0 on every path so artifacts that already landed
      # are never discarded; soft TERMINATION is not soft TRUTH — only a verified
      # sync writes PASS (0783 R4). A gate PASS can never convert a failed sync
      # into success, and a gate FAIL fails the step (0770 failure routing).
      - kind: shell
        options:
          command: >-
            mkdir -p .spur/run &&
            if [ -f plugins/sp/scripts/wrapup-steps.ts ]; then
              bun plugins/sp/scripts/wrapup-steps.ts feature-transition;
            elif W="$(superskill script path sp wrapup-steps.mjs 2>/dev/null)" && [ -f "$W" ]; then
              node "$W" feature-transition;
            else
              echo "wrapup-steps failed closed — script not found — run 'superskill install sp'" >&2;
              printf 'FAIL\n' > ".spur/run/$__runId-wrapup-sync.status";
            fi

  - id: branch-cleanup
    description: >
      Irreversible-operation HITL gate, consent-only (0770). If vars.merge=true,
      branch cleanup (merge or delete) is a candidate — but this gate records
      the operator decision and performs NO git operation itself; an operator
      or a separate approved surface executes any actual merge/delete. This
      gate ALWAYS pauses — even under --auto — because branch operations are
      irreversible (Auto-Decision Principle #6).
    pause: true
    onEnter:
      - kind: hitl.confirm
        options:
          prompt: "Branch cleanup for the validated task list (.spur/run/${vars.__runId}-wrapup-tasks.json). This is IRREVERSIBLE (merge or delete). Confirm to proceed?"

  - id: done
    description: >
      Terminal — wrap-up complete. Summary: tasks wrapped, learnings captured,
      metrics recorded, feature advanced or explicitly no-change (if applicable),
      branch-cleanup decision recorded (consent-only; no git operation performed).
    onEnter:
      - kind: note
        options:
          message: "Wrap-up pipeline complete for the validated task list at .spur/run/${vars.__runId}-wrapup-tasks.json. Learnings at .spur/memory/learnings.md, metrics at .spur/memory/wrapup-metrics.jsonl. Branch cleanup: consent recorded only (merge=${vars.merge}); no git operation performed by wrap-up."

  - id: skipped
    description: Terminal — wrap-up skipped (empty task list or operator abort).

  - id: failed
    description: >
      Terminal — wrap-up failed (0770): invalid wrap input, an unresolved or
      non-completed task, a failed metrics capture, a failed required sync, or
      a failed feature gate. Already-written artifacts (learnings, metrics
      rows, route reason) are preserved; nothing is rolled back.

transitions:
  # ── start -> task-resolve ──
  - from: start
    to: task-resolve
    description: Begin wrap-up.
    guard:
      kind: always

  # ── task-resolve: validation fail + proportional route table (0758 R1-R4, 0770) ──
  - from: task-resolve
    to: failed
    description: >
      Validation FAIL (0770): malformed wrap input, a non-string/empty entry, an
      unresolved or non-completed task, or an empty __runId. Declared before the
      route edges so invalid input can never be absorbed by the empty-list skip.
    guard:
      kind: shell
      options:
        command: 'test "$(cat .spur/run/$__runId-wrapup-resolve.status 2>/dev/null)" = FAIL'
  # Route edges key on the validated run-scoped capture, never raw vars.tasks
  # (0783 R2). A missing or corrupted capture yields -1, which satisfies none
  # of the numeric edges, so the run falls through to the always-defense
  # `failed` edge instead of claiming a skip or a route.
  - from: task-resolve
    to: skipped
    description: Validated task list is empty — skip wrap-up (only a validated [] may skip).
    guard:
      kind: shell
      options:
        command: 'test "$(jq length .spur/run/$__runId-wrapup-tasks.json 2>/dev/null || echo -1)" -eq 0'
  - from: task-resolve
    to: metrics-record
    description: Proportional fast path — complete and consistent evidence bypasses doc-sync.
    guard:
      kind: shell
      options:
        command: 'test "$(jq length .spur/run/$__runId-wrapup-tasks.json 2>/dev/null || echo -1)" -gt 0 && test "$mode" = fast'
  - from: task-resolve
    to: doc-sync
    description: Proportional safety path — missing/unknown/conflicting evidence routes to full doc-sync.
    guard:
      kind: shell
      options:
        command: 'test "$(jq length .spur/run/$__runId-wrapup-tasks.json 2>/dev/null || echo -1)" -gt 0 && test "$mode" != fast'
  - from: task-resolve
    to: failed
    description: >
      Defense (0770) — no PASS/FAIL resolve status (shell crashed or never ran):
      route to `failed` rather than claiming a skip.
    guard:
      kind: always

  # ── doc-sync: contract-first routing (ADR-118 pilot, 0871) ──
  # Declaration order matters: the contract-violation edge is tried first, then
  # success (action-ok), then the executor-failure defense (always) — a failed
  # agent.run reaches these guards because doc-sync's agent.run declares
  # onError: continue.
  - from: doc-sync
    to: repair
    description: >
      Contract violation (ADR-118): the agent exited 0 but missed its declared
      post-condition. Route to the cheap repair edge — never re-dispatch the
      full agent.run on this attempt.
    trigger: contract-violation
    guard:
      kind: contract-violation
  - from: doc-sync
    to: learnings-append
    description: Doc drift repair + learning capture succeeded — append learnings.
    guard:
      kind: action-ok
  - from: doc-sync
    to: failed
    description: >
      Executor failure (non-zero exit, signal, or dispatch error) — keep the
      existing fail semantics; a contract violation never falls through here
      because the contract-violation edge is declared first.
    trigger: executor-failure
    guard:
      kind: always

  # ── linear body: learnings-append -> metrics-record ──
  - from: learnings-append
    to: metrics-record
    description: Learning capture appended (or soft-skipped) — record metrics.
    guard:
      kind: always

  # ── repair: cheap path, no re-dispatch -> metrics-record ──
  - from: repair
    to: metrics-record
    description: Contract-violation repair recorded — proceed to metrics without re-dispatch.
    guard:
      kind: always

  # ── metrics-record: fail edge, then conditional routing (feature → merge → done) ──
  # Declaration order matters: the FAIL edge is tried first (0770), then
  # feature-transition (if feature is set), then branch-cleanup (if merge=true
  # but no feature), then done (neither). Routing edges additionally require
  # the metrics status PASS so a missing status can never claim success.
  - from: metrics-record
    to: failed
    description: >
      Metrics capture failed for at least one task — a missing metrics row is
      recorded as failure, never silently omitted as success (0770).
    guard:
      kind: shell
      options:
        command: 'test "$(cat .spur/run/$__runId-wrapup-metrics.status 2>/dev/null)" = FAIL'
  - from: metrics-record
    to: feature-transition
    description: Metrics PASS and feature id is set — advance feature through legal lifecycle edges.
    guard:
      kind: shell
      options:
        command: 'test "$(cat .spur/run/$__runId-wrapup-metrics.status 2>/dev/null)" = PASS && test -n "$feature"'
  - from: metrics-record
    to: branch-cleanup
    description: Metrics PASS, no feature id, but merge is requested — go to branch cleanup.
    guard:
      kind: shell
      options:
        command: 'test "$(cat .spur/run/$__runId-wrapup-metrics.status 2>/dev/null)" = PASS && test -z "$feature" && test "$merge" = true'
  - from: metrics-record
    to: done
    description: Metrics PASS, no feature id and no merge — wrap-up is done.
    guard:
      kind: shell
      options:
        command: 'test "$(cat .spur/run/$__runId-wrapup-metrics.status 2>/dev/null)" = PASS && test -z "$feature" && test "$merge" != true'

  # ── feature-transition: fail edge, then branch-cleanup (if merge) or done ──
  - from: feature-transition
    to: failed
    description: >
      Required synchronization failed (0770): non-zero sync exit, an invalid
      sync result, or a failed feature gate. The failed status carries the
      actual reason; already-written learnings/metrics stay on disk.
    guard:
      kind: shell
      options:
        command: 'test "$(cat .spur/run/$__runId-wrapup-sync.status 2>/dev/null)" = FAIL'
  - from: feature-transition
    to: branch-cleanup
    description: Sync PASS — merge is requested.
    guard:
      kind: shell
      options:
        command: 'test "$(cat .spur/run/$__runId-wrapup-sync.status 2>/dev/null)" = PASS && test "$merge" = true'
  - from: feature-transition
    to: done
    description: Sync PASS — no merge requested.
    guard:
      kind: shell
      options:
        command: 'test "$(cat .spur/run/$__runId-wrapup-sync.status 2>/dev/null)" = PASS && test "$merge" != true'

  # ── branch-cleanup: HITL exhaustive routing (yes / no / cancel → done) ──
  # No irreversible git op is wired yet; confirmation is recorded, then wrap
  # completes.
  - from: branch-cleanup
    to: done
    description: Branch cleanup HITL answered — wrap-up complete.
    guard:
      kind: shell
      options:
        command: 'test "$__hitlAnswer" = yes || test "$__hitlAnswer" = no || test "$__hitlAnswer" = cancel'
  - from: branch-cleanup
    to: done
    description: Defense — missing HITL answer still completes wrap (cleanup is optional).
    guard:
      kind: always
