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/.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/.github/workflows/charter-governance.yml b/.github/workflows/charter-governance.yml index cc82792..eb280bb 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: @@ -35,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 d82b1ec..b4fcc62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,17 +12,17 @@ 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 build - 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 + - 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 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 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 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/ 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..a538c99 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # 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.** +> **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. @@ -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 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)