# Decompose And Commit Unstaged Changes

> Decompose unstaged working-tree changes into narrow concerns, peel them into git-stage-batch batches, rebuild as a fine-grained atomic commit series, or polish an already-built series Use when this capability is needed.

- Skill: `tomevault-io/decompose-and-commit-unstaged-changes` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add tomevault-io/decompose-and-commit-unstaged-changes`
- Raw SKILL.md: https://api.skillmd.com/api/skills/tomevault-io/decompose-and-commit-unstaged-changes/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: tomevault-io (https://skillmd.com/u/tomevault-io)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/tomevault-io/decompose-and-commit-unstaged-changes

---


# Decompose and Commit Unstaged Changes

Orchestrate the decomposition of unstaged working-tree changes into a clean
commit series through three phases, each delegated to a specialized agent.
When requested, polish an already-built commit series without re-running the
decomposition phases.

## Usage

```text
/decompose-and-commit-unstaged-changes
/decompose-and-commit-unstaged-changes deconstruct
/decompose-and-commit-unstaged-changes reconstruct
/decompose-and-commit-unstaged-changes resume
/decompose-and-commit-unstaged-changes history-polish BASE_SHA
```

Without an argument, run all three phases end to end. With `deconstruct`,
run Phases 1-2 only. With `reconstruct`, assume batches already exist and
run Phase 3 only. With `resume`, inspect the workspace-local workflow state
directory, batch refs, and the current `HEAD`, then continue from the latest
phase whose gate can still be proven. With `history-polish`, assume the final
tree is already committed and rewrite only the existing commit series between
the explicit `BASE_SHA` and `HEAD`; do not read an old checkpoint to choose the
base for a fresh history-polish run.

This skill is autonomous and non-interactive. Do not ask the user to review
intermediate steps.

If `git-stage-batch` is available directly in `PATH`, use it. If not, fall
back to `pipx run git-stage-batch`.

Before using any `git-stage-batch` command, read the installed command
documentation for the top-level command and every subcommand you intend to
use. The installed man pages/help output are the authority. Do not invent
subcommands, selectors, flags, or argument shapes from memory.

```bash
git-stage-batch --help
git-stage-batch start --help
git-stage-batch show --help
git-stage-batch status --help
git-stage-batch include --help
git-stage-batch discard --help
git-stage-batch apply --help
git-stage-batch reset --help
git-stage-batch again --help
git-stage-batch stop --help
git-stage-batch list --help
git-stage-batch drop --help
git-stage-batch block-file --help
git-stage-batch suggest-fixup --help
```

If a command or option is not shown by the installed documentation, do not use
it. If the skill text and installed help disagree, follow the installed help
and report the discrepancy.

Use the checkpoint helper for every mode. First move to the repository root,
create the workspace-local state directory, and locally block it from
`git-stage-batch` review:

```bash
REPO_ROOT=$(git --no-optional-locks rev-parse --show-toplevel)
cd "$REPO_ROOT"
export DECOMPOSE_STATE_DIR=$(python .claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py state-dir)
mkdir -p "$DECOMPOSE_STATE_DIR"
git-stage-batch block-file --local-only .git-stage-batch/
python .claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py status
```

Before a full run or a `deconstruct` run, check for stale batch state:

```bash
git-stage-batch status
git-stage-batch list
```

If a session is active or `git-stage-batch list` shows preexisting
`decompose-*` batches, stop and report the stale state before Phase 1. Do not
fold old batches into a new plan. In `reconstruct` mode, existing batches are
allowed only because they are the requested input, and Gate 2 must still pass.
In `resume` mode, existing batches are potential checkpoint state, not stale
by default. They must still pass Gate 2 before Phase 3.

Before a full run or a `deconstruct` run, also treat any existing
`decompose-plan.json` and `decompose-narrative.md` in the workflow state
directory as stale output from another attempt. Phase 1 must not read them as
input. Remove any old candidate and narrative files before analysis; the
final plan is overwritten only after the candidate passes Gate 1:

```bash
python - <<'PY'
import subprocess
from pathlib import Path
state_dir = Path(subprocess.check_output(
    ["python", ".claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py", "state-dir"],
    text=True,
).strip())
state_dir.mkdir(parents=True, exist_ok=True)
(state_dir / "decompose-plan.candidate.json").unlink(missing_ok=True)
(state_dir / "decompose-plan.json").unlink(missing_ok=True)
(state_dir / "decompose-narrative.md").unlink(missing_ok=True)
(state_dir / "decompose-refinement.md").unlink(missing_ok=True)
PY
```

For a fresh full or `deconstruct` run, record the new checkpoint immediately
after recording the base commit:

```bash
python .claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py start --mode full --base "$BASE_SHA"
```

Use `--mode deconstruct` instead of `--mode full` for a `deconstruct` run.

## Resume Mode

`resume` is conservative. It may reuse artifacts only after re-running the
gate that proves those artifacts are valid for the current tree.

Start with:

```bash
REPO_ROOT=$(git --no-optional-locks rev-parse --show-toplevel)
cd "$REPO_ROOT"
export DECOMPOSE_STATE_DIR=$(python .claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py state-dir)
mkdir -p "$DECOMPOSE_STATE_DIR"
git-stage-batch block-file --local-only .git-stage-batch/
python .claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py status --json
git --no-optional-locks status --short
git --no-optional-locks log --oneline -20
```

Then choose the resume point:

1. If `resume_target` is `gate1`, rerun Gate 1 against
   `$DECOMPOSE_STATE_DIR/decompose-plan.candidate.json`. If Gate 1 passes,
   promote the plan and continue to Phase 2. If Gate 1 fails, discard the
   candidate/narrative as stale analysis output and rerun Phase 1.
2. If `resume_target` is `phase2-after-gate1`, rerun Gate 1 against the
   current plan. If it passes, continue Phase 2. If it fails, rerun Phase 1.
3. If `resume_target` is `phase3-after-gate2`, rerun Gate 2 from refs. If it
   passes, continue Phase 3 with remaining batches. If it fails, return to
   Phase 2 and fix the batch plan before rebuilding.
4. If `resume_target` is `gate3-or-manual-audit`, rerun Gate 3 and the
   committed-snapshot verification loop before reporting success. If any
   batch refs remain, prefer `phase3-after-gate2` instead.
5. If `resume_target` is `fresh`, run the normal full workflow from Phase 1.

Do not treat a candidate plan plus narrative as sufficient progress by
itself. Candidate artifacts are resumable only through Gate 1. A failed Gate 1
means the prior analysis was not a checkpoint; it was a rejected draft.

After every successful gate or phase transition, run:

```bash
python .claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py mark --phase PHASE-NAME
```

## History-Polish Mode

`history-polish` is a targeted rewrite mode for a series that has already been
rebuilt and committed. It does not run Phase 1 analysis, Phase 2 deconstruction,
or Phase 3 batch application. Its only job is to improve the existing commit
series so it reads like a natural incremental evolution while preserving the
final tree exactly.

Use this mode when the final `HEAD` tree is correct but the series still has
broad snapshot commits, late repair/process commits, generic artifact-shaped
subjects, docs-before-code commits, implementation-only runs followed by
test-only runs, or other narrative problems. This mode is also the resumable
entry point for rerunning only the final split and repair-integration stages
after an earlier full run was interrupted.

Start from the repository root, block workflow state from batch review, and
read the installed `git-stage-batch` help before using any batch command:

```bash
REPO_ROOT=$(git --no-optional-locks rev-parse --show-toplevel)
cd "$REPO_ROOT"
export DECOMPOSE_STATE_DIR=$(python .claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py state-dir)
mkdir -p "$DECOMPOSE_STATE_DIR"
git-stage-batch block-file --local-only .git-stage-batch/
python .claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py status --json
git-stage-batch --help
git-stage-batch start --help
git-stage-batch show --help
git-stage-batch include --help
git-stage-batch stop --help
git-stage-batch status --help
git-stage-batch list --help
```

Determine the base of the series from the explicit `BASE_SHA` argument. Fresh
`history-polish` must not read an old checkpoint, `history-polish-*` file,
narrative, plan, audit, or review artifact before the fresh start step clears
the state directory. Existing state files are contaminated inputs from another
attempt, not context for the current polish run. If no base argument was
supplied, stop and ask for `BASE_SHA`; do not guess from branch names, old
refs, reflog entries, or checkpoint state.

```bash
if test -z "${BASE_SHA:-}"; then
  echo "history-polish requires BASE_SHA; rerun as history-polish BASE_SHA"
  exit 1
