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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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"
63 changes: 62 additions & 1 deletion BUILDLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pkg>@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.
Expand Down
51 changes: 49 additions & 2 deletions CONSTITUTION.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# The constitution framework — Constitution

```
framework: constitution@0.17.0 (self-hosted)
framework: constitution@0.17.2 (self-hosted)
ratifier: Chinmay
```

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
20 changes: 20 additions & 0 deletions cli/src/engine/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
68 changes: 68 additions & 0 deletions cli/src/engine/hooks.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
15 changes: 15 additions & 0 deletions cli/src/engine/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading