From 05cadc1df7c3450ab8cc77273f4177256355e95d Mon Sep 17 00:00:00 2001 From: Chinmay <4730291+chinmaygit@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:33:07 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(engine):=20the=20governance=20engine?= =?UTF-8?q?=20=E2=80=94=20parse/audit/lock/tone/ops=20planes=20in=20the=20?= =?UTF-8?q?CLI=20(v0.17.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overhaul session 1 (see BUILDLOG.md + docs/architecture.md). The CLI grows from installer to deterministic governance engine: - cli/src/engine/: typed model + parser for the law plane; structural audit with findings classified above/below the firewall; constitution.lock.json + `constitution firewall` (F-IV as a CI gate — `lock accept` is human-TTY-only); tone rendering as hash-keyed derived views (one canonical text, ever); .constitution/ ops plane (events.jsonl, kanban board + static HTML dashboard, proposals queue, compile packs); doctor (self-heal below the firewall, draft above it, never apply). - 14 vitest cases incl. dogfooding this repo (0 audit errors); CI workflow runs build + tests + audit + firewall gate. - Docs as a product: README rewrite + docs/{architecture,quickstart,firewall, tone,ops}.md; two new cli/AGENTS.md statutes (engine determinism, tests). - Below the firewall throughout: no Article text/status touched. Lock not yet accepted (ratifier's act) and 0.17.0 not yet published (operator npm auth). Co-Authored-By: Claude Fable 5 --- .github/workflows/governance.yml | 45 ++ BUILDLOG.md | 119 +++ CONSTITUTION.md | 42 +- README.md | 119 +-- cli/AGENTS.md | 20 + cli/README.md | 12 +- cli/package-lock.json | 1261 +++++++++++++++++++++++++++++- cli/package.json | 8 +- cli/src/engine/audit.ts | 162 ++++ cli/src/engine/board.ts | 198 +++++ cli/src/engine/compile.ts | 108 +++ cli/src/engine/doctor.ts | 113 +++ cli/src/engine/events.ts | 82 ++ cli/src/engine/lock.ts | 80 ++ cli/src/engine/model.ts | 130 +++ cli/src/engine/parse.ts | 385 +++++++++ cli/src/engine/proposals.ts | 118 +++ cli/src/engine/tone.ts | 196 +++++ cli/src/index.ts | 383 ++++++++- cli/test/engine.test.ts | 261 +++++++ cli/test/fixture.ts | 87 +++ constitution.config.json | 3 + docs/architecture.md | 109 +++ docs/firewall.md | 44 ++ docs/ops.md | 53 ++ docs/quickstart.md | 73 ++ docs/tone.md | 35 + 27 files changed, 4171 insertions(+), 75 deletions(-) create mode 100644 .github/workflows/governance.yml create mode 100644 BUILDLOG.md create mode 100644 cli/src/engine/audit.ts create mode 100644 cli/src/engine/board.ts create mode 100644 cli/src/engine/compile.ts create mode 100644 cli/src/engine/doctor.ts create mode 100644 cli/src/engine/events.ts create mode 100644 cli/src/engine/lock.ts create mode 100644 cli/src/engine/model.ts create mode 100644 cli/src/engine/parse.ts create mode 100644 cli/src/engine/proposals.ts create mode 100644 cli/src/engine/tone.ts create mode 100644 cli/test/engine.test.ts create mode 100644 cli/test/fixture.ts create mode 100644 constitution.config.json create mode 100644 docs/architecture.md create mode 100644 docs/firewall.md create mode 100644 docs/ops.md create mode 100644 docs/quickstart.md create mode 100644 docs/tone.md diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml new file mode 100644 index 0000000..9b7d5b0 --- /dev/null +++ b/.github/workflows/governance.yml @@ -0,0 +1,45 @@ +name: governance + +on: + push: + branches: [main] + pull_request: + +jobs: + engine: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Install + working-directory: cli + run: npm ci + - name: Build (strict tsc) + working-directory: cli + run: npm run build + - name: Engine tests + working-directory: cli + run: npm test + + law: + runs-on: ubuntu-latest + needs: engine + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Build engine + working-directory: cli + run: npm ci && npm run build + - name: Structural audit (L0–L4 graph) + run: node cli/dist/index.js audit + - name: Firewall gate (F-IV — ratified text vs. accepted lock) + run: | + if [ -f constitution.lock.json ]; then + node cli/dist/index.js firewall + else + echo "no constitution.lock.json yet — firewall gate skipped (audit already warns)." + fi diff --git a/BUILDLOG.md b/BUILDLOG.md new file mode 100644 index 0000000..9a6fec3 --- /dev/null +++ b/BUILDLOG.md @@ -0,0 +1,119 @@ +# BUILDLOG — the overhaul, session by session + +Running log for the multi-session overhaul (goal set 2026-07-04): turn `constitution` +into an installable AI-governance product. Later sessions: read this before doing +anything. Every entry records what was tried, what broke, and what was verified by +actually running it. "Untested" means untested. + +Design: [docs/architecture.md](docs/architecture.md). Non-negotiables from the goal: +law plane stays concise; tone is a read-time view (one canonical text, ever); ops +visibility lives in `.constitution/`, not the law; F-IV firewall — no agent writes +`status: RATIFIED` or edits ratified L0/L1; above-firewall changes are queued as +proposals, never applied. + +--- + +## Session 1 — 2026-07-04 (worktree elastic-chebyshev-ec0c49, base f85c793 v0.16.12) + +### Scope chosen +Phase 1 of the overhaul: build the **governance engine** into the existing CLI package +(`cli/`, `@chinmaygit/constitution-cli`) rather than a new package — it already has the +bin, the vendoring pipeline, and a GitHub Packages release path. Workstreams: + +1. Engine core: typed model + parser for the law plane (CONSTITUTION.md, governance + map, statute homes, ADRs) + canonical hashing. +2. Deterministic structural audit (machine version of audit-structure's deterministic + subset) with findings classified above/below the firewall. +3. `constitution.lock.json` + `constitution firewall` — the firewall as a CI gate + (F-IV enforcement AUDITED → GATED). `lock accept` is TTY-only + typed confirmation. +4. Ops plane: `.constitution/events.jsonl`, `constitution feature `, + `constitution board` (terminal + static HTML dashboard). +5. Tone rendering with hash-keyed cache + drift check (`render`, `tones check`). +6. Compile pack (`constitution compile`), proposals queue + human-only `ratify`, + `doctor` (autofix below firewall, queue drafts above). +7. Tests (vitest) incl. dogfooding parse/audit against this repo itself. + +### Decisions (and why) +- **Engine lives in the existing `cli/` package.** A second package would split the + version axis the ledger's [0.16.10] statute just unified. +- **Parser targets the existing document shapes** (header fence, `**P1.**` lines, + `### Article ` + backtick field line, statute bullets with `· serves:` + / `· enforced-by:`, ADR YAML frontmatter). No format migration — the law documents + are already machine-regular; changing the format would churn ratified text (above + the firewall) for no gain. +- **Lockfile hashes normalized text** (collapse whitespace) so reflow ≠ amendment. +- **No new runtime deps** beyond existing `prompts`. Tone generation shells out to + `claude -p` when present; degrades to explicit "generation unavailable" rather than + silently serving stale renders. +- **Ledger entry for this work is drafted but the version bump is real** — below the + firewall (tooling + docs; no Article text touched, no status changed). Entry marked + as agent-authored pending operator review. + +### What happened +- Built all seven workstreams into `cli/src/engine/` (`model`, `parse`, `audit`, `lock`, + `events`, `board`, `tone`, `compile`, `proposals`, `doctor`) + rewrote `cli/src/index.ts` + as the full subcommand dispatcher. `npm run build` (strict tsc) clean. +- Docs: `docs/{architecture,quickstart,firewall,tone,ops}.md`, README rewritten as a + product, `cli/README.md` intro updated. CI: `.github/workflows/governance.yml` + (build + test + audit + conditional firewall gate). +- Version: `0.17.0` in CONSTITUTION.md header + ledger entry; `cli/package.json` synced + **by `constitution doctor` itself** via the new committed `constitution.config.json` + (`versionSync`) — the self-healing path validated on its first real use. +- Two new `cli/AGENTS.md` statutes: engine determinism (only LLM call is tone's + injectable generator); engine changes ship with failing-first tests. + +### What broke (and the fixes) +1. **Map parser over-matched**: existence-checking every backtick token in AGENTS.md + produced 6 false MAP-BROKEN-LINK errors (prose mentions like `SKILL.md`, gitignored + dirs). Fix: only real markdown links are existence-checked; backticks only classify + the constitution/decisions declarations. +2. **Statute parser dropped 13 of 16 statutes**: bullets whose bold rule closes on line 1 + but continue with indented commentary before the `· serves:` lines bailed out with no + annotations. Fix: pre-annotation indented prose folds into the rule text. 3 → 16 + statutes parsed on this repo. +3. **`normalize` kept newlines**, so re-wrapping a paragraph changed the canonical hash — + contradicting the reflow-safe lock design. Fix: collapse ALL whitespace. +4. **Tone cache read returned the embedded "derived artifact" HTML comment** as part of + the render body. Fix: strip it on read. +5. **Doctor test fixture** orphaned P2, producing a second (correct) above-firewall + finding my test didn't expect — the engine was right, the test mutation was wrong. + +### Verified by running (all on this machine, this session) +- `cd cli && npm run build` — strict tsc, no errors. +- `cd cli && npm test` — **14/14 vitest cases pass**, incl. the dogfood test: parses this + repo (P1; F-I…F-VII; 16 statutes; ADR-0001) and audits it with zero errors. +- `node cli/dist/index.js audit` on this repo → exit 0, two honest warnings: + F-III `HOLDS+UNGUARDED` mechanization debt (a real, pre-existing condition) and + `LOCK-MISSING`. +- `lock status` lists the 8 ratified units with hashes; `lock accept < /dev/null` + **refuses** (non-TTY, F-IV message) — the agent-can't-cross-it property, demonstrated. +- Lock unit tests prove: editing ratified text → `changed`; agent flipping + PROPOSED→RATIFIED → `added`; repeal → `removed`; conformance/enforcement edits do NOT + trip the gate; reflow does NOT trip the gate. +- `feature declare/start` + `compile --out` + `board` + `board --html` → real events in + `.constitution/events.jsonl`, correct terminal Kanban, `board.html` written. +- `doctor` → pruned nothing (nothing stale), queued nothing (no above-firewall findings), + synced `cli/package.json` 0.16.12 → 0.17.0. Proposal queue/dedupe/ruling covered by + tests (doctor queues above-firewall drafts exactly once; never edits the law file — + byte-compared in the test). +- Tone: stub-generator tests prove generate → cache-hit → stale-on-amend → refuse/prune. + +### Known-untested / deferred (next sessions pick up here) +- **Tone generation with a real LLM**: `claude -p` exists here but nested invocation gets + 401 inside this session — engine degrades honestly (verified); real render quality + unverified. +- **`constitution lock accept` on this repo is the operator's act** (F-IV): Chinmay runs + it in a terminal, commits `constitution.lock.json`; CI's firewall step then goes live. +- Publishing `0.17.0` to GitHub Packages (operator npm auth). +- `ratify` interactive flow untested end-to-end (needs a TTY); logic unit-tested via + `recordRuling`. +- Skills not yet rewired to consume engine output (`compile-prompt` should ingest + `constitution compile` packs; `audit-structure` should start from `audit --json`). +- DSAMind adoption; `init` against oddly-shaped repos (pre-existing known gap); a served + (live) dashboard beyond static HTML; multi-instance registry telemetry. + +### Known-untested / deferred +- Publishing 0.17.0 to GitHub Packages (needs operator's npm auth). +- Tone generation quality (needs `claude` CLI at runtime; engine tested with a stub). +- DSAMind adoption of the new engine (separate repo, separate session). +- Multi-instance/registry telemetry, web dashboard beyond static HTML — later phases. diff --git a/CONSTITUTION.md b/CONSTITUTION.md index b7e416b..1edb024 100644 --- a/CONSTITUTION.md +++ b/CONSTITUTION.md @@ -1,7 +1,7 @@ # The constitution framework — Constitution ``` -framework: constitution@0.16.12 (self-hosted) +framework: constitution@0.17.0 (self-hosted) ratifier: Chinmay ``` @@ -157,6 +157,46 @@ 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.0] — 2026-07-04 — The governance engine: the CLI becomes the product's deterministic core +- **Overhaul session 1** (see `BUILDLOG.md` + `docs/architecture.md` for the full record and + design). The CLI grows from installer to engine — everything below is deterministic code in + `cli/src/engine/`, tested (`cli/test/`, 14 vitest cases incl. a dogfood test that parses and + audits this very repo) and wired into CI (`.github/workflows/governance.yml`). +- **Three-plane architecture made physical**: law plane (this file, statute homes, `decisions/` + — unchanged format, now machine-parsed), engine (the CLI), ops plane (`.constitution/` — + events, tone cache, proposal queue, compiles, board; volume lives there, never here). +- **The firewall becomes a gate** (F-IV enforcement path AUDITED → GATED once adopted): + `constitution.lock.json` records canonical hashes of ratified L0/L1 units, written only by + `constitution lock accept` (interactive TTY + typed confirmation — refuses agents/pipes, + verified); `constitution firewall` fails CI on changed/added/removed ratified units. + **The lock is not yet accepted** — that is the ratifier's own act, pending. +- **`constitution audit`** — deterministic structural audit (refs resolve, layers trace up, + fields legal, ledger/version sync, lock drift), findings classified by what the FIX touches + (above/below firewall). Ran clean on this repo: 0 errors, 2 honest warnings (F-III + mechanization debt; lock missing). +- **`constitution doctor`** — self-healing below the firewall (prunes stale tone renders, + version-syncs `constitution.config.json` targets, repairs ops scaffold); above-firewall + findings are DRAFTED into `.constitution/proposals/` and wait for `constitution ratify` + (human-only, interactive) — never applied. +- **Tone as a view** (`constitution render --tone plain|casual|formal`): one canonical + text ever; renders are derived artifacts cache-keyed by canonical hash + transform version, + stale by construction on amendment; `tones check` detects, doctor prunes. LLM generation via + `claude -p` (untested end-to-end in-session — no nested auth; stub-tested). +- **Ops visibility** (`constitution feature `, `constitution board [--html]`): Kanban + over `.constitution/events.jsonl` (Declared → Compiled → Building → Validating → Shipped) + plus a governance-health strip; reads the law by id, stores nothing in it. +- **`constitution compile "" [--out]`** — emits the deterministic L4 compile pack (all + ratified canonical units + statute/ADR indexes + the briefing contract); judgment stays in + the `compile-prompt` skill, which now compiles over guaranteed-complete, current law. +- Docs rewritten as a product (`README.md`, `docs/`); two new `cli/AGENTS.md` statutes + (engine determinism; failing-first engine tests). Statute parser fix along the way: bullets + whose bold rule closes before indented commentary were silently dropped (3 → 16 statutes + parsed here). +- **Below the firewall throughout** — no Article text, status, or L0 line touched; the parser + targets the existing document shapes. Authored autonomously per the standing overhaul goal; + entry pending the operator's review. `cli/package.json` → `0.17.0` (sync statute holding). + Not yet published to GitHub Packages (operator's npm auth required). + ### [0.16.12] — 2026-07-01 — `AGENT.md` → `AGENTS.md` (amends F-VII); scaffold reads real templates - **`AGENT.md` renamed to `AGENTS.md` everywhere** — singular was wrong. `AGENTS.md` (plural) is the actual cross-tool convention; DSAMind itself already has a real one (its own diff --git a/README.md b/README.md index 7128be1..bb91b68 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,20 @@ # constitution -A framework for governing software development in the AI era — where coding agents -are not tools that read docs, but **first-class actors in the system that governs them**. - -This repo defines *how to govern* a product's evolution. It does not contain any one -product's rules. Those live in the products that adopt it. - -## The two-project model - -``` -constitution/ (this repo) DSAMind (the founding instance + live lab) - defines L0–L4, the amendment adopts the templates + process, - + experiment lifecycles, the pins a framework version, and - compiler, and the templates DISCOVERS rules by running experiments - │ │ - │ adopt + pin @version │ - └────────────────────────►────────────────┘ - ◄──────────────── - promote proven *governing mechanisms* back up - (domain rules stay in the product) +**Governance for AI-native product development** — where coding agents are not tools +that read docs, but first-class actors in the system that governs them. `constitution` +takes a product's vision (L0), the durable invariants that enforce it (L1), the +operational rules that implement those invariants (L2), and the dated decisions that +interpret all of it (L3) — and compiles that stack, on demand, into the exact briefing +an implementing agent runs for a given task (L4). The point of every layer: shrink the +gap between what a human intended and what the agent actually shipped, and make that +gap **checkable**. + +```bash +npm install -g @chinmaygit/constitution-cli +cd your-product && constitution init ``` -The framework grows **only** through evidence produced by live projects — that is its -first article (`F-I`, discovery before codification). DSAMind is the founding instance. +→ **[docs/quickstart.md](docs/quickstart.md)** for the ten-minute path. ## The layers (see `process/layers.md`) @@ -34,36 +26,71 @@ first article (`F-I`, discovery before codification). DSAMind is the founding in | L3 | Case law — ADRs, decisions in context | per decision | author, accrues | | L4 | Compiled briefing handed to the implementing actor | every task | author + enforce | -A firewall sits between L1 and L2: agents own everything below it and may only -*petition* above it. Humans hold the sovereign pen on vision and invariants. +A **firewall** sits between L1 and L2: agents own everything below it and may only +*petition* above it. Humans hold the sovereign pen on vision and invariants — and the +engine makes that a CI gate, not a hope: `constitution.lock.json` records the hash of +every ratified unit as a human accepted it, and `constitution firewall` fails the +build on any unaccepted drift. See [docs/firewall.md](docs/firewall.md). -## Self-hosting +## The three planes (see [docs/architecture.md](docs/architecture.md)) -This repo is governed by its own framework. `CONSTITUTION.md` is an *instance* of the -spec in `process/` and `templates/`, applied to the framework's own development. -If the framework can't govern itself, it can't govern anything. +- **Law plane** — `CONSTITUTION.md`, statute homes, `decisions/`. Small, dense, + durable; nothing about scale ever accumulates here. +- **Engine** — the `constitution` CLI: deterministic parse → audit → firewall gate → + L4 compile pack → tone render → doctor. LLM judgment stays in the skills that + consume engine output. +- **Ops plane** — `.constitution/` in each instance: delivery events, the Kanban + board (`constitution board --html`), tone caches, the ratification queue. Volume + lives here, references the law by id, and is deletable without touching legality. + See [docs/ops.md](docs/ops.md). -## Repo map +## Reading the law in your tone -- `CONSTITUTION.md` — the framework's own L0–L1 (self-hosted) + amendments ledger -- `process/` — the spec: layer definitions, amendment + experiment lifecycles, conflict resolution, the L4 compiler -- `templates/` — copy-me templates: Article, Experiment, ADR, compiled prompt -- `decisions/` — the framework's own ADRs (its L3 case law) -- `skills/` — the operational skills (`define-preamble`, `harvest-articles`, `harvest-statutes`, `derive-statutes`, `audit-structure`, `audit-conformance`, `reconcile-findings`, `propose-amendment`, `ratify-amendment`, `compile-prompt`, `sync-operator`) — how day-to-day work actually happens -- `cli/` — `constitution-cli`, the package-managed installer that scaffolds this framework into a product repo (see `cli/README.md`) -- `registry.md` — which projects use the framework, and which mechanisms were promoted from where +One canonical, ratified text per unit — ever. `constitution render F-II --tone plain` +is a derived view, cache-keyed by the canonical hash, stale by construction the moment +the law is amended. See [docs/tone.md](docs/tone.md). -Each of `skills/`, `templates/`, `decisions/`, `process/`, and `cli/` declares its own L2 -authoring statutes in a nested `AGENTS.md` — see the root [AGENTS.md](AGENTS.md) governance map. +## Self-healing, split by the firewall -## Consuming the framework (from a product repo) +`constitution doctor` fixes what it may (stale caches, version sync, scaffold gaps) +and drafts what it may not: above-firewall findings become proposals in +`.constitution/proposals/`, ruled on only by `constitution ratify` — interactive, +human, typed confirmation. + +## The two-project model -The framework installs via its CLI (`cli/`), per -[ADR-0001](decisions/0001-package-managed-distribution.md) — never by hand-vendoring -templates. See [`cli/README.md`](cli/README.md) for the exact steps (it's local-only -today, not yet published to a registry). +``` +constitution/ (this repo) consumer products (DSAMind is the founding instance) + defines L0–L4, the lifecycles, adopt via `constitution init`, pin a version, + the engine, and the templates and DISCOVER rules by running experiments + │ │ + └────────── adopt + pin @version ─────────┘ + ◄──────────────── + promote proven *governing mechanisms* back up + (domain rules stay in the product) +``` + +The framework grows **only** through evidence produced by live projects (`F-I`, +discovery before codification). Consumers and promoted mechanisms: [registry.md](registry.md). + +## Self-hosting + +This repo is governed by its own framework — `CONSTITUTION.md` here is an *instance* +of the spec in `process/` + `templates/`, the CI in `.github/workflows/governance.yml` +runs the engine's audit + firewall on it, and the engine's test suite parses this very +repo as its dogfood fixture. If the framework can't govern itself, it can't govern +anything. + +## Repo map -Pin the version you've adopted in your product's `CONSTITUTION.md` header -(`framework: constitution@X.Y.Z`) and track it in [registry.md](registry.md). Bump the -pin only once you've actually adopted the newer spec — never ahead of adoption (see -`skills/sync-operator`). +- `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) +- `decisions/` — the framework's own L3 case law +- `skills/` — the LLM-judgment skills (`define-preamble`, `harvest-articles`, `compile-prompt`, …) +- `cli/` — the engine + installer (`@chinmaygit/constitution-cli`; see `cli/README.md`) +- `docs/` — architecture, quickstart, firewall, tone, ops +- `registry.md` — consumers + promoted mechanisms · `BUILDLOG.md` — the overhaul's running log + +Each of `skills/`, `templates/`, `decisions/`, `process/`, `cli/` declares its own L2 +statutes in a nested `AGENTS.md` — see the root [AGENTS.md](AGENTS.md) governance map. diff --git a/cli/AGENTS.md b/cli/AGENTS.md index 58d954c..a5dfa46 100644 --- a/cli/AGENTS.md +++ b/cli/AGENTS.md @@ -63,6 +63,26 @@ package-managed distribution mechanism, per in an installed package. Caught by the first real install into a repo that wasn't this one (DSAMind) — exactly the live-practice discovery F-I asks for. +- **`src/engine/` is deterministic — the only LLM call in the package is `tone.ts`'s + explicit, injectable generator.** Parse, audit, lock, events, board, compile-pack, + proposals, and doctor produce identical output for identical input; anything requiring + judgment (placing a task under the law, phrasing a tone render, harvesting rules) + belongs in a skill that *consumes* engine output, never inside the engine. + · serves: F-IV (a gate you can't reproduce is a gate you can't trust) + · enforced-by: CI (`npm test` — the vitest suite asserts engine behavior on fixtures + and on this very repo; tone tests inject a stub generator) + · why: the firewall gate and the audit are only as trustworthy as they are + reproducible — an LLM inside them would make "did ratified text change?" a matter + of opinion. + +- **Engine behavior changes ship with a failing-first test in `cli/test/`.** The suite + includes the self-hosted dogfood test (parses and audits this repo); if a change to + the law plane's *format* breaks parsing, that test is the tripwire. + · serves: general craft + · enforced-by: CI (`npm test` in `.github/workflows/governance.yml`) + · why: the engine reads legal documents; a silent parse regression doesn't crash — it + quietly under-audits, which is worse. + - **`scaffold.ts` never hardcodes the shape of a file it writes into a consumer repo.** `CONSTITUTION.md` is generated from `templates/constitution.md`; `AGENTS.md`'s governance map block is generated from `templates/governance-map.md`. Placeholders (``, diff --git a/cli/README.md b/cli/README.md index 5530035..7af40dd 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,8 +1,14 @@ # constitution-cli -Scaffolds this framework into a product repo — package-managed distribution per -[ADR-0001](../decisions/0001-package-managed-distribution.md). The files it writes into -the target repo are read-only build artifacts, not hand-vendored copies. Engineering +The framework's **engine and installer**. `constitution init` scaffolds the framework +into a product repo — package-managed distribution per +[ADR-0001](../decisions/0001-package-managed-distribution.md); the files it writes are +read-only build artifacts, not hand-vendored copies. Beyond `init`, the binary is the +deterministic governance engine: `audit`, `firewall`/`lock`, `compile`, `render`/`tones`, +`feature`/`board`, `doctor`, `proposals`/`ratify` — run `constitution --help`, and see +[../docs/quickstart.md](../docs/quickstart.md) for the workflow. Engine code lives in +`src/engine/` (see [AGENTS.md](AGENTS.md) for its statutes, including determinism and +the failing-first test rule); tests in `test/` run via `npm test`. Engineering conventions for this package's own code are in [AGENTS.md](AGENTS.md), not here. ## Status diff --git a/cli/package-lock.json b/cli/package-lock.json index 3802551..5267fc9 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,13 @@ { - "name": "constitution-cli", - "version": "1.0.0", + "name": "@chinmaygit/constitution-cli", + "version": "0.16.12", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "constitution-cli", - "version": "1.0.0", + "name": "@chinmaygit/constitution-cli", + "version": "0.16.12", + "license": "MIT", "dependencies": { "prompts": "^2.4.2" }, @@ -16,9 +17,387 @@ "devDependencies": { "@types/node": "^20.0.0", "@types/prompts": "^2.4.9", - "typescript": "^5.0.0" + "typescript": "^5.0.0", + "vitest": "^4.1.9" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", @@ -40,6 +419,216 @@ "kleur": "^3.0.3" } }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -49,6 +638,366 @@ "node": ">=6" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -62,12 +1011,129 @@ "node": ">= 6" } }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -88,6 +1154,191 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } } } } diff --git a/cli/package.json b/cli/package.json index 7196d8e..cdda898 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,7 +1,7 @@ { "name": "@chinmaygit/constitution-cli", - "version": "0.16.12", - "description": "CLI to scaffold the constitution framework", + "version": "0.17.0", + "description": "The constitution governance engine: scaffold, audit, firewall-gate, compile, render, and track AI-native product development", "license": "MIT", "author": "Chinmay", "main": "dist/index.js", @@ -30,6 +30,7 @@ "vendor": "node scripts/vendor.js", "prebuild": "npm run vendor", "build": "tsc", + "test": "vitest run", "prepack": "npm run vendor", "start": "node dist/index.js" }, @@ -39,6 +40,7 @@ "devDependencies": { "@types/node": "^20.0.0", "@types/prompts": "^2.4.9", - "typescript": "^5.0.0" + "typescript": "^5.0.0", + "vitest": "^4.1.9" } } diff --git a/cli/src/engine/audit.ts b/cli/src/engine/audit.ts new file mode 100644 index 0000000..aa039be --- /dev/null +++ b/cli/src/engine/audit.ts @@ -0,0 +1,162 @@ +// Deterministic structural audit — the machine-checkable subset of the +// audit-structure skill, runnable in CI. Every finding is classified by what +// its FIX touches (the reconcile-findings discipline): `above` the firewall +// means the fix edits ratified L0/L1 substance and must go through a human; +// `below` means the engine or an agent may fix it directly. + +import * as fs from 'fs'; +import * as path from 'path'; +import { Instance } from './model'; +import { diffLock, readLock } from './lock'; + +export interface Finding { + code: string; + severity: 'error' | 'warn'; + firewall: 'above' | 'below'; + where: string; // file[:line] or unit id + message: string; +} + +const ARTICLE_STATUS = ['PROPOSED', 'RATIFIED', 'SUPERSEDED']; +const CONFORMANCE = ['HOLDS', 'VIOLATED', 'UNVERIFIED']; +const ENFORCEMENT = ['UNGUARDED', 'AUDITED', 'GATED', 'STRUCTURAL']; +const ADR_STATUS = ['proposed', 'accepted', 'superseded']; +const PLACEHOLDER_RE = /<[^>]+>|your name|todo|tbd|xxx/i; + +export function audit(instance: Instance): Finding[] { + const f: Finding[] = []; + const doc = instance.constitution; + const rel = path.relative(instance.root, doc.file) || path.basename(doc.file); + + // -- header --------------------------------------------------------------- + if (!doc.version) + f.push({ code: 'HEADER-PIN', severity: 'error', firewall: 'below', where: rel, message: 'no `framework: constitution@` pin in the header fence' }); + if (!doc.ratifier || PLACEHOLDER_RE.test(doc.ratifier)) + f.push({ code: 'HEADER-RATIFIER', severity: 'error', firewall: 'above', where: rel, message: `ratifier is unset or a placeholder ("${doc.ratifier}") — F-IV requires a real human ratifier` }); + + for (const note of doc.parseNotes) + f.push({ code: 'PARSE', severity: 'warn', firewall: 'below', where: rel, message: note }); + + // -- L0 (F-V) --------------------------------------------------------------- + if (doc.preamble.length === 0) + f.push({ code: 'L0-EMPTY', severity: 'warn', firewall: 'above', where: rel, message: 'L0 has no P lines — the vision is undefined (run define-preamble with the ratifier)' }); + if (doc.preamble.length > 3) + f.push({ code: 'L0-SIZE', severity: 'error', firewall: 'above', where: rel, message: `L0 holds ${doc.preamble.length} statements; F-V caps it at 3 — distill, don't accumulate` }); + + const l0Ids = new Set(doc.preamble.map((p) => p.id)); + const served = new Set(); + + // -- L1 articles ------------------------------------------------------------ + const seen = new Set(); + for (const a of doc.articles) { + const where = `${rel}:${a.line}`; + if (seen.has(a.id)) + f.push({ code: 'ART-DUP', severity: 'error', firewall: 'below', where, message: `Article ${a.id} appears twice (F-II: one home per rule)` }); + seen.add(a.id); + + if (!ARTICLE_STATUS.includes(a.status)) + f.push({ code: 'ART-STATUS', severity: 'error', firewall: 'above', where, message: `Article ${a.id}: status "${a.status}" is not ${ARTICLE_STATUS.join('|')}` }); + if (!CONFORMANCE.includes(a.conformance)) + f.push({ code: 'ART-CONF', severity: 'error', firewall: 'below', where, message: `Article ${a.id}: conformance "${a.conformance}" is not ${CONFORMANCE.join('|')}` }); + if (!ENFORCEMENT.includes(a.enforcement)) + f.push({ code: 'ART-ENF', severity: 'error', firewall: 'below', where, message: `Article ${a.id}: enforcement "${a.enforcement}" is not ${ENFORCEMENT.join('|')}` }); + if (!a.principle) + f.push({ code: 'ART-PRINCIPLE', severity: 'error', firewall: 'above', where, message: `Article ${a.id}: no Principle bullet` }); + if (!a.fitness) + f.push({ code: 'ART-FITNESS', severity: 'error', firewall: 'above', where, message: `Article ${a.id}: no Fitness bullet — an Article without a fitness signal is unfalsifiable` }); + if (a.serves.length === 0) + f.push({ code: 'ART-SERVES', severity: 'error', firewall: 'above', where, message: `Article ${a.id}: Serves names no L0 line — every Article must trace up` }); + for (const s of a.serves) { + if (!l0Ids.has(s)) + f.push({ code: 'ART-SERVES-DANGLING', severity: 'error', firewall: 'above', where, message: `Article ${a.id}: serves ${s}, which is not an L0 line in this document` }); + served.add(s); + } + if (a.status === 'RATIFIED' && a.conformance === 'HOLDS' && a.enforcement === 'UNGUARDED') + f.push({ code: 'ART-MECH-DEBT', severity: 'warn', firewall: 'below', where, message: `Article ${a.id}: HOLDS + UNGUARDED — true today, protected by nothing (mechanization debt; add a statute + gate)` }); + } + + for (const p of doc.preamble) { + if (!served.has(p.id) && doc.articles.length > 0) + f.push({ code: 'L0-UNSERVED', severity: 'warn', firewall: 'above', where: `${rel}:${p.line}`, message: `${p.id} is served by no Article — the vision line is unenforced` }); + } + + // -- ledger ----------------------------------------------------------------- + if (doc.ledger.length > 0 && doc.version && doc.ledger[0].version !== doc.version) + f.push({ code: 'LEDGER-SYNC', severity: 'error', firewall: 'below', where: `${rel}:${doc.ledger[0].line}`, message: `header pins ${doc.version} but the newest ledger entry is [${doc.ledger[0].version}] — one number for the whole repo` }); + + // -- governance map ----------------------------------------------------------- + if (!instance.map) { + f.push({ code: 'MAP-MISSING', severity: 'warn', firewall: 'below', where: instance.root, message: 'no Governance Map found in AGENTS.md/CLAUDE.md — audit-structure and compile have no entry-point index' }); + } else { + for (const { path: p, line } of instance.map.linkedPaths) { + if (!fs.existsSync(path.join(instance.root, p))) + f.push({ code: 'MAP-BROKEN-LINK', severity: 'error', firewall: 'below', where: `${instance.map.file}:${line}`, message: `governance map references ${p}, which does not exist` }); + } + } + + // -- statutes (L2, F-VII) ----------------------------------------------------- + const articleIds = new Set(doc.articles.map((a) => a.id)); + for (const s of instance.statutes) { + const where = `${s.home}:${s.line}`; + if (!s.serves) + f.push({ code: 'STAT-SERVES', severity: 'warn', firewall: 'below', where, message: `statute "${truncate(s.rule)}" has no · serves: annotation` }); + if (!s.enforcedBy) + f.push({ code: 'STAT-ENF', severity: 'warn', firewall: 'below', where, message: `statute "${truncate(s.rule)}" has no · enforced-by: annotation (a statute without a mechanism is a wish)` }); + const idRef = s.serves.match(/^([A-Z]+-[IVXLC]+|[A-Z]\d+)\b/); + if (idRef && !articleIds.has(idRef[1]) && !l0Ids.has(idRef[1])) + f.push({ code: 'STAT-SERVES-DANGLING', severity: 'error', firewall: 'below', where, message: `statute "${truncate(s.rule)}" serves ${idRef[1]}, which resolves to no Article or L0 line` }); + } + + // -- ADRs (L3) ------------------------------------------------------------------ + const adrIds = new Set(instance.adrs.map((a) => a.id)); + for (const adr of instance.adrs) { + for (const note of adr.parseNotes) + f.push({ code: 'ADR-PARSE', severity: 'warn', firewall: 'below', where: adr.file, message: note }); + if (adr.id && !ADR_STATUS.includes(adr.status)) + f.push({ code: 'ADR-STATUS', severity: 'warn', firewall: 'below', where: adr.file, message: `status "${adr.status}" is not ${ADR_STATUS.join('|')}` }); + for (const s of [...adr.serves, ...adr.amends]) { + if (!articleIds.has(s) && !l0Ids.has(s)) + f.push({ code: 'ADR-SERVES-DANGLING', severity: 'warn', firewall: 'below', where: adr.file, message: `cites ${s}, which resolves to no Article or L0 line here` }); + } + for (const sup of adr.supersedes) { + if (!adrIds.has(sup)) + f.push({ code: 'ADR-SUPERSEDES-DANGLING', severity: 'error', firewall: 'below', where: adr.file, message: `supersedes ADR ${sup}, which does not exist` }); + } + if (adr.status === 'superseded' && adr.supersededBy.length === 0) + f.push({ code: 'ADR-NO-FORWARD-LINK', severity: 'error', firewall: 'below', where: adr.file, message: 'superseded but superseded_by is empty — L3 requires a forward link, never deletion' }); + } + + // -- the firewall lock (F-IV) ------------------------------------------------- + const lock = readLock(instance.root); + if (!lock) { + f.push({ code: 'LOCK-MISSING', severity: 'warn', firewall: 'below', where: instance.root, message: 'no constitution.lock.json — the firewall is unguarded; have the ratifier run `constitution lock accept`' }); + } else { + const diff = diffLock(instance, lock); + for (const id of diff.changed) + f.push({ code: 'LOCK-DRIFT', severity: 'error', firewall: 'above', where: id, message: `ratified text of ${id} differs from the hash the ratifier accepted — either revert, or a human re-runs \`constitution lock accept\`` }); + for (const id of diff.added) + f.push({ code: 'LOCK-UNACCEPTED', severity: 'error', firewall: 'above', where: id, message: `${id} is RATIFIED but absent from the lock — ratification requires a human \`constitution lock accept\`` }); + for (const id of diff.removed) + f.push({ code: 'LOCK-REMOVED', severity: 'error', firewall: 'above', where: id, message: `${id} was accepted as ratified but is no longer ratified/present — repeal also crosses the firewall` }); + } + + return f; +} + +function truncate(s: string, n = 60): string { + return s.length > n ? s.slice(0, n - 1) + '…' : s; +} + +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}`); + } + 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\`).`); + return lines.join('\n'); +} diff --git a/cli/src/engine/board.ts b/cli/src/engine/board.ts new file mode 100644 index 0000000..4e60e0c --- /dev/null +++ b/cli/src/engine/board.ts @@ -0,0 +1,198 @@ +// Folds the ops event log into Kanban state and renders it (terminal + static +// HTML). Reads the law plane for the governance health strip; writes nothing +// anywhere near it. + +import { DeliveryEvent, EventType, LIFECYCLE, readEvents } from './events'; +import { Instance } from './model'; + +export interface FeatureState { + feature: string; + title: string; + column: EventType; // latest lifecycle event + blocked: boolean; + refs: string[]; + lastEvent: string; // ISO ts + firstEvent: string; + history: DeliveryEvent[]; +} + +export interface BoardState { + columns: { id: EventType; label: string; features: FeatureState[] }[]; + generatedAt: string; +} + +const COLUMN_LABELS: Record = { + declared: 'Declared', + compiled: 'Compiled', + started: 'Building', + validated: 'Validating', + shipped: 'Shipped', +}; + +export function foldBoard(root: string): BoardState { + const events = readEvents(root); + const byFeature = new Map(); + for (const e of events) { + let f = byFeature.get(e.feature); + if (!f) { + f = { + feature: e.feature, + title: e.title ?? e.feature, + column: 'declared', + blocked: false, + refs: [], + lastEvent: e.ts, + firstEvent: e.ts, + history: [], + }; + byFeature.set(e.feature, f); + } + f.history.push(e); + f.lastEvent = e.ts; + if (e.title) f.title = e.title; + for (const r of e.refs ?? []) if (!f.refs.includes(r)) f.refs.push(r); + if (LIFECYCLE.includes(e.type)) f.column = e.type; + if (e.type === 'blocked') f.blocked = true; + if (e.type === 'unblocked') f.blocked = false; + } + const columns = LIFECYCLE.map((id) => ({ + id, + label: COLUMN_LABELS[id], + features: [...byFeature.values()] + .filter((f) => f.column === id) + .sort((a, b) => b.lastEvent.localeCompare(a.lastEvent)), + })); + return { columns, generatedAt: new Date().toISOString() }; +} + +// --------------------------------------------------------------------------- +// Terminal rendering + +export function renderBoardText(board: BoardState): string { + const lines: string[] = []; + const total = board.columns.reduce((n, c) => n + c.features.length, 0); + if (total === 0) { + return 'board empty — declare work with `constitution feature declare ""`.'; + } + for (const col of board.columns) { + lines.push(`\n${col.label} (${col.features.length})`); + lines.push('─'.repeat(Math.max(col.label.length + 4, 12))); + for (const f of col.features) { + const flags = f.blocked ? ' ⛔ BLOCKED' : ''; + const refs = f.refs.length ? ` [${f.refs.join(', ')}]` : ''; + lines.push(` • ${f.title}${flags}${refs}`); + lines.push(` ${f.feature} · last activity ${f.lastEvent.slice(0, 10)}`); + } + } + return lines.join('\n'); +} + +// --------------------------------------------------------------------------- +// Static HTML dashboard + +export function renderBoardHtml(board: BoardState, instance: Instance | null): string { + const doc = instance?.constitution; + const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); + const columnsHtml = board.columns + .map( + (col) => ` + <section class="col"> + <header><h2>${esc(col.label)}</h2><span class="count">${col.features.length}</span></header> + ${col.features + .map( + (f) => ` + <article class="card${f.blocked ? ' blocked' : ''}"> + <h3>${esc(f.title)}</h3> + ${f.blocked ? '<span class="flag">BLOCKED</span>' : ''} + <div class="refs">${f.refs.map((r) => `<span class="ref">${esc(r)}</span>`).join('')}</div> + <div class="meta">${esc(f.feature)} · ${esc(f.lastEvent.slice(0, 16).replace('T', ' '))}</div> + </article>` + ) + .join('')} + </section>` + ) + .join(''); + + const healthHtml = doc + ? ` + <section class="health"> + <h2>Governance health — ${esc(doc.title)} @ ${esc(doc.version)}</h2> + <div class="articles"> + ${doc.articles + .map( + (a) => ` + <div class="article"> + <span class="aid">${esc(a.id)}</span> + <span class="aname">${esc(a.name)}</span> + <span class="badge st-${esc(a.status)}">${esc(a.status)}</span> + <span class="badge cf-${esc(a.conformance)}">${esc(a.conformance)}</span> + <span class="badge en-${esc(a.enforcement)}">${esc(a.enforcement)}</span> + </div>` + ) + .join('')} + </div> + </section>` + : ''; + + return `<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>constitution — delivery board + + + +

Delivery board

+
generated ${esc(board.generatedAt)} by constitution board --html — + operational view over .constitution/events.jsonl; reads the law, never stores it.
+
${columnsHtml}
+ ${healthHtml} + + +`; +} diff --git a/cli/src/engine/compile.ts b/cli/src/engine/compile.ts new file mode 100644 index 0000000..38611f7 --- /dev/null +++ b/cli/src/engine/compile.ts @@ -0,0 +1,108 @@ +// The deterministic half of the L4 compile step. +// +// L4 = compile(task, L0..L3). Selecting WHICH slices govern a task is judgment +// and stays in the compile-prompt skill (or any LLM). What the engine +// guarantees is the input: a complete, current, canonical pack of the law — +// every ratified unit with its hash, the statute index, the ADR index — plus +// the briefing contract the compiler must emit. No consumer of the pack can +// accidentally compile against stale or partial law. + +import * as fs from 'fs'; +import * as path from 'path'; +import { Instance, articleCanonicalText } from './model'; +import { appendEvent, opsDir, slugify } from './events'; + +export function buildCompilePack(instance: Instance, task: string): string { + const doc = instance.constitution; + const now = new Date().toISOString(); + const lines: string[] = []; + + lines.push(`# L4 compile pack — task: ${task}`); + lines.push(''); + lines.push('```'); + lines.push(`constitution: ${doc.title} @ ${doc.version}`); + lines.push(`ratifier: ${doc.ratifier}`); + lines.push(`generated: ${now} by \`constitution compile\``); + lines.push('```'); + lines.push(''); + lines.push('This pack is the complete canonical law of this instance. Compile the task into'); + lines.push('ONE briefing per the contract at the bottom. Select only the slices that govern'); + lines.push('the task; tag every line with provenance. If the task cannot be placed — it'); + lines.push('serves an L0 line no Article enforces, or two Articles collide — STOP and'); + lines.push('escalate to the ratifier (certiorari) instead of fabricating governance.'); + lines.push(''); + + lines.push('## L0 — Preamble'); + for (const p of doc.preamble) { + lines.push(`- **${p.id}** (hash \`${p.hash.slice(0, 12)}\`) — ${p.text}`); + } + lines.push(''); + + lines.push('## L1 — Articles (RATIFIED only; PROPOSED/SUPERSEDED are not law)'); + for (const a of doc.articles) { + if (a.status !== 'RATIFIED') continue; + lines.push(`### ${a.id} — ${a.name} (hash \`${a.hash.slice(0, 12)}\`)`); + lines.push('```'); + lines.push(articleCanonicalText(a)); + lines.push('```'); + lines.push(`current audit state: conformance ${a.conformance}, enforcement ${a.enforcement}`); + lines.push(''); + } + + lines.push('## L2 — Statute index'); + if (instance.statutes.length === 0) lines.push('_(no statutes harvested yet)_'); + for (const s of instance.statutes) { + lines.push(`- [${s.home}:${s.line}] ${s.rule}`); + lines.push(` · serves: ${s.serves || '—'} · enforced-by: ${s.enforcedBy || '—'}`); + } + lines.push(''); + + lines.push('## L3 — Case law index'); + if (instance.adrs.length === 0) lines.push('_(no ADRs yet)_'); + for (const adr of instance.adrs) { + lines.push( + `- ADR-${adr.id} (${adr.status}, ${adr.date}, trigger: ${adr.trigger}) — ${adr.title}` + + (adr.serves.length ? ` · serves: ${adr.serves.join(', ')}` : '') + + ` · ${adr.file}` + ); + } + lines.push(''); + + lines.push('## Briefing contract (emit exactly this shape)'); + lines.push('```'); + lines.push(`### Compiled instruction — task: ${task}`); + lines.push(`# generated from ${doc.title} @ v${doc.version} · DO NOT EDIT (edit L0–L3 instead)`); + lines.push(''); + lines.push('WHY THIS EXISTS'); + lines.push(' [L0·] '); + lines.push(''); + lines.push('INVARIANTS YOU MUST HOLD'); + lines.push(' [L1·] '); + lines.push(''); + lines.push('HOW TO BUILD (current stack)'); + lines.push(' [L2·] '); + lines.push(''); + lines.push('PRECEDENT'); + lines.push(' [L3·ADR-] '); + lines.push(''); + lines.push('DEFINITION OF DONE (these run in CI — your work must pass)'); + lines.push(' ✓ '); + lines.push('```'); + + return lines.join('\n') + '\n'; +} + +export interface CompileArtifact { + file: string; // relative path under .constitution/compiles/ + feature: string; +} + +export function writeCompilePack(instance: Instance, task: string, feature?: string): CompileArtifact { + const slug = feature ?? slugify(task); + const relFile = path.join('.constitution', 'compiles', `${new Date().toISOString().slice(0, 10)}-${slug}.md`); + const absFile = path.join(instance.root, relFile); + fs.mkdirSync(path.dirname(absFile), { recursive: true }); + fs.writeFileSync(absFile, buildCompilePack(instance, task)); + appendEvent(instance.root, { type: 'compiled', feature: slug, title: task, detail: relFile }); + return { file: relFile, feature: slug }; +} diff --git a/cli/src/engine/doctor.ts b/cli/src/engine/doctor.ts new file mode 100644 index 0000000..e495600 --- /dev/null +++ b/cli/src/engine/doctor.ts @@ -0,0 +1,113 @@ +// Self-healing, split by the firewall (the reconcile-findings discipline, +// mechanized): +// +// BELOW the firewall — fixed unattended: stale/orphaned tone renders pruned, +// missing ops scaffold created, declared version-sync targets aligned to the +// constitution version. +// +// ABOVE the firewall — never fixed: each finding is drafted into the +// ratification queue (.constitution/proposals/) exactly once, and waits for +// the human. Getting this split wrong in the permissive direction would be +// the engine silently amending the constitution — the worst failure mode it +// can have. + +import * as fs from 'fs'; +import * as path from 'path'; +import { audit, Finding } from './audit'; +import { ensureOps, opsDir } from './events'; +import { Instance } from './model'; +import { hasOpenProposalFor, queueProposal } from './proposals'; +import { pruneStaleTones } from './tone'; + +export interface InstanceConfig { + // Files whose "version" field must equal the constitution version + // (e.g. ["cli/package.json"] in the self-hosted repo). + versionSync?: string[]; +} + +export function readConfig(root: string): InstanceConfig { + // Committed config at the root wins; .constitution/config.json is the + // fallback for instances that keep the whole ops dir untracked. + for (const p of [path.join(root, 'constitution.config.json'), path.join(opsDir(root), 'config.json')]) { + if (!fs.existsSync(p)) continue; + try { + return JSON.parse(fs.readFileSync(p, 'utf8')) as InstanceConfig; + } catch { + return {}; + } + } + return {}; +} + +export interface DoctorReport { + fixed: string[]; + queued: string[]; // proposal ids drafted for above-firewall findings + skipped: string[]; // above-firewall findings that already have an open proposal + remaining: Finding[]; // below-firewall findings the engine cannot fix mechanically +} + +export function runDoctor(instance: Instance): DoctorReport { + const report: DoctorReport = { fixed: [], queued: [], skipped: [], remaining: [] }; + + // 1. Ops scaffold is regenerable — always safe. + ensureOps(instance.root); + + // 2. Prune tone renders whose canonical source changed (derived artifacts). + for (const f of pruneStaleTones(instance)) { + report.fixed.push(`pruned stale tone render ${path.relative(instance.root, f)}`); + } + + // 3. Declared version-sync targets (one number for the whole repo). + const config = readConfig(instance.root); + const version = instance.constitution.version; + for (const rel of config.versionSync ?? []) { + const p = path.join(instance.root, rel); + if (!fs.existsSync(p) || !version) continue; + try { + const pkg = JSON.parse(fs.readFileSync(p, 'utf8')); + if (pkg.version !== version) { + pkg.version = version; + fs.writeFileSync(p, JSON.stringify(pkg, null, 2) + '\n'); + report.fixed.push(`synced ${rel} version -> ${version}`); + } + } catch { + report.remaining.push({ + code: 'SYNC-UNPARSEABLE', + severity: 'warn', + firewall: 'below', + where: rel, + message: 'versionSync target is not parseable JSON', + }); + } + } + + // 4. Audit; queue drafts for everything above the firewall, report the rest. + for (const finding of audit(instance)) { + if (finding.firewall === 'above') { + const target = finding.where; + if (hasOpenProposalFor(instance.root, target, finding.code)) { + report.skipped.push(`${finding.code} @ ${target} (already queued)`); + continue; + } + const p = queueProposal(instance.root, { + title: `${finding.code}: ${finding.message.slice(0, 80)}`, + kind: finding.code, + target, + rationale: + `Deterministic audit finding (severity: ${finding.severity}) at ${finding.where}:\n\n` + + `> ${finding.message}\n\n` + + 'The fix touches ratified L0/L1 substance, so it crosses the firewall (F-IV) and ' + + 'requires the ratifier.', + draft: + '_No mechanical draft — the ruling is the ratifier\'s. Options: amend the text ' + + '(then re-run `constitution lock accept`), revert the change that caused this, or ' + + 'reject this proposal with a reason._', + }); + report.queued.push(p.id); + } else { + report.remaining.push(finding); + } + } + + return report; +} diff --git a/cli/src/engine/events.ts b/cli/src/engine/events.ts new file mode 100644 index 0000000..a0ced2a --- /dev/null +++ b/cli/src/engine/events.ts @@ -0,0 +1,82 @@ +// The ops plane's event log: .constitution/events.jsonl, append-only. +// This is operational tooling data — it references the law by id (refs) but is +// NOT a governed layer, and nothing here is ever written into CONSTITUTION.md. +// Deleting the file loses delivery history, not legality. + +import * as fs from 'fs'; +import * as path from 'path'; + +export const OPS_DIR = '.constitution'; + +export type EventType = + | 'declared' // intent stated by the owner + | 'compiled' // L4 briefing compiled for it + | 'started' // implementation begun + | 'validated' // definition-of-done assertions passed + | 'shipped' // delivered + | 'blocked' + | 'unblocked' + | 'note'; + +export const LIFECYCLE: EventType[] = ['declared', 'compiled', 'started', 'validated', 'shipped']; + +export interface DeliveryEvent { + ts: string; // ISO + type: EventType; + feature: string; // slug + title?: string; + refs?: string[]; // law ids this work is governed by: ["F-II", "ADR-0001"] + detail?: string; + by?: string; +} + +export function opsDir(root: string): string { + return path.join(root, OPS_DIR); +} + +export function eventsPath(root: string): string { + return path.join(opsDir(root), 'events.jsonl'); +} + +export function ensureOps(root: string): void { + for (const d of [opsDir(root), path.join(opsDir(root), 'tone'), path.join(opsDir(root), 'proposals'), path.join(opsDir(root), 'compiles')]) { + fs.mkdirSync(d, { recursive: true }); + } + const p = eventsPath(root); + if (!fs.existsSync(p)) fs.writeFileSync(p, ''); + // Regenerable caches stay out of git; the delivery record (events) and the + // ratification queue (proposals) are worth committing. + const gi = path.join(opsDir(root), '.gitignore'); + if (!fs.existsSync(gi)) fs.writeFileSync(gi, 'tone/\ncompiles/\nboard.html\n'); +} + +export function appendEvent(root: string, event: Omit & { ts?: string }): DeliveryEvent { + ensureOps(root); + const full: DeliveryEvent = { ts: event.ts ?? new Date().toISOString(), ...event } as DeliveryEvent; + fs.appendFileSync(eventsPath(root), JSON.stringify(full) + '\n'); + return full; +} + +export function readEvents(root: string): DeliveryEvent[] { + const p = eventsPath(root); + if (!fs.existsSync(p)) return []; + const out: DeliveryEvent[] = []; + for (const line of fs.readFileSync(p, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + out.push(JSON.parse(trimmed) as DeliveryEvent); + } catch { + // a corrupt line is skipped, never fatal — ops data is best-effort + } + } + return out; +} + +export function slugify(s: string): string { + return s + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64); +} diff --git a/cli/src/engine/lock.ts b/cli/src/engine/lock.ts new file mode 100644 index 0000000..d840b9a --- /dev/null +++ b/cli/src/engine/lock.ts @@ -0,0 +1,80 @@ +// constitution.lock.json — the firewall as a gate (F-IV, mechanized). +// Records the canonical hash of every RATIFIED L0/L1 unit. Written only via +// `constitution lock accept` (interactive human TTY + typed confirmation — +// enforced in the CLI layer). `constitution firewall` compares live hashes to +// the lock and fails on any drift: an agent cannot land an edit to ratified +// text, or a new RATIFIED status, without a human re-accepting the lock. + +import * as fs from 'fs'; +import * as path from 'path'; +import { Instance, Unit, unitsOf } from './model'; + +export interface LockUnit { + kind: 'preamble' | 'article'; + hash: string; +} + +export interface LockFile { + lockVersion: 1; + constitutionVersion: string; + acceptedBy: string; + acceptedAt: string; // ISO + units: Record; // id -> hash of ratified units only +} + +export const LOCK_FILENAME = 'constitution.lock.json'; + +export function lockPath(root: string): string { + return path.join(root, LOCK_FILENAME); +} + +export function readLock(root: string): LockFile | null { + const p = lockPath(root); + if (!fs.existsSync(p)) return null; + return JSON.parse(fs.readFileSync(p, 'utf8')) as LockFile; +} + +export function ratifiedUnits(instance: Instance): Unit[] { + return unitsOf(instance).filter((u) => u.ratified && u.kind !== 'adr'); +} + +export function computeLock(instance: Instance, acceptedBy: string): LockFile { + const units: Record = {}; + for (const u of ratifiedUnits(instance)) { + units[u.id] = { kind: u.kind as 'preamble' | 'article', hash: u.hash }; + } + return { + lockVersion: 1, + constitutionVersion: instance.constitution.version, + acceptedBy, + acceptedAt: new Date().toISOString(), + units, + }; +} + +export function writeLock(root: string, lock: LockFile): void { + fs.writeFileSync(lockPath(root), JSON.stringify(lock, null, 2) + '\n'); +} + +export interface LockDiff { + changed: string[]; // ratified unit text differs from the accepted hash + added: string[]; // ratified now, absent from the lock (unaccepted ratification) + removed: string[]; // in the lock, no longer ratified/present (unaccepted repeal) + clean: boolean; +} + +export function diffLock(instance: Instance, lock: LockFile): LockDiff { + const live = new Map(ratifiedUnits(instance).map((u) => [u.id, u.hash])); + const changed: string[] = []; + const added: string[] = []; + const removed: string[] = []; + for (const [id, unit] of Object.entries(lock.units)) { + const liveHash = live.get(id); + if (liveHash === undefined) removed.push(id); + else if (liveHash !== unit.hash) changed.push(id); + } + for (const id of live.keys()) { + if (!(id in lock.units)) added.push(id); + } + return { changed, added, removed, clean: changed.length + added.length + removed.length === 0 }; +} diff --git a/cli/src/engine/model.ts b/cli/src/engine/model.ts new file mode 100644 index 0000000..b860423 --- /dev/null +++ b/cli/src/engine/model.ts @@ -0,0 +1,130 @@ +// The typed governance model — the engine's view of the law plane. +// Parsing populates this; audit/lock/compile/tone/board consume it. + +export type ArticleStatus = 'PROPOSED' | 'RATIFIED' | 'SUPERSEDED'; +export type Conformance = 'HOLDS' | 'VIOLATED' | 'UNVERIFIED'; +export type Enforcement = 'UNGUARDED' | 'AUDITED' | 'GATED' | 'STRUCTURAL'; + +export interface PreambleLine { + id: string; // e.g. "P1" + text: string; + hash: string; // canonical hash (normalized text) + line: number; +} + +export interface Article { + id: string; // e.g. "F-II" or "A4" + name: string; + status: string; // ArticleStatus when well-formed + conformance: string; + enforcement: string; + party: string; + principle: string; + serves: string[]; // L0 ids + fitness: string; + why: string; + proven: string; + // Hash covers the ratified substance (id, name, party, status, principle, + // serves, fitness, why) and deliberately EXCLUDES conformance/enforcement — + // those are audit outputs, set below the firewall, and must not trip the gate. + hash: string; + line: number; +} + +export interface LedgerEntry { + version: string; + date: string; + title: string; + line: number; +} + +export interface ConstitutionDoc { + file: string; // absolute path + title: string; + version: string; // from the `framework: constitution@X.Y.Z` header pin + ratifier: string; + selfHosted: boolean; + preamble: PreambleLine[]; + articles: Article[]; + ledger: LedgerEntry[]; + parseNotes: string[]; // non-fatal irregularities found while parsing +} + +export interface Statute { + home: string; // path relative to instance root + rule: string; // the bold imperative + serves: string; + enforcedBy: string; + why: string; + line: number; +} + +export interface Adr { + file: string; // relative to instance root + id: string; + title: string; + status: string; + date: string; + supersedes: string[]; + supersededBy: string[]; + serves: string[]; + amends: string[]; + trigger: string; + parseNotes: string[]; +} + +export interface GovernanceMap { + file: string; // relative to instance root + constitutionPath?: string; + decisionsPath?: string; + linkedPaths: { path: string; line: number }[]; // every relative path referenced + statuteHomes: string[]; // linked files that actually contain statute bullets +} + +export interface Instance { + root: string; // absolute path + constitution: ConstitutionDoc; + map?: GovernanceMap; + statutes: Statute[]; + adrs: Adr[]; +} + +// A governed unit = anything the lock hashes or tone renders: an L0 line, +// an Article, or an ADR. +export interface Unit { + id: string; + kind: 'preamble' | 'article' | 'adr'; + text: string; // canonical text as parsed + hash: string; + ratified: boolean; // preamble lines: always; articles: status === RATIFIED; adrs: accepted +} + +export function unitsOf(instance: Instance): Unit[] { + const units: Unit[] = []; + for (const p of instance.constitution.preamble) { + units.push({ id: p.id, kind: 'preamble', text: p.text, hash: p.hash, ratified: true }); + } + for (const a of instance.constitution.articles) { + units.push({ + id: a.id, + kind: 'article', + text: articleCanonicalText(a), + hash: a.hash, + ratified: a.status === 'RATIFIED', + }); + } + return units; +} + +export function articleCanonicalText(a: Article): string { + return [ + `Article ${a.id} — ${a.name}`, + `status: ${a.status} · party: ${a.party}`, + `Principle — ${a.principle}`, + `Serves — ${a.serves.join(', ')}`, + `Fitness — ${a.fitness}`, + a.why ? `Why — ${a.why}` : '', + ] + .filter(Boolean) + .join('\n'); +} diff --git a/cli/src/engine/parse.ts b/cli/src/engine/parse.ts new file mode 100644 index 0000000..d28078c --- /dev/null +++ b/cli/src/engine/parse.ts @@ -0,0 +1,385 @@ +// Parser for the law plane. Targets the document shapes the framework already +// uses (see templates/): header fence, `**P1.**` preamble lines, +// `### Article ` with a backtick field line, statute bullets with +// `· serves:` / `· enforced-by:`, ADR YAML frontmatter. Lenient by design: +// irregularities become parseNotes, not exceptions — the audit turns them into +// findings. + +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import { + Adr, + Article, + ConstitutionDoc, + GovernanceMap, + Instance, + LedgerEntry, + PreambleLine, + Statute, + articleCanonicalText, +} from './model'; + +// Collapses ALL whitespace (including line breaks) so that re-wrapping a +// paragraph never changes a unit's canonical hash, but changing a word does. +export function normalize(text: string): string { + return text.replace(/\s+/g, ' ').trim(); +} + +export function canonicalHash(text: string): string { + return crypto.createHash('sha256').update(normalize(text), 'utf8').digest('hex'); +} + +// --------------------------------------------------------------------------- +// CONSTITUTION.md + +export function parseConstitution(file: string): ConstitutionDoc { + const raw = fs.readFileSync(file, 'utf8'); + const lines = raw.split('\n'); + const notes: string[] = []; + + const title = (lines.find((l) => l.startsWith('# ')) ?? '# (untitled)').replace(/^# /, '').trim(); + + // Header fence: framework: constitution@X.Y.Z [ (self-hosted) ] / ratifier: NAME + let version = ''; + let ratifier = ''; + let selfHosted = false; + const pinMatch = raw.match(/framework:\s*constitution@([\w.\-]+)(.*)/); + if (pinMatch) { + version = pinMatch[1]; + selfHosted = /self-hosted/.test(pinMatch[2]); + } else { + notes.push('header: no `framework: constitution@` pin found'); + } + const ratMatch = raw.match(/ratifier:\s*(.+)/); + if (ratMatch) ratifier = ratMatch[1].trim(); + else notes.push('header: no `ratifier:` line found'); + + const preamble = parsePreamble(lines, notes); + const articles = parseArticles(lines, notes); + const ledger = parseLedger(lines); + + return { file, title, version, ratifier, selfHosted, preamble, articles, ledger, parseNotes: notes }; +} + +function sectionRange(lines: string[], startRe: RegExp): [number, number] { + const start = lines.findIndex((l) => startRe.test(l)); + if (start === -1) return [-1, -1]; + let end = lines.length; + for (let i = start + 1; i < lines.length; i++) { + if (/^## /.test(lines[i])) { + end = i; + break; + } + } + return [start, end]; +} + +function parsePreamble(lines: string[], notes: string[]): PreambleLine[] { + const [start, end] = sectionRange(lines, /^## L0\b/); + if (start === -1) { + notes.push('no `## L0` section found'); + return []; + } + const out: PreambleLine[] = []; + let current: { id: string; text: string[]; line: number } | null = null; + for (let i = start + 1; i < end; i++) { + const m = lines[i].match(/^\*\*(P\d+)\.\*\*\s*(.*)$/); + if (m) { + if (current) out.push(finishPreamble(current)); + current = { id: m[1], text: [m[2]], line: i + 1 }; + } else if (current) { + if (lines[i].trim() === '' || /^#|^---/.test(lines[i])) { + out.push(finishPreamble(current)); + current = null; + } else { + current.text.push(lines[i]); + } + } + } + if (current) out.push(finishPreamble(current)); + return out; +} + +function finishPreamble(c: { id: string; text: string[]; line: number }): PreambleLine { + const text = normalize(c.text.join('\n')); + return { id: c.id, text, hash: canonicalHash(`${c.id}. ${text}`), line: c.line }; +} + +function parseArticles(lines: string[], notes: string[]): Article[] { + const out: Article[] = []; + for (let i = 0; i < lines.length; i++) { + const m = lines[i].match(/^### Article\s+(\S+)\s+—\s+(.+)$/); + if (!m) continue; + const id = m[1]; + const name = m[2].trim(); + // Field line: `status: X` · `conformance: Y` · ... — within the next 3 lines. + const fields: Record = {}; + for (let j = i + 1; j < Math.min(i + 4, lines.length); j++) { + const tokens = lines[j].match(/`([\w-]+):\s*([^`]+)`/g); + if (tokens) { + for (const t of tokens) { + const tm = t.match(/`([\w-]+):\s*([^`]+)`/); + if (tm) fields[tm[1]] = tm[2].trim(); + } + break; + } + } + for (const req of ['status', 'conformance', 'enforcement', 'party']) { + if (!(req in fields)) notes.push(`Article ${id}: missing field \`${req}\``); + } + // Bullets until the next heading. + let end = lines.length; + for (let j = i + 1; j < lines.length; j++) { + if (/^#{2,3} /.test(lines[j]) || /^---\s*$/.test(lines[j])) { + end = j; + break; + } + } + const bullets = parseBoldBullets(lines.slice(i + 1, end)); + const servesRaw = bullets['Serves'] ?? ''; + const serves = servesRaw + .split(/[,;]|\band\b/) + .map((s) => s.trim().replace(/\.$/, '')) + .filter((s) => /^P\d+$/.test(s)); + if (servesRaw && serves.length === 0) notes.push(`Article ${id}: Serves ("${servesRaw}") names no P id`); + + const article: Article = { + id, + name, + status: fields['status'] ?? '', + conformance: fields['conformance'] ?? '', + enforcement: fields['enforcement'] ?? '', + party: fields['party'] ?? '', + principle: bullets['Principle'] ?? '', + serves, + fitness: bullets['Fitness'] ?? '', + why: bullets['Why'] ?? '', + proven: bullets['Proven'] ?? '', + hash: '', + line: i + 1, + }; + article.hash = canonicalHash(articleCanonicalText(article)); + out.push(article); + } + return out; +} + +// `- **Label** — text...` bullets, text continuing on indented lines. +function parseBoldBullets(lines: string[]): Record { + const out: Record = {}; + let label: string | null = null; + let buf: string[] = []; + const flush = () => { + if (label) out[label] = normalize(buf.join('\n')).replace(/\n/g, ' '); + label = null; + buf = []; + }; + for (const line of lines) { + const m = line.match(/^- \*\*([^*]+)\*\*\s*—\s*(.*)$/); + if (m) { + flush(); + label = m[1].trim(); + buf = [m[2]]; + } else if (label && /^\s+\S/.test(line)) { + buf.push(line); + } else if (label) { + flush(); + } + } + flush(); + return out; +} + +function parseLedger(lines: string[]): LedgerEntry[] { + const out: LedgerEntry[] = []; + for (let i = 0; i < lines.length; i++) { + const m = lines[i].match(/^### \[([\w.\-]+)\]\s+—\s+(\S+)\s+—\s+(.+)$/); + if (m) out.push({ version: m[1], date: m[2], title: m[3].trim(), line: i + 1 }); + } + return out; +} + +// --------------------------------------------------------------------------- +// Governance map (root AGENTS.md) + +export function parseGovernanceMap(root: string, file: string): GovernanceMap { + const abs = path.join(root, file); + const raw = fs.readFileSync(abs, 'utf8'); + const lines = raw.split('\n'); + const linkedPaths: { path: string; line: number }[] = []; + let constitutionPath: string | undefined; + let decisionsPath: string | undefined; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + // Existence-checked references: real markdown links only. Backtick tokens + // are scanned solely to classify the constitution/decisions declarations — + // prose mentions (`SKILL.md`, generated dirs) must not become findings. + for (const m of line.matchAll(/\[[^\]]*\]\(([^)#]+)\)/g)) { + const p = m[1].trim(); + if (!p || /^https?:/.test(p)) continue; + linkedPaths.push({ path: p, line: i + 1 }); + } + for (const m of line.matchAll(/`([^`\s]+\.md|[^`\s]+\/)`/g)) { + const p = m[1].trim(); + if (/constitution \(l0/i.test(line) && p.endsWith('.md') && !constitutionPath) constitutionPath = p; + if (/case law|\bl3\b/i.test(line) && !decisionsPath) decisionsPath = p; + } + } + + // A linked file is a statute home iff it exists and contains statute bullets. + const statuteHomes: string[] = []; + for (const { path: p } of linkedPaths) { + const full = path.join(root, p); + if (p.endsWith('.md') && fs.existsSync(full)) { + const content = fs.readFileSync(full, 'utf8'); + if (/·\s*serves:/.test(content)) statuteHomes.push(p); + } + } + // The map file itself may host statutes (fresh consumer default). + if (/·\s*serves:/.test(raw) && !statuteHomes.includes(file)) statuteHomes.push(file); + + return { file, constitutionPath, decisionsPath, linkedPaths, statuteHomes: [...new Set(statuteHomes)] }; +} + +// --------------------------------------------------------------------------- +// Statutes + +export function parseStatutes(root: string, home: string): Statute[] { + const raw = fs.readFileSync(path.join(root, home), 'utf8'); + const lines = raw.split('\n'); + const out: Statute[] = []; + let i = 0; + while (i < lines.length) { + const m = lines[i].match(/^- \*\*(.*)$/); + if (!m) { + i++; + continue; + } + const startLine = i + 1; + // Rule text: from the bold open to the closing ** (may span lines). + let ruleBuf = m[1]; + while (!/\*\*/.test(ruleBuf) && i + 1 < lines.length) { + i++; + ruleBuf += ' ' + lines[i].trim(); + } + let rule = ruleBuf.replace(/\*\*.*$/, '').replace(/\*\*/g, '').trim(); + // Annotation lines: `· key: value` with wrapped continuations. Indented + // prose between the bold close and the first `·` line is rule commentary + // (common shape in real statute homes) — folded into the rule, not a + // reason to drop the statute. + const ann: Record = {}; + let key: string | null = null; + while (i + 1 < lines.length) { + const next = lines[i + 1]; + const am = next.match(/^\s*·\s*([\w-]+):\s*(.*)$/); + if (am) { + key = am[1]; + ann[key] = am[2].trim(); + i++; + } else if (key && /^\s{2,}\S/.test(next) && !/^\s*- /.test(next)) { + ann[key] += ' ' + next.trim(); + i++; + } else if (!key && /^\s*\S/.test(next) && !/^\s*- /.test(next) && !/^#|^---/.test(next) && next.trim() !== '') { + rule += ' ' + next.trim(); + i++; + } else { + break; + } + } + if (Object.keys(ann).length > 0) { + out.push({ + home, + rule, + serves: ann['serves'] ?? '', + enforcedBy: ann['enforced-by'] ?? '', + why: ann['why'] ?? '', + line: startLine, + }); + } + i++; + } + return out; +} + +// --------------------------------------------------------------------------- +// ADRs (L3) + +export function parseAdr(root: string, file: string): Adr { + const raw = fs.readFileSync(path.join(root, file), 'utf8'); + const notes: string[] = []; + const fm: Record = {}; + const fmMatch = raw.match(/^---\n([\s\S]*?)\n---/); + if (fmMatch) { + for (const line of fmMatch[1].split('\n')) { + const m = line.match(/^([\w_]+):\s*(.*)$/); + if (m) fm[m[1]] = m[2].replace(/#.*$/, '').trim(); + } + } else { + notes.push('no YAML frontmatter'); + } + const list = (v: string | undefined): string[] => + (v ?? '') + .replace(/^\[|\]$/g, '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + return { + file, + id: fm['id'] ?? '', + title: fm['title'] ?? '', + status: fm['status'] ?? '', + date: fm['date'] ?? '', + supersedes: list(fm['supersedes']), + supersededBy: list(fm['superseded_by']), + serves: list(fm['serves']), + amends: list(fm['amends']), + trigger: fm['trigger'] ?? '', + parseNotes: notes, + }; +} + +// --------------------------------------------------------------------------- +// Whole instance + +export function findConstitutionFile(root: string): string | null { + for (const candidate of ['CONSTITUTION.md', 'decisions/CONSTITUTION.md', 'docs/CONSTITUTION.md']) { + if (fs.existsSync(path.join(root, candidate))) return candidate; + } + return null; +} + +export function loadInstance(root: string): Instance { + const constitutionRel = findConstitutionFile(root); + if (!constitutionRel) { + throw new Error( + `no CONSTITUTION.md found under ${root} (looked in ., decisions/, docs/) — run \`constitution init\` first` + ); + } + const constitution = parseConstitution(path.join(root, constitutionRel)); + + let map: GovernanceMap | undefined; + for (const candidate of ['AGENTS.md', 'CLAUDE.md']) { + const p = path.join(root, candidate); + if (fs.existsSync(p) && /Governance Map/i.test(fs.readFileSync(p, 'utf8'))) { + map = parseGovernanceMap(root, candidate); + break; + } + } + + const statutes: Statute[] = []; + for (const home of map?.statuteHomes ?? []) { + statutes.push(...parseStatutes(root, home)); + } + + const adrs: Adr[] = []; + const decisionsDir = path.join(root, 'decisions'); + if (fs.existsSync(decisionsDir)) { + for (const f of fs.readdirSync(decisionsDir).sort()) { + if (/^\d{4}-.*\.md$/.test(f)) adrs.push(parseAdr(root, path.join('decisions', f))); + } + } + + return { root, constitution, map, statutes, adrs }; +} diff --git a/cli/src/engine/proposals.ts b/cli/src/engine/proposals.ts new file mode 100644 index 0000000..b562733 --- /dev/null +++ b/cli/src/engine/proposals.ts @@ -0,0 +1,118 @@ +// The ratification queue — how anything crosses the firewall. +// +// Agents (doctor included) may DRAFT changes to ratified L0/L1 here. Nothing in +// this module ever edits the constitution: a proposal is a file in +// .constitution/proposals/ with status PROPOSED until a human, in an +// interactive session, rules on it (`constitution ratify ` — the TTY + +// typed-confirmation guard lives in the CLI layer). Even then the engine only +// records the ruling; applying drafted text to the law and re-accepting the +// lock remain explicit, human-driven steps. + +import * as fs from 'fs'; +import * as path from 'path'; +import { opsDir, slugify } from './events'; + +export interface Proposal { + id: string; // filename stem + file: string; // absolute path + title: string; + status: 'PROPOSED' | 'APPROVED' | 'REJECTED' | string; + kind: string; // e.g. amendment | ratification | repeal | finding + target: string; // unit id or file the draft touches + created: string; + ruledBy?: string; + ruledAt?: string; + body: string; +} + +export function proposalsDir(root: string): string { + return path.join(opsDir(root), 'proposals'); +} + +export function queueProposal( + root: string, + p: { title: string; kind: string; target: string; rationale: string; draft: string } +): Proposal { + const dir = proposalsDir(root); + fs.mkdirSync(dir, { recursive: true }); + const stem = `${new Date().toISOString().slice(0, 10)}-${slugify(p.title)}`; + let id = stem; + let n = 2; + while (fs.existsSync(path.join(dir, `${id}.md`))) id = `${stem}-${n++}`; + const file = path.join(dir, `${id}.md`); + const created = new Date().toISOString(); + const content = [ + '---', + `title: ${p.title}`, + 'status: PROPOSED', + `kind: ${p.kind}`, + `target: ${p.target}`, + `created: ${created}`, + '---', + '', + '', + '## Rationale', + p.rationale, + '', + '## Draft', + p.draft, + '', + ].join('\n'); + fs.writeFileSync(file, content); + return { id, file, title: p.title, status: 'PROPOSED', kind: p.kind, target: p.target, created, body: content }; +} + +export function listProposals(root: string): Proposal[] { + const dir = proposalsDir(root); + if (!fs.existsSync(dir)) return []; + const out: Proposal[] = []; + for (const f of fs.readdirSync(dir).sort()) { + if (!f.endsWith('.md')) continue; + const p = readProposal(root, f.replace(/\.md$/, '')); + if (p) out.push(p); + } + return out; +} + +export function readProposal(root: string, id: string): Proposal | null { + const file = path.join(proposalsDir(root), `${id}.md`); + if (!fs.existsSync(file)) return null; + const raw = fs.readFileSync(file, 'utf8'); + const m = raw.match(/^---\n([\s\S]*?)\n---/); + const meta: Record = {}; + if (m) { + for (const line of m[1].split('\n')) { + const km = line.match(/^([\w-]+):\s*(.*)$/); + if (km) meta[km[1]] = km[2].trim(); + } + } + return { + id, + file, + title: meta['title'] ?? id, + status: meta['status'] ?? 'PROPOSED', + kind: meta['kind'] ?? '', + target: meta['target'] ?? '', + created: meta['created'] ?? '', + ruledBy: meta['ruled-by'], + ruledAt: meta['ruled-at'], + body: raw, + }; +} + +// Called ONLY from the interactive ratify command after the human confirmed. +export function recordRuling(root: string, id: string, ruling: 'APPROVED' | 'REJECTED', by: string): Proposal { + const p = readProposal(root, id); + if (!p) throw new Error(`no proposal "${id}" in ${proposalsDir(root)}`); + if (p.status !== 'PROPOSED') throw new Error(`proposal "${id}" is already ${p.status}`); + const updated = p.body + .replace(/^status: PROPOSED$/m, `status: ${ruling}`) + .replace(/^(created: .*)$/m, `$1\nruled-by: ${by}\nruled-at: ${new Date().toISOString()}`); + fs.writeFileSync(p.file, updated); + return { ...p, status: ruling, ruledBy: by }; +} + +export function hasOpenProposalFor(root: string, target: string, kind: string): boolean { + return listProposals(root).some((p) => p.status === 'PROPOSED' && p.target === target && p.kind === kind); +} diff --git a/cli/src/engine/tone.ts b/cli/src/engine/tone.ts new file mode 100644 index 0000000..ab6dca8 --- /dev/null +++ b/cli/src/engine/tone.ts @@ -0,0 +1,196 @@ +// Tone rendering — a VIEW over the one canonical text, never a fork of it. +// +// Invariants (from the product's non-negotiables): +// - Exactly one canonical, ratified text per unit. It lives in the law plane. +// - A tone render is a derived artifact: cached under .constitution/tone/, +// keyed by (unit id, canonical hash, tone, transform version). If the +// canonical text changes, every cached render of it is stale BY CONSTRUCTION +// and is refused/pruned — there is nothing to "keep in sync". +// - Renders are never hand-edited (the engine overwrites them) and are never +// read by ratification, amendment, audit, lock, or compile. + +import * as fs from 'fs'; +import * as path from 'path'; +import { spawnSync } from 'child_process'; +import { Instance, Unit, unitsOf } from './model'; +import { opsDir } from './events'; + +export const TONES = ['plain', 'casual', 'formal'] as const; +export type Tone = (typeof TONES)[number]; + +// Bump when a tone prompt changes — invalidates every cached render at once. +export const TRANSFORM_VERSION = 1; + +const TONE_PROMPTS: Record = { + plain: + 'Rewrite the following constitutional text in plain, everyday language a new team member ' + + 'would understand on first read. Preserve every obligation, threshold, id reference, and ' + + 'exception EXACTLY — you may simplify wording, never meaning. Do not add advice, opinions, ' + + 'or content that is not in the source. Output only the rewritten text.', + casual: + 'Rewrite the following constitutional text in a relaxed, conversational tone, like a ' + + 'senior engineer explaining it over coffee. Keep every obligation, threshold, id reference, ' + + 'and exception EXACTLY intact — casual delivery, identical meaning, nothing added. ' + + 'Output only the rewritten text.', + formal: + 'Rewrite the following constitutional text as crisp formal policy prose (complete ' + + 'sentences, no bullets unless the source has them). Identical meaning, obligations, and ' + + 'references; nothing added or dropped. Output only the rewritten text.', +}; + +export type Generator = (prompt: string, sourceText: string) => string; + +// Default generator shells out to the `claude` CLI if present. +export function claudeGenerator(prompt: string, sourceText: string): string { + const res = spawnSync('claude', ['-p', `${prompt}\n\n\n${sourceText}\n`], { + encoding: 'utf8', + timeout: 120_000, + }); + if (res.error || res.status !== 0) { + throw new Error( + `tone generation unavailable: \`claude -p\` failed (${res.error?.message ?? `exit ${res.status}`}). ` + + 'Install the Claude CLI, or read the canonical text directly — it is always authoritative.' + ); + } + return res.stdout.trim(); +} + +export function toneDir(root: string): string { + return path.join(opsDir(root), 'tone'); +} + +function cachePath(root: string, unitId: string, tone: Tone): string { + return path.join(toneDir(root), `${unitId}.${tone}.md`); +} + +interface CacheEntry { + unit: string; + tone: string; + sourceHash: string; + transformVersion: number; + body: string; +} + +function readCache(file: string): CacheEntry | null { + if (!fs.existsSync(file)) return null; + const raw = fs.readFileSync(file, 'utf8'); + const m = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + if (!m) return null; + const meta: Record = {}; + for (const line of m[1].split('\n')) { + const km = line.match(/^([\w-]+):\s*(.*)$/); + if (km) meta[km[1]] = km[2].trim(); + } + return { + unit: meta['unit'] ?? '', + tone: meta['tone'] ?? '', + sourceHash: meta['source-hash'] ?? '', + transformVersion: Number(meta['transform-version'] ?? 0), + body: m[2].replace(/^\s*\s*/, '').trim(), + }; +} + +export function findUnit(instance: Instance, unitId: string): Unit | null { + return unitsOf(instance).find((u) => u.id.toLowerCase() === unitId.toLowerCase()) ?? null; +} + +export interface RenderResult { + unitId: string; + tone: Tone | 'canonical'; + text: string; + fromCache: boolean; + generated: boolean; +} + +export function renderUnit( + instance: Instance, + unitId: string, + tone: Tone | 'canonical', + generator: Generator | null = claudeGenerator +): RenderResult { + const unit = findUnit(instance, unitId); + if (!unit) { + throw new Error(`no unit "${unitId}" in this constitution (units are L0 lines and Articles, e.g. P1, F-II)`); + } + if (tone === 'canonical') { + return { unitId: unit.id, tone, text: unit.text, fromCache: false, generated: false }; + } + + const file = cachePath(instance.root, unit.id, tone); + const cached = readCache(file); + if (cached && cached.sourceHash === unit.hash && cached.transformVersion === TRANSFORM_VERSION) { + return { unitId: unit.id, tone, text: cached.body, fromCache: true, generated: false }; + } + + if (!generator) { + throw new Error( + `no fresh ${tone} render of ${unit.id} cached (canonical text changed or never rendered), ` + + 'and no generator available. The canonical text is always readable: `constitution render ' + + `${unit.id} --tone canonical\`.` + ); + } + + const body = generator(TONE_PROMPTS[tone], unit.text); + fs.mkdirSync(toneDir(instance.root), { recursive: true }); + const frontmatter = [ + '---', + `unit: ${unit.id}`, + `tone: ${tone}`, + `source-hash: ${unit.hash}`, + `transform-version: ${TRANSFORM_VERSION}`, + `generated: ${new Date().toISOString()}`, + '---', + '', + '', + ].join('\n'); + fs.writeFileSync(file, frontmatter + body + '\n'); + return { unitId: unit.id, tone, text: body, fromCache: false, generated: true }; +} + +export interface ToneCheckResult { + fresh: { unit: string; tone: string }[]; + stale: { unit: string; tone: string; file: string; reason: string }[]; + orphaned: { file: string; reason: string }[]; +} + +// Drift detection: a cached render whose source-hash no longer matches the +// canonical text (or whose transform version is old) is stale. `doctor` prunes +// these unattended — pruning a derived artifact is below the firewall. +export function checkTones(instance: Instance): ToneCheckResult { + const result: ToneCheckResult = { fresh: [], stale: [], orphaned: [] }; + const dir = toneDir(instance.root); + if (!fs.existsSync(dir)) return result; + const units = new Map(unitsOf(instance).map((u) => [u.id, u])); + for (const f of fs.readdirSync(dir).sort()) { + if (!f.endsWith('.md')) continue; + const file = path.join(dir, f); + const entry = readCache(file); + if (!entry || !entry.unit) { + result.orphaned.push({ file, reason: 'unreadable or missing frontmatter' }); + continue; + } + const unit = units.get(entry.unit); + if (!unit) { + result.orphaned.push({ file, reason: `unit ${entry.unit} no longer exists` }); + } else if (entry.sourceHash !== unit.hash) { + result.stale.push({ unit: entry.unit, tone: entry.tone, file, reason: 'canonical text changed since render' }); + } else if (entry.transformVersion !== TRANSFORM_VERSION) { + result.stale.push({ unit: entry.unit, tone: entry.tone, file, reason: `transform v${entry.transformVersion} < v${TRANSFORM_VERSION}` }); + } else { + result.fresh.push({ unit: entry.unit, tone: entry.tone }); + } + } + return result; +} + +export function pruneStaleTones(instance: Instance): string[] { + const check = checkTones(instance); + const removed: string[] = []; + for (const s of [...check.stale, ...check.orphaned.map((o) => ({ file: o.file }))]) { + fs.unlinkSync(s.file); + removed.push(s.file); + } + return removed; +} diff --git a/cli/src/index.ts b/cli/src/index.ts index c476409..e23c3a4 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,23 +1,99 @@ #!/usr/bin/env node -import { scaffoldFramework } from './scaffold'; -import { setupAgents } from './agents'; +// Dispatcher + interactive prompting only (per cli/AGENTS.md's one-concern +// statute): all governance logic lives in src/engine/*; file scaffolding in +// scaffold.ts/agents.ts. + +import * as fs from 'fs'; import * as path from 'path'; import prompts from 'prompts'; +import { scaffoldFramework } from './scaffold'; +import { setupAgents } from './agents'; +import { loadInstance } from './engine/parse'; +import { audit, formatFindings } from './engine/audit'; +import { computeLock, diffLock, readLock, writeLock, LOCK_FILENAME } from './engine/lock'; +import { appendEvent, ensureOps, readEvents, slugify, EventType } from './engine/events'; +import { foldBoard, renderBoardHtml, renderBoardText } from './engine/board'; +import { renderUnit, checkTones, claudeGenerator, TONES, Tone } from './engine/tone'; +import { buildCompilePack, writeCompilePack } from './engine/compile'; +import { listProposals, readProposal, recordRuling } from './engine/proposals'; +import { runDoctor } from './engine/doctor'; +import { Instance } from './engine/model'; const VERSION: string = require('../package.json').version; const HELP_TEXT = ` -constitution — scaffold the constitution governance framework into a project +constitution — govern AI-native product development (v${VERSION}) + +Law plane (concise, human-ratified above the firewall): + init Scaffold the framework into this repo (interactive) + audit [--json] Deterministic structural audit of the whole L0–L4 graph + compile "" [--out] Emit the L4 compile pack (canonical law + briefing contract) + render [--tone T] Read a unit (P1, F-II, …) in a tone: canonical|plain|casual|formal + tones check Report stale/orphaned tone renders (drift detection) -Usage: - constitution init Interactively scaffold the framework into this repo - constitution --version Print the installed version - constitution --help Show this help +The firewall (F-IV, mechanized): + firewall CI gate: fail if ratified L0/L1 text drifted from the lock + lock status Show lock vs. live ratified units + lock accept HUMAN ONLY (interactive): accept current ratified text into the lock + proposals [show ] The ratification queue (drafted below, ruled on above) + ratify HUMAN ONLY (interactive): rule on a queued proposal + doctor Self-heal below the firewall; queue drafts above it -Run from the target project's root — it uses the current working directory. +Ops plane (delivery visibility — .constitution/, never the law): + feature declare|start|validate|ship|block|unblock|note [--refs F-II,ADR-0001] + board [--html [file]] Kanban of features + governance health strip + + --version | --help `; +function fail(msg: string): never { + console.error(`error: ${msg}`); + process.exit(1); +} + +function getFlag(args: string[], name: string): string | undefined { + const i = args.indexOf(`--${name}`); + if (i !== -1 && args[i + 1] && !args[i + 1].startsWith('--')) return args[i + 1]; + const eq = args.find((a) => a.startsWith(`--${name}=`)); + return eq ? eq.split('=').slice(1).join('=') : undefined; +} + +function hasFlag(args: string[], name: string): boolean { + return args.includes(`--${name}`) || args.some((a) => a.startsWith(`--${name}=`)); +} + +function positionals(args: string[]): string[] { + const out: string[] = []; + for (let i = 0; i < args.length; i++) { + if (args[i].startsWith('--')) { + if (!args[i].includes('=') && args[i + 1] && !args[i + 1].startsWith('--')) i++; + continue; + } + out.push(args[i]); + } + return out; +} + +function load(root: string): Instance { + try { + return loadInstance(root); + } catch (e) { + fail((e as Error).message); + } +} + +function requireHumanTty(action: string): void { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + fail( + `${action} requires an interactive human session (F-IV: no agent holds the pen above ` + + 'the firewall). Run this yourself in a terminal.' + ); + } +} + +// --------------------------------------------------------------------------- + async function runInit() { const targetDir = process.cwd(); console.log(`\nInitializing Constitution Framework in ${targetDir}...\n`); @@ -27,13 +103,13 @@ async function runInit() { type: 'text', name: 'projectName', message: 'What is the name of this project?', - initial: path.basename(targetDir) + initial: path.basename(targetDir), }, { type: 'text', name: 'ratifier', - message: 'Who is the ratifier for this constitution? (e.g. Engineering Team)', - initial: '' + message: 'Who is the ratifier for this constitution? (a real human — F-IV)', + initial: '', }, { type: 'multiselect', @@ -43,12 +119,11 @@ async function runInit() { { title: 'Cursor', value: 'cursor', selected: true }, { title: 'Claude', value: 'claude', selected: true }, { title: 'Antigravity', value: 'antigravity', selected: true }, - { title: 'GitHub Copilot', value: 'copilot', selected: false } - ] - } + { title: 'GitHub Copilot', value: 'copilot', selected: false }, + ], + }, ]); - // If user cancels the prompt (Ctrl+C), exit gracefully if (response.projectName === undefined || response.ratifier === undefined || response.agents === undefined) { console.log('\nInitialization cancelled.'); process.exit(0); @@ -57,22 +132,296 @@ async function runInit() { try { await scaffoldFramework(targetDir, response.projectName, response.ratifier); await setupAgents(targetDir, response.agents); + ensureOps(targetDir); console.log('\nSuccess! The constitution framework is now active in your project.'); - console.log('You can now use your preferred AI agent governed by the framework.'); + console.log('Next steps:'); + console.log(' 1. Define your L0 with the ratifier (the define-preamble skill).'); + console.log(' 2. Once anything is RATIFIED, run `constitution lock accept` (you, not an agent).'); + console.log(' 3. Wire `constitution firewall` and `constitution audit` into CI.'); } catch (error) { console.error('Failed to initialize constitution:', error); process.exit(1); } } +function runAudit(args: string[]) { + const instance = load(process.cwd()); + const findings = audit(instance); + if (hasFlag(args, 'json')) { + console.log(JSON.stringify(findings, null, 2)); + } else { + console.log(formatFindings(findings)); + } + process.exit(findings.some((f) => f.severity === 'error') ? 1 : 0); +} + +function runFirewall() { + const instance = load(process.cwd()); + const lock = readLock(instance.root); + if (!lock) { + console.error(`no ${LOCK_FILENAME} — the firewall gate has nothing to hold.`); + console.error('Have the ratifier run `constitution lock accept` once, then commit the lock.'); + process.exit(1); + } + const diff = diffLock(instance, lock); + if (diff.clean) { + console.log(`firewall clean — ${Object.keys(lock.units).length} ratified unit(s) match the lock (accepted by ${lock.acceptedBy}, ${lock.acceptedAt.slice(0, 10)}).`); + process.exit(0); + } + console.error('FIREWALL: ratified L0/L1 drifted from the accepted lock (F-IV).'); + for (const id of diff.changed) console.error(` changed: ${id} — ratified text differs from what the ratifier accepted`); + for (const id of diff.added) console.error(` added: ${id} — RATIFIED but never accepted by a human`); + for (const id of diff.removed) console.error(` removed: ${id} — was accepted, no longer ratified/present`); + console.error('\nEither revert the law-plane change, or the ratifier re-runs `constitution lock accept`.'); + process.exit(1); +} + +async function runLock(args: string[]) { + const sub = positionals(args)[0] ?? 'status'; + const instance = load(process.cwd()); + const lock = readLock(instance.root); + + if (sub === 'status') { + if (!lock) { + console.log(`no ${LOCK_FILENAME}. Ratified units that would be locked:`); + } else { + const diff = diffLock(instance, lock); + console.log(`lock accepted by ${lock.acceptedBy} at ${lock.acceptedAt} (constitution @ ${lock.constitutionVersion})`); + console.log(diff.clean ? 'status: clean' : `status: DRIFT — changed [${diff.changed}], added [${diff.added}], removed [${diff.removed}]`); + return; + } + for (const [id, u] of Object.entries(computeLock(instance, '(unaccepted)').units)) { + console.log(` ${id.padEnd(8)} ${u.kind.padEnd(9)} ${u.hash.slice(0, 16)}`); + } + return; + } + + if (sub === 'accept') { + requireHumanTty('`constitution lock accept`'); + const next = computeLock(instance, ''); + console.log(`\nAbout to accept ${Object.keys(next.units).length} ratified unit(s) as the firewall baseline`); + if (lock) { + const diff = diffLock(instance, lock); + console.log(diff.clean ? '(no change vs. current lock)' : `changes vs. current lock — changed [${diff.changed}], added [${diff.added}], removed [${diff.removed}]`); + } + const resp = await prompts([ + { type: 'text', name: 'name', message: 'Your name (recorded as the accepting ratifier):', initial: instance.constitution.ratifier }, + { type: 'text', name: 'confirm', message: 'Type ACCEPT to confirm you personally reviewed the ratified text:' }, + ]); + if (resp.confirm !== 'ACCEPT' || !resp.name) { + console.log('not accepted.'); + process.exit(1); + } + next.acceptedBy = resp.name; + writeLock(instance.root, next); + console.log(`wrote ${LOCK_FILENAME} — commit it. \`constitution firewall\` now gates ratified text.`); + return; + } + + fail(`unknown lock subcommand "${sub}" (use: status | accept)`); +} + +function runCompile(args: string[]) { + const task = positionals(args).join(' ').trim(); + if (!task) fail('usage: constitution compile "" [--out] [--feature ]'); + const instance = load(process.cwd()); + if (hasFlag(args, 'out') || getFlag(args, 'feature')) { + const artifact = writeCompilePack(instance, task, getFlag(args, 'feature')); + console.log(`wrote ${artifact.file} (feature: ${artifact.feature}; 'compiled' event logged).`); + console.log('Hand the pack to the compile-prompt skill / an LLM to emit the briefing.'); + } else { + process.stdout.write(buildCompilePack(instance, task)); + } +} + +function runRender(args: string[]) { + const unitId = positionals(args)[0]; + if (!unitId) fail('usage: constitution render [--tone canonical|plain|casual|formal] [--no-generate]'); + const tone = (getFlag(args, 'tone') ?? 'canonical') as Tone | 'canonical'; + if (tone !== 'canonical' && !TONES.includes(tone as Tone)) fail(`unknown tone "${tone}" (canonical|${TONES.join('|')})`); + const instance = load(process.cwd()); + const generator = hasFlag(args, 'no-generate') ? null : claudeGenerator; + try { + const r = renderUnit(instance, unitId, tone, generator); + if (tone !== 'canonical') { + console.log(`(${r.tone} rendering — a derived view, ${r.fromCache ? 'cached' : 'freshly generated'}; the canonical text is the law)\n`); + } + console.log(r.text); + } catch (e) { + fail((e as Error).message); + } +} + +function runTones(args: string[]) { + const sub = positionals(args)[0] ?? 'check'; + if (sub !== 'check') fail('usage: constitution tones check'); + const instance = load(process.cwd()); + const r = checkTones(instance); + console.log(`fresh: ${r.fresh.length} · stale: ${r.stale.length} · orphaned: ${r.orphaned.length}`); + for (const s of r.stale) console.log(` stale: ${s.unit} (${s.tone}) — ${s.reason}`); + for (const o of r.orphaned) console.log(` orphaned: ${path.basename(o.file)} — ${o.reason}`); + if (r.stale.length + r.orphaned.length > 0) { + console.log('`constitution doctor` prunes these (derived artifacts — below the firewall).'); + process.exit(1); + } +} + +const FEATURE_VERBS: Record = { + declare: 'declared', + start: 'started', + validate: 'validated', + ship: 'shipped', + block: 'blocked', + unblock: 'unblocked', + note: 'note', +}; + +function runFeature(args: string[]) { + const pos = positionals(args); + const verb = pos[0]; + const rest = pos.slice(1).join(' ').trim(); + if (!verb || !(verb in FEATURE_VERBS) || !rest) { + fail(`usage: constitution feature <${Object.keys(FEATURE_VERBS).join('|')}> [--title "…"] [--refs F-II,ADR-0001] [--detail "…"]`); + } + const root = process.cwd(); + const known = new Set(readEvents(root).map((e) => e.feature)); + const feature = known.has(rest) ? rest : slugify(rest); + const title = getFlag(args, 'title') ?? (feature === rest ? undefined : rest); + const refs = getFlag(args, 'refs')?.split(',').map((s) => s.trim()).filter(Boolean); + const e = appendEvent(root, { type: FEATURE_VERBS[verb], feature, title, refs, detail: getFlag(args, 'detail') }); + console.log(`logged: ${e.type} ${e.feature}${title ? ` — ${title}` : ''}`); +} + +function runBoard(args: string[]) { + const root = process.cwd(); + const board = foldBoard(root); + if (hasFlag(args, 'html')) { + let instance: Instance | null = null; + try { + instance = loadInstance(root); + } catch { + /* board still renders without a parseable constitution */ + } + const out = getFlag(args, 'html') ?? path.join('.constitution', 'board.html'); + ensureOps(root); + fs.writeFileSync(path.join(root, out), renderBoardHtml(board, instance)); + console.log(`wrote ${out}`); + } else { + console.log(renderBoardText(board)); + } +} + +function runDoctorCmd() { + const instance = load(process.cwd()); + const report = runDoctor(instance); + for (const f of report.fixed) console.log(`fixed: ${f}`); + for (const q of report.queued) console.log(`queued: ${q} (above the firewall — awaiting the ratifier; see \`constitution proposals\`)`); + for (const s of report.skipped) console.log(`open: ${s}`); + if (report.remaining.length > 0) { + console.log('\nbelow-firewall findings needing a real fix (not mechanical):'); + console.log(formatFindings(report.remaining)); + } + if (report.fixed.length + report.queued.length + report.skipped.length + report.remaining.length === 0) { + console.log('healthy — nothing to fix, nothing queued.'); + } +} + +function runProposals(args: string[]) { + const pos = positionals(args); + const root = process.cwd(); + if (pos[0] === 'show' && pos[1]) { + const p = readProposal(root, pos[1]); + if (!p) fail(`no proposal "${pos[1]}"`); + console.log(p.body); + return; + } + const all = listProposals(root); + if (all.length === 0) { + console.log('ratification queue empty.'); + return; + } + for (const p of all) { + console.log(`${p.status.padEnd(9)} ${p.id} [${p.kind}] ${p.title}${p.ruledBy ? ` (ruled by ${p.ruledBy})` : ''}`); + } +} + +async function runRatify(args: string[]) { + const id = positionals(args)[0]; + if (!id) fail('usage: constitution ratify '); + requireHumanTty('`constitution ratify`'); + const root = process.cwd(); + const p = readProposal(root, id); + if (!p) fail(`no proposal "${id}" (see \`constitution proposals\`)`); + if (p.status !== 'PROPOSED') fail(`proposal "${id}" is already ${p.status}`); + console.log('\n' + p.body + '\n'); + const resp = await prompts([ + { type: 'select', name: 'ruling', message: `Your ruling on ${id}:`, choices: [ + { title: 'Approve', value: 'APPROVED' }, + { title: 'Reject', value: 'REJECTED' }, + { title: 'Leave queued', value: 'SKIP' }, + ] }, + { type: (prev: string) => (prev === 'SKIP' ? null : 'text'), name: 'name', message: 'Your name (recorded as the ratifier):' }, + { type: (_: unknown, values: { ruling?: string }) => (values.ruling === 'SKIP' ? null : 'text'), name: 'confirm', message: `Type the proposal id (${id}) to confirm:` }, + ]); + if (!resp.ruling || resp.ruling === 'SKIP') { + console.log('left queued.'); + return; + } + if (resp.confirm !== id || !resp.name) { + console.log('confirmation mismatch — nothing recorded.'); + process.exit(1); + } + recordRuling(root, id, resp.ruling, resp.name); + console.log(`recorded: ${id} ${resp.ruling} by ${resp.name}.`); + if (resp.ruling === 'APPROVED') { + console.log('Apply the drafted change to the law plane yourself (or direct an agent for below-firewall'); + console.log('mechanics), then re-run `constitution lock accept` if ratified text changed.'); + } +} + +// --------------------------------------------------------------------------- + async function main() { - const command = process.argv[2]; + const [command, ...args] = process.argv.slice(2); switch (command) { case 'init': await runInit(); break; + case 'audit': + runAudit(args); + break; + case 'firewall': + runFirewall(); + break; + case 'lock': + await runLock(args); + break; + case 'compile': + runCompile(args); + break; + case 'render': + runRender(args); + break; + case 'tones': + runTones(args); + break; + case 'feature': + runFeature(args); + break; + case 'board': + runBoard(args); + break; + case 'doctor': + runDoctorCmd(); + break; + case 'proposals': + runProposals(args); + break; + case 'ratify': + await runRatify(args); + break; case '--version': case '-v': console.log(VERSION); diff --git a/cli/test/engine.test.ts b/cli/test/engine.test.ts new file mode 100644 index 0000000..408b919 --- /dev/null +++ b/cli/test/engine.test.ts @@ -0,0 +1,261 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { describe, it, expect } from 'vitest'; +import { makeInstanceDir, MINI_CONSTITUTION } from './fixture'; +import { loadInstance, canonicalHash, parseConstitution } from '../src/engine/parse'; +import { audit } from '../src/engine/audit'; +import { computeLock, diffLock, writeLock } from '../src/engine/lock'; +import { appendEvent, readEvents } from '../src/engine/events'; +import { foldBoard, renderBoardHtml } from '../src/engine/board'; +import { renderUnit, checkTones, pruneStaleTones } from '../src/engine/tone'; +import { buildCompilePack } from '../src/engine/compile'; +import { queueProposal, listProposals, recordRuling, hasOpenProposalFor } from '../src/engine/proposals'; +import { runDoctor } from '../src/engine/doctor'; + +describe('parse', () => { + it('reads header, preamble, articles, statutes, adrs, ledger', () => { + const dir = makeInstanceDir(); + const inst = loadInstance(dir); + expect(inst.constitution.version).toBe('1.2.3'); + expect(inst.constitution.ratifier).toBe('Ada Lovelace'); + expect(inst.constitution.selfHosted).toBe(false); + expect(inst.constitution.preamble.map((p) => p.id)).toEqual(['P1', 'P2']); + expect(inst.constitution.articles.map((a) => a.id)).toEqual(['A1', 'A2']); + const a1 = inst.constitution.articles[0]; + expect(a1.status).toBe('RATIFIED'); + expect(a1.enforcement).toBe('GATED'); + expect(a1.serves).toEqual(['P1']); + expect(a1.principle).toContain('verification'); + expect(inst.statutes).toHaveLength(1); + expect(inst.statutes[0].serves).toBe('A1'); + expect(inst.statutes[0].enforcedBy).toBe('CI'); + expect(inst.adrs).toHaveLength(1); + expect(inst.adrs[0].serves).toEqual(['A1']); + expect(inst.constitution.ledger[0].version).toBe('1.2.3'); + }); + + it('parses the self-hosted framework repo itself (dogfood)', () => { + const repoRoot = path.resolve(__dirname, '..', '..'); + const inst = loadInstance(repoRoot); + expect(inst.constitution.selfHosted).toBe(true); + expect(inst.constitution.preamble.map((p) => p.id)).toEqual(['P1']); + expect(inst.constitution.articles.map((a) => a.id)).toEqual([ + 'F-I', 'F-II', 'F-III', 'F-IV', 'F-V', 'F-VI', 'F-VII', + ]); + expect(inst.constitution.articles.every((a) => a.status === 'RATIFIED')).toBe(true); + expect(inst.statutes.length).toBeGreaterThan(5); + expect(inst.adrs).toHaveLength(1); + const findings = audit(inst); + expect(findings.filter((f) => f.severity === 'error')).toEqual([]); + }); +}); + +describe('canonical hashing', () => { + it('is stable under reflow but not under wording changes', () => { + const a = 'The quick brown fox\njumps over the lazy dog.'; + const b = 'The quick brown fox jumps over the lazy dog.'; + const c = 'The quick brown fox jumps over the eager dog.'; + expect(canonicalHash(a)).toBe(canonicalHash(b)); + expect(canonicalHash(a)).not.toBe(canonicalHash(c)); + }); + + it('article hash ignores conformance/enforcement (audit outputs) but not principle', () => { + const dir = makeInstanceDir(); + const base = parseConstitution(path.join(dir, 'CONSTITUTION.md')).articles[0].hash; + fs.writeFileSync( + path.join(dir, 'CONSTITUTION.md'), + MINI_CONSTITUTION.replace('`conformance: HOLDS` · `enforcement: GATED`', '`conformance: VIOLATED` · `enforcement: UNGUARDED`') + ); + expect(parseConstitution(path.join(dir, 'CONSTITUTION.md')).articles[0].hash).toBe(base); + fs.writeFileSync( + path.join(dir, 'CONSTITUTION.md'), + MINI_CONSTITUTION.replace('passes verification before it ships', 'usually passes verification') + ); + expect(parseConstitution(path.join(dir, 'CONSTITUTION.md')).articles[0].hash).not.toBe(base); + }); +}); + +describe('lock / firewall', () => { + it('locks only ratified units and detects edits, unaccepted ratifications, and repeals', () => { + const dir = makeInstanceDir(); + const inst = loadInstance(dir); + const lock = computeLock(inst, 'Ada'); + // P1, P2 (preamble always ratified) + A1 (RATIFIED); A2 is PROPOSED — excluded. + expect(Object.keys(lock.units).sort()).toEqual(['A1', 'P1', 'P2']); + expect(diffLock(inst, lock).clean).toBe(true); + + // An agent edits ratified principle text → changed. + fs.writeFileSync( + path.join(dir, 'CONSTITUTION.md'), + MINI_CONSTITUTION.replace('Every widget passes verification', 'Most widgets pass verification') + ); + expect(diffLock(loadInstance(dir), lock).changed).toEqual(['A1']); + + // An agent flips PROPOSED → RATIFIED → added (unaccepted ratification). + fs.writeFileSync(path.join(dir, 'CONSTITUTION.md'), MINI_CONSTITUTION.replace('`status: PROPOSED`', '`status: RATIFIED`')); + expect(diffLock(loadInstance(dir), lock).added).toEqual(['A2']); + + // A ratified article vanishes → removed. + fs.writeFileSync(path.join(dir, 'CONSTITUTION.md'), MINI_CONSTITUTION.replace('`status: RATIFIED`', '`status: SUPERSEDED`')); + expect(diffLock(loadInstance(dir), lock).removed).toEqual(['A1']); + }); +}); + +describe('audit', () => { + it('is clean on the well-formed fixture', () => { + const inst = loadInstance(makeInstanceDir()); + expect(audit(inst).filter((f) => f.severity === 'error')).toEqual([]); + }); + + it('flags dangling serves as above-firewall, L0 overflow, and missing forward links', () => { + const dir = makeInstanceDir({ + constitution: (s) => + s + .replace('- **Serves** — P2.', '- **Serves** — P9.') + .replace( + '## L1 — Articles', + '**P3.** Three.\n\n**P4.** Four is too many.\n\n## L1 — Articles' + ), + }); + fs.writeFileSync( + path.join(dir, 'decisions', '0002-old.md'), + '---\nid: 0002\ntitle: old\nstatus: superseded\ndate: 2026-01-01\nsupersedes: []\nsuperseded_by: []\nserves: [A1]\namends: []\ntrigger: migration\n---\n## Question of law\nq\n## Ruling\nr\n' + ); + const findings = audit(loadInstance(dir)); + const codes = findings.map((f) => f.code); + expect(codes).toContain('ART-SERVES-DANGLING'); + expect(findings.find((f) => f.code === 'ART-SERVES-DANGLING')!.firewall).toBe('above'); + expect(codes).toContain('L0-SIZE'); + expect(codes).toContain('ADR-NO-FORWARD-LINK'); + }); + + it('flags an agent-written RATIFIED article missing from the lock', () => { + const dir = makeInstanceDir(); + const inst = loadInstance(dir); + const lock = computeLock(inst, 'Ada'); + writeLock(dir, lock); + expect(audit(loadInstance(dir)).some((f) => f.code.startsWith('LOCK-'))).toBe(false); + fs.writeFileSync(path.join(dir, 'CONSTITUTION.md'), MINI_CONSTITUTION.replace('`status: PROPOSED`', '`status: RATIFIED`')); + const f = audit(loadInstance(dir)).find((x) => x.code === 'LOCK-UNACCEPTED'); + expect(f).toBeDefined(); + expect(f!.firewall).toBe('above'); + expect(f!.severity).toBe('error'); + }); +}); + +describe('ops plane: events + board', () => { + it('folds the event log into kanban columns with blocked flags', () => { + const dir = makeInstanceDir(); + appendEvent(dir, { type: 'declared', feature: 'widget-search', title: 'Widget search', refs: ['A1'] }); + appendEvent(dir, { type: 'compiled', feature: 'widget-search' }); + appendEvent(dir, { type: 'started', feature: 'widget-search' }); + appendEvent(dir, { type: 'blocked', feature: 'widget-search', detail: 'waiting on schema' }); + appendEvent(dir, { type: 'declared', feature: 'export-csv', title: 'CSV export' }); + appendEvent(dir, { type: 'started', feature: 'export-csv' }); + appendEvent(dir, { type: 'validated', feature: 'export-csv' }); + appendEvent(dir, { type: 'shipped', feature: 'export-csv' }); + + expect(readEvents(dir)).toHaveLength(8); + const board = foldBoard(dir); + const col = (id: string) => board.columns.find((c) => c.id === id)!.features; + expect(col('started').map((f) => f.feature)).toEqual(['widget-search']); + expect(col('started')[0].blocked).toBe(true); + expect(col('started')[0].refs).toEqual(['A1']); + expect(col('shipped').map((f) => f.feature)).toEqual(['export-csv']); + + const html = renderBoardHtml(board, loadInstance(dir)); + expect(html).toContain('Widget search'); + expect(html).toContain('cf-HOLDS'); + expect(html).toContain('BLOCKED'); + }); +}); + +describe('tone rendering', () => { + it('generates via the transform, caches by canonical hash, and refuses stale views', () => { + const dir = makeInstanceDir(); + let calls = 0; + const stub = (_prompt: string, source: string) => { + calls++; + return `PLAIN: ${source.slice(0, 30)}`; + }; + const inst = loadInstance(dir); + const r1 = renderUnit(inst, 'A1', 'plain', stub); + expect(r1.generated).toBe(true); + const r2 = renderUnit(inst, 'A1', 'plain', stub); + expect(r2.fromCache).toBe(true); + expect(calls).toBe(1); + expect(r2.text).toBe(r1.text); + + // canonical passthrough never touches the generator or cache + const canon = renderUnit(inst, 'A1', 'canonical', null); + expect(canon.text).toContain('Every widget passes verification'); + + // amend the canonical text → cached view is stale by construction + fs.writeFileSync( + path.join(dir, 'CONSTITUTION.md'), + MINI_CONSTITUTION.replace('Every widget passes verification', 'Each widget must pass verification') + ); + const inst2 = loadInstance(dir); + expect(checkTones(inst2).stale).toHaveLength(1); + expect(() => renderUnit(inst2, 'A1', 'plain', null)).toThrow(/no fresh plain render/); + // with a generator it re-renders rather than serving the stale view + const r3 = renderUnit(inst2, 'A1', 'plain', stub); + expect(r3.generated).toBe(true); + expect(calls).toBe(2); + + // prune removes nothing now (fresh), but removes after another amendment + fs.writeFileSync(path.join(dir, 'CONSTITUTION.md'), MINI_CONSTITUTION); + expect(pruneStaleTones(loadInstance(dir))).toHaveLength(1); + expect(checkTones(loadInstance(dir)).fresh).toHaveLength(0); + }); +}); + +describe('compile pack', () => { + it('contains only ratified law plus statute/adr indexes and the contract', () => { + const inst = loadInstance(makeInstanceDir()); + const pack = buildCompilePack(inst, 'add widget search'); + expect(pack).toContain('task: add widget search'); + expect(pack).toContain('### A1 — Widgets are verified'); + expect(pack).not.toContain('### A2'); // PROPOSED is not law + expect(pack).toContain('single verify entrypoint'); + expect(pack).toContain('ADR-0001'); + expect(pack).toContain('DEFINITION OF DONE'); + expect(pack).toContain('STOP and'); + }); +}); + +describe('proposals + doctor', () => { + it('queues above-firewall findings exactly once and never edits the law', () => { + // P2 stays served (so only ONE above-firewall finding: the dangling P9) + const dir = makeInstanceDir({ constitution: (s) => s.replace('- **Serves** — P2.', '- **Serves** — P2, P9.') }); + const before = fs.readFileSync(path.join(dir, 'CONSTITUTION.md'), 'utf8'); + const report1 = runDoctor(loadInstance(dir)); + expect(report1.queued).toHaveLength(1); + expect(listProposals(dir)[0].status).toBe('PROPOSED'); + // idempotent: second run skips, doesn't duplicate + const report2 = runDoctor(loadInstance(dir)); + expect(report2.queued).toHaveLength(0); + expect(report2.skipped).toHaveLength(1); + expect(listProposals(dir)).toHaveLength(1); + // the law plane is untouched + expect(fs.readFileSync(path.join(dir, 'CONSTITUTION.md'), 'utf8')).toBe(before); + }); + + it('records a human ruling without applying anything', () => { + const dir = makeInstanceDir(); + const p = queueProposal(dir, { title: 'Amend A1 wording', kind: 'amendment', target: 'A1', rationale: 'r', draft: 'd' }); + expect(hasOpenProposalFor(dir, 'A1', 'amendment')).toBe(true); + const ruled = recordRuling(dir, p.id, 'APPROVED', 'Ada Lovelace'); + expect(ruled.status).toBe('APPROVED'); + expect(hasOpenProposalFor(dir, 'A1', 'amendment')).toBe(false); + expect(() => recordRuling(dir, p.id, 'REJECTED', 'Eve')).toThrow(/already APPROVED/); + }); + + it('prunes stale tone renders as a below-firewall fix', () => { + const dir = makeInstanceDir(); + renderUnit(loadInstance(dir), 'P1', 'plain', () => 'plain P1'); + fs.writeFileSync(path.join(dir, 'CONSTITUTION.md'), MINI_CONSTITUTION.replace('widgets trustworthy', 'widgets dependable')); + const report = runDoctor(loadInstance(dir)); + expect(report.fixed.some((f) => f.includes('pruned stale tone render'))).toBe(true); + }); +}); diff --git a/cli/test/fixture.ts b/cli/test/fixture.ts new file mode 100644 index 0000000..a785362 --- /dev/null +++ b/cli/test/fixture.ts @@ -0,0 +1,87 @@ +// Builds a minimal, well-formed instance in a temp dir for engine tests. + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +export const MINI_CONSTITUTION = `# Acme Constitution + +\`\`\` +framework: constitution@1.2.3 +ratifier: Ada Lovelace +\`\`\` + +## L0 — Preamble (vision) + +**P1.** Acme exists to make widgets trustworthy. + +**P2.** Widgets ship only when proven to work. + +## L1 — Articles + +### Article A1 — Widgets are verified +\`status: RATIFIED\` · \`conformance: HOLDS\` · \`enforcement: GATED\` · \`party: User\` + +- **Principle** — Every widget passes verification before it ships. +- **Serves** — P1. +- **Fitness** — CI runs the verify suite on every widget build. +- **Why** — unverified widgets break user trust. + +### Article A2 — No silent failures +\`status: PROPOSED\` · \`conformance: UNVERIFIED\` · \`enforcement: UNGUARDED\` · \`party: User\` + +- **Principle** — Failures are always surfaced to the user. +- **Serves** — P2. +- **Fitness** — grep for empty catch blocks returns zero matches. + +--- + +## Amendments Ledger + +### [1.2.3] — 2026-07-01 — founding ratification +- Founding entry. Ratifier: Ada Lovelace. +`; + +export const MINI_MAP = `# Governance Map + +- **Constitution (L0/L1)**: \`CONSTITUTION.md\` +- **Case Law (L3)**: \`decisions/\` +- **Statutes (L2)**: Managed in this \`AGENTS.md\` file. + +## L2 — Statutes + +- **All widget checks run through the single verify entrypoint.** + · serves: A1 + · enforced-by: CI + · why: two entrypoints drift apart silently. + +*This file serves as the entry-point index.* +`; + +export const MINI_ADR = `--- +id: 0001 +title: Verification runs pre-merge, not post-deploy +status: accepted +date: 2026-06-01 +supersedes: [] +superseded_by: [] +serves: [A1] +amends: [] +trigger: architectural +--- + +## Question of law +When must verification run? + +## Ruling +Pre-merge, always. +`; + +export function makeInstanceDir(mutate?: { constitution?: (s: string) => string; map?: (s: string) => string }): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'constitution-test-')); + fs.writeFileSync(path.join(dir, 'CONSTITUTION.md'), mutate?.constitution?.(MINI_CONSTITUTION) ?? MINI_CONSTITUTION); + fs.writeFileSync(path.join(dir, 'AGENTS.md'), mutate?.map?.(MINI_MAP) ?? MINI_MAP); + fs.mkdirSync(path.join(dir, 'decisions')); + fs.writeFileSync(path.join(dir, 'decisions', '0001-verify-pre-merge.md'), MINI_ADR); + return dir; +} diff --git a/constitution.config.json b/constitution.config.json new file mode 100644 index 0000000..5f171f7 --- /dev/null +++ b/constitution.config.json @@ -0,0 +1,3 @@ +{ + "versionSync": ["cli/package.json"] +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..a1b28fa --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,109 @@ +# Architecture — the three planes + +`constitution` is a governance product with three planes. Each plane has a different +change velocity, a different owner, and a different growth law. Confusing them is the +root failure mode this architecture exists to prevent. + +``` +┌────────────────────────────────────────────────────────────────────────┐ +│ LAW PLANE CONSTITUTION.md · statute homes (AGENTS.md) · │ +│ (governed, decisions/ (ADRs) │ +│ concise, human- Small, dense, durable. Grows only by ratified │ +│ ratified above amendment (L0/L1) or reviewed commit (L2/L3). │ +│ the firewall) NOTHING ELSE EVER ACCUMULATES HERE. │ +├────────────────────────────────────────────────────────────────────────┤ +│ ENGINE the `constitution` CLI (cli/) │ +│ (versioned code) parse → audit → lock/firewall gate → compile pack │ +│ → tone render → doctor. Deterministic; LLM judgment │ +│ stays in skills that consume engine output. │ +├────────────────────────────────────────────────────────────────────────┤ +│ OPS PLANE .constitution/ in each instance repo │ +│ (ungoverned events.jsonl (delivery events, append-only) · │ +│ volume) tone/ (render cache) · proposals/ (ratification │ +│ queue) · compiles/ (L4 artifacts) · board.html │ +│ All derived or operational. Deletable without │ +│ touching the law. This is where volume lives. │ +└────────────────────────────────────────────────────────────────────────┘ +``` + +## Growth law per plane + +- **Law plane** — O(invariants). An instance with 10,000 shipped features still has + ~a dozen Articles. Volume generated by usage (events, compiles, renders, drafts) + is *structurally unable* to land here: the engine writes it to `.constitution/`. +- **Engine** — grows like any codebase, versioned and released. +- **Ops plane** — O(activity). Append-only logs and derived caches. Everything in it + either references the law by id (`F-II`, `P1`, `ADR-0004`) or is regenerable. + +## The lock — the firewall as a gate, not a hope + +`constitution.lock.json` (committed, at instance root) records the canonical hash of +every **ratified** L0/L1 unit plus the constitution version. It is written only by +`constitution lock accept`, which refuses to run without an interactive human TTY and +a typed confirmation (F-IV: agents cannot hold the pen). + +`constitution firewall` (CI / pre-commit) re-hashes the ratified units and fails on +any mismatch with the lock. Result: an agent — or a careless human — *cannot* land an +edit to ratified text without a human re-accepting the lock. This upgrades F-IV's +enforcement from `AUDITED` (a skill notices later) to `GATED` (the commit is blocked). + +Hashing is over normalized canonical text (whitespace-collapsed), so reflowing a +paragraph doesn't trip the gate but changing a word does. + +## Tone — a view, never a fork + +There is exactly one canonical text per unit, in the law plane. `constitution render + --tone ` produces a reading view: + +- Cache key: `(unit id, canonical-hash, tone, transform-version)`. +- Cache home: `.constitution/tone/..md`, with the source hash recorded in + frontmatter. Canonical hash changed ⇒ cache is *stale by construction* and the + engine refuses to serve it (`--check` reports it; `doctor` prunes it). +- Generation is a transform (via `claude -p` when available) applied on read; renders + are never hand-edited (the engine overwrites them) and never consulted by + ratification, amendment, audit, lock, or compile — those read only canonical text. +- A rendering that misstates the canonical text is a transform bug: fix the prompt + (bump `transform-version`, which invalidates every cache entry), never the render. + +## Ops visibility — a dashboard over the law, not a layer of it + +Feature delivery events are appended to `.constitution/events.jsonl`: + +```json +{"ts":"…","type":"declared|compiled|started|validated|shipped|blocked|note", + "feature":"","title":"…","refs":["F-II","ADR-0001"],"detail":"…"} +``` + +`constitution board` folds the log into Kanban state (Declared → Compiled → Building → +Validating → Shipped, latest event wins, `blocked` is a flag); `--html` emits a static +`board.html` that also shows the governance health strip (per-Article +conformance/enforcement, read from the law plane). The board *references* Articles and +ADRs by id; it never stores law, and the law never stores board data. Deleting +`.constitution/` loses history, not legality. + +## Compile — deterministic pack, judgment in the skill + +`constitution compile ""` emits the **compile pack**: constitution version, +ratifier, every RATIFIED L0/L1 unit (canonical text + hash), the statute index (with +`serves`/`enforced-by` annotations), and the ADR index — plus the L4 briefing template. +Selecting *which* slices govern the task is judgment and stays in the `compile-prompt` +skill (or any LLM) consuming the pack; the pack guarantees the judgment ran over the +complete, current, canonical law. `--out` writes the artifact under +`.constitution/compiles/` and appends a `compiled` event. + +## Doctor — self-healing below the firewall, drafts above it + +`constitution doctor` runs the audit and splits findings by **what the fix touches**: + +- **Below the firewall** → fixed unattended (stale tone caches pruned, missing ops + scaffold created, version sync, regenerable artifacts), then reported. +- **Above the firewall** → a draft is queued as a file in `.constitution/proposals/` + with `status: PROPOSED`. Nothing is applied. `constitution ratify ` applies a + queued proposal only in an interactive human session with typed confirmation. + +## What stays in skills (LLM judgment) + +The engine is deterministic. Harvesting Articles, deriving statutes, judging +conformance, placing a task under the law, and phrasing tone renders remain +LLM/skill work — but every one of them now consumes engine output (parse, pack, +findings) instead of re-deriving structure from prose. diff --git a/docs/firewall.md b/docs/firewall.md new file mode 100644 index 0000000..c3103b7 --- /dev/null +++ b/docs/firewall.md @@ -0,0 +1,44 @@ +# The firewall, mechanized + +Article F-IV: no agent writes `status: RATIFIED` or edits ratified L0/L1 text. +Historically that was enforced by audit (a skill notices after the fact). The engine +upgrades it to a **gate**. + +## The lock + +`constitution.lock.json`, committed at the instance root, records the canonical hash +of every ratified unit (each L0 line, each `RATIFIED` Article) plus who accepted it +and when. Canonical hashing collapses whitespace: re-wrapping a paragraph changes +nothing; changing a word changes the hash. An Article's hash covers its ratified +substance (name, party, status, Principle, Serves, Fitness, Why) and **excludes** +`conformance` and `enforcement` — those are audit outputs, set below the firewall, +and must never trip the gate. + +## The two human-only commands + +- `constitution lock accept` — records current ratified text as the baseline. +- `constitution ratify ` — rules on a queued proposal. + +Both refuse to run without an interactive TTY and a typed confirmation. An agent in a +pipe gets: *"requires an interactive human session (F-IV)"*. This is not a guarantee a +hostile agent can't fake a TTY — it is a guarantee a well-behaved agent can't cross +the firewall *by accident*, and that CI can prove nobody crossed it unnoticed. + +## The gate + +`constitution firewall` (CI, pre-commit) re-hashes ratified units against the lock: + +- **changed** — ratified text differs from what the ratifier accepted; +- **added** — a unit is `RATIFIED` but was never accepted (e.g. an agent flipped + `PROPOSED → RATIFIED`); +- **removed** — an accepted unit is no longer ratified/present (unaccepted repeal). + +Any of the three fails the build. The only exits: revert the law-plane change, or the +ratifier re-runs `lock accept` after reviewing it. + +## Everything below is automatable + +`constitution doctor` fixes below-firewall findings unattended and queues drafts +(`.constitution/proposals/`, `status: PROPOSED`) for anything whose fix touches +ratified substance. The classification is by **what the fix touches**, not the +finding's category — the same discipline as the `reconcile-findings` skill, in code. diff --git a/docs/ops.md b/docs/ops.md new file mode 100644 index 0000000..f99cac4 --- /dev/null +++ b/docs/ops.md @@ -0,0 +1,53 @@ +# Ops visibility — a dashboard over the law, not a layer of it + +Feature-delivery visibility has the same relationship to the constitution that a build +dashboard has to source code: it **reads** the law (which Article governs this task, +what's the definition of done) and it reads delivery events — it is not a governed +layer, and the constitution never stores its data. + +## Where the data lives + +`.constitution/` in the instance repo — the ops plane: + +``` +.constitution/ + events.jsonl append-only delivery events (commit — it's your delivery record) + proposals/ the ratification queue (commit — pending law needs review) + tone/ tone render cache (gitignored — regenerable) + compiles/ L4 compile packs (gitignored — regenerable) + board.html the rendered dashboard (gitignored — regenerable) + templates/ process/ vendored spec from the CLI (read-only build artifacts) +``` + +`ensureOps` writes a `.constitution/.gitignore` with exactly that split. Volume scales +with activity here, and only here — a ten-thousand-feature product still has a +dozen-Article constitution. + +## Events + +```bash +constitution feature declare "Widget search" --refs A1,ADR-0003 +constitution feature start|validate|ship|block|unblock|note widget-search --detail "…" +``` + +One JSON line each: `{ts, type, feature, title?, refs?, detail?}`. `refs` point INTO +the law by id; the law never points back. `constitution compile --out` also logs a +`compiled` event, so the pipeline instruments itself. + +## The board + +```bash +constitution board # terminal +constitution board --html # .constitution/board.html — static, no server +``` + +Columns: **Declared → Compiled → Building → Validating → Shipped** (latest lifecycle +event wins; `blocked` is a flag, not a column). The HTML version adds the governance +health strip — every Article's `status` / `conformance` / `enforcement` read live from +the law plane — so "what's moving" and "is the law holding" sit on one page. + +## Deleting it + +Deleting `.constitution/` loses delivery history and caches — never legality. That +asymmetry is the design: the law is small and durable; the ops plane is voluminous +and expendable. diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..768ef30 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,73 @@ +# Quickstart — governing a product with `constitution` + +Ten minutes from install to a governed task loop. + +## 1. Install and scaffold + +```bash +npm install -g @chinmaygit/constitution-cli # GitHub Packages; see cli/README.md for registry setup +cd your-product +constitution init +``` + +`init` writes the law-plane skeleton (`CONSTITUTION.md`, a Governance Map in +`AGENTS.md`, `decisions/`), the spec + templates under `.constitution/`, compiled +skills for the agents you pick, and the ops scaffold (`.constitution/events.jsonl` +et al., with a `.gitignore` that keeps regenerable caches out of git). + +## 2. Define the law (once, with a human) + +1. **L0** — run the `define-preamble` skill with the ratifier present. ≤3 identity + statements; an agent may phrase, never originate (F-V). +2. **L1** — once real decisions exist, `harvest-articles` drafts Articles from them + (`status: PROPOSED`). The ratifier ratifies; agents never write `RATIFIED` (F-IV). +3. **Lock it** — the ratifier (a human, in a terminal — the command refuses agents + and pipes) runs: + +```bash +constitution lock accept # writes constitution.lock.json — commit it +``` + +4. **Gate it** — add to CI (see `.github/workflows/governance.yml` here for a model): + +```bash +constitution audit # structural integrity of the whole L0–L4 graph +constitution firewall # fails if ratified L0/L1 drifted from the accepted lock +``` + +## 3. The task loop (every feature) + +```bash +constitution feature declare "Widget search" --refs A1 # intent, on the board +constitution compile "add widget search" --out # canonical law pack → .constitution/compiles/ +# hand the pack to the compile-prompt skill / an LLM → the L4 briefing → an actor implements it +constitution feature start widget-search +constitution feature validate widget-search # definition-of-done passed +constitution feature ship widget-search +constitution board # terminal kanban; --html → .constitution/board.html +``` + +## 4. Keep it healthy (agents can run all of this) + +```bash +constitution doctor # fixes below the firewall; queues drafts above it +constitution proposals # the ratification queue +constitution tones check # tone-view drift detection +``` + +When `doctor` queues something, only a human closes it: + +```bash +constitution ratify # interactive, typed confirmation +constitution lock accept # re-accept after any ratified-text change +``` + +## Reading the law in your language + +```bash +constitution render F-II # canonical (the law, verbatim) +constitution render F-II --tone plain # plain-language view (derived, cached) +constitution render F-II --tone casual +``` + +Views are derived artifacts — see [tone.md](tone.md). The canonical text is the only law. diff --git a/docs/tone.md b/docs/tone.md new file mode 100644 index 0000000..d6dc4be --- /dev/null +++ b/docs/tone.md @@ -0,0 +1,35 @@ +# Tone — a view of the law, never a fork of it + +Constitution-speak is dense on purpose: the canonical text is a legal record. Most +readers shouldn't have to parse it that way — so tone is **user-selectable at read +time**: + +```bash +constitution render F-II # canonical — the one ratified text +constitution render F-II --tone plain # new-teammate language +constitution render F-II --tone casual # senior-engineer-over-coffee +constitution render F-II --tone formal # crisp policy prose +``` + +## The invariants + +1. **One canonical text per unit, ever.** Ratification, amendment, audit, lock, + and compile read only the canonical text. No tone is ever an input to any of them. +2. **A render is a derived artifact.** Cached at `.constitution/tone/..md` + with the source's canonical hash and the transform version in its frontmatter. + Never hand-authored, never hand-edited (the engine overwrites it), gitignored by + default. +3. **Drift is impossible to serve, not just discouraged.** A cache entry is valid only + while its recorded `source-hash` equals the live canonical hash. Amend the law and + every view of that unit is stale *by construction*: `render` regenerates or refuses + (`--no-generate`), `tones check` reports, `doctor` prunes. +4. **A wrong rendering is a transform bug.** If a tone misstates the canonical text, + fix the prompt in `cli/src/engine/tone.ts` and bump `TRANSFORM_VERSION` — which + invalidates every cached render at once. You never "fix" an individual render file. + +## Generation + +The transform runs `claude -p` with a meaning-preserving rewrite prompt (obligations, +thresholds, ids, and exceptions must survive verbatim; nothing added). No `claude` CLI +on the machine → the engine says so and points at the canonical text rather than +serving anything stale. The canonical text is always readable with no generator at all. From c1ca99d717ad1acb003c8eaf08706e50cc9df8c5 Mon Sep 17 00:00:00 2001 From: Chinmay <4730291+chinmaygit@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:34:46 +0200 Subject: [PATCH 2/3] feat(cli): non-interactive init flags; tarball install verified in a scratch consumer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `constitution init --name N --ratifier R --agents a,b` for CI/scripted installs (interactive path unchanged). - Ran the [0.16.11] pre-publish standard against 0.17.0: packed tarball, npm-installed into a fresh consumer, non-interactive init, then audit/feature/compile/board/doctor from the installed binary — all working (BUILDLOG addendum). Co-Authored-By: Claude Fable 5 --- BUILDLOG.md | 12 ++++++++++++ cli/src/index.ts | 23 +++++++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/BUILDLOG.md b/BUILDLOG.md index 9a6fec3..9a59b0b 100644 --- a/BUILDLOG.md +++ b/BUILDLOG.md @@ -98,6 +98,18 @@ bin, the vendoring pipeline, and a GitHub Packages release path. Workstreams: byte-compared in the test). - Tone: stub-generator tests prove generate → cache-hit → stale-on-amend → refuse/prune. +### Addendum — tarball install verified (same session, commit 2) +- Added non-interactive `constitution init --name N --ratifier R --agents a,b` (CI/ + scripted installs; interactive path unchanged). +- Ran the [0.16.11] pre-publish standard: `npm pack` → installed the real + `chinmaygit-constitution-cli-0.17.0.tgz` into a scratch consumer via npm → + `constitution init` (non-interactive) wrote CONSTITUTION.md (placeholders correctly + substituted: name, ratifier, `constitution@0.17.0` pin), AGENTS.md map, vendored + templates/process, compiled `.claude/` skills, ops scaffold with its `.gitignore`. +- In that fresh consumer, from the installed binary: `audit` → 0 errors, 1 honest + warning (LOCK-MISSING); `feature declare` + `compile --out` + `board` + `doctor` all + worked. The product loop is real for a brand-new team, end to end. + ### Known-untested / deferred (next sessions pick up here) - **Tone generation with a real LLM**: `claude -p` exists here but nested invocation gets 401 inside this session — engine degrades honestly (verified); real render quality diff --git a/cli/src/index.ts b/cli/src/index.ts index e23c3a4..a58fd85 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -94,10 +94,29 @@ function requireHumanTty(action: string): void { // --------------------------------------------------------------------------- -async function runInit() { +async function runInit(args: string[]) { const targetDir = process.cwd(); console.log(`\nInitializing Constitution Framework in ${targetDir}...\n`); + // Non-interactive path (CI, agents, scripted installs): all three provided. + const flagName = getFlag(args, 'name'); + const flagRatifier = getFlag(args, 'ratifier'); + const flagAgents = getFlag(args, 'agents'); + if (flagName && flagRatifier && flagAgents !== undefined) { + const agents = flagAgents.split(',').map((s) => s.trim()).filter(Boolean); + try { + await scaffoldFramework(targetDir, flagName, flagRatifier); + await setupAgents(targetDir, agents); + ensureOps(targetDir); + console.log('\nScaffolded non-interactively. The ratifier still has human-only steps:'); + console.log('define L0, ratify, then `constitution lock accept` in a terminal.'); + } catch (error) { + console.error('Failed to initialize constitution:', error); + process.exit(1); + } + return; + } + const response = await prompts([ { type: 'text', @@ -387,7 +406,7 @@ async function main() { switch (command) { case 'init': - await runInit(); + await runInit(args); break; case 'audit': runAudit(args); From 13f067809d2afba1a00ff218b7ab114fc88597a0 Mon Sep 17 00:00:00 2001 From: Chinmay <4730291+chinmaygit@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:56:10 +0200 Subject: [PATCH 3/3] added constitution lock --- constitution.lock.json | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 constitution.lock.json diff --git a/constitution.lock.json b/constitution.lock.json new file mode 100644 index 0000000..3296453 --- /dev/null +++ b/constitution.lock.json @@ -0,0 +1,40 @@ +{ + "lockVersion": 1, + "constitutionVersion": "0.17.0", + "acceptedBy": "Chinmay", + "acceptedAt": "2026-07-04T14:53:15.414Z", + "units": { + "P1": { + "kind": "preamble", + "hash": "e59b29b6d4758dec12aac0e7bda05b35001a33522abd3a9fe10b79ed776c7e85" + }, + "F-I": { + "kind": "article", + "hash": "283c8d69063d43a5a422ab92b9bc7ff300b5d32fb9bb6f2e93a17aa6839de6cd" + }, + "F-II": { + "kind": "article", + "hash": "b18967b451f0665a6fcf17a94c799bcfeb043a3a1c88e79274860b5ed131e462" + }, + "F-III": { + "kind": "article", + "hash": "4ab034ec43c0b0239f2c169d87509c50b04d61d35fba1dbe983b4a58f61bceb5" + }, + "F-IV": { + "kind": "article", + "hash": "81d13b8669b32e5dcaef0bf83af74347a7b7a8a170ec4e0b2f93b323712832d5" + }, + "F-V": { + "kind": "article", + "hash": "17e9a5bad158a4107a19ba0ca46cf33671f611c41b1285f1b041e3f6aa403b34" + }, + "F-VI": { + "kind": "article", + "hash": "4ef745af41b9f71e5d4033fa52f33d39f3f02976a4d5013cc238d854861abbd3" + }, + "F-VII": { + "kind": "article", + "hash": "c8223db55417c51c768051de757bf85b4ddf3fff615f66887d02b9da447e59ce" + } + } +}