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
32 changes: 32 additions & 0 deletions packages/adf/src/__tests__/content-classifier.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,38 @@ describe('meta-comment filtering', () => {
});
});

// ============================================================================
// Heading-level STAY — operational/session-protocol headings (#198)
// ============================================================================

describe('heading-level STAY for operational headings (#198)', () => {
it('returns STAY for imperative items under "Session Registration"', () => {
const result = classifyElement(rule('ALWAYS run: stackd register', 'imperative'), 'Session Registration');
expect(result.decision).toBe('STAY');
expect(result.reason).toContain('Operational');
});

it('returns STAY for items under "Session Protocol"', () => {
const result = classifyElement(rule('Submit the job via stackd submit', 'neutral'), 'Session Protocol');
expect(result.decision).toBe('STAY');
});

it('returns STAY for items under "AEGIS Task Provenance"', () => {
const result = classifyElement(prose('Record provenance in AEGIS.'), 'AEGIS Task Provenance');
expect(result.decision).toBe('STAY');
});

it('returns STAY for items under "Receipt" heading', () => {
const result = classifyElement(prose('Attach stackd receipt to PR.'), 'Receipt');
expect(result.decision).toBe('STAY');
});

it('does NOT suppress migration for regular domain headings', () => {
const result = classifyElement(rule('NEVER commit secrets', 'imperative'), 'API Endpoints', triggerMap);
expect(result.decision).toBe('MIGRATE');
});
});

// ============================================================================
// Table-block classification (#51a)
// ============================================================================
Expand Down
23 changes: 23 additions & 0 deletions packages/adf/src/content-classifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,16 @@ const META_PATTERNS: RegExp[] = [
/\bwhen (?:stackbilt)?charter is bootstrapped/i,
];

// Headings that signal operational/session-protocol content. Items under these
// headings describe runtime procedures, not project domain rules (#198).
const STAY_HEADING_PATTERNS: RegExp[] = [
/^session\b/i,
/\bprotocol\b/i,
/\breceipt\b/i,
/\bprovenance\b/i,
/\bregistration\b/i,
];

// ============================================================================
// Classification Helpers
// ============================================================================
Expand Down Expand Up @@ -249,6 +259,19 @@ export function classifyElement(
routingTrace = { headingModule, phraseOverride, candidateScores: scores };
}

// Heading-level STAY: operational/session-protocol headings (#198)
const headingLower = heading.toLowerCase();
if (STAY_HEADING_PATTERNS.some(p => p.test(headingLower))) {
return {
decision: 'STAY',
targetSection: 'CONTEXT',
targetModule: module,
weight: 'advisory',
reason: 'Operational/session-protocol heading — stay in vendor file',
routingTrace,
};
}

// Check STAY patterns first
if (matchesStayPattern(text, config?.stayPatterns)) {
return {
Expand Down
46 changes: 46 additions & 0 deletions packages/cli/src/__tests__/integration/vendor-bloat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,4 +303,50 @@ describe('vendor bloat pipeline (integration)', () => {
// Environment should be preserved
expect(claudeContent).toContain('## Environment');
});

it('adf tidy preserves Session/Protocol sections verbatim and does not route them to core.adf (#198)', async () => {
const tmp = makeTempDir('session-retain');
writeFixtureRepo(tmp);
process.chdir(tmp);

// CLAUDE.md with a thin pointer + operational protocol sections + bloat
fs.writeFileSync(path.join(tmp, 'CLAUDE.md'), `# CLAUDE.md

> **DO NOT add rules, constraints, or context to this file.**
> This file is auto-managed by Charter. All project rules live in \`.ai/\`.
> New rules should be added to the appropriate \`.ai/*.adf\` module.
> See \`.ai/manifest.adf\` for the module routing manifest.

## Environment
- Node 20
- pnpm 9

## Session Registration
- Run: register --session
- Submit the job via session CLI

## Session Protocol
- Always confirm receipt after submit

## Architecture
- NEVER bypass the repository layer
- ALWAYS validate inputs at the boundary
`);

// Apply tidy
const tidy = await captureJson(adfTidyCommand, jsonOptions, []);
const tidyResult = tidy.output as { totalExtracted: number };
expect(tidyResult.totalExtracted).toBeGreaterThan(0); // Architecture items extracted

// Session and Protocol sections must survive in CLAUDE.md
const after = fs.readFileSync(path.join(tmp, 'CLAUDE.md'), 'utf-8');
expect(after).toContain('## Session Registration');
expect(after).toContain('## Session Protocol');
expect(after).toContain('register --session');
expect(after).toContain('confirm receipt');

// Architecture bloat must NOT survive in CLAUDE.md
expect(after).not.toContain('## Architecture');
expect(after).not.toContain('bypass the repository layer');
});
});
84 changes: 51 additions & 33 deletions packages/cli/src/commands/adf-tidy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,41 @@ function analyzeVendorFile(
* Any content before the first H2 that isn't part of the pointer is also bloat.
* The ## Environment section and its items are legitimate retained content.
*/

/** True for H2 headings that should stay in the vendor file verbatim (#198). */
function isRetainedHeading(trimmedLine: string): boolean {
return (
/^## (Environment|Module Index)$/.test(trimmedLine) ||
/^## Session\b/i.test(trimmedLine) ||
/^## .*\b(Protocol|Receipt|Provenance|Registration)\b/i.test(trimmedLine)
);
}

/**
* Read all retained sections (Environment + operational protocol headings) from
* vendor file content and return them as a verbatim block to re-append after the
* thin pointer. Used by restorePointer to preserve section structure (#198).
*/
function readRetainedSections(content: string): string {
const lines = content.split('\n');
const out: string[] = [];
let inRetained = false;

for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('## ')) {
inRetained = isRetainedHeading(trimmed);
if (inRetained) out.push(line);
continue;
}
if (inRetained) out.push(line);
}

