diff --git a/BUILDLOG.md b/BUILDLOG.md index 4002d31..a278066 100644 --- a/BUILDLOG.md +++ b/BUILDLOG.md @@ -186,7 +186,199 @@ gate live and clean. Worktree rebased onto main. (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. + +--- + +## Session 3 — 2026-07-04 (same worktree; DSAMind adoption; v0.17.2 published + operator's global updated) + +### Context at start +Operator published 0.17.1/0.17.2 and updated their global CLI (pre-commit hook now +enforces for real). Scope: point the engine at DSAMind (`~/Workspace/dsa_project`) — a +real, independently-ratified constitution (11 Articles, its own Amendments Ledger back to +[0.1.0]) — the first test of the engine against a document it wasn't hand-built against. + +### What happened — real bugs found by dogfooding, not hypothesized +Running `constitution audit` against DSAMind's actual `CONSTITUTION.md` surfaced the +engine's format assumptions were narrower than "the framework's own documents" — they +were narrower than *this repo's own dogfood shape specifically*. Fixed four real parser/ +audit bugs in `cli/src/engine/{parse,audit}.ts` (tests added, `cli/test/engine.test.ts`): + +1. **P-line parser required bare `**P1.**`** — DSAMind titles its P-lines + (`**P1 — Fluency, not coverage.**`). Fixed to accept an optional `— Title` before the + closing `.**`, folding the title into the statement text (nothing discarded). +2. **Article parser required exactly `### Article `** — DSAMind groups Articles under + `### §A` domain sections, so Articles are one level deeper: `#### Article `. Fixed + to accept `###`/`####`, and widened the bullet-block end-boundary to stop at either + level so one Article's bullets don't swallow the next. +3. **Serves-list parser choked on parenthetical commentary** — DSAMind annotates each + served P-line inline (`P1 (fluency is measured...), P4 (create-value: ...)`); the old + split-on-comma fragmented *inside* the parens, and no fragment matched the exact + `^P\d+$` filter, so real Articles read as serving nothing (cascading into false + `ART-SERVES` / `ART-SERVES-DANGLING`/`L0-UNSERVED` findings). Fixed by stripping + `(...)` groups before splitting. +4. **`ART-STATUS` rejected `SUPERSEDED — `** — the exact shape `ratify-amendment` + itself is speced to produce for a superseded Article. Fixed to accept the dated form. +5. **`LEDGER-SYNC` fired unconditionally** — it compared the header's `framework: + constitution@X` pin against the newest Amendments Ledger entry version, which is only + the same axis when `selfHosted` (this repo pins itself). A downstream consumer's ledger + tracks *its own* constitution version, independent of the framework spec version it + pins — DSAMind's ledger is at its own `[0.11.0]` while pinning `framework@0.16.12`, + legitimately. `doc.selfHosted` was already parsed but never read anywhere — wired the + existing field into the one check that needed it, rather than adding new plumbing. + +### Verified by running +- `cd cli && npm test` — **21/21** (19 prior + 2 new: real-world format-variance parsing, + LEDGER-SYNC scoped to self-hosted only). +- `npm run build` — clean; self-audit (`node cli/dist/index.js audit` on this repo) — + **0 findings**, unchanged by the fix (confirms nothing regressed on the format the + engine already handled). +- Before the fix: `constitution audit` on DSAMind → 1 error (`LEDGER-SYNC`), 13 warnings, + most cascading false positives (`L0-EMPTY` even though DSAMind has a real 4-line + Preamble; 9× `ADR-SERVES-DANGLING` because zero Articles parsed at all). +- After the fix, same repo → **16 errors, 6 warnings, all real**: + - `L0-SIZE` (F-V caps L0 at ≤3; DSAMind has 4 — P1–P3 Mission + P4 Mandate). **Not an + engine bug** — checked framework's own `Article F-V` fitness text + (`CONSTITUTION.md:89`, "L0 holds ≤3 statements") and it has no Mission/Mandate + carve-out, even though the framework's own ledger ([0.5.0]) later adopted the + Mission/Mandate split as a first-class L0 shape. This is a genuine above-firewall + open question for the ratifier — does F-V need an amendment for the two-part shape it + already adopted, or should the Mission+Mandate total stay ≤3? **Flagged, not + resolved** — no amendment drafted this session. + - 5× `ART-MECH-DEBT` (A4, B1, B2, C1, D2 = `HOLDS`+`UNGUARDED`) — **matches DSAMind's + own hand-audited "mechanization backlog" list in its Amendments Ledger exactly**, an + independent cross-check that the engine's derivation logic is sound. + - `L0-UNSERVED` for P2 — genuine: no Article's `Serves` cites P2 anywhere in the document + (checked by hand). + - 15× `LOCK-UNACCEPTED` — genuine: DSAMind's `constitution.lock.json` exists + (`acceptedBy: Chinmay`, timestamp today) but has `units: {}` — accepted before any + units existed, or accepted against a version the parser couldn't see yet. Needs a + fresh `constitution lock accept` now that parsing surfaces the real units. + +### Still open after session 3 +- **DSAMind's own `constitution lock accept`** — operator's act (F-IV), now meaningful + since parsing works. Also `npm install` in `dsa_project` (or a fresh publish pull) to + pick up the fixed engine — verification above used the worktree's local `dist/`, not + DSAMind's installed `node_modules` copy. +- **The F-V / Mission-Mandate `L0-SIZE` question** — needs the ratifier's call: amend F-V's + fitness signal, or trim DSAMind's L0. Either path goes through `propose-amendment` + + `ratify-amendment`; not started. +- DSAMind's own below-firewall findings (P2 unserved, mechanization backlog) are + candidates for `constitution doctor` / manual statute-writing in that repo, but that's + DSAMind's own governance work, not this framework's. +- This session's fixes are uncommitted in this worktree — not yet published. + +### Addendum — upgrading an existing consumer, found by trying it on DSAMind (same session) +Operator asked how to bring an old consumer (DSAMind: scaffolded 2026-07-01, before this +overhaul existed) up to date — compiled skills stuck at pre-engine versions +(`audit-structure` 1.3.2, not the engine-consuming 1.4.0), no CI firewall gate, no +pre-commit hook, `@chinmaygit/constitution-cli` present in `node_modules` but **not +declared** in `package.json`/lockfile (an unreproducible local artifact — absent on any +fresh clone or in CI). Investigated whether `constitution init` (re-run non-interactively) +is a safe update mechanism, since `setupAgents` already unconditionally overwrites every +compiled `SKILL.md` — and found two real bugs that would have made it unsafe: + +1. **`scaffold.ts`'s "does AGENTS.md already have a governance map" check was + case-sensitive** (`includes('Governance Map')`) while the engine's own detection + (`engine/parse.ts`) is case-insensitive (`/Governance Map/i`). DSAMind's actual heading + is `## Governance map (entry point)` (lowercase "map") — re-running `init` would have + silently appended a second, generic governance-map block on top of DSAMind's + already-customized one. Fixed to match the engine's own case-insensitive check. +2. **`scaffold.ts` wrote a blanket `.constitution/` line to the consumer's root + `.gitignore`.** `engine/events.ts`'s `ensureOps` also writes its own nested + `.constitution/.gitignore` (`tone/`, `compiles/`, `board.html`) specifically so + `events.jsonl` and `proposals/` stay committed — "the delivery record and the + ratification queue are worth committing," per its own comment. Verified empirically + (a throwaway git repo) that a parent-directory ignore makes git never even look for a + nested un-ignore: the blanket rule silently swallowed `events.jsonl` and + `proposals/*.json` too, contradicting the ops-plane's explicit design and — for + DSAMind specifically, which already has `.constitution/process`/`templates` **committed** + from its original init — adding a rule that (harmlessly, since git doesn't retroactively + untrack) would have gone on to block any future ops-plane commits. Fixed: the ignore is + now scoped to just `.constitution/templates/` and `.constitution/process/` (the + regenerable vendored copies), leaving the ops subdirectories to the nested + `.gitignore`'s finer-grained rules. + +**Verified by running:** dry-run `init` against a full copy of DSAMind (scratch dir, not +the real repo) — CONSTITUTION.md/AGENTS.md byte-identical before/after, only +`audit-structure`/`compile-prompt` (the two skills that changed upstream) rewritten in +`.claude/skills/` + `.agents/skills/` to 1.4.0/1.2.0, the other 9 untouched. Separately, a +fresh-consumer dry run (`git init` + `constitution init` + `feature declare` + `git add -A`) +confirms `.constitution/events.jsonl` is now stageable while `templates/`/`process/`/ +`tone/`/`compiles/`/`board.html` stay ignored. Two new tests +(scaffold `.gitignore` scoping; case-insensitive governance-map re-init) — **25/25** total. + +**The upgrade runbook for an existing consumer** (DSAMind or any pre-overhaul repo), +in order: +1. `npm install --save-dev @chinmaygit/constitution-cli@latest` in the consumer repo + (DSAMind currently has it in `node_modules/` but undeclared — not reproducible on a + fresh clone or in CI). +2. `constitution init --name --ratifier --agents claude,antigravity` + (non-interactive, safe to re-run) — refreshes compiled skills only; CONSTITUTION.md/ + AGENTS.md are left alone if already present. +3. `constitution doctor` — self-heals below-firewall findings, queues above-firewall ones + as proposals. +4. `constitution hooks install` — arms the pre-commit audit+firewall gate. +5. Add a CI workflow calling `constitution audit` + `constitution firewall` (DSAMind has + `ci.yml`/`version-changelog.yml` but no governance gate yet — port + `.github/workflows/governance.yml` from this repo). +6. **Operator-only, F-IV:** `constitution lock accept` — DSAMind's current + `constitution.lock.json` has `acceptedBy: Chinmay` but `units: {}` (accepted before + parsing worked), so every RATIFIED unit currently reads `LOCK-UNACCEPTED`. +7. Bump DSAMind's `framework: constitution@0.16.12` header pin to the installed version + once steps 1–6 land — not done automatically; no L0/L1 *schema* changed between + 0.16.12 and 0.17.2 (this overhaul added CLI tooling, not new Article fields), so the + bump is a formality once the tooling catches up, but it's still a header edit inside + the ratified document and should be a deliberate, reviewed step, not silent. +Steps 2–5 are mechanical/below-firewall; 6–7 are the ratifier's own act. **Not yet run +against the real `dsa_project` repo this session** — only against scratch copies, pending +the operator's go-ahead to touch a live product repo. + +### Addendum — proposed amendment: three version axes, three homes (same session) + +Operator asked, given the LEDGER-SYNC fix's two-axis distinction: where should the +*installed tooling* version live (they suggested `package.json`), and floated skills +self-checking + auto-migrating tooling on every run — then explicitly invoked +`/propose-amendment`. Ran that skill end to end; drafted, did **not** ratify: + +- **Ran the L1 inclusion test** on the candidate before drafting: general, traces to L0 + (P1, via F-II), falsifiable, survives a tech swap — passes. Considered a brand-new + Article ("F-VIII") vs. extending F-II; chose **extending F-II** (already the "one home + per rule" Article, already amended once by ADR-0001 for the closely related + package-managed-distribution question) — flagged in the ADR's "Alternatives considered" + that if the ratifier disagrees this is a fitting extension rather than genuinely new + territory, the correct redirect is `harvest-articles`, not a bigger amendment here. +- **decisions/0002-version-axis-separation.md** (`status: proposed`, `serves`/`amends: + [F-II]`, `trigger: certiorari`) — names the three axes and gives each one home: an + instance's own Amendments Ledger version; the adopted-spec `framework:` header pin + (ratified conformance claim, never auto-bumped past what's adopted); the installed + tooling version (consumer's own package manifest — confirming the operator's + `package.json` instinct, since that's already the natural, existing home for "what's + installed," no new field needed). Drafted F-II `Principle`/`Fitness` additions + (additive only — nothing superseded, no forward-link needed) plus `Why`/`Proven` + bullets citing this session's actual LEDGER-SYNC bug as the evidence. `decisions/ + INDEX.md` updated in the same change per `decisions/AGENTS.md`'s own statute. + **CONSTITUTION.md itself was not touched** — the drafted text lives only in the ADR, + per the firewall; only `ratify-amendment` may write it into ratified law. +- **Agreement-only, not measured** — explicit reason on record (per the skill's hard + rule against silently skipping the experiment path): this clarifies already-adopted + practice (`sync-operator`'s existing "never bump a consumer's pin past what it has + adopted") and is evidenced by a bug already found and fixed in code this session; + there's no catch-rate/friction hypothesis to pre-register. +- **Explicitly split off, not ruled on**: the "skills self-check tooling + auto-migrate" + half of the operator's proposal. No mechanism exists yet to evaluate — open questions + recorded in the ADR's Consequences: what "check is current" means without a silent + network call on every skill invocation; whether `doctor` should only *report* tooling + drift (consistent with existing propose-don't-enact discipline) or actually run `npm + update` unattended (a supply-chain-trust question, separate from version-tracking); and + where per-version migration steps would live (a `migrations/` dir keyed by version + range, run from `constitution doctor`, is the natural extension of doctor's existing + `versionSync` mechanism — unbuilt). Once a concrete mechanism exists, *that's* a genuine + F-III pre-registration candidate; this ADR is not it. +- Verified: `constitution audit` and `constitution firewall` both stay clean with the new + ADR present (L3, outside the lock) — confirms the proposal touched nothing above the + firewall. +- **Open**: awaiting the ratifier's decision (`ratify-amendment`) — accept the F-II + extension as drafted, request changes, or redirect to `harvest-articles` if a standalone + Article is preferred after all. diff --git a/CONSTITUTION.md b/CONSTITUTION.md index 21b99bb..dbcb552 100644 --- a/CONSTITUTION.md +++ b/CONSTITUTION.md @@ -1,7 +1,7 @@ # The constitution framework — Constitution ``` -framework: constitution@0.17.2 (self-hosted) +framework: constitution@0.17.3 (self-hosted) ratifier: Chinmay ``` @@ -52,10 +52,18 @@ Ratification is agreement; conformance is reality; enforcement is reality's half duplicated across layers, nor across the framework and an instance. A versioned package manager installation (e.g., via a CLI) satisfies this rule: the package is the single home, and the files it writes to the instance are its read-only build artifacts. + A version number is a governed fact too. Three exist, each with one home: the instance's + own Amendments Ledger version, the framework spec version it has adopted (the header + pin — a ratified claim, never bumped ahead of what's actually adopted), and the + framework tooling installed (the instance's own package manifest). These are never + compared to each other as if they were the same number — except in this framework's + own repo, where the pin and the ledger are the same number on purpose. - **Serves** — P1. - **Fitness** — no rule's text appears verbatim in two layer documents; every cross-layer reference (`serves` / `amends` / `supersedes` / `party`) resolves and every layer traces up; - no rule lives outside a layer. Verified by the `audit-structure` skill. + no rule lives outside a layer. No check treats the ledger version, the header pin, and the + installed tooling version as one axis, except for this framework's own self-hosted repo. + Verified by the `audit-structure` skill. ### Article F-III — Experiments are pre-registered `status: RATIFIED` · `conformance: HOLDS` · `enforcement: AUDITED` · `party: N/A` @@ -157,6 +165,27 @@ 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.3] — 2026-07-05 — Ratified by Chinmay: Article F-II extended — three version numbers, three homes +- **Ratified by Chinmay, 2026-07-05**, per [ADR-0002](decisions/0002-version-axis-separation.md). +- Found auditing DSAMind (`dsa_project`): the engine's `LEDGER-SYNC` check compared a + consumer's adopted-spec header pin against its own Amendments Ledger version and flagged + a real, correct mismatch as an error. Those are two different, legitimately independent + numbers. Fixed in code the same day (`cli/src/engine/audit.ts`); this entry writes the + distinction into law so it isn't lost. +- **Article F-II — Principle and Fitness extended** (`status: RATIFIED`, unchanged). Names + three version facts, each with exactly one home: an instance's own Amendments Ledger + version, the framework spec version it has adopted (the header pin — a ratified claim, + never bumped ahead of what's adopted), and the framework tooling installed (the instance's + own package manifest, e.g. `package.json`). Purely additive — nothing existing changed or + was superseded, no forward link needed. +- Agreement-only — no pre-registered experiment. There was no catch-rate or friction + hypothesis to measure, just a fact to state clearly. +- **Not part of this amendment**: whether skills should auto-check installed tooling is + current and auto-run migrations when it isn't. No mechanism exists yet to rule on; left + as open follow-up work (see ADR-0002's Consequences). +- Above the firewall — F-II's `status` did not change (already `RATIFIED`); its Principle + and Fitness text did, transcribed per `ratify-amendment`. + ### [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, diff --git a/cli/package-lock.json b/cli/package-lock.json index 5267fc9..bd0f7cf 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chinmaygit/constitution-cli", - "version": "0.16.12", + "version": "0.17.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chinmaygit/constitution-cli", - "version": "0.16.12", + "version": "0.17.2", "license": "MIT", "dependencies": { "prompts": "^2.4.2" diff --git a/cli/package.json b/cli/package.json index 854c996..05344ec 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@chinmaygit/constitution-cli", - "version": "0.17.2", + "version": "0.17.3", "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 e140f70..4c8de97 100644 --- a/cli/src/engine/audit.ts +++ b/cli/src/engine/audit.ts @@ -54,7 +54,10 @@ export function audit(instance: Instance): Finding[] { 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)) + // `SUPERSEDED — ` (the amendment lifecycle's own supersession shape, + // e.g. from `ratify-amendment`) carries a date suffix — still a valid status. + const statusOk = ARTICLE_STATUS.includes(a.status) || /^SUPERSEDED\s+—\s+.+$/.test(a.status); + if (!statusOk) 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('|')}` }); @@ -80,8 +83,13 @@ export function audit(instance: Instance): Finding[] { 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) + // -- ledger ------------------------------------------------------------------- + // Only the framework's own self-hosted repo pins itself in the header — there + // the header version and the ledger's own version axis are the same number. + // A downstream consumer's header pins the *framework spec* it adopted, while + // its ledger tracks the *product's own* constitution version; those are two + // legitimately independent axes and must not be compared. + if (doc.selfHosted && 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 ----------------------------------------------------------- diff --git a/cli/src/engine/parse.ts b/cli/src/engine/parse.ts index ca4d1a6..5754c85 100644 --- a/cli/src/engine/parse.ts +++ b/cli/src/engine/parse.ts @@ -85,10 +85,13 @@ function parsePreamble(lines: string[], notes: string[]): PreambleLine[] { 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*(.*)$/); + // `**P1.** text` or `**P1 — Title.** text` — the title (if any) is part of + // the statement, not separate content. + const m = lines[i].match(/^\*\*(P\d+)(?:\s+—\s+([^*]*))?\.\*\*\s*(.*)$/); if (m) { if (current) out.push(finishPreamble(current)); - current = { id: m[1], text: [m[2]], line: i + 1 }; + const title = m[2] ? `${m[2]}.` : ''; + current = { id: m[1], text: [title, m[3]].filter(Boolean), line: i + 1 }; } else if (current) { if (lines[i].trim() === '' || /^#|^---/.test(lines[i])) { out.push(finishPreamble(current)); @@ -110,7 +113,9 @@ function finishPreamble(c: { id: string; text: string[]; line: number }): Preamb 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+(.+)$/); + // `### Article A1 — Name` at top level, or `#### Article A1 — Name` when + // Articles are grouped under `### §
` domain headings. + const m = lines[i].match(/^#{3,4} Article\s+(\S+)\s+—\s+(.+)$/); if (!m) continue; const id = m[1]; const name = m[2].trim(); @@ -132,16 +137,19 @@ function parseArticles(lines: string[], notes: string[]): Article[] { // 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])) { + if (/^#{2,4} /.test(lines[j]) || /^---\s*$/.test(lines[j])) { end = j; break; } } const bullets = parseBoldBullets(lines.slice(i + 1, end)); const servesRaw = bullets['Serves'] ?? ''; + // Strip parenthetical commentary first — "P1 (fluency is ..., the unit ...)" + // has its own commas that would otherwise fragment the split below. const serves = servesRaw + .replace(/\([^)]*\)/g, '') .split(/[,;]|\band\b/) - .map((s) => s.trim().replace(/\.$/, '')) + .map((s) => s.trim().replace(/\.$/, '').trim()) .filter((s) => /^P\d+$/.test(s)); if (servesRaw && serves.length === 0) notes.push(`Article ${id}: Serves ("${servesRaw}") names no P id`); diff --git a/cli/src/scaffold.ts b/cli/src/scaffold.ts index 44c6c4f..85b8fbd 100644 --- a/cli/src/scaffold.ts +++ b/cli/src/scaffold.ts @@ -29,15 +29,23 @@ export async function scaffoldFramework(targetDir: string, projectName: string, } } - // Update .gitignore + // Update .gitignore — only the regenerable vendored copies (re-written by + // every `init`/`doctor` from the installed package), never the whole + // `.constitution/` tree: `engine/events.ts`'s `ensureOps` writes its own + // nested `.constitution/.gitignore` that keeps `events.jsonl` and + // `proposals/` committed (the delivery record and ratification queue are + // meant to be tracked) while ignoring `tone/`/`compiles/`/`board.html`. A + // blanket `.constitution/` entry here would silently shadow that — once a + // parent directory is ignored, git won't look inside for a narrower rule. const gitignorePath = path.join(targetDir, '.gitignore'); + const vendoredIgnore = '.constitution/templates/\n.constitution/process/\n'; if (fs.existsSync(gitignorePath)) { const gitignoreContent = fs.readFileSync(gitignorePath, 'utf-8'); - if (!gitignoreContent.includes('.constitution/')) { - fs.appendFileSync(gitignorePath, '\n# Framework namespace\n.constitution/\n', 'utf-8'); + if (!gitignoreContent.includes('.constitution/templates/')) { + fs.appendFileSync(gitignorePath, `\n# Framework namespace (vendored copies only; see .constitution/.gitignore for ops-plane rules)\n${vendoredIgnore}`, 'utf-8'); } } else { - fs.writeFileSync(gitignorePath, '# Framework namespace\n.constitution/\n', 'utf-8'); + fs.writeFileSync(gitignorePath, `# Framework namespace (vendored copies only; see .constitution/.gitignore for ops-plane rules)\n${vendoredIgnore}`, 'utf-8'); } // Initialize CONSTITUTION.md from templates/constitution.md -- never hand-write the @@ -76,7 +84,11 @@ export async function scaffoldFramework(targetDir: string, projectName: string, } else { console.log('AGENTS.md already exists, safe-appending governance map block...'); const existingContent = fs.readFileSync(agentsPath, 'utf-8'); - if (!existingContent.includes('Governance Map')) { + // Case-insensitive, matching how the engine itself detects a governance map + // (engine/parse.ts) -- a consumer's own map ("## Governance map (entry + // point)") must count as present, or a re-run of `init` (the update path) + // would append a duplicate generic block onto an already-customized map. + if (!/Governance Map/i.test(existingContent)) { fs.appendFileSync(agentsPath, `\n${agentMapBlock}`, 'utf-8'); } else { console.log('Governance map block already present in AGENTS.md, skipping append.'); diff --git a/cli/test/engine.test.ts b/cli/test/engine.test.ts index 408b919..305300d 100644 --- a/cli/test/engine.test.ts +++ b/cli/test/engine.test.ts @@ -11,6 +11,9 @@ 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'; +import { scaffoldFramework } from '../src/scaffold'; +import { ensureOps } from '../src/engine/events'; +import * as os from 'os'; describe('parse', () => { it('reads header, preamble, articles, statutes, adrs, ledger', () => { @@ -44,12 +47,113 @@ describe('parse', () => { ]); expect(inst.constitution.articles.every((a) => a.status === 'RATIFIED')).toBe(true); expect(inst.statutes.length).toBeGreaterThan(5); - expect(inst.adrs).toHaveLength(1); + expect(inst.adrs).toHaveLength(2); const findings = audit(inst); expect(findings.filter((f) => f.severity === 'error')).toEqual([]); }); }); +describe('parse: real-world format variance (found dogfooding against DSAMind)', () => { + it('parses titled P-lines, section-grouped #### Articles, Serves parentheticals, and dated SUPERSEDED status', () => { + const dir = makeInstanceDir({ + constitution: () => `# Acme Constitution + +\`\`\` +framework: constitution@1.2.3 +ratifier: Ada Lovelace +\`\`\` + +## L0 — Preamble (vision) + +**P1 — Fluency, not coverage.** +Acme exists to make widgets trustworthy. + +**P2 — In service of the interview.** +Widgets ship only when proven to work. + +## L1 — Articles + +### §A — Widget model +_Serves P1, P2._ + +#### Article A1 — Widgets are verified +\`status: RATIFIED\` · \`conformance: HOLDS\` · \`enforcement: GATED\` · \`party: User\` + +- **Principle** — Every widget passes verification before it ships. +- **Serves** — P1 (fluency is measured, not assumed), P2 (the interview is the point). +- **Fitness** — CI runs the verify suite on every widget build. + +#### Article A2 — Legacy widget rule +\`status: SUPERSEDED — 2026-07-02\` · \`conformance: HOLDS\` · \`enforcement: UNGUARDED\` · \`party: User\` + +- **Principle** — Widgets used to be hand-checked. +- **Serves** — P2 (grow). +- **Fitness** — n/a — superseded. + +--- + +## Amendments Ledger + +### [1.2.3] — 2026-07-01 — founding ratification +- Founding entry. Ratifier: Ada Lovelace. +`, + }); + const inst = loadInstance(dir); + expect(inst.constitution.preamble.map((p) => p.id)).toEqual(['P1', 'P2']); + expect(inst.constitution.preamble[0].text).toContain('Fluency, not coverage'); + expect(inst.constitution.preamble[0].text).toContain('trustworthy'); + expect(inst.constitution.articles.map((a) => a.id)).toEqual(['A1', 'A2']); + expect(inst.constitution.articles[0].serves).toEqual(['P1', 'P2']); + expect(inst.constitution.articles[1].serves).toEqual(['P2']); + expect(inst.constitution.articles[1].status).toBe('SUPERSEDED — 2026-07-02'); + + const findings = audit(inst); + expect(findings.map((f) => f.code)).not.toContain('L0-EMPTY'); + expect(findings.map((f) => f.code)).not.toContain('ART-STATUS'); + expect(findings.map((f) => f.code)).not.toContain('ART-SERVES'); + expect(findings.map((f) => f.code)).not.toContain('PARSE'); + }); + + it('does not apply LEDGER-SYNC to a non-self-hosted instance whose own ledger version differs from the framework pin', () => { + const dir = makeInstanceDir({ + constitution: (s) => s.replace('### [1.2.3] — 2026-07-01', '### [0.5.0] — 2026-07-01'), + }); + const findings = audit(loadInstance(dir)); + expect(findings.map((f) => f.code)).not.toContain('LEDGER-SYNC'); + }); +}); + +describe('scaffold: .gitignore does not shadow the ops-plane\'s own rules', () => { + it('ignores only the regenerable vendored copies, leaving events.jsonl/proposals trackable', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'constitution-scaffold-')); + await scaffoldFramework(dir, 'Acme', 'Ada'); + ensureOps(dir); + const gitignore = fs.readFileSync(path.join(dir, '.gitignore'), 'utf8'); + expect(gitignore).toContain('.constitution/templates/'); + expect(gitignore).toContain('.constitution/process/'); + expect(gitignore).not.toMatch(/^\.constitution\/\s*$/m); + + // Simulate git's own ignore-matching: a blanket `.constitution/` entry + // would make this file unreachable regardless of the nested .gitignore. + const rules = gitignore.split('\n').filter(Boolean).filter((l) => !l.startsWith('#')); + const ignoredAsDir = rules.some((r) => r === '.constitution/' || r === '.constitution'); + expect(ignoredAsDir).toBe(false); + + const opsIgnore = fs.readFileSync(path.join(dir, '.constitution', '.gitignore'), 'utf8'); + expect(opsIgnore).toContain('tone/'); + expect(opsIgnore).toContain('compiles/'); + expect(opsIgnore).not.toContain('events.jsonl'); + }); + + it('re-running init on an existing governance map (case-insensitive heading) does not duplicate it', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'constitution-scaffold-')); + fs.writeFileSync(path.join(dir, 'AGENTS.md'), '## Governance map (entry point)\n\nCustom, hand-authored map.\n'); + await scaffoldFramework(dir, 'Acme', 'Ada'); + const content = fs.readFileSync(path.join(dir, 'AGENTS.md'), 'utf8'); + expect(content).toBe('## Governance map (entry point)\n\nCustom, hand-authored map.\n'); + }); +}); + describe('canonical hashing', () => { it('is stable under reflow but not under wording changes', () => { const a = 'The quick brown fox\njumps over the lazy dog.'; diff --git a/constitution.lock.json b/constitution.lock.json index 3296453..0c44172 100644 --- a/constitution.lock.json +++ b/constitution.lock.json @@ -1,8 +1,8 @@ { "lockVersion": 1, - "constitutionVersion": "0.17.0", + "constitutionVersion": "0.17.3", "acceptedBy": "Chinmay", - "acceptedAt": "2026-07-04T14:53:15.414Z", + "acceptedAt": "2026-07-05T08:45:28.047Z", "units": { "P1": { "kind": "preamble", @@ -14,7 +14,7 @@ }, "F-II": { "kind": "article", - "hash": "b18967b451f0665a6fcf17a94c799bcfeb043a3a1c88e79274860b5ed131e462" + "hash": "06ed10f12ad527f757a808764c7ee7f6fcc3432bfb06737a6180ad7a980334a5" }, "F-III": { "kind": "article", diff --git a/decisions/0002-version-axis-separation.md b/decisions/0002-version-axis-separation.md new file mode 100644 index 0000000..cc5a0d2 --- /dev/null +++ b/decisions/0002-version-axis-separation.md @@ -0,0 +1,61 @@ +--- +id: 0002 +title: Three version numbers, three homes +status: accepted +date: 2026-07-05 +supersedes: [] +superseded_by: [] +serves: [F-II] +amends: [F-II] +trigger: certiorari +--- + +## Question of law +A product has three different version numbers. We were treating two of them as one. + +1. **Its own constitution version** — the product's Amendments Ledger (DSAMind is at `[0.11.0]`). +2. **The framework spec version it adopted** — the `framework: constitution@X.Y.Z` pin in + its `CONSTITUTION.md` header. Ratified by a human. Allowed to lag behind the latest spec. +3. **The framework tooling it has installed** — the CLI version in its own `package.json`. + +Today's bug: the engine's `LEDGER-SYNC` check compared #2 to #1 and treated any mismatch as +an error. But #1 and #2 don't have to match — a product's own version number has nothing to +do with which framework spec it adopted. Already fixed in code +(`cli/src/engine/audit.ts`). This ADR writes the rule into F-II so it isn't lost. + +Not decided here: should skills auto-check that #3 is current, and auto-run a migration when +it isn't? No such mechanism exists yet, so there's nothing to rule on. See Consequences. + +## Ruling +Extend Article F-II ("one home per rule"): a version number is a rule too, and each of the +three numbers above gets exactly one home. Nothing existing changes — this only adds text. + +**Add to Principle:** +> A version number is a governed fact. Three exist, each with one home: the instance's own +> Amendments Ledger version, the framework spec version it has adopted (the header pin — a +> ratified claim, never bumped ahead of what's actually adopted), and the framework tooling +> installed (the instance's own package manifest). These are never compared to each other as +> if they were the same number — except in this framework's own repo, where the pin and the +> ledger are the same number on purpose. + +**Add to Fitness:** +> No check treats the ledger version, the header pin, and the installed tooling version as +> one axis, except for this framework's own self-hosted repo. + +## Constitutional impact +Adds two sentences to F-II's Principle and Fitness. Nothing is removed or superseded. + +## Consequences +- **Positive** — stops today's false alarm (DSAMind's correct version numbers were flagged + as an error) from happening again. +- **Negative** — none. +- **Not decided here** — auto-checking installed tooling and auto-running migrations. No + mechanism exists yet to build on, so it's follow-up engineering work, not part of this + amendment. If a mechanism gets built later, whether it actually helps is worth measuring + then (F-III), not assumed now. + +## Alternatives considered +- **New Article instead of extending F-II** — rejected. This is a version of "one home per + rule," which is what F-II already covers. +- **Measure this instead of just ratifying it** — rejected. There's no hypothesis to test, + just a fact to state clearly. diff --git a/decisions/INDEX.md b/decisions/INDEX.md index c87af62..a73b17c 100644 --- a/decisions/INDEX.md +++ b/decisions/INDEX.md @@ -6,3 +6,4 @@ an ADR) are L2 statutes — see [AGENTS.md](AGENTS.md). | id | title | status | date | |----|-------|--------|------| | 0001 | Allow package-managed installation of skills to satisfy F-II | accepted | 2026-07-01 | +| 0002 | Three version numbers, three homes | accepted | 2026-07-05 |