From ae61847b76df5683f1a29e716531eb718f0682db Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Tue, 19 May 2026 06:31:39 -0500 Subject: [PATCH 1/3] feat: charter context command + charter_brief MCP tool (closes #113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `charter context` CLI command and `charter_brief` MCP tool. Generates a pre-digested repo brief (identity, surface, hotspots, sensitivity, governance) within a 2000-token budget — replacing 15-30 cold-boot discovery tool calls for AI agents entering a Charter-governed repo. - `packages/cli/src/commands/context.ts`: BriefModel build-then-render pattern, blast seed strategy by preset, truncation algorithm (hotspots→tables→routes→ON_DEMAND), --stdout-only / --verbose / --write flags - `packages/cli/src/commands/serve.ts`: registers `charter_brief` MCP tool - `packages/cli/src/commands/init.ts`: adds `context.md` to .charter/.gitignore - `packages/cli/src/commands/bootstrap.ts`: post-commit hook next-step for refresh - `packages/cli/src/__tests__/context.test.ts`: 3 tests (sections, token ceiling, --write) - `scripts/measure-brief-size.mjs`: CI gate — fails if brief > 2000 tokens - `scripts/eval-brief-coverage.mjs`: CI gate — fixture-based orientation coverage eval - `.github/workflows/ci.yml`: adds both CI gates after build step - `docs/cli-reference.md`: full charter context reference section - `README.md`: charter context quick-start and charter_brief MCP mention Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 8 +- README.md | 3 + docs/cli-reference.md | 60 ++ packages/cli/src/__tests__/context.test.ts | 156 +++++ packages/cli/src/commands/bootstrap.ts | 5 + packages/cli/src/commands/context.ts | 690 +++++++++++++++++++++ packages/cli/src/commands/init.ts | 1 + packages/cli/src/commands/serve.ts | 29 + packages/cli/src/index.ts | 6 + scripts/eval-brief-coverage.mjs | 100 +++ scripts/measure-brief-size.mjs | 31 + 11 files changed, 1087 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/__tests__/context.test.ts create mode 100644 packages/cli/src/commands/context.ts create mode 100644 scripts/eval-brief-coverage.mjs create mode 100644 scripts/measure-brief-size.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5fa681..67ef1c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,13 +11,17 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '20' cache: 'pnpm' - run: pnpm install --frozen-lockfile - run: pnpm run build + - name: Measure brief token budget + run: node scripts/measure-brief-size.mjs + - name: Eval brief orientation coverage + run: node scripts/eval-brief-coverage.mjs - run: pnpm run typecheck - name: Docs sync check run: pnpm run docs:check diff --git a/README.md b/README.md index e863720..3ed458c 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,8 @@ METRICS [load-bearing]: Claude Code can query `getProjectContext`, `getArchitecturalDecisions`, `getProjectState`, and `getRecentChanges` directly. +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. + ## Commands ### Govern @@ -107,6 +109,7 @@ Claude Code can query `getProjectContext`, `getArchitecturalDecisions`, `getProj charter # Repo risk/value snapshot charter bootstrap --ci github # One-command onboarding charter bootstrap --security-sensitive # SECURITY.md + hard security drift denies +charter context # pre-digested repo brief for AI agents (routes, hotspots, governance) charter doctor # Environment/config health check charter validate # Commit governance (trailers) charter drift # Pattern drift scanning diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 0f96920..569d649 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -324,6 +324,66 @@ npx charter blast src/foo.ts --root ./packages/server # scan a subdirector **Semantics:** zero runtime dependencies, no LLM calls, no TypeScript compiler API. Regex-based import extraction — trades some precision for universality across JavaScript/TypeScript/ESM/CommonJS projects. +### charter context + +Pre-digested repo brief for AI agents. Composes routes (surface), hotspots (blast), sensitivity tags, and governance posture into a single bounded markdown document. + +**The fastest way to orient an AI agent in a Charter-governed repo.** Reading the brief replaces 15-30 cold-boot discovery tool calls. + +#### Usage + +```bash +npx charter context # print brief + write .charter/context.md +npx charter context --stdout-only # print only, no file write +npx charter context --verbose # no token ceiling (for human reading) +npx charter context --write # write .charter/context.md only (for hooks) +``` + +#### Brief sections + +| Section | Source | Always present | +| ------- | ------ | -------------- | +| Identity | `.charter/config.json` + `package.json` | Yes | +| Surface | `charter surface` (routes + D1 tables) | Yes | +| Hotspots | `charter blast` (top hot files by importer count) | Yes | +| Sensitivity | `.charter/config.json` sensitivity tags | Yes | +| Governance | `.ai/manifest.adf` module routing | Yes | + +#### Token budget + +The brief is capped at **2000 tokens** (~8000 characters). When content exceeds the budget, sections are truncated in this order: hotspots tail → D1 tables → routes → governance ON_DEMAND entries. A `## Truncated` section is appended listing what was reduced. + +Use `--verbose` to remove the ceiling for interactive sessions. + +#### MCP tool + +`charter serve` registers `charter_brief`. Agents should call this first: + +> "CALL THIS FIRST when entering a Charter-governed repo. Returns routes, hotspots, sensitivity tags, and governance in a single pre-digested brief — replaces 15-30 discovery tool calls." + +#### Post-commit hook (keep brief fresh) + +```bash +echo 'charter context --write' >> .git/hooks/post-commit +chmod +x .git/hooks/post-commit +``` + +Or use `charter bootstrap` — it adds this as a suggested next step. + +#### Blast seed strategy + +Seeds for hotspot analysis are chosen by preset (from `.charter/config.json`): + +| Preset | Seeds | +| ------ | ----- | +| worker | `src/index.*`, route files, wrangler entry | +| frontend | `src/App.*`, `src/main.*`, `src/index.*` | +| backend | `src/index.*`, `src/server.*`, `src/app.*` | +| fullstack | worker + frontend seeds | +| cli | `bin/` entries, `src/commands/*.ts` | +| docs | `README.md`, `docs/*.md` | +| unknown | `src/index.*`, `src/main.*` | + ### charter surface Extract the API surface of a project: HTTP routes and database schema tables. diff --git a/packages/cli/src/__tests__/context.test.ts b/packages/cli/src/__tests__/context.test.ts new file mode 100644 index 0000000..a9f4f6e --- /dev/null +++ b/packages/cli/src/__tests__/context.test.ts @@ -0,0 +1,156 @@ +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 { generateBrief, contextCommand } from '../commands/context'; + +const options: CLIOptions = { + configPath: '.charter', + format: 'text', + ciMode: false, + yes: false, +}; + +// Track original cwd and temp dirs for cleanup +const originalCwd = process.cwd(); +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'charter-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 }); + } + vi.restoreAllMocks(); +}); + +describe('generateBrief on minimal repo returns all 5 sections', () => { + it('generates a brief with all required sections', { timeout: 30000 }, async () => { + const tmp = makeTempDir(); + process.chdir(tmp); + + // Set up package.json + fs.writeFileSync( + path.join(tmp, 'package.json'), + JSON.stringify({ + name: 'test-worker', + version: '1.0.0', + description: 'A test worker', + }), + 'utf8' + ); + + // Set up .charter/config.json + fs.mkdirSync(path.join(tmp, '.charter'), { recursive: true }); + fs.writeFileSync( + path.join(tmp, '.charter', 'config.json'), + JSON.stringify({ stack: 'worker', preset: 'worker' }), + 'utf8' + ); + + // Set up src/index.ts with a Hono route + fs.mkdirSync(path.join(tmp, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(tmp, 'src', 'index.ts'), + `import { Hono } from 'hono';\nconst app = new Hono();\napp.get('/api/hello', (c) => c.json({ ok: true }));\nexport default app;\n`, + 'utf8' + ); + + // Set up .ai/manifest.adf with core.adf in DEFAULT_LOAD + fs.mkdirSync(path.join(tmp, '.ai'), { recursive: true }); + fs.writeFileSync( + path.join(tmp, '.ai', 'manifest.adf'), + `ADF: 0.1\n\nDEFAULT_LOAD:\n - core.adf\n\nON_DEMAND:\n - backend.adf (Triggers on: backend, api)\n`, + 'utf8' + ); + + const result = await generateBrief({ configPath: '.charter', aiDir: '.ai' }); + + expect(result.markdown).toContain('## Identity'); + expect(result.markdown).toContain('## Surface'); + expect(result.markdown).toContain('## Hotspots'); + expect(result.markdown).toContain('## Sensitivity'); + expect(result.markdown).toContain('## Governance'); + }); +}); + +describe('generateBrief respects token ceiling', () => { + it('truncates when surface has many routes and keeps tokenCount <= 2000', { timeout: 30000 }, async () => { + const tmp = makeTempDir(); + process.chdir(tmp); + + fs.writeFileSync( + path.join(tmp, 'package.json'), + JSON.stringify({ name: 'large-surface', version: '1.0.0' }), + 'utf8' + ); + fs.mkdirSync(path.join(tmp, '.charter'), { recursive: true }); + fs.writeFileSync( + path.join(tmp, '.charter', 'config.json'), + JSON.stringify({ stack: 'backend', preset: 'backend' }), + 'utf8' + ); + + // Create many routes with very long paths to guarantee > 8000 chars in surface section + fs.mkdirSync(path.join(tmp, 'src'), { recursive: true }); + const routeLines: string[] = [`import { Hono } from 'hono';`, `const app = new Hono();`]; + // Generate 50 routes with very long paths (each route line ~100 chars in markdown table) + for (let i = 0; i < 50; i++) { + const pad = 'a'.repeat(60); // long path padding + routeLines.push( + `app.get('/api/${pad}-endpoint-number-${i}/very/deep/nesting/path', (c) => c.json({ id: ${i} }));` + ); + } + routeLines.push('export default app;'); + fs.writeFileSync(path.join(tmp, 'src', 'index.ts'), routeLines.join('\n'), 'utf8'); + + // Create a large manifest to push Governance section over budget + fs.mkdirSync(path.join(tmp, '.ai'), { recursive: true }); + const manifestLines = ['ADF: 0.1', '', 'DEFAULT_LOAD:', ' - core.adf', '', 'ON_DEMAND:']; + for (let i = 0; i < 30; i++) { + // Long trigger lists to add bulk to the ON_DEMAND section + const triggers = Array.from({ length: 10 }, (_, j) => `feature-${i}-trigger-${j}-keyword`).join(', '); + manifestLines.push(` - module-${i}.adf (Triggers on: ${triggers})`); + } + fs.writeFileSync(path.join(tmp, '.ai', 'manifest.adf'), manifestLines.join('\n'), 'utf8'); + + const result = await generateBrief({ configPath: '.charter', aiDir: '.ai' }); + + expect(result.tokenCount).toBeLessThanOrEqual(2000); + expect(result.truncated).toBe(true); + }); +}); + +describe('contextCommand writes .charter/context.md', () => { + it('writes context.md when --write flag is passed', { timeout: 30000 }, async () => { + const tmp = makeTempDir(); + process.chdir(tmp); + + fs.writeFileSync( + path.join(tmp, 'package.json'), + JSON.stringify({ name: 'write-test', version: '0.1.0' }), + 'utf8' + ); + + // Silence stdout for the write-only test + vi.spyOn(console, 'log').mockImplementation(() => {}); + + const exitCode = await contextCommand( + { ...options, configPath: path.join(tmp, '.charter') }, + ['--write'] + ); + + expect(exitCode).toBe(0); + const outPath = path.join(tmp, '.charter', 'context.md'); + expect(fs.existsSync(outPath)).toBe(true); + const content = fs.readFileSync(outPath, 'utf8'); + expect(content).toContain('## Identity'); + }); +}); diff --git a/packages/cli/src/commands/bootstrap.ts b/packages/cli/src/commands/bootstrap.ts index ee08c2c..dd213f4 100644 --- a/packages/cli/src/commands/bootstrap.ts +++ b/packages/cli/src/commands/bootstrap.ts @@ -357,6 +357,11 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro required: false, reason: 'Install commit-msg hook for trailer enforcement', }); + result.nextSteps.push({ + cmd: 'echo \'charter context --write\' >> .git/hooks/post-commit && chmod +x .git/hooks/post-commit', + required: false, + reason: 'Keep .charter/context.md fresh after each commit (charter brief auto-refresh)', + }); } // ======================================================================== diff --git a/packages/cli/src/commands/context.ts b/packages/cli/src/commands/context.ts new file mode 100644 index 0000000..61e28bc --- /dev/null +++ b/packages/cli/src/commands/context.ts @@ -0,0 +1,690 @@ +/** + * charter context + * + * Generates a pre-digested repo brief for AI agents: routes, hotspots, + * governance, and sensitivity — all within a token budget. + * + * Output modes: + * charter context print to stdout + write .charter/context.md + * charter context --stdout-only print to stdout only + * charter context --verbose no token ceiling (for human review) + * charter context --write write .charter/context.md only, no stdout + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { execSync } from 'node:child_process'; +import { analyze as analyzeBlast, BlastInputSchema } from '@stackbilt/blast'; +import { analyze as analyzeSurface, SurfaceInputSchema } from '@stackbilt/surface'; +import { parseAdf, parseManifest } from '@stackbilt/adf'; +import { detectTsconfigAliases } from './blast'; +import type { CLIOptions } from '../index'; +import { EXIT_CODE } from '../index'; + +// ============================================================================ +// Public types +// ============================================================================ + +export interface BriefOptions { + configPath?: string; // default '.charter' + aiDir?: string; // default '.ai' + verbose?: boolean; // if true, no token ceiling — for human use +} + +export interface BriefResult { + markdown: string; + tokenCount: number; // estimated (chars / 4, ceiling) + truncated: boolean; + truncatedSections: string[]; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const TOKEN_CEILING = 2000; +const CHAR_CEILING = TOKEN_CEILING * 4; // 8000 chars + +/** Estimate tokens from a string (ceiling). */ +function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +// ============================================================================ +// Git SHA +// ============================================================================ + +function getGitSha(): string { + try { + return execSync('git rev-parse HEAD', { + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf8', + }) + .trim() + .slice(0, 8); + } catch { + return ''; + } +} + +// ============================================================================ +// Seed strategy (keyed by preset) +// ============================================================================ + +function getSeedCandidates(preset: string, root: string, pkgBin: Record): string[] { + const candidates: string[] = []; + + const tryExts = (base: string): string[] => + ['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs'].map((ext) => base + ext); + + const srcIndex = tryExts(path.join(root, 'src', 'index')); + const srcMain = tryExts(path.join(root, 'src', 'main')); + const srcApp = tryExts(path.join(root, 'src', 'App')); + const srcServer = tryExts(path.join(root, 'src', 'server')); + const srcAppLower = tryExts(path.join(root, 'src', 'app')); + const wranglerEntry = resolveWranglerEntry(root); + + // Files with "routes" in name + const routeFiles = findFilesWithNamePattern(root, /routes/i); + + const workerSeeds = [ + ...srcIndex, + ...routeFiles, + ...(wranglerEntry ? [wranglerEntry] : []), + ]; + const frontendSeeds = [...srcApp, ...srcMain, ...srcIndex]; + const backendSeeds = [...srcIndex, ...srcServer, ...srcAppLower]; + const cliSeeds = [ + ...Object.values(pkgBin).map((b) => path.resolve(root, b)), + ...findCommandFiles(root), + ]; + + switch (preset) { + case 'worker': + candidates.push(...workerSeeds); + break; + case 'frontend': + candidates.push(...frontendSeeds); + break; + case 'backend': + candidates.push(...backendSeeds); + break; + case 'fullstack': + candidates.push(...workerSeeds, ...frontendSeeds); + break; + case 'cli': + candidates.push(...cliSeeds); + break; + case 'docs': { + const readme = path.join(root, 'README.md'); + if (fs.existsSync(readme)) candidates.push(readme); + const docsMds = findMarkdownFiles(root); + candidates.push(...docsMds); + break; + } + default: + candidates.push(...srcIndex, ...srcMain); + break; + } + + // Filter to existing files, deduplicate, cap at 10 + const seen = new Set(); + const result: string[] = []; + for (const c of candidates) { + if (!seen.has(c) && fs.existsSync(c)) { + seen.add(c); + result.push(c); + if (result.length >= 10) break; + } + } + return result; +} + +function resolveWranglerEntry(root: string): string | null { + const wranglerPath = path.join(root, 'wrangler.toml'); + if (!fs.existsSync(wranglerPath)) return null; + try { + const content = fs.readFileSync(wranglerPath, 'utf8'); + const m = content.match(/^main\s*=\s*["']?([^"'\n]+)["']?/m); + if (m) return path.resolve(root, m[1].trim()); + } catch { + // ignore + } + return null; +} + +function findFilesWithNamePattern(root: string, pattern: RegExp): string[] { + const result: string[] = []; + const ignore = new Set(['node_modules', 'dist', 'build', '.git', '.next', 'coverage', '__tests__']); + function walk(dir: string) { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + if (ignore.has(e.name)) continue; + const full = path.join(dir, e.name); + if (e.isDirectory()) walk(full); + else if (e.isFile() && pattern.test(e.name)) result.push(full); + } + } + walk(root); + return result.slice(0, 5); +} + +function findCommandFiles(root: string): string[] { + const cmdsDir = path.join(root, 'src', 'commands'); + if (!fs.existsSync(cmdsDir)) return []; + try { + return fs + .readdirSync(cmdsDir) + .filter((f) => /\.[mc]?[tj]sx?$/.test(f)) + .slice(0, 5) + .map((f) => path.join(cmdsDir, f)); + } catch { + return []; + } +} + +function findMarkdownFiles(root: string): string[] { + const docsDir = path.join(root, 'docs'); + if (!fs.existsSync(docsDir)) return []; + try { + return fs + .readdirSync(docsDir) + .filter((f) => /\.md$/i.test(f)) + .slice(0, 5) + .map((f) => path.join(docsDir, f)); + } catch { + return []; + } +} + +// ============================================================================ +// Brief model +// ============================================================================ + +interface BriefModel { + // Identity + packageName: string; + stack: string; + preset: string; + version: string; + description: string | null; + bin: string | null; + + // Surface + routes: Array<{ method: string; path: string; framework: string }>; + totalRoutes: number; + totalFrameworks: number; + schemas: Array<{ name: string; columns: string[] }>; + totalTables: number; + + // Hotspots + hotFiles: Array<{ file: string; importers: number }>; + fileCount: number; + hotspotError: string | null; + + // Sensitivity + sensitivityTags: string[]; + + // Governance + defaultLoad: string[]; + onDemand: Array<{ path: string; triggers: string[] }>; + noManifest: boolean; + + // Footer + gitSha: string; + timestamp: string; +} + +// ============================================================================ +// Rendering +// ============================================================================ + +function renderBrief( + model: BriefModel, + opts: { + routeLimit: number; + tableLimit: number; + hotfileLimit: number; + onDemandLimit: number | null; + truncatedSections: string[]; + } +): string { + const lines: string[] = []; + + // Title + lines.push(`# ${model.packageName} — repo brief`); + lines.push(''); + + // Identity + lines.push('## Identity'); + lines.push(`- **Stack**: ${model.stack}`); + lines.push(`- **Preset**: ${model.preset}`); + lines.push(`- **Package**: ${model.packageName} v${model.version}`); + if (model.description) lines.push(`- **Description**: ${model.description}`); + if (model.bin) lines.push(`- **Bin**: ${model.bin}`); + lines.push(''); + + // Surface + const routeLimit = opts.routeLimit; + const tableLimit = opts.tableLimit; + const displayedRoutes = model.routes.slice(0, routeLimit); + const displayedTables = model.schemas.slice(0, tableLimit); + + lines.push('## Surface'); + lines.push( + `${model.totalRoutes} routes across ${model.totalFrameworks} frameworks · ${model.totalTables} D1 tables` + ); + lines.push(''); + if (displayedRoutes.length > 0) { + lines.push('| Method | Path | Framework |'); + lines.push('| ------ | ---- | --------- |'); + for (const r of displayedRoutes) { + lines.push(`| ${r.method} | ${r.path} | ${r.framework} |`); + } + lines.push(''); + } + if (displayedTables.length > 0) { + lines.push(`D1 Tables (top ${Math.min(tableLimit, displayedTables.length)} max):`); + for (const t of displayedTables) { + lines.push(`- \`${t.name}\`: ${t.columns.join(', ')}`); + } + lines.push(''); + } + if (displayedRoutes.length === 0 && displayedTables.length === 0) { + lines.push('_No routes or D1 tables detected._'); + lines.push(''); + } + + // Hotspots + const hotfileLimit = opts.hotfileLimit; + const displayedHotfiles = model.hotFiles.slice(0, hotfileLimit); + + lines.push('## Hotspots'); + if (model.hotspotError) { + lines.push(`Hotspots: ${model.hotspotError}`); + } else { + lines.push( + `${model.fileCount} source files scanned · top hot files by importer count` + ); + lines.push(''); + if (displayedHotfiles.length > 0) { + lines.push('| File | Importers | Flag |'); + lines.push('| ---- | --------- | ---- |'); + for (const h of displayedHotfiles) { + const flag = h.importers >= 10 ? 'CROSS_CUTTING' : ''; + lines.push(`| ${h.file} | ${h.importers} | ${flag} |`); + } + } + } + lines.push(''); + + // Sensitivity + lines.push('## Sensitivity'); + if (model.sensitivityTags.length > 0) { + for (const tag of model.sensitivityTags) { + lines.push(`- ${tag}`); + } + } else { + lines.push('No sensitivity configuration found.'); + } + lines.push(''); + + // Governance + lines.push('## Governance'); + if (model.noManifest) { + lines.push('No ADF manifest found.'); + } else { + lines.push(`**DEFAULT_LOAD**: ${model.defaultLoad.join(', ') || '(none)'}`); + const onDemandEntries = + opts.onDemandLimit !== null + ? model.onDemand.slice(0, opts.onDemandLimit) + : model.onDemand; + if (onDemandEntries.length > 0) { + const parts = onDemandEntries.map((m) => { + const triggers = m.triggers.length > 0 ? ` (triggers: ${m.triggers.join(', ')})` : ''; + return `${m.path}${triggers}`; + }); + lines.push(`**ON_DEMAND**: ${parts.join(', ')}`); + } else { + lines.push('**ON_DEMAND**: (none)'); + } + } + lines.push(''); + + // See also + lines.push('## See also'); + lines.push('- `CLAUDE.md` for human-authored rules and project conventions'); + lines.push(''); + + // Truncated section + if (opts.truncatedSections.length > 0) { + lines.push('## Truncated'); + for (const s of opts.truncatedSections) { + lines.push(`- ${s}`); + } + lines.push(''); + } + + // Footer + lines.push('---'); + lines.push(`_Generated from git SHA ${model.gitSha} · ${model.timestamp}_`); + + return lines.join('\n'); +} + +// ============================================================================ +// generateBrief — core implementation +// ============================================================================ + +export async function generateBrief(options?: BriefOptions): Promise { + const configPath = path.resolve(options?.configPath ?? '.charter'); + const aiDir = path.resolve(options?.aiDir ?? '.ai'); + const verbose = options?.verbose ?? false; + const root = process.cwd(); + + // ---- Load .charter/config.json ---- + let charterConfig: Record = {}; + try { + const cfgPath = path.join(configPath, 'config.json'); + if (fs.existsSync(cfgPath)) { + charterConfig = JSON.parse(fs.readFileSync(cfgPath, 'utf8')) as Record; + } + } catch { + // ignore + } + + const stack = (charterConfig.stack as string | undefined) ?? 'unknown'; + const preset = (charterConfig.preset as string | undefined) ?? 'default'; + const sensitivityTags: string[] = []; + const sensCfg = charterConfig.sensitivity; + if (sensCfg && typeof sensCfg === 'object' && sensCfg !== null) { + if (Array.isArray((sensCfg as Record).tags)) { + for (const t of (sensCfg as { tags: unknown[] }).tags) { + if (typeof t === 'string') sensitivityTags.push(t); + } + } + } else if (Array.isArray(charterConfig.sensitivityTags)) { + for (const t of charterConfig.sensitivityTags as unknown[]) { + if (typeof t === 'string') sensitivityTags.push(t); + } + } + + // ---- Load package.json ---- + let packageName = path.basename(root); + let version = '0.0.0'; + let description: string | null = null; + let binString: string | null = null; + let pkgBin: Record = {}; + try { + const pkgPath = path.join(root, 'package.json'); + if (fs.existsSync(pkgPath)) { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as Record; + if (typeof pkg.name === 'string') packageName = pkg.name; + if (typeof pkg.version === 'string') version = pkg.version; + if (typeof pkg.description === 'string') description = pkg.description; + if (pkg.bin && typeof pkg.bin === 'object' && pkg.bin !== null) { + pkgBin = pkg.bin as Record; + binString = Object.entries(pkgBin) + .map(([k, v]) => `${k} → ${v}`) + .join(', '); + } else if (typeof pkg.bin === 'string') { + binString = pkg.bin; + } + } + } catch { + // ignore + } + + // ---- Surface analysis ---- + let routes: Array<{ method: string; path: string; framework: string }> = []; + let totalRoutes = 0; + let totalFrameworks = 0; + let schemas: Array<{ name: string; columns: string[] }> = []; + let totalTables = 0; + try { + const surfaceInput = SurfaceInputSchema.parse({ root }); + const surface = analyzeSurface(surfaceInput); + routes = surface.routes.map((r) => ({ + method: r.method, + path: r.prefix ? `${r.prefix}${r.path}` : r.path, + framework: r.framework, + })); + totalRoutes = surface.summary.routeCount; + totalFrameworks = Object.keys(surface.summary.routesByFramework).length; + schemas = surface.schemas.map((t) => ({ + name: t.name, + columns: t.columns.map((c) => { + const flags: string[] = []; + if (c.primaryKey) flags.push('pk'); + const base = c.name; + return flags.length > 0 ? `${base} (${flags.join(', ')})` : base; + }), + })); + totalTables = surface.summary.schemaTableCount; + } catch { + // surface analysis failure is non-fatal + } + + // ---- Blast / hotspot analysis ---- + let hotFiles: Array<{ file: string; importers: number }> = []; + let fileCount = 0; + let hotspotError: string | null = null; + try { + const seeds = getSeedCandidates(preset, root, pkgBin); + if (seeds.length === 0) { + hotspotError = 'analysis unavailable'; + } else { + const aliases = detectTsconfigAliases(root); + const blastInput = BlastInputSchema.parse({ + seeds, + root, + aliases, + }); + const blastResult = analyzeBlast(blastInput); + fileCount = blastResult.fileCount; + hotFiles = blastResult.hotFiles.map((h) => ({ + file: h.file, + importers: h.importers, + })); + } + } catch { + hotspotError = 'analysis unavailable'; + } + + // ---- ADF manifest ---- + let defaultLoad: string[] = []; + let onDemand: Array<{ path: string; triggers: string[] }> = []; + let noManifest = false; + try { + const manifestPath = path.join(aiDir, 'manifest.adf'); + if (fs.existsSync(manifestPath)) { + const raw = fs.readFileSync(manifestPath, 'utf8'); + const doc = parseAdf(raw); + const manifest = parseManifest(doc); + defaultLoad = manifest.defaultLoad; + onDemand = manifest.onDemand.map((m) => ({ path: m.path, triggers: m.triggers })); + } else { + noManifest = true; + } + } catch { + noManifest = true; + } + + // ---- Build model ---- + const model: BriefModel = { + packageName, + stack, + preset, + version, + description, + bin: binString, + routes, + totalRoutes, + totalFrameworks, + schemas, + totalTables, + hotFiles, + fileCount, + hotspotError, + sensitivityTags, + defaultLoad, + onDemand, + noManifest, + gitSha: getGitSha(), + timestamp: new Date().toISOString(), + }; + + // ---- Truncation loop ---- + if (verbose) { + const markdown = renderBrief(model, { + routeLimit: model.routes.length, + tableLimit: model.schemas.length, + hotfileLimit: model.hotFiles.length, + onDemandLimit: null, + truncatedSections: [], + }); + return { + markdown, + tokenCount: estimateTokens(markdown), + truncated: false, + truncatedSections: [], + }; + } + + // Truncation parameters (start at max) + let routeLimit = Math.min(10, model.routes.length); + let tableLimit = Math.min(5, model.schemas.length); + let hotfileLimit = Math.min(10, model.hotFiles.length); + let onDemandLimit: number | null = null; + const truncatedSections: string[] = []; + + // Helper to try rendering and check budget + const tryRender = () => + renderBrief(model, { + routeLimit, + tableLimit, + hotfileLimit, + onDemandLimit, + truncatedSections, + }); + + let markdown = tryRender(); + + if (markdown.length <= CHAR_CEILING) { + return { + markdown, + tokenCount: estimateTokens(markdown), + truncated: false, + truncatedSections: [], + }; + } + + // Step 1: Reduce hotspots to top 5 + if (hotfileLimit > 5) { + const before = hotfileLimit; + hotfileLimit = 5; + truncatedSections.push(`Hotspots: reduced from ${before} to ${hotfileLimit} files`); + markdown = tryRender(); + if (markdown.length <= CHAR_CEILING) { + return { + markdown, + tokenCount: estimateTokens(markdown), + truncated: true, + truncatedSections: [...truncatedSections], + }; + } + } + + // Step 2: Reduce D1 tables to top 3 + if (tableLimit > 3) { + const before = tableLimit; + tableLimit = 3; + truncatedSections.push(`D1 Tables: reduced from ${before} to ${tableLimit} tables`); + markdown = tryRender(); + if (markdown.length <= CHAR_CEILING) { + return { + markdown, + tokenCount: estimateTokens(markdown), + truncated: true, + truncatedSections: [...truncatedSections], + }; + } + } + + // Step 3: Reduce routes to top 5 + if (routeLimit > 5) { + const before = routeLimit; + routeLimit = 5; + truncatedSections.push(`Routes: reduced from ${before} to ${routeLimit} routes`); + markdown = tryRender(); + if (markdown.length <= CHAR_CEILING) { + return { + markdown, + tokenCount: estimateTokens(markdown), + truncated: true, + truncatedSections: [...truncatedSections], + }; + } + } + + // Step 4: Truncate Governance ON_DEMAND to first 3 + if (onDemandLimit === null || (typeof onDemandLimit === 'number' && onDemandLimit > 3)) { + const before = model.onDemand.length; + onDemandLimit = 3; + truncatedSections.push(`Governance ON_DEMAND: reduced from ${before} to ${onDemandLimit} entries`); + markdown = tryRender(); + } + + // Even after all truncations, return what we have + return { + markdown, + tokenCount: estimateTokens(markdown), + truncated: truncatedSections.length > 0, + truncatedSections: [...truncatedSections], + }; +} + +// ============================================================================ +// CLI adapter +// ============================================================================ + +export async function contextCommand(options: CLIOptions, args: string[]): Promise { + const stdoutOnly = args.includes('--stdout-only'); + const verbose = args.includes('--verbose'); + const writeOnly = args.includes('--write'); + + const result = await generateBrief({ + configPath: options.configPath, + aiDir: '.ai', + verbose, + }); + + const { markdown } = result; + + if (!writeOnly) { + console.log(markdown); + } + + if (!stdoutOnly) { + const configDir = path.resolve(options.configPath); + try { + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + const outPath = path.join(configDir, 'context.md'); + fs.writeFileSync(outPath, markdown, 'utf8'); + } catch (err) { + // Writing to .charter/ is non-fatal — log and continue + process.stderr.write( + `charter context: failed to write .charter/context.md: ${(err as Error).message}\n` + ); + } + } + + return EXIT_CODE.SUCCESS; +} diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 333213d..288411c 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -226,6 +226,7 @@ Keep governance workflows aligned across all active agent instruction standards. const GITIGNORE_CONTENT = `# Charter local state .cache/ +context.md `; const SECURITY_TEMPLATE = `# Security Policy diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 46e0cb0..10462fb 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -27,6 +27,7 @@ import { validateConstraints, } from '@stackbilt/adf'; import { analyze as analyzeBlast, BlastInputSchema } from '@stackbilt/blast'; +import { generateBrief } from './context'; import { analyze as analyzeSurface, SurfaceInputSchema, @@ -294,6 +295,34 @@ function registerTools(server: McpServer, aiDir: string): void { }, ); + (server.registerTool as Function)( + 'charter_brief', + { + description: + 'CALL THIS FIRST when entering a Charter-governed repo. Returns routes, hotspots, sensitivity tags, and governance posture in a single pre-digested brief — replaces 15-30 discovery tool calls and 10k-50k tokens of cold-boot discovery. Returns markdown by default or structured JSON when format="json".', + inputSchema: { + format: z.enum(['markdown', 'json']).optional().describe( + 'Response format. "markdown" (default) returns the brief as human/agent-readable markdown. "json" returns structured metadata including tokenCount and truncated flag.', + ), + verbose: z.boolean().optional().describe( + 'If true, removes the 2000-token size ceiling. Use for interactive human sessions only.', + ), + }, + }, + async (rawInput: unknown) => { + try { + const input = (rawInput ?? {}) as { format?: 'markdown' | 'json'; verbose?: boolean }; + const result = await generateBrief({ verbose: input.verbose ?? false }); + const text = input.format === 'json' + ? JSON.stringify({ markdown: result.markdown, tokenCount: result.tokenCount, truncated: result.truncated, truncatedSections: result.truncatedSections }, null, 2) + : result.markdown; + return { content: [{ type: 'text' as const, text }] }; + } catch (err) { + return { content: [{ type: 'text' as const, text: `Error: ${err instanceof Error ? err.message : String(err)}` }], isError: true }; + } + }, + ); + (server.registerTool as Function)( 'getRecentChanges', { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index bf77378..5aad8af 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -25,6 +25,7 @@ import { runCommand } from './commands/run'; import { scoreCommand } from './commands/score'; import { blastCommand } from './commands/blast'; import { surfaceCommand } from './commands/surface'; +import { contextCommand } from './commands/context'; import { recordTelemetryEvent } from './telemetry'; import { getFlag } from './flags'; import packageJson from '../package.json'; @@ -39,6 +40,8 @@ Usage: charter bootstrap [--ci github] [--preset ] [--yes] [--force] [--skip-install] [--skip-doctor] One-command repo onboarding (detect + setup + ADF + install + doctor) --security-sensitive adds SECURITY.md, hard security drift denies, and a security test check + charter context [--stdout-only] [--verbose] [--write] + Pre-digested repo brief for AI agents (routes, hotspots, governance) charter setup [--ci github] [--preset ] [--detect-only] [--no-dependency-sync] Bootstrap .charter/ and optional CI workflow charter init [--preset ] [--guided] @@ -169,6 +172,9 @@ export async function run(args: string[]): Promise { case 'bootstrap': exitCode = await bootstrapCommand(options, restArgs); break; + case 'context': + exitCode = await contextCommand(options, restArgs); + break; case 'setup': exitCode = await setupCommand(options, restArgs); break; diff --git a/scripts/eval-brief-coverage.mjs b/scripts/eval-brief-coverage.mjs new file mode 100644 index 0000000..20bda10 --- /dev/null +++ b/scripts/eval-brief-coverage.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +/** + * eval-brief-coverage.mjs + * + * Deterministic fixture-based eval for charter context. + * Verifies that the brief's content coverage is sufficient for agent orientation. + * + * Does NOT call a live model — tests structural coverage against fixture answers. + * CI-safe and reproducible. + * + * Acceptance: a brief-primed agent should reach first useful action in ≤5 tool calls. + * This script verifies the brief contains enough information to answer 5 standard + * orientation questions without any additional tool calls. + */ + +import { execSync } from 'node:child_process'; +import * as assert from 'node:assert'; + +const REQUIRED_SECTIONS = ['## Identity', '## Surface', '## Hotspots', '## Sensitivity', '## Governance', '## See also']; + +// 5 standard agent orientation questions — answers must be findable in the brief alone +const ORIENTATION_CHECKS = [ + { + question: 'What stack/preset is this repo?', + check: (brief) => /\*\*Stack\*\*:/.test(brief), + description: 'Brief must contain Stack identity', + }, + { + question: 'What HTTP routes does this project expose?', + check: (brief) => /## Surface/.test(brief), + description: 'Brief must contain Surface section', + }, + { + question: 'Which files are most load-bearing (hot)?', + check: (brief) => /## Hotspots/.test(brief) && (/importers/.test(brief) || /Hotspots: analysis unavailable/.test(brief)), + description: 'Brief must contain Hotspots with importer data', + }, + { + question: 'What governance modules are loaded by default?', + check: (brief) => /## Governance/.test(brief) && (/DEFAULT_LOAD/.test(brief) || /No ADF manifest/.test(brief)), + description: 'Brief must contain Governance/DEFAULT_LOAD', + }, + { + question: 'Where are the human-authored rules?', + check: (brief) => /## See also/.test(brief) && /CLAUDE\.md/.test(brief), + description: 'Brief must reference CLAUDE.md in See also', + }, +]; + +let brief; +try { + brief = execSync('node packages/cli/dist/bin.js context --stdout-only', { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); +} catch (err) { + console.error('eval-brief-coverage: could not run charter context (is CLI built?)'); + console.error(err.message); + process.exit(1); +} + +let passed = 0; +let failed = 0; + +console.log('=== charter context eval: orientation coverage ===\n'); + +// Check all required sections present +for (const section of REQUIRED_SECTIONS) { + if (brief.includes(section)) { + console.log(` ✓ Section present: ${section}`); + passed++; + } else { + console.error(` ✗ Missing section: ${section}`); + failed++; + } +} + +// Check 5 orientation questions answerable from brief alone +console.log('\n--- Orientation coverage (≤5 tool calls target) ---'); +for (const { question, check, description } of ORIENTATION_CHECKS) { + if (check(brief)) { + console.log(` ✓ Q: "${question}"`); + passed++; + } else { + console.error(` ✗ Q: "${question}"`); + console.error(` Expected: ${description}`); + failed++; + } +} + +const total = passed + failed; +console.log(`\nResult: ${passed}/${total} checks passed`); + +if (failed > 0) { + console.error(`\nFAIL: ${failed} coverage check(s) failed.`); + console.error('Agent orientation coverage insufficient — brief does not answer all 5 standard questions.'); + process.exit(1); +} + +console.log('\nPASS: Brief covers all 5 orientation questions. Agent cold-boot cost target is achievable.'); diff --git a/scripts/measure-brief-size.mjs b/scripts/measure-brief-size.mjs new file mode 100644 index 0000000..fa5f82c --- /dev/null +++ b/scripts/measure-brief-size.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node +// scripts/measure-brief-size.mjs +import { execSync } from 'node:child_process'; + +const TOKEN_BUDGET = 2000; +const CHARS_PER_TOKEN = 4; +const CHAR_BUDGET = TOKEN_BUDGET * CHARS_PER_TOKEN; + +let output; +try { + output = execSync('node packages/cli/dist/bin.js context --stdout-only', { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); +} catch (err) { + console.error('measure-brief-size: could not run charter context (is CLI built?)'); + console.error(err.message); + process.exit(1); +} + +const chars = output.length; +const estimatedTokens = Math.ceil(chars / CHARS_PER_TOKEN); +console.log(`Brief size: ${chars} chars ≈ ${estimatedTokens} tokens (budget: ${TOKEN_BUDGET})`); + +if (estimatedTokens > TOKEN_BUDGET) { + console.error(`ERROR: Brief exceeds ${TOKEN_BUDGET}-token budget by ${estimatedTokens - TOKEN_BUDGET} tokens.`); + console.error('Reduce section content or verify truncation logic in packages/cli/src/commands/context.ts'); + process.exit(1); +} + +console.log('OK: Brief is within token budget.'); From 2ec4bd058265f0fc08036fff30420d0e957a3467 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Tue, 19 May 2026 06:34:53 -0500 Subject: [PATCH 2/3] chore: bump all packages to 0.14.0 for charter context release Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 11 +++++++++++ packages/adf/package.json | 2 +- packages/blast/package.json | 2 +- packages/ci/package.json | 4 ++-- packages/classify/package.json | 4 ++-- packages/cli/package.json | 20 ++++++++++---------- packages/core/package.json | 2 +- packages/drift/package.json | 4 ++-- packages/git/package.json | 4 ++-- packages/surface/package.json | 2 +- packages/types/package.json | 2 +- packages/validate/package.json | 4 ++-- 12 files changed, 36 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c52cab..e4d1399 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project are documented in this file. The format is based on Keep a Changelog and follows Semantic Versioning. +## [0.14.0] - 2026-05-19 + +Synchronized version bump for all `@stackbilt/*` packages to 0.14.0. + +### Added +- **`charter context`** — new CLI command that generates a pre-digested repo brief (identity, surface, hotspots, sensitivity, governance) within a 2,000-token budget. Designed as the fastest way to orient an AI agent entering a Charter-governed repo; replaces 15-30 cold-boot discovery tool calls. Flags: `--stdout-only`, `--verbose` (no ceiling), `--write` (file only). Writes `.charter/context.md` by default. +- **`charter_brief` MCP tool** — `charter serve` now registers `charter_brief`, which calls `generateBrief()` and returns the same bounded brief over MCP. Tool description instructs agents to call it first in any session. +- **CI brief quality gates** — `scripts/measure-brief-size.mjs` (exits 1 if brief > 2000 tokens) and `scripts/eval-brief-coverage.mjs` (fixture-based check requiring all 5 sections and 5 orientation questions to be answerable from the brief alone) are added to `.github/workflows/ci.yml` after the build step. +- **`context.md` excluded from `.charter/.gitignore`** — generated brief is local-only by default; committed to `.gitignore` by `charter bootstrap` and `charter init`. + ## [0.13.0] - 2026-05-19 Synchronized version bump for all `@stackbilt/*` packages to 0.13.0. @@ -505,6 +515,7 @@ All 345 existing tests pass. ### Security - Added repository security policy and reporting process. +[0.14.0]: https://github.com/stackbilt-dev/charter/compare/v0.13.0...v0.14.0 [0.13.0]: https://github.com/stackbilt-dev/charter/compare/v0.12.1...v0.13.0 [0.12.1]: https://github.com/stackbilt-dev/charter/compare/v0.12.0...v0.12.1 [0.5.0]: https://github.com/stackbilt-dev/charter/compare/v0.4.2...v0.5.0 diff --git a/packages/adf/package.json b/packages/adf/package.json index afd91de..8152a53 100644 --- a/packages/adf/package.json +++ b/packages/adf/package.json @@ -1,7 +1,7 @@ { "name": "@stackbilt/adf", "sideEffects": false, - "version": "0.13.0", + "version": "0.14.0", "description": "ADF (Attention-Directed Format) — AST-backed context format for AI agents", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/blast/package.json b/packages/blast/package.json index 1218cc6..56a3ef2 100644 --- a/packages/blast/package.json +++ b/packages/blast/package.json @@ -1,7 +1,7 @@ { "name": "@stackbilt/blast", "sideEffects": false, - "version": "0.13.0", + "version": "0.14.0", "description": "Blast radius analysis via reverse dependency graph + BFS traversal", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/ci/package.json b/packages/ci/package.json index ad5d6e5..5bc82b7 100644 --- a/packages/ci/package.json +++ b/packages/ci/package.json @@ -1,7 +1,7 @@ { "name": "@stackbilt/ci", "sideEffects": false, - "version": "0.13.0", + "version": "0.14.0", "description": "GitHub Actions adapter for Charter governance checks", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -29,7 +29,7 @@ }, "homepage": "https://github.com/Stackbilt-dev/charter#readme", "dependencies": { - "@stackbilt/types": "^0.13.0" + "@stackbilt/types": "^0.14.0" }, "scripts": { "prepublishOnly": "node ../../scripts/ensure-pnpm-publish.mjs" diff --git a/packages/classify/package.json b/packages/classify/package.json index b621441..0d46bb0 100644 --- a/packages/classify/package.json +++ b/packages/classify/package.json @@ -1,7 +1,7 @@ { "name": "@stackbilt/classify", "sideEffects": false, - "version": "0.13.0", + "version": "0.14.0", "description": "Heuristic change classification (SURFACE/LOCAL/CROSS_CUTTING)", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -29,7 +29,7 @@ }, "homepage": "https://github.com/Stackbilt-dev/charter#readme", "dependencies": { - "@stackbilt/types": "^0.13.0" + "@stackbilt/types": "^0.14.0" }, "scripts": { "prepublishOnly": "node ../../scripts/ensure-pnpm-publish.mjs" diff --git a/packages/cli/package.json b/packages/cli/package.json index 191265e..398b780 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@stackbilt/cli", "sideEffects": false, - "version": "0.13.0", + "version": "0.14.0", "description": "Charter CLI — repo-level governance checks + architecture scaffolding", "bin": { "charter": "./dist/bin.js", @@ -41,15 +41,15 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", - "@stackbilt/adf": "^0.13.0", - "@stackbilt/blast": "^0.13.0", - "@stackbilt/classify": "^0.13.0", - "@stackbilt/core": "^0.13.0", - "@stackbilt/drift": "^0.13.0", - "@stackbilt/git": "^0.13.0", - "@stackbilt/surface": "^0.13.0", - "@stackbilt/types": "^0.13.0", - "@stackbilt/validate": "^0.13.0" + "@stackbilt/adf": "^0.14.0", + "@stackbilt/blast": "^0.14.0", + "@stackbilt/classify": "^0.14.0", + "@stackbilt/core": "^0.14.0", + "@stackbilt/drift": "^0.14.0", + "@stackbilt/git": "^0.14.0", + "@stackbilt/surface": "^0.14.0", + "@stackbilt/types": "^0.14.0", + "@stackbilt/validate": "^0.14.0" }, "license": "Apache-2.0", "author": "Stackbilt LLC" diff --git a/packages/core/package.json b/packages/core/package.json index 62c829c..56d0561 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@stackbilt/core", "sideEffects": false, - "version": "0.13.0", + "version": "0.14.0", "description": "Core schemas, sanitization, and error handling for Charter Kit", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/drift/package.json b/packages/drift/package.json index c203588..9b029da 100644 --- a/packages/drift/package.json +++ b/packages/drift/package.json @@ -1,7 +1,7 @@ { "name": "@stackbilt/drift", "sideEffects": false, - "version": "0.13.0", + "version": "0.14.0", "description": "Drift scanner — detects codebase divergence from governance patterns", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -29,7 +29,7 @@ }, "homepage": "https://github.com/Stackbilt-dev/charter#readme", "dependencies": { - "@stackbilt/types": "^0.13.0" + "@stackbilt/types": "^0.14.0" }, "scripts": { "prepublishOnly": "node ../../scripts/ensure-pnpm-publish.mjs" diff --git a/packages/git/package.json b/packages/git/package.json index f915015..b9c8643 100644 --- a/packages/git/package.json +++ b/packages/git/package.json @@ -1,7 +1,7 @@ { "name": "@stackbilt/git", "sideEffects": false, - "version": "0.13.0", + "version": "0.14.0", "description": "Git trailer parsing, commit risk scoring, and PR validation", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -29,7 +29,7 @@ }, "homepage": "https://github.com/Stackbilt-dev/charter#readme", "dependencies": { - "@stackbilt/types": "^0.13.0" + "@stackbilt/types": "^0.14.0" }, "scripts": { "prepublishOnly": "node ../../scripts/ensure-pnpm-publish.mjs" diff --git a/packages/surface/package.json b/packages/surface/package.json index 91821f3..adbeaf2 100644 --- a/packages/surface/package.json +++ b/packages/surface/package.json @@ -1,7 +1,7 @@ { "name": "@stackbilt/surface", "sideEffects": false, - "version": "0.13.0", + "version": "0.14.0", "description": "API surface extraction: routes (Hono/Express) + D1 SQL schema", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/types/package.json b/packages/types/package.json index 17b7310..8e0d799 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,7 +1,7 @@ { "name": "@stackbilt/types", "sideEffects": false, - "version": "0.13.0", + "version": "0.14.0", "description": "Shared type definitions for the Charter Kit", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/validate/package.json b/packages/validate/package.json index 86672f9..db97ed3 100644 --- a/packages/validate/package.json +++ b/packages/validate/package.json @@ -1,7 +1,7 @@ { "name": "@stackbilt/validate", "sideEffects": false, - "version": "0.13.0", + "version": "0.14.0", "description": "Citation validation, message classification, and governance checks", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -29,7 +29,7 @@ }, "homepage": "https://github.com/Stackbilt-dev/charter#readme", "dependencies": { - "@stackbilt/types": "^0.13.0" + "@stackbilt/types": "^0.14.0" }, "scripts": { "prepublishOnly": "node ../../scripts/ensure-pnpm-publish.mjs" From 3e2964525ead14bbf8aadf4153b1a107a9de9efa Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Tue, 19 May 2026 07:02:41 -0500 Subject: [PATCH 3/3] fix: address review findings in charter context command - Remove dead `assert` import from eval-brief-coverage.mjs - Fix misleading "fixture" comment (script runs live against local repo) - Add mutual-exclusion guard: --stdout-only + --write now exits RUNTIME_ERROR with a clear message instead of silently doing nothing - Add Step 5 hard-slice fallback in truncation loop to guarantee brief never exceeds CHAR_CEILING regardless of ON_DEMAND trigger list length Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/commands/context.ts | 22 +++++++++++++++++++++- scripts/eval-brief-coverage.mjs | 8 ++++---- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/context.ts b/packages/cli/src/commands/context.ts index 61e28bc..87d7161 100644 --- a/packages/cli/src/commands/context.ts +++ b/packages/cli/src/commands/context.ts @@ -638,9 +638,24 @@ export async function generateBrief(options?: BriefOptions): Promise CHAR_CEILING) { + truncatedSections.push('Content hard-sliced to fit token budget'); + markdown = markdown.slice(0, CHAR_CEILING) + '\n\n## Truncated\n- Content hard-sliced to fit token budget\n'; } - // Even after all truncations, return what we have return { markdown, tokenCount: estimateTokens(markdown), @@ -658,6 +673,11 @@ export async function contextCommand(options: CLIOptions, args: string[]): Promi const verbose = args.includes('--verbose'); const writeOnly = args.includes('--write'); + if (stdoutOnly && writeOnly) { + process.stderr.write('charter context: --stdout-only and --write are mutually exclusive\n'); + return EXIT_CODE.RUNTIME_ERROR; + } + const result = await generateBrief({ configPath: options.configPath, aiDir: '.ai', diff --git a/scripts/eval-brief-coverage.mjs b/scripts/eval-brief-coverage.mjs index 20bda10..eff4118 100644 --- a/scripts/eval-brief-coverage.mjs +++ b/scripts/eval-brief-coverage.mjs @@ -2,10 +2,11 @@ /** * eval-brief-coverage.mjs * - * Deterministic fixture-based eval for charter context. - * Verifies that the brief's content coverage is sufficient for agent orientation. + * Structural coverage eval for charter context. + * Runs `charter context --stdout-only` against the local repo and verifies the brief + * contains all required sections and answers 5 standard agent orientation questions. * - * Does NOT call a live model — tests structural coverage against fixture answers. + * Does NOT call a live model — tests structural coverage of live output. * CI-safe and reproducible. * * Acceptance: a brief-primed agent should reach first useful action in ≤5 tool calls. @@ -14,7 +15,6 @@ */ import { execSync } from 'node:child_process'; -import * as assert from 'node:assert'; const REQUIRED_SECTIONS = ['## Identity', '## Surface', '## Hotspots', '## Sensitivity', '## Governance', '## See also'];