diff --git a/PUBLISHING.md b/PUBLISHING.md
index 92a6a47..e140581 100644
--- a/PUBLISHING.md
+++ b/PUBLISHING.md
@@ -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:
diff --git a/README.md b/README.md
index 1459b47..9e7979c 100644
--- a/README.md
+++ b/README.md
@@ -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.
@@ -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
{
@@ -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).
@@ -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
diff --git a/docs/cli-reference.md b/docs/cli-reference.md
index 515e0bb..94fb8c8 100644
--- a/docs/cli-reference.md
+++ b/docs/cli-reference.md
@@ -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
` — 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.
@@ -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 |
|-----------|--------------|-----|
@@ -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`. |
diff --git a/docs/context-refresh-resume.md b/docs/context-refresh-resume.md
index a2af206..94bafa5 100644
--- a/docs/context-refresh-resume.md
+++ b/docs/context-refresh-resume.md
@@ -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`
diff --git a/packages/cli/src/__tests__/bootstrap.test.ts b/packages/cli/src/__tests__/bootstrap.test.ts
index 3d92a92..a9a993c 100644
--- a/packages/cli/src/__tests__/bootstrap.test.ts
+++ b/packages/cli/src/__tests__/bootstrap.test.ts
@@ -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 },
diff --git a/packages/cli/src/__tests__/serve-context.test.ts b/packages/cli/src/__tests__/serve-context.test.ts
new file mode 100644
index 0000000..10fe3ed
--- /dev/null
+++ b/packages/cli/src/__tests__/serve-context.test.ts
@@ -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);
+ });
+});
diff --git a/packages/cli/src/commands/bootstrap.ts b/packages/cli/src/commands/bootstrap.ts
index dd213f4..0ff9302 100644
--- a/packages/cli/src/commands/bootstrap.ts
+++ b/packages/cli/src/commands/bootstrap.ts
@@ -330,9 +330,9 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro
// Build next steps
result.nextSteps.push({
- cmd: 'charter serve # start MCP server for Claude Code / Cursor integration',
+ cmd: 'charter serve # start MCP server for Claude Code / Codex / Cursor integration',
required: false,
- reason: 'Enable real-time governance via MCP (add to .claude/settings.json)',
+ reason: 'Enable real-time governance via MCP (wire in .mcp.json or .claude/settings.json)',
});
result.nextSteps.push({
cmd: 'Review .charter/patterns/ and customize for your stack',
@@ -622,6 +622,16 @@ function runSetupPhase(
updated.push('package.json (devDependencies)');
}
+ const mcpConfig = ensureProjectMcpConfig('.ai', force);
+ if (mcpConfig.created) {
+ created.push('.mcp.json');
+ } else if (mcpConfig.updated) {
+ updated.push('.mcp.json');
+ }
+ if (mcpConfig.warning) {
+ warnings.push(mcpConfig.warning);
+ }
+
return {
step: {
name: 'setup',
@@ -644,6 +654,75 @@ function runSetupPhase(
}
}
+function ensureProjectMcpConfig(
+ aiDir: string,
+ force: boolean,
+): { created: boolean; updated: boolean; warning?: string } {
+ const configPath = path.resolve('.mcp.json');
+ const desiredServer = {
+ command: 'npx',
+ args: ['@stackbilt/cli', 'serve', '--ai-dir', path.resolve(aiDir)],
+ };
+
+ const configExists = fs.existsSync(configPath);
+ let root: Record = {};
+ if (configExists) {
+ try {
+ const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ return {
+ created: false,
+ updated: false,
+ warning: 'Skipped MCP config update: .mcp.json must contain a JSON object at the top level.',
+ };
+ }
+ root = { ...(parsed as Record) };
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : String(err);
+ return {
+ created: false,
+ updated: false,
+ warning: `Skipped MCP config update: .mcp.json is not valid JSON (${msg}).`,
+ };
+ }
+ }
+
+ const mcpServersRaw = root.mcpServers;
+ if (mcpServersRaw !== undefined && (typeof mcpServersRaw !== 'object' || mcpServersRaw === null || Array.isArray(mcpServersRaw))) {
+ return {
+ created: false,
+ updated: false,
+ warning: 'Skipped MCP config update: .mcp.json#mcpServers must be a JSON object.',
+ };
+ }
+
+ const mcpServers = { ...((mcpServersRaw as Record | undefined) ?? {}) };
+ const existingCharter = mcpServers.charter;
+ const sameServer = JSON.stringify(existingCharter) === JSON.stringify(desiredServer);
+ if (sameServer) {
+ return { created: false, updated: false };
+ }
+
+ if (existingCharter !== undefined && !force) {
+ return {
+ created: false,
+ updated: false,
+ warning: 'Skipped MCP config update: .mcp.json already defines mcpServers.charter (use --force to replace it).',
+ };
+ }
+
+ mcpServers.charter = desiredServer;
+ root.mcpServers = mcpServers;
+ fs.writeFileSync(configPath, JSON.stringify(root, null, 2) + '\n');
+ const absolutePathWarning = 'Generated .mcp.json uses an absolute --ai-dir path. Update it if you share this file across machines.';
+
+ if (!configExists) {
+ return { created: true, updated: false, warning: absolutePathWarning };
+ }
+
+ return { created: false, updated: true, warning: absolutePathWarning };
+}
+
// ============================================================================
// Phase 3: ADF Init
// ============================================================================
@@ -1190,6 +1269,9 @@ function isAlreadyThinPointer(filePath: string): boolean {
* Prompt user for a yes/no answer via readline.
*/
function promptYesNo(question: string): Promise {
+ if (!process.stdin.isTTY) {
+ return Promise.resolve(false);
+ }
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
diff --git a/packages/cli/src/commands/context-refresh.ts b/packages/cli/src/commands/context-refresh.ts
index ab551c1..67b1bed 100644
--- a/packages/cli/src/commands/context-refresh.ts
+++ b/packages/cli/src/commands/context-refresh.ts
@@ -113,6 +113,10 @@ interface RefreshOptionsResolved {
config: ContextConfig;
}
+interface ContextRefreshIO {
+ log?: (message: string) => void;
+}
+
const SOURCE_SET = new Set(['git', 'github']);
const DEFAULT_CONFIG: ContextConfig = {
version: 1,
@@ -139,7 +143,12 @@ const DEFAULT_CONFIG: ContextConfig = {
},
};
-export async function contextRefreshCommand(options: CLIOptions, args: string[]): Promise {
+export async function contextRefreshCommand(
+ options: CLIOptions,
+ args: string[],
+ io?: ContextRefreshIO,
+): Promise {
+ const log = io?.log ?? console.log;
const resolved = resolveOptions(options, args);
const snapshotPath = path.join(resolved.aiDirAbs, 'context.snapshot.json');
const contextAdfPath = path.join(resolved.aiDirAbs, 'context.adf');
@@ -156,7 +165,7 @@ export async function contextRefreshCommand(options: CLIOptions, args: string[])
: null,
};
if (options.format === 'json') {
- console.log(JSON.stringify({
+ log(JSON.stringify({
status: 'skipped',
reason: 'fresh_snapshot',
generatedAt: existing?.generatedAt ?? null,
@@ -168,12 +177,12 @@ export async function contextRefreshCommand(options: CLIOptions, args: string[])
errors: [],
}, null, 2));
} else {
- console.log('');
- console.log(' charter context-refresh');
- console.log(' Status: skipped (fresh snapshot)');
- console.log(` Snapshot: ${files.snapshotJson}`);
- console.log(` TTL (mins): ${resolved.ttlMinutes}`);
- console.log('');
+ log('');
+ log(' charter context-refresh');
+ log(' Status: skipped (fresh snapshot)');
+ log(` Snapshot: ${files.snapshotJson}`);
+ log(` TTL (mins): ${resolved.ttlMinutes}`);
+ log('');
}
return EXIT_CODE.SUCCESS;
}
@@ -204,7 +213,7 @@ export async function contextRefreshCommand(options: CLIOptions, args: string[])
};
if (options.format === 'json') {
- console.log(JSON.stringify({
+ log(JSON.stringify({
status: 'ok',
reason: status,
generatedAt: snapshot.generatedAt,
@@ -216,22 +225,22 @@ export async function contextRefreshCommand(options: CLIOptions, args: string[])
errors: snapshot.errors,
}, null, 2));
} else {
- console.log('');
- console.log(' charter context-refresh');
- console.log(` Status: ${status}`);
- console.log(` Sources: ${snapshot.sourcesUsed.join(', ') || '(none)'}`);
- console.log(` Wrote: ${files.contextAdf}`);
- console.log(` Snapshot: ${files.snapshotJson}`);
+ log('');
+ log(' charter context-refresh');
+ log(` Status: ${status}`);
+ log(` Sources: ${snapshot.sourcesUsed.join(', ') || '(none)'}`);
+ log(` Wrote: ${files.contextAdf}`);
+ log(` Snapshot: ${files.snapshotJson}`);
if (files.outputMarkdown) {
- console.log(` Mirrored MD: ${files.outputMarkdown}`);
+ log(` Mirrored MD: ${files.outputMarkdown}`);
}
if (snapshot.warnings.length > 0) {
- console.log(` Warnings: ${snapshot.warnings.length}`);
+ log(` Warnings: ${snapshot.warnings.length}`);
}
if (snapshot.errors.length > 0) {
- console.log(` Errors: ${snapshot.errors.length}`);
+ log(` Errors: ${snapshot.errors.length}`);
}
- console.log('');
+ log('');
}
return EXIT_CODE.SUCCESS;
diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts
index 891c9eb..e3d8880 100644
--- a/packages/cli/src/commands/serve.ts
+++ b/packages/cli/src/commands/serve.ts
@@ -5,7 +5,7 @@
* Supports stdio (default) and SSE transports.
*
* Usage:
- * charter serve # stdio, for Claude Code
+ * charter serve # stdio, for Claude Code/Codex/Cursor
* charter serve --transport sse --port 3847 # SSE, for network access
* charter serve --name "my-project" # custom server name
*/
@@ -29,6 +29,7 @@ import {
} from '@stackbilt/adf';
import { analyze as analyzeBlast, BlastInputSchema } from '@stackbilt/blast';
import { generateBrief } from './context';
+import { contextRefreshCommand } from './context-refresh';
import {
analyze as analyzeSurface,
SurfaceInputSchema,
@@ -44,6 +45,23 @@ import { detectTsconfigAliases } from './blast';
// ============================================================================
const DEFAULT_PORT = 3847;
+const CONTEXT_SOURCE_SET = new Set(['git', 'github'] as const);
+
+type ContextSourceName = 'git' | 'github';
+
+const CharterContextInputSchema = z.object({
+ refresh: z.boolean().optional().describe(
+ 'If true, refresh context before reading by running context-refresh.',
+ ),
+ sources: z.array(z.enum(['git', 'github'])).optional().describe(
+ 'Optional source override used only when refresh=true (for example ["git","github"]).',
+ ),
+ ttlMinutes: z.number().optional().describe(
+ 'Optional TTL override used only when refresh=true.',
+ ),
+});
+
+type CharterContextInput = z.infer;
// ============================================================================
// Command Entry
@@ -88,7 +106,7 @@ export async function serveCommand(options: CLIOptions, args: string[]): Promise
version: '1.0.0',
});
- registerTools(server, aiDir);
+ registerTools(server, aiDir, options);
registerResources(server, aiDir);
if (options.format !== 'json') {
@@ -106,7 +124,30 @@ export async function serveCommand(options: CLIOptions, args: string[]): Promise
// Tool Registration
// ============================================================================
-function registerTools(server: McpServer, aiDir: string): void {
+function registerTools(server: McpServer, aiDir: string, options: CLIOptions): void {
+
+ (server.registerTool as Function)(
+ 'charter_context',
+ {
+ description:
+ 'Returns the current `.ai/context.snapshot.json` payload as structured JSON. Set refresh=true to run `charter context-refresh` first, then return the refreshed snapshot.',
+ inputSchema: CharterContextInputSchema.shape,
+ },
+ async (rawInput: unknown) => {
+ try {
+ const input = CharterContextInputSchema.parse(rawInput ?? {});
+ const result = await loadCharterContextSnapshot(options, aiDir, input);
+ return {
+ content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
+ };
+ } catch (err) {
+ return {
+ content: [{ type: 'text' as const, text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
+ isError: true,
+ };
+ }
+ },
+ );
(server.registerTool as Function)(
'getProjectContext',
@@ -598,3 +639,61 @@ function inferProjectName(aiDir: string): string {
// Fall back to directory name
return path.basename(process.cwd());
}
+
+export async function loadCharterContextSnapshot(
+ options: CLIOptions,
+ aiDir: string,
+ input?: CharterContextInput,
+): Promise<{ refreshed: boolean; snapshotPath: string; snapshot: unknown }> {
+ const refresh = input?.refresh ?? false;
+ const snapshotPathAbs = path.join(aiDir, 'context.snapshot.json');
+ const snapshotPathRel = path.relative(process.cwd(), snapshotPathAbs);
+
+ if (refresh) {
+ const args = ['--ai-dir', aiDir];
+ if (input?.sources && input.sources.length > 0) {
+ // Defensive guard: this helper is exported and may be called from plain JS.
+ const invalid = input.sources.filter((entry) => !CONTEXT_SOURCE_SET.has(entry));
+ if (invalid.length > 0) {
+ throw new CLIError(`Invalid sources: ${invalid.join(', ')}. Supported: git, github.`);
+ }
+ args.push('--sources', input.sources.join(','));
+ }
+ if (input?.ttlMinutes !== undefined) {
+ if (!Number.isFinite(input.ttlMinutes) || input.ttlMinutes <= 0) {
+ throw new CLIError(`Invalid ttlMinutes: ${input.ttlMinutes}. Must be a positive number.`);
+ }
+ args.push('--ttl-minutes', String(Math.floor(input.ttlMinutes)));
+ }
+
+ const exitCode = await contextRefreshCommand(
+ { ...options, format: 'json' },
+ args,
+ { log: () => {} },
+ );
+ if (exitCode !== EXIT_CODE.SUCCESS) {
+ throw new CLIError(`context-refresh exited with code ${exitCode}`);
+ }
+ }
+
+ if (!fs.existsSync(snapshotPathAbs)) {
+ throw new CLIError(
+ `Context snapshot not found at ${snapshotPathRel}. Run \`charter context-refresh\` or call charter_context with refresh=true.`,
+ );
+ }
+
+ let snapshot: unknown;
+ try {
+ snapshot = JSON.parse(fs.readFileSync(snapshotPathAbs, 'utf-8'));
+ } catch (err) {
+ throw new CLIError(
+ `Failed to parse context snapshot at ${snapshotPathRel}: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+
+ return {
+ refreshed: refresh,
+ snapshotPath: snapshotPathRel,
+ snapshot,
+ };
+}
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 2e83e32..723c35f 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -69,7 +69,7 @@ Usage:
Install git pre-commit hook for ADF evidence gate
charter adf ADF context format tools (init, fmt, patch, create, bundle, sync, evidence, migrate, metrics)
charter serve [--name ] [--ai-dir ]
- Expose ADF project context as an MCP server (stdio, for Claude Code)
+ Expose ADF project context as an MCP server (stdio, for Claude Code/Codex/Cursor)
charter login --key Store Stackbilt API key
charter login --logout Clear stored credentials
charter architect Generate tech stack from project description