Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 48 additions & 1 deletion packages/adf/src/formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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('');
Expand Down Expand Up @@ -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(/<!--[\s\S]*?-->/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;
}
}
1 change: 1 addition & 0 deletions packages/adf/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ export type {
TriggerMap,
ClassifierConfig,
} from './content-classifier';
export { stripCharterSentinels, isCharterSentinel } from './sentinels';
export * from './types';
export * from './errors';
6 changes: 5 additions & 1 deletion packages/adf/src/markdown-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* and detects rule strength (imperative vs advisory).
*/

import { stripCharterSentinels } from './sentinels';

// ============================================================================
// Types
// ============================================================================
Expand Down Expand Up @@ -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 = '';
Expand Down
57 changes: 57 additions & 0 deletions packages/adf/src/sentinels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Charter Sentinel Detection — prevents charter from re-ingesting its own output.
*
* Charter-managed blocks are delimited by HTML comment sentinels:
* <!-- charter:<name>:start --> ... <!-- charter:<name>:end -->
*
* 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 = /^<!--\s*charter:[a-z0-9_-]+:start\s*-->$/;

/** Regex matching a charter sentinel end tag. */
const SENTINEL_END = /^<!--\s*charter:[a-z0-9_-]+:end\s*-->$/;

/**
* Strip all charter-managed sentinel blocks from content.
*
* Removes everything between matching `<!-- charter:*:start -->` and
* `<!-- charter:*:end -->` 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);
}
5 changes: 4 additions & 1 deletion packages/cli/src/commands/adf-tidy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
34 changes: 27 additions & 7 deletions packages/cli/src/commands/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
},
Expand Down Expand Up @@ -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}`);
Expand Down
13 changes: 10 additions & 3 deletions packages/cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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[] = [];

Expand Down Expand Up @@ -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',
Expand Down
Loading