From 9ee9334e2735b28637a3368b74dfa16bfe926951 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Sat, 23 May 2026 05:57:52 -0500 Subject: [PATCH] feat(serve): session-start hook wiring + CLAUDE.md warm-start section (#155) - `charter hook print --claude` emits the UserPromptSubmit hook config JSON that auto-runs `charter context-refresh --once` at each Claude Code session open - `charter bootstrap` nextSteps now surfaces the hook command with a reason line explaining that charter_context returns live state, not a cold snapshot - Both POINTER_CLAUDE_MD templates (thin and hybrid) gain a `## Session Start` section directing agents to call charter_context before any other action, with fallback instructions for sessions without charter serve running - docs/cli-reference.md updated to document --emit-pointers Session Start output Closes #155. Co-Authored-By: Claude Sonnet 4.6 --- docs/cli-reference.md | 2 +- packages/cli/src/__tests__/adf-init.test.ts | 38 ++++++++++++++++++++- packages/cli/src/__tests__/hook.test.ts | 36 ++++++++++++++++++- packages/cli/src/commands/adf.ts | 12 +++++++ packages/cli/src/commands/bootstrap.ts | 6 ++++ packages/cli/src/commands/hook.ts | 33 +++++++++++++++++- packages/cli/src/index.ts | 1 + 7 files changed, 124 insertions(+), 4 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 94fb8c8..53c20c4 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -154,7 +154,7 @@ npx charter adf init --module testing # add a single module to existing - `--ai-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 ` — add a single module to existing `.ai/` (delegates to `adf create`) **Default scaffolding** (worker/frontend/backend/fullstack presets): diff --git a/packages/cli/src/__tests__/adf-init.test.ts b/packages/cli/src/__tests__/adf-init.test.ts index ce51e83..7c39f56 100644 --- a/packages/cli/src/__tests__/adf-init.test.ts +++ b/packages/cli/src/__tests__/adf-init.test.ts @@ -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[] = []; @@ -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 diff --git a/packages/cli/src/__tests__/hook.test.ts b/packages/cli/src/__tests__/hook.test.ts index 7ace1bd..826f07f 100644 --- a/packages/cli/src/__tests__/hook.test.ts +++ b/packages/cli/src/__tests__/hook.test.ts @@ -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 = { @@ -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'); + }); }); diff --git a/packages/cli/src/commands/adf.ts b/packages/cli/src/commands/adf.ts index 4792b6a..568a339 100644 --- a/packages/cli/src/commands/adf.ts +++ b/packages/cli/src/commands/adf.ts @@ -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 `; @@ -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 diff --git a/packages/cli/src/commands/bootstrap.ts b/packages/cli/src/commands/bootstrap.ts index 0ff9302..1085955 100644 --- a/packages/cli/src/commands/bootstrap.ts +++ b/packages/cli/src/commands/bootstrap.ts @@ -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 // ======================================================================== diff --git a/packages/cli/src/commands/hook.ts b/packages/cli/src/commands/hook.ts index 397784e..5865dd6 100644 --- a/packages/cli/src/commands/hook.ts +++ b/packages/cli/src/commands/hook.ts @@ -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 { 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'); @@ -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.'); @@ -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(''); } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 723c35f..23a9276 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -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 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/Codex/Cursor)