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
40 changes: 40 additions & 0 deletions .constitution/prose-baseline.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"createdAt": "2026-07-06T22:30:28.735Z",
"keys": [
"PROSE-SENTENCE-LEN::CONSTITUTION.md:54",
"PROSE-SENTENCE-LEN::CONSTITUTION.md:114",
"PROSE-SENTENCE-LEN::CONSTITUTION.md:135",
"PROSE-SENTENCE-LEN::cli/AGENTS.md:24",
"PROSE-SENTENCE-LEN::cli/AGENTS.md:37",
"PROSE-SENTENCE-LEN::cli/AGENTS.md:53",
"PROSE-SENTENCE-LEN::cli/AGENTS.md:67",
"PROSE-SENTENCE-LEN::cli/AGENTS.md:86",
"PROSE-SENTENCE-LEN::cli/AGENTS.md:98",
"PROSE-SENTENCE-LEN::cli/AGENTS.md:106",
"PROSE-SENTENCE-LEN::decisions/0001-package-managed-distribution.md",
"PROSE-SENTENCE-LEN::decisions/0002-version-axis-separation.md",
"PROSE-STACKED-QUALIFIER::decisions/0002-version-axis-separation.md",
"PROSE-SENTENCE-LEN::decisions/0003-tighten-version-axis-clause.md",
"PROSE-STACKED-QUALIFIER::decisions/0003-tighten-version-axis-clause.md",
"LEDGER-LENGTH::CONSTITUTION.md:204",
"LEDGER-LENGTH::CONSTITUTION.md:233",
"LEDGER-LENGTH::CONSTITUTION.md:254",
"LEDGER-LENGTH::CONSTITUTION.md:272",
"LEDGER-LENGTH::CONSTITUTION.md:301",
"LEDGER-LENGTH::CONSTITUTION.md:341",
"LEDGER-LENGTH::CONSTITUTION.md:378",
"LEDGER-LENGTH::CONSTITUTION.md:430",
"LEDGER-LENGTH::CONSTITUTION.md:458",
"LEDGER-LENGTH::CONSTITUTION.md:489",
"LEDGER-LENGTH::CONSTITUTION.md:511",
"LEDGER-LENGTH::CONSTITUTION.md:532",
"LEDGER-LENGTH::CONSTITUTION.md:561",
"LEDGER-LENGTH::CONSTITUTION.md:584",
"LEDGER-LENGTH::CONSTITUTION.md:605",
"LEDGER-LENGTH::CONSTITUTION.md:632",
"LEDGER-LENGTH::CONSTITUTION.md:675",
"LEDGER-LENGTH::CONSTITUTION.md:695",
"LEDGER-LENGTH::CONSTITUTION.md:708",
"LEDGER-LENGTH::CONSTITUTION.md:727"
]
}
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,11 @@ build/
cli/test-run/

