Status: REFERENCE (complete). The 3 implementation phases landed in order: Phase 2 → Phase 3a → Phase 3b. Historical plan:
Sequencing: 3 implementation PRs (Phase 2 → Phase 3a → Phase 3b), each its own Task Brief. Phase 2 lands the minimal CLI surface change and removes
--allow-dirty; Phase 3a symmetrises baseline-side sourcing and adds staged-index; Phase 3b deletes thepre-commitsubcommand and migrates its.pre-commit-hooks.yamlentry to the unifiedchecksurface.Completion record: Phase 2 landed in PR #100, Phase 3a in PR #101, and Phase 3b in PR #102. This document is retained only as a historical record.
Trigger: PR #98 (
fix(check): preserve explicit --candidate-rev under --allow-dirty (#97)) landed a Phase 1 mitigation but exposed a design hole —--allow-dirtyconflates two orthogonal concerns ("permission to run on a dirty tree" vs "select working tree as candidate source"). Closing this hole is the entire scope of this planning doc.
semantic-ci check selects its candidate snapshot through a single
boolean flag (--allow-dirty) that silently couples three behaviors:
- Permission to proceed despite an uncommitted working tree.
- Source selection = "the working tree IS the candidate".
- Cache suppression for the candidate side (no commit SHA).
PR #98 split (3) cleanly by deriving
candidate_uses_working_tree = allow_dirty AND candidate_rev is None
and ignoring --allow-dirty for source-selection purposes when an
explicit --candidate-rev is given. That is a viable Phase 1, but it
leaves the surface confusing:
- the flag's name still implies (1) but its behavior also encodes (2)
- there is no symmetric way to say "evaluate the working tree as the baseline" — the only baseline source today is "a git ref"
- the
pre-commitsubcommand uses a third candidate source (git checkout-indexstaged snapshot) that is exposed as a separate subcommand rather than as a uniform source-selection axis - JSON envelope does not record which source was actually used, so downstream consumers cannot distinguish a HEAD-vs-HEAD pass from a working-tree-vs-HEAD pass
This brief replaces the implicit flag with an explicit source-selection
axis on both sides (--candidate-source, --baseline-source), folds
the staged snapshot into the same axis, and removes the redundant
pre-commit subcommand. The engine contract (§23.1 input neutrality)
is unchanged — only CLI-layer sourcing semantics move.
In scope (all 3 phases):
- CLI surface:
--candidate-source {commit, working-tree, staged-index}--baseline-sourcewith the same enum, oncheck
- removal of
--allow-dirty - staged-index resolver shared between phases (replacing the
pre-commitsubcommand's internalgit checkout-indexpath) - JSON envelope provenance:
engine.{baseline,candidate}.{source, rev} - conflict / degenerate handling rules (usage error vs warning, see §6)
- removal of the
pre-commitsubcommand and migration of.pre-commit-hooks.yamlto the unifiedcheckform - docs refresh:
cli_usage.md,code_semantic_ci_design.md §23.1 / §23.2, README hook example, migration note
Out of scope:
- engine signature changes —
engine.check_pair(baseline_path, candidate_path)stays a path-snapshot comparator compare/observe/compile-repair/validate-plan/target-doctor/target-catalogCLI surfaces- any change to cache key, extractor, or evaluator internals beyond what naturally falls out of the candidate/baseline path being a different temp directory
- deprecation / alias period for
--allow-dirty— hard removal in Phase 2 (see §4)
The 7 design questions surfaced in the design session are resolved as follows. All sub-decisions are now binding and Task Briefs for Phase 2 / 3a / 3b MUST cite this section by name.
| # | Question | Decision |
|---|---|---|
| 1 | Drop --allow-dirty immediately vs deprecate? |
Hard delete in Phase 2. No alias, no deprecation warning, no compatibility shim. |
| 2 | Phase 2.5 (--baseline-source alone)? |
Dropped. Folded into Phase 3a together with staged-index. |
| 3 | Input conflict (source=working-tree + rev=SHA) |
Usage error, exit 2. Same shape for all 4 conflict permutations once staged-index lands. |
| 4 | Degenerate cases (baseline = candidate = same volatile source) | Warning + proceed. Verdict will be the trivial no-drift result; we do not refuse. |
| 5 | JSON provenance landing phase | Phase 2. Envelope gains engine.baseline.{source,rev} and engine.candidate.{source,rev}; schema_version minor bump. |
| 6 | pre-commit subcommand removal style |
Hard delete in Phase 3b. .pre-commit-hooks.yaml rewritten to invoke check --candidate-source=staged-index. No alias subcommand. |
| 7 | PR splitting | 3 PRs: Phase 2, Phase 3a, Phase 3b — each lands independently. No combined PR. |
Style summary: aggressive / clean-cut. No backwards-compatibility
hacks, no rename-with-underscore, no // removed comments, no
deprecation period.
- Add
--candidate-source {commit, working-tree}tocheck, default =commit. - Remove
--allow-dirtyentirely (no alias). - Conflict rule:
--candidate-source=working-treetogether with an explicit--candidate-rev <SHA>→ usage error, exit 2 with the message formerror: --candidate-source=working-tree is incompatible with --candidate-rev.
When the candidate source is working-tree AND the host clone has
uncommitted changes that the user might have intended to NOT include
(i.e. the user passed neither --candidate-source=working-tree nor
--candidate-source=commit, only the default path), we no longer have
a warning to print — the default is commit and HEAD is what gets
evaluated. The pre-Phase 2 warning (warning: working tree is dirty; running against HEAD instead) is removed.
In its place, when --candidate-source=working-tree is selected
explicitly and the working tree is clean, emit a one-line stderr
informational note (default-off behind --verbose, identical shape to
existing cache-hit notes):
note: candidate source = working tree (no uncommitted changes detected; equivalent to HEAD).
This is purely diagnostic and never affects the verdict.
Add to the existing envelope:
"engine": {
"baseline": { "source": "commit", "rev": "<sha>" },
"candidate": { "source": "working-tree", "rev": null }
}source∈{commit, working-tree}(Phase 2 enum), expanded to includestaged-indexin Phase 3a.revis the resolved commit SHA whensource == commit;nullotherwise.- Bump
schema_versionminor (e.g."4"→"4.1"or"5"per current convention; final value pinned in the Phase 2 Task Brief).
The engine signature
engine.check_pair(baseline_path: Path, candidate_path: Path, intent)
is unchanged. The CLI is solely responsible for resolving a
source into a path snapshot. Update code_semantic_ci_design.md §23.1 text to make this explicit:
CLI-layer source resolution (
--candidate-source/--baseline-source) is the only adapter between user intent and the engine's path-snapshot contract. The engine MUST NOT learn about "working tree" or "staged index" as input categories; it only sees two materialized directories.
- Delete all
--allow-dirtytest cases. The 4 architecture invariants added in PR #98 (tests/architecture/test_check_provenance.py) are rewritten in terms of--candidate-source(the inv-1 / inv-2 shapes stay; only the CLI invocation changes). - New conflict test:
check --candidate-source=working-tree --candidate-rev <SHA>exits 2 with the expected message. - New provenance test: JSON envelope contains
engine.{baseline,candidate}.{source,rev}with correct values for 3 invocations: default (commit/commit), explicit working-tree candidate, explicit ref-backed candidate.
~120 LOC implementation + ~30 LOC envelope serialization + ~40 lines docs + ~5 new/rewritten tests. Single PR. Expected round count: 0–1 under the post-experience-externalization envelope (AGENTS.md §5.5).
- Extend
--candidate-sourceenum: addstaged-index. - Add
--baseline-source {commit, working-tree, staged-index}with default =commit. - Default rev resolution:
--baseline-source=commitunset rev → existing default (origin/main→main→master)--candidate-source=commitunset rev →HEAD--candidate-source=staged-indexunset baseline rev → HEAD commit (matches oldpre-commitsemantics; pinned here so Phase 3b removal does not silently flip the default)--candidate-source=working-treeunset baseline rev → unchanged from Phase 2 (i.e. existing baseline default)
- Implementation:
git checkout-index --prefix=<tempdir>/into a temp directory, materialize the staged tree, return a snapshot path. - Lives next to the existing working-tree resolver in the CLI layer.
- Cache suppression: any source ∈ {working-tree, staged-index} skips cache writes for that side (already true for working-tree from PR #98; staged-index extends the same predicate).
All 4 combinations below are usage error, exit 2:
| Side | Source | Rev | Outcome |
|---|---|---|---|
| candidate | working-tree |
explicit SHA | error |
| candidate | staged-index |
explicit SHA | error |
| baseline | working-tree |
explicit SHA | error |
| baseline | staged-index |
explicit SHA | error |
Error message form: error: --{candidate,baseline}-source=<value> is incompatible with --{candidate,baseline}-rev.
When both sides resolve to the same volatile source — i.e.
baseline-source == candidate-source AND both ∈ {working-tree,
staged-index} — emit a stderr warning and run the verdict (which will
trivially be no-drift):
warning: baseline and candidate resolve to the same <source> snapshot; verdict will report no drift by construction.
We deliberately do not refuse the run — there are legitimate uses (smoke-testing the pipeline, reproducing a known-empty diff for contract tests).
- Add 6×6 (or relevant subset) source × source matrix to
docs/cli_usage.md. Document each cell with the canonical use case and the typical default for the unspecified rev. - Refresh
code_semantic_ci_design.md §23.2 Application Matrixso the listed application surfaces (PR review, pre-merge gate, pre-commit, pre-generation simulation, etc.) map onto the (baseline-source,candidate-source) tuple.
- Staged-index resolver unit tests (snapshot equals
git checkout-indexoutput; cleanup on exit). - Source × source matrix: representative cells covering the canonical PR-review (commit, commit), pre-commit-style (commit, staged-index), simulation (working-tree, working-tree) cases.
- All 4 conflict rules: parametrized exit-2 test.
- Degenerate-case warning fires on each of the 2 same-source permutations.
- JSON envelope
sourcefield admitsstaged-index.
~150 LOC (resolver + arg parsing + serialization extension) + ~60 lines docs + ~10 new tests. Single PR.
- Delete
src/semantic_ci_code/cli/commands/pre_commit.py. - Remove
pre-commitfrom the argparse subparser dispatcher incli/main.py. - Update
.pre-commit-hooks.yamlso the published hook entry invokessemantic-ci check --candidate-source=staged-indexinstead ofsemantic-ci pre-commit.
-
Remove all
pre-commitsubcommand references fromREADME.md,docs/cli_usage.md,docs/exit_codes.md,docs/json_schema.md,docs/code_semantic_ci_design.md. -
Add a short migration block (single section) in
docs/cli_usage.mdMigration appendix:Migrated in Phase 3b:
semantic-ci pre-commit [...]→semantic-ci check --candidate-source=staged-index [...]. The evaluation semantics (HEAD commit baseline vs staged index candidate) are identical.
- Delete
tests/cli/test_pre_commit.py(subcommand no longer exists). - Add a regression test asserting
semantic-ci pre-commitexits as argparseSystemExitwith an unknown-subcommand message (i.e. the removal is observable from the CLI). - Add a
.pre-commit-hooks.yamlsmoke test: install the hook into a temp git repo and assert it invokes through tocheck --candidate-source=staged-indexwith exit 0 / 1 / 2 routing preserved.
~50 LOC (mostly deletions) + ~30 lines docs (mostly deletions) + ~2 tests. Single PR.
For traceability — these were considered in the design session and explicitly rejected. They MUST NOT be reintroduced in any of the 3 Task Briefs without re-opening this planning doc.
| Rejected option | Reason |
|---|---|
Phase 2.5 (--baseline-source alone, no staged-index) |
Splits a single coherent symmetry change across 2 PRs without payoff; folded into Phase 3a. |
--allow-dirty alias for --candidate-source=working-tree |
Backwards-compatibility shim of the kind CLAUDE.md "aggressive / clean-cut" policy rejects. PR #98 is recent enough that no external callers are pinned to the flag. |
--candidate-source=auto (infer from working-tree state) |
Replaces one implicit conflation with another. Users SHOULD spell out their source. |
| Refusing the same-source degenerate case (exit 2) | Legitimate use cases exist (smoke-test, contract test of the empty diff). Warning + proceed is the right tradeoff. |
Engine-level source enum (engine learns about working-tree) |
Violates §23.1 input neutrality. Engine stays a 2-path comparator. |
Phase 2 → lands `--candidate-source` + removes `--allow-dirty` + JSON provenance
↓ (stop condition: PR merged, `--allow-dirty` no longer in any test)
Phase 3a → adds `--baseline-source` + `staged-index`
↓ (stop condition: source × source matrix tests pass; pre-commit subcommand
still present and functional)
Phase 3b → deletes `pre-commit` subcommand + rewrites `.pre-commit-hooks.yaml`
(stop condition: `pre-commit` removed, hook entry uses `check
--candidate-source=staged-index`, full pytest green)
Each Phase is independently shippable. Phase 3a is not allowed to
start until Phase 2 is on main; Phase 3b is not allowed to start until
Phase 3a is on main (the staged-index source must exist on check
before the pre-commit subcommand is deleted, or the published hook
will break for any user who tracks main during the gap).
Required reading before drafting the Phase 2 Task Brief:
- This file, §3 (lock-in table) and §4 (Phase 2 scope) in full.
docs/code_semantic_ci_design.md §23.1(input neutrality) — confirm the engine signature change is zero.- PR #98 (
fix(check): preserve explicit --candidate-rev under --allow-dirty) — the Phase 1 mitigation that Phase 2 replaces. In particular, the 4 architecture invariants intests/architecture/test_check_provenance.pymove to the new CLI surface 1:1; do not delete and re-derive them. docs/cli_usage.md— current--allow-dirtysection, deleted in Phase 2.docs/json_schema.md— envelopeschema_versionpolicy, for the minor bump.AGENTS.md §5.6Maintenance Practice 7 rules and§5.7Anti-Patterns 7 items.docs/brief_8_planning.md §15brief-drafting checklist.
- schema_version — resolved by the landed Phase 2 envelope update and kept
synchronized with
docs/json_schema.md. - Working-tree clean note — resolved by the landed CLI implementation.
compare/observesource flags — re-evaluated after Phase 3b and intentionally unchanged.comparealready accepts arbitrary path snapshots;observeremains a single-state surface.pre-commit-hooks.yamlsmoke test — resolved by the landed Phase 3b discipline and CLI tests.
Phase 3b PR (the last of the 3) MUST also verify:
- No occurrence of
--allow-dirtyinsrc/,tests/, ordocs/(grep clean). - No occurrence of the
pre-commitsubcommand insrc/(CLI registration),tests/cli/, or.pre-commit-hooks.yaml. -
--candidate-sourceand--baseline-sourcedocumented incli_usage.mdwith the source × source matrix. - JSON envelope
schema_versionreflects the Phase 2 bump, unchanged through 3a/3b. -
code_semantic_ci_design.md §23.1text explicitly states the engine never learns about source categories. -
code_semantic_ci_design.md §23.2 Application Matrixlists each application surface as a (baseline-source, candidate-source) tuple. - Migration note present in
cli_usage.mdcovering thepre-commit→check --candidate-source=staged-indexrewrite. - Full pytest green on Python 3.11 / 3.12 / 3.13.
- Ruff check + format clean.
- Source session: 2026-05-22, post-PR #98 design discussion (Phase 1
mitigation already landed via PR #98 =
claude/repository-issue-review-BVt9Ybranch, merge commitbf4af3b). - Adoption style ("X = aggressive / clean-cut") was chosen in-session after a 4-style trade-off comparison (conservative / additive / symmetry-first / aggressive). All 7 sub-decisions inherit from that style choice (see §3).
- This planning doc supersedes the loose "Phase 2 Task Brief" framing implied by PR #98's PR body. The Phase 1 mitigation in PR #98 stays — Phase 2 cleanly replaces it.