// Trim trailing blank lines
while (out.length > 0 && out[out.length - 1].trim() === '') out.pop();
return out.length > 0 ? '\n' + out.join('\n') + '\n' : '';
}

function extractBeyondPointer(content: string, fileName: string): string {
const baseName = path.basename(fileName);
const template = getPointerTemplates()[baseName];
Expand Down Expand Up @@ -312,11 +347,7 @@ function extractBeyondPointer(content: string, fileName: string): string {
// Section detection
if (trimmed.startsWith('## ')) {
// Charter-managed and operational protocol sections retained in the vendor file (#198).
// Environment: WSL/OS runtime config.
// Module Index: charter-generated on-demand listing.
// Session Start / Session Protocol: operational wiring steps that belong in the
// vendor file as environment context, not in a domain ADF module.
const retained = /^## (Environment|Module Index|Session Start|Session Protocol|Session Setup)$/.test(trimmed);
const retained = isRetainedHeading(trimmed);
if (retained) {
inEnvironmentSection = true;
continue;
Expand Down Expand Up @@ -470,7 +501,10 @@ function restorePointer(filePath: string, stayItems: MigrationItem[]): void {
'> See `.ai/manifest.adf` for the module routing manifest.\n';
}

// Re-attach retained environment items
// Read current file before overwriting — used to preserve retained sections.
const currentContent = fs.readFileSync(fullPath, 'utf-8');

// Re-attach any freshly-classified STAY items into ## Environment.
const envItems = stayItems.filter(i =>
i.classification.reason.includes('Environment') ||
i.classification.reason.includes('runtime') ||
Expand All @@ -488,33 +522,17 @@ function restorePointer(filePath: string, stayItems: MigrationItem[]): void {
}
}

// Read current content to preserve any existing ## Environment items
// that were already there (not extracted as bloat)
const currentContent = fs.readFileSync(fullPath, 'utf-8');
const currentLines = currentContent.split('\n');
let inEnv = false;
const existingEnvLines: string[] = [];
for (const line of currentLines) {
if (line.trim() === '## Environment') {
inEnv = true;
continue;
}
if (line.startsWith('## ') && line.trim() !== '## Environment') {
inEnv = false;
}
if (inEnv && line.trim().startsWith('- ')) {
existingEnvLines.push(line);
}
}

// If we didn't extract any stay items but there are existing env lines, preserve them
if (envItems.length === 0 && existingEnvLines.length > 0) {
const envSection = '\n## Environment\n' + existingEnvLines.join('\n') + '\n';
if (pointer.includes('## Environment')) {
pointer = pointer.replace(/## Environment[\s\S]*$/, envSection.trim() + '\n');
} else {
pointer += envSection;
}
// Re-append all retained sections (Environment, Session *, Protocol, etc.) from
// the current file verbatim so that operational protocol blocks survive the rewrite
// (#198). readRetainedSections() covers the same heading set as extractBeyondPointer.
const retainedBlock = readRetainedSections(currentContent);
if (retainedBlock) {
// Drop any existing ## Environment block already in `pointer` — it will be
// replaced by the verbatim block from the current file (which is fresher).
pointer = pointer.replace(/\n## Environment[\s\S]*$/, '');
pointer = pointer.trimEnd() + retainedBlock;
} else if (envItems.length === 0) {
// Nothing retained and no stay items — pointer is clean, nothing to append.
}

fs.writeFileSync(fullPath, pointer);
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/adf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
formatAdf,
applyPatches,
CANONICAL_KEY_ORDER,
COMPILE_BANNER_MARKER,
} from '@stackbilt/adf';
import type { AdfDocument, PatchOperation } from '@stackbilt/adf';
import type { CLIOptions } from '../index';
Expand Down Expand Up @@ -250,6 +251,7 @@ interface AdfInitResult {

/** Strings that identify an agent config file as a thin pointer to .ai/. */
export const POINTER_MARKERS = [
COMPILE_BANNER_MARKER,
'Do not duplicate ADF rules here',
'Do not duplicate rules from .ai/',
'Do not add stack rules here',
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { parseAdf, parseManifest, stripCharterSentinels, evaluateLocBudgets, mat
import type { LocBudgetRule } from '@stackbilt/adf';
import { isGitRepo } from '../git-helpers';
import { POINTER_MARKERS } from './adf';
import { COMPILE_BANNER_MARKER } from '@stackbilt/adf';

interface DoctorResult {
status: 'PASS' | 'WARN';
Expand Down Expand Up @@ -293,6 +294,9 @@ export async function doctorCommand(options: CLIOptions, args: string[] = []): P
}

for (const { file, content } of pointerFiles) {
// Compile-output files contain ADF content by design — skip bloat scan.
if (content.includes(COMPILE_BANNER_MARKER)) continue;

// Strip charter-managed sentinel blocks before scanning for bloat/keywords.
const strippedContent = stripCharterSentinels(content);
const lines = strippedContent.split('\n');
Expand Down
112 changes: 112 additions & 0 deletions packages/types/src/__tests__/tiers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* Tests for the tiered execution contract (#201).
*
* Verifies that each TierDefinition constant is structurally complete:
* - Every type value in the union appears in tiers[]
* - Every tier has a non-empty description and constraint description
* - mode is a valid value
* - tiers[] contains no duplicates
*/

import { describe, it, expect } from 'vitest';
import type { TierDefinition } from '../index';
import {
APP_MODE_TIERS,
URGENCY_TIERS,
COMPLEXITY_TIERS,
CHANGE_CLASS_TIERS,
COMMIT_RISK_TIERS,
} from '../index';

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function assertTierDefinitionComplete<T extends string>(def: TierDefinition<T>): void {
expect(def.name.length).toBeGreaterThan(0);
expect(def.tiers.length).toBeGreaterThan(0);
expect(['additive', 'absolute']).toContain(def.mode);

// No duplicate tiers
const unique = new Set(def.tiers);
expect(unique.size).toBe(def.tiers.length);

// Every tier has a description and a constraint description
for (const tier of def.tiers) {
expect(def.descriptions[tier]).toBeTruthy();
expect(def.constraints[tier]).toBeDefined();
expect(def.constraints[tier].description.length).toBeGreaterThan(0);
}
}

// ---------------------------------------------------------------------------
// Structural completeness
// ---------------------------------------------------------------------------

describe('APP_MODE_TIERS', () => {
it('is structurally complete', () => assertTierDefinitionComplete(APP_MODE_TIERS));

it('contains all AppMode values', () => {
const values: string[] = ['GOVERNANCE', 'STRATEGY', 'DRAFTER', 'RED_TEAM', 'BRIEF'];
for (const v of values) {
expect(APP_MODE_TIERS.tiers).toContain(v);
}
});

it('is absolute mode', () => expect(APP_MODE_TIERS.mode).toBe('absolute'));
});

describe('URGENCY_TIERS', () => {
it('is structurally complete', () => assertTierDefinitionComplete(URGENCY_TIERS));

it('contains all Urgency values in ascending severity order', () => {
expect(URGENCY_TIERS.tiers).toEqual(['LOW', 'STANDARD', 'ELEVATED', 'CRITICAL']);
});

it('CRITICAL has the most restrictive constraint', () => {
expect(URGENCY_TIERS.constraints.CRITICAL.description).toContain('escalation');
});
});

describe('COMPLEXITY_TIERS', () => {
it('is structurally complete', () => assertTierDefinitionComplete(COMPLEXITY_TIERS));

it('contains all Complexity values in ascending severity order', () => {
expect(COMPLEXITY_TIERS.tiers).toEqual(['TRIVIAL', 'SIMPLE', 'MODERATE', 'COMPLEX', 'EPIC']);
});

it('EPIC requires architecture review', () => {
expect(COMPLEXITY_TIERS.constraints.EPIC.description).toContain('Architecture review');
});
});

describe('CHANGE_CLASS_TIERS', () => {
it('is structurally complete', () => assertTierDefinitionComplete(CHANGE_CLASS_TIERS));

it('contains all ChangeClass values', () => {
const values: string[] = ['SURFACE', 'LOCAL', 'CROSS_CUTTING'];
for (const v of values) {
expect(CHANGE_CLASS_TIERS.tiers).toContain(v);
}
});

it('CROSS_CUTTING requires committee review', () => {
expect(CHANGE_CLASS_TIERS.constraints.CROSS_CUTTING.description).toContain('Committee review');
});
});

describe('COMMIT_RISK_TIERS', () => {
it('is structurally complete', () => assertTierDefinitionComplete(COMMIT_RISK_TIERS));

it('contains all CommitRiskLevel values in ascending severity order', () => {
expect(COMMIT_RISK_TIERS.tiers).toEqual(['LOW', 'MEDIUM', 'HIGH']);
});

it('HIGH requires human review', () => {
expect(COMMIT_RISK_TIERS.constraints.HIGH.description).toContain('human review');
});

it('LOW trailer is optional', () => {
expect(COMMIT_RISK_TIERS.constraints.LOW.description).toContain('optional');
});
});
Loading