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
2 changes: 1 addition & 1 deletion docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ npx charter adf init --module testing # add a single module to existing

- `--ai-dir <dir>` — custom directory path (default: `.ai`). Resolved to an absolute path at runtime.
- `--force` — overwrite existing files. Without this flag, existing `.adf` files are skipped and reported; only the missing `manifest.adf` is written.
- `--emit-pointers` — generate thin pointer files (`CLAUDE.md`, `.cursorrules`, `agents.md`)
- `--emit-pointers` — generate thin pointer files (`CLAUDE.md`, `.cursorrules`, `agents.md`). The generated `CLAUDE.md` includes a `## Session Start` section with guidance to call the `charter_context` MCP tool at session start, giving agents the live constraint surface before any other action.
- `--module <name>` — add a single module to existing `.ai/` (delegates to `adf create`)

**Default scaffolding** (worker/frontend/backend/fullstack presets):
Expand Down
38 changes: 37 additions & 1 deletion packages/cli/src/__tests__/adf-init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { adfCommand } from '../commands/adf';
import { adfCommand, POINTER_CLAUDE_MD, POINTER_CLAUDE_MD_HYBRID } from '../commands/adf';

const originalCwd = process.cwd();
const tempDirs: string[] = [];
Expand Down Expand Up @@ -80,6 +80,42 @@ describe('charter adf init — scaffolding guard', () => {
});
});

describe('charter adf init --emit-pointers — CLAUDE.md Session Start section', () => {
it('POINTER_CLAUDE_MD contains a Session Start section', () => {
expect(POINTER_CLAUDE_MD).toContain('## Session Start');
expect(POINTER_CLAUDE_MD).toContain('charter_context');
});

it('POINTER_CLAUDE_MD_HYBRID contains a Session Start section', () => {
expect(POINTER_CLAUDE_MD_HYBRID).toContain('## Session Start');
expect(POINTER_CLAUDE_MD_HYBRID).toContain('charter_context');
});

it('POINTER_CLAUDE_MD Session Start section appears before Environment section', () => {
const sessionStartIdx = POINTER_CLAUDE_MD.indexOf('## Session Start');
const environmentIdx = POINTER_CLAUDE_MD.indexOf('## Environment');
expect(sessionStartIdx).toBeGreaterThan(-1);
expect(environmentIdx).toBeGreaterThan(-1);
expect(sessionStartIdx).toBeLessThan(environmentIdx);
});

it('POINTER_CLAUDE_MD_HYBRID Session Start section appears before Module Index section', () => {
const sessionStartIdx = POINTER_CLAUDE_MD_HYBRID.indexOf('## Session Start');
const moduleIndexIdx = POINTER_CLAUDE_MD_HYBRID.indexOf('## Module Index');
expect(sessionStartIdx).toBeGreaterThan(-1);
expect(moduleIndexIdx).toBeGreaterThan(-1);
expect(sessionStartIdx).toBeLessThan(moduleIndexIdx);
});

it('writes CLAUDE.md with Session Start section when --emit-pointers is used', async () => {
const tmp = makeTmp();
await adfCommand(DEFAULT_OPTIONS, ['init', '--emit-pointers']);
const claudeMd = fs.readFileSync(path.join(tmp, 'CLAUDE.md'), 'utf-8');
expect(claudeMd).toContain('## Session Start');
expect(claudeMd).toContain('charter_context');
});
});