# Framework namespace
.constitution/
# Excluding contents (not the directory itself, `.constitution/*`) so the
# negation below can still reach inside it — git can't un-ignore a file whose
# parent directory is itself excluded.
.constitution/*
# ...except the EXP-0001 baseline snapshot: this must be committed so the
# WARN-ONLY window's false-positive-rate evidence is consistent across
# commits and CI, not re-seeded from scratch on every fresh checkout.
!.constitution/prose-baseline.json
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,17 @@
- `skills/`, `process/`, `templates/`, `decisions/`, `cli/` at the repo root are the
**source of truth** for this repo's own tooling — edit here, then release (version bump
+ tag).
- `experiments/` — this repo's own pre-registered candidate rules under measurement
(`process/experiment-lifecycle.md` governs the shape and states; no separate `AGENTS.md`
yet — an experiment file follows `templates/experiment.md` directly, not a folder-local
statute).

## Source vs. installed artifacts

This repo dogfoods its own CLI: running `constitution install` here (as anywhere) writes
compiled, agent-specific copies into `.claude/skills/`, `.agents/skills/`, and `.cursor/rules/`.

- **Edit**: `skills/`, `process/`, `templates/`, `decisions/`, `cli/` — these are git-tracked and versioned.
- **Edit**: `skills/`, `process/`, `templates/`, `decisions/`, `experiments/`, `cli/` — these are git-tracked and versioned.
- **Never edit**: `.claude/`, `.agents/`, `.cursor/` — gitignored, generated, and overwritten on
every install/upgrade. Treat them like a `dist/` folder. If content there looks stale or wrong,
fix the source in `skills/` and re-run the CLI — don't hand-patch the copy.
Expand Down
14 changes: 13 additions & 1 deletion CONSTITUTION.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# The constitution framework — Constitution

```
framework: constitution@0.17.5 (self-hosted)
framework: constitution@0.17.6 (self-hosted)
ratifier: Chinmay
```

Expand Down Expand Up @@ -174,6 +174,18 @@ on the same Article is the signal that the Article itself needs amending.
Superseded clauses are never deleted — they are kept here with a forward link and the
ADR that justified the change.

### [0.17.6] — 2026-07-06 — Governance prose clarity: WARN-ONLY checks (EXP-0001)
- **What changed:** `constitution audit` gained three WARN-ONLY findings
(`PROSE-SENTENCE-LEN`, `PROSE-STACKED-QUALIFIER`, `LEDGER-LENGTH`) checking
Article/Statute/ADR prose and Ledger entry length. Never blocks; a pre-registered
F-III experiment (`experiments/EXP-0001-governance-prose-clarity.md`) governs
promotion.
- **Why:** F-II's own text got denser with every amendment (ADR-0002, ADR-0003); this
framework's law should catch exactly this kind of drift on itself, not just on the
products it governs.
- **ADR:** none — below the firewall throughout (new engine checks, a new experiment
file, template guidance; no Article text or status touched).

### [0.17.5] — 2026-07-05 — Re-audit Conformance
- **Audit:** Ran `audit-conformance` against all seven Articles, same session as ADR-0003.
- **Findings:**
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,10 @@ anything.

- `CONSTITUTION.md` — the framework's own L0–L1 + amendments ledger
- `process/` — the spec: layers, amendment + experiment lifecycles, conflict resolution, the compiler
- `templates/` — copy-me templates (Article, Statute, ADR, experiment, compiled prompt)
- `templates/` — copy-me templates (Article, Statute, ADR, ledger entry, experiment, constitution, governance map, compiled prompt)
- `decisions/` — the framework's own L3 case law
- `experiments/` — this repo's own pre-registered, WARN-ONLY candidate rules under measurement
(see `process/experiment-lifecycle.md`); dogfooding the lifecycle on the framework itself
- `skills/` — the LLM-judgment skills (`define-preamble`, `harvest-articles`, `compile-prompt`, …)
- `cli/` — the engine + installer (`constitution-cli`; see `cli/README.md`)
- `docs/` — architecture, quickstart, firewall, tone, ops
Expand Down
55 changes: 55 additions & 0 deletions TODOS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# TODOS

## Governance Engine

### Delta-based ratification gate (Approach C)

**What:** `propose-amendment` computes word-count/complexity of a proposed Article
rewrite against the text it replaces, and refuses to mark a "minor/wording" amendment
ratification-ready if density increased on both axes, without an explicit ratifier
override.

**Why:** the WARN-ONLY prose checks (PROSE-SENTENCE-LEN, PROSE-STACKED-QUALIFIER,
LEDGER-LENGTH) catch dense text once it exists. This would catch the *act* of making an
amendment denser, at the moment it happens — the actual observed failure pattern: every
one of F-II's 3 revisions added density, none removed any.

**Context:** proposed during the EXP-0001 (governance-prose-clarity) design review as a
fast-follow, deliberately deferred until the WARN-ONLY window produces real evidence
about what "denser" should mean numerically. No off-the-shelf tool exists for
text-delta complexity scoring; readability-proxy metrics (Flesch-Kincaid-style) are
blunt for technical/legal prose. Reuses `cli/src/engine/prose.ts`'s word-count
primitives as the delta-comparison input.

**Effort:** L
**Priority:** P2
**Depends on:** EXP-0001 shipping first (this PR) — do not start before that lands and
produces the false-positive-rate evidence this gate's thresholds should be based on.

### Fix parseBoldBullets truncating multi-paragraph bullets at blank lines

**What:** `parseBoldBullets` (`cli/src/engine/parse.ts`) stops capturing a bold-bullet's
continuation text at the first blank line, silently dropping any further paragraphs.

**Why:** discovered while calibrating `PROSE-STACKED-QUALIFIER` against Article F-II —
its actual `Principle` text has a second paragraph ("These three are never compared as
one axis, with one exception...") separated from the preceding numbered list by a blank
line. `Article.principle` silently ends before that paragraph, so the "with one
exception" clause is invisible to every check that reads `.principle`, not just the new
prose checks — `ART-*` checks and anything else consuming that field see a truncated
version of the ratified text.

**Context:** not fixed as part of the governance-prose-brevity work — pre-existing
parser behavior, out of scope for that PR. If a future check needs full multi-paragraph
bullet text, `parseBoldBullets` needs to keep consuming past single blank lines,
stopping only at the next bold-bullet marker or a real section boundary. Touches a
shared, load-bearing parser function every existing check depends on — needs its own
careful test coverage (multi-paragraph bullets, where a blank line legitimately means
"the bullet ended" vs. "just a paragraph break") before changing behavior other checks
silently rely on today.

**Effort:** M
**Priority:** P2
**Depends on:** None

## Completed
4 changes: 2 additions & 2 deletions cli/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "constitution-cli",
"version": "0.17.5",
"version": "0.17.6",
"description": "The constitution governance engine: scaffold, audit, firewall-gate, compile, render, and track AI-native product development",
"license": "MIT",
"author": "Chinmay",
Expand Down
135 changes: 134 additions & 1 deletion cli/src/engine/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,20 @@ import * as fs from 'fs';
import * as path from 'path';
import { Instance } from './model';
import { diffLock, readLock } from './lock';
import { opsDir } from './events';
import { countWords, hasStackedQualifier, sentenceLengths } from './prose';

export interface Finding {
code: string;
severity: 'error' | 'warn';
firewall: 'above' | 'below';
where: string; // file[:line] or unit id
message: string;
// Set only on PROSE-*/LEDGER-LENGTH findings (EXP-0001, WARN-ONLY candidate
// rule): true if this finding's (code, where) pair was already present in
// the baseline snapshot taken when the experiment started, false if it's
// new since then. Undefined for every other check.
baseline?: boolean;
}

