# ADR 0002: Wrap Pipeline Contract

**Status:** Accepted (2026-05-14, drafted in #139 Chunk 2). **Default flipped to V2 on 2026-05-19 via #139 Chunk 11c** — see migration-path entry #4 below. Extended 2026-05-15 (#139 Chunk 3 — runner skeleton + `wrapV2` opt-in shipped behind the flag; #139 Chunk 4 — real `lint` / `test` step handlers + runner halt-condition broadened to `blocker: "errors-only"`; #139 Chunk 5 — real `ai-content` step handler with send/poll/capture/validate semantics + `{previousMemoryBlock}` prompt interpolation token). Extended 2026-05-16 (#139 Chunk 6 — real `priming-roll` step handler with plan parsing, managed-block delimiter convention, and staged-write contract; #139 Chunk 7 — real `critic-check` step handler with session-history heuristic + `.tangleclaw/critic-runs.json` read contract + `options.criticSkipRationale` staging). Extended 2026-05-17 (#139 Chunk 8 — real `pr-check` step handler with `gh pr list` integration, session-scope partitioning, and `options.prHandling` resolution staging). Extended 2026-05-18 (#139 Chunk 9 — real `commit` step handler: single-transaction flush of staged writes, `git add -A` + session-derived commit message, `lastWrapSha` stamping on `projConfig`, real `blocker:true` contract with pre-commit-hook surfacing; bundled prawduct / minimal / tilt templates' commit step now declare `blocker:true` explicitly; #139 Chunk 10 — frontend multi-step drawer, `triggerWrap(projectName, options)` second-arg threading, `POST /wrap` accepts `body.options` and surfaces `pipelineResult`, HTTP 200-on-blocked semantics, three decision widgets — `test` override, `critic-check` rationale, `pr-check` resolutions — collected into runner `options` on retry). Extended 2026-05-21 (post-#139 open-queue #2 — bundled prawduct `ai-content` prompts populated for `changelog-update`, `learnings-capture`, `memory-update`; empty-prompt → `skipped` contract preserved via the `minimal` methodology anchor). Extended 2026-05-22 (post-#139 open-queue #3 — real `version-bump` step handler ships at `lib/wrap-steps/version-bump.js`; closes the last #139 Chunk-3 no-op stub; introduces the composite-staged-key convention for multi-file write steps; `lib/wrap-steps/commit.js:_buildBodyLines` gains a deduped `{oldVersion, newVersion, bumpLevel}` duck-type so the wrap commit body carries `- Bumped <old> → <new> (<level>)`). **Extended 2026-05-30 (#264 — wrap-pipeline safety): two paired safety layers ship together.** (a) `lib/wrap-steps/commit.js` now auto-branches when wrap fires on `main`/`master`: creates `wrap/<YYYYMMDDHHmmss>-<project-slug>`, switches, commits there. Escape hatch: `context.options.allowDirectToMain === true` bypasses entirely (for trivial doc fixes / hot-fixes per CLAUDE.md's "direct main commits only for trivial doc edits or incident hot-fixes" carve-out). `output.autoBranched` + `output.originalBranch` surface the decision for Chunk 10's UI to render "Wrap committed on branch X (auto-branched off main)" alongside push/PR affordances. (b) `lib/wrap-steps/critic-check.js` now halts the pipeline when `.tangleclaw/critic-runs.json` has an entry with `ranAt: "actual"` whose `findings` array contains a finding of `severity: "blocking"` (case-insensitive). The bundled prawduct template flips this step's `blocker: false` to `blocker: "errors-only"` so the runner's `step.blocker === "errors-only"` && `!ok` halt condition fires. Operator-override: `options.criticBlockingOverride === true` plus an `options.criticBlockingOverrideReason` string proceeds anyway but stages an `overrideBlockingFindings` entry; `_buildBodyLines` adds an audit-trail commit-message footer line "Critic-override: \<reason\> (\<N\> blocking finding(s) ignored)" so the override survives in `git log` and `gh pr view`. **Why this is an ADR amendment, not just code:** Chunks 7 + 10 explicitly declared `critic-check` `blocker: false` by design. The 631acb5 incident (2026-05-26: a session-wrap commit landed directly on main with a Critic-blocking framework regression, exactly the failure mode the warning-not-halt design left open) is the empirical evidence that warning-not-halt is insufficient under real operator workflows. The override path preserves the operator's authority while the default closes the silent-bypass path. **Methodology authors retain control:** `blocker: false` on the `critic-check` step in a custom methodology template restores warning-only semantics (the runner halts only when `blocker === true || === "errors-only"`). The prawduct template ships `"errors-only"` as the safe default for the methodology that introduced the failure mode; other methodologies opt in explicitly. **Extended 2026-06-11 (#328 — content steps fail loud):** the three prawduct `ai-content` content steps (`changelog-update`, `learnings-capture`, `memory-update`) flip from non-blocker to `blocker: true` + `allowOverride: true`. Previously a `memory-update` timeout returned `ok:false` but, being a non-blocker, let the pipeline reach `commit` — a complete-looking wrap landed with no MEMORY refresh (the silent-partial-wrap bug). Now such a failure halts before `commit`; the drawer offers Retry or a step-scoped "Skip & note" override (`options.skipAiContent[stepId]`, threaded/accumulated across retries by `public/session.js`, staged as `{aiContentSkipped:true}` so `_buildBodyLines` records it). The timeout blocker message no longer hardcodes "wrap pipeline blocked" (it described the pipeline, which the handler can't know) — it now names the step + "no idle detected" with an `output.remediation`. No double-bump risk: `version-bump` stages in-memory and only `commit` flushes, so a halt evaporates staged writes and retry re-derives.
**Source issue:** #139 — Methodology-aware single-button session wrap
**Related issues:** #136 (template reconciler), #145 (hook precondition gate), #155 (generalized template-array reconciliation), #158 (hook-entry backfill)
**Related ADR:** ADR 0001 — Symmetric Capability Gates (the read-once shim mandated below is an instance of this rule)

---

## Context

TangleClaw's Session Wrap button is, at the time of this ADR, a *prompt fabricator*. `lib/sessions.js:triggerWrap` reads the methodology's `wrap: {command, steps, captureFields}` block, builds a natural-language prompt, and sends it to the AI engine. Whatever the AI does next is the de-facto pipeline. TangleClaw never inspects, orders, or gates the actual steps.

Eight concrete gaps follow from this design (catalogued in #139): janitor pass not run, Critic compliance not verified, tests not gated, durable memory not deterministically updated, priming pointer not rolled by TC, structured output not derived from memory, multi-step commits leaking into the working tree, push-on-main without confirmation. Users have absorbed the gap by performing a *manual* wrap *before* pressing the button — two wraps where there should be one.

The architectural shift #139 enacts: **TangleClaw owns the workflow; the AI owns the content.** Deterministic steps (lint, tests, git ops, version bump, fs reads/writes) execute server-side. The AI engine is invoked only at explicit handoffs for content-generation steps (memory block, structured summary).

This ADR documents the *contract* that the new pipeline runs against. Chunk 2 lands the schema and back-compat shim. Chunks 3–11 implement the runner, step kinds, frontend, and rollout flag. Chunk 12 (optional) extends prompt templates to non-Claude engines.

---

## Decision

A methodology template declares a `wrap_pipeline` block. The Session Wrap button executes the declared pipeline server-side, invoking the AI only on `ai-content` step boundaries.

### Schema

```jsonc
{
  "wrap_pipeline": {
    "schemaVersion": "1.0",
    "promptTemplates": {
      "claude": "Perform a session wrap. ..."
      // codex / gemini variants land via #139 Chunk 12 (or separate issues)
    },
    "steps": [
      { "id": "open-pr-check",   "kind": "pr-check",      "blocker": false },
      { "id": "lint",            "kind": "lint",          "blocker": "errors-only", "scope": "in-session" },
      { "id": "test",            "kind": "test",          "blocker": true,          "allowOverride": true },
      { "id": "critic-check",    "kind": "critic-check",  "blocker": false,         "warnOn": "medium-plus" },
      { "id": "memory-update",   "kind": "ai-content",    "prompt": "Update .tangleclaw/memories/MEMORY.md session block…" },
      { "id": "priming-roll",    "kind": "priming-roll" },
      { "id": "summary-derive",  "kind": "ai-content",    "prompt": "From the MEMORY block above, derive structured output…", "captureFields": ["summary", "nextSteps", "learnings"] },
      { "id": "version-bump",    "kind": "version-bump",  "blocker": false },
      { "id": "commit",          "kind": "commit",        "messageBuilder": "session-content" }
    ]
  }
}
```

### Step kinds (the runner's dispatch table)

| Kind            | Owner   | Behavior |
|---|---|---|
| `pr-check`      | server  | `gh pr list --state open --author @me`; surface open PRs; ask user how to handle. Never blocks. |
| `lint`          | server  | Run project's `lintCommand` on files changed since last wrap. `blocker: "errors-only"` blocks on lint errors but not warnings. `scope: "in-session"` limits findings to commits since last wrap. |
| `test`          | server  | Run project's `testCommand`. Red → block. `allowOverride: true` lets user pass `--skip-tests` from the UI; the skip is recorded in the wrap commit body. |
| `critic-check`  | server  | Heuristic on session history (commit count + line-change count + chunk-tag detection). Warn UI surfaces if heuristic trips and no Critic agent ran. Never blocks; logs skip rationale to MEMORY. |
| `ai-content`    | hybrid  | Server fabricates the per-step prompt from template + session context; sends to AI via tmux; captures output; validates shape. Used by `changelog-update`, `learnings-capture`, `memory-update`. **Content steps declare `blocker: true` + `allowOverride: true` (#328):** a timeout/validation failure halts the pipeline *before* `commit` (no silent partial wrap); the operator either waits + Retries, or ticks "Skip & note" (`options.skipAiContent[stepId]`) to wrap without that step, recorded in the commit body. An empty-prompt step still `skipped`s (ok:true) and never halts. |
| `priming-roll`  | server  | Parse `.claude/plans/<plan>.md` for current chunk pointer; roll forward in `.claude/priming/build-session.md`. Carry blocker annotations through. |
| `version-bump`  | server  | If CHANGELOG has `[Unreleased]` entries and project has a `version.json`, bump and update CHANGELOG. Optional, never blocks. |
| `commit`        | server  | One git commit aggregating all server-side mutations + AI-produced files. Message built from `messageBuilder` strategy. Skip if truly clean. |

### Runner contract

```js
// lib/wrap-pipeline.js (Chunk 3)
async function runWrapPipeline(projectName, options) {
  // returns { ok, blockedAt, results: [{stepId, status, output, blockers, prompt?, aiResponse?}], commitSha, summary }
}
```

Each step returns `{ok: boolean, status: 'done'|'blocked'|'skipped', output: any, blockers: string[]}`. The runner halts the pipeline on `!ok` whenever `step.blocker === true` OR `step.blocker === "errors-only"` — both forms are halt-class. The handler is responsible for deciding what counts as an "error" in the enum case (e.g. lint exits non-zero → `ok: false`); the runner then halts. Any other `blocker` value (`false`, `undefined`, unrecognized strings) never halts — the step result is informational only. The runner is **single-transaction**: server-side mutations stage in memory or a per-pipeline scratch dir; only the `commit` step touches the project's git index. A failure produces no commit; success produces one commit (or zero on a clean session).

The Chunk 4 broadening of the halt condition from `=== true` only to `=== true || === "errors-only"` is deliberate: the schema's `blocker: "errors-only"` form was always specified to "block on lint errors but not warnings" (see the step-kind table above), and an enum that doesn't halt the pipeline would have been misleadingly named. The Chunk 3 runner's `=== true` only check was a placeholder while no real handler returned `!ok`; Chunk 4 collapses the placeholder. Callers reading `step.blocker` for any other purpose MUST use the same disjunction (`=== true || === "errors-only"`) — per ADR 0001 (Symmetric Capability Gates), drift here re-creates the PR #125 incident class.

### Back-compat shim (Chunk 2)

`lib/skills.js` exports `wrapShapeFromTemplate(template)` (and the unchanged `getWrapSkill(methodologyId)`) returning the legacy `{command, steps, captureFields}` shape regardless of whether the source template uses `wrap_pipeline` or the legacy `wrap` block:

- **`wrap_pipeline` present** → `command: null`; `steps` = `wrap_pipeline.steps[].id` in declaration order; `captureFields` = the flattened-and-deduplicated union of every step's `captureFields` array.
- **`wrap_pipeline` absent and legacy `wrap` present** → pass through verbatim (`{command, steps, captureFields}` straight from the template).
- **Neither present** → `null`.

The shim is the single read-point for "what are the wrap steps?" — both `lib/sessions.js:triggerWrap` and `lib/eval-audit.js:scoreWrapQuality` go through it. ADR 0001 mandates this: two files coordinating around the same conceptual state must read through one predicate or the gates drift.

The shim survives until `wrapV2: true` becomes the default in Chunk 11; at that point the legacy `wrap` fallback branch is removable and the dispatch table becomes the source of truth.

### Reconciliation

`wrap_pipeline.steps` is registered in `lib/store.js:ARRAY_RECONCILERS` with `mergeBy: id` policy (#155 Chunk 2). New bundled steps with a new `id` value are appended to live templates on reconcile; existing entries are never mutated. Legacy `wrap.steps` / `wrap.captureFields` reconcilers remain in the table as inert safety nets — bundled templates no longer ship those paths, so the policy table short-circuits when bundled arrays are absent.

ADR 0001's documented limitation applies: a user-removed step is treated as stale and re-added on reconcile. Tombstones would solve it but are out of scope for #139.

### Rollout (Chunks 3 → 11)

The runner ships behind `projConfig.wrapV2` (default `false`, Chunk 3). Existing projects stay on the legacy `triggerWrap` path. The legacy NL-prompt code remains for one release cycle after the default flips (Chunk 11) and is excised in a follow-up. This phased pattern mirrors the engine-config rollout (#119) and the silentPrime rollout (#137) — both shipped behind opt-in flags before defaulting on.

---

## Consequences

### Positive

- **Single source of truth for wrap steps.** The shim (and later, the runner) is the only path that knows the schema. Callers stop reading `template.wrap.steps` directly.
- **Schema is reconciler-aware from day one.** A new bundled step lands in users' runtime templates without a one-shot copy — same pattern that closed #136 / #155.
- **Migration window is bounded.** Chunk 2's shim is dual-shape on purpose; Chunk 11's default flip removes one branch; the follow-up release deletes the legacy `wrap` block reads entirely.
- **Pipeline is declarative.** Adding a new methodology with a different wrap pipeline = a JSON file edit + (if a new step kind is needed) a one-line dispatch-table entry.

### Negative / accepted trade-offs

- **The shim's `captureFields` synthesis is union-of-steps, not stepwise.** Anything depending on "which step's output produces which captureField" can't tell from the legacy shape. No current caller depends on this, but a future caller (e.g., a step-level diagnostic UI) would have to read `wrap_pipeline.steps[].captureFields` directly.
- **`mergeBy:id` does not propagate new `captureFields` values onto an existing step.** A bundled release adding `learnings` to an existing `memory-update` step's `captureFields` array will NOT flow into live runtime templates already on that `id` — the `_reconcileMergeBy` policy is additive only and never overwrites a matched entry's field values (symmetric with `phases` and `actions`). To force the change to propagate, the methodology author must rev the step `id` (e.g. rename `memory-update` → `memory-update-v2`); the reconciler then sees a new id and appends the bundled step. Same operational rule as `phases.id` / `actions.label` versioning — pinned by `_reconcileMergeBy`'s additive-only contract (see ADR 0001's mergeBy section). If stepwise field propagation becomes a real need, the fix is a per-path reconciler that traverses inside matched entries, not a global policy change.
- **`command: null` is forced from the new schema.** Methodologies wanting a custom override prompt go via `promptTemplates.<engine>` once the runner honors it (Chunk 3+). For the migration window, no bundled methodology sets a non-null `command`, so the synthesized null preserves byte-equal `triggerWrap` behavior.
- **Symmetric-gates burden lives in `wrapShapeFromTemplate`.** Any future "read the wrap shape" caller MUST go through this helper, not re-read templates. ADR 0001's incident catalog is the cost of forgetting this; the helper export + the test in `test/skills.test.js` are the enforcement.
- **Legacy reconciler entries (`wrap.steps`, `wrap.captureFields`) stay in `ARRAY_RECONCILERS` until Chunk 11's cleanup.** They're inert, but readers must understand "policy table has the new path AND retains the old paths as safety nets" — a one-line table comment explains this.

### Out of scope (entire #139)

- Auto-dispatch of Critic on missing-Critic detection (deferred; warn + log skip rationale only).
- Tombstone-based "user-removed step X" handling — pipeline is methodology-declared; user customizations either fork the methodology or use a custom one.
- Branch-protection / push-on-main flow — push step lives outside the pipeline.
- Per-step retry with exponential backoff — failure blocks; user fixes and retries.
- Methodology authoring UX — schema is authored by editing template JSON directly.

---

## Migration path (Chunk 2 → Chunk 11)

1. **Chunk 2 (landed 2026-05-14):** Bundled templates ship `wrap_pipeline` block. Legacy `wrap` block removed from bundles. Shim reads both; existing installs continue to function on the legacy path until reconcile picks up the new bundled schema.
2. **Chunk 3 (landed 2026-05-15):** Runner skeleton (`lib/wrap-pipeline.js:runWrapPipeline`) + per-kind step modules under `lib/wrap-steps/` + `projConfig.wrapV2` opt-in flag (default `false`). All eight step kinds dispatch to no-op stubs returning the canonical `{ok:true, status:'done', output:null, blockers:[]}` result. Block-true halt semantics, unknown-kind skip, and thrown-error capture are wired in the runner up-front — Chunks 4–9 only need to fill in step bodies, not touch the dispatch or error-handling skeleton.
3. **Chunks 4–10:** Real step implementations + frontend UI ship behind `wrapV2: false`. Legacy `triggerWrap` path remains the default; new path is dogfooded on opt-in projects.
   - **Chunk 4 (landed 2026-05-15):** Real `lint` and `test` handlers replace the Chunk 3 no-op stubs at `lib/wrap-steps/lint.js` and `lib/wrap-steps/test.js`. Both shell out to `projConfig.lintCommand` / `projConfig.testCommand` (Chunk 3 defaults: `null` → skipped). Test handler honors `step.allowOverride === true` + `options.skipTests === true` → `skipped` with override flag. Lint handler uses `git status --porcelain` to scope to in-session changes; file args are appended after a `--` end-of-options separator and each is single-quote-escaped (`'\''` close-reopen idiom for embedded quotes). Runner extended: (a) `_buildStepContext` threads `options` into `context.options` (defaults to `{}`); (b) halt condition broadened from `=== true` to `=== true || === "errors-only"` per the contract above.
   - **Chunk 5 (landed 2026-05-15):** Real `ai-content` handler replaces the Chunk 3 no-op stub at `lib/wrap-steps/ai-content.js`. Handler sends the (optionally-interpolated) `step.prompt` via `tmux.sendKeys`, polls `sessions.detectIdle` until idle is reported (cap: 5 min), captures full pane scrollback, and validates the result. If `step.captureFields[]` is set, each field must appear as a `## Heading` block with non-empty content; otherwise the handler asserts only a ≥20-char non-trivial response. Successful captures stage in `context.staged[step.id] = {capturedText, parsedFields}` for the eventual `commit` step (Chunk 9) to consume. **Prompt interpolation:** one token in Chunk 5 — `{previousMemoryBlock}` is replaced with the captured text of the prior step whose `stepId === 'memory-update'` (and whose `status === 'done'`); unrecognized braces pass through verbatim. The handler is the same code path for both `memory-update` and `summary-derive` step instances — only the `step.prompt` and `step.captureFields` differ. **Module-load cycle:** `lib/sessions.js` is lazy-required inside the `detectIdle` adapter to break the `sessions.js → wrap-pipeline.js → wrap-steps/ai-content.js → sessions.js` chain; at call time the cycle is fully resolved.
   - **Chunk 6 (landed 2026-05-16):** Real `priming-roll` handler replaces the Chunk 3 no-op stub at `lib/wrap-steps/priming-roll.js`. Pure server-side filesystem reads + a staged (deferred) write — no AI handoff. **Plan format:** the handler parses `### Chunk N: Title` headings; `✅` anywhere on the heading line marks that chunk done; ids tolerate dotted / lettered sub-chunk numbering (`1`, `2a`, `10c.2`, `12.3a.4`). The "current" chunk is the first un-done heading in document order; "next" is whatever follows it. **Blocker annotations:** the first `**Blocked on:** <text>` line within a chunk body (case-insensitive) is captured and surfaced inline on the rolled pointer so the next session sees blockers without re-reading the plan. **Plan path resolution:** `step.planPath` (project-relative or absolute) wins when set; otherwise the handler scans `<project>/.claude/plans/*.md` and requires exactly one match — zero or two+ → `{ok:false, status:'blocked'}` with a "set step.planPath to disambiguate" message. **Priming-file managed block:** the handler edits a region of `.claude/priming/build-session.md` (overridable via `step.primingPath`) delimited by `<!-- TANGLECLAW:PRIMING-ROLL:BEGIN -->` / `<!-- TANGLECLAW:PRIMING-ROLL:END -->`. The rest of the file is sacrosanct — user-authored surround is preserved byte-for-byte; when the markers don't exist yet, the handler appends a fresh managed block separated by a blank line. **Single-transaction discipline (matches Chunk 5):** the handler does NOT touch the filesystem itself; it stages `context.staged[step.id] = {primingPath, newContent, changed, pointer, planPath}`, and the Chunk 9 `commit` step is the only step that flushes staged writes to the working tree. A re-run on an unchanged plan produces an unchanged `newContent` and reports `output.changed === false`; the `commit` step is expected to skip writes when `changed === false`. **Module exports:** six pure helpers (`_parseChunks`, `_selectPointer`, `_renderPointerBody`, `_replaceManagedBlock`, `_resolvePlanPath`, `_resolvePrimingPath`) plus the marker constants — pinned for unit-testability and for the Chunk 9 commit-step contract.
   - **Chunk 7 (landed 2026-05-16):** Real `critic-check` handler replaces the Chunk 3 no-op stub at `lib/wrap-steps/critic-check.js`. Heuristic on session history: medium+ trips when commit count ≥ `step.commitThreshold` (default 10), total line changes ≥ `step.lineChangeThreshold` (default 500), or a chunk-tag pattern (`\bchunk[\s\-_]?N(?:\.N[a-z]?)*\b`, case-insensitive) appears in the current branch name or any commit subject. **Session range** uses `git symbolic-ref refs/remotes/origin/HEAD` → main branch; range spec `<main>..HEAD`. Fallback: `HEAD~10..HEAD` when the symref is missing, current branch is the main branch (work-directly-on-main case), or HEAD is detached — the degradation is surfaced in `output.rangeDegraded` + `output.rangeDegradedReason` so the Chunk 10 UI can warn the heuristic is operating in degraded mode. Chunk 9 will replace this with a `lastWrapSha`-stamped range once the `commit` step starts recording it. **Critic-dispatch detection** reads `<project>/.tangleclaw/critic-runs.json` — an array of `{branchName: string, timestamp: ISO 8601, ...}` entries — and filters to entries whose `branchName` matches the current branch. Any match → `criticRan: true`. Missing file / malformed JSON / non-array / no current-branch entries → `criticRan: false`. **The producer is intentionally NOT shipped in Chunk 7**: the `invoke-critic` action button in methodology templates (`actions[]`) currently has no implementation, and the frontend wiring lives in Chunk 10. Chunk 7 ships only the read contract so the pipeline can produce meaningful output today via test fixtures, and the write side can land in any later chunk without re-touching this handler. **Output shape:** `{warning, isMediumPlus, criticRan, branch, mainBranch, rangeSpec, rangeDegraded, rangeDegradedReason, heuristic: {commits, lineChanges, insertions, deletions, chunkTag, chunkTagSource, chunkTagMatch, commitThreshold, lineChangeThreshold}, criticRunsRecent, owedRationale}`. **Blocker contract: always `false`** — the spec says missing-Critic is a methodology rule the user can knowingly skip with rationale, not a structural failure. The handler always returns `ok:true`; the warning lives in `output.warning`. Even on a misconfigured project (missing `context.project.path`) the handler returns `{ok:true, status:'skipped'}` to honor the never-blocks contract. **Rationale staging.** When `warning === true` AND `options.criticSkipRationale` is a non-empty trimmed string, the rationale is staged at `context.staged[step.id] = {warning, owedRationale, branchName, isMediumPlus, criticRan, heuristic}` for the Chunk 9 `commit` step + Chunk 5 `memory-update` prompt to consume. Whitespace-only rationale is treated as no rationale (owedRationale → `null`). No warning → nothing staged. **Single-transaction discipline (matches Chunks 5 + 6):** the handler reads only — it never writes git, never writes the filesystem; staging is the sole side effect. **Scope discipline:** the bundled prawduct template is intentionally NOT extended with `{kind: "critic-check"}` in this chunk — that would change the legacy `wrapV2:false` NL-prompt path's byte-equal behavior (matches the strict scope policy from Chunks 4 / 5 / 6, where the bundled template was untouched). Methodology authors can opt their template in; a future chunk (likely paired with the Chunk-11 default flip) will wire prawduct's pipeline to include `critic-check` as part of the methodology-and-UI rollout.
   - **Chunk 8 (landed 2026-05-17):** Real `pr-check` handler replaces the Chunk 3 no-op stub at `lib/wrap-steps/pr-check.js`. Reads open authored PRs via `gh pr list --state open --author @me --json number,title,headRefName,baseRefName,url,createdAt,isDraft,author`; partitions into `sessionScoped` (PRs whose `headRefName === currentBranch`) and `otherOpen` so the Chunk 10 UI can render two distinct buckets. **`gh` availability gate:** `gh --version` (no auth, no network) — missing → `{ok:true, status:'skipped', output:{reason:'gh CLI not available'}}`. **`gh pr list` failure modes** (no auth, non-GitHub remote, API hiccup, malformed JSON, non-array JSON) all degrade to `{ok:true, status:'skipped'}` with the first 200 chars of `stderr`/`stdout` surfaced in `output.detail` so the UI can render the recovery hint inline. **Draft filtering:** `step.includeDrafts` defaults `false`; drafts are hidden by default. The unfiltered count is surfaced in `output.counts.rawTotal` so the UI can show a "N drafts hidden" hint without re-calling gh. **Resolution staging.** `options.prHandling` accepts two shapes — a string shortcut (`'merge'|'defer'|'ignore'` applied to every session-scoped PR) or a per-PR object map (`{prNumber: 'merge'|'defer'|'ignore'}`). Both string and number keys are normalized to string before lookup. Invalid keys (PR not session-scoped) and invalid values (handling not in the enum) are surfaced via `output.invalidHandling[]` rather than silently dropped — the UI prompts the user to fix and re-submit. **Single-transaction discipline (matches Chunks 5–7):** the handler reads only — never mutates git, never writes the filesystem. `context.staged[step.id] = {branch, sessionScoped, resolutions, invalidHandling}` only when `sessionScoped.length > 0 || resolutions has entries` (no PRs + no resolutions = nothing for the commit step to do). **Blocker contract: always `ok:true`** — every git/gh probe is wrapped in an outer `try/catch` per the Chunk 7 always-ok pattern; thrown probes degrade to skipped. **Session-scope filter is intentionally minimal:** `headRefName === currentBranch` is the single source of truth — more elaborate matching (PR title chunk-tag, issue cross-reference) is Chunk-10 UI territory. **Scope discipline:** bundled prawduct template intentionally NOT extended with `{kind: "pr-check"}` (matches Chunks 4 / 5 / 6 / 7's strict pattern — would change the legacy NL-prompt byte-equal pin); a future chunk paired with the Chunk-11 default flip will wire it in.
   - **Chunk 9 (landed 2026-05-18):** Real `commit` handler replaces the Chunk 3 no-op stub at `lib/wrap-steps/commit.js`. The load-bearing single-transaction flush point — every prior step that staged a write or commit-message ingredient lands here. **Four-phase orchestration:** (1) flush staged filesystem writes — duck-typed on shape `{primingPath, newContent, changed}` so today's only producer (priming-roll) and any future write-producing step that mimics the shape Just Works without a dispatch-table edit; `changed === false` is treated as no-op so idempotent re-wraps stay write-free, missing `changed` defaults to "needs write" (defensive); (2) detect "anything to commit?" via `git status --porcelain` — empty output → `{ok:true, status:'skipped', output:{reason:'no changes to commit', commitSha:null}}`, the runner threads the null SHA up and `lastWrapSha` is NOT stamped on a clean session; (3) `git add -A` + `git commit -m <session-derived message>` — the user pressing Wrap is opting into "everything in my working tree belongs to this session" (Chunk 10's UI will surface the file list so the user can cancel before this step runs); (4) `git rev-parse HEAD` captures the commit SHA, then `_stampLastWrapSha(project.path, sha)` writes `projConfig.lastWrapSha = sha` via the file-based `projectConfig.load`/`.save` round-trip. **Blocker contract: real `blocker:true`** (not always-ok). Unlike Chunks 5–8 which were always-ok with outer try/catch, this step is the load-bearing transactional gate — `git status` failure, `git add` failure, or `git commit` failure (typically a pre-commit hook rejection) → `{ok:false, status:'blocked', blockers:[...]}` and the runner halts. The git/hook output (`stderr` first, falling back to `stdout`, trimmed) is surfaced verbatim in `blockers[]` so the user sees what to fix. Per CLAUDE.md's Git Safety Protocol we do NOT pass `--no-verify` — the pre-commit hook is the user's intentional gate. **Commit message strategy: `session-content`.** Subject derived from a chunk-tag regex on the current branch (`chunk-9-…` → `Session wrap (chunk 9)`; dotted/lettered ids like `chunk-10c.2` are honored to match the priming-roll / critic-check chunk-id grammar) with a branch-name fallback (`Session wrap on feat/some-feature`) and a generic fallback for detached HEAD (`Session wrap`). Body assembled from shape-typed staged entries via `_buildBodyLines`: priming-roll's `pointer.current` → `- Priming rolled to Chunk N — Title` (with `(blocked on: …)` on a follow-up line when present), ai-content's `capturedText` → `- AI content (<step-id>): captured` or `- AI content (<step-id>): captured fields [a, b, c]` if `parsedFields` is non-empty, critic-check's `owedRationale` → `- Critic skip rationale: <text>` (or warning-without-rationale form when warning fired without a rationale), pr-check's `sessionScoped` + `resolutions` → `- Open session-scoped PRs: N` + `  - PR #<num>: merge|defer|ignore` per resolution. Subject capped at `MAX_SUBJECT_LEN = 72` chars with `…` truncation marker. Anything not matching a known shape is silently skipped — extra staging keys aren't an error. **`lastWrapSha` stamping is non-fatal on failure.** The commit already landed; the stamp is a hint for later steps' range detection. `_stampLastWrapSha` returns `boolean` and `log.warn`s on failure; the outcome surfaces on `output.stamped` so the Chunk 10 UI can render "stamp failed" inline rather than producing a runner-level blocker. Once enough wraps have stamped on a project, Chunks 4 (lint scope) and 7 (critic-check range detection) can drop their `HEAD~10..HEAD` fallback in favor of `<lastWrapSha>..HEAD` — that wiring lands in a future chunk; Chunk 9 just makes the stamp available. **Runner threading:** `lib/wrap-pipeline.js:runWrapPipeline` now reads `commitSha` from any step result whose `output.commitSha` is a non-empty string and populates the top-level `result.commitSha`. The threading is step-id-agnostic (first-wins on a populated `output.commitSha`) so a future methodology with a renamed commit step still surfaces the SHA without a runner edit. Halted pipelines and clean-session skips leave `result.commitSha = null`. **`projConfig.lastWrapSha` field:** added to `DEFAULT_PROJECT_CONFIG` (defaults `null`); round-trips through `projectConfigApi.load`/`.save` and merges with on-disk configs that pre-date this field via the existing deep-merge loop in `projectConfigApi.load`. **Bundled-template change (first chunk to touch `wrap_pipeline` schema since Chunk 2):** `data/templates/prawduct/template.json` and `data/templates/minimal/template.json` each declare `"blocker": true` on their commit step. (`data/templates/tilt/template.json` carries the same change locally but is gitignored, so it does not ride along in this PR — the matching tilt-blocker test in `test/wrap-pipeline.test.js` is gracefully skipped when the file is absent.) The back-compat shim (`lib/skills.js:wrapShapeFromTemplate`) reads only `step.id` and `step.captureFields[]`, so adding `blocker` to an existing step does NOT change the legacy `wrapV2:false` NL-prompt path's byte-equal output — the regression pin in `test/sessions.test.js` continues to pass. The choice to declare it in the template (vs. an implicit "commit kind is implicitly blocker:true" rule in the runner) keeps the runner shape-dumb and the methodology template the single source of truth. **Test-scaffolding pollution fix:** several Chunk-3-era tests ran the full pipeline against tmp-dir projects that aren't git repos — they passed in Chunks 3–8 because every step was either a stub or always-ok. Now that `commit` is a real blocker handler, those tests would hit `git status` failure and return `ok:false`. The affected tests in `test/wrap-pipeline.test.js` and `test/sessions.test.js` now monkey-patch `STEP_DISPATCH['commit']` to the canonical no-op for the duration of their assertion (with `finally`-block restore), keeping each test focused on what it actually asserts. The Chunk-9 commit-handler tests stand up real git repos via `execSync('git init ...')` in `beforeEach`.
   - **Chunk 10 (landed 2026-05-18):** Frontend multi-step wrap progress drawer + options-threading wire-up from request body → runner. New `public/wrap-drawer.js` exposes DOM-free helpers (`buildStepRow`, `summarizePipelineStatus`, `decisionWidgetForBlockedStep`, `warningWidgetForStep`, `prCheckResolutionWidget`, `collectOptionsFromAccessors`, `deriveDetail`) consumed by `public/session.js`; mirrors `public/api-helper.js`'s factory pattern so the logic is vm-sandbox testable in pure Node. **Runner-API surface:** `lib/sessions.js:triggerWrap(projectName, options)` gains an `options` second arg (V2-only — ignored on the legacy NL-prompt path so existing call sites stay back-compat); `_triggerWrapV2` passes through to `wrapPipeline.runWrapPipeline(name, options)`. The runner already accepted `options = {}` since Chunk 4; Chunk 10 closes the loop from the HTTP boundary to the runner. **`POST /api/sessions/:project/wrap` body contract:** `{password?, options?}` — `options` is object-only (arrays / strings / numbers / null all coerce to `undefined` so the runner's default-param governs). Response gains `pipelineResult` on V2 paths (still absent on V1 to preserve byte-equal back-compat). **HTTP semantics on blocked pipeline: `200 OK` with `status:'blocked'` (NOT `500`).** A blocked pipeline is an expected outcome that the drawer renders — the runner produced a structured result, the endpoint returns it. `500` is reserved for the runner throwing (no `pipelineResult` present); `404` still for missing project / session; `403` still for password-required-but-missing/wrong. **Decision-widget contract — three kinds, one collection path.** (1) `test` blocked (`blocker:true` + `allowOverride:true`) → checkbox → `options.skipTests:true` on retry. (2) `critic-check` warning (`blocker:false` step with `output.warning === true`) → textarea → `options.criticSkipRationale:<trimmed text>` on retry (whitespace-only text is dropped so the gate can't be bypassed with spaces). (3) `pr-check` unresolved session-scoped PRs (`output.sessionScoped.length > 0` and not in `output.resolutions`) → per-PR `<select>` (`merge` / `defer` / `ignore`) → `options.prHandling: {[String(prNumber)]: <enum>}` on retry (key type is `string` to match the runner's `_normalizeHandling` `String(key)` lookup; both string-shortcut and per-PR-map shapes accepted by the runner). `lint` blocked and `commit` blocked intentionally have NO recovery widget — the user fixes outside the drawer (lint errors → edit code; pre-commit hook rejection → fix the hook-rejected issue) and clicks Retry. `decisionWidgetForBlockedStep` returns `null` for these kinds; the drawer still surfaces blockers inline. **Action-button contract.** Retry visible whenever there's something actionable (blocker OR an unresolved widget). Done visible on clean ok:true AND on warning-only state (the latter lets the user accept warnings as-is without producing a second commit — see "Re-run trade-off" below). Cancel/Close always present. **Re-run trade-off (warning state).** Because `critic-check` is `blocker:false` and `pr-check` is `blocker:false`, the pipeline runs through to `commit` even on the first run when those steps emit warnings or have unresolved entries. A retry-with-options re-runs the whole pipeline and produces a SECOND commit on top of the first. The drawer's inline note in the pr-check resolution widget surfaces this so the user can choose Done (accept current state) over Retry (re-run with options + second commit). Pausing the pipeline *before* commit on warning state is structurally out of scope for Chunk 10 (would require runner-side state machine + resumption) and is tracked as a follow-up. **Password replay on retry.** The wrap endpoint enforces `deleteProtected` on every call (V1 and V2 alike); the drawer caches the password collected by the initial confirm modal and includes it on retry so delete-protected installs don't re-prompt. Cleared on drawer close. **No live progress updates (deferred).** TangleClaw has no WebSocket / SSE infrastructure today (only ttyd's terminal proxy). The pipeline runs synchronously inside the HTTP request; the drawer renders the final state, not in-progress. The build plan's "WebSocket or SSE for live status updates" is deferred to a separate chunk (file as a follow-up issue when scheduled). The drawer's `--running` status tone is reserved in CSS for the eventual live-progress phase but unreachable today. **Scope discipline:** the bundled prawduct / minimal / tilt templates are NOT modified in this chunk — adding pipeline-default steps (e.g. wiring `critic-check` into prawduct) would change the legacy `wrapV2:false` NL-prompt byte-equal pin; that wiring lands paired with the Chunk-11 default flip.
4. **Chunk 11 (split into 11a / 11b / 11c during the 11a session-start scope review, 2026-05-19):** the original Chunk 11 — "flip the default + fold in lifecycle / prawduct wiring / invoke-critic producer" — needs the pre-flip plumbing to land first so V2 is a quality experience when the flag flips. Split:
   - **Chunk 11a:** V2 session-lifecycle transition — `_triggerWrapV2` calls `store.sessions.wrap(active.id, summary)` + teardown (kill tmux, release doc locks, clear idle + wrap-pane caches) on `pipelineResult.ok && pipelineResult.commitSha`. Halted / thrown / clean-session paths leave the session active per spec. Summary string synthesized from the pipeline result. Pure addition to the V2 path; no legacy-path effects, so it's safe to ship pre-flip.
   - **Chunk 11b:** `invoke-critic` action producer — wires the prawduct `invoke-critic` action button (currently no-op) so clicking "Run Critic" writes an entry to `.tangleclaw/critic-runs.json` per Chunk 7's read contract. Without this, `critic-check` always emits `warning: true` post-flip because there's no producer for the read side.
   - **Chunk 11c (landed 2026-05-19):** Default flip. `DEFAULT_PROJECT_CONFIG.wrapV2: false → true` at `lib/store.js`; brand-new projects and projects whose on-disk `.tangleclaw/project.json` predates the field both pick up the V2 path. Existing projects with an explicit `wrapV2: false` in their on-disk config keep the legacy path — `projectConfigApi.load`'s deep-merge preserves the override. Prawduct's bundled `wrap_pipeline.steps` is extended with `open-pr-check` (kind `pr-check`) and `critic-check` (kind `critic-check`), both `blocker: false`. The shim at `lib/skills.js:wrapShapeFromTemplate` flattens every `step.id` into the legacy `steps` array, so this template change also alters the legacy NL prompt's `Wrap steps: …` join — intentional, and both byte-equal regression pins (`test/sessions.test.js` and `test/skills.test.js`) are updated to reflect the post-Chunk-11c step list. Two `wrapV2`-related tests get their semantics inverted to match the new default: the absent-flag test now asserts V2-routing instead of legacy fallback; the explicit-`wrapV2:false` test is renamed to "explicit opt-out" and pinned against the new step list. **Migration path documented:** README adds a `Session Wrap (V2 default, opt-out path)` section with the per-project `wrapV2: false` recipe; this ADR's Status line carries the Chunk-11c marker. **Post-flip behavior of the V2 path is fully covered by Chunks 11a + 11b:** session lifecycle ends correctly (11a — `store.sessions.wrap` + teardown); `critic-check` warns are clearable via the in-banner `Run Critic` button which writes the file `critic-check` reads (11b). **Legacy code stays one release:** `triggerWrap`'s legacy NL-prompt branch remains executable behind `wrapV2: false`; a follow-up release strips it along with the back-compat shim in `lib/skills.js:wrapShapeFromTemplate`.
5. **Post-#139 follow-up release:** Delete the legacy `wrap`-block branch from `wrapShapeFromTemplate`. Remove inert `wrap.steps` / `wrap.captureFields` reconciler entries from `ARRAY_RECONCILERS`. Delete the legacy NL-prompt code from `triggerWrap`. Schema migration complete.

This ADR is the durable home for the architectural pattern. The self-deleting auto-memory `project-issue-139-methodology-wrap.md` retires once #139 closes; this file persists.
