diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..19f174e --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,78 @@ +name: publish + +# Every merge to main publishes @chinmaygit/constitution-cli to GitHub Packages — +# IF the version is new. A merge that doesn't bump cli/package.json (kept equal to +# the CONSTITUTION.md header version by statute) publishes nothing and still passes. +# This mechanizes two cli/AGENTS.md statutes: "don't bump without publishing" and +# "one version number for the whole repo" — both formerly prompt-only, now GATED. + +on: + push: + branches: [main] + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + defaults: + run: + working-directory: cli + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://npm.pkg.github.com + scope: '@chinmaygit' + + - name: Install + run: npm ci + + - name: Build (vendors skills/templates/process, then strict tsc) + run: npm run build + + - name: Test + run: npm test + + - name: Gate — package version equals the constitution version + working-directory: . + run: | + CONST_V=$(grep -oE 'constitution@[0-9]+\.[0-9]+\.[0-9]+[^ ]*' CONSTITUTION.md | head -1 | cut -d@ -f2) + PKG_V=$(node -p "require('./cli/package.json').version") + echo "constitution: $CONST_V · cli/package.json: $PKG_V" + if [ "$CONST_V" != "$PKG_V" ]; then + echo "::error::version drift — cli/AGENTS.md statute: one number for the whole repo (run \`constitution doctor\`)" + exit 1 + fi + + - name: Publish (skipped if this version is already on the registry) + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PKG=$(node -p "require('./package.json').name") + V=$(node -p "require('./package.json').version") + if npm view "$PKG@$V" version >/dev/null 2>&1; then + echo "$PKG@$V already published — nothing to do." + exit 0 + fi + npm publish + echo "published $PKG@$V" + + - name: Verify the published tarball actually scaffolds + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + working-directory: . + run: | + V=$(node -p "require('./cli/package.json').version") + mkdir -p /tmp/consumer && cd /tmp/consumer + npm init -y >/dev/null + echo "@chinmaygit:registry=https://npm.pkg.github.com" > .npmrc + echo "//npm.pkg.github.com/:_authToken=\${NODE_AUTH_TOKEN}" >> .npmrc + npm install "@chinmaygit/constitution-cli@$V" + ./node_modules/.bin/constitution init --name CiSmoke --ratifier "CI Smoke" --agents claude + ./node_modules/.bin/constitution audit + test -f CONSTITUTION.md && test -d .constitution + echo "post-publish smoke test passed for $V" diff --git a/BUILDLOG.md b/BUILDLOG.md index 9a59b0b..4002d31 100644 --- a/BUILDLOG.md +++ b/BUILDLOG.md @@ -110,7 +110,68 @@ bin, the vendoring pipeline, and a GitHub Packages release path. Workstreams: 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) +--- + +## Session 2 — 2026-07-04 (same worktree, after PR #1 merged; v0.17.1) + +### Context at start +PR #1 merged to main; **the ratifier accepted the lock himself** (13f0678) — firewall +gate live and clean. Worktree rebased onto main. + +### Built + verified by running +- **F-III mechanized**: engine parses `experiments/` and audits pre-registration + (EXP-PARSE/STATUS/FIELDS/PREREG/PREREG-FUTURE findings). F-III `enforcement` flipped + `UNGUARDED → AUDITED` in the law — and `constitution firewall` stayed clean across + that edit, proving audit-derived fields are outside the lock hash. **Self-audit is + now 0 findings.** +- **`constitution hooks install`**: worktree-safe (`git rev-parse --git-path hooks`) + pre-commit running audit + firewall. Live save: the first draft would have blocked + every commit on the operator's machine — his global CLI is still **0.16.12**, which + errors "Unknown command: audit" (exit 1). Rewrote the hook to capability-check + (`--help | grep firewall`) and skip loudly on old/missing CLIs. Verified by executing + the installed hook with the 0.16.12 PATH: prints the skip message, exit 0. Installed + on this repo (hooks are shared across worktrees). +- **Skills consume the engine now**: `audit-structure 1.4.0` starts from + `constitution audit --json` (ground truth for deterministic checks; judgment reserved + for what the engine can't see); `compile-prompt 1.2.0` starts from + `constitution compile` packs; manual protocols kept as fallback. +- Tests: **19/19** (new: experiments suite incl. a last-section regex regression — + JS has no `\Z` anchor, caught in review before it shipped; hooks suite). +- `npm whoami --registry=https://npm.pkg.github.com` → `chinmaygit`: publish auth IS + present on this machine (session 1 assumed it wasn't). + +### Addendum — publish-on-merge (operator-directed, same session; v0.17.2) +- Operator: "every PR merge should publish the package." Added + `.github/workflows/publish.yml`: on push to main → build, test, version-sync gate + (pkg == constitution header), publish to GitHub Packages via built-in `GITHUB_TOKEN` + **only if the version isn't already on the registry**, then smoke-test the published + tarball (install into a fresh consumer, non-interactive `init`, `audit`, assert + scaffold) — the `[0.16.11]` lesson as a standing gate. +- Two `cli/AGENTS.md` statutes upgraded `prompt-only → CI` (bump-without-publish; + one-version-number). Supersedes the "operator runs `npm publish`" open item below: + the merge click is now the publish authorization. +- Validated locally before pushing: the gate's grep/cut extracts the header version + correctly (0.17.2 == pkg 0.17.2); `npm view @0.16.12` resolves against the real + registry with this machine's auth and `@0.17.2` correctly reports not-found; audit + + firewall clean. **The workflow itself is untested until the first real merge** — + watch the Actions run on PR #2's merge; the smoke-test step is the likeliest first + failure point (registry auth propagation for freshly-published versions). + +### Still open after session 2 +- Publish 0.17.1 + update the operator's global (0.16.12 → 0.17.1) so the pre-commit + hook actually enforces (currently it skips with a warning). **Attempted this session: + auth exists (`npm whoami` → chinmaygit) but the harness's permission layer blocked + `npm publish` as an operator-only outward action — correctly. Operator commands:** + ```bash + cd cli && npm publish # ships @chinmaygit/constitution-cli@0.17.1 + npm install -g @chinmaygit/constitution-cli@0.17.1 # arms the pre-commit hook + ``` +- Tone generation with a real LLM (nested `claude -p` still 401s in-session). +- DSAMind adoption; interactive `ratify` end-to-end; served dashboard. + +--- + +### Known-untested / deferred (carried from session 1) - **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. diff --git a/CONSTITUTION.md b/CONSTITUTION.md index 1edb024..21b99bb 100644 --- a/CONSTITUTION.md +++ b/CONSTITUTION.md @@ -1,7 +1,7 @@ # The constitution framework — Constitution ``` -framework: constitution@0.17.0 (self-hosted) +framework: constitution@0.17.2 (self-hosted) ratifier: Chinmay ``` @@ -58,7 +58,7 @@ Ratification is agreement; conformance is reality; enforcement is reality's half no rule lives outside a layer. Verified by the `audit-structure` skill. ### Article F-III — Experiments are pre-registered -`status: RATIFIED` · `conformance: HOLDS` · `enforcement: UNGUARDED` · `party: N/A` +`status: RATIFIED` · `conformance: HOLDS` · `enforcement: AUDITED` · `party: N/A` - **Principle** — Every candidate rule declares its hypothesis, metric, and decision rule **before** it runs. The decision rule is frozen for the experiment's duration. @@ -157,6 +157,53 @@ 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.2] — 2026-07-04 — Publish-on-merge: every merge to main ships the package (operator-directed) +- Operator directive: "every PR merge should publish the package." New + `.github/workflows/publish.yml`: on push to main — build (vendor + strict tsc), test, + **gate** (fail if `cli/package.json` ≠ this header's version), **publish** to GitHub + Packages via the built-in `GITHUB_TOKEN` (`packages: write`) *only if that version isn't + already on the registry* (a merge with no bump publishes nothing and passes), then + **smoke-test the published tarball**: install it into a fresh consumer, run the + non-interactive `init` + `audit`, assert the scaffold exists — the `[0.16.11]` lesson + ("a clean tarball listing isn't proof the tool works") as a standing gate. +- Two `cli/AGENTS.md` statutes upgraded `prompt-only → CI` accordingly: "don't bump + without publishing" and "one version number for the whole repo" — both now GATED by + the workflow rather than the publisher's memory. +- This also closes session 2's "publish 0.17.1" open item by superseding it: the manual + `npm publish` the harness rightly blocked an agent from running is now a repo-owned CI + act that fires on the operator's own merge click — the merge *is* the authorization. +- Below the firewall (workflow + L2 annotations + version bump). Authored on operator + instruction; entry pending review with the PR. `cli/package.json` → `0.17.2` via doctor. + +### [0.17.1] — 2026-07-04 — F-III mechanized; the firewall reaches the commit; skills consume the engine +- **Overhaul session 2** (BUILDLOG.md updated). PR #1 (0.17.0) was merged and the ratifier + personally accepted the lock (`constitution.lock.json`, commit 13f0678) — `constitution + firewall` now runs clean against it; the F-IV gate is live in CI. +- **F-III mechanized**: the engine now parses `experiments/` (per `templates/experiment.md`) + and audits pre-registration deterministically — any experiment at/past PRE-REGISTERED with + an empty/placeholder Hypothesis, Metric, or Decision rule, a missing/invalid/future + `pre-registered` date while RUNNING+, or an illegal status is an error (`EXP-*` findings). + Accordingly F-III's `enforcement` flips `UNGUARDED → AUDITED` — an audit-derived field, + below the firewall by design, and verified: `constitution firewall` stayed clean across the + edit (the lock hashes ratified substance only). The self-audit now reports **0 findings**. +- **`constitution hooks install`**: a worktree-safe pre-commit hook running audit + firewall + locally — the gate moves from CI-only to commit-time. Idempotent (marker line), refuses to + clobber a foreign hook without `--force`, skips gracefully (with a loud message) when the + PATH CLI is missing or predates 0.17.0 — that last case was caught live: the operator's + global was still 0.16.12 and the first hook draft would have blocked every commit with + "Unknown command". Installed on this repo; verified both skip paths by executing the hook. +- **Skills now consume the engine** instead of re-deriving structure from prose: + `audit-structure` (`1.3.2 → 1.4.0`) starts from `constitution audit --json` as ground truth + for the deterministic checks and spends its judgment only where the engine can't + (semantic duplication, ungoverned prose rules, meaning-level reference checks); + `compile-prompt` (`1.1.2 → 1.2.0`) starts from `constitution compile` packs (complete + canonical law, guaranteed current) and keeps the manual discovery protocol as fallback. +- Engine suite now **19 vitest cases** (experiments parsing/auditing incl. a last-section + regex regression the review caught before it shipped; hook install/idempotence/refusal). +- Below the firewall throughout: the only law-plane text edit is F-III's `enforcement` + field, which the constitution itself defines as set-by-audit. Authored autonomously; + entry pending operator review. `cli/package.json` → `0.17.1` via `constitution doctor`. + ### [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 diff --git a/cli/AGENTS.md b/cli/AGENTS.md index a5dfa46..a85bc4e 100644 --- a/cli/AGENTS.md +++ b/cli/AGENTS.md @@ -26,8 +26,9 @@ package-managed distribution mechanism, per `package.json` with no matching `npm publish` is a lie the registry can catch — don't bump without publishing, and don't publish without bumping past what's already live. · serves: general craft (documentation must not outrun reality) - · enforced-by: prompt-only (a mechanization candidate — a CI publish-on-tag workflow - would make this GATED instead of relying on the publisher's memory) + · enforced-by: CI (`.github/workflows/publish.yml` — every merge to main publishes + any not-yet-published version automatically, then smoke-tests the published + tarball by scaffolding a fresh consumer; a bump can no longer outrun a publish) · why: this statute existed to keep docs honest before publishing was real (see `CONSTITUTION.md` ledger — the decision that flipped it); now it keeps the published version and the repo's `package.json` from drifting apart instead. @@ -38,8 +39,9 @@ package-managed distribution mechanism, per lands in the same change as a `cli/` publish updates both together; a bump to one without the other is the bug, not a valid state. · serves: F-II (one home for "what version is this") - · enforced-by: prompt-only (mechanization candidate — a CI check comparing the two - would make this GATED) + · enforced-by: CI (`.github/workflows/publish.yml` fails the publish job on any + mismatch between `cli/package.json` and the `CONSTITUTION.md` header; locally, + `constitution doctor` auto-syncs via `constitution.config.json`) · why: two independently-numbered versions for one repo is exactly the confusion a consumer hits first — "why does `constitution --version` say 1.0.0 when the spec ledger is at 0.16.x." One axis removes the question. diff --git a/cli/package.json b/cli/package.json index cdda898..854c996 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@chinmaygit/constitution-cli", - "version": "0.17.0", + "version": "0.17.2", "description": "The constitution governance engine: scaffold, audit, firewall-gate, compile, render, and track AI-native product development", "license": "MIT", "author": "Chinmay", diff --git a/cli/src/engine/audit.ts b/cli/src/engine/audit.ts index aa039be..e140f70 100644 --- a/cli/src/engine/audit.ts +++ b/cli/src/engine/audit.ts @@ -126,6 +126,26 @@ export function audit(instance: Instance): Finding[] { 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' }); } + // -- experiments (F-III: pre-registered before running) ------------------------ + const EXP_STATUS = ['DRAFT', 'PRE-REGISTERED', 'RUNNING', 'MEASURED', 'GRADUATED', 'REJECTED', 'ITERATE']; + const PAST_DRAFT = EXP_STATUS.slice(1); // anything at or beyond PRE-REGISTERED + for (const exp of instance.experiments) { + for (const note of exp.parseNotes) + f.push({ code: 'EXP-PARSE', severity: 'warn', firewall: 'below', where: exp.file, message: note }); + if (!EXP_STATUS.includes(exp.status)) + f.push({ code: 'EXP-STATUS', severity: 'error', firewall: 'below', where: exp.file, message: `status "${exp.status}" is not ${EXP_STATUS.join('|')}` }); + if (PAST_DRAFT.includes(exp.status)) { + for (const [label, value] of [['Hypothesis', exp.hypothesis], ['Metric', exp.metric], ['Decision rule', exp.decisionRule]] as const) { + if (!value) + f.push({ code: 'EXP-FIELDS', severity: 'error', firewall: 'below', where: exp.file, message: `${exp.status} but "${label}" is empty/placeholder — F-III freezes all three before running` }); + } + if (!/^\d{4}-\d{2}-\d{2}$/.test(exp.preRegistered)) + f.push({ code: 'EXP-PREREG', severity: 'error', firewall: 'below', where: exp.file, message: `${exp.status} with no valid pre-registered date — running an unregistered experiment violates F-III` }); + else if (exp.preRegistered > new Date().toISOString().slice(0, 10)) + f.push({ code: 'EXP-PREREG-FUTURE', severity: 'error', firewall: 'below', where: exp.file, message: `pre-registered ${exp.preRegistered} is in the future` }); + } + } + // -- the firewall lock (F-IV) ------------------------------------------------- const lock = readLock(instance.root); if (!lock) { diff --git a/cli/src/engine/hooks.ts b/cli/src/engine/hooks.ts new file mode 100644 index 0000000..b8ab2fd --- /dev/null +++ b/cli/src/engine/hooks.ts @@ -0,0 +1,68 @@ +// `constitution hooks install` — the firewall as a LOCAL gate: a pre-commit +// hook running `constitution audit` + `constitution firewall`, so ratified-text +// drift is blocked at commit time, not discovered in CI. Idempotent; refuses to +// clobber a hook it didn't write (marker line) unless forced. + +import * as fs from 'fs'; +import * as path from 'path'; +import { execFileSync } from 'child_process'; + +export const HOOK_MARKER = '# constitution-governance-hook v1'; + +const HOOK_SCRIPT = `#!/bin/sh +${HOOK_MARKER} +# Installed by \`constitution hooks install\`. Blocks commits that break the +# constitution's structural audit or drift ratified L0/L1 from the accepted +# lock (F-IV). Re-run \`constitution hooks install\` to update; delete to remove. + +if ! command -v constitution >/dev/null 2>&1; then + echo "constitution CLI not on PATH — governance pre-commit checks SKIPPED." >&2 + exit 0 +fi + +# Pre-0.17 CLIs have no audit/firewall subcommands — don't block commits with +# "Unknown command"; say why and pass. +if ! constitution --help 2>/dev/null | grep -q "firewall"; then + echo "constitution CLI $(constitution --version 2>/dev/null) predates the governance engine (0.17.0) — checks SKIPPED; update the global install." >&2 + exit 0 +fi + +constitution audit || { + echo "" >&2 + echo "pre-commit blocked: structural audit failed (see findings above)." >&2 + exit 1 +} + +if [ -f constitution.lock.json ]; then + constitution firewall || { + echo "" >&2 + echo "pre-commit blocked: ratified L0/L1 drifted from the accepted lock (F-IV)." >&2 + echo "Revert the law-plane change, or the ratifier runs \\\`constitution lock accept\\\`." >&2 + exit 1 + } +fi +`; + +export function hooksDir(root: string): string { + // Worktree-safe: .git may be a file pointing elsewhere. + const out = execFileSync('git', ['rev-parse', '--git-path', 'hooks'], { cwd: root, encoding: 'utf8' }).trim(); + return path.isAbsolute(out) ? out : path.join(root, out); +} + +export function installHook(root: string, force = false): { path: string; action: 'installed' | 'updated' } { + const dir = hooksDir(root); + fs.mkdirSync(dir, { recursive: true }); + const hookPath = path.join(dir, 'pre-commit'); + let action: 'installed' | 'updated' = 'installed'; + if (fs.existsSync(hookPath)) { + const existing = fs.readFileSync(hookPath, 'utf8'); + if (!existing.includes(HOOK_MARKER) && !force) { + throw new Error( + `${hookPath} exists and was not written by constitution — merge the checks yourself or re-run with --force to overwrite` + ); + } + action = 'updated'; + } + fs.writeFileSync(hookPath, HOOK_SCRIPT, { mode: 0o755 }); + return { path: hookPath, action }; +} diff --git a/cli/src/engine/model.ts b/cli/src/engine/model.ts index b860423..557ba8e 100644 --- a/cli/src/engine/model.ts +++ b/cli/src/engine/model.ts @@ -81,12 +81,27 @@ export interface GovernanceMap { statuteHomes: string[]; // linked files that actually contain statute bullets } +export interface Experiment { + file: string; // relative to instance root + id: string; // e.g. "EXP-0001" + name: string; + status: string; // DRAFT | PRE-REGISTERED | RUNNING | MEASURED | GRADUATED | REJECTED | ITERATE + preRegistered: string; // YYYY-MM-DD or '' + ratifier: string; + candidate: string; + hypothesis: string; + metric: string; + decisionRule: string; + parseNotes: string[]; +} + export interface Instance { root: string; // absolute path constitution: ConstitutionDoc; map?: GovernanceMap; statutes: Statute[]; adrs: Adr[]; + experiments: Experiment[]; } // A governed unit = anything the lock hashes or tone renders: an L0 line, diff --git a/cli/src/engine/parse.ts b/cli/src/engine/parse.ts index d28078c..ca4d1a6 100644 --- a/cli/src/engine/parse.ts +++ b/cli/src/engine/parse.ts @@ -12,6 +12,7 @@ import { Adr, Article, ConstitutionDoc, + Experiment, GovernanceMap, Instance, LedgerEntry, @@ -340,6 +341,52 @@ export function parseAdr(root: string, file: string): Adr { }; } +// --------------------------------------------------------------------------- +// Experiments (candidate rules under measurement — F-III's subject matter) + +const PLACEHOLDER_SECTION = /^<[^>]*>$/; + +export function parseExperiment(root: string, file: string): Experiment { + const raw = fs.readFileSync(path.join(root, file), 'utf8'); + const notes: string[] = []; + + const heading = raw.match(/^# (EXP-\d+)\s*·\s*(.+)$/m); + if (!heading) notes.push('no `# EXP- · ` heading'); + + // Fenced field block: candidate / status / pre-registered / ratifier. + const fields: Record = {}; + const fence = raw.match(/```\n([\s\S]*?)```/); + if (fence) { + for (const line of fence[1].split('\n')) { + const m = line.match(/^([\w-]+)\s+(?:→\s+)?(.+?)(?:\s+#.*)?$/); + if (m) fields[m[1]] = m[2].trim(); + } + } else { + notes.push('no fenced field block (candidate/status/pre-registered/ratifier)'); + } + + const section = (name: string): string => { + // (?=\n## |$) — up to the next section heading or end of file; JS has no \Z. + const m = raw.match(new RegExp(`^## ${name}[^\\n]*\\n([\\s\\S]*?)(?=\\n## |$)`, 'm')); + const text = normalize(m?.[1] ?? ''); + return PLACEHOLDER_SECTION.test(text) ? '' : text; + }; + + return { + file, + id: heading?.[1] ?? '', + name: heading?.[2]?.trim() ?? '', + status: (fields['status'] ?? '').split(/[|\s]/)[0].trim(), + preRegistered: PLACEHOLDER_SECTION.test(fields['pre-registered'] ?? '') ? '' : (fields['pre-registered'] ?? ''), + ratifier: fields['ratifier'] ?? '', + candidate: fields['candidate'] ?? '', + hypothesis: section('Hypothesis'), + metric: section('Metric'), + decisionRule: section('Decision rule'), + parseNotes: notes, + }; +} + // --------------------------------------------------------------------------- // Whole instance @@ -381,5 +428,13 @@ export function loadInstance(root: string): Instance { } } - return { root, constitution, map, statutes, adrs }; + const experiments: Experiment[] = []; + const expDir = path.join(root, 'experiments'); + if (fs.existsSync(expDir)) { + for (const f of fs.readdirSync(expDir).sort()) { + if (/^EXP-\d+.*\.md$/i.test(f)) experiments.push(parseExperiment(root, path.join('experiments', f))); + } + } + + return { root, constitution, map, statutes, adrs, experiments }; } diff --git a/cli/src/index.ts b/cli/src/index.ts index a58fd85..73bbeb0 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -18,6 +18,7 @@ import { renderUnit, checkTones, claudeGenerator, TONES, Tone } from './engine/t import { buildCompilePack, writeCompilePack } from './engine/compile'; import { listProposals, readProposal, recordRuling } from './engine/proposals'; import { runDoctor } from './engine/doctor'; +import { installHook } from './engine/hooks'; import { Instance } from './engine/model'; const VERSION: string = require('../package.json').version; @@ -39,6 +40,7 @@ The firewall (F-IV, mechanized): 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 + hooks install [--force] Pre-commit gate: audit + firewall run before every commit Ops plane (delivery visibility — .constitution/, never the law): feature declare|start|validate|ship|block|unblock|note [--refs F-II,ADR-0001] @@ -435,6 +437,16 @@ async function main() { case 'doctor': runDoctorCmd(); break; + case 'hooks': { + if (positionals(args)[0] !== 'install') fail('usage: constitution hooks install [--force]'); + try { + const r = installHook(process.cwd(), hasFlag(args, 'force')); + console.log(`${r.action}: ${r.path} — audit + firewall now gate every commit here.`); + } catch (e) { + fail((e as Error).message); + } + break; + } case 'proposals': runProposals(args); break; diff --git a/cli/test/experiments.test.ts b/cli/test/experiments.test.ts new file mode 100644 index 0000000..4eaefa6 --- /dev/null +++ b/cli/test/experiments.test.ts @@ -0,0 +1,105 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { execFileSync } from 'child_process'; +import { describe, it, expect } from 'vitest'; +import { makeInstanceDir } from './fixture'; +import { loadInstance } from '../src/engine/parse'; +import { audit } from '../src/engine/audit'; +import { installHook, HOOK_MARKER } from '../src/engine/hooks'; + +const GOOD_EXP = `# EXP-0001 · verify-suite catch rate + +\`\`\` +candidate → Article A3 (draft, not yet law) +status RUNNING +pre-registered 2026-06-01 +ratifier Ada Lovelace +\`\`\` + +## Hypothesis +The verify suite catches 90% of seeded widget defects. + +## Enforcement during the experiment +Warn-only: failures logged, not blocking. + +## Metric +Catch rate over seeded defects; guardrail: false-positive rate < 5%. + +## Decision rule (frozen — do not edit after PRE-REGISTERED) +- RATIFY if catch rate ≥ 90% over 50 defects +- REJECT otherwise + +## Result (fill at MEASURED) + +`; + +function withExperiment(content: string, name = 'EXP-0001-catch-rate.md'): string { + const dir = makeInstanceDir(); + fs.mkdirSync(path.join(dir, 'experiments')); + fs.writeFileSync(path.join(dir, 'experiments', name), content); + return dir; +} + +describe('experiments (F-III mechanized)', () => { + it('parses a well-formed experiment and audits clean', () => { + const dir = withExperiment(GOOD_EXP); + const inst = loadInstance(dir); + expect(inst.experiments).toHaveLength(1); + const e = inst.experiments[0]; + expect(e.id).toBe('EXP-0001'); + expect(e.status).toBe('RUNNING'); + expect(e.preRegistered).toBe('2026-06-01'); + expect(e.hypothesis).toContain('90%'); + expect(e.decisionRule).toContain('RATIFY if'); + expect(audit(inst).filter((f) => f.code.startsWith('EXP-'))).toEqual([]); + }); + + it('flags RUNNING without pre-registration and placeholder fields', () => { + const dir = withExperiment( + GOOD_EXP.replace('pre-registered 2026-06-01', 'pre-registered ').replace( + /## Metric\n[^\n]+/, + '## Metric\n' + ) + ); + const codes = audit(loadInstance(dir)).map((f) => f.code); + expect(codes).toContain('EXP-PREREG'); + expect(codes).toContain('EXP-FIELDS'); + }); + + it('a DRAFT may be empty; a future pre-registration may not', () => { + const draftDir = withExperiment(GOOD_EXP.replace('status RUNNING', 'status DRAFT').replace('pre-registered 2026-06-01', 'pre-registered ')); + expect(audit(loadInstance(draftDir)).filter((f) => f.code.startsWith('EXP-'))).toEqual([]); + + const futureDir = withExperiment(GOOD_EXP.replace('2026-06-01', '2099-01-01')); + expect(audit(loadInstance(futureDir)).map((f) => f.code)).toContain('EXP-PREREG-FUTURE'); + }); +}); + +describe('hooks install', () => { + it('installs into a fresh repo, is idempotent, and refuses foreign hooks without --force', () => { + const dir = makeInstanceDir(); + execFileSync('git', ['init', '-q'], { cwd: dir }); + const r1 = installHook(dir); + expect(r1.action).toBe('installed'); + const content = fs.readFileSync(r1.path, 'utf8'); + expect(content).toContain(HOOK_MARKER); + expect(content).toContain('constitution firewall'); + expect(fs.statSync(r1.path).mode & 0o111).toBeTruthy(); + + expect(installHook(dir).action).toBe('updated'); + + fs.writeFileSync(r1.path, '#!/bin/sh\necho custom hook\n'); + expect(() => installHook(dir)).toThrow(/not written by constitution/); + expect(installHook(dir, true).action).toBe('updated'); + }); +}); + +describe('experiment section parsing edge case', () => { + it('reads a required section that is the last section in the file', () => { + const truncated = GOOD_EXP.split('## Result')[0].trimEnd() + '\n'; + const dir = withExperiment(truncated); + const inst = loadInstance(dir); + expect(inst.experiments[0].decisionRule).toContain('RATIFY if'); + expect(audit(inst).filter((f) => f.code.startsWith('EXP-'))).toEqual([]); + }); +}); diff --git a/skills/audit-structure/SKILL.md b/skills/audit-structure/SKILL.md index 8c47b85..b1bf8df 100644 --- a/skills/audit-structure/SKILL.md +++ b/skills/audit-structure/SKILL.md @@ -5,7 +5,7 @@ metadata: scope: project layer: cross-cutting enforces: F-II - version: "1.3.2" + version: "1.4.0" --- # Audit the constitution's structural integrity (L0–L4) @@ -23,6 +23,27 @@ are above the firewall (F-IV). It does not check code-vs-fitness (`audit-conform statutes (`derive-statutes`). To actually close a finding this audit reports, use `reconcile-findings` — it consumes this skill's output and applies what's below the firewall. +## Step 0 — run the deterministic engine first + +If the `constitution` CLI ≥ 0.17.0 is available (on PATH, or `node cli/dist/index.js` +in the framework repo itself), start with: + +```bash +constitution audit --json +``` + +That is the engine's deterministic subset of this audit — reference resolution, field +legality, L0 size/coverage, ledger/version sync, statute annotations, ADR frontmatter ++ forward links, experiment pre-registration (F-III), and lock/firewall drift (F-IV) +— already classified `above`/`below` the firewall by what the fix touches. **Treat its +output as ground truth for those checks; do not re-derive them by hand.** Your added +value is everything the engine cannot judge: semantic duplication (the same rule +*paraphrased* in two homes — the engine only compares text), ungoverned rules hiding +in prose, whether a `party:` tag matches the Preamble's named parties in meaning, +INDEX-vs-files completeness, and whether a reference that *resolves* still *means* +what the citing text claims. If the CLI is unavailable, run the full manual protocol +below. + ## The checks **1. Trace-up — every layer resolves to the one above:** diff --git a/skills/compile-prompt/SKILL.md b/skills/compile-prompt/SKILL.md index a9ef839..712dcd6 100644 --- a/skills/compile-prompt/SKILL.md +++ b/skills/compile-prompt/SKILL.md @@ -5,7 +5,7 @@ metadata: scope: project layer: L4 enforces: process/compiler.md - version: "1.1.2" + version: "1.2.0" --- # Compile a task into the L4 actor briefing @@ -23,6 +23,23 @@ through L0→L3 and hands the actor exactly the law that governs it. Spec: [process/compiler.md](../../process/compiler.md). Template: [templates/compiled-prompt.md](../../templates/compiled-prompt.md). +## Step 0 — get the compile pack from the engine + +If the `constitution` CLI ≥ 0.17.0 is available, do not gather the law by hand: + +```bash +constitution compile "" # pack to stdout +constitution compile "" --out # pack to .constitution/compiles/ + a 'compiled' ops event +``` + +The pack is the engine's guarantee that you are compiling over the **complete, current, +canonical** law: every ratified L0/L1 unit verbatim with its hash, the full statute and +ADR indexes, and the briefing contract. Your job is then pure judgment — *select* the +governing slices, tighten them to the task, and derive the definition of done. The +discovery protocol below remains the fallback when the CLI is unavailable, and the +authority for anything the pack doesn't carry (e.g. reading a full ADR body the index +only names). + ## Grounding rules (read first) 1. **Compile, don't implement.** The output is the briefing artifact, full stop. A *separate*