fi
git --no-optional-locks merge-base --is-ancestor "$BASE_SHA" HEAD
```

Require a clean tree and no stale batch session before rewriting history:

```bash
git --no-optional-locks status --short
git-stage-batch list
git-stage-batch status
```

If any command reports pending work, active state, or stale `decompose-*`
batches, stop and report the blocker. Do not start history polishing while
ordinary working-tree changes or old batch refs are present.

Start a fresh checkpoint before reading or writing any state artifacts. The
start command clears stale files in `.git-stage-batch/` for non-resume modes;
that cleanup is intentional. Only after that fresh start should this run record
the tree and series it is about to preserve:

```bash
python .claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py start --mode history-polish --base "$BASE_SHA"
git --no-optional-locks rev-parse HEAD^{tree} > "$DECOMPOSE_STATE_DIR/history-polish-pre-tree.txt"
git --no-optional-locks rev-list --count "$BASE_SHA"..HEAD > "$DECOMPOSE_STATE_DIR/history-polish-pre-count.txt"
git --no-optional-locks log --reverse --format='%H %s' "$BASE_SHA"..HEAD > "$DECOMPOSE_STATE_DIR/history-polish-pre-series.txt"
python .claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py mark --phase history-polish-running --note "starting history polish"
```

Before judging commits, generate a pressure list of commits that are presumed
split candidates. Do not rely on intuition or subject lines alone:

```bash
python - "$BASE_SHA" <<'PY' > "$DECOMPOSE_STATE_DIR/history-polish-pressure-list.txt"
import re
import subprocess
import sys

