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
/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.
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:
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:
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:
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:
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:
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:
- 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.
- 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.
- 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.
- 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.
- 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:
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:
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.
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:
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:
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:
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:
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:
#### 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.
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.
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:
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:
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:
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:
- Expand each
expected_commits entry into a candidate sub-concern. If two
entries would each leave a coherent product state, split the concern.
- 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.
- 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.
- 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.
- Rewrite the candidate so the concern list, peel order, rebuild order,
evolution ladder, dependencies, and narrative milestones reflect the
refined concerns.
- 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.
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)
1---2name: decompose-and-commit-unstaged-changes3description: 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.4---56# Decompose and Commit Unstaged Changes78Orchestrate the decomposition of unstaged working-tree changes into a clean9commit series through three phases, each delegated to a specialized agent.10When requested, polish an already-built commit series without re-running the11decomposition phases.1213## Usage1415```text16/decompose-and-commit-unstaged-changes17/decompose-and-commit-unstaged-changes deconstruct18/decompose-and-commit-unstaged-changes reconstruct19/decompose-and-commit-unstaged-changes resume20/decompose-and-commit-unstaged-changes history-polish BASE_SHA21```2223Without an argument, run all three phases end to end. With `deconstruct`,24run Phases 1-2 only. With `reconstruct`, assume batches already exist and25run Phase 3 only. With `resume`, inspect the workspace-local workflow state26directory, batch refs, and the current `HEAD`, then continue from the latest27phase whose gate can still be proven. With `history-polish`, assume the final28tree is already committed and rewrite only the existing commit series between29the explicit `BASE_SHA` and `HEAD`; do not read an old checkpoint to choose the30base for a fresh history-polish run.3132This skill is autonomous and non-interactive. Do not ask the user to review33intermediate steps.3435If `git-stage-batch` is available directly in `PATH`, use it. If not, fall36back to `pipx run git-stage-batch`.3738Before using any `git-stage-batch` command, read the installed command39documentation for the top-level command and every subcommand you intend to40use. The installed man pages/help output are the authority. Do not invent41subcommands, selectors, flags, or argument shapes from memory.4243```bash44git-stage-batch --help45git-stage-batch start --help46git-stage-batch show --help47git-stage-batch status --help48git-stage-batch include --help49git-stage-batch discard --help50git-stage-batch apply --help51git-stage-batch reset --help52git-stage-batch again --help53git-stage-batch stop --help54git-stage-batch list --help55git-stage-batch drop --help56git-stage-batch block-file --help57git-stage-batch suggest-fixup --help58```5960If a command or option is not shown by the installed documentation, do not use61it. If the skill text and installed help disagree, follow the installed help62and report the discrepancy.6364Use the checkpoint helper for every mode. First move to the repository root,65create the workspace-local state directory, and locally block it from66`git-stage-batch` review:6768```bash69REPO_ROOT=$(git --no-optional-locks rev-parse --show-toplevel)70cd "$REPO_ROOT"71export DECOMPOSE_STATE_DIR=$(python .claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py state-dir)72mkdir -p "$DECOMPOSE_STATE_DIR"73git-stage-batch block-file --local-only .git-stage-batch/74python .claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py status75```7677Before a full run or a `deconstruct` run, check for stale batch state:7879```bash80git-stage-batch status81git-stage-batch list82```8384If a session is active or `git-stage-batch list` shows preexisting85`decompose-*` batches, stop and report the stale state before Phase 1. Do not86fold old batches into a new plan. In `reconstruct` mode, existing batches are87allowed only because they are the requested input, and Gate 2 must still pass.88In `resume` mode, existing batches are potential checkpoint state, not stale89by default. They must still pass Gate 2 before Phase 3.9091Before a full run or a `deconstruct` run, also treat any existing92`decompose-plan.json` and `decompose-narrative.md` in the workflow state93directory as stale output from another attempt. Phase 1 must not read them as94input. Remove any old candidate and narrative files before analysis; the95final plan is overwritten only after the candidate passes Gate 1:9697```bash98python - <<'PY'99import subprocess100from pathlib import Path101state_dir = Path(subprocess.check_output(102 ["python", ".claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py", "state-dir"],103 text=True,104).strip())105state_dir.mkdir(parents=True, exist_ok=True)106(state_dir / "decompose-plan.candidate.json").unlink(missing_ok=True)107(state_dir / "decompose-plan.json").unlink(missing_ok=True)108(state_dir / "decompose-narrative.md").unlink(missing_ok=True)109(state_dir / "decompose-refinement.md").unlink(missing_ok=True)110PY111```112113For a fresh full or `deconstruct` run, record the new checkpoint immediately114after recording the base commit:115116```bash117python .claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py start --mode full --base "$BASE_SHA"118```119120Use `--mode deconstruct` instead of `--mode full` for a `deconstruct` run.121122## Resume Mode123124`resume` is conservative. It may reuse artifacts only after re-running the125gate that proves those artifacts are valid for the current tree.126127Start with:128129```bash130REPO_ROOT=$(git --no-optional-locks rev-parse --show-toplevel)131cd "$REPO_ROOT"132export DECOMPOSE_STATE_DIR=$(python .claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py state-dir)133mkdir -p "$DECOMPOSE_STATE_DIR"134git-stage-batch block-file --local-only .git-stage-batch/135python .claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py status --json136git --no-optional-locks status --short137git --no-optional-locks log --oneline -20138```139140Then choose the resume point:1411421. If `resume_target` is `gate1`, rerun Gate 1 against143 `$DECOMPOSE_STATE_DIR/decompose-plan.candidate.json`. If Gate 1 passes,144 promote the plan and continue to Phase 2. If Gate 1 fails, discard the145 candidate/narrative as stale analysis output and rerun Phase 1.1462. If `resume_target` is `phase2-after-gate1`, rerun Gate 1 against the147 current plan. If it passes, continue Phase 2. If it fails, rerun Phase 1.1483. If `resume_target` is `phase3-after-gate2`, rerun Gate 2 from refs. If it149 passes, continue Phase 3 with remaining batches. If it fails, return to150 Phase 2 and fix the batch plan before rebuilding.1514. If `resume_target` is `gate3-or-manual-audit`, rerun Gate 3 and the152 committed-snapshot verification loop before reporting success. If any153 batch refs remain, prefer `phase3-after-gate2` instead.1545. If `resume_target` is `fresh`, run the normal full workflow from Phase 1.155156Do not treat a candidate plan plus narrative as sufficient progress by157itself. Candidate artifacts are resumable only through Gate 1. A failed Gate 1158means the prior analysis was not a checkpoint; it was a rejected draft.159160After every successful gate or phase transition, run:161162```bash163python .claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py mark --phase PHASE-NAME164```165166## History-Polish Mode167168`history-polish` is a targeted rewrite mode for a series that has already been169rebuilt and committed. It does not run Phase 1 analysis, Phase 2 deconstruction,170or Phase 3 batch application. Its only job is to improve the existing commit171series so it reads like a natural incremental evolution while preserving the172final tree exactly.173174Use this mode when the final `HEAD` tree is correct but the series still has175broad snapshot commits, late repair/process commits, generic artifact-shaped176subjects, docs-before-code commits, implementation-only runs followed by177test-only runs, or other narrative problems. This mode is also the resumable178entry point for rerunning only the final split and repair-integration stages179after an earlier full run was interrupted.180181Start from the repository root, block workflow state from batch review, and182read the installed `git-stage-batch` help before using any batch command:183184```bash185REPO_ROOT=$(git --no-optional-locks rev-parse --show-toplevel)186cd "$REPO_ROOT"187export DECOMPOSE_STATE_DIR=$(python .claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py state-dir)188mkdir -p "$DECOMPOSE_STATE_DIR"189git-stage-batch block-file --local-only .git-stage-batch/190python .claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py status --json191git-stage-batch --help192git-stage-batch start --help193git-stage-batch show --help194git-stage-batch include --help195git-stage-batch stop --help196git-stage-batch status --help197git-stage-batch list --help198```199200Determine the base of the series from the explicit `BASE_SHA` argument. Fresh201`history-polish` must not read an old checkpoint, `history-polish-*` file,202narrative, plan, audit, or review artifact before the fresh start step clears203the state directory. Existing state files are contaminated inputs from another204attempt, not context for the current polish run. If no base argument was205supplied, stop and ask for `BASE_SHA`; do not guess from branch names, old206refs, reflog entries, or checkpoint state.207208```bash209if test -z "${BASE_SHA:-}"; then210 echo "history-polish requires BASE_SHA; rerun as history-polish BASE_SHA"211 exit 1212fi213git --no-optional-locks merge-base --is-ancestor "$BASE_SHA" HEAD214```215216Require a clean tree and no stale batch session before rewriting history:217218```bash219git --no-optional-locks status --short220git-stage-batch list221git-stage-batch status222```223224If any command reports pending work, active state, or stale `decompose-*`225batches, stop and report the blocker. Do not start history polishing while226ordinary working-tree changes or old batch refs are present.227228Start a fresh checkpoint before reading or writing any state artifacts. The229start command clears stale files in `.git-stage-batch/` for non-resume modes;230that cleanup is intentional. Only after that fresh start should this run record231the tree and series it is about to preserve:232233```bash234python .claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py start --mode history-polish --base "$BASE_SHA"235git --no-optional-locks rev-parse HEAD^{tree} > "$DECOMPOSE_STATE_DIR/history-polish-pre-tree.txt"236git --no-optional-locks rev-list --count "$BASE_SHA"..HEAD > "$DECOMPOSE_STATE_DIR/history-polish-pre-count.txt"237git --no-optional-locks log --reverse --format='%H %s' "$BASE_SHA"..HEAD > "$DECOMPOSE_STATE_DIR/history-polish-pre-series.txt"238python .claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py mark --phase history-polish-running --note "starting history polish"239```240241Before judging commits, generate a pressure list of commits that are presumed242split candidates. Do not rely on intuition or subject lines alone:243244```bash245python - "$BASE_SHA" <<'PY' > "$DECOMPOSE_STATE_DIR/history-polish-pressure-list.txt"246import re247import subprocess248import sys249250base = sys.argv[1]251log = subprocess.check_output(252 ["git", "--no-optional-locks", "log", "--reverse", "--format=%H%x00%s", f"{base}..HEAD"],253 text=True,254)255for line in log.splitlines():256 if not line:257 continue258 sha, subject = line.split("\x00", 1)259 stat = subprocess.check_output(260 ["git", "--no-optional-locks", "show", "--shortstat", "--format=", "--find-renames", sha],261 text=True,262 )263 names = subprocess.check_output(264 ["git", "--no-optional-locks", "show", "--name-only", "--format=", "--find-renames", sha],265 text=True,266 ).splitlines()267 files = insertions = deletions = 0268 m = re.search(r"(\d+) files? changed", stat)269 if m:270 files = int(m.group(1))271 m = re.search(r"(\d+) insertions?", stat)272 if m:273 insertions = int(m.group(1))274 m = re.search(r"(\d+) deletions?", stat)275 if m:276 deletions = int(m.group(1))277 reasons = []278 if insertions + deletions >= 500:279 reasons.append(f"{insertions + deletions} changed lines")280 if files >= 10:281 reasons.append(f"{files} files")282 if re.search(r"\b(Add|Register|Cover|Scaffold|Invoke|Wire|Expand)\b", subject, re.I):283 reasons.append("artifact/action-shaped subject")284 if any(re.search(r"(^docs?/|README|examples/|tests?/|workflows?|cli|build|pyproject|setup|Makefile)", p) for p in names):285 reasons.append("docs/tests/orchestration/build surface")286 if reasons:287 print(f"{sha[:12]} {subject} :: {'; '.join(reasons)}")288PY289```290291Then run the polish in three passes:2922931. Final evolution split audit. Inspect every commit in `BASE_SHA..HEAD` in294 order. For each commit, record `keep`, `split`, `reword`, or `integrate` in295 `$DECOMPOSE_STATE_DIR/history-polish-audit.md`, with the concrete reason and296 the smaller product state that should exist after any replacement commit.297 Use the `Split a broad committed snapshot` procedure below for every split298 candidate. Every commit in `history-polish-pressure-list.txt` starts as299 `split` until proven otherwise. A `keep` verdict for one of those commits is300 valid only when the audit lists the concrete split probes considered and the301 exact immediate breakage or narrative regression each probe would cause.302 Also reconcile the subject and body against the patch: every meaningful303 helper, result field, fixture family, REST surface, data model, CLI branch,304 docs section, and build hook must be either the named outcome of the commit,305 required support for that named outcome, or a separate outcome that needs a306 later replacement commit. A narrow subject does not make unmentioned patch307 content part of the same concern.308 Vague explanations such as "single behavior", "same module", "one module",309 "same function", "one function", "one entry point", "single CLI entry310 point", "fixture set", "tests belong together", "tests for one module",311 "tests for one function", "coherent unit", "shared helper", "large but312 related", "single pipeline", "one pipeline", "same pipeline",313 "full pipeline", "execution pipeline", "artificial subdivision",314 "no meaningful subdivision", "across its variants", or "all stages" are315 failed audit entries.316317 A pipeline, function, module, command, test file, or fixture tree is not a318 concern boundary by itself. If a pressured `keep` claims that a patch is one319 pipeline, one function, or one module, the audit must name the smallest first320 runnable version of that pipeline/function/module, then list each later321 enrichment, adopter, variant, error path, docs section, fixture, and proof322 that could land after the spine. If any later item can be added while the323 earlier spine still builds and passes its narrow proof, split it. A keep is324 valid only when every proposed later item would immediately break the325 committed snapshot or make the history less coherent in a concrete,326 path-specific way.327328 After any rewrite, restart the audit from the beginning because later SHAs329 and dependencies have changed.330 Use this shape for pressured keeps:331332 ```text333 #### SHA subject334 - Verdict: KEEP335 - Pressure: 1530 changed lines; tests/orchestration surface336 - Smallest runnable spine: parser setup plus one executor assertion that337 proves the command dispatches a minimal request.338 - Later enrichments checked: fixture builders, second executor mode,339 error-path assertions, docs examples.340 - Split probes considered:341 1. Move parser setup before executor assertions.342 Immediate breakage: test file imports helper X that is introduced by the343 executor assertion block on the same commit; separating would require a344 new smaller helper commit, so create that helper split first or keep is345 invalid.346 2. Move fixture builders before lookaside assertions.347 Immediate breakage: no breakage; split this commit.348 - Result: SPLIT because probe 2 is independently coherent.349 ```350351 A pressured `keep` with any "no breakage" probe is not a keep; split it. A352 pressured `keep` that does not name a smallest runnable spine and later353 enrichments is not a keep; continue splitting.3542. Repair/process integration. Scan the full range for commits that restore,355 repair, clean up, compensate for decomposition, or mention process356 mechanics. Use the `Integrate late repair commits` procedure below to amend357 each hunk into the earlier commit where it first belonged, then drop the358 repair/process commit. If a hunk cannot be placed confidently, fail the359 workflow instead of keeping a repair commit.3603. Subject and narrative cleanup. Reword subjects that describe artifacts361 instead of outcomes, contain multiple actions, contain `and`, `also`,362 `as well as`, or a semicolon, or hide multiple behaviors behind a generic363 summary. Reword with an edit stop so the replacement subject can be checked364 against the actual patch:365366```bash367BASE_SHA=PUT_BASE_SHA_HERE368BAD_SHA=PUT_COMMIT_WITH_BAD_SUBJECT_HERE369BAD_SHORT=$(git rev-parse --short=7 "$BAD_SHA")370GIT_SEQUENCE_EDITOR="sed -i -E 's/^pick (${BAD_SHORT}[0-9a-f]*) /edit \\1 /'" git rebase -i "$BASE_SHA"371git --no-optional-locks show --stat --patch --find-renames HEAD372NEW_SUBJECT='PUT_SINGLE_OUTCOME_SUBJECT_HERE'373git commit --amend -m "$NEW_SUBJECT"374python .claude/skills/decompose-and-commit-unstaged-changes/scripts/verify-head-snapshot.py --ref HEAD -- python -m compileall -q src tests375git rebase --continue376```377378After every split, integration, or reword, rerun the relevant verification379loop over the changed committed snapshots. Do not continue with a failing380intermediate commit just because the final tree will be fixed later.381382Before reporting success, reject weak audit language, rerun Gate 3 in full,383and verify that the final tree did not change:384385```bash386python - <<'PY'387import os388import re389import sys390from pathlib import Path391392audit = Path(os.environ["DECOMPOSE_STATE_DIR"]) / "history-polish-audit.md"393text = audit.read_text(encoding="utf-8")394weak = re.compile(395 r"\b(single behavior|same module|one module|same function|one function|"396 r"one entry point|single CLI entry point|fixture set|tests belong together|"397 r"tests for one module|tests for one function|coherent unit|shared helper|"398 r"large but related|single pipeline|one pipeline|same pipeline|"399 r"full pipeline|execution pipeline|artificial subdivision|"400 r"no meaningful subdivision|across its variants|all stages)\b",401 re.I,402)403matches = [line for line in text.splitlines() if weak.search(line)]404if matches:405 print("history-polish audit has weak keep rationale; split or write concrete immediate breakage")406 print("\n".join(matches[:20]))407 sys.exit(1)408blocks = re.split(r"\n####\s+", text)409missing_spine = []410for block in blocks:411 if "Verdict: KEEP" not in block or "Pressure:" not in block:412 continue413 if not re.search(r"\b(pipeline|function|module|command|entry point|test file|fixture tree)\b", block, re.I):414 continue415 if not re.search(r"Smallest runnable", block, re.I):416 missing_spine.append(block.splitlines()[0][:160])417if missing_spine:418 print("pressured keep lacks Smallest runnable spine analysis:")419 print("\n".join(missing_spine[:20]))420 sys.exit(1)421PY422test "$(git --no-optional-locks rev-parse HEAD^{tree})" = "$(cat "$DECOMPOSE_STATE_DIR/history-polish-pre-tree.txt")"423git --no-optional-locks rev-list --count "$BASE_SHA"..HEAD > "$DECOMPOSE_STATE_DIR/history-polish-post-count.txt"424git --no-optional-locks log --reverse --format='%H %s' "$BASE_SHA"..HEAD > "$DECOMPOSE_STATE_DIR/history-polish-post-series.txt"425python .claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py mark --phase history-polish-complete --note "history polish passed"426```427428The completion report for this mode must include the original commit count,429the final commit count, which commits were split, which repair/process commits430were integrated or dropped, which subjects were reworded, which pressured431commits were kept with exact breakage reasons, and the validation commands that432passed.433434## Operating Contract435436These rules override any conflicting behavior. They apply to all phases.437438- A concern is a product, workflow, or architectural capability — not an439 artifact category.440- The primary deliverable is a believable evolving history where each commit441 reads like the next step a maintainer could have taken.442- Before choosing concern boundaries, build a simplified-project evolution443 ladder. Each ladder step names the smaller product that would exist after444 that step, the regions/tests that prove it, and the future content that445 must not appear yet. Concerns and rebuild commits must trace back to this446 ladder.447- After drafting concerns, run a concern refinement pass before Gate 1.448 Inspect every concern as if it were an incoming batch. Expand its449 `expected_commits`, `internal_slices`, `files_wholly_owned`, and shared450 regions into plausible smaller concerns. If any smaller concern would be451 coherent, promote it into the concern list before batching. Do not leave452 independently useful behaviors hidden inside `expected_commits` or453 `internal_slices`.454- Write transient decomposition artifacts under the workspace-local workflow455 state directory printed by `decompose-checkpoint.py state-dir`. The default456 is `$REPO_ROOT/.git-stage-batch/`, not `.git`, `.claude`, or `/var/tmp`.457 At the start of every run, create that directory and run458 `git-stage-batch block-file --local-only .git-stage-batch/` before writing459 state. Override with `DECOMPOSE_STATE_DIR` only when needed.460- Before writing JSON, write `decompose-narrative.md` in that workflow state461 directory.462 The narrative must describe the current committed `HEAD` in detail, the463 final working tree in detail, and how each module, command, test file, docs464 section, build file, or fixture tree that already exists at `HEAD` evolves.465 For each new file, it must describe the smallest first version and later466 growth.467- The narrative must describe changes, not only additions. For every468 existing committed code path, command, test, docs section, build file, or469 fixture surface that is touched, say how the existing thing changes at each470 relevant step and which final-tree content is still absent.471- Every intermediate `HEAD` must be coherent. Do not create a commit that472 knowingly leaves an import, parser, registry, submodule, or tested entry473 point broken for a later commit to repair.474- An adopter cannot land before the behavior it adopts. CLI handlers, parser475 entries, docs sections, examples, and coordinators must not rely on476 call-time imports, untested branches, placeholders, or future providers to477 be coherent. High-level user workflows land after the lower operations they478 invoke.479- Final module-level imports are not hard dependency constraints. Imports,480 `__all__` entries, parser registrations, dispatch tables, registry rows,481 and docs indexes must evolve with the concern that first uses each symbol or482 entry. Do not force a provider to land early merely because the final file483 imports it at top level.484- Shared infrastructure is not a keep-together reason. It is the reason to485 create an earlier scaffold concern, followed by one consumer, provider,486 workflow, command branch, or result shape at a time.487- Do not create broad foundation/core/infrastructure buckets. Independent488 no-import utilities, model records, enforcement hooks, replay paths, and489 test groups still land one first-consumer behavior at a time.490- Every concern batch must also be coherent as a replayable unit. During491 deconstruction, validate the batch ref itself before moving on; a remaining492 working tree can be repaired while the batch still contains a partial parser493 call, partial test, or other unrebuildable slice.494- Distinguish ownership from shared syntax context. Use `discard --to` for495 the current concern's owned lines. Use `include --to` to copy shared496 wrappers, neighboring entries, or aggregation context needed for the batch497 to parse; copied context does not become part of the concern.498- Commit subjects must name one action in one clause. If the subject499 contains `and`, `also`, `as well as`, a semicolon, or two independent500 actions, the commit must be split.501- A generic subject is not a loophole. If expanding the staged diff into502 concrete items reveals multiple independently useful behaviors, split the503 commit even when the subject itself is short and grammatical.504- A complete file, module, test suite, coordinator, or docs section is not a505 concern by default. If it looks like a finished subsystem copied from the506 final tree, split it into the ladder steps by which the subsystem could507 have grown.508- A pipeline, function, module, command, test file, or fixture tree is not a509 concern by default. First commit the smallest runnable spine that has a510 coherent product outcome and narrow proof. Later enrichments, variants,511 adopters, error paths, docs sections, fixtures, and tests land as subsequent512 commits unless moving them later would immediately break a committed513 snapshot in a concrete, path-specific way.514- Do not mention decomposition, batches, repairs, peeling, reconstruction,515 or process mechanics in commit messages.516- Scale is not a split criterion. Hundreds of tool calls means the work is517 large, not that the split should be broadened.518- Pragmatic shortcuts are false economy. "Good enough for now", broad519 grouping to save time, shared-region-only ownership, deferred tests, future520 repair commits, and vague summaries will fail the gates and force the work521 to be repeated with more context spent. Get the concern boundary, batch522 boundary, and commit boundary right the first time.523- `git-stage-batch apply --from BATCH` restores batch content to the524 working tree only. It does not stage anything. During rebuild, treat the525 restored content as an unstaged diff, then stage commit-sized slices with526 the same care used by `commit-unstaged-changes`.527- The staging primitive for this workflow is `git-stage-batch include`.528 Do not use Git's native interactive, partial, path, or whole-tree staging529 to assemble commit slices. Plain `git add` is reserved only for marking530 manually resolved rebase conflicts, not for ordinary commit construction.531 When the right historical snapshot is clearer as a rewritten file than as532 selected original hunks, stage that intentional snapshot with533 `git-stage-batch include --file PATH --as-stdin`; it is still a534 `git-stage-batch include` operation and must be audited as one atomic535 commit.536- Before choosing `git-stage-batch` syntax, re-check the relevant installed537 subcommand help. Never use a guessed `git-stage-batch` command, flag, or538 selector; the command must be present in the help/man page you just read.539- Do not describe `apply --from` as applying to the index. If a batch should540 be staged directly, the command family is `include --from`; do not use that541 shortcut during this workflow unless there is a deliberate reason and the542 resulting staged diff is still audited as one atomic commit.543- A fresh end-to-end run must not reuse old `decompose-*` batches or an old544 `git-stage-batch` session. Stale batches are inputs from another attempt,545 not evidence for the current decomposition.546547## Git Command Concurrency548549Always pass `--no-optional-locks` to read-only git commands (`status`,550`diff`, `log`, `show`, `ls-tree`). Without it, parallel git commands race551for `.git/index.lock`.552553## Phase 1: Analysis554555Record the base commit for later reference:556557```bash558BASE_SHA=$(git --no-optional-locks log --format='%H' -1)559python .claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py start --mode full --base "$BASE_SHA"560```561562For a `deconstruct` run, use `--mode deconstruct`. For a `resume` run that563reruns Phase 1, use `--mode resume` and keep the original checkpoint `base`564if one exists.565566Spawn `Agent(decompose-analyzer)` with this prompt:567568> Analyze the unstaged working tree and produce a structured concern plan.569> Compute `DECOMPOSE_STATE_DIR` with570> `python .claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py state-dir`.571> First write `$DECOMPOSE_STATE_DIR/decompose-narrative.md`: a prose572> evolution narrative from the current committed state to the final working573> tree. Describe the current committed state in detail, the final working tree574> in detail, the beginning/middle/end of the history, how each module,575> command, test file, docs section, build file, or fixture tree that already576> exists at HEAD evolves, including a required Existing Surface Evolution577> table for every touched committed path, and the smallest first version of578> each new file in a required New Surface Growth table. Treat final579> module-level imports, registries, parser tables, dispatch maps, and docs580> indexes as evolving surfaces, not fixed constraints from the final tree;581> describe them in a required Aggregation Evolution table.582> Then write a simplified-project evolution ladder. Derive concern boundaries583> from that narrative and ladder instead of from final file/module boundaries.584> Run a concern refinement pass before writing the final candidate: for every585> concern, review its expected commits, internal slices, whole-file claims,586> shared regions, docs, tests, fixtures, and CLI/build/parser changes as587> possible sub-concerns. Split any independently coherent behavior into a new588> concern. Write `$DECOMPOSE_STATE_DIR/decompose-refinement.md` with the589> before/after concern list and the exact immediate breakage for every590> retained keep-together decision.591> Do not read any existing `$DECOMPOSE_STATE_DIR/decompose-plan.json` as592> input. Write only `$DECOMPOSE_STATE_DIR/decompose-plan.candidate.json`;593> replace that candidate path if it already exists.594> After writing the narrative and candidate plan, run595> `python .claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py mark --phase phase1-candidate --note "candidate plan written"`.596> The current base commit is {BASE_SHA}.597598### Concern Refinement Pass599600Before Gate 1 promotion, the candidate must pass a refinement pass. This pass601prevents broad batches from surviving by hiding smaller behaviors inside602`expected_commits`, `internal_slices`, or whole-file ownership.603604Read `$DECOMPOSE_STATE_DIR/decompose-plan.candidate.json` and605`$DECOMPOSE_STATE_DIR/decompose-narrative.md`, then verify every concern:6066071. Expand each `expected_commits` entry into a candidate sub-concern. If two608 entries would each leave a coherent product state, split the concern.6092. Expand every `internal_slices` entry into a candidate sub-concern. If a610 slice has its own proving tests, user-visible result, parser branch,611 provider variant, fixture path, data record, or docs section, split it.6123. Treat `files_wholly_owned` as suspicious, not reassuring. Large source,613 test, docs, coordinator, fixture, and orchestration files must evolve614 through concerns unless generated or data-only.6154. For each shared file region, ask whether the region is owned behavior or616 only syntax context. Owned behavior becomes a concern; context stays617 `include`-only.6185. Rewrite the candidate so the concern list, peel order, rebuild order,619 evolution ladder, dependencies, and narrative milestones reflect the620 refined concerns.6216. Write `$DECOMPOSE_STATE_DIR/decompose-refinement.md` with one section per622 original concern: original purpose, proposed sub-concerns, promoted623 sub-concerns, retained keep-together decisions, and exact immediate624 breakage for each retained decision.625626`internal_slices` are allowed only as a scalpel map for one retained concern627that truly cannot be made coherent as smaller concerns. They are not a628substitute for concern splitting.629630### Gate 1: Validate the concern plan631632After Phase 1 completes, read `$DECOMPOSE_STATE_DIR/decompose-narrative.md`633and `$DECOMPOSE_STATE_DIR/decompose-plan.candidate.json`, then run this634mechanical validator before doing any subjective review:635636Run this validator exactly as written against the candidate path. Do not637substitute an older validator, a shorter validator, or validation against638`$DECOMPOSE_STATE_DIR/decompose-plan.json`.639640```bash641python - <<'PY'642import json, re, subprocess, sys643from pathlib import Path644state_dir = Path(subprocess.check_output(645 ["python", ".claude/skills/decompose-and-commit-unstaged-changes/scripts/decompose-checkpoint.py", "state-dir"],646 text=True,647).strip())648narrative_path = state_dir / "decompose-narrative.md"649if not narrative_path.exists():650 print(f"- missing {narrative_path}", file=sys.stderr)651 sys.exit(1)652narrative = narrative_path.read_text(encoding="utf-8")653refinement_path = state_dir / "decompose-refinement.md"654if not refinement_path.exists():655 print(f"- missing {refinement_path}", file=sys.stderr)656 sys.exit(1)657refinement = refinement_path.read_text(encoding="utf-8")658if len(refinement.strip()) < 200 or not re.search(r"\b(promoted sub-concerns|retained keep-together|expected_commits|internal_slices)\b", refinement, re.I):659 print(f"- {refinement_path} does not look like a real concern refinement audit", file=sys.stderr)660 sys.exit(1)661required_sections = [662 "Current Committed State",663 "Final Working Tree State",664 "Existing Surface Evolution",665 "New Surface Growth",666 "Aggregation Evolution",667 "Beginning",668 "Middle",669 "End",670 "Forbidden Shortcuts Found",671]672narrative_bad = [673 section for section in required_sections674 if not re.search(rf"^#+\s+{re.escape(section)}\s*$", narrative, re.M)675]676if narrative_bad:677 print(678 "\n".join(f"- narrative missing section: {section}" for section in narrative_bad),679 file=sys.stderr,680 )681 sys.exit(1)682683def section_body(text, heading):684 match = re.search(rf"^#+\s+{re.escape(heading)}\s*$", text, re.M)685 if match is None:686 return ""687 rest = text[match.end():]688 next_heading = re.search(r"^#+\s+\S.*$", rest, re.M)689 return rest[: next_heading.start()] if next_heading else rest690691existing_surface_section = section_body(narrative, "Existing Surface Evolution")692new_surface_section = section_body(narrative, "New Surface Growth")693aggregation_section = section_body(narrative, "Aggregation Evolution")694middle_section = section_body(narrative, "Middle")695plan = json.load(open(state_dir / "decompose-plan.candidate.json", encoding="utf-8"))696plan_text = json.dumps(plan, sort_keys=True)697bad = []698try:699 modified_existing_paths = subprocess.check_output(700 ["git", "--no-optional-locks", "diff", "--name-only", "--diff-filter=M", "HEAD"],701 text=True,702 ).splitlines()703 added_paths = subprocess.check_output(704 ["git", "--no-optional-locks", "diff", "--name-only", "--diff-filter=A", "HEAD"],705 text=True,706 ).splitlines()707 untracked_paths = subprocess.check_output(708 ["git", "--no-optional-locks", "ls-files", "--others", "--exclude-standard", "--directory"],709 text=True,710 ).splitlines()711 added_paths = sorted(set(added_paths + untracked_paths))712except subprocess.CalledProcessError as exc:713 bad.append(f"failed to inspect working tree paths: {exc}")714 modified_existing_paths = []715 added_paths = []716717large_new_code_paths = []718for path in added_paths:719 path_obj = Path(path)720 if path_obj.exists() and path_obj.is_file() and path.endswith((".py", ".pyi")):721 try:722 line_count = len(path_obj.read_text(encoding="utf-8", errors="ignore").splitlines())723 except OSError:724 line_count = 0725 if line_count > 600:726 large_new_code_paths.append((path, line_count))727728def is_new_surface_path(path):729 if path.startswith(".git/"):730 return False731 if path == ".gitmodules":732 return True733 if path.endswith("/"):734 return path.startswith(("examples/", "docs/", "src/", "tests/"))735 return path.endswith((".py", ".md", ".toml", ".json", ".yaml", ".yml"))736737for path in modified_existing_paths:738 if path not in existing_surface_section:739 bad.append(f"Existing Surface Evolution missing modified HEAD path: {path}")740for path in added_paths:741 if path not in new_surface_section and is_new_surface_path(path):742 bad.append(f"New Surface Growth missing added surface: {path}")743module_catalog_headings = re.findall(744 r"^#{3,}\s+.*\.(?:py|md|toml|json|ya?ml)\b",745 middle_section,746 re.M,747)748if len(module_catalog_headings) >= 5:749 bad.append("Middle looks like a file/module catalog; make Middle historical-step prose")750for paragraph in re.split(r"\n\s*\n", middle_section):751 file_mentions = re.findall(r"\b[\w./-]+\.(?:py|md|toml|json|ya?ml)\b", paragraph)752 if len(set(file_mentions)) >= 5:753 bad.append("Middle paragraph bundles too many files; split it into behavior steps")754 variants = set(re.findall(r"\b(triage|backport|rebase|rebuild)\b", paragraph, re.I))755 if len(variants) > 1:756 bad.append("Middle paragraph bundles multiple workflow variants")757if re.search(758 r"\b(call[- ]time imports?|imported at call time|lazy imports?|placeholder|"759 r"untested branches?|future providers?|primary user workflow)\b",760 narrative + "\n" + plan_text,761 re.I,762):763 bad.append("Plan uses call-time imports, placeholders, untested branches, future providers, or primary-workflow priority as a coherence excuse")764if re.search(r"\b(foundation|core|infrastructure)\s+(layer|bucket)\b", middle_section, re.I):765 bad.append("Middle uses a broad foundation/core/infrastructure bucket")766if modified_existing_paths and "|" not in existing_surface_section:767 bad.append("Existing Surface Evolution must use a table with step, before, change, and absent columns")768if added_paths and "|" not in new_surface_section:769 bad.append("New Surface Growth must use a table with step, smallest version, first consumer/test, and absent columns")770if "|" not in aggregation_section:771 bad.append("Aggregation Evolution must use a table for imports, parser registrations, registries, dispatch maps, and docs indexes")772raw_concerns = plan.get("concerns", [])773if not isinstance(raw_concerns, list):774 bad.append("concerns must be a list")775 raw_concerns = []776raw_ladder = plan.get("evolution_ladder", [])777if not isinstance(raw_ladder, list) or not raw_ladder:778 bad.append("evolution_ladder must be a non-empty list")779 raw_ladder = []780ladder_steps = []781ladder_region_paths = set()782modified_ladder_paths = set()783for i, step in enumerate(raw_ladder, 1):784 if not isinstance(step, dict):785 bad.append(f"evolution_ladder entry {i} must be an object")786 continue787 step_no = step.get("step")788 if not isinstance(step_no, int):789 bad.append(f"evolution_ladder entry {i}: step must be an integer")790 else:791 ladder_steps.append(step_no)792 for key in ("behavior_after", "why_next_simplest"):793 if not isinstance(step.get(key), str) or not step.get(key, "").strip():794 bad.append(f"evolution_ladder entry {i}: missing {key}")795 for key in ("regions_introduced_or_evolved", "tests", "must_not_appear_yet"):796 value = step.get(key)797 if not isinstance(value, list) or not value:798 bad.append(f"evolution_ladder entry {i}: {key} must be a non-empty list")799 regions = step.get("regions_introduced_or_evolved")800 if isinstance(regions, list):801 for j, region in enumerate(regions, 1):802 if not isinstance(region, dict):803 bad.append(f"evolution_ladder entry {i}: region {j} must be an object")804 continue805 for key in ("path", "anchor", "change_kind", "before_state", "after_state", "still_absent"):806 if key == "still_absent":807 if not isinstance(region.get(key), list):808 bad.append(f"evolution_ladder entry {i}: region {j} still_absent must be a list")809 elif not isinstance(region.get(key), str) or not region.get(key, "").strip():810 bad.append(f"evolution_ladder entry {i}: region {j} missing {key}")811 path = region.get("path")812 if isinstance(path, str):813 ladder_region_paths.add(path)814 change_kind = region.get("change_kind")815 if change_kind not in {"introduced", "modified-from-head", "modified-from-earlier"}:816 bad.append(f"evolution_ladder 817818…(truncated)