const ARTICLE_STATUS = ['PROPOSED', 'RATIFIED', 'SUPERSEDED'];
Expand All @@ -23,6 +30,21 @@ const ENFORCEMENT = ['UNGUARDED', 'AUDITED', 'GATED', 'STRUCTURAL'];
const ADR_STATUS = ['proposed', 'accepted', 'superseded'];
const PLACEHOLDER_RE = /<[^>]+>|your name|todo|tbd|xxx/i;

// EXP-0001 draft thresholds — tunable during the WARN-ONLY window, not final.
// experiments/EXP-0001-governance-prose-clarity.md's Hypothesis section states
// these same two numbers in prose; if you retune one here, update that file's
// wording too (or the experiment's own record of what it tested goes stale).
const SENTENCE_WORD_CEILING = 30;
const LEDGER_WORD_CEILING = 150;
const PROSE_BASELINE_FILE = 'prose-baseline.json';

// NOT side-effect-free: on its first call in a given instance, this writes
// .constitution/prose-baseline.json (see writeProseBaseline below) to seed
// the EXP-0001 false-positive baseline. Every caller — the pre-commit hook,
// `constitution audit --json` in CI, and every test that calls audit()
// directly — triggers this on a fresh instance. A future dry-run mode or a
// read-only caller needs to know this before assuming audit() never touches
// disk.
export function audit(instance: Instance): Finding[] {
const f: Finding[] = [];
const doc = instance.constitution;
Expand Down Expand Up @@ -154,6 +176,89 @@ export function audit(instance: Instance): Finding[] {
}
}

// -- governance prose clarity (EXP-0001, WARN-ONLY candidate rule) -----------
// Never blocks anything (severity is always 'warn') — this is evidence
// gathering for a pre-registered experiment (F-III), not yet a ratified
// rule. See experiments/0001-governance-prose-clarity.md.
const proseFindings: Finding[] = [];
const checkProse = (where: string, label: string, text: string) => {
if (!text) return;
const longSentences = sentenceLengths(text).filter((len) => len > SENTENCE_WORD_CEILING);
if (longSentences.length > 0) {
proseFindings.push({
code: 'PROSE-SENTENCE-LEN',
severity: 'warn',
firewall: 'below',
where,
message: `${label}: ${longSentences.length} sentence(s) over ${SENTENCE_WORD_CEILING} words (worst: ${Math.max(...longSentences)}) — candidate rule EXP-0001, not yet ratified`,
});
}
if (hasStackedQualifier(text)) {
proseFindings.push({
code: 'PROSE-STACKED-QUALIFIER',
severity: 'warn',
firewall: 'below',
where,
message: `${label}: stacks 2+ distinct qualifier patterns (em-dash aside / except-unless-scoped-to / nested parenthetical) — candidate rule EXP-0001, not yet ratified`,
});
}
};
for (const a of doc.articles) {
const where = `${rel}:${a.line}`;
checkProse(where, `Article ${a.id} Principle`, a.principle);
checkProse(where, `Article ${a.id} Fitness`, a.fitness);
checkProse(where, `Article ${a.id} Why`, a.why);
}
for (const s of instance.statutes) {
const where = `${s.home}:${s.line}`;
checkProse(where, `statute "${truncate(s.rule)}"`, s.rule);
checkProse(where, `statute "${truncate(s.rule)}" Why`, s.why);
}
for (const adr of instance.adrs) {
checkProse(adr.file, `ADR ${adr.id || adr.file}`, adr.body);
}
for (const entry of doc.ledger) {
const where = `${rel}:${entry.line}`;
const words = countWords(entry.body);
if (words > LEDGER_WORD_CEILING) {
proseFindings.push({
code: 'LEDGER-LENGTH',
severity: 'warn',
firewall: 'below',
where,
message: `ledger entry [${entry.version}] runs ${words} words (cap ${LEDGER_WORD_CEILING}) — narrative belongs in BUILDLOG.md, not the ledger (candidate rule EXP-0001)`,
});
}
}
// Baseline-snapshot: isolate pre-existing (already-known) findings from new
// ones, so the WARN-ONLY window's false-positive-rate metric isn't
// contaminated by the same known-dense text re-firing every commit — these
// checks run over whole documents, not a diff. Self-initializes on first
// run: if no baseline exists yet, today's findings ARE the baseline
// (nothing is "new" on day one). A missing/corrupt baseline file degrades
// to "nothing known" rather than crashing or silently re-seeding over data
// that might still be recoverable.
// Keyed by (code, where) — location, not field. Two dense fields on the same
// Article/Statute line (e.g. both Principle and Fitness) share one key, so a
// fixed Principle can still read as "known" via a still-bad Fitness at the
// same line. Acceptable for a WARN-ONLY evidence-gathering signal; tighten
// to a per-field key only if the WARN-ONLY window's data shows this
// coarseness is actually masking real false-positive-rate signal.
const keyOf = (fnd: Finding) => `${fnd.code}::${fnd.where}`;
const baseline = readProseBaseline(instance.root);
if (baseline === null) {
// Dedupe before writing — multiple fields (Principle + Fitness) on the
// same Article/Statute line produce the same (code, where) key, and an
// undeduped array would grow duplicate entries every time this branch
// ran (adversarial review finding: the committed baseline had 39 keys,
// only 35 unique). A Set is the correct on-disk shape for a key set.
writeProseBaseline(instance.root, [...new Set(proseFindings.map(keyOf))]);
for (const pf of proseFindings) pf.baseline = true;
} else {
for (const pf of proseFindings) pf.baseline = baseline.has(keyOf(pf));
}
f.push(...proseFindings);

