Contracts
charter-lint-json.md
Contract — charter lint --json (extended)
Backed by: FR-001 .. FR-004, FR-015
Top-level shape (post-mission)
{
"findings": [ /* existing LintFinding shape */ ],
"scanned_at": "2026-05-23T13:00:00+00:00",
"feature_scope": null,
"duration_seconds": 0.123,
"drg_node_count": 0,
"drg_edge_count": 0,
"graph_state": "merged" | "built_in_only" | "missing" /* NEW */
}
graph_state values
| Value | Meaning | What lint actually scanned |
|---|---|---|
merged | Built-in DRG plus optional org-pack fragments plus project DRG | the merged graph |
built_in_only | Project DRG absent (either synthesize declared built_in_only: true or the file is simply missing) | the built-in DRG only |
missing | No DRG loadable (neither project nor built-in resolvable) | nothing |
Human-banner mapping (FR-003)
graph_state | Banner line printed before the per-layer block |
|---|---|
merged | (existing) Charter Lint - layers: + per-layer markers |
built_in_only | Charter Lint - layers: + [built-in] + [no project overlay — run \spec-kitty charter synthesize\] |
missing | Charter Lint: no lintable graph found — run \spec-kitty charter synthesize\`` |
"No decay detected" branch
The current banner unconditionally prints [green]No decay detected[/green] followed by Scanned 0 nodes. Post-mission:
- If
graph_state == "missing": the "No decay detected" line MUST NOT print. Instead, the lint surface MUST print the remediation hint and exit with a non-zero return code only when the user passed--strict(default exit remains 0 for backward compatibility, but the banner is informative). - If
graph_state == "built_in_only": "No decay detected" prints with a qualifier —[green]No decay detected[/green] dim[/dim]. - If
graph_state == "merged"and findings empty: existing banner unchanged.
Vocabulary
Layer markers in human output (already use [built-in]) remain unchanged. Per-layer markers for org packs use [org:<pack-name>]. Project layer marker is [project]. There MUST be no occurrence of [shipped] in any banner output (FR-016).
charter-preflight-json.md
Contract — spec-kitty charter preflight (new)
Backed by: FR-006, FR-007, FR-008
CLI surface
Usage: spec-kitty charter preflight [OPTIONS]
Verify charter-derived state and (optionally) refresh stale or missing
synthesized doctrine before a governed session begins.
Options:
--json Emit JSON result.
--auto-refresh Run safe refresh steps automatically when applicable
(default: off; opt-in via config flag or this flag).
--strict Exit non-zero on any non-fresh state (default: exit
zero unless a hard block occurs).
--help
JSON output
{
"passed": true | false,
"checks": [
{
"name": "charter_source",
"state": "fresh" | "stale" | "missing" | "invalid" | "skipped",
"detail": "human-readable",
"remediation": "exact command" | null
},
{
"name": "synced_bundle",
"state": "...",
"detail": "...",
"remediation": null
},
{
"name": "synthesized_drg",
"state": "fresh" | "stale" | "missing" | "built_in_only" | "invalid" | "skipped",
"detail": "...",
"remediation": "spec-kitty charter synthesize" | null
}
],
"auto_refresh_applied": true | false,
"auto_refresh_actions": ["spec-kitty charter sync", "spec-kitty charter synthesize"],
"blocked_reason": "human-readable" | null,
"warnings": ["human-readable advisory"]
}
State semantics
| Field | Description |
|---|---|
passed | true iff every check is fresh, skipped, or built_in_only. |
auto_refresh_applied | true iff --auto-refresh was honoured AND at least one refresh action ran. |
auto_refresh_actions | Ordered list of exact commands executed. |
blocked_reason | Non-null iff passed=false AND auto_refresh_applied=false. The string MUST include an actionable next command. |
warnings | Optional structured non-blocking advisory messages. Omitted when empty for backward compatibility. Human warning text MUST NOT be prepended to JSON stdout. |
Fresh projects with no authored charter stack (charter_source, synced_bundle, and synthesized_drg all missing) are advisory only for read-only/dashboard consumers that explicitly enable missing-charter tolerance. In that mode the runner returns passed=true, marks checks skipped, and places the missing-charter guidance in warnings. Mutation gates leave this tolerance disabled so actions that require charter-derived state still fail closed.
Safety rule (FR-008)
When the worktree contains uncommitted changes to .kittify/charter/ or .kittify/doctrine/, --auto-refresh MUST be a no-op:
auto_refresh_applied: falseblocked_reason: "uncommitted generated artifacts; commit or stash and retry"- Each affected file is named in
detailof the corresponding check.
Detection mechanism (binding)
Uncommitted-artifact detection MUST use a single git status --porcelain -- .kittify/charter/ .kittify/doctrine/ invocation, parsed line-by-line. Any output line marks the worktree as dirty for the purposes of --auto-refresh. The detection MUST NOT use git diff, file-mtime heuristics, or string-matching of pathnames — these all produce known false negatives on Windows.
Failure-mode contract:
gitnot on PATH →passed=false,blocked_reason: "git CLI not available; cannot determine worktree cleanliness".git statusreturns non-zero →passed=false,blocked_reasonincludes the exit code and stderr first line.- Detection itself MUST complete within 100 ms on a clean tree to stay under NFR-001.
Hook caller contract (DIR-031)
The session-start hook callable run_charter_preflight(...) is consumed by three entry points. Each consumer obeys the following rules:
| Consumer | On passed=true | On passed=false |
|---|---|---|
spec-kitty next | log a single line at INFO level; continue. | abort with exit code 1; print result.blocked_reason; do not perform any state mutation. |
spec-kitty implement WP## | log + continue. | abort with exit code 1 before creating or reusing any worktree; do not modify .kittify/. |
dashboard serve / dashboard start | log + continue. | start the server but render a top-of-page banner (severity=critical) carrying result.blocked_reason and the exact remediation command. Do NOT silently fall back to stale doctrine. |
Each consumer MUST load project-level preflight policy from .kittify/config.yaml. preflight.enabled defaults to true; when set to false, consumers skip the gate and return a synthetic passing result. preflight.auto_refresh defaults to false and is forwarded to run_charter_preflight(auto_refresh=<config>). A consumer MUST NOT pass auto_refresh=True unconditionally.
Exit codes
| Code | Meaning |
|---|---|
| 0 | passed=true OR (passed=false AND --strict not set AND blocked_reason non-null) |
| 1 | passed=false AND --strict set |
| 2 | Hard error (charter file unreadable, etc.) — never produces a JSON payload |
Hook contract
Internal callable surface for session-start hooks:
from specify_cli.charter_preflight import run_charter_preflight, CharterPreflightResult
result: CharterPreflightResult = run_charter_preflight(
repo_root=Path("."),
auto_refresh=False,
strict=False,
)
Callers (spec-kitty next, spec-kitty implement, dashboard serve) check result.passed and either log + continue (when passed=true) or surface result.blocked_reason and abort.
charter-status-json.md
Contract — charter status --json (extended)
Backed by: FR-005, FR-009 (built_in_only flag), FR-015 (vocabulary)
Top-level shape (post-mission)
{
"result": "success",
"charter_sync": { /* existing */ },
"synthesis": { /* existing — see Synthesis section below */ },
"org_layer": { /* existing */ },
"freshness": { /* NEW — see Freshness section below */ }
}
freshness (NEW)
{
"charter_source": {
"state": "fresh" | "stale" | "missing" | "invalid",
"last_change": "2026-05-19T13:00:45.966069+00:00" | null,
"remediation": "spec-kitty charter sync" | null
},
"synced_bundle": {
"state": "fresh" | "stale" | "missing" | "invalid",
"last_change": "..." | null,
"remediation": "spec-kitty charter sync" | null
},
"synthesized_drg": {
"state": "fresh" | "stale" | "missing" | "built_in_only" | "invalid",
"last_change": "..." | null,
"remediation": "spec-kitty charter synthesize" | null
}
}
Each sub-object MUST be present (never elided). State "built_in_only" on synthesized_drg is set when synthesis-manifest.yaml declares built_in_only: true (FR-009).
synthesis (existing — vocabulary changes only)
Fields generation_state, evidence, provenance, manifest keep their existing shape, but any string value of "shipped" is replaced with "built-in" (FR-015). External consumers who pattern-matched "shipped" MUST be migrated; CHANGELOG entry (FR-017) documents this.
Staleness computation
charter_source.state = "stale"when the file's SHA-256 differs from the hash stored in.kittify/charter/metadata.yaml.synced_bundle.state = "stale"when any bundle file's mtime is older thancharter_source.last_change.synthesized_drg.state = "stale"whensynthesis-manifest.yaml.run_idreferences inputs whose mtime is older thansynced_bundle.last_change.synthesized_drg.state = "missing"when.kittify/doctrine/graph.yamldoes not exist ANDsynthesis-manifest.yamldoes not declarebuilt_in_only: true.
Backward compatibility
- Existing keys (
charter_sync,synthesis,org_layer) keep their shape. - New key
freshnessis additive. - Vocabulary change is breaking and documented in CHANGELOG.
Failure modes
charter status --jsonexits non-zero ONLY when the charter file cannot be read at all (existing behaviour). All freshness sub-states map toresult: "success"with informative sub-states; staleness is not a CLI error.
pack-validator-advisory.md
Contract — Pack-validator advisory rules
Backed by: FR-010 .. FR-014, R-9
Detection precedence
Given a pack artifact (tactic / styleguide / paradigm / procedure / agent-profile) with id == X:
1. Both overrides: Y and enhances: Z declared → intent_conflict ERROR. Message: "overrides and enhances are mutually exclusive on <kind> <id>". No further checks for this artifact.
2. overrides: Y declared, Y not a built-in <kind> ID → unknown_target ERROR. Message: "<kind> <id> declares overrides: <Y>, but no built-in <kind> with that id exists".
3. enhances: Z declared, Z not a built-in <kind> ID → unknown_target ERROR. Message: "<kind> <id> declares enhances: <Z>, but no built-in <kind> with that id exists".
4. Either field declared and target valid → advisory suppressed for this artifact. DRG auto-emits the corresponding edge.
5. Neither field declared, ID matches a built-in <kind> → same_id_collision ADVISORY (reworded). Message: "artifact id '<id>' will field-merge into the built-in <kind> — declare 'enhances: <id>' to suppress this advisory, or 'overrides: <id>' to declare a full replacement".
6. Neither field declared, ID does NOT match a built-in <kind> → no advisory (pack-only artifact, normal case).
DRG edge auto-emission (FR-014)
When enhances: tactic-y is set on pack tactic tactic-x, the following edge is auto-emitted into the pack's DRG fragment:
# pydantic_model: doctrine.drg.models.DRGEdge
# expect: valid
source: tactic:tactic-x
target: tactic:tactic-y
relation: enhances
reason: "declared via tactic.enhances field"
When overrides: tactic-y is set on pack tactic tactic-x:
# pydantic_model: doctrine.drg.models.DRGEdge
# expect: valid
source: tactic:tactic-x
target: tactic:tactic-y
relation: overrides
reason: "declared via tactic.overrides field"
Same pattern for the other four artifact kinds, using the appropriate URN prefix (styleguide:, paradigm:, procedure:, agent_profile:).
CLI JSON shape
spec-kitty doctrine pack validate --json extends the existing ValidationIssue list:
{
"ok": false,
"issues": [
{
"severity": "error",
"category": "intent_conflict",
"artifact_type": "tactics",
"artifact_id": "context-boundary-inference",
"file": "/path/to/pack/tactics/context-boundary-inference.tactic.yaml",
"message": "overrides and enhances are mutually exclusive on tactic context-boundary-inference"
},
{
"severity": "error",
"category": "unknown_target",
"artifact_type": "tactics",
"artifact_id": "team-topology-tactic",
"file": "...",
"message": "tactic team-topology-tactic declares enhances: foo-bar-tactic, but no built-in tactic with that id exists"
},
{
"severity": "advisory",
"category": "same_id_collision",
"artifact_type": "tactics",
"artifact_id": "secure-design-checklist",
"file": "...",
"message": "artifact id 'secure-design-checklist' will field-merge into the built-in tactic — declare 'enhances: secure-design-checklist' to suppress this advisory, or 'overrides: secure-design-checklist' to declare a full replacement"
}
]
}
Exit code
ok: false (at least one ERROR) → exit non-zero. ADVISORY-only output → exit zero.