base = sys.argv[1]
log = subprocess.check_output(
    ["git", "--no-optional-locks", "log", "--reverse", "--format=%H%x00%s", f"{base}..HEAD"],
    text=True,
)
for line in log.splitlines():
    if not line:
        continue
    sha, subject = line.split("\x00", 1)
    stat = subprocess.check_output(
        ["git", "--no-optional-locks", "show", "--shortstat", "--format=", "--find-renames", sha],
        text=True,
    )
    names = subprocess.check_output(
        ["git", "--no-optional-locks", "show", "--name-only", "--format=", "--find-renames", sha],
        text=True,
    ).splitlines()
    files = insertions = deletions = 0
    m = re.search(r"(\d+) files? changed", stat)
    if m:
        files = int(m.group(1))
    m = re.search(r"(\d+) insertions?", stat)
    if m:
        insertions = int(m.group(1))
    m = re.search(r"(\d+) deletions?", stat)
    if m:
        deletions = int(m.group(1))
    reasons = []
    if insertions + deletions >= 500:
        reasons.append(f"{insertions + deletions} changed lines")
    if files >= 10:
        reasons.append(f"{files} files")
    if re.search(r"\b(Add|Register|Cover|Scaffold|Invoke|Wire|Expand)\b", subject, re.I):
        reasons.append("artifact/action-shaped subject")
    if any(re.search(r"(^docs?/|README|examples/|tests?/|workflows?|cli|build|pyproject|setup|Makefile)", p) for p in names):
        reasons.append("docs/tests/orchestration/build surface")
    if reasons:
        print(f"{sha[:12]} {subject} :: {'; '.join(reasons)}")