// -- the firewall lock (F-IV) -------------------------------------------------
const lock = readLock(instance.root);
if (!lock) {
Expand All @@ -175,14 +280,42 @@ function truncate(s: string, n = 60): string {
return s.length > n ? s.slice(0, n - 1) + '…' : s;
}

// null = no baseline file exists yet (caller should seed one from today's
// findings). A Set (possibly empty) = a baseline exists; empty specifically
// covers a corrupt/malformed file — degrade to "nothing known" rather than
// crash or silently overwrite whatever's there.
function readProseBaseline(root: string): Set<string> | null {
const p = path.join(opsDir(root), PROSE_BASELINE_FILE);
if (!fs.existsSync(p)) return null;
try {
const parsed = JSON.parse(fs.readFileSync(p, 'utf8'));
return new Set(Array.isArray(parsed?.keys) ? parsed.keys : []);
} catch {
return new Set();
}
}

function writeProseBaseline(root: string, keys: string[]): void {
const dir = opsDir(root);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, PROSE_BASELINE_FILE),
JSON.stringify({ createdAt: new Date().toISOString(), keys }, null, 2) + '\n'
);
}

export function formatFindings(findings: Finding[]): string {
if (findings.length === 0) return 'audit clean — 0 findings.';
const lines: string[] = [];
const errors = findings.filter((x) => x.severity === 'error');
const warns = findings.filter((x) => x.severity === 'warn');
for (const x of findings) {
const fw = x.firewall === 'above' ? 'ABOVE-FIREWALL' : 'below';
lines.push(`${x.severity.toUpperCase().padEnd(5)} ${x.code.padEnd(24)} [${fw}] ${x.where} — ${x.message}`);
// baseline === false means new since the EXP-0001 snapshot was taken —
// surfaced here too, not just in --json, so a human running `constitution
// audit` can tell "known since day one" apart from "this commit's doing."
const tag = x.baseline === false ? ' [NEW]' : '';
lines.push(`${x.severity.toUpperCase().padEnd(5)} ${x.code.padEnd(24)} [${fw}] ${x.where} — ${x.message}${tag}`);
}
lines.push('');
lines.push(`${errors.length} error(s), ${warns.length} warning(s). Above-firewall findings need the ratifier; the rest are fixable below (see \`constitution doctor\`).`);
Expand Down
2 changes: 2 additions & 0 deletions cli/src/engine/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export interface LedgerEntry {
version: string;
date: string;
title: string;
body: string; // prose after the heading, to the next entry or end of file
line: number;
}

Expand Down Expand Up @@ -65,6 +66,7 @@ export interface Adr {
title: string;
status: string;
date: string;
body: string; // prose after the YAML frontmatter fence, to end of file
supersedes: string[];
supersededBy: string[];
serves: string[];
Expand Down
Loading
Loading