From 3111908997dae653f906176d339fe1256864018d Mon Sep 17 00:00:00 2001 From: Kevin Overmier Date: Thu, 5 Mar 2026 17:02:20 -0600 Subject: [PATCH 1/5] fix(security): remove shell injection surface and block directory traversal Closes #43: Remove shell: true from runGit() in git-helpers.ts. Node.js resolves the git binary via PATH directly without a shell on WSL, Linux, macOS, and Windows. shell: true is unnecessary and allows shell metacharacters in args to be interpreted as shell syntax. Closes #42: Validate module paths in adf create before path.join. Paths containing ".." or absolute paths are rejected with a clear error. A secondary resolved-path check confirms the final path stays within the .ai/ directory, guarding against platform-specific bypass patterns. Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/commands/adf.ts | 12 ++++++++++++ packages/cli/src/git-helpers.ts | 15 +++++++-------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/adf.ts b/packages/cli/src/commands/adf.ts index c9dad52..ff06b19 100644 --- a/packages/cli/src/commands/adf.ts +++ b/packages/cli/src/commands/adf.ts @@ -480,7 +480,19 @@ function adfCreate(options: CLIOptions, args: string[]): number { const modulePath = moduleArg.endsWith('.adf') ? moduleArg : `${moduleArg}.adf`; const moduleRelPath = modulePath.replace(/\\/g, '/'); + + // Prevent directory traversal: reject paths that escape the .ai/ directory + if (moduleRelPath.includes('..') || path.isAbsolute(moduleRelPath)) { + throw new CLIError(`Invalid module path: "${moduleRelPath}". Path must not contain ".." or be absolute.`); + } + const moduleAbsPath = path.join(aiDir, moduleRelPath); + const resolvedAiDir = path.resolve(aiDir); + const resolvedModulePath = path.resolve(moduleAbsPath); + if (!resolvedModulePath.startsWith(resolvedAiDir + path.sep)) { + throw new CLIError(`Invalid module path: "${moduleRelPath}". Path must stay within ${aiDir}/.`); + } + fs.mkdirSync(path.dirname(moduleAbsPath), { recursive: true }); let fileCreated = false; diff --git a/packages/cli/src/git-helpers.ts b/packages/cli/src/git-helpers.ts index 54b059d..455eed9 100644 --- a/packages/cli/src/git-helpers.ts +++ b/packages/cli/src/git-helpers.ts @@ -1,9 +1,9 @@ /** * Shared git invocation helpers. * - * Centralizes all child-process git calls behind a single `runGit()` that - * uses `shell: true` for cross-platform PATH resolution (fixes WSL, CMD, - * PowerShell parity — see ADX-005 F2). + * Centralizes all child-process git calls behind a single `runGit()`. + * All args are hardcoded call-site strings, never user input — but we + * still avoid `shell: true` to eliminate any shell-injection surface. */ import { execFileSync } from 'node:child_process'; @@ -16,17 +16,16 @@ import type { GitCommit } from '@stackbilt/types'; /** * Run a git command and return its stdout. * - * Uses `shell: true` so that the OS shell resolves the `git` binary via - * PATH. This is the key cross-platform fix: `execFileSync` *without* a - * shell can fail on WSL/Windows when git lives in a PATH entry the Node - * process doesn't see directly. + * Does NOT use `shell: true` — Node resolves `git` via PATH directly, which + * works on WSL, Linux, macOS, and Windows (Git for Windows adds git to PATH + * at install time). Using shell: true is unnecessary here and would allow + * shell metacharacters in args to be interpreted as shell syntax. */ export function runGit(args: string[]): string { return execFileSync('git', args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 10 * 1024 * 1024, - shell: true, }); } From 59a934723fbadb79b3b7af31f4856f7e41df96aa Mon Sep 17 00:00:00 2001 From: Kevin Overmier Date: Thu, 5 Mar 2026 17:03:05 -0600 Subject: [PATCH 2/5] feat(adf): populate command, unknown-op error clarity, CONTEXT scaffold section charter adf populate [--dry-run] [--force] [--ai-dir ] Reads package.json, README.md, and stack detection signals to auto-fill ADF files with project-specific content after charter adf init. Populates CONTEXT in core/backend/frontend.adf and STATE in state.adf. Idempotent: skips files with non-scaffold content unless --force. patcher: unknown ops now produce a clear error listing valid op names instead of the cryptic "handlers[op.op] is not a function" TypeError. CORE_SCAFFOLD: add a CONTEXT section placeholder so ADD_BULLET section:CONTEXT works immediately after adf init without requiring ADD_SECTION first. adf patch help: list all valid ops with concrete usage examples. bootstrap/init next steps: point to charter adf populate as step 1 instead of generic "edit core.adf manually" guidance. harness: extend SDLC corpus and runner with mixed QA/backend signal scenarios that exposed the classifier routing issues filed in #44/#45. Co-Authored-By: Claude Sonnet 4.6 --- harness/adf-inspector.ts | 7 +- harness/corpus/sdlc.ts | 138 +++++++ harness/runner.ts | 117 +++++- harness/types.ts | 28 ++ .../src/__tests__/content-classifier.test.ts | 18 + packages/adf/src/content-classifier.ts | 21 +- packages/adf/src/patcher.ts | 10 +- packages/cli/src/commands/adf-populate.ts | 390 ++++++++++++++++++ packages/cli/src/commands/adf.ts | 26 +- packages/cli/src/commands/bootstrap.ts | 4 +- 10 files changed, 745 insertions(+), 14 deletions(-) create mode 100644 harness/corpus/sdlc.ts create mode 100644 packages/cli/src/commands/adf-populate.ts diff --git a/harness/adf-inspector.ts b/harness/adf-inspector.ts index d898111..ea37784 100644 --- a/harness/adf-inspector.ts +++ b/harness/adf-inspector.ts @@ -120,6 +120,9 @@ export function printSnapshot(snapshot: AdfSnapshot, previous?: AdfSnapshot): vo export function detectAccumulationIssues(snapshots: AdfSnapshot[]): string[] { const issues: string[] = []; if (snapshots.length < 2) return issues; + const MIN_ABSOLUTE_GROWTH = 10; + const MIN_BASELINE_ITEMS = 3; + const MAX_SECTION_ITEMS = 20; const first = snapshots[0]; const last = snapshots[snapshots.length - 1]; @@ -131,13 +134,13 @@ export function detectAccumulationIssues(snapshots: AdfSnapshot[]): string[] { const growth = mod.totalItems - start.totalItems; const growthRate = start.totalItems > 0 ? growth / start.totalItems : growth; - if (growthRate > 2) { + if (growth >= MIN_ABSOLUTE_GROWTH && start.totalItems >= MIN_BASELINE_ITEMS && growthRate > 2) { issues.push(`${mod.module}: grew ${growth} items (${(growthRate * 100).toFixed(0)}% increase) — possible accumulation`); } // Check any single section that got very large for (const sec of mod.sections) { - if (sec.itemCount > 15) { + if (sec.itemCount > MAX_SECTION_ITEMS) { issues.push(`${mod.module} > ${sec.key}: ${sec.itemCount} items — section may need pruning`); } } diff --git a/harness/corpus/sdlc.ts b/harness/corpus/sdlc.ts new file mode 100644 index 0000000..e5ed949 --- /dev/null +++ b/harness/corpus/sdlc.ts @@ -0,0 +1,138 @@ +/** + * SDLC-focused scenarios — validate that ADF modules stay updated and portable + * as project guidance evolves from requirements through release. + */ + +import type { Scenario } from '../types'; + +export const sdlcScenarios: Scenario[] = [ + { + id: 'fullstack-sdlc-handoff-portability', + archetype: 'fullstack', + description: 'Rules evolve across SDLC phases while remaining portable through ADF modules', + manifest: { + onDemand: [ + { path: 'frontend.adf', triggers: ['react', 'component', 'ui', 'css', 'vite', 'tsx'] }, + { path: 'backend.adf', triggers: ['api', 'endpoint', 'route', 'handler', 'database', 'auth', 'zod', 'request', 'response'] }, + { path: 'infra.adf', triggers: ['deploy', 'release', 'rollback', 'ci', 'pipeline', 'docker', 'env', 'artifact'] }, + { path: 'qa.adf', triggers: ['test', 'testing', 'playwright', 'contract', 'smoke', 'verification', 'evidence', 'auditability'] }, + ], + }, + sessions: [ + { + label: 'session-1: requirements', + inject: ` +## API Requirements + +- Every API endpoint must publish request and response schemas +- Auth is required for all write endpoints +- Route handlers must return structured error codes +- Database migrations must be reviewed before merge +`, + expected: { 'backend.adf': 4 }, + }, + { + label: 'session-2: design', + inject: ` +## System Design + +- React UI components must map one-to-one to approved design tokens +- API handlers must validate all payloads with Zod +- Route naming must stay stable across versions +- Frontend component props must be typed in TSX files +`, + expected: { 'frontend.adf': 2, 'backend.adf': 2 }, + }, + { + label: 'session-3: implementation', + inject: ` +## Implementation Rules + +- API route files live under \`app/api/\` and use one handler per endpoint +- Database writes must run inside transactions +- Auth checks execute before any handler business logic +- Build artifacts are generated only in CI pipeline jobs +`, + expected: { 'backend.adf': 3, 'infra.adf': 1 }, + }, + { + label: 'session-4: verification', + inject: ` +## Verification + +- CI pipeline must run unit, integration, and Playwright suites on every PR +- API contract tests validate request and response schema compatibility +- Deploy preview environments must run smoke checks before approval +- Test artifacts are uploaded from CI for auditability +`, + expected: { 'qa.adf': 4 }, + }, + { + label: 'session-5: release and portability handoff', + inject: ` +## Release Handoff + +- Deploy jobs must consume versioned artifacts from the pipeline only +- Rollback instructions must be validated in staging before production release +- Environment configuration uses env keys defined in the deployment checklist +- Release evidence includes CI run ID, artifact hash, and deployment timestamp +`, + expected: { 'infra.adf': 4 }, + }, + ], + }, + { + id: 'fullstack-sdlc-generic-checklist-routing', + archetype: 'fullstack', + description: 'Generic SDLC handoff headings still separate verification evidence from release operations', + manifest: { + onDemand: [ + { path: 'frontend.adf', triggers: ['react', 'component', 'ui', 'css', 'vite', 'tsx'] }, + { path: 'backend.adf', triggers: ['api', 'endpoint', 'route', 'handler', 'database', 'auth', 'zod', 'request', 'response'] }, + { path: 'infra.adf', triggers: ['deploy', 'release', 'rollback', 'ci', 'pipeline', 'docker', 'env', 'artifact'] }, + { path: 'qa.adf', triggers: ['test', 'testing', 'playwright', 'contract', 'smoke', 'verification', 'evidence', 'auditability'] }, + ], + }, + sessions: [ + { + label: 'session-1: generic checklist handoff', + inject: ` +## Checklist + +- Playwright smoke tests must pass before release approval +- Contract test evidence is attached to the deployment record for auditability +- Release artifact hashes are recorded before deploy starts +- Rollback drills must use the staged deploy artifact from the pipeline +`, + expected: { 'qa.adf': 2, 'infra.adf': 2 }, + }, + ], + }, + { + id: 'fullstack-sdlc-mixed-qa-backend-signals', + archetype: 'fullstack', + description: 'Mixed backend and QA wording in a generic checklist should still route by dominant verification vs API intent', + manifest: { + onDemand: [ + { path: 'frontend.adf', triggers: ['react', 'component', 'ui', 'css', 'vite', 'tsx'] }, + { path: 'backend.adf', triggers: ['api', 'endpoint', 'route', 'handler', 'database', 'auth', 'zod', 'request', 'response'] }, + { path: 'infra.adf', triggers: ['deploy', 'release', 'rollback', 'ci', 'pipeline', 'docker', 'env', 'artifact'] }, + { path: 'qa.adf', triggers: ['test', 'testing', 'playwright', 'contract', 'smoke', 'verification', 'evidence', 'auditability'] }, + ], + }, + sessions: [ + { + label: 'session-1: mixed checklist bullets', + inject: ` +## Checklist + +- API contract test evidence must be attached to the release review for auditability +- Request and response schema contract tests must pass before merging backend changes +- Endpoint smoke tests run in CI before deploy approval +- API handler error responses are verified against contract fixtures +`, + expected: { 'qa.adf': 3, 'backend.adf': 1 }, + }, + ], + }, +]; diff --git a/harness/runner.ts b/harness/runner.ts index 675a4e3..24866b0 100644 --- a/harness/runner.ts +++ b/harness/runner.ts @@ -21,7 +21,8 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { execFileSync } from 'node:child_process'; -import type { Scenario, TidyOutput, ScenarioResult, HarnessReport } from './types'; +import { buildMigrationPlan, parseMarkdownSections, type TriggerMap } from '../packages/adf/src'; +import type { Scenario, TidyOutput, ScenarioResult, HarnessReport, StaticSessionAudit, StaticItemRoute } from './types'; import { evaluateSession, printSessionResult } from './evaluator'; import { generateScenarios, getArchetypeManifest } from './ollama'; import { REAL_REPOS } from './corpus/real-repos'; @@ -31,6 +32,7 @@ import { workerScenarios } from './corpus/worker'; import { backendScenarios } from './corpus/backend'; import { fullstackScenarios } from './corpus/fullstack'; import { edgeCaseScenarios } from './corpus/edge-cases'; +import { sdlcScenarios } from './corpus/sdlc'; // ============================================================================ // Config @@ -44,6 +46,7 @@ const ALL_STATIC: Scenario[] = [ ...backendScenarios, ...fullstackScenarios, ...edgeCaseScenarios, + ...sdlcScenarios, ]; const OLLAMA_ARCHETYPES = ['worker', 'backend', 'fullstack']; @@ -158,7 +161,12 @@ function runTidy(repoDir: string, dryRun = true): TidyOutput { function runStaticScenario(scenario: Scenario): ScenarioResult { const tmp = makeTempRepo(scenario); const sessionResults = []; + const sessionAudits: StaticSessionAudit[] = []; + const snapshots: AdfSnapshot[] = []; + let prevSnapshot: AdfSnapshot | undefined; let scenarioPass = true; + const baseClaude = THIN_POINTER.trim(); + const aiDir = path.join(tmp, '.ai'); for (const session of scenario.sessions) { // Each session: inject onto thin pointer, dry-run to evaluate, then apply @@ -173,7 +181,43 @@ function runStaticScenario(scenario: Scenario): ScenarioResult { // Apply tidy (non-dry-run) to route content into ADF modules, restoring // CLAUDE.md to thin pointer so the next session sees a clean baseline. - runTidy(tmp, false); + const applyOutput = runTidy(tmp, false); + + const postClaude = fs.readFileSync(path.join(tmp, 'CLAUDE.md'), 'utf-8').trim(); + const claudeRestored = postClaude === baseClaude; + if (!claudeRestored) { + scenarioPass = false; + console.log(' portability warning: CLAUDE.md was not restored to thin pointer state'); + } + + const snapshot = inspectAdfModules(aiDir, session.label, prevSnapshot); + snapshots.push(snapshot); + prevSnapshot = snapshot; + const itemRoutes = previewItemRoutes(session.inject, scenario); + + sessionAudits.push({ + sessionLabel: session.label, + dryRunExtracted: tidyOutput.totalExtracted, + appliedModulesModified: applyOutput.modulesModified, + claudeRestored, + adfTotalItems: snapshot.totalItemsAcrossAllModules, + modulesGrew: snapshot.grew, + itemRoutes, + }); + + if (!sessionResult.pass) { + console.log(' item routing preview:'); + for (const item of itemRoutes) { + const matches = item.matchedTriggers.length > 0 ? ` | matches=${item.matchedTriggers.join(', ')} score=${item.matchScore}` : ''; + console.log(` [${item.heading || 'preamble'} -> ${item.headingModule}] ${item.targetModule} (${item.targetSection}) :: ${item.content}${matches}`); + } + } + } + + const accumulationIssues = detectAccumulationIssues(snapshots); + if (accumulationIssues.length > 0) { + console.log(' accumulation warnings:'); + for (const issue of accumulationIssues) console.log(` - ${issue}`); } return { @@ -181,10 +225,79 @@ function runStaticScenario(scenario: Scenario): ScenarioResult { archetype: scenario.archetype, description: scenario.description, sessions: sessionResults, + staticAudit: { + sessions: sessionAudits, + accumulationIssues, + }, pass: scenarioPass, }; } +function previewItemRoutes(inject: string, scenario: Scenario): StaticItemRoute[] { + const triggerMap: TriggerMap = {}; + for (const entry of scenario.manifest.onDemand) { + if (entry.triggers.length > 0) { + triggerMap[entry.path] = entry.triggers.map(trigger => trigger.toLowerCase()); + } + } + + const sections = parseMarkdownSections(inject); + const plan = buildMigrationPlan(sections, undefined, triggerMap); + + return plan.items.map(item => ({ + heading: item.sourceHeading, + content: item.element.content, + headingModule: previewHeadingModule(item.sourceHeading), + targetModule: item.classification.targetModule, + targetSection: item.classification.targetSection, + decision: item.classification.decision, + reason: item.classification.reason, + ...scoreItemAgainstTriggers(item.element.content, triggerMap), + })); +} + +function previewHeadingModule(heading: string): string { + const lower = heading.toLowerCase(); + if (/\b(design.system|ui|frontend|css|component|react|vue|svelte|next|nextjs|tailwind|shadcn|radix|storybook|vite|vitest|playwright|remix|nuxt|astro)\b/.test(lower)) { + return 'frontend.adf'; + } + if (/\b(qa|quality|test|testing|verification|validate|validation|contract|smoke|evidence|audit)\b/.test(lower)) { + return 'qa.adf'; + } + if (/\b(auth|authentication|authorization|security|secret|token|permission|cors|rate.limit|jwt|oauth|clerk|nextauth|lucia|session|cookie|csrf|xss|password|bcrypt)\b/.test(lower)) { + return 'security.adf'; + } + if (/\b(deploy|deployment|infrastructure|infra|ci|cd|pipeline|config|configuration|environment|env|docker|wrangler|cloudflare|vercel|netlify|railway|fly|render|github.actions|kv|d1|r2|queue|durable.object)\b/.test(lower)) { + return 'infra.adf'; + } + if (/\b(api|backend|server|database|db|endpoint|query|migration|handler|prisma|drizzle|mongoose|postgres|postgresql|mysql|sqlite|express|fastify|hono|trpc|zod|graphql)\b/.test(lower)) { + return 'backend.adf'; + } + return 'core.adf'; +} + +function scoreItemAgainstTriggers(text: string, triggerMap: TriggerMap): Pick { + const lower = text.toLowerCase(); + let matchedTriggers: string[] = []; + let matchScore = 0; + + for (const triggers of Object.values(triggerMap)) { + const currentMatches = triggers.filter(trigger => + new RegExp(`\\b${escapeRegex(trigger)}(?:s|ed|ing|ment|tion|ity|ication)?\\b`, 'i').test(lower), + ); + if (currentMatches.length > matchScore) { + matchedTriggers = currentMatches; + matchScore = currentMatches.length; + } + } + + return { matchedTriggers, matchScore }; +} + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + // ============================================================================ // Ollama Scenario Runner (exploratory — no expected routing) // ============================================================================ diff --git a/harness/types.ts b/harness/types.ts index f46c1ad..3eb2cdc 100644 --- a/harness/types.ts +++ b/harness/types.ts @@ -87,9 +87,37 @@ export interface ScenarioResult { archetype: string; description: string; sessions: SessionResult[]; + staticAudit?: StaticScenarioAudit; pass: boolean; } +export interface StaticSessionAudit { + sessionLabel: string; + dryRunExtracted: number; + appliedModulesModified: string[]; + claudeRestored: boolean; + adfTotalItems: number; + modulesGrew: string[]; + itemRoutes: StaticItemRoute[]; +} + +export interface StaticScenarioAudit { + sessions: StaticSessionAudit[]; + accumulationIssues: string[]; +} + +export interface StaticItemRoute { + heading: string; + content: string; + headingModule: string; + targetModule: string; + targetSection: string; + decision: 'STAY' | 'MIGRATE'; + reason: string; + matchedTriggers: string[]; + matchScore: number; +} + // ============================================================================ // Run Report // ============================================================================ diff --git a/packages/adf/src/__tests__/content-classifier.test.ts b/packages/adf/src/__tests__/content-classifier.test.ts index 5946173..164b122 100644 --- a/packages/adf/src/__tests__/content-classifier.test.ts +++ b/packages/adf/src/__tests__/content-classifier.test.ts @@ -28,6 +28,11 @@ describe('classifyElement', () => { expect(result.targetModule).toBe('backend.adf'); }); + it('routes verification headings to qa.adf', () => { + const result = classifyElement(rule('Run contract tests before release'), 'Verification'); + expect(result.targetModule).toBe('qa.adf'); + }); + it('routes to core.adf for generic headings', () => { const result = classifyElement(rule('Use conventional commits'), 'Conventions'); expect(result.targetModule).toBe('core.adf'); @@ -55,6 +60,19 @@ describe('classifyElement', () => { expect(result.targetModule).toBe('frontend.adf'); }); + it('chooses the module with the strongest trigger match instead of first match', () => { + const qaTriggerMap: TriggerMap = { + 'infra.adf': ['ci', 'pipeline', 'artifact'], + 'qa.adf': ['test', 'playwright', 'evidence', 'auditability'], + }; + const result = classifyElement( + rule('Playwright test evidence is uploaded from the CI pipeline for auditability'), + 'Checklist', + qaTriggerMap, + ); + expect(result.targetModule).toBe('qa.adf'); + }); + it('stays on core.adf when no trigger keyword matches', () => { const result = classifyElement(rule('Use conventional commits'), 'Conventions', triggerMap); expect(result.targetModule).toBe('core.adf'); diff --git a/packages/adf/src/content-classifier.ts b/packages/adf/src/content-classifier.ts index 423f44e..0500eac 100644 --- a/packages/adf/src/content-classifier.ts +++ b/packages/adf/src/content-classifier.ts @@ -95,6 +95,9 @@ function headingToModule(heading: string, routes?: ClassifierConfig['headingRout if (/\b(design.system|ui|frontend|css|component|react|vue|svelte|next|nextjs|tailwind|shadcn|radix|storybook|vite|vitest|playwright|remix|nuxt|astro)\b/.test(lower)) { return 'frontend.adf'; } + if (/\b(qa|quality|test|testing|verification|validate|validation|contract|smoke|evidence|audit)\b/.test(lower)) { + return 'qa.adf'; + } if (/\b(auth|authentication|authorization|security|secret|token|permission|cors|rate.limit|jwt|oauth|clerk|nextauth|lucia|session|cookie|csrf|xss|password|bcrypt)\b/.test(lower)) { return 'security.adf'; } @@ -117,17 +120,31 @@ function escapeRegex(str: string): string { */ function contentToModule(text: string, triggerMap: TriggerMap): string { const lower = text.toLowerCase(); + let bestModule = 'core.adf'; + let bestScore = 0; + let bestSpecificity = 0; + for (const [module, triggers] of Object.entries(triggerMap)) { + let score = 0; + let specificity = 0; + for (const trigger of triggers) { // Match whole words or common suffixes (s, ed, ing, ment, tion, ity, ication). // This allows "token" → "tokens", "deploy" → "deploying"/"deployment", // "auth" → "authentication" — while blocking "author", "apiary", "authority". if (new RegExp(`\\b${escapeRegex(trigger)}(?:s|ed|ing|ment|tion|ity|ication)?\\b`, 'i').test(lower)) { - return module; + score++; + specificity = Math.max(specificity, trigger.length); } } + + if (score > bestScore || (score === bestScore && specificity > bestSpecificity)) { + bestModule = module; + bestScore = score; + bestSpecificity = specificity; + } } - return 'core.adf'; + return bestModule; } // ============================================================================ diff --git a/packages/adf/src/patcher.ts b/packages/adf/src/patcher.ts index 66a1396..5245b38 100644 --- a/packages/adf/src/patcher.ts +++ b/packages/adf/src/patcher.ts @@ -37,8 +37,16 @@ const handlers: Record Ad }; function applyOne(doc: AdfDocument, op: PatchOperation): AdfDocument { + const handler = handlers[op.op]; + if (!handler) { + const valid = Object.keys(handlers).join(', '); + throw new AdfPatchError( + `Unknown patch op: '${op.op}'. Valid ops: ${valid}`, + String(op.op) + ); + } // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (handlers[op.op] as any)(doc, op); + return (handler as any)(doc, op); } // ============================================================================ diff --git a/packages/cli/src/commands/adf-populate.ts b/packages/cli/src/commands/adf-populate.ts new file mode 100644 index 0000000..960f2ec --- /dev/null +++ b/packages/cli/src/commands/adf-populate.ts @@ -0,0 +1,390 @@ +/** + * charter adf populate + * + * Auto-fills ADF context files from codebase signals: + * package.json, README.md, and stack detection. + * + * Replaces scaffold placeholder content with project-specific context. + * Skips sections that have already been customized (unless --force). + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { parseAdf, formatAdf, applyPatches } from '@stackbilt/adf'; +import type { AdfSection, PatchOperation } from '@stackbilt/adf'; +import type { CLIOptions } from '../index'; +import { CLIError, EXIT_CODE } from '../index'; +import { getFlag } from '../flags'; +import { + loadPackageContexts, + detectStack, + inferProjectName, + type PackageContext, + type DetectionResult, +} from './setup'; + +// ============================================================================ +// Scaffold markers — used to detect un-authored placeholder content +// ============================================================================ + +const SCAFFOLD_MARKERS = [ + 'Frontend module scaffold', + 'Backend module scaffold', + 'Module scaffold', + 'Add framework-specific constraints', + 'Add service/API/database constraints', + 'Add project-specific rules', + "run 'charter adf populate'", + 'Project context (run', + 'Repository initialized with ADF context system', + 'Configure on-demand modules for your stack', +]; + +function hasScaffoldContent(section: AdfSection): boolean { + const text = sectionText(section); + return SCAFFOLD_MARKERS.some(m => text.includes(m)); +} + +function sectionText(section: AdfSection): string { + switch (section.content.type) { + case 'list': return section.content.items.join('\n'); + case 'text': return section.content.value; + case 'map': return section.content.entries.map(e => `${e.key}: ${e.value}`).join('\n'); + default: return ''; + } +} + +// ============================================================================ +// Command Entry +// ============================================================================ + +export async function adfPopulateCommand(options: CLIOptions, args: string[]): Promise { + const dryRun = args.includes('--dry-run'); + const force = options.yes || args.includes('--force'); + const aiDir = getFlag(args, '--ai-dir') || '.ai'; + const manifestPath = path.join(aiDir, 'manifest.adf'); + + if (!fs.existsSync(manifestPath)) { + throw new CLIError(`manifest.adf not found at ${manifestPath}. Run 'charter adf init' first.`); + } + + // Gather codebase signals + const contexts = loadPackageContexts(); + const detection = detectStack(contexts); + const projectName = inferProjectName(contexts) || path.basename(process.cwd()); + const rootPkg = readRootPackageJson(); + const description = rootPkg?.description; + const readmeSummary = readReadmeSummary(); + + const results: Array<{ file: string; ops: number; status: 'populated' | 'skipped' | 'missing' }> = []; + + const fileTasks: Array<{ file: string; build: () => PatchOperation[] | null }> = [ + { + file: path.join(aiDir, 'core.adf'), + build: () => buildCoreOps(aiDir, projectName, description, readmeSummary, detection, contexts, force), + }, + { + file: path.join(aiDir, 'state.adf'), + build: () => buildStateOps(aiDir, detection, force), + }, + { + file: path.join(aiDir, 'backend.adf'), + build: () => buildBackendOps(aiDir, detection, force), + }, + { + file: path.join(aiDir, 'frontend.adf'), + build: () => buildFrontendOps(aiDir, detection, force), + }, + ]; + + for (const task of fileTasks) { + if (!fs.existsSync(task.file)) { + results.push({ file: task.file, ops: 0, status: 'missing' }); + continue; + } + + const ops = task.build(); + if (!ops || ops.length === 0) { + results.push({ file: task.file, ops: 0, status: 'skipped' }); + continue; + } + + if (!dryRun) { + const input = fs.readFileSync(task.file, 'utf-8'); + const doc = parseAdf(input); + const patched = applyPatches(doc, ops); + fs.writeFileSync(task.file, formatAdf(patched)); + } + + results.push({ file: task.file, ops: ops.length, status: 'populated' }); + } + + if (options.format === 'json') { + console.log(JSON.stringify({ + dryRun, + projectName, + detection: { + preset: detection.suggestedPreset, + confidence: detection.confidence, + runtime: detection.runtime, + frameworks: detection.frameworks, + }, + results, + }, null, 2)); + } else { + const prefix = dryRun ? '[dry-run] ' : ''; + console.log(` ${prefix}ADF context populated from codebase signals:`); + console.log(` Project: ${projectName}${description ? ' — ' + description : ''}`); + console.log(` Stack: ${detection.suggestedPreset} (${detection.confidence} confidence)`); + if (detection.frameworks.length > 0) { + console.log(` Frameworks: ${detection.frameworks.join(', ')}`); + } + console.log(''); + for (const r of results) { + if (r.status === 'missing') continue; + const icon = r.status === 'populated' ? '[ok]' : '[skip]'; + const detail = r.status === 'populated' + ? `${r.ops} op${r.ops === 1 ? '' : 's'} applied` + : 'already customized — use --force to overwrite'; + console.log(` ${icon} ${r.file} (${detail})`); + } + if (dryRun) { + console.log(''); + console.log(' Run without --dry-run to apply.'); + } + } + + return EXIT_CODE.SUCCESS; +} + +// ============================================================================ +// core.adf ops +// ============================================================================ + +function buildCoreOps( + aiDir: string, + projectName: string, + description: string | undefined, + readmeSummary: string | undefined, + detection: DetectionResult, + contexts: PackageContext[], + force: boolean +): PatchOperation[] | null { + const filePath = path.join(aiDir, 'core.adf'); + const input = fs.readFileSync(filePath, 'utf-8'); + const doc = parseAdf(input); + const ops: PatchOperation[] = []; + + // Build CONTEXT items from signals + const contextItems: string[] = [ + `project: ${projectName}${description ? ' — ' + description : ''}`, + ]; + if (readmeSummary) contextItems.push(readmeSummary); + if (detection.runtime.length > 0) contextItems.push(`runtime: ${detection.runtime.join(', ')}`); + if (detection.frameworks.length > 0) contextItems.push(`stack: ${detection.frameworks.join(', ')}`); + if (detection.monorepo) contextItems.push('monorepo: true'); + + const contextSection = doc.sections.find(s => s.key === 'CONTEXT'); + if (!contextSection) { + ops.push({ + op: 'ADD_SECTION', + key: 'CONTEXT', + decoration: '\u{1F4CB}', + content: { type: 'list', items: contextItems }, + }); + } else if (force || hasScaffoldContent(contextSection)) { + ops.push({ + op: 'REPLACE_SECTION', + key: 'CONTEXT', + content: { type: 'list', items: contextItems }, + }); + } + + // Add stack-specific constraints (additive, never overwrite existing) + const constraintsSection = doc.sections.find(s => s.key === 'CONSTRAINTS'); + if (constraintsSection && constraintsSection.content.type === 'list') { + const existingItems = constraintsSection.content.items; + + const addConstraint = (value: string, matchFn: (item: string) => boolean) => { + if (!existingItems.some(matchFn)) { + ops.push({ op: 'ADD_BULLET', section: 'CONSTRAINTS', value }); + } + }; + + // ESM: detect type: "module" in any package.json + const isEsm = contexts.some(ctx => { + try { + const pkg = JSON.parse(fs.readFileSync(ctx.source, 'utf-8')); + return pkg.type === 'module'; + } catch { return false; } + }); + if (isEsm) { + addConstraint( + 'Use .js extensions for all ESM imports (never .ts in import paths)', + item => item.includes('.js extensions') || (item.includes('ESM') && item.includes('import')) + ); + } + + if (detection.signals.hasCloudflare) { + addConstraint( + 'No Node.js-specific APIs in Worker handlers; use CF-native APIs (fetch, KV, D1, R2)', + item => item.includes('Worker handler') || (item.includes('Node') && item.includes('CF')) + ); + } + + if (detection.signals.hasPnpm && detection.monorepo) { + addConstraint( + 'Internal packages use pnpm workspace:^ protocol, never relative paths', + item => item.includes('workspace') || (item.includes('pnpm') && item.includes('package')) + ); + } + } + + return ops.length > 0 ? ops : null; +} + +// ============================================================================ +// state.adf ops +// ============================================================================ + +function buildStateOps( + aiDir: string, + detection: DetectionResult, + force: boolean +): PatchOperation[] | null { + const filePath = path.join(aiDir, 'state.adf'); + const input = fs.readFileSync(filePath, 'utf-8'); + const doc = parseAdf(input); + + const stateSection = doc.sections.find(s => s.key === 'STATE'); + if (!stateSection) return null; + if (!force && !hasScaffoldContent(stateSection)) return null; + + const stackSummary = [ + ...detection.runtime, + ...detection.frameworks, + ].join(', ') || detection.suggestedPreset; + + return [{ + op: 'REPLACE_SECTION', + key: 'STATE', + content: { + type: 'map', + entries: [ + { key: 'CURRENT', value: `Charter initialized — ${stackSummary} project` }, + { key: 'NEXT', value: 'Author project-specific constraints in core.adf' }, + ], + }, + }]; +} + +// ============================================================================ +// backend.adf ops +// ============================================================================ + +function buildBackendOps( + aiDir: string, + detection: DetectionResult, + force: boolean +): PatchOperation[] | null { + const filePath = path.join(aiDir, 'backend.adf'); + if (!fs.existsSync(filePath)) return null; + + const input = fs.readFileSync(filePath, 'utf-8'); + const doc = parseAdf(input); + + const contextSection = doc.sections.find(s => s.key === 'CONTEXT'); + if (contextSection && !force && !hasScaffoldContent(contextSection)) return null; + + const items: string[] = []; + if (detection.signals.hasWorker || detection.signals.hasCloudflare) { + items.push('Cloudflare Workers edge runtime (wrangler deploy)'); + } + if (detection.signals.hasHono) { + items.push('Hono for route composition — typed, lightweight, edge-compatible'); + } + if (!detection.signals.hasWorker && detection.signals.hasBackend) { + items.push('Node.js backend service with typed request boundaries'); + } + if (items.length === 0) { + items.push('Backend module — add service/API/database constraints and rules'); + } + + const op: PatchOperation = contextSection + ? { op: 'REPLACE_SECTION', key: 'CONTEXT', content: { type: 'list', items } } + : { op: 'ADD_SECTION', key: 'CONTEXT', decoration: '\u{1F4CB}', content: { type: 'list', items } }; + + return [op]; +} + +// ============================================================================ +// frontend.adf ops +// ============================================================================ + +function buildFrontendOps( + aiDir: string, + detection: DetectionResult, + force: boolean +): PatchOperation[] | null { + const filePath = path.join(aiDir, 'frontend.adf'); + if (!fs.existsSync(filePath)) return null; + + const input = fs.readFileSync(filePath, 'utf-8'); + const doc = parseAdf(input); + + const contextSection = doc.sections.find(s => s.key === 'CONTEXT'); + if (contextSection && !force && !hasScaffoldContent(contextSection)) return null; + + const items: string[] = []; + if (detection.signals.hasReact) items.push('React component model (hooks-based, no class components)'); + if (detection.signals.hasVite) items.push('Vite for build tooling and dev server'); + if (items.length === 0) { + items.push('Frontend module — add framework-specific constraints and rules'); + } + + const op: PatchOperation = contextSection + ? { op: 'REPLACE_SECTION', key: 'CONTEXT', content: { type: 'list', items } } + : { op: 'ADD_SECTION', key: 'CONTEXT', decoration: '\u{1F4CB}', content: { type: 'list', items } }; + + return [op]; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +function readRootPackageJson(): { name?: string; description?: string; type?: string } | null { + try { + return JSON.parse(fs.readFileSync(path.resolve('package.json'), 'utf-8')); + } catch { + return null; + } +} + +function readReadmeSummary(): string | undefined { + for (const name of ['README.md', 'readme.md', 'Readme.md']) { + try { + const content = fs.readFileSync(path.resolve(name), 'utf-8'); + const lines = content.split('\n'); + let inParagraph = false; + const paragraphLines: string[] = []; + + for (const line of lines) { + if (line.startsWith('#')) continue; + if (line.trim() === '') { + if (inParagraph) break; + continue; + } + inParagraph = true; + paragraphLines.push(line.trim()); + if (paragraphLines.length >= 2) break; + } + + if (paragraphLines.length > 0) { + const summary = paragraphLines.join(' '); + return summary.length > 120 ? summary.slice(0, 117) + '...' : summary; + } + } catch { /* file not found */ } + } + return undefined; +} diff --git a/packages/cli/src/commands/adf.ts b/packages/cli/src/commands/adf.ts index ff06b19..ed398df 100644 --- a/packages/cli/src/commands/adf.ts +++ b/packages/cli/src/commands/adf.ts @@ -22,6 +22,7 @@ import { adfSync } from './adf-sync'; import { adfEvidence } from './adf-evidence'; import { adfMetricsCommand } from './adf-metrics'; import { adfTidyCommand } from './adf-tidy'; +import { adfPopulateCommand } from './adf-populate'; // ============================================================================ // Scaffold Content @@ -45,6 +46,9 @@ export const MANIFEST_SCAFFOLD = `ADF: 0.1 export const CORE_SCAFFOLD = `ADF: 0.1 +\u{1F4CB} CONTEXT: + - Project context (run 'charter adf populate' to auto-fill from codebase) + \u{1F4D6} GUIDE [advisory]: - Pure runtime/environment? (OS, line endings) \u2192 CLAUDE.md, not ADF - Universal architecture constraint? \u2192 core.adf CONSTRAINTS [load-bearing] @@ -160,8 +164,10 @@ export async function adfCommand(options: CLIOptions, args: string[]): Promise"', ], @@ -319,8 +325,8 @@ function adfInit(options: CLIOptions, args: string[]): number { } console.log(''); console.log(' Next steps:'); - console.log(' 1. Edit core.adf with your universal repo rules'); - console.log(' 2. Edit frontend.adf/backend.adf stubs or replace with domain modules'); + console.log(' 1. Run: charter adf populate # auto-fill ADF files from codebase signals'); + console.log(' 2. Edit core.adf to add project-specific constraints and rules'); console.log(' 3. Run: charter adf fmt .ai/core.adf --check'); console.log(' 4. Run: charter adf bundle --task "" to compile context for an agent session'); console.log(' (The verify:adf script runs this automatically in CI)'); @@ -585,7 +591,17 @@ function printHelp(): void { console.log(''); console.log(' charter adf patch --ops | --ops-file '); console.log(' Apply ADF_PATCH operations to a file.'); + console.log(' Valid ops: ADD_BULLET, REPLACE_BULLET, REMOVE_BULLET,'); + console.log(' ADD_SECTION, REPLACE_SECTION, REMOVE_SECTION, UPDATE_METRIC'); + console.log(' Examples:'); + console.log(' ADD_BULLET: {"op":"ADD_BULLET","section":"CONSTRAINTS","value":"..."}'); + console.log(' ADD_SECTION: {"op":"ADD_SECTION","key":"CONTEXT","decoration":"📋","content":{"type":"list","items":["..."]}}'); + console.log(' REPLACE_SECTION: {"op":"REPLACE_SECTION","key":"STATE","content":{"type":"map","entries":[{"key":"CURRENT","value":"..."}]}}'); console.log(''); + console.log(' charter adf populate [--ai-dir ] [--dry-run] [--force]'); + console.log(' Auto-fill ADF files from codebase signals (package.json, README, stack detection).'); + console.log(' Populates CONTEXT in core/backend/frontend.adf and STATE in state.adf.'); + console.log(' Skips files with existing custom content unless --force.'); console.log(' charter adf create [--ai-dir ] [--triggers "a,b,c"] [--load default|on-demand] [--force]'); console.log(' Create a module file and register it in manifest DEFAULT_LOAD or ON_DEMAND.'); console.log(' --triggers: comma-separated trigger keywords (for ON_DEMAND entries).'); diff --git a/packages/cli/src/commands/bootstrap.ts b/packages/cli/src/commands/bootstrap.ts index e4bbb16..6691932 100644 --- a/packages/cli/src/commands/bootstrap.ts +++ b/packages/cli/src/commands/bootstrap.ts @@ -237,9 +237,9 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro reason: 'Customize blessed stack patterns', }); result.nextSteps.push({ - cmd: 'Add project-specific rules to .ai/core.adf', + cmd: 'charter adf populate # auto-fill ADF files from codebase signals', required: false, - reason: 'Add project-specific ADF rules', + reason: 'Populate ADF context from package.json, README, and stack detection', }); result.nextSteps.push({ cmd: 'git add .charter .ai CLAUDE.md .cursorrules agents.md && git commit -m "chore: bootstrap charter governance"', From 12f35cfe030898f6c2805b0431f3ef8aa2025091 Mon Sep 17 00:00:00 2001 From: Kevin Overmier Date: Thu, 5 Mar 2026 17:38:23 -0600 Subject: [PATCH 3/5] feat(classifier): QA phrase override, routing trace, tidy --verbose (#44 #45 #46) - QA compound phrases (smoke test, contract test, schema compat, approval gate, verified against, test fixtures) now fire a phrase-level override BEFORE keyword scoring so they cannot be outvoted by raw infra/backend keyword count (#44, #45) - contentToModule() returns { module, phraseOverride?, scores } so all per-module candidate scores are visible to callers (#46) - ClassificationResult gains optional routingTrace?: RoutingTrace with headingModule, phraseOverride, and candidateScores for debugging - adf tidy --verbose prints per-item routing rationale (module, section, trigger scores or phrase override) (#46) - 9 new tests covering phrase override routing, guard against absent qa.adf in triggerMap, and routingTrace shape (#44 #45 #46) Co-Authored-By: Claude Opus 4.6 --- .../src/__tests__/content-classifier.test.ts | 120 ++++++++++++++++++ packages/adf/src/content-classifier.ts | 78 +++++++++++- packages/adf/src/index.ts | 1 + packages/cli/src/commands/adf-tidy.ts | 49 ++++++- 4 files changed, 243 insertions(+), 5 deletions(-) diff --git a/packages/adf/src/__tests__/content-classifier.test.ts b/packages/adf/src/__tests__/content-classifier.test.ts index 164b122..e5916f7 100644 --- a/packages/adf/src/__tests__/content-classifier.test.ts +++ b/packages/adf/src/__tests__/content-classifier.test.ts @@ -155,3 +155,123 @@ describe('buildMigrationPlan', () => { expect(plan.migrateItems[0].classification.targetModule).toBe('core.adf'); }); }); + +// ============================================================================ +// QA phrase override routing (#44, #45) +// ============================================================================ + +describe('QA phrase override routing', () => { + const mixedTriggerMap: TriggerMap = { + 'infra.adf': ['ci', 'pipeline', 'artifact', 'deploy'], + 'backend.adf': ['api', 'database', 'migration'], + 'qa.adf': ['test', 'smoke', 'contract', 'evidence'], + }; + + it('routes "smoke test" bullet to qa.adf even when infra keywords dominate (#44)', () => { + // "ci pipeline artifact" would win on raw keyword count vs single "smoke test" + const result = classifyElement( + rule('Run smoke tests against the CI pipeline artifact before promoting'), + 'Checklist', + mixedTriggerMap, + ); + expect(result.targetModule).toBe('qa.adf'); + expect(result.routingTrace?.phraseOverride).toBe('qa.adf'); + }); + + it('routes "contract test" bullet to qa.adf even when backend keywords coexist (#45)', () => { + // "api", "database", "migration" would score 3 for backend vs 1 "contract" for qa + const result = classifyElement( + rule('Run contract tests for all API and database migration endpoints'), + 'Release', + mixedTriggerMap, + ); + expect(result.targetModule).toBe('qa.adf'); + expect(result.routingTrace?.phraseOverride).toBe('qa.adf'); + }); + + it('routes "schema compat" bullet to qa.adf', () => { + const result = classifyElement( + rule('Verify schema compat before every deploy'), + 'Checklist', + mixedTriggerMap, + ); + expect(result.targetModule).toBe('qa.adf'); + }); + + it('routes "approval gate" bullet to qa.adf', () => { + const result = classifyElement( + rule('All deploys must pass the approval gate'), + 'Checklist', + mixedTriggerMap, + ); + expect(result.targetModule).toBe('qa.adf'); + }); + + it('does NOT fire phrase override when qa.adf is absent from triggerMap', () => { + // Without qa.adf in the map, phrase override cannot fire — falls through to keyword scoring + const infraOnly: TriggerMap = { + 'infra.adf': ['ci', 'pipeline', 'artifact'], + }; + const result = classifyElement( + rule('Run smoke tests against the CI pipeline artifact'), + 'Checklist', + infraOnly, + ); + // Falls back to keyword scoring: ci+pipeline+artifact → infra.adf wins + expect(result.targetModule).toBe('infra.adf'); + expect(result.routingTrace?.phraseOverride).toBeUndefined(); + }); +}); + +// ============================================================================ +// Routing trace observability (#46) +// ============================================================================ + +describe('routing trace (#46)', () => { + const traceMap: TriggerMap = { + 'frontend.adf': ['react', 'css', 'ui'], + 'backend.adf': ['api', 'node', 'db'], + 'qa.adf': ['test', 'smoke', 'contract'], + }; + + it('attaches routingTrace when content-based routing fires', () => { + const result = classifyElement( + rule('React components use PascalCase'), + 'Conventions', + traceMap, + ); + expect(result.routingTrace).toBeDefined(); + expect(result.routingTrace?.headingModule).toBe('core.adf'); + expect(result.routingTrace?.candidateScores).toBeDefined(); + expect(result.routingTrace?.candidateScores['frontend.adf']).toBeGreaterThan(0); + }); + + it('populates phraseOverride when a QA phrase pattern matches', () => { + const result = classifyElement( + rule('Run smoke tests against staging before release'), + 'Checklist', + traceMap, + ); + expect(result.routingTrace?.phraseOverride).toBe('qa.adf'); + expect(result.routingTrace?.candidateScores['qa.adf']).toBe(Infinity); + }); + + it('candidateScores contains an entry for every module in the triggerMap', () => { + const result = classifyElement( + rule('Use React for all UI components'), + 'General', + traceMap, + ); + expect(result.routingTrace).toBeDefined(); + const scores = result.routingTrace!.candidateScores; + expect('frontend.adf' in scores).toBe(true); + expect('backend.adf' in scores).toBe(true); + expect('qa.adf' in scores).toBe(true); + }); + + it('does not attach routingTrace when heading-based routing resolves without fallback', () => { + // Heading "UI Components" resolves to frontend.adf directly — no content fallback runs + const result = classifyElement(rule('Use PascalCase'), 'UI Components', traceMap); + expect(result.routingTrace).toBeUndefined(); + }); +}); diff --git a/packages/adf/src/content-classifier.ts b/packages/adf/src/content-classifier.ts index 0500eac..5564101 100644 --- a/packages/adf/src/content-classifier.ts +++ b/packages/adf/src/content-classifier.ts @@ -27,12 +27,23 @@ export interface ClassifierConfig { headingRoutes?: Array<{ pattern: RegExp; module: string }>; } +export interface RoutingTrace { + /** What headingToModule() returned before content-based fallback. */ + headingModule: string; + /** Set when a phrase-level QA override fired instead of keyword scoring. */ + phraseOverride?: string; + /** Per-module keyword match score from the triggerMap scoring pass. */ + candidateScores: Record; +} + export interface ClassificationResult { decision: RouteDecision; targetSection: AdfTargetSection; targetModule: string; weight: WeightTag; reason: string; + /** Populated when content-based routing ran. Opt-in observability for #46. */ + routingTrace?: RoutingTrace; } export interface MigrationItem { @@ -114,12 +125,53 @@ function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } +/** + * Phrase-level QA patterns that signal verification/testing intent even when + * infra or backend keywords coexist in the same bullet (fixes #44, #45). + * + * These fire BEFORE trigger-keyword scoring and route to qa.adf when that + * module is present in the manifest's triggerMap. + */ +const QA_PHRASE_PATTERNS: RegExp[] = [ + /\bsmoke[\s-]tests?\b/i, + /\bcontract[\s-]tests?\b/i, + /\bschema[\s-]compat/i, + /\bapproval[\s-]gat/i, // "approval gate", "approval gating" + /\bverified\s+against\b/i, + /\btest[\s-]fixtures?\b/i, +]; + +/** + * Return the override module if strong QA phrase patterns are detected, else null. + * Callers must confirm the override module exists in the triggerMap before using it. + */ +function contentRouteOverride(text: string): string | null { + if (QA_PHRASE_PATTERNS.some(p => p.test(text))) return 'qa.adf'; + return null; +} + /** * Content-based fallback routing. When heading-based routing returns core.adf, * scan element content against ON_DEMAND trigger keywords from the manifest. + * + * Returns the winning module plus a score map and optional phrase-override for + * routing observability (#46). */ -function contentToModule(text: string, triggerMap: TriggerMap): string { +function contentToModule( + text: string, + triggerMap: TriggerMap, +): { module: string; phraseOverride?: string; scores: Record } { const lower = text.toLowerCase(); + const scores: Record = {}; + + // Phrase-level override: high-signal compound patterns beat keyword counting. + // Only fires when the override module is actually in the manifest triggerMap. + const override = contentRouteOverride(text); + if (override && override in triggerMap) { + scores[override] = Infinity; // sentinel — phrase match beats all trigger scores + return { module: override, phraseOverride: override, scores }; + } + let bestModule = 'core.adf'; let bestScore = 0; let bestSpecificity = 0; @@ -138,13 +190,16 @@ function contentToModule(text: string, triggerMap: TriggerMap): string { } } + scores[module] = score; + if (score > bestScore || (score === bestScore && specificity > bestSpecificity)) { bestModule = module; bestScore = score; bestSpecificity = specificity; } } - return bestModule; + + return { module: bestModule, scores }; } // ============================================================================ @@ -161,12 +216,16 @@ export function classifyElement( config?: ClassifierConfig, ): ClassificationResult { const text = element.content; - let module = headingToModule(heading, config?.headingRoutes); + const headingModule = headingToModule(heading, config?.headingRoutes); + let module = headingModule; + let routingTrace: RoutingTrace | undefined; // Content-based fallback: when heading routes to core.adf, check element // content against ON_DEMAND trigger keywords from the manifest. if (module === 'core.adf' && triggerMap) { - module = contentToModule(text, triggerMap); + const { module: contentModule, phraseOverride, scores } = contentToModule(text, triggerMap); + module = contentModule; + routingTrace = { headingModule, phraseOverride, candidateScores: scores }; } // Check STAY patterns first @@ -177,6 +236,7 @@ export function classifyElement( targetModule: module, weight: 'advisory', reason: 'Environment/runtime-specific (STAY in vendor file)', + routingTrace, }; } @@ -190,6 +250,7 @@ export function classifyElement( targetModule: module, weight: 'load-bearing', reason: 'Imperative rule (NEVER/ALWAYS/MUST)', + routingTrace, }; } if (element.strength === 'advisory') { @@ -199,6 +260,7 @@ export function classifyElement( targetModule: module, weight: 'advisory', reason: 'Advisory rule (prefer/should/bias)', + routingTrace, }; } // Neutral rules — check heading context for more signal @@ -209,6 +271,7 @@ export function classifyElement( targetModule: module, weight: 'advisory', reason: 'Naming/style convention', + routingTrace, }; } if (/\b(git|commit|workflow|hook)\b/i.test(heading)) { @@ -218,6 +281,7 @@ export function classifyElement( targetModule: module, weight: 'load-bearing', reason: 'Git workflow rule', + routingTrace, }; } // Default neutral rule → CONSTRAINTS advisory @@ -227,6 +291,7 @@ export function classifyElement( targetModule: module, weight: 'advisory', reason: 'Rule (neutral strength)', + routingTrace, }; } @@ -239,6 +304,7 @@ export function classifyElement( reason: element.language === 'bash' || element.language === 'sh' ? 'Build/tool commands' : 'Code reference', + routingTrace, }; } @@ -249,6 +315,7 @@ export function classifyElement( targetModule: module, weight: 'advisory', reason: 'Tabular reference data', + routingTrace, }; } @@ -261,6 +328,7 @@ export function classifyElement( targetModule: module, weight: 'advisory', reason: 'Architecture description', + routingTrace, }; } // Directory/config descriptions → CONTEXT @@ -271,6 +339,7 @@ export function classifyElement( targetModule: module, weight: 'advisory', reason: 'Configuration/structure description', + routingTrace, }; } // Default prose → CONTEXT (never silently dropped) @@ -280,6 +349,7 @@ export function classifyElement( targetModule: module, weight: 'advisory', reason: 'Informational context', + routingTrace, }; } } diff --git a/packages/adf/src/index.ts b/packages/adf/src/index.ts index 2856de6..79e287c 100644 --- a/packages/adf/src/index.ts +++ b/packages/adf/src/index.ts @@ -10,6 +10,7 @@ export type { MarkdownSection, MarkdownElement, RuleStrength, StrengthConfig } f export { classifyElement, isDuplicateItem, buildMigrationPlan } from './content-classifier'; export type { ClassificationResult, + RoutingTrace, MigrationItem, MigrationPlan, RouteDecision, diff --git a/packages/cli/src/commands/adf-tidy.ts b/packages/cli/src/commands/adf-tidy.ts index 3ef3f76..d12e53b 100644 --- a/packages/cli/src/commands/adf-tidy.ts +++ b/packages/cli/src/commands/adf-tidy.ts @@ -67,12 +67,23 @@ interface ModuleSizeWarning { itemCount: number; } +interface ItemRoute { + item: string; + targetModule: string; + targetSection: string; + headingModule: string; + phraseOverride?: string; + candidateScores: Record; +} + interface TidyResult { dryRun: boolean; files: TidyFileResult[]; totalExtracted: number; modulesModified: string[]; moduleWarnings: ModuleSizeWarning[]; + /** Populated when --verbose: per-item routing trace for each migrated item. */ + itemRoutes?: ItemRoute[]; } // ============================================================================ @@ -82,6 +93,7 @@ interface TidyResult { export async function adfTidyCommand(options: CLIOptions, args: string[]): Promise { const dryRun = args.includes('--dry-run'); const ciMode = args.includes('--ci'); + const verbose = args.includes('--verbose'); const sourceFile = getFlag(args, '--source'); const aiDir = getFlag(args, '--ai-dir') || '.ai'; @@ -162,7 +174,28 @@ export async function adfTidyCommand(options: CLIOptions, args: string[]): Promi ? projectModuleWarnings(aiDir, allModuleGroups) : scanModuleWarnings(aiDir, modulesModified); - const result: TidyResult = { dryRun, files: fileResults, totalExtracted, modulesModified, moduleWarnings }; + // Collect per-item routing trace when --verbose (or always in JSON mode) + let itemRoutes: ItemRoute[] | undefined; + if (verbose || options.format === 'json') { + itemRoutes = []; + for (const [, sectionGroups] of Object.entries(allModuleGroups)) { + for (const [, items] of Object.entries(sectionGroups)) { + for (const item of items) { + const trace = item.classification.routingTrace; + itemRoutes.push({ + item: item.element.content.slice(0, 100), + targetModule: item.classification.targetModule, + targetSection: item.classification.targetSection, + headingModule: trace?.headingModule ?? item.classification.targetModule, + phraseOverride: trace?.phraseOverride, + candidateScores: trace?.candidateScores ?? {}, + }); + } + } + } + } + + const result: TidyResult = { dryRun, files: fileResults, totalExtracted, modulesModified, moduleWarnings, itemRoutes }; // Output if (options.format === 'json') { @@ -606,6 +639,20 @@ function printTextResult(result: TidyResult): void { console.log(` ${clean.length} file(s) already clean.`); } + // Verbose: per-item routing rationale + if (result.itemRoutes && result.itemRoutes.length > 0) { + console.log(''); + console.log(' Routing trace:'); + for (const r of result.itemRoutes) { + const preview = r.item.length > 60 ? r.item.slice(0, 57) + '...' : r.item; + const via = r.phraseOverride + ? `phrase override (${r.phraseOverride})` + : `scores: ${Object.entries(r.candidateScores).map(([m, s]) => `${m}=${s}`).join(', ')}`; + console.log(` "${preview}"`); + console.log(` → ${r.targetModule} / ${r.targetSection} [${via}]`); + } + } + if (result.moduleWarnings.length > 0) { console.log(''); console.log(' ⚠ Module size warnings:'); From 96e91d92673a1e8bbe00287e64be077395783ad3 Mon Sep 17 00:00:00 2001 From: Kevin Overmier Date: Thu, 5 Mar 2026 17:43:14 -0600 Subject: [PATCH 4/5] feat(cold-start): migrate --keep-summary, migrate --audit, doctor sparse-pointer warn (#39 #40 #41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - adf migrate --keep-summary: injects auto-generated "## Architecture Summary" block listing migrated module names and section counts so CLAUDE.md thin pointer gives agents architectural orientation without duplicating rules (#39) - adf migrate --audit: prints per-module breakdown (constraints/context/ advisory counts) and flags potential misroutes via routingTrace — items routed to core.adf that scored non-zero for a specialized module (#40) - doctor: adds 'INFO' status tier (soft, does not fail overall check); warns [info] when thin-pointer CLAUDE.md has <15 lines and no stack/framework keywords — agents have zero orientation, suggests charter adf populate (#41) Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands/adf-migrate.ts | 124 +++++++++++++++++++++-- packages/cli/src/commands/bootstrap.ts | 2 +- packages/cli/src/commands/doctor.ts | 20 +++- 3 files changed, 133 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/commands/adf-migrate.ts b/packages/cli/src/commands/adf-migrate.ts index 1faa8bc..a666b2f 100644 --- a/packages/cli/src/commands/adf-migrate.ts +++ b/packages/cli/src/commands/adf-migrate.ts @@ -18,7 +18,7 @@ import { isDuplicateItem, buildMigrationPlan, } from '@stackbilt/adf'; -import type { AdfDocument, PatchOperation, MigrationItem, TriggerMap } from '@stackbilt/adf'; +import type { AdfDocument, PatchOperation, MigrationItem, MigrationPlan, TriggerMap } from '@stackbilt/adf'; import type { CLIOptions } from '../index'; import { CLIError, EXIT_CODE } from '../index'; import { getFlag } from '../flags'; @@ -53,6 +53,8 @@ const POINTER_TEMPLATES: Record = { export async function adfMigrateCommand(options: CLIOptions, args: string[]): Promise { const dryRun = args.includes('--dry-run'); const noBackup = args.includes('--no-backup'); + const keepSummary = args.includes('--keep-summary'); + const audit = args.includes('--audit'); const sourceFile = getFlag(args, '--source'); const mergeStrategy = (getFlag(args, '--merge-strategy') || 'dedupe') as 'append' | 'dedupe' | 'replace'; const aiDir = getFlag(args, '--ai-dir') || '.ai'; @@ -79,22 +81,26 @@ export async function adfMigrateCommand(options: CLIOptions, args: string[]): Pr const results: SourceMigrationResult[] = []; for (const source of sources) { - const result = migrateSource(source, aiDir, mergeStrategy, dryRun, noBackup, options); + const result = migrateSource(source, aiDir, mergeStrategy, dryRun, noBackup, keepSummary, options); results.push(result); } // Output if (options.format === 'json') { - console.log(JSON.stringify({ - dryRun, - mergeStrategy, - sources: results, - }, null, 2)); + const output: Record = { dryRun, mergeStrategy, sources: results }; + if (audit) output.audit = results.map(r => buildAuditReport(r)); + console.log(JSON.stringify(output, null, 2)); } else { for (const r of results) { printTextResult(r, dryRun); } + if (audit) { + for (const r of results) { + printAuditReport(r); + } + } + if (dryRun) { console.log(''); console.log(' Run without --dry-run to apply.'); @@ -130,6 +136,7 @@ export function migrateSource( mergeStrategy: 'append' | 'dedupe' | 'replace', dryRun: boolean, noBackup: boolean, + keepSummary: boolean, options: CLIOptions ): SourceMigrationResult { const fullPath = path.resolve(sourcePath); @@ -248,8 +255,8 @@ export function migrateSource( applyMigrationToModule(modulePath, sectionGroups, mergeStrategy); } - // Write thin pointer with retained STAY items - writePointerWithRetained(fullPath, sourcePath, plan.stayItems); + // Write thin pointer with retained STAY items (and optional architecture summary) + writePointerWithRetained(fullPath, sourcePath, plan.stayItems, keepSummary ? plan : undefined); } return { @@ -396,7 +403,8 @@ function formatItemForAdf(item: MigrationItem): string { function writePointerWithRetained( fullPath: string, fileName: string, - stayItems: MigrationItem[] + stayItems: MigrationItem[], + plan?: MigrationPlan ): void { const baseName = path.basename(fileName); let pointer = POINTER_TEMPLATES[baseName]; @@ -431,6 +439,26 @@ function writePointerWithRetained( } } + // --keep-summary: inject auto-generated architecture summary for agent orientation (#39) + if (plan && plan.migrateItems.length > 0) { + const moduleGroups = new Map(); + for (const item of plan.migrateItems) { + const mod = item.classification.targetModule; + if (!moduleGroups.has(mod)) moduleGroups.set(mod, { CONSTRAINTS: 0, CONTEXT: 0, ADVISORY: 0 }); + moduleGroups.get(mod)![item.classification.targetSection as 'CONSTRAINTS' | 'CONTEXT' | 'ADVISORY']++; + } + const today = new Date().toISOString().slice(0, 10); + let summary = `\n## Architecture Summary\n> Auto-generated by \`charter adf migrate\` on ${today}.\n> Rules live in \`.ai/\` — do not duplicate them here.\n\n`; + for (const [mod, counts] of moduleGroups) { + const parts: string[] = []; + if (counts.CONSTRAINTS > 0) parts.push(`${counts.CONSTRAINTS} constraints`); + if (counts.CONTEXT > 0) parts.push(`${counts.CONTEXT} context`); + if (counts.ADVISORY > 0) parts.push(`${counts.ADVISORY} advisory`); + summary += `- **${mod.replace('.adf', '')}**: ${parts.join(', ')} → \`.ai/${mod}\`\n`; + } + pointer += summary; + } + fs.writeFileSync(fullPath, pointer); } @@ -470,6 +498,82 @@ function printTextResult(result: SourceMigrationResult, dryRun: boolean): void { console.log(''); } +// ============================================================================ +// Audit Report (#40) +// ============================================================================ + +interface AuditModuleSummary { + module: string; + constraints: number; + context: number; + advisory: number; + total: number; + potentialMisroutes: string[]; +} + +interface AuditReport { + source: string; + modules: AuditModuleSummary[]; +} + +function buildAuditReport(result: SourceMigrationResult): AuditReport { + if (!result.plan) return { source: result.source, modules: [] }; + + const moduleMap = new Map(); + + for (const item of result.plan.migrateItems) { + const mod = item.classification.targetModule; + if (!moduleMap.has(mod)) { + moduleMap.set(mod, { module: mod, constraints: 0, context: 0, advisory: 0, total: 0, potentialMisroutes: [] }); + } + const entry = moduleMap.get(mod)!; + entry.total++; + if (item.classification.targetSection === 'CONSTRAINTS') entry.constraints++; + if (item.classification.targetSection === 'CONTEXT') entry.context++; + if (item.classification.targetSection === 'ADVISORY') entry.advisory++; + + // Flag potential misroutes: routed to core.adf but has non-zero scores for specialized modules + if (mod === 'core.adf' && item.classification.routingTrace) { + const scores = item.classification.routingTrace.candidateScores; + const betterModule = Object.entries(scores) + .filter(([m, s]) => m !== 'core.adf' && s > 0) + .sort(([, a], [, b]) => (b as number) - (a as number))[0]; + if (betterModule) { + const preview = item.element.content.length > 50 + ? item.element.content.slice(0, 47) + '...' + : item.element.content; + entry.potentialMisroutes.push(`"${preview}" (score: ${betterModule[0]}=${betterModule[1]})`); + } + } + } + + return { source: result.source, modules: [...moduleMap.values()] }; +} + +function printAuditReport(result: SourceMigrationResult): void { + if (!result.plan || result.skipped) return; + + const report = buildAuditReport(result); + console.log(` Audit: ${report.source}`); + + if (report.modules.length === 0) { + console.log(' No items migrated.'); + return; + } + + for (const mod of report.modules) { + const parts: string[] = []; + if (mod.constraints > 0) parts.push(`${mod.constraints} constraints`); + if (mod.context > 0) parts.push(`${mod.context} context`); + if (mod.advisory > 0) parts.push(`${mod.advisory} advisory`); + console.log(` ${mod.module}: ${parts.join(', ')} (${mod.total} total)`); + for (const misroute of mod.potentialMisroutes) { + console.log(` [?] possible misroute: ${misroute}`); + } + } + console.log(''); +} + // ============================================================================ // Helpers // ============================================================================ diff --git a/packages/cli/src/commands/bootstrap.ts b/packages/cli/src/commands/bootstrap.ts index 6691932..73a3aff 100644 --- a/packages/cli/src/commands/bootstrap.ts +++ b/packages/cli/src/commands/bootstrap.ts @@ -600,7 +600,7 @@ function runMigratePhase( // Auto-migrate with --yes const results: SourceMigrationResult[] = []; for (const source of sources) { - const result = migrateSource(source, aiDir, 'dedupe', false, false, options); + const result = migrateSource(source, aiDir, 'dedupe', false, false, false, options); results.push(result); } diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 3f43589..0a8d012 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -17,7 +17,7 @@ interface DoctorResult { status: 'PASS' | 'WARN'; checks: Array<{ name: string; - status: 'PASS' | 'WARN'; + status: 'PASS' | 'WARN' | 'INFO'; details: string; }>; } @@ -276,6 +276,22 @@ export async function doctorCommand(options: CLIOptions, args: string[] = []): P }); } + // Cold-start check: thin pointers with no architectural orientation (#41) + // A pointer that's <15 lines and contains no stack/framework keywords gives + // agents zero context about the project. Soft [info] — does not fail doctor. + const STACK_KEYWORDS = /\b(react|vue|svelte|next|nuxt|astro|remix|angular|node|bun|deno|python|go|rust|postgres|mysql|sqlite|d1|prisma|drizzle|hono|express|fastify|trpc|cloudflare|vercel|railway|docker|kubernetes)\b/i; + for (const { file, content } of pointerFiles) { + const lineCount = content.split('\n').filter(l => l.trim()).length; + const hasStackHint = STACK_KEYWORDS.test(content); + if (lineCount < 15 && !hasStackHint) { + checks.push({ + name: 'adf cold start', + status: 'INFO', + details: `${file} is a thin pointer with ${lineCount} lines and no stack keywords — agents have no architecture orientation. Run: charter adf populate`, + }); + } + } + // Sync lock status if (manifest.sync.length > 0) { const lockFile = path.join(aiDir, '.adf.lock'); @@ -308,7 +324,7 @@ export async function doctorCommand(options: CLIOptions, args: string[] = []): P } else { console.log(` Doctor status: ${result.status}`); for (const check of result.checks) { - const icon = check.status === 'PASS' ? '[ok]' : '[warn]'; + const icon = check.status === 'PASS' ? '[ok]' : check.status === 'INFO' ? '[info]' : '[warn]'; console.log(` ${icon} ${check.name}: ${check.details}`); } } From 3d83f05df87b8e8c37588c85d013dd2189ddfbff Mon Sep 17 00:00:00 2001 From: Kevin Overmier Date: Thu, 5 Mar 2026 17:44:00 -0600 Subject: [PATCH 5/5] chore: bump to v0.8.0 Co-Authored-By: Claude Opus 4.6 --- package.json | 3 ++- packages/adf/package.json | 2 +- packages/ci/package.json | 2 +- packages/classify/package.json | 2 +- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/drift/package.json | 2 +- packages/git/package.json | 2 +- packages/types/package.json | 2 +- packages/validate/package.json | 2 +- 10 files changed, 11 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 1d4a926..42029dc 100644 --- a/package.json +++ b/package.json @@ -42,5 +42,6 @@ "typescript": "~5.8.2", "vitest": "^4.0.18", "zod": "^3.24.1" - } + }, + "version": "0.8.0" } diff --git a/packages/adf/package.json b/packages/adf/package.json index 58f6115..c307123 100644 --- a/packages/adf/package.json +++ b/packages/adf/package.json @@ -1,7 +1,7 @@ { "name": "@stackbilt/adf", "sideEffects": false, - "version": "0.7.0", + "version": "0.8.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/ci/package.json b/packages/ci/package.json index 5838c58..86ba3fa 100644 --- a/packages/ci/package.json +++ b/packages/ci/package.json @@ -1,7 +1,7 @@ { "name": "@stackbilt/ci", "sideEffects": false, - "version": "0.7.0", + "version": "0.8.0", "description": "GitHub Actions adapter for Charter governance checks", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/classify/package.json b/packages/classify/package.json index 89fd6b6..a0984b6 100644 --- a/packages/classify/package.json +++ b/packages/classify/package.json @@ -1,7 +1,7 @@ { "name": "@stackbilt/classify", "sideEffects": false, - "version": "0.7.0", + "version": "0.8.0", "description": "Heuristic change classification (SURFACE/LOCAL/CROSS_CUTTING)", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/cli/package.json b/packages/cli/package.json index 804b717..51833ce 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@stackbilt/cli", "sideEffects": false, - "version": "0.7.0", + "version": "0.8.0", "description": "Charter CLI — repo-level governance checks", "bin": { "charter": "./dist/bin.js" diff --git a/packages/core/package.json b/packages/core/package.json index 16b0d76..31f1521 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@stackbilt/core", "sideEffects": false, - "version": "0.7.0", + "version": "0.8.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 95248f6..b76dfd4 100644 --- a/packages/drift/package.json +++ b/packages/drift/package.json @@ -1,7 +1,7 @@ { "name": "@stackbilt/drift", "sideEffects": false, - "version": "0.7.0", + "version": "0.8.0", "description": "Drift scanner — detects codebase divergence from governance patterns", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/git/package.json b/packages/git/package.json index b22e892..252227e 100644 --- a/packages/git/package.json +++ b/packages/git/package.json @@ -1,7 +1,7 @@ { "name": "@stackbilt/git", "sideEffects": false, - "version": "0.7.0", + "version": "0.8.0", "description": "Git trailer parsing, commit risk scoring, and PR validation", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/types/package.json b/packages/types/package.json index 7a8d71f..42d3b2c 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,7 +1,7 @@ { "name": "@stackbilt/types", "sideEffects": false, - "version": "0.7.0", + "version": "0.8.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 1c47132..a50e0f9 100644 --- a/packages/validate/package.json +++ b/packages/validate/package.json @@ -1,7 +1,7 @@ { "name": "@stackbilt/validate", "sideEffects": false, - "version": "0.7.0", + "version": "0.8.0", "description": "Citation validation, message classification, and governance checks", "main": "./dist/index.js", "types": "./dist/index.d.ts",