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
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@ Stays in CSA Cloud:
Use GitHub Issues for bugs/features. Include Node.js version, OS, and `charter --version` output.
For security issues, follow `SECURITY.md`.

## Papers Workflow

- GitHub Issues/PRs are the canonical tracker for active engineering work.
- `papers/` is a curated narrative layer (feedback, research, release plans), not a full mirror of all links.
- New `AGENT_DX_FEEDBACK_*.md` files must include frontmatter keys required by `scripts/papers-lint.mjs`:
`feedback-id`, `date`, `source`, `severity`, `bucket`, `status`, `tracked-issues`, `tracked-prs`.
- Release plans under `papers/releases/*-plan.md` must include the release-plan frontmatter schema validated by `scripts/papers-lint.mjs`.

## License

By contributing, you agree that contributions are licensed under Apache License 2.0.
73 changes: 51 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,27 @@

Charter is a local-first governance toolkit with a built-in AI context compiler. It ships **ADF (Attention-Directed Format)** -- a modular, AST-backed context system that replaces monolithic `.cursorrules` and `claude.md` files -- alongside offline governance checks for commit trailers, risk scoring, drift detection, and change classification.

<!-- DOCSYNC:BEGIN:ecosystem-coordination -->
## Charter: Local Enforcement + ADF Context Compiler

Charter runs in your terminal and CI pipeline. It validates commit trailers, scores drift against your blessed stack, and blocks merges on violations. Zero SaaS dependency - all checks are deterministic and local.

Charter also ships **ADF (Attention-Directed Format)** - a modular, AST-backed context system that replaces monolithic `.cursorrules` and `claude.md` files with compiled, trigger-routed `.ai/` modules. ADF treats LLM context as a compiled language: emoji-decorated semantic keys, typed patch operations, manifest-driven progressive disclosure, and metric ceilings with CI evidence gating.

```bash
npm install --save-dev @stackbilt/cli
npx charter setup --preset fullstack --ci github --yes
npx charter adf init # scaffold .ai/ context directory
```

**Governance commands:** `validate`, `drift`, `audit`, `classify`, `hook install`.
**ADF commands:** `adf init`, `adf fmt`, `adf patch`, `adf bundle`, `adf sync`, `adf evidence`.

For quantitative analysis of ADF's impact on autonomous system architecture, see the [Context-as-Code white paper](https://github.com/stackbilt-dev/charter/blob/main/papers/context-as-code-v1.1.md).

