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
19 changes: 19 additions & 0 deletions PUBLISHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,25 @@ Confirm:
- behavior matches docs and exit-code contract.
- mixed-stack repos are detected correctly, or can be corrected with explicit `--preset fullstack`.

Release completion gates (required before declaring release complete):

1. GitHub artifacts:
- release exists for the tag (for example `v0.16.0`)
- `release.yml` run for the tag is `success`
2. npm publication:
- all 12 workspace packages are resolvable at the target version
3. tracker hygiene:
- release-scoped issues/PR follow-ups are closed or explicitly carried forward

Example npm verification command for a target version:

```bash
VERSION=0.16.0
for p in types core adf git classify validate drift blast surface policies ci cli; do
npm view "@stackbilt/${p}@${VERSION}" version
done
```

## Release Artifacts

After successful publish:
Expand Down
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Detects your stack, scaffolds `.ai/`, migrates existing CLAUDE.md / `.cursorrule
- **Measurable constraints** — per-module metric ceilings (LOC, complexity, bloat) validated at commit time and in CI.
- **Codebase analysis** — `charter blast` reverse-dependency graphs, `charter surface` route/schema fingerprints. Deterministic, zero runtime deps.
- **Drift + audit** — anti-pattern scans, commit governance, CI-ready exit codes.
- **MCP server** — `charter serve` exposes project context to Claude Code.
- **MCP server** — `charter serve` exposes project context to Claude Code, Codex, and Cursor.

Compose with the broader [Stackbilt ecosystem](https://github.com/Stackbilt-dev) — [audit-chain](https://github.com/Stackbilt-dev/audit-chain), [worker-observability](https://github.com/Stackbilt-dev/worker-observability), [llm-providers](https://github.com/Stackbilt-dev/llm-providers), [adf](https://www.npmjs.com/package/@stackbilt/adf) — when you need them.

Expand Down Expand Up @@ -84,7 +84,7 @@ METRICS [load-bearing]:

`charter adf evidence --auto-measure` validates these live. Pre-commit hooks reject code that exceeds ceilings. CI workflows gate merges. Charter enforces its own rules on its own codebase -- every commit.

### MCP server for Claude Code
### MCP server for Claude Code and Codex

```json
{
Expand All @@ -99,6 +99,19 @@ METRICS [load-bearing]:

Claude Code can query `getProjectContext`, `getArchitecturalDecisions`, `getProjectState`, and `getRecentChanges` directly.

Codex/Cursor can use the same MCP wiring via `.mcp.json`:

```json
{
"mcpServers": {
"charter": {
"command": "npx",
"args": ["@stackbilt/cli", "serve", "--ai-dir", "/absolute/path/to/.ai"]
}
}
}
```

The `charter_brief` MCP tool composes routes, hotspots, and governance into a single pre-digested brief — call it first in any agent session to skip 15-30 cold-boot discovery calls.

For live session continuity snapshots, use `charter context-refresh` to produce `.ai/context.adf` + `.ai/context.snapshot.json` (with optional GitHub source and TTL controls).
Expand Down Expand Up @@ -129,7 +142,7 @@ charter adf migrate # Migrate existing configs
charter adf sync --check # Verify files match lock
charter adf fmt .ai/core.adf --write # Reformat to canonical form
charter adf metrics recalibrate # Adjust ceilings to current state
charter serve # MCP server for Claude Code
charter serve # MCP server for Claude Code, Codex, Cursor
```

### Analyze
Expand Down
7 changes: 4 additions & 3 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,12 +341,12 @@ npx charter blast src/foo.ts --root ./packages/server # scan a subdirector

### charter serve

Expose ADF project context as an MCP server over stdio, for use with Claude Code.
Expose ADF project context as an MCP server over stdio, for use with Claude Code, Codex, and Cursor.

```bash
npx charter serve # stdio MCP server (default)
npx charter serve --ai-dir /abs/path/.ai # explicit ADF directory
npx charter serve --name "my-project" # override the server name shown in Claude Code
npx charter serve --name "my-project" # override the server name shown in MCP clients
```

- `--ai-dir <dir>` — path to the `.ai/` ADF directory (default: `.ai`). **Always resolved to an absolute path at startup.** When wiring in `.mcp.json`, use an absolute path or a path relative to the project root — relative paths are resolved against the working directory at spawn time, which may differ from the project root in multi-repo setups.
Expand All @@ -369,7 +369,7 @@ Use an absolute path for `--ai-dir`. A relative path like `.ai` resolves against

#### Startup errors

If startup validation fails (missing `.ai/` directory or `manifest.adf`), `charter serve` emits a structured JSON-RPC error to stdout before exiting so Claude Code can surface a human-readable message:
If startup validation fails (missing `.ai/` directory or `manifest.adf`), `charter serve` emits a structured JSON-RPC error to stdout before exiting so MCP clients can surface a human-readable message:

| Condition | Error message | Fix |
|-----------|--------------|-----|
Expand All @@ -381,6 +381,7 @@ If startup validation fails (missing `.ai/` directory or `manifest.adf`), `chart
| Tool | Description |
|------|-------------|
| `charter_brief` | **Call first.** Pre-digested repo brief — routes, hotspots, governance. |
| `charter_context` | Session continuity snapshot reader/refresher (`.ai/context.snapshot.json`). Use `refresh=true` to run `context-refresh` before reading. |
| `getProjectContext` | ADF bundle resolved for a given task or trigger keywords. |
| `getProjectState` | Constraint validation results across all loaded modules. |
| `getArchitecturalDecisions` | Load-bearing constraints from `core.adf`. |
Expand Down
26 changes: 26 additions & 0 deletions docs/context-refresh-resume.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,32 @@ To close `#155`, complete:
- TTL tuning guidance
- example hook wiring

## Phase 3 Kickoff Checklist (v0.16.0+)

Track this as the active implementation sequence for `#155`:

1. `serve` wiring:
- add MCP tool contract for `charter_context`
- support read-only mode (`refresh=false`) and refresh mode (`refresh=true`)
2. runtime behavior:
- return structured JSON from `.ai/context.snapshot.json` when available
- on missing snapshot + `refresh=false`, return explicit actionable error
- on `refresh=true`, invoke existing `context-refresh` pipeline and return refreshed snapshot
3. tests:
- tool reads existing snapshot
- tool refresh path returns updated snapshot
- missing snapshot behavior is deterministic and documented
4. docs:
- `docs/cli-reference.md` tool contract
- `README.md` session-start flow using `--once` and TTL guidance

Definition of done for Phase 3:

- `charter serve` exposes `charter_context` with stable JSON output
- end-to-end tests pass for read/refresh/error paths
- docs include a copy/pasteable session-start hook example
- issue `#155` can close with no remaining TODOs

## Suggested Restart Plan (Next Session)

1. Add `charter_context` tool registration in `packages/cli/src/commands/serve.ts`
Expand Down
51 changes: 51 additions & 0 deletions packages/cli/src/__tests__/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,57 @@ STATE:
expect(updatedSecurityCheck.status).toBe('PASS');
});