PY
```

Then run the polish in three passes:

1. Final evolution split audit. Inspect every commit in `BASE_SHA..HEAD` in
   order. For each commit, record `keep`, `split`, `reword`, or `integrate` in
   `$DECOMPOSE_STATE_DIR/history-polish-audit.md`, with the concrete reason and
   the smaller product state that should exist after any replacement commit.
   Use the `Split a broad committed snapshot` procedure below for every split
   candidate. Every commit in `history-polish-pressure-list.txt` starts as
   `split` until proven otherwise. A `keep` verdict for one of those commits is
   valid only when the audit lists the concrete split probes considered and the
   exact immediate breakage or narrative regression each probe would cause.
   Also reconcile the subject and body against the patch: every meaningful
   helper, result field, fixture family, REST surface, data model, CLI branch,
   docs section, and build hook must be either the named outcome of the commit,
   required support for that named outcome, or a separate outcome that needs a
   later replacement commit. A narrow subject does not make unmentioned patch
   content part of the same concern.
   Vague explanations such as "single behavior", "same module", "one module",
   "same function", "one function", "one entry point", "single CLI entry
   point", "fixture set", "tests belong together", "tests for one module",
   "tests for one function", "coherent unit", "shared helper", "large but
   related", "single pipeline", "one pipeline", "same pipeline",
   "full pipeline", "execution pipeline", "artificial subdivision",
   "no meaningful subdivision", "across its variants", or "all stages" are
   failed audit entries.

   A pipeline, function, module, command, test file, or fixture tree is not a
   concern boundary by itself. If a pressured `keep` claims that a patch is one
   pipeline, one function, or one module, the audit must name the smallest first
   runnable version of that pipeline/function/module, then list each later
   enrichment, adopter, variant, error path, docs section, fixture, and proof
   that could land after the spine. If any later item can be added while the
   earlier spine still builds and passes its narrow proof, split it. A keep is
   valid only when every proposed later item would immediately break the
   committed snapshot or make the history less coherent in a concrete,
   path-specific way.

   After any rewrite, restart the audit from the beginning because later SHAs
   and dependencies have changed.
   Use this shape for pressured keeps:

   ```text
   #### SHA subject
   - Verdict: KEEP
   - Pressure: 1530 changed lines; tests/orchestration surface
   - Smallest runnable spine: parser setup plus one executor assertion that
     proves the command dispatches a minimal request.
   - Later enrichments checked: fixture builders, second executor mode,
     error-path assertions, docs examples.
   - Split probes considered:
     1. Move parser setup before executor assertions.
        Immediate breakage: test file imports helper X that is introduced by the
        executor assertion block on the same commit; separating would require a
        new smaller helper commit, so create that helper split first or keep is
        invalid.
     2. Move fixture builders before lookaside assertions.
        Immediate breakage: no breakage; split this commit.
   - Result: SPLIT because probe 2 is independently coherent.
   ```

   A pressured `keep` with any "no breakage" probe is not a keep; split it. A
   pressured `keep` that does not name a smallest runnable spine and later
   enrichments is not a keep; continue splitting.
2. Repair/process integration. Scan the full range for commits that restore,
   repair, clean up, compensate for decomposition, or mention process
   mechanics. Use the `Integrate late repair commits` procedure below to amend
   each hunk into the earlier commit where it first belonged, then drop the
   repair/process commit. If a hunk cannot be placed confidently, fail the
   workflow instead of keeping a repair commit.
3. Subject and narrative cleanup. Reword subjects that describe artifacts
   instead of outcomes, contain multiple actions, contain `and`, `also`,
   `as well as`, or a semicolon, or hide multiple behaviors behind a generic
   summary. Reword with an edit stop so the replacement subject can be checked
   against the actual patch:

```bash
BASE_SHA=PUT_BASE_SHA_HERE
BAD_SHA=PUT_COMMIT_WITH_BAD_SUBJECT_HERE
BAD_SHORT=$(git rev-parse --short=7 "$BAD_SHA")
GIT_SEQUENCE_EDITOR="sed -i -E 's/^pick (${BAD_SHORT}[0-9a-f]*) /edit \\1 /'" git rebase -i "$BASE_SHA"
git --no-optional-locks show --stat --patch --find-renames HEAD
NEW_SUBJECT='PUT_SINGLE_OUTCOME_SUBJECT_HERE'
git commit --amend -m "$NEW_SUBJECT"
python .claude/skills/decompose-and-commit-unstaged-changes/scripts/verify-head-snapshot.py --ref HEAD -- python -m compileall -q src tests
git rebase --continue
```

After every split, integration, or reword, rerun the relevant verification
loop over the changed committed snapshots. Do not continue with a failing
intermediate commit just because the final tree will be fixed later.

Before reporting success, reject weak audit language, rerun Gate 3 in full,
and verify that the final tree did not change:

```bash
python - <<'PY'
import os
import re
import sys
from pathlib import Path

audit = Path(os.environ["DECOMPOSE_STATE_DIR"]) / "history-polish-audit.md"
text = audit.read_text(encoding="utf-8")
weak = re.compile(
    r"\b(single behavior|same module|one module|same function|one function|"
    r"one entry point|single CLI entry point|fixture set|tests belong together|"
    r"tests for one module|tests for one function|coherent unit|shared helper|"
    r"large but related|single pipeline|one pipeline|same pipeline|"
    r"full pipeline|execution pipeline|artificial subdivision|"
    r"no meaningful subdivision|across its variants|all stages)\b",
    re.I,
)
matches = [line for line in text.splitlines() if weak.search(line)]
if matches:
    print("history-polish audit has weak keep rationale; split or write concrete immediate breakage")
    print("\n".join(matches[:20]))
    sys.exit(1)
blocks = re.split(r"\n####\s+", text)
missing_spine = []
for block in blocks:
    if "Verdict: KEEP" not in block or "Pressure:" not in block:
        continue
    if not re.search(r"\b(pipeline|function|module|command|entry point|test file|fixture tree)\b", block, re.I):
        continue
    if not re.search(r"Smallest runnable", block, re.I):
        missing_spine.append(block.splitlines()[0][:160])
if missing_spine:
    print("pressured keep lacks Smallest runnable spine analysis:")
    print("\n".join(missing_spine[:20]))
    sys.exit(1)
PY
test "$(git --no-optional-locks rev-parse HEAD^{tree})" = "$(cat "$DECOMPOSE_STATE_DIR/history-polish-pre-tree.txt")"
git --no-optional-locks rev-list --count "$BASE_SHA"..HEAD > "$DECOMPOSE_STATE_DIR/history-polish-post-count.txt"
git --no-optional-locks log --reverse --format='%H %s' "$BASE_SHA"..HEAD > "$DECOMPOSE_STATE_DIR/history-polish-post-series.txt"
python .claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py mark --phase history-polish-complete --note "history polish passed"
```

The completion report for this mode must include the original commit count,
the final commit count, which commits were split, which repair/process commits
were integrated or dropped, which subjects were reworded, which pressured
commits were kept with exact breakage reasons, and the validation commands that
passed.

## Operating Contract

These rules override any conflicting behavior. They apply to all phases.

- A concern is a product, workflow, or architectural capability — not an
  artifact category.
- The primary deliverable is a believable evolving history where each commit
  reads like the next step a maintainer could have taken.
- Before choosing concern boundaries, build a simplified-project evolution
  ladder. Each ladder step names the smaller product that would exist after
  that step, the regions/tests that prove it, and the future content that
  must not appear yet. Concerns and rebuild commits must trace back to this
  ladder.
- After drafting concerns, run a concern refinement pass before Gate 1.
  Inspect every concern as if it were an incoming batch. Expand its
  `expected_commits`, `internal_slices`, `files_wholly_owned`, and shared
  regions into plausible smaller concerns. If any smaller concern would be
  coherent, promote it into the concern list before batching. Do not leave
  independently useful behaviors hidden inside `expected_commits` or
  `internal_slices`.
- Write transient decomposition artifacts under the workspace-local workflow
  state directory printed by `decompose-checkpoint.py state-dir`. The default
  is `$REPO_ROOT/.git-stage-batch/`, not `.git`, `.claude`, or `/var/tmp`.
  At the start of every run, create that directory and run
  `git-stage-batch block-file --local-only .git-stage-batch/` before writing
  state. Override with `DECOMPOSE_STATE_DIR` only when needed.
- Before writing JSON, write `decompose-narrative.md` in that workflow state
  directory.
  The narrative must describe the current committed `HEAD` in detail, the
  final working tree in detail, and how each module, command, test file, docs
  section, build file, or fixture tree that already exists at `HEAD` evolves.
  For each new file, it must describe the smallest first version and later
  growth.
- The narrative must describe changes, not only additions. For every
  existing committed code path, command, test, docs section, build file, or
  fixture surface that is touched, say how the existing thing changes at each
  relevant step and which final-tree content is still absent.
- Every intermediate `HEAD` must be coherent. Do not create a commit that
  knowingly leaves an import, parser, registry, submodule, or tested entry
  point broken for a later commit to repair.
- An adopter cannot land before the behavior it adopts. CLI handlers, parser
  entries, docs sections, examples, and coordinators must not rely on
  call-time imports, untested branches, placeholders, or future providers to
  be coherent. High-level user workflows land after the lower operations they
  invoke.
- Final module-level imports are not hard dependency constraints. Imports,
  `__all__` entries, parser registrations, dispatch tables, registry rows,
  and docs indexes must evolve with the concern that first uses each symbol or
  entry. Do not force a provider to land early merely because the final file
  imports it at top level.
- Shared infrastructure is not a keep-together reason. It is the reason to
  create an earlier scaffold concern, followed by one consumer, provider,
  workflow, command branch, or result shape at a time.
- Do not create broad foundation/core/infrastructure buckets. Independent
  no-import utilities, model records, enforcement hooks, replay paths, and
  test groups still land one first-consumer behavior at a time.
- Every concern batch must also be coherent as a replayable unit. During
  deconstruction, validate the batch ref itself before moving on; a remaining
  working tree can be repaired while the batch still contains a partial parser
  call, partial test, or other unrebuildable slice.
- Distinguish ownership from shared syntax context. Use `discard --to` for
  the current concern's owned lines. Use `include --to` to copy shared
  wrappers, neighboring entries, or aggregation context needed for the batch
  to parse; copied context does not become part of the concern.
- Commit subjects must name one action in one clause. If the subject
  contains `and`, `also`, `as well as`, a semicolon, or two independent
  actions, the commit must be split.
- A generic subject is not a loophole. If expanding the staged diff into
  concrete items reveals multiple independently useful behaviors, split the
  commit even when the subject itself is short and grammatical.
- A complete file, module, test suite, coordinator, or docs section is not a
  concern by default. If it looks like a finished subsystem copied from the
  final tree, split it into the ladder steps by which the subsystem could
  have grown.
- A pipeline, function, module, command, test file, or fixture tree is not a
  concern by default. First commit the smallest runnable spine that has a
  coherent product outcome and narrow proof. Later enrichments, variants,
  adopters, error paths, docs sections, fixtures, and tests land as subsequent
  commits unless moving them later would immediately break a committed
  snapshot in a concrete, path-specific way.
- Do not mention decomposition, batches, repairs, peeling, reconstruction,
  or process mechanics in commit messages.
- Scale is not a split criterion. Hundreds of tool calls means the work is
  large, not that the split should be broadened.
- Pragmatic shortcuts are false economy. "Good enough for now", broad
  grouping to save time, shared-region-only ownership, deferred tests, future
  repair commits, and vague summaries will fail the gates and force the work
  to be repeated with more context spent. Get the concern boundary, batch
  boundary, and commit boundary right the first time.
- `git-stage-batch apply --from BATCH` restores batch content to the
  working tree only. It does not stage anything. During rebuild, treat the
  restored content as an unstaged diff, then stage commit-sized slices with
  the same care used by `commit-unstaged-changes`.
- The staging primitive for this workflow is `git-stage-batch include`.
  Do not use Git's native interactive, partial, path, or whole-tree staging
  to assemble commit slices. Plain `git add` is reserved only for marking
  manually resolved rebase conflicts, not for ordinary commit construction.
  When the right historical snapshot is clearer as a rewritten file than as
  selected original hunks, stage that intentional snapshot with
  `git-stage-batch include --file PATH --as-stdin`; it is still a
  `git-stage-batch include` operation and must be audited as one atomic
  commit.
- Before choosing `git-stage-batch` syntax, re-check the relevant installed
  subcommand help. Never use a guessed `git-stage-batch` command, flag, or
  selector; the command must be present in the help/man page you just read.
- Do not describe `apply --from` as applying to the index. If a batch should
  be staged directly, the command family is `include --from`; do not use that
  shortcut during this workflow unless there is a deliberate reason and the
  resulting staged diff is still audited as one atomic commit.
- A fresh end-to-end run must not reuse old `decompose-*` batches or an old
  `git-stage-batch` session. Stale batches are inputs from another attempt,
  not evidence for the current decomposition.

## Git Command Concurrency

Always pass `--no-optional-locks` to read-only git commands (`status`,
`diff`, `log`, `show`, `ls-tree`). Without it, parallel git commands race
for `.git/index.lock`.

## Phase 1: Analysis

Record the base commit for later reference:

```bash
BASE_SHA=$(git --no-optional-locks log --format='%H' -1)
python .claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py start --mode full --base "$BASE_SHA"
```

For a `deconstruct` run, use `--mode deconstruct`. For a `resume` run that
reruns Phase 1, use `--mode resume` and keep the original checkpoint `base`
if one exists.

Spawn `Agent(decompose-analyzer)` with this prompt:

> Analyze the unstaged working tree and produce a structured concern plan.
> Compute `DECOMPOSE_STATE_DIR` with
> `python .claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py state-dir`.
> First write `$DECOMPOSE_STATE_DIR/decompose-narrative.md`: a prose
> evolution narrative from the current committed state to the final working
> tree. Describe the current committed state in detail, the final working tree
> in detail, the beginning/middle/end of the history, how each module,
> command, test file, docs section, build file, or fixture tree that already
> exists at HEAD evolves, including a required Existing Surface Evolution
> table for every touched committed path, and the smallest first version of
> each new file in a required New Surface Growth table. Treat final
> module-level imports, registries, parser tables, dispatch maps, and docs
> indexes as evolving surfaces, not fixed constraints from the final tree;
> describe them in a required Aggregation Evolution table.
> Then write a simplified-project evolution ladder. Derive concern boundaries
> from that narrative and ladder instead of from final file/module boundaries.
> Run a concern refinement pass before writing the final candidate: for every
> concern, review its expected commits, internal slices, whole-file claims,
> shared regions, docs, tests, fixtures, and CLI/build/parser changes as
> possible sub-concerns. Split any independently coherent behavior into a new
> concern. Write `$DECOMPOSE_STATE_DIR/decompose-refinement.md` with the
> before/after concern list and the exact immediate breakage for every
> retained keep-together decision.
> Do not read any existing `$DECOMPOSE_STATE_DIR/decompose-plan.json` as
> input. Write only `$DECOMPOSE_STATE_DIR/decompose-plan.candidate.json`;
> replace that candidate path if it already exists.
> After writing the narrative and candidate plan, run
> `python .claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py mark --phase phase1-candidate --note "candidate plan written"`.
> The current base commit is {BASE_SHA}.

### Concern Refinement Pass

Before Gate 1 promotion, the candidate must pass a refinement pass. This pass
prevents broad batches from surviving by hiding smaller behaviors inside
`expected_commits`, `internal_slices`, or whole-file ownership.

Read `$DECOMPOSE_STATE_DIR/decompose-plan.candidate.json` and
`$DECOMPOSE_STATE_DIR/decompose-narrative.md`, then verify every concern:

1. Expand each `expected_commits` entry into a candidate sub-concern. If two
   entries would each leave a coherent product state, split the concern.
2. Expand every `internal_slices` entry into a candidate sub-concern. If a
   slice has its own proving tests, user-visible result, parser branch,
   provider variant, fixture path, data record, or docs section, split it.
3. Treat `files_wholly_owned` as suspicious, not reassuring. Large source,
   test, docs, coordinator, fixture, and orchestration files must evolve
   through concerns unless generated or data-only.
4. For each shared file region, ask whether the region is owned behavior or
   only syntax context. Owned behavior becomes a concern; context stays
   `include`-only.
5. Rewrite the candidate so the concern list, peel order, rebuild order,
   evolution ladder, dependencies, and narrative milestones reflect the
   refined concerns.
6. Write `$DECOMPOSE_STATE_DIR/decompose-refinement.md` with one section per
   original concern: original purpose, proposed sub-concerns, promoted
   sub-concerns, retained keep-together decisions, and exact immediate
   breakage for each retained decision.

`internal_slices` are allowed only as a scalpel map for one retained concern
that truly cannot be made coherent as smaller concerns. They are not a
substitute for concern splitting.

### Gate 1: Validate the concern plan

After Phase 1 completes, read `$DECOMPOSE_STATE_DIR/decompose-narrative.md`
and `$DECOMPOSE_STATE_DIR/decompose-plan.candidate.json`, then run this
mechanical validator before doing any subjective review:

Run this validator exactly as written against the candidate path. Do not
substitute an older validator, a shorter validator, or validation against
`$DECOMPOSE_STATE_DIR/decompose-plan.json`.

```bash
python - <<'PY'
import json, re, subprocess, sys
from pathlib import Path
state_dir = Path(subprocess.check_output(
    ["python", ".claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py", "state-dir"],
    text=True,
).strip())
narrative_path = state_dir / "decompose-narrative.md"
if not narrative_path.exists():
    print(f"- missing {narrative_path}", file=sys.stderr)
    sys.exit(1)