describe('charter serve startup — error discrimination', () => {
it('distinguishes missing .ai/ dir from missing manifest.adf in error message', () => {
// The distinction is tested indirectly via the error message text that
Expand Down
36 changes: 35 additions & 1 deletion packages/cli/src/__tests__/hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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 { hookCommand } from '../commands/hook';
import { hookCommand, printClaudeHookConfig } from '../commands/hook';
import type { CLIOptions } from '../index';

const baseOptions: CLIOptions = {
Expand Down Expand Up @@ -56,4 +56,38 @@ describe('hookCommand', () => {
const content = fs.readFileSync(hookPath, 'utf-8');
expect(content).toContain('echo "custom"');
});

it('hook print --claude returns 0 and outputs UserPromptSubmit config', async () => {
const logs: string[] = [];
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
logs.push(args.map(String).join(' '));
});

const exitCode = await hookCommand(baseOptions, ['print', '--claude']);
expect(exitCode).toBe(0);

const output = logs.join('\n');
expect(output).toContain('UserPromptSubmit');
expect(output).toContain('charter context-refresh --once');
});

it('printClaudeHookConfig outputs valid JSON with correct shape', () => {
const logs: string[] = [];
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
logs.push(args.map(String).join(' '));
});

printClaudeHookConfig();

const output = logs.join('\n');
const parsed = JSON.parse(output) as {
hooks: { UserPromptSubmit: Array<{ matcher: string; hooks: Array<{ type: string; command: string }> }> };
};
expect(parsed.hooks.UserPromptSubmit).toHaveLength(1);
expect(parsed.hooks.UserPromptSubmit[0].hooks[0].command).toBe('charter context-refresh --once');
});

it('hook print without --claude throws', async () => {
await expect(hookCommand(baseOptions, ['print'])).rejects.toThrow('hook print requires --claude');
});
});
12 changes: 12 additions & 0 deletions packages/cli/src/commands/adf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,12 @@ export const POINTER_CLAUDE_MD = `# CLAUDE.md
> New rules should be added to the appropriate \`.ai/*.adf\` module.
> See \`.ai/manifest.adf\` for the module routing manifest.

## Session Start

Call \`charter_context\` (MCP tool) before any other action. It returns the live constraint surface — open work, recent decisions, and metric ceilings — so you act within current project state rather than reconstructing it from scratch.

If \`charter serve\` is not running, read \`.ai/context.snapshot.json\` directly or run \`charter context-refresh\` to generate it.

## Environment
<!-- Add runtime/OS/shell-specific notes here (not stack rules) -->
`;
Expand All @@ -280,6 +286,12 @@ export const POINTER_CLAUDE_MD_HYBRID = `# CLAUDE.md
> New rules should be added to the appropriate \`.ai/*.adf\` module.
> See \`.ai/manifest.adf\` for the module routing manifest.

## Session Start

Call \`charter_context\` (MCP tool) before any other action. It returns the live constraint surface — open work, recent decisions, and metric ceilings — so you act within current project state rather than reconstructing it from scratch.

If \`charter serve\` is not running, read \`.ai/context.snapshot.json\` directly or run \`charter context-refresh\` to generate it.

## Module Index
<!-- charter:module-index:start -->
<!-- charter:module-index:end -->
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/commands/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,12 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro
});
}

result.nextSteps.push({
cmd: 'charter hook print --claude # paste output into .claude/settings.json → hooks.UserPromptSubmit',
required: false,
reason: 'Auto-refresh context at session start so charter_context returns live state, not a cold snapshot, before the agent acts',
});

// ========================================================================
// Governance Gaps — surface what's configured but not enforced
// ========================================================================
Expand Down
33 changes: 32 additions & 1 deletion packages/cli/src/commands/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,40 @@ if [ -f ".ai/manifest.adf" ]; then
fi
`;

// Charter cannot write to .claude/settings.json safely — it's user-controlled.
// `print --claude` emits the config snippet for the user to paste instead.
const CLAUDE_SESSION_HOOK_CONFIG = {
hooks: {
UserPromptSubmit: [
{
matcher: '.*',
hooks: [{ type: 'command', command: 'charter context-refresh --once' }],
},
],
},
};

export function printClaudeHookConfig(): void {
console.log(JSON.stringify(CLAUDE_SESSION_HOOK_CONFIG, null, 2));
}

export async function hookCommand(options: CLIOptions, args: string[]): Promise<number> {
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
printHelp();
return EXIT_CODE.SUCCESS;
}

if (args[0] === 'print') {
const wantClaude = args.includes('--claude');
if (!wantClaude) {
throw new CLIError('hook print requires --claude.');
}
printClaudeHookConfig();
return EXIT_CODE.SUCCESS;
}

if (args[0] !== 'install') {
throw new CLIError(`Unknown hook subcommand: ${args[0]}. Supported: install`);
throw new CLIError(`Unknown hook subcommand: ${args[0]}. Supported: install, print`);
}

const wantCommitMsg = args.includes('--commit-msg');
Expand Down Expand Up @@ -240,6 +266,7 @@ function printHelp(): void {
console.log(' Usage:');
console.log(' charter hook install --commit-msg [--force]');
console.log(' charter hook install --pre-commit [--force]');
console.log(' charter hook print --claude');
console.log('');
console.log(' --commit-msg: Install a git commit-msg hook that normalizes Governed-By and');
console.log(' Resolves-Request trailers using git interpret-trailers.');
Expand All @@ -249,4 +276,8 @@ function printHelp(): void {
console.log(' Vendor file bloat is extracted, routed to .adf modules, and re-staged.');
console.log(' Skip tidy with CHARTER_SKIP_TIDY=1. Only gates when .ai/manifest.adf exists.');
console.log('');
console.log(' print --claude: Print the Claude Code session hook config snippet to stdout.');
console.log(' Paste into .claude/settings.json → hooks.UserPromptSubmit to auto-refresh');
console.log(' context at session start so charter_context returns live state.');
console.log('');
}
1 change: 1 addition & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ Usage:
Install git commit-msg hook for trailer normalization
charter hook install --pre-commit [--force]
Install git pre-commit hook for ADF evidence gate
charter hook print --claude Print Claude Code session hook config (paste into .claude/settings.json)
charter adf <subcommand> ADF context format tools (init, fmt, patch, create, bundle, sync, evidence, migrate, metrics)
charter serve [--name <name>] [--ai-dir <dir>]
Expose ADF project context as an MCP server (stdio, for Claude Code/Codex/Cursor)
Expand Down
Loading