Contracts

dossier-snapshot-ownership.md

Contract — Dossier Snapshot Ownership (post-#845)

Maps to FR-009, FR-010, FR-011 and INV-845-{1,2,3} in data-model.md.

Ownership policy: EXCLUDE FROM DIRTY-STATE

<feature_dir>/.kittify/dossiers/<mission_slug>/snapshot-latest.json is treated as a mutable, derived, ephemeral artifact. It is:

1. Excluded from version control via root .gitignore. 2. Excluded from worktree dirty-state pre-flight in transition gates (agent tasks move-task and related). 3. Recomputable on demand from the dossier source (compute_snapshot() in src/specify_cli/dossier/snapshot.py).

Reviewers do not see snapshot diffs in PRs. There is no commit churn from snapshot writes.

Invariants

IDRule
D1The snapshot path glob /.kittify/dossiers//snapshot-latest.json is in the root .gitignore.
D2The pre-flight code path used by agent tasks move-task and related transitions explicitly filters paths matching D1 from its dirty-state computation. The filter is in code, not only in .gitignore (belt-and-suspenders).
D3Real worktree dirty state outside D1's pattern still blocks the transition. The mission only suppresses the self-inflicted dirty state.

Producer obligations (snapshot writers in src/specify_cli/dossier/snapshot.py)

  • save_snapshot() continues to write snapshot-latest.json exactly as today. No staging, no committing, no special branch interaction. The file is just a file.
  • No new error handling required — write semantics are unchanged.

Consumer obligations (transition gates in src/specify_cli/cli/commands/agent/tasks.py, helpers in src/specify_cli/status/)

When computing "is the worktree dirty for this transition?":

dirty_files = compute_dirty_files(repo_root)  # however the existing code does this
filtered = [f for f in dirty_files if not _is_dossier_snapshot(f)]
return bool(filtered)  # block only if non-snapshot dirty files remain

_is_dossier_snapshot(path) returns True when path matches the glob in D1.

Regression test contract (tests/integration/test_dossier_snapshot_no_self_block.py)

GIVEN a clean worktree on a mission with no other dirty state
WHEN a mission command writes <feature_dir>/.kittify/dossiers/<slug>/snapshot-latest.json
THEN the very next call to `spec-kitty agent tasks move-task <wp> --to <lane>` succeeds
  AND the snapshot file is left in place (not deleted, not auto-committed)
GIVEN a worktree where `snapshot-latest.json` was just written AND another file (unrelated) has uncommitted edits
WHEN `agent tasks move-task` runs
THEN it fails with a dirty-state error THAT NAMES the unrelated file (not the snapshot)

What this contract does NOT change

  • The location, name, schema, or computation of snapshot-latest.json.
  • compute_snapshot(), save_snapshot(), load_snapshot() signatures or behavior.
  • Other dossier artifacts (only snapshot-latest.json is in scope).
  • The transition state machine.

next-prompt-file-contract.md

Contract — next --json prompt-file field (post-#844)

Maps to FR-004, FR-005, FR-006, FR-007, FR-008 and INV-844-{1,2,3} in data-model.md.

Wire format (the parts this mission touches)

{
  "kind": "step | blocked | complete | ...",
  "prompt_file": "<absolute path to prompt body file> | null",
  "reason": "<short string, present when kind != step>",
  "...": "..."
}
  • prompt_file is the only producer-side wire field for the prompt path. Verified in source: Decision.to_dict() in src/specify_cli/next/decision.py emits only prompt_file. There is no prompt_path field on the Decision dataclass.
  • The current charter E2E (tests/e2e/test_charter_epic_golden_path.py:570) reads payload.get("prompt_file") or payload.get("prompt_path") as a defensive consumer-side fallback. This mission preserves that fallback in the E2E for backward compatibility but does not introduce prompt_path as a wire field. Producer code (the runtime) writes prompt_file only.

Invariants enforced by this mission

IDRule
C1When kind == "step", prompt_file is a non-empty string. Null and empty-string are illegal.
C2When kind == "step", the value emitted under C1 resolves to an existing file at envelope-construction time. Path(value).is_file() is true.
C3When the runtime cannot produce a step (no actionable composed action, blocked dependency, etc.), the envelope's kind is not "step". The runtime returns kind=blocked (or another non-step kind) with a reason. kind=step with a missing prompt is a runtime invariant violation.

Producer obligations (spec-kitty next runtime)

  • Construct decisions through RuntimeDecision (or peer construction site). The dataclass __post_init__ enforces C1/C2 at construction time.
  • Producer code sets the prompt_file attribute. There is no prompt_path attribute on the dataclass; do not reference one.
  • A site that can produce a kind=step decision but cannot resolve a prompt MUST catch the validator's exception and emit kind=blocked with reason="prompt_file_not_resolvable" (or a more specific reason). It MUST NOT silently emit an illegal envelope.

Consumer obligations (tests/e2e/test_charter_epic_golden_path.py)

The E2E test reads prompt_file as primary and prompt_path as a defensive fallback (this fallback is preserved verbatim — it covers any historical or downstream consumer that may emit either key). For every issued decision where kind == "step":

prompt = payload.get("prompt_file") or payload.get("prompt_path")
assert prompt is not None, "kind=step must carry a prompt_file (C1)"
assert prompt != "", "kind=step prompt_file must be non-empty (C1)"
assert Path(prompt).is_file(), f"kind=step prompt_file must resolve on disk (C2): {prompt}"

For non-step kinds, the test does not assert on the prompt fields.

Doctrine surface (host-facing)

  • src/doctrine/skills/spec-kitty-runtime-next/SKILL.md is updated to state explicitly: "A kind=step envelope with a null or non-resolvable prompt_file is illegal. If you see one, treat it as a runtime bug, not a kind=blocked substitute."
  • The inline comment at src/specify_cli/next/decision.py:79 ("advance mode populates this") is replaced with a comment that names the C1/C2/C3 contract.

What this contract does NOT change

  • The set of legal kind values (no new kind introduced).
  • The wire-format keys themselves (no rename, no removal).
  • Behavior for non-step kinds (blocked, complete, etc.).
  • Any other field on the envelope.

specify-plan-commit-boundary.md

Contract — Specify/Plan Auto-Commit Boundary (post-#846)

Maps to FR-012, FR-013, FR-014, FR-015, FR-016 and INV-846-{1,2,3,4} in data-model.md.

Surface inventory

There are two Python-side auto-commit paths that today land mission artifacts on the target branch:

PathLocationWhat it commits todayWhat this mission changes
mission createsrc/specify_cli/cli/commands/agent/mission.py (the create command's safe_commit call)meta.json + the empty spec.md scaffoldDrop spec.md from the create-time commit. The agent commits the populated spec.md after writing substantive content.
setup-plansrc/specify_cli/cli/commands/agent/mission.py near line 973 (_commit_to_branch(plan_file, …))plan.md after the slash-template populates itGate the commit on is_substantive(plan, "plan"). Add an entry-time check that spec.md is committed AND substantive; if not, do not write or commit plan.md.

The /spec-kitty.specify slash-template instructs the agent to commit substantive spec.md content; that commit happens outside Python and is unchanged by this mission.

Boundary (post-fix)

After this WP lands:

1. mission create does not commit spec.md at all. Empty scaffolds remain untracked at create time. 2. setup-plan entry: the command verifies that spec.md is BOTH committed (tracked + present in HEAD) AND substantive. If either fails, the command emits phase_complete=False with a blocked_reason and returns without writing or committing plan.md. 3. setup-plan exit: the existing _commit_to_branch(plan_file, …) call is gated on is_substantive(plan_path, "plan"). If false, emit phase_complete=False / blocked_reason and skip the commit. 4. Workflow status JSON reflects all of the above accurately. A non-substantive or uncommitted-substantive state is incomplete, never "ready".

is_substantive(file_path, kind) definition (revised — section-presence only)

def is_substantive(file_path: Path, kind: Literal["spec", "plan"]) -> bool:
    """Return True iff the file contains substantive (non-template) content for the given artifact kind."""
    body = file_path.read_text(encoding="utf-8")
    return _has_required_sections(body, kind)

Required sections per kind:

kindRequired signal (must be present AND not template-placeholder)
specAt least one row in a Functional Requirements table that has an FR-\d{3} ID followed by non-empty description content. The row must NOT consist entirely of template placeholders like [NEEDS CLARIFICATION …] or [e.g., …].
planA populated Technical Context section where Language/Version (and at least one peer field like Primary Dependencies) contains a real value — NOT a placeholder like [e.g., Python 3.11 …] or [NEEDS CLARIFICATION …].

The earlier "byte-length OR section-presence" formulation was rejected. Byte-length-only would pass scaffold + arbitrary prose, recreating the failure mode this mission exists to fix. See research.md R7 (revised) for the rationale.

is_committed(file_path, repo_root) definition

def is_committed(file_path: Path, repo_root: Path) -> bool:
    """Return True iff the file is git-tracked AND present at HEAD."""
    # subprocess: `git ls-files --error-unmatch <rel>` and `git cat-file -e HEAD:<rel>`

Used at the setup-plan entry gate to enforce INV-846-2.

Producer obligations (src/specify_cli/cli/commands/agent/mission.py)

mission create

# OLD (today):
safe_commit(repo_path=..., files_to_commit=[meta_file, spec_file, ...], commit_message="...")

# NEW (post-fix):
safe_commit(repo_path=..., files_to_commit=[meta_file, ...],  # spec_file omitted
            commit_message="Add meta and scaffolding for feature ...")
# The empty spec.md scaffold remains on disk but untracked. The agent commits it
# after writing substantive content (existing slash-template behavior).

setup-plan

# Entry gate (top of setup-plan):
if not is_committed(spec_path, repo_root) or not is_substantive(spec_path, "spec"):
    return {
        "phase_complete": False,
        "blocked_reason": (
            "spec.md must be committed and substantive before the plan phase can begin. "
            "Populate Functional Requirements and commit, then re-run setup-plan."
        ),
        # no plan.md written, no commit made
    }

# ... write plan.md from template, allow agent population ...

# Exit gate (replace the existing _commit_to_branch(plan_file, ...) call):
if is_substantive(plan_path, "plan"):
    _commit_to_branch(plan_file, mission_slug, "plan", repo_root, target_branch, json_output)
    payload["phase_complete"] = True
else:
    payload["phase_complete"] = False
    payload["blocked_reason"] = (
        "plan.md content is not substantive yet; populate Technical Context with real values "
        "and re-run setup-plan to commit."
    )
    # do NOT commit

Consumer obligations (workflow status reporters, mission setup-plan --json, dashboard)

  • Treat phase_complete=False with blocked_reason containing the substantive-content phrase as incomplete, never as "ready".
  • Do not silently retry or auto-advance.

Regression test contract (tests/integration/test_specify_plan_commit_boundary.py)

GIVEN a fresh `mission create` invocation
WHEN we inspect the resulting commits
THEN spec.md is NOT committed (it exists on disk but is untracked)
  AND meta.json IS committed
GIVEN a fresh mission with an UNCOMMITTED, populated spec.md (real FR rows)
WHEN setup-plan runs
THEN no plan.md is committed
  AND JSON output reports phase_complete=False with a blocked_reason naming "committed and substantive"
GIVEN a fresh mission with a COMMITTED spec.md that is ONLY scaffold (empty FR table)
WHEN setup-plan runs
THEN no plan.md is committed
  AND JSON output reports phase_complete=False with a blocked_reason naming substantive content
GIVEN a fresh mission with a COMMITTED, SUBSTANTIVE spec.md (≥1 FR row populated)
AND the agent has populated plan.md with a real Technical Context
WHEN setup-plan runs
THEN plan.md IS committed
  AND JSON output reports phase_complete=True
GIVEN the same setup as above BUT plan.md is left as template placeholder
WHEN setup-plan runs
THEN plan.md is NOT committed
  AND JSON output reports phase_complete=False with a substantive-plan blocked_reason

Template documentation (src/specify_cli/missions/<mission-type>/command-templates/{specify,plan}.md)

Each template file gets a short "Commit Boundary" subsection explaining:

  • Why the workflow may refuse to write or commit plan.md.
  • What "substantive content" means operationally for this artifact.
  • How to advance the workflow: populate substantive content, commit it (for spec.md) or re-run setup-plan (for plan.md).

What this contract does NOT change

  • The location of spec.md / plan.md.
  • The shape of the slash-template instructions (the agent still writes substantive content into spec.md and plan.md from the slash-template flow).
  • The set of required sections beyond the explicit pair listed above.
  • Any auto-commit behavior outside the two paths in the inventory above (e.g. agent acceptance, agent tasks, etc.).
  • Existing missions whose spec.md was already committed empty by the pre-fix mission create. Those legacy missions remain on disk; their "spec phase complete" state is determined by the same entry gate (is_committed AND is_substantive). A legacy empty-but-committed spec.md will be reported as incomplete until the agent populates and re-commits it.