From 7feff8fb5278fb1e36c9150fe6efad585687217c Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Fri, 27 Feb 2026 07:09:57 -0600 Subject: [PATCH 01/12] chore(misc): apply maintenance updates --- .ai/core.adf | 2 +- .charter/telemetry/events.ndjson | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 .charter/telemetry/events.ndjson diff --git a/.ai/core.adf b/.ai/core.adf index 5a443fa..4ef0e5f 100644 --- a/.ai/core.adf +++ b/.ai/core.adf @@ -27,7 +27,7 @@ ADF: 0.1 adf_commands_loc: 0 / 650 [lines] adf_bundle_loc: 0 / 200 [lines] adf_sync_loc: 0 / 250 [lines] - adf_evidence_loc: 0 / 300 [lines] + adf_evidence_loc: 0 / 380 [lines] adf_migrate_loc: 0 / 500 [lines] bundler_loc: 0 / 500 [lines] parser_loc: 0 / 300 [lines] diff --git a/.charter/telemetry/events.ndjson b/.charter/telemetry/events.ndjson new file mode 100644 index 0000000..ab7bc6a --- /dev/null +++ b/.charter/telemetry/events.ndjson @@ -0,0 +1 @@ +{"version":1,"timestamp":"2026-02-27T13:09:13.599Z","commandPath":"adf.evidence","flags":["--auto-measure","--ci"],"format":"text","ciMode":true,"durationMs":383,"exitCode":1,"success":false} From ce2f515fa4b6a7eb36ac585e89dc3eb15adca9fd Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Fri, 27 Feb 2026 07:10:24 -0600 Subject: [PATCH 02/12] docs(repo): update project documentation --- CHANGELOG.md | 9 +++++++++ README.md | 2 ++ docs-snippets/charter-cli-reference.md | 13 +++++++++++++ 3 files changed, 24 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c24ea0b..cbc9ff9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project are documented in this file. The format is based on Keep a Changelog and follows Semantic Versioning. +## [Unreleased] + +### Added +- **`charter adf metrics recalibrate`**: New subcommand to re-measure LOC from manifest metric sources, propose new ceilings with configurable headroom, and update metric baselines/ceilings with required rationale (`--reason` or `--auto-rationale`). +- **Budget rationale trail**: Recalibration writes `BUDGET_RATIONALES` map entries so metric-cap changes carry explicit context for later review. + +### Changed +- **Stale baseline detection in evidence**: `charter adf evidence` now detects stale metric baselines (current vs baseline drift), emits structured `staleBaselines` warnings (baseline/current/delta/recommendedCeiling/rationaleRequired), and suggests recalibration actions. + ## [0.4.2] - 2026-02-27 ### Added diff --git a/README.md b/README.md index ee0136c..1b9928c 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ charter adf sync --check # Validate metric constraints and produce a structured evidence report charter adf evidence --auto-measure --format json +charter adf metrics recalibrate --headroom 15 --reason "Added new built modules after scope expansion" --dry-run charter telemetry report --period 24h --format json ``` @@ -225,6 +226,7 @@ Teams often score lower early due to missing governance trailers. Use this ramp: - `charter adf sync --check [--ai-dir ]`: verify source .adf files match locked hashes (exit 1 on drift) - `charter adf sync --write [--ai-dir ]`: update `.adf.lock` with current source hashes - `charter adf evidence [--task ""] [--ai-dir ] [--auto-measure] [--context '{"k":v}'] [--context-file ]`: validate metric constraints and produce structured evidence report +- `charter adf metrics recalibrate [--headroom ] [--reason ""|--auto-rationale] [--dry-run]`: recalibrate metric baselines/ceilings from current LOC and record budget rationale - `charter adf migrate [--dry-run] [--source ] [--no-backup] [--merge-strategy append|dedupe|replace]`: ingest existing agent config files and migrate content into ADF modules - `charter telemetry report [--period <30m|24h|7d>]`: summarize passive local CLI telemetry from `.charter/telemetry/events.ndjson` - `charter why`: explain adoption rationale and expected payoff diff --git a/docs-snippets/charter-cli-reference.md b/docs-snippets/charter-cli-reference.md index be8cb6a..0d8a304 100644 --- a/docs-snippets/charter-cli-reference.md +++ b/docs-snippets/charter-cli-reference.md @@ -195,6 +195,19 @@ npx charter adf evidence --context-file metrics.json **CI mode:** exits 1 on any constraint failure. Warnings (at boundary) surface in the report but do not fail the build. Output includes constraint results, weight summary (load-bearing / advisory / unweighted), sync status, advisory-only warnings, and a `nextActions` array. +When stale baselines are detected, JSON output includes `staleBaselines` entries with `baseline`, `current`, `delta`, `recommendedCeiling`, and `rationaleRequired`. + +### charter adf metrics recalibrate + +Re-measures LOC from manifest metric sources and recalibrates metric values/ceilings using a configurable headroom policy. + +```bash +npx charter adf metrics recalibrate --headroom 15 --reason "Added new built modules after scope increase" --dry-run +npx charter adf metrics recalibrate --headroom 20 --auto-rationale +``` + +- Writes metric rationale entries into a `BUDGET_RATIONALES` section. +- `--reason` (or `--auto-rationale`) is required for recalibration updates. ### ADF Automation Gate From a726b7409c10908c68eed7c918493eea0d155498 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Fri, 27 Feb 2026 07:10:49 -0600 Subject: [PATCH 03/12] feat(cli): apply logical updates --- packages/cli/README.md | 4 +- .../cli/src/__tests__/adf-metrics.test.ts | 95 +++++++ packages/cli/src/commands/adf-evidence.ts | 67 ++++- packages/cli/src/commands/adf-metrics.ts | 256 ++++++++++++++++++ packages/cli/src/commands/adf.ts | 8 +- packages/cli/src/index.ts | 2 +- 6 files changed, 428 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/__tests__/adf-metrics.test.ts create mode 100644 packages/cli/src/commands/adf-metrics.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index e74ef81..8dfc553 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -282,6 +282,7 @@ charter adf sync --check [--ai-dir ] charter adf sync --write [--ai-dir ] charter adf evidence [--task ""] [--ai-dir ] [--auto-measure] [--context '{"key": value}'] [--context-file ] +charter adf metrics recalibrate [--headroom ] [--reason ""|--auto-rationale] [--dry-run] charter adf migrate [--dry-run] [--source ] [--no-backup] [--merge-strategy append|dedupe|replace] [--ai-dir ] ``` @@ -294,7 +295,8 @@ charter adf migrate [--dry-run] [--source ] [--no-backup] - `bundle`: Read `manifest.adf`, resolve ON_DEMAND modules via keyword matching against the task, and output merged context with token estimate, trigger observability (matched keywords, load reasons), unmatched modules, and advisory-only warnings. Missing ON_DEMAND files are warnings in output (`missingModules` in JSON), while missing DEFAULT_LOAD files still fail. - `sync --check`: Verify source `.adf` files match their locked hashes. Exits 1 if any source has drifted since last sync. - `sync --write`: Update `.adf.lock` with current source hashes. -- `evidence`: Validate all metric ceilings in the merged document and produce a structured pass/fail evidence report. `--auto-measure` counts lines in files referenced by the manifest METRICS section. `--context` or `--context-file` inject external metric overrides that take precedence over auto-measured and document values. In `--ci` mode, exits 1 on constraint failures (warnings don't fail). The governance workflow template runs this automatically on PRs when `.ai/manifest.adf` is present. +- `evidence`: Validate all metric ceilings in the merged document and produce a structured pass/fail evidence report. `--auto-measure` counts lines in files referenced by the manifest METRICS section. `--context` or `--context-file` inject external metric overrides that take precedence over auto-measured and document values. In `--ci` mode, exits 1 on constraint failures (warnings don't fail). Also reports stale-baseline warnings (baseline vs current delta + recommended ceiling) when baseline values drift significantly. The governance workflow template runs this automatically on PRs when `.ai/manifest.adf` is present. +- `metrics recalibrate`: Re-measure current LOC from manifest metric sources, propose and apply new ceilings using configurable headroom, and append rationale records to `BUDGET_RATIONALES`. Requires explicit rationale (`--reason`) unless `--auto-rationale` is used. - `migrate`: Scan existing agent config files (CLAUDE.md, .cursorrules, agents.md, GEMINI.md, copilot-instructions.md), classify content using the ADX-002 decision tree, and migrate into ADF modules. `--dry-run` previews the migration plan without writing files. `--source ` targets a single file. `--no-backup` skips `.pre-adf-migrate.bak` creation. `--merge-strategy` controls deduplication: `dedupe` (default, skip items already in ADF), `append` (always add), or `replace`. Environment-specific rules (WSL, PATH, credential helpers) are retained in the thin pointer. ### `charter telemetry` diff --git a/packages/cli/src/__tests__/adf-metrics.test.ts b/packages/cli/src/__tests__/adf-metrics.test.ts new file mode 100644 index 0000000..0d78ba9 --- /dev/null +++ b/packages/cli/src/__tests__/adf-metrics.test.ts @@ -0,0 +1,95 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { CLIOptions } from '../index'; +import { adfMetricsCommand } from '../commands/adf-metrics'; +import { adfEvidence } from '../commands/adf-evidence'; + +const baseOptions: CLIOptions = { + configPath: '.charter', + format: 'json', + ciMode: false, + yes: false, +}; + +const originalCwd = process.cwd(); +const tempDirs: string[] = []; + +afterEach(() => { + process.chdir(originalCwd); + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + vi.restoreAllMocks(); +}); + +function writeFixtureRepo(tmp: string, baseline = 100): void { + fs.mkdirSync(path.join(tmp, '.ai'), { recursive: true }); + fs.mkdirSync(path.join(tmp, 'src'), { recursive: true }); + + fs.writeFileSync(path.join(tmp, '.ai', 'manifest.adf'), `ADF: 0.1 +DEFAULT_LOAD: + - core.adf + - state.adf + +METRICS: + COMPONENTS_TOTAL_LOC: src/components.ts +`); + fs.writeFileSync(path.join(tmp, '.ai', 'core.adf'), `ADF: 0.1 +METRICS: + components_total_loc: ${baseline} / 120 [lines] +`); + fs.writeFileSync(path.join(tmp, '.ai', 'state.adf'), 'ADF: 0.1\nSTATE:\n CURRENT: testing\n'); + fs.writeFileSync(path.join(tmp, 'src', 'components.ts'), Array.from({ length: 200 }, (_, i) => `line_${i}`).join('\n') + '\n'); +} + +describe('adf metrics recalibrate', () => { + it('requires rationale by default', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'charter-metrics-test-')); + tempDirs.push(tmp); + process.chdir(tmp); + writeFixtureRepo(tmp); + + expect(() => adfMetricsCommand(baseOptions, ['recalibrate'])).toThrow('requires --reason'); + }); + + it('recalibrates metric ceilings and writes rationale entries', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'charter-metrics-test-')); + tempDirs.push(tmp); + process.chdir(tmp); + writeFixtureRepo(tmp); + + const exitCode = adfMetricsCommand(baseOptions, ['recalibrate', '--headroom', '20', '--reason', 'Scope expanded with new built views']); + expect(exitCode).toBe(0); + + const measured = fs.readFileSync(path.join(tmp, 'src', 'components.ts'), 'utf-8').split('\n').length; + const ceiling = Math.ceil(measured * 1.2); + const core = fs.readFileSync(path.join(tmp, '.ai', 'core.adf'), 'utf-8'); + expect(core).toContain(`components_total_loc: ${measured} / ${ceiling} [lines]`); + expect(core).toContain('BUDGET_RATIONALES'); + expect(core).toContain('Scope expanded with new built views'); + }); +}); + +describe('adf evidence stale baseline warnings', () => { + it('emits staleBaselines when measured value greatly exceeds baseline value', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'charter-evidence-test-')); + tempDirs.push(tmp); + process.chdir(tmp); + writeFixtureRepo(tmp, 80); + + const logs: string[] = []; + vi.spyOn(console, 'log').mockImplementation((msg: string) => logs.push(msg)); + const exitCode = adfEvidence(baseOptions, ['--ai-dir', '.ai', '--auto-measure']); + expect(exitCode).toBe(0); + + const out = JSON.parse(logs[0]) as { staleBaselines?: Array<{ metric: string; rationaleRequired: boolean }> }; + expect(out.staleBaselines?.length).toBeGreaterThan(0); + expect(out.staleBaselines?.[0].metric).toBe('components_total_loc'); + expect(out.staleBaselines?.[0].rationaleRequired).toBe(true); + }); +}); diff --git a/packages/cli/src/commands/adf-evidence.ts b/packages/cli/src/commands/adf-evidence.ts index 3dc4d49..1d33803 100644 --- a/packages/cli/src/commands/adf-evidence.ts +++ b/packages/cli/src/commands/adf-evidence.ts @@ -13,7 +13,7 @@ import { bundleModules, validateConstraints, } from '@stackbilt/adf'; -import type { EvidenceResult } from '@stackbilt/adf'; +import type { AdfDocument, EvidenceResult } from '@stackbilt/adf'; import type { CLIOptions } from '../index'; import { CLIError, EXIT_CODE } from '../index'; import { hashContent, loadLockFile } from './adf-sync'; @@ -25,12 +25,23 @@ interface AutoMeasurement { error?: string; } +interface StaleBaselineWarning { + metric: string; + baseline: number; + current: number; + delta: number; + ratio: number; + recommendedCeiling: number; + rationaleRequired: boolean; +} + export function adfEvidence(options: CLIOptions, args: string[]): number { const task = getFlag(args, '--task'); const aiDir = getFlag(args, '--ai-dir') || '.ai'; const contextJson = getFlag(args, '--context'); const contextFile = getFlag(args, '--context-file'); const autoMeasure = args.includes('--auto-measure'); + const staleThreshold = parseStaleThreshold(getFlag(args, '--stale-threshold') || '1.2'); const manifestPath = path.join(aiDir, 'manifest.adf'); if (!fs.existsSync(manifestPath)) { @@ -95,6 +106,7 @@ export function adfEvidence(options: CLIOptions, args: string[]): number { try { const bundle = bundleModules(aiDir, modulePaths, readFile, keywords); const evidence: EvidenceResult = validateConstraints(bundle.mergedDocument, context); + const staleBaselines = detectStaleBaselines(bundle.mergedDocument, context, staleThreshold); // Check sync status const lockFile = path.join(aiDir, '.adf.lock'); @@ -124,6 +136,7 @@ export function adfEvidence(options: CLIOptions, args: string[]): number { allPassing: evidence.allPassing, failCount: evidence.failCount, warnCount: evidence.warnCount, + staleBaselineCount: staleBaselines.length, syncStatus: { allInSync, staleCount }, }; if (task) { @@ -136,6 +149,9 @@ export function adfEvidence(options: CLIOptions, args: string[]): number { if (autoMeasured.length > 0) { jsonOut.autoMeasured = autoMeasured; } + if (staleBaselines.length > 0) { + jsonOut.staleBaselines = staleBaselines; + } // Suggest logical next steps based on results const nextActions: string[] = []; if (!evidence.allPassing) { @@ -147,6 +163,9 @@ export function adfEvidence(options: CLIOptions, args: string[]): number { if (evidence.warnCount > 0) { nextActions.push('Review metrics at ceiling boundary'); } + if (staleBaselines.length > 0) { + nextActions.push('charter adf metrics recalibrate --headroom 15 --reason "" --dry-run'); + } if (nextActions.length > 0) { jsonOut.nextActions = nextActions; } @@ -178,6 +197,14 @@ export function adfEvidence(options: CLIOptions, args: string[]): number { console.log(''); } + if (staleBaselines.length > 0) { + console.log(' Stale baseline warnings:'); + for (const s of staleBaselines) { + console.log(` [warn] ${s.metric}: baseline ${s.baseline}, current ${s.current}, delta ${s.delta}, recommended ceiling ${s.recommendedCeiling} (rationale required)`); + } + console.log(''); + } + // Weight summary console.log(' Section weights:'); console.log(` Load-bearing: ${evidence.weightSummary.loadBearing}`); @@ -260,3 +287,41 @@ function readJsonFlag(filePath: string, flagName: string): string { } return fs.readFileSync(filePath, 'utf-8'); } + +function parseStaleThreshold(raw: string): number { + const parsed = parseFloat(raw); + if (!Number.isFinite(parsed) || parsed < 1.0 || parsed > 10) { + throw new CLIError(`Invalid --stale-threshold value: ${raw}. Use a number between 1.0 and 10.0.`); + } + return parsed; +} + +function detectStaleBaselines( + doc: AdfDocument, + context: Record | undefined, + staleThreshold: number +): StaleBaselineWarning[] { + if (!context) return []; + const warnings: StaleBaselineWarning[] = []; + for (const section of doc.sections) { + if (section.key !== 'METRICS' || section.content.type !== 'metric') continue; + for (const entry of section.content.entries) { + if (entry.value <= 0) continue; + const key = entry.key.toLowerCase(); + const current = context[key]; + if (!Number.isFinite(current)) continue; + const ratio = current / entry.value; + if (ratio < staleThreshold) continue; + warnings.push({ + metric: key, + baseline: entry.value, + current, + delta: current - entry.value, + ratio: Number(ratio.toFixed(2)), + recommendedCeiling: Math.ceil(current * 1.15), + rationaleRequired: true, + }); + } + } + return warnings; +} diff --git a/packages/cli/src/commands/adf-metrics.ts b/packages/cli/src/commands/adf-metrics.ts new file mode 100644 index 0000000..ef73060 --- /dev/null +++ b/packages/cli/src/commands/adf-metrics.ts @@ -0,0 +1,256 @@ +/** + * charter adf metrics + * + * Metric budget utilities for recalibration workflows. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { formatAdf, parseAdf, parseManifest } from '@stackbilt/adf'; +import type { AdfDocument } from '@stackbilt/adf'; +import type { CLIOptions } from '../index'; +import { CLIError, EXIT_CODE } from '../index'; + +interface MetricUpdate { + metric: string; + current: number; + previousValue: number; + previousCeiling: number; + recommendedCeiling: number; + module: string; +} + +interface ModuleUpdate { + modulePath: string; + document: AdfDocument; + updates: MetricUpdate[]; +} + +export function adfMetricsCommand(options: CLIOptions, args: string[]): number { + if (args.length === 0 || args.includes('--help') || args.includes('-h')) { + printHelp(); + return EXIT_CODE.SUCCESS; + } + + const subcommand = args[0]; + const rest = args.slice(1); + switch (subcommand) { + case 'recalibrate': + return metricsRecalibrate(options, rest); + default: + throw new CLIError(`Unknown adf metrics subcommand: ${subcommand}. Supported: recalibrate`); + } +} + +function metricsRecalibrate(options: CLIOptions, args: string[]): number { + const aiDir = getFlag(args, '--ai-dir') || '.ai'; + const manifestPath = path.join(aiDir, 'manifest.adf'); + const headroomPercent = parseHeadroom(getFlag(args, '--headroom') || '15'); + const dryRun = args.includes('--dry-run'); + const autoRationale = args.includes('--auto-rationale'); + const reason = getFlag(args, '--reason'); + + if (!fs.existsSync(manifestPath)) { + throw new CLIError(`manifest.adf not found at ${manifestPath}. Run: charter adf init`); + } + + if (!autoRationale && !reason) { + throw new CLIError('metrics recalibrate requires --reason "" or --auto-rationale.'); + } + + const manifestDoc = parseAdf(fs.readFileSync(manifestPath, 'utf-8')); + const manifest = parseManifest(manifestDoc); + if (manifest.metrics.length === 0) { + throw new CLIError('No METRICS sources found in manifest.adf; cannot recalibrate.'); + } + + const measured = measureManifestMetrics(manifest.metrics); + const updateByModule = buildModuleUpdates(aiDir, manifest, measured, headroomPercent); + const allUpdates = [...updateByModule.values()].flatMap((u) => u.updates); + + if (allUpdates.length === 0) { + if (options.format === 'json') { + console.log(JSON.stringify({ aiDir, updated: false, reason: 'no matching metric entries found' }, null, 2)); + } else { + console.log(' No matching metric entries found in module METRICS sections.'); + } + return EXIT_CODE.SUCCESS; + } + + const rationaleText = autoRationale + ? buildAutoRationale(headroomPercent, allUpdates.length) + : reason!; + + for (const moduleUpdate of updateByModule.values()) { + ensureRationaleEntry(moduleUpdate.document, moduleUpdate.updates, rationaleText); + } + + if (!dryRun) { + writeModuleUpdatesAtomically(aiDir, [...updateByModule.values()]); + } + + const output = { + aiDir, + dryRun, + headroomPercent, + metricsUpdated: allUpdates.length, + modulesTouched: [...updateByModule.keys()], + updates: allUpdates.map((u) => ({ + metric: u.metric, + module: u.module, + baseline: u.previousValue, + current: u.current, + delta: u.current - u.previousValue, + previousCeiling: u.previousCeiling, + recommendedCeiling: u.recommendedCeiling, + rationaleRequired: true, + })), + rationale: rationaleText, + }; + + if (options.format === 'json') { + console.log(JSON.stringify(output, null, 2)); + } else { + console.log(` Recalibration ${dryRun ? 'preview' : 'complete'} (${allUpdates.length} metric update(s))`); + console.log(` Headroom policy: +${headroomPercent}%`); + for (const update of allUpdates) { + const delta = update.current - update.previousValue; + console.log( + ` - ${update.metric} [${update.module}]: baseline ${update.previousValue}, current ${update.current}, delta ${delta}, ceiling ${update.previousCeiling} -> ${update.recommendedCeiling}` + ); + } + console.log(` Rationale: ${rationaleText}`); + } + + return EXIT_CODE.SUCCESS; +} + +function measureManifestMetrics(metricSources: Array<{ key: string; path: string }>): Record { + const measured: Record = {}; + for (const source of metricSources) { + const metricKey = source.key.toLowerCase(); + const sourcePath = path.resolve(source.path); + if (!fs.existsSync(sourcePath)) continue; + const content = fs.readFileSync(sourcePath, 'utf-8'); + measured[metricKey] = content.split('\n').length; + } + return measured; +} + +function buildModuleUpdates( + aiDir: string, + manifest: ReturnType, + measured: Record, + headroomPercent: number +): Map { + const modulePaths = [...new Set([...manifest.defaultLoad, ...manifest.onDemand.map((m) => m.path)])]; + const updates = new Map(); + + for (const modulePath of modulePaths) { + const fullPath = path.join(aiDir, modulePath); + if (!fs.existsSync(fullPath)) continue; + const doc = parseAdf(fs.readFileSync(fullPath, 'utf-8')); + + const moduleUpdates: MetricUpdate[] = []; + for (const section of doc.sections) { + if (section.key !== 'METRICS' || section.content.type !== 'metric') continue; + for (const entry of section.content.entries) { + const key = entry.key.toLowerCase(); + const current = measured[key]; + if (current === undefined) continue; + const recommendedCeiling = Math.ceil(current * (1 + headroomPercent / 100)); + moduleUpdates.push({ + metric: key, + current, + previousValue: entry.value, + previousCeiling: entry.ceiling, + recommendedCeiling, + module: modulePath, + }); + entry.value = current; + entry.ceiling = recommendedCeiling; + } + } + + if (moduleUpdates.length > 0) { + updates.set(modulePath, { + modulePath, + document: doc, + updates: moduleUpdates, + }); + } + } + + return updates; +} + +function ensureRationaleEntry(doc: AdfDocument, updates: MetricUpdate[], rationale: string): void { + const key = 'BUDGET_RATIONALES'; + let section = doc.sections.find((s) => s.key === key); + if (!section) { + section = { + key, + decoration: null, + content: { type: 'map', entries: [] }, + }; + doc.sections.push(section); + } + if (section.content.type !== 'map') { + return; + } + + const date = new Date().toISOString().slice(0, 10); + for (const update of updates) { + const rationaleKey = `${update.metric}_${date}`; + const rationaleValue = `${update.previousValue} -> ${update.current}, ceiling ${update.previousCeiling} -> ${update.recommendedCeiling}; ${rationale}`; + const existing = section.content.entries.find((entry) => entry.key === rationaleKey); + if (existing) { + existing.value = rationaleValue; + } else { + section.content.entries.push({ key: rationaleKey, value: rationaleValue }); + } + } +} + +function writeModuleUpdatesAtomically(aiDir: string, updates: ModuleUpdate[]): void { + const tempFiles: Array<{ temp: string; target: string }> = []; + for (const update of updates) { + const target = path.join(aiDir, update.modulePath); + const temp = `${target}.tmp-${process.pid}-${Date.now()}`; + fs.writeFileSync(temp, formatAdf(update.document)); + tempFiles.push({ temp, target }); + } + for (const entry of tempFiles) { + fs.renameSync(entry.temp, entry.target); + } +} + +function parseHeadroom(raw: string): number { + const parsed = parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed < 1 || parsed > 200) { + throw new CLIError(`Invalid --headroom value: ${raw}. Use an integer between 1 and 200.`); + } + return parsed; +} + +function buildAutoRationale(headroomPercent: number, metricCount: number): string { + const date = new Date().toISOString().slice(0, 10); + return `Recalibrated ${metricCount} metric baseline(s) on ${date} using +${headroomPercent}% headroom from current measured LOC.`; +} + +function getFlag(args: string[], flag: string): string | undefined { + const idx = args.indexOf(flag); + if (idx !== -1 && idx + 1 < args.length) { + return args[idx + 1]; + } + return undefined; +} + +function printHelp(): void { + console.log(''); + console.log(' charter adf metrics'); + console.log(''); + console.log(' Usage:'); + console.log(' charter adf metrics recalibrate [--headroom ] [--reason ""|--auto-rationale] [--dry-run] [--ai-dir ]'); + console.log(''); +} diff --git a/packages/cli/src/commands/adf.ts b/packages/cli/src/commands/adf.ts index e11382d..1eb1b6b 100644 --- a/packages/cli/src/commands/adf.ts +++ b/packages/cli/src/commands/adf.ts @@ -19,6 +19,7 @@ import { adfMigrateCommand } from './adf-migrate'; import { adfBundle } from './adf-bundle'; import { adfSync } from './adf-sync'; import { adfEvidence } from './adf-evidence'; +import { adfMetricsCommand } from './adf-metrics'; // ============================================================================ // Scaffold Content @@ -109,8 +110,10 @@ export async function adfCommand(options: CLIOptions, args: string[]): Promise] [--reason ""|--auto-rationale] [--dry-run]'); + console.log(' Recalibrate metric baselines/ceilings from current LOC with required rationale.'); + console.log(''); } function parseTriggers(raw: string | undefined): string[] { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 5f8d7f1..1044fae 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -43,7 +43,7 @@ Usage: Install git commit-msg hook for trailer normalization charter hook install --pre-commit [--force] Install git pre-commit hook for ADF evidence gate - charter adf ADF context format tools (init, fmt, patch, create, bundle, sync, evidence, migrate) + charter adf ADF context format tools (init, fmt, patch, create, bundle, sync, evidence, migrate, metrics) charter telemetry report Local telemetry summary (passive CLI observability) charter why Explain why teams adopt Charter and expected ROI charter doctor [--adf-only] Check CLI + config health (or ADF-only wiring checks) From 6f67b77e0d9a7c11471d8c643da4d462c90f6e9b Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Fri, 27 Feb 2026 07:14:09 -0600 Subject: [PATCH 04/12] chore(misc): apply maintenance updates --- .ai/.adf.lock | 2 +- .charter/telemetry/events.ndjson | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.ai/.adf.lock b/.ai/.adf.lock index d50247a..fef2fa9 100644 --- a/.ai/.adf.lock +++ b/.ai/.adf.lock @@ -1,3 +1,3 @@ { - "core.adf": "74a53c306c131c1c" + "core.adf": "9ea44032a909ed99" } diff --git a/.charter/telemetry/events.ndjson b/.charter/telemetry/events.ndjson index ab7bc6a..da42420 100644 --- a/.charter/telemetry/events.ndjson +++ b/.charter/telemetry/events.ndjson @@ -1 +1 @@ -{"version":1,"timestamp":"2026-02-27T13:09:13.599Z","commandPath":"adf.evidence","flags":["--auto-measure","--ci"],"format":"text","ciMode":true,"durationMs":383,"exitCode":1,"success":false} +{"version":1,"timestamp":"2026-02-27T13:14:03.606Z","commandPath":"adf.sync","flags":["--write","--format"],"format":"json","ciMode":false,"durationMs":24,"exitCode":0,"success":true} From 5cc99b4bfb6402171311ee61b753d00d6d7fd599 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Fri, 27 Feb 2026 07:15:07 -0600 Subject: [PATCH 05/12] chore(misc): apply maintenance updates --- .charter/telemetry/events.ndjson | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .charter/telemetry/events.ndjson diff --git a/.charter/telemetry/events.ndjson b/.charter/telemetry/events.ndjson deleted file mode 100644 index da42420..0000000 --- a/.charter/telemetry/events.ndjson +++ /dev/null @@ -1 +0,0 @@ -{"version":1,"timestamp":"2026-02-27T13:14:03.606Z","commandPath":"adf.sync","flags":["--write","--format"],"format":"json","ciMode":false,"durationMs":24,"exitCode":0,"success":true} From 8614bea664429d1be815ff3221e5ad2b9c5c7807 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Fri, 27 Feb 2026 07:15:26 -0600 Subject: [PATCH 06/12] chore(repo): update repository configuration --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 5e1d6ee..9d569fb 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ AGENTS.md CLAUDE.md plans/ governance/ + +# local telemetry artifacts +.charter/telemetry/ From 8f6d7d1b9bb542753228b7987d5e3883e10df96c Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Fri, 27 Feb 2026 13:21:35 -0600 Subject: [PATCH 07/12] docs(readme): add npm version badge Shows live release version from npm instead of stale GitHub Releases tag. Co-Authored-By: Claude Opus 4.6 --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 1b9928c..3fb002d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Charter Kit +[![npm version](https://img.shields.io/npm/v/@stackbilt/cli?label=charter&color=5F7FFF&style=for-the-badge)](https://www.npmjs.com/package/@stackbilt/cli) + ![Charter Kit hero](./stackbilt-charter-2.png) > **ADF is currently running its inaugural full-SDLC cycle to gather real-world data for iterative improvement. Updates coming soon and regularly.** From 807216fea2d416dfdb385fc7df8c4cb322d49362 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Sat, 28 Feb 2026 09:33:36 -0600 Subject: [PATCH 08/12] ci(release): add workflow for tag-based GitHub releases Supports both automatic releases on v* tag push and manual workflow_dispatch backfill for historical tags. Extracts release notes from CHANGELOG.md automatically. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/release.yml | 91 +++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bbfe63e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,91 @@ +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Existing tag to publish (for backfill), e.g. v0.4.2' + required: true + type: string + +permissions: + contents: write + +jobs: + publish-release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve tag + id: tag + shell: bash + run: | + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + TAG="${{ inputs.tag }}" + else + TAG="${GITHUB_REF_NAME}" + fi + + if [[ -z "${TAG}" ]]; then + echo "Tag could not be resolved." >&2 + exit 1 + fi + + echo "value=${TAG}" >> "$GITHUB_OUTPUT" + + - name: Verify tag + shell: bash + run: | + TAG="${{ steps.tag.outputs.value }}" + if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Invalid tag format: ${TAG}. Expected v.." >&2 + exit 1 + fi + + if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then + PKG_VERSION="$(node -p "require('./packages/cli/package.json').version")" + EXPECTED_TAG="v${PKG_VERSION}" + + if [[ "${TAG}" != "${EXPECTED_TAG}" ]]; then + echo "Tag/version mismatch on push: got ${TAG}, expected ${EXPECTED_TAG}" >&2 + exit 1 + fi + else + if ! git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then + echo "Tag not found in repository: ${TAG}" >&2 + exit 1 + fi + fi + + - name: Build release notes from CHANGELOG + shell: bash + run: | + TAG="${{ steps.tag.outputs.value }}" + VERSION="${TAG#v}" + + awk -v version="${VERSION}" ' + BEGIN { in_section=0 } + $0 ~ "^## \\[" version "\\]" { in_section=1; print; next } + in_section && $0 ~ "^## \\[" { exit } + in_section { print } + ' CHANGELOG.md > release_notes.md + + if [[ ! -s release_notes.md ]]; then + echo "## ${TAG}" > release_notes.md + echo >> release_notes.md + echo "See CHANGELOG.md for release details." >> release_notes.md + fi + + - name: Create or update GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.tag.outputs.value }} + name: ${{ steps.tag.outputs.value }} + body_path: release_notes.md + generate_release_notes: true From b8f109cc797b5a13a7f3ca71e1fb7623f9c08ed5 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Sat, 28 Feb 2026 09:42:20 -0600 Subject: [PATCH 09/12] docs(readme): remove ADF architecture image and update SDLC status Remove large ADF_1.png embed and update callout to reflect completion of the inaugural SDLC cycle with ongoing iterative improvement. Co-Authored-By: Claude Opus 4.6 --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 3fb002d..a538c99 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,10 @@ ![Charter Kit hero](./stackbilt-charter-2.png) -> **ADF is currently running its inaugural full-SDLC cycle to gather real-world data for iterative improvement. Updates coming soon and regularly.** +> **ADF has completed its inaugural full-SDLC cycle and is now in iterative improvement. Expect frequent updates as real-world feedback shapes the format.** Charter is a local-first governance toolkit with a built-in AI context compiler. It ships **ADF (Attention-Directed Format)** -- a modular, AST-backed context system that replaces monolithic `.cursorrules` and `claude.md` files -- alongside offline governance checks for commit trailers, risk scoring, drift detection, and change classification. -![ADF Architecture](./ADF_1.png) - ## ADF: Attention-Directed Format ADF treats LLM context as a compiled language. Instead of dumping flat markdown into a context window, ADF uses emoji-decorated semantic keys, a strict AST, and a module system with progressive disclosure -- so agents load only the context they need for the current task. From 12eb99bea7433c83bb42a0322fa24756b7d6d3e7 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Sat, 28 Feb 2026 10:41:04 -0600 Subject: [PATCH 10/12] fix(ci): remove explicit pnpm version to resolve action-setup conflict pnpm/action-setup@v4 now errors when both `version` param and package.json `packageManager` field are set. Let the action auto-detect from packageManager instead. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/charter-governance.yml | 2 -- .github/workflows/ci.yml | 12 +++++------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/.github/workflows/charter-governance.yml b/.github/workflows/charter-governance.yml index cc82792..f42c46e 100644 --- a/.github/workflows/charter-governance.yml +++ b/.github/workflows/charter-governance.yml @@ -19,8 +19,6 @@ jobs: fetch-depth: 0 - uses: pnpm/action-setup@v4 - with: - version: 9 - uses: actions/setup-node@v4 with: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d82b1ec..8b9bc31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,17 +12,15 @@ jobs: steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 9 - uses: actions/setup-node@v4 with: node-version: '20' cache: 'pnpm' - run: pnpm install --frozen-lockfile - run: pnpm run typecheck - - run: pnpm run build - - run: pnpm run docs:check - - run: pnpm run verify:adf - - run: pnpm run test - - run: node packages/cli/dist/bin.js --help + - run: pnpm run build + - run: pnpm run docs:check + - run: pnpm run verify:adf + - run: pnpm run test + - run: node packages/cli/dist/bin.js --help From de3b0d3a1953db4df03251295fc4ec73835eeecb Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Sat, 28 Feb 2026 11:00:30 -0600 Subject: [PATCH 11/12] fix(ci): reorder build before typecheck, add pnpm support to governance - CI: move `build` before `typecheck` so cross-package .d.ts files exist when tsc --noEmit runs against the root tsconfig. - Governance: detect pnpm-lock.yaml and use pnpm install when present, fixing workspace:^ protocol failures in this repo. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 2 +- .github/workflows/governance.yml | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b9bc31..4047254 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,8 +17,8 @@ jobs: node-version: '20' cache: 'pnpm' - run: pnpm install --frozen-lockfile - - run: pnpm run typecheck - run: pnpm run build + - run: pnpm run typecheck - run: pnpm run docs:check - run: pnpm run verify:adf - run: pnpm run test diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index 232dbe4..f98b1af 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -30,13 +30,19 @@ jobs: with: fetch-depth: 0 # Full history needed for commit analysis + - uses: pnpm/action-setup@v4 + if: hashFiles('pnpm-lock.yaml') != '' + - uses: actions/setup-node@v4 with: node-version: '20' + cache: ${{ hashFiles('pnpm-lock.yaml') != '' && 'pnpm' || hashFiles('package-lock.json') != '' && 'npm' || '' }} - name: Install dependencies run: | - if [ -f package-lock.json ]; then + if [ -f pnpm-lock.yaml ]; then + pnpm install --frozen-lockfile + elif [ -f package-lock.json ]; then npm ci else npm install From 7e532dcb1b08443691f0aa60e0b45dbe58d74d20 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Sat, 28 Feb 2026 12:45:14 -0600 Subject: [PATCH 12/12] fix(ci): guard docs:check and drift scan on required files - CI: skip docs:check when .docsync.json is absent - charter-governance: skip drift scan when no pattern files exist, matching the guard already present in governance.yml Co-Authored-By: Claude Opus 4.6 --- .github/workflows/charter-governance.yml | 1 + .github/workflows/ci.yml | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/charter-governance.yml b/.github/workflows/charter-governance.yml index f42c46e..eb280bb 100644 --- a/.github/workflows/charter-governance.yml +++ b/.github/workflows/charter-governance.yml @@ -33,6 +33,7 @@ jobs: - name: Drift Scan run: npx charter drift --ci --format text + if: hashFiles('.charter/patterns/*.json') != '' - name: ADF Wiring & Pointer Integrity run: npx charter doctor --adf-only --ci --format text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4047254..b4fcc62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,9 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm run build - run: pnpm run typecheck - - run: pnpm run docs:check + - name: Docs sync check + run: pnpm run docs:check + if: hashFiles('.docsync.json') != '' - run: pnpm run verify:adf - run: pnpm run test - run: node packages/cli/dist/bin.js --help