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

ValueMeaningWhat lint actually scanned
mergedBuilt-in DRG plus optional org-pack fragments plus project DRGthe merged graph
built_in_onlyProject DRG absent (either synthesize declared built_in_only: true or the file is simply missing)the built-in DRG only
missingNo DRG loadable (neither project nor built-in resolvable)nothing

Human-banner mapping (FR-003)

graph_stateBanner line printed before the per-layer block
merged(existing) Charter Lint - layers: + per-layer markers
built_in_onlyCharter Lint - layers: + [built-in] + [no project overlay — run \spec-kitty charter synthesize\]
missingCharter 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

FieldDescription
passedtrue iff every check is fresh, skipped, or built_in_only.
auto_refresh_appliedtrue iff --auto-refresh was honoured AND at least one refresh action ran.
auto_refresh_actionsOrdered list of exact commands executed.
blocked_reasonNon-null iff passed=false AND auto_refresh_applied=false. The string MUST include an actionable next command.
warningsOptional 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: false
  • blocked_reason: "uncommitted generated artifacts; commit or stash and retry"
  • Each affected file is named in detail of 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:

  • git not on PATH → passed=false, blocked_reason: "git CLI not available; cannot determine worktree cleanliness".
  • git status returns non-zero → passed=false, blocked_reason includes 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:

ConsumerOn passed=trueOn passed=false
spec-kitty nextlog 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 startlog + 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

CodeMeaning
0passed=true OR (passed=false AND --strict not set AND blocked_reason non-null)
1passed=false AND --strict set
2Hard 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 than charter_source.last_change.
  • synthesized_drg.state = "stale" when synthesis-manifest.yaml.run_id references inputs whose mtime is older than synced_bundle.last_change.
  • synthesized_drg.state = "missing" when .kittify/doctrine/graph.yaml does not exist AND synthesis-manifest.yaml does not declare built_in_only: true.

Backward compatibility

  • Existing keys (charter_sync, synthesis, org_layer) keep their shape.
  • New key freshness is additive.
  • Vocabulary change is breaking and documented in CHANGELOG.

Failure modes

  • charter status --json exits non-zero ONLY when the charter file cannot be read at all (existing behaviour). All freshness sub-states map to result: "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 declaredintent_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> IDunknown_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> IDunknown_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.