From d6561b0c47b99d0334e3876a4b1dda706a712a4b Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Sun, 29 Mar 2026 17:43:37 -0500 Subject: [PATCH] fix: sentinel awareness, fmt normalization, lifecycle-scaled scoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three root fixes for 6 issues: 1. Charter sentinel detection (#70, #71, #75) — new stripCharterSentinels() utility prevents charter from re-ingesting its own output (module index tables). Wired into markdown parser, tidy bloat scanner, and doctor keyword density scanner. 2. Structural normalization in fmt (#75) — formatter now collapses duplicate list markers (- - - X → X), strips HTML comments and markdown tables from ADF section bodies. 3. Lifecycle-aware scoring (#72, #73) — doctor suppresses cold-start info when .ai/ modules exist (pointer IS doing its job). Audit scales trailer weight by commit count: 20% at ≤20 commits → 50% at ≥100 commits. Also adds WSL2 install guidance to README (#74). Closes #70, #71, #72, #73, #74, #75 Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 2 + packages/adf/src/formatter.ts | 49 ++++++++++++++++++++++- packages/adf/src/index.ts | 1 + packages/adf/src/markdown-parser.ts | 6 ++- packages/adf/src/sentinels.ts | 57 +++++++++++++++++++++++++++ packages/cli/src/commands/adf-tidy.ts | 5 ++- packages/cli/src/commands/audit.ts | 34 ++++++++++++---- packages/cli/src/commands/doctor.ts | 13 ++++-- 8 files changed, 154 insertions(+), 13 deletions(-) create mode 100644 packages/adf/src/sentinels.ts diff --git a/README.md b/README.md index 2642b8f..2d27d38 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ npm install --save-dev @stackbilt/cli For pnpm workspaces: `pnpm add -Dw @stackbilt/cli`. For global install: `npm install -g @stackbilt/cli`. +> **WSL2 note:** If your project lives on the Windows filesystem (`/mnt/c/...`), pnpm may fail with `EACCES` permission errors due to WSL2/NTFS cross-filesystem limitations with atomic renames. Use `pnpm add --force` to work around this, or move your project to a Linux-native path (e.g., `~/projects/`) for best performance. + **Free to try.** `charter login --key sb_live_xxx` to connect your [Stackbilt](https://stackbilt.dev) API key for full scaffold output. ## AI agent governance with ADF diff --git a/packages/adf/src/formatter.ts b/packages/adf/src/formatter.ts index 664cee4..57b8413 100644 --- a/packages/adf/src/formatter.ts +++ b/packages/adf/src/formatter.ts @@ -3,6 +3,7 @@ * * Strict emission: auto-injects standard emoji decorations, * sorts sections by canonical key order, uses 2-space indent. + * Normalizes structural artifacts from migrate/tidy (#75). */ import type { AdfDocument, AdfSection, AdfContent } from './types'; @@ -13,7 +14,7 @@ export function formatAdf(doc: AdfDocument): string { lines.push(`ADF: ${doc.version}`); - const sorted = sortSections(doc.sections); + const sorted = sortSections(doc.sections).map(normalizeSection); for (let i = 0; i < sorted.length; i++) { lines.push(''); @@ -90,3 +91,49 @@ function formatBody(content: AdfContent): string[] { } } } + +// ============================================================================ +// Structural Normalization (#75) +// ============================================================================ + +/** Collapse duplicate list markers (- - - X → X) in list items. */ +function normalizeListItem(item: string): string { + return item.replace(/^(?:-\s+)+/, '').trim(); +} + +/** Strip HTML comments from text content. */ +function stripHtmlComments(text: string): string { + return text.replace(//g, '').trim(); +} + +/** Strip markdown table syntax from text content (not valid ADF). */ +function stripMarkdownTables(text: string): string { + return text + .split('\n') + .filter(line => !/^\s*\|.*\|/.test(line)) + .join('\n') + .trim(); +} + +/** Normalize a section's content to remove migration artifacts. */ +function normalizeSection(section: AdfSection): AdfSection { + const { content } = section; + + switch (content.type) { + case 'list': { + const normalized = content.items + .map(normalizeListItem) + .filter(item => item.length > 0); + return { ...section, content: { type: 'list', items: normalized } }; + } + case 'text': { + let value = stripHtmlComments(content.value); + value = stripMarkdownTables(value); + // Collapse runs of blank lines left by stripping + value = value.replace(/\n{3,}/g, '\n\n').trim(); + return { ...section, content: { type: 'text', value } }; + } + default: + return section; + } +} diff --git a/packages/adf/src/index.ts b/packages/adf/src/index.ts index 79e287c..33b382d 100644 --- a/packages/adf/src/index.ts +++ b/packages/adf/src/index.ts @@ -19,5 +19,6 @@ export type { TriggerMap, ClassifierConfig, } from './content-classifier'; +export { stripCharterSentinels, isCharterSentinel } from './sentinels'; export * from './types'; export * from './errors'; diff --git a/packages/adf/src/markdown-parser.ts b/packages/adf/src/markdown-parser.ts index d6568c0..e5821b9 100644 --- a/packages/adf/src/markdown-parser.ts +++ b/packages/adf/src/markdown-parser.ts @@ -5,6 +5,8 @@ * and detects rule strength (imperative vs advisory). */ +import { stripCharterSentinels } from './sentinels'; + // ============================================================================ // Types // ============================================================================ @@ -83,7 +85,9 @@ function detectStrength(text: string, config?: StrengthConfig): RuleStrength { * are classified as rules, code blocks, table rows, or prose. */ export function parseMarkdownSections(input: string, config?: StrengthConfig): MarkdownSection[] { - const lines = input.split('\n'); + // Strip charter-managed sentinel blocks (e.g., module index tables) before + // parsing so migrate/tidy never classify charter's own rendered output. + const lines = stripCharterSentinels(input).split('\n'); const sections: MarkdownSection[] = []; let currentHeading = ''; diff --git a/packages/adf/src/sentinels.ts b/packages/adf/src/sentinels.ts new file mode 100644 index 0000000..10ceebf --- /dev/null +++ b/packages/adf/src/sentinels.ts @@ -0,0 +1,57 @@ +/** + * Charter Sentinel Detection — prevents charter from re-ingesting its own output. + * + * Charter-managed blocks are delimited by HTML comment sentinels: + * ... + * + * These blocks must be excluded from classification, keyword scanning, and + * bloat detection so that migrate/tidy/doctor don't treat charter's own + * rendered output as user-authored content. + */ + +/** Regex matching a charter sentinel start tag. */ +const SENTINEL_START = /^$/; + +/** Regex matching a charter sentinel end tag. */ +const SENTINEL_END = /^$/; + +/** + * Strip all charter-managed sentinel blocks from content. + * + * Removes everything between matching `` and + * `` comment pairs, inclusive of the sentinel lines. + * Unmatched start sentinels strip to EOF. Handles multiple blocks. + */ +export function stripCharterSentinels(content: string): string { + const lines = content.split('\n'); + const result: string[] = []; + let inSentinel = false; + + for (const line of lines) { + const trimmed = line.trim(); + + if (!inSentinel && SENTINEL_START.test(trimmed)) { + inSentinel = true; + continue; + } + + if (inSentinel && SENTINEL_END.test(trimmed)) { + inSentinel = false; + continue; + } + + if (!inSentinel) { + result.push(line); + } + } + + return result.join('\n'); +} + +/** + * Test whether a line is a charter sentinel marker (start or end). + */ +export function isCharterSentinel(line: string): boolean { + const trimmed = line.trim(); + return SENTINEL_START.test(trimmed) || SENTINEL_END.test(trimmed); +} diff --git a/packages/cli/src/commands/adf-tidy.ts b/packages/cli/src/commands/adf-tidy.ts index 79af675..cfe6d58 100644 --- a/packages/cli/src/commands/adf-tidy.ts +++ b/packages/cli/src/commands/adf-tidy.ts @@ -16,6 +16,7 @@ import { parseMarkdownSections, isDuplicateItem, buildMigrationPlan, + stripCharterSentinels, } from '@stackbilt/adf'; import type { AdfDocument, PatchOperation, MigrationItem, TriggerMap } from '@stackbilt/adf'; import type { CLIOptions } from '../index'; @@ -269,7 +270,9 @@ function extractBeyondPointer(content: string, fileName: string): string { if (!template) return ''; - const lines = content.split('\n'); + // Strip charter-managed sentinel blocks before scanning for bloat. + // Without this, tidy treats the module index table as user-authored content. + const lines = stripCharterSentinels(content).split('\n'); const bloatLines: string[] = []; let inEnvironmentSection = false; let inPointerHeader = true; // Start true — skip the pointer preamble diff --git a/packages/cli/src/commands/audit.ts b/packages/cli/src/commands/audit.ts index 3363235..36cbb44 100644 --- a/packages/cli/src/commands/audit.ts +++ b/packages/cli/src/commands/audit.ts @@ -155,7 +155,23 @@ function generateAuditReport( const patternScore = Math.min(100, activePatterns.length * 20); const policyScore = policyCoverage.coveragePercent; - const overall = Math.round((trailerScore * 0.5) + (patternScore * 0.3) + (policyScore * 0.2)); + // Scale trailer weight by project maturity (#73). Greenfield projects with + // few commits shouldn't be penalized for missing trailers on infra/CI commits. + // Weight ramps linearly: 0.2 at ≤20 commits → 0.5 at ≥100 commits. + const commitCount = commits.length; + const trailerWeight = commitCount >= 100 + ? 0.5 + : commitCount <= 20 + ? 0.2 + : 0.2 + 0.3 * ((commitCount - 20) / 80); + const remainingWeight = 1 - trailerWeight; + // Pattern and policy share the remaining weight in their original 3:2 ratio. + const patternWeight = remainingWeight * 0.6; + const policyWeight = remainingWeight * 0.4; + + const overall = Math.round( + (trailerScore * trailerWeight) + (patternScore * patternWeight) + (policyScore * policyWeight), + ); const scoreInputs = { coveragePercent, activePatterns: activePatterns.length, @@ -196,9 +212,9 @@ function generateAuditReport( policyDocumentation: Math.round(policyScore), }, criteria: { - trailerCoverage: 'coverage_percent * 1.5 (max 100). 67%+ coverage earns full points.', - patternDefinitions: 'active_pattern_count * 20 (max 100). 5+ active patterns earns full points.', - policyDocumentation: 'policy section coverage percent from config.audit.policyCoverage.requiredSections (max 100).', + trailerCoverage: `coverage_percent * 1.5 (max 100). 67%+ coverage earns full points. Weight: ${Math.round(trailerWeight * 100)}% (scales by commit count: 20% at ≤20 commits, 50% at ≥100).`, + patternDefinitions: `active_pattern_count * 20 (max 100). 5+ active patterns earns full points. Weight: ${Math.round(patternWeight * 100)}%.`, + policyDocumentation: `policy section coverage percent from config.audit.policyCoverage.requiredSections (max 100). Weight: ${Math.round(policyWeight * 100)}%.`, }, recommendations: getRecommendations(scoreInputs), }, @@ -238,9 +254,13 @@ function printReport(report: AuditReport): void { } console.log(''); console.log(' Score Breakdown'); - console.log(` Trailer coverage: ${report.score.breakdown.trailerCoverage}/100 (50% weight)`); - console.log(` Pattern definitions: ${report.score.breakdown.patternDefinitions}/100 (30% weight)`); - console.log(` Policy documentation: ${report.score.breakdown.policyDocumentation}/100 (20% weight)`); + // Extract weights from criteria strings (they include the dynamic weight %) + const twMatch = report.score.criteria.trailerCoverage.match(/Weight:\s*(\d+)%/); + const pwMatch = report.score.criteria.patternDefinitions.match(/Weight:\s*(\d+)%/); + const dwMatch = report.score.criteria.policyDocumentation.match(/Weight:\s*(\d+)%/); + console.log(` Trailer coverage: ${report.score.breakdown.trailerCoverage}/100 (${twMatch?.[1] ?? '50'}% weight)`); + console.log(` Pattern definitions: ${report.score.breakdown.patternDefinitions}/100 (${pwMatch?.[1] ?? '30'}% weight)`); + console.log(` Policy documentation: ${report.score.breakdown.policyDocumentation}/100 (${dwMatch?.[1] ?? '20'}% weight)`); console.log(''); console.log(' Scoring Criteria'); console.log(` - Trailer coverage: ${report.score.criteria.trailerCoverage}`); diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 0a8d012..c66002e 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -9,7 +9,7 @@ import * as path from 'node:path'; import type { CLIOptions } from '../index'; import { EXIT_CODE } from '../index'; import { loadPatterns } from '../config'; -import { parseAdf, parseManifest } from '@stackbilt/adf'; +import { parseAdf, parseManifest, stripCharterSentinels } from '@stackbilt/adf'; import { isGitRepo } from '../git-helpers'; import { POINTER_MARKERS } from './adf'; @@ -207,7 +207,9 @@ export async function doctorCommand(options: CLIOptions, args: string[] = []): P } for (const { file, content } of pointerFiles) { - const lines = content.split('\n'); + // Strip charter-managed sentinel blocks before scanning for bloat/keywords. + const strippedContent = stripCharterSentinels(content); + const lines = strippedContent.split('\n'); const lineCount = lines.length; const fileWarnings: string[] = []; @@ -279,11 +281,16 @@ 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. + // + // HOWEVER: if the file is a validated thin pointer to a populated .ai/ + // directory with modules, the pointer IS doing its job — agents get context + // from .ai/ modules, not from the pointer file. Suppress in that case (#72). 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; + const hasPopulatedModules = allModulePaths.length > 0; 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) { + if (lineCount < 15 && !hasStackHint && !hasPopulatedModules) { checks.push({ name: 'adf cold start', status: 'INFO',