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
| ID | Rule |
|---|---|
| D1 | The snapshot path glob /.kittify/dossiers//snapshot-latest.json is in the root .gitignore. |
| D2 | The 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). |
| D3 | Real 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 writesnapshot-latest.jsonexactly 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.jsonis 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_fileis the only producer-side wire field for the prompt path. Verified in source:Decision.to_dict()insrc/specify_cli/next/decision.pyemits onlyprompt_file. There is noprompt_pathfield on theDecisiondataclass.- The current charter E2E (
tests/e2e/test_charter_epic_golden_path.py:570) readspayload.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 introduceprompt_pathas a wire field. Producer code (the runtime) writesprompt_fileonly.
Invariants enforced by this mission
| ID | Rule |
|---|---|
| C1 | When kind == "step", prompt_file is a non-empty string. Null and empty-string are illegal. |
| C2 | When kind == "step", the value emitted under C1 resolves to an existing file at envelope-construction time. Path(value).is_file() is true. |
| C3 | When 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_fileattribute. There is noprompt_pathattribute on the dataclass; do not reference one. - A site that can produce a
kind=stepdecision but cannot resolve a prompt MUST catch the validator's exception and emitkind=blockedwithreason="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.mdis updated to state explicitly: "Akind=stepenvelope with a null or non-resolvableprompt_fileis illegal. If you see one, treat it as a runtime bug, not akind=blockedsubstitute."- 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
kindvalues (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:
| Path | Location | What it commits today | What this mission changes |
|---|---|---|---|
mission create | src/specify_cli/cli/commands/agent/mission.py (the create command's safe_commit call) | meta.json + the empty spec.md scaffold | Drop spec.md from the create-time commit. The agent commits the populated spec.md after writing substantive content. |
setup-plan | src/specify_cli/cli/commands/agent/mission.py near line 973 (_commit_to_branch(plan_file, …)) | plan.md after the slash-template populates it | Gate 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:
| kind | Required signal (must be present AND not template-placeholder) |
|---|---|
spec | At 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., …]. |
plan | A 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=Falsewithblocked_reasoncontaining 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.mdandplan.mdfrom 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.mdwas already committed empty by the pre-fixmission 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-committedspec.mdwill be reported as incomplete until the agent populates and re-commits it.