For iterative UX findings and versioned improvement plans, see the [`papers/` index](https://github.com/stackbilt-dev/charter/blob/main/papers/README.md).
<!-- DOCSYNC:END:ecosystem-coordination -->

## ADF: Attention-Directed Format

ADF treats LLM context as a compiled language. Instead of dumping flat markdown into a context window, ADF uses emoji-decorated semantic keys, a strict AST, and a module system with progressive disclosure -- so agents load only the context they need for the current task.
Expand Down Expand Up @@ -97,33 +118,33 @@ Here is the actual output from Charter's dogfood run:
ADF Evidence Report
===================
Modules loaded: core.adf, state.adf
Token estimate: ~371
Token budget: 4000 (9%)
Token estimate: ~494
Token budget: 4000 (12%)

Auto-measured:
adf_commands_loc: 413 lines (packages/cli/src/commands/adf.ts)
adf_bundle_loc: 154 lines (packages/cli/src/commands/adf-bundle.ts)
adf_sync_loc: 204 lines (packages/cli/src/commands/adf-sync.ts)
adf_evidence_loc: 263 lines (packages/cli/src/commands/adf-evidence.ts)
adf_migrate_loc: 453 lines (packages/cli/src/commands/adf-migrate.ts)
bundler_loc: 415 lines (packages/adf/src/bundler.ts)
adf_commands_loc: 577 lines (packages/cli/src/commands/adf.ts)
adf_bundle_loc: 175 lines (packages/cli/src/commands/adf-bundle.ts)
adf_sync_loc: 213 lines (packages/cli/src/commands/adf-sync.ts)
adf_evidence_loc: 312 lines (packages/cli/src/commands/adf-evidence.ts)
adf_migrate_loc: 455 lines (packages/cli/src/commands/adf-migrate.ts)
bundler_loc: 413 lines (packages/adf/src/bundler.ts)
parser_loc: 214 lines (packages/adf/src/parser.ts)
cli_entry_loc: 149 lines (packages/cli/src/index.ts)
cli_entry_loc: 191 lines (packages/cli/src/index.ts)

Section weights:
Load-bearing: 2
Advisory: 0
Unweighted: 3

Constraints:
[ok] adf_commands_loc: 413 / 500 [lines] -- PASS
[ok] adf_bundle_loc: 154 / 200 [lines] -- PASS
[ok] adf_sync_loc: 204 / 250 [lines] -- PASS
[ok] adf_evidence_loc: 263 / 300 [lines] -- PASS
[ok] adf_migrate_loc: 453 / 500 [lines] -- PASS
[ok] bundler_loc: 415 / 500 [lines] -- PASS
[ok] adf_commands_loc: 577 / 650 [lines] -- PASS
[ok] adf_bundle_loc: 175 / 200 [lines] -- PASS
[ok] adf_sync_loc: 213 / 250 [lines] -- PASS
[ok] adf_evidence_loc: 312 / 380 [lines] -- PASS
[ok] adf_migrate_loc: 455 / 500 [lines] -- PASS
[ok] bundler_loc: 413 / 500 [lines] -- PASS
[ok] parser_loc: 214 / 300 [lines] -- PASS
[ok] cli_entry_loc: 149 / 200 [lines] -- PASS
[ok] cli_entry_loc: 191 / 200 [lines] -- PASS

Sync: all sources in sync

Expand All @@ -133,7 +154,7 @@ Here is the actual output from Charter's dogfood run:
What this shows:

- **Metric ceilings enforce LOC limits on source files.** Each key in the `METRICS` section of an `.adf` module declares a ceiling. The `--auto-measure` flag counts lines live from the source files referenced in the manifest.
- **Self-correcting architecture.** When `adf_commands_loc` hit 93% of its 900-line ceiling in v0.3.4, Charter's own evidence gate caught it. The file was split into four focused modules (`adf.ts`, `adf-bundle.ts`, `adf-sync.ts`, `adf-evidence.ts`), each with its own ceiling. The pre-commit hook now prevents this from happening silently again.
- **Self-correcting architecture.** When `adf_commands_loc` approached its ceiling in v0.3.4, Charter's own evidence gate caught it. The file was split into focused modules (`adf.ts`, `adf-bundle.ts`, `adf-sync.ts`, `adf-evidence.ts`, `adf-migrate.ts`), each with its own ceiling. The pre-commit hook now prevents this from happening silently again.
- **CI gating.** Generated governance workflows run `charter doctor --adf-only --ci` and `charter adf evidence --auto-measure --ci` when `.ai/manifest.adf` is present, blocking merges on ADF wiring violations or ceiling breaches.
- **Pre-commit enforcement.** `charter hook install --pre-commit` installs a git hook that enforces `doctor --adf-only` + ADF evidence checks (or `pnpm run verify:adf` when present). When an agent runs unattended, wiring/ceiling violations block the commit.
- **Available to any repo.** This is the same system you get by running `charter adf init` in your own project.
Expand Down Expand Up @@ -163,7 +184,8 @@ For pnpm workspaces use `pnpm add -Dw @stackbilt/cli`. For a global install use

```bash
charter # Repo risk/value snapshot
charter setup --ci github # Apply governance baseline
charter bootstrap --ci github # One-command onboarding (detect + setup + ADF + install + doctor)
charter setup --ci github # Apply governance baseline (or use bootstrap)
charter doctor # Validate environment/config
charter validate # Check commit governance
charter drift # Scan for stack drift
Expand Down Expand Up @@ -205,9 +227,14 @@ Teams often score lower early due to missing governance trailers. Use this ramp:

</details>

## Cross-Platform Support

Charter v0.5.0 works across WSL, PowerShell, CMD, macOS, and Linux. All git operations use a unified invocation layer with cross-platform PATH resolution. Line endings are normalized via `.gitattributes` (LF for source, CRLF for `.bat`/`.cmd`/`.ps1`).

## Command Reference

- `charter`: show repo risk/value snapshot and recommended next action
- `charter bootstrap [--ci github] [--preset <name>] [--yes] [--skip-install] [--skip-doctor]`: one-command onboarding (detect + setup + ADF + install + doctor)
- `charter setup [--ci github] [--preset <worker|frontend|backend|fullstack>] [--detect-only] [--no-dependency-sync]`: detect stack and scaffold `.charter/` baseline
- `charter init [--preset <worker|frontend|backend|fullstack>]`: scaffold `.charter/` templates only
- `charter doctor [--adf-only]`: validate environment/config state (`--adf-only` runs strict ADF wiring checks)
Expand Down Expand Up @@ -272,12 +299,14 @@ packages/

## Research & White Papers

The [`papers/`](./papers/) directory contains versioned white papers documenting
ADF design rationale and quantitative analysis.
The [`papers/`](./papers/) directory is the curated narrative layer for Charter's
iterative process:

| Paper | Description |
| Entry Point | Purpose |
|---|---|
| [Context-as-Code v1.1](./papers/context-as-code-v1.1.md) | Quantifies ADF impact on a PRD-driven AI Orchestration Engine v2 SDLC: 80% token reduction, 0% LOC-limit violations across 33 modules. |
| [Papers Index](./papers/README.md) | Canonical overview of research papers, UX feedback, and release planning docs. |
| [UX Feedback Index](./papers/ux-feedback/README.md) | Journey-bucketed ADX findings (Onboarding, Daily Use, Reliability/Trust, Output Ergonomics, Automation/CI). |
| [Release Plans Index](./papers/releases/README.md) | Versioned plans that map selected feedback to implementation outcomes. |

## Release Docs

Expand Down
2 changes: 2 additions & 0 deletions docs-snippets/charter-oss-ecosystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ npx charter adf init # scaffold .ai/ context directory
**ADF commands:** `adf init`, `adf fmt`, `adf patch`, `adf bundle`, `adf sync`, `adf evidence`.

For quantitative analysis of ADF's impact on autonomous system architecture, see the [Context-as-Code white paper](https://github.com/stackbilt-dev/charter/blob/main/papers/context-as-code-v1.1.md).

For iterative UX findings and versioned improvement plans, see the [`papers/` index](https://github.com/stackbilt-dev/charter/blob/main/papers/README.md).
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
"test:coverage": "bash -lc \"pnpm exec vitest run --coverage\"",
"scorecard:generate": "node scripts/generate-scorecard.mjs",
"scorecard:validate": "node scripts/validate-scorecard.mjs",
"docs:sync": "node scripts/docs-sync.mjs --write",
"docs:check": "node scripts/docs-sync.mjs --check",
"docs:sync": "node scripts/docs-sync.mjs --write && node scripts/papers-lint.mjs",
"docs:check": "node scripts/docs-sync.mjs --check && node scripts/papers-lint.mjs",
"docs:oss:sync": "node scripts/docs-sync.mjs --write --config .docsync.oss.json",
"docs:oss:check": "node scripts/docs-sync.mjs --check --config .docsync.oss.json",
"docs:oss:auto": "node scripts/docs-oss-auto-sync.mjs --config .docsync.oss.json",
Expand Down
2 changes: 1 addition & 1 deletion packages/adf/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@stackbilt/adf",
"sideEffects": false,
"version": "0.4.2",
"version": "0.5.0",
"description": "ADF (Attention-Directed Format) — AST-backed context format for AI agents",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand Down
32 changes: 32 additions & 0 deletions packages/adf/src/__tests__/patcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,38 @@ describe('applyPatches', () => {

// --- Weight in ADD_SECTION ---

// --- ADD_BULLET on text section (F3 fix) ---

it('ADD_BULLET: converts text section to list and appends', () => {
const result = applyPatches(makeDoc(), [
{ op: 'ADD_BULLET', section: 'TASK', value: 'New item' },
]);
const sec = result.sections.find(s => s.key === 'TASK')!;
expect(sec.content.type).toBe('list');
if (sec.content.type === 'list') {
expect(sec.content.items).toEqual(['Build feature', 'New item']);
}
});

it('ADD_BULLET: converts empty text section to list', () => {
const doc: AdfDocument = {
version: '0.1',
sections: [
{ key: 'CONTEXT', decoration: null, content: { type: 'text', value: '' } },
],
};
const result = applyPatches(doc, [
{ op: 'ADD_BULLET', section: 'CONTEXT', value: 'First item' },
]);
const sec = result.sections.find(s => s.key === 'CONTEXT')!;
expect(sec.content.type).toBe('list');
if (sec.content.type === 'list') {
expect(sec.content.items).toEqual(['First item']);
}
});

// --- Weight in ADD_SECTION ---

it('ADD_SECTION: preserves weight annotation', () => {
const result = applyPatches(makeDoc(), [
{
Expand Down
9 changes: 7 additions & 2 deletions packages/adf/src/patcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* Throws AdfPatchError with context on any invalid operation.
*/

import type { AdfDocument, AdfSection, PatchOperation } from './types';
import type { AdfContent, AdfDocument, AdfSection, PatchOperation } from './types';
import { AdfPatchError } from './errors';

export function applyPatches(doc: AdfDocument, ops: PatchOperation[]): AdfDocument {
Expand Down Expand Up @@ -62,9 +62,14 @@ function addBullet(doc: AdfDocument, sectionKey: string, value: string): AdfDocu
} else {
section.content.entries.push({ key: value.trim(), value: '' });
}
} else if (section.content.type === 'text') {
// Convert text section to list, preserving existing prose as first item
const existing = section.content.value.trim();
const items = existing ? [existing, value] : [value];
(section as { content: AdfContent }).content = { type: 'list', items };
} else {
throw new AdfPatchError(
`Cannot ADD_BULLET to ${section.content.type} section "${sectionKey}". Section must be list or map.`,
`Cannot ADD_BULLET to ${section.content.type} section "${sectionKey}". Section must be list, map, or text.`,
'ADD_BULLET',
sectionKey
);
Expand Down
2 changes: 1 addition & 1 deletion packages/ci/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@stackbilt/ci",
"sideEffects": false,
"version": "0.4.2",
"version": "0.5.0",
"description": "GitHub Actions adapter for Charter governance checks",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand Down
2 changes: 1 addition & 1 deletion packages/classify/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@stackbilt/classify",
"sideEffects": false,
"version": "0.4.2",
"version": "0.5.0",
"description": "Heuristic change classification (SURFACE/LOCAL/CROSS_CUTTING)",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@stackbilt/cli",
"sideEffects": false,
"version": "0.4.2",
"version": "0.5.0",
"description": "Charter CLI — repo-level governance checks",
"bin": {
"charter": "./dist/bin.js"
Expand Down
124 changes: 124 additions & 0 deletions packages/cli/src/__tests__/git-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { execFileSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import {
runGit,
isGitRepo,
hasCommits,
getGitErrorMessage,
parseCommitMetadata,
parseChangedFilesByCommit,
getRecentCommitRange,
} from '../git-helpers';

describe('git-helpers', () => {
let originalCwd: string;
let tempDir: string;

beforeEach(() => {
originalCwd = process.cwd();
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'charter-git-helpers-'));
process.chdir(tempDir);
});

afterEach(() => {
process.chdir(originalCwd);
fs.rmSync(tempDir, { recursive: true, force: true });
});

describe('runGit', () => {
it('succeeds inside a git repo', () => {
execFileSync('git', ['init'], { stdio: 'ignore' });
const result = runGit(['rev-parse', '--is-inside-work-tree']).trim();
expect(result).toBe('true');
});

it('throws outside a git repo', () => {
expect(() => runGit(['rev-parse', '--is-inside-work-tree'])).toThrow();
});
});

describe('isGitRepo', () => {
it('returns true inside a git repo', () => {
execFileSync('git', ['init'], { stdio: 'ignore' });
expect(isGitRepo()).toBe(true);
});

it('returns false outside a git repo', () => {
expect(isGitRepo()).toBe(false);
});
});

describe('hasCommits', () => {
it('returns false on empty repo', () => {
execFileSync('git', ['init'], { stdio: 'ignore' });
expect(hasCommits()).toBe(false);
});

it('returns true after a commit', () => {
execFileSync('git', ['init'], { stdio: 'ignore' });
execFileSync('git', ['config', 'user.email', 'test@test.com'], { stdio: 'ignore' });
execFileSync('git', ['config', 'user.name', 'Test'], { stdio: 'ignore' });
fs.writeFileSync(path.join(tempDir, 'file.txt'), 'hello');
execFileSync('git', ['add', '.'], { stdio: 'ignore' });
execFileSync('git', ['commit', '-m', 'init'], { stdio: 'ignore' });
expect(hasCommits()).toBe(true);
});
});

describe('getGitErrorMessage', () => {
it('extracts stderr from exec error', () => {
const err = Object.assign(new Error('fail'), { stderr: 'fatal: not a repo' });
expect(getGitErrorMessage(err)).toBe('fatal: not a repo');
});

it('falls back to message', () => {
expect(getGitErrorMessage(new Error('some error'))).toBe('some error');
});

it('returns fallback for non-Error', () => {
expect(getGitErrorMessage('string')).toBe('Unknown git error.');
});
});

describe('parseCommitMetadata', () => {
it('parses git log format', () => {
const log = 'abc123\x1fAlice\x1f2026-01-01T00:00:00Z\x1fInitial commit\x1e';
const result = parseCommitMetadata(log);
expect(result).toHaveLength(1);
expect(result[0].sha).toBe('abc123');
expect(result[0].author).toBe('Alice');
expect(result[0].message).toBe('Initial commit');
});

it('handles multiple commits', () => {
const log = 'aaa\x1fA\x1f2026-01-01\x1fFirst\x1ebbb\x1fB\x1f2026-01-02\x1fSecond\x1e';
expect(parseCommitMetadata(log)).toHaveLength(2);
});
});

describe('parseChangedFilesByCommit', () => {
it('parses name-only log', () => {
const log = [
'a'.repeat(40),
'src/index.ts',
'src/util.ts',
'',
'b'.repeat(40),
'README.md',
].join('\n');
const result = parseChangedFilesByCommit(log);
expect(result.get('a'.repeat(40))).toEqual(['src/index.ts', 'src/util.ts']);
expect(result.get('b'.repeat(40))).toEqual(['README.md']);
});
});

describe('getRecentCommitRange', () => {
it('returns HEAD on empty repo', () => {
execFileSync('git', ['init'], { stdio: 'ignore' });
expect(getRecentCommitRange()).toBe('HEAD');
});
});
});
Loading