From 2fd28b64c1b99fc755d00ad28f9170ecf7612560 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Sat, 17 Oct 2026 20:08:53 -0500 Subject: [PATCH 1/2] fix(compile): don't carry the template's ## Environment placeholder (#364) A CLAUDE.md written by 1.9.3 holds the template's `## Environment` with only its HTML comment. With a second, user-written `## Environment`, `adf compile --write --mode compiled` carried both into the retained block: divergentRetainedHeadings keyed sections by heading, so the user's copy made the heading divergent and composeCompiled then took every section with it. Judge each retained section on its own against the pointer templates (isTemplateRetainedSection; CRLF and trailing whitespace ignored), so the untouched placeholder is template text and only the user's section is carried. A placeholder the user wrote under is still carried. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../cli/src/__tests__/adf-retained.test.ts | 82 +++++++++++++++++++ packages/cli/src/commands/adf-compile.ts | 52 +++++++----- packages/cli/src/commands/adf-retained.ts | 8 +- 4 files changed, 122 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46f24b7..b8c94bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on Keep a Changelog and follows Semantic Versioning. ### Fixed +- **`charter adf compile --target claude --write --mode compiled` no longer carries the template's empty `## Environment` placeholder into the retained block** (`#364`). A `CLAUDE.md` written by 1.9.3 (or any later bootstrap) holds `## Environment` with only the `` comment. When you had added a second `## Environment` of your own, converting the pointer to compiled output carried both, comment included, and reported `kept ## Environment, ## Environment`. Each retained section is now compared with the pointer templates on its own (ignoring CRLF and trailing whitespace), so the untouched placeholder is dropped as template text and only your section is carried. A placeholder you wrote under is yours and is carried as before. - **`charter adf populate`, and so every `charter bootstrap`, re-added stack constraints to a `CONSTRAINTS` section you had edited** (`#371`; 2.1.0 listed this as still open under `#354`). Deleting a bullet from `core.adf` and re-running bootstrap brought a stack rule back (for example the Cloudflare `No Node.js-specific APIs in Worker handlers` rule), because populate appended any detected stack constraint it did not find. Populate now remembers what it has added or offered in a new file, `.ai/.populate.json` (`{ "version": 1, "constraints": { "core.adf": { "added": [...], "offered": [...] } } }`, sorted, written only when it changes). Commit it with the rest of `.ai/`; bootstrap lists it under what it created, and its `git add .ai` next step already includes it. The rules: - A constraint recorded as added or offered is never added again, whether it is still in the section or you deleted it. Deleting the one rule populate added to a plain Worker repo now sticks, and a deleted `CONSTRAINTS` section is not recreated once the file has a record. - New constraints go only into a section that is untouched: the template's bullets, in order, followed by nothing but bullets populate recorded as added. A missing section is created only while there is no record. It goes right after whichever of `GUIDE` and `CONTEXT` comes later, which in the scaffold's order is its own place, rather than at the end of the file (when populate adds a missing `CONTEXT` in the same run, that one is appended at the end and `CONSTRAINTS` follows it); its header is written as the scaffold writes it (`⚠️ CONSTRAINTS [load-bearing]:`). diff --git a/packages/cli/src/__tests__/adf-retained.test.ts b/packages/cli/src/__tests__/adf-retained.test.ts index e9f1924..5bd81cd 100644 --- a/packages/cli/src/__tests__/adf-retained.test.ts +++ b/packages/cli/src/__tests__/adf-retained.test.ts @@ -430,6 +430,88 @@ describe('compile --write --mode compiled carries retained sections', () => { }); }); +// ============================================================================ +// 3b. the template's ## Environment placeholder is template text (#364) +// ============================================================================ + +describe('the untouched ## Environment placeholder is never carried (#364)', () => { + // CLAUDE.md exactly as `npx @stackbilt/cli@1.9.3 bootstrap --yes` writes it + // (published tarball, default preset). The placeholder is unchanged since 1.9.3. + const V193_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.', '', + '## Session Start', '', + 'Call `charter_context` (MCP tool) before any other action. It returns the live constraint surface — open work, recent decisions, and metric ceilings — so you act within current project state rather than reconstructing it from scratch.', '', + 'If `charter serve` is not running, read `.ai/context.snapshot.json` directly or run `charter context-refresh` to generate it.', '', + '## Module Index', + '', + '| Module | Triggers | Load |', + '|--------|----------|------|', + '| core.adf | _(always loaded)_ | DEFAULT |', + '| state.adf | _(always loaded)_ | DEFAULT |', + '| frontend.adf | React, CSS, UI | ON_DEMAND |', + '| backend.adf | API, Node, DB | ON_DEMAND |', + '', '', + '## Environment', + '', '', + ].join('\n'); + const PLACEHOLDER = ''; + const USER_ENV = '## Environment\n- Windows host; use Git Bash.'; + const withUserEnv = `${V193_CLAUDE_MD}\n${USER_ENV}\n`; + + it('the 1.9.3 file still holds the current template\'s placeholder', () => { + expect(POINTER_CLAUDE_MD_HYBRID).toContain(`## Environment\n${PLACEHOLDER}\n`); + expect(V193_CLAUDE_MD).toContain(`## Environment\n${PLACEHOLDER}\n`); + }); + + it('placeholder only: nothing is carried', () => { + expect(composeCompiled(FRESH, V193_CLAUDE_MD, 'CLAUDE.md')).toMatchObject({ output: FRESH, retainedSections: [], retainedSource: 'none' }); + }); + + it('placeholder plus a user ## Environment: only the user section is carried', () => { + const c = composeCompiled(FRESH, withUserEnv, 'CLAUDE.md'); + expect(c).toMatchObject({ retainedSource: 'pointer', retainedSections: ['## Environment'], body: USER_ENV }); + expect(c.output).toBe(`${FRESH.trimEnd()}\n\n${block(USER_ENV)}`); + expect(c.output).not.toContain(PLACEHOLDER); + expect(linesConvertWouldDrop(withUserEnv, c.output)).toEqual([]); + }); + + it('a placeholder the user wrote under is the user\'s: both sections are carried', () => { + const edited = withUserEnv.replace(`${PLACEHOLDER}\n`, `${PLACEHOLDER}\n- WSL for builds\n`); + const c = composeCompiled(FRESH, edited, 'CLAUDE.md'); + expect(c.retainedSections).toEqual(['## Environment', '## Environment']); + expect(c.body).toBe(`## Environment\n${PLACEHOLDER}\n- WSL for builds\n\n${USER_ENV}`); + }); + + it('CRLF line endings and trailing spaces on the placeholder change nothing', () => { + const crlf = withUserEnv.replace(`${PLACEHOLDER}\n`, `${PLACEHOLDER} \n`).replace(/\n/g, '\r\n'); + const c = composeCompiled(FRESH, crlf, 'CLAUDE.md'); + expect(c).toMatchObject({ retainedSource: 'pointer', retainedSections: ['## Environment'], body: USER_ENV }); + expect(composeCompiled(FRESH, V193_CLAUDE_MD.replace(/\n/g, '\r\n'), 'CLAUDE.md').output).toBe(FRESH); + }); + + it('compile --write --mode compiled on the 1.9.3 upgrade path writes only the user section', async () => { + scaffold(); + fs.writeFileSync('CLAUDE.md', withUserEnv); + const r = await runJson(['compile', '--target', 'claude', '--write', '--mode', 'compiled']); + expect(r.code).toBe(0); + expect(r.json.targets[0]).toMatchObject({ status: 'written', previousMode: 'pointer', retainedSections: ['## Environment'], retainedSource: 'pointer' }); + expect(read('CLAUDE.md')).toBe(`${(await fresh('claude')).trimEnd()}\n\n${block(USER_ENV)}`); + }); + + it('compile --write --mode compiled on an untouched 1.9.3 file writes the bare render', async () => { + scaffold(); + fs.writeFileSync('CLAUDE.md', V193_CLAUDE_MD.replace(/\n/g, '\r\n')); + const r = await runJson(['compile', '--target', 'claude', '--write', '--mode', 'compiled']); + expect(r.code).toBe(0); + expect(r.json.targets[0]).toMatchObject({ status: 'written', retainedSections: [] }); + expect(read('CLAUDE.md')).toBe(await fresh('claude')); + }); +}); + // ============================================================================ // 4. vendor restore (K2, K3) // ============================================================================ diff --git a/packages/cli/src/commands/adf-compile.ts b/packages/cli/src/commands/adf-compile.ts index 2628b98..1be5dba 100644 --- a/packages/cli/src/commands/adf-compile.ts +++ b/packages/cli/src/commands/adf-compile.ts @@ -256,52 +256,66 @@ function pointerTemplatesFor(filename: string): string[] { return byFilename[filename] ?? []; } +/** A retained section's identity: its heading and its body, line ends trimmed. */ +function sectionKey(heading: string, bodyLines: readonly string[]): string { + return `${heading}\n${bodyLines.map(l => l.trimEnd()).join('\n').trim()}`; +} + /** - * Split a retained-sections block (from readRetainedSections) into heading → body. - * Charter-managed sentinel blocks are stripped first, so a populated - * `## Module Index` compares equal to the empty one in the template. + * The retained sections of `content` (from readRetainedSections), in order. + * A heading that occurs twice gives two entries (#364). Charter-managed + * sentinel blocks are stripped first, so a populated `## Module Index` + * compares equal to the empty one in the template. */ -function retainedSectionMap(content: string): Map { - const block = readRetainedSections(stripCharterSentinels(content)); - const sections = new Map(); +function retainedSectionKeys(content: string): Array<{ heading: string; key: string }> { + const block = readRetainedSections(stripCharterSentinels(content.replace(/\r\n/g, '\n'))); + const sections: Array<{ heading: string; key: string }> = []; let heading: string | null = null; let body: string[] = []; + const flush = (): void => { + if (heading) sections.push({ heading, key: sectionKey(heading, body) }); + }; for (const line of block.split('\n')) { if (line.trim().startsWith('## ')) { - if (heading) sections.set(heading, body.join('\n').trim()); + flush(); heading = line.trim(); body = []; continue; } if (heading) body.push(line); } - if (heading) sections.set(heading, body.join('\n').trim()); + flush(); return sections; } +/** + * True when a retained section (heading plus body lines) is, line ends aside, + * one a pointer template for this file carries untouched, such as the + * `## Environment` placeholder with its HTML comment. It is template text, not + * the user's, so it is never carried into a retained block (#364). + */ +export function isTemplateRetainedSection(filename: string, heading: string, bodyLines: readonly string[]): boolean { + const key = sectionKey(heading, bodyLines); + return pointerTemplatesFor(filename).some(t => retainedSectionKeys(t).some(s => s.key === key)); +} + /** * Headings whose retained content differs from every pointer template for this * file — i.e. notes a user wrote into the `## Environment` slot the templates * invite them to use, or an operational `## ... Protocol` block that `adf tidy` - * deliberately keeps in the vendor file (#198). + * deliberately keeps in the vendor file (#198). Each section is judged on its + * own: a heading listed twice is divergent when either copy is (#364). * * No `.ai/` module holds these sections, so converting a pointer to compiled * output carries them into its retained block (adf-retained.ts, #303). */ export function divergentRetainedHeadings(existing: string, filename: string): string[] { - const templates = pointerTemplatesFor(filename); - const pristine = new Set(); - for (const template of templates) { - for (const [heading, body] of retainedSectionMap(template)) { - pristine.add(`${heading}\n${body}`); - } - } - + const pristine = new Set(pointerTemplatesFor(filename).flatMap(t => retainedSectionKeys(t).map(s => s.key))); const divergent: string[] = []; - for (const [heading, body] of retainedSectionMap(existing)) { - if (!pristine.has(`${heading}\n${body}`)) divergent.push(heading); + for (const { heading, key } of retainedSectionKeys(existing)) { + if (!pristine.has(key) && !divergent.includes(heading)) divergent.push(heading); } return divergent; } diff --git a/packages/cli/src/commands/adf-retained.ts b/packages/cli/src/commands/adf-retained.ts index b7b1e45..262d522 100644 --- a/packages/cli/src/commands/adf-retained.ts +++ b/packages/cli/src/commands/adf-retained.ts @@ -31,7 +31,7 @@ import * as path from 'node:path'; import { readRetainedSections } from './adf-tidy'; import { inferVendorMode, linesRestoreWouldDrop } from './adf-vendor'; import type { VendorFile } from './adf-vendor'; -import { divergentRetainedHeadings } from './adf-compile'; +import { divergentRetainedHeadings, isTemplateRetainedSection } from './adf-compile'; export const RETAINED_START = ''; export const RETAINED_END = ''; @@ -422,8 +422,12 @@ export function composeCompiled(fresh: string, existing: string | null, file: st body = joinSections(found.carry.map(take)); source = 'legacy'; } else if (mode === 'pointer') { + // Each section is judged on its own: an untouched template placeholder is + // not carried even when the user added a second section of that name (#364). const divergent = new Set(divergentRetainedHeadings(normalized, file).filter(h => h !== '## Module Index')); - body = joinSections(sectionsOf(lines, fullCode).filter(s => divergent.has(s.heading)).map(take)); + body = joinSections(sectionsOf(lines, fullCode) + .filter(s => divergent.has(s.heading) && !isTemplateRetainedSection(file, s.heading, lines.slice(s.start + 1, s.end))) + .map(take)); source = 'pointer'; } else { body = joinSections(sectionsOf(lines, fullCode).filter(s => isRetained(s.heading) && s.heading !== '## Module Index').map(take)); From 6a67accacd53aa4462088a079862d414193b6aad Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Sat, 17 Oct 2026 20:20:24 -0500 Subject: [PATCH 2/2] docs(changelog): #364 scope is every bootstrap since 0.3.3; note existing blocks Review of #384: the placeholder dates from 8cbfd78 (v0.3.3), and a retained block an earlier compile already wrote is kept verbatim, so say how to clean it. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8c94bd..8f57ec1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ The format is based on Keep a Changelog and follows Semantic Versioning. ### Fixed -- **`charter adf compile --target claude --write --mode compiled` no longer carries the template's empty `## Environment` placeholder into the retained block** (`#364`). A `CLAUDE.md` written by 1.9.3 (or any later bootstrap) holds `## Environment` with only the `` comment. When you had added a second `## Environment` of your own, converting the pointer to compiled output carried both, comment included, and reported `kept ## Environment, ## Environment`. Each retained section is now compared with the pointer templates on its own (ignoring CRLF and trailing whitespace), so the untouched placeholder is dropped as template text and only your section is carried. A placeholder you wrote under is yours and is carried as before. +- **`charter adf compile --target claude --write --mode compiled` no longer carries the template's empty `## Environment` placeholder into the retained block** (`#364`). A `CLAUDE.md` written by any bootstrap since 0.3.3 (the 1.9.3 upgrade path included) holds `## Environment` with only the `` comment. When you had added a second `## Environment` of your own, converting the pointer to compiled output carried both, comment included, and reported `kept ## Environment, ## Environment`. Each retained section is now compared with the pointer templates on its own (ignoring CRLF, whitespace at line ends and blank lines around the body), so the untouched placeholder is dropped as template text and only your section is carried. A placeholder you wrote under is yours and is carried as before. A retained block that an earlier compile already wrote is copied verbatim as always: if yours holds the placeholder, delete it from the block by hand. - **`charter adf populate`, and so every `charter bootstrap`, re-added stack constraints to a `CONSTRAINTS` section you had edited** (`#371`; 2.1.0 listed this as still open under `#354`). Deleting a bullet from `core.adf` and re-running bootstrap brought a stack rule back (for example the Cloudflare `No Node.js-specific APIs in Worker handlers` rule), because populate appended any detected stack constraint it did not find. Populate now remembers what it has added or offered in a new file, `.ai/.populate.json` (`{ "version": 1, "constraints": { "core.adf": { "added": [...], "offered": [...] } } }`, sorted, written only when it changes). Commit it with the rest of `.ai/`; bootstrap lists it under what it created, and its `git add .ai` next step already includes it. The rules: - A constraint recorded as added or offered is never added again, whether it is still in the section or you deleted it. Deleting the one rule populate added to a plain Worker repo now sticks, and a deleted `CONSTRAINTS` section is not recreated once the file has a record. - New constraints go only into a section that is untouched: the template's bullets, in order, followed by nothing but bullets populate recorded as added. A missing section is created only while there is no record. It goes right after whichever of `GUIDE` and `CONTEXT` comes later, which in the scaffold's order is its own place, rather than at the end of the file (when populate adds a missing `CONTEXT` in the same run, that one is appended at the end and `CONSTRAINTS` follows it); its header is written as the scaffold writes it (`⚠️ CONSTRAINTS [load-bearing]:`).