it('creates .mcp.json with charter MCP server wiring for Codex/Cursor', async () => {
const exitCode = await bootstrapCommand(
{ ...baseOptions, yes: true },
['--yes', '--preset', 'worker', '--skip-install', '--skip-doctor'],
);

expect(exitCode).toBe(0);
expect(fs.existsSync('.mcp.json')).toBe(true);

const parsed = JSON.parse(fs.readFileSync('.mcp.json', 'utf-8'));
expect(parsed).toHaveProperty('mcpServers.charter');
expect(parsed.mcpServers.charter.command).toBe('npx');
expect(parsed.mcpServers.charter.args).toEqual([
'@stackbilt/cli',
'serve',
'--ai-dir',
path.resolve('.ai'),
]);
});

it('does not overwrite existing mcpServers.charter without --force', async () => {
fs.writeFileSync(
'.mcp.json',
JSON.stringify(
{
mcpServers: {
charter: {
command: 'charter',
args: ['serve'],
},
github: {
command: 'npx',
args: ['@modelcontextprotocol/server-github'],
},
},
},
null,
2,
) + '\n',
);

const before = fs.readFileSync('.mcp.json', 'utf-8');
const exitCode = await bootstrapCommand(
baseOptions,
['--preset', 'worker', '--skip-install', '--skip-doctor'],
);

expect(exitCode).toBe(0);
expect(fs.readFileSync('.mcp.json', 'utf-8')).toBe(before);
});

