From 094140b8a6b9a6b869dbd1a16e95c8729abca0ff Mon Sep 17 00:00:00 2001 From: SoFlo1 Date: Thu, 14 May 2026 08:55:31 -0400 Subject: [PATCH] gm-o9t8.15: formalize .gemba/constitution.md schema (v1.0.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JSON Schema: lock down properties, additionalProperties: false, add required schema_version (semver), examples, full descriptions. - New version.go: CurrentVersion="1.0.0" + MigrateConstitution that idempotently injects schema_version into frontmatter or '## Config'. - Parser: read top-level YAML frontmatter in addition to '## Config', surface SchemaVersion + UnknownKeys; Load returns ErrUnknownConstitutionKey for unrecognized top-level keys. - Lint: new ScanConstitution emits constitution-version-mismatch (warn) and constitution-unknown-key (error); wired into 'gemba constitution lint'. - Template: frontmatter at the top with schema_version + permissive defaults for every catalogued key. - Whitepaper: new §10 "Constitution Schema (Reference)" listing the catalog and pointing at the JSON Schema. Existing constitutions without schema_version still load (migration auto-injects); strict rejection is reserved for unknown keys. --- docs/design/asdd-whitepaper.md | 25 +++++ internal/cli/spec.go | 9 +- internal/spec/constitution/constitution.go | 103 +++++++++++++++-- internal/spec/constitution/version.go | 105 ++++++++++++++++++ internal/spec/constitution/version_test.go | 73 ++++++++++++ internal/spec/lint/lint.go | 57 ++++++++++ internal/spec/schema/constitution.schema.json | 38 ++++++- internal/spec/templates/constitution.md.tmpl | 28 ++--- 8 files changed, 408 insertions(+), 30 deletions(-) create mode 100644 internal/spec/constitution/version.go create mode 100644 internal/spec/constitution/version_test.go diff --git a/docs/design/asdd-whitepaper.md b/docs/design/asdd-whitepaper.md index dd19090a..b43d9c7b 100644 --- a/docs/design/asdd-whitepaper.md +++ b/docs/design/asdd-whitepaper.md @@ -252,6 +252,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). diff --git a/internal/cli/spec.go b/internal/cli/spec.go index 2750b6db..b816af9e 100644 --- a/internal/cli/spec.go +++ b/internal/cli/spec.go @@ -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) diff --git a/internal/spec/constitution/constitution.go b/internal/spec/constitution/constitution.go index 190a1a19..2da64a69 100644 --- a/internal/spec/constitution/constitution.go +++ b/internal/spec/constitution/constitution.go @@ -1,9 +1,15 @@ // 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" @@ -11,11 +17,19 @@ import ( "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 @@ -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: @@ -75,25 +94,69 @@ 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 { @@ -101,7 +164,16 @@ func Parse(data []byte) Constitution { } 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) @@ -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"), @@ -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 diff --git a/internal/spec/constitution/version.go b/internal/spec/constitution/version.go new file mode 100644 index 00000000..1a9f98d9 --- /dev/null +++ b/internal/spec/constitution/version.go @@ -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 +} diff --git a/internal/spec/constitution/version_test.go b/internal/spec/constitution/version_test.go new file mode 100644 index 00000000..aee537cd --- /dev/null +++ b/internal/spec/constitution/version_test.go @@ -0,0 +1,73 @@ +package constitution + +import ( + "errors" + "strings" + "testing" +) + +func TestMigrateConstitution_InjectsIntoFrontmatter(t *testing.T) { + in := []byte("---\nasdd_mode: true\n---\n\n# Body\n") + out, err := MigrateConstitution(in) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(out), "schema_version: "+CurrentVersion) { + t.Fatalf("expected schema_version line injected, got:\n%s", out) + } + // Idempotent: a second pass must not mutate beyond whitespace. + out2, err := MigrateConstitution(out) + if err != nil { + t.Fatal(err) + } + if string(out) != string(out2) { + t.Fatalf("not idempotent:\nA: %s\nB: %s", out, out2) + } +} + +func TestMigrateConstitution_InjectsIntoConfigFence(t *testing.T) { + in := []byte("# C\n\n## Config\n\n```yaml\nasdd_mode: true\n```\n") + out, err := MigrateConstitution(in) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(out), "schema_version: "+CurrentVersion) { + t.Fatalf("expected schema_version injected into config fence, got:\n%s", out) + } +} + +func TestMigrateConstitution_NoYAMLPrependsFrontmatter(t *testing.T) { + in := []byte("# Plain markdown\n\nNo YAML here.\n") + out, err := MigrateConstitution(in) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(out), "---\nschema_version: "+CurrentVersion+"\n---\n") { + t.Fatalf("expected prepended frontmatter, got:\n%s", out) + } +} + +func TestMigrateConstitution_PresentVersionPasses(t *testing.T) { + in := []byte("---\nschema_version: \"" + CurrentVersion + "\"\nasdd_mode: true\n---\n") + out, err := MigrateConstitution(in) + if err != nil { + t.Fatal(err) + } + if string(out) != string(in) { + t.Fatalf("present version should be no-op") + } +} + +func TestMigrateConstitution_UnsupportedVersion(t *testing.T) { + in := []byte("---\nschema_version: 9.9.9\n---\n") + _, err := MigrateConstitution(in) + if !errors.Is(err, ErrUnsupportedConstitutionVersion) { + t.Fatalf("expected ErrUnsupportedConstitutionVersion, got %v", err) + } +} + +func TestCurrentVersion_IsSemver(t *testing.T) { + if !versionLineRe.MatchString("schema_version: " + CurrentVersion) { + t.Fatalf("CurrentVersion %q does not match semver pattern", CurrentVersion) + } +} diff --git a/internal/spec/lint/lint.go b/internal/spec/lint/lint.go index 8c9d41ee..f086fefb 100644 --- a/internal/spec/lint/lint.go +++ b/internal/spec/lint/lint.go @@ -9,7 +9,9 @@ package lint import ( + "fmt" "io/fs" + "os" "path/filepath" "strings" @@ -68,3 +70,58 @@ func Scan(projectRoot string, c constitution.Constitution) ([]Finding, error) { } return findings, nil } + +// ScanConstitution emits findings about the constitution document at +// constitutionPath itself. Currently it implements the +// `constitution-version-mismatch` rule (warn): the document must declare a +// schema_version equal to constitution.CurrentVersion. A missing version is +// auto-injected at parse time but still surfaces a warn-level finding so +// operators know to commit the migration. +// +// constitutionPath may be empty, in which case ScanConstitution returns nil +// (nothing to check). +func ScanConstitution(constitutionPath string) ([]Finding, error) { + if constitutionPath == "" { + return nil, nil + } + raw, err := os.ReadFile(constitutionPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + c := constitution.Parse(raw) + var findings []Finding + if !hasSchemaVersionLine(raw) { + findings = append(findings, Finding{ + Path: constitutionPath, + Rule: "constitution-version-mismatch", + Severity: "warn", + Message: fmt.Sprintf("constitution is missing schema_version; expected %q (run 'gemba constitution lint' to migrate)", constitution.CurrentVersion), + }) + } else if c.SchemaVersion != constitution.CurrentVersion { + findings = append(findings, Finding{ + Path: constitutionPath, + Rule: "constitution-version-mismatch", + Severity: "warn", + Message: fmt.Sprintf("constitution schema_version %q does not match supported version %q", c.SchemaVersion, constitution.CurrentVersion), + }) + } + for _, k := range c.UnknownKeys { + findings = append(findings, Finding{ + Path: constitutionPath, + Rule: "constitution-unknown-key", + Severity: "error", + Message: fmt.Sprintf("unknown constitution key %q (additionalProperties: false)", k), + }) + } + return findings, nil +} + +// hasSchemaVersionLine reports whether the raw document explicitly declares +// schema_version. We look for the literal key prefix to avoid being fooled by +// the migrated copy of the same input. +func hasSchemaVersionLine(raw []byte) bool { + return strings.Contains(string(raw), "schema_version:") +} diff --git a/internal/spec/schema/constitution.schema.json b/internal/spec/schema/constitution.schema.json index 8cd1cc48..7df807af 100644 --- a/internal/spec/schema/constitution.schema.json +++ b/internal/spec/schema/constitution.schema.json @@ -1,26 +1,40 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://gemba.dev/schema/constitution-1.0.0.json", "title": "Gemba project constitution", + "description": "Schema for .gemba/constitution.md (ASDD knobs). The on-disk format is Markdown with a top-level YAML frontmatter block and/or a '## Config' fenced YAML block; this schema validates the merged key/value map.", "type": "object", + "required": ["schema_version"], "properties": { + "schema_version": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$", + "default": "1.0.0", + "description": "Constitution schema version (semver). Current value is 1.0.0; future versions branch in MigrateConstitution." + }, "asdd_mode": { "type": "boolean", + "default": false, "description": "When true, the project uses Agentic Spec-Driven Development (tasks live in bd, not tasks.md)." }, "spec_strict": { "type": "boolean", - "description": "Master switch: enables strict ASDD enforcement (hooks, lint, preflight)." + "default": false, + "description": "Master switch: enables strict ASDD enforcement (hooks, lint, preflight). Child knobs inherit this value when unset." }, "spec_strict_no_tasks_md": { "type": "boolean", - "description": "When true, the PreToolUse hook rejects writes to tasks.md and gemba constitution lint reports their presence as an error. Inherits spec_strict by default." + "default": false, + "description": "When true, the PreToolUse hook rejects writes to tasks.md / todo.md and 'gemba constitution lint' reports their presence as an error. Inherits spec_strict by default." }, "require_decision_parent": { "type": "boolean", + "default": false, "description": "When true, spec frontmatter MUST include a decision_parent key. Inherits spec_strict by default." }, "forbid_orphan_beads": { "type": "boolean", + "default": false, "description": "When true, every story bead under the spec's decision_parent must have a spec field referencing the spec. Inherits spec_strict by default." }, "min_ac_count": { @@ -31,8 +45,26 @@ }, "require_priority": { "type": "boolean", + "default": false, "description": "When true, every Story section must declare Priority:. Inherits spec_strict by default." } }, - "additionalProperties": true + "additionalProperties": false, + "examples": [ + { + "schema_version": "1.0.0", + "asdd_mode": false, + "spec_strict": false + }, + { + "schema_version": "1.0.0", + "asdd_mode": true, + "spec_strict": true, + "spec_strict_no_tasks_md": true, + "require_decision_parent": true, + "forbid_orphan_beads": true, + "require_priority": true, + "min_ac_count": 3 + } + ] } diff --git a/internal/spec/templates/constitution.md.tmpl b/internal/spec/templates/constitution.md.tmpl index df44464d..28dcfa98 100644 --- a/internal/spec/templates/constitution.md.tmpl +++ b/internal/spec/templates/constitution.md.tmpl @@ -1,4 +1,15 @@ - + +--- +schema_version: 1.0.0 # constitution schema version (semver) +asdd_mode: false # true => tasks live in bd, not tasks.md +spec_strict: false # master switch for strict ASDD enforcement +spec_strict_no_tasks_md: false # reject writes to tasks.md / todo.md (inherits spec_strict) +require_decision_parent: false # spec frontmatter must include decision_parent (inherits spec_strict) +forbid_orphan_beads: false # every story bead must reference its spec (inherits spec_strict) +require_priority: false # every Story section must declare Priority: (inherits spec_strict) +min_ac_count: 0 # minimum AC list items per Story (0 disables) +--- + # Project Constitution ## Principles @@ -8,15 +19,6 @@ ## Constraints -- ASDD mode is active. Writes to `tasks.md` / `todo.md` are rejected by hook. - -## Config - -```yaml -asdd_mode: true -spec_strict: true -# When true, the PreToolUse hook rejects writes to tasks.md and -# 'gemba constitution lint' reports their presence as an error. -# Defaults to the value of spec_strict. -spec_strict_no_tasks_md: true -``` +- Permissive defaults. Flip knobs in the frontmatter above to opt into ASDD + enforcement (hooks, lint, preflight). See docs/design/asdd-whitepaper.md §10 + for the full key catalog.