narrative = narrative_path.read_text(encoding="utf-8")
refinement_path = state_dir / "decompose-refinement.md"
if not refinement_path.exists():
    print(f"- missing {refinement_path}", file=sys.stderr)
    sys.exit(1)
refinement = refinement_path.read_text(encoding="utf-8")
if len(refinement.strip()) < 200 or not re.search(r"\b(promoted sub-concerns|retained keep-together|expected_commits|internal_slices)\b", refinement, re.I):
    print(f"- {refinement_path} does not look like a real concern refinement audit", file=sys.stderr)
    sys.exit(1)
required_sections = [
    "Current Committed State",
    "Final Working Tree State",
    "Existing Surface Evolution",
    "New Surface Growth",
    "Aggregation Evolution",
    "Beginning",
    "Middle",
    "End",
    "Forbidden Shortcuts Found",
]
narrative_bad = [
    section for section in required_sections
    if not re.search(rf"^#+\s+{re.escape(section)}\s*$", narrative, re.M)
]
if narrative_bad:
    print(
        "\n".join(f"- narrative missing section: {section}" for section in narrative_bad),
        file=sys.stderr,
    )
    sys.exit(1)

def section_body(text, heading):
    match = re.search(rf"^#+\s+{re.escape(heading)}\s*$", text, re.M)
    if match is None:
        return ""
    rest = text[match.end():]
    next_heading = re.search(r"^#+\s+\S.*$", rest, re.M)
    return rest[: next_heading.start()] if next_heading else rest

existing_surface_section = section_body(narrative, "Existing Surface Evolution")
new_surface_section = section_body(narrative, "New Surface Growth")
aggregation_section = section_body(narrative, "Aggregation Evolution")
middle_section = section_body(narrative, "Middle")
plan = json.load(open(state_dir / "decompose-plan.candidate.json", encoding="utf-8"))
plan_text = json.dumps(plan, sort_keys=True)
bad = []
try:
    modified_existing_paths = subprocess.check_output(
        ["git", "--no-optional-locks", "diff", "--name-only", "--diff-filter=M", "HEAD"],
        text=True,
    ).splitlines()
    added_paths = subprocess.check_output(
        ["git", "--no-optional-locks", "diff", "--name-only", "--diff-filter=A", "HEAD"],
        text=True,
    ).splitlines()
    untracked_paths = subprocess.check_output(
        ["git", "--no-optional-locks", "ls-files", "--others", "--exclude-standard", "--directory"],
        text=True,
    ).splitlines()
    added_paths = sorted(set(added_paths + untracked_paths))
except subprocess.CalledProcessError as exc:
    bad.append(f"failed to inspect working tree paths: {exc}")
    modified_existing_paths = []
    added_paths = []

large_new_code_paths = []
for path in added_paths:
    path_obj = Path(path)
    if path_obj.exists() and path_obj.is_file() and path.endswith((".py", ".pyi")):
        try:
            line_count = len(path_obj.read_text(encoding="utf-8", errors="ignore").splitlines())
        except OSError:
            line_count = 0
        if line_count > 600:
            large_new_code_paths.append((path, line_count))

def is_new_surface_path(path):
    if path.startswith(".git/"):
        return False
    if path == ".gitmodules":
        return True
    if path.endswith("/"):
        return path.startswith(("examples/", "docs/", "src/", "tests/"))
    return path.endswith((".py", ".md", ".toml", ".json", ".yaml", ".yml"))

for path in modified_existing_paths:
    if path not in existing_surface_section:
        bad.append(f"Existing Surface Evolution missing modified HEAD path: {path}")
for path in added_paths:
    if path not in new_surface_section and is_new_surface_path(path):
        bad.append(f"New Surface Growth missing added surface: {path}")
module_catalog_headings = re.findall(
    r"^#{3,}\s+.*\.(?:py|md|toml|json|ya?ml)\b",
    middle_section,
    re.M,
)
if len(module_catalog_headings) >= 5:
    bad.append("Middle looks like a file/module catalog; make Middle historical-step prose")
for paragraph in re.split(r"\n\s*\n", middle_section):
    file_mentions = re.findall(r"\b[\w./-]+\.(?:py|md|toml|json|ya?ml)\b", paragraph)
    if len(set(file_mentions)) >= 5:
        bad.append("Middle paragraph bundles too many files; split it into behavior steps")
    variants = set(re.findall(r"\b(triage|backport|rebase|rebuild)\b", paragraph, re.I))
    if len(variants) > 1:
        bad.append("Middle paragraph bundles multiple workflow variants")
if re.search(
    r"\b(call[- ]time imports?|imported at call time|lazy imports?|placeholder|"
    r"untested branches?|future providers?|primary user workflow)\b",
    narrative + "\n" + plan_text,
    re.I,
):
    bad.append("Plan uses call-time imports, placeholders, untested branches, future providers, or primary-workflow priority as a coherence excuse")
if re.search(r"\b(foundation|core|infrastructure)\s+(layer|bucket)\b", middle_section, re.I):
    bad.append("Middle uses a broad foundation/core/infrastructure bucket")
if modified_existing_paths and "|" not in existing_surface_section:
    bad.append("Existing Surface Evolution must use a table with step, before, change, and absent columns")
if added_paths and "|" not in new_surface_section:
    bad.append("New Surface Growth must use a table with step, smallest version, first consumer/test, and absent columns")
if "|" not in aggregation_section:
    bad.append("Aggregation Evolution must use a table for imports, parser registrations, registries, dispatch maps, and docs indexes")
raw_concerns = plan.get("concerns", [])
if not isinstance(raw_concerns, list):
    bad.append("concerns must be a list")
    raw_concerns = []
raw_ladder = plan.get("evolution_ladder", [])
if not isinstance(raw_ladder, list) or not raw_ladder:
    bad.append("evolution_ladder must be a non-empty list")
    raw_ladder = []
ladder_steps = []
ladder_region_paths = set()
modified_ladder_paths = set()
for i, step in enumerate(raw_ladder, 1):
    if not isinstance(step, dict):
        bad.append(f"evolution_ladder entry {i} must be an object")
        continue
    step_no = step.get("step")
    if not isinstance(step_no, int):
        bad.append(f"evolution_ladder entry {i}: step must be an integer")
    else:
        ladder_steps.append(step_no)
    for key in ("behavior_after", "why_next_simplest"):
        if not isinstance(step.get(key), str) or not step.get(key, "").strip():
            bad.append(f"evolution_ladder entry {i}: missing {key}")
    for key in ("regions_introduced_or_evolved", "tests", "must_not_appear_yet"):
        value = step.get(key)
        if not isinstance(value, list) or not value:
            bad.append(f"evolution_ladder entry {i}: {key} must be a non-empty list")
    regions = step.get("regions_introduced_or_evolved")
    if isinstance(regions, list):
        for j, region in enumerate(regions, 1):
            if not isinstance(region, dict):
                bad.append(f"evolution_ladder entry {i}: region {j} must be an object")
                continue
            for key in ("path", "anchor", "change_kind", "before_state", "after_state", "still_absent"):
                if key == "still_absent":
                    if not isinstance(region.get(key), list):
                        bad.append(f"evolution_ladder entry {i}: region {j} still_absent must be a list")
                elif not isinstance(region.get(key), str) or not region.get(key, "").strip():
                    bad.append(f"evolution_ladder entry {i}: region {j} missing {key}")
            path = region.get("path")
            if isinstance(path, str):
                ladder_region_paths.add(path)
            change_kind = region.get("change_kind")
            if change_kind not in {"introduced", "modified-from-head", "modified-from-earlier"}:
                bad.append(f"evolution_ladder 

…(truncated)