it('treats security deny drift matches as CI policy violations', async () => {
await bootstrapCommand(
{ ...baseOptions, yes: true },
Expand Down
104 changes: 104 additions & 0 deletions packages/cli/src/__tests__/serve-context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { CLIOptions } from '../index';
import { EXIT_CODE } from '../index';
import { CLIError } from '../index';
import { loadCharterContextSnapshot } from '../commands/serve';

const contextRefreshCommandMock = vi.hoisted(() => vi.fn());
vi.mock('../commands/context-refresh', () => ({
contextRefreshCommand: contextRefreshCommandMock,
}));

const baseOptions: CLIOptions = {
configPath: '.charter',
format: 'text',
ciMode: false,
yes: false,
};

const originalCwd = process.cwd();
const tempDirs: string[] = [];

function makeTempDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'charter-serve-context-test-'));
tempDirs.push(dir);
return dir;
}

afterEach(() => {
process.chdir(originalCwd);
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) fs.rmSync(dir, { recursive: true, force: true });
}
});

beforeEach(() => {
contextRefreshCommandMock.mockReset();
});

describe('loadCharterContextSnapshot', () => {
it('returns existing snapshot when refresh is false', async () => {
const tmp = makeTempDir();
process.chdir(tmp);

const aiDir = path.join(tmp, '.ai');
fs.mkdirSync(aiDir, { recursive: true });
const snapshotPath = path.join(aiDir, 'context.snapshot.json');
fs.writeFileSync(snapshotPath, JSON.stringify({ version: 1, generatedAt: '2026-01-01T00:00:00Z' }), 'utf8');

const result = await loadCharterContextSnapshot(baseOptions, aiDir, { refresh: false });
expect(result.refreshed).toBe(false);
expect(result.snapshotPath).toBe('.ai/context.snapshot.json');
expect((result.snapshot as { version: number }).version).toBe(1);
});

it('throws actionable error when snapshot is missing and refresh is false', async () => {
const tmp = makeTempDir();
process.chdir(tmp);

const aiDir = path.join(tmp, '.ai');
fs.mkdirSync(aiDir, { recursive: true });

await expect(
loadCharterContextSnapshot(baseOptions, aiDir, { refresh: false }),
).rejects.toThrowError(CLIError);
await expect(
loadCharterContextSnapshot(baseOptions, aiDir, { refresh: false }),
).rejects.toThrow(/refresh=true/);
});

it('refreshes snapshot when refresh is true', async () => {
const tmp = makeTempDir();
process.chdir(tmp);

const aiDir = path.join(tmp, '.ai');
contextRefreshCommandMock.mockImplementation(async (_options: CLIOptions, args: string[]) => {
const aiDirArgIndex = args.indexOf('--ai-dir');
const targetAiDir = aiDirArgIndex !== -1 ? args[aiDirArgIndex + 1] : aiDir;
fs.mkdirSync(targetAiDir, { recursive: true });
fs.writeFileSync(
path.join(targetAiDir, 'context.snapshot.json'),
JSON.stringify({ version: 1, generatedAt: '2026-01-01T00:00:00Z', sourcesUsed: ['git'] }),
'utf8',
);
fs.writeFileSync(path.join(targetAiDir, 'context.adf'), 'ADF: 0.1\n\nSTATE:\n CURRENT: Refreshed\n', 'utf8');
return EXIT_CODE.SUCCESS;
});

const result = await loadCharterContextSnapshot(
{ ...baseOptions, format: 'json' },
aiDir,
{ refresh: true, sources: ['git'] },
);

expect(result.refreshed).toBe(true);
expect(contextRefreshCommandMock).toHaveBeenCalledTimes(1);
expect(fs.existsSync(path.join(aiDir, 'context.snapshot.json'))).toBe(true);
expect(fs.existsSync(path.join(aiDir, 'context.adf'))).toBe(true);
expect((result.snapshot as { version: number }).version).toBe(1);
});
});
Loading
Loading