Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/design/asdd-whitepaper.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,31 @@ is the canonical CLI surface reference; this whitepaper is the canonical
reference for the *approach*; the constitution and per-spec documents are
downstream of both.

## 10 Constitution Schema (Reference)

The constitution lives at `.gemba/constitution.md`. The canonical JSON
Schema is `internal/spec/schema/constitution.schema.json` (id
`https://gemba.dev/schema/constitution-1.0.0.json`). Keys are supplied via a
top-level YAML frontmatter block and/or a `## Config` yaml fence; both are
merged. Unknown top-level keys are rejected (`additionalProperties: false`).

Current catalog (schema `1.0.0`):

- `schema_version` (string, semver, required) — constitution schema version.
- `asdd_mode` (bool, default false) — tasks live in bd, not tasks.md.
- `spec_strict` (bool, default false) — master strict-mode switch; child knobs inherit when unset.
- `spec_strict_no_tasks_md` (bool, inherits `spec_strict`) — reject writes to tasks.md / todo.md.
- `require_decision_parent` (bool, inherits `spec_strict`) — spec frontmatter must declare `decision_parent`.
- `forbid_orphan_beads` (bool, inherits `spec_strict`) — every story bead must reference its spec.
- `require_priority` (bool, inherits `spec_strict`) — every Story must declare `Priority:`.
- `min_ac_count` (integer, default 0) — minimum AC list items per Story; 0 disables.

Versioning. The active version constant lives in
`internal/spec/constitution/version.go`; future schema changes bump
`CurrentVersion` and add a branch in `MigrateConstitution`. Existing files
without `schema_version` are migrated transparently and surface a
`constitution-version-mismatch` (warn) finding from `gemba constitution lint`.

## Decisions

- ADR 0001 — Spec Kit integration: hook-layer only (2026-05-13). See [docs/design/adr/0001-spec-kit-integration-strategy.md](adr/0001-spec-kit-integration-strategy.md).
9 changes: 7 additions & 2 deletions internal/cli/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -302,13 +302,18 @@ func newConstitutionLintCmd() *cobra.Command {
if err != nil {
return err
}
c, _, _ := constitution.Load(root)
c, cpath, _ := constitution.Load(root)
findings, err := lint.Scan(root, c)
if err != nil {
return err
}
cf, err := lint.ScanConstitution(cpath)
if err != nil {
return err
}
findings = append(findings, cf...)
for _, f := range findings {
fmt.Fprintf(cmd.OutOrStdout(), "%s: %s (rule=%s)\n", f.Path, f.Message, f.Rule)
fmt.Fprintf(cmd.OutOrStdout(), "%s: %s (rule=%s severity=%s)\n", f.Path, f.Message, f.Rule, f.Severity)
}
if strict && len(findings) > 0 {
os.Exit(1)
Expand Down
103 changes: 91 additions & 12 deletions internal/spec/constitution/constitution.go
Original file line number Diff line number Diff line change
@@ -1,21 +1,35 @@
// Package constitution parses the project constitution document. The
// constitution is a Markdown file with an embedded YAML "## Config" stanza
// that controls ASDD enforcement knobs.
// constitution is a Markdown file with knobs supplied either via a top-level
// YAML frontmatter block (`--- ... ---`) and/or an embedded `## Config` yaml
// fenced block. Keys are merged with frontmatter taking precedence.
//
// The schema is defined in internal/spec/schema/constitution.schema.json and
// versioned via CurrentVersion (see version.go).
package constitution

import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
)

// ErrUnknownConstitutionKey is returned when a constitution declares a
// top-level key the schema does not recognize (additionalProperties: false).
var ErrUnknownConstitutionKey = errors.New("constitution: unknown top-level key")

// Constitution captures the typed knobs that drive enforcement.
//
// Bool fields are pointers so we can distinguish "unset" from "false" for
// inheritance defaults (e.g. SpecStrictNoTasksMD inherits SpecStrict).
type Constitution struct {
// SchemaVersion is the declared schema version. Always set after Load /
// Parse; defaults to CurrentVersion when migration injected it.
SchemaVersion string

ASDDMode *bool
SpecStrict *bool
SpecStrictNoTasksMD *bool
Expand All @@ -26,6 +40,11 @@ type Constitution struct {
ForbidOrphanBeads *bool
RequirePriority *bool
MinACCount *int

// UnknownKeys lists top-level keys the parser saw but the schema does
// not catalog. Surface via lint as constitution-version-mismatch or as
// ErrUnknownConstitutionKey for callers that need strict rejection.
UnknownKeys []string
}

// SpecStrictNoTasksMDEffective returns the effective value with inheritance:
Expand Down Expand Up @@ -75,33 +94,86 @@ func (c Constitution) ASDDModeEffective() bool {
return false
}

// knownKeys lists every top-level key catalogued by the schema. Keep in sync
// with internal/spec/schema/constitution.schema.json.
var knownKeys = map[string]struct{}{
"schema_version": {},
"asdd_mode": {},
"spec_strict": {},
"spec_strict_no_tasks_md": {},
"require_decision_parent": {},
"forbid_orphan_beads": {},
"min_ac_count": {},
"require_priority": {},
}

var (
configBlockRe = regexp.MustCompile("(?s)```ya?ml\\s*\\n(.*?)```")
kvRe = regexp.MustCompile(`^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+?)\s*(#.*)?$`)
frontmatterRe = regexp.MustCompile(`(?s)\A\s*---\r?\n(.*?)\r?\n---\s*(?:\r?\n|$)`)
versionLineRe = regexp.MustCompile(`(?m)^\s*schema_version\s*:\s*["']?([0-9]+\.[0-9]+\.[0-9]+)["']?\s*$`)
)

// Parse reads constitution Markdown bytes and returns the typed config.
//
// Parse runs MigrateConstitution on input first, so callers can rely on
// c.SchemaVersion being non-empty for any non-empty input. Unknown top-level
// keys are recorded in c.UnknownKeys (the linter / Load decide whether to
// promote them to an error).
func Parse(data []byte) Constitution {
c := Constitution{}
text := string(data)
// Locate the Config section first; we still parse any yaml fence in case
// the config block lacks a header.
migrated, err := MigrateConstitution(data)
if err != nil {
// Migration only fails for an unsupported version; surface the raw
// version we read so SchemaVersion reflects truth.
if m := versionLineRe.FindSubmatch(data); m != nil {
c.SchemaVersion = string(m[1])
}
return c
}
text := string(migrated)

// 1) Frontmatter block at the top of the file (if any).
if fm := frontmatterRe.FindStringSubmatch(text); fm != nil {
applyKVBlock(&c, fm[1])
}

// 2) '## Config' yaml fence (legacy form, still supported).
if idx := strings.Index(text, "## Config"); idx >= 0 {
text = text[idx:]
sub := text[idx:]
if m := configBlockRe.FindStringSubmatch(sub); m != nil {
applyKVBlock(&c, m[1])
}
}
matches := configBlockRe.FindStringSubmatch(text)
if matches == nil {
return c

if c.SchemaVersion == "" {
c.SchemaVersion = CurrentVersion
}
body := matches[1]
return c
}

// applyKVBlock parses a YAML-shaped key/value block (one key per line) and
// mutates c. Unknown top-level keys are recorded on c.UnknownKeys; recognised
// keys overwrite previously-set fields so callers can layer blocks in
// priority order (frontmatter then ## Config).
func applyKVBlock(c *Constitution, body string) {
for _, line := range strings.Split(body, "\n") {
m := kvRe.FindStringSubmatch(line)
if m == nil {
continue
}
key := strings.ToLower(m[1])
val := strings.TrimSpace(strings.Trim(m[2], "\""))
val = strings.TrimSpace(strings.Trim(val, "'"))

if _, ok := knownKeys[key]; !ok {
c.UnknownKeys = append(c.UnknownKeys, key)
continue
}

switch key {
case "schema_version":
c.SchemaVersion = val
case "asdd_mode", "spec_strict", "spec_strict_no_tasks_md",
"require_decision_parent", "forbid_orphan_beads", "require_priority":
bv, ok := parseBool(val)
Expand Down Expand Up @@ -131,11 +203,14 @@ func Parse(data []byte) Constitution {
c.MinACCount = &n
}
}
return c
}

// Load reads the constitution from a project root. It searches a small list of
// canonical paths; returns a zero-value Constitution if none exist.
//
// Load enforces additionalProperties: false: when the constitution declares
// keys not recognised by the schema, Load returns ErrUnknownConstitutionKey
// (wrapped so callers can inspect via errors.Is).
func Load(projectRoot string) (Constitution, string, error) {
candidates := []string{
filepath.Join(projectRoot, ".gemba", "constitution.md"),
Expand All @@ -144,7 +219,11 @@ func Load(projectRoot string) (Constitution, string, error) {
for _, p := range candidates {
data, err := os.ReadFile(p)
if err == nil {
return Parse(data), p, nil
c := Parse(data)
if len(c.UnknownKeys) > 0 {
return c, p, fmt.Errorf("%w: %v (in %s)", ErrUnknownConstitutionKey, c.UnknownKeys, p)
}
return c, p, nil
}
if !os.IsNotExist(err) {
return Constitution{}, "", err
Expand Down
105 changes: 105 additions & 0 deletions internal/spec/constitution/version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package constitution

import (
"bytes"
"fmt"
"regexp"
"strings"
)

// CurrentVersion is the active constitution schema version. Future schema
// changes bump this and add a branch in MigrateConstitution.
const CurrentVersion = "1.0.0"

// ErrUnsupportedConstitutionVersion is returned by MigrateConstitution when
// the raw document declares a schema_version this build cannot read.
var ErrUnsupportedConstitutionVersion = fmt.Errorf("constitution: unsupported schema_version (this build understands %s)", CurrentVersion)

// schemaVersionRe matches a YAML key line of the form `schema_version: "1.2.3"`
// or `schema_version: 1.2.3` (quotes optional). It is intentionally limited to
// top-of-line YAML; we do not parse the whole document just to find the key.
var schemaVersionRe = regexp.MustCompile(`(?m)^\s*schema_version\s*:\s*["']?([0-9]+\.[0-9]+\.[0-9]+)["']?\s*$`)

// MigrateConstitution returns a version-normalized copy of raw. If the input
// declares no schema_version, MigrateConstitution injects schema_version =
// CurrentVersion into the top-most YAML region (frontmatter if present, else
// the '## Config' yaml fence). When schema_version is present, it must equal
// CurrentVersion; otherwise ErrUnsupportedConstitutionVersion is returned.
//
// Migration is idempotent: calling MigrateConstitution on its own output is a
// no-op modulo whitespace.
func MigrateConstitution(raw []byte) ([]byte, error) {
if m := schemaVersionRe.FindSubmatch(raw); m != nil {
got := string(m[1])
if got != CurrentVersion {
return nil, fmt.Errorf("%w: got %q", ErrUnsupportedConstitutionVersion, got)
}
return raw, nil
}

// No schema_version present — inject it. Prefer YAML frontmatter at the
// very top (`---\n...\n---`). If none, fall back to the '## Config' yaml
// fence. If neither exists, prepend a fresh frontmatter block.
if out, ok := injectIntoFrontmatter(raw); ok {
return out, nil
}
if out, ok := injectIntoConfigFence(raw); ok {
return out, nil
}
// Last resort: prepend a frontmatter block.
header := []byte("---\nschema_version: " + CurrentVersion + "\n---\n")
return append(header, raw...), nil
}

// injectIntoFrontmatter writes `schema_version: X.Y.Z` into a leading
// `---` YAML frontmatter block. Returns (out, true) when one is found.
func injectIntoFrontmatter(raw []byte) ([]byte, bool) {
text := string(raw)
// Allow a leading HTML comment / blank lines before the frontmatter.
trimmed := strings.TrimLeft(text, " \t\r\n")
prefixLen := len(text) - len(trimmed)
if !strings.HasPrefix(trimmed, "---\n") && !strings.HasPrefix(trimmed, "---\r\n") {
return nil, false
}
// Find the closing `---` on its own line after the opening fence.
body := trimmed[4:]
closeIdx := strings.Index(body, "\n---")
if closeIdx < 0 {
return nil, false
}
fmBody := body[:closeIdx]
rest := body[closeIdx:]
insert := "schema_version: " + CurrentVersion + "\n"
// Prepend the version line inside the frontmatter.
newFM := insert + fmBody
var buf bytes.Buffer
buf.WriteString(text[:prefixLen])
buf.WriteString("---\n")
buf.WriteString(newFM)
buf.WriteString(rest)
return buf.Bytes(), true
}

// injectIntoConfigFence writes `schema_version: X.Y.Z` into the first
// ```yaml fenced block following a '## Config' heading.
func injectIntoConfigFence(raw []byte) ([]byte, bool) {
text := string(raw)
cfgIdx := strings.Index(text, "## Config")
if cfgIdx < 0 {
return nil, false
}
// Locate the next yaml fence after the heading.
rest := text[cfgIdx:]
fenceRe := regexp.MustCompile("(?s)```ya?ml\\s*\\n(.*?)```")
loc := fenceRe.FindStringSubmatchIndex(rest)
if loc == nil {
return nil, false
}
bodyStart := loc[2] // start of capture group 1 (yaml body)
insert := "schema_version: " + CurrentVersion + "\n"
var buf bytes.Buffer
buf.WriteString(text[:cfgIdx+bodyStart])
buf.WriteString(insert)
buf.WriteString(text[cfgIdx+bodyStart:])
return buf.Bytes(), true
}
